Characters
Explanation. Back to Entities and the architecture overview.
Besides its stats and inventory, a Character has a class list, a race, a
background, proficiency lists, hit dice, and a level-up flow. Each of these
is a pure module beside Character.js, and each takes a value and returns a
new value.
data catalogs (plain data, no logic)
data/classCatalog.js hit die, proficiencies, skill choices, caster type,
subclasses (with subclass casting),
subclass level, ASI levels, features by level
data/arcaneClasses.js the Sorcerer, Warlock, and Wizard entries
data/classParts.js the curves, shared features, and subclasses
that both class files build from
data/raceCatalog.js races and their traits
data/backgroundCatalog.js backgrounds
data/skills.js the ability of each of the 18 skills
data/feats.js the built-in feat catalog
|
v
entity modules (pure logic over character values)
ClassCasting.js the caster fields of a class membership, with the
subclass applied
Classes.js caster reads: spellSaveDC, spellAttackBonus,
cantrip and prepared limits
SpellLearning.js which spells each caster class can learn at its level
Multiclass.js the class-list accessor (see below)
Races.js resolveRace: catalog first, stored snapshot fallback
Backgrounds.js resolve a stored id to its definition
Proficiencies.js assemble and edit the seven proficiency lists
HitDice.js max HP derivation, hit dice as resource pools
ClassPools.js the use counts of class features as resource pools
Experience.js the SRD table of XP per level
LevelUp.js pending levels, ASI and feat choices, unlocked features
LevelAssign.js commit a pending level to a class
Subclass.js set or clear a subclass, then resync slots and spells
FeatChoices.js the picks of the take-feat dialog, and feat riders
FeatRequirement.js check the ability, armor, and spellcasting
requirements of a feat
FeatureGrants.js apply and undo the grants of a structured feature
GrantLedger.js the grant records of feats and features, rebuilt
on undo
Features.js class features as numbers that the combat paths use
Progression.js the writers that app and UI code call, each followed
by derive
CharacterSpellbook.js learn, prepare, and copy the spells of a spellbook
CharacterInventory.js add, hand over, edit, and remove inventory stacks
Purse.js the coins of a character: add, set, spend, format,
and the load step that moves a "Gold (gp)" stack
into gp
Shop.js buy a priced library entry with the coins of a
character
|
v
Character.js the character value itself; withDefaults runs on
every load
The catalogs describe what a class or a race is, and the entity modules
describe what happens when a character has one. types/class.ts,
types/race.ts, and types/feat.ts declare the catalog types.
Contents
- Derived pools
- Classes and multiclassing
- Subclass casting
- Proficiencies
- Hit points and hit dice
- Leveling up
- Feats
- Class features
- Eldritch invocations
- Pact of the Tome
- Mystic Arcanum
- Load-time defaults
Derived pools
Spell slots, hit dice, and maximum HP are functions of the class list, the character level, the ability scores, and the class catalog. The app stores them as resource pools and does not compute them again on read. Every write that can change an input therefore has to derive them again, or the pools stay wrong.
Progression.derive is that step. It runs syncSlotsToLevel, then
syncHitDice, then syncClassPools, then reconcileMaxHP, and it keeps
what the character already spent from each pool. A character whose pools
already match comes back as the same object.
ClassPools.js derives the pools of the class features that have a count of
uses: Second Wind, Action Surge, Indomitable, Rage, Bardic Inspiration, Channel Divinity,
Divine Sense, Lay on Hands, ki, Wild Shape, sorcery points, and Arcane
Recovery. A table of rules gives the 5e SRD count at each class level. Some
counts also read the CHA modifier: Bardic Inspiration uses at least 1, and
Divine Sense uses 1 plus the modifier. Each pool also gets a recharge of
'short' or 'long'. Channel Divinity comes from the cleric and the
paladin, and a character with both classes gets the larger count, because a
second class that grants the feature adds no use. Rage and Wild Shape have no
pool at level 20, where their uses have no limit. The pools go after HP, the
slot pools, and the hit dice, ahead of any pool that the GM adds. The ids come
from PoolIds.js, so the combat actions can spend a use by the same
constant.
Character.restAll(character, kind) refills the pools for a rest of the kind
'short' or 'long'. A long rest refills every pool except one whose
recharge is 'none', and it restores half of the hit dice. A short rest
refills the pact slots and each pool whose recharge is 'short'. It
leaves HP, spell slots, hit dice, and every other pool as they are, apart
from the shortRestRegain points of a pool that sets that field. A pool with
no recharge waits for a long rest. The sorcery points get a
shortRestRegain of 4 at sorcerer 20, for Sorcerous Restoration.
CustomPools.js owns the pools that the GM adds on the character sheet. Such
a pool has an id of the form pool-N and a recharge of 'short',
'long', or 'none'. A 'long' pool can also set shortRestRegain, and
the other two drop it, because a short-rest pool refills in full and a
'none' pool refills on no rest. The sheet offers Add pool, and an Edit and a
Remove button on each such pool. A derived pool gets no Edit button, because the
next derive would write its size and recharge again.
Progression.js also exports the writers that app and UI code call:
withClasses, withRace, withCustomRace, withProficiencies, withExpertise, applyASI,
takeFeat, undoLastChoice, applyFeatureGrant, undoFeatureGrant,
and withEquipped. Each is the writer of a lower module followed
by derive. The lower modules keep the raw writers so that they can stay
plain list arithmetic. A call to a raw writer from app code skips the
reconcile and leaves the pools out of date.
Classes and multiclassing
entities/Multiclass.js is the class-list accessor. getClasses returns the
memberships. A save that stores scalar class and subclass fields reads as
a list with one entry. withClasses cleans each write, and primaryClass,
classLevelOf, and pendingLevels read across the list. Every class-aware
function goes through this accessor and does not read character.classes
directly. The single-class and multiclass paths are then the same path,
because a fighter is a character whose class list has one entry.
entities/Races.js and entities/Backgrounds.js resolve a stored id to its
definition. resolveRace uses the live catalog first and falls back to the
stored raceTraits snapshot. A hand-typed race, or a race that the GM deleted
from the catalog, therefore still loads and saves with its traits.
A race can define an abilityChoice (a count of abilities, the gain for each,
and the abilities it excludes) and a skillChoice (a count of skills). The
Half-Elf has both. withRace takes the picks as a third argument and checks
them with validRacePicks. The chosen abilities join the snapshot’s
abilityIncreases, so a later race change takes them back off the scores.
The chosen skills go to the snapshot’s chosenSkills, which resolveRace
keeps and assembleProficiencies merges with the fixed race skills.
A resistanceChoice (the Dragonborn ancestry) lists damage types, and the
picked one joins the snapshot’s resistances as chosenResistance, where
defensesOf reads it. A toolChoice (the Dwarf) works the same way, and the
picked tool joins tools as chosenTool. A cantripChoice (the High Elf) names a
class list. withRace adds the picked cantrip to the spellbook cantrips
with that class as its source, so spellSaveDC and spellAttackBonus read
the class spell ability (INT) through casterDefOf even for a character
with no caster class. The snapshot records it as chosenCantrip.
Races.raceCantrips lists it while the book still has it, and
Classes.cantripLimit adds that count, so the cantrip does not use a class
cantrip. A race change or a new pick takes the old cantrip out of the book,
and unlearnCantrip clears chosenCantrip.
entities/RaceTraits.js turns some trait names into roll effects.
isLucky makes resolveD20 in Checks.js roll a natural 1 again on a
character’s save or check, and on a spell target save through the lucky
flag of CastTarget. A weapon attack and a death save roll in the dice tray,
so app/luckyRoll.js rolls the tray selection again when a Lucky roller
shows a natural 1 on the kept d20. raceSaveSlant returns advantage for Brave, Fey
Ancestry, Dwarven Resilience, and Gnome Cunning, and spellRollTargets
folds it into each spell target’s save mode with the context from
spellRaceSave, which treats a creature action as a save that is not
magic. repeatSaves takes a slantOf callback and a lucky flag, so the
end-of-turn retry in retryImposedSaves gets the same race effects. A
creature has no race, so these functions return no
effect for it.
Subclass casting
A class membership (ClassRef) stores its subclass as a name. Each class in
data/classCatalog.js lists its catalog subclasses in subclasses, and each entry
has an id and a name. A subclass with a casting entry replaces these
caster fields of its class: casterType, spellAbility, spellListId,
knownRule, cantripsKnown, and the ritual flags. The Fighter’s Eldritch
Knight and the Rogue’s Arcane Trickster have this entry. Both are 'third'
casters on the wizard list, with INT.
entities/ClassCasting.js resolves a membership to its caster fields.
casterDefFor(ref) returns the class definition, or a frozen merge of the
class and the casting entry of its subclass. The match compares the stored
name with the subclass id or name and ignores case, so an imported
'eldritch-knight' and a typed 'Eldritch Knight' resolve the same.
The merge applies only from the class’s subclassLevel. A Fighter 2 with an
Eldritch Knight subclass on record reads the plain Fighter definition, so it
has no spell ability and no slots. Each class and subclass pair has one merged
object, so withSubclass and the creature edit can compare two results by
identity to find a change in casting.
Every reader of caster fields takes the membership, not the class id alone.
casterTypeOf, isCasterRef, spellListOf, and casterName are the small
readers. Classes.casterDefOf(character, classId) finds the character’s
membership for a class id, because the spellbook’s sources map and the cast
paths record a class id. Classes.isCasterClass(classId) answers for the
class alone. A creature caster or a template asks
ClassCasting.castsAs(classId, subclass, level) instead.
SpellSlots.js imports ClassCasting.js and not Classes.js, because
Classes.js imports SpellSlots.js. ClassCasting.js imports only the class
data, so it cannot close an import cycle.
The third-caster slot table in SpellSlots.js starts with two 1st-level slots
at class level 3 and ends with one 4th-level slot at level 19. In a
multiclass, casterLevelContribution('third', n) adds floor(n / 3).
characterSlots counts only the classes whose own table grants slots at their
level, which is the 5e rule that a class counts once it has its Spellcasting
feature. Without this filter, a Fighter 4 (Eldritch Knight) / Paladin 1 would
read the combined table at level 1 and get two slots in place of three.
entities/Subclass.js writes the subclass. withSubclass(character, classId,
text) stores the catalog name for a catalog match and the trimmed text for
any other name. An empty text clears the subclass. The write derives again
only when the casting changes, so naming a cleric’s domain keeps its spent
slots.
A class that stops casting loses the spellbook entries that sources records
under it. A character with no caster class left loses all slot and pact
pools. syncSlotsToLevel returns a martial character unchanged and does not
remove these pools, because derive runs on every load and would remove pools
that a GM added by hand.
LevelAssign.hasChoiceAt counts a set subclass as a claim on the class’s
subclass level. The donor path moves a class’s newest level to a new class.
Without the claim, an Eldritch Knight moved from level 3 to level 2 would keep
a subclass that casts nothing.
Proficiencies
entities/Proficiencies.js assembles the seven proficiency lists (saves,
skills, expertise, weapons, armor, tools, and languages) from the class, the
race, and the background (assembleProficiencies). withProficiencies
applies or edits the lists, and withExpertise sets the expertise list on its
own. The weapons list has two parts, the categories (simple, martial) and
the named weapons.
Both writers run normalizeProficiencies, which is the only place that
removes duplicates and cuts expertise down to the skills that the character is
proficient in. Expertise doubles a proficiency, so it cannot exist without
one, and no writer has to prune it. A patch that names no expertise keeps the
character’s expertise, so an edit to the tool list does not clear a player’s
picks.
A save with a top-level Character.expertise field loads with that list
inside the proficiencies, so the field needs no migration step. The
isProficient* predicates and hasExpertise return false for a character
with no proficiency lists.
Hit points and hit dice
entities/HitDice.js derives max HP from the class hit die plus the CON
modifier per level (classMaxHP, the 5e average rule). traitHPBonus adds
the per-level grants on top. A race snapshot and a taken feat record each
keep an hpPerLevel value that counts once per assigned class level (the
Hill Dwarf’s Dwarven Toughness, the Tough feat). A subclass hpPerLevel
counts once per level of its own class from the class’s subclassLevel on
(Draconic Resilience). A GM who types a maximum in the HP and AC dialog sets
hpOverride, and reconcileMaxHP then leaves the pool alone.
Character.clearMaxHPOverride drops the flag and derives the pool again,
which is what “Use class HP” in the dialog does. The dialog
(app/characterEdit.js) sets the override only when the typed maximum
differs from the one it showed, so an AC-only edit does not pin HP. It also models hit
dice as resource pools sized to the assigned class levels. withHitDice
creates the pools, and syncHitDice derives them again and keeps the spent
count. spendHitDie spends one die and heals through restoreResource, so a
dying character who rises above 0 HP stops dying, and a dead character spends
nothing. restoreHitDice gives back half
of the total dice on a long rest, largest die size first.
entities/RestHitDice.js spends the counts that the Short rest dialog
(ui/ShortRestDialog.js) collects. spendRestDice rolls each pool’s count
and stops at full HP, and spendablePools leaves out a dead character.
Leveling up
entities/LevelUp.js and entities/LevelAssign.js run the level-up flow. For
a character with a class, addXP leaves each earned level pending and does
not apply it. assignLevel commits a pending level to a chosen class. It
grows HP, adds a hit die, and advances spell slots.
A class ASI level leaves a pending improvement, which applyASI or takeFeat
spends. The app stores each choice against the class and class level that
earned it (slotKey builds that key), so a slot can have at most one choice.
Each choice also records its order, which undoLastChoice reads.
A single-class character with no pending level can move their newest level
into a new class. assignLevel refuses that move while an ASI, feat, or
feature record claims the level (hasChoiceAt). The moved level would
otherwise leave the record and its increases with no level to claim them. The
assign dialog lists each new class as disabled and asks the player to undo the
choice first.
LevelAssign.js also builds the options of the assign dialog.
assignOptions(character) lists every held class one level up and every new
class that the prerequisites allow. It then adds the classes that the
character cannot take, as disabled entries that name the requirement. The
requirement is the new class’s own, unless the blocked entry is a held class
whose prerequisite the character no longer meets. In 5e, the prerequisites
gate leaving a class the same way as they gate entering one. prereqText
writes the requirement (“STR 13 or DEX 13”), and className resolves a class
id for display.
Feats
A feat choice stores a stamp of what the feat did, not a reference to the
catalog. takeFeat takes a plain name or a FeatStamp (types/feat.ts),
which holds the resolved picks of a library feat. It applies the ability
increases to the stats and merges the proficiency grants through
normalizeProficiencies. It records these fields on the choice:
- the ability increases and the roll rider
requested, every proficiency that the feat asked forgranted, the entries that the merge added
undoLastChoice and the sheet read the stamp, as they do for the increases of
a race. A later edit to the library entry therefore does not change a
character that already took the feat.
Undo subtracts the increases and passes the proficiencies to
GrantLedger.rebuildGrants. That function takes the current lists, removes
every entry that a feat or feature record added, and merges the requests of
the remaining records back on top. A proficiency that two records both ask for
therefore stays through the undo of either one. Each remaining record is
stamped again with what it added in that replay, so the next undo reads the
right difference. An expertise on a removed skill goes with the skill.
The rebuild has limits. A matching grant that the GM made by hand between the
take and the undo also comes off, which is the same risk that a stat edit
poses to an ASI undo. A choice with none of the stamp fields undoes as a bare
name, and a choice with granted but no requested reads its granted list
as its request.
entities/FeatChoices.js computes the take-feat dialog. availableFeats
filters the catalog to the feats that the character has not taken, and a
repeatable feat stays on offer. abilityPool and choicePool compute the
options of each pick minus what the character already has. buildStamp folds
the picks and the fixed grants of the feat into the stamp that takeFeat
applies.
entities/FeatRequirement.js checks the structured requires field of a feat:
minimum scores, an armor proficiency, or the ability to cast. featOptions
lists an unmet feat last and disabled, with its prerequisite text. A feat with
only prerequisite text is open to everyone. The Library feat form sets
requires through ui/FeatRequirementFields.js, with one minimum score, one
armor proficiency, and the spellcasting box, and requirementFromDraft
turns those controls into the stored field.
The dialogs are in ui/EffectPicks.js. The class-feature grant flow uses the
same picks, so a feat and a feature with the same effects prompt the same way.
A pick whose pool has no more options than the count grants them all with no
prompt. The expertise prompt runs after the skill picks, because its options
depend on them. ui/LevelAssignFlow.js and ui/ImprovementFlow.js run both flows,
and ui/CharacterProgress.js places their buttons on the sheet.
Class features
A class feature in featuresByLevel (data/classCatalog.js) is a plain name or a
{ name, effects } object (ClassFeatureDef in types/class.ts). The effects
use the feat effect vocabulary from types/feat.ts. A plain name is for
display only. LevelUp.unlockedFeatures collects the entries that the class
levels of a character reach, and the sheet prints that list.
entities/FeatureGrants.js owns the grant lifecycle of a structured feature.
An unlocked feature with effects and no record in character.featureChoices
is pending. The app stores no pending flag, so a character created at
level 1, an imported save, and a hand-edited class list all show their
unclaimed grants the same way.
applyFeatureGrant merges the picks through normalizeProficiencies. It
records what the feature asked for and what the merge added, the same stamp
that a feat choice records. undoFeatureGrant rebuilds the lists through
GrantLedger.rebuildGrants, so a pick that a feat or another feature also
grants stays, and the feature becomes pending again. The stamp never lists a
grant that the character already had from the GM, so undo cannot remove it.
featureRiders adds the standing roll riders of a feature to
FeatChoices.riderSources, which every roll site calls. The Rogue grants
Expertise this way at levels 1 and 6, and the Bard at levels 3 and 10.
The Rogue’s expertise choice also names tools: ["thieves' tools"], so
FeatChoices.expertisePool offers thieves’ tools when the rogue holds them.
Proficiencies.EXPERTISE_TOOLS lists the tools that can keep expertise, and
normalizeProficiencies drops an expertise entry for any other tool, or for
a tool the character does not hold.
A second class-only effect, { kind: 'abilityRaise', abilities, amount, cap }
(AbilityRaise in types/class.ts), raises scores with no pick. Primal
Champion at barbarian 20 raises STR and CON by 4, to at most 24.
FeatureGrants.needsGrant leaves a feature with only raises out of the
pending list. entities/AbilityRaises.js collects the raises from the
catalog, and Equipment.effectiveStats applies them to the base scores
before the item buffs. A level taken away removes the raise, because the
raise is read from the class levels and has no record. The module reads the
catalog directly, because an import of LevelUp.js from Equipment.js
closes a cycle through Classes.js.
The Fighting Style feature of the fighter (level 1), the paladin (level 2),
and the ranger (level 2) carries one class-only effect,
{ kind: 'fightingStyle', from } (ClassFeatureEffect in types/class.ts).
from lists the style ids from data/fightingStyles.js that the class
offers. The grant dialog offers one select over the styles the character has
not taken yet, and applyFeatureGrant stores the pick as style on the
feature’s record. entities/FightingStyle.js reads the style ids back from
featureChoices and works out the numbers:
| Style | Where it applies |
|---|---|
| Archery | WeaponSwing.prepareSwing adds 2 to the attack of a ranged weapon |
| Defense | Armor.armorClass adds 1 while the chest slot holds body armor |
| Dueling | WeaponSwing.hitDamage adds 2 to one-handed melee damage when no other weapon is in hand |
| Great Weapon Fighting | hitDamage sets rerollBelow: 2 on the damage parts of a two-handed melee swing, and rollDamage rerolls each 1 or 2 once |
| Two-Weapon Fighting | hitDamage keeps the ability modifier on the off-hand swing |
Protection needs a reaction and the positions of allies, which the app does not model, so the sheet shows it as text only.
entities/Features.js reads the names of level-scaling features as numbers.
attacksPerAction gives 2 for ‘Extra Attack’, and 3 or 4 for the numbered
Fighter features that follow it. It takes the best count across the class
list, because Extra Attack does not stack in 5e. It also takes the higher of that count and
PactWeapon.pactAttacks, so a warlock with Thirsting Blade swings twice, and
a fighter 5 / warlock 5 with the invocation still swings twice. The optional
weapon argument names the weapon of the swing, and Thirsting Blade counts
only when that weapon is the pact weapon. sneakAttackDice gives the
number of d6 that Sneak Attack adds, from the level in the class that granted
it. hasFeature and featureSource are the exact-name lookups under both.
A structured effect models a grant made once. A value that scales with the class level stays a name match, because the app derives it on each read. A homebrew class that uses the same names gets the same rules.
Adding a class mechanic lists the files to change for each kind of class feature.
Eldritch invocations
entities/Invocations.js defines the rules of the warlock’s eldritch
invocations and pact boon, and data/invocationCatalog.js lists the SRD
invocations. A character stores its picked ids in invocations, its boon
in pactBoon, and the once-per-rest invocations it spent in
invocationUses, which Character.longRest clears. getInvocations
keeps only the stored picks that the character still qualifies for, up to
the count of its warlock level. A warlock that loses a level or changes its
boon loses the invocations that no longer apply, and no writer has to prune
the list. setInvocations writes the list through the same filter.
InvocationLevelUp.js keeps the level-up rules. pendingInvocationCount
and pactBoonPending tell the sheet and the level-up what is left to
pick, and swapInvocation replaces one known pick in place with an
invocation that qualifies and is not picked. ui/InvocationLevelFlow.js
gathers the boon, the new picks, and the swap against a preview of the
level, and applyWarlockPicks applies them again, boon first, to the
character read after the last dialog closes.
Beguiling Influence grants two skills through the grant ledger. Its record
sits in featureChoices under the key warlock 2 Beguiling Influence, so
GrantLedger.rebuildGrants treats it like a claimed class feature. An undo
of the invocation keeps a skill that a feat also grants. Progression.derive
calls settleInvocationSkills, which matches the record to the invocations
that apply, so a warlock that drops below 2nd level loses the skills. The
donor path of LevelAssign asks invocationsClaim whether a warlock level
keeps an invocation or the pact boon, and it refuses to move such a level,
the same as a level with any other choice record. LoadCoercion.warlockPicks
coerces the three stored fields on load: a list that is not an array of
strings reads as empty, and an unknown boon drops.
invokedSpell returns a spell as the invocations change it: Eldritch Blast
with addsModifier or a new range, and an at-will spell with a Self range
or with no material. app/combatants.spellsOf and the sheet spell list map
their spells through it, and castPlan in app/castPlan.js maps its spell
again. A spell that the caster also knows lists as the book has it, because
those lists pass atWill: false, and castPlan applies the at-will
changes only on the at-will cast. invocationCast tells castPlan how the
cast is paid. An at-will cast is a free cast at the spell’s own level. A
once-per-rest cast offers only the pact slot level (pactSlotLevels) and
passes granted and pool: 'pact' to castSpell, which skips the
spellbook check and pays from the pact pool only. resolveCast reads the use
again off the live caster, refuses a use that another tab spent while the
dialog was open, and then marks the use with markInvocationUsed.
Pact of the Tome
entities/PactTome.js defines the Book of Shadows. The three book cantrips
sit in spellbook.cantrips with the warlock as their source, so every cast
path reads them as warlock cantrips with CHA, and bookOfShadows.cantrips
marks them. Classes.cantripLimit adds the book cantrips that the
spellbook still has to the class limit, so they do not count against it.
The rituals of Book of Ancient Secrets sit in bookOfShadows.rituals only,
and not in the known list, because a known spell of a known-rule class
casts with a slot. SpellView.isRitualOnly is true for a ritual in the
book, so castPlan offers no slot, payForCast accepts the ritual cast,
and the sheet lists it. Classes.hasRitualCasting is true for a warlock
with the invocation, and with a spell id only for a ritual in the book.
PactTome.settleTome takes back what a lost tome granted, and
Invocations.setInvocations calls it, so setPactBoon and every
invocation change run it. Without the Tome boon, the ids in
bookOfShadows.cantrips leave spellbook.cantrips and spellbook.sources,
and bookOfShadows goes. Without Book of Ancient Secrets, the rituals empty.
setTomeCantrips never lists a cantrip that the spellbook already has, and
unlearnCantrip drops a forgotten id from the book, so the list names only
the cantrips the book granted and a class cantrip never leaves.
toCaster copies invocations, pactBoon, and bookOfShadows, so these
checks read the caster view. PactTome.js imports only Multiclass.js,
because Classes.js imports it and Invocations.js imports the
spellbook, so its invocation check reads the stored picks and the Tome
boon directly.
Mystic Arcanum
entities/MysticArcanum.js defines the Mystic Arcanum. At warlock levels 11,
13, 15, and 17 the warlock picks one warlock spell of 6th, 7th, 8th, and 9th
level, and the character stores the picks in mysticArcanum, keyed by spell
level. getArcana keeps only the picks that the warlock level still grants.
arcanumCast returns an InvocationCast with oncePerRest and free set,
and warlockCast asks the invocations first and the arcanum second.
castPlan, resolveCast, and castRoutes read warlockCast, so an
arcanum casts at the spell’s own level with no slot and no spellbook check.
A spent arcanum sits in invocationUses under the id arcanum-<level>, so
markInvocationUsed spends it and Character.longRest gives it back. A
spent arcanum of a spell that the spellbook also has casts the usual way
with a slot. The level-up flow asks for each arcanum pick through
applyWarlockPicks, and arcanaClaim tells the donor path of
LevelAssign that a warlock level keeps a pick.
entities/CastRoute.js lists the ways to pay for a spell that has more
than one. A caster with an open repeat can repeat it or cast it anew, and a
warlock that knows an at-will spell can cast it at will or with a slot.
runCast asks the GM first when the list is not empty, and it passes the
answer to castPlan as its route. A fresh cast of a repeat spell removes
the old repeat chip with SpellRepeat.dropRepeat, because the old chip can
outlast the new one.
entities/PactWeapon.js reads the pact weapon of a Pact of the Blade
warlock. The character stores the inventory id of that weapon in
pactWeapon, and pactWeapon returns the item only while the character has
the Blade boon and still carries a weapon or bow with that id.
CharacterInventory.removeItem clears the id when the stack leaves, so a
given or discarded weapon never keeps the mark. pactAttacks gives 2 for a
warlock with Thirsting Blade (the pactAttack effect) and a marked pact
weapon. With a weapon passed, it gives 2 only when that weapon is the pact
weapon, and 1 for any other. pactDamage gives the flat necrotic term of Lifedrinker (the
pactDamage effect), equal to the CHA modifier with a minimum of 1, for a hit
with the pact weapon. The weapon swing adds that term after the crit
doubling, because a crit doubles only dice. MagicWeapon.weaponIsMagical
calls isPactWeapon, so the pact weapon counts as magical against
Stoneskin.
Load-time defaults
Character.withDefaults runs on every character that the app loads, and
campaign/Campaigns.js maps every loaded character through it. It converts a
scalar class to a list, creates an empty proficiency structure where one is
missing, and keeps a race string as it is. It also reconciles the loaded pools
through Progression.derive, so a save that was edited by hand comes back
with pools that match its class list, level, and CON.