diff --git a/src/main/characterStore.ts b/src/main/characterStore.ts index 26791a4..2b0349e 100644 --- a/src/main/characterStore.ts +++ b/src/main/characterStore.ts @@ -16,6 +16,12 @@ export interface CharacterEntityRef { entityRef: string; } +export interface CharacterClassSelection { + class: CharacterEntityRef; + subclass: CharacterEntityRef | null; + level: number; +} + export interface CharacterProfile { name: string; level: number; @@ -94,6 +100,7 @@ export interface CharacterSteps { export interface CharacterSelections { class: CharacterEntityRef | null; subclass: CharacterEntityRef | null; + classes: CharacterClassSelection[]; background: CharacterEntityRef | null; species: CharacterEntityRef | null; } @@ -219,6 +226,7 @@ const DEFAULT_STEPS: CharacterSteps = { const DEFAULT_SELECTIONS: CharacterSelections = { class: null, subclass: null, + classes: [], background: null, species: null }; @@ -375,6 +383,63 @@ function normalizeEntityRef(value: unknown, expectedType: CharacterEntityType): }; } +function normalizeClassSelection(value: unknown): CharacterClassSelection | null { + if (!isRecord(value)) { + return null; + } + + const classRef = normalizeEntityRef(value.class, "class"); + if (!classRef) { + return null; + } + + const subclassRef = normalizeEntityRef(value.subclass, "subclass"); + return { + class: classRef, + subclass: subclassRef, + level: toInteger(value.level, 1, 1, 20) + }; +} + +function getTotalClassLevel(classes: CharacterClassSelection[]): number { + const total = classes.reduce((sum, cls) => sum + cls.level, 0); + return Math.max(1, Math.min(20, total)); +} + +function normalizeClassSelections( + value: unknown, + fallbackClass: CharacterEntityRef | null, + fallbackSubclass: CharacterEntityRef | null, + fallbackLevel: number +): CharacterClassSelection[] { + const out: CharacterClassSelection[] = []; + const seen = new Set(); + + if (Array.isArray(value)) { + for (const raw of value) { + const normalized = normalizeClassSelection(raw); + if (!normalized) { + continue; + } + if (seen.has(normalized.class.entityRef)) { + continue; + } + seen.add(normalized.class.entityRef); + out.push(normalized); + } + } + + if (!out.length && fallbackClass) { + out.push({ + class: fallbackClass, + subclass: fallbackSubclass, + level: Math.max(1, Math.min(20, fallbackLevel)) + }); + } + + return out; +} + function normalizeProfile(value: unknown): CharacterProfile { const profile = isRecord(value) ? value : {}; return { @@ -521,11 +586,17 @@ function normalizeAbout(value: unknown): CharacterAboutStep { }; } -function normalizeSelections(value: unknown): CharacterSelections { +function normalizeSelections(value: unknown, fallbackLevel: number): CharacterSelections { const selections = isRecord(value) ? value : {}; + const primaryClass = normalizeEntityRef(selections.class, "class"); + const primarySubclass = normalizeEntityRef(selections.subclass, "subclass"); + const classes = normalizeClassSelections(selections.classes, primaryClass, primarySubclass, fallbackLevel); + const firstClass = classes[0]; + return { - class: normalizeEntityRef(selections.class, "class"), - subclass: normalizeEntityRef(selections.subclass, "subclass"), + class: firstClass?.class ?? primaryClass, + subclass: firstClass?.subclass ?? primarySubclass, + classes, background: normalizeEntityRef(selections.background, "background"), species: normalizeEntityRef(selections.species, "species") }; @@ -549,8 +620,12 @@ function normalizeCharacterRecord(raw: unknown): CharacterDocument { const root = isRecord(raw) ? raw : {}; const profile = normalizeProfile(root.profile); - const selections = normalizeSelections(root.selections); + const selections = normalizeSelections(root.selections, profile.level); const steps = normalizeSteps(root.steps); + const normalizedProfile: CharacterProfile = { + ...profile, + level: selections.classes.length ? getTotalClassLevel(selections.classes) : profile.level + }; const idCandidate = toString(root.id).trim(); const id = idCandidate || randomUUID(); @@ -561,7 +636,7 @@ function normalizeCharacterRecord(raw: unknown): CharacterDocument { id, createdAt: toIsoDate(root.createdAt, nowIso), updatedAt: toIsoDate(root.updatedAt, nowIso), - profile, + profile: normalizedProfile, selections, steps, notes: toString(root.notes).trim() @@ -655,7 +730,7 @@ export function listCharacters(charactersDir: string): CharacterSummary[] { id: character.id, name: character.profile.name, level: character.profile.level, - className: character.selections.class?.name ?? null, + className: character.selections.classes[0]?.class.name ?? character.selections.class?.name ?? null, backgroundName: character.selections.background?.name ?? null, speciesName: character.selections.species?.name ?? null, createdAt: character.createdAt, diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 795019e..c8cc3ac 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -76,6 +76,12 @@ interface CharacterEntityRef { entityRef: string; } +interface CharacterClassSelection { + class: CharacterEntityRef; + subclass: CharacterEntityRef | null; + level: number; +} + interface CharacterProfile { name: string; level: number; @@ -151,6 +157,7 @@ interface CharacterDocument { selections: { class: CharacterEntityRef | null; subclass: CharacterEntityRef | null; + classes: CharacterClassSelection[]; background: CharacterEntityRef | null; species: CharacterEntityRef | null; }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6c46dc9..5a4d129 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { FormEvent, JSX, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, JSX, MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; const DEFAULT_PAGE = "/5etools.html"; const APP_CHARACTERS_MENU_ID = "__characters"; @@ -93,11 +93,22 @@ type RendererHandle = { render: (entry: unknown) => string; }; +type RendererHoverLinkOptions = { + isSpecifiedLinkData?: boolean; + page?: string; + source?: string; + hash?: string; + preloadId?: string; + customHashId?: string; + isFauxPage?: boolean; + isAllowRedirect?: boolean; +}; + type RendererGlobal = { get: () => RendererHandle; hover?: { _doInit?: () => void; - pHandleLinkMouseOver?: (evt: MouseEvent, ele: HTMLElement, opts?: unknown) => Promise; + pHandleLinkMouseOver?: (evt: MouseEvent, ele: HTMLElement, opts?: RendererHoverLinkOptions) => Promise; handleLinkMouseLeave?: (evt: MouseEvent, ele: HTMLElement) => void; handleLinkMouseMove?: (evt: MouseEvent, ele: HTMLElement) => void; handleLinkClick?: (evt: MouseEvent, ele: HTMLElement, opts?: { isForceOpenPermanent?: boolean }) => void; @@ -106,6 +117,12 @@ type RendererGlobal = { }; }; +type UrlUtilGlobal = { + PG_SPELLS?: string; + URL_TO_HASH_BUILDER?: Record string) | undefined>; + encodeArrayForHash?: (...parts: string[]) => string; +}; + type TopbarMenuEntry = { id: string; type: "link" | "action" | "divider" | "label" | "submenu"; @@ -137,6 +154,7 @@ declare global { TemplateUtil?: { initVanilla?: () => void; }; + UrlUtil?: UrlUtilGlobal; } } @@ -632,6 +650,7 @@ function createNewCharacterDraft(): CharacterDocument { selections: { class: null, subclass: null, + classes: [], background: null, species: null }, @@ -774,6 +793,78 @@ function toSubclassEntityRef(option: CharacterFeatureOption): CharacterEntityRef }; } +function getSourceEdition(sourceRaw: string): "2014" | "2024" { + return sourceRaw.trim().toUpperCase().startsWith("X") ? "2024" : "2014"; +} + +function getDraftClassSelections(draft: CharacterDocument | null): CharacterClassSelection[] { + if (!draft) { + return []; + } + + if (Array.isArray(draft.selections.classes) && draft.selections.classes.length) { + const seen = new Set(); + return draft.selections.classes + .map((entry) => ({ + class: entry.class, + subclass: entry.subclass ?? null, + level: Math.max(1, Math.min(20, Math.trunc(entry.level || 1))) + })) + .filter((entry) => { + const key = entry.class?.entityRef?.trim(); + if (!key || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + } + + if (draft.selections.class) { + return [ + { + class: draft.selections.class, + subclass: draft.selections.subclass ?? null, + level: Math.max(1, Math.min(20, draft.profile.level || 1)) + } + ]; + } + + return []; +} + +function getClassLevelTotal(classes: CharacterClassSelection[]): number { + const total = classes.reduce((sum, entry) => sum + Math.max(1, Math.trunc(entry.level || 1)), 0); + return Math.max(1, Math.min(20, total)); +} + +function withDraftClassSelections(draft: CharacterDocument, classesRaw: CharacterClassSelection[]): CharacterDocument { + const classes = classesRaw + .map((entry) => ({ + class: entry.class, + subclass: entry.subclass ?? null, + level: Math.max(1, Math.min(20, Math.trunc(entry.level || 1))) + })) + .filter((entry) => Boolean(entry.class?.entityRef)); + + const primary = classes[0]; + const totalLevel = classes.length ? getClassLevelTotal(classes) : 1; + + return { + ...draft, + profile: { + ...draft.profile, + level: totalLevel + }, + selections: { + ...draft.selections, + class: primary?.class ?? null, + subclass: primary?.subclass ?? null, + classes + } + }; +} + function buildAssetUrl(baseUrl: string, relativePath: string | null): string { if (!baseUrl || !relativePath) { return ""; @@ -1217,6 +1308,87 @@ function parseSkillProficiencyValue( }; } +function getClassRequirementSets(detail: CharacterSelectionDetail | null): AbilityKey[][] { + if (!detail) { + return []; + } + + const raw = safeJsonParse>(detail.rawJson); + if (!raw) { + return []; + } + + const requirementSets: AbilityKey[][] = []; + const multiclassingRaw = isRecord(raw.multiclassing) ? raw.multiclassing : null; + const requirementsRaw = multiclassingRaw && isRecord(multiclassingRaw.requirements) ? multiclassingRaw.requirements : null; + + if (requirementsRaw) { + const orRaw = Array.isArray(requirementsRaw.or) ? requirementsRaw.or : null; + if (orRaw?.length) { + for (const optionRaw of orRaw) { + if (!isRecord(optionRaw)) { + continue; + } + const keys = Object.entries(optionRaw) + .filter(([key, value]) => isAbilityKey(key.toLowerCase()) && typeof value === "number" && Number.isFinite(value) && value > 0) + .map(([key]) => key.toLowerCase() as AbilityKey); + if (!keys.length) { + continue; + } + + // 5etools uses this shape for Fighter ("Str 13 or Dex 13"), so each key is a separate option set. + keys.forEach((key) => requirementSets.push([key])); + } + } else { + const keys = Object.entries(requirementsRaw) + .filter(([key, value]) => isAbilityKey(key.toLowerCase()) && typeof value === "number" && Number.isFinite(value) && value > 0) + .map(([key]) => key.toLowerCase() as AbilityKey); + if (keys.length) { + requirementSets.push(keys); + } + } + } + + if (requirementSets.length) { + return requirementSets; + } + + const primary = raw.primaryAbility; + if (Array.isArray(primary)) { + for (const optionRaw of primary) { + if (!isRecord(optionRaw)) { + continue; + } + const keys = Object.keys(optionRaw) + .map((key) => key.toLowerCase()) + .filter((key): key is AbilityKey => isAbilityKey(key)); + if (keys.length) { + requirementSets.push(keys); + } + } + } else if (isRecord(primary)) { + const keys = Object.keys(primary) + .map((key) => key.toLowerCase()) + .filter((key): key is AbilityKey => isAbilityKey(key)); + if (keys.length) { + requirementSets.push(keys); + } + } + + return requirementSets; +} + +function meetsAnyClassRequirementSet( + scores: Record, + requirementSets: AbilityKey[][] +): boolean { + if (!requirementSets.length) { + return true; + } + + return requirementSets.some((requiredSet) => requiredSet.every((ability) => (scores[ability] ?? 0) >= 13)); +} + function parseClassSkills(detail: CharacterSelectionDetail | null): ParsedSkillData { if (!detail) { return { granted: [], choices: [] }; @@ -1230,6 +1402,23 @@ function parseClassSkills(detail: CharacterSelectionDetail | null): ParsedSkillD return parseSkillProficiencyValue(raw.startingProficiencies.skills, "Class", "class"); } +function parseMulticlassClassSkills(detail: CharacterSelectionDetail | null): ParsedSkillData { + if (!detail) { + return { granted: [], choices: [] }; + } + + const raw = safeJsonParse>(detail.rawJson); + if (!raw || !isRecord(raw.multiclassing) || !isRecord(raw.multiclassing.proficienciesGained)) { + return { granted: [], choices: [] }; + } + + return parseSkillProficiencyValue( + raw.multiclassing.proficienciesGained.skills, + `${detail.name} Multiclass`, + `multiclass-${detail.entityRef}` + ); +} + function parseBackgroundSkills(detail: CharacterSelectionDetail | null): ParsedSkillData { if (!detail) { return { granted: [], choices: [] }; @@ -1318,19 +1507,221 @@ function getFixedFeatUids(detail: CharacterSelectionDetail | null): string[] { return [...out]; } -function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: number): { +const FULL_CASTER_SLOT_TABLE: number[][] = [ + [2, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0], + [4, 2, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 2, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 1, 0, 0, 0, 0, 0], + [4, 3, 3, 2, 0, 0, 0, 0, 0], + [4, 3, 3, 3, 1, 0, 0, 0, 0], + [4, 3, 3, 3, 2, 0, 0, 0, 0], + [4, 3, 3, 3, 2, 1, 0, 0, 0], + [4, 3, 3, 3, 2, 1, 0, 0, 0], + [4, 3, 3, 3, 2, 1, 1, 0, 0], + [4, 3, 3, 3, 2, 1, 1, 0, 0], + [4, 3, 3, 3, 2, 1, 1, 1, 0], + [4, 3, 3, 3, 2, 1, 1, 1, 0], + [4, 3, 3, 3, 2, 1, 1, 1, 1], + [4, 3, 3, 3, 3, 1, 1, 1, 1], + [4, 3, 3, 3, 3, 2, 1, 1, 1], + [4, 3, 3, 3, 3, 2, 2, 1, 1] +]; + +const HALF_CASTER_SLOT_TABLE: number[][] = [ + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [2, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0], + [4, 2, 0, 0, 0, 0, 0, 0, 0], + [4, 2, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 2, 0, 0, 0, 0, 0, 0], + [4, 3, 2, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 1, 0, 0, 0, 0, 0], + [4, 3, 3, 1, 0, 0, 0, 0, 0], + [4, 3, 3, 2, 0, 0, 0, 0, 0], + [4, 3, 3, 2, 0, 0, 0, 0, 0], + [4, 3, 3, 3, 1, 0, 0, 0, 0], + [4, 3, 3, 3, 1, 0, 0, 0, 0], + [4, 3, 3, 3, 2, 0, 0, 0, 0], + [4, 3, 3, 3, 2, 0, 0, 0, 0] +]; + +const THIRD_CASTER_SLOT_TABLE: number[][] = [ + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [2, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0], + [4, 2, 0, 0, 0, 0, 0, 0, 0], + [4, 2, 0, 0, 0, 0, 0, 0, 0], + [4, 2, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 2, 0, 0, 0, 0, 0, 0], + [4, 3, 2, 0, 0, 0, 0, 0, 0], + [4, 3, 2, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 1, 0, 0, 0, 0, 0], + [4, 3, 3, 1, 0, 0, 0, 0, 0] +]; + +const ARTIFICER_SLOT_TABLE: number[][] = [ + [2, 0, 0, 0, 0, 0, 0, 0, 0], + [2, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 0, 0, 0, 0, 0, 0, 0, 0], + [4, 2, 0, 0, 0, 0, 0, 0, 0], + [4, 2, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 0, 0, 0, 0, 0, 0, 0], + [4, 3, 2, 0, 0, 0, 0, 0, 0], + [4, 3, 2, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 0, 0, 0, 0, 0, 0], + [4, 3, 3, 1, 0, 0, 0, 0, 0], + [4, 3, 3, 1, 0, 0, 0, 0, 0], + [4, 3, 3, 2, 0, 0, 0, 0, 0], + [4, 3, 3, 2, 0, 0, 0, 0, 0], + [4, 3, 3, 3, 1, 0, 0, 0, 0], + [4, 3, 3, 3, 1, 0, 0, 0, 0], + [4, 3, 3, 3, 2, 0, 0, 0, 0], + [4, 3, 3, 3, 2, 0, 0, 0, 0] +]; + +const PACT_SLOT_COUNT_BY_LEVEL = [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4]; +const PACT_SLOT_LEVEL_BY_LEVEL = [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5]; + +function getSpellcastingAbilityKey(detail: CharacterSelectionDetail | null): AbilityKey | null { + if (!detail) { + return null; + } + + const raw = safeJsonParse>(detail.rawJson); + const value = typeof raw?.spellcastingAbility === "string" ? raw.spellcastingAbility.trim().toLowerCase() : ""; + return isAbilityKey(value) ? value : null; +} + +function parseSpellLevelFromCell(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + const parsed = Math.trunc(value); + return parsed >= 1 && parsed <= 9 ? parsed : null; + } + + if (typeof value !== "string") { + return null; + } + + const raw = value.trim(); + if (!raw) { + return null; + } + + const levelAttr = raw.match(/level\s*=\s*(\d+)/i); + if (levelAttr) { + const parsed = Number.parseInt(levelAttr[1], 10); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 9 ? parsed : null; + } + + const ord = raw.match(/(\d+)\s*(?:st|nd|rd|th)/i); + if (ord) { + const parsed = Number.parseInt(ord[1], 10); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 9 ? parsed : null; + } + + const cleaned = stripTagsFallback(raw); + const attrFromCleaned = cleaned.match(/level\s*=\s*(\d+)/i); + if (attrFromCleaned) { + const parsed = Number.parseInt(attrFromCleaned[1], 10); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 9 ? parsed : null; + } + + const ordFromCleaned = cleaned.match(/(\d+)\s*(?:st|nd|rd|th)/i); + if (ordFromCleaned) { + const parsed = Number.parseInt(ordFromCleaned[1], 10); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 9 ? parsed : null; + } + + return null; +} + +function toSlotTableRows(casterProgression: string): number[][] { + if (casterProgression === "full") { + return FULL_CASTER_SLOT_TABLE; + } + if (casterProgression === "1/2") { + return HALF_CASTER_SLOT_TABLE; + } + if (casterProgression === "1/3") { + return THIRD_CASTER_SLOT_TABLE; + } + if (casterProgression === "artificer") { + return ARTIFICER_SLOT_TABLE; + } + return []; +} + +function parsePreparedFormulaCount(formulaRaw: string, level: number, abilityModifierValue: number): number | null { + const formula = formulaRaw.trim(); + if (!formula) { + return null; + } + + let expression = formula + .replace(/<\$level\$>/gi, String(level)) + .replace(/<\$[a-z]{3}_mod\$>/gi, String(abilityModifierValue)) + .replace(/<\$prof_bonus\$>/gi, String(getProficiencyBonus(level))); + + expression = stripTagsFallback(expression).replace(/\s+/g, " ").trim(); + if (!expression) { + return null; + } + + const safe = expression.replace(/[^0-9+\-*/(). ]/g, "").trim(); + if (!safe || !/^[0-9+\-*/(). ]+$/.test(safe)) { + return null; + } + + try { + const result = Function(`"use strict"; return (${safe});`)() as unknown; + if (typeof result !== "number" || !Number.isFinite(result)) { + return null; + } + return Math.max(1, Math.floor(result)); + } catch { + return null; + } +} + +function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: number, spellcastingAbilityModifier = 0): { cantrips: number; prepared: number; known: number; + additionalKnown: number; slots: Array<{ level: number; slots: number; label: string }>; hasPactMagic: boolean; + maxSpellLevel: number; + spellcastingAbility: AbilityKey | null; } { const out = { cantrips: 0, prepared: 0, known: 0, + additionalKnown: 0, slots: [] as Array<{ level: number; slots: number; label: string }>, - hasPactMagic: false + hasPactMagic: false, + maxSpellLevel: 0, + spellcastingAbility: null as AbilityKey | null }; if (!detail) { @@ -1342,10 +1733,17 @@ function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: return out; } + const casterProgression = typeof raw.casterProgression === "string" ? raw.casterProgression.trim().toLowerCase() : ""; + const spellcastingAbility = typeof raw.spellcastingAbility === "string" ? raw.spellcastingAbility.trim().toLowerCase() : ""; + if (isAbilityKey(spellcastingAbility)) { + out.spellcastingAbility = spellcastingAbility; + } + const levelIndex = Math.max(0, Math.min(19, level - 1)); const cantripProgression = Array.isArray(raw.cantripProgression) ? raw.cantripProgression : []; const preparedSpellsProgression = Array.isArray(raw.preparedSpellsProgression) ? raw.preparedSpellsProgression : []; const spellsKnownProgression = Array.isArray(raw.spellsKnownProgression) ? raw.spellsKnownProgression : []; + const classTableGroups = Array.isArray(raw.classTableGroups) ? raw.classTableGroups : []; if (typeof cantripProgression[levelIndex] === "number") { out.cantrips = Math.max(0, Math.trunc(cantripProgression[levelIndex])); @@ -1357,12 +1755,11 @@ function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: if (typeof spellsKnownProgression[levelIndex] === "number") { out.known = Math.max(0, Math.trunc(spellsKnownProgression[levelIndex])); - } else { - out.known = out.prepared; } - const groups = Array.isArray(raw.classTableGroups) ? raw.classTableGroups : []; - groups.forEach((groupRaw) => { + const slotMap = new Map(); + + classTableGroups.forEach((groupRaw) => { if (!isRecord(groupRaw)) { return; } @@ -1374,37 +1771,199 @@ function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: return; } + const slotLevelColIndex = labels.findIndex((it) => typeof it === "string" && it.toLowerCase().includes("slot level")); + + const parseNumeric = (value: unknown): number => { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.trunc(value)); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) { + return Math.max(0, Number.parseInt(trimmed, 10)); + } + } + return 0; + }; + labels.forEach((labelRaw, labelIndex) => { const label = typeof labelRaw === "string" ? labelRaw : ""; const lower = label.toLowerCase(); - if (!lower.includes("slot")) { + const cell = levelRow[labelIndex]; + + if (!out.cantrips && lower.includes("cantrip")) { + const value = parseNumeric(cell); + if (value > 0) { + out.cantrips = value; + } + } + if (!out.prepared && lower.includes("prepared")) { + const value = parseNumeric(cell); + if (value > 0) { + out.prepared = value; + } + } + if (!out.known && lower.includes("spells known")) { + const value = parseNumeric(cell); + if (value > 0) { + out.known = value; + } + } + + if (labelIndex === slotLevelColIndex || lower.includes("cantrip") || lower.includes("prepared") || lower.includes("known")) { return; } - const value = levelRow[labelIndex]; - const slots = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; + const slots = parseNumeric(cell); if (!slots) { return; } - const levelMatch = lower.match(/(\d+)(?:st|nd|rd|th)/); - const spellLevel = levelMatch ? Number.parseInt(levelMatch[1], 10) : 1; - out.slots.push({ - level: Number.isFinite(spellLevel) ? spellLevel : 1, - slots, - label - }); + let spellLevel = parseSpellLevelFromCell(labelRaw); + if (spellLevel == null && slotLevelColIndex >= 0) { + spellLevel = parseSpellLevelFromCell(levelRow[slotLevelColIndex]); + } + if (spellLevel == null) { + return; + } - if (lower.includes("pact")) { + slotMap.set(spellLevel, (slotMap.get(spellLevel) || 0) + slots); + if (lower.includes("pact") || (lower.includes("spell slots") && slotLevelColIndex >= 0) || casterProgression === "pact") { out.hasPactMagic = true; } }); }); - out.slots.sort((a, b) => a.level - b.level || a.label.localeCompare(b.label)); + if (!slotMap.size) { + const progressionRows = toSlotTableRows(casterProgression); + const row = progressionRows[levelIndex] || []; + row.forEach((slots, spellLevelIndex) => { + if (slots > 0) { + slotMap.set(spellLevelIndex + 1, slots); + } + }); + } + + if (casterProgression === "pact") { + const pactSlots = PACT_SLOT_COUNT_BY_LEVEL[levelIndex] || 0; + const pactSpellLevel = PACT_SLOT_LEVEL_BY_LEVEL[levelIndex] || 0; + if (pactSlots > 0 && pactSpellLevel > 0) { + slotMap.set(pactSpellLevel, Math.max(slotMap.get(pactSpellLevel) || 0, pactSlots)); + out.hasPactMagic = true; + } + } + + if (!out.prepared && typeof raw.preparedSpells === "string") { + const parsed = parsePreparedFormulaCount(raw.preparedSpells, level, spellcastingAbilityModifier); + if (parsed != null) { + out.prepared = parsed; + } + } + + const fixedKnownByLevelRaw = isRecord(raw.spellsKnownProgressionFixedByLevel) ? raw.spellsKnownProgressionFixedByLevel : null; + if (fixedKnownByLevelRaw) { + Object.entries(fixedKnownByLevelRaw).forEach(([classLevelRaw, fixedBySpellLevelRaw]) => { + const classLevel = Number.parseInt(classLevelRaw, 10); + if (!Number.isFinite(classLevel) || classLevel > level || !isRecord(fixedBySpellLevelRaw)) { + return; + } + + Object.entries(fixedBySpellLevelRaw).forEach(([spellLevelRaw, countRaw]) => { + const spellLevel = Number.parseInt(spellLevelRaw, 10); + const count = typeof countRaw === "number" && Number.isFinite(countRaw) ? Math.max(0, Math.trunc(countRaw)) : 0; + if (!Number.isFinite(spellLevel) || spellLevel < 1 || spellLevel > 9 || count <= 0) { + return; + } + out.additionalKnown += count; + if (spellLevel > out.maxSpellLevel) { + out.maxSpellLevel = spellLevel; + } + }); + }); + } + + out.slots = Array.from(slotMap.entries()) + .sort(([a], [b]) => a - b) + .map(([spellLevel, slots]) => ({ + level: spellLevel, + slots, + label: out.hasPactMagic && casterProgression === "pact" ? `Pact Slots (Level ${spellLevel})` : `Level ${spellLevel} Slots` + })); + + if (!out.maxSpellLevel) { + out.maxSpellLevel = out.slots.reduce((max, slot) => Math.max(max, slot.level), 0); + } + return out; } +function getCasterProgression(detail: CharacterSelectionDetail | null): string { + if (!detail) { + return ""; + } + const raw = safeJsonParse>(detail.rawJson); + return typeof raw?.casterProgression === "string" ? raw.casterProgression.trim().toLowerCase() : ""; +} + +function getMulticlassCasterContribution(detail: CharacterSelectionDetail | null, level: number): number { + const classLevel = Math.max(1, Math.min(20, Math.trunc(level || 1))); + const progression = getCasterProgression(detail); + if (!progression) { + return 0; + } + + if (progression === "full") { + return classLevel; + } + if (progression === "1/2") { + const edition = getSourceEdition(detail?.source || ""); + return edition === "2024" ? Math.ceil(classLevel / 2) : Math.floor(classLevel / 2); + } + if (progression === "artificer") { + return Math.ceil(classLevel / 2); + } + if (progression === "1/3") { + return Math.floor(classLevel / 3); + } + return 0; +} + +function isPactCaster(detail: CharacterSelectionDetail | null): boolean { + return getCasterProgression(detail) === "pact"; +} + +function isSpellcastingCaster(detail: CharacterSelectionDetail | null): boolean { + const progression = getCasterProgression(detail); + return progression === "full" || progression === "1/2" || progression === "1/3" || progression === "artificer"; +} + +function getMulticlassSpellSlotsByCasterLevel(casterLevel: number): Array<{ level: number; slots: number; label: string }> { + if (casterLevel < 1) { + return []; + } + + const levelIndex = Math.max(0, Math.min(19, casterLevel - 1)); + const row = FULL_CASTER_SLOT_TABLE[levelIndex] || []; + return row + .map((slots, index) => ({ + level: index + 1, + slots, + label: `Level ${index + 1} Slots` + })) + .filter((slot) => slot.slots > 0); +} + +function getLeveledSpellSelectionCap(spellcasting: { + known: number; + prepared: number; + additionalKnown?: number; +}): number { + if (spellcasting.known > 0) { + return Math.max(0, spellcasting.known + (spellcasting.additionalKnown || 0)); + } + return Math.max(0, spellcasting.prepared); +} + function readEquipmentTypeLabel(value: string): string { const map: Record = { instrumentMusical: "Musical Instrument", @@ -1643,6 +2202,15 @@ function parseNameSourceToken(value: string): { name: string; source: string } | return { name, source }; } +function draftSpellSelectionsToTokens(values: string[]): string[] { + return values + .map((token) => { + const parsed = parseNameSourceToken(token) || parseFeatUid(token); + return parsed ? toNameSourceToken(parsed.name, parsed.source) : ""; + }) + .filter((token) => Boolean(token)); +} + function cpToCurrency(cpRaw: number): Record<"cp" | "sp" | "ep" | "gp" | "pp", number> { let cp = Math.max(0, Math.trunc(cpRaw)); const pp = Math.floor(cp / 1000); @@ -1804,6 +2372,80 @@ function getSpellClassesFromLookup( return out; } +function getAlwaysPreparedSpellTokens(detail: CharacterSelectionDetail | null, level: number): string[] { + if (!detail) { + return []; + } + + const raw = safeJsonParse>(detail.rawJson); + if (!raw || !Array.isArray(raw.additionalSpells)) { + return []; + } + + const defaultSource = detail.source.trim().toUpperCase() || "PHB"; + const out = new Set(); + + const parseSpellUid = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const [nameRaw, sourceRaw] = trimmed.split("|"); + const name = (nameRaw || "").trim(); + if (!name) { + return null; + } + const source = (sourceRaw || defaultSource).trim().toUpperCase(); + return toNameSourceToken(name, source || defaultSource); + }; + + const collectSpellEntries = (value: unknown): void => { + if (typeof value === "string") { + const token = parseSpellUid(value); + if (token) { + out.add(token); + } + return; + } + if (Array.isArray(value)) { + value.forEach((it) => collectSpellEntries(it)); + return; + } + if (isRecord(value) && Array.isArray(value.spells)) { + value.spells.forEach((it) => collectSpellEntries(it)); + } + }; + + raw.additionalSpells.forEach((entryRaw) => { + if (!isRecord(entryRaw) || !isRecord(entryRaw.prepared)) { + return; + } + + Object.entries(entryRaw.prepared).forEach(([gateRaw, spellListRaw]) => { + const gate = gateRaw.trim().toLowerCase(); + let isUnlocked = true; + + if (/^\d+$/.test(gate)) { + const requiredLevel = Number.parseInt(gate, 10); + isUnlocked = level >= requiredLevel; + } else { + const spellGate = gate.match(/^s(\d+)$/); + if (spellGate) { + const requiredLevel = Number.parseInt(spellGate[1], 10); + isUnlocked = level >= requiredLevel; + } + } + + if (!isUnlocked) { + return; + } + collectSpellEntries(spellListRaw); + }); + }); + + return [...out]; +} + function SetupView() { const [setupState, setSetupState] = useState(null); const [sourceMode, setSourceMode] = useState<"url" | "zip">("url"); @@ -2082,14 +2724,23 @@ function WikiView() { const { kind, option } = pendingEntitySelection; updateDraft((draft) => { if (kind === "class") { - return { - ...draft, - selections: { - ...draft.selections, - class: toEntityRef(option), - subclass: null + const nextClassRef = toEntityRef(option); + const existing = getDraftClassSelections(draft); + if (existing.some((entry) => entry.class.entityRef === nextClassRef.entityRef)) { + return draft; + } + if (getClassLevelTotal(existing) >= 20) { + return draft; + } + + return withDraftClassSelections(draft, [ + ...existing, + { + class: nextClassRef, + subclass: null, + level: 1 } - }; + ]); } if (kind === "species") { @@ -2881,25 +3532,161 @@ function WikiView() { ); useEffect(() => { - const selectedRefs = [ - characterDraft?.selections.class?.entityRef, - characterDraft?.selections.background?.entityRef, - characterDraft?.selections.species?.entityRef - ] - .filter((it): it is string => Boolean(it)) - .slice(0, 3); + const selectedRefs = new Set(); + if (characterDraft?.selections.class?.entityRef) { + selectedRefs.add(characterDraft.selections.class.entityRef); + } + if (characterDraft?.selections.subclass?.entityRef) { + selectedRefs.add(characterDraft.selections.subclass.entityRef); + } + if (characterDraft?.selections.background?.entityRef) { + selectedRefs.add(characterDraft.selections.background.entityRef); + } + if (characterDraft?.selections.species?.entityRef) { + selectedRefs.add(characterDraft.selections.species.entityRef); + } + (characterDraft?.selections.classes || []).forEach((entry) => { + if (entry.class?.entityRef) { + selectedRefs.add(entry.class.entityRef); + } + if (entry.subclass?.entityRef) { + selectedRefs.add(entry.subclass.entityRef); + } + }); for (const ref of selectedRefs) { void ensureOptionDetail(ref); } - }, [characterDraft?.selections.background?.entityRef, characterDraft?.selections.class?.entityRef, characterDraft?.selections.species?.entityRef, ensureOptionDetail]); + }, [ + characterDraft?.selections.classes, + characterDraft?.selections.background?.entityRef, + characterDraft?.selections.class?.entityRef, + characterDraft?.selections.species?.entityRef, + characterDraft?.selections.subclass?.entityRef, + ensureOptionDetail + ]); - const selectedClassDetail = characterDraft?.selections.class ? detailCache[characterDraft.selections.class.entityRef] ?? null : null; + const selectedClassEntries = useMemo(() => getDraftClassSelections(characterDraft), [characterDraft]); + const selectedPrimaryClassEntry = selectedClassEntries[0] ?? null; + const selectedClassDetail = selectedPrimaryClassEntry ? detailCache[selectedPrimaryClassEntry.class.entityRef] ?? null : null; + const selectedClassDetails = useMemo( + () => + selectedClassEntries + .map((entry) => ({ + entry, + detail: detailCache[entry.class.entityRef] ?? null, + subclassDetail: entry.subclass ? detailCache[entry.subclass.entityRef] ?? null : null + })) + .filter((it) => Boolean(it.entry.class)), + [detailCache, selectedClassEntries] + ); const selectedSpeciesDetail = characterDraft?.selections.species ? detailCache[characterDraft.selections.species.entityRef] ?? null : null; const selectedBackgroundDetail = characterDraft?.selections.background ? detailCache[characterDraft.selections.background.entityRef] ?? null : null; const entityPopoverDetail = entityPopover ? detailCache[entityPopover.entityRef] ?? null : null; + const selectedClassRefSet = useMemo( + () => new Set(selectedClassEntries.map((entry) => entry.class.entityRef)), + [selectedClassEntries] + ); + const classOptionByRef = useMemo(() => { + const out = new Map(); + (characterOptions?.classes ?? []).forEach((option) => out.set(option.entityRef, option)); + return out; + }, [characterOptions?.classes]); + const selectedClassEdition = useMemo(() => { + const primary = selectedClassEntries[0]; + if (!primary) { + return null; + } + + return ( + classOptionByRef.get(primary.class.entityRef)?.sourceEdition ?? + detailCache[primary.class.entityRef]?.sourceEdition ?? + getSourceEdition(primary.class.source) + ); + }, [classOptionByRef, detailCache, selectedClassEntries]); + const classEligibilityByRef = useMemo(() => { + const out = new Map(); + const abilityScores = characterDraft?.steps.abilityScores.scores ?? { + str: 10, + dex: 10, + con: 10, + int: 10, + wis: 10, + cha: 10 + }; + const enforcePrereqs = + Boolean(characterDraft?.steps.preferences.usePrerequisitesMulticlass) && selectedClassEntries.length > 0; + + filteredClassOptions.forEach((option) => { + if (selectedClassRefSet.has(option.entityRef)) { + out.set(option.entityRef, { disabled: true, reason: "Class already added" }); + return; + } + + if (getClassLevelTotal(selectedClassEntries) >= 20) { + out.set(option.entityRef, { disabled: true, reason: "Maximum total level reached" }); + return; + } + + const candidateEdition = + option.sourceEdition ?? detailCache[option.entityRef]?.sourceEdition ?? getSourceEdition(option.source); + if (selectedClassEdition && candidateEdition !== selectedClassEdition) { + out.set(option.entityRef, { + disabled: true, + reason: `Class year mismatch (${selectedClassEdition} only for current character)` + }); + return; + } + + if (enforcePrereqs) { + const candidate = detailCache[option.entityRef] ?? null; + if (!candidate) { + out.set(option.entityRef, { disabled: false, reason: null }); + return; + } + + const allClassDetails = [...selectedClassDetails.map((it) => it.detail), candidate]; + const failing = allClassDetails.find((detail) => { + const requirementSets = getClassRequirementSets(detail); + return !meetsAnyClassRequirementSet(abilityScores, requirementSets); + }); + if (failing) { + out.set(option.entityRef, { disabled: true, reason: `Ability prerequisites not met (${failing.name})` }); + return; + } + } + + out.set(option.entityRef, { disabled: false, reason: null }); + }); + + return out; + }, [ + characterDraft?.steps.abilityScores.scores, + characterDraft?.steps.preferences.usePrerequisitesMulticlass, + detailCache, + filteredClassOptions, + classOptionByRef, + selectedClassDetails, + selectedClassEdition, + selectedClassEntries.length, + selectedClassRefSet + ]); + + useEffect(() => { + const classOptions = characterOptions?.classes ?? []; + if (!classOptions.length) { + return; + } + + classOptions.forEach((option) => { + if (detailCache[option.entityRef]) { + return; + } + void ensureOptionDetail(option.entityRef); + }); + }, [characterOptions?.classes, detailCache, ensureOptionDetail]); useEffect(() => { if (characterModalMode !== "editor") { @@ -2931,10 +3718,24 @@ function WikiView() { } }, [characterModalMode, characterStep, closeEntityPopover, entityPopover]); - const classSkillData = useMemo( - () => parseClassSkills(selectedClassDetail), - [selectedClassDetail?.entityRef, selectedClassDetail?.rawJson] - ); + const classSkillData = useMemo(() => { + const primary = parseClassSkills(selectedClassDetail); + const secondary = selectedClassDetails + .slice(1) + .map((it) => parseMulticlassClassSkills(it.detail)) + .reduce( + (acc, part) => ({ + granted: [...acc.granted, ...part.granted], + choices: [...acc.choices, ...part.choices] + }), + { granted: [] as string[], choices: [] as SkillChoiceRequirement[] } + ); + + return { + granted: uniqueStrings([...primary.granted, ...secondary.granted]), + choices: [...primary.choices, ...secondary.choices] + }; + }, [selectedClassDetail, selectedClassDetails]); const backgroundSkillData = useMemo( () => parseBackgroundSkills(selectedBackgroundDetail), [selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson] @@ -3002,8 +3803,12 @@ function WikiView() { ); const classFeatSlotCount = useMemo( - () => getClassFeatSlotCount(selectedClassDetail, characterDraft?.profile.level ?? 1), - [selectedClassDetail?.entityRef, selectedClassDetail?.featureCards, characterDraft?.profile.level] + () => + selectedClassDetails.reduce( + (sum, it) => sum + getClassFeatSlotCount(it.detail, it.entry.level), + 0 + ), + [selectedClassDetails] ); const normalizedFeatSelections = useMemo(() => { @@ -3095,45 +3900,172 @@ function WikiView() { [featByKey, normalizedFeatSelections] ); - const classSpellcasting = useMemo( - () => parseClassSpellcasting(selectedClassDetail, characterDraft?.profile.level ?? 1), - [selectedClassDetail?.entityRef, selectedClassDetail?.rawJson, characterDraft?.profile.level] + const classSpellcastingByRef = useMemo(() => { + const out = new Map>(); + const scores = characterDraft?.steps.abilityScores.scores; + selectedClassDetails.forEach(({ entry, detail }) => { + const abilityKey = getSpellcastingAbilityKey(detail); + const abilityModifier = + abilityKey && scores ? getAbilityModifierNumber(scores[abilityKey] ?? 10) : 0; + out.set(entry.class.entityRef, parseClassSpellcasting(detail, entry.level, abilityModifier)); + }); + return out; + }, [characterDraft?.steps.abilityScores.scores, selectedClassDetails]); + + const classSpellcasting = useMemo(() => { + let requiredCantrips = 0; + let requiredLeveledSpells = 0; + let maxSpellLevel = 0; + let spellcastingClassCount = 0; + let multiclassCasterLevel = 0; + let singleSpellcastingSlots: Array<{ level: number; slots: number; label: string }> = []; + const pactSlotMap = new Map(); + + selectedClassDetails.forEach(({ entry, detail }) => { + const parsed = + classSpellcastingByRef.get(entry.class.entityRef) || parseClassSpellcasting(detail, entry.level, 0); + requiredCantrips += parsed.cantrips; + requiredLeveledSpells += getLeveledSpellSelectionCap(parsed); + maxSpellLevel = Math.max(maxSpellLevel, parsed.maxSpellLevel); + + if (isSpellcastingCaster(detail)) { + spellcastingClassCount += 1; + multiclassCasterLevel += getMulticlassCasterContribution(detail, entry.level); + if (!singleSpellcastingSlots.length) { + singleSpellcastingSlots = parsed.slots.map((slot) => ({ + level: slot.level, + slots: slot.slots, + label: `Level ${slot.level} Slots` + })); + } + } + + if (isPactCaster(detail)) { + parsed.slots.forEach((slot) => { + pactSlotMap.set(slot.level, Math.max(pactSlotMap.get(slot.level) || 0, slot.slots)); + }); + } + }); + + const spellcastingSlots = + spellcastingClassCount > 1 + ? getMulticlassSpellSlotsByCasterLevel(Math.min(20, multiclassCasterLevel)) + : singleSpellcastingSlots; + const pactSlots = Array.from(pactSlotMap.entries()) + .sort(([a], [b]) => a - b) + .map(([level, slots]) => ({ + level, + slots, + label: `Pact Slots (Level ${level})` + })); + const slots = [...spellcastingSlots, ...pactSlots]; + + if (!maxSpellLevel) { + maxSpellLevel = slots.reduce((max, slot) => Math.max(max, slot.level), 0); + } + + return { + cantrips: requiredCantrips, + prepared: requiredLeveledSpells, + known: 0, + additionalKnown: 0, + slots, + hasPactMagic: pactSlots.length > 0, + maxSpellLevel, + spellcastingAbility: null as AbilityKey | null + }; + }, [classSpellcastingByRef, selectedClassDetails]); + + const alwaysPreparedSpellTokens = useMemo( + () => + uniqueStrings( + selectedClassDetails.flatMap(({ entry, detail, subclassDetail }) => [ + ...getAlwaysPreparedSpellTokens(detail, entry.level), + ...getAlwaysPreparedSpellTokens(subclassDetail, entry.level) + ]) + ), + [selectedClassDetails] ); - const selectedClassSpellPool = useMemo(() => { - if (!spellCatalog || !characterDraft?.selections.class) { + const alwaysPreparedSpellTokenSet = useMemo(() => new Set(alwaysPreparedSpellTokens), [alwaysPreparedSpellTokens]); + + const alwaysPreparedSpellNameSet = useMemo(() => { + const out = new Set(); + alwaysPreparedSpellTokens.forEach((token) => { + const parsed = parseNameSourceToken(token); + if (!parsed) { + return; + } + out.add(parsed.name.trim().toLowerCase()); + }); + return out; + }, [alwaysPreparedSpellTokens]); + + const classSelectableSpellPool = useMemo(() => { + if (!spellCatalog || !selectedClassDetails.length) { return [] as SpellCatalogItem[]; } - const className = characterDraft.selections.class.name.trim().toLowerCase(); - const classSource = characterDraft.selections.class.source.trim().toUpperCase(); - const sourceAliases = new Set([classSource]); - if (classSource === "XPHB") { - sourceAliases.add("PHB"); - } - if (classSource === "PHB") { - sourceAliases.add("XPHB"); - } + + const out = new Map(); + + selectedClassDetails.forEach(({ entry, detail }) => { + const className = entry.class.name.trim().toLowerCase(); + const classSource = entry.class.source.trim().toUpperCase(); + const sourceAliases = new Set([classSource]); + if (classSource === "XPHB") { + sourceAliases.add("PHB"); + } + if (classSource === "PHB") { + sourceAliases.add("XPHB"); + } + + const spellcasting = classSpellcastingByRef.get(entry.class.entityRef); + const maxClassSpellLevel = spellcasting?.maxSpellLevel ?? 0; + + spellCatalog + .filter((spell) => sourceBuckets.includes(spell.sourceBucket)) + .filter((spell) => { + const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className); + if (!matchingByName.length) { + return false; + } + const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim())); + if (!hasSourceData) { + return true; + } + return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase())); + }) + .filter((spell) => spell.level === 0 || spell.level <= maxClassSpellLevel) + .forEach((spell) => { + const token = toNameSourceToken(spell.name, spell.source); + out.set(token, spell); + }); + }); + + return Array.from(out.values()).filter((spell) => { + const token = toNameSourceToken(spell.name, spell.source); + return !alwaysPreparedSpellTokenSet.has(token) && !alwaysPreparedSpellNameSet.has(spell.name.trim().toLowerCase()); + }); + }, [spellCatalog, selectedClassDetails, sourceBuckets, classSpellcastingByRef, alwaysPreparedSpellTokenSet, alwaysPreparedSpellNameSet]); + + const classSpellByToken = useMemo(() => { + const map = new Map(); + classSelectableSpellPool.forEach((spell) => { + map.set(toNameSourceToken(spell.name, spell.source), spell); + }); + return map; + }, [classSelectableSpellPool]); + + const selectedClassSpellPool = useMemo(() => { const needle = spellSearch.trim().toLowerCase(); - return spellCatalog - .filter((spell) => sourceBuckets.includes(spell.sourceBucket)) - .filter((spell) => { - const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className); - if (!matchingByName.length) { - return false; - } - const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim())); - if (!hasSourceData) { - return true; - } - return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase())); - }) + return classSelectableSpellPool .filter((spell) => { if (!needle) { return true; } return `${spell.name} ${spell.source} ${spell.level}`.toLowerCase().includes(needle); }); - }, [spellCatalog, characterDraft?.selections.class, sourceBuckets, spellSearch]); + }, [classSelectableSpellPool, spellSearch]); const selectedCantripPool = useMemo( () => selectedClassSpellPool.filter((spell) => spell.level === 0).sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)), @@ -3158,15 +4090,7 @@ function WikiView() { }, [selectedClassSpellPool]); const selectedSpellTokens = useMemo( - () => - uniqueStrings( - (characterDraft?.steps.spells.selected ?? []) - .map((token) => { - const parsed = parseNameSourceToken(token) || parseFeatUid(token); - return parsed ? toNameSourceToken(parsed.name, parsed.source) : ""; - }) - .filter((it) => Boolean(it)) - ), + () => uniqueStrings(draftSpellSelectionsToTokens(characterDraft?.steps.spells.selected ?? [])), [characterDraft?.steps.spells.selected] ); @@ -3184,19 +4108,176 @@ function WikiView() { .filter((it): it is SpellCatalogItem => Boolean(it)); }, [spellCatalog, selectedSpellTokens]); + const alwaysPreparedSpellItems = useMemo(() => { + const byToken = new Map(); + (spellCatalog ?? []).forEach((spell) => { + byToken.set(toNameSourceToken(spell.name, spell.source), spell); + }); + + return alwaysPreparedSpellTokens + .map((token) => { + const direct = byToken.get(token); + if (direct) { + return direct; + } + + const parsed = parseNameSourceToken(token); + if (!parsed) { + return null; + } + + return ( + (spellCatalog ?? []).find((spell) => spell.name.trim().toLowerCase() === parsed.name.trim().toLowerCase()) || { + key: `always:${token}`, + name: parsed.name, + source: parsed.source, + level: 0, + school: "", + sourceBucket: classifySourceBucket(parsed.source).sourceBucket, + sourceEdition: classifySourceBucket(parsed.source).sourceEdition, + classes: [] + } + ); + }) + .filter((it): it is SpellCatalogItem => Boolean(it)); + }, [alwaysPreparedSpellTokens, spellCatalog]); + const selectedCantripCount = selectedSpellItems.filter((it) => it.level === 0).length; const selectedPreparedCount = selectedSpellItems.filter((it) => it.level > 0).length; const spellSlotMatrix = useMemo(() => { - const byLevel = new Map(); - classSpellcasting.slots.forEach((slot) => { - byLevel.set(slot.level, (byLevel.get(slot.level) || 0) + slot.slots); - }); - return Array.from(byLevel.entries()) - .sort(([a], [b]) => a - b) - .map(([level, slots]) => ({ level, slots })); + return classSpellcasting.slots.map((slot, index) => ({ + id: `${slot.label}-${slot.level}-${index}`, + level: slot.level, + slots: slot.slots, + label: slot.label + })); }, [classSpellcasting.slots]); + const getSpellHoverLinkData = useCallback((spell: SpellCatalogItem): { page: string; source: string; hash: string } | null => { + const urlUtil = window.UrlUtil; + const page = urlUtil?.PG_SPELLS || "spells.html"; + + let hash = ""; + const hashBuilder = urlUtil?.URL_TO_HASH_BUILDER?.[page] || urlUtil?.URL_TO_HASH_BUILDER?.spell; + if (hashBuilder) { + try { + hash = `${hashBuilder({ name: spell.name, source: spell.source }) || ""}`.trim(); + } catch { + hash = ""; + } + } + + if (!hash && urlUtil?.encodeArrayForHash) { + hash = `${urlUtil.encodeArrayForHash(spell.name, spell.source) || ""}`.trim(); + } + + if (!hash) { + return null; + } + + return { + page, + source: spell.source, + hash + }; + }, []); + + const handleSpellHoverMouseEnter = useCallback( + (event: ReactMouseEvent, spell: SpellCatalogItem) => { + if (!isRenderRuntimeReady) { + return; + } + + const hover = window.Renderer?.hover; + if (!hover?.pHandleLinkMouseOver) { + return; + } + + const linkData = getSpellHoverLinkData(spell); + if (!linkData) { + return; + } + + void hover.pHandleLinkMouseOver(event.nativeEvent, event.currentTarget, { + isSpecifiedLinkData: true, + page: linkData.page, + source: linkData.source, + hash: linkData.hash, + isAllowRedirect: true + }); + }, + [getSpellHoverLinkData, isRenderRuntimeReady] + ); + + const handleSpellHoverMouseMove = useCallback((event: ReactMouseEvent) => { + window.Renderer?.hover?.handleLinkMouseMove?.(event.nativeEvent, event.currentTarget); + }, []); + + const handleSpellHoverMouseLeave = useCallback((event: ReactMouseEvent) => { + window.Renderer?.hover?.handleLinkMouseLeave?.(event.nativeEvent, event.currentTarget); + }, []); + + useEffect(() => { + if (!characterDraft) { + return; + } + + const requiredCantrips = classSpellcasting.cantrips || 0; + const requiredPrepared = getLeveledSpellSelectionCap(classSpellcasting); + const current = uniqueStrings( + draftSpellSelectionsToTokens(characterDraft.steps.spells.selected) + ); + + const next: string[] = []; + let cantripCount = 0; + let preparedCount = 0; + current.forEach((token) => { + const spell = classSpellByToken.get(token); + if (!spell) { + return; + } + + if (spell.level === 0) { + if (requiredCantrips <= 0 || cantripCount >= requiredCantrips) { + return; + } + cantripCount += 1; + next.push(token); + return; + } + + if (requiredPrepared <= 0 || preparedCount >= requiredPrepared) { + return; + } + preparedCount += 1; + next.push(token); + }); + + if (areStringArraysEqual(current, next)) { + return; + } + + updateDraft((draft) => ({ + ...draft, + steps: { + ...draft.steps, + spells: { + ...draft.steps.spells, + selected: next + } + } + })); + }, [ + characterDraft?.id, + characterDraft?.steps.spells.selected, + classSpellByToken, + classSpellcasting.cantrips, + classSpellcasting.known, + classSpellcasting.prepared, + updateDraft + ]); + const classEquipmentGroups = useMemo( () => parseStartingEquipmentChoices(selectedClassDetail, "Class"), [selectedClassDetail?.entityRef, selectedClassDetail?.rawJson] @@ -3481,7 +4562,7 @@ function WikiView() { } if (stepIndex === 0) { - return Boolean(draft.selections.class) && draft.steps.preferences.sourceBuckets.length > 0; + return getDraftClassSelections(draft).length > 0 && draft.steps.preferences.sourceBuckets.length > 0; } if (stepIndex === 1) { @@ -3533,12 +4614,15 @@ function WikiView() { } if (stepIndex === 4) { - const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; + const classSelections = getDraftClassSelections(draft); + const classSlotCount = classSelections.reduce((sum, classSelection) => { + const classDetail = detailCache[classSelection.class.entityRef] ?? null; + return sum + getClassFeatSlotCount(classDetail, classSelection.level); + }, 0); const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null; const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null; - const slotCount = getClassFeatSlotCount(classDetail, draft.profile.level); - if (slotCount <= 0) { + if (classSlotCount <= 0) { return true; } @@ -3556,16 +4640,25 @@ function WikiView() { .map((it) => sanitizeFeatKey(it.name, it.source)) ).filter((key) => !fixedFeatKeySet.has(key)).length; - return variableCount >= slotCount; + return variableCount >= classSlotCount; } if (stepIndex === 5) { - const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; + const classSelections = getDraftClassSelections(draft); + const primaryClass = classSelections[0]; + const primaryClassDetail = primaryClass ? detailCache[primaryClass.class.entityRef] ?? null : null; + const secondaryRequirements = classSelections + .slice(1) + .flatMap((entry) => { + const classDetail = detailCache[entry.class.entityRef] ?? null; + return parseMulticlassClassSkills(classDetail).choices; + }); const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null; const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null; const requirements = [ - ...parseClassSkills(classDetail).choices, + ...parseClassSkills(primaryClassDetail).choices, + ...secondaryRequirements, ...parseBackgroundSkills(backgroundDetail).choices, ...parseSpeciesSkills(speciesDetail).choices ]; @@ -3581,10 +4674,22 @@ function WikiView() { } if (stepIndex === 6) { - const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; - const spellcasting = parseClassSpellcasting(classDetail, draft.profile.level); - const requiredCantrips = spellcasting.cantrips; - const requiredPrepared = spellcasting.known || spellcasting.prepared || 0; + const classSelections = getDraftClassSelections(draft); + const classDetails = classSelections.map((entry) => ({ + entry, + classDetail: detailCache[entry.class.entityRef] ?? null, + subclassDetail: entry.subclass ? detailCache[entry.subclass.entityRef] ?? null : null + })); + + const classSpellcasts = classDetails.map(({ entry, classDetail }) => { + const spellcastingAbilityKey = getSpellcastingAbilityKey(classDetail); + const spellcastingAbilityModifier = + spellcastingAbilityKey != null ? getAbilityModifierNumber(draft.steps.abilityScores.scores[spellcastingAbilityKey] ?? 10) : 0; + const spellcasting = parseClassSpellcasting(classDetail, entry.level, spellcastingAbilityModifier); + return { entry, classDetail, spellcasting }; + }); + const requiredCantrips = classSpellcasts.reduce((sum, it) => sum + it.spellcasting.cantrips, 0); + const requiredPrepared = classSpellcasts.reduce((sum, it) => sum + getLeveledSpellSelectionCap(it.spellcasting), 0); if (requiredCantrips <= 0 && requiredPrepared <= 0) { return true; @@ -3594,34 +4699,105 @@ function WikiView() { return draft.steps.spells.selected.length > 0; } - const spellLevelByToken = new Map(); + if (!classSelections.length) { + return false; + } + + const allowedBuckets = new Set(draft.steps.preferences.sourceBuckets); + const alwaysPreparedTokens = new Set([ + ...classDetails.flatMap(({ entry, classDetail, subclassDetail }) => [ + ...getAlwaysPreparedSpellTokens(classDetail, entry.level), + ...getAlwaysPreparedSpellTokens(subclassDetail, entry.level) + ]) + ]); + const alwaysPreparedNames = new Set(); + alwaysPreparedTokens.forEach((token) => { + const parsed = parseNameSourceToken(token); + if (!parsed) { + return; + } + alwaysPreparedNames.add(parsed.name.trim().toLowerCase()); + }); + const spellByToken = new Map(); spellCatalog.forEach((spell) => { - spellLevelByToken.set(toNameSourceToken(spell.name, spell.source), spell.level); + spellByToken.set(toNameSourceToken(spell.name, spell.source), spell); }); let cantripCount = 0; let preparedCount = 0; - uniqueStrings(draft.steps.spells.selected).forEach((token) => { - const parsed = parseNameSourceToken(token) || parseFeatUid(token); - if (!parsed) { + let hasInvalidSelection = false; + uniqueStrings(draftSpellSelectionsToTokens(draft.steps.spells.selected)).forEach((token) => { + if (hasInvalidSelection) { return; } - const level = spellLevelByToken.get(toNameSourceToken(parsed.name, parsed.source)); - if (level == null) { + const tokenName = parseNameSourceToken(token)?.name.trim().toLowerCase() || ""; + if (alwaysPreparedTokens.has(token) || (tokenName && alwaysPreparedNames.has(tokenName))) { return; } - if (level === 0) { + + const spell = spellByToken.get(token); + if (!spell) { + hasInvalidSelection = true; + return; + } + if (!allowedBuckets.has(spell.sourceBucket)) { + hasInvalidSelection = true; + return; + } + + const classCanSelectSpell = classSpellcasts.some(({ entry, spellcasting }) => { + if (spell.level > 0 && spell.level > spellcasting.maxSpellLevel) { + return false; + } + + const className = entry.class.name.trim().toLowerCase(); + const classSource = entry.class.source.trim().toUpperCase(); + const sourceAliases = new Set([classSource]); + if (classSource === "XPHB") { + sourceAliases.add("PHB"); + } + if (classSource === "PHB") { + sourceAliases.add("XPHB"); + } + + const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className); + if (!matchingByName.length) { + return false; + } + const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim())); + if (!hasSourceData) { + return true; + } + return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase())); + }); + + if (!classCanSelectSpell) { + hasInvalidSelection = true; + return; + } + + if (spell.level === 0) { cantripCount += 1; } else { preparedCount += 1; } }); - return cantripCount >= requiredCantrips && preparedCount >= requiredPrepared; + if (hasInvalidSelection) { + return false; + } + + return ( + cantripCount >= requiredCantrips + && preparedCount >= requiredPrepared + && cantripCount <= requiredCantrips + && preparedCount <= requiredPrepared + ); } if (stepIndex === 7) { - const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; + const primaryClass = getDraftClassSelections(draft)[0]; + const classDetail = primaryClass ? detailCache[primaryClass.class.entityRef] ?? null : null; const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null; const groups = [...parseStartingEquipmentChoices(classDetail, "Class"), ...parseStartingEquipmentChoices(backgroundDetail, "Background")]; if (!groups.length) { @@ -3683,6 +4859,11 @@ function WikiView() { return

Loading details...

; } + const classSelectionForDetail = + kind === "class" + ? getDraftClassSelections(characterDraft).find((entry) => entry.class.entityRef === detail.entityRef) || null + : null; + return (

{detail.name}

@@ -3711,16 +4892,21 @@ function WikiView() { @@ -4184,6 +5362,134 @@ function WikiView() {
+
+

Selected Classes

+ {selectedClassEntries.length ? ( +
+ {selectedClassEntries.map((entry, index) => { + const classDetail = detailCache[entry.class.entityRef] ?? null; + const subclassFeature = classDetail?.featureCards.find((feature) => feature.options?.length); + const canDecrease = entry.level > 1; + const canIncrease = characterDraft.profile.level < 20; + const isPrimaryClass = index === 0; + + return ( +
+
+ {entry.class.name} + + {entry.class.source} + + {isPrimaryClass ? "Primary" : "Multiclass"} + + +
+

+ {isPrimaryClass + ? "Primary class: gains full class and starting proficiency package." + : "Multiclass: gains class features by level, plus multiclass proficiency gains only."} +

+ + {subclassFeature?.options?.length ? ( + + ) : null} + +
+ + Level {entry.level} + + + +
+
+ ); + })} +
+ ) : ( +

Add at least one class to continue.

+ )} +
+