7363 lines
279 KiB
TypeScript
7363 lines
279 KiB
TypeScript
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function stripTagsFallback(value: string): string {
|
||
return value
|
||
.replace(/\{@([^}]+)\}/g, (_match: string, innerRaw: string) => {
|
||
const inner = innerRaw.trim();
|
||
if (!inner) {
|
||
return "";
|
||
}
|
||
|
||
const firstSpace = inner.indexOf(" ");
|
||
const payload = firstSpace >= 0 ? inner.slice(firstSpace + 1) : inner;
|
||
const parts = payload.split("|").map((it) => it.trim());
|
||
return parts[2] || parts[3] || parts[0] || parts[1] || "";
|
||
})
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function renderEntryText(text: string, isParserReady: boolean): string {
|
||
const value = text.trim();
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
if (isParserReady && window.Renderer?.get) {
|
||
try {
|
||
const renderer = window.Renderer.get();
|
||
renderer.setFirstSection(true);
|
||
return renderer.render(value);
|
||
} catch {
|
||
// Fall through to basic escaped text below.
|
||
}
|
||
}
|
||
|
||
return escapeHtml(stripTagsFallback(value));
|
||
}
|
||
|
||
function ParsedEntryText(props: {
|
||
text: string;
|
||
isParserReady: boolean;
|
||
className?: string;
|
||
}): JSX.Element | null {
|
||
const { text, isParserReady, className } = props;
|
||
const html = useMemo(() => renderEntryText(text, isParserReady), [text, isParserReady]);
|
||
|
||
if (!html) {
|
||
return null;
|
||
}
|
||
|
||
return <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 />;
|
||
}
|