From a73c5ea002b27710280adb957a9d5825951f13e9 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 25 Feb 2026 23:53:08 +0000 Subject: [PATCH] Most of CC flow complete. Refactored css. Code is now a mess. Navbar > Sidebar. CSS still hates me move to tailwind :pray: --- README.md | 9 +- src/main/characterData.ts | 1130 ++++++++ src/main/characterStore.ts | 721 +++++ src/main/main.ts | 197 +- src/main/wikiServer.ts | 311 +- src/preload/preload.ts | 176 +- src/renderer/App.tsx | 5644 +++++++++++++++++++++++++++++++++++- src/renderer/styles.css | 2074 ++++++++++++- src/renderer/vite-env.d.ts | 172 ++ src/renderer/wikiNav.ts | 92 +- 10 files changed, 10331 insertions(+), 195 deletions(-) create mode 100644 src/main/characterData.ts create mode 100644 src/main/characterStore.ts diff --git a/README.md b/README.md index ea7a0d0..e7ff9fb 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Current stage: - Build 0 complete: Electron boots directly into local 5etools wiki. - Stage 1 in progress: React shell navigation + light visual reskin pass, while preserving wiki behavior. +- Character Creator milestone 1 added: + - Create new character + - Load character from JSON + - View/edit saved characters ## Prerequisites @@ -79,7 +83,10 @@ This builds a macOS arm64 package and writes output to `release/`. - The app starts an internal local HTTP server for wiki content. - This avoids `file://` loading limitations while keeping content fully local. - Electron launch scripts clear `ELECTRON_RUN_AS_NODE` automatically, so app boot is stable even if that variable is set in your shell environment. -- SQLite is intentionally deferred until post-Build 0 to avoid native module/toolchain issues while the wiki bootstrap is stabilized. +- Character files are stored as JSON under the app user-data directory: + - `/characters/.json` + - one file per character +- SQLite is intentionally deferred until a later character-system migration phase. - Build identity is set to: - Product name: `5ET-D` - App ID: `com.notBoris.5et-desktop` diff --git a/src/main/characterData.ts b/src/main/characterData.ts new file mode 100644 index 0000000..38305af --- /dev/null +++ b/src/main/characterData.ts @@ -0,0 +1,1130 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type CharacterEntityType = "class" | "background" | "species"; +export type CharacterSourceBucket = "homebrew" | "core2024" | "expanded2024" | "core2014" | "expanded2014"; + +export interface CharacterSelectionOption { + entityType: CharacterEntityType; + name: string; + source: string; + hash: string; + entityRef: string; + details: string; + sourceName: string; + sourceGroup: string | null; + sourcePublished: string | null; + sourceBucket: CharacterSourceBucket; + sourceEdition: "2014" | "2024" | null; + imagePath: string | null; + summary: string; +} + +export interface CharacterFeatureOption { + name: string; + source: string; + hash: string; + entityRef: string; + details: string; +} + +export interface CharacterFeatureCard { + id: string; + title: string; + level: number | null; + summary: string; + options?: CharacterFeatureOption[]; +} + +export interface CharacterSelectionDetail extends CharacterSelectionOption { + featureCards: CharacterFeatureCard[]; + rawJson: string; +} + +export interface CharacterSelectionOptionSets { + classes: CharacterSelectionOption[]; + backgrounds: CharacterSelectionOption[]; + species: CharacterSelectionOption[]; +} + +interface SourceBookRecord { + source?: string; + name?: string; + group?: string; + published?: string; +} + +interface ClassRecord { + name?: string; + source?: string; + page?: number; + edition?: string; + hd?: { faces?: number }; + proficiency?: unknown; + primaryAbility?: unknown; + spellcastingAbility?: string; + classFeatures?: unknown; + optionalfeatureProgression?: unknown; + subclassTitle?: string; + entries?: unknown; +} + +interface ClassFeatureRecord { + name?: string; + source?: string; + className?: string; + classSource?: string; + level?: number; + entries?: unknown; +} + +interface SubclassRecord { + name?: string; + shortName?: string; + source?: string; + className?: string; + classSource?: string; + entries?: unknown; +} + +interface BackgroundRecord { + name?: string; + source?: string; + page?: number; + ability?: unknown; + entries?: unknown; +} + +interface SpeciesRecord { + name?: string; + source?: string; + page?: number; + size?: unknown; + speed?: unknown; + entries?: unknown; +} + +interface FluffRecord { + name?: string; + source?: string; + entries?: unknown; + images?: unknown; +} + +interface FluffInfo { + imagePath: string | null; + summary: string; +} + +interface SourceMeta { + source: string; + sourceName: string; + sourceGroup: string | null; + sourcePublished: string | null; + sourceBucket: CharacterSourceBucket; + sourceEdition: "2014" | "2024" | null; + isOfficial: boolean; +} + +interface CacheValue { + rootDir: string; + options: CharacterSelectionOptionSets; + detailsByRef: Map; +} + +let cache: CacheValue | null = null; + +const CORE_SOURCE_IDS = new Set(["PHB", "DMG", "XPHB", "XDMG"]); +const ABILITY_ABV = new Set(["str", "dex", "con", "int", "wis", "cha"]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readJsonFile(filePath: string): unknown { + const raw = fs.readFileSync(filePath, "utf8"); + return JSON.parse(raw) as unknown; +} + +function sanitizeToken(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +function buildEntityRef(entityType: CharacterEntityType | "subclass", sourceRaw: string, name: string): { + source: string; + hash: string; + entityRef: string; +} { + const source = sourceRaw.trim().toUpperCase(); + const hash = `${sanitizeToken(name)}_${sanitizeToken(source)}`; + return { + source, + hash, + entityRef: `${entityType}|${source}|${hash}` + }; +} + +function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function collectTextChunks(value: unknown, out: string[], maxChunks = 12): void { + if (out.length >= maxChunks || value == null) { + return; + } + + if (typeof value === "string") { + const cleaned = value.replace(/\s+/g, " ").trim(); + if (cleaned) { + out.push(cleaned); + } + return; + } + + if (Array.isArray(value)) { + for (const entry of value) { + if (out.length >= maxChunks) { + break; + } + collectTextChunks(entry, out, maxChunks); + } + return; + } + + if (!isRecord(value)) { + return; + } + + if (typeof value.entry === "string") { + const cleaned = value.entry.replace(/\s+/g, " ").trim(); + if (cleaned) { + out.push(cleaned); + } + } + + if (typeof value.text === "string") { + const cleaned = value.text.replace(/\s+/g, " ").trim(); + if (cleaned) { + out.push(cleaned); + } + } + + if (Array.isArray(value.entries)) { + collectTextChunks(value.entries, out, maxChunks); + } + + if (Array.isArray(value.items)) { + collectTextChunks(value.items, out, maxChunks); + } +} + +function getSummaryFromEntries(value: unknown): string { + const chunks: string[] = []; + collectTextChunks(value, chunks, 8); + if (!chunks.length) { + return ""; + } + + return chunks.join(" ").trim(); +} + +function getImagePath(images: unknown): string | null { + if (!Array.isArray(images)) { + return null; + } + + for (const image of images) { + if (!isRecord(image)) { + continue; + } + + const href = image.href; + if (!isRecord(href)) { + continue; + } + + if (href.type !== "internal") { + continue; + } + + const imagePath = normalizeString(href.path); + if (!imagePath) { + continue; + } + + return `img/${imagePath.replace(/^\/+/, "")}`; + } + + return null; +} + +function buildFluffKey(name: string, source: string): string { + return `${name.trim().toLowerCase()}__${source.trim().toUpperCase()}`; +} + +function addFluffRecordsToMap(map: Map, records: FluffRecord[]): void { + for (const record of records) { + const name = normalizeString(record.name); + const source = normalizeString(record.source).toUpperCase(); + if (!name || !source) { + continue; + } + + const key = buildFluffKey(name, source); + const imagePath = getImagePath(record.images); + const summary = getSummaryFromEntries(record.entries); + + map.set(key, { + imagePath, + summary + }); + } +} + +function loadClassFluff(rootDir: string): Map { + const map = new Map(); + const classDir = path.join(rootDir, "data", "class"); + + let files: string[]; + try { + files = fs.readdirSync(classDir); + } catch { + return map; + } + + for (const fileName of files) { + if (!fileName.startsWith("fluff-class-") || !fileName.endsWith(".json")) { + continue; + } + + const filePath = path.join(classDir, fileName); + if (!fs.existsSync(filePath)) { + continue; + } + + const parsed = readJsonFile(filePath); + const classFluff = isRecord(parsed) && Array.isArray(parsed.classFluff) ? (parsed.classFluff as FluffRecord[]) : []; + addFluffRecordsToMap(map, classFluff); + } + + return map; +} + +function loadBackgroundFluff(rootDir: string): Map { + const map = new Map(); + const filePath = path.join(rootDir, "data", "fluff-backgrounds.json"); + if (!fs.existsSync(filePath)) { + return map; + } + + const parsed = readJsonFile(filePath); + const records = isRecord(parsed) && Array.isArray(parsed.backgroundFluff) + ? (parsed.backgroundFluff as FluffRecord[]) + : []; + addFluffRecordsToMap(map, records); + return map; +} + +function loadSpeciesFluff(rootDir: string): Map { + const map = new Map(); + const filePath = path.join(rootDir, "data", "fluff-races.json"); + if (!fs.existsSync(filePath)) { + return map; + } + + const parsed = readJsonFile(filePath); + const records = isRecord(parsed) && Array.isArray(parsed.raceFluff) ? (parsed.raceFluff as FluffRecord[]) : []; + addFluffRecordsToMap(map, records); + return map; +} + +function parseSourceYear(value: string | null): number | null { + if (!value) { + return null; + } + + const yearRaw = value.slice(0, 4); + const year = Number.parseInt(yearRaw, 10); + return Number.isFinite(year) ? year : null; +} + +function parseParserSourceDates(rootDir: string): Map { + const dateBySource = new Map(); + const parserPath = path.join(rootDir, "js", "parser.js"); + if (!fs.existsSync(parserPath)) { + return dateBySource; + } + + const raw = fs.readFileSync(parserPath, "utf8"); + + const sourceByConst = new Map(); + const constRegex = /Parser\.SRC_([A-Za-z0-9_]+)\s*=\s*"([^"]+)";/g; + let constMatch: RegExpExecArray | null; + while ((constMatch = constRegex.exec(raw)) !== null) { + const constName = constMatch[1]; + const source = constMatch[2]; + if (!constName || !source) { + continue; + } + sourceByConst.set(constName, source.toUpperCase()); + } + + const dateRegex = /Parser\.SOURCE_JSON_TO_DATE\[Parser\.SRC_([A-Za-z0-9_]+)\]\s*=\s*"(\d{4}-\d{2}-\d{2})";/g; + let dateMatch: RegExpExecArray | null; + while ((dateMatch = dateRegex.exec(raw)) !== null) { + const constName = dateMatch[1]; + const published = dateMatch[2]; + if (!constName || !published) { + continue; + } + + const source = sourceByConst.get(constName); + if (!source) { + continue; + } + + if (!dateBySource.has(source)) { + dateBySource.set(source, published); + } + } + + return dateBySource; +} + +function classifySourceMeta(input: { + source: string; + sourceName?: string; + sourceGroup?: string | null; + sourcePublished?: string | null; + isOfficial: boolean; +}): SourceMeta { + const source = input.source.trim().toUpperCase(); + const sourceGroup = input.sourceGroup ? input.sourceGroup.trim().toLowerCase() : null; + const sourcePublished = input.sourcePublished ? input.sourcePublished.trim() : null; + const sourceName = (input.sourceName || source).trim() || source; + + if (!input.isOfficial) { + return { + source, + sourceName, + sourceGroup, + sourcePublished, + sourceBucket: "homebrew", + sourceEdition: null, + isOfficial: false + }; + } + + const year = parseSourceYear(sourcePublished); + const is2024Era = year != null ? year >= 2024 : source.startsWith("X"); + const isCore = CORE_SOURCE_IDS.has(source) || sourceGroup === "core"; + + return { + source, + sourceName, + sourceGroup, + sourcePublished, + sourceBucket: is2024Era ? (isCore ? "core2024" : "expanded2024") : isCore ? "core2014" : "expanded2014", + sourceEdition: is2024Era ? "2024" : "2014", + isOfficial: true + }; +} + +function buildSourceMetaMap(rootDir: string): Map { + const rawMap = new Map(); + + const booksPath = path.join(rootDir, "data", "books.json"); + if (fs.existsSync(booksPath)) { + const parsed = readJsonFile(booksPath); + const books = isRecord(parsed) && Array.isArray(parsed.book) ? (parsed.book as SourceBookRecord[]) : []; + for (const book of books) { + const source = normalizeString(book.source).toUpperCase(); + if (!source) { + continue; + } + + if (!rawMap.has(source)) { + rawMap.set(source, { + sourceName: normalizeString(book.name) || source, + sourceGroup: normalizeString(book.group) || null, + sourcePublished: normalizeString(book.published) || null + }); + } + } + } + + const adventuresPath = path.join(rootDir, "data", "adventures.json"); + if (fs.existsSync(adventuresPath)) { + const parsed = readJsonFile(adventuresPath); + const adventures = isRecord(parsed) && Array.isArray(parsed.adventure) + ? (parsed.adventure as SourceBookRecord[]) + : []; + + for (const adventure of adventures) { + const source = normalizeString(adventure.source).toUpperCase(); + if (!source || rawMap.has(source)) { + continue; + } + + rawMap.set(source, { + sourceName: normalizeString(adventure.name) || source, + sourceGroup: normalizeString(adventure.group) || null, + sourcePublished: normalizeString(adventure.published) || null + }); + } + } + + const parserDates = parseParserSourceDates(rootDir); + for (const [source, published] of parserDates.entries()) { + const existing = rawMap.get(source); + if (existing) { + if (!existing.sourcePublished) { + existing.sourcePublished = published; + } + continue; + } + + rawMap.set(source, { + sourceName: source, + sourceGroup: null, + sourcePublished: published + }); + } + + const sourceMetaMap = new Map(); + for (const [source, meta] of rawMap.entries()) { + sourceMetaMap.set( + source, + classifySourceMeta({ + source, + sourceName: meta.sourceName, + sourceGroup: meta.sourceGroup, + sourcePublished: meta.sourcePublished, + isOfficial: true + }) + ); + } + + return sourceMetaMap; +} + +function getSourceMeta(sourceRaw: string, sourceMetaMap: Map): SourceMeta { + const source = sourceRaw.trim().toUpperCase(); + const known = sourceMetaMap.get(source); + if (known) { + return known; + } + + return classifySourceMeta({ + source, + sourceName: source, + sourceGroup: null, + sourcePublished: null, + isOfficial: false + }); +} + +function getPrimaryAbilities(record: ClassRecord): string[] { + const primary = record.primaryAbility; + const out: string[] = []; + + if (Array.isArray(primary)) { + for (const entry of primary) { + if (!isRecord(entry)) { + continue; + } + + for (const key of Object.keys(entry)) { + const lower = key.toLowerCase(); + if (ABILITY_ABV.has(lower) && !out.includes(lower.toUpperCase())) { + out.push(lower.toUpperCase()); + } + } + } + } + + if (out.length) { + return out; + } + + const proficiency = record.proficiency; + if (Array.isArray(proficiency)) { + for (const entry of proficiency) { + if (typeof entry !== "string") { + continue; + } + + const lower = entry.toLowerCase(); + if (ABILITY_ABV.has(lower) && !out.includes(lower.toUpperCase())) { + out.push(lower.toUpperCase()); + } + } + } + + if (!out.length && typeof record.spellcastingAbility === "string") { + const lower = record.spellcastingAbility.toLowerCase(); + if (ABILITY_ABV.has(lower)) { + out.push(lower.toUpperCase()); + } + } + + return out; +} + +function getSpeciesSize(value: unknown): string { + if (!Array.isArray(value)) { + return ""; + } + + return value + .map((it) => (typeof it === "string" ? it.trim() : "")) + .filter((it) => Boolean(it)) + .join("/"); +} + +function getSpeciesWalkSpeed(value: unknown): string { + if (!value) { + return ""; + } + + if (typeof value === "number") { + return `${value}ft`; + } + + if (!isRecord(value)) { + return ""; + } + + const walk = value.walk; + if (typeof walk === "number") { + return `${walk}ft`; + } + + if (walk === true) { + return "variable"; + } + + return ""; +} + +function getBackgroundAbilitySummary(value: unknown): string { + if (!Array.isArray(value) || value.length === 0) { + return ""; + } + + const abilities = new Set(); + for (const part of value) { + if (!isRecord(part)) { + continue; + } + + for (const key of Object.keys(part)) { + const lower = key.toLowerCase(); + if (ABILITY_ABV.has(lower)) { + abilities.add(lower.toUpperCase()); + } + } + + const choose = part.choose; + if (isRecord(choose)) { + const weighted = choose.weighted; + if (isRecord(weighted) && Array.isArray(weighted.from)) { + for (const from of weighted.from) { + if (typeof from !== "string") { + continue; + } + const lower = from.toLowerCase(); + if (ABILITY_ABV.has(lower)) { + abilities.add(lower.toUpperCase()); + } + } + } + } + } + + return Array.from(abilities).join("/"); +} + +function collectNamedEntryCards( + value: unknown, + cards: CharacterFeatureCard[], + seen: Set, + maxCards = 14 +): void { + if (cards.length >= maxCards || value == null) { + return; + } + + if (Array.isArray(value)) { + for (const entry of value) { + if (cards.length >= maxCards) { + break; + } + collectNamedEntryCards(entry, cards, seen, maxCards); + } + return; + } + + if (!isRecord(value)) { + return; + } + + const name = normalizeString(value.name); + if (name) { + const key = name.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + cards.push({ + id: `entry-${cards.length}`, + title: name, + level: null, + summary: getSummaryFromEntries(value.entries || value.entry || "") + }); + } + } + + if (Array.isArray(value.entries)) { + collectNamedEntryCards(value.entries, cards, seen, maxCards); + } + + if (Array.isArray(value.items)) { + collectNamedEntryCards(value.items, cards, seen, maxCards); + } +} + +function buildClassFeatureLookup(classFeatures: ClassFeatureRecord[]): Map { + const map = new Map(); + + for (const feature of classFeatures) { + const name = normalizeString(feature.name); + const className = normalizeString(feature.className); + const classSource = normalizeString(feature.classSource).toUpperCase(); + const source = normalizeString(feature.source).toUpperCase(); + const level = typeof feature.level === "number" ? feature.level : null; + + if (!name || !className || level == null) { + continue; + } + + const baseKeys = [ + `${name}|${className}|${classSource}|${level}`, + `${name}|${className}||${level}`, + `${name}|${className}|${source}|${level}` + ]; + + for (const key of baseKeys) { + if (!key.includes("||") && map.has(key)) { + continue; + } + + if (!map.has(key)) { + map.set(key, feature); + } + } + } + + return map; +} + +function resolveClassFeature( + featureLookup: Map, + featureName: string, + className: string, + classSource: string, + level: number, + featureSource?: string +): ClassFeatureRecord | null { + const keys = [ + `${featureName}|${className}|${classSource}|${level}`, + `${featureName}|${className}||${level}`, + featureSource ? `${featureName}|${className}|${featureSource}|${level}` : "" + ].filter(Boolean); + + for (const key of keys) { + const found = featureLookup.get(key); + if (found) { + return found; + } + } + + return null; +} + +function parseClassFeatureRef(value: unknown): { + name: string; + className: string; + classSource: string; + level: number; + featureSource: string; +} | null { + const ref = typeof value === "string" ? value : isRecord(value) && typeof value.classFeature === "string" ? value.classFeature : ""; + if (!ref) { + return null; + } + + const parts = ref.split("|"); + const name = (parts[0] || "").trim(); + const className = (parts[1] || "").trim(); + const classSource = (parts[2] || "").trim().toUpperCase(); + const levelRaw = parts[3] || ""; + const level = Number.parseInt(levelRaw, 10); + const featureSource = (parts[4] || "").trim().toUpperCase(); + + if (!name || !className || !Number.isFinite(level)) { + return null; + } + + return { + name, + className, + classSource, + level, + featureSource + }; +} + +function createOption( + entityType: CharacterEntityType, + name: string, + sourceRaw: string, + details: string, + sourceMeta: SourceMeta, + imagePath: string | null, + summary: string +): CharacterSelectionOption { + const { source, hash, entityRef } = buildEntityRef(entityType, sourceRaw, name); + return { + entityType, + name, + source, + hash, + entityRef, + details, + sourceName: sourceMeta.sourceName, + sourceGroup: sourceMeta.sourceGroup, + sourcePublished: sourceMeta.sourcePublished, + sourceBucket: sourceMeta.sourceBucket, + sourceEdition: sourceMeta.sourceEdition, + imagePath, + summary + }; +} + +function buildCache(rootDir: string): CacheValue { + const detailsByRef = new Map(); + const sourceMetaMap = buildSourceMetaMap(rootDir); + + const classFluffMap = loadClassFluff(rootDir); + const backgroundFluffMap = loadBackgroundFluff(rootDir); + const speciesFluffMap = loadSpeciesFluff(rootDir); + + const classDir = path.join(rootDir, "data", "class"); + const classIndexPath = path.join(classDir, "index.json"); + const classIndexJson = readJsonFile(classIndexPath); + const classFileNames = isRecord(classIndexJson) ? Object.values(classIndexJson).filter((it): it is string => typeof it === "string") : []; + + const allClasses: ClassRecord[] = []; + const allClassFeatures: ClassFeatureRecord[] = []; + const allSubclasses: SubclassRecord[] = []; + + for (const fileName of classFileNames) { + const filePath = path.join(classDir, fileName); + if (!fs.existsSync(filePath)) { + continue; + } + + const parsed = readJsonFile(filePath); + if (!isRecord(parsed)) { + continue; + } + + if (Array.isArray(parsed.class)) { + allClasses.push(...(parsed.class as ClassRecord[])); + } + + if (Array.isArray(parsed.classFeature)) { + allClassFeatures.push(...(parsed.classFeature as ClassFeatureRecord[])); + } + + if (Array.isArray(parsed.subclass)) { + allSubclasses.push(...(parsed.subclass as SubclassRecord[])); + } + } + + const featureLookup = buildClassFeatureLookup(allClassFeatures); + const subclassesByClassKey = new Map(); + for (const subclass of allSubclasses) { + const className = normalizeString(subclass.className); + const classSource = normalizeString(subclass.classSource).toUpperCase(); + if (!className) { + continue; + } + + const key = `${className}__${classSource}`; + const list = subclassesByClassKey.get(key) || []; + list.push(subclass); + subclassesByClassKey.set(key, list); + } + + const classOptions: CharacterSelectionOption[] = []; + for (const cls of allClasses) { + const name = normalizeString(cls.name); + const source = normalizeString(cls.source).toUpperCase(); + if (!name || !source) { + continue; + } + + const sourceMeta = getSourceMeta(source, sourceMetaMap); + const fluff = classFluffMap.get(buildFluffKey(name, source)); + + const hitDieFaces = typeof cls.hd?.faces === "number" ? `d${cls.hd.faces}` : ""; + const primaryAbilities = getPrimaryAbilities(cls); + const details = [ + hitDieFaces ? `Hit Die ${hitDieFaces}` : "", + primaryAbilities.length ? `Primary ${primaryAbilities.join("/")}` : "", + typeof cls.page === "number" ? `Page ${cls.page}` : "", + cls.edition ? `Edition ${cls.edition}` : "" + ] + .filter(Boolean) + .join(" • "); + + const summary = fluff?.summary || getSummaryFromEntries(cls.entries); + + const option = createOption("class", name, source, details, sourceMeta, fluff?.imagePath || null, summary); + + const featureCards: CharacterFeatureCard[] = []; + const seenFeatureCards = new Set(); + + if (Array.isArray(cls.classFeatures)) { + for (const featureRefRaw of cls.classFeatures) { + const parsedRef = parseClassFeatureRef(featureRefRaw); + if (!parsedRef) { + continue; + } + + const feature = resolveClassFeature( + featureLookup, + parsedRef.name, + parsedRef.className, + parsedRef.classSource, + parsedRef.level, + parsedRef.featureSource + ); + + const featureSummary = getSummaryFromEntries(feature?.entries || ""); + const cardKey = `${parsedRef.level}-${parsedRef.name}`.toLowerCase(); + if (seenFeatureCards.has(cardKey)) { + continue; + } + + seenFeatureCards.add(cardKey); + featureCards.push({ + id: `class-feature-${featureCards.length}`, + title: parsedRef.name, + level: parsedRef.level, + summary: featureSummary + }); + } + } + + if (Array.isArray(cls.optionalfeatureProgression)) { + for (const prog of cls.optionalfeatureProgression) { + if (!isRecord(prog)) { + continue; + } + + const title = normalizeString(prog.name); + if (!title) { + continue; + } + + const key = `choice-${title}`.toLowerCase(); + if (seenFeatureCards.has(key)) { + continue; + } + + seenFeatureCards.add(key); + featureCards.push({ + id: `class-choice-${featureCards.length}`, + title, + level: null, + summary: "Contains selectable options as the class levels up." + }); + } + } + + const subclassKey = `${name}__${source}`; + const subclassList = (subclassesByClassKey.get(subclassKey) || []) + .filter((subclass) => Boolean(normalizeString(subclass.name)) && Boolean(normalizeString(subclass.source))) + .sort((a, b) => { + const aName = normalizeString(a.name); + const bName = normalizeString(b.name); + return aName.localeCompare(bName) || normalizeString(a.source).localeCompare(normalizeString(b.source)); + }); + + if (subclassList.length) { + const subclassOptions: CharacterFeatureOption[] = subclassList.map((subclass) => { + const subclassName = normalizeString(subclass.name); + const subclassSource = normalizeString(subclass.source).toUpperCase(); + const { hash, entityRef } = buildEntityRef("subclass", subclassSource, `${name}-${subclassName}`); + const subclassSummary = getSummaryFromEntries(subclass.entries); + + return { + name: subclassName, + source: subclassSource, + hash, + entityRef, + details: subclassSummary + }; + }); + + featureCards.unshift({ + id: "subclass-choice", + title: normalizeString(cls.subclassTitle) || "Subclass", + level: null, + summary: "Select one option.", + options: subclassOptions + }); + } + + const detail: CharacterSelectionDetail = { + ...option, + featureCards, + rawJson: JSON.stringify(cls, null, 2) + }; + + classOptions.push(option); + detailsByRef.set(option.entityRef, detail); + } + + const backgroundPath = path.join(rootDir, "data", "backgrounds.json"); + const backgroundParsed = readJsonFile(backgroundPath); + const backgroundRecords = isRecord(backgroundParsed) && Array.isArray(backgroundParsed.background) + ? (backgroundParsed.background as BackgroundRecord[]) + : []; + + const backgroundOptions: CharacterSelectionOption[] = []; + for (const bg of backgroundRecords) { + const name = normalizeString(bg.name); + const source = normalizeString(bg.source).toUpperCase(); + if (!name || !source) { + continue; + } + + const sourceMeta = getSourceMeta(source, sourceMetaMap); + const fluff = backgroundFluffMap.get(buildFluffKey(name, source)); + + const abilitySummary = getBackgroundAbilitySummary(bg.ability); + const details = [ + abilitySummary ? `Ability ${abilitySummary}` : "", + typeof bg.page === "number" ? `Page ${bg.page}` : "" + ] + .filter(Boolean) + .join(" • "); + + const summary = fluff?.summary || getSummaryFromEntries(bg.entries); + const option = createOption("background", name, source, details, sourceMeta, fluff?.imagePath || null, summary); + + const featureCards: CharacterFeatureCard[] = []; + collectNamedEntryCards(bg.entries, featureCards, new Set(), 12); + + const detail: CharacterSelectionDetail = { + ...option, + featureCards, + rawJson: JSON.stringify(bg, null, 2) + }; + + backgroundOptions.push(option); + detailsByRef.set(option.entityRef, detail); + } + + const speciesPath = path.join(rootDir, "data", "races.json"); + const speciesParsed = readJsonFile(speciesPath); + const speciesRecords = isRecord(speciesParsed) && Array.isArray(speciesParsed.race) + ? (speciesParsed.race as SpeciesRecord[]) + : []; + + const speciesOptions: CharacterSelectionOption[] = []; + for (const race of speciesRecords) { + const name = normalizeString(race.name); + const source = normalizeString(race.source).toUpperCase(); + if (!name || !source) { + continue; + } + + const sourceMeta = getSourceMeta(source, sourceMetaMap); + const fluff = speciesFluffMap.get(buildFluffKey(name, source)); + + const size = getSpeciesSize(race.size); + const walkSpeed = getSpeciesWalkSpeed(race.speed); + const details = [ + size ? `Size ${size}` : "", + walkSpeed ? `Walk ${walkSpeed}` : "", + typeof race.page === "number" ? `Page ${race.page}` : "" + ] + .filter(Boolean) + .join(" • "); + + const summary = fluff?.summary || getSummaryFromEntries(race.entries); + const option = createOption("species", name, source, details, sourceMeta, fluff?.imagePath || null, summary); + + const featureCards: CharacterFeatureCard[] = []; + collectNamedEntryCards(race.entries, featureCards, new Set(), 12); + + const detail: CharacterSelectionDetail = { + ...option, + featureCards, + rawJson: JSON.stringify(race, null, 2) + }; + + speciesOptions.push(option); + detailsByRef.set(option.entityRef, detail); + } + + const sortOptions = (arr: CharacterSelectionOption[]): CharacterSelectionOption[] => + arr + .sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)) + .filter((option, index, sourceArr) => index === sourceArr.findIndex((it) => it.entityRef === option.entityRef)); + + return { + rootDir, + options: { + classes: sortOptions(classOptions), + backgrounds: sortOptions(backgroundOptions), + species: sortOptions(speciesOptions) + }, + detailsByRef + }; +} + +function getCache(rootDir: string): CacheValue { + if (cache && cache.rootDir === rootDir) { + return cache; + } + + const value = buildCache(rootDir); + cache = value; + return value; +} + +export function getCharacterSelectionOptions(rootDir: string): CharacterSelectionOptionSets { + return getCache(rootDir).options; +} + +export function getCharacterSelectionDetail(rootDir: string, entityRefRaw: string): CharacterSelectionDetail | null { + const entityRef = entityRefRaw.trim(); + if (!entityRef) { + return null; + } + + const detail = getCache(rootDir).detailsByRef.get(entityRef); + return detail || null; +} + +export function clearCharacterSelectionCache(): void { + cache = null; +} diff --git a/src/main/characterStore.ts b/src/main/characterStore.ts new file mode 100644 index 0000000..26791a4 --- /dev/null +++ b/src/main/characterStore.ts @@ -0,0 +1,721 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +export type CharacterEntityType = "class" | "subclass" | "background" | "species" | "item" | "feat" | "spell"; +export type AdvancementType = "milestone" | "xp"; +export type HpMode = "average" | "maximum" | "rolled"; +export type AbilityScoreMethod = "standardArray" | "pointBuy" | "manual" | "rolled"; +export type AbilityKey = "str" | "dex" | "con" | "int" | "wis" | "cha"; + +export interface CharacterEntityRef { + entityType: CharacterEntityType; + name: string; + source: string; + hash: string; + entityRef: string; +} + +export interface CharacterProfile { + name: string; + level: number; + alignment: string; + faith: string; + lifestyle: string; + age: string; + gender: string; + height: string; + weight: string; + eyes: string; + skin: string; + hair: string; +} + +export interface CharacterPreferencesStep { + sourceBuckets: string[]; + useOptionalClassFeatures: boolean; + customizeOrigin: boolean; + advancementType: AdvancementType; + hpMode: HpMode; + usePrerequisitesFeats: boolean; + usePrerequisitesMulticlass: boolean; +} + +export interface CharacterAbilityScoresStep { + method: AbilityScoreMethod; + scores: Record; + rolledValues: [number, number, number, number, number, number]; + assignments: Record; + backgroundBonusModeIndex: number; + backgroundBonusSelections: AbilityKey[]; +} + +export interface CharacterFeatsStep { + selected: string[]; + notes: string; +} + +export interface CharacterSkillsStep { + selected: string[]; + notes: string; +} + +export interface CharacterSpellsStep { + selected: string[]; + notes: string; +} + +export interface CharacterEquipmentStep { + startingEquipmentText: string; + inventoryText: string; + possessionsText: string; + currency: Record<"cp" | "sp" | "ep" | "gp" | "pp", number>; +} + +export interface CharacterAboutStep { + personality: string; + ideals: string; + bonds: string; + flaws: string; + backstory: string; + notes: string; +} + +export interface CharacterSteps { + preferences: CharacterPreferencesStep; + abilityScores: CharacterAbilityScoresStep; + feats: CharacterFeatsStep; + skills: CharacterSkillsStep; + spells: CharacterSpellsStep; + equipment: CharacterEquipmentStep; + about: CharacterAboutStep; +} + +export interface CharacterSelections { + class: CharacterEntityRef | null; + subclass: CharacterEntityRef | null; + background: CharacterEntityRef | null; + species: CharacterEntityRef | null; +} + +export interface CharacterDocument { + schema: "5etd.character"; + version: 1; + id: string; + createdAt: string; + updatedAt: string; + profile: CharacterProfile; + selections: CharacterSelections; + steps: CharacterSteps; + notes: string; +} + +export interface CharacterSummary { + id: string; + name: string; + level: number; + className: string | null; + backgroundName: string | null; + speciesName: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CharacterUpsertInput { + id?: string; + profile?: Partial; + selections?: Partial; + steps?: Partial; + notes?: string; + createdAt?: string; +} + +const CHARACTER_SCHEMA = "5etd.character"; +const CHARACTER_VERSION = 1; +const CHARACTERS_DIRNAME = "characters"; +const ABILITY_KEYS: readonly AbilityKey[] = ["str", "dex", "con", "int", "wis", "cha"]; + +const DEFAULT_PROFILE: CharacterProfile = { + name: "Unnamed Character", + level: 1, + alignment: "", + faith: "", + lifestyle: "", + age: "", + gender: "", + height: "", + weight: "", + eyes: "", + skin: "", + hair: "" +}; + +const DEFAULT_STEPS: CharacterSteps = { + 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: [15, 14, 13, 12, 10, 8], + 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: "" + } +}; + +const DEFAULT_SELECTIONS: CharacterSelections = { + class: null, + subclass: null, + background: null, + species: null +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function toString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function toBoolean(value: unknown, fallback = false): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function toInteger(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || Number.isNaN(value)) { + return fallback; + } + + const rounded = Math.trunc(value); + return Math.min(max, Math.max(min, rounded)); +} + +function toIsoDate(value: unknown, fallback: string): string { + if (typeof value !== "string") { + return fallback; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return fallback; + } + + return date.toISOString(); +} + +function toSourceBuckets(value: unknown, fallback: string[]): string[] { + if (!Array.isArray(value)) { + return fallback; + } + + const cleaned = value + .map((it) => (typeof it === "string" ? it.trim() : "")) + .filter((it) => Boolean(it)); + + return cleaned.length ? Array.from(new Set(cleaned)) : fallback; +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set( + value + .map((it) => (typeof it === "string" ? it.trim() : "")) + .filter((it) => Boolean(it)) + ) + ); +} + +function toAbilityKeys(value: unknown, max = 6): AbilityKey[] { + if (!Array.isArray(value)) { + return []; + } + + const out: AbilityKey[] = []; + for (const item of value) { + if (typeof item !== "string") { + continue; + } + + const lowered = item.trim().toLowerCase(); + if (!lowered || !ABILITY_KEYS.includes(lowered as AbilityKey)) { + continue; + } + + const key = lowered as AbilityKey; + if (!out.includes(key)) { + out.push(key); + } + + if (out.length >= max) { + break; + } + } + + return out; +} + +function sanitizeToken(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +function buildEntityRef(entityType: CharacterEntityType, source: string, hash: string): string { + return `${entityType}|${source}|${hash}`; +} + +function parseEntityRefParts(entityRef: string): { source: string; hash: string } | null { + const parts = entityRef.split("|"); + if (parts.length !== 3) { + return null; + } + + const source = parts[1]?.trim() || ""; + const hash = parts[2]?.trim() || ""; + if (!source || !hash) { + return null; + } + + return { source, hash }; +} + +function normalizeEntityRef(value: unknown, expectedType: CharacterEntityType): CharacterEntityRef | null { + if (!isRecord(value)) { + return null; + } + + const name = toString(value.name).trim(); + const entityRefRaw = toString(value.entityRef).trim(); + + let source = toString(value.source).trim().toUpperCase(); + let hash = toString(value.hash).trim(); + + if ((!source || !hash) && entityRefRaw) { + const parsed = parseEntityRefParts(entityRefRaw); + if (parsed) { + source ||= parsed.source.toUpperCase(); + hash ||= parsed.hash; + } + } + + if (!hash && name && source) { + hash = `${sanitizeToken(name)}_${sanitizeToken(source)}`; + } + + if (!name || !source || !hash) { + return null; + } + + return { + entityType: expectedType, + name, + source, + hash, + entityRef: buildEntityRef(expectedType, source, hash) + }; +} + +function normalizeProfile(value: unknown): CharacterProfile { + const profile = isRecord(value) ? value : {}; + return { + name: toString(profile.name, DEFAULT_PROFILE.name).trim() || DEFAULT_PROFILE.name, + level: toInteger(profile.level, DEFAULT_PROFILE.level, 1, 20), + alignment: toString(profile.alignment).trim(), + faith: toString(profile.faith).trim(), + lifestyle: toString(profile.lifestyle).trim(), + age: toString(profile.age).trim(), + gender: toString(profile.gender).trim(), + height: toString(profile.height).trim(), + weight: toString(profile.weight).trim(), + eyes: toString(profile.eyes).trim(), + skin: toString(profile.skin).trim(), + hair: toString(profile.hair).trim() + }; +} + +function normalizePreferences(value: unknown): CharacterPreferencesStep { + const pref = isRecord(value) ? value : {}; + + const advancementType = toString(pref.advancementType, DEFAULT_STEPS.preferences.advancementType); + const hpMode = toString(pref.hpMode, DEFAULT_STEPS.preferences.hpMode); + + return { + sourceBuckets: toSourceBuckets(pref.sourceBuckets, DEFAULT_STEPS.preferences.sourceBuckets), + useOptionalClassFeatures: toBoolean(pref.useOptionalClassFeatures, DEFAULT_STEPS.preferences.useOptionalClassFeatures), + customizeOrigin: toBoolean(pref.customizeOrigin, DEFAULT_STEPS.preferences.customizeOrigin), + advancementType: advancementType === "xp" ? "xp" : "milestone", + hpMode: hpMode === "maximum" || hpMode === "rolled" ? hpMode : "average", + usePrerequisitesFeats: toBoolean(pref.usePrerequisitesFeats, DEFAULT_STEPS.preferences.usePrerequisitesFeats), + usePrerequisitesMulticlass: toBoolean( + pref.usePrerequisitesMulticlass, + DEFAULT_STEPS.preferences.usePrerequisitesMulticlass + ) + }; +} + +function normalizeAbilityScores(value: unknown): CharacterAbilityScoresStep { + const ability = isRecord(value) ? value : {}; + const methodRaw = toString(ability.method, DEFAULT_STEPS.abilityScores.method); + const method: AbilityScoreMethod = + methodRaw === "pointBuy" || methodRaw === "manual" || methodRaw === "rolled" ? methodRaw : "standardArray"; + + const scoresRaw = isRecord(ability.scores) ? ability.scores : {}; + + const rolledRaw = Array.isArray(ability.rolledValues) + ? ability.rolledValues + .map((it) => toInteger(it, 0, 1, 30)) + .filter((it) => Number.isInteger(it) && it > 0) + .slice(0, 6) + : []; + + const rolledValues: [number, number, number, number, number, number] = + rolledRaw.length === 6 + ? ([ + rolledRaw[0], + rolledRaw[1], + rolledRaw[2], + rolledRaw[3], + rolledRaw[4], + rolledRaw[5] + ] as [number, number, number, number, number, number]) + : [...DEFAULT_STEPS.abilityScores.rolledValues]; + + const assignmentsRaw = isRecord(ability.assignments) ? ability.assignments : {}; + const assignments: Record = { + str: toInteger(assignmentsRaw.str, DEFAULT_STEPS.abilityScores.assignments.str, 0, 5), + dex: toInteger(assignmentsRaw.dex, DEFAULT_STEPS.abilityScores.assignments.dex, 0, 5), + con: toInteger(assignmentsRaw.con, DEFAULT_STEPS.abilityScores.assignments.con, 0, 5), + int: toInteger(assignmentsRaw.int, DEFAULT_STEPS.abilityScores.assignments.int, 0, 5), + wis: toInteger(assignmentsRaw.wis, DEFAULT_STEPS.abilityScores.assignments.wis, 0, 5), + cha: toInteger(assignmentsRaw.cha, DEFAULT_STEPS.abilityScores.assignments.cha, 0, 5) + }; + + return { + method, + scores: { + str: toInteger(scoresRaw.str, DEFAULT_STEPS.abilityScores.scores.str, 1, 30), + dex: toInteger(scoresRaw.dex, DEFAULT_STEPS.abilityScores.scores.dex, 1, 30), + con: toInteger(scoresRaw.con, DEFAULT_STEPS.abilityScores.scores.con, 1, 30), + int: toInteger(scoresRaw.int, DEFAULT_STEPS.abilityScores.scores.int, 1, 30), + wis: toInteger(scoresRaw.wis, DEFAULT_STEPS.abilityScores.scores.wis, 1, 30), + cha: toInteger(scoresRaw.cha, DEFAULT_STEPS.abilityScores.scores.cha, 1, 30) + }, + rolledValues, + assignments, + backgroundBonusModeIndex: toInteger(ability.backgroundBonusModeIndex, DEFAULT_STEPS.abilityScores.backgroundBonusModeIndex, 0, 10), + backgroundBonusSelections: toAbilityKeys(ability.backgroundBonusSelections, 6) + }; +} + +function normalizeFeats(value: unknown): CharacterFeatsStep { + const step = isRecord(value) ? value : {}; + return { + selected: toStringArray(step.selected), + notes: toString(step.notes).trim() + }; +} + +function normalizeSkills(value: unknown): CharacterSkillsStep { + const step = isRecord(value) ? value : {}; + return { + selected: toStringArray(step.selected), + notes: toString(step.notes).trim() + }; +} + +function normalizeSpells(value: unknown): CharacterSpellsStep { + const step = isRecord(value) ? value : {}; + return { + selected: toStringArray(step.selected), + notes: toString(step.notes).trim() + }; +} + +function normalizeEquipment(value: unknown): CharacterEquipmentStep { + const equipment = isRecord(value) ? value : {}; + const currencyRaw = isRecord(equipment.currency) ? equipment.currency : {}; + + return { + startingEquipmentText: toString(equipment.startingEquipmentText).trim(), + inventoryText: toString(equipment.inventoryText).trim(), + possessionsText: toString(equipment.possessionsText).trim(), + currency: { + cp: toInteger(currencyRaw.cp, DEFAULT_STEPS.equipment.currency.cp, 0, 999999), + sp: toInteger(currencyRaw.sp, DEFAULT_STEPS.equipment.currency.sp, 0, 999999), + ep: toInteger(currencyRaw.ep, DEFAULT_STEPS.equipment.currency.ep, 0, 999999), + gp: toInteger(currencyRaw.gp, DEFAULT_STEPS.equipment.currency.gp, 0, 999999), + pp: toInteger(currencyRaw.pp, DEFAULT_STEPS.equipment.currency.pp, 0, 999999) + } + }; +} + +function normalizeAbout(value: unknown): CharacterAboutStep { + const about = isRecord(value) ? value : {}; + return { + personality: toString(about.personality).trim(), + ideals: toString(about.ideals).trim(), + bonds: toString(about.bonds).trim(), + flaws: toString(about.flaws).trim(), + backstory: toString(about.backstory).trim(), + notes: toString(about.notes).trim() + }; +} + +function normalizeSelections(value: unknown): CharacterSelections { + const selections = isRecord(value) ? value : {}; + return { + class: normalizeEntityRef(selections.class, "class"), + subclass: normalizeEntityRef(selections.subclass, "subclass"), + background: normalizeEntityRef(selections.background, "background"), + species: normalizeEntityRef(selections.species, "species") + }; +} + +function normalizeSteps(value: unknown): CharacterSteps { + const steps = isRecord(value) ? value : {}; + return { + preferences: normalizePreferences(steps.preferences), + abilityScores: normalizeAbilityScores(steps.abilityScores), + feats: normalizeFeats(steps.feats), + skills: normalizeSkills(steps.skills), + spells: normalizeSpells(steps.spells), + equipment: normalizeEquipment(steps.equipment), + about: normalizeAbout(steps.about) + }; +} + +function normalizeCharacterRecord(raw: unknown): CharacterDocument { + const nowIso = new Date().toISOString(); + const root = isRecord(raw) ? raw : {}; + + const profile = normalizeProfile(root.profile); + const selections = normalizeSelections(root.selections); + const steps = normalizeSteps(root.steps); + + const idCandidate = toString(root.id).trim(); + const id = idCandidate || randomUUID(); + + return { + schema: CHARACTER_SCHEMA, + version: CHARACTER_VERSION, + id, + createdAt: toIsoDate(root.createdAt, nowIso), + updatedAt: toIsoDate(root.updatedAt, nowIso), + profile, + selections, + steps, + notes: toString(root.notes).trim() + }; +} + +function getCharacterFilePath(charactersDir: string, id: string): string { + return path.join(charactersDir, `${id}.json`); +} + +function ensureCharactersDir(charactersDir: string): void { + fs.mkdirSync(charactersDir, { recursive: true }); +} + +function readCharacterFile(filePath: string): CharacterDocument { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + return normalizeCharacterRecord(parsed); +} + +function writeCharacterFile(charactersDir: string, character: CharacterDocument): void { + ensureCharactersDir(charactersDir); + const filePath = getCharacterFilePath(charactersDir, character.id); + fs.writeFileSync(filePath, JSON.stringify(character, null, 2), "utf8"); +} + +export function getCharactersDirectory(userDataDir: string): string { + return path.join(userDataDir, CHARACTERS_DIRNAME); +} + +export function createDefaultCharacter(): CharacterDocument { + const nowIso = new Date().toISOString(); + return { + schema: CHARACTER_SCHEMA, + version: CHARACTER_VERSION, + id: randomUUID(), + createdAt: nowIso, + updatedAt: nowIso, + profile: { ...DEFAULT_PROFILE }, + selections: { ...DEFAULT_SELECTIONS }, + steps: { + preferences: { ...DEFAULT_STEPS.preferences }, + abilityScores: { + ...DEFAULT_STEPS.abilityScores, + scores: { ...DEFAULT_STEPS.abilityScores.scores }, + rolledValues: [...DEFAULT_STEPS.abilityScores.rolledValues] as [number, number, number, number, number, number], + assignments: { ...DEFAULT_STEPS.abilityScores.assignments }, + backgroundBonusSelections: [...DEFAULT_STEPS.abilityScores.backgroundBonusSelections] + }, + feats: { + ...DEFAULT_STEPS.feats, + selected: [...DEFAULT_STEPS.feats.selected] + }, + skills: { + ...DEFAULT_STEPS.skills, + selected: [...DEFAULT_STEPS.skills.selected] + }, + spells: { + ...DEFAULT_STEPS.spells, + selected: [...DEFAULT_STEPS.spells.selected] + }, + equipment: { + ...DEFAULT_STEPS.equipment, + currency: { ...DEFAULT_STEPS.equipment.currency } + }, + about: { + ...DEFAULT_STEPS.about + } + }, + notes: "" + }; +} + +export function listCharacters(charactersDir: string): CharacterSummary[] { + if (!fs.existsSync(charactersDir)) { + return []; + } + + const entries = fs.readdirSync(charactersDir, { withFileTypes: true }); + const characters: CharacterSummary[] = []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) { + continue; + } + + const filePath = path.join(charactersDir, entry.name); + try { + const character = readCharacterFile(filePath); + characters.push({ + id: character.id, + name: character.profile.name, + level: character.profile.level, + className: character.selections.class?.name ?? null, + backgroundName: character.selections.background?.name ?? null, + speciesName: character.selections.species?.name ?? null, + createdAt: character.createdAt, + updatedAt: character.updatedAt + }); + } catch { + continue; + } + } + + return characters.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export function getCharacterById(charactersDir: string, id: string): CharacterDocument | null { + const filePath = getCharacterFilePath(charactersDir, id); + if (!fs.existsSync(filePath)) { + return null; + } + + return readCharacterFile(filePath); +} + +export function saveCharacter(charactersDir: string, input: CharacterUpsertInput): CharacterDocument { + const nowIso = new Date().toISOString(); + + const existing = input.id ? getCharacterById(charactersDir, input.id) : null; + const normalized = normalizeCharacterRecord({ + ...input, + id: input.id || existing?.id || randomUUID(), + createdAt: existing?.createdAt ?? input.createdAt ?? nowIso, + updatedAt: nowIso + }); + + writeCharacterFile(charactersDir, normalized); + return normalized; +} + +export function importCharacterFromFile(charactersDir: string, sourceFilePath: string): CharacterDocument { + const raw = fs.readFileSync(sourceFilePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + const normalized = normalizeCharacterRecord(parsed); + + const existing = getCharacterById(charactersDir, normalized.id); + const nowIso = new Date().toISOString(); + const persisted: CharacterDocument = { + ...normalized, + createdAt: existing?.createdAt ?? normalized.createdAt ?? nowIso, + updatedAt: nowIso + }; + + writeCharacterFile(charactersDir, persisted); + return persisted; +} + +export function deleteCharacter(charactersDir: string, id: string): boolean { + const filePath = getCharacterFilePath(charactersDir, id); + if (!fs.existsSync(filePath)) { + return false; + } + + fs.rmSync(filePath, { force: true }); + return true; +} diff --git a/src/main/main.ts b/src/main/main.ts index de3aa27..5b0494f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,7 +1,15 @@ import fs from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; -import { app, BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"; +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + type MenuItemConstructorOptions, + type OpenDialogOptions +} from "electron"; import { clearDesktopAppConfig, loadDesktopAppConfig, @@ -9,6 +17,42 @@ import { type DesktopAppConfig } from "./appConfig"; import { DEFAULT_EXTERNAL_IMAGE_ROOT, findWikiRootDir, startWikiServer, type WikiServer } from "./wikiServer"; +import { clearCharacterSelectionCache, getCharacterSelectionDetail, getCharacterSelectionOptions } from "./characterData"; +import { + deleteCharacter, + getCharacterById, + getCharactersDirectory, + importCharacterFromFile, + listCharacters, + saveCharacter, + type CharacterDocument, + type CharacterSummary, + type CharacterUpsertInput +} from "./characterStore"; + +// Hardware acceleration is enabled by default. +// Set ELECTRON_DISABLE_HARDWARE_ACCELERATION=1 to opt out for troubleshooting. +if (process.env.ELECTRON_DISABLE_HARDWARE_ACCELERATION === "1") { + app.disableHardwareAcceleration(); +} + +// Keep hardware acceleration enabled, but allow backend tuning on Apple Silicon. +// Electron/Chromium can emit mailbox errors on some macOS GPU driver paths. +// Using ANGLE "gl" is often more stable while still GPU-accelerated. +if (process.platform === "darwin" && process.arch === "arm64") { + const requestedAngleBackend = (process.env.ELECTRON_ANGLE_BACKEND || "").trim().toLowerCase(); + const angleBackend = + requestedAngleBackend === "metal" || requestedAngleBackend === "gl" || requestedAngleBackend === "default" + ? requestedAngleBackend + : "gl"; + + if (angleBackend !== "default") { + app.commandLine.appendSwitch("use-angle", angleBackend); + } + + // Prefer the validating command decoder for stability over passthrough on some drivers. + app.commandLine.appendSwitch("use-cmd-decoder", "validating"); +} const DEV_RENDERER_URL = process.env.VITE_DEV_SERVER_URL; const DEFAULT_SOURCE_REPO_URL = "https://github.com/5etools-mirror-3/5etools-src.git"; @@ -39,12 +83,55 @@ interface SetupRunResult { error?: string; } +type CharacterSelectionOptionSets = ReturnType; +type CharacterSelectionDetail = NonNullable>; + let mainWindow: BrowserWindow | null = null; let wikiServer: WikiServer | null = null; let wikiServerRootDir: string | null = null; let wikiServerExternalImageRoot: string | null = null; let isSetupInProgress = false; +function setApplicationMenu(): void { + const isMac = process.platform === "darwin"; + + const template: MenuItemConstructorOptions[] = isMac + ? [ + { + label: app.name, + submenu: [{ role: "about" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" }] + }, + { + label: "Edit", + submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "pasteAndMatchStyle" }, { role: "delete" }, { role: "selectAll" }] + }, + { + label: "View", + submenu: [{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }] + }, + { + label: "Window", + submenu: [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] + } + ] + : [ + { + label: "File", + submenu: [{ role: "quit" }] + }, + { + label: "Edit", + submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "delete" }, { role: "selectAll" }] + }, + { + label: "View", + submenu: [{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }] + } + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + function isDevMode(): boolean { return !app.isPackaged; } @@ -136,6 +223,29 @@ function normalizeRequiredZipPath(raw: string): string { return absolutePath; } +function normalizeRequiredJsonPath(raw: string, label: string): string { + const value = raw.trim(); + if (!value) { + throw new Error(`${label} path is required.`); + } + + const absolutePath = path.resolve(value); + if (!fs.existsSync(absolutePath)) { + throw new Error(`${label} path does not exist: ${absolutePath}`); + } + + const stat = fs.statSync(absolutePath); + if (!stat.isFile()) { + throw new Error(`${label} path is not a file: ${absolutePath}`); + } + + if (path.extname(absolutePath).toLowerCase() !== ".json") { + throw new Error(`${label} file must be a .json file.`); + } + + return absolutePath; +} + function isWikiRootDirectory(dir: string): boolean { return fs.existsSync(path.join(dir, "5etools.html")) && fs.existsSync(path.join(dir, "data")); } @@ -329,6 +439,7 @@ async function prepareSourceFromSetup(input: SetupRunInput): Promise<{ rootDir: imageBaseUrl, downloadImages }); + clearCharacterSelectionCache(); return { rootDir, externalImageRoot: imageBaseUrl }; } @@ -451,6 +562,7 @@ function clearOnboardingData(): void { } clearDesktopAppConfig(); + clearCharacterSelectionCache(); } function resolveConfiguredSource(config: DesktopAppConfig): string | null { @@ -476,6 +588,28 @@ async function pTryStartWikiFromLocalConfig(): Promise { return true; } +function getCharactersDir(): string { + return getCharactersDirectory(app.getPath("userData")); +} + +function getCharacterOptions(): CharacterSelectionOptionSets { + const rootDir = wikiServerRootDir ?? resolveConfiguredSource(loadDesktopAppConfig()); + if (!rootDir) { + throw new Error("Cannot load character options: 5etools source is unavailable."); + } + + return getCharacterSelectionOptions(rootDir); +} + +function getCharacterOptionDetail(entityRef: string): CharacterSelectionDetail | null { + const rootDir = wikiServerRootDir ?? resolveConfiguredSource(loadDesktopAppConfig()); + if (!rootDir) { + throw new Error("Cannot load character option details: 5etools source is unavailable."); + } + + return getCharacterSelectionDetail(rootDir, entityRef); +} + function registerIpcHandlers(): void { ipcMain.handle("wiki:get-url", async () => { if (!wikiServer) { @@ -540,10 +674,71 @@ function registerIpcHandlers(): void { isSetupInProgress = false; } }); + + ipcMain.handle("characters:list", async (): Promise => { + return listCharacters(getCharactersDir()); + }); + + ipcMain.handle("characters:get", async (_event, id: string): Promise => { + const sanitizedId = String(id || "").trim(); + if (!sanitizedId) { + return null; + } + + return getCharacterById(getCharactersDir(), sanitizedId); + }); + + ipcMain.handle("characters:save", async (_event, input: CharacterUpsertInput): Promise => { + return saveCharacter(getCharactersDir(), input); + }); + + ipcMain.handle("characters:delete", async (_event, id: string): Promise => { + const sanitizedId = String(id || "").trim(); + if (!sanitizedId) { + return false; + } + + return deleteCharacter(getCharactersDir(), sanitizedId); + }); + + ipcMain.handle("characters:get-options", async (): Promise => { + return getCharacterOptions(); + }); + + ipcMain.handle("characters:get-option-detail", async (_event, entityRefRaw: string): Promise => { + const entityRef = String(entityRefRaw || "").trim(); + if (!entityRef) { + return null; + } + + return getCharacterOptionDetail(entityRef); + }); + + ipcMain.handle("characters:pick-json", async (event): Promise => { + const owner = BrowserWindow.fromWebContents(event.sender) || mainWindow; + const dialogOptions: OpenDialogOptions = { + title: "Select Character JSON", + properties: ["openFile"], + filters: [{ name: "JSON files", extensions: ["json"] }] + }; + const result = owner ? await dialog.showOpenDialog(owner, dialogOptions) : await dialog.showOpenDialog(dialogOptions); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + return result.filePaths[0] || null; + }); + + ipcMain.handle("characters:import-json", async (_event, inputPath: string): Promise => { + const sourcePath = normalizeRequiredJsonPath(String(inputPath || ""), "Character JSON"); + return importCharacterFromFile(getCharactersDir(), sourcePath); + }); } async function bootstrap(): Promise { registerIpcHandlers(); + setApplicationMenu(); const hasLocalSource = await pTryStartWikiFromLocalConfig(); await openAppWindow(!hasLocalSource); diff --git a/src/main/wikiServer.ts b/src/main/wikiServer.ts index 2367ed7..75604dd 100644 --- a/src/main/wikiServer.ts +++ b/src/main/wikiServer.ts @@ -39,13 +39,26 @@ const MIME_TYPES: Record = { ".xml": "application/xml; charset=utf-8" }; +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" +}; + +function withCorsHeaders(headers: Record): Record { + return { + ...CORS_HEADERS, + ...headers + }; +} + function getDesktopReskinBootstrap(externalImageRoot: string | null): string { return ` (() => { ${externalImageRoot ? `globalThis.DEPLOYED_IMG_ROOT = ${JSON.stringify(externalImageRoot)};` : ""} const root = document.documentElement; - root.classList.add("desktop-reskin"); + root.classList.add("desktop-reskin", "desktop-app-navbar-mode"); ${ SHOULD_FORCE_NIGHT_THEME @@ -69,6 +82,15 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string { console.info("[5ET-D]", content); }; + const postToDesktop = (type, payload) => { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ type, ...(payload || {}) }, "*"); + return true; + } + + return false; + }; + const requestOnboardingReset = () => { const message = [ "Reset onboarding and remove local 5etools data?", @@ -89,20 +111,6 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string { doToastInfo("Reset onboarding is unavailable in this context."); }; - window.addEventListener("message", (event) => { - const data = event?.data; - if (!data || typeof data !== "object") { - return; - } - - if (data.type !== "5etd:reset-onboarding-result" || data.ok !== false) { - return; - } - - const reason = typeof data.error === "string" && data.error ? data.error : "unknown error"; - doToastInfo(\`Reset onboarding failed: \${reason}\`); - }); - const closeDropdown = (li, toggle) => { if (typeof NavBar !== "undefined" && typeof NavBar._closeDropdownElement === "function") { NavBar._closeDropdownElement(li); @@ -175,9 +183,21 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string { menu.append(menuItem); }; - addMenuItem("New", () => doToastInfo("Characters: New is coming in a later build.")); - addMenuItem("Load from JSON", () => doToastInfo("Characters: Load from JSON is coming in a later build.")); - addMenuItem("My Characters", () => doToastInfo("Characters: My Characters is coming in a later build.")); + addMenuItem("New", () => { + if (!postToDesktop("5etd:characters:new")) { + doToastInfo("Characters menu is unavailable in this context."); + } + }); + addMenuItem("Load from JSON", () => { + if (!postToDesktop("5etd:characters:load-json")) { + doToastInfo("Characters menu is unavailable in this context."); + } + }); + addMenuItem("My Characters", () => { + if (!postToDesktop("5etd:characters:list")) { + doToastInfo("Characters menu is unavailable in this context."); + } + }); li.append(toggle, menu); @@ -329,9 +349,221 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string { const applyDesktopMenuExtensions = () => { addCharactersMenu(); addResetOnboardingMenu(); - scheduleAutoFocusSearch(); + bindNativeNavbarSync(); + postNativeNavbarModel(); }; + const NAVBAR_ACTION_ATTR = "data-5etd-nav-action-id"; + + const cleanNavLabel = (value) => String(value || "").replace(/\\s+/g, " ").replace(/[▾▼►]+/g, "").trim(); + + const getNavPrimaryText = (ele) => { + for (const node of Array.from(ele.childNodes || [])) { + if (node.nodeType === Node.TEXT_NODE) { + const text = cleanNavLabel(node.textContent || ""); + if (text) return text; + } + } + return cleanNavLabel(ele.textContent || ""); + }; + + const isActionLikeHref = (hrefRaw) => { + const href = String(hrefRaw || "").trim().toLowerCase(); + return !href || href === "#" || href.startsWith("javascript:"); + }; + + const parseNavbarMenuList = (list, idPrefix) => { + const entries = []; + const liElements = Array.from(list?.children || []).filter((it) => it instanceof HTMLLIElement); + + liElements.forEach((li, index) => { + const entryId = \`\${idPrefix}-\${index}\`; + + 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 = cleanNavLabel(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"); + const submenuList = li.querySelector(":scope > ul"); + const label = toggle ? getNavPrimaryText(toggle) : ""; + if (!label) return; + + entries.push({ + id: \`\${entryId}-submenu\`, + type: "submenu", + label, + children: submenuList ? parseNavbarMenuList(submenuList, \`\${entryId}-submenu\`) : [] + }); + return; + } + + const link = li.querySelector(":scope > a"); + if (link) { + const label = getNavPrimaryText(link); + const href = String(link.getAttribute("href") || "").trim(); + if (isActionLikeHref(href)) { + const actionId = \`\${entryId}-action\`; + link.setAttribute(NAVBAR_ACTION_ATTR, actionId); + entries.push({ id: \`\${entryId}-action\`, type: "action", label: label || "Action", actionId }); + return; + } + + entries.push({ + id: \`\${entryId}-link\`, + type: "link", + label: label || href, + href, + isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href) + }); + return; + } + + const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it) => it instanceof HTMLSpanElement); + spans.forEach((span, spanIndex) => { + const label = cleanNavLabel(span.textContent || span.title || ""); + const actionId = \`\${entryId}-span-\${spanIndex}\`; + span.setAttribute(NAVBAR_ACTION_ATTR, actionId); + entries.push({ id: \`\${entryId}-span-\${spanIndex}\`, type: "action", label: label || "Action", actionId }); + }); + }); + + return entries; + }; + + const buildNativeNavbarGroups = () => { + const navbar = document.getElementById("navbar"); + if (!navbar) return []; + + const groups = []; + const rootItems = Array.from(navbar.children).filter((it) => 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"); + const menuList = li.querySelector(":scope > ul"); + const label = toggle ? getNavPrimaryText(toggle) : ""; + if (!label) return; + + groups.push({ + id: \`root-\${index}-dropdown\`, + kind: "dropdown", + label, + items: menuList ? parseNavbarMenuList(menuList, \`root-\${index}-list\`) : [] + }); + return; + } + + const link = li.querySelector(":scope > a"); + if (!link) return; + + const label = getNavPrimaryText(link); + const href = String(link.getAttribute("href") || "").trim(); + if (!label) return; + + if (isActionLikeHref(href)) { + const actionId = \`root-\${index}-action\`; + link.setAttribute(NAVBAR_ACTION_ATTR, actionId); + groups.push({ + id: \`root-\${index}-action\`, + kind: "action", + label, + actionId + }); + return; + } + + groups.push({ + id: \`root-\${index}-link\`, + kind: "link", + label, + href, + isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href) + }); + }); + + return groups; + }; + + const postNativeNavbarModel = () => { + if (!window.parent || window.parent === window) return; + window.parent.postMessage( + { + type: "5etd:native-navbar-data", + groups: buildNativeNavbarGroups() + }, + "*" + ); + }; + + let navbarSyncObserver = null; + const bindNativeNavbarSync = () => { + const navbar = document.getElementById("navbar"); + if (!navbar || navbar.dataset.desktopNavSyncBound === "1") { + return; + } + + navbar.dataset.desktopNavSyncBound = "1"; + navbarSyncObserver?.disconnect?.(); + navbarSyncObserver = new MutationObserver(() => { + window.requestAnimationFrame(postNativeNavbarModel); + }); + navbarSyncObserver.observe(navbar, { childList: true, subtree: true, attributes: true, characterData: true }); + }; + + const runNativeNavbarAction = (actionId) => { + const id = String(actionId || "").trim(); + if (!id) return; + + const navbar = document.getElementById("navbar"); + if (!navbar) return; + + const actionEle = navbar.querySelector(\`[\${NAVBAR_ACTION_ATTR}="\${id}"]\`); + if (!(actionEle instanceof HTMLElement)) { + return; + } + + actionEle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); + actionEle.click(); + }; + + window.addEventListener("message", (event) => { + const data = event?.data; + if (!data || typeof data !== "object") { + return; + } + + if (data.type === "5etd:reset-onboarding-result" && data.ok === false) { + const reason = typeof data.error === "string" && data.error ? data.error : "unknown error"; + doToastInfo(\`Reset onboarding failed: \${reason}\`); + return; + } + + if (data.type === "5etd:native-navbar-request") { + bindNativeNavbarSync(); + postNativeNavbarModel(); + return; + } + + if (data.type === "5etd:native-navbar-run-action") { + runNativeNavbarAction(data.actionId); + } + }); + window.addEventListener("DOMContentLoaded", applyDesktopMenuExtensions); window.addEventListener("load", applyDesktopMenuExtensions); })(); @@ -1001,6 +1233,27 @@ html.desktop-reskin .modal__inner { border-radius: 12px !important; box-shadow: 0 16px 28px rgba(0, 0, 0, 0.35); } + +/* App-managed top bar mode: keep 5etools content, hide native header/nav chrome */ +html.desktop-reskin.desktop-app-navbar-mode body { + padding-top: 0 !important; +} + +html.desktop-reskin.desktop-app-navbar-mode .viewport-wrapper { + inset: 0 !important; + height: auto !important; + min-height: 100vh !important; +} + +html.desktop-reskin.desktop-app-navbar-mode header.page__header, +html.desktop-reskin.desktop-app-navbar-mode .page__header, +html.desktop-reskin.desktop-app-navbar-mode #navigation, +html.desktop-reskin.desktop-app-navbar-mode nav.page__nav, +html.desktop-reskin.desktop-app-navbar-mode #navigation.page__nav, +html.desktop-reskin.desktop-app-navbar-mode .page__nav, +html.desktop-reskin.desktop-app-navbar-mode .page__disp-download-progress { + display: none !important; +} `; export interface WikiServer { @@ -1127,10 +1380,10 @@ async function pTryServeExternalImage( const responseBuffer = Buffer.from(await response.arrayBuffer()); const contentType = response.headers.get("content-type") ?? getMimeType(externalImageUrl.pathname); - res.writeHead(200, { + res.writeHead(200, withCorsHeaders({ "Content-Type": contentType, "Cache-Control": "public, max-age=3600" - }); + })); res.end(responseBuffer); return true; } catch { @@ -1184,13 +1437,19 @@ export async function startWikiServer(options: StartWikiServerOptions = {}): Pro return await new Promise((resolve, reject) => { const server = http.createServer((req, res) => { + if (req.method === "OPTIONS") { + res.writeHead(204, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" })); + res.end(); + return; + } + const url = req.url ?? "/"; let filePath: string; try { filePath = resolveRequestPath(rootDir, url); } catch { - res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.writeHead(403, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" })); res.end("Forbidden"); return; } @@ -1203,13 +1462,13 @@ export async function startWikiServer(options: StartWikiServerOptions = {}): Pro return; } - res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.writeHead(404, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" })); res.end("Not Found"); }); return; } - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.writeHead(500, withCorsHeaders({ "Content-Type": "text/plain; charset=utf-8" })); res.end("Internal Server Error"); return; } @@ -1218,10 +1477,10 @@ export async function startWikiServer(options: StartWikiServerOptions = {}): Pro const transformedBody = extension === ".html" && SHOULD_INJECT_RESKIN ? injectDesktopReskin(data.toString("utf8"), reskinBootstrap) : data; - res.writeHead(200, { + res.writeHead(200, withCorsHeaders({ "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" - }); + })); if (typeof transformedBody === "string") { res.end(transformedBody, "utf8"); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 8691f79..795019e 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -22,10 +22,184 @@ interface SetupRunResult { error?: string; } +type CharacterEntityType = "class" | "background" | "species"; +type CharacterSourceBucket = "homebrew" | "core2024" | "expanded2024" | "core2014" | "expanded2014"; + +interface CharacterSelectionOption { + entityType: CharacterEntityType; + name: string; + source: string; + hash: string; + entityRef: string; + details: string; + sourceName: string; + sourceGroup: string | null; + sourcePublished: string | null; + sourceBucket: CharacterSourceBucket; + sourceEdition: "2014" | "2024" | null; + imagePath: string | null; + summary: string; +} + +interface CharacterFeatureOption { + name: string; + source: string; + hash: string; + entityRef: string; + details: string; +} + +interface CharacterFeatureCard { + id: string; + title: string; + level: number | null; + summary: string; + options?: CharacterFeatureOption[]; +} + +interface CharacterSelectionDetail extends CharacterSelectionOption { + featureCards: CharacterFeatureCard[]; + rawJson: string; +} + +interface CharacterSelectionOptionSets { + classes: CharacterSelectionOption[]; + backgrounds: CharacterSelectionOption[]; + species: CharacterSelectionOption[]; +} + +interface CharacterEntityRef { + entityType: "class" | "subclass" | "background" | "species" | "item" | "feat" | "spell"; + name: string; + source: string; + hash: string; + entityRef: string; +} + +interface CharacterProfile { + name: string; + level: number; + alignment: string; + faith: string; + lifestyle: string; + age: string; + gender: string; + height: string; + weight: string; + eyes: string; + skin: string; + hair: string; +} + +interface CharacterPreferencesStep { + sourceBuckets: string[]; + useOptionalClassFeatures: boolean; + customizeOrigin: boolean; + advancementType: "milestone" | "xp"; + hpMode: "average" | "maximum" | "rolled"; + usePrerequisitesFeats: boolean; + usePrerequisitesMulticlass: boolean; +} + +interface CharacterAbilityScoresStep { + method: "standardArray" | "pointBuy" | "manual" | "rolled"; + scores: Record<"str" | "dex" | "con" | "int" | "wis" | "cha", number>; + rolledValues: [number, number, number, number, number, number]; + assignments: Record<"str" | "dex" | "con" | "int" | "wis" | "cha", number>; + backgroundBonusModeIndex: number; + backgroundBonusSelections: Array<"str" | "dex" | "con" | "int" | "wis" | "cha">; +} + +interface CharacterFeatsStep { + selected: string[]; + notes: string; +} + +interface CharacterSkillsStep { + selected: string[]; + notes: string; +} + +interface CharacterSpellsStep { + selected: string[]; + notes: string; +} + +interface CharacterEquipmentStep { + startingEquipmentText: string; + inventoryText: string; + possessionsText: string; + currency: Record<"cp" | "sp" | "ep" | "gp" | "pp", number>; +} + +interface CharacterAboutStep { + personality: string; + ideals: string; + bonds: string; + flaws: string; + backstory: string; + notes: string; +} + +interface CharacterDocument { + schema: "5etd.character"; + version: 1; + id: string; + createdAt: string; + updatedAt: string; + profile: CharacterProfile; + selections: { + class: CharacterEntityRef | null; + subclass: CharacterEntityRef | null; + background: CharacterEntityRef | null; + species: CharacterEntityRef | null; + }; + steps: { + preferences: CharacterPreferencesStep; + abilityScores: CharacterAbilityScoresStep; + feats: CharacterFeatsStep; + skills: CharacterSkillsStep; + spells: CharacterSpellsStep; + equipment: CharacterEquipmentStep; + about: CharacterAboutStep; + }; + notes: string; +} + +interface CharacterSummary { + id: string; + name: string; + level: number; + className: string | null; + backgroundName: string | null; + speciesName: string | null; + createdAt: string; + updatedAt: string; +} + +interface CharacterUpsertInput { + id?: string; + profile?: Partial; + selections?: Partial; + steps?: Partial; + notes?: string; + createdAt?: string; +} + contextBridge.exposeInMainWorld("desktopApi", { getWikiUrl: () => ipcRenderer.invoke("wiki:get-url") as Promise, getSetupState: () => ipcRenderer.invoke("setup:get-state") as Promise, pickSourceZip: () => ipcRenderer.invoke("setup:pick-source-zip") as Promise, runSetup: (input: SetupRunInput) => ipcRenderer.invoke("setup:run", input) as Promise, - resetOnboarding: () => ipcRenderer.invoke("setup:reset-onboarding") as Promise + resetOnboarding: () => ipcRenderer.invoke("setup:reset-onboarding") as Promise, + listCharacters: () => ipcRenderer.invoke("characters:list") as Promise, + getCharacter: (id: string) => ipcRenderer.invoke("characters:get", id) as Promise, + saveCharacter: (input: CharacterUpsertInput) => ipcRenderer.invoke("characters:save", input) as Promise, + deleteCharacter: (id: string) => ipcRenderer.invoke("characters:delete", id) as Promise, + getCharacterOptions: () => ipcRenderer.invoke("characters:get-options") as Promise, + getCharacterOptionDetail: (entityRef: string) => + ipcRenderer.invoke("characters:get-option-detail", entityRef) as Promise, + pickCharacterJson: () => ipcRenderer.invoke("characters:pick-json") as Promise, + importCharacterJson: (filePath: string) => + ipcRenderer.invoke("characters:import-json", filePath) as Promise }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3c671ba..a72b6c1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,7 +1,1737 @@ -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; -import { WIKI_NAV_SECTIONS } from "./wikiNav"; +import { FormEvent, JSX, useCallback, useEffect, useMemo, useRef, useState } from "react"; const DEFAULT_PAGE = "/5etools.html"; +const CARD_PAGE_SIZE = 8; +const APP_CHARACTERS_MENU_ID = "__characters"; + +const CHARACTER_STEPS = [ + "Class", + "Species", + "Background", + "Ability Scores", + "Feats", + "Skills", + "Spells", + "Equipment", + "About", + "Finish" +] as const; + +const ABILITY_KEYS = ["str", "dex", "con", "int", "wis", "cha"] as const; +type AbilityKey = (typeof ABILITY_KEYS)[number]; + +const ABILITY_LABELS: Record = { + str: "Strength", + dex: "Dexterity", + con: "Constitution", + int: "Intelligence", + wis: "Wisdom", + cha: "Charisma" +}; + +const STANDARD_ARRAY: [number, number, number, number, number, number] = [15, 14, 13, 12, 10, 8]; + +const SKILL_OPTIONS = [ + "Acrobatics", + "Animal Handling", + "Arcana", + "Athletics", + "Deception", + "History", + "Insight", + "Intimidation", + "Investigation", + "Medicine", + "Nature", + "Perception", + "Performance", + "Persuasion", + "Religion", + "Sleight of Hand", + "Stealth", + "Survival" +] as const; + +const SOURCE_BUCKET_OPTIONS = [ + { key: "homebrew", label: "Homebrew (non-WotC)" }, + { key: "core2024", label: "Core 2024" }, + { key: "expanded2024", label: "Expanded 2024" }, + { key: "core2014", label: "Core 2014" }, + { key: "expanded2014", label: "Expanded 2014" } +] as const; + +const FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS = [ + "lib/jquery.js", + "js/parser.js", + "js/utils.js", + "js/utils-ui.js", + "lib/localforage.js", + "js/utils-dataloader.js", + "js/render.js", + "js/render-dice.js", + "js/hist.js" +] as const; + +const FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS = [ + "js/utils-brew.js", + "js/utils-config.js", + "js/shim-esmodules.js" +] as const; + +const FIVEETOOLS_RENDER_STYLESHEET_PATHS = ["css/bootstrap.css", "css/main.css"] as const; + +type CharacterModalMode = "closed" | "list" | "editor"; +type EntityPopoverKind = "class" | "species" | "background"; + +type RendererHandle = { + setBaseUrl: (value: string) => RendererHandle; + setAddHandlers: (value: boolean) => RendererHandle; + setFirstSection: (value: boolean) => RendererHandle; + render: (entry: unknown) => string; +}; + +type RendererGlobal = { + get: () => RendererHandle; + hover?: { + _doInit?: () => void; + pHandleLinkMouseOver?: (evt: MouseEvent, ele: HTMLElement, opts?: unknown) => Promise; + handleLinkMouseLeave?: (evt: MouseEvent, ele: HTMLElement) => void; + handleLinkMouseMove?: (evt: MouseEvent, ele: HTMLElement) => void; + handleLinkClick?: (evt: MouseEvent, ele: HTMLElement, opts?: { isForceOpenPermanent?: boolean }) => void; + handleLinkWheel?: (evt: WheelEvent, ele: HTMLElement) => void; + handleLinkDragStart?: (evt: DragEvent, ele: HTMLElement) => void; + }; +}; + +type TopbarMenuEntry = { + id: string; + type: "link" | "action" | "divider" | "label" | "submenu"; + label?: string; + href?: string; + isExternal?: boolean; + actionId?: string; + children?: TopbarMenuEntry[]; +}; + +type TopbarMenuGroup = { + id: string; + kind: "dropdown" | "link" | "action"; + label: string; + href?: string; + isExternal?: boolean; + actionId?: string; + items?: TopbarMenuEntry[]; +}; + +declare global { + interface Window { + Renderer?: RendererGlobal; + JqueryUtil?: { + initEnhancements?: () => void; + }; + TemplateUtil?: { + initVanilla?: () => void; + }; + } +} + +let renderRuntimePromise: Promise | null = null; +let renderRuntimeBaseUrl = ""; + +function normalizeWikiBaseUrl(value: string): string { + return value.replace(/\/+$/, ""); +} + +function cleanTopbarLabel(value: string): string { + return value + .replace(/\s+/g, " ") + .replace(/[▾▼►]+/g, "") + .trim(); +} + +function getElementPrimaryText(ele: Element): string { + for (const node of Array.from(ele.childNodes)) { + if (node.nodeType === Node.TEXT_NODE) { + const text = cleanTopbarLabel(node.textContent || ""); + if (text) { + return text; + } + } + } + return cleanTopbarLabel(ele.textContent || ""); +} + +function getPathnameFromHrefLoose(hrefRaw: string): string { + const href = hrefRaw.trim(); + if (!href) { + return ""; + } + try { + return new URL(href, "http://localhost").pathname.toLowerCase(); + } catch { + return href.split(/[?#]/)[0].toLowerCase(); + } +} + +function areTopbarGroupsEquivalent(a: TopbarMenuGroup[], b: TopbarMenuGroup[]): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function parseNativeTopbarMenuList( + list: HTMLUListElement, + 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}`; + 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 submenuList = li.querySelector(":scope > ul") as HTMLUListElement | null; + const label = toggle ? getElementPrimaryText(toggle) : ""; + if (!label) { + return; + } + entries.push({ + id: `${entryId}-submenu`, + type: "submenu", + label, + children: submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : [] + }); + 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 (isActionLike) { + entries.push({ + id: `${entryId}-action`, + type: "action", + label: label || "Action", + actionId: registerAction(link) + }); + return; + } + + entries.push({ + id: `${entryId}-link`, + type: "link", + label: label || href, + 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); + spans.forEach((span, spanIndex) => { + const label = cleanTopbarLabel(span.textContent || span.title || ""); + entries.push({ + id: `${entryId}-span-${spanIndex}`, + type: "action", + label: label || "Action", + actionId: registerAction(span) + }); + }); + }); + + return entries; +} + +function parseNativeTopbarGroups(navbar: HTMLElement): { + groups: TopbarMenuGroup[]; + actionHandlers: Map void>; +} { + let actionIx = 0; + const actionHandlers = new Map void>(); + const registerAction = (ele: HTMLElement): string => { + const id = `native-action-${actionIx++}`; + actionHandlers.set(id, () => { + ele.click(); + }); + return id; + }; + + const groups: TopbarMenuGroup[] = []; + const rootItems = Array.from(navbar.children).filter((it): it is HTMLLIElement => it instanceof HTMLLIElement); + rootItems.forEach((li, index) => { + if (li.classList.contains("omni__wrp-input") || li.classList.contains("desktop-nav-characters")) { + return; + } + + if (li.classList.contains("dropdown--navbar")) { + const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null; + const menuList = li.querySelector(":scope > ul") as HTMLUListElement | null; + const label = toggle ? getElementPrimaryText(toggle) : ""; + if (!label) { + return; + } + groups.push({ + id: `root-${index}-dropdown`, + kind: "dropdown", + label, + items: menuList ? parseNativeTopbarMenuList(menuList, registerAction, `root-${index}-list`) : [] + }); + return; + } + + const link = li.querySelector(":scope > a") as HTMLAnchorElement | null; + if (!link) { + return; + } + + const href = (link.getAttribute("href") || "").trim(); + const label = getElementPrimaryText(link); + if (!label) { + return; + } + + const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:"); + if (isActionLike) { + groups.push({ + id: `root-${index}-action`, + kind: "action", + label, + actionId: registerAction(link) + }); + return; + } + + groups.push({ + id: `root-${index}-link`, + kind: "link", + label, + href, + isExternal: link.target === "_blank" || /^https?:\/\//i.test(href) + }); + }); + + return { groups, actionHandlers }; +} + +function loadExternalScript(src: string, scriptType: "classic" | "module" = "classic"): Promise { + return new Promise((resolve, reject) => { + const selector = `script[data-5etd-render-runtime="true"][src="${src}"][data-5etd-script-type="${scriptType}"]`; + const existing = document.querySelector(selector) as HTMLScriptElement | null; + if (existing) { + if (existing.dataset.loaded === "true") { + resolve(); + return; + } + + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { once: true }); + return; + } + + const script = document.createElement("script"); + script.src = src; + script.type = scriptType === "module" ? "module" : "text/javascript"; + script.async = false; + script.dataset.loaded = "false"; + script.dataset["5etdRenderRuntime"] = "true"; + script.dataset["5etdScriptType"] = scriptType; + script.addEventListener( + "load", + () => { + script.dataset.loaded = "true"; + resolve(); + }, + { once: true } + ); + script.addEventListener( + "error", + () => { + reject(new Error(`Failed to load ${src}`)); + }, + { once: true } + ); + document.head.append(script); + }); +} + +function loadExternalStylesheet(href: string): Promise { + return new Promise((resolve, reject) => { + const selector = `link[data-5etd-render-runtime-style="true"][href="${href}"]`; + const existing = document.querySelector(selector) as HTMLLinkElement | null; + if (existing) { + if (existing.dataset.loaded === "true") { + resolve(); + return; + } + + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => reject(new Error(`Failed to load stylesheet ${href}`)), { once: true }); + return; + } + + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href; + link.dataset.loaded = "false"; + link.dataset["5etdRenderRuntimeStyle"] = "true"; + link.addEventListener( + "load", + () => { + link.dataset.loaded = "true"; + resolve(); + }, + { once: true } + ); + link.addEventListener( + "error", + () => { + reject(new Error(`Failed to load stylesheet ${href}`)); + }, + { once: true } + ); + + // Insert before Vite-injected app styles where possible, so app-specific CSS keeps precedence. + const firstStyleOrLink = document.head.querySelector("style, link[rel='stylesheet']"); + if (firstStyleOrLink) { + document.head.insertBefore(link, firstStyleOrLink); + } else { + document.head.append(link); + } + }); +} + +async function ensure5etoolsRenderRuntime(wikiUrl: string): Promise { + const baseUrl = normalizeWikiBaseUrl(wikiUrl); + if (!baseUrl) { + return; + } + + if (window.Renderer?.get && renderRuntimeBaseUrl === baseUrl) { + return; + } + + if (renderRuntimePromise && renderRuntimeBaseUrl === baseUrl) { + await renderRuntimePromise; + return; + } + + renderRuntimeBaseUrl = baseUrl; + renderRuntimePromise = (async () => { + (window as unknown as { RENDERER_BASE_URL?: string }).RENDERER_BASE_URL = `${baseUrl}/`; + + for (const stylePath of FIVEETOOLS_RENDER_STYLESHEET_PATHS) { + await loadExternalStylesheet(new URL(stylePath, `${baseUrl}/`).toString()); + } + + for (const scriptPath of FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS) { + await loadExternalScript(new URL(scriptPath, `${baseUrl}/`).toString(), "classic"); + } + + for (const scriptPath of FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS) { + await loadExternalScript(new URL(scriptPath, `${baseUrl}/`).toString(), "module"); + } + + if (!window.Renderer?.get) { + throw new Error("5etools Renderer runtime did not initialize."); + } + + // `Renderer.hover` internally uses `ee` template helpers from utils.js. + // These are initialized by `JqueryUtil.initEnhancements()` in normal 5etools page bootstraps. + // We must do this manually in the character view runtime. + if (window.JqueryUtil?.initEnhancements) { + window.JqueryUtil.initEnhancements(); + } else { + window.TemplateUtil?.initVanilla?.(); + } + + window.Renderer.get().setBaseUrl(`${baseUrl}/`).setAddHandlers(true).setFirstSection(true); + window.Renderer.hover?._doInit?.(); + })(); + + await renderRuntimePromise; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function stripTagsFallback(value: string): string { + return value + .replace(/\{@([^}]+)\}/g, (_match: string, innerRaw: string) => { + const inner = innerRaw.trim(); + if (!inner) { + return ""; + } + + const firstSpace = inner.indexOf(" "); + const payload = firstSpace >= 0 ? inner.slice(firstSpace + 1) : inner; + const parts = payload.split("|").map((it) => it.trim()); + return parts[2] || parts[3] || parts[0] || parts[1] || ""; + }) + .replace(/\s+/g, " ") + .trim(); +} + +function renderEntryText(text: string, isParserReady: boolean): string { + const value = text.trim(); + if (!value) { + return ""; + } + + if (isParserReady && window.Renderer?.get) { + try { + const renderer = window.Renderer.get(); + renderer.setFirstSection(true); + return renderer.render(value); + } catch { + // Fall through to basic escaped text below. + } + } + + return escapeHtml(stripTagsFallback(value)); +} + +function ParsedEntryText(props: { + text: string; + isParserReady: boolean; + className?: string; +}): JSX.Element | null { + const { text, isParserReady, className } = props; + const html = useMemo(() => renderEntryText(text, isParserReady), [text, isParserReady]); + + if (!html) { + return null; + } + + return
; +} + +function createNewCharacterDraft(): CharacterDocument { + const now = new Date().toISOString(); + return { + schema: "5etd.character", + version: 1, + id: crypto.randomUUID(), + createdAt: now, + updatedAt: now, + profile: { + name: "Unnamed Character", + level: 1, + alignment: "", + faith: "", + lifestyle: "", + age: "", + gender: "", + height: "", + weight: "", + eyes: "", + skin: "", + hair: "" + }, + selections: { + class: null, + subclass: null, + background: null, + species: null + }, + steps: { + preferences: { + sourceBuckets: ["core2024"], + useOptionalClassFeatures: true, + customizeOrigin: true, + advancementType: "milestone", + hpMode: "average", + usePrerequisitesFeats: true, + usePrerequisitesMulticlass: true + }, + abilityScores: { + method: "standardArray", + scores: { + str: 15, + dex: 14, + con: 13, + int: 12, + wis: 10, + cha: 8 + }, + rolledValues: [...STANDARD_ARRAY], + assignments: { + str: 0, + dex: 1, + con: 2, + int: 3, + wis: 4, + cha: 5 + }, + backgroundBonusModeIndex: 0, + backgroundBonusSelections: [] + }, + feats: { + selected: [], + notes: "" + }, + skills: { + selected: [], + notes: "" + }, + spells: { + selected: [], + notes: "" + }, + equipment: { + startingEquipmentText: "", + inventoryText: "", + possessionsText: "", + currency: { + cp: 0, + sp: 0, + ep: 0, + gp: 0, + pp: 0 + } + }, + about: { + personality: "", + ideals: "", + bonds: "", + flaws: "", + backstory: "", + notes: "" + } + }, + notes: "" + }; +} + +function abilityModifier(score: number): string { + const mod = Math.floor((score - 10) / 2); + return mod >= 0 ? `+${mod}` : `${mod}`; +} + +function formatIso(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString(); +} + +function parseLineList(raw: string): string[] { + return Array.from( + new Set( + raw + .split(/\r?\n/g) + .map((it) => it.trim()) + .filter((it) => Boolean(it)) + ) + ); +} + +function toLineList(items: string[]): string { + return items.join("\n"); +} + +function filterOptions(options: CharacterSelectionOption[], query: string): CharacterSelectionOption[] { + const needle = query.trim().toLowerCase(); + if (!needle) { + return options; + } + + return options.filter((option) => { + const haystack = `${option.name} ${option.source} ${option.sourceName} ${option.details} ${option.summary}`.toLowerCase(); + return haystack.includes(needle); + }); +} + +function filterBySourceBuckets(options: CharacterSelectionOption[], sourceBuckets: string[]): CharacterSelectionOption[] { + if (!sourceBuckets.length) { + return []; + } + + const selected = new Set(sourceBuckets); + return options.filter((option) => selected.has(option.sourceBucket)); +} + +function clampPage(page: number, totalItems: number): number { + const totalPages = Math.max(1, Math.ceil(totalItems / CARD_PAGE_SIZE)); + return Math.max(1, Math.min(totalPages, page)); +} + +function paginateOptions(options: CharacterSelectionOption[], page: number): CharacterSelectionOption[] { + const safePage = clampPage(page, options.length); + const start = (safePage - 1) * CARD_PAGE_SIZE; + return options.slice(start, start + CARD_PAGE_SIZE); +} + +function toEntityRef(option: CharacterSelectionOption): CharacterEntityRef { + return { + entityType: option.entityType, + name: option.name, + source: option.source, + hash: option.hash, + entityRef: option.entityRef + }; +} + +function toSubclassEntityRef(option: CharacterFeatureOption): CharacterEntityRef { + return { + entityType: "subclass", + name: option.name, + source: option.source, + hash: option.hash, + entityRef: option.entityRef + }; +} + +function buildAssetUrl(baseUrl: string, relativePath: string | null): string { + if (!baseUrl || !relativePath) { + return ""; + } + + return new URL(relativePath.replace(/^\/+/, ""), `${baseUrl}/`).toString(); +} + +function roll4d6DropLowest(): number { + const rolls = [ + Math.floor(Math.random() * 6) + 1, + Math.floor(Math.random() * 6) + 1, + Math.floor(Math.random() * 6) + 1, + Math.floor(Math.random() * 6) + 1 + ].sort((a, b) => a - b); + + return rolls[1] + rolls[2] + rolls[3]; +} + +function getScoresFromAssignments( + pool: [number, number, number, number, number, number], + assignments: Record +): Record { + return { + str: pool[assignments.str] ?? 8, + dex: pool[assignments.dex] ?? 8, + con: pool[assignments.con] ?? 8, + int: pool[assignments.int] ?? 8, + wis: pool[assignments.wis] ?? 8, + cha: pool[assignments.cha] ?? 8 + }; +} + +function getPointBuyCost(score: number): number { + const map: Record = { + 8: 0, + 9: 1, + 10: 2, + 11: 3, + 12: 4, + 13: 5, + 14: 7, + 15: 9 + }; + + return map[score] ?? 99; +} + +function getPointBuySpent(scores: Record): number { + return ABILITY_KEYS.reduce((total, key) => total + getPointBuyCost(scores[key]), 0); +} + +interface BackgroundAbilityMode { + key: string; + from: AbilityKey[]; + weights: number[]; + label: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isAbilityKey(value: string): value is AbilityKey { + return ABILITY_KEYS.includes(value as AbilityKey); +} + +function getBackgroundAbilityModes(detail: CharacterSelectionDetail | null): BackgroundAbilityMode[] { + if (!detail || detail.entityType !== "background") { + return []; + } + + try { + const parsed = JSON.parse(detail.rawJson) as unknown; + if (!isRecord(parsed) || !Array.isArray(parsed.ability)) { + return []; + } + + const out: BackgroundAbilityMode[] = []; + parsed.ability.forEach((partRaw, index) => { + if (!isRecord(partRaw)) { + return; + } + + const chooseRaw = partRaw.choose; + if (!isRecord(chooseRaw)) { + return; + } + + const weightedRaw = chooseRaw.weighted; + if (!isRecord(weightedRaw)) { + return; + } + + if (!Array.isArray(weightedRaw.from) || !Array.isArray(weightedRaw.weights)) { + return; + } + + const from = Array.from( + new Set( + weightedRaw.from + .map((it) => (typeof it === "string" ? it.trim().toLowerCase() : "")) + .filter((it) => Boolean(it) && isAbilityKey(it)) + ) + ) as AbilityKey[]; + + const weights = weightedRaw.weights + .map((it) => (typeof it === "number" && Number.isFinite(it) ? Math.max(1, Math.trunc(it)) : 0)) + .filter((it) => it > 0); + + if (!from.length || !weights.length || weights.length > from.length) { + return; + } + + const label = weights.map((it) => `+${it}`).join(" / "); + out.push({ + key: `bg-ability-mode-${index}`, + from, + weights, + label + }); + }); + + return out; + } catch { + return []; + } +} + +function areAbilityArraysEqual(a: AbilityKey[], b: AbilityKey[]): boolean { + if (a.length !== b.length) { + return false; + } + + return a.every((value, index) => value === b[index]); +} + +function normalizeBackgroundBonusSelections(mode: BackgroundAbilityMode | null, selectedRaw: AbilityKey[]): AbilityKey[] { + if (!mode) { + return []; + } + + const selected: AbilityKey[] = []; + const used = new Set(); + + for (const value of selectedRaw) { + if (!mode.from.includes(value) || used.has(value)) { + continue; + } + selected.push(value); + used.add(value); + if (selected.length >= mode.weights.length) { + break; + } + } + + for (const candidate of mode.from) { + if (selected.length >= mode.weights.length) { + break; + } + if (used.has(candidate)) { + continue; + } + selected.push(candidate); + used.add(candidate); + } + + return selected; +} + +function getBackgroundBonusTotals(mode: BackgroundAbilityMode | null, selected: AbilityKey[]): Record { + const totals: Record = { + str: 0, + dex: 0, + con: 0, + int: 0, + wis: 0, + cha: 0 + }; + + if (!mode) { + return totals; + } + + mode.weights.forEach((weight, index) => { + const ability = selected[index]; + if (!ability) { + return; + } + totals[ability] += weight; + }); + + return totals; +} + +function getScoresWithBonuses( + baseScores: Record, + bonuses: Record +): Record { + return { + str: baseScores.str + bonuses.str, + dex: baseScores.dex + bonuses.dex, + con: baseScores.con + bonuses.con, + int: baseScores.int + bonuses.int, + wis: baseScores.wis + bonuses.wis, + cha: baseScores.cha + bonuses.cha + }; +} + +const CORE_SOURCE_CODES = new Set(["PHB", "DMG", "XPHB", "XDMG"]); + +const SKILL_TO_ABILITY: Record<(typeof SKILL_OPTIONS)[number], AbilityKey> = { + Acrobatics: "dex", + "Animal Handling": "wis", + Arcana: "int", + Athletics: "str", + Deception: "cha", + History: "int", + Insight: "wis", + Intimidation: "cha", + Investigation: "int", + Medicine: "wis", + Nature: "int", + Perception: "wis", + Performance: "cha", + Persuasion: "cha", + Religion: "int", + "Sleight of Hand": "dex", + Stealth: "dex", + Survival: "wis" +}; + +const ABILITY_SCORE_COST_TABLE: Record = { + 8: 0, + 9: 1, + 10: 2, + 11: 3, + 12: 4, + 13: 5, + 14: 7, + 15: 9 +}; + +interface SkillChoiceRequirement { + id: string; + sourceLabel: string; + title: string; + count: number; + options: string[]; +} + +interface ParsedSkillData { + granted: string[]; + choices: SkillChoiceRequirement[]; +} + +interface FeatCatalogItem { + key: string; + name: string; + source: string; + sourceBucket: CharacterSourceBucket; + sourceEdition: "2014" | "2024" | null; + category: string | null; + summary: string; +} + +interface SpellCatalogItem { + key: string; + name: string; + source: string; + level: number; + school: string; + sourceBucket: CharacterSourceBucket; + sourceEdition: "2014" | "2024" | null; + classes: Array<{ name: string; source: string }>; +} + +type SpellSourceLookup = Record< + string, + Record< + string, + { + class?: Record>; + classVariant?: Record>; + } + > +>; + +interface EquipmentGrant { + id: string; + label: string; + cpValue: number; +} + +interface EquipmentChoiceOption { + id: string; + key: string; + label: string; + grants: EquipmentGrant[]; +} + +interface EquipmentChoiceGroup { + id: string; + label: string; + options: EquipmentChoiceOption[]; +} + +interface EquipmentSelectionState { + selections: Record; + addedItems: string[]; +} + +interface SkillsNotesState { + text: string; + picksByChoiceId: Record; + extraSkills: string[]; +} + +function safeJsonParse(value: string): T | null { + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} + +function getSourceBadgeText(source: string, sourceEdition: "2014" | "2024" | null): string { + const src = source.trim().toUpperCase(); + if (src === "XPHB") { + return "2024"; + } + + if (src === "PHB") { + return "2014"; + } + + return sourceEdition ?? ""; +} + +function classifySourceBucket(sourceRaw: string): { + sourceBucket: CharacterSourceBucket; + sourceEdition: "2014" | "2024" | null; +} { + const source = sourceRaw.trim().toUpperCase(); + if (!source) { + return { + sourceBucket: "expanded2014", + sourceEdition: null + }; + } + + const is2024 = source.startsWith("X"); + const isCore = CORE_SOURCE_CODES.has(source); + return { + sourceBucket: is2024 ? (isCore ? "core2024" : "expanded2024") : isCore ? "core2014" : "expanded2014", + sourceEdition: is2024 ? "2024" : "2014" + }; +} + +function normalizeSkillName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +const SKILL_NAME_LOOKUP = (() => { + const out = new Map(); + for (const skill of SKILL_OPTIONS) { + out.set(normalizeSkillName(skill), skill); + } + return out; +})(); + +function toSkillName(value: string): string | null { + return SKILL_NAME_LOOKUP.get(normalizeSkillName(value)) || null; +} + +function parseSkillProficiencyValue( + value: unknown, + sourceLabel: string, + idPrefix: string +): ParsedSkillData { + const granted = new Set(); + const choices: SkillChoiceRequirement[] = []; + if (!Array.isArray(value)) { + return { granted: [], choices: [] }; + } + + value.forEach((entryRaw, entryIndex) => { + if (!isRecord(entryRaw)) { + return; + } + + const chooseRaw = entryRaw.choose; + if (isRecord(chooseRaw)) { + const from = Array.isArray(chooseRaw.from) + ? chooseRaw.from.map((it) => (typeof it === "string" ? toSkillName(it) : null)).filter((it): it is string => Boolean(it)) + : [...SKILL_OPTIONS]; + const count = typeof chooseRaw.count === "number" && Number.isFinite(chooseRaw.count) ? Math.max(1, Math.trunc(chooseRaw.count)) : 1; + if (from.length) { + choices.push({ + id: `${idPrefix}-choose-${entryIndex}`, + sourceLabel, + title: `${sourceLabel} Skill Choice`, + count: Math.min(count, from.length), + options: Array.from(new Set(from)) + }); + } + return; + } + + Object.entries(entryRaw).forEach(([key, val]) => { + if (key === "any" && typeof val === "number" && val > 0) { + choices.push({ + id: `${idPrefix}-any-${entryIndex}`, + sourceLabel, + title: `${sourceLabel} Skill Choice`, + count: Math.min(Math.trunc(val), SKILL_OPTIONS.length), + options: [...SKILL_OPTIONS] + }); + return; + } + + if (val !== true) { + return; + } + + const skill = toSkillName(key); + if (skill) { + granted.add(skill); + } + }); + }); + + return { + granted: [...granted], + choices + }; +} + +function parseClassSkills(detail: CharacterSelectionDetail | null): ParsedSkillData { + if (!detail) { + return { granted: [], choices: [] }; + } + + const raw = safeJsonParse>(detail.rawJson); + if (!raw || !isRecord(raw.startingProficiencies)) { + return { granted: [], choices: [] }; + } + + return parseSkillProficiencyValue(raw.startingProficiencies.skills, "Class", "class"); +} + +function parseBackgroundSkills(detail: CharacterSelectionDetail | null): ParsedSkillData { + if (!detail) { + return { granted: [], choices: [] }; + } + + const raw = safeJsonParse>(detail.rawJson); + return parseSkillProficiencyValue(raw?.skillProficiencies, "Background", "background"); +} + +function parseSpeciesSkills(detail: CharacterSelectionDetail | null): ParsedSkillData { + if (!detail) { + return { granted: [], choices: [] }; + } + + const raw = safeJsonParse>(detail.rawJson); + return parseSkillProficiencyValue(raw?.skillProficiencies, "Species", "species"); +} + +function getProficiencyBonus(level: number): number { + return Math.max(2, Math.min(6, 2 + Math.floor((Math.max(1, level) - 1) / 4))); +} + +function sanitizeFeatKey(name: string, source: string): string { + return `${name.trim().toLowerCase()}|${source.trim().toUpperCase()}`; +} + +function parseFeatUid(uidRaw: string): { name: string; source: string } | null { + const uid = uidRaw.trim(); + if (!uid) { + return null; + } + + const [nameRaw, sourceRaw] = uid.split("|"); + const name = (nameRaw || "").trim(); + const source = (sourceRaw || "PHB").trim().toUpperCase(); + if (!name) { + return null; + } + + return { name, source }; +} + +function getClassFeatSlotCount(detail: CharacterSelectionDetail | null, level: number): number { + if (!detail) { + return 0; + } + + return detail.featureCards.filter((card) => { + if (card.level != null && card.level > level) { + return false; + } + + const title = card.title.toLowerCase(); + return /ability score improvement|fighting style feat|epic boon|\bfeat\b/i.test(title); + }).length; +} + +function getFixedFeatUids(detail: CharacterSelectionDetail | null): string[] { + if (!detail || detail.entityType === "class") { + return []; + } + + const raw = safeJsonParse>(detail.rawJson); + if (!raw || !Array.isArray(raw.feats)) { + return []; + } + + const out = new Set(); + raw.feats.forEach((entryRaw) => { + if (!isRecord(entryRaw)) { + return; + } + + Object.entries(entryRaw).forEach(([key, value]) => { + if (value !== true) { + return; + } + const parsed = parseFeatUid(key); + if (!parsed) { + return; + } + out.add(`${parsed.name}|${parsed.source}`); + }); + }); + + return [...out]; +} + +function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: number): { + cantrips: number; + prepared: number; + known: number; + slots: Array<{ level: number; slots: number; label: string }>; + hasPactMagic: boolean; +} { + const out = { + cantrips: 0, + prepared: 0, + known: 0, + slots: [] as Array<{ level: number; slots: number; label: string }>, + hasPactMagic: false + }; + + if (!detail) { + return out; + } + + const raw = safeJsonParse>(detail.rawJson); + if (!raw) { + return out; + } + + const levelIndex = Math.max(0, Math.min(19, level - 1)); + const cantripProgression = Array.isArray(raw.cantripProgression) ? raw.cantripProgression : []; + const preparedSpellsProgression = Array.isArray(raw.preparedSpellsProgression) ? raw.preparedSpellsProgression : []; + const spellsKnownProgression = Array.isArray(raw.spellsKnownProgression) ? raw.spellsKnownProgression : []; + + if (typeof cantripProgression[levelIndex] === "number") { + out.cantrips = Math.max(0, Math.trunc(cantripProgression[levelIndex])); + } + + if (typeof preparedSpellsProgression[levelIndex] === "number") { + out.prepared = Math.max(0, Math.trunc(preparedSpellsProgression[levelIndex])); + } + + if (typeof spellsKnownProgression[levelIndex] === "number") { + out.known = Math.max(0, Math.trunc(spellsKnownProgression[levelIndex])); + } else { + out.known = out.prepared; + } + + const groups = Array.isArray(raw.classTableGroups) ? raw.classTableGroups : []; + groups.forEach((groupRaw) => { + if (!isRecord(groupRaw)) { + return; + } + + const labels = Array.isArray(groupRaw.colLabels) ? groupRaw.colLabels : []; + const rows = Array.isArray(groupRaw.rowsSpellProgression) ? groupRaw.rowsSpellProgression : Array.isArray(groupRaw.rows) ? groupRaw.rows : []; + const levelRow = Array.isArray(rows[levelIndex]) ? rows[levelIndex] : null; + if (!levelRow) { + return; + } + + labels.forEach((labelRaw, labelIndex) => { + const label = typeof labelRaw === "string" ? labelRaw : ""; + const lower = label.toLowerCase(); + if (!lower.includes("slot")) { + return; + } + + const value = levelRow[labelIndex]; + const slots = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; + if (!slots) { + return; + } + + const levelMatch = lower.match(/(\d+)(?:st|nd|rd|th)/); + const spellLevel = levelMatch ? Number.parseInt(levelMatch[1], 10) : 1; + out.slots.push({ + level: Number.isFinite(spellLevel) ? spellLevel : 1, + slots, + label + }); + + if (lower.includes("pact")) { + out.hasPactMagic = true; + } + }); + }); + + out.slots.sort((a, b) => a.level - b.level || a.label.localeCompare(b.label)); + return out; +} + +function readEquipmentTypeLabel(value: string): string { + const map: Record = { + instrumentMusical: "Musical Instrument", + toolArtisan: "Artisan's Tools" + }; + return map[value] || value; +} + +function parseEquipmentGrant(raw: unknown, idPrefix: string, index: number): EquipmentGrant[] { + if (!isRecord(raw)) { + return []; + } + + const grants: EquipmentGrant[] = []; + + if (typeof raw.item === "string") { + const parsed = parseFeatUid(raw.item.replace(/^([^|]+)\|/, "$1|")); + const [nameRaw] = raw.item.split("|"); + const quantity = typeof raw.quantity === "number" ? Math.max(1, Math.trunc(raw.quantity)) : 1; + grants.push({ + id: `${idPrefix}-item-${index}`, + label: `${nameRaw.trim()}${quantity > 1 ? ` x${quantity}` : ""}`, + cpValue: 0 + }); + if (parsed) { + void parsed; + } + } + + if (typeof raw.value === "number" && Number.isFinite(raw.value)) { + const cp = Math.max(0, Math.trunc(raw.value)); + const gp = cp / 100; + grants.push({ + id: `${idPrefix}-gp-${index}`, + label: `${gp} GP`, + cpValue: cp + }); + } + + if (typeof raw.equipmentType === "string") { + grants.push({ + id: `${idPrefix}-type-${index}`, + label: `Choose ${readEquipmentTypeLabel(raw.equipmentType)}`, + cpValue: 0 + }); + } + + if (Array.isArray(raw.equipmentTypes) && raw.equipmentTypes.length) { + const labels = raw.equipmentTypes.filter((it): it is string => typeof it === "string").map((it) => readEquipmentTypeLabel(it)); + if (labels.length) { + grants.push({ + id: `${idPrefix}-types-${index}`, + label: `Choose ${labels.join(" or ")}`, + cpValue: 0 + }); + } + } + + if (typeof raw.special === "string" && raw.special.trim()) { + grants.push({ + id: `${idPrefix}-special-${index}`, + label: raw.special.trim(), + cpValue: 0 + }); + } + + return grants; +} + +function parseStartingEquipmentChoices( + detail: CharacterSelectionDetail | null, + sourceLabel: "Class" | "Background" +): EquipmentChoiceGroup[] { + if (!detail) { + return []; + } + + const raw = safeJsonParse>(detail.rawJson); + if (!raw) { + return []; + } + + const startingEquipment = raw.startingEquipment; + if (!Array.isArray(startingEquipment)) { + return []; + } + + const groups: EquipmentChoiceGroup[] = []; + startingEquipment.forEach((groupRaw, groupIndex) => { + if (!isRecord(groupRaw)) { + return; + } + + const options: EquipmentChoiceOption[] = []; + Object.entries(groupRaw) + .filter(([_k, value]) => Array.isArray(value)) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([key, value]) => { + const grants = (value as unknown[]).flatMap((it, itemIndex) => parseEquipmentGrant(it, `${sourceLabel}-${groupIndex}-${key}`, itemIndex)); + options.push({ + id: `${sourceLabel}-${groupIndex}-${key}`, + key, + label: `${sourceLabel} Option ${key}`, + grants + }); + }); + + if (!options.length) { + return; + } + + groups.push({ + id: `${sourceLabel}-${groupIndex}`, + label: `${sourceLabel} Starting Equipment ${groupIndex + 1}`, + options + }); + }); + + return groups; +} + +function parseEquipmentSelectionState(raw: string, fallbackInventoryRaw: string): EquipmentSelectionState { + const parsed = safeJsonParse<{ selections?: Record; addedItems?: unknown[] }>(raw); + if (parsed && isRecord(parsed.selections) && Array.isArray(parsed.addedItems)) { + const selections: Record = {}; + Object.entries(parsed.selections).forEach(([k, v]) => { + if (typeof v === "string" && v.trim()) { + selections[k] = v; + } + }); + + return { + selections, + addedItems: parsed.addedItems + .filter((it): it is string => typeof it === "string") + .map((it) => it.trim()) + .filter((it) => Boolean(it)) + }; + } + + return { + selections: {}, + addedItems: parseLineList(fallbackInventoryRaw) + }; +} + +function stringifyEquipmentSelectionState(state: EquipmentSelectionState): string { + return JSON.stringify( + { + selections: state.selections, + addedItems: state.addedItems + }, + null, + 2 + ); +} + +function parseSkillsNotes(raw: string): SkillsNotesState { + const parsed = safeJsonParse<{ text?: string; picksByChoiceId?: Record; extraSkills?: unknown[] }>(raw); + if (!parsed) { + return { text: raw, picksByChoiceId: {}, extraSkills: [] }; + } + + const hasKnownShape = typeof parsed.text === "string" || isRecord(parsed.picksByChoiceId) || Array.isArray(parsed.extraSkills); + if (!hasKnownShape) { + return { text: raw, picksByChoiceId: {}, extraSkills: [] }; + } + + const picksByChoiceId: Record = {}; + if (isRecord(parsed.picksByChoiceId)) { + Object.entries(parsed.picksByChoiceId).forEach(([choiceId, picksRaw]) => { + if (!Array.isArray(picksRaw)) { + return; + } + picksByChoiceId[choiceId] = picksRaw + .filter((it): it is string => typeof it === "string") + .map((it) => toSkillName(it)) + .filter((it): it is string => Boolean(it)); + }); + } + + const extraSkills = Array.isArray(parsed.extraSkills) + ? uniqueStrings( + parsed.extraSkills + .filter((it): it is string => typeof it === "string") + .map((it) => toSkillName(it)) + .filter((it): it is string => Boolean(it)) + ) + : []; + + return { + text: typeof parsed.text === "string" ? parsed.text : "", + picksByChoiceId, + extraSkills + }; +} + +function stringifySkillsNotes(state: SkillsNotesState): string { + return JSON.stringify( + { + text: state.text, + picksByChoiceId: state.picksByChoiceId, + extraSkills: state.extraSkills + }, + null, + 2 + ); +} + +function getAbilityModifierNumber(score: number): number { + return Math.floor((score - 10) / 2); +} + +function areStringArraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false; + } + return a.every((it, ix) => it === b[ix]); +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.filter((it) => Boolean(it)))); +} + +function toNameSourceToken(name: string, source: string): string { + return `${name.trim()}|${source.trim().toUpperCase()}`; +} + +function parseNameSourceToken(value: string): { name: string; source: string } | null { + const [nameRaw, sourceRaw] = value.split("|"); + const name = (nameRaw || "").trim(); + const source = (sourceRaw || "").trim().toUpperCase(); + if (!name || !source) { + return null; + } + return { name, source }; +} + +function cpToCurrency(cpRaw: number): Record<"cp" | "sp" | "ep" | "gp" | "pp", number> { + let cp = Math.max(0, Math.trunc(cpRaw)); + const pp = Math.floor(cp / 1000); + cp -= pp * 1000; + const gp = Math.floor(cp / 100); + cp -= gp * 100; + const ep = Math.floor(cp / 50); + cp -= ep * 50; + const sp = Math.floor(cp / 10); + cp -= sp * 10; + return { cp, sp, ep, gp, pp }; +} + +function parseInventoryLabel(value: string): { label: string; count: number } { + const match = value.trim().match(/^(.*?)\s*x\s*(\d+)$/i); + if (!match) { + return { label: value.trim(), count: 1 }; + } + + const label = (match[1] || "").trim(); + const count = Number.parseInt((match[2] || "1").trim(), 10); + return { + label: label || value.trim(), + count: Number.isFinite(count) && count > 0 ? count : 1 + }; +} + +function collectEntryText(value: unknown, out: string[], limit = 5): void { + if (out.length >= limit || value == null) { + return; + } + + if (typeof value === "string") { + const cleaned = stripTagsFallback(value); + if (cleaned) { + out.push(cleaned); + } + return; + } + + if (Array.isArray(value)) { + value.forEach((it) => collectEntryText(it, out, limit)); + return; + } + + if (!isRecord(value)) { + return; + } + + if (typeof value.entry === "string") { + collectEntryText(value.entry, out, limit); + } + if (typeof value.text === "string") { + collectEntryText(value.text, out, limit); + } + if (Array.isArray(value.entries)) { + collectEntryText(value.entries, out, limit); + } + if (Array.isArray(value.items)) { + collectEntryText(value.items, out, limit); + } +} + +function getEntrySummary(value: unknown): string { + const chunks: string[] = []; + collectEntryText(value, chunks, 3); + return chunks.join(" ").trim(); +} + +function normalizeSpellClasses(classesRaw: Array<{ name: string; source: string }>): Array<{ name: string; source: string }> { + const seen = new Set(); + const out: Array<{ name: string; source: string }> = []; + classesRaw.forEach((entry) => { + const name = entry.name.trim(); + const source = entry.source.trim().toUpperCase(); + if (!name) { + return; + } + const key = `${name.toLowerCase()}|${source}`; + if (seen.has(key)) { + return; + } + seen.add(key); + out.push({ name, source }); + }); + return out; +} + +function getSpellClassesFromRaw(spellRaw: Record): Array<{ name: string; source: string }> { + const out: Array<{ name: string; source: string }> = []; + + const classesRaw = spellRaw.classes; + if (!isRecord(classesRaw)) { + return out; + } + + const lists = ["fromClassList", "fromClassListVariant", "fromClassListLegacy"] as const; + lists.forEach((listKey) => { + const list = classesRaw[listKey]; + if (!Array.isArray(list)) { + return; + } + + list.forEach((entry) => { + if (!isRecord(entry)) { + return; + } + const name = typeof entry.name === "string" ? entry.name.trim() : ""; + const source = typeof entry.source === "string" ? entry.source.trim().toUpperCase() : ""; + if (!name) { + return; + } + out.push({ name, source }); + }); + }); + + return out; +} + +function getSpellClassesFromLookup( + spellRaw: Record, + sourceLookup: SpellSourceLookup | null +): Array<{ name: string; source: string }> { + if (!sourceLookup) { + return []; + } + + const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toLowerCase() : ""; + const name = typeof spellRaw.name === "string" ? spellRaw.name.trim().toLowerCase() : ""; + if (!source || !name) { + return []; + } + + const record = sourceLookup[source]?.[name]; + if (!record) { + return []; + } + + const out: Array<{ name: string; source: string }> = []; + const collect = (mapRaw: Record> | undefined) => { + if (!mapRaw) { + return; + } + Object.entries(mapRaw).forEach(([classSource, classMap]) => { + if (!isRecord(classMap)) { + return; + } + Object.keys(classMap).forEach((className) => { + if (!className.trim()) { + return; + } + out.push({ name: className.trim(), source: classSource.trim().toUpperCase() }); + }); + }); + }; + + collect(record.class); + collect(record.classVariant); + return out; +} function SetupView() { const [setupState, setSetupState] = useState(null); @@ -181,7 +1911,253 @@ function WikiView() { const [wikiUrl, setWikiUrl] = useState(""); const [error, setError] = useState(""); const [currentPath, setCurrentPath] = useState(DEFAULT_PAGE); + const [isRenderRuntimeReady, setIsRenderRuntimeReady] = useState(false); + const [openTopMenuId, setOpenTopMenuId] = useState(null); + const [topbarSearch, setTopbarSearch] = useState(""); + const [topbarGroups, setTopbarGroups] = useState([]); const frameRef = useRef(null); + const topbarRef = useRef(null); + + const [characterModalMode, setCharacterModalMode] = useState("closed"); + const [characterStep, setCharacterStep] = useState(0); + const [characterDraft, setCharacterDraft] = useState(null); + const [characterSummaries, setCharacterSummaries] = useState([]); + const [characterOptions, setCharacterOptions] = useState(null); + const [detailCache, setDetailCache] = useState>({}); + const [characterError, setCharacterError] = useState(""); + const [characterNotice, setCharacterNotice] = useState(""); + const [isCharacterBusy, setIsCharacterBusy] = useState(false); + + const [classSearch, setClassSearch] = useState(""); + const [speciesSearch, setSpeciesSearch] = useState(""); + const [backgroundSearch, setBackgroundSearch] = useState(""); + const [featSearch, setFeatSearch] = useState(""); + const [spellSearch, setSpellSearch] = useState(""); + const [equipmentItemInput, setEquipmentItemInput] = useState(""); + + const [classPage, setClassPage] = useState(1); + const [speciesPage, setSpeciesPage] = useState(1); + const [backgroundPage, setBackgroundPage] = useState(1); + const [entityPopover, setEntityPopover] = useState<{ kind: EntityPopoverKind; entityRef: string } | null>(null); + const [featCatalog, setFeatCatalog] = useState(null); + const [spellCatalog, setSpellCatalog] = useState(null); + const [itemCatalog, setItemCatalog] = useState([]); + + const updateDraft = useCallback((updater: (current: CharacterDocument) => CharacterDocument) => { + setCharacterDraft((current) => { + if (!current) { + return current; + } + return updater(current); + }); + }, []); + + + const loadCharacterOptions = useCallback(async () => { + if (characterOptions) { + return characterOptions; + } + + const options = await window.desktopApi.getCharacterOptions(); + setCharacterOptions(options); + return options; + }, [characterOptions]); + + const ensureOptionDetail = useCallback( + async (entityRef: string) => { + const trimmed = entityRef.trim(); + if (!trimmed) { + return null; + } + + if (detailCache[trimmed]) { + return detailCache[trimmed]; + } + + const detail = await window.desktopApi.getCharacterOptionDetail(trimmed); + if (!detail) { + return null; + } + + setDetailCache((current) => ({ ...current, [trimmed]: detail })); + return detail; + }, + [detailCache] + ); + + const openEntityPopover = useCallback( + (kind: EntityPopoverKind, entityRef: string) => { + const trimmed = entityRef.trim(); + if (!trimmed) { + return; + } + void ensureOptionDetail(trimmed); + setEntityPopover({ kind, entityRef: trimmed }); + }, + [ensureOptionDetail] + ); + + const refreshCharacterSummaries = useCallback(async () => { + const list = await window.desktopApi.listCharacters(); + setCharacterSummaries(list); + }, []); + + const resetEditorUiState = useCallback(() => { + setClassSearch(""); + setSpeciesSearch(""); + setBackgroundSearch(""); + setFeatSearch(""); + setSpellSearch(""); + setEquipmentItemInput(""); + setClassPage(1); + setSpeciesPage(1); + setBackgroundPage(1); + setCharacterStep(0); + }, []); + + const openCharacterList = useCallback(async () => { + setCharacterError(""); + setCharacterNotice(""); + setIsCharacterBusy(true); + try { + setCharacterModalMode("list"); + await refreshCharacterSummaries(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load character list."; + setCharacterError(message); + setCharacterModalMode("list"); + } finally { + setIsCharacterBusy(false); + } + }, [refreshCharacterSummaries]); + + const openNewCharacter = useCallback(async () => { + setCharacterError(""); + setCharacterNotice(""); + setIsCharacterBusy(true); + try { + await loadCharacterOptions(); + setCharacterDraft(createNewCharacterDraft()); + resetEditorUiState(); + setCharacterModalMode("editor"); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to start a new character."; + setCharacterError(message); + setCharacterModalMode("list"); + } finally { + setIsCharacterBusy(false); + } + }, [loadCharacterOptions, resetEditorUiState]); + + const openCharacterById = useCallback( + async (id: string) => { + setCharacterError(""); + setCharacterNotice(""); + setIsCharacterBusy(true); + try { + await loadCharacterOptions(); + const record = await window.desktopApi.getCharacter(id); + if (!record) { + setCharacterError("Character file was not found."); + setCharacterModalMode("list"); + return; + } + + setCharacterDraft(record); + resetEditorUiState(); + setCharacterModalMode("editor"); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to open character."; + setCharacterError(message); + setCharacterModalMode("list"); + } finally { + setIsCharacterBusy(false); + } + }, + [loadCharacterOptions, resetEditorUiState] + ); + + const loadCharacterFromJson = useCallback(async () => { + setCharacterError(""); + setCharacterNotice(""); + setIsCharacterBusy(true); + try { + const selectedPath = await window.desktopApi.pickCharacterJson(); + if (!selectedPath) { + return; + } + + await loadCharacterOptions(); + const imported = await window.desktopApi.importCharacterJson(selectedPath); + await refreshCharacterSummaries(); + setCharacterDraft(imported); + resetEditorUiState(); + setCharacterNotice(`Imported character: ${imported.profile.name}`); + setCharacterModalMode("editor"); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to import character JSON."; + setCharacterError(message); + setCharacterModalMode("list"); + } finally { + setIsCharacterBusy(false); + } + }, [loadCharacterOptions, refreshCharacterSummaries, resetEditorUiState]); + + const deleteCharacterById = useCallback( + async (id: string) => { + if (!window.confirm("Delete this character file? This action cannot be undone.")) { + return; + } + + setCharacterError(""); + setCharacterNotice(""); + setIsCharacterBusy(true); + try { + const deleted = await window.desktopApi.deleteCharacter(id); + if (!deleted) { + setCharacterError("Character file could not be deleted."); + return; + } + + setCharacterNotice("Character deleted."); + await refreshCharacterSummaries(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to delete character."; + setCharacterError(message); + } finally { + setIsCharacterBusy(false); + } + }, + [refreshCharacterSummaries] + ); + + const saveCharacterDraft = useCallback(async () => { + if (!characterDraft) { + return; + } + + setCharacterError(""); + setCharacterNotice(""); + setIsCharacterBusy(true); + try { + const saved = await window.desktopApi.saveCharacter({ + id: characterDraft.id, + createdAt: characterDraft.createdAt, + profile: characterDraft.profile, + selections: characterDraft.selections, + steps: characterDraft.steps, + notes: characterDraft.notes + }); + setCharacterDraft(saved); + setCharacterNotice(`Saved ${saved.profile.name}.`); + await refreshCharacterSummaries(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save character."; + setCharacterError(message); + } finally { + setIsCharacterBusy(false); + } + }, [characterDraft, refreshCharacterSummaries]); useEffect(() => { window.desktopApi @@ -193,6 +2169,206 @@ function WikiView() { }); }, []); + useEffect(() => { + if (!wikiUrl) { + setIsRenderRuntimeReady(false); + return; + } + + let isCancelled = false; + void ensure5etoolsRenderRuntime(wikiUrl) + .then(() => { + if (!isCancelled) { + setIsRenderRuntimeReady(true); + } + }) + .catch((runtimeError: unknown) => { + console.error("[5ET-D] Failed to initialize 5etools render runtime", runtimeError); + if (!isCancelled) { + setIsRenderRuntimeReady(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [wikiUrl]); + + useEffect(() => { + if (characterModalMode !== "editor" || !wikiUrl || !isRenderRuntimeReady) { + return; + } + + let isCancelled = false; + const baseUrl = `${normalizeWikiBaseUrl(wikiUrl)}/`; + + const loadFeats = async () => { + if (featCatalog) { + return; + } + + const response = await fetch(new URL("data/feats.json", baseUrl).toString()); + if (!response.ok) { + throw new Error(`Failed to load feats (${response.status})`); + } + + const payload = (await response.json()) as { feat?: unknown[] }; + const featsRaw = Array.isArray(payload.feat) ? payload.feat : []; + const next = featsRaw + .map((featRaw): FeatCatalogItem | null => { + if (!isRecord(featRaw)) { + return null; + } + + const name = typeof featRaw.name === "string" ? featRaw.name.trim() : ""; + const source = typeof featRaw.source === "string" ? featRaw.source.trim().toUpperCase() : ""; + if (!name || !source) { + return null; + } + + const sourceMeta = classifySourceBucket(source); + const summary = getEntrySummary(featRaw.entries); + return { + key: sanitizeFeatKey(name, source), + name, + source, + sourceBucket: sourceMeta.sourceBucket, + sourceEdition: sourceMeta.sourceEdition, + category: typeof featRaw.category === "string" ? featRaw.category : null, + summary + }; + }) + .filter((it): it is FeatCatalogItem => Boolean(it)) + .sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)); + + if (!isCancelled) { + setFeatCatalog(next); + } + }; + + const loadSpells = async () => { + if (spellCatalog) { + return; + } + + const runtime = window as unknown as { + DataUtil?: { + spell?: { + pLoadAll?: () => Promise; + }; + }; + Renderer?: { + spell?: { + getCombinedClasses?: (spell: unknown, prop: string) => Array<{ name: string; source: string }>; + }; + }; + }; + + const pLoadAll = runtime.DataUtil?.spell?.pLoadAll; + const getCombinedClasses = runtime.Renderer?.spell?.getCombinedClasses; + if (!pLoadAll) { + if (!isCancelled) { + setSpellCatalog([]); + } + return; + } + + let sourceLookup: SpellSourceLookup | null = null; + try { + const lookupResp = await fetch(new URL("data/generated/gendata-spell-source-lookup.json", baseUrl).toString()); + if (lookupResp.ok) { + sourceLookup = (await lookupResp.json()) as SpellSourceLookup; + } + } catch { + sourceLookup = null; + } + + const spellsRaw = await pLoadAll(); + const next = (Array.isArray(spellsRaw) ? spellsRaw : []) + .map((spellRaw): SpellCatalogItem | null => { + if (!isRecord(spellRaw)) { + return null; + } + + const name = typeof spellRaw.name === "string" ? spellRaw.name.trim() : ""; + const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toUpperCase() : ""; + if (!name || !source) { + return null; + } + + const classesFromRenderer = getCombinedClasses + ? (getCombinedClasses(spellRaw, "fromClassList") || []) + .map((it) => ({ + name: typeof it.name === "string" ? it.name.trim() : "", + source: typeof it.source === "string" ? it.source.trim().toUpperCase() : "" + })) + .filter((it) => Boolean(it.name)) + : []; + const classesFromRaw = getSpellClassesFromRaw(spellRaw); + const classesFromLookup = getSpellClassesFromLookup(spellRaw, sourceLookup); + const classes = normalizeSpellClasses([...classesFromRenderer, ...classesFromRaw, ...classesFromLookup]); + + const sourceMeta = classifySourceBucket(source); + const level = typeof spellRaw.level === "number" ? Math.max(0, Math.trunc(spellRaw.level)) : 0; + const school = typeof spellRaw.school === "string" ? spellRaw.school : ""; + + return { + key: sanitizeFeatKey(name, source), + name, + source, + level, + school, + sourceBucket: sourceMeta.sourceBucket, + sourceEdition: sourceMeta.sourceEdition, + classes + }; + }) + .filter((it): it is SpellCatalogItem => Boolean(it)) + .sort((a, b) => a.level - b.level || a.name.localeCompare(b.name) || a.source.localeCompare(b.source)); + + if (!isCancelled) { + setSpellCatalog(next); + } + }; + + const loadItems = async () => { + if (itemCatalog.length) { + return; + } + + const response = await fetch(new URL("data/items-base.json", baseUrl).toString()); + if (!response.ok) { + throw new Error(`Failed to load item catalog (${response.status})`); + } + + const payload = (await response.json()) as { baseitem?: unknown[] }; + const itemsRaw = Array.isArray(payload.baseitem) ? payload.baseitem : []; + const next = Array.from( + new Set( + itemsRaw + .map((it) => (isRecord(it) && typeof it.name === "string" ? it.name.trim() : "")) + .filter((it) => Boolean(it)) + ) + ).sort((a, b) => a.localeCompare(b)); + + if (!isCancelled) { + setItemCatalog(next); + } + }; + + void (async () => { + try { + await Promise.all([loadFeats(), loadSpells(), loadItems()]); + } catch (catalogError) { + console.error("[5ET-D] Failed to load character builder catalogs", catalogError); + } + })(); + + return () => { + isCancelled = true; + }; + }, [characterModalMode, wikiUrl, isRenderRuntimeReady, featCatalog, spellCatalog, itemCatalog.length]); + useEffect(() => { const onMessage = (event: MessageEvent) => { const frameWindow = frameRef.current?.contentWindow; @@ -204,31 +2380,98 @@ function WikiView() { return; } - const data = event.data as { type?: string }; - if (data.type !== "5etd:reset-onboarding") { - return; - } + const data = event.data as { type?: string; groups?: TopbarMenuGroup[] }; - void (async () => { - const result = await window.desktopApi.resetOnboarding(); - if (result.ok) { + if (data.type === "5etd:native-navbar-data") { + if (!Array.isArray(data.groups)) { return; } - frameWindow.postMessage( - { - type: "5etd:reset-onboarding-result", - ok: false, - error: result.error || "Failed to reset onboarding." - }, - "*" - ); - })(); + setTopbarGroups((current) => (areTopbarGroupsEquivalent(current, data.groups as TopbarMenuGroup[]) ? current : (data.groups as TopbarMenuGroup[]))); + return; + } + + if (data.type === "5etd:reset-onboarding") { + void (async () => { + const result = await window.desktopApi.resetOnboarding(); + if (result.ok) { + return; + } + + frameWindow.postMessage( + { + type: "5etd:reset-onboarding-result", + ok: false, + error: result.error || "Failed to reset onboarding." + }, + "*" + ); + })(); + return; + } + + if (data.type === "5etd:characters:new") { + void openNewCharacter(); + return; + } + + if (data.type === "5etd:characters:load-json") { + void loadCharacterFromJson(); + return; + } + + if (data.type === "5etd:characters:list") { + void openCharacterList(); + } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, []); + }, [loadCharacterFromJson, openCharacterList, openNewCharacter]); + + useEffect(() => { + if (!openTopMenuId) { + return; + } + + const onDocumentMouseDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (!topbarRef.current?.contains(target)) { + setOpenTopMenuId(null); + } + }; + + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpenTopMenuId(null); + } + }; + + document.addEventListener("mousedown", onDocumentMouseDown); + window.addEventListener("keydown", onWindowKeyDown); + return () => { + document.removeEventListener("mousedown", onDocumentMouseDown); + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [openTopMenuId]); + + useEffect(() => { + const frame = frameRef.current; + if (!frame) { + return; + } + + const requestNativeNavbarModel = () => { + frame.contentWindow?.postMessage({ type: "5etd:native-navbar-request" }, "*"); + }; + + frame.addEventListener("load", requestNativeNavbarModel); + requestNativeNavbarModel(); + return () => frame.removeEventListener("load", requestNativeNavbarModel); + }, [currentPath, characterModalMode]); const frameSrc = useMemo(() => { if (!wikiUrl) { @@ -238,6 +2481,1125 @@ function WikiView() { 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); + }, []); + + const onSubmitTopbarSearch = useCallback((event: FormEvent) => { + event.preventDefault(); + const query = topbarSearch.trim(); + navigateWikiPath(query ? `/search.html?q=${encodeURIComponent(query)}` : "/search.html"); + }, [navigateWikiPath, topbarSearch]); + + const isSameAppPath = useCallback((leftRaw: string, rightRaw: string): boolean => { + const left = getPathnameFromHrefLoose(leftRaw); + const right = getPathnameFromHrefLoose(rightRaw); + if (!left || !right) { + return false; + } + if (left === right) { + return true; + } + const homeAliases = new Set(["/index.html", "/5etools.html"]); + return homeAliases.has(left) && homeAliases.has(right); + }, []); + + const resolveWikiPathFromHref = useCallback( + (hrefRaw: string): string | null => { + const href = hrefRaw.trim(); + if (!href || href.startsWith("#")) { + return null; + } + + const hrefLower = href.toLowerCase(); + if (hrefLower.startsWith("javascript:") || hrefLower.startsWith("mailto:") || hrefLower.startsWith("tel:")) { + return null; + } + + if (!wikiUrl) { + return null; + } + + try { + const base = new URL(`${normalizeWikiBaseUrl(wikiUrl)}/`); + const resolved = new URL(href, base); + if (resolved.origin !== base.origin) { + return null; + } + + if (!resolved.pathname.toLowerCase().endsWith(".html")) { + return null; + } + + const path = resolved.pathname.startsWith("/") ? resolved.pathname : `/${resolved.pathname}`; + return `${path}${resolved.search}${resolved.hash}`; + } catch { + return null; + } + }, + [wikiUrl] + ); + + const runTopbarAction = useCallback((actionId: string | undefined) => { + if (!actionId) { + return; + } + frameRef.current?.contentWindow?.postMessage({ type: "5etd:native-navbar-run-action", actionId }, "*"); + }, []); + + 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.type === "action") { + runTopbarAction(entry.actionId); + setOpenTopMenuId(null); + return; + } + + if (entry.type === "link" && 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)); + return; + } + + if (group.kind === "action") { + runTopbarAction(group.actionId); + setOpenTopMenuId(null); + return; + } + + if (group.kind === "link" && group.href) { + navigateTopbarHref(group.href, group.isExternal); + } + }, [navigateTopbarHref, runTopbarAction]); + + const renderTopbarEntries = useCallback((entries: TopbarMenuEntry[], depth = 0): JSX.Element[] => { + return entries.flatMap((entry) => { + if (entry.type === "divider") { + return [
]; + } + + if (entry.type === "label") { + return [
{entry.label}
]; + } + + if (entry.type === "submenu") { + const children = entry.children?.length ? renderTopbarEntries(entry.children, depth + 1) : []; + return [ +
0 ? "topbar-submenu topbar-submenu--nested" : "topbar-submenu"}> +
{entry.label}
+
{children}
+
+ ]; + } + + const isActive = entry.type === "link" && entry.href ? isSameAppPath(entry.href, currentPathname) : false; + return [ + + ]; + }); + }, [activateTopbarEntry, currentPathname, isSameAppPath]); + + const sourceBuckets = characterDraft?.steps.preferences.sourceBuckets ?? []; + + const filteredClassOptions = useMemo( + () => filterOptions(filterBySourceBuckets(characterOptions?.classes ?? [], sourceBuckets), classSearch), + [characterOptions?.classes, sourceBuckets, classSearch] + ); + + const filteredSpeciesOptions = useMemo( + () => filterOptions(filterBySourceBuckets(characterOptions?.species ?? [], sourceBuckets), speciesSearch), + [characterOptions?.species, sourceBuckets, speciesSearch] + ); + + const filteredBackgroundOptions = useMemo( + () => filterOptions(filterBySourceBuckets(characterOptions?.backgrounds ?? [], sourceBuckets), backgroundSearch), + [characterOptions?.backgrounds, sourceBuckets, backgroundSearch] + ); + + const classTotalPages = Math.max(1, Math.ceil(filteredClassOptions.length / CARD_PAGE_SIZE)); + const speciesTotalPages = Math.max(1, Math.ceil(filteredSpeciesOptions.length / CARD_PAGE_SIZE)); + const backgroundTotalPages = Math.max(1, Math.ceil(filteredBackgroundOptions.length / CARD_PAGE_SIZE)); + + const classPageSafe = clampPage(classPage, filteredClassOptions.length); + const speciesPageSafe = clampPage(speciesPage, filteredSpeciesOptions.length); + const backgroundPageSafe = clampPage(backgroundPage, filteredBackgroundOptions.length); + + const classPageOptions = useMemo( + () => paginateOptions(filteredClassOptions, classPageSafe), + [filteredClassOptions, classPageSafe] + ); + const speciesPageOptions = useMemo( + () => paginateOptions(filteredSpeciesOptions, speciesPageSafe), + [filteredSpeciesOptions, speciesPageSafe] + ); + const backgroundPageOptions = useMemo( + () => paginateOptions(filteredBackgroundOptions, backgroundPageSafe), + [filteredBackgroundOptions, backgroundPageSafe] + ); + + useEffect(() => { + setClassPage((current) => clampPage(current, filteredClassOptions.length)); + }, [filteredClassOptions.length]); + + useEffect(() => { + setSpeciesPage((current) => clampPage(current, filteredSpeciesOptions.length)); + }, [filteredSpeciesOptions.length]); + + useEffect(() => { + setBackgroundPage((current) => clampPage(current, filteredBackgroundOptions.length)); + }, [filteredBackgroundOptions.length]); + + useEffect(() => { + const selectedRefs = [ + characterDraft?.selections.class?.entityRef, + characterDraft?.selections.background?.entityRef, + characterDraft?.selections.species?.entityRef + ] + .filter((it): it is string => Boolean(it)) + .slice(0, 3); + + for (const ref of selectedRefs) { + void ensureOptionDetail(ref); + } + }, [characterDraft?.selections.background?.entityRef, characterDraft?.selections.class?.entityRef, characterDraft?.selections.species?.entityRef, ensureOptionDetail]); + + const selectedClassDetail = characterDraft?.selections.class ? detailCache[characterDraft.selections.class.entityRef] ?? null : null; + const selectedSpeciesDetail = characterDraft?.selections.species ? detailCache[characterDraft.selections.species.entityRef] ?? null : null; + const selectedBackgroundDetail = characterDraft?.selections.background + ? detailCache[characterDraft.selections.background.entityRef] ?? null + : null; + const entityPopoverDetail = entityPopover ? detailCache[entityPopover.entityRef] ?? null : null; + + useEffect(() => { + if (characterModalMode !== "editor" || !entityPopover) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setEntityPopover(null); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [characterModalMode, entityPopover]); + + useEffect(() => { + if (characterModalMode !== "editor" || !entityPopover) { + return; + } + if (characterStep < 0 || characterStep > 2) { + setEntityPopover(null); + } + }, [characterModalMode, characterStep, entityPopover]); + + const classSkillData = useMemo( + () => parseClassSkills(selectedClassDetail), + [selectedClassDetail?.entityRef, selectedClassDetail?.rawJson] + ); + const backgroundSkillData = useMemo( + () => parseBackgroundSkills(selectedBackgroundDetail), + [selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson] + ); + const speciesSkillData = useMemo( + () => parseSpeciesSkills(selectedSpeciesDetail), + [selectedSpeciesDetail?.entityRef, selectedSpeciesDetail?.rawJson] + ); + + const skillChoiceRequirements = useMemo( + () => [...classSkillData.choices, ...backgroundSkillData.choices, ...speciesSkillData.choices], + [classSkillData, backgroundSkillData, speciesSkillData] + ); + + const grantedSkills = useMemo( + () => uniqueStrings([...classSkillData.granted, ...backgroundSkillData.granted, ...speciesSkillData.granted]).sort((a, b) => a.localeCompare(b)), + [classSkillData, backgroundSkillData, speciesSkillData] + ); + + const skillNotesState = useMemo( + () => parseSkillsNotes(characterDraft?.steps.skills.notes ?? ""), + [characterDraft?.steps.skills.notes] + ); + + const normalizedSkillChoicePicks = useMemo(() => { + const out: Record = {}; + for (const choice of skillChoiceRequirements) { + const picksRaw = skillNotesState.picksByChoiceId[choice.id] || []; + const picks: string[] = []; + for (const skill of picksRaw) { + if (!choice.options.includes(skill) || picks.includes(skill)) { + continue; + } + picks.push(skill); + if (picks.length >= choice.count) { + break; + } + } + out[choice.id] = picks; + } + return out; + }, [skillChoiceRequirements, skillNotesState.picksByChoiceId]); + + const manualSkillSelections = useMemo( + () => + uniqueStrings(skillNotesState.extraSkills.map((it) => toSkillName(it)).filter((it): it is string => Boolean(it))).sort((a, b) => + a.localeCompare(b) + ), + [skillNotesState.extraSkills] + ); + + const allProficientSkills = useMemo( + () => + uniqueStrings([ + ...grantedSkills, + ...Object.values(normalizedSkillChoicePicks).flatMap((picks) => picks), + ...manualSkillSelections + ]).sort((a, b) => a.localeCompare(b)), + [grantedSkills, manualSkillSelections, normalizedSkillChoicePicks] + ); + + const fixedFeatTokens = useMemo( + () => uniqueStrings([...getFixedFeatUids(selectedBackgroundDetail), ...getFixedFeatUids(selectedSpeciesDetail)]).sort((a, b) => a.localeCompare(b)), + [selectedBackgroundDetail?.rawJson, selectedSpeciesDetail?.rawJson] + ); + + const classFeatSlotCount = useMemo( + () => getClassFeatSlotCount(selectedClassDetail, characterDraft?.profile.level ?? 1), + [selectedClassDetail?.entityRef, selectedClassDetail?.featureCards, characterDraft?.profile.level] + ); + + const normalizedFeatSelections = useMemo(() => { + const raw = characterDraft?.steps.feats.selected ?? []; + return uniqueStrings( + raw + .map((token) => { + const parsed = parseNameSourceToken(token) || parseFeatUid(token); + return parsed ? toNameSourceToken(parsed.name, parsed.source) : ""; + }) + .filter((it) => Boolean(it)) + ); + }, [characterDraft?.steps.feats.selected]); + + const variableFeatSelections = useMemo(() => { + const fixed = new Set( + fixedFeatTokens + .map((token) => parseNameSourceToken(token)) + .filter((it): it is { name: string; source: string } => Boolean(it)) + .map((it) => sanitizeFeatKey(it.name, it.source)) + ); + const filtered = normalizedFeatSelections.filter((token) => { + const parsed = parseNameSourceToken(token); + if (!parsed) { + return false; + } + return !fixed.has(sanitizeFeatKey(parsed.name, parsed.source)); + }); + return filtered.slice(0, Math.max(0, classFeatSlotCount)); + }, [normalizedFeatSelections, fixedFeatTokens, classFeatSlotCount]); + + const filteredFeatCatalog = useMemo(() => { + const needle = featSearch.trim().toLowerCase(); + return (featCatalog ?? []) + .filter((feat) => sourceBuckets.includes(feat.sourceBucket)) + .filter((feat) => { + if (!needle) { + return true; + } + return `${feat.name} ${feat.source} ${feat.summary} ${feat.category || ""}`.toLowerCase().includes(needle); + }); + }, [featCatalog, sourceBuckets, featSearch]); + + const featByKey = useMemo(() => { + const out = new Map(); + (featCatalog ?? []).forEach((feat) => out.set(feat.key, feat)); + return out; + }, [featCatalog]); + + const fixedFeatKeySet = useMemo(() => { + const out = new Set(); + fixedFeatTokens.forEach((token) => { + const parsed = parseNameSourceToken(token); + if (!parsed) { + return; + } + out.add(sanitizeFeatKey(parsed.name, parsed.source)); + }); + return out; + }, [fixedFeatTokens]); + + const selectedFeatItems = useMemo( + () => + normalizedFeatSelections.map((token) => { + const parsed = parseNameSourceToken(token); + if (!parsed) { + return { + key: token, + name: token, + source: "", + sourceBucket: "expanded2014" as CharacterSourceBucket, + sourceEdition: null, + category: null, + summary: "" + }; + } + return ( + featByKey.get(sanitizeFeatKey(parsed.name, parsed.source)) || { + key: sanitizeFeatKey(parsed.name, parsed.source), + name: parsed.name, + source: parsed.source, + sourceBucket: classifySourceBucket(parsed.source).sourceBucket, + sourceEdition: classifySourceBucket(parsed.source).sourceEdition, + category: null, + summary: "" + } + ); + }), + [featByKey, normalizedFeatSelections] + ); + + const classSpellcasting = useMemo( + () => parseClassSpellcasting(selectedClassDetail, characterDraft?.profile.level ?? 1), + [selectedClassDetail?.entityRef, selectedClassDetail?.rawJson, characterDraft?.profile.level] + ); + + const selectedClassSpellPool = useMemo(() => { + if (!spellCatalog || !characterDraft?.selections.class) { + return [] as SpellCatalogItem[]; + } + const className = characterDraft.selections.class.name.trim().toLowerCase(); + const classSource = characterDraft.selections.class.source.trim().toUpperCase(); + const sourceAliases = new Set([classSource]); + if (classSource === "XPHB") { + sourceAliases.add("PHB"); + } + if (classSource === "PHB") { + sourceAliases.add("XPHB"); + } + const needle = spellSearch.trim().toLowerCase(); + return spellCatalog + .filter((spell) => sourceBuckets.includes(spell.sourceBucket)) + .filter((spell) => { + const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className); + if (!matchingByName.length) { + return false; + } + const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim())); + if (!hasSourceData) { + return true; + } + return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase())); + }) + .filter((spell) => { + if (!needle) { + return true; + } + return `${spell.name} ${spell.source} ${spell.level}`.toLowerCase().includes(needle); + }); + }, [spellCatalog, characterDraft?.selections.class, sourceBuckets, spellSearch]); + + const selectedCantripPool = useMemo( + () => selectedClassSpellPool.filter((spell) => spell.level === 0).sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)), + [selectedClassSpellPool] + ); + + const selectedLeveledSpellPoolByLevel = useMemo(() => { + const groups = new Map(); + selectedClassSpellPool + .filter((spell) => spell.level > 0) + .forEach((spell) => { + const list = groups.get(spell.level) || []; + list.push(spell); + groups.set(spell.level, list); + }); + return Array.from(groups.entries()) + .sort(([a], [b]) => a - b) + .map(([level, spells]) => ({ + level, + spells: [...spells].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)) + })); + }, [selectedClassSpellPool]); + + const selectedSpellTokens = useMemo( + () => + uniqueStrings( + (characterDraft?.steps.spells.selected ?? []) + .map((token) => { + const parsed = parseNameSourceToken(token) || parseFeatUid(token); + return parsed ? toNameSourceToken(parsed.name, parsed.source) : ""; + }) + .filter((it) => Boolean(it)) + ), + [characterDraft?.steps.spells.selected] + ); + + const selectedSpellItems = useMemo(() => { + const map = new Map(); + (spellCatalog ?? []).forEach((spell) => map.set(spell.key, spell)); + return selectedSpellTokens + .map((token) => { + const parsed = parseNameSourceToken(token); + if (!parsed) { + return null; + } + return map.get(sanitizeFeatKey(parsed.name, parsed.source)) || null; + }) + .filter((it): it is SpellCatalogItem => Boolean(it)); + }, [spellCatalog, selectedSpellTokens]); + + const selectedCantripCount = selectedSpellItems.filter((it) => it.level === 0).length; + const selectedPreparedCount = selectedSpellItems.filter((it) => it.level > 0).length; + + const spellSlotMatrix = useMemo(() => { + const byLevel = new Map(); + classSpellcasting.slots.forEach((slot) => { + byLevel.set(slot.level, (byLevel.get(slot.level) || 0) + slot.slots); + }); + return Array.from(byLevel.entries()) + .sort(([a], [b]) => a - b) + .map(([level, slots]) => ({ level, slots })); + }, [classSpellcasting.slots]); + + const classEquipmentGroups = useMemo( + () => parseStartingEquipmentChoices(selectedClassDetail, "Class"), + [selectedClassDetail?.entityRef, selectedClassDetail?.rawJson] + ); + const backgroundEquipmentGroups = useMemo( + () => parseStartingEquipmentChoices(selectedBackgroundDetail, "Background"), + [selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson] + ); + + const equipmentChoiceGroups = useMemo( + () => [...classEquipmentGroups, ...backgroundEquipmentGroups], + [classEquipmentGroups, backgroundEquipmentGroups] + ); + + const equipmentSelectionState = useMemo( + () => + parseEquipmentSelectionState( + characterDraft?.steps.equipment.startingEquipmentText ?? "", + characterDraft?.steps.equipment.inventoryText ?? "" + ), + [characterDraft?.steps.equipment.startingEquipmentText, characterDraft?.steps.equipment.inventoryText] + ); + + const normalizedEquipmentSelections = useMemo(() => { + const out: Record = {}; + equipmentChoiceGroups.forEach((group) => { + const selectedOptionId = equipmentSelectionState.selections[group.id]; + const hasSelected = group.options.some((option) => option.id === selectedOptionId); + out[group.id] = hasSelected ? selectedOptionId : group.options[0]?.id || ""; + }); + return out; + }, [equipmentChoiceGroups, equipmentSelectionState.selections]); + + const resolvedEquipmentGrants = useMemo(() => { + const grants: EquipmentGrant[] = []; + equipmentChoiceGroups.forEach((group) => { + const selectedId = normalizedEquipmentSelections[group.id]; + const option = group.options.find((it) => it.id === selectedId); + if (!option) { + return; + } + grants.push(...option.grants); + }); + return grants; + }, [equipmentChoiceGroups, normalizedEquipmentSelections]); + + const resolvedEquipmentRows = useMemo(() => { + const counts = new Map(); + const sourceLabels = [ + ...resolvedEquipmentGrants.map((grant) => grant.label).filter((label) => !/gp$/i.test(label)), + ...equipmentSelectionState.addedItems + ]; + + sourceLabels.forEach((rawLabel) => { + const parsed = parseInventoryLabel(rawLabel); + if (!parsed.label) { + return; + } + counts.set(parsed.label, (counts.get(parsed.label) || 0) + parsed.count); + }); + + return Array.from(counts.entries()) + .map(([label, count]) => ({ label, count })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [resolvedEquipmentGrants, equipmentSelectionState.addedItems]); + + const resolvedEquipmentCurrency = useMemo( + () => cpToCurrency(resolvedEquipmentGrants.reduce((total, grant) => total + grant.cpValue, 0)), + [resolvedEquipmentGrants] + ); + + const backgroundAbilityModes = useMemo( + () => getBackgroundAbilityModes(selectedBackgroundDetail), + [selectedBackgroundDetail?.rawJson, selectedBackgroundDetail?.entityRef] + ); + + const activeBackgroundAbilityMode = useMemo(() => { + if (!characterDraft || !backgroundAbilityModes.length) { + return null; + } + + const safeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, characterDraft.steps.abilityScores.backgroundBonusModeIndex)); + return backgroundAbilityModes[safeIndex] ?? null; + }, [backgroundAbilityModes, characterDraft?.steps.abilityScores.backgroundBonusModeIndex, characterDraft]); + + const normalizedBackgroundBonusSelections = useMemo(() => { + if (!characterDraft) { + return []; + } + return normalizeBackgroundBonusSelections(activeBackgroundAbilityMode, characterDraft.steps.abilityScores.backgroundBonusSelections); + }, [activeBackgroundAbilityMode, characterDraft?.steps.abilityScores.backgroundBonusSelections, characterDraft]); + + const backgroundBonusTotals = useMemo( + () => getBackgroundBonusTotals(activeBackgroundAbilityMode, normalizedBackgroundBonusSelections), + [activeBackgroundAbilityMode, normalizedBackgroundBonusSelections] + ); + + const abilityTotals = useMemo(() => { + if (!characterDraft) { + return null; + } + return getScoresWithBonuses(characterDraft.steps.abilityScores.scores, backgroundBonusTotals); + }, [characterDraft?.steps.abilityScores.scores, backgroundBonusTotals, characterDraft]); + + useEffect(() => { + if (!characterDraft) { + return; + } + + const current = characterDraft.steps.abilityScores; + if (!backgroundAbilityModes.length) { + if (current.backgroundBonusModeIndex === 0 && current.backgroundBonusSelections.length === 0) { + return; + } + + updateDraft((draft) => ({ + ...draft, + steps: { + ...draft.steps, + abilityScores: { + ...draft.steps.abilityScores, + backgroundBonusModeIndex: 0, + backgroundBonusSelections: [] + } + } + })); + return; + } + + const clampedModeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, current.backgroundBonusModeIndex)); + const mode = backgroundAbilityModes[clampedModeIndex] ?? null; + const normalizedSelections = normalizeBackgroundBonusSelections(mode, current.backgroundBonusSelections); + + const hasModeChanged = clampedModeIndex !== current.backgroundBonusModeIndex; + const hasSelectionsChanged = !areAbilityArraysEqual(normalizedSelections, current.backgroundBonusSelections); + if (!hasModeChanged && !hasSelectionsChanged) { + return; + } + + updateDraft((draft) => ({ + ...draft, + steps: { + ...draft.steps, + abilityScores: { + ...draft.steps.abilityScores, + backgroundBonusModeIndex: clampedModeIndex, + backgroundBonusSelections: normalizedSelections + } + } + })); + }, [ + backgroundAbilityModes, + characterDraft?.id, + characterDraft?.steps.abilityScores.backgroundBonusModeIndex, + characterDraft?.steps.abilityScores.backgroundBonusSelections, + updateDraft + ]); + + useEffect(() => { + if (!characterDraft) { + return; + } + + const desiredNotes = + skillChoiceRequirements.length > 0 || manualSkillSelections.length > 0 + ? stringifySkillsNotes({ + text: skillNotesState.text, + picksByChoiceId: normalizedSkillChoicePicks, + extraSkills: manualSkillSelections + }) + : skillNotesState.text; + + const currentSelected = [...characterDraft.steps.skills.selected].sort((a, b) => a.localeCompare(b)); + const desiredSelected = [...allProficientSkills].sort((a, b) => a.localeCompare(b)); + const isSelectedSame = areStringArraysEqual(currentSelected, desiredSelected); + const isNotesSame = characterDraft.steps.skills.notes === desiredNotes; + if (isSelectedSame && isNotesSame) { + return; + } + + updateDraft((draft) => ({ + ...draft, + steps: { + ...draft.steps, + skills: { + ...draft.steps.skills, + selected: desiredSelected, + notes: desiredNotes + } + } + })); + }, [ + allProficientSkills, + characterDraft?.id, + characterDraft?.steps.skills.notes, + characterDraft?.steps.skills.selected, + normalizedSkillChoicePicks, + manualSkillSelections, + skillChoiceRequirements.length, + skillNotesState.text, + updateDraft + ]); + + useEffect(() => { + if (!characterDraft) { + return; + } + + const desired = uniqueStrings([...fixedFeatTokens, ...variableFeatSelections]).sort((a, b) => a.localeCompare(b)); + const current = [...normalizedFeatSelections].sort((a, b) => a.localeCompare(b)); + if (areStringArraysEqual(current, desired)) { + return; + } + + updateDraft((draft) => ({ + ...draft, + steps: { + ...draft.steps, + feats: { + ...draft.steps.feats, + selected: desired + } + } + })); + }, [characterDraft?.id, fixedFeatTokens, normalizedFeatSelections, updateDraft, variableFeatSelections]); + + useEffect(() => { + if (!characterDraft || !equipmentChoiceGroups.length) { + return; + } + + const desiredState: EquipmentSelectionState = { + selections: normalizedEquipmentSelections, + addedItems: equipmentSelectionState.addedItems + }; + const desiredStartingEquipment = stringifyEquipmentSelectionState(desiredState); + const desiredInventory = desiredState.addedItems.join("\n"); + const currentCurrency = characterDraft.steps.equipment.currency; + const desiredCurrency = resolvedEquipmentCurrency; + const isCurrencySame = + currentCurrency.cp === desiredCurrency.cp && + currentCurrency.sp === desiredCurrency.sp && + currentCurrency.ep === desiredCurrency.ep && + currentCurrency.gp === desiredCurrency.gp && + currentCurrency.pp === desiredCurrency.pp; + + if ( + characterDraft.steps.equipment.startingEquipmentText === desiredStartingEquipment && + characterDraft.steps.equipment.inventoryText === desiredInventory && + isCurrencySame + ) { + return; + } + + updateDraft((draft) => ({ + ...draft, + steps: { + ...draft.steps, + equipment: { + ...draft.steps.equipment, + startingEquipmentText: desiredStartingEquipment, + inventoryText: desiredInventory, + currency: desiredCurrency + } + } + })); + }, [ + characterDraft?.id, + characterDraft?.steps.equipment.currency, + characterDraft?.steps.equipment.inventoryText, + characterDraft?.steps.equipment.startingEquipmentText, + equipmentChoiceGroups.length, + equipmentSelectionState.addedItems, + normalizedEquipmentSelections, + resolvedEquipmentCurrency, + updateDraft + ]); + + const isStepComplete = useCallback((stepIndex: number, draft: CharacterDocument | null): boolean => { + if (!draft) { + return false; + } + + if (stepIndex === 0) { + return Boolean(draft.selections.class) && draft.steps.preferences.sourceBuckets.length > 0; + } + + if (stepIndex === 1) { + return Boolean(draft.selections.species); + } + + if (stepIndex === 2) { + return Boolean(draft.selections.background); + } + + if (stepIndex === 3) { + const { method, scores, assignments } = draft.steps.abilityScores; + let isBaseValid = true; + + if (method === "pointBuy") { + const valuesOk = ABILITY_KEYS.every((key) => scores[key] >= 8 && scores[key] <= 15); + isBaseValid = valuesOk && getPointBuySpent(scores) <= 27; + } + + if (method === "manual" && isBaseValid) { + isBaseValid = ABILITY_KEYS.every((key) => Number.isInteger(scores[key]) && scores[key] >= 1 && scores[key] <= 30); + } + + if ((method === "standardArray" || method === "rolled") && isBaseValid) { + const assignmentValues = ABILITY_KEYS.map((key) => assignments[key]); + const unique = new Set(assignmentValues); + isBaseValid = assignmentValues.every((it) => Number.isInteger(it) && it >= 0 && it <= 5) && unique.size === 6; + } + + if (!isBaseValid) { + return false; + } + + const selectedBackgroundRef = draft.selections.background?.entityRef; + if (!selectedBackgroundRef) { + return true; + } + + const backgroundDetail = detailCache[selectedBackgroundRef] ?? null; + const modes = getBackgroundAbilityModes(backgroundDetail); + if (!modes.length) { + return true; + } + + const safeModeIndex = Math.max(0, Math.min(modes.length - 1, draft.steps.abilityScores.backgroundBonusModeIndex)); + const mode = modes[safeModeIndex] ?? null; + const selections = normalizeBackgroundBonusSelections(mode, draft.steps.abilityScores.backgroundBonusSelections); + return mode ? selections.length === mode.weights.length : true; + } + + if (stepIndex === 4) { + const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; + const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null; + const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null; + + const slotCount = getClassFeatSlotCount(classDetail, draft.profile.level); + if (slotCount <= 0) { + return true; + } + + const fixedFeatKeySet = new Set( + uniqueStrings([...getFixedFeatUids(backgroundDetail), ...getFixedFeatUids(speciesDetail)]) + .map((token) => parseNameSourceToken(token)) + .filter((it): it is { name: string; source: string } => Boolean(it)) + .map((it) => sanitizeFeatKey(it.name, it.source)) + ); + + const variableCount = uniqueStrings( + draft.steps.feats.selected + .map((token) => parseNameSourceToken(token) || parseFeatUid(token)) + .filter((it): it is { name: string; source: string } => Boolean(it)) + .map((it) => sanitizeFeatKey(it.name, it.source)) + ).filter((key) => !fixedFeatKeySet.has(key)).length; + + return variableCount >= slotCount; + } + + if (stepIndex === 5) { + const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; + const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null; + const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null; + + const requirements = [ + ...parseClassSkills(classDetail).choices, + ...parseBackgroundSkills(backgroundDetail).choices, + ...parseSpeciesSkills(speciesDetail).choices + ]; + if (!requirements.length) { + return true; + } + + const notesState = parseSkillsNotes(draft.steps.skills.notes); + return requirements.every((choice) => { + const picks = uniqueStrings((notesState.picksByChoiceId[choice.id] || []).filter((it) => choice.options.includes(it))); + return picks.length >= choice.count; + }); + } + + if (stepIndex === 6) { + const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; + const spellcasting = parseClassSpellcasting(classDetail, draft.profile.level); + const requiredCantrips = spellcasting.cantrips; + const requiredPrepared = spellcasting.known || spellcasting.prepared || 0; + + if (requiredCantrips <= 0 && requiredPrepared <= 0) { + return true; + } + + if (!spellCatalog?.length) { + return draft.steps.spells.selected.length > 0; + } + + const spellLevelByToken = new Map(); + spellCatalog.forEach((spell) => { + spellLevelByToken.set(toNameSourceToken(spell.name, spell.source), spell.level); + }); + + let cantripCount = 0; + let preparedCount = 0; + uniqueStrings(draft.steps.spells.selected).forEach((token) => { + const parsed = parseNameSourceToken(token) || parseFeatUid(token); + if (!parsed) { + return; + } + const level = spellLevelByToken.get(toNameSourceToken(parsed.name, parsed.source)); + if (level == null) { + return; + } + if (level === 0) { + cantripCount += 1; + } else { + preparedCount += 1; + } + }); + + return cantripCount >= requiredCantrips && preparedCount >= requiredPrepared; + } + + if (stepIndex === 7) { + const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null; + const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null; + const groups = [...parseStartingEquipmentChoices(classDetail, "Class"), ...parseStartingEquipmentChoices(backgroundDetail, "Background")]; + if (!groups.length) { + return true; + } + + const selections = parseEquipmentSelectionState(draft.steps.equipment.startingEquipmentText, draft.steps.equipment.inventoryText).selections; + return groups.every((group) => { + const selectedId = selections[group.id]; + return group.options.some((option) => option.id === selectedId); + }); + } + + if (stepIndex === 8) { + return Boolean(draft.profile.name.trim()) && Boolean(draft.steps.about.backstory.trim() || draft.steps.about.notes.trim()); + } + + if (stepIndex === CHARACTER_STEPS.length - 1) { + return true; + } + + return false; + }, [detailCache, spellCatalog]); + + const stepCompletion = useMemo( + () => CHARACTER_STEPS.map((_, index) => isStepComplete(index, characterDraft)), + [characterDraft, isStepComplete] + ); + + const currentStepLabel = CHARACTER_STEPS[characterStep] ?? CHARACTER_STEPS[0]; + const progressionSteps = CHARACTER_STEPS.slice(0, CHARACTER_STEPS.length - 1); + const canMoveNext = useMemo(() => { + if (!characterDraft) { + return false; + } + + if (characterStep >= CHARACTER_STEPS.length - 1) { + return false; + } + + if (characterStep === CHARACTER_STEPS.length - 2) { + return true; + } + + return isStepComplete(characterStep, characterDraft); + }, [characterDraft, characterStep, isStepComplete]); + + const nextStep = useCallback(() => { + setCharacterStep((current) => Math.min(CHARACTER_STEPS.length - 1, current + 1)); + }, []); + + const prevStep = useCallback(() => { + setCharacterStep((current) => Math.max(0, current - 1)); + }, []); + + const renderEntityDetailContent = useCallback( + (detail: CharacterSelectionDetail | null, kind: EntityPopoverKind) => { + if (!detail) { + return

Loading details...

; + } + + return ( +
+

{detail.name}

+

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

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