import { FormEvent, JSX, MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; const DEFAULT_PAGE = "/5etools.html"; const APP_CHARACTERS_MENU_ID = "__characters"; const CHARACTER_STEPS = [ "Class", "Species", "Background", "Ability Scores", "Feats", "Skills", "Spells", "Equipment", "About", "Finish" ] as const; const ABILITY_KEYS = ["str", "dex", "con", "int", "wis", "cha"] as const; type AbilityKey = (typeof ABILITY_KEYS)[number]; const ABILITY_LABELS: Record = { str: "Strength", dex: "Dexterity", con: "Constitution", int: "Intelligence", wis: "Wisdom", cha: "Charisma" }; const STANDARD_ARRAY: [number, number, number, number, number, number] = [15, 14, 13, 12, 10, 8]; const SKILL_OPTIONS = [ "Acrobatics", "Animal Handling", "Arcana", "Athletics", "Deception", "History", "Insight", "Intimidation", "Investigation", "Medicine", "Nature", "Perception", "Performance", "Persuasion", "Religion", "Sleight of Hand", "Stealth", "Survival" ] as const; const SOURCE_BUCKET_OPTIONS = [ { key: "homebrew", label: "Homebrew (non-WotC)" }, { key: "core2024", label: "Core 2024" }, { key: "expanded2024", label: "Expanded 2024" }, { key: "core2014", label: "Core 2014" }, { key: "expanded2014", label: "Expanded 2014" } ] as const; const FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS = [ "lib/jquery.js", "js/parser.js", "js/utils.js", "js/utils-ui.js", "lib/localforage.js", "js/utils-dataloader.js", "js/render.js", "js/render-dice.js", "js/hist.js" ] as const; const FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS = [ "js/utils-brew.js", "js/utils-config.js", "js/shim-esmodules.js" ] as const; const FIVEETOOLS_RENDER_STYLESHEET_PATHS = ["css/bootstrap.css", "css/main.css"] as const; type CharacterModalMode = "closed" | "list" | "editor"; type EntityPopoverKind = "class" | "species" | "background"; type PendingEntitySelection = { kind: EntityPopoverKind; option: CharacterSelectionOption; }; type RendererHandle = { setBaseUrl: (value: string) => RendererHandle; setAddHandlers: (value: boolean) => RendererHandle; setFirstSection: (value: boolean) => 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?: RendererHoverLinkOptions) => Promise; handleLinkMouseLeave?: (evt: MouseEvent, ele: HTMLElement) => void; handleLinkMouseMove?: (evt: MouseEvent, ele: HTMLElement) => void; handleLinkClick?: (evt: MouseEvent, ele: HTMLElement, opts?: { isForceOpenPermanent?: boolean }) => void; handleLinkWheel?: (evt: WheelEvent, ele: HTMLElement) => void; handleLinkDragStart?: (evt: DragEvent, ele: HTMLElement) => void; }; }; 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"; label?: string; datePrefix?: string; sourceColor?: string; href?: string; isExternal?: boolean; actionId?: string; children?: TopbarMenuEntry[]; }; type TopbarMenuGroup = { id: string; kind: "dropdown" | "link" | "action"; label: string; href?: string; isExternal?: boolean; actionId?: string; items?: TopbarMenuEntry[]; }; declare global { interface Window { Renderer?: RendererGlobal; JqueryUtil?: { initEnhancements?: () => void; }; TemplateUtil?: { initVanilla?: () => void; }; UrlUtil?: UrlUtilGlobal; } } let renderRuntimePromise: Promise | null = null; let renderRuntimeBaseUrl = ""; function normalizeWikiBaseUrl(value: string): string { return value.replace(/\/+$/, ""); } function cleanTopbarLabel(value: string): string { return value .replace(/\s+/g, " ") .replace(/[▾▼►]+/g, "") .trim(); } function getElementPrimaryText(ele: Element): string { for (const node of Array.from(ele.childNodes)) { if (node.nodeType === Node.TEXT_NODE) { const text = cleanTopbarLabel(node.textContent || ""); if (text) { return text; } } } return cleanTopbarLabel(ele.textContent || ""); } function getElementMenuMeta(ele: Element | null): Pick { if (!ele) { return {}; } const out: Pick = {}; const dateEle = ele.querySelector(":scope > .page__nav-date"); if (dateEle) { const datePrefix = cleanTopbarLabel(dateEle.textContent || ""); if (datePrefix) { out.datePrefix = datePrefix; } } const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source"); if (sourceEle instanceof HTMLElement) { const computed = getComputedStyle(sourceEle); const sourceColor = (computed.backgroundColor || computed.borderLeftColor || "").trim(); if ( sourceColor && sourceColor !== "transparent" && !/^rgba?\(\s*0\s*,\s*0\s*,\s*0(?:\s*,\s*0)?\s*\)$/i.test(sourceColor) ) { out.sourceColor = sourceColor; } } return out; } function getPathnameFromHrefLoose(hrefRaw: string): string { const href = hrefRaw.trim(); if (!href) { return ""; } try { return new URL(href, "http://localhost").pathname.toLowerCase(); } catch { return href.split(/[?#]/)[0].toLowerCase(); } } function parseNativeTopbarMenuList( list: Element, registerAction: (ele: HTMLElement) => string, idPrefix: string ): TopbarMenuEntry[] { const entries: TopbarMenuEntry[] = []; const liElements = Array.from(list.children).filter((it): it is HTMLLIElement => it instanceof HTMLLIElement); liElements.forEach((li, index) => { const entryId = `${idPrefix}-${index}`; const submenuList = ( li.querySelector(":scope > ul") || (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null) ) as Element | null; const submenuChildren = submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : []; if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) { entries.push({ id: `${entryId}-divider`, type: "divider" }); return; } if (li.classList.contains("nav2-list__label")) { const label = cleanTopbarLabel(li.textContent || ""); if (label) { entries.push({ id: `${entryId}-label`, type: "label", label }); } return; } if (li.classList.contains("dropdown--navbar")) { const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null; const label = toggle ? getElementPrimaryText(toggle) : ""; if (!label) { return; } entries.push({ id: `${entryId}-submenu`, type: "submenu", label, ...getElementMenuMeta(toggle), children: submenuChildren }); return; } if (li.classList.contains("nav2-accord__wrp")) { const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child"); const label = headPrimary ? getElementPrimaryText(headPrimary) : ""; if (!label || !submenuChildren.length) { return; } entries.push({ id: `${entryId}-accordion`, type: "submenu", label, ...getElementMenuMeta(headPrimary), children: submenuChildren }); return; } const link = li.querySelector(":scope > a") as HTMLAnchorElement | null; if (link) { const label = getElementPrimaryText(link); const href = (link.getAttribute("href") || "").trim(); const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:"); if (submenuChildren.length) { entries.push({ id: `${entryId}-submenu-link`, type: "submenu", label: label || "Section", ...getElementMenuMeta(link), href: !isActionLike ? href : undefined, actionId: isActionLike ? registerAction(link) : undefined, isExternal: !isActionLike ? link.target === "_blank" || /^https?:\/\//i.test(href) : false, children: submenuChildren }); return; } if (isActionLike) { entries.push({ id: `${entryId}-action`, type: "action", label: label || "Action", ...getElementMenuMeta(link), actionId: registerAction(link) }); return; } entries.push({ id: `${entryId}-link`, type: "link", label: label || href, ...getElementMenuMeta(link), href, isExternal: link.target === "_blank" || /^https?:\/\//i.test(href) }); return; } const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it): it is HTMLSpanElement => it instanceof HTMLSpanElement); if (submenuChildren.length && spans.length) { const first = spans[0]; const label = cleanTopbarLabel(first.textContent || first.title || ""); entries.push({ id: `${entryId}-submenu-span`, type: "submenu", label: label || "Section", ...getElementMenuMeta(first), actionId: registerAction(first), children: submenuChildren }); return; } spans.forEach((span, spanIndex) => { const label = cleanTopbarLabel(span.textContent || span.title || ""); entries.push({ id: `${entryId}-span-${spanIndex}`, type: "action", label: label || "Action", ...getElementMenuMeta(span), actionId: registerAction(span) }); }); }); return entries; } function parseNativeTopbarGroups(navbar: HTMLElement): { groups: TopbarMenuGroup[]; actionHandlers: Map void>; } { let actionIx = 0; const actionHandlers = new Map void>(); const registerAction = (ele: HTMLElement): string => { const id = `native-action-${actionIx++}`; actionHandlers.set(id, () => { ele.click(); }); return id; }; const groups: TopbarMenuGroup[] = []; const rootItems = Array.from(navbar.children).filter((it): it is HTMLLIElement => it instanceof HTMLLIElement); rootItems.forEach((li, index) => { if (li.classList.contains("omni__wrp-input") || li.classList.contains("desktop-nav-characters")) { return; } if (li.classList.contains("dropdown--navbar")) { const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null; const menuList = li.querySelector(":scope > ul") as HTMLUListElement | null; const label = toggle ? getElementPrimaryText(toggle) : ""; if (!label) { return; } groups.push({ id: `root-${index}-dropdown`, kind: "dropdown", label, items: menuList ? parseNativeTopbarMenuList(menuList, registerAction, `root-${index}-list`) : [] }); return; } const link = li.querySelector(":scope > a") as HTMLAnchorElement | null; if (!link) { return; } const href = (link.getAttribute("href") || "").trim(); const label = getElementPrimaryText(link); if (!label) { return; } const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:"); if (isActionLike) { groups.push({ id: `root-${index}-action`, kind: "action", label, actionId: registerAction(link) }); return; } groups.push({ id: `root-${index}-link`, kind: "link", label, href, isExternal: link.target === "_blank" || /^https?:\/\//i.test(href) }); }); return { groups, actionHandlers }; } function loadExternalScript(src: string, scriptType: "classic" | "module" = "classic"): Promise { return new Promise((resolve, reject) => { const selector = `script[data-5etd-render-runtime="true"][src="${src}"][data-5etd-script-type="${scriptType}"]`; const existing = document.querySelector(selector) as HTMLScriptElement | null; if (existing) { if (existing.dataset.loaded === "true") { resolve(); return; } existing.addEventListener("load", () => resolve(), { once: true }); existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { once: true }); return; } const script = document.createElement("script"); script.src = src; script.type = scriptType === "module" ? "module" : "text/javascript"; script.async = false; script.dataset.loaded = "false"; script.dataset["5etdRenderRuntime"] = "true"; script.dataset["5etdScriptType"] = scriptType; script.addEventListener( "load", () => { script.dataset.loaded = "true"; resolve(); }, { once: true } ); script.addEventListener( "error", () => { reject(new Error(`Failed to load ${src}`)); }, { once: true } ); document.head.append(script); }); } function loadExternalStylesheet(href: string): Promise { return new Promise((resolve, reject) => { const selector = `link[data-5etd-render-runtime-style="true"][href="${href}"]`; const existing = document.querySelector(selector) as HTMLLinkElement | null; if (existing) { if (existing.dataset.loaded === "true") { resolve(); return; } existing.addEventListener("load", () => resolve(), { once: true }); existing.addEventListener("error", () => reject(new Error(`Failed to load stylesheet ${href}`)), { once: true }); return; } const link = document.createElement("link"); link.rel = "stylesheet"; link.href = href; link.dataset.loaded = "false"; link.dataset["5etdRenderRuntimeStyle"] = "true"; link.addEventListener( "load", () => { link.dataset.loaded = "true"; resolve(); }, { once: true } ); link.addEventListener( "error", () => { reject(new Error(`Failed to load stylesheet ${href}`)); }, { once: true } ); // Insert before Vite-injected app styles where possible, so app-specific CSS keeps precedence. const firstStyleOrLink = document.head.querySelector("style, link[rel='stylesheet']"); if (firstStyleOrLink) { document.head.insertBefore(link, firstStyleOrLink); } else { document.head.append(link); } }); } async function ensure5etoolsRenderRuntime(wikiUrl: string): Promise { const baseUrl = normalizeWikiBaseUrl(wikiUrl); if (!baseUrl) { return; } if (window.Renderer?.get && renderRuntimeBaseUrl === baseUrl) { return; } if (renderRuntimePromise && renderRuntimeBaseUrl === baseUrl) { await renderRuntimePromise; return; } renderRuntimeBaseUrl = baseUrl; renderRuntimePromise = (async () => { (window as unknown as { RENDERER_BASE_URL?: string }).RENDERER_BASE_URL = `${baseUrl}/`; for (const stylePath of FIVEETOOLS_RENDER_STYLESHEET_PATHS) { await loadExternalStylesheet(new URL(stylePath, `${baseUrl}/`).toString()); } for (const scriptPath of FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS) { await loadExternalScript(new URL(scriptPath, `${baseUrl}/`).toString(), "classic"); } for (const scriptPath of FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS) { await loadExternalScript(new URL(scriptPath, `${baseUrl}/`).toString(), "module"); } if (!window.Renderer?.get) { throw new Error("5etools Renderer runtime did not initialize."); } // `Renderer.hover` internally uses `ee` template helpers from utils.js. // These are initialized by `JqueryUtil.initEnhancements()` in normal 5etools page bootstraps. // We must do this manually in the character view runtime. if (window.JqueryUtil?.initEnhancements) { window.JqueryUtil.initEnhancements(); } else { window.TemplateUtil?.initVanilla?.(); } window.Renderer.get().setBaseUrl(`${baseUrl}/`).setAddHandlers(true).setFirstSection(true); window.Renderer.hover?._doInit?.(); })(); await renderRuntimePromise; } function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function stripTagsFallback(value: string): string { return value .replace(/\{@([^}]+)\}/g, (_match: string, innerRaw: string) => { const inner = innerRaw.trim(); if (!inner) { return ""; } const firstSpace = inner.indexOf(" "); const payload = firstSpace >= 0 ? inner.slice(firstSpace + 1) : inner; const parts = payload.split("|").map((it) => it.trim()); return parts[2] || parts[3] || parts[0] || parts[1] || ""; }) .replace(/\s+/g, " ") .trim(); } function renderEntryText(text: string, isParserReady: boolean): string { const value = text.trim(); if (!value) { return ""; } if (isParserReady && window.Renderer?.get) { try { const renderer = window.Renderer.get(); renderer.setFirstSection(true); return renderer.render(value); } catch { // Fall through to basic escaped text below. } } return escapeHtml(stripTagsFallback(value)); } function ParsedEntryText(props: { text: string; isParserReady: boolean; className?: string; }): JSX.Element | null { const { text, isParserReady, className } = props; const html = useMemo(() => renderEntryText(text, isParserReady), [text, isParserReady]); if (!html) { return null; } return
; } function createNewCharacterDraft(): CharacterDocument { const now = new Date().toISOString(); return { schema: "5etd.character", version: 1, id: crypto.randomUUID(), createdAt: now, updatedAt: now, profile: { name: "Unnamed Character", level: 1, alignment: "", faith: "", lifestyle: "", age: "", gender: "", height: "", weight: "", eyes: "", skin: "", hair: "" }, selections: { class: null, subclass: null, classes: [], background: null, species: null }, steps: { preferences: { sourceBuckets: ["core2024"], useOptionalClassFeatures: true, customizeOrigin: true, advancementType: "milestone", hpMode: "average", usePrerequisitesFeats: true, usePrerequisitesMulticlass: true }, abilityScores: { method: "standardArray", scores: { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 }, rolledValues: [...STANDARD_ARRAY], assignments: { str: 0, dex: 1, con: 2, int: 3, wis: 4, cha: 5 }, backgroundBonusModeIndex: 0, backgroundBonusSelections: [] }, feats: { selected: [], notes: "" }, skills: { selected: [], notes: "" }, spells: { selected: [], notes: "" }, equipment: { startingEquipmentText: "", inventoryText: "", possessionsText: "", currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 } }, about: { personality: "", ideals: "", bonds: "", flaws: "", backstory: "", notes: "" } }, notes: "" }; } function abilityModifier(score: number): string { const mod = Math.floor((score - 10) / 2); return mod >= 0 ? `+${mod}` : `${mod}`; } function formatIso(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } return date.toLocaleString(); } function parseLineList(raw: string): string[] { return Array.from( new Set( raw .split(/\r?\n/g) .map((it) => it.trim()) .filter((it) => Boolean(it)) ) ); } function toLineList(items: string[]): string { return items.join("\n"); } function filterOptions(options: CharacterSelectionOption[], query: string): CharacterSelectionOption[] { const needle = query.trim().toLowerCase(); if (!needle) { return options; } return options.filter((option) => { const haystack = `${option.name} ${option.source} ${option.sourceName} ${option.details} ${option.summary}`.toLowerCase(); return haystack.includes(needle); }); } function filterBySourceBuckets(options: CharacterSelectionOption[], sourceBuckets: string[]): CharacterSelectionOption[] { if (!sourceBuckets.length) { return []; } const selected = new Set(sourceBuckets); return options.filter((option) => selected.has(option.sourceBucket)); } function toEntityRef(option: CharacterSelectionOption): CharacterEntityRef { return { entityType: option.entityType, name: option.name, source: option.source, hash: option.hash, entityRef: option.entityRef }; } function toSubclassEntityRef(option: CharacterFeatureOption): CharacterEntityRef { return { entityType: "subclass", name: option.name, source: option.source, hash: option.hash, entityRef: option.entityRef }; } 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 ""; } return new URL(relativePath.replace(/^\/+/, ""), `${baseUrl}/`).toString(); } function roll4d6DropLowest(): number { const rolls = [ Math.floor(Math.random() * 6) + 1, Math.floor(Math.random() * 6) + 1, Math.floor(Math.random() * 6) + 1, Math.floor(Math.random() * 6) + 1 ].sort((a, b) => a - b); return rolls[1] + rolls[2] + rolls[3]; } function getScoresFromAssignments( pool: [number, number, number, number, number, number], assignments: Record ): Record { return { str: pool[assignments.str] ?? 8, dex: pool[assignments.dex] ?? 8, con: pool[assignments.con] ?? 8, int: pool[assignments.int] ?? 8, wis: pool[assignments.wis] ?? 8, cha: pool[assignments.cha] ?? 8 }; } function getPointBuyCost(score: number): number { const map: Record = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 }; return map[score] ?? 99; } function getPointBuySpent(scores: Record): number { return ABILITY_KEYS.reduce((total, key) => total + getPointBuyCost(scores[key]), 0); } interface BackgroundAbilityMode { key: string; from: AbilityKey[]; weights: number[]; label: string; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isAbilityKey(value: string): value is AbilityKey { return ABILITY_KEYS.includes(value as AbilityKey); } function getBackgroundAbilityModes(detail: CharacterSelectionDetail | null): BackgroundAbilityMode[] { if (!detail || detail.entityType !== "background") { return []; } try { const parsed = JSON.parse(detail.rawJson) as unknown; if (!isRecord(parsed) || !Array.isArray(parsed.ability)) { return []; } const out: BackgroundAbilityMode[] = []; parsed.ability.forEach((partRaw, index) => { if (!isRecord(partRaw)) { return; } const chooseRaw = partRaw.choose; if (!isRecord(chooseRaw)) { return; } const weightedRaw = chooseRaw.weighted; if (!isRecord(weightedRaw)) { return; } if (!Array.isArray(weightedRaw.from) || !Array.isArray(weightedRaw.weights)) { return; } const from = Array.from( new Set( weightedRaw.from .map((it) => (typeof it === "string" ? it.trim().toLowerCase() : "")) .filter((it) => Boolean(it) && isAbilityKey(it)) ) ) as AbilityKey[]; const weights = weightedRaw.weights .map((it) => (typeof it === "number" && Number.isFinite(it) ? Math.max(1, Math.trunc(it)) : 0)) .filter((it) => it > 0); if (!from.length || !weights.length || weights.length > from.length) { return; } const label = weights.map((it) => `+${it}`).join(" / "); out.push({ key: `bg-ability-mode-${index}`, from, weights, label }); }); return out; } catch { return []; } } function areAbilityArraysEqual(a: AbilityKey[], b: AbilityKey[]): boolean { if (a.length !== b.length) { return false; } return a.every((value, index) => value === b[index]); } function normalizeBackgroundBonusSelections(mode: BackgroundAbilityMode | null, selectedRaw: AbilityKey[]): AbilityKey[] { if (!mode) { return []; } const selected: AbilityKey[] = []; const used = new Set(); for (const value of selectedRaw) { if (!mode.from.includes(value) || used.has(value)) { continue; } selected.push(value); used.add(value); if (selected.length >= mode.weights.length) { break; } } for (const candidate of mode.from) { if (selected.length >= mode.weights.length) { break; } if (used.has(candidate)) { continue; } selected.push(candidate); used.add(candidate); } return selected; } function getBackgroundBonusTotals(mode: BackgroundAbilityMode | null, selected: AbilityKey[]): Record { const totals: Record = { str: 0, dex: 0, con: 0, int: 0, wis: 0, cha: 0 }; if (!mode) { return totals; } mode.weights.forEach((weight, index) => { const ability = selected[index]; if (!ability) { return; } totals[ability] += weight; }); return totals; } function getScoresWithBonuses( baseScores: Record, bonuses: Record ): Record { return { str: baseScores.str + bonuses.str, dex: baseScores.dex + bonuses.dex, con: baseScores.con + bonuses.con, int: baseScores.int + bonuses.int, wis: baseScores.wis + bonuses.wis, cha: baseScores.cha + bonuses.cha }; } const CORE_SOURCE_CODES = new Set(["PHB", "DMG", "XPHB", "XDMG"]); const SKILL_TO_ABILITY: Record<(typeof SKILL_OPTIONS)[number], AbilityKey> = { Acrobatics: "dex", "Animal Handling": "wis", Arcana: "int", Athletics: "str", Deception: "cha", History: "int", Insight: "wis", Intimidation: "cha", Investigation: "int", Medicine: "wis", Nature: "int", Perception: "wis", Performance: "cha", Persuasion: "cha", Religion: "int", "Sleight of Hand": "dex", Stealth: "dex", Survival: "wis" }; const ABILITY_SCORE_COST_TABLE: Record = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 }; interface SkillChoiceRequirement { id: string; sourceLabel: string; title: string; count: number; options: string[]; } interface ParsedSkillData { granted: string[]; choices: SkillChoiceRequirement[]; } interface FeatCatalogItem { key: string; name: string; source: string; sourceBucket: CharacterSourceBucket; sourceEdition: "2014" | "2024" | null; category: string | null; summary: string; } interface SpellCatalogItem { key: string; name: string; source: string; level: number; school: string; sourceBucket: CharacterSourceBucket; sourceEdition: "2014" | "2024" | null; classes: Array<{ name: string; source: string }>; } type SpellSourceLookup = Record< string, Record< string, { class?: Record>; classVariant?: Record>; } > >; interface EquipmentGrant { id: string; label: string; cpValue: number; } interface EquipmentChoiceOption { id: string; key: string; label: string; grants: EquipmentGrant[]; } interface EquipmentChoiceGroup { id: string; label: string; options: EquipmentChoiceOption[]; } interface EquipmentSelectionState { selections: Record; addedItems: string[]; } interface SkillsNotesState { text: string; picksByChoiceId: Record; extraSkills: string[]; } function safeJsonParse(value: string): T | null { try { return JSON.parse(value) as T; } catch { return null; } } function getSourceBadgeText(source: string, sourceEdition: "2014" | "2024" | null): string { const src = source.trim().toUpperCase(); if (src === "XPHB") { return "2024"; } if (src === "PHB") { return "2014"; } return sourceEdition ?? ""; } function classifySourceBucket(sourceRaw: string): { sourceBucket: CharacterSourceBucket; sourceEdition: "2014" | "2024" | null; } { const source = sourceRaw.trim().toUpperCase(); if (!source) { return { sourceBucket: "expanded2014", sourceEdition: null }; } const is2024 = source.startsWith("X"); const isCore = CORE_SOURCE_CODES.has(source); return { sourceBucket: is2024 ? (isCore ? "core2024" : "expanded2024") : isCore ? "core2014" : "expanded2014", sourceEdition: is2024 ? "2024" : "2014" }; } function normalizeSkillName(value: string): string { return value .trim() .toLowerCase() .replace(/[^a-z]+/g, " ") .replace(/\s+/g, " ") .trim(); } const SKILL_NAME_LOOKUP = (() => { const out = new Map(); for (const skill of SKILL_OPTIONS) { out.set(normalizeSkillName(skill), skill); } return out; })(); function toSkillName(value: string): string | null { return SKILL_NAME_LOOKUP.get(normalizeSkillName(value)) || null; } function parseSkillProficiencyValue( value: unknown, sourceLabel: string, idPrefix: string ): ParsedSkillData { const granted = new Set(); const choices: SkillChoiceRequirement[] = []; if (!Array.isArray(value)) { return { granted: [], choices: [] }; } value.forEach((entryRaw, entryIndex) => { if (!isRecord(entryRaw)) { return; } const chooseRaw = entryRaw.choose; if (isRecord(chooseRaw)) { const from = Array.isArray(chooseRaw.from) ? chooseRaw.from.map((it) => (typeof it === "string" ? toSkillName(it) : null)).filter((it): it is string => Boolean(it)) : [...SKILL_OPTIONS]; const count = typeof chooseRaw.count === "number" && Number.isFinite(chooseRaw.count) ? Math.max(1, Math.trunc(chooseRaw.count)) : 1; if (from.length) { choices.push({ id: `${idPrefix}-choose-${entryIndex}`, sourceLabel, title: `${sourceLabel} Skill Choice`, count: Math.min(count, from.length), options: Array.from(new Set(from)) }); } return; } Object.entries(entryRaw).forEach(([key, val]) => { if (key === "any" && typeof val === "number" && val > 0) { choices.push({ id: `${idPrefix}-any-${entryIndex}`, sourceLabel, title: `${sourceLabel} Skill Choice`, count: Math.min(Math.trunc(val), SKILL_OPTIONS.length), options: [...SKILL_OPTIONS] }); return; } if (val !== true) { return; } const skill = toSkillName(key); if (skill) { granted.add(skill); } }); }); return { granted: [...granted], choices }; } 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: [] }; } const raw = safeJsonParse>(detail.rawJson); if (!raw || !isRecord(raw.startingProficiencies)) { return { granted: [], choices: [] }; } 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: [] }; } const raw = safeJsonParse>(detail.rawJson); return parseSkillProficiencyValue(raw?.skillProficiencies, "Background", "background"); } function parseSpeciesSkills(detail: CharacterSelectionDetail | null): ParsedSkillData { if (!detail) { return { granted: [], choices: [] }; } const raw = safeJsonParse>(detail.rawJson); return parseSkillProficiencyValue(raw?.skillProficiencies, "Species", "species"); } function getProficiencyBonus(level: number): number { return Math.max(2, Math.min(6, 2 + Math.floor((Math.max(1, level) - 1) / 4))); } function sanitizeFeatKey(name: string, source: string): string { return `${name.trim().toLowerCase()}|${source.trim().toUpperCase()}`; } function parseFeatUid(uidRaw: string): { name: string; source: string } | null { const uid = uidRaw.trim(); if (!uid) { return null; } const [nameRaw, sourceRaw] = uid.split("|"); const name = (nameRaw || "").trim(); const source = (sourceRaw || "PHB").trim().toUpperCase(); if (!name) { return null; } return { name, source }; } function getClassFeatSlotCount(detail: CharacterSelectionDetail | null, level: number): number { if (!detail) { return 0; } return detail.featureCards.filter((card) => { if (card.level != null && card.level > level) { return false; } const title = card.title.toLowerCase(); return /ability score improvement|fighting style feat|epic boon|\bfeat\b/i.test(title); }).length; } function getFixedFeatUids(detail: CharacterSelectionDetail | null): string[] { if (!detail || detail.entityType === "class") { return []; } const raw = safeJsonParse>(detail.rawJson); if (!raw || !Array.isArray(raw.feats)) { return []; } const out = new Set(); raw.feats.forEach((entryRaw) => { if (!isRecord(entryRaw)) { return; } Object.entries(entryRaw).forEach(([key, value]) => { if (value !== true) { return; } const parsed = parseFeatUid(key); if (!parsed) { return; } out.add(`${parsed.name}|${parsed.source}`); }); }); return [...out]; } 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, maxSpellLevel: 0, spellcastingAbility: null as AbilityKey | null }; if (!detail) { return out; } const raw = safeJsonParse>(detail.rawJson); if (!raw) { 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])); } if (typeof preparedSpellsProgression[levelIndex] === "number") { out.prepared = Math.max(0, Math.trunc(preparedSpellsProgression[levelIndex])); } if (typeof spellsKnownProgression[levelIndex] === "number") { out.known = Math.max(0, Math.trunc(spellsKnownProgression[levelIndex])); } const slotMap = new Map(); classTableGroups.forEach((groupRaw) => { if (!isRecord(groupRaw)) { return; } const labels = Array.isArray(groupRaw.colLabels) ? groupRaw.colLabels : []; const rows = Array.isArray(groupRaw.rowsSpellProgression) ? groupRaw.rowsSpellProgression : Array.isArray(groupRaw.rows) ? groupRaw.rows : []; const levelRow = Array.isArray(rows[levelIndex]) ? rows[levelIndex] : null; if (!levelRow) { 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(); 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 slots = parseNumeric(cell); if (!slots) { return; } let spellLevel = parseSpellLevelFromCell(labelRaw); if (spellLevel == null && slotLevelColIndex >= 0) { spellLevel = parseSpellLevelFromCell(levelRow[slotLevelColIndex]); } if (spellLevel == null) { return; } slotMap.set(spellLevel, (slotMap.get(spellLevel) || 0) + slots); if (lower.includes("pact") || (lower.includes("spell slots") && slotLevelColIndex >= 0) || casterProgression === "pact") { out.hasPactMagic = true; } }); }); 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", toolArtisan: "Artisan's Tools" }; return map[value] || value; } function parseEquipmentGrant(raw: unknown, idPrefix: string, index: number): EquipmentGrant[] { if (!isRecord(raw)) { return []; } const grants: EquipmentGrant[] = []; if (typeof raw.item === "string") { const parsed = parseFeatUid(raw.item.replace(/^([^|]+)\|/, "$1|")); const [nameRaw] = raw.item.split("|"); const quantity = typeof raw.quantity === "number" ? Math.max(1, Math.trunc(raw.quantity)) : 1; grants.push({ id: `${idPrefix}-item-${index}`, label: `${nameRaw.trim()}${quantity > 1 ? ` x${quantity}` : ""}`, cpValue: 0 }); if (parsed) { void parsed; } } if (typeof raw.value === "number" && Number.isFinite(raw.value)) { const cp = Math.max(0, Math.trunc(raw.value)); const gp = cp / 100; grants.push({ id: `${idPrefix}-gp-${index}`, label: `${gp} GP`, cpValue: cp }); } if (typeof raw.equipmentType === "string") { grants.push({ id: `${idPrefix}-type-${index}`, label: `Choose ${readEquipmentTypeLabel(raw.equipmentType)}`, cpValue: 0 }); } if (Array.isArray(raw.equipmentTypes) && raw.equipmentTypes.length) { const labels = raw.equipmentTypes.filter((it): it is string => typeof it === "string").map((it) => readEquipmentTypeLabel(it)); if (labels.length) { grants.push({ id: `${idPrefix}-types-${index}`, label: `Choose ${labels.join(" or ")}`, cpValue: 0 }); } } if (typeof raw.special === "string" && raw.special.trim()) { grants.push({ id: `${idPrefix}-special-${index}`, label: raw.special.trim(), cpValue: 0 }); } return grants; } function parseStartingEquipmentChoices( detail: CharacterSelectionDetail | null, sourceLabel: "Class" | "Background" ): EquipmentChoiceGroup[] { if (!detail) { return []; } const raw = safeJsonParse>(detail.rawJson); if (!raw) { return []; } const startingEquipment = raw.startingEquipment; if (!Array.isArray(startingEquipment)) { return []; } const groups: EquipmentChoiceGroup[] = []; startingEquipment.forEach((groupRaw, groupIndex) => { if (!isRecord(groupRaw)) { return; } const options: EquipmentChoiceOption[] = []; Object.entries(groupRaw) .filter(([_k, value]) => Array.isArray(value)) .sort(([a], [b]) => a.localeCompare(b)) .forEach(([key, value]) => { const grants = (value as unknown[]).flatMap((it, itemIndex) => parseEquipmentGrant(it, `${sourceLabel}-${groupIndex}-${key}`, itemIndex)); options.push({ id: `${sourceLabel}-${groupIndex}-${key}`, key, label: `${sourceLabel} Option ${key}`, grants }); }); if (!options.length) { return; } groups.push({ id: `${sourceLabel}-${groupIndex}`, label: `${sourceLabel} Starting Equipment ${groupIndex + 1}`, options }); }); return groups; } function parseEquipmentSelectionState(raw: string, fallbackInventoryRaw: string): EquipmentSelectionState { const parsed = safeJsonParse<{ selections?: Record; addedItems?: unknown[] }>(raw); if (parsed && isRecord(parsed.selections) && Array.isArray(parsed.addedItems)) { const selections: Record = {}; Object.entries(parsed.selections).forEach(([k, v]) => { if (typeof v === "string" && v.trim()) { selections[k] = v; } }); return { selections, addedItems: parsed.addedItems .filter((it): it is string => typeof it === "string") .map((it) => it.trim()) .filter((it) => Boolean(it)) }; } return { selections: {}, addedItems: parseLineList(fallbackInventoryRaw) }; } function stringifyEquipmentSelectionState(state: EquipmentSelectionState): string { return JSON.stringify( { selections: state.selections, addedItems: state.addedItems }, null, 2 ); } function parseSkillsNotes(raw: string): SkillsNotesState { const parsed = safeJsonParse<{ text?: string; picksByChoiceId?: Record; extraSkills?: unknown[] }>(raw); if (!parsed) { return { text: raw, picksByChoiceId: {}, extraSkills: [] }; } const hasKnownShape = typeof parsed.text === "string" || isRecord(parsed.picksByChoiceId) || Array.isArray(parsed.extraSkills); if (!hasKnownShape) { return { text: raw, picksByChoiceId: {}, extraSkills: [] }; } const picksByChoiceId: Record = {}; if (isRecord(parsed.picksByChoiceId)) { Object.entries(parsed.picksByChoiceId).forEach(([choiceId, picksRaw]) => { if (!Array.isArray(picksRaw)) { return; } picksByChoiceId[choiceId] = picksRaw .filter((it): it is string => typeof it === "string") .map((it) => toSkillName(it)) .filter((it): it is string => Boolean(it)); }); } const extraSkills = Array.isArray(parsed.extraSkills) ? uniqueStrings( parsed.extraSkills .filter((it): it is string => typeof it === "string") .map((it) => toSkillName(it)) .filter((it): it is string => Boolean(it)) ) : []; return { text: typeof parsed.text === "string" ? parsed.text : "", picksByChoiceId, extraSkills }; } function stringifySkillsNotes(state: SkillsNotesState): string { return JSON.stringify( { text: state.text, picksByChoiceId: state.picksByChoiceId, extraSkills: state.extraSkills }, null, 2 ); } function getAbilityModifierNumber(score: number): number { return Math.floor((score - 10) / 2); } function areStringArraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) { return false; } return a.every((it, ix) => it === b[ix]); } function uniqueStrings(values: string[]): string[] { return Array.from(new Set(values.filter((it) => Boolean(it)))); } function toNameSourceToken(name: string, source: string): string { return `${name.trim()}|${source.trim().toUpperCase()}`; } function parseNameSourceToken(value: string): { name: string; source: string } | null { const [nameRaw, sourceRaw] = value.split("|"); const name = (nameRaw || "").trim(); const source = (sourceRaw || "").trim().toUpperCase(); if (!name || !source) { return null; } 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); cp -= pp * 1000; const gp = Math.floor(cp / 100); cp -= gp * 100; const ep = Math.floor(cp / 50); cp -= ep * 50; const sp = Math.floor(cp / 10); cp -= sp * 10; return { cp, sp, ep, gp, pp }; } function parseInventoryLabel(value: string): { label: string; count: number } { const match = value.trim().match(/^(.*?)\s*x\s*(\d+)$/i); if (!match) { return { label: value.trim(), count: 1 }; } const label = (match[1] || "").trim(); const count = Number.parseInt((match[2] || "1").trim(), 10); return { label: label || value.trim(), count: Number.isFinite(count) && count > 0 ? count : 1 }; } function collectEntryText(value: unknown, out: string[], limit = 5): void { if (out.length >= limit || value == null) { return; } if (typeof value === "string") { const cleaned = stripTagsFallback(value); if (cleaned) { out.push(cleaned); } return; } if (Array.isArray(value)) { value.forEach((it) => collectEntryText(it, out, limit)); return; } if (!isRecord(value)) { return; } if (typeof value.entry === "string") { collectEntryText(value.entry, out, limit); } if (typeof value.text === "string") { collectEntryText(value.text, out, limit); } if (Array.isArray(value.entries)) { collectEntryText(value.entries, out, limit); } if (Array.isArray(value.items)) { collectEntryText(value.items, out, limit); } } function getEntrySummary(value: unknown): string { const chunks: string[] = []; collectEntryText(value, chunks, 3); return chunks.join(" ").trim(); } function normalizeSpellClasses(classesRaw: Array<{ name: string; source: string }>): Array<{ name: string; source: string }> { const seen = new Set(); const out: Array<{ name: string; source: string }> = []; classesRaw.forEach((entry) => { const name = entry.name.trim(); const source = entry.source.trim().toUpperCase(); if (!name) { return; } const key = `${name.toLowerCase()}|${source}`; if (seen.has(key)) { return; } seen.add(key); out.push({ name, source }); }); return out; } function getSpellClassesFromRaw(spellRaw: Record): Array<{ name: string; source: string }> { const out: Array<{ name: string; source: string }> = []; const classesRaw = spellRaw.classes; if (!isRecord(classesRaw)) { return out; } const lists = ["fromClassList", "fromClassListVariant", "fromClassListLegacy"] as const; lists.forEach((listKey) => { const list = classesRaw[listKey]; if (!Array.isArray(list)) { return; } list.forEach((entry) => { if (!isRecord(entry)) { return; } const name = typeof entry.name === "string" ? entry.name.trim() : ""; const source = typeof entry.source === "string" ? entry.source.trim().toUpperCase() : ""; if (!name) { return; } out.push({ name, source }); }); }); return out; } function getSpellClassesFromLookup( spellRaw: Record, sourceLookup: SpellSourceLookup | null ): Array<{ name: string; source: string }> { if (!sourceLookup) { return []; } const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toLowerCase() : ""; const name = typeof spellRaw.name === "string" ? spellRaw.name.trim().toLowerCase() : ""; if (!source || !name) { return []; } const record = sourceLookup[source]?.[name]; if (!record) { return []; } const out: Array<{ name: string; source: string }> = []; const collect = (mapRaw: Record> | undefined) => { if (!mapRaw) { return; } Object.entries(mapRaw).forEach(([classSource, classMap]) => { if (!isRecord(classMap)) { return; } Object.keys(classMap).forEach((className) => { if (!className.trim()) { return; } out.push({ name: className.trim(), source: classSource.trim().toUpperCase() }); }); }); }; collect(record.class); collect(record.classVariant); 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"); const [sourceRepoUrl, setSourceRepoUrl] = useState(""); const [sourceZipPath, setSourceZipPath] = useState(""); const [imageRepoUrl, setImageRepoUrl] = useState(""); const [imageBaseUrl, setImageBaseUrl] = useState(""); const [downloadImages, setDownloadImages] = useState(true); const [error, setError] = useState(""); const [status, setStatus] = useState("Checking local 5etools source..."); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { window.desktopApi .getSetupState() .then((state) => { setSetupState(state); setSourceRepoUrl(state.sourceRepoUrl); setImageRepoUrl(state.imageRepoUrl); setImageBaseUrl(state.imageBaseUrl); setStatus(state.needsSetup ? "Local 5etools source not found. Configure first-run setup below." : "Setup already complete."); }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : "Unknown setup-state error"; setError(message); setStatus("Failed to load setup state."); }); }, []); const onSubmit = async (event: FormEvent) => { event.preventDefault(); setError(""); setIsSubmitting(true); setStatus( sourceMode === "zip" ? "Extracting local source ZIP and preparing files. This can take a few minutes..." : "Downloading and preparing local 5etools source. This can take a few minutes..." ); try { const result = await window.desktopApi.runSetup({ mode: sourceMode, sourceRepoUrl, sourceZipPath, imageRepoUrl, imageBaseUrl, downloadImages }); if (!result.ok) { setError(result.error || "Setup failed."); setStatus("Setup failed."); setIsSubmitting(false); return; } setStatus("Setup complete. Launching wiki..."); } catch (err) { const message = err instanceof Error ? err.message : "Unknown setup error"; setError(message); setStatus("Setup failed."); setIsSubmitting(false); } }; const onClickBrowseZip = async () => { const selected = await window.desktopApi.pickSourceZip(); if (selected) { setSourceZipPath(selected); } }; return (

First Run Setup

{status}

{setupState?.sourceDir ?

Detected local source: {setupState.sourceDir}

: null} {error ?

{error}

: null}
{sourceMode === "url" ? ( ) : ( )}
); } function WikiView() { const [wikiUrl, setWikiUrl] = useState(""); const [error, setError] = useState(""); const [currentPath, setCurrentPath] = useState(DEFAULT_PAGE); const [isRenderRuntimeReady, setIsRenderRuntimeReady] = useState(false); const [openTopMenuId, setOpenTopMenuId] = useState(null); const [openTopMenuPath, setOpenTopMenuPath] = useState([]); const [topbarSearch, setTopbarSearch] = useState(""); const [topbarGroups, setTopbarGroups] = useState([]); const frameRef = useRef(null); const topbarRef = useRef(null); const topbarGroupsSignatureRef = useRef(""); const [characterModalMode, setCharacterModalMode] = useState("closed"); const [characterStep, setCharacterStep] = useState(0); const [characterDraft, setCharacterDraft] = useState(null); const [characterSummaries, setCharacterSummaries] = useState([]); const [characterOptions, setCharacterOptions] = useState(null); const [detailCache, setDetailCache] = useState>({}); const [characterError, setCharacterError] = useState(""); const [characterNotice, setCharacterNotice] = useState(""); const [isCharacterBusy, setIsCharacterBusy] = useState(false); const [classSearch, setClassSearch] = useState(""); const [speciesSearch, setSpeciesSearch] = useState(""); const [backgroundSearch, setBackgroundSearch] = useState(""); const [featSearch, setFeatSearch] = useState(""); const [spellSearch, setSpellSearch] = useState(""); const [equipmentItemInput, setEquipmentItemInput] = useState(""); const [entityPopover, setEntityPopover] = useState<{ kind: EntityPopoverKind; entityRef: string } | null>(null); const [pendingEntitySelection, setPendingEntitySelection] = useState(null); const [featCatalog, setFeatCatalog] = useState(null); const [spellCatalog, setSpellCatalog] = useState(null); const [itemCatalog, setItemCatalog] = useState([]); const updateDraft = useCallback((updater: (current: CharacterDocument) => CharacterDocument) => { setCharacterDraft((current) => { if (!current) { return current; } return updater(current); }); }, []); const loadCharacterOptions = useCallback(async () => { if (characterOptions) { return characterOptions; } const options = await window.desktopApi.getCharacterOptions(); setCharacterOptions(options); return options; }, [characterOptions]); const ensureOptionDetail = useCallback( async (entityRef: string) => { const trimmed = entityRef.trim(); if (!trimmed) { return null; } if (detailCache[trimmed]) { return detailCache[trimmed]; } const detail = await window.desktopApi.getCharacterOptionDetail(trimmed); if (!detail) { return null; } setDetailCache((current) => ({ ...current, [trimmed]: detail })); return detail; }, [detailCache] ); const openEntityPopover = useCallback( (kind: EntityPopoverKind, entityRef: string) => { const trimmed = entityRef.trim(); if (!trimmed) { return; } void ensureOptionDetail(trimmed); setEntityPopover({ kind, entityRef: trimmed }); }, [ensureOptionDetail] ); const closeEntityPopover = useCallback(() => { setEntityPopover(null); setPendingEntitySelection(null); }, []); const confirmPendingEntitySelection = useCallback(() => { if (!pendingEntitySelection) { closeEntityPopover(); return; } const { kind, option } = pendingEntitySelection; updateDraft((draft) => { if (kind === "class") { 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") { return { ...draft, selections: { ...draft.selections, species: toEntityRef(option) } }; } return { ...draft, selections: { ...draft.selections, background: toEntityRef(option) }, steps: { ...draft.steps, abilityScores: { ...draft.steps.abilityScores, backgroundBonusModeIndex: 0, backgroundBonusSelections: [] } } }; }); closeEntityPopover(); }, [closeEntityPopover, pendingEntitySelection, updateDraft]); const refreshCharacterSummaries = useCallback(async () => { const list = await window.desktopApi.listCharacters(); setCharacterSummaries(list); }, []); const resetEditorUiState = useCallback(() => { setClassSearch(""); setSpeciesSearch(""); setBackgroundSearch(""); setFeatSearch(""); setSpellSearch(""); setEquipmentItemInput(""); setCharacterStep(0); }, []); const openCharacterList = useCallback(async () => { setCharacterError(""); setCharacterNotice(""); setIsCharacterBusy(true); try { setCharacterModalMode("list"); await refreshCharacterSummaries(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to load character list."; setCharacterError(message); setCharacterModalMode("list"); } finally { setIsCharacterBusy(false); } }, [refreshCharacterSummaries]); const openNewCharacter = useCallback(async () => { setCharacterError(""); setCharacterNotice(""); setIsCharacterBusy(true); try { await loadCharacterOptions(); setCharacterDraft(createNewCharacterDraft()); resetEditorUiState(); setCharacterModalMode("editor"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to start a new character."; setCharacterError(message); setCharacterModalMode("list"); } finally { setIsCharacterBusy(false); } }, [loadCharacterOptions, resetEditorUiState]); const openCharacterById = useCallback( async (id: string) => { setCharacterError(""); setCharacterNotice(""); setIsCharacterBusy(true); try { await loadCharacterOptions(); const record = await window.desktopApi.getCharacter(id); if (!record) { setCharacterError("Character file was not found."); setCharacterModalMode("list"); return; } setCharacterDraft(record); resetEditorUiState(); setCharacterModalMode("editor"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to open character."; setCharacterError(message); setCharacterModalMode("list"); } finally { setIsCharacterBusy(false); } }, [loadCharacterOptions, resetEditorUiState] ); const loadCharacterFromJson = useCallback(async () => { setCharacterError(""); setCharacterNotice(""); setIsCharacterBusy(true); try { const selectedPath = await window.desktopApi.pickCharacterJson(); if (!selectedPath) { return; } await loadCharacterOptions(); const imported = await window.desktopApi.importCharacterJson(selectedPath); await refreshCharacterSummaries(); setCharacterDraft(imported); resetEditorUiState(); setCharacterNotice(`Imported character: ${imported.profile.name}`); setCharacterModalMode("editor"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to import character JSON."; setCharacterError(message); setCharacterModalMode("list"); } finally { setIsCharacterBusy(false); } }, [loadCharacterOptions, refreshCharacterSummaries, resetEditorUiState]); const deleteCharacterById = useCallback( async (id: string) => { if (!window.confirm("Delete this character file? This action cannot be undone.")) { return; } setCharacterError(""); setCharacterNotice(""); setIsCharacterBusy(true); try { const deleted = await window.desktopApi.deleteCharacter(id); if (!deleted) { setCharacterError("Character file could not be deleted."); return; } setCharacterNotice("Character deleted."); await refreshCharacterSummaries(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to delete character."; setCharacterError(message); } finally { setIsCharacterBusy(false); } }, [refreshCharacterSummaries] ); const saveCharacterDraft = useCallback(async () => { if (!characterDraft) { return; } setCharacterError(""); setCharacterNotice(""); setIsCharacterBusy(true); try { const saved = await window.desktopApi.saveCharacter({ id: characterDraft.id, createdAt: characterDraft.createdAt, profile: characterDraft.profile, selections: characterDraft.selections, steps: characterDraft.steps, notes: characterDraft.notes }); setCharacterDraft(saved); setCharacterNotice(`Saved ${saved.profile.name}.`); await refreshCharacterSummaries(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to save character."; setCharacterError(message); } finally { setIsCharacterBusy(false); } }, [characterDraft, refreshCharacterSummaries]); useEffect(() => { window.desktopApi .getWikiUrl() .then((url) => setWikiUrl(url)) .catch((err: unknown) => { const message = err instanceof Error ? err.message : "Unknown error"; setError(message); }); }, []); useEffect(() => { if (!wikiUrl) { setIsRenderRuntimeReady(false); return; } let isCancelled = false; void ensure5etoolsRenderRuntime(wikiUrl) .then(() => { if (!isCancelled) { setIsRenderRuntimeReady(true); } }) .catch((runtimeError: unknown) => { console.error("[5ET-D] Failed to initialize 5etools render runtime", runtimeError); if (!isCancelled) { setIsRenderRuntimeReady(false); } }); return () => { isCancelled = true; }; }, [wikiUrl]); useEffect(() => { if (characterModalMode !== "editor" || !wikiUrl || !isRenderRuntimeReady) { return; } let isCancelled = false; const baseUrl = `${normalizeWikiBaseUrl(wikiUrl)}/`; const loadFeats = async () => { if (featCatalog) { return; } const response = await fetch(new URL("data/feats.json", baseUrl).toString()); if (!response.ok) { throw new Error(`Failed to load feats (${response.status})`); } const payload = (await response.json()) as { feat?: unknown[] }; const featsRaw = Array.isArray(payload.feat) ? payload.feat : []; const next = featsRaw .map((featRaw): FeatCatalogItem | null => { if (!isRecord(featRaw)) { return null; } const name = typeof featRaw.name === "string" ? featRaw.name.trim() : ""; const source = typeof featRaw.source === "string" ? featRaw.source.trim().toUpperCase() : ""; if (!name || !source) { return null; } const sourceMeta = classifySourceBucket(source); const summary = getEntrySummary(featRaw.entries); return { key: sanitizeFeatKey(name, source), name, source, sourceBucket: sourceMeta.sourceBucket, sourceEdition: sourceMeta.sourceEdition, category: typeof featRaw.category === "string" ? featRaw.category : null, summary }; }) .filter((it): it is FeatCatalogItem => Boolean(it)) .sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)); if (!isCancelled) { setFeatCatalog(next); } }; const loadSpells = async () => { if (spellCatalog) { return; } const runtime = window as unknown as { DataUtil?: { spell?: { pLoadAll?: () => Promise; }; }; Renderer?: { spell?: { getCombinedClasses?: (spell: unknown, prop: string) => Array<{ name: string; source: string }>; }; }; }; const spellDataUtil = runtime.DataUtil?.spell; const getCombinedClasses = runtime.Renderer?.spell?.getCombinedClasses; if (!spellDataUtil?.pLoadAll) { if (!isCancelled) { setSpellCatalog([]); } return; } let sourceLookup: SpellSourceLookup | null = null; try { const lookupResp = await fetch(new URL("data/generated/gendata-spell-source-lookup.json", baseUrl).toString()); if (lookupResp.ok) { sourceLookup = (await lookupResp.json()) as SpellSourceLookup; } } catch { sourceLookup = null; } const spellsRaw = await spellDataUtil.pLoadAll(); const next = (Array.isArray(spellsRaw) ? spellsRaw : []) .map((spellRaw): SpellCatalogItem | null => { if (!isRecord(spellRaw)) { return null; } const name = typeof spellRaw.name === "string" ? spellRaw.name.trim() : ""; const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toUpperCase() : ""; if (!name || !source) { return null; } const classesFromRenderer = getCombinedClasses ? (getCombinedClasses(spellRaw, "fromClassList") || []) .map((it) => ({ name: typeof it.name === "string" ? it.name.trim() : "", source: typeof it.source === "string" ? it.source.trim().toUpperCase() : "" })) .filter((it) => Boolean(it.name)) : []; const classesFromRaw = getSpellClassesFromRaw(spellRaw); const classesFromLookup = getSpellClassesFromLookup(spellRaw, sourceLookup); const classes = normalizeSpellClasses([...classesFromRenderer, ...classesFromRaw, ...classesFromLookup]); const sourceMeta = classifySourceBucket(source); const level = typeof spellRaw.level === "number" ? Math.max(0, Math.trunc(spellRaw.level)) : 0; const school = typeof spellRaw.school === "string" ? spellRaw.school : ""; return { key: sanitizeFeatKey(name, source), name, source, level, school, sourceBucket: sourceMeta.sourceBucket, sourceEdition: sourceMeta.sourceEdition, classes }; }) .filter((it): it is SpellCatalogItem => Boolean(it)) .sort((a, b) => a.level - b.level || a.name.localeCompare(b.name) || a.source.localeCompare(b.source)); if (!isCancelled) { setSpellCatalog(next); } }; const loadItems = async () => { if (itemCatalog.length) { return; } const response = await fetch(new URL("data/items-base.json", baseUrl).toString()); if (!response.ok) { throw new Error(`Failed to load item catalog (${response.status})`); } const payload = (await response.json()) as { baseitem?: unknown[] }; const itemsRaw = Array.isArray(payload.baseitem) ? payload.baseitem : []; const next = Array.from( new Set( itemsRaw .map((it) => (isRecord(it) && typeof it.name === "string" ? it.name.trim() : "")) .filter((it) => Boolean(it)) ) ).sort((a, b) => a.localeCompare(b)); if (!isCancelled) { setItemCatalog(next); } }; void (async () => { try { await Promise.all([loadFeats(), loadSpells(), loadItems()]); } catch (catalogError) { console.error("[5ET-D] Failed to load character builder catalogs", catalogError); } })(); return () => { isCancelled = true; }; }, [characterModalMode, wikiUrl, isRenderRuntimeReady, featCatalog, spellCatalog, itemCatalog.length]); useEffect(() => { const onMessage = (event: MessageEvent) => { const frameWindow = frameRef.current?.contentWindow; if (!frameWindow || event.source !== frameWindow) { return; } if (!event.data || typeof event.data !== "object") { return; } const data = event.data as { type?: string; groups?: TopbarMenuGroup[] }; if (data.type === "5etd:native-navbar-data") { if (!Array.isArray(data.groups)) { return; } const nextGroups = data.groups as TopbarMenuGroup[]; let nextSignature = ""; try { nextSignature = JSON.stringify(nextGroups); } catch { nextSignature = `groups:${nextGroups.length}:${Date.now()}`; } if (topbarGroupsSignatureRef.current === nextSignature) { return; } topbarGroupsSignatureRef.current = nextSignature; setTopbarGroups(nextGroups); return; } if (data.type === "5etd:reset-onboarding") { void (async () => { const result = await window.desktopApi.resetOnboarding(); if (result.ok) { return; } frameWindow.postMessage( { type: "5etd:reset-onboarding-result", ok: false, error: result.error || "Failed to reset onboarding." }, "*" ); })(); return; } if (data.type === "5etd:characters:new") { void openNewCharacter(); return; } if (data.type === "5etd:characters:load-json") { void loadCharacterFromJson(); return; } if (data.type === "5etd:characters:list") { void openCharacterList(); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [loadCharacterFromJson, openCharacterList, openNewCharacter]); useEffect(() => { if (!openTopMenuId) { return; } const onDocumentMouseDown = (event: MouseEvent) => { const target = event.target; if (!(target instanceof Node)) { return; } if (!topbarRef.current?.contains(target)) { setOpenTopMenuId(null); } }; const onWindowKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpenTopMenuId(null); } }; document.addEventListener("mousedown", onDocumentMouseDown); window.addEventListener("keydown", onWindowKeyDown); return () => { document.removeEventListener("mousedown", onDocumentMouseDown); window.removeEventListener("keydown", onWindowKeyDown); }; }, [openTopMenuId]); useEffect(() => { const frame = frameRef.current; if (!frame) { return; } const requestNativeNavbarModel = () => { frame.contentWindow?.postMessage({ type: "5etd:native-navbar-request" }, "*"); }; frame.addEventListener("load", requestNativeNavbarModel); requestNativeNavbarModel(); return () => frame.removeEventListener("load", requestNativeNavbarModel); }, [currentPath, characterModalMode]); const frameSrc = useMemo(() => { if (!wikiUrl) { return ""; } return new URL(currentPath, `${wikiUrl}/`).toString(); }, [currentPath, wikiUrl]); const currentPathname = useMemo(() => { try { return new URL(currentPath, "http://localhost").pathname.toLowerCase(); } catch { return currentPath.split(/[?#]/)[0]?.toLowerCase() || DEFAULT_PAGE; } }, [currentPath]); const navigateWikiPath = useCallback((path: string) => { setCurrentPath(path); setCharacterModalMode("closed"); setOpenTopMenuId(null); setOpenTopMenuPath([]); }, []); const onSubmitTopbarSearch = useCallback((event: FormEvent) => { event.preventDefault(); const query = topbarSearch.trim(); navigateWikiPath(query ? `/search.html?q=${encodeURIComponent(query)}` : "/search.html"); }, [navigateWikiPath, topbarSearch]); const isSameAppPath = useCallback((leftRaw: string, rightRaw: string): boolean => { const left = getPathnameFromHrefLoose(leftRaw); const right = getPathnameFromHrefLoose(rightRaw); if (!left || !right) { return false; } if (left === right) { return true; } const homeAliases = new Set(["/index.html", "/5etools.html"]); return homeAliases.has(left) && homeAliases.has(right); }, []); const resolveWikiPathFromHref = useCallback( (hrefRaw: string): string | null => { const href = hrefRaw.trim(); if (!href || href.startsWith("#")) { return null; } const hrefLower = href.toLowerCase(); if (hrefLower.startsWith("javascript:") || hrefLower.startsWith("mailto:") || hrefLower.startsWith("tel:")) { return null; } if (!wikiUrl) { return null; } try { const base = new URL(`${normalizeWikiBaseUrl(wikiUrl)}/`); const resolved = new URL(href, base); if (resolved.origin !== base.origin) { return null; } if (!resolved.pathname.toLowerCase().endsWith(".html")) { return null; } const path = resolved.pathname.startsWith("/") ? resolved.pathname : `/${resolved.pathname}`; return `${path}${resolved.search}${resolved.hash}`; } catch { return null; } }, [wikiUrl] ); const runTopbarAction = useCallback((actionId: string | undefined) => { if (!actionId) { return; } frameRef.current?.contentWindow?.postMessage({ type: "5etd:native-navbar-run-action", actionId }, "*"); setOpenTopMenuPath([]); }, []); const navigateTopbarHref = useCallback((hrefRaw: string, isExternal?: boolean) => { const href = hrefRaw.trim(); if (!href) { return; } if (isExternal || /^https?:\/\//i.test(href)) { window.open(href, "_blank", "noopener"); return; } const resolved = resolveWikiPathFromHref(href); if (resolved) { navigateWikiPath(resolved); return; } if (href.startsWith("/")) { navigateWikiPath(href); return; } navigateWikiPath(`/${href.replace(/^\.?\//, "")}`); }, [navigateWikiPath, resolveWikiPathFromHref]); const activateTopbarEntry = useCallback((entry: TopbarMenuEntry) => { if (entry.actionId) { runTopbarAction(entry.actionId); setOpenTopMenuId(null); return; } if (entry.href) { navigateTopbarHref(entry.href, entry.isExternal); return; } }, [navigateTopbarHref, runTopbarAction]); const activateTopbarGroup = useCallback((group: TopbarMenuGroup) => { if (group.kind === "dropdown") { setOpenTopMenuId((current) => (current === group.id ? null : group.id)); setOpenTopMenuPath([]); return; } if (group.kind === "action") { runTopbarAction(group.actionId); setOpenTopMenuId(null); return; } if (group.kind === "link" && group.href) { navigateTopbarHref(group.href, group.isExternal); } }, [navigateTopbarHref, runTopbarAction]); useEffect(() => { if (!openTopMenuId) { setOpenTopMenuPath([]); } }, [openTopMenuId]); const openTopbarSubmenuAtDepth = useCallback((depth: number, entryId: string) => { setOpenTopMenuPath((current) => { if (current[depth] === entryId && current.length === depth + 1) { return current; } return [...current.slice(0, depth), entryId]; }); }, []); const renderTopbarDropdown = useCallback((entries: TopbarMenuEntry[]): JSX.Element => { const renderEntryLabel = (entry: TopbarMenuEntry): JSX.Element => ( {entry.datePrefix ? {entry.datePrefix} : null} {entry.sourceColor ? ); const columns: TopbarMenuEntry[][] = [entries]; let currentEntries = entries; for (let depth = 0; depth < openTopMenuPath.length; depth += 1) { const activeSubmenuId = openTopMenuPath[depth]; if (!activeSubmenuId) { break; } const submenuEntry = currentEntries.find((entry) => entry.type === "submenu" && entry.id === activeSubmenuId); if (!submenuEntry?.children?.length) { break; } columns.push(submenuEntry.children); currentEntries = submenuEntry.children; } return (
{columns.map((columnEntries, depth) => (
{columnEntries.map((entry) => { if (entry.type === "divider") { return
; } if (entry.type === "label") { return
{entry.label}
; } if (entry.type === "submenu") { const isSubmenuActive = openTopMenuPath[depth] === entry.id; const hasChildren = Boolean(entry.children?.length); const className = [ "topbar-dropdown-item", "topbar-dropdown-item--submenu", isSubmenuActive ? "is-active" : "" ] .filter(Boolean) .join(" "); return ( ); } const isActive = entry.type === "link" && entry.href ? isSameAppPath(entry.href, currentPathname) : false; return ( ); })}
))}
); }, [activateTopbarEntry, currentPathname, isSameAppPath, openTopMenuPath, openTopbarSubmenuAtDepth]); const sourceBuckets = characterDraft?.steps.preferences.sourceBuckets ?? []; const filteredClassOptions = useMemo( () => filterOptions(filterBySourceBuckets(characterOptions?.classes ?? [], sourceBuckets), classSearch), [characterOptions?.classes, sourceBuckets, classSearch] ); const filteredSpeciesOptions = useMemo( () => filterOptions(filterBySourceBuckets(characterOptions?.species ?? [], sourceBuckets), speciesSearch), [characterOptions?.species, sourceBuckets, speciesSearch] ); const filteredBackgroundOptions = useMemo( () => filterOptions(filterBySourceBuckets(characterOptions?.backgrounds ?? [], sourceBuckets), backgroundSearch), [characterOptions?.backgrounds, sourceBuckets, backgroundSearch] ); useEffect(() => { 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.classes, characterDraft?.selections.background?.entityRef, characterDraft?.selections.class?.entityRef, characterDraft?.selections.species?.entityRef, characterDraft?.selections.subclass?.entityRef, ensureOptionDetail ]); 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") { closeEntityPopover(); } }, [characterModalMode, closeEntityPopover]); useEffect(() => { if (characterModalMode !== "editor" || !entityPopover) { return; } const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { closeEntityPopover(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [characterModalMode, closeEntityPopover, entityPopover]); useEffect(() => { if (characterModalMode !== "editor" || !entityPopover) { return; } if (characterStep < 0 || characterStep > 2) { closeEntityPopover(); } }, [characterModalMode, characterStep, closeEntityPopover, entityPopover]); 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] ); const speciesSkillData = useMemo( () => parseSpeciesSkills(selectedSpeciesDetail), [selectedSpeciesDetail?.entityRef, selectedSpeciesDetail?.rawJson] ); const skillChoiceRequirements = useMemo( () => [...classSkillData.choices, ...backgroundSkillData.choices, ...speciesSkillData.choices], [classSkillData, backgroundSkillData, speciesSkillData] ); const grantedSkills = useMemo( () => uniqueStrings([...classSkillData.granted, ...backgroundSkillData.granted, ...speciesSkillData.granted]).sort((a, b) => a.localeCompare(b)), [classSkillData, backgroundSkillData, speciesSkillData] ); const skillNotesState = useMemo( () => parseSkillsNotes(characterDraft?.steps.skills.notes ?? ""), [characterDraft?.steps.skills.notes] ); const normalizedSkillChoicePicks = useMemo(() => { const out: Record = {}; for (const choice of skillChoiceRequirements) { const picksRaw = skillNotesState.picksByChoiceId[choice.id] || []; const picks: string[] = []; for (const skill of picksRaw) { if (!choice.options.includes(skill) || picks.includes(skill)) { continue; } picks.push(skill); if (picks.length >= choice.count) { break; } } out[choice.id] = picks; } return out; }, [skillChoiceRequirements, skillNotesState.picksByChoiceId]); const manualSkillSelections = useMemo( () => uniqueStrings(skillNotesState.extraSkills.map((it) => toSkillName(it)).filter((it): it is string => Boolean(it))).sort((a, b) => a.localeCompare(b) ), [skillNotesState.extraSkills] ); const allProficientSkills = useMemo( () => uniqueStrings([ ...grantedSkills, ...Object.values(normalizedSkillChoicePicks).flatMap((picks) => picks), ...manualSkillSelections ]).sort((a, b) => a.localeCompare(b)), [grantedSkills, manualSkillSelections, normalizedSkillChoicePicks] ); const fixedFeatTokens = useMemo( () => uniqueStrings([...getFixedFeatUids(selectedBackgroundDetail), ...getFixedFeatUids(selectedSpeciesDetail)]).sort((a, b) => a.localeCompare(b)), [selectedBackgroundDetail?.rawJson, selectedSpeciesDetail?.rawJson] ); const classFeatSlotCount = useMemo( () => selectedClassDetails.reduce( (sum, it) => sum + getClassFeatSlotCount(it.detail, it.entry.level), 0 ), [selectedClassDetails] ); const normalizedFeatSelections = useMemo(() => { const raw = characterDraft?.steps.feats.selected ?? []; return uniqueStrings( raw .map((token) => { const parsed = parseNameSourceToken(token) || parseFeatUid(token); return parsed ? toNameSourceToken(parsed.name, parsed.source) : ""; }) .filter((it) => Boolean(it)) ); }, [characterDraft?.steps.feats.selected]); const variableFeatSelections = useMemo(() => { const fixed = new Set( fixedFeatTokens .map((token) => parseNameSourceToken(token)) .filter((it): it is { name: string; source: string } => Boolean(it)) .map((it) => sanitizeFeatKey(it.name, it.source)) ); const filtered = normalizedFeatSelections.filter((token) => { const parsed = parseNameSourceToken(token); if (!parsed) { return false; } return !fixed.has(sanitizeFeatKey(parsed.name, parsed.source)); }); return filtered.slice(0, Math.max(0, classFeatSlotCount)); }, [normalizedFeatSelections, fixedFeatTokens, classFeatSlotCount]); const filteredFeatCatalog = useMemo(() => { const needle = featSearch.trim().toLowerCase(); return (featCatalog ?? []) .filter((feat) => sourceBuckets.includes(feat.sourceBucket)) .filter((feat) => { if (!needle) { return true; } return `${feat.name} ${feat.source} ${feat.summary} ${feat.category || ""}`.toLowerCase().includes(needle); }); }, [featCatalog, sourceBuckets, featSearch]); const featByKey = useMemo(() => { const out = new Map(); (featCatalog ?? []).forEach((feat) => out.set(feat.key, feat)); return out; }, [featCatalog]); const fixedFeatKeySet = useMemo(() => { const out = new Set(); fixedFeatTokens.forEach((token) => { const parsed = parseNameSourceToken(token); if (!parsed) { return; } out.add(sanitizeFeatKey(parsed.name, parsed.source)); }); return out; }, [fixedFeatTokens]); const selectedFeatItems = useMemo( () => normalizedFeatSelections.map((token) => { const parsed = parseNameSourceToken(token); if (!parsed) { return { key: token, name: token, source: "", sourceBucket: "expanded2014" as CharacterSourceBucket, sourceEdition: null, category: null, summary: "" }; } return ( featByKey.get(sanitizeFeatKey(parsed.name, parsed.source)) || { key: sanitizeFeatKey(parsed.name, parsed.source), name: parsed.name, source: parsed.source, sourceBucket: classifySourceBucket(parsed.source).sourceBucket, sourceEdition: classifySourceBucket(parsed.source).sourceEdition, category: null, summary: "" } ); }), [featByKey, normalizedFeatSelections] ); 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 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 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 classSelectableSpellPool .filter((spell) => { if (!needle) { return true; } return `${spell.name} ${spell.source} ${spell.level}`.toLowerCase().includes(needle); }); }, [classSelectableSpellPool, spellSearch]); const selectedCantripPool = useMemo( () => selectedClassSpellPool.filter((spell) => spell.level === 0).sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)), [selectedClassSpellPool] ); const selectedLeveledSpellPoolByLevel = useMemo(() => { const groups = new Map(); selectedClassSpellPool .filter((spell) => spell.level > 0) .forEach((spell) => { const list = groups.get(spell.level) || []; list.push(spell); groups.set(spell.level, list); }); return Array.from(groups.entries()) .sort(([a], [b]) => a - b) .map(([level, spells]) => ({ level, spells: [...spells].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)) })); }, [selectedClassSpellPool]); const selectedSpellTokens = useMemo( () => uniqueStrings(draftSpellSelectionsToTokens(characterDraft?.steps.spells.selected ?? [])), [characterDraft?.steps.spells.selected] ); const selectedSpellItems = useMemo(() => { const map = new Map(); (spellCatalog ?? []).forEach((spell) => map.set(spell.key, spell)); return selectedSpellTokens .map((token) => { const parsed = parseNameSourceToken(token); if (!parsed) { return null; } return map.get(sanitizeFeatKey(parsed.name, parsed.source)) || null; }) .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(() => { 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] ); const backgroundEquipmentGroups = useMemo( () => parseStartingEquipmentChoices(selectedBackgroundDetail, "Background"), [selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson] ); const equipmentChoiceGroups = useMemo( () => [...classEquipmentGroups, ...backgroundEquipmentGroups], [classEquipmentGroups, backgroundEquipmentGroups] ); const equipmentSelectionState = useMemo( () => parseEquipmentSelectionState( characterDraft?.steps.equipment.startingEquipmentText ?? "", characterDraft?.steps.equipment.inventoryText ?? "" ), [characterDraft?.steps.equipment.startingEquipmentText, characterDraft?.steps.equipment.inventoryText] ); const normalizedEquipmentSelections = useMemo(() => { const out: Record = {}; equipmentChoiceGroups.forEach((group) => { const selectedOptionId = equipmentSelectionState.selections[group.id]; const hasSelected = group.options.some((option) => option.id === selectedOptionId); out[group.id] = hasSelected ? selectedOptionId : group.options[0]?.id || ""; }); return out; }, [equipmentChoiceGroups, equipmentSelectionState.selections]); const resolvedEquipmentGrants = useMemo(() => { const grants: EquipmentGrant[] = []; equipmentChoiceGroups.forEach((group) => { const selectedId = normalizedEquipmentSelections[group.id]; const option = group.options.find((it) => it.id === selectedId); if (!option) { return; } grants.push(...option.grants); }); return grants; }, [equipmentChoiceGroups, normalizedEquipmentSelections]); const resolvedEquipmentRows = useMemo(() => { const counts = new Map(); const sourceLabels = [ ...resolvedEquipmentGrants.map((grant) => grant.label).filter((label) => !/gp$/i.test(label)), ...equipmentSelectionState.addedItems ]; sourceLabels.forEach((rawLabel) => { const parsed = parseInventoryLabel(rawLabel); if (!parsed.label) { return; } counts.set(parsed.label, (counts.get(parsed.label) || 0) + parsed.count); }); return Array.from(counts.entries()) .map(([label, count]) => ({ label, count })) .sort((a, b) => a.label.localeCompare(b.label)); }, [resolvedEquipmentGrants, equipmentSelectionState.addedItems]); const resolvedEquipmentCurrency = useMemo( () => cpToCurrency(resolvedEquipmentGrants.reduce((total, grant) => total + grant.cpValue, 0)), [resolvedEquipmentGrants] ); const backgroundAbilityModes = useMemo( () => getBackgroundAbilityModes(selectedBackgroundDetail), [selectedBackgroundDetail?.rawJson, selectedBackgroundDetail?.entityRef] ); const activeBackgroundAbilityMode = useMemo(() => { if (!characterDraft || !backgroundAbilityModes.length) { return null; } const safeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, characterDraft.steps.abilityScores.backgroundBonusModeIndex)); return backgroundAbilityModes[safeIndex] ?? null; }, [backgroundAbilityModes, characterDraft?.steps.abilityScores.backgroundBonusModeIndex, characterDraft]); const normalizedBackgroundBonusSelections = useMemo(() => { if (!characterDraft) { return []; } return normalizeBackgroundBonusSelections(activeBackgroundAbilityMode, characterDraft.steps.abilityScores.backgroundBonusSelections); }, [activeBackgroundAbilityMode, characterDraft?.steps.abilityScores.backgroundBonusSelections, characterDraft]); const backgroundBonusTotals = useMemo( () => getBackgroundBonusTotals(activeBackgroundAbilityMode, normalizedBackgroundBonusSelections), [activeBackgroundAbilityMode, normalizedBackgroundBonusSelections] ); const abilityTotals = useMemo(() => { if (!characterDraft) { return null; } return getScoresWithBonuses(characterDraft.steps.abilityScores.scores, backgroundBonusTotals); }, [characterDraft?.steps.abilityScores.scores, backgroundBonusTotals, characterDraft]); useEffect(() => { if (!characterDraft) { return; } const current = characterDraft.steps.abilityScores; if (!backgroundAbilityModes.length) { if (current.backgroundBonusModeIndex === 0 && current.backgroundBonusSelections.length === 0) { return; } updateDraft((draft) => ({ ...draft, steps: { ...draft.steps, abilityScores: { ...draft.steps.abilityScores, backgroundBonusModeIndex: 0, backgroundBonusSelections: [] } } })); return; } const clampedModeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, current.backgroundBonusModeIndex)); const mode = backgroundAbilityModes[clampedModeIndex] ?? null; const normalizedSelections = normalizeBackgroundBonusSelections(mode, current.backgroundBonusSelections); const hasModeChanged = clampedModeIndex !== current.backgroundBonusModeIndex; const hasSelectionsChanged = !areAbilityArraysEqual(normalizedSelections, current.backgroundBonusSelections); if (!hasModeChanged && !hasSelectionsChanged) { return; } updateDraft((draft) => ({ ...draft, steps: { ...draft.steps, abilityScores: { ...draft.steps.abilityScores, backgroundBonusModeIndex: clampedModeIndex, backgroundBonusSelections: normalizedSelections } } })); }, [ backgroundAbilityModes, characterDraft?.id, characterDraft?.steps.abilityScores.backgroundBonusModeIndex, characterDraft?.steps.abilityScores.backgroundBonusSelections, updateDraft ]); useEffect(() => { if (!characterDraft) { return; } const desiredNotes = skillChoiceRequirements.length > 0 || manualSkillSelections.length > 0 ? stringifySkillsNotes({ text: skillNotesState.text, picksByChoiceId: normalizedSkillChoicePicks, extraSkills: manualSkillSelections }) : skillNotesState.text; const currentSelected = [...characterDraft.steps.skills.selected].sort((a, b) => a.localeCompare(b)); const desiredSelected = [...allProficientSkills].sort((a, b) => a.localeCompare(b)); const isSelectedSame = areStringArraysEqual(currentSelected, desiredSelected); const isNotesSame = characterDraft.steps.skills.notes === desiredNotes; if (isSelectedSame && isNotesSame) { return; } updateDraft((draft) => ({ ...draft, steps: { ...draft.steps, skills: { ...draft.steps.skills, selected: desiredSelected, notes: desiredNotes } } })); }, [ allProficientSkills, characterDraft?.id, characterDraft?.steps.skills.notes, characterDraft?.steps.skills.selected, normalizedSkillChoicePicks, manualSkillSelections, skillChoiceRequirements.length, skillNotesState.text, updateDraft ]); useEffect(() => { if (!characterDraft) { return; } const desired = uniqueStrings([...fixedFeatTokens, ...variableFeatSelections]).sort((a, b) => a.localeCompare(b)); const current = [...normalizedFeatSelections].sort((a, b) => a.localeCompare(b)); if (areStringArraysEqual(current, desired)) { return; } updateDraft((draft) => ({ ...draft, steps: { ...draft.steps, feats: { ...draft.steps.feats, selected: desired } } })); }, [characterDraft?.id, fixedFeatTokens, normalizedFeatSelections, updateDraft, variableFeatSelections]); useEffect(() => { if (!characterDraft || !equipmentChoiceGroups.length) { return; } const desiredState: EquipmentSelectionState = { selections: normalizedEquipmentSelections, addedItems: equipmentSelectionState.addedItems }; const desiredStartingEquipment = stringifyEquipmentSelectionState(desiredState); const desiredInventory = desiredState.addedItems.join("\n"); const currentCurrency = characterDraft.steps.equipment.currency; const desiredCurrency = resolvedEquipmentCurrency; const isCurrencySame = currentCurrency.cp === desiredCurrency.cp && currentCurrency.sp === desiredCurrency.sp && currentCurrency.ep === desiredCurrency.ep && currentCurrency.gp === desiredCurrency.gp && currentCurrency.pp === desiredCurrency.pp; if ( characterDraft.steps.equipment.startingEquipmentText === desiredStartingEquipment && characterDraft.steps.equipment.inventoryText === desiredInventory && isCurrencySame ) { return; } updateDraft((draft) => ({ ...draft, steps: { ...draft.steps, equipment: { ...draft.steps.equipment, startingEquipmentText: desiredStartingEquipment, inventoryText: desiredInventory, currency: desiredCurrency } } })); }, [ characterDraft?.id, characterDraft?.steps.equipment.currency, characterDraft?.steps.equipment.inventoryText, characterDraft?.steps.equipment.startingEquipmentText, equipmentChoiceGroups.length, equipmentSelectionState.addedItems, normalizedEquipmentSelections, resolvedEquipmentCurrency, updateDraft ]); const isStepComplete = useCallback((stepIndex: number, draft: CharacterDocument | null): boolean => { if (!draft) { return false; } if (stepIndex === 0) { return getDraftClassSelections(draft).length > 0 && draft.steps.preferences.sourceBuckets.length > 0; } if (stepIndex === 1) { return Boolean(draft.selections.species); } if (stepIndex === 2) { return Boolean(draft.selections.background); } if (stepIndex === 3) { const { method, scores, assignments } = draft.steps.abilityScores; let isBaseValid = true; if (method === "pointBuy") { const valuesOk = ABILITY_KEYS.every((key) => scores[key] >= 8 && scores[key] <= 15); isBaseValid = valuesOk && getPointBuySpent(scores) <= 27; } if (method === "manual" && isBaseValid) { isBaseValid = ABILITY_KEYS.every((key) => Number.isInteger(scores[key]) && scores[key] >= 1 && scores[key] <= 30); } if ((method === "standardArray" || method === "rolled") && isBaseValid) { const assignmentValues = ABILITY_KEYS.map((key) => assignments[key]); const unique = new Set(assignmentValues); isBaseValid = assignmentValues.every((it) => Number.isInteger(it) && it >= 0 && it <= 5) && unique.size === 6; } if (!isBaseValid) { return false; } const selectedBackgroundRef = draft.selections.background?.entityRef; if (!selectedBackgroundRef) { return true; } const backgroundDetail = detailCache[selectedBackgroundRef] ?? null; const modes = getBackgroundAbilityModes(backgroundDetail); if (!modes.length) { return true; } const safeModeIndex = Math.max(0, Math.min(modes.length - 1, draft.steps.abilityScores.backgroundBonusModeIndex)); const mode = modes[safeModeIndex] ?? null; const selections = normalizeBackgroundBonusSelections(mode, draft.steps.abilityScores.backgroundBonusSelections); return mode ? selections.length === mode.weights.length : true; } if (stepIndex === 4) { 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; if (classSlotCount <= 0) { return true; } const fixedFeatKeySet = new Set( uniqueStrings([...getFixedFeatUids(backgroundDetail), ...getFixedFeatUids(speciesDetail)]) .map((token) => parseNameSourceToken(token)) .filter((it): it is { name: string; source: string } => Boolean(it)) .map((it) => sanitizeFeatKey(it.name, it.source)) ); const variableCount = uniqueStrings( draft.steps.feats.selected .map((token) => parseNameSourceToken(token) || parseFeatUid(token)) .filter((it): it is { name: string; source: string } => Boolean(it)) .map((it) => sanitizeFeatKey(it.name, it.source)) ).filter((key) => !fixedFeatKeySet.has(key)).length; return variableCount >= classSlotCount; } if (stepIndex === 5) { 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(primaryClassDetail).choices, ...secondaryRequirements, ...parseBackgroundSkills(backgroundDetail).choices, ...parseSpeciesSkills(speciesDetail).choices ]; if (!requirements.length) { return true; } const notesState = parseSkillsNotes(draft.steps.skills.notes); return requirements.every((choice) => { const picks = uniqueStrings((notesState.picksByChoiceId[choice.id] || []).filter((it) => choice.options.includes(it))); return picks.length >= choice.count; }); } if (stepIndex === 6) { 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; } if (!spellCatalog?.length) { return draft.steps.spells.selected.length > 0; } 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) => { spellByToken.set(toNameSourceToken(spell.name, spell.source), spell); }); let cantripCount = 0; let preparedCount = 0; let hasInvalidSelection = false; uniqueStrings(draftSpellSelectionsToTokens(draft.steps.spells.selected)).forEach((token) => { if (hasInvalidSelection) { return; } const tokenName = parseNameSourceToken(token)?.name.trim().toLowerCase() || ""; if (alwaysPreparedTokens.has(token) || (tokenName && alwaysPreparedNames.has(tokenName))) { return; } 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; } }); if (hasInvalidSelection) { return false; } return ( cantripCount >= requiredCantrips && preparedCount >= requiredPrepared && cantripCount <= requiredCantrips && preparedCount <= requiredPrepared ); } if (stepIndex === 7) { 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) { return true; } const selections = parseEquipmentSelectionState(draft.steps.equipment.startingEquipmentText, draft.steps.equipment.inventoryText).selections; return groups.every((group) => { const selectedId = selections[group.id]; return group.options.some((option) => option.id === selectedId); }); } if (stepIndex === 8) { return Boolean(draft.profile.name.trim()) && Boolean(draft.steps.about.backstory.trim() || draft.steps.about.notes.trim()); } if (stepIndex === CHARACTER_STEPS.length - 1) { return true; } return false; }, [detailCache, spellCatalog]); const stepCompletion = useMemo( () => CHARACTER_STEPS.map((_, index) => isStepComplete(index, characterDraft)), [characterDraft, isStepComplete] ); const currentStepLabel = CHARACTER_STEPS[characterStep] ?? CHARACTER_STEPS[0]; const progressionSteps = CHARACTER_STEPS.slice(0, CHARACTER_STEPS.length - 1); const canMoveNext = useMemo(() => { if (!characterDraft) { return false; } if (characterStep >= CHARACTER_STEPS.length - 1) { return false; } if (characterStep === CHARACTER_STEPS.length - 2) { return true; } return isStepComplete(characterStep, characterDraft); }, [characterDraft, characterStep, isStepComplete]); const nextStep = useCallback(() => { setCharacterStep((current) => Math.min(CHARACTER_STEPS.length - 1, current + 1)); }, []); const prevStep = useCallback(() => { setCharacterStep((current) => Math.max(0, current - 1)); }, []); const renderEntityDetailContent = useCallback( (detail: CharacterSelectionDetail | null, kind: EntityPopoverKind) => { if (!detail) { return

Loading details...

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

{detail.name}

{detail.sourceName} ({detail.source}) • {detail.sourceBucket}

{detail.summary ? ( ) : null} {detail.featureCards.length ? (
{detail.featureCards.map((feature) => (
{feature.title} {feature.level != null ? Level {feature.level} : null}
{feature.summary ? ( ) : null} {kind === "class" && feature.options?.length ? ( ) : null}
))}
) : null}
View JSON
{detail.rawJson}
); }, [characterDraft, isRenderRuntimeReady, updateDraft] ); useEffect(() => { if (characterModalMode === "closed") { return; } const onDocumentClickCapture = (event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) { return; } const anchor = target.closest("a[href]") as HTMLAnchorElement | null; if (!anchor) { return; } const isVetLink = anchor.hasAttribute("data-vet-page") && anchor.hasAttribute("data-vet-hash"); if (isVetLink) { // Let native 5etools link handlers drive hover/popover behavior. return; } const href = anchor.getAttribute("href"); if (!href) { return; } const wikiPath = resolveWikiPathFromHref(href); if (!wikiPath) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); setCurrentPath(wikiPath); setCharacterModalMode("closed"); }; document.addEventListener("click", onDocumentClickCapture, true); return () => document.removeEventListener("click", onDocumentClickCapture, true); }, [characterModalMode, resolveWikiPathFromHref]); if (error) { return (

5etools Desktop failed to start

{error}

); } if (!wikiUrl) { return (

Starting 5etools Desktop

Loading local 5etools wiki...

); } return (

5etools Desktop

Compendium

setTopbarSearch(event.target.value)} placeholder="Search everywhere..." />
{characterModalMode === "closed" ? (