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:
Lukas
2026-03-10 22:46:13 +01:00
parent 99d1ba1bcd
commit 91120d7c82
31 changed files with 38321 additions and 63422 deletions

View File

@@ -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",

View File

@@ -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>
);

View File

@@ -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", () => {

View File

@@ -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);
}
});

View File

@@ -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),

View 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;
}

View 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;
}

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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,
};
}

View File

@@ -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);