Implement the 029-on-demand-bestiary feature that replaces the bundled XMM bestiary JSON with a compact search index (~350KB) and on-demand source loading, where users explicitly provide a URL or upload a JSON file to fetch full stat block data per source, which is then normalized and cached in IndexedDB (with in-memory fallback) so creature stat blocks load instantly on subsequent visits while keeping the app bundle small and never auto-fetching copyrighted content
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"@initiative/domain": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -6,9 +6,10 @@ import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { SourceManager } from "./components/source-manager";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { useBestiary } from "./hooks/use-bestiary";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
|
||||
function rollDice(): number {
|
||||
@@ -34,44 +35,43 @@ export function App() {
|
||||
makeStore,
|
||||
} = useEncounter();
|
||||
|
||||
const { search, getCreature, isLoaded } = useBestiary();
|
||||
const {
|
||||
search,
|
||||
getCreature,
|
||||
isLoaded,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
} = useBestiary();
|
||||
|
||||
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
|
||||
null,
|
||||
);
|
||||
const [suggestions, setSuggestions] = useState<Creature[]>([]);
|
||||
const [selectedCreatureId, setSelectedCreatureId] =
|
||||
useState<CreatureId | null>(null);
|
||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
||||
|
||||
const selectedCreature: Creature | null = selectedCreatureId
|
||||
? (getCreature(selectedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(creature: Creature) => {
|
||||
addFromBestiary(creature);
|
||||
setSelectedCreature(creature);
|
||||
(result: SearchResult) => {
|
||||
addFromBestiary(result);
|
||||
// Derive the creature ID so stat block panel can try to show it
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
setSelectedCreatureId(
|
||||
`${result.source.toLowerCase()}:${slug}` as CreatureId,
|
||||
);
|
||||
},
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleShowStatBlock = useCallback((creature: Creature) => {
|
||||
setSelectedCreature(creature);
|
||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||
setSelectedCreatureId(creatureId as CreatureId);
|
||||
}, []);
|
||||
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
const creature = getCreature(creatureId as CreatureId);
|
||||
if (creature) setSelectedCreature(creature);
|
||||
},
|
||||
[getCreature],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(query: string) => {
|
||||
if (!isLoaded || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setSuggestions(search(query));
|
||||
},
|
||||
[isLoaded, search],
|
||||
);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
||||
@@ -102,9 +102,8 @@ export function App() {
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
||||
const active = encounter.combatants[encounter.activeIndex];
|
||||
if (!active?.creatureId || !isLoaded) return;
|
||||
const creature = getCreature(active.creatureId as CreatureId);
|
||||
if (creature) setSelectedCreature(creature);
|
||||
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
|
||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -117,9 +116,16 @@ export function App() {
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sourceManagerOpen && (
|
||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
||||
<SourceManager onCacheCleared={refreshCache} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex flex-col pb-2">
|
||||
@@ -163,17 +169,19 @@ export function App() {
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
suggestions={suggestions}
|
||||
onSearchChange={handleSearchChange}
|
||||
onShowStatBlock={handleShowStatBlock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat Block Panel */}
|
||||
<StatBlockPanel
|
||||
creatureId={selectedCreatureId}
|
||||
creature={selectedCreature}
|
||||
onClose={() => setSelectedCreature(null)}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
onClose={() => setSelectedCreatureId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeBestiary } from "../bestiary-adapter.js";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../bestiary-adapter.js";
|
||||
|
||||
beforeAll(() => {
|
||||
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||
});
|
||||
|
||||
describe("normalizeBestiary", () => {
|
||||
it("normalizes a simple creature", () => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { expect, it } from "vitest";
|
||||
import rawData from "../../../../../data/bestiary/xmm.json";
|
||||
import { normalizeBestiary } from "../bestiary-adapter.js";
|
||||
|
||||
it("normalizes all 503 monsters without error", () => {
|
||||
const creatures = normalizeBestiary(
|
||||
rawData as unknown as Parameters<typeof normalizeBestiary>[0],
|
||||
);
|
||||
expect(creatures.length).toBe(503);
|
||||
for (const c of creatures) {
|
||||
expect(c.name).toBeTruthy();
|
||||
expect(c.id).toBeTruthy();
|
||||
expect(c.ac).toBeGreaterThanOrEqual(0);
|
||||
expect(c.hp.average).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
@@ -81,9 +81,11 @@ interface RawSpellcasting {
|
||||
|
||||
// --- Source mapping ---
|
||||
|
||||
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
|
||||
XMM: "MM 2024",
|
||||
};
|
||||
let sourceDisplayNames: Record<string, string> = {};
|
||||
|
||||
export function setSourceDisplayNames(names: Record<string, string>): void {
|
||||
sourceDisplayNames = names;
|
||||
}
|
||||
|
||||
// --- Size mapping ---
|
||||
|
||||
@@ -353,7 +355,13 @@ function makeCreatureId(source: string, name: string): CreatureId {
|
||||
* Normalizes raw 5etools bestiary JSON into domain Creature[].
|
||||
*/
|
||||
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||
return raw.monster.map((m) => {
|
||||
// Filter out _copy entries — these reference another source's monster
|
||||
// and lack their own stats (ac, hp, cr, etc.)
|
||||
const monsters = raw.monster.filter(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
||||
(m) => !(m as any)._copy,
|
||||
);
|
||||
return monsters.map((m) => {
|
||||
const crStr = extractCr(m.cr);
|
||||
const ac = extractAc(m.ac);
|
||||
|
||||
@@ -361,7 +369,7 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||
id: makeCreatureId(m.source, m.name),
|
||||
name: m.name,
|
||||
source: m.source,
|
||||
sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source,
|
||||
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
|
||||
size: formatSize(m.size),
|
||||
type: formatType(m.type),
|
||||
alignment: formatAlignment(m.alignment),
|
||||
|
||||
139
apps/web/src/adapters/bestiary-cache.ts
Normal file
139
apps/web/src/adapters/bestiary-cache.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
readonly displayName: string;
|
||||
readonly creatureCount: number;
|
||||
readonly cachedAt: number;
|
||||
}
|
||||
|
||||
interface CachedSourceRecord {
|
||||
sourceCode: string;
|
||||
displayName: string;
|
||||
creatures: Creature[];
|
||||
cachedAt: number;
|
||||
creatureCount: number;
|
||||
}
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
let dbFailed = false;
|
||||
|
||||
// In-memory fallback when IndexedDB is unavailable
|
||||
const memoryStore = new Map<string, CachedSourceRecord>();
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase | null> {
|
||||
if (db) return db;
|
||||
if (dbFailed) return null;
|
||||
|
||||
try {
|
||||
db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, {
|
||||
keyPath: "sourceCode",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
return db;
|
||||
} catch {
|
||||
dbFailed = true;
|
||||
console.warn(
|
||||
"IndexedDB unavailable — bestiary cache will not persist across sessions.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheSource(
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: Creature[],
|
||||
): Promise<void> {
|
||||
const record: CachedSourceRecord = {
|
||||
sourceCode,
|
||||
displayName,
|
||||
creatures,
|
||||
cachedAt: Date.now(),
|
||||
creatureCount: creatures.length,
|
||||
};
|
||||
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.put(STORE_NAME, record);
|
||||
} else {
|
||||
memoryStore.set(sourceCode, record);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSourceCached(sourceCode: string): Promise<boolean> {
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
const record = await database.get(STORE_NAME, sourceCode);
|
||||
return record !== undefined;
|
||||
}
|
||||
return memoryStore.has(sourceCode);
|
||||
}
|
||||
|
||||
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
|
||||
return all.map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
}));
|
||||
}
|
||||
return [...memoryStore.values()].map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function clearSource(sourceCode: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.delete(STORE_NAME, sourceCode);
|
||||
} else {
|
||||
memoryStore.delete(sourceCode);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAll(): Promise<void> {
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.clear(STORE_NAME);
|
||||
} else {
|
||||
memoryStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAllCachedCreatures(): Promise<
|
||||
Map<CreatureId, Creature>
|
||||
> {
|
||||
const map = new Map<CreatureId, Creature>();
|
||||
const database = await getDb();
|
||||
|
||||
let records: CachedSourceRecord[];
|
||||
if (database) {
|
||||
records = await database.getAll(STORE_NAME);
|
||||
} else {
|
||||
records = [...memoryStore.values()];
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
for (const creature of record.creatures) {
|
||||
map.set(creature.id, creature);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
56
apps/web/src/adapters/bestiary-index-adapter.ts
Normal file
56
apps/web/src/adapters/bestiary-index-adapter.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
|
||||
|
||||
import rawIndex from "../../../../data/bestiary/index.json";
|
||||
|
||||
interface CompactCreature {
|
||||
readonly n: string;
|
||||
readonly s: string;
|
||||
readonly ac: number;
|
||||
readonly hp: number;
|
||||
readonly dx: number;
|
||||
readonly cr: string;
|
||||
readonly ip: number;
|
||||
readonly sz: string;
|
||||
readonly tp: string;
|
||||
}
|
||||
|
||||
interface CompactIndex {
|
||||
readonly sources: Record<string, string>;
|
||||
readonly creatures: readonly CompactCreature[];
|
||||
}
|
||||
|
||||
function mapCreature(c: CompactCreature): BestiaryIndexEntry {
|
||||
return {
|
||||
name: c.n,
|
||||
source: c.s,
|
||||
ac: c.ac,
|
||||
hp: c.hp,
|
||||
dex: c.dx,
|
||||
cr: c.cr,
|
||||
initiativeProficiency: c.ip,
|
||||
size: c.sz,
|
||||
type: c.tp,
|
||||
};
|
||||
}
|
||||
|
||||
let cachedIndex: BestiaryIndex | undefined;
|
||||
|
||||
export function loadBestiaryIndex(): BestiaryIndex {
|
||||
if (cachedIndex) return cachedIndex;
|
||||
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
cachedIndex = {
|
||||
sources: compact.sources,
|
||||
creatures: compact.creatures.map(mapCreature),
|
||||
};
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
export function getDefaultFetchUrl(sourceCode: string): string {
|
||||
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-${sourceCode.toLowerCase()}.json`;
|
||||
}
|
||||
|
||||
export function getSourceDisplayName(sourceCode: string): string {
|
||||
const index = loadBestiaryIndex();
|
||||
return index.sources[sourceCode] ?? sourceCode;
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { Search } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import { BestiarySearch } from "./bestiary-search.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface ActionBarProps {
|
||||
onAddCombatant: (name: string) => void;
|
||||
onAddFromBestiary: (creature: Creature) => void;
|
||||
bestiarySearch: (query: string) => Creature[];
|
||||
onAddFromBestiary: (result: SearchResult) => void;
|
||||
bestiarySearch: (query: string) => SearchResult[];
|
||||
bestiaryLoaded: boolean;
|
||||
suggestions: Creature[];
|
||||
onSearchChange: (query: string) => void;
|
||||
onShowStatBlock?: (creature: Creature) => void;
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
@@ -20,12 +17,10 @@ export function ActionBar({
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
suggestions,
|
||||
onSearchChange,
|
||||
onShowStatBlock,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
|
||||
const handleAdd = (e: FormEvent) => {
|
||||
@@ -33,28 +28,30 @@ export function ActionBar({
|
||||
if (nameInput.trim() === "") return;
|
||||
onAddCombatant(nameInput);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
onSearchChange(value);
|
||||
if (value.length >= 2) {
|
||||
setSuggestions(bestiarySearch(value));
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCreature = (creature: Creature) => {
|
||||
onAddFromBestiary(creature);
|
||||
const handleSelectCreature = (result: SearchResult) => {
|
||||
onAddFromBestiary(result);
|
||||
setSearchOpen(false);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
onShowStatBlock?.(creature);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (creature: Creature) => {
|
||||
onAddFromBestiary(creature);
|
||||
const handleSelectSuggestion = (result: SearchResult) => {
|
||||
onAddFromBestiary(result);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
onShowStatBlock?.(creature);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -71,7 +68,7 @@ export function ActionBar({
|
||||
handleSelectSuggestion(suggestions[suggestionIndex]);
|
||||
} else if (e.key === "Escape") {
|
||||
setSuggestionIndex(-1);
|
||||
onSearchChange("");
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,8 +97,8 @@ export function ActionBar({
|
||||
{suggestions.length > 0 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||
<ul className="max-h-48 overflow-y-auto py-1">
|
||||
{suggestions.map((creature, i) => (
|
||||
<li key={creature.id}>
|
||||
{suggestions.map((result, i) => (
|
||||
<li key={`${result.source}:${result.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
@@ -109,12 +106,12 @@ export function ActionBar({
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
}`}
|
||||
onClick={() => handleSelectSuggestion(creature)}
|
||||
onClick={() => handleSelectSuggestion(result)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{creature.name}</span>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
@@ -7,12 +6,13 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface BestiarySearchProps {
|
||||
onSelectCreature: (creature: Creature) => void;
|
||||
onSelectCreature: (result: SearchResult) => void;
|
||||
onClose: () => void;
|
||||
searchFn: (query: string) => Creature[];
|
||||
searchFn: (query: string) => SearchResult[];
|
||||
}
|
||||
|
||||
export function BestiarySearch({
|
||||
@@ -101,8 +101,8 @@ export function BestiarySearch({
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-60 overflow-y-auto py-1">
|
||||
{results.map((creature, i) => (
|
||||
<li key={creature.id}>
|
||||
{results.map((result, i) => (
|
||||
<li key={`${result.source}:${result.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
@@ -110,12 +110,12 @@ export function BestiarySearch({
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-hover-neutral-bg"
|
||||
}`}
|
||||
onClick={() => onSelectCreature(creature)}
|
||||
onClick={() => onSelectCreature(result)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
>
|
||||
<span>{creature.name}</span>
|
||||
<span>{result.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
{result.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
131
apps/web/src/components/source-fetch-prompt.tsx
Normal file
131
apps/web/src/components/source-fetch-prompt.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceFetchPromptProps {
|
||||
sourceCode: string;
|
||||
sourceDisplayName: string;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
onSourceLoaded: () => void;
|
||||
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
export function SourceFetchPrompt({
|
||||
sourceCode,
|
||||
sourceDisplayName,
|
||||
fetchAndCacheSource,
|
||||
onSourceLoaded,
|
||||
onUploadSource,
|
||||
}: SourceFetchPromptProps) {
|
||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setStatus("fetching");
|
||||
setError("");
|
||||
try {
|
||||
await fetchAndCacheSource(sourceCode, url);
|
||||
onSourceLoaded();
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setError(e instanceof Error ? e.message : "Failed to fetch source data");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setStatus("fetching");
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
await onUploadSource(sourceCode, json);
|
||||
onSourceLoaded();
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to process uploaded file",
|
||||
);
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Load {sourceDisplayName}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Stat block data for this source needs to be loaded. Enter a URL or
|
||||
upload a JSON file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="source-url" className="text-xs text-muted-foreground">
|
||||
Source URL
|
||||
</label>
|
||||
<Input
|
||||
id="source-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={status === "fetching"}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleFetch}
|
||||
disabled={status === "fetching" || !url}
|
||||
>
|
||||
{status === "fetching" ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{status === "fetching" ? "Loading..." : "Load"}
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground">or</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={status === "fetching"}
|
||||
>
|
||||
<Upload className="mr-1 h-3 w-3" />
|
||||
Upload file
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "error" && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/web/src/components/source-manager.tsx
Normal file
81
apps/web/src/components/source-manager.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Database, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface SourceManagerProps {
|
||||
onCacheCleared: () => void;
|
||||
}
|
||||
|
||||
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
const cached = await bestiaryCache.getCachedSources();
|
||||
setSources(cached);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSources();
|
||||
}, [loadSources]);
|
||||
|
||||
const handleClearSource = async (sourceCode: string) => {
|
||||
await bestiaryCache.clearSource(sourceCode);
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
await bestiaryCache.clearAll();
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
};
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<Database className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No cached sources</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
Cached Sources
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={handleClearAll}>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{sources.map((source) => (
|
||||
<li
|
||||
key={source.sourceCode}
|
||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm text-foreground">
|
||||
{source.displayName}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{source.creatureCount} creatures
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClearSource(source.sourceCode)}
|
||||
className="text-muted-foreground hover:text-hover-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,43 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creatureId: CreatureId | null;
|
||||
creature: Creature | null;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
const colonIndex = cId.indexOf(":");
|
||||
if (colonIndex === -1) return "";
|
||||
return cId.slice(0, colonIndex).toUpperCase();
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
creatureId,
|
||||
creature,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
onClose,
|
||||
}: StatBlockPanelProps) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
const [needsFetch, setNeedsFetch] = useState(false);
|
||||
const [checkingCache, setCheckingCache] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1024px)");
|
||||
@@ -20,7 +46,66 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (!creature) return null;
|
||||
// When creatureId changes, check if we need to show the fetch prompt
|
||||
useEffect(() => {
|
||||
if (!creatureId || creature) {
|
||||
setNeedsFetch(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceCode = extractSourceCode(creatureId);
|
||||
if (!sourceCode) {
|
||||
setNeedsFetch(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingCache(true);
|
||||
isSourceCached(sourceCode).then((cached) => {
|
||||
// If source is cached but creature not found, it's an edge case
|
||||
// If source is not cached, show fetch prompt
|
||||
setNeedsFetch(!cached);
|
||||
setCheckingCache(false);
|
||||
});
|
||||
}, [creatureId, creature, isSourceCached]);
|
||||
|
||||
if (!creatureId) return null;
|
||||
|
||||
const sourceCode = extractSourceCode(creatureId);
|
||||
|
||||
const handleSourceLoaded = async () => {
|
||||
await refreshCache();
|
||||
setNeedsFetch(false);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (checkingCache) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (creature) {
|
||||
return <StatBlock creature={creature} />;
|
||||
}
|
||||
|
||||
if (needsFetch && sourceCode) {
|
||||
return (
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
onSourceLoaded={handleSourceLoaded}
|
||||
onUploadSource={uploadAndCacheSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
No stat block available
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
@@ -37,9 +122,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<StatBlock creature={creature} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +152,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||
<StatBlock creature={creature} />
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
@@ -9,6 +9,7 @@ interface TurnNavigationProps {
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
onRollAllInitiative: () => void;
|
||||
onOpenSourceManager: () => void;
|
||||
}
|
||||
|
||||
export function TurnNavigation({
|
||||
@@ -17,6 +18,7 @@ export function TurnNavigation({
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
onRollAllInitiative,
|
||||
onOpenSourceManager,
|
||||
}: TurnNavigationProps) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -62,6 +64,16 @@ export function TurnNavigation({
|
||||
>
|
||||
<D20Icon className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onOpenSourceManager}
|
||||
title="Manage cached sources"
|
||||
aria-label="Manage cached sources"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -1,62 +1,126 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { normalizeBestiary } from "../adapters/bestiary-adapter.js";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
Creature,
|
||||
CreatureId,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../adapters/bestiary-adapter.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import {
|
||||
getSourceDisplayName,
|
||||
loadBestiaryIndex,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
|
||||
export interface SearchResult extends BestiaryIndexEntry {
|
||||
readonly sourceDisplayName: string;
|
||||
}
|
||||
|
||||
interface BestiaryHook {
|
||||
search: (query: string) => Creature[];
|
||||
search: (query: string) => SearchResult[];
|
||||
getCreature: (id: CreatureId) => Creature | undefined;
|
||||
allCreatures: Creature[];
|
||||
isLoaded: boolean;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useBestiary(): BestiaryHook {
|
||||
const [creatures, setCreatures] = useState<Creature[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const creatureMapRef = useRef<Map<string, Creature>>(new Map());
|
||||
const loadAttempted = useRef(false);
|
||||
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadAttempted.current) return;
|
||||
loadAttempted.current = true;
|
||||
const index = loadBestiaryIndex();
|
||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||
if (index.creatures.length > 0) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
|
||||
import("../../../../data/bestiary/xmm.json")
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies per entry
|
||||
.then((mod: any) => {
|
||||
const raw = mod.default ?? mod;
|
||||
try {
|
||||
const normalized = normalizeBestiary(raw);
|
||||
const map = new Map<string, Creature>();
|
||||
for (const c of normalized) {
|
||||
map.set(c.id, c);
|
||||
}
|
||||
creatureMapRef.current = map;
|
||||
setCreatures(normalized);
|
||||
setIsLoaded(true);
|
||||
} catch {
|
||||
// Normalization failed — bestiary unavailable
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Import failed — bestiary unavailable
|
||||
});
|
||||
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||
creatureMapRef.current = map;
|
||||
setTick((t) => t + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const search = useMemo(() => {
|
||||
return (query: string): Creature[] => {
|
||||
if (query.length < 2) return [];
|
||||
const lower = query.toLowerCase();
|
||||
return creatures
|
||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 10);
|
||||
};
|
||||
}, [creatures]);
|
||||
|
||||
const getCreature = useMemo(() => {
|
||||
return (id: CreatureId): Creature | undefined => {
|
||||
return creatureMapRef.current.get(id);
|
||||
};
|
||||
const search = useCallback((query: string): SearchResult[] => {
|
||||
if (query.length < 2) return [];
|
||||
const lower = query.toLowerCase();
|
||||
const index = loadBestiaryIndex();
|
||||
return index.creatures
|
||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 10)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
sourceDisplayName: getSourceDisplayName(c.source),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return { search, getCreature, allCreatures: creatures, isLoaded };
|
||||
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
|
||||
return creatureMapRef.current.get(id);
|
||||
}, []);
|
||||
|
||||
const isSourceCachedFn = useCallback(
|
||||
(sourceCode: string): Promise<boolean> => {
|
||||
return bestiaryCache.isSourceCached(sourceCode);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchAndCacheSource = useCallback(
|
||||
async (sourceCode: string, url: string): Promise<void> => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const json = await response.json();
|
||||
const creatures = normalizeBestiary(json);
|
||||
const displayName = getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
for (const c of creatures) {
|
||||
creatureMapRef.current.set(c.id, c);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const uploadAndCacheSource = useCallback(
|
||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
||||
const creatures = normalizeBestiary(jsonData as any);
|
||||
const displayName = getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
for (const c of creatures) {
|
||||
creatureMapRef.current.set(c.id, c);
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||
creatureMapRef.current = map;
|
||||
setTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
search,
|
||||
getCreature,
|
||||
isLoaded,
|
||||
isSourceCached: isSourceCachedFn,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
toggleConditionUseCase,
|
||||
} from "@initiative/application";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
CombatantId,
|
||||
ConditionId,
|
||||
Creature,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
} from "@initiative/domain";
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
isDomainError,
|
||||
creatureId as makeCreatureId,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -240,11 +241,11 @@ export function useEncounter() {
|
||||
}, [makeStore]);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(creature: Creature) => {
|
||||
(entry: BestiaryIndexEntry) => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
creature.name,
|
||||
entry.name,
|
||||
existingNames,
|
||||
);
|
||||
|
||||
@@ -262,25 +263,32 @@ export function useEncounter() {
|
||||
if (isDomainError(addResult)) return;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, creature.hp.average);
|
||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (creature.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, creature.ac);
|
||||
if (entry.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, entry.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive creatureId from source + name
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||
|
||||
// Set creatureId on the combatant
|
||||
const currentEncounter = store.get();
|
||||
const updated = {
|
||||
...currentEncounter,
|
||||
combatants: currentEncounter.combatants.map((c) =>
|
||||
c.id === id ? { ...c, creatureId: creature.id } : c,
|
||||
c.id === id ? { ...c, creatureId: cId } : c,
|
||||
),
|
||||
};
|
||||
setEncounter(updated);
|
||||
|
||||
Reference in New Issue
Block a user