Implements PF2e as an alternative game system alongside D&D 5e/5.5e. Settings modal "Game System" selector switches conditions, bestiary, stat block layout, and initiative calculation between systems. - Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3) - 2,502 PF2e creatures from bundled search index (77 sources) - PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods - Perception-based initiative rolling - System-scoped source cache (D&D and PF2e sources don't collide) - Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[]) - Difficulty indicator hidden in PF2e mode (excluded from MVP) Closes dostulata/initiative#19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
130 lines
3.4 KiB
TypeScript
130 lines
3.4 KiB
TypeScript
import { useCallback, useRef, useState } from "react";
|
|
import { useAdapters } from "../contexts/adapter-context.js";
|
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
|
|
|
const BATCH_SIZE = 6;
|
|
|
|
interface BulkImportState {
|
|
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
|
readonly total: number;
|
|
readonly completed: number;
|
|
readonly failed: number;
|
|
}
|
|
|
|
const IDLE_STATE: BulkImportState = {
|
|
status: "idle",
|
|
total: 0,
|
|
completed: 0,
|
|
failed: 0,
|
|
};
|
|
|
|
interface BulkImportHook {
|
|
state: BulkImportState;
|
|
startImport: (
|
|
baseUrl: string,
|
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
|
refreshCache: () => Promise<void>,
|
|
) => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
export function useBulkImport(): BulkImportHook {
|
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
|
const { edition } = useRulesEditionContext();
|
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
|
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
|
const countersRef = useRef({ completed: 0, failed: 0 });
|
|
|
|
const startImport = useCallback(
|
|
(
|
|
baseUrl: string,
|
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
|
refreshCache: () => Promise<void>,
|
|
) => {
|
|
const allCodes = indexPort.getAllSourceCodes();
|
|
const total = allCodes.length;
|
|
|
|
countersRef.current = { completed: 0, failed: 0 };
|
|
setState({ status: "loading", total, completed: 0, failed: 0 });
|
|
|
|
void (async () => {
|
|
const cacheChecks = await Promise.all(
|
|
allCodes.map(async (code) => ({
|
|
code,
|
|
cached: await isSourceCached(code),
|
|
})),
|
|
);
|
|
|
|
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
|
|
const uncached = cacheChecks.filter((c) => !c.cached);
|
|
|
|
countersRef.current.completed = alreadyCached;
|
|
|
|
if (uncached.length === 0) {
|
|
setState({
|
|
status: "complete",
|
|
total,
|
|
completed: total,
|
|
failed: 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState((s) => ({ ...s, completed: alreadyCached }));
|
|
|
|
const batches: { code: string }[][] = [];
|
|
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
|
batches.push(uncached.slice(i, i + BATCH_SIZE));
|
|
}
|
|
|
|
await batches.reduce(
|
|
(chain, batch) =>
|
|
chain.then(() =>
|
|
Promise.allSettled(
|
|
batch.map(async ({ code }) => {
|
|
const url = indexPort.getDefaultFetchUrl(code, baseUrl);
|
|
try {
|
|
await fetchAndCacheSource(code, url);
|
|
countersRef.current.completed++;
|
|
} catch (err) {
|
|
countersRef.current.failed++;
|
|
console.warn(
|
|
`[bulk-import] FAILED ${code} (${url}):`,
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
}
|
|
setState({
|
|
status: "loading",
|
|
total,
|
|
completed: countersRef.current.completed,
|
|
failed: countersRef.current.failed,
|
|
});
|
|
}),
|
|
),
|
|
),
|
|
Promise.resolve() as Promise<unknown>,
|
|
);
|
|
|
|
await refreshCache();
|
|
|
|
const { completed, failed } = countersRef.current;
|
|
setState({
|
|
status: failed > 0 ? "partial-failure" : "complete",
|
|
total,
|
|
completed,
|
|
failed,
|
|
});
|
|
})();
|
|
},
|
|
[indexPort],
|
|
);
|
|
|
|
const reset = useCallback(() => {
|
|
setState(IDLE_STATE);
|
|
}, []);
|
|
|
|
return { state, startImport, reset };
|
|
}
|