import { FormEvent, JSX, useCallback, useEffect, useMemo, useRef, useState } from "react"; const DEFAULT_PAGE = "/5etools.html"; const CARD_PAGE_SIZE = 8; 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 RendererHandle = { setBaseUrl: (value: string) => RendererHandle; setAddHandlers: (value: boolean) => RendererHandle; setFirstSection: (value: boolean) => RendererHandle; render: (entry: unknown) => string; }; type RendererGlobal = { get: () => RendererHandle; hover?: { _doInit?: () => void; pHandleLinkMouseOver?: (evt: MouseEvent, ele: HTMLElement, opts?: unknown) => 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 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; }; } } 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 areTopbarGroupsEquivalent(a: TopbarMenuGroup[], b: TopbarMenuGroup[]): boolean { return JSON.stringify(a) === JSON.stringify(b); } 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, 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 clampPage(page: number, totalItems: number): number { const totalPages = Math.max(1, Math.ceil(totalItems / CARD_PAGE_SIZE)); return Math.max(1, Math.min(totalPages, page)); } function paginateOptions(options: CharacterSelectionOption[], page: number): CharacterSelectionOption[] { const safePage = clampPage(page, options.length); const start = (safePage - 1) * CARD_PAGE_SIZE; return options.slice(start, start + CARD_PAGE_SIZE); } 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 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 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 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]; } function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: number): { cantrips: number; prepared: number; known: number; slots: Array<{ level: number; slots: number; label: string }>; hasPactMagic: boolean; } { const out = { cantrips: 0, prepared: 0, known: 0, slots: [] as Array<{ level: number; slots: number; label: string }>, hasPactMagic: false }; if (!detail) { return out; } const raw = safeJsonParse>(detail.rawJson); if (!raw) { return out; } 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 : []; 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])); } else { out.known = out.prepared; } const groups = Array.isArray(raw.classTableGroups) ? raw.classTableGroups : []; groups.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; } labels.forEach((labelRaw, labelIndex) => { const label = typeof labelRaw === "string" ? labelRaw : ""; const lower = label.toLowerCase(); if (!lower.includes("slot")) { return; } const value = levelRow[labelIndex]; const slots = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; if (!slots) { return; } const levelMatch = lower.match(/(\d+)(?:st|nd|rd|th)/); const spellLevel = levelMatch ? Number.parseInt(levelMatch[1], 10) : 1; out.slots.push({ level: Number.isFinite(spellLevel) ? spellLevel : 1, slots, label }); if (lower.includes("pact")) { out.hasPactMagic = true; } }); }); out.slots.sort((a, b) => a.level - b.level || a.label.localeCompare(b.label)); return out; } 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 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 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 [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 [classPage, setClassPage] = useState(1); const [speciesPage, setSpeciesPage] = useState(1); const [backgroundPage, setBackgroundPage] = useState(1); const [entityPopover, setEntityPopover] = useState<{ kind: EntityPopoverKind; entityRef: string } | null>(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 refreshCharacterSummaries = useCallback(async () => { const list = await window.desktopApi.listCharacters(); setCharacterSummaries(list); }, []); const resetEditorUiState = useCallback(() => { setClassSearch(""); setSpeciesSearch(""); setBackgroundSearch(""); setFeatSearch(""); setSpellSearch(""); setEquipmentItemInput(""); setClassPage(1); setSpeciesPage(1); setBackgroundPage(1); 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 pLoadAll = runtime.DataUtil?.spell?.pLoadAll; const getCombinedClasses = runtime.Renderer?.spell?.getCombinedClasses; if (!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 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; } setTopbarGroups((current) => (areTopbarGroupsEquivalent(current, data.groups as TopbarMenuGroup[]) ? current : (data.groups as TopbarMenuGroup[]))); 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] ); const classTotalPages = Math.max(1, Math.ceil(filteredClassOptions.length / CARD_PAGE_SIZE)); const speciesTotalPages = Math.max(1, Math.ceil(filteredSpeciesOptions.length / CARD_PAGE_SIZE)); const backgroundTotalPages = Math.max(1, Math.ceil(filteredBackgroundOptions.length / CARD_PAGE_SIZE)); const classPageSafe = clampPage(classPage, filteredClassOptions.length); const speciesPageSafe = clampPage(speciesPage, filteredSpeciesOptions.length); const backgroundPageSafe = clampPage(backgroundPage, filteredBackgroundOptions.length); const classPageOptions = useMemo( () => paginateOptions(filteredClassOptions, classPageSafe), [filteredClassOptions, classPageSafe] ); const speciesPageOptions = useMemo( () => paginateOptions(filteredSpeciesOptions, speciesPageSafe), [filteredSpeciesOptions, speciesPageSafe] ); const backgroundPageOptions = useMemo( () => paginateOptions(filteredBackgroundOptions, backgroundPageSafe), [filteredBackgroundOptions, backgroundPageSafe] ); useEffect(() => { setClassPage((current) => clampPage(current, filteredClassOptions.length)); }, [filteredClassOptions.length]); useEffect(() => { setSpeciesPage((current) => clampPage(current, filteredSpeciesOptions.length)); }, [filteredSpeciesOptions.length]); useEffect(() => { setBackgroundPage((current) => clampPage(current, filteredBackgroundOptions.length)); }, [filteredBackgroundOptions.length]); useEffect(() => { const selectedRefs = [ characterDraft?.selections.class?.entityRef, characterDraft?.selections.background?.entityRef, characterDraft?.selections.species?.entityRef ] .filter((it): it is string => Boolean(it)) .slice(0, 3); for (const ref of selectedRefs) { void ensureOptionDetail(ref); } }, [characterDraft?.selections.background?.entityRef, characterDraft?.selections.class?.entityRef, characterDraft?.selections.species?.entityRef, ensureOptionDetail]); const selectedClassDetail = characterDraft?.selections.class ? detailCache[characterDraft.selections.class.entityRef] ?? null : null; 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; useEffect(() => { if (characterModalMode !== "editor" || !entityPopover) { return; } const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setEntityPopover(null); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [characterModalMode, entityPopover]); useEffect(() => { if (characterModalMode !== "editor" || !entityPopover) { return; } if (characterStep < 0 || characterStep > 2) { setEntityPopover(null); } }, [characterModalMode, characterStep, entityPopover]); const classSkillData = useMemo( () => parseClassSkills(selectedClassDetail), [selectedClassDetail?.entityRef, selectedClassDetail?.rawJson] ); 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( () => getClassFeatSlotCount(selectedClassDetail, characterDraft?.profile.level ?? 1), [selectedClassDetail?.entityRef, selectedClassDetail?.featureCards, characterDraft?.profile.level] ); 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 classSpellcasting = useMemo( () => parseClassSpellcasting(selectedClassDetail, characterDraft?.profile.level ?? 1), [selectedClassDetail?.entityRef, selectedClassDetail?.rawJson, characterDraft?.profile.level] ); const selectedClassSpellPool = useMemo(() => { if (!spellCatalog || !characterDraft?.selections.class) { return [] as SpellCatalogItem[]; } const className = characterDraft.selections.class.name.trim().toLowerCase(); const classSource = characterDraft.selections.class.source.trim().toUpperCase(); const sourceAliases = new Set([classSource]); if (classSource === "XPHB") { sourceAliases.add("PHB"); } if (classSource === "PHB") { sourceAliases.add("XPHB"); } const needle = spellSearch.trim().toLowerCase(); return spellCatalog .filter((spell) => sourceBuckets.includes(spell.sourceBucket)) .filter((spell) => { const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className); if (!matchingByName.length) { return false; } const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim())); if (!hasSourceData) { return true; } return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase())); }) .filter((spell) => { if (!needle) { return true; } return `${spell.name} ${spell.source} ${spell.level}`.toLowerCase().includes(needle); }); }, [spellCatalog, characterDraft?.selections.class, sourceBuckets, 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( (characterDraft?.steps.spells.selected ?? []) .map((token) => { const parsed = parseNameSourceToken(token) || parseFeatUid(token); return parsed ? toNameSourceToken(parsed.name, parsed.source) : ""; }) .filter((it) => Boolean(it)) ), [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 selectedCantripCount = selectedSpellItems.filter((it) => it.level === 0).length; const selectedPreparedCount = selectedSpellItems.filter((it) => it.level > 0).length; const spellSlotMatrix = useMemo(() => { const byLevel = new Map(); classSpellcasting.slots.forEach((slot) => { byLevel.set(slot.level, (byLevel.get(slot.level) || 0) + slot.slots); }); return Array.from(byLevel.entries()) .sort(([a], [b]) => a - b) .map(([level, slots]) => ({ level, slots })); }, [classSpellcasting.slots]); 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 Boolean(draft.selections.class) && 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 classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null; const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null; const slotCount = getClassFeatSlotCount(classDetail, draft.profile.level); if (slotCount <= 0) { 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 >= slotCount; } if (stepIndex === 5) { const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null; const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null; const requirements = [ ...parseClassSkills(classDetail).choices, ...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 classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; const spellcasting = parseClassSpellcasting(classDetail, draft.profile.level); const requiredCantrips = spellcasting.cantrips; const requiredPrepared = spellcasting.known || spellcasting.prepared || 0; if (requiredCantrips <= 0 && requiredPrepared <= 0) { return true; } if (!spellCatalog?.length) { return draft.steps.spells.selected.length > 0; } const spellLevelByToken = new Map(); spellCatalog.forEach((spell) => { spellLevelByToken.set(toNameSourceToken(spell.name, spell.source), spell.level); }); let cantripCount = 0; let preparedCount = 0; uniqueStrings(draft.steps.spells.selected).forEach((token) => { const parsed = parseNameSourceToken(token) || parseFeatUid(token); if (!parsed) { return; } const level = spellLevelByToken.get(toNameSourceToken(parsed.name, parsed.source)); if (level == null) { return; } if (level === 0) { cantripCount += 1; } else { preparedCount += 1; } }); return cantripCount >= requiredCantrips && preparedCount >= requiredPrepared; } if (stepIndex === 7) { const classDetail = draft.selections.class ? detailCache[draft.selections.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...

; } 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?.selections.subclass?.entityRef, 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" ? (