Add Pathfinder 2e game system mode
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>
This commit is contained in:
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
ConditionId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import type { ConditionId, PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
@@ -11,6 +7,7 @@ import {
|
||||
playerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SearchResult } from "../use-bestiary.js";
|
||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||
|
||||
function emptyState(): EncounterState {
|
||||
@@ -45,9 +42,11 @@ function stateWithHp(name: string, maxHp: number): EncounterState {
|
||||
});
|
||||
}
|
||||
|
||||
const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
||||
const BESTIARY_ENTRY: SearchResult = {
|
||||
system: "dnd",
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
@@ -57,6 +56,19 @@ const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
const PF2E_BESTIARY_ENTRY: SearchResult = {
|
||||
system: "pf2e",
|
||||
name: "Goblin Warrior",
|
||||
source: "B1",
|
||||
sourceDisplayName: "Bestiary",
|
||||
level: -1,
|
||||
ac: 16,
|
||||
hp: 6,
|
||||
perception: 5,
|
||||
size: "small",
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
describe("encounterReducer", () => {
|
||||
describe("add-combatant", () => {
|
||||
it("adds a combatant and pushes undo", () => {
|
||||
@@ -236,7 +248,9 @@ describe("encounterReducer", () => {
|
||||
conditionId: "blinded" as ConditionId,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].conditions).toContain("blinded");
|
||||
expect(next.encounter.combatants[0].conditions).toContainEqual({
|
||||
id: "blinded",
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles concentration", () => {
|
||||
@@ -327,6 +341,19 @@ describe("encounterReducer", () => {
|
||||
expect(names).toContain("Goblin 1");
|
||||
expect(names).toContain("Goblin 2");
|
||||
});
|
||||
|
||||
it("adds PF2e creature with HP, AC, and creatureId", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-from-bestiary",
|
||||
entry: PF2E_BESTIARY_ENTRY,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.name).toBe("Goblin Warrior");
|
||||
expect(c.maxHp).toBe(6);
|
||||
expect(c.ac).toBe(16);
|
||||
expect(c.creatureId).toBe("b1:goblin-warrior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-multiple-from-bestiary", () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import type { SearchResult } from "../use-bestiary.js";
|
||||
import { useEncounter } from "../use-encounter.js";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -152,9 +153,11 @@ describe("useEncounter", () => {
|
||||
expect(result.current.canRollAllInitiative).toBe(false);
|
||||
|
||||
// Add from bestiary to get a creature combatant
|
||||
const entry: BestiaryIndexEntry = {
|
||||
const entry: SearchResult = {
|
||||
system: "dnd",
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
@@ -175,9 +178,11 @@ describe("useEncounter", () => {
|
||||
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
const entry: BestiaryIndexEntry = {
|
||||
const entry: SearchResult = {
|
||||
system: "dnd",
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
@@ -202,9 +207,11 @@ describe("useEncounter", () => {
|
||||
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
const entry: BestiaryIndexEntry = {
|
||||
const entry: SearchResult = {
|
||||
system: "dnd",
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useRulesEdition } from "../use-rules-edition.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
const STORAGE_KEY = "initiative:game-system";
|
||||
const OLD_STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
describe("useRulesEdition", () => {
|
||||
afterEach(() => {
|
||||
@@ -11,6 +12,7 @@ describe("useRulesEdition", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
act(() => result.current.setEdition("5.5e"));
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(OLD_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("defaults to 5.5e", () => {
|
||||
@@ -42,4 +44,31 @@ describe("useRulesEdition", () => {
|
||||
|
||||
expect(r2.current.edition).toBe("5e");
|
||||
});
|
||||
|
||||
it("accepts pf2e as a valid game system", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => result.current.setEdition("pf2e"));
|
||||
|
||||
expect(result.current.edition).toBe("pf2e");
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("pf2e");
|
||||
});
|
||||
|
||||
it("migrates from old storage key on fresh module load", async () => {
|
||||
// Set up old key before re-importing the module
|
||||
localStorage.setItem(OLD_STORAGE_KEY, "5e");
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
|
||||
// Force a fresh module so loadEdition() re-runs at init time
|
||||
vi.resetModules();
|
||||
const { useRulesEdition: freshHook } = await import(
|
||||
"../use-rules-edition.js"
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
expect(result.current.edition).toBe("5e");
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||
expect(localStorage.getItem(OLD_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
BestiaryIndexEntry,
|
||||
Creature,
|
||||
CreatureId,
|
||||
Pf2eBestiaryIndexEntry,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../adapters/bestiary-adapter.js";
|
||||
import {
|
||||
normalizePf2eBestiary,
|
||||
setPf2eSourceDisplayNames,
|
||||
} from "../adapters/pf2e-bestiary-adapter.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
|
||||
export interface SearchResult extends BestiaryIndexEntry {
|
||||
readonly sourceDisplayName: string;
|
||||
}
|
||||
export type SearchResult =
|
||||
| (BestiaryIndexEntry & {
|
||||
readonly system: "dnd";
|
||||
readonly sourceDisplayName: string;
|
||||
})
|
||||
| (Pf2eBestiaryIndexEntry & {
|
||||
readonly system: "pf2e";
|
||||
readonly sourceDisplayName: string;
|
||||
});
|
||||
|
||||
interface BestiaryHook {
|
||||
search: (query: string) => SearchResult[];
|
||||
getCreature: (id: CreatureId) => Creature | undefined;
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined;
|
||||
isLoaded: boolean;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
@@ -28,28 +40,47 @@ interface BestiaryHook {
|
||||
}
|
||||
|
||||
export function useBestiary(): BestiaryHook {
|
||||
const { bestiaryCache, bestiaryIndex } = useAdapters();
|
||||
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [creatureMap, setCreatureMap] = useState(
|
||||
() => new Map<CreatureId, Creature>(),
|
||||
() => new Map<CreatureId, AnyCreature>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = bestiaryIndex.loadIndex();
|
||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||
if (index.creatures.length > 0) {
|
||||
|
||||
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
|
||||
setPf2eSourceDisplayNames(pf2eIndex.sources as Record<string, string>);
|
||||
|
||||
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
|
||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||
setCreatureMap(map);
|
||||
});
|
||||
}, [bestiaryCache, bestiaryIndex]);
|
||||
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): SearchResult[] => {
|
||||
if (query.length < 2) return [];
|
||||
const lower = query.toLowerCase();
|
||||
|
||||
if (edition === "pf2e") {
|
||||
const index = pf2eBestiaryIndex.loadIndex();
|
||||
return index.creatures
|
||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 10)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
system: "pf2e" as const,
|
||||
sourceDisplayName: pf2eBestiaryIndex.getSourceDisplayName(c.source),
|
||||
}));
|
||||
}
|
||||
|
||||
const index = bestiaryIndex.loadIndex();
|
||||
return index.creatures
|
||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||
@@ -57,24 +88,27 @@ export function useBestiary(): BestiaryHook {
|
||||
.slice(0, 10)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
system: "dnd" as const,
|
||||
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
|
||||
}));
|
||||
},
|
||||
[bestiaryIndex],
|
||||
[bestiaryIndex, pf2eBestiaryIndex, edition],
|
||||
);
|
||||
|
||||
const getCreature = useCallback(
|
||||
(id: CreatureId): Creature | undefined => {
|
||||
(id: CreatureId): AnyCreature | undefined => {
|
||||
return creatureMap.get(id);
|
||||
},
|
||||
[creatureMap],
|
||||
);
|
||||
|
||||
const system = edition === "pf2e" ? "pf2e" : "dnd";
|
||||
|
||||
const isSourceCachedFn = useCallback(
|
||||
(sourceCode: string): Promise<boolean> => {
|
||||
return bestiaryCache.isSourceCached(sourceCode);
|
||||
return bestiaryCache.isSourceCached(system, sourceCode);
|
||||
},
|
||||
[bestiaryCache],
|
||||
[bestiaryCache, system],
|
||||
);
|
||||
|
||||
const fetchAndCacheSource = useCallback(
|
||||
@@ -86,9 +120,20 @@ export function useBestiary(): BestiaryHook {
|
||||
);
|
||||
}
|
||||
const json = await response.json();
|
||||
const creatures = normalizeBestiary(json);
|
||||
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
const creatures =
|
||||
edition === "pf2e"
|
||||
? normalizePf2eBestiary(json)
|
||||
: normalizeBestiary(json);
|
||||
const displayName =
|
||||
edition === "pf2e"
|
||||
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||
: bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(
|
||||
system,
|
||||
sourceCode,
|
||||
displayName,
|
||||
creatures,
|
||||
);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const c of creatures) {
|
||||
@@ -97,15 +142,27 @@ export function useBestiary(): BestiaryHook {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[bestiaryCache, bestiaryIndex],
|
||||
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||
);
|
||||
|
||||
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 = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
const creatures =
|
||||
edition === "pf2e"
|
||||
? normalizePf2eBestiary(jsonData as { creature: unknown[] })
|
||||
: normalizeBestiary(
|
||||
jsonData as Parameters<typeof normalizeBestiary>[0],
|
||||
);
|
||||
const displayName =
|
||||
edition === "pf2e"
|
||||
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||
: bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(
|
||||
system,
|
||||
sourceCode,
|
||||
displayName,
|
||||
creatures,
|
||||
);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const c of creatures) {
|
||||
@@ -114,7 +171,7 @@ export function useBestiary(): BestiaryHook {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[bestiaryCache, bestiaryIndex],
|
||||
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||
);
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
|
||||
@@ -29,7 +30,9 @@ interface BulkImportHook {
|
||||
}
|
||||
|
||||
export function useBulkImport(): BulkImportHook {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
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 });
|
||||
|
||||
@@ -40,7 +43,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
) => {
|
||||
const allCodes = bestiaryIndex.getAllSourceCodes();
|
||||
const allCodes = indexPort.getAllSourceCodes();
|
||||
const total = allCodes.length;
|
||||
|
||||
countersRef.current = { completed: 0, failed: 0 };
|
||||
@@ -81,7 +84,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
chain.then(() =>
|
||||
Promise.allSettled(
|
||||
batch.map(async ({ code }) => {
|
||||
const url = bestiaryIndex.getDefaultFetchUrl(code, baseUrl);
|
||||
const url = indexPort.getDefaultFetchUrl(code, baseUrl);
|
||||
try {
|
||||
await fetchAndCacheSource(code, url);
|
||||
countersRef.current.completed++;
|
||||
@@ -115,7 +118,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
});
|
||||
})();
|
||||
},
|
||||
[bestiaryIndex],
|
||||
[indexPort],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
}
|
||||
|
||||
type CreatureInfo = {
|
||||
cr: string;
|
||||
cr?: string;
|
||||
source: string;
|
||||
sourceDisplayName: string;
|
||||
};
|
||||
@@ -87,10 +87,11 @@ function buildBreakdownEntry(
|
||||
};
|
||||
}
|
||||
if (creature) {
|
||||
const cr = creature.cr ?? null;
|
||||
return {
|
||||
combatant: c,
|
||||
cr: creature.cr,
|
||||
xp: crToXp(creature.cr),
|
||||
cr,
|
||||
xp: cr ? crToXp(cr) : null,
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
side,
|
||||
@@ -132,7 +133,7 @@ function resolveCr(
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const cr = creature ? creature.cr : (c.cr ?? null);
|
||||
const cr = creature?.cr ?? c.cr ?? null;
|
||||
return { cr, creature };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CombatantDescriptor,
|
||||
CreatureId,
|
||||
@@ -20,7 +21,7 @@ export function resolveSide(c: Combatant): "party" | "enemy" {
|
||||
function buildDescriptors(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
): CombatantDescriptor[] {
|
||||
const descriptors: CombatantDescriptor[] = [];
|
||||
for (const c of combatants) {
|
||||
@@ -28,9 +29,10 @@ function buildDescriptors(
|
||||
const level = c.playerCharacterId
|
||||
? characters.find((p) => p.id === c.playerCharacterId)?.level
|
||||
: undefined;
|
||||
const cr = c.creatureId
|
||||
? getCreature(c.creatureId)?.cr
|
||||
: (c.cr ?? undefined);
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const creatureCr =
|
||||
creature && !("system" in creature) ? creature.cr : undefined;
|
||||
const cr = creatureCr ?? c.cr ?? undefined;
|
||||
|
||||
if (level !== undefined || cr !== undefined) {
|
||||
descriptors.push({ level, cr, side });
|
||||
@@ -46,6 +48,8 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
return useMemo(() => {
|
||||
if (edition === "pf2e") return null;
|
||||
|
||||
const descriptors = buildDescriptors(
|
||||
encounter.combatants,
|
||||
characters,
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
adjustHpUseCase,
|
||||
advanceTurnUseCase,
|
||||
clearEncounterUseCase,
|
||||
decrementConditionUseCase,
|
||||
editCombatantUseCase,
|
||||
redoUseCase,
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
setConditionValueUseCase,
|
||||
setCrUseCase,
|
||||
setHpUseCase,
|
||||
setInitiativeUseCase,
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
undoUseCase,
|
||||
} from "@initiative/application";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
CombatantId,
|
||||
CombatantInit,
|
||||
ConditionId,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import type { SearchResult } from "./use-bestiary.js";
|
||||
|
||||
// -- Types --
|
||||
|
||||
@@ -61,14 +63,25 @@ type EncounterAction =
|
||||
id: CombatantId;
|
||||
conditionId: ConditionId;
|
||||
}
|
||||
| {
|
||||
type: "set-condition-value";
|
||||
id: CombatantId;
|
||||
conditionId: ConditionId;
|
||||
value: number;
|
||||
}
|
||||
| {
|
||||
type: "decrement-condition";
|
||||
id: CombatantId;
|
||||
conditionId: ConditionId;
|
||||
}
|
||||
| { type: "toggle-concentration"; id: CombatantId }
|
||||
| { type: "clear-encounter" }
|
||||
| { type: "undo" }
|
||||
| { type: "redo" }
|
||||
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
|
||||
| { type: "add-from-bestiary"; entry: SearchResult }
|
||||
| {
|
||||
type: "add-multiple-from-bestiary";
|
||||
entry: BestiaryIndexEntry;
|
||||
entry: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||
@@ -156,7 +169,7 @@ function resolveAndRename(store: EncounterStore, name: string): string {
|
||||
|
||||
function addOneFromBestiary(
|
||||
store: EncounterStore,
|
||||
entry: BestiaryIndexEntry,
|
||||
entry: SearchResult,
|
||||
nextId: number,
|
||||
): {
|
||||
cId: CreatureId;
|
||||
@@ -215,7 +228,7 @@ function handleUndoRedo(
|
||||
|
||||
function handleAddFromBestiary(
|
||||
state: EncounterState,
|
||||
entry: BestiaryIndexEntry,
|
||||
entry: SearchResult,
|
||||
count: number,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
@@ -325,6 +338,8 @@ function dispatchEncounterAction(
|
||||
| { type: "set-cr" }
|
||||
| { type: "set-side" }
|
||||
| { type: "toggle-condition" }
|
||||
| { type: "set-condition-value" }
|
||||
| { type: "decrement-condition" }
|
||||
| { type: "toggle-concentration" }
|
||||
>,
|
||||
): EncounterState {
|
||||
@@ -373,6 +388,17 @@ function dispatchEncounterAction(
|
||||
case "toggle-condition":
|
||||
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||
break;
|
||||
case "set-condition-value":
|
||||
result = setConditionValueUseCase(
|
||||
store,
|
||||
action.id,
|
||||
action.conditionId,
|
||||
action.value,
|
||||
);
|
||||
break;
|
||||
case "decrement-condition":
|
||||
result = decrementConditionUseCase(store, action.id, action.conditionId);
|
||||
break;
|
||||
case "toggle-concentration":
|
||||
result = toggleConcentrationUseCase(store, action.id);
|
||||
break;
|
||||
@@ -522,6 +548,16 @@ export function useEncounter() {
|
||||
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||
[],
|
||||
),
|
||||
setConditionValue: useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId, value: number) =>
|
||||
dispatch({ type: "set-condition-value", id, conditionId, value }),
|
||||
[],
|
||||
),
|
||||
decrementCondition: useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatch({ type: "decrement-condition", id, conditionId }),
|
||||
[],
|
||||
),
|
||||
toggleConcentration: useCallback(
|
||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||
[],
|
||||
@@ -530,15 +566,12 @@ export function useEncounter() {
|
||||
() => dispatch({ type: "clear-encounter" }),
|
||||
[],
|
||||
),
|
||||
addFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
dispatch({ type: "add-from-bestiary", entry });
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
),
|
||||
addFromBestiary: useCallback((entry: SearchResult): CreatureId | null => {
|
||||
dispatch({ type: "add-from-bestiary", entry });
|
||||
return null;
|
||||
}, []),
|
||||
addMultipleFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||
(entry: SearchResult, count: number): CreatureId | null => {
|
||||
dispatch({
|
||||
type: "add-multiple-from-bestiary",
|
||||
entry,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
const STORAGE_KEY = "initiative:game-system";
|
||||
const OLD_STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let currentEdition: RulesEdition = loadEdition();
|
||||
@@ -9,7 +10,14 @@ let currentEdition: RulesEdition = loadEdition();
|
||||
function loadEdition(): RulesEdition {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === "5e" || raw === "5.5e") return raw;
|
||||
if (raw === "5e" || raw === "5.5e" || raw === "pf2e") return raw;
|
||||
// Migrate from old key
|
||||
const old = localStorage.getItem(OLD_STORAGE_KEY);
|
||||
if (old === "5e" || old === "5.5e") {
|
||||
localStorage.setItem(STORAGE_KEY, old);
|
||||
localStorage.removeItem(OLD_STORAGE_KEY);
|
||||
return old;
|
||||
}
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user