Wizard Spellbook Management
Wizards maintain a dedicated spellbook alongside the regular spell-preparation workflow. A spell must be learned into the spellbook before it can be prepared or cast. Wizard handling applies to the class whose identifier is wizard and to any class with Force Wizard Mode enabled in Class Rules.
Each wizard-enabled class is treated independently: its own Learn tab, journal-backed spellbook, copied-spell metadata flag, and counters.
The Learn tab
Section titled “The Learn tab”
A Learn tab is rendered for every wizard-enabled class on the actor, alongside that class’s regular Prepare tab. The tab label reads “<Class> Spell Book” and carries the module icon.
The tab lists:
- Spells from the class’s assigned spell list not yet in the spellbook.
- Eligible spell scrolls detected in the actor’s inventory (see Scroll Scanner).
Spells above the actor’s maximum castable level for that class are filtered out.
The sidebar carries an Affordable Only filter that hides spells the character cannot pay for. It appears only while spell learning costs are deducted.
Row states
Section titled “Row states”Each spell row on the Learn tab resolves to one of three states:
| State | Control | Behavior |
|---|---|---|
| Available from the spell list | Learn button | Free copies are added immediately. Paid copies open a confirmation dialog quoting the gold cost and time from WizardBook.getCopyingCost() and WizardBook.getCopyingMinutes(). |
| Available from a scroll | Learn button with scroll icon | Calls WizardBook.learnFromScroll. While Consume Scrolls When Learning is enabled, the scroll’s quantity is decremented; the scroll is deleted when quantity hits zero. |
| Already in the spellbook | Source-labeled badge | Shows a source icon and label (see Learning Sources). Hovering reveals an eraser-icon Unlearn Spell affordance; clicking removes the spell from the spellbook immediately, with no confirmation prompt. |
Footer counters
Section titled “Footer counters”The Learn tab footer displays:
current/max Spells. Current is the spellbook’s total learned count, max isgetMaxSpellsAllowed().N Free. Remaining free-pool slots fromgetRemainingFreeSpells(). When this reaches zero, any further non-scroll copies require gold and time.- The character’s total wealth, refreshed alongside the counters.
Learning spells
Section titled “Learning spells”From the class spell list
Section titled “From the class spell list”Clicking Learn queries WizardBook.getCopyingCost(actor, classId, spell):
- If the result is free (cantrip or free pool not yet depleted), the spell is added directly without a dialog.
- Otherwise a confirmation dialog shows the gold cost and time. On confirm, if Deduct Spell Learning Costs is enabled, the gold is deducted through the system’s currency manager, and only then is the spell added with source
copied.
While Deduct Spell Learning Costs is enabled, every learnable spell shows its copying cost beside the Learn button, a free spell shows a free badge instead, and a spell the character cannot pay for shows its cost in a red state with its Learn button disabled and the shortfall as the button tooltip. With that setting off, no cost badge is shown.
From scrolls
Section titled “From scrolls”Scroll rows route to WizardBook.learnFromScroll. The spell is added with source scroll and consumes no free-pool slot. Enabling Charge Cost for Scroll Copying applies the normal copying cost to a scroll copy. While Consume Scrolls When Learning is on, the scroll item’s quantity is decremented, and the item is deleted when quantity would drop below 1.
Unlearning
Section titled “Unlearning”The in-spellbook eraser-icon action calls WizardBook.removeSpellFromSpellbook(actor, classId, uuid):
- Removes the UUID from the spellbook journal page’s
system.spellsSet. - Removes the matching metadata entry from the
wizardCopiedSpells_<classId>actor flag.
Cost and time
Section titled “Cost and time”Gold cost
Section titled “Gold cost”cost = spellLevel * spellLearningCostMultiplierCantrips and free copies always cost 0 GP.
minutes = spellLevel * spellLearningTimeMultiplierCantrips always take 1 minute. Copying time is displayed in the largest whole unit that fits the duration.
Both multipliers are configurable per class in Class Rules.
Free spell pool
Section titled “Free spell pool”Capacity
Section titled “Capacity”max = startingSpells + max(0, classLevel - 1) * spellsPerLevelstartingSpells and spellsPerLevel are configurable per class in Class Rules. Class level reads from actor.spellcastingClasses[classId].system.levels.
Accounting
Section titled “Accounting”A spell occupies a free slot unless it was learned via copied or scroll, recorded in the wizardCopiedSpells_<classId> flag.
- Used free = spellbook entries with no matching entry in the copied-spells flag.
- Remaining free =
max(0, capacity − used).
Cantrips are always treated as free regardless of remaining pool.
getMaxSpellsAllowed() returns the same formula and defines both the free-pool ceiling and the total spellbook capacity shown in the footer.
Spellbook storage
Section titled “Spellbook storage”Journal page
Section titled “Journal page”Each wizard-enabled class gets a dedicated JournalEntry in the spell-book.custom-spell-lists compendium pack, placed in the Actor Spellbooks folder. Access goes through WizardBook._getOrCreateSpellbookJournal(actor, classId).
The journal has one page of type spells; its system.spells Set holds the learned UUIDs.
| Location | Value |
|---|---|
Journal flags (spell-book) | actorId, classIdentifier, isActorSpellbook: true, creationDate |
Page system.identifier | <slug(actor.name)>-<classId>-spellbook |
Page system.type | 'actor-spellbook' |
| Ownership | Mirrors the actor’s owners: everyone with ownership level 3 retains ownership on the journal and page. |
Journal name is the actor’s name for the default wizard class, or <actor> (<className>) for other wizard-enabled classes.
Copied-spell metadata
Section titled “Copied-spell metadata”Spells learned as copied or scroll append an entry to the actor.flags.spell-book.wizardCopiedSpells_<classId> flag:
| Field | Description |
|---|---|
spellUuid | Spell UUID |
dateCopied | Date.now() timestamp |
cost | Gold paid (0 for scroll) |
timeSpent | Duration of the copy, in minutes |
fromScroll | true for scroll learns, false for paid copies |
Unlearning removes the entry. Spells learned via free have no flag entry; absence of an entry is what marks a spell as a free-pool occupant. The initial and levelUp source constants are defined but produce no flag entry and are currently unused in any write path.
GM Approval for Spell Copying
Section titled “GM Approval for Spell Copying”With GM Approval for Spell Copying enabled, a player’s copy attempt from the class spell list is stored on the character as a pending request instead of being applied. Scroll-based learning bypasses this check entirely, even when Charge Cost for Scroll Copying applies a gold cost to it. See Spell Copy Approval for the GM side of the workflow.
Learning sources
Section titled “Learning sources”Every spell in the spellbook resolves to one of the following sources, defined in WIZARD_SPELL_SOURCE:
| Source | Meaning | Badge label | Icon |
|---|---|---|---|
initial | Defined constant; no grant logic is currently implemented | Level-up | book |
free | Copied from the class spell list while the free pool still had capacity | Level-up | book |
copied | Paid gold-copy from the class spell list | Purchased | book |
scroll | Learned by consuming/reading a scroll | Scroll | scroll |
levelUp | Defined constant; no grant logic is currently implemented | Level-up | book |
WizardBook.getSpellLearningSource() infers from the wizardCopiedSpells_<classId> flag: entries with fromScroll: true resolve to scroll, other entries to copied, and missing entries fall back to free. initial and levelUp produce no flag entry and are therefore indistinguishable from free at runtime. The badge template checks the source only against scroll; every other source renders the same book icon, distinguished instead by badge color and label text.
Scroll Scanner
Section titled “Scroll Scanner”scanForScrollSpells(actor, maxSpellLevel) in scripts/data/scroll-processor.mjs builds the scroll section of the Learn tab.
Detection
Section titled “Detection”- Filters
actor.itemsto entries withtype === 'consumable'andsystem.type.value === 'scroll'. - For each scroll, extracts a spell UUID from, in order:
- Any activity with
activity.spell.uuid. - Any activity effect reference whose corresponding effect has an
originUUID. - Fallback: parse the scroll name (
"Spell Scroll: <Name>") and search item packs for a spell matching that name, preferringsystem.level === scroll.flags.dnd5e.spellLevel.base.
- Any activity with
- Rejects spells above
maxSpellLevel. Cantrips always pass.
Filtering
Section titled “Filtering”Scrolls whose contained spell UUID is already in the wizard’s spellbook are excluded from the Learn tab; the spell is already known.
Ritual casting integration
Section titled “Ritual casting integration”Set per-class ritual behavior in Class Rules under Ritual Casting. Three modes:
| Mode | Label | Behavior |
|---|---|---|
none | No Ritual Casting | Rituals are prepared like any other spell. |
prepared | Cast Rituals Only If Prepared | Rituals can be cast without consuming a slot, but only while prepared. |
always | Cast Any Known Ritual | On save, spells in the wizard’s spellbook flagged as ritual are auto-injected into the actor as ritual-mode items (system.method: 'ritual', system.prepared: 0), even when not prepared. |
SpellManager marks each auto-injected ritual item with an isModuleRitual item flag, which identifies module-managed ritual copies on subsequent saves. On each save:
- Items flagged from a previous mode that no longer apply are cleaned up.
- Current ritual spells from the spellbook are inserted or refreshed.
Switching a class away from always clears the ritual items it generated.
See Ruleset Types and Meanings for the full ruleset table.
Initial spells and level-up grants
Section titled “Initial spells and level-up grants”WizardBook._ensureFlagsInitialized runs the first time a wizard-enabled class is processed for an actor. It creates an empty wizardCopiedSpells_<classId> flag array on the actor if one is not already present. It does not add spell UUIDs to the spellbook and does not reference WIZARD_DEFAULTS.STARTING_SPELLS.
WIZARD_SPELL_SOURCE.INITIAL and WIZARD_SPELL_SOURCE.LEVEL_UP are defined constants with no corresponding write path; no automatic grant of initial or level-up spells is implemented. The free-pool capacity formula in getMaxSpellsAllowed() uses startingSpells and spellsPerLevel as ceiling inputs only. The GM or player must manually learn the spells the calculated capacity makes available.
Chris’s Premades compatibility
Section titled “Chris’s Premades compatibility”When the Chris’s Premades module is active and the CPR compatibility world setting is enabled, every preparation save triggers an actor refresh, re-evaluating that module’s item replacements against the new spell preparation state.
Force Wizard Mode
Section titled “Force Wizard Mode”Class Rules expose a per-class Force Wizard Mode toggle. When on, the class is treated as a wizard for all purposes:
- A Learn tab is rendered for it.
- Its spells are managed through a dedicated wizard journal and copied-spells flag.
- Cost, time, free pool, and ritual integration apply using that class’s own rules.
Use it for homebrew classes, variant classes, or subclasses that copy spells into a book.
Wizard detection comes from ClassManager.getWizardEnabledClasses(), which returns every spellcasting class whose identifier is wizard or has forceWizardMode === true. Each returned class gets independent state; there is no priority between them.
Multi-class wizards
Section titled “Multi-class wizards”A character with multiple wizard-enabled classes has:
- One Learn tab per wizard-enabled class.
- One spellbook journal per class in
custom-spell-lists. - Independent
wizardCopiedSpells_<classId>flags. - Independent free-pool, max-spell, and ritual-mode state driven by each class’s own level and rules.
No spells are shared between spellbooks.
API summary
Section titled “API summary”Key static methods on WizardBook (scripts/managers/wizard-book.mjs):
| Method | Purpose |
|---|---|
getWizardSpellbook(actor, classId) | Returns the UUID array from the journal page |
isSpellInSpellbook(actor, classId, uuid) | Membership check |
addSpellToSpellbook(actor, classId, uuid, source, metadata) | Add a spell and optionally record copied metadata |
removeSpellFromSpellbook(actor, classId, uuid) | Remove a spell and clean up metadata |
copySpell(actor, classId, uuid, cost, time, source) | Add with optional currency deduction |
learnFromScroll(actor, classId, scroll, options) | Learn the spell a scroll is linked to and consume the scroll |
getCopyingCost(actor, classId, spell) | { cost, isFree } |
getCopyingMinutes(actor, classId, spell) | Copying duration in minutes |
formatCopyingTime(minutes) | Display string for a duration in minutes |
getTotalWealth(actor) | Actor currency summed into base currency units |
canAfford(actor, cost) | Whether the actor can pay a cost |
getMaxSpellsAllowed(actor, classId) | Capacity formula result |
getUsedFreeSpells(actor, classId) | Count of non-paid spells in the spellbook |
getRemainingFreeSpells(actor, classId) | max - used, floored at zero |
isSpellFree(actor, classId, spell) | Whether the next copy would be free |
getSpellLearningSource(actor, classId, uuid) | Resolves to free / copied / scroll from the flag |
invalidateCache(actor) | Drop journal and spellbook caches |
Related modules:
scripts/apps/player-spell-book.mjs: Learn tab render (#buildRenderContextwithisLearn),#onLearnSpell,#onLearnSpellFromScroll,#onUnlearnSpell.scripts/managers/spell-manager.mjs: save flow and ritual auto-injection.scripts/data/scroll-processor.mjs:scanForScrollSpells.scripts/constants.mjs:WIZARD_DEFAULTS,WIZARD_SPELL_SOURCE,FLAGS.WIZARD_COPIED_SPELLS.templates/apps/player/tab-learn.hbs,templates/components/spell-item.hbs: Learn tab and per-row wizard action block.