Files
5et-desktop/src/renderer/App.tsx
T

7363 lines
279 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FormEvent, JSX, MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
const DEFAULT_PAGE = "/5etools.html";
const APP_CHARACTERS_MENU_ID = "__characters";
const CHARACTER_STEPS = [
"Class",
"Species",
"Background",
"Ability Scores",
"Feats",
"Skills",
"Spells",
"Equipment",
"About",
"Finish"
] as const;
const ABILITY_KEYS = ["str", "dex", "con", "int", "wis", "cha"] as const;
type AbilityKey = (typeof ABILITY_KEYS)[number];
const ABILITY_LABELS: Record<AbilityKey, string> = {
str: "Strength",
dex: "Dexterity",
con: "Constitution",
int: "Intelligence",
wis: "Wisdom",
cha: "Charisma"
};
const STANDARD_ARRAY: [number, number, number, number, number, number] = [15, 14, 13, 12, 10, 8];
const SKILL_OPTIONS = [
"Acrobatics",
"Animal Handling",
"Arcana",
"Athletics",
"Deception",
"History",
"Insight",
"Intimidation",
"Investigation",
"Medicine",
"Nature",
"Perception",
"Performance",
"Persuasion",
"Religion",
"Sleight of Hand",
"Stealth",
"Survival"
] as const;
const SOURCE_BUCKET_OPTIONS = [
{ key: "homebrew", label: "Homebrew (non-WotC)" },
{ key: "core2024", label: "Core 2024" },
{ key: "expanded2024", label: "Expanded 2024" },
{ key: "core2014", label: "Core 2014" },
{ key: "expanded2014", label: "Expanded 2014" }
] as const;
const FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS = [
"lib/jquery.js",
"js/parser.js",
"js/utils.js",
"js/utils-ui.js",
"lib/localforage.js",
"js/utils-dataloader.js",
"js/render.js",
"js/render-dice.js",
"js/hist.js"
] as const;
const FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS = [
"js/utils-brew.js",
"js/utils-config.js",
"js/shim-esmodules.js"
] as const;
const FIVEETOOLS_RENDER_STYLESHEET_PATHS = ["css/bootstrap.css", "css/main.css"] as const;
type CharacterModalMode = "closed" | "list" | "editor";
type EntityPopoverKind = "class" | "species" | "background";
type PendingEntitySelection = {
kind: EntityPopoverKind;
option: CharacterSelectionOption;
};
type RendererHandle = {
setBaseUrl: (value: string) => RendererHandle;
setAddHandlers: (value: boolean) => RendererHandle;
setFirstSection: (value: boolean) => RendererHandle;
render: (entry: unknown) => string;
};
type RendererHoverLinkOptions = {
isSpecifiedLinkData?: boolean;
page?: string;
source?: string;
hash?: string;
preloadId?: string;
customHashId?: string;
isFauxPage?: boolean;
isAllowRedirect?: boolean;
};
type RendererGlobal = {
get: () => RendererHandle;
hover?: {
_doInit?: () => void;
pHandleLinkMouseOver?: (evt: MouseEvent, ele: HTMLElement, opts?: RendererHoverLinkOptions) => Promise<void>;
handleLinkMouseLeave?: (evt: MouseEvent, ele: HTMLElement) => void;
handleLinkMouseMove?: (evt: MouseEvent, ele: HTMLElement) => void;
handleLinkClick?: (evt: MouseEvent, ele: HTMLElement, opts?: { isForceOpenPermanent?: boolean }) => void;
handleLinkWheel?: (evt: WheelEvent, ele: HTMLElement) => void;
handleLinkDragStart?: (evt: DragEvent, ele: HTMLElement) => void;
};
};
type UrlUtilGlobal = {
PG_SPELLS?: string;
URL_TO_HASH_BUILDER?: Record<string, ((entity: { name: string; source: string }) => string) | undefined>;
encodeArrayForHash?: (...parts: string[]) => string;
};
type TopbarMenuEntry = {
id: string;
type: "link" | "action" | "divider" | "label" | "submenu";
label?: string;
datePrefix?: string;
sourceColor?: string;
href?: string;
isExternal?: boolean;
actionId?: string;
children?: TopbarMenuEntry[];
};
type TopbarMenuGroup = {
id: string;
kind: "dropdown" | "link" | "action";
label: string;
href?: string;
isExternal?: boolean;
actionId?: string;
items?: TopbarMenuEntry[];
};
declare global {
interface Window {
Renderer?: RendererGlobal;
JqueryUtil?: {
initEnhancements?: () => void;
};
TemplateUtil?: {
initVanilla?: () => void;
};
UrlUtil?: UrlUtilGlobal;
}
}
let renderRuntimePromise: Promise<void> | 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<TopbarMenuEntry, "datePrefix" | "sourceColor"> {
if (!ele) {
return {};
}
const out: Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> = {};
const dateEle = ele.querySelector(":scope > .page__nav-date");
if (dateEle) {
const datePrefix = cleanTopbarLabel(dateEle.textContent || "");
if (datePrefix) {
out.datePrefix = datePrefix;
}
}
const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source");
if (sourceEle instanceof HTMLElement) {
const computed = getComputedStyle(sourceEle);
const sourceColor = (computed.backgroundColor || computed.borderLeftColor || "").trim();
if (
sourceColor
&& sourceColor !== "transparent"
&& !/^rgba?\(\s*0\s*,\s*0\s*,\s*0(?:\s*,\s*0)?\s*\)$/i.test(sourceColor)
) {
out.sourceColor = sourceColor;
}
}
return out;
}
function getPathnameFromHrefLoose(hrefRaw: string): string {
const href = hrefRaw.trim();
if (!href) {
return "";
}
try {
return new URL(href, "http://localhost").pathname.toLowerCase();
} catch {
return href.split(/[?#]/)[0].toLowerCase();
}
}
function parseNativeTopbarMenuList(
list: Element,
registerAction: (ele: HTMLElement) => string,
idPrefix: string
): TopbarMenuEntry[] {
const entries: TopbarMenuEntry[] = [];
const liElements = Array.from(list.children).filter((it): it is HTMLLIElement => it instanceof HTMLLIElement);
liElements.forEach((li, index) => {
const entryId = `${idPrefix}-${index}`;
const submenuList = (
li.querySelector(":scope > ul")
|| (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null)
) as Element | null;
const submenuChildren = submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : [];
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
entries.push({ id: `${entryId}-divider`, type: "divider" });
return;
}
if (li.classList.contains("nav2-list__label")) {
const label = cleanTopbarLabel(li.textContent || "");
if (label) {
entries.push({ id: `${entryId}-label`, type: "label", label });
}
return;
}
if (li.classList.contains("dropdown--navbar")) {
const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null;
const label = toggle ? getElementPrimaryText(toggle) : "";
if (!label) {
return;
}
entries.push({
id: `${entryId}-submenu`,
type: "submenu",
label,
...getElementMenuMeta(toggle),
children: submenuChildren
});
return;
}
if (li.classList.contains("nav2-accord__wrp")) {
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
const label = headPrimary ? getElementPrimaryText(headPrimary) : "";
if (!label || !submenuChildren.length) {
return;
}
entries.push({
id: `${entryId}-accordion`,
type: "submenu",
label,
...getElementMenuMeta(headPrimary),
children: submenuChildren
});
return;
}
const link = li.querySelector(":scope > a") as HTMLAnchorElement | null;
if (link) {
const label = getElementPrimaryText(link);
const href = (link.getAttribute("href") || "").trim();
const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:");
if (submenuChildren.length) {
entries.push({
id: `${entryId}-submenu-link`,
type: "submenu",
label: label || "Section",
...getElementMenuMeta(link),
href: !isActionLike ? href : undefined,
actionId: isActionLike ? registerAction(link) : undefined,
isExternal: !isActionLike ? link.target === "_blank" || /^https?:\/\//i.test(href) : false,
children: submenuChildren
});
return;
}
if (isActionLike) {
entries.push({
id: `${entryId}-action`,
type: "action",
label: label || "Action",
...getElementMenuMeta(link),
actionId: registerAction(link)
});
return;
}
entries.push({
id: `${entryId}-link`,
type: "link",
label: label || href,
...getElementMenuMeta(link),
href,
isExternal: link.target === "_blank" || /^https?:\/\//i.test(href)
});
return;
}
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it): it is HTMLSpanElement => it instanceof HTMLSpanElement);
if (submenuChildren.length && spans.length) {
const first = spans[0];
const label = cleanTopbarLabel(first.textContent || first.title || "");
entries.push({
id: `${entryId}-submenu-span`,
type: "submenu",
label: label || "Section",
...getElementMenuMeta(first),
actionId: registerAction(first),
children: submenuChildren
});
return;
}
spans.forEach((span, spanIndex) => {
const label = cleanTopbarLabel(span.textContent || span.title || "");
entries.push({
id: `${entryId}-span-${spanIndex}`,
type: "action",
label: label || "Action",
...getElementMenuMeta(span),
actionId: registerAction(span)
});
});
});
return entries;
}
function parseNativeTopbarGroups(navbar: HTMLElement): {
groups: TopbarMenuGroup[];
actionHandlers: Map<string, () => void>;
} {
let actionIx = 0;
const actionHandlers = new Map<string, () => 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<void> {
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<void> {
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<void> {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}
function createNewCharacterDraft(): CharacterDocument {
const now = new Date().toISOString();
return {
schema: "5etd.character",
version: 1,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
profile: {
name: "Unnamed Character",
level: 1,
alignment: "",
faith: "",
lifestyle: "",
age: "",
gender: "",
height: "",
weight: "",
eyes: "",
skin: "",
hair: ""
},
selections: {
class: null,
subclass: null,
classes: [],
background: null,
species: null
},
steps: {
preferences: {
sourceBuckets: ["core2024"],
useOptionalClassFeatures: true,
customizeOrigin: true,
advancementType: "milestone",
hpMode: "average",
usePrerequisitesFeats: true,
usePrerequisitesMulticlass: true
},
abilityScores: {
method: "standardArray",
scores: {
str: 15,
dex: 14,
con: 13,
int: 12,
wis: 10,
cha: 8
},
rolledValues: [...STANDARD_ARRAY],
assignments: {
str: 0,
dex: 1,
con: 2,
int: 3,
wis: 4,
cha: 5
},
backgroundBonusModeIndex: 0,
backgroundBonusSelections: []
},
feats: {
selected: [],
notes: ""
},
skills: {
selected: [],
notes: ""
},
spells: {
selected: [],
notes: ""
},
equipment: {
startingEquipmentText: "",
inventoryText: "",
possessionsText: "",
currency: {
cp: 0,
sp: 0,
ep: 0,
gp: 0,
pp: 0
}
},
about: {
personality: "",
ideals: "",
bonds: "",
flaws: "",
backstory: "",
notes: ""
}
},
notes: ""
};
}
function abilityModifier(score: number): string {
const mod = Math.floor((score - 10) / 2);
return mod >= 0 ? `+${mod}` : `${mod}`;
}
function formatIso(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
function parseLineList(raw: string): string[] {
return Array.from(
new Set(
raw
.split(/\r?\n/g)
.map((it) => it.trim())
.filter((it) => Boolean(it))
)
);
}
function toLineList(items: string[]): string {
return items.join("\n");
}
function filterOptions(options: CharacterSelectionOption[], query: string): CharacterSelectionOption[] {
const needle = query.trim().toLowerCase();
if (!needle) {
return options;
}
return options.filter((option) => {
const haystack = `${option.name} ${option.source} ${option.sourceName} ${option.details} ${option.summary}`.toLowerCase();
return haystack.includes(needle);
});
}
function filterBySourceBuckets(options: CharacterSelectionOption[], sourceBuckets: string[]): CharacterSelectionOption[] {
if (!sourceBuckets.length) {
return [];
}
const selected = new Set(sourceBuckets);
return options.filter((option) => selected.has(option.sourceBucket));
}
function toEntityRef(option: CharacterSelectionOption): CharacterEntityRef {
return {
entityType: option.entityType,
name: option.name,
source: option.source,
hash: option.hash,
entityRef: option.entityRef
};
}
function toSubclassEntityRef(option: CharacterFeatureOption): CharacterEntityRef {
return {
entityType: "subclass",
name: option.name,
source: option.source,
hash: option.hash,
entityRef: option.entityRef
};
}
function getSourceEdition(sourceRaw: string): "2014" | "2024" {
return sourceRaw.trim().toUpperCase().startsWith("X") ? "2024" : "2014";
}
function getDraftClassSelections(draft: CharacterDocument | null): CharacterClassSelection[] {
if (!draft) {
return [];
}
if (Array.isArray(draft.selections.classes) && draft.selections.classes.length) {
const seen = new Set<string>();
return draft.selections.classes
.map((entry) => ({
class: entry.class,
subclass: entry.subclass ?? null,
level: Math.max(1, Math.min(20, Math.trunc(entry.level || 1)))
}))
.filter((entry) => {
const key = entry.class?.entityRef?.trim();
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
if (draft.selections.class) {
return [
{
class: draft.selections.class,
subclass: draft.selections.subclass ?? null,
level: Math.max(1, Math.min(20, draft.profile.level || 1))
}
];
}
return [];
}
function getClassLevelTotal(classes: CharacterClassSelection[]): number {
const total = classes.reduce((sum, entry) => sum + Math.max(1, Math.trunc(entry.level || 1)), 0);
return Math.max(1, Math.min(20, total));
}
function withDraftClassSelections(draft: CharacterDocument, classesRaw: CharacterClassSelection[]): CharacterDocument {
const classes = classesRaw
.map((entry) => ({
class: entry.class,
subclass: entry.subclass ?? null,
level: Math.max(1, Math.min(20, Math.trunc(entry.level || 1)))
}))
.filter((entry) => Boolean(entry.class?.entityRef));
const primary = classes[0];
const totalLevel = classes.length ? getClassLevelTotal(classes) : 1;
return {
...draft,
profile: {
...draft.profile,
level: totalLevel
},
selections: {
...draft.selections,
class: primary?.class ?? null,
subclass: primary?.subclass ?? null,
classes
}
};
}
function buildAssetUrl(baseUrl: string, relativePath: string | null): string {
if (!baseUrl || !relativePath) {
return "";
}
return new URL(relativePath.replace(/^\/+/, ""), `${baseUrl}/`).toString();
}
function roll4d6DropLowest(): number {
const rolls = [
Math.floor(Math.random() * 6) + 1,
Math.floor(Math.random() * 6) + 1,
Math.floor(Math.random() * 6) + 1,
Math.floor(Math.random() * 6) + 1
].sort((a, b) => a - b);
return rolls[1] + rolls[2] + rolls[3];
}
function getScoresFromAssignments(
pool: [number, number, number, number, number, number],
assignments: Record<AbilityKey, number>
): Record<AbilityKey, number> {
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<number, number> = {
8: 0,
9: 1,
10: 2,
11: 3,
12: 4,
13: 5,
14: 7,
15: 9
};
return map[score] ?? 99;
}
function getPointBuySpent(scores: Record<AbilityKey, number>): 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<string, unknown> {
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<AbilityKey>();
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<AbilityKey, number> {
const totals: Record<AbilityKey, number> = {
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<AbilityKey, number>,
bonuses: Record<AbilityKey, number>
): Record<AbilityKey, number> {
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<number, number> = {
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<string, Record<string, unknown>>;
classVariant?: Record<string, Record<string, unknown>>;
}
>
>;
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<string, string>;
addedItems: string[];
}
interface SkillsNotesState {
text: string;
picksByChoiceId: Record<string, string[]>;
extraSkills: string[];
}
function safeJsonParse<T>(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<string, string>();
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<string>();
const choices: SkillChoiceRequirement[] = [];
if (!Array.isArray(value)) {
return { granted: [], choices: [] };
}
value.forEach((entryRaw, entryIndex) => {
if (!isRecord(entryRaw)) {
return;
}
const chooseRaw = entryRaw.choose;
if (isRecord(chooseRaw)) {
const from = Array.isArray(chooseRaw.from)
? chooseRaw.from.map((it) => (typeof it === "string" ? toSkillName(it) : null)).filter((it): it is string => Boolean(it))
: [...SKILL_OPTIONS];
const count = typeof chooseRaw.count === "number" && Number.isFinite(chooseRaw.count) ? Math.max(1, Math.trunc(chooseRaw.count)) : 1;
if (from.length) {
choices.push({
id: `${idPrefix}-choose-${entryIndex}`,
sourceLabel,
title: `${sourceLabel} Skill Choice`,
count: Math.min(count, from.length),
options: Array.from(new Set(from))
});
}
return;
}
Object.entries(entryRaw).forEach(([key, val]) => {
if (key === "any" && typeof val === "number" && val > 0) {
choices.push({
id: `${idPrefix}-any-${entryIndex}`,
sourceLabel,
title: `${sourceLabel} Skill Choice`,
count: Math.min(Math.trunc(val), SKILL_OPTIONS.length),
options: [...SKILL_OPTIONS]
});
return;
}
if (val !== true) {
return;
}
const skill = toSkillName(key);
if (skill) {
granted.add(skill);
}
});
});
return {
granted: [...granted],
choices
};
}
function getClassRequirementSets(detail: CharacterSelectionDetail | null): AbilityKey[][] {
if (!detail) {
return [];
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw) {
return [];
}
const requirementSets: AbilityKey[][] = [];
const multiclassingRaw = isRecord(raw.multiclassing) ? raw.multiclassing : null;
const requirementsRaw = multiclassingRaw && isRecord(multiclassingRaw.requirements) ? multiclassingRaw.requirements : null;
if (requirementsRaw) {
const orRaw = Array.isArray(requirementsRaw.or) ? requirementsRaw.or : null;
if (orRaw?.length) {
for (const optionRaw of orRaw) {
if (!isRecord(optionRaw)) {
continue;
}
const keys = Object.entries(optionRaw)
.filter(([key, value]) => isAbilityKey(key.toLowerCase()) && typeof value === "number" && Number.isFinite(value) && value > 0)
.map(([key]) => key.toLowerCase() as AbilityKey);
if (!keys.length) {
continue;
}
// 5etools uses this shape for Fighter ("Str 13 or Dex 13"), so each key is a separate option set.
keys.forEach((key) => requirementSets.push([key]));
}
} else {
const keys = Object.entries(requirementsRaw)
.filter(([key, value]) => isAbilityKey(key.toLowerCase()) && typeof value === "number" && Number.isFinite(value) && value > 0)
.map(([key]) => key.toLowerCase() as AbilityKey);
if (keys.length) {
requirementSets.push(keys);
}
}
}
if (requirementSets.length) {
return requirementSets;
}
const primary = raw.primaryAbility;
if (Array.isArray(primary)) {
for (const optionRaw of primary) {
if (!isRecord(optionRaw)) {
continue;
}
const keys = Object.keys(optionRaw)
.map((key) => key.toLowerCase())
.filter((key): key is AbilityKey => isAbilityKey(key));
if (keys.length) {
requirementSets.push(keys);
}
}
} else if (isRecord(primary)) {
const keys = Object.keys(primary)
.map((key) => key.toLowerCase())
.filter((key): key is AbilityKey => isAbilityKey(key));
if (keys.length) {
requirementSets.push(keys);
}
}
return requirementSets;
}
function meetsAnyClassRequirementSet(
scores: Record<AbilityKey, number>,
requirementSets: AbilityKey[][]
): boolean {
if (!requirementSets.length) {
return true;
}
return requirementSets.some((requiredSet) => requiredSet.every((ability) => (scores[ability] ?? 0) >= 13));
}
function parseClassSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
if (!detail) {
return { granted: [], choices: [] };
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw || !isRecord(raw.startingProficiencies)) {
return { granted: [], choices: [] };
}
return parseSkillProficiencyValue(raw.startingProficiencies.skills, "Class", "class");
}
function parseMulticlassClassSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
if (!detail) {
return { granted: [], choices: [] };
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw || !isRecord(raw.multiclassing) || !isRecord(raw.multiclassing.proficienciesGained)) {
return { granted: [], choices: [] };
}
return parseSkillProficiencyValue(
raw.multiclassing.proficienciesGained.skills,
`${detail.name} Multiclass`,
`multiclass-${detail.entityRef}`
);
}
function parseBackgroundSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
if (!detail) {
return { granted: [], choices: [] };
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
return parseSkillProficiencyValue(raw?.skillProficiencies, "Background", "background");
}
function parseSpeciesSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
if (!detail) {
return { granted: [], choices: [] };
}
const raw = safeJsonParse<Record<string, unknown>>(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<Record<string, unknown>>(detail.rawJson);
if (!raw || !Array.isArray(raw.feats)) {
return [];
}
const out = new Set<string>();
raw.feats.forEach((entryRaw) => {
if (!isRecord(entryRaw)) {
return;
}
Object.entries(entryRaw).forEach(([key, value]) => {
if (value !== true) {
return;
}
const parsed = parseFeatUid(key);
if (!parsed) {
return;
}
out.add(`${parsed.name}|${parsed.source}`);
});
});
return [...out];
}
const FULL_CASTER_SLOT_TABLE: number[][] = [
[2, 0, 0, 0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0, 0, 0, 0],
[4, 2, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 2, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 1, 0, 0, 0, 0, 0],
[4, 3, 3, 2, 0, 0, 0, 0, 0],
[4, 3, 3, 3, 1, 0, 0, 0, 0],
[4, 3, 3, 3, 2, 0, 0, 0, 0],
[4, 3, 3, 3, 2, 1, 0, 0, 0],
[4, 3, 3, 3, 2, 1, 0, 0, 0],
[4, 3, 3, 3, 2, 1, 1, 0, 0],
[4, 3, 3, 3, 2, 1, 1, 0, 0],
[4, 3, 3, 3, 2, 1, 1, 1, 0],
[4, 3, 3, 3, 2, 1, 1, 1, 0],
[4, 3, 3, 3, 2, 1, 1, 1, 1],
[4, 3, 3, 3, 3, 1, 1, 1, 1],
[4, 3, 3, 3, 3, 2, 1, 1, 1],
[4, 3, 3, 3, 3, 2, 2, 1, 1]
];
const HALF_CASTER_SLOT_TABLE: number[][] = [
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[2, 0, 0, 0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0, 0, 0, 0],
[4, 2, 0, 0, 0, 0, 0, 0, 0],
[4, 2, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 2, 0, 0, 0, 0, 0, 0],
[4, 3, 2, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 1, 0, 0, 0, 0, 0],
[4, 3, 3, 1, 0, 0, 0, 0, 0],
[4, 3, 3, 2, 0, 0, 0, 0, 0],
[4, 3, 3, 2, 0, 0, 0, 0, 0],
[4, 3, 3, 3, 1, 0, 0, 0, 0],
[4, 3, 3, 3, 1, 0, 0, 0, 0],
[4, 3, 3, 3, 2, 0, 0, 0, 0],
[4, 3, 3, 3, 2, 0, 0, 0, 0]
];
const THIRD_CASTER_SLOT_TABLE: number[][] = [
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[2, 0, 0, 0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0, 0, 0, 0],
[4, 2, 0, 0, 0, 0, 0, 0, 0],
[4, 2, 0, 0, 0, 0, 0, 0, 0],
[4, 2, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 2, 0, 0, 0, 0, 0, 0],
[4, 3, 2, 0, 0, 0, 0, 0, 0],
[4, 3, 2, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 1, 0, 0, 0, 0, 0],
[4, 3, 3, 1, 0, 0, 0, 0, 0]
];
const ARTIFICER_SLOT_TABLE: number[][] = [
[2, 0, 0, 0, 0, 0, 0, 0, 0],
[2, 0, 0, 0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0, 0, 0, 0],
[3, 0, 0, 0, 0, 0, 0, 0, 0],
[4, 2, 0, 0, 0, 0, 0, 0, 0],
[4, 2, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 0, 0, 0, 0, 0, 0, 0],
[4, 3, 2, 0, 0, 0, 0, 0, 0],
[4, 3, 2, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 0, 0, 0, 0, 0, 0],
[4, 3, 3, 1, 0, 0, 0, 0, 0],
[4, 3, 3, 1, 0, 0, 0, 0, 0],
[4, 3, 3, 2, 0, 0, 0, 0, 0],
[4, 3, 3, 2, 0, 0, 0, 0, 0],
[4, 3, 3, 3, 1, 0, 0, 0, 0],
[4, 3, 3, 3, 1, 0, 0, 0, 0],
[4, 3, 3, 3, 2, 0, 0, 0, 0],
[4, 3, 3, 3, 2, 0, 0, 0, 0]
];
const PACT_SLOT_COUNT_BY_LEVEL = [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4];
const PACT_SLOT_LEVEL_BY_LEVEL = [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5];
function getSpellcastingAbilityKey(detail: CharacterSelectionDetail | null): AbilityKey | null {
if (!detail) {
return null;
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
const value = typeof raw?.spellcastingAbility === "string" ? raw.spellcastingAbility.trim().toLowerCase() : "";
return isAbilityKey(value) ? value : null;
}
function parseSpellLevelFromCell(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
const parsed = Math.trunc(value);
return parsed >= 1 && parsed <= 9 ? parsed : null;
}
if (typeof value !== "string") {
return null;
}
const raw = value.trim();
if (!raw) {
return null;
}
const levelAttr = raw.match(/level\s*=\s*(\d+)/i);
if (levelAttr) {
const parsed = Number.parseInt(levelAttr[1], 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 9 ? parsed : null;
}
const ord = raw.match(/(\d+)\s*(?:st|nd|rd|th)/i);
if (ord) {
const parsed = Number.parseInt(ord[1], 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 9 ? parsed : null;
}
const cleaned = stripTagsFallback(raw);
const attrFromCleaned = cleaned.match(/level\s*=\s*(\d+)/i);
if (attrFromCleaned) {
const parsed = Number.parseInt(attrFromCleaned[1], 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 9 ? parsed : null;
}
const ordFromCleaned = cleaned.match(/(\d+)\s*(?:st|nd|rd|th)/i);
if (ordFromCleaned) {
const parsed = Number.parseInt(ordFromCleaned[1], 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 9 ? parsed : null;
}
return null;
}
function toSlotTableRows(casterProgression: string): number[][] {
if (casterProgression === "full") {
return FULL_CASTER_SLOT_TABLE;
}
if (casterProgression === "1/2") {
return HALF_CASTER_SLOT_TABLE;
}
if (casterProgression === "1/3") {
return THIRD_CASTER_SLOT_TABLE;
}
if (casterProgression === "artificer") {
return ARTIFICER_SLOT_TABLE;
}
return [];
}
function parsePreparedFormulaCount(formulaRaw: string, level: number, abilityModifierValue: number): number | null {
const formula = formulaRaw.trim();
if (!formula) {
return null;
}
let expression = formula
.replace(/<\$level\$>/gi, String(level))
.replace(/<\$[a-z]{3}_mod\$>/gi, String(abilityModifierValue))
.replace(/<\$prof_bonus\$>/gi, String(getProficiencyBonus(level)));
expression = stripTagsFallback(expression).replace(/\s+/g, " ").trim();
if (!expression) {
return null;
}
const safe = expression.replace(/[^0-9+\-*/(). ]/g, "").trim();
if (!safe || !/^[0-9+\-*/(). ]+$/.test(safe)) {
return null;
}
try {
const result = Function(`"use strict"; return (${safe});`)() as unknown;
if (typeof result !== "number" || !Number.isFinite(result)) {
return null;
}
return Math.max(1, Math.floor(result));
} catch {
return null;
}
}
function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: number, spellcastingAbilityModifier = 0): {
cantrips: number;
prepared: number;
known: number;
additionalKnown: number;
slots: Array<{ level: number; slots: number; label: string }>;
hasPactMagic: boolean;
maxSpellLevel: number;
spellcastingAbility: AbilityKey | null;
} {
const out = {
cantrips: 0,
prepared: 0,
known: 0,
additionalKnown: 0,
slots: [] as Array<{ level: number; slots: number; label: string }>,
hasPactMagic: false,
maxSpellLevel: 0,
spellcastingAbility: null as AbilityKey | null
};
if (!detail) {
return out;
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw) {
return out;
}
const casterProgression = typeof raw.casterProgression === "string" ? raw.casterProgression.trim().toLowerCase() : "";
const spellcastingAbility = typeof raw.spellcastingAbility === "string" ? raw.spellcastingAbility.trim().toLowerCase() : "";
if (isAbilityKey(spellcastingAbility)) {
out.spellcastingAbility = spellcastingAbility;
}
const levelIndex = Math.max(0, Math.min(19, level - 1));
const cantripProgression = Array.isArray(raw.cantripProgression) ? raw.cantripProgression : [];
const preparedSpellsProgression = Array.isArray(raw.preparedSpellsProgression) ? raw.preparedSpellsProgression : [];
const spellsKnownProgression = Array.isArray(raw.spellsKnownProgression) ? raw.spellsKnownProgression : [];
const classTableGroups = Array.isArray(raw.classTableGroups) ? raw.classTableGroups : [];
if (typeof cantripProgression[levelIndex] === "number") {
out.cantrips = Math.max(0, Math.trunc(cantripProgression[levelIndex]));
}
if (typeof preparedSpellsProgression[levelIndex] === "number") {
out.prepared = Math.max(0, Math.trunc(preparedSpellsProgression[levelIndex]));
}
if (typeof spellsKnownProgression[levelIndex] === "number") {
out.known = Math.max(0, Math.trunc(spellsKnownProgression[levelIndex]));
}
const slotMap = new Map<number, number>();
classTableGroups.forEach((groupRaw) => {
if (!isRecord(groupRaw)) {
return;
}
const labels = Array.isArray(groupRaw.colLabels) ? groupRaw.colLabels : [];
const rows = Array.isArray(groupRaw.rowsSpellProgression) ? groupRaw.rowsSpellProgression : Array.isArray(groupRaw.rows) ? groupRaw.rows : [];
const levelRow = Array.isArray(rows[levelIndex]) ? rows[levelIndex] : null;
if (!levelRow) {
return;
}
const slotLevelColIndex = labels.findIndex((it) => typeof it === "string" && it.toLowerCase().includes("slot level"));
const parseNumeric = (value: unknown): number => {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.trunc(value));
}
if (typeof value === "string") {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
return Math.max(0, Number.parseInt(trimmed, 10));
}
}
return 0;
};
labels.forEach((labelRaw, labelIndex) => {
const label = typeof labelRaw === "string" ? labelRaw : "";
const lower = label.toLowerCase();
const cell = levelRow[labelIndex];
if (!out.cantrips && lower.includes("cantrip")) {
const value = parseNumeric(cell);
if (value > 0) {
out.cantrips = value;
}
}
if (!out.prepared && lower.includes("prepared")) {
const value = parseNumeric(cell);
if (value > 0) {
out.prepared = value;
}
}
if (!out.known && lower.includes("spells known")) {
const value = parseNumeric(cell);
if (value > 0) {
out.known = value;
}
}
if (labelIndex === slotLevelColIndex || lower.includes("cantrip") || lower.includes("prepared") || lower.includes("known")) {
return;
}
const slots = parseNumeric(cell);
if (!slots) {
return;
}
let spellLevel = parseSpellLevelFromCell(labelRaw);
if (spellLevel == null && slotLevelColIndex >= 0) {
spellLevel = parseSpellLevelFromCell(levelRow[slotLevelColIndex]);
}
if (spellLevel == null) {
return;
}
slotMap.set(spellLevel, (slotMap.get(spellLevel) || 0) + slots);
if (lower.includes("pact") || (lower.includes("spell slots") && slotLevelColIndex >= 0) || casterProgression === "pact") {
out.hasPactMagic = true;
}
});
});
if (!slotMap.size) {
const progressionRows = toSlotTableRows(casterProgression);
const row = progressionRows[levelIndex] || [];
row.forEach((slots, spellLevelIndex) => {
if (slots > 0) {
slotMap.set(spellLevelIndex + 1, slots);
}
});
}
if (casterProgression === "pact") {
const pactSlots = PACT_SLOT_COUNT_BY_LEVEL[levelIndex] || 0;
const pactSpellLevel = PACT_SLOT_LEVEL_BY_LEVEL[levelIndex] || 0;
if (pactSlots > 0 && pactSpellLevel > 0) {
slotMap.set(pactSpellLevel, Math.max(slotMap.get(pactSpellLevel) || 0, pactSlots));
out.hasPactMagic = true;
}
}
if (!out.prepared && typeof raw.preparedSpells === "string") {
const parsed = parsePreparedFormulaCount(raw.preparedSpells, level, spellcastingAbilityModifier);
if (parsed != null) {
out.prepared = parsed;
}
}
const fixedKnownByLevelRaw = isRecord(raw.spellsKnownProgressionFixedByLevel) ? raw.spellsKnownProgressionFixedByLevel : null;
if (fixedKnownByLevelRaw) {
Object.entries(fixedKnownByLevelRaw).forEach(([classLevelRaw, fixedBySpellLevelRaw]) => {
const classLevel = Number.parseInt(classLevelRaw, 10);
if (!Number.isFinite(classLevel) || classLevel > level || !isRecord(fixedBySpellLevelRaw)) {
return;
}
Object.entries(fixedBySpellLevelRaw).forEach(([spellLevelRaw, countRaw]) => {
const spellLevel = Number.parseInt(spellLevelRaw, 10);
const count = typeof countRaw === "number" && Number.isFinite(countRaw) ? Math.max(0, Math.trunc(countRaw)) : 0;
if (!Number.isFinite(spellLevel) || spellLevel < 1 || spellLevel > 9 || count <= 0) {
return;
}
out.additionalKnown += count;
if (spellLevel > out.maxSpellLevel) {
out.maxSpellLevel = spellLevel;
}
});
});
}
out.slots = Array.from(slotMap.entries())
.sort(([a], [b]) => a - b)
.map(([spellLevel, slots]) => ({
level: spellLevel,
slots,
label: out.hasPactMagic && casterProgression === "pact" ? `Pact Slots (Level ${spellLevel})` : `Level ${spellLevel} Slots`
}));
if (!out.maxSpellLevel) {
out.maxSpellLevel = out.slots.reduce((max, slot) => Math.max(max, slot.level), 0);
}
return out;
}
function getCasterProgression(detail: CharacterSelectionDetail | null): string {
if (!detail) {
return "";
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
return typeof raw?.casterProgression === "string" ? raw.casterProgression.trim().toLowerCase() : "";
}
function getMulticlassCasterContribution(detail: CharacterSelectionDetail | null, level: number): number {
const classLevel = Math.max(1, Math.min(20, Math.trunc(level || 1)));
const progression = getCasterProgression(detail);
if (!progression) {
return 0;
}
if (progression === "full") {
return classLevel;
}
if (progression === "1/2") {
const edition = getSourceEdition(detail?.source || "");
return edition === "2024" ? Math.ceil(classLevel / 2) : Math.floor(classLevel / 2);
}
if (progression === "artificer") {
return Math.ceil(classLevel / 2);
}
if (progression === "1/3") {
return Math.floor(classLevel / 3);
}
return 0;
}
function isPactCaster(detail: CharacterSelectionDetail | null): boolean {
return getCasterProgression(detail) === "pact";
}
function isSpellcastingCaster(detail: CharacterSelectionDetail | null): boolean {
const progression = getCasterProgression(detail);
return progression === "full" || progression === "1/2" || progression === "1/3" || progression === "artificer";
}
function getMulticlassSpellSlotsByCasterLevel(casterLevel: number): Array<{ level: number; slots: number; label: string }> {
if (casterLevel < 1) {
return [];
}
const levelIndex = Math.max(0, Math.min(19, casterLevel - 1));
const row = FULL_CASTER_SLOT_TABLE[levelIndex] || [];
return row
.map((slots, index) => ({
level: index + 1,
slots,
label: `Level ${index + 1} Slots`
}))
.filter((slot) => slot.slots > 0);
}
function getLeveledSpellSelectionCap(spellcasting: {
known: number;
prepared: number;
additionalKnown?: number;
}): number {
if (spellcasting.known > 0) {
return Math.max(0, spellcasting.known + (spellcasting.additionalKnown || 0));
}
return Math.max(0, spellcasting.prepared);
}
function readEquipmentTypeLabel(value: string): string {
const map: Record<string, string> = {
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<Record<string, unknown>>(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<string, string>; addedItems?: unknown[] }>(raw);
if (parsed && isRecord(parsed.selections) && Array.isArray(parsed.addedItems)) {
const selections: Record<string, string> = {};
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<string, unknown>; 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<string, string[]> = {};
if (isRecord(parsed.picksByChoiceId)) {
Object.entries(parsed.picksByChoiceId).forEach(([choiceId, picksRaw]) => {
if (!Array.isArray(picksRaw)) {
return;
}
picksByChoiceId[choiceId] = picksRaw
.filter((it): it is string => typeof it === "string")
.map((it) => toSkillName(it))
.filter((it): it is string => Boolean(it));
});
}
const extraSkills = Array.isArray(parsed.extraSkills)
? uniqueStrings(
parsed.extraSkills
.filter((it): it is string => typeof it === "string")
.map((it) => toSkillName(it))
.filter((it): it is string => Boolean(it))
)
: [];
return {
text: typeof parsed.text === "string" ? parsed.text : "",
picksByChoiceId,
extraSkills
};
}
function stringifySkillsNotes(state: SkillsNotesState): string {
return JSON.stringify(
{
text: state.text,
picksByChoiceId: state.picksByChoiceId,
extraSkills: state.extraSkills
},
null,
2
);
}
function getAbilityModifierNumber(score: number): number {
return Math.floor((score - 10) / 2);
}
function areStringArraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false;
}
return a.every((it, ix) => it === b[ix]);
}
function uniqueStrings(values: string[]): string[] {
return Array.from(new Set(values.filter((it) => Boolean(it))));
}
function toNameSourceToken(name: string, source: string): string {
return `${name.trim()}|${source.trim().toUpperCase()}`;
}
function parseNameSourceToken(value: string): { name: string; source: string } | null {
const [nameRaw, sourceRaw] = value.split("|");
const name = (nameRaw || "").trim();
const source = (sourceRaw || "").trim().toUpperCase();
if (!name || !source) {
return null;
}
return { name, source };
}
function draftSpellSelectionsToTokens(values: string[]): string[] {
return values
.map((token) => {
const parsed = parseNameSourceToken(token) || parseFeatUid(token);
return parsed ? toNameSourceToken(parsed.name, parsed.source) : "";
})
.filter((token) => Boolean(token));
}
function cpToCurrency(cpRaw: number): Record<"cp" | "sp" | "ep" | "gp" | "pp", number> {
let cp = Math.max(0, Math.trunc(cpRaw));
const pp = Math.floor(cp / 1000);
cp -= pp * 1000;
const gp = Math.floor(cp / 100);
cp -= gp * 100;
const ep = Math.floor(cp / 50);
cp -= ep * 50;
const sp = Math.floor(cp / 10);
cp -= sp * 10;
return { cp, sp, ep, gp, pp };
}
function parseInventoryLabel(value: string): { label: string; count: number } {
const match = value.trim().match(/^(.*?)\s*x\s*(\d+)$/i);
if (!match) {
return { label: value.trim(), count: 1 };
}
const label = (match[1] || "").trim();
const count = Number.parseInt((match[2] || "1").trim(), 10);
return {
label: label || value.trim(),
count: Number.isFinite(count) && count > 0 ? count : 1
};
}
function collectEntryText(value: unknown, out: string[], limit = 5): void {
if (out.length >= limit || value == null) {
return;
}
if (typeof value === "string") {
const cleaned = stripTagsFallback(value);
if (cleaned) {
out.push(cleaned);
}
return;
}
if (Array.isArray(value)) {
value.forEach((it) => collectEntryText(it, out, limit));
return;
}
if (!isRecord(value)) {
return;
}
if (typeof value.entry === "string") {
collectEntryText(value.entry, out, limit);
}
if (typeof value.text === "string") {
collectEntryText(value.text, out, limit);
}
if (Array.isArray(value.entries)) {
collectEntryText(value.entries, out, limit);
}
if (Array.isArray(value.items)) {
collectEntryText(value.items, out, limit);
}
}
function getEntrySummary(value: unknown): string {
const chunks: string[] = [];
collectEntryText(value, chunks, 3);
return chunks.join(" ").trim();
}
function normalizeSpellClasses(classesRaw: Array<{ name: string; source: string }>): Array<{ name: string; source: string }> {
const seen = new Set<string>();
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<string, unknown>): 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<string, unknown>,
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<string, Record<string, unknown>> | undefined) => {
if (!mapRaw) {
return;
}
Object.entries(mapRaw).forEach(([classSource, classMap]) => {
if (!isRecord(classMap)) {
return;
}
Object.keys(classMap).forEach((className) => {
if (!className.trim()) {
return;
}
out.push({ name: className.trim(), source: classSource.trim().toUpperCase() });
});
});
};
collect(record.class);
collect(record.classVariant);
return out;
}
function getAlwaysPreparedSpellTokens(detail: CharacterSelectionDetail | null, level: number): string[] {
if (!detail) {
return [];
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw || !Array.isArray(raw.additionalSpells)) {
return [];
}
const defaultSource = detail.source.trim().toUpperCase() || "PHB";
const out = new Set<string>();
const parseSpellUid = (value: string): string | null => {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const [nameRaw, sourceRaw] = trimmed.split("|");
const name = (nameRaw || "").trim();
if (!name) {
return null;
}
const source = (sourceRaw || defaultSource).trim().toUpperCase();
return toNameSourceToken(name, source || defaultSource);
};
const collectSpellEntries = (value: unknown): void => {
if (typeof value === "string") {
const token = parseSpellUid(value);
if (token) {
out.add(token);
}
return;
}
if (Array.isArray(value)) {
value.forEach((it) => collectSpellEntries(it));
return;
}
if (isRecord(value) && Array.isArray(value.spells)) {
value.spells.forEach((it) => collectSpellEntries(it));
}
};
raw.additionalSpells.forEach((entryRaw) => {
if (!isRecord(entryRaw) || !isRecord(entryRaw.prepared)) {
return;
}
Object.entries(entryRaw.prepared).forEach(([gateRaw, spellListRaw]) => {
const gate = gateRaw.trim().toLowerCase();
let isUnlocked = true;
if (/^\d+$/.test(gate)) {
const requiredLevel = Number.parseInt(gate, 10);
isUnlocked = level >= requiredLevel;
} else {
const spellGate = gate.match(/^s(\d+)$/);
if (spellGate) {
const requiredLevel = Number.parseInt(spellGate[1], 10);
isUnlocked = level >= requiredLevel;
}
}
if (!isUnlocked) {
return;
}
collectSpellEntries(spellListRaw);
});
});
return [...out];
}
function SetupView() {
const [setupState, setSetupState] = useState<SetupState | null>(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 (
<main className="status-wrap setup-wrap">
<section className="status-panel setup-panel">
<h1>First Run Setup</h1>
<p>{status}</p>
{setupState?.sourceDir ? <p className="setup-meta">Detected local source: {setupState.sourceDir}</p> : null}
{error ? <p className="setup-error">{error}</p> : null}
<form className="setup-form" onSubmit={onSubmit}>
<div className="setup-mode-row" role="radiogroup" aria-label="Source input mode">
<button
type="button"
className={sourceMode === "url" ? "setup-mode-btn is-active" : "setup-mode-btn"}
onClick={() => setSourceMode("url")}
disabled={isSubmitting}
>
Source URL
</button>
<button
type="button"
className={sourceMode === "zip" ? "setup-mode-btn is-active" : "setup-mode-btn"}
onClick={() => setSourceMode("zip")}
disabled={isSubmitting}
>
Source ZIP
</button>
</div>
{sourceMode === "url" ? (
<label className="setup-field">
<span>5etools source repository URL</span>
<input
type="url"
value={sourceRepoUrl}
onChange={(event) => setSourceRepoUrl(event.target.value)}
required={sourceMode === "url"}
disabled={isSubmitting}
placeholder="https://github.com/5etools-mirror-2/5etools-mirror-2.github.io.git"
/>
</label>
) : (
<label className="setup-field">
<span>5etools source ZIP file</span>
<div className="setup-zip-row">
<input
type="text"
value={sourceZipPath}
onChange={(event) => setSourceZipPath(event.target.value)}
required={sourceMode === "zip"}
disabled={isSubmitting}
placeholder="/path/to/5etools-source.zip"
/>
<button type="button" className="setup-zip-pick" onClick={onClickBrowseZip} disabled={isSubmitting}>
Browse
</button>
</div>
</label>
)}
<label className="setup-field">
<span>5etools image repository URL</span>
<input
type="url"
value={imageRepoUrl}
onChange={(event) => setImageRepoUrl(event.target.value)}
required
disabled={isSubmitting}
placeholder="https://github.com/5etools-mirror-2/5etools-img"
/>
</label>
<label className="setup-field">
<span>External image base URL fallback</span>
<input
type="url"
value={imageBaseUrl}
onChange={(event) => setImageBaseUrl(event.target.value)}
required
disabled={isSubmitting}
placeholder="https://5e.tools/img/"
/>
</label>
<label className="setup-checkbox">
<input
type="checkbox"
checked={downloadImages}
onChange={(event) => setDownloadImages(event.target.checked)}
disabled={isSubmitting}
/>
<span>Download local image repository (recommended)</span>
</label>
<button className="setup-submit" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Setting up..." : "Run Setup"}
</button>
</form>
</section>
</main>
);
}
function WikiView() {
const [wikiUrl, setWikiUrl] = useState<string>("");
const [error, setError] = useState<string>("");
const [currentPath, setCurrentPath] = useState<string>(DEFAULT_PAGE);
const [isRenderRuntimeReady, setIsRenderRuntimeReady] = useState(false);
const [openTopMenuId, setOpenTopMenuId] = useState<string | null>(null);
const [openTopMenuPath, setOpenTopMenuPath] = useState<string[]>([]);
const [topbarSearch, setTopbarSearch] = useState("");
const [topbarGroups, setTopbarGroups] = useState<TopbarMenuGroup[]>([]);
const frameRef = useRef<HTMLIFrameElement | null>(null);
const topbarRef = useRef<HTMLElement | null>(null);
const topbarGroupsSignatureRef = useRef<string>("");
const [characterModalMode, setCharacterModalMode] = useState<CharacterModalMode>("closed");
const [characterStep, setCharacterStep] = useState<number>(0);
const [characterDraft, setCharacterDraft] = useState<CharacterDocument | null>(null);
const [characterSummaries, setCharacterSummaries] = useState<CharacterSummary[]>([]);
const [characterOptions, setCharacterOptions] = useState<CharacterSelectionOptionSets | null>(null);
const [detailCache, setDetailCache] = useState<Record<string, CharacterSelectionDetail>>({});
const [characterError, setCharacterError] = useState<string>("");
const [characterNotice, setCharacterNotice] = useState<string>("");
const [isCharacterBusy, setIsCharacterBusy] = useState(false);
const [classSearch, setClassSearch] = useState("");
const [speciesSearch, setSpeciesSearch] = useState("");
const [backgroundSearch, setBackgroundSearch] = useState("");
const [featSearch, setFeatSearch] = useState("");
const [spellSearch, setSpellSearch] = useState("");
const [equipmentItemInput, setEquipmentItemInput] = useState("");
const [entityPopover, setEntityPopover] = useState<{ kind: EntityPopoverKind; entityRef: string } | null>(null);
const [pendingEntitySelection, setPendingEntitySelection] = useState<PendingEntitySelection | null>(null);
const [featCatalog, setFeatCatalog] = useState<FeatCatalogItem[] | null>(null);
const [spellCatalog, setSpellCatalog] = useState<SpellCatalogItem[] | null>(null);
const [itemCatalog, setItemCatalog] = useState<string[]>([]);
const updateDraft = useCallback((updater: (current: CharacterDocument) => CharacterDocument) => {
setCharacterDraft((current) => {
if (!current) {
return current;
}
return updater(current);
});
}, []);
const loadCharacterOptions = useCallback(async () => {
if (characterOptions) {
return characterOptions;
}
const options = await window.desktopApi.getCharacterOptions();
setCharacterOptions(options);
return options;
}, [characterOptions]);
const ensureOptionDetail = useCallback(
async (entityRef: string) => {
const trimmed = entityRef.trim();
if (!trimmed) {
return null;
}
if (detailCache[trimmed]) {
return detailCache[trimmed];
}
const detail = await window.desktopApi.getCharacterOptionDetail(trimmed);
if (!detail) {
return null;
}
setDetailCache((current) => ({ ...current, [trimmed]: detail }));
return detail;
},
[detailCache]
);
const openEntityPopover = useCallback(
(kind: EntityPopoverKind, entityRef: string) => {
const trimmed = entityRef.trim();
if (!trimmed) {
return;
}
void ensureOptionDetail(trimmed);
setEntityPopover({ kind, entityRef: trimmed });
},
[ensureOptionDetail]
);
const closeEntityPopover = useCallback(() => {
setEntityPopover(null);
setPendingEntitySelection(null);
}, []);
const confirmPendingEntitySelection = useCallback(() => {
if (!pendingEntitySelection) {
closeEntityPopover();
return;
}
const { kind, option } = pendingEntitySelection;
updateDraft((draft) => {
if (kind === "class") {
const nextClassRef = toEntityRef(option);
const existing = getDraftClassSelections(draft);
if (existing.some((entry) => entry.class.entityRef === nextClassRef.entityRef)) {
return draft;
}
if (getClassLevelTotal(existing) >= 20) {
return draft;
}
return withDraftClassSelections(draft, [
...existing,
{
class: nextClassRef,
subclass: null,
level: 1
}
]);
}
if (kind === "species") {
return {
...draft,
selections: {
...draft.selections,
species: toEntityRef(option)
}
};
}
return {
...draft,
selections: {
...draft.selections,
background: toEntityRef(option)
},
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: 0,
backgroundBonusSelections: []
}
}
};
});
closeEntityPopover();
}, [closeEntityPopover, pendingEntitySelection, updateDraft]);
const refreshCharacterSummaries = useCallback(async () => {
const list = await window.desktopApi.listCharacters();
setCharacterSummaries(list);
}, []);
const resetEditorUiState = useCallback(() => {
setClassSearch("");
setSpeciesSearch("");
setBackgroundSearch("");
setFeatSearch("");
setSpellSearch("");
setEquipmentItemInput("");
setCharacterStep(0);
}, []);
const openCharacterList = useCallback(async () => {
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
setCharacterModalMode("list");
await refreshCharacterSummaries();
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load character list.";
setCharacterError(message);
setCharacterModalMode("list");
} finally {
setIsCharacterBusy(false);
}
}, [refreshCharacterSummaries]);
const openNewCharacter = useCallback(async () => {
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
await loadCharacterOptions();
setCharacterDraft(createNewCharacterDraft());
resetEditorUiState();
setCharacterModalMode("editor");
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to start a new character.";
setCharacterError(message);
setCharacterModalMode("list");
} finally {
setIsCharacterBusy(false);
}
}, [loadCharacterOptions, resetEditorUiState]);
const openCharacterById = useCallback(
async (id: string) => {
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
await loadCharacterOptions();
const record = await window.desktopApi.getCharacter(id);
if (!record) {
setCharacterError("Character file was not found.");
setCharacterModalMode("list");
return;
}
setCharacterDraft(record);
resetEditorUiState();
setCharacterModalMode("editor");
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to open character.";
setCharacterError(message);
setCharacterModalMode("list");
} finally {
setIsCharacterBusy(false);
}
},
[loadCharacterOptions, resetEditorUiState]
);
const loadCharacterFromJson = useCallback(async () => {
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
const selectedPath = await window.desktopApi.pickCharacterJson();
if (!selectedPath) {
return;
}
await loadCharacterOptions();
const imported = await window.desktopApi.importCharacterJson(selectedPath);
await refreshCharacterSummaries();
setCharacterDraft(imported);
resetEditorUiState();
setCharacterNotice(`Imported character: ${imported.profile.name}`);
setCharacterModalMode("editor");
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to import character JSON.";
setCharacterError(message);
setCharacterModalMode("list");
} finally {
setIsCharacterBusy(false);
}
}, [loadCharacterOptions, refreshCharacterSummaries, resetEditorUiState]);
const deleteCharacterById = useCallback(
async (id: string) => {
if (!window.confirm("Delete this character file? This action cannot be undone.")) {
return;
}
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
const deleted = await window.desktopApi.deleteCharacter(id);
if (!deleted) {
setCharacterError("Character file could not be deleted.");
return;
}
setCharacterNotice("Character deleted.");
await refreshCharacterSummaries();
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete character.";
setCharacterError(message);
} finally {
setIsCharacterBusy(false);
}
},
[refreshCharacterSummaries]
);
const saveCharacterDraft = useCallback(async () => {
if (!characterDraft) {
return;
}
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
const saved = await window.desktopApi.saveCharacter({
id: characterDraft.id,
createdAt: characterDraft.createdAt,
profile: characterDraft.profile,
selections: characterDraft.selections,
steps: characterDraft.steps,
notes: characterDraft.notes
});
setCharacterDraft(saved);
setCharacterNotice(`Saved ${saved.profile.name}.`);
await refreshCharacterSummaries();
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save character.";
setCharacterError(message);
} finally {
setIsCharacterBusy(false);
}
}, [characterDraft, refreshCharacterSummaries]);
useEffect(() => {
window.desktopApi
.getWikiUrl()
.then((url) => setWikiUrl(url))
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
});
}, []);
useEffect(() => {
if (!wikiUrl) {
setIsRenderRuntimeReady(false);
return;
}
let isCancelled = false;
void ensure5etoolsRenderRuntime(wikiUrl)
.then(() => {
if (!isCancelled) {
setIsRenderRuntimeReady(true);
}
})
.catch((runtimeError: unknown) => {
console.error("[5ET-D] Failed to initialize 5etools render runtime", runtimeError);
if (!isCancelled) {
setIsRenderRuntimeReady(false);
}
});
return () => {
isCancelled = true;
};
}, [wikiUrl]);
useEffect(() => {
if (characterModalMode !== "editor" || !wikiUrl || !isRenderRuntimeReady) {
return;
}
let isCancelled = false;
const baseUrl = `${normalizeWikiBaseUrl(wikiUrl)}/`;
const loadFeats = async () => {
if (featCatalog) {
return;
}
const response = await fetch(new URL("data/feats.json", baseUrl).toString());
if (!response.ok) {
throw new Error(`Failed to load feats (${response.status})`);
}
const payload = (await response.json()) as { feat?: unknown[] };
const featsRaw = Array.isArray(payload.feat) ? payload.feat : [];
const next = featsRaw
.map((featRaw): FeatCatalogItem | null => {
if (!isRecord(featRaw)) {
return null;
}
const name = typeof featRaw.name === "string" ? featRaw.name.trim() : "";
const source = typeof featRaw.source === "string" ? featRaw.source.trim().toUpperCase() : "";
if (!name || !source) {
return null;
}
const sourceMeta = classifySourceBucket(source);
const summary = getEntrySummary(featRaw.entries);
return {
key: sanitizeFeatKey(name, source),
name,
source,
sourceBucket: sourceMeta.sourceBucket,
sourceEdition: sourceMeta.sourceEdition,
category: typeof featRaw.category === "string" ? featRaw.category : null,
summary
};
})
.filter((it): it is FeatCatalogItem => Boolean(it))
.sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
if (!isCancelled) {
setFeatCatalog(next);
}
};
const loadSpells = async () => {
if (spellCatalog) {
return;
}
const runtime = window as unknown as {
DataUtil?: {
spell?: {
pLoadAll?: () => Promise<unknown[]>;
};
};
Renderer?: {
spell?: {
getCombinedClasses?: (spell: unknown, prop: string) => Array<{ name: string; source: string }>;
};
};
};
const spellDataUtil = runtime.DataUtil?.spell;
const getCombinedClasses = runtime.Renderer?.spell?.getCombinedClasses;
if (!spellDataUtil?.pLoadAll) {
if (!isCancelled) {
setSpellCatalog([]);
}
return;
}
let sourceLookup: SpellSourceLookup | null = null;
try {
const lookupResp = await fetch(new URL("data/generated/gendata-spell-source-lookup.json", baseUrl).toString());
if (lookupResp.ok) {
sourceLookup = (await lookupResp.json()) as SpellSourceLookup;
}
} catch {
sourceLookup = null;
}
const spellsRaw = await spellDataUtil.pLoadAll();
const next = (Array.isArray(spellsRaw) ? spellsRaw : [])
.map((spellRaw): SpellCatalogItem | null => {
if (!isRecord(spellRaw)) {
return null;
}
const name = typeof spellRaw.name === "string" ? spellRaw.name.trim() : "";
const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toUpperCase() : "";
if (!name || !source) {
return null;
}
const classesFromRenderer = getCombinedClasses
? (getCombinedClasses(spellRaw, "fromClassList") || [])
.map((it) => ({
name: typeof it.name === "string" ? it.name.trim() : "",
source: typeof it.source === "string" ? it.source.trim().toUpperCase() : ""
}))
.filter((it) => Boolean(it.name))
: [];
const classesFromRaw = getSpellClassesFromRaw(spellRaw);
const classesFromLookup = getSpellClassesFromLookup(spellRaw, sourceLookup);
const classes = normalizeSpellClasses([...classesFromRenderer, ...classesFromRaw, ...classesFromLookup]);
const sourceMeta = classifySourceBucket(source);
const level = typeof spellRaw.level === "number" ? Math.max(0, Math.trunc(spellRaw.level)) : 0;
const school = typeof spellRaw.school === "string" ? spellRaw.school : "";
return {
key: sanitizeFeatKey(name, source),
name,
source,
level,
school,
sourceBucket: sourceMeta.sourceBucket,
sourceEdition: sourceMeta.sourceEdition,
classes
};
})
.filter((it): it is SpellCatalogItem => Boolean(it))
.sort((a, b) => a.level - b.level || a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
if (!isCancelled) {
setSpellCatalog(next);
}
};
const loadItems = async () => {
if (itemCatalog.length) {
return;
}
const response = await fetch(new URL("data/items-base.json", baseUrl).toString());
if (!response.ok) {
throw new Error(`Failed to load item catalog (${response.status})`);
}
const payload = (await response.json()) as { baseitem?: unknown[] };
const itemsRaw = Array.isArray(payload.baseitem) ? payload.baseitem : [];
const next = Array.from(
new Set(
itemsRaw
.map((it) => (isRecord(it) && typeof it.name === "string" ? it.name.trim() : ""))
.filter((it) => Boolean(it))
)
).sort((a, b) => a.localeCompare(b));
if (!isCancelled) {
setItemCatalog(next);
}
};
void (async () => {
try {
await Promise.all([loadFeats(), loadSpells(), loadItems()]);
} catch (catalogError) {
console.error("[5ET-D] Failed to load character builder catalogs", catalogError);
}
})();
return () => {
isCancelled = true;
};
}, [characterModalMode, wikiUrl, isRenderRuntimeReady, featCatalog, spellCatalog, itemCatalog.length]);
useEffect(() => {
const onMessage = (event: MessageEvent<unknown>) => {
const frameWindow = frameRef.current?.contentWindow;
if (!frameWindow || event.source !== frameWindow) {
return;
}
if (!event.data || typeof event.data !== "object") {
return;
}
const data = event.data as { type?: string; groups?: TopbarMenuGroup[] };
if (data.type === "5etd:native-navbar-data") {
if (!Array.isArray(data.groups)) {
return;
}
const nextGroups = data.groups as TopbarMenuGroup[];
let nextSignature = "";
try {
nextSignature = JSON.stringify(nextGroups);
} catch {
nextSignature = `groups:${nextGroups.length}:${Date.now()}`;
}
if (topbarGroupsSignatureRef.current === nextSignature) {
return;
}
topbarGroupsSignatureRef.current = nextSignature;
setTopbarGroups(nextGroups);
return;
}
if (data.type === "5etd:reset-onboarding") {
void (async () => {
const result = await window.desktopApi.resetOnboarding();
if (result.ok) {
return;
}
frameWindow.postMessage(
{
type: "5etd:reset-onboarding-result",
ok: false,
error: result.error || "Failed to reset onboarding."
},
"*"
);
})();
return;
}
if (data.type === "5etd:characters:new") {
void openNewCharacter();
return;
}
if (data.type === "5etd:characters:load-json") {
void loadCharacterFromJson();
return;
}
if (data.type === "5etd:characters:list") {
void openCharacterList();
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [loadCharacterFromJson, openCharacterList, openNewCharacter]);
useEffect(() => {
if (!openTopMenuId) {
return;
}
const onDocumentMouseDown = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (!topbarRef.current?.contains(target)) {
setOpenTopMenuId(null);
}
};
const onWindowKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpenTopMenuId(null);
}
};
document.addEventListener("mousedown", onDocumentMouseDown);
window.addEventListener("keydown", onWindowKeyDown);
return () => {
document.removeEventListener("mousedown", onDocumentMouseDown);
window.removeEventListener("keydown", onWindowKeyDown);
};
}, [openTopMenuId]);
useEffect(() => {
const frame = frameRef.current;
if (!frame) {
return;
}
const requestNativeNavbarModel = () => {
frame.contentWindow?.postMessage({ type: "5etd:native-navbar-request" }, "*");
};
frame.addEventListener("load", requestNativeNavbarModel);
requestNativeNavbarModel();
return () => frame.removeEventListener("load", requestNativeNavbarModel);
}, [currentPath, characterModalMode]);
const frameSrc = useMemo(() => {
if (!wikiUrl) {
return "";
}
return new URL(currentPath, `${wikiUrl}/`).toString();
}, [currentPath, wikiUrl]);
const currentPathname = useMemo(() => {
try {
return new URL(currentPath, "http://localhost").pathname.toLowerCase();
} catch {
return currentPath.split(/[?#]/)[0]?.toLowerCase() || DEFAULT_PAGE;
}
}, [currentPath]);
const navigateWikiPath = useCallback((path: string) => {
setCurrentPath(path);
setCharacterModalMode("closed");
setOpenTopMenuId(null);
setOpenTopMenuPath([]);
}, []);
const onSubmitTopbarSearch = useCallback((event: FormEvent<HTMLFormElement>) => {
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 => (
<span className="topbar-item-content">
{entry.datePrefix ? <span className="topbar-item-date">{entry.datePrefix}</span> : null}
{entry.sourceColor ? <span className="topbar-item-source" style={{ backgroundColor: entry.sourceColor }} aria-hidden="true" /> : null}
<span className="topbar-item-text">{entry.label}</span>
</span>
);
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 (
<div className="topbar-dropdown-columns">
{columns.map((columnEntries, depth) => (
<div key={`col-${depth}`} className="topbar-dropdown-column">
{columnEntries.map((entry) => {
if (entry.type === "divider") {
return <div key={entry.id} className="topbar-dropdown-divider" />;
}
if (entry.type === "label") {
return <div key={entry.id} className="topbar-dropdown-label">{entry.label}</div>;
}
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 (
<button
type="button"
key={entry.id}
className={className}
onMouseEnter={() => {
if (!hasChildren) {
return;
}
openTopbarSubmenuAtDepth(depth, entry.id);
}}
onFocus={() => {
if (!hasChildren) {
return;
}
openTopbarSubmenuAtDepth(depth, entry.id);
}}
onClick={() => {
if (hasChildren) {
openTopbarSubmenuAtDepth(depth, entry.id);
return;
}
activateTopbarEntry(entry);
}}
>
{renderEntryLabel(entry)}
{hasChildren ? <span className="topbar-submenu-caret" aria-hidden="true">▸</span> : null}
</button>
);
}
const isActive = entry.type === "link" && entry.href ? isSameAppPath(entry.href, currentPathname) : false;
return (
<button
type="button"
key={entry.id}
className={isActive ? "topbar-dropdown-item is-active" : "topbar-dropdown-item"}
onClick={() => activateTopbarEntry(entry)}
>
{renderEntryLabel(entry)}
</button>
);
})}
</div>
))}
</div>
);
}, [activateTopbarEntry, currentPathname, isSameAppPath, openTopMenuPath, openTopbarSubmenuAtDepth]);
const sourceBuckets = characterDraft?.steps.preferences.sourceBuckets ?? [];
const filteredClassOptions = useMemo(
() => filterOptions(filterBySourceBuckets(characterOptions?.classes ?? [], sourceBuckets), classSearch),
[characterOptions?.classes, sourceBuckets, classSearch]
);
const filteredSpeciesOptions = useMemo(
() => filterOptions(filterBySourceBuckets(characterOptions?.species ?? [], sourceBuckets), speciesSearch),
[characterOptions?.species, sourceBuckets, speciesSearch]
);
const filteredBackgroundOptions = useMemo(
() => filterOptions(filterBySourceBuckets(characterOptions?.backgrounds ?? [], sourceBuckets), backgroundSearch),
[characterOptions?.backgrounds, sourceBuckets, backgroundSearch]
);
useEffect(() => {
const selectedRefs = new Set<string>();
if (characterDraft?.selections.class?.entityRef) {
selectedRefs.add(characterDraft.selections.class.entityRef);
}
if (characterDraft?.selections.subclass?.entityRef) {
selectedRefs.add(characterDraft.selections.subclass.entityRef);
}
if (characterDraft?.selections.background?.entityRef) {
selectedRefs.add(characterDraft.selections.background.entityRef);
}
if (characterDraft?.selections.species?.entityRef) {
selectedRefs.add(characterDraft.selections.species.entityRef);
}
(characterDraft?.selections.classes || []).forEach((entry) => {
if (entry.class?.entityRef) {
selectedRefs.add(entry.class.entityRef);
}
if (entry.subclass?.entityRef) {
selectedRefs.add(entry.subclass.entityRef);
}
});
for (const ref of selectedRefs) {
void ensureOptionDetail(ref);
}
}, [
characterDraft?.selections.classes,
characterDraft?.selections.background?.entityRef,
characterDraft?.selections.class?.entityRef,
characterDraft?.selections.species?.entityRef,
characterDraft?.selections.subclass?.entityRef,
ensureOptionDetail
]);
const selectedClassEntries = useMemo(() => getDraftClassSelections(characterDraft), [characterDraft]);
const selectedPrimaryClassEntry = selectedClassEntries[0] ?? null;
const selectedClassDetail = selectedPrimaryClassEntry ? detailCache[selectedPrimaryClassEntry.class.entityRef] ?? null : null;
const selectedClassDetails = useMemo(
() =>
selectedClassEntries
.map((entry) => ({
entry,
detail: detailCache[entry.class.entityRef] ?? null,
subclassDetail: entry.subclass ? detailCache[entry.subclass.entityRef] ?? null : null
}))
.filter((it) => Boolean(it.entry.class)),
[detailCache, selectedClassEntries]
);
const selectedSpeciesDetail = characterDraft?.selections.species ? detailCache[characterDraft.selections.species.entityRef] ?? null : null;
const selectedBackgroundDetail = characterDraft?.selections.background
? detailCache[characterDraft.selections.background.entityRef] ?? null
: null;
const entityPopoverDetail = entityPopover ? detailCache[entityPopover.entityRef] ?? null : null;
const selectedClassRefSet = useMemo(
() => new Set(selectedClassEntries.map((entry) => entry.class.entityRef)),
[selectedClassEntries]
);
const classOptionByRef = useMemo(() => {
const out = new Map<string, CharacterSelectionOption>();
(characterOptions?.classes ?? []).forEach((option) => out.set(option.entityRef, option));
return out;
}, [characterOptions?.classes]);
const selectedClassEdition = useMemo(() => {
const primary = selectedClassEntries[0];
if (!primary) {
return null;
}
return (
classOptionByRef.get(primary.class.entityRef)?.sourceEdition ??
detailCache[primary.class.entityRef]?.sourceEdition ??
getSourceEdition(primary.class.source)
);
}, [classOptionByRef, detailCache, selectedClassEntries]);
const classEligibilityByRef = useMemo(() => {
const out = new Map<string, { disabled: boolean; reason: string | null }>();
const abilityScores = characterDraft?.steps.abilityScores.scores ?? {
str: 10,
dex: 10,
con: 10,
int: 10,
wis: 10,
cha: 10
};
const enforcePrereqs =
Boolean(characterDraft?.steps.preferences.usePrerequisitesMulticlass) && selectedClassEntries.length > 0;
filteredClassOptions.forEach((option) => {
if (selectedClassRefSet.has(option.entityRef)) {
out.set(option.entityRef, { disabled: true, reason: "Class already added" });
return;
}
if (getClassLevelTotal(selectedClassEntries) >= 20) {
out.set(option.entityRef, { disabled: true, reason: "Maximum total level reached" });
return;
}
const candidateEdition =
option.sourceEdition ?? detailCache[option.entityRef]?.sourceEdition ?? getSourceEdition(option.source);
if (selectedClassEdition && candidateEdition !== selectedClassEdition) {
out.set(option.entityRef, {
disabled: true,
reason: `Class year mismatch (${selectedClassEdition} only for current character)`
});
return;
}
if (enforcePrereqs) {
const candidate = detailCache[option.entityRef] ?? null;
if (!candidate) {
out.set(option.entityRef, { disabled: false, reason: null });
return;
}
const allClassDetails = [...selectedClassDetails.map((it) => it.detail), candidate];
const failing = allClassDetails.find((detail) => {
const requirementSets = getClassRequirementSets(detail);
return !meetsAnyClassRequirementSet(abilityScores, requirementSets);
});
if (failing) {
out.set(option.entityRef, { disabled: true, reason: `Ability prerequisites not met (${failing.name})` });
return;
}
}
out.set(option.entityRef, { disabled: false, reason: null });
});
return out;
}, [
characterDraft?.steps.abilityScores.scores,
characterDraft?.steps.preferences.usePrerequisitesMulticlass,
detailCache,
filteredClassOptions,
classOptionByRef,
selectedClassDetails,
selectedClassEdition,
selectedClassEntries.length,
selectedClassRefSet
]);
useEffect(() => {
const classOptions = characterOptions?.classes ?? [];
if (!classOptions.length) {
return;
}
classOptions.forEach((option) => {
if (detailCache[option.entityRef]) {
return;
}
void ensureOptionDetail(option.entityRef);
});
}, [characterOptions?.classes, detailCache, ensureOptionDetail]);
useEffect(() => {
if (characterModalMode !== "editor") {
closeEntityPopover();
}
}, [characterModalMode, closeEntityPopover]);
useEffect(() => {
if (characterModalMode !== "editor" || !entityPopover) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeEntityPopover();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [characterModalMode, closeEntityPopover, entityPopover]);
useEffect(() => {
if (characterModalMode !== "editor" || !entityPopover) {
return;
}
if (characterStep < 0 || characterStep > 2) {
closeEntityPopover();
}
}, [characterModalMode, characterStep, closeEntityPopover, entityPopover]);
const classSkillData = useMemo(() => {
const primary = parseClassSkills(selectedClassDetail);
const secondary = selectedClassDetails
.slice(1)
.map((it) => parseMulticlassClassSkills(it.detail))
.reduce(
(acc, part) => ({
granted: [...acc.granted, ...part.granted],
choices: [...acc.choices, ...part.choices]
}),
{ granted: [] as string[], choices: [] as SkillChoiceRequirement[] }
);
return {
granted: uniqueStrings([...primary.granted, ...secondary.granted]),
choices: [...primary.choices, ...secondary.choices]
};
}, [selectedClassDetail, selectedClassDetails]);
const backgroundSkillData = useMemo(
() => parseBackgroundSkills(selectedBackgroundDetail),
[selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson]
);
const speciesSkillData = useMemo(
() => parseSpeciesSkills(selectedSpeciesDetail),
[selectedSpeciesDetail?.entityRef, selectedSpeciesDetail?.rawJson]
);
const skillChoiceRequirements = useMemo(
() => [...classSkillData.choices, ...backgroundSkillData.choices, ...speciesSkillData.choices],
[classSkillData, backgroundSkillData, speciesSkillData]
);
const grantedSkills = useMemo(
() => uniqueStrings([...classSkillData.granted, ...backgroundSkillData.granted, ...speciesSkillData.granted]).sort((a, b) => a.localeCompare(b)),
[classSkillData, backgroundSkillData, speciesSkillData]
);
const skillNotesState = useMemo(
() => parseSkillsNotes(characterDraft?.steps.skills.notes ?? ""),
[characterDraft?.steps.skills.notes]
);
const normalizedSkillChoicePicks = useMemo(() => {
const out: Record<string, string[]> = {};
for (const choice of skillChoiceRequirements) {
const picksRaw = skillNotesState.picksByChoiceId[choice.id] || [];
const picks: string[] = [];
for (const skill of picksRaw) {
if (!choice.options.includes(skill) || picks.includes(skill)) {
continue;
}
picks.push(skill);
if (picks.length >= choice.count) {
break;
}
}
out[choice.id] = picks;
}
return out;
}, [skillChoiceRequirements, skillNotesState.picksByChoiceId]);
const manualSkillSelections = useMemo(
() =>
uniqueStrings(skillNotesState.extraSkills.map((it) => toSkillName(it)).filter((it): it is string => Boolean(it))).sort((a, b) =>
a.localeCompare(b)
),
[skillNotesState.extraSkills]
);
const allProficientSkills = useMemo(
() =>
uniqueStrings([
...grantedSkills,
...Object.values(normalizedSkillChoicePicks).flatMap((picks) => picks),
...manualSkillSelections
]).sort((a, b) => a.localeCompare(b)),
[grantedSkills, manualSkillSelections, normalizedSkillChoicePicks]
);
const fixedFeatTokens = useMemo(
() => uniqueStrings([...getFixedFeatUids(selectedBackgroundDetail), ...getFixedFeatUids(selectedSpeciesDetail)]).sort((a, b) => a.localeCompare(b)),
[selectedBackgroundDetail?.rawJson, selectedSpeciesDetail?.rawJson]
);
const classFeatSlotCount = useMemo(
() =>
selectedClassDetails.reduce(
(sum, it) => sum + getClassFeatSlotCount(it.detail, it.entry.level),
0
),
[selectedClassDetails]
);
const normalizedFeatSelections = useMemo(() => {
const raw = characterDraft?.steps.feats.selected ?? [];
return uniqueStrings(
raw
.map((token) => {
const parsed = parseNameSourceToken(token) || parseFeatUid(token);
return parsed ? toNameSourceToken(parsed.name, parsed.source) : "";
})
.filter((it) => Boolean(it))
);
}, [characterDraft?.steps.feats.selected]);
const variableFeatSelections = useMemo(() => {
const fixed = new Set(
fixedFeatTokens
.map((token) => parseNameSourceToken(token))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => sanitizeFeatKey(it.name, it.source))
);
const filtered = normalizedFeatSelections.filter((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return false;
}
return !fixed.has(sanitizeFeatKey(parsed.name, parsed.source));
});
return filtered.slice(0, Math.max(0, classFeatSlotCount));
}, [normalizedFeatSelections, fixedFeatTokens, classFeatSlotCount]);
const filteredFeatCatalog = useMemo(() => {
const needle = featSearch.trim().toLowerCase();
return (featCatalog ?? [])
.filter((feat) => sourceBuckets.includes(feat.sourceBucket))
.filter((feat) => {
if (!needle) {
return true;
}
return `${feat.name} ${feat.source} ${feat.summary} ${feat.category || ""}`.toLowerCase().includes(needle);
});
}, [featCatalog, sourceBuckets, featSearch]);
const featByKey = useMemo(() => {
const out = new Map<string, FeatCatalogItem>();
(featCatalog ?? []).forEach((feat) => out.set(feat.key, feat));
return out;
}, [featCatalog]);
const fixedFeatKeySet = useMemo(() => {
const out = new Set<string>();
fixedFeatTokens.forEach((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return;
}
out.add(sanitizeFeatKey(parsed.name, parsed.source));
});
return out;
}, [fixedFeatTokens]);
const selectedFeatItems = useMemo(
() =>
normalizedFeatSelections.map((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return {
key: token,
name: token,
source: "",
sourceBucket: "expanded2014" as CharacterSourceBucket,
sourceEdition: null,
category: null,
summary: ""
};
}
return (
featByKey.get(sanitizeFeatKey(parsed.name, parsed.source)) || {
key: sanitizeFeatKey(parsed.name, parsed.source),
name: parsed.name,
source: parsed.source,
sourceBucket: classifySourceBucket(parsed.source).sourceBucket,
sourceEdition: classifySourceBucket(parsed.source).sourceEdition,
category: null,
summary: ""
}
);
}),
[featByKey, normalizedFeatSelections]
);
const classSpellcastingByRef = useMemo(() => {
const out = new Map<string, ReturnType<typeof parseClassSpellcasting>>();
const scores = characterDraft?.steps.abilityScores.scores;
selectedClassDetails.forEach(({ entry, detail }) => {
const abilityKey = getSpellcastingAbilityKey(detail);
const abilityModifier =
abilityKey && scores ? getAbilityModifierNumber(scores[abilityKey] ?? 10) : 0;
out.set(entry.class.entityRef, parseClassSpellcasting(detail, entry.level, abilityModifier));
});
return out;
}, [characterDraft?.steps.abilityScores.scores, selectedClassDetails]);
const classSpellcasting = useMemo(() => {
let requiredCantrips = 0;
let requiredLeveledSpells = 0;
let maxSpellLevel = 0;
let spellcastingClassCount = 0;
let multiclassCasterLevel = 0;
let singleSpellcastingSlots: Array<{ level: number; slots: number; label: string }> = [];
const pactSlotMap = new Map<number, number>();
selectedClassDetails.forEach(({ entry, detail }) => {
const parsed =
classSpellcastingByRef.get(entry.class.entityRef) || parseClassSpellcasting(detail, entry.level, 0);
requiredCantrips += parsed.cantrips;
requiredLeveledSpells += getLeveledSpellSelectionCap(parsed);
maxSpellLevel = Math.max(maxSpellLevel, parsed.maxSpellLevel);
if (isSpellcastingCaster(detail)) {
spellcastingClassCount += 1;
multiclassCasterLevel += getMulticlassCasterContribution(detail, entry.level);
if (!singleSpellcastingSlots.length) {
singleSpellcastingSlots = parsed.slots.map((slot) => ({
level: slot.level,
slots: slot.slots,
label: `Level ${slot.level} Slots`
}));
}
}
if (isPactCaster(detail)) {
parsed.slots.forEach((slot) => {
pactSlotMap.set(slot.level, Math.max(pactSlotMap.get(slot.level) || 0, slot.slots));
});
}
});
const spellcastingSlots =
spellcastingClassCount > 1
? getMulticlassSpellSlotsByCasterLevel(Math.min(20, multiclassCasterLevel))
: singleSpellcastingSlots;
const pactSlots = Array.from(pactSlotMap.entries())
.sort(([a], [b]) => a - b)
.map(([level, slots]) => ({
level,
slots,
label: `Pact Slots (Level ${level})`
}));
const slots = [...spellcastingSlots, ...pactSlots];
if (!maxSpellLevel) {
maxSpellLevel = slots.reduce((max, slot) => Math.max(max, slot.level), 0);
}
return {
cantrips: requiredCantrips,
prepared: requiredLeveledSpells,
known: 0,
additionalKnown: 0,
slots,
hasPactMagic: pactSlots.length > 0,
maxSpellLevel,
spellcastingAbility: null as AbilityKey | null
};
}, [classSpellcastingByRef, selectedClassDetails]);
const alwaysPreparedSpellTokens = useMemo(
() =>
uniqueStrings(
selectedClassDetails.flatMap(({ entry, detail, subclassDetail }) => [
...getAlwaysPreparedSpellTokens(detail, entry.level),
...getAlwaysPreparedSpellTokens(subclassDetail, entry.level)
])
),
[selectedClassDetails]
);
const alwaysPreparedSpellTokenSet = useMemo(() => new Set(alwaysPreparedSpellTokens), [alwaysPreparedSpellTokens]);
const alwaysPreparedSpellNameSet = useMemo(() => {
const out = new Set<string>();
alwaysPreparedSpellTokens.forEach((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return;
}
out.add(parsed.name.trim().toLowerCase());
});
return out;
}, [alwaysPreparedSpellTokens]);
const classSelectableSpellPool = useMemo(() => {
if (!spellCatalog || !selectedClassDetails.length) {
return [] as SpellCatalogItem[];
}
const out = new Map<string, SpellCatalogItem>();
selectedClassDetails.forEach(({ entry, detail }) => {
const className = entry.class.name.trim().toLowerCase();
const classSource = entry.class.source.trim().toUpperCase();
const sourceAliases = new Set([classSource]);
if (classSource === "XPHB") {
sourceAliases.add("PHB");
}
if (classSource === "PHB") {
sourceAliases.add("XPHB");
}
const spellcasting = classSpellcastingByRef.get(entry.class.entityRef);
const maxClassSpellLevel = spellcasting?.maxSpellLevel ?? 0;
spellCatalog
.filter((spell) => sourceBuckets.includes(spell.sourceBucket))
.filter((spell) => {
const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className);
if (!matchingByName.length) {
return false;
}
const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim()));
if (!hasSourceData) {
return true;
}
return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase()));
})
.filter((spell) => spell.level === 0 || spell.level <= maxClassSpellLevel)
.forEach((spell) => {
const token = toNameSourceToken(spell.name, spell.source);
out.set(token, spell);
});
});
return Array.from(out.values()).filter((spell) => {
const token = toNameSourceToken(spell.name, spell.source);
return !alwaysPreparedSpellTokenSet.has(token) && !alwaysPreparedSpellNameSet.has(spell.name.trim().toLowerCase());
});
}, [spellCatalog, selectedClassDetails, sourceBuckets, classSpellcastingByRef, alwaysPreparedSpellTokenSet, alwaysPreparedSpellNameSet]);
const classSpellByToken = useMemo(() => {
const map = new Map<string, SpellCatalogItem>();
classSelectableSpellPool.forEach((spell) => {
map.set(toNameSourceToken(spell.name, spell.source), spell);
});
return map;
}, [classSelectableSpellPool]);
const selectedClassSpellPool = useMemo(() => {
const needle = spellSearch.trim().toLowerCase();
return classSelectableSpellPool
.filter((spell) => {
if (!needle) {
return true;
}
return `${spell.name} ${spell.source} ${spell.level}`.toLowerCase().includes(needle);
});
}, [classSelectableSpellPool, spellSearch]);
const selectedCantripPool = useMemo(
() => selectedClassSpellPool.filter((spell) => spell.level === 0).sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)),
[selectedClassSpellPool]
);
const selectedLeveledSpellPoolByLevel = useMemo(() => {
const groups = new Map<number, SpellCatalogItem[]>();
selectedClassSpellPool
.filter((spell) => spell.level > 0)
.forEach((spell) => {
const list = groups.get(spell.level) || [];
list.push(spell);
groups.set(spell.level, list);
});
return Array.from(groups.entries())
.sort(([a], [b]) => a - b)
.map(([level, spells]) => ({
level,
spells: [...spells].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source))
}));
}, [selectedClassSpellPool]);
const selectedSpellTokens = useMemo(
() => uniqueStrings(draftSpellSelectionsToTokens(characterDraft?.steps.spells.selected ?? [])),
[characterDraft?.steps.spells.selected]
);
const selectedSpellItems = useMemo(() => {
const map = new Map<string, SpellCatalogItem>();
(spellCatalog ?? []).forEach((spell) => map.set(spell.key, spell));
return selectedSpellTokens
.map((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return null;
}
return map.get(sanitizeFeatKey(parsed.name, parsed.source)) || null;
})
.filter((it): it is SpellCatalogItem => Boolean(it));
}, [spellCatalog, selectedSpellTokens]);
const alwaysPreparedSpellItems = useMemo(() => {
const byToken = new Map<string, SpellCatalogItem>();
(spellCatalog ?? []).forEach((spell) => {
byToken.set(toNameSourceToken(spell.name, spell.source), spell);
});
return alwaysPreparedSpellTokens
.map((token) => {
const direct = byToken.get(token);
if (direct) {
return direct;
}
const parsed = parseNameSourceToken(token);
if (!parsed) {
return null;
}
return (
(spellCatalog ?? []).find((spell) => spell.name.trim().toLowerCase() === parsed.name.trim().toLowerCase()) || {
key: `always:${token}`,
name: parsed.name,
source: parsed.source,
level: 0,
school: "",
sourceBucket: classifySourceBucket(parsed.source).sourceBucket,
sourceEdition: classifySourceBucket(parsed.source).sourceEdition,
classes: []
}
);
})
.filter((it): it is SpellCatalogItem => Boolean(it));
}, [alwaysPreparedSpellTokens, spellCatalog]);
const selectedCantripCount = selectedSpellItems.filter((it) => it.level === 0).length;
const selectedPreparedCount = selectedSpellItems.filter((it) => it.level > 0).length;
const spellSlotMatrix = useMemo(() => {
return classSpellcasting.slots.map((slot, index) => ({
id: `${slot.label}-${slot.level}-${index}`,
level: slot.level,
slots: slot.slots,
label: slot.label
}));
}, [classSpellcasting.slots]);
const getSpellHoverLinkData = useCallback((spell: SpellCatalogItem): { page: string; source: string; hash: string } | null => {
const urlUtil = window.UrlUtil;
const page = urlUtil?.PG_SPELLS || "spells.html";
let hash = "";
const hashBuilder = urlUtil?.URL_TO_HASH_BUILDER?.[page] || urlUtil?.URL_TO_HASH_BUILDER?.spell;
if (hashBuilder) {
try {
hash = `${hashBuilder({ name: spell.name, source: spell.source }) || ""}`.trim();
} catch {
hash = "";
}
}
if (!hash && urlUtil?.encodeArrayForHash) {
hash = `${urlUtil.encodeArrayForHash(spell.name, spell.source) || ""}`.trim();
}
if (!hash) {
return null;
}
return {
page,
source: spell.source,
hash
};
}, []);
const handleSpellHoverMouseEnter = useCallback(
(event: ReactMouseEvent<HTMLElement>, spell: SpellCatalogItem) => {
if (!isRenderRuntimeReady) {
return;
}
const hover = window.Renderer?.hover;
if (!hover?.pHandleLinkMouseOver) {
return;
}
const linkData = getSpellHoverLinkData(spell);
if (!linkData) {
return;
}
void hover.pHandleLinkMouseOver(event.nativeEvent, event.currentTarget, {
isSpecifiedLinkData: true,
page: linkData.page,
source: linkData.source,
hash: linkData.hash,
isAllowRedirect: true
});
},
[getSpellHoverLinkData, isRenderRuntimeReady]
);
const handleSpellHoverMouseMove = useCallback((event: ReactMouseEvent<HTMLElement>) => {
window.Renderer?.hover?.handleLinkMouseMove?.(event.nativeEvent, event.currentTarget);
}, []);
const handleSpellHoverMouseLeave = useCallback((event: ReactMouseEvent<HTMLElement>) => {
window.Renderer?.hover?.handleLinkMouseLeave?.(event.nativeEvent, event.currentTarget);
}, []);
useEffect(() => {
if (!characterDraft) {
return;
}
const requiredCantrips = classSpellcasting.cantrips || 0;
const requiredPrepared = getLeveledSpellSelectionCap(classSpellcasting);
const current = uniqueStrings(
draftSpellSelectionsToTokens(characterDraft.steps.spells.selected)
);
const next: string[] = [];
let cantripCount = 0;
let preparedCount = 0;
current.forEach((token) => {
const spell = classSpellByToken.get(token);
if (!spell) {
return;
}
if (spell.level === 0) {
if (requiredCantrips <= 0 || cantripCount >= requiredCantrips) {
return;
}
cantripCount += 1;
next.push(token);
return;
}
if (requiredPrepared <= 0 || preparedCount >= requiredPrepared) {
return;
}
preparedCount += 1;
next.push(token);
});
if (areStringArraysEqual(current, next)) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
spells: {
...draft.steps.spells,
selected: next
}
}
}));
}, [
characterDraft?.id,
characterDraft?.steps.spells.selected,
classSpellByToken,
classSpellcasting.cantrips,
classSpellcasting.known,
classSpellcasting.prepared,
updateDraft
]);
const classEquipmentGroups = useMemo(
() => parseStartingEquipmentChoices(selectedClassDetail, "Class"),
[selectedClassDetail?.entityRef, selectedClassDetail?.rawJson]
);
const backgroundEquipmentGroups = useMemo(
() => parseStartingEquipmentChoices(selectedBackgroundDetail, "Background"),
[selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson]
);
const equipmentChoiceGroups = useMemo(
() => [...classEquipmentGroups, ...backgroundEquipmentGroups],
[classEquipmentGroups, backgroundEquipmentGroups]
);
const equipmentSelectionState = useMemo(
() =>
parseEquipmentSelectionState(
characterDraft?.steps.equipment.startingEquipmentText ?? "",
characterDraft?.steps.equipment.inventoryText ?? ""
),
[characterDraft?.steps.equipment.startingEquipmentText, characterDraft?.steps.equipment.inventoryText]
);
const normalizedEquipmentSelections = useMemo(() => {
const out: Record<string, string> = {};
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<string, number>();
const sourceLabels = [
...resolvedEquipmentGrants.map((grant) => grant.label).filter((label) => !/gp$/i.test(label)),
...equipmentSelectionState.addedItems
];
sourceLabels.forEach((rawLabel) => {
const parsed = parseInventoryLabel(rawLabel);
if (!parsed.label) {
return;
}
counts.set(parsed.label, (counts.get(parsed.label) || 0) + parsed.count);
});
return Array.from(counts.entries())
.map(([label, count]) => ({ label, count }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [resolvedEquipmentGrants, equipmentSelectionState.addedItems]);
const resolvedEquipmentCurrency = useMemo(
() => cpToCurrency(resolvedEquipmentGrants.reduce((total, grant) => total + grant.cpValue, 0)),
[resolvedEquipmentGrants]
);
const backgroundAbilityModes = useMemo(
() => getBackgroundAbilityModes(selectedBackgroundDetail),
[selectedBackgroundDetail?.rawJson, selectedBackgroundDetail?.entityRef]
);
const activeBackgroundAbilityMode = useMemo(() => {
if (!characterDraft || !backgroundAbilityModes.length) {
return null;
}
const safeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, characterDraft.steps.abilityScores.backgroundBonusModeIndex));
return backgroundAbilityModes[safeIndex] ?? null;
}, [backgroundAbilityModes, characterDraft?.steps.abilityScores.backgroundBonusModeIndex, characterDraft]);
const normalizedBackgroundBonusSelections = useMemo(() => {
if (!characterDraft) {
return [];
}
return normalizeBackgroundBonusSelections(activeBackgroundAbilityMode, characterDraft.steps.abilityScores.backgroundBonusSelections);
}, [activeBackgroundAbilityMode, characterDraft?.steps.abilityScores.backgroundBonusSelections, characterDraft]);
const backgroundBonusTotals = useMemo(
() => getBackgroundBonusTotals(activeBackgroundAbilityMode, normalizedBackgroundBonusSelections),
[activeBackgroundAbilityMode, normalizedBackgroundBonusSelections]
);
const abilityTotals = useMemo(() => {
if (!characterDraft) {
return null;
}
return getScoresWithBonuses(characterDraft.steps.abilityScores.scores, backgroundBonusTotals);
}, [characterDraft?.steps.abilityScores.scores, backgroundBonusTotals, characterDraft]);
useEffect(() => {
if (!characterDraft) {
return;
}
const current = characterDraft.steps.abilityScores;
if (!backgroundAbilityModes.length) {
if (current.backgroundBonusModeIndex === 0 && current.backgroundBonusSelections.length === 0) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: 0,
backgroundBonusSelections: []
}
}
}));
return;
}
const clampedModeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, current.backgroundBonusModeIndex));
const mode = backgroundAbilityModes[clampedModeIndex] ?? null;
const normalizedSelections = normalizeBackgroundBonusSelections(mode, current.backgroundBonusSelections);
const hasModeChanged = clampedModeIndex !== current.backgroundBonusModeIndex;
const hasSelectionsChanged = !areAbilityArraysEqual(normalizedSelections, current.backgroundBonusSelections);
if (!hasModeChanged && !hasSelectionsChanged) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: clampedModeIndex,
backgroundBonusSelections: normalizedSelections
}
}
}));
}, [
backgroundAbilityModes,
characterDraft?.id,
characterDraft?.steps.abilityScores.backgroundBonusModeIndex,
characterDraft?.steps.abilityScores.backgroundBonusSelections,
updateDraft
]);
useEffect(() => {
if (!characterDraft) {
return;
}
const desiredNotes =
skillChoiceRequirements.length > 0 || manualSkillSelections.length > 0
? stringifySkillsNotes({
text: skillNotesState.text,
picksByChoiceId: normalizedSkillChoicePicks,
extraSkills: manualSkillSelections
})
: skillNotesState.text;
const currentSelected = [...characterDraft.steps.skills.selected].sort((a, b) => a.localeCompare(b));
const desiredSelected = [...allProficientSkills].sort((a, b) => a.localeCompare(b));
const isSelectedSame = areStringArraysEqual(currentSelected, desiredSelected);
const isNotesSame = characterDraft.steps.skills.notes === desiredNotes;
if (isSelectedSame && isNotesSame) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
skills: {
...draft.steps.skills,
selected: desiredSelected,
notes: desiredNotes
}
}
}));
}, [
allProficientSkills,
characterDraft?.id,
characterDraft?.steps.skills.notes,
characterDraft?.steps.skills.selected,
normalizedSkillChoicePicks,
manualSkillSelections,
skillChoiceRequirements.length,
skillNotesState.text,
updateDraft
]);
useEffect(() => {
if (!characterDraft) {
return;
}
const desired = uniqueStrings([...fixedFeatTokens, ...variableFeatSelections]).sort((a, b) => a.localeCompare(b));
const current = [...normalizedFeatSelections].sort((a, b) => a.localeCompare(b));
if (areStringArraysEqual(current, desired)) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
feats: {
...draft.steps.feats,
selected: desired
}
}
}));
}, [characterDraft?.id, fixedFeatTokens, normalizedFeatSelections, updateDraft, variableFeatSelections]);
useEffect(() => {
if (!characterDraft || !equipmentChoiceGroups.length) {
return;
}
const desiredState: EquipmentSelectionState = {
selections: normalizedEquipmentSelections,
addedItems: equipmentSelectionState.addedItems
};
const desiredStartingEquipment = stringifyEquipmentSelectionState(desiredState);
const desiredInventory = desiredState.addedItems.join("\n");
const currentCurrency = characterDraft.steps.equipment.currency;
const desiredCurrency = resolvedEquipmentCurrency;
const isCurrencySame =
currentCurrency.cp === desiredCurrency.cp &&
currentCurrency.sp === desiredCurrency.sp &&
currentCurrency.ep === desiredCurrency.ep &&
currentCurrency.gp === desiredCurrency.gp &&
currentCurrency.pp === desiredCurrency.pp;
if (
characterDraft.steps.equipment.startingEquipmentText === desiredStartingEquipment &&
characterDraft.steps.equipment.inventoryText === desiredInventory &&
isCurrencySame
) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
startingEquipmentText: desiredStartingEquipment,
inventoryText: desiredInventory,
currency: desiredCurrency
}
}
}));
}, [
characterDraft?.id,
characterDraft?.steps.equipment.currency,
characterDraft?.steps.equipment.inventoryText,
characterDraft?.steps.equipment.startingEquipmentText,
equipmentChoiceGroups.length,
equipmentSelectionState.addedItems,
normalizedEquipmentSelections,
resolvedEquipmentCurrency,
updateDraft
]);
const isStepComplete = useCallback((stepIndex: number, draft: CharacterDocument | null): boolean => {
if (!draft) {
return false;
}
if (stepIndex === 0) {
return getDraftClassSelections(draft).length > 0 && draft.steps.preferences.sourceBuckets.length > 0;
}
if (stepIndex === 1) {
return Boolean(draft.selections.species);
}
if (stepIndex === 2) {
return Boolean(draft.selections.background);
}
if (stepIndex === 3) {
const { method, scores, assignments } = draft.steps.abilityScores;
let isBaseValid = true;
if (method === "pointBuy") {
const valuesOk = ABILITY_KEYS.every((key) => scores[key] >= 8 && scores[key] <= 15);
isBaseValid = valuesOk && getPointBuySpent(scores) <= 27;
}
if (method === "manual" && isBaseValid) {
isBaseValid = ABILITY_KEYS.every((key) => Number.isInteger(scores[key]) && scores[key] >= 1 && scores[key] <= 30);
}
if ((method === "standardArray" || method === "rolled") && isBaseValid) {
const assignmentValues = ABILITY_KEYS.map((key) => assignments[key]);
const unique = new Set(assignmentValues);
isBaseValid = assignmentValues.every((it) => Number.isInteger(it) && it >= 0 && it <= 5) && unique.size === 6;
}
if (!isBaseValid) {
return false;
}
const selectedBackgroundRef = draft.selections.background?.entityRef;
if (!selectedBackgroundRef) {
return true;
}
const backgroundDetail = detailCache[selectedBackgroundRef] ?? null;
const modes = getBackgroundAbilityModes(backgroundDetail);
if (!modes.length) {
return true;
}
const safeModeIndex = Math.max(0, Math.min(modes.length - 1, draft.steps.abilityScores.backgroundBonusModeIndex));
const mode = modes[safeModeIndex] ?? null;
const selections = normalizeBackgroundBonusSelections(mode, draft.steps.abilityScores.backgroundBonusSelections);
return mode ? selections.length === mode.weights.length : true;
}
if (stepIndex === 4) {
const classSelections = getDraftClassSelections(draft);
const classSlotCount = classSelections.reduce((sum, classSelection) => {
const classDetail = detailCache[classSelection.class.entityRef] ?? null;
return sum + getClassFeatSlotCount(classDetail, classSelection.level);
}, 0);
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null;
if (classSlotCount <= 0) {
return true;
}
const fixedFeatKeySet = new Set(
uniqueStrings([...getFixedFeatUids(backgroundDetail), ...getFixedFeatUids(speciesDetail)])
.map((token) => parseNameSourceToken(token))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => sanitizeFeatKey(it.name, it.source))
);
const variableCount = uniqueStrings(
draft.steps.feats.selected
.map((token) => parseNameSourceToken(token) || parseFeatUid(token))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => sanitizeFeatKey(it.name, it.source))
).filter((key) => !fixedFeatKeySet.has(key)).length;
return variableCount >= classSlotCount;
}
if (stepIndex === 5) {
const classSelections = getDraftClassSelections(draft);
const primaryClass = classSelections[0];
const primaryClassDetail = primaryClass ? detailCache[primaryClass.class.entityRef] ?? null : null;
const secondaryRequirements = classSelections
.slice(1)
.flatMap((entry) => {
const classDetail = detailCache[entry.class.entityRef] ?? null;
return parseMulticlassClassSkills(classDetail).choices;
});
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null;
const requirements = [
...parseClassSkills(primaryClassDetail).choices,
...secondaryRequirements,
...parseBackgroundSkills(backgroundDetail).choices,
...parseSpeciesSkills(speciesDetail).choices
];
if (!requirements.length) {
return true;
}
const notesState = parseSkillsNotes(draft.steps.skills.notes);
return requirements.every((choice) => {
const picks = uniqueStrings((notesState.picksByChoiceId[choice.id] || []).filter((it) => choice.options.includes(it)));
return picks.length >= choice.count;
});
}
if (stepIndex === 6) {
const classSelections = getDraftClassSelections(draft);
const classDetails = classSelections.map((entry) => ({
entry,
classDetail: detailCache[entry.class.entityRef] ?? null,
subclassDetail: entry.subclass ? detailCache[entry.subclass.entityRef] ?? null : null
}));
const classSpellcasts = classDetails.map(({ entry, classDetail }) => {
const spellcastingAbilityKey = getSpellcastingAbilityKey(classDetail);
const spellcastingAbilityModifier =
spellcastingAbilityKey != null ? getAbilityModifierNumber(draft.steps.abilityScores.scores[spellcastingAbilityKey] ?? 10) : 0;
const spellcasting = parseClassSpellcasting(classDetail, entry.level, spellcastingAbilityModifier);
return { entry, classDetail, spellcasting };
});
const requiredCantrips = classSpellcasts.reduce((sum, it) => sum + it.spellcasting.cantrips, 0);
const requiredPrepared = classSpellcasts.reduce((sum, it) => sum + getLeveledSpellSelectionCap(it.spellcasting), 0);
if (requiredCantrips <= 0 && requiredPrepared <= 0) {
return true;
}
if (!spellCatalog?.length) {
return draft.steps.spells.selected.length > 0;
}
if (!classSelections.length) {
return false;
}
const allowedBuckets = new Set(draft.steps.preferences.sourceBuckets);
const alwaysPreparedTokens = new Set<string>([
...classDetails.flatMap(({ entry, classDetail, subclassDetail }) => [
...getAlwaysPreparedSpellTokens(classDetail, entry.level),
...getAlwaysPreparedSpellTokens(subclassDetail, entry.level)
])
]);
const alwaysPreparedNames = new Set<string>();
alwaysPreparedTokens.forEach((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return;
}
alwaysPreparedNames.add(parsed.name.trim().toLowerCase());
});
const spellByToken = new Map<string, SpellCatalogItem>();
spellCatalog.forEach((spell) => {
spellByToken.set(toNameSourceToken(spell.name, spell.source), spell);
});
let cantripCount = 0;
let preparedCount = 0;
let hasInvalidSelection = false;
uniqueStrings(draftSpellSelectionsToTokens(draft.steps.spells.selected)).forEach((token) => {
if (hasInvalidSelection) {
return;
}
const tokenName = parseNameSourceToken(token)?.name.trim().toLowerCase() || "";
if (alwaysPreparedTokens.has(token) || (tokenName && alwaysPreparedNames.has(tokenName))) {
return;
}
const spell = spellByToken.get(token);
if (!spell) {
hasInvalidSelection = true;
return;
}
if (!allowedBuckets.has(spell.sourceBucket)) {
hasInvalidSelection = true;
return;
}
const classCanSelectSpell = classSpellcasts.some(({ entry, spellcasting }) => {
if (spell.level > 0 && spell.level > spellcasting.maxSpellLevel) {
return false;
}
const className = entry.class.name.trim().toLowerCase();
const classSource = entry.class.source.trim().toUpperCase();
const sourceAliases = new Set([classSource]);
if (classSource === "XPHB") {
sourceAliases.add("PHB");
}
if (classSource === "PHB") {
sourceAliases.add("XPHB");
}
const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className);
if (!matchingByName.length) {
return false;
}
const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim()));
if (!hasSourceData) {
return true;
}
return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase()));
});
if (!classCanSelectSpell) {
hasInvalidSelection = true;
return;
}
if (spell.level === 0) {
cantripCount += 1;
} else {
preparedCount += 1;
}
});
if (hasInvalidSelection) {
return false;
}
return (
cantripCount >= requiredCantrips
&& preparedCount >= requiredPrepared
&& cantripCount <= requiredCantrips
&& preparedCount <= requiredPrepared
);
}
if (stepIndex === 7) {
const primaryClass = getDraftClassSelections(draft)[0];
const classDetail = primaryClass ? detailCache[primaryClass.class.entityRef] ?? null : null;
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
const groups = [...parseStartingEquipmentChoices(classDetail, "Class"), ...parseStartingEquipmentChoices(backgroundDetail, "Background")];
if (!groups.length) {
return true;
}
const selections = parseEquipmentSelectionState(draft.steps.equipment.startingEquipmentText, draft.steps.equipment.inventoryText).selections;
return groups.every((group) => {
const selectedId = selections[group.id];
return group.options.some((option) => option.id === selectedId);
});
}
if (stepIndex === 8) {
return Boolean(draft.profile.name.trim()) && Boolean(draft.steps.about.backstory.trim() || draft.steps.about.notes.trim());
}
if (stepIndex === CHARACTER_STEPS.length - 1) {
return true;
}
return false;
}, [detailCache, spellCatalog]);
const stepCompletion = useMemo(
() => CHARACTER_STEPS.map((_, index) => isStepComplete(index, characterDraft)),
[characterDraft, isStepComplete]
);
const currentStepLabel = CHARACTER_STEPS[characterStep] ?? CHARACTER_STEPS[0];
const progressionSteps = CHARACTER_STEPS.slice(0, CHARACTER_STEPS.length - 1);
const canMoveNext = useMemo(() => {
if (!characterDraft) {
return false;
}
if (characterStep >= CHARACTER_STEPS.length - 1) {
return false;
}
if (characterStep === CHARACTER_STEPS.length - 2) {
return true;
}
return isStepComplete(characterStep, characterDraft);
}, [characterDraft, characterStep, isStepComplete]);
const nextStep = useCallback(() => {
setCharacterStep((current) => Math.min(CHARACTER_STEPS.length - 1, current + 1));
}, []);
const prevStep = useCallback(() => {
setCharacterStep((current) => Math.max(0, current - 1));
}, []);
const renderEntityDetailContent = useCallback(
(detail: CharacterSelectionDetail | null, kind: EntityPopoverKind) => {
if (!detail) {
return <p className="character-empty">Loading details...</p>;
}
const classSelectionForDetail =
kind === "class"
? getDraftClassSelections(characterDraft).find((entry) => entry.class.entityRef === detail.entityRef) || null
: null;
return (
<div className="entity-detail entity-detail--popover">
<h4>{detail.name}</h4>
<p className="entity-detail-meta">
{detail.sourceName} ({detail.source}) • {detail.sourceBucket}
</p>
{detail.summary ? (
<ParsedEntryText text={detail.summary} isParserReady={isRenderRuntimeReady} className="entity-detail-text" />
) : null}
{detail.featureCards.length ? (
<div className="entity-feature-list">
{detail.featureCards.map((feature) => (
<article key={feature.id} className="entity-feature-card">
<header>
<strong>{feature.title}</strong>
{feature.level != null ? <span>Level {feature.level}</span> : null}
</header>
{feature.summary ? (
<ParsedEntryText text={feature.summary} isParserReady={isRenderRuntimeReady} className="entity-feature-text" />
) : null}
{kind === "class" && feature.options?.length ? (
<label className="character-field">
<span>Choose an option</span>
<select
value={classSelectionForDetail?.subclass?.entityRef ?? ""}
onChange={(event) => {
const next = feature.options?.find((opt) => opt.entityRef === event.target.value) || null;
updateDraft((draft) => {
const classes = getDraftClassSelections(draft);
const nextClasses = classes.map((entry) =>
entry.class.entityRef === detail.entityRef
? {
...entry,
subclass: next ? toSubclassEntityRef(next) : null
}
: entry
);
return withDraftClassSelections(draft, nextClasses);
});
}}
>
<option value="">Select...</option>
{feature.options.map((opt) => (
<option key={opt.entityRef} value={opt.entityRef}>
{opt.name} ({opt.source})
</option>
))}
</select>
</label>
) : null}
</article>
))}
</div>
) : null}
<details className="entity-json">
<summary>View JSON</summary>
<pre>{detail.rawJson}</pre>
</details>
</div>
);
},
[characterDraft, isRenderRuntimeReady, updateDraft]
);
useEffect(() => {
if (characterModalMode === "closed") {
return;
}
const onDocumentClickCapture = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
if (!anchor) {
return;
}
const isVetLink = anchor.hasAttribute("data-vet-page") && anchor.hasAttribute("data-vet-hash");
if (isVetLink) {
// Let native 5etools link handlers drive hover/popover behavior.
return;
}
const href = anchor.getAttribute("href");
if (!href) {
return;
}
const wikiPath = resolveWikiPathFromHref(href);
if (!wikiPath) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
setCurrentPath(wikiPath);
setCharacterModalMode("closed");
};
document.addEventListener("click", onDocumentClickCapture, true);
return () => document.removeEventListener("click", onDocumentClickCapture, true);
}, [characterModalMode, resolveWikiPathFromHref]);
if (error) {
return (
<main className="status-wrap">
<section className="status-panel">
<h1>5etools Desktop failed to start</h1>
<p>{error}</p>
</section>
</main>
);
}
if (!wikiUrl) {
return (
<main className="status-wrap">
<section className="status-panel">
<h1>Starting 5etools Desktop</h1>
<p>Loading local 5etools wiki...</p>
</section>
</main>
);
}
return (
<main className="shell" aria-label="5etools Desktop Wiki Shell">
<header className="app-topbar" ref={topbarRef}>
<div className="app-topbar-brand">
<p className="app-topbar-kicker">5etools Desktop</p>
<h1 className="app-topbar-title">Compendium</h1>
</div>
<nav className="app-topbar-nav" aria-label="5etools Top Navigation">
{topbarGroups.map((group) => {
const isOpen = openTopMenuId === group.id;
const isActive = group.kind === "link" && group.href ? isSameAppPath(group.href, currentPathname) : false;
return (
<div key={group.id} className={isOpen ? "topbar-menu is-open" : "topbar-menu"}>
<button
type="button"
className={isActive ? "topbar-menu-toggle is-active" : "topbar-menu-toggle"}
onClick={() => activateTopbarGroup(group)}
aria-expanded={group.kind === "dropdown" ? isOpen : undefined}
>
<span>{group.label}</span>
{group.kind === "dropdown" ? <span className="topbar-caret" aria-hidden="true">▾</span> : null}
</button>
{group.kind === "dropdown" && isOpen ? (
<div className="topbar-dropdown-menu" role="menu">
{renderTopbarDropdown(group.items ?? [])}
</div>
) : null}
</div>
);
})}
<div className={openTopMenuId === APP_CHARACTERS_MENU_ID ? "topbar-menu is-open" : "topbar-menu"}>
<button
type="button"
className={characterModalMode !== "closed" ? "topbar-menu-toggle is-active" : "topbar-menu-toggle"}
onClick={() => {
setOpenTopMenuId((current) => (current === APP_CHARACTERS_MENU_ID ? null : APP_CHARACTERS_MENU_ID));
setOpenTopMenuPath([]);
}}
aria-expanded={openTopMenuId === APP_CHARACTERS_MENU_ID}
>
<span>Characters</span>
<span className="topbar-caret" aria-hidden="true">▾</span>
</button>
{openTopMenuId === APP_CHARACTERS_MENU_ID ? (
<div className="topbar-dropdown-menu" role="menu">
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void openNewCharacter(); }}>
New
</button>
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void loadCharacterFromJson(); }}>
Load from JSON
</button>
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void openCharacterList(); }}>
My Characters
</button>
</div>
) : null}
</div>
</nav>
<form className="app-topbar-search" onSubmit={onSubmitTopbarSearch}>
<input
type="search"
value={topbarSearch}
onChange={(event) => setTopbarSearch(event.target.value)}
placeholder="Search everywhere..."
/>
</form>
</header>
<section className="workspace">
{characterModalMode === "closed" ? (
<section className="frame-shell frame-shell--full">
<iframe ref={frameRef} className="wiki-frame" src={frameSrc} title="5etools Wiki" />
</section>
) : (
<section className="frame-shell frame-shell--full character-shell" role="region" aria-label="Character Creator">
<div className={characterModalMode === "editor" ? "character-modal is-editor" : "character-modal"}>
<header className="character-modal-header">
<h2>Character Creator</h2>
<div className="character-modal-actions">
<button type="button" onClick={() => void openCharacterList()} disabled={isCharacterBusy}>
My Characters
</button>
<button type="button" onClick={() => void openNewCharacter()} disabled={isCharacterBusy}>
New
</button>
<button type="button" onClick={() => void loadCharacterFromJson()} disabled={isCharacterBusy}>
Load JSON
</button>
<button type="button" className="is-subtle" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
Back to Wiki
</button>
</div>
</header>
{characterError ? <p className="character-alert character-alert--error">{characterError}</p> : null}
{characterNotice ? <p className="character-alert character-alert--info">{characterNotice}</p> : null}
{characterModalMode === "list" ? (
<div className="character-list-wrap">
<div className="character-list-header">
<h3>Saved Characters</h3>
<button type="button" onClick={() => void refreshCharacterSummaries()} disabled={isCharacterBusy}>
Refresh
</button>
</div>
{characterSummaries.length === 0 ? (
<p className="character-empty">No characters saved yet.</p>
) : (
<ul className="character-list">
{characterSummaries.map((summary) => (
<li key={summary.id} className="character-list-item">
<div>
<p className="character-list-title">{summary.name}</p>
<p className="character-list-meta">
Level {summary.level} • {summary.className || "No class"} • {summary.backgroundName || "No background"} •{" "}
{summary.speciesName || "No species"}
</p>
<p className="character-list-time">Updated {formatIso(summary.updatedAt)}</p>
</div>
<div className="character-list-actions">
<button type="button" onClick={() => void openCharacterById(summary.id)} disabled={isCharacterBusy}>
Open
</button>
<button type="button" className="is-danger" onClick={() => void deleteCharacterById(summary.id)} disabled={isCharacterBusy}>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
) : (
<div className="character-editor-wrap">
<header className="character-builder-header">
<div className="character-builder-step">
<h3>{currentStepLabel}</h3>
<span className={stepCompletion[characterStep] ? "character-builder-step-state is-complete" : "character-builder-step-state is-pending"}>
{stepCompletion[characterStep] ? "Complete" : "Needs Choices"}
</span>
</div>
<div className="character-builder-actions">
<button
type="button"
className="is-subtle"
onClick={() => setCharacterStep(CHARACTER_STEPS.length - 1)}
disabled={!characterDraft || isCharacterBusy}
>
Review Character
</button>
<button type="button" className="is-subtle" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
Compendium
</button>
<button type="button" className="is-danger" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
Exit
</button>
</div>
</header>
{characterDraft ? (
<div className="character-step-content">
{characterStep === 0 ? (
<section className="character-section">
<h3>Step 1: Class</h3>
<div className="character-grid">
<label className="character-field">
<span>Name</span>
<input
type="text"
value={characterDraft.profile.name}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, name: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Total Level</span>
<input
type="number"
min={1}
max={20}
value={characterDraft.profile.level}
readOnly
/>
</label>
<label className="character-field">
<span>Advancement Type</span>
<select
value={characterDraft.steps.preferences.advancementType}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
advancementType: event.target.value === "xp" ? "xp" : "milestone"
}
}
}))
}
>
<option value="milestone">Milestone</option>
<option value="xp">XP</option>
</select>
</label>
<label className="character-field">
<span>Hit Point Type</span>
<select
value={characterDraft.steps.preferences.hpMode}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
hpMode:
event.target.value === "maximum" || event.target.value === "rolled"
? event.target.value
: "average"
}
}
}))
}
>
<option value="average">Average</option>
<option value="maximum">Maximum</option>
<option value="rolled">Rolled</option>
</select>
</label>
</div>
<div className="character-source-grid">
{SOURCE_BUCKET_OPTIONS.map((bucket) => {
const isChecked = characterDraft.steps.preferences.sourceBuckets.includes(bucket.key);
return (
<label key={bucket.key} className="character-checkbox">
<input
type="checkbox"
checked={isChecked}
onChange={(event) =>
updateDraft((draft) => {
const current = draft.steps.preferences.sourceBuckets;
const next = event.target.checked
? Array.from(new Set([...current, bucket.key]))
: current.filter((it) => it !== bucket.key);
return {
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
sourceBuckets: next
}
}
};
})
}
/>
<span>{bucket.label}</span>
</label>
);
})}
</div>
<div className="character-toggle-grid">
<label className="character-checkbox">
<input
type="checkbox"
checked={characterDraft.steps.preferences.useOptionalClassFeatures}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
useOptionalClassFeatures: event.target.checked
}
}
}))
}
/>
<span>Optional Class Features</span>
</label>
<label className="character-checkbox">
<input
type="checkbox"
checked={characterDraft.steps.preferences.customizeOrigin}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
customizeOrigin: event.target.checked
}
}
}))
}
/>
<span>Customize Origin</span>
</label>
<label className="character-checkbox">
<input
type="checkbox"
checked={characterDraft.steps.preferences.usePrerequisitesFeats}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
usePrerequisitesFeats: event.target.checked
}
}
}))
}
/>
<span>Use Feat Prerequisites</span>
</label>
<label className="character-checkbox">
<input
type="checkbox"
checked={characterDraft.steps.preferences.usePrerequisitesMulticlass}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
usePrerequisitesMulticlass: event.target.checked
}
}
}))
}
/>
<span>Use Multiclass Prerequisites</span>
</label>
</div>
<div className="character-class-list">
<h4>Selected Classes</h4>
{selectedClassEntries.length ? (
<div className="character-class-list-grid">
{selectedClassEntries.map((entry, index) => {
const classDetail = detailCache[entry.class.entityRef] ?? null;
const subclassFeature = classDetail?.featureCards.find((feature) => feature.options?.length);
const canDecrease = entry.level > 1;
const canIncrease = characterDraft.profile.level < 20;
const isPrimaryClass = index === 0;
return (
<article key={entry.class.entityRef} className="character-class-chip">
<header>
<strong>{entry.class.name}</strong>
<span className="character-class-chip-badges">
<span>{entry.class.source}</span>
<span className={isPrimaryClass ? "character-class-chip-role is-primary" : "character-class-chip-role"}>
{isPrimaryClass ? "Primary" : "Multiclass"}
</span>
</span>
</header>
<p className="character-class-chip-note">
{isPrimaryClass
? "Primary class: gains full class and starting proficiency package."
: "Multiclass: gains class features by level, plus multiclass proficiency gains only."}
</p>
{subclassFeature?.options?.length ? (
<label className="character-field">
<span>{subclassFeature.title}</span>
<select
value={entry.subclass?.entityRef ?? ""}
onChange={(event) => {
const nextSubclass =
subclassFeature.options?.find((opt) => opt.entityRef === event.target.value) || null;
updateDraft((draft) => {
const currentClasses = getDraftClassSelections(draft);
const nextClasses = currentClasses.map((currentEntry) =>
currentEntry.class.entityRef === entry.class.entityRef
? {
...currentEntry,
subclass: nextSubclass ? toSubclassEntityRef(nextSubclass) : null
}
: currentEntry
);
return withDraftClassSelections(draft, nextClasses);
});
}}
>
<option value="">Select...</option>
{subclassFeature.options.map((opt) => (
<option key={opt.entityRef} value={opt.entityRef}>
{opt.name} ({opt.source})
</option>
))}
</select>
</label>
) : null}
<div className="character-class-chip-actions">
<button
type="button"
onClick={() =>
updateDraft((draft) => {
const next = getDraftClassSelections(draft).map((currentEntry) =>
currentEntry.class.entityRef === entry.class.entityRef
? { ...currentEntry, level: Math.max(1, currentEntry.level - 1) }
: currentEntry
);
return withDraftClassSelections(draft, next);
})
}
disabled={!canDecrease}
>
-
</button>
<span>Level {entry.level}</span>
<button
type="button"
onClick={() =>
updateDraft((draft) => {
const next = getDraftClassSelections(draft).map((currentEntry) =>
currentEntry.class.entityRef === entry.class.entityRef
? { ...currentEntry, level: Math.min(20, currentEntry.level + 1) }
: currentEntry
);
return withDraftClassSelections(draft, next);
})
}
disabled={!canIncrease}
>
+
</button>
<button
type="button"
className="is-danger"
onClick={() =>
updateDraft((draft) => {
const next = getDraftClassSelections(draft).filter(
(currentEntry) => currentEntry.class.entityRef !== entry.class.entityRef
);
return withDraftClassSelections(draft, next);
})
}
>
Remove
</button>
<button
type="button"
className="is-subtle"
onClick={() => {
setPendingEntitySelection(null);
openEntityPopover("class", entry.class.entityRef);
}}
>
View
</button>
</div>
</article>
);
})}
</div>
) : (
<p className="character-empty">Add at least one class to continue.</p>
)}
</div>
<label className="character-field">
<span>Search classes</span>
<input type="search" value={classSearch} onChange={(event) => setClassSearch(event.target.value)} />
</label>
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{filteredClassOptions.map((option) => {
const isSelected = selectedClassRefSet.has(option.entityRef);
const eligibility = classEligibilityByRef.get(option.entityRef) || { disabled: false, reason: null };
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
const hoverTitle =
eligibility.reason ||
(selectedClassEntries.length
? "View class details and add as multiclass"
: "View class details and add as primary class");
return (
<button
type="button"
key={option.entityRef}
className={[
"entity-tile",
isSelected ? "is-selected" : "",
eligibility.disabled ? "is-disabled" : ""
]
.filter(Boolean)
.join(" ")}
onClick={() => {
if (eligibility.disabled) {
return;
}
setPendingEntitySelection({ kind: "class", option });
openEntityPopover("class", option.entityRef);
}}
aria-disabled={eligibility.disabled}
title={hoverTitle}
>
<span className="entity-tile-media">
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
<span className="entity-tile-titlebar">
<span className="entity-tile-label">{option.name}</span>
<span className="entity-tile-meta">{option.source}</span>
</span>
</span>
</button>
);
})}
</div>
{filteredClassOptions.length === 0 ? <p className="character-empty">No classes match the selected source buckets.</p> : null}
<div className="entity-list-actions">
<button
type="button"
className="is-subtle"
onClick={() => {
if (!selectedPrimaryClassEntry) {
return;
}
setPendingEntitySelection(null);
openEntityPopover("class", selectedPrimaryClassEntry.class.entityRef);
}}
disabled={!selectedPrimaryClassEntry}
>
{selectedPrimaryClassEntry ? `View ${selectedPrimaryClassEntry.class.name} details` : "Select a class"}
</button>
</div>
</div>
</div>
</section>
) : null}
{characterStep === 1 ? (
<section className="character-section">
<h3>Step 2: Species</h3>
<label className="character-field">
<span>Search species</span>
<input type="search" value={speciesSearch} onChange={(event) => setSpeciesSearch(event.target.value)} />
</label>
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{filteredSpeciesOptions.map((option) => {
const isSelected = characterDraft.selections.species?.entityRef === option.entityRef;
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
return (
<button
type="button"
key={option.entityRef}
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
onClick={() => {
setPendingEntitySelection({ kind: "species", option });
openEntityPopover("species", option.entityRef);
}}
>
<span className="entity-tile-media">
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
<span className="entity-tile-titlebar">
<span className="entity-tile-label">{option.name}</span>
<span className="entity-tile-meta">{option.source}</span>
</span>
</span>
</button>
);
})}
</div>
{filteredSpeciesOptions.length === 0 ? <p className="character-empty">No species match the selected source buckets.</p> : null}
<div className="entity-list-actions">
<button
type="button"
className="is-subtle"
onClick={() => {
if (!characterDraft.selections.species) {
return;
}
setPendingEntitySelection(null);
openEntityPopover("species", characterDraft.selections.species.entityRef);
}}
disabled={!characterDraft.selections.species}
>
{characterDraft.selections.species
? `View ${characterDraft.selections.species.name} details`
: "Select a species"}
</button>
</div>
</div>
</div>
</section>
) : null}
{characterStep === 2 ? (
<section className="character-section">
<h3>Step 3: Background</h3>
<label className="character-field">
<span>Search backgrounds</span>
<input type="search" value={backgroundSearch} onChange={(event) => setBackgroundSearch(event.target.value)} />
</label>
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{filteredBackgroundOptions.map((option) => {
const isSelected = characterDraft.selections.background?.entityRef === option.entityRef;
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
return (
<button
type="button"
key={option.entityRef}
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
onClick={() => {
setPendingEntitySelection({ kind: "background", option });
openEntityPopover("background", option.entityRef);
}}
>
<span className="entity-tile-media">
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
<span className="entity-tile-titlebar">
<span className="entity-tile-label">{option.name}</span>
<span className="entity-tile-meta">{option.source}</span>
</span>
</span>
</button>
);
})}
</div>
{filteredBackgroundOptions.length === 0 ? (
<p className="character-empty">No backgrounds match the selected source buckets.</p>
) : null}
<div className="entity-list-actions">
<button
type="button"
className="is-subtle"
onClick={() => {
if (!characterDraft.selections.background) {
return;
}
setPendingEntitySelection(null);
openEntityPopover("background", characterDraft.selections.background.entityRef);
}}
disabled={!characterDraft.selections.background}
>
{characterDraft.selections.background
? `View ${characterDraft.selections.background.name} details`
: "Select a background"}
</button>
</div>
</div>
</div>
</section>
) : null}
{characterStep === 3 ? (
<section className="character-section character-section--ability">
<h3>Step 4: Ability Scores</h3>
<div className="ability-method-row">
{([
["standardArray", "Standard"],
["pointBuy", "Point Buy"],
["rolled", "Roll"],
["manual", "Manual"]
] as const).map(([method, label]) => (
<button
key={method}
type="button"
className={characterDraft.steps.abilityScores.method === method ? "is-active" : ""}
onClick={() =>
updateDraft((draft) => {
if (method === "standardArray") {
const pool = [...STANDARD_ARRAY] as [number, number, number, number, number, number];
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method,
rolledValues: pool,
assignments: {
str: 0,
dex: 1,
con: 2,
int: 3,
wis: 4,
cha: 5
},
scores: {
str: 15,
dex: 14,
con: 13,
int: 12,
wis: 10,
cha: 8
}
}
}
};
}
if (method === "rolled") {
const pool = draft.steps.abilityScores.rolledValues;
const assignments = draft.steps.abilityScores.assignments;
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method,
rolledValues: pool,
assignments,
scores: getScoresFromAssignments(pool, assignments)
}
}
};
}
if (method === "pointBuy") {
const normalized: Record<AbilityKey, number> = {
str: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.str)),
dex: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.dex)),
con: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.con)),
int: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.int)),
wis: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.wis)),
cha: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.cha))
};
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method,
scores: normalized
}
}
};
}
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method
}
}
};
})
}
>
{label}
</button>
))}
</div>
<div className="ability-layout">
<aside className="ability-sidebar">
{characterDraft.steps.abilityScores.method === "rolled" ? (
<article className="ability-panel">
<div className="ability-panel-headline">
<h4>Roll Stats</h4>
<button
type="button"
onClick={() =>
updateDraft((draft) => {
const rolled = [
roll4d6DropLowest(),
roll4d6DropLowest(),
roll4d6DropLowest(),
roll4d6DropLowest(),
roll4d6DropLowest(),
roll4d6DropLowest()
] as [number, number, number, number, number, number];
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method: "rolled",
rolledValues: rolled,
scores: getScoresFromAssignments(rolled, draft.steps.abilityScores.assignments)
}
}
};
})
}
>
Roll All
</button>
</div>
<div className="ability-roll-list">
{characterDraft.steps.abilityScores.rolledValues.map((value, idx) => (
<div key={`roll-${idx}`} className="ability-roll-row">
<span>Roll #{idx + 1}</span>
<strong>{value}</strong>
<button
type="button"
className="is-subtle"
onClick={() =>
updateDraft((draft) => {
const nextValues = [...draft.steps.abilityScores.rolledValues] as [
number,
number,
number,
number,
number,
number
];
nextValues[idx] = roll4d6DropLowest();
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
rolledValues: nextValues,
scores: getScoresFromAssignments(nextValues, draft.steps.abilityScores.assignments)
}
}
};
})
}
>
Roll
</button>
</div>
))}
</div>
<button
type="button"
className="is-subtle"
onClick={() =>
updateDraft((draft) => {
const rankOrder = draft.steps.abilityScores.rolledValues
.map((value, index) => ({ value, index }))
.sort((a, b) => b.value - a.value)
.map((it) => it.index);
const nextAssignments: Record<AbilityKey, number> = {
str: rankOrder[0] ?? 0,
dex: rankOrder[1] ?? 1,
con: rankOrder[2] ?? 2,
int: rankOrder[3] ?? 3,
wis: rankOrder[4] ?? 4,
cha: rankOrder[5] ?? 5
};
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
assignments: nextAssignments,
scores: getScoresFromAssignments(draft.steps.abilityScores.rolledValues, nextAssignments)
}
}
};
})
}
>
Assign All
</button>
</article>
) : null}
{characterDraft.steps.abilityScores.method === "standardArray" ? (
<article className="ability-panel">
<h4>Standard Array</h4>
<p className="ability-inline-meta">Assign each slot once: 15, 14, 13, 12, 10, 8.</p>
</article>
) : null}
{characterDraft.steps.abilityScores.method === "pointBuy" ? (
<article className="ability-panel">
<h4>Point Buy</h4>
<p className="ability-inline-meta">
Budget 27 • Spent {getPointBuySpent(characterDraft.steps.abilityScores.scores)} • Remaining{" "}
{27 - getPointBuySpent(characterDraft.steps.abilityScores.scores)}
</p>
</article>
) : null}
{characterDraft.steps.abilityScores.method === "manual" ? (
<article className="ability-panel">
<h4>Manual Scores</h4>
<p className="ability-inline-meta">Set each score directly. Allowed range: 1 to 30.</p>
</article>
) : null}
{selectedBackgroundDetail ? (
<article className="ability-panel">
<div className="ability-panel-headline">
<h4>{selectedBackgroundDetail.name}</h4>
<span className="ability-panel-meta">
{selectedBackgroundDetail.sourceName} ({selectedBackgroundDetail.source})
</span>
</div>
{selectedBackgroundDetail.summary ? (
<ParsedEntryText
text={selectedBackgroundDetail.summary}
isParserReady={isRenderRuntimeReady}
className="ability-background-summary"
/>
) : null}
{backgroundAbilityModes.length > 1 ? (
<label className="character-field">
<span>Ability Increase Pattern</span>
<select
value={characterDraft.steps.abilityScores.backgroundBonusModeIndex}
onChange={(event) => {
const nextModeIndex = Math.max(
0,
Math.min(backgroundAbilityModes.length - 1, Number.parseInt(event.target.value, 10) || 0)
);
const mode = backgroundAbilityModes[nextModeIndex] ?? null;
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: nextModeIndex,
backgroundBonusSelections: normalizeBackgroundBonusSelections(
mode,
draft.steps.abilityScores.backgroundBonusSelections
)
}
}
}));
}}
>
{backgroundAbilityModes.map((mode, index) => (
<option key={mode.key} value={index}>
{mode.label}
</option>
))}
</select>
</label>
) : null}
{activeBackgroundAbilityMode ? (
<div className="ability-background-selectors">
{activeBackgroundAbilityMode.weights.map((weight, index) => (
<label key={`ability-bonus-${index}`} className="character-field">
<span>Select ability for +{weight}</span>
<select
value={normalizedBackgroundBonusSelections[index] ?? activeBackgroundAbilityMode.from[0]}
onChange={(event) => {
const selectedRaw = event.target.value.trim().toLowerCase();
if (!isAbilityKey(selectedRaw)) {
return;
}
updateDraft((draft) => {
const modeIndex = Math.max(
0,
Math.min(
backgroundAbilityModes.length - 1,
draft.steps.abilityScores.backgroundBonusModeIndex
)
);
const mode = backgroundAbilityModes[modeIndex] ?? null;
if (!mode) {
return draft;
}
const nextSelections = normalizeBackgroundBonusSelections(
mode,
draft.steps.abilityScores.backgroundBonusSelections
);
nextSelections[index] = selectedRaw;
const normalized = normalizeBackgroundBonusSelections(mode, nextSelections);
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusSelections: normalized
}
}
};
});
}}
>
{activeBackgroundAbilityMode.from.map((abilityKey) => {
const usedByOther = normalizedBackgroundBonusSelections.some(
(selectedAbility, selectedIndex) =>
selectedIndex !== index && selectedAbility === abilityKey
);
return (
<option key={`ability-choice-${index}-${abilityKey}`} value={abilityKey} disabled={usedByOther}>
{ABILITY_LABELS[abilityKey]}
</option>
);
})}
</select>
</label>
))}
</div>
) : null}
</article>
) : null}
</aside>
<div className="ability-main">
<div className="ability-assignment-grid ability-assignment-grid--ability-step">
{ABILITY_KEYS.map((key) => {
const baseScore = characterDraft.steps.abilityScores.scores[key];
const bonusScore = backgroundBonusTotals[key] ?? 0;
const totalScore = abilityTotals?.[key] ?? baseScore + bonusScore;
const method = characterDraft.steps.abilityScores.method;
return (
<article key={key} className="ability-assignment-card ability-assignment-card--detailed">
<h4>{ABILITY_LABELS[key]}</h4>
{method === "standardArray" || method === "rolled" ? (
<label className="character-field">
<span>Assign Value</span>
<select
value={characterDraft.steps.abilityScores.assignments[key]}
onChange={(event) => {
const nextIdx = Math.max(0, Math.min(5, Number.parseInt(event.target.value, 10) || 0));
updateDraft((draft) => {
const nextAssignments = {
...draft.steps.abilityScores.assignments,
[key]: nextIdx
};
const nextScores = getScoresFromAssignments(draft.steps.abilityScores.rolledValues, nextAssignments);
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
assignments: nextAssignments,
scores: nextScores
}
}
};
});
}}
>
{characterDraft.steps.abilityScores.rolledValues.map((_value, idx) => {
const usedByOther = ABILITY_KEYS.some(
(other) => other !== key && characterDraft.steps.abilityScores.assignments[other] === idx
);
return (
<option key={`opt-${key}-${idx}`} value={idx} disabled={usedByOther}>
Slot {idx + 1}: {characterDraft.steps.abilityScores.rolledValues[idx]}
</option>
);
})}
</select>
</label>
) : null}
{method === "pointBuy" ? (
<label className="character-field">
<span>Score</span>
<input
type="number"
min={8}
max={15}
value={baseScore}
onChange={(event) => {
const next = Math.max(8, Math.min(15, Number.parseInt(event.target.value || "8", 10) || 8));
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
scores: {
...draft.steps.abilityScores.scores,
[key]: next
}
}
}
}));
}}
/>
</label>
) : null}
{method === "manual" ? (
<label className="character-field">
<span>Score</span>
<input
type="number"
min={1}
max={30}
value={baseScore}
onChange={(event) => {
const next = Math.max(1, Math.min(30, Number.parseInt(event.target.value || "1", 10) || 1));
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
scores: {
...draft.steps.abilityScores.scores,
[key]: next
}
}
}
}));
}}
/>
</label>
) : null}
<p className="ability-inline-meta">
Base {baseScore} • Bonus {bonusScore >= 0 ? `+${bonusScore}` : `${bonusScore}`} • Total {totalScore} • Modifier{" "}
{abilityModifier(totalScore)}
{method === "pointBuy" ? ` • Cost ${getPointBuyCost(baseScore)}` : ""}
</p>
</article>
);
})}
</div>
</div>
</div>
</section>
) : null}
{characterStep === 4 ? (
<section className="character-section">
<h3>Step 5: Feats</h3>
<div className="chooser-header">
<p>
Available picks: <strong>{classFeatSlotCount}</strong> • Chosen: <strong>{variableFeatSelections.length}</strong>
</p>
</div>
{fixedFeatTokens.length ? (
<article className="chooser-panel">
<h4>Granted Feats</h4>
<div className="chip-row">
{fixedFeatTokens.map((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return null;
}
return (
<span key={`fixed-feat-${token}`} className="chip chip--locked">
{parsed.name} ({parsed.source})
</span>
);
})}
</div>
</article>
) : null}
<label className="character-field">
<span>Search feats</span>
<input type="search" value={featSearch} onChange={(event) => setFeatSearch(event.target.value)} />
</label>
<div className="chooser-layout">
<div className="chooser-panel chooser-panel--scroll">
<div className="feat-list">
{filteredFeatCatalog.map((feat) => {
const token = toNameSourceToken(feat.name, feat.source);
const key = sanitizeFeatKey(feat.name, feat.source);
const isSelected = normalizedFeatSelections.includes(token);
const isFixed = fixedFeatKeySet.has(key);
const isAtCap = !isSelected && !isFixed && variableFeatSelections.length >= classFeatSlotCount;
return (
<button
type="button"
key={feat.key}
className={isSelected ? "feat-card is-selected" : "feat-card"}
onClick={() => {
if (isFixed) {
return;
}
updateDraft((draft) => {
const current = uniqueStrings(
draft.steps.feats.selected
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => toNameSourceToken(it.name, it.source))
);
const has = current.includes(token);
let next = current;
if (has) {
next = current.filter((it) => it !== token);
} else {
const fixedKeys = new Set(
fixedFeatTokens
.map((it) => parseNameSourceToken(it))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => sanitizeFeatKey(it.name, it.source))
);
const variableCount = current.filter((it) => {
const parsed = parseNameSourceToken(it);
return parsed ? !fixedKeys.has(sanitizeFeatKey(parsed.name, parsed.source)) : false;
}).length;
if (variableCount >= classFeatSlotCount) {
return draft;
}
next = [...current, token];
}
return {
...draft,
steps: {
...draft.steps,
feats: {
...draft.steps.feats,
selected: next
}
}
};
});
}}
disabled={isAtCap}
>
<span className="feat-card-title">{feat.name}</span>
<span className="feat-card-meta">
{feat.source} {feat.category ? `• ${feat.category}` : ""}
</span>
{feat.summary ? <span className="feat-card-summary">{feat.summary}</span> : null}
</button>
);
})}
</div>
{filteredFeatCatalog.length === 0 ? <p className="character-empty">No feats match current sources and search.</p> : null}
</div>
<aside className="chooser-panel chooser-panel--scroll">
<h4>Selected Feats</h4>
<div className="feat-list">
{selectedFeatItems.map((feat) => {
const isFixed = fixedFeatKeySet.has(sanitizeFeatKey(feat.name, feat.source));
return (
<article key={`selected-${feat.key}`} className="feat-card is-selected">
<span className="feat-card-title">{feat.name}</span>
<span className="feat-card-meta">
{feat.source || "Unknown source"}
{isFixed ? " • granted" : ""}
</span>
{feat.summary ? <span className="feat-card-summary">{feat.summary}</span> : null}
</article>
);
})}
</div>
{!selectedFeatItems.length ? <p className="character-empty">No feats selected yet.</p> : null}
<label className="character-field">
<span>Feat Notes</span>
<textarea
rows={4}
value={characterDraft.steps.feats.notes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
feats: {
...draft.steps.feats,
notes: event.target.value
}
}
}))
}
/>
</label>
</aside>
</div>
</section>
) : null}
{characterStep === 5 ? (
<section className="character-section">
<h3>Step 6: Skills</h3>
<div className="chooser-layout">
<div className="chooser-panel chooser-panel--scroll">
<article className="feature-choice-card">
<h4>Granted Proficiencies</h4>
{grantedSkills.length ? (
<div className="chip-row">
{grantedSkills.map((skill) => (
<span key={`granted-${skill}`} className="chip chip--locked">
{skill}
</span>
))}
</div>
) : (
<p className="character-empty">No fixed skill proficiencies from current class/species/background.</p>
)}
</article>
{skillChoiceRequirements.map((choice) => {
const picks = normalizedSkillChoicePicks[choice.id] || [];
return (
<article key={choice.id} className="feature-choice-card">
<h4>
{choice.title} <span className="feature-choice-count">{picks.length}/{choice.count}</span>
</h4>
<div className="feature-choice-grid">
{Array.from({ length: choice.count }).map((_, ix) => (
<label key={`${choice.id}-pick-${ix}`} className="character-field">
<span>{choice.sourceLabel} pick {ix + 1}</span>
<select
value={picks[ix] || ""}
onChange={(event) => {
const picked = toSkillName(event.target.value);
updateDraft((draft) => {
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
const nextByChoice = { ...parsedNotes.picksByChoiceId };
const nextPicks = [...(nextByChoice[choice.id] || [])];
nextPicks[ix] = picked || "";
nextByChoice[choice.id] = uniqueStrings(nextPicks.map((it) => toSkillName(it) || "").filter((it) => Boolean(it)));
return {
...draft,
steps: {
...draft.steps,
skills: {
...draft.steps.skills,
notes: stringifySkillsNotes({
text: parsedNotes.text,
picksByChoiceId: nextByChoice,
extraSkills: parsedNotes.extraSkills
})
}
}
};
});
}}
>
<option value="">Select a skill</option>
{choice.options.map((option) => {
const usedElsewhere = picks.some((it, pIx) => pIx !== ix && it === option);
return (
<option key={`${choice.id}-${option}`} value={option} disabled={usedElsewhere}>
{option}
</option>
);
})}
</select>
</label>
))}
</div>
</article>
);
})}
<article className="feature-choice-card">
<h4>Additional Skill Proficiencies</h4>
<p className="feature-choice-hint">
Add selections from class/background/species features which are not automatically parsed yet.
</p>
<div className="chip-toggle-grid">
{SKILL_OPTIONS.map((skill) => {
const selected = manualSkillSelections.includes(skill);
return (
<button
key={`manual-skill-${skill}`}
type="button"
className={selected ? "chip-toggle is-active" : "chip-toggle"}
onClick={() =>
updateDraft((draft) => {
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
const next = new Set(
parsedNotes.extraSkills
.map((it) => toSkillName(it))
.filter((it): it is string => Boolean(it))
);
if (next.has(skill)) {
next.delete(skill);
} else {
next.add(skill);
}
return {
...draft,
steps: {
...draft.steps,
skills: {
...draft.steps.skills,
notes: stringifySkillsNotes({
text: parsedNotes.text,
picksByChoiceId: parsedNotes.picksByChoiceId,
extraSkills: Array.from(next).sort((a, b) => a.localeCompare(b))
})
}
}
};
})
}
>
{skill}
</button>
);
})}
</div>
</article>
<label className="character-field">
<span>Skill Notes</span>
<textarea
rows={4}
value={skillNotesState.text}
onChange={(event) =>
updateDraft((draft) => {
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
return {
...draft,
steps: {
...draft.steps,
skills: {
...draft.steps.skills,
notes: stringifySkillsNotes({
text: event.target.value,
picksByChoiceId: parsedNotes.picksByChoiceId,
extraSkills: parsedNotes.extraSkills
})
}
}
};
})
}
/>
</label>
</div>
<aside className="chooser-panel chooser-panel--scroll">
<h4>
Skills Matrix • Proficiency Bonus +{getProficiencyBonus(characterDraft.profile.level)}
</h4>
<div className="skill-matrix">
<table>
<thead>
<tr>
<th>Skill</th>
<th>Ability</th>
<th>Mod</th>
<th>Prof</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{SKILL_OPTIONS.map((skill) => {
const ability = SKILL_TO_ABILITY[skill];
const score = abilityTotals?.[ability] ?? characterDraft.steps.abilityScores.scores[ability];
const mod = getAbilityModifierNumber(score);
const isProficient = allProficientSkills.includes(skill);
const profBonus = getProficiencyBonus(characterDraft.profile.level);
const total = mod + (isProficient ? profBonus : 0);
return (
<tr key={`skill-row-${skill}`}>
<td>{skill}</td>
<td>{ABILITY_LABELS[ability]}</td>
<td>{mod >= 0 ? `+${mod}` : mod}</td>
<td>{isProficient ? `+${profBonus}` : "—"}</td>
<td>{total >= 0 ? `+${total}` : total}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</aside>
</div>
</section>
) : null}
{characterStep === 6 ? (
<section className="character-section">
<h3>Step 7: Spells</h3>
{!selectedClassEntries.length ? (
<p className="character-empty">Choose at least one class first to load spells.</p>
) : (
<div className="chooser-layout">
<div className="chooser-panel chooser-panel--scroll">
<h4>Spell Selection</h4>
<label className="character-field">
<span>Search available spells</span>
<input type="search" value={spellSearch} onChange={(event) => setSpellSearch(event.target.value)} />
</label>
<div className="spell-cap-row">
<span>Cantrips: {selectedCantripCount}/{classSpellcasting.cantrips || 0}</span>
<span>Prepared/Known: {selectedPreparedCount}/{getLeveledSpellSelectionCap(classSpellcasting)}</span>
{alwaysPreparedSpellItems.length ? <span>Always Prepared: {alwaysPreparedSpellItems.length}</span> : null}
</div>
<div className="spell-card-list">
<div className="spell-level-group">
<h5>Cantrips</h5>
{selectedCantripPool.map((spell) => {
const token = toNameSourceToken(spell.name, spell.source);
const isSelected = selectedSpellTokens.includes(token);
const canAdd =
isSelected ||
((classSpellcasting.cantrips || 0) > 0 && selectedCantripCount < (classSpellcasting.cantrips || 0));
return (
<button
type="button"
key={spell.key}
className={[
"spell-pick-card",
isSelected ? "is-selected" : "",
!canAdd ? "is-disabled" : ""
].filter(Boolean).join(" ")}
onMouseEnter={(event) => handleSpellHoverMouseEnter(event, spell)}
onMouseMove={handleSpellHoverMouseMove}
onMouseLeave={handleSpellHoverMouseLeave}
onClick={() =>
updateDraft((draft) => {
const current = uniqueStrings(
draft.steps.spells.selected
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => toNameSourceToken(it.name, it.source))
);
const has = current.includes(token);
const next = has ? current.filter((it) => it !== token) : canAdd ? [...current, token] : current;
return {
...draft,
steps: {
...draft.steps,
spells: {
...draft.steps.spells,
selected: next
}
}
};
})
}
aria-disabled={!canAdd}
>
<span className="spell-pick-title">{spell.name}</span>
<span className="spell-pick-meta">Cantrip • {spell.source}</span>
</button>
);
})}
{!selectedCantripPool.length ? <p className="character-empty">No cantrips matched your filters.</p> : null}
</div>
{selectedLeveledSpellPoolByLevel.map((group) => (
<div key={`spell-level-group-${group.level}`} className="spell-level-group">
<h5>Level {group.level}</h5>
{group.spells.map((spell) => {
const token = toNameSourceToken(spell.name, spell.source);
const isSelected = selectedSpellTokens.includes(token);
const preparedCap = getLeveledSpellSelectionCap(classSpellcasting);
const canAdd = isSelected || (preparedCap > 0 && selectedPreparedCount < preparedCap);
return (
<button
type="button"
key={spell.key}
className={[
"spell-pick-card",
isSelected ? "is-selected" : "",
!canAdd ? "is-disabled" : ""
].filter(Boolean).join(" ")}
onMouseEnter={(event) => handleSpellHoverMouseEnter(event, spell)}
onMouseMove={handleSpellHoverMouseMove}
onMouseLeave={handleSpellHoverMouseLeave}
onClick={() =>
updateDraft((draft) => {
const current = uniqueStrings(
draft.steps.spells.selected
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => toNameSourceToken(it.name, it.source))
);
const has = current.includes(token);
const next = has ? current.filter((it) => it !== token) : canAdd ? [...current, token] : current;
return {
...draft,
steps: {
...draft.steps,
spells: {
...draft.steps.spells,
selected: next
}
}
};
})
}
aria-disabled={!canAdd}
>
<span className="spell-pick-title">{spell.name}</span>
<span className="spell-pick-meta">Level {group.level} • {spell.source}</span>
</button>
);
})}
</div>
))}
</div>
{selectedCantripPool.length === 0 && selectedLeveledSpellPoolByLevel.length === 0 ? (
<p className="character-empty">No class spells matched your filters.</p>
) : null}
</div>
<aside className="chooser-panel chooser-panel--scroll">
<h4>Selected Spells</h4>
{spellSlotMatrix.length ? (
<>
<div className="slot-matrix">
{spellSlotMatrix.map((slot) => (
<span key={slot.id} className="slot-chip">
L{slot.level}
<strong>{slot.slots}</strong>
</span>
))}
</div>
<div className="slot-detail-list">
{classSpellcasting.slots.map((slot, index) => (
<span key={`slot-detail-${slot.label}-${index}`}>{slot.label}</span>
))}
</div>
</>
) : (
<p className="character-empty">No spell slots at this level.</p>
)}
{alwaysPreparedSpellItems.length ? (
<>
<h4>Always Prepared</h4>
<div className="spell-selected-list">
{alwaysPreparedSpellItems.map((spell) => (
<article
key={`always-${spell.key}`}
className="spell-selected-card"
onMouseEnter={(event) => handleSpellHoverMouseEnter(event, spell)}
onMouseMove={handleSpellHoverMouseMove}
onMouseLeave={handleSpellHoverMouseLeave}
>
<strong>{spell.name}</strong>
<span>
{spell.level === 0 ? "Cantrip" : `Level ${spell.level}`} • {spell.source}
</span>
</article>
))}
</div>
</>
) : null}
<div className="spell-selected-list">
{selectedSpellItems.map((spell) => (
<article
key={`sel-${spell.key}`}
className="spell-selected-card"
onMouseEnter={(event) => handleSpellHoverMouseEnter(event, spell)}
onMouseMove={handleSpellHoverMouseMove}
onMouseLeave={handleSpellHoverMouseLeave}
>
<strong>{spell.name}</strong>
<span>
{spell.level === 0 ? "Cantrip" : `Level ${spell.level}`} • {spell.source}
</span>
</article>
))}
</div>
{!selectedSpellItems.length ? <p className="character-empty">No spells selected.</p> : null}
<label className="character-field">
<span>Spell Notes</span>
<textarea
rows={4}
value={characterDraft.steps.spells.notes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
spells: {
...draft.steps.spells,
notes: event.target.value
}
}
}))
}
/>
</label>
</aside>
</div>
)}
</section>
) : null}
{characterStep === 7 ? (
<section className="character-section">
<h3>Step 8: Equipment</h3>
<div className="chooser-layout">
<div className="chooser-panel chooser-panel--scroll">
<h4>Starting Equipment Choices</h4>
{equipmentChoiceGroups.map((group) => (
<article key={group.id} className="feature-choice-card">
<h4>{group.label}</h4>
<label className="character-field">
<span>Choose one</span>
<select
value={normalizedEquipmentSelections[group.id] || ""}
onChange={(event) =>
updateDraft((draft) => {
const currentState = parseEquipmentSelectionState(
draft.steps.equipment.startingEquipmentText,
draft.steps.equipment.inventoryText
);
currentState.selections[group.id] = event.target.value;
return {
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
startingEquipmentText: stringifyEquipmentSelectionState(currentState)
}
}
};
})
}
>
{group.options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
</article>
))}
{equipmentChoiceGroups.length === 0 ? <p className="character-empty">No starting equipment choices for current selections.</p> : null}
<article className="feature-choice-card">
<h4>Add Inventory Item</h4>
<div className="add-item-row">
<input
type="text"
list="equipment-item-catalog"
value={equipmentItemInput}
onChange={(event) => setEquipmentItemInput(event.target.value)}
placeholder="Add custom or catalog item"
/>
<datalist id="equipment-item-catalog">
{itemCatalog.slice(0, 500).map((item) => (
<option key={`item-opt-${item}`} value={item} />
))}
</datalist>
<button
type="button"
onClick={() => {
const trimmed = equipmentItemInput.trim();
if (!trimmed) {
return;
}
updateDraft((draft) => {
const currentState = parseEquipmentSelectionState(
draft.steps.equipment.startingEquipmentText,
draft.steps.equipment.inventoryText
);
if (!currentState.addedItems.includes(trimmed)) {
currentState.addedItems = [...currentState.addedItems, trimmed];
}
return {
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
startingEquipmentText: stringifyEquipmentSelectionState(currentState),
inventoryText: currentState.addedItems.join("\n")
}
}
};
});
setEquipmentItemInput("");
}}
>
Add
</button>
</div>
<div className="chip-row">
{equipmentSelectionState.addedItems.map((item) => (
<button
key={`added-item-${item}`}
type="button"
className="chip chip--removable"
onClick={() =>
updateDraft((draft) => {
const currentState = parseEquipmentSelectionState(
draft.steps.equipment.startingEquipmentText,
draft.steps.equipment.inventoryText
);
currentState.addedItems = currentState.addedItems.filter((it) => it !== item);
return {
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
startingEquipmentText: stringifyEquipmentSelectionState(currentState),
inventoryText: currentState.addedItems.join("\n")
}
}
};
})
}
>
{item} ×
</button>
))}
</div>
</article>
<label className="character-field">
<span>Personal Possessions</span>
<textarea
rows={3}
value={characterDraft.steps.equipment.possessionsText}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
possessionsText: event.target.value
}
}
}))
}
/>
</label>
</div>
<aside className="chooser-panel chooser-panel--scroll">
<h4>Starting Inventory</h4>
<div className="inventory-list">
{resolvedEquipmentRows.map((row) => (
<div key={`inventory-item-${row.label}`} className="inventory-item">
<span>{row.label}</span>
<strong>{row.count}</strong>
</div>
))}
</div>
{!resolvedEquipmentRows.length ? <p className="character-empty">No equipment selected yet.</p> : null}
<div className="currency-grid">
{(["cp", "sp", "ep", "gp", "pp"] as const).map((coin) => (
<label key={coin} className="character-field">
<span>{coin.toUpperCase()}</span>
<input type="number" min={0} value={characterDraft.steps.equipment.currency[coin]} readOnly />
</label>
))}
</div>
</aside>
</div>
</section>
) : null}
{characterStep === 8 ? (
<section className="character-section">
<h3>Step 9: About</h3>
<div className="character-grid">
<label className="character-field">
<span>Alignment</span>
<input
type="text"
value={characterDraft.profile.alignment}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, alignment: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Faith</span>
<input
type="text"
value={characterDraft.profile.faith}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, faith: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Lifestyle</span>
<input
type="text"
value={characterDraft.profile.lifestyle}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, lifestyle: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Age</span>
<input
type="text"
value={characterDraft.profile.age}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, age: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Gender</span>
<input
type="text"
value={characterDraft.profile.gender}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, gender: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Height</span>
<input
type="text"
value={characterDraft.profile.height}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, height: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Weight</span>
<input
type="text"
value={characterDraft.profile.weight}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, weight: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Eyes</span>
<input
type="text"
value={characterDraft.profile.eyes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, eyes: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Skin</span>
<input
type="text"
value={characterDraft.profile.skin}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, skin: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Hair</span>
<input
type="text"
value={characterDraft.profile.hair}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, hair: event.target.value }
}))
}
/>
</label>
</div>
<label className="character-field">
<span>Personality Traits</span>
<textarea
rows={3}
value={characterDraft.steps.about.personality}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
personality: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>Ideals</span>
<textarea
rows={2}
value={characterDraft.steps.about.ideals}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
ideals: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>Bonds</span>
<textarea
rows={2}
value={characterDraft.steps.about.bonds}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
bonds: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>Flaws</span>
<textarea
rows={2}
value={characterDraft.steps.about.flaws}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
flaws: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>Backstory</span>
<textarea
rows={4}
value={characterDraft.steps.about.backstory}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
backstory: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>About Notes</span>
<textarea
rows={3}
value={characterDraft.steps.about.notes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
notes: event.target.value
}
}
}))
}
/>
</label>
</section>
) : null}
{characterStep === 9 ? (
<section className="character-section">
<h3>Finish</h3>
<div className="character-summary">
<p>
<strong>Name:</strong> {characterDraft.profile.name}
</p>
<p>
<strong>Level:</strong> {characterDraft.profile.level}
</p>
<p>
<strong>Class:</strong> {characterDraft.selections.class?.name || "Not set"}
</p>
<p>
<strong>Subclass:</strong> {characterDraft.selections.subclass?.name || "Not set"}
</p>
{selectedClassEntries.length > 1 ? (
<p>
<strong>Multiclass:</strong>{" "}
{selectedClassEntries.map((entry) => `${entry.class.name} ${entry.level}`).join(", ")}
</p>
) : null}
<p>
<strong>Species:</strong> {characterDraft.selections.species?.name || "Not set"}
</p>
<p>
<strong>Background:</strong> {characterDraft.selections.background?.name || "Not set"}
</p>
<p>
<strong>Source Buckets:</strong> {characterDraft.steps.preferences.sourceBuckets.join(", ") || "None"}
</p>
</div>
<label className="character-field">
<span>General Notes</span>
<textarea
rows={5}
value={characterDraft.notes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
notes: event.target.value
}))
}
/>
</label>
<div className="character-finish-actions">
<button type="button" onClick={() => void saveCharacterDraft()} disabled={isCharacterBusy}>
Save Character
</button>
<button type="button" className="is-subtle" onClick={() => void openCharacterList()} disabled={isCharacterBusy}>
Back to My Characters
</button>
</div>
</section>
) : null}
<footer className="character-flow-footer">
<button type="button" className="character-flow-nav" onClick={prevStep} disabled={characterStep === 0 || isCharacterBusy}>
Previous
</button>
<div className="character-flow-rail" role="tablist" aria-label="Character creator steps">
{progressionSteps.map((stepLabel, index) => {
const isActive = characterStep === index;
const isComplete = stepCompletion[index];
const className = [
"character-flow-step",
isActive ? "is-active" : "",
isComplete ? "is-complete" : "is-pending"
]
.filter(Boolean)
.join(" ");
return (
<button
key={stepLabel}
type="button"
className={className}
onClick={() => setCharacterStep(index)}
disabled={!characterDraft}
role="tab"
aria-selected={isActive}
title={stepLabel}
>
<span className="character-flow-step-index">{isComplete ? "✓" : index + 1}</span>
{isActive ? <span className="character-flow-step-label">{stepLabel}</span> : null}
</button>
);
})}
<button
type="button"
className={characterStep === CHARACTER_STEPS.length - 1 ? "character-flow-step is-active is-review" : "character-flow-step is-review"}
onClick={() => setCharacterStep(CHARACTER_STEPS.length - 1)}
disabled={!characterDraft}
role="tab"
aria-selected={characterStep === CHARACTER_STEPS.length - 1}
title="Review Character"
>
<span className="character-flow-step-label">Review</span>
</button>
</div>
<button
type="button"
className="character-flow-nav"
onClick={nextStep}
disabled={characterStep >= CHARACTER_STEPS.length - 1 || !canMoveNext || isCharacterBusy}
>
{characterStep >= CHARACTER_STEPS.length - 2 ? "Review" : "Next"}
</button>
</footer>
{entityPopover ? (
<div
className="entity-popover-backdrop"
role="presentation"
onClick={closeEntityPopover}
>
<section
className="entity-popover"
role="dialog"
aria-modal="true"
aria-label={`${entityPopover.kind} details`}
onClick={(event) => event.stopPropagation()}
>
<header className="entity-popover-header">
<h4>
{entityPopover.kind === "class"
? "Class details"
: entityPopover.kind === "species"
? "Species details"
: "Background details"}
</h4>
<button type="button" className="is-subtle" onClick={closeEntityPopover}>
{pendingEntitySelection ? "Cancel" : "Close"}
</button>
</header>
<div className="entity-popover-body">{renderEntityDetailContent(entityPopoverDetail, entityPopover.kind)}</div>
<footer className="entity-popover-footer">
{pendingEntitySelection ? (
<>
<button type="button" className="is-subtle" onClick={closeEntityPopover}>
Cancel
</button>
<button type="button" onClick={confirmPendingEntitySelection}>
Add {pendingEntitySelection.kind}
</button>
</>
) : (
<button type="button" className="is-subtle" onClick={closeEntityPopover}>
Close
</button>
)}
</footer>
</section>
</div>
) : null}
</div>
) : (
<p className="character-empty">No character loaded.</p>
)}
</div>
)}
</div>
</section>
)}
</section>
</main>
);
}
export default function App() {
const isSetupMode = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get("setup") === "1";
}, []);
if (isSetupMode) {
return <SetupView />;
}
return <WikiView />;
}