Implement the 021-bestiary-statblock feature that adds a searchable D&D 2024 Monster Manual creature library with inline autocomplete suggestions, full stat block display in a fixed side panel, auto-numbering of duplicate creature names, HP/AC pre-fill from bestiary data, and automatic stat block display on turn change for wide viewports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { useBestiary } from "./hooks/use-bestiary";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
|
||||
export function App() {
|
||||
@@ -17,51 +21,125 @@ export function App() {
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
} = useEncounter();
|
||||
|
||||
const { search, getCreature, isLoaded } = useBestiary();
|
||||
|
||||
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
|
||||
null,
|
||||
);
|
||||
const [suggestions, setSuggestions] = useState<Creature[]>([]);
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(creature: Creature) => {
|
||||
addFromBestiary(creature);
|
||||
setSelectedCreature(creature);
|
||||
},
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleShowStatBlock = useCallback((creature: Creature) => {
|
||||
setSelectedCreature(creature);
|
||||
}, []);
|
||||
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
const creature = getCreature(
|
||||
creatureId as import("@initiative/domain").CreatureId,
|
||||
);
|
||||
if (creature) setSelectedCreature(creature);
|
||||
},
|
||||
[getCreature],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(query: string) => {
|
||||
if (!isLoaded || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setSuggestions(search(query));
|
||||
},
|
||||
[isLoaded, search],
|
||||
);
|
||||
|
||||
// Auto-show stat block for the active combatant when turn changes,
|
||||
// but only when the viewport is wide enough to show it alongside the tracker
|
||||
useEffect(() => {
|
||||
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 import("@initiative/domain").CreatureId,
|
||||
);
|
||||
if (creature) setSelectedCreature(creature);
|
||||
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 px-4 py-8">
|
||||
{/* Header */}
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Initiative Tracker
|
||||
</h1>
|
||||
</header>
|
||||
<div className="h-screen overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-4 py-8">
|
||||
{/* Header */}
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Initiative Tracker
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Turn Navigation */}
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
/>
|
||||
{/* Turn Navigation */}
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
/>
|
||||
|
||||
{/* Combatant List */}
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{encounter.combatants.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
No combatants yet — add one to get started
|
||||
</p>
|
||||
) : (
|
||||
encounter.combatants.map((c, i) => (
|
||||
<CombatantRow
|
||||
key={c.id}
|
||||
combatant={c}
|
||||
isActive={i === encounter.activeIndex}
|
||||
onRename={editCombatant}
|
||||
onSetInitiative={setInitiative}
|
||||
onRemove={removeCombatant}
|
||||
onSetHp={setHp}
|
||||
onAdjustHp={adjustHp}
|
||||
onSetAc={setAc}
|
||||
onToggleCondition={toggleCondition}
|
||||
onToggleConcentration={toggleConcentration}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{/* Combatant List */}
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{encounter.combatants.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
No combatants yet — add one to get started
|
||||
</p>
|
||||
) : (
|
||||
encounter.combatants.map((c, i) => (
|
||||
<CombatantRow
|
||||
key={c.id}
|
||||
combatant={c}
|
||||
isActive={i === encounter.activeIndex}
|
||||
onRename={editCombatant}
|
||||
onSetInitiative={setInitiative}
|
||||
onRemove={removeCombatant}
|
||||
onSetHp={setHp}
|
||||
onAdjustHp={adjustHp}
|
||||
onSetAc={setAc}
|
||||
onToggleCondition={toggleCondition}
|
||||
onToggleConcentration={toggleConcentration}
|
||||
onShowStatBlock={
|
||||
c.creatureId
|
||||
? () => handleCombatantStatBlock(c.creatureId as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
suggestions={suggestions}
|
||||
onSearchChange={handleSearchChange}
|
||||
onShowStatBlock={handleShowStatBlock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
<ActionBar onAddCombatant={addCombatant} />
|
||||
{/* Stat Block Panel */}
|
||||
<StatBlockPanel
|
||||
creature={selectedCreature}
|
||||
onClose={() => setSelectedCreature(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
326
apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
Normal file
326
apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeBestiary } from "../bestiary-adapter.js";
|
||||
|
||||
describe("normalizeBestiary", () => {
|
||||
it("normalizes a simple creature", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Goblin Warrior",
|
||||
source: "XMM",
|
||||
size: ["S"],
|
||||
type: { type: "fey", tags: ["goblinoid"] },
|
||||
alignment: ["C", "N"],
|
||||
ac: [15],
|
||||
hp: { average: 10, formula: "3d6" },
|
||||
speed: { walk: 30 },
|
||||
str: 8,
|
||||
dex: 15,
|
||||
con: 10,
|
||||
int: 10,
|
||||
wis: 8,
|
||||
cha: 8,
|
||||
skill: { stealth: "+6" },
|
||||
senses: ["Darkvision 60 ft."],
|
||||
passive: 9,
|
||||
languages: ["Common", "Goblin"],
|
||||
cr: "1/4",
|
||||
action: [
|
||||
{
|
||||
name: "Scimitar",
|
||||
entries: [
|
||||
"{@atkr m} {@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2}) Slashing damage.",
|
||||
],
|
||||
},
|
||||
],
|
||||
bonus: [
|
||||
{
|
||||
name: "Nimble Escape",
|
||||
entries: [
|
||||
"The goblin takes the {@action Disengage|XPHB} or {@action Hide|XPHB} action.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
expect(creatures).toHaveLength(1);
|
||||
|
||||
const c = creatures[0];
|
||||
expect(c.id).toBe("xmm:goblin-warrior");
|
||||
expect(c.name).toBe("Goblin Warrior");
|
||||
expect(c.source).toBe("XMM");
|
||||
expect(c.sourceDisplayName).toBe("MM 2024");
|
||||
expect(c.size).toBe("Small");
|
||||
expect(c.type).toBe("Fey (Goblinoid)");
|
||||
expect(c.alignment).toBe("Chaotic Neutral");
|
||||
expect(c.ac).toBe(15);
|
||||
expect(c.hp).toEqual({ average: 10, formula: "3d6" });
|
||||
expect(c.speed).toBe("30 ft.");
|
||||
expect(c.abilities.dex).toBe(15);
|
||||
expect(c.cr).toBe("1/4");
|
||||
expect(c.proficiencyBonus).toBe(2);
|
||||
expect(c.passive).toBe(9);
|
||||
expect(c.skills).toBe("Stealth +6");
|
||||
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||
expect(c.languages).toBe("Common, Goblin");
|
||||
expect(c.actions).toHaveLength(1);
|
||||
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
|
||||
expect(c.actions?.[0].text).not.toContain("{@");
|
||||
expect(c.bonusActions).toHaveLength(1);
|
||||
expect(c.bonusActions?.[0].text).toContain("Disengage");
|
||||
expect(c.bonusActions?.[0].text).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("normalizes a creature with legendary actions", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Aboleth",
|
||||
source: "XMM",
|
||||
size: ["L"],
|
||||
type: "aberration",
|
||||
alignment: ["L", "E"],
|
||||
ac: [17],
|
||||
hp: { average: 135, formula: "18d10 + 36" },
|
||||
speed: { walk: 10, swim: 40 },
|
||||
str: 21,
|
||||
dex: 9,
|
||||
con: 15,
|
||||
int: 18,
|
||||
wis: 15,
|
||||
cha: 18,
|
||||
save: { con: "+6", int: "+8", wis: "+6" },
|
||||
senses: ["Darkvision 120 ft."],
|
||||
passive: 12,
|
||||
languages: ["Deep Speech", "Telepathy 120 ft."],
|
||||
cr: "10",
|
||||
legendary: [
|
||||
{
|
||||
name: "Lash",
|
||||
entries: ["The aboleth makes one Tentacle attack."],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const c = creatures[0];
|
||||
expect(c.legendaryActions).toBeDefined();
|
||||
expect(c.legendaryActions?.preamble).toContain("3 Legendary Actions");
|
||||
expect(c.legendaryActions?.entries).toHaveLength(1);
|
||||
expect(c.legendaryActions?.entries[0].name).toBe("Lash");
|
||||
});
|
||||
|
||||
it("normalizes a creature with spellcasting", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Test Caster",
|
||||
source: "XMM",
|
||||
size: ["M"],
|
||||
type: "humanoid",
|
||||
ac: [12],
|
||||
hp: { average: 40, formula: "9d8" },
|
||||
speed: { walk: 30 },
|
||||
str: 10,
|
||||
dex: 14,
|
||||
con: 10,
|
||||
int: 17,
|
||||
wis: 12,
|
||||
cha: 11,
|
||||
passive: 11,
|
||||
cr: "6",
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Spellcasting",
|
||||
headerEntries: [
|
||||
"The caster casts spells using Intelligence (spell save {@dc 15}):",
|
||||
],
|
||||
will: ["{@spell Detect Magic|XPHB}", "{@spell Mage Hand|XPHB}"],
|
||||
daily: {
|
||||
"2e": ["{@spell Fireball|XPHB}"],
|
||||
"1": ["{@spell Dimension Door|XPHB}"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const c = creatures[0];
|
||||
expect(c.spellcasting).toHaveLength(1);
|
||||
const sc = c.spellcasting?.[0];
|
||||
expect(sc).toBeDefined();
|
||||
expect(sc?.name).toBe("Spellcasting");
|
||||
expect(sc?.headerText).toContain("DC 15");
|
||||
expect(sc?.headerText).not.toContain("{@");
|
||||
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
|
||||
expect(sc?.daily).toHaveLength(2);
|
||||
expect(sc?.daily).toContainEqual({
|
||||
uses: 2,
|
||||
each: true,
|
||||
spells: ["Fireball"],
|
||||
});
|
||||
expect(sc?.daily).toContainEqual({
|
||||
uses: 1,
|
||||
each: false,
|
||||
spells: ["Dimension Door"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a creature with object-type type field", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Swarm of Bats",
|
||||
source: "XMM",
|
||||
size: ["L"],
|
||||
type: { type: "beast", swarmSize: "T" },
|
||||
ac: [12],
|
||||
hp: { average: 11, formula: "2d10" },
|
||||
speed: { walk: 5, fly: 30 },
|
||||
str: 5,
|
||||
dex: 15,
|
||||
con: 10,
|
||||
int: 2,
|
||||
wis: 12,
|
||||
cha: 4,
|
||||
passive: 11,
|
||||
resist: ["bludgeoning", "piercing", "slashing"],
|
||||
conditionImmune: ["charmed", "frightened"],
|
||||
cr: "1/4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const c = creatures[0];
|
||||
expect(c.type).toBe("Swarm of Tiny Beasts");
|
||||
expect(c.resist).toBe("Bludgeoning, Piercing, Slashing");
|
||||
expect(c.conditionImmune).toBe("Charmed, Frightened");
|
||||
});
|
||||
|
||||
it("normalizes a creature with conditional resistances", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Half-Dragon",
|
||||
source: "XMM",
|
||||
size: ["M"],
|
||||
type: "humanoid",
|
||||
ac: [18],
|
||||
hp: { average: 65, formula: "10d8 + 20" },
|
||||
speed: { walk: 30 },
|
||||
str: 16,
|
||||
dex: 13,
|
||||
con: 14,
|
||||
int: 10,
|
||||
wis: 11,
|
||||
cha: 10,
|
||||
passive: 10,
|
||||
cr: "5",
|
||||
resist: [
|
||||
{
|
||||
special: "Damage type chosen for the Draconic Origin trait",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const c = creatures[0];
|
||||
expect(c.resist).toBe("Damage type chosen for the Draconic Origin trait");
|
||||
});
|
||||
|
||||
it("normalizes a creature with multiple sizes", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Aberrant Cultist",
|
||||
source: "XMM",
|
||||
size: ["S", "M"],
|
||||
type: "humanoid",
|
||||
ac: [13],
|
||||
hp: { average: 22, formula: "4d8 + 4" },
|
||||
speed: { walk: 30 },
|
||||
str: 11,
|
||||
dex: 14,
|
||||
con: 12,
|
||||
int: 10,
|
||||
wis: 13,
|
||||
cha: 8,
|
||||
passive: 11,
|
||||
cr: "1/2",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
expect(creatures[0].size).toBe("Small or Medium");
|
||||
});
|
||||
|
||||
it("normalizes a creature with CR as object", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Dragon",
|
||||
source: "XMM",
|
||||
size: ["H"],
|
||||
type: "dragon",
|
||||
ac: [19],
|
||||
hp: { average: 256, formula: "19d12 + 133" },
|
||||
speed: { walk: 40 },
|
||||
str: 27,
|
||||
dex: 10,
|
||||
con: 25,
|
||||
int: 16,
|
||||
wis: 13,
|
||||
cha: 23,
|
||||
passive: 23,
|
||||
cr: { cr: "17", xpLair: 20000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
expect(creatures[0].cr).toBe("17");
|
||||
expect(creatures[0].proficiencyBonus).toBe(6);
|
||||
});
|
||||
|
||||
it("handles fly speed with hover condition", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Air Elemental",
|
||||
source: "XMM",
|
||||
size: ["L"],
|
||||
type: "elemental",
|
||||
ac: [15],
|
||||
hp: { average: 90, formula: "12d10 + 24" },
|
||||
speed: {
|
||||
walk: 10,
|
||||
fly: { number: 90, condition: "(hover)" },
|
||||
canHover: true,
|
||||
},
|
||||
str: 14,
|
||||
dex: 20,
|
||||
con: 14,
|
||||
int: 6,
|
||||
wis: 10,
|
||||
cha: 6,
|
||||
passive: 10,
|
||||
cr: "5",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
||||
});
|
||||
});
|
||||
16
apps/web/src/adapters/__tests__/bestiary-full.test.ts
Normal file
16
apps/web/src/adapters/__tests__/bestiary-full.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
138
apps/web/src/adapters/__tests__/strip-tags.test.ts
Normal file
138
apps/web/src/adapters/__tests__/strip-tags.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripTags } from "../strip-tags.js";
|
||||
|
||||
describe("stripTags", () => {
|
||||
it("returns text unchanged when no tags present", () => {
|
||||
expect(stripTags("Hello world")).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("strips {@spell Name|Source} to Name", () => {
|
||||
expect(stripTags("{@spell Fireball|XPHB}")).toBe("Fireball");
|
||||
});
|
||||
|
||||
it("strips {@condition Name|Source} to Name", () => {
|
||||
expect(stripTags("{@condition Frightened|XPHB}")).toBe("Frightened");
|
||||
});
|
||||
|
||||
it("strips {@damage dice} to dice", () => {
|
||||
expect(stripTags("{@damage 2d10}")).toBe("2d10");
|
||||
});
|
||||
|
||||
it("strips {@dice value} to value", () => {
|
||||
expect(stripTags("{@dice 5d10}")).toBe("5d10");
|
||||
});
|
||||
|
||||
it("strips {@dc N} to DC N", () => {
|
||||
expect(stripTags("{@dc 15}")).toBe("DC 15");
|
||||
});
|
||||
|
||||
it("strips {@hit N} to +N", () => {
|
||||
expect(stripTags("{@hit 5}")).toBe("+5");
|
||||
});
|
||||
|
||||
it("strips {@h} to Hit: ", () => {
|
||||
expect(stripTags("{@h}")).toBe("Hit: ");
|
||||
});
|
||||
|
||||
it("strips {@hom} to Hit or Miss: ", () => {
|
||||
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
|
||||
});
|
||||
|
||||
it("strips {@atkr m} to Melee Attack Roll:", () => {
|
||||
expect(stripTags("{@atkr m}")).toBe("Melee Attack Roll:");
|
||||
});
|
||||
|
||||
it("strips {@atkr r} to Ranged Attack Roll:", () => {
|
||||
expect(stripTags("{@atkr r}")).toBe("Ranged Attack Roll:");
|
||||
});
|
||||
|
||||
it("strips {@atkr m,r} to Melee or Ranged Attack Roll:", () => {
|
||||
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
||||
});
|
||||
|
||||
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
||||
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
||||
});
|
||||
|
||||
it("strips {@recharge} to (Recharge 6)", () => {
|
||||
expect(stripTags("{@recharge}")).toBe("(Recharge 6)");
|
||||
});
|
||||
|
||||
it("strips {@actSave wis} to Wisdom saving throw", () => {
|
||||
expect(stripTags("{@actSave wis}")).toBe("Wisdom saving throw");
|
||||
});
|
||||
|
||||
it("strips {@actSaveFail} to Failure:", () => {
|
||||
expect(stripTags("{@actSaveFail}")).toBe("Failure:");
|
||||
});
|
||||
|
||||
it("strips {@actSaveFail 2} to Failure by 2 or More:", () => {
|
||||
expect(stripTags("{@actSaveFail 2}")).toBe("Failure by 2 or More:");
|
||||
});
|
||||
|
||||
it("strips {@actSaveSuccess} to Success:", () => {
|
||||
expect(stripTags("{@actSaveSuccess}")).toBe("Success:");
|
||||
});
|
||||
|
||||
it("strips {@actTrigger} to Trigger:", () => {
|
||||
expect(stripTags("{@actTrigger}")).toBe("Trigger:");
|
||||
});
|
||||
|
||||
it("strips {@actResponse} to Response:", () => {
|
||||
expect(stripTags("{@actResponse}")).toBe("Response:");
|
||||
});
|
||||
|
||||
it("strips {@variantrule Name|Source|Display} to Display", () => {
|
||||
expect(stripTags("{@variantrule Cone [Area of Effect]|XPHB|Cone}")).toBe(
|
||||
"Cone",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips {@action Name|Source|Display} to Display", () => {
|
||||
expect(stripTags("{@action Disengage|XPHB|Disengage}")).toBe("Disengage");
|
||||
});
|
||||
|
||||
it("strips {@skill Name|Source} to Name", () => {
|
||||
expect(stripTags("{@skill Perception|XPHB}")).toBe("Perception");
|
||||
});
|
||||
|
||||
it("strips {@creature Name|Source} to Name", () => {
|
||||
expect(stripTags("{@creature Goblin|XPHB}")).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("strips {@hazard Name|Source} to Name", () => {
|
||||
expect(stripTags("{@hazard Lava|XPHB}")).toBe("Lava");
|
||||
});
|
||||
|
||||
it("strips {@status Name|Source} to Name", () => {
|
||||
expect(stripTags("{@status Bloodied|XPHB}")).toBe("Bloodied");
|
||||
});
|
||||
|
||||
it("handles unknown tags by extracting first segment", () => {
|
||||
expect(stripTags("{@unknown Something|else}")).toBe("Something");
|
||||
});
|
||||
|
||||
it("handles multiple tags in the same string", () => {
|
||||
expect(stripTags("{@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2})")).toBe(
|
||||
"+4, reach 5 ft. Hit: 5 (1d6 + 2)",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles nested tags gracefully", () => {
|
||||
expect(
|
||||
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
|
||||
).toBe("The spell Fireball deals 8d6.");
|
||||
});
|
||||
|
||||
it("handles text with no tags", () => {
|
||||
expect(stripTags("Just plain text.")).toBe("Just plain text.");
|
||||
});
|
||||
|
||||
it("strips {@actSaveSuccessOrFail} to Success or Failure:", () => {
|
||||
expect(stripTags("{@actSaveSuccessOrFail}")).toBe("Success or Failure:");
|
||||
});
|
||||
|
||||
it("strips {@action Name|Source} to Name when no display text", () => {
|
||||
expect(stripTags("{@action Disengage|XPHB}")).toBe("Disengage");
|
||||
});
|
||||
});
|
||||
404
apps/web/src/adapters/bestiary-adapter.ts
Normal file
404
apps/web/src/adapters/bestiary-adapter.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import type {
|
||||
Creature,
|
||||
CreatureId,
|
||||
DailySpells,
|
||||
LegendaryBlock,
|
||||
SpellcastingBlock,
|
||||
TraitBlock,
|
||||
} from "@initiative/domain";
|
||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||
import { stripTags } from "./strip-tags.js";
|
||||
|
||||
// --- Raw 5etools types (minimal, for parsing) ---
|
||||
|
||||
interface RawMonster {
|
||||
name: string;
|
||||
source: string;
|
||||
size: string[];
|
||||
type: string | { type: string; tags?: string[]; swarmSize?: string };
|
||||
alignment?: string[];
|
||||
ac: (number | { ac: number; from?: string[] })[];
|
||||
hp: { average: number; formula: string };
|
||||
speed: Record<
|
||||
string,
|
||||
number | { number: number; condition?: string } | boolean
|
||||
>;
|
||||
str: number;
|
||||
dex: number;
|
||||
con: number;
|
||||
int: number;
|
||||
wis: number;
|
||||
cha: number;
|
||||
save?: Record<string, string>;
|
||||
skill?: Record<string, string>;
|
||||
senses?: string[];
|
||||
passive: number;
|
||||
resist?: (string | { special: string })[];
|
||||
immune?: (string | { special: string })[];
|
||||
vulnerable?: (string | { special: string })[];
|
||||
conditionImmune?: string[];
|
||||
languages?: string[];
|
||||
cr: string | { cr: string };
|
||||
trait?: RawEntry[];
|
||||
action?: RawEntry[];
|
||||
bonus?: RawEntry[];
|
||||
reaction?: RawEntry[];
|
||||
legendary?: RawEntry[];
|
||||
legendaryActions?: number;
|
||||
legendaryActionsLair?: number;
|
||||
legendaryHeader?: string[];
|
||||
spellcasting?: RawSpellcasting[];
|
||||
}
|
||||
|
||||
interface RawEntry {
|
||||
name: string;
|
||||
entries: (string | RawEntryObject)[];
|
||||
}
|
||||
|
||||
interface RawEntryObject {
|
||||
type: string;
|
||||
items?: (
|
||||
| string
|
||||
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
|
||||
)[];
|
||||
style?: string;
|
||||
name?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
}
|
||||
|
||||
interface RawSpellcasting {
|
||||
name: string;
|
||||
headerEntries: string[];
|
||||
will?: string[];
|
||||
daily?: Record<string, string[]>;
|
||||
rest?: Record<string, string[]>;
|
||||
hidden?: string[];
|
||||
ability?: string;
|
||||
displayAs?: string;
|
||||
legendary?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// --- Source mapping ---
|
||||
|
||||
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
|
||||
XMM: "MM 2024",
|
||||
};
|
||||
|
||||
// --- Size mapping ---
|
||||
|
||||
const SIZE_MAP: Record<string, string> = {
|
||||
T: "Tiny",
|
||||
S: "Small",
|
||||
M: "Medium",
|
||||
L: "Large",
|
||||
H: "Huge",
|
||||
G: "Gargantuan",
|
||||
};
|
||||
|
||||
// --- Alignment mapping ---
|
||||
|
||||
const ALIGNMENT_MAP: Record<string, string> = {
|
||||
L: "Lawful",
|
||||
N: "Neutral",
|
||||
C: "Chaotic",
|
||||
G: "Good",
|
||||
E: "Evil",
|
||||
U: "Unaligned",
|
||||
};
|
||||
|
||||
function formatAlignment(codes?: string[]): string {
|
||||
if (!codes || codes.length === 0) return "Unaligned";
|
||||
if (codes.length === 1 && codes[0] === "U") return "Unaligned";
|
||||
if (codes.length === 1 && codes[0] === "N") return "Neutral";
|
||||
return codes.map((c) => ALIGNMENT_MAP[c] ?? c).join(" ");
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function formatSize(sizes: string[]): string {
|
||||
return sizes.map((s) => SIZE_MAP[s] ?? s).join(" or ");
|
||||
}
|
||||
|
||||
function formatType(
|
||||
type:
|
||||
| string
|
||||
| {
|
||||
type: string | { choose: string[] };
|
||||
tags?: string[];
|
||||
swarmSize?: string;
|
||||
},
|
||||
): string {
|
||||
if (typeof type === "string") return capitalize(type);
|
||||
|
||||
const baseType =
|
||||
typeof type.type === "string"
|
||||
? capitalize(type.type)
|
||||
: type.type.choose.map(capitalize).join(" or ");
|
||||
|
||||
let result = baseType;
|
||||
if (type.tags && type.tags.length > 0) {
|
||||
result += ` (${type.tags.map(capitalize).join(", ")})`;
|
||||
}
|
||||
if (type.swarmSize) {
|
||||
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
|
||||
result = `Swarm of ${swarmSizeLabel} ${result}s`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function extractAc(ac: RawMonster["ac"]): {
|
||||
value: number;
|
||||
source?: string;
|
||||
} {
|
||||
const first = ac[0];
|
||||
if (typeof first === "number") {
|
||||
return { value: first };
|
||||
}
|
||||
return {
|
||||
value: first.ac,
|
||||
source: first.from ? stripTags(first.from.join(", ")) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSpeed(speed: RawMonster["speed"]): string {
|
||||
const parts: string[] = [];
|
||||
for (const [mode, value] of Object.entries(speed)) {
|
||||
if (mode === "canHover") continue;
|
||||
if (typeof value === "boolean") continue;
|
||||
|
||||
let numStr: string;
|
||||
let condition = "";
|
||||
if (typeof value === "number") {
|
||||
numStr = `${value} ft.`;
|
||||
} else {
|
||||
numStr = `${value.number} ft.`;
|
||||
if (value.condition) {
|
||||
condition = ` ${value.condition}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "walk") {
|
||||
parts.push(`${numStr}${condition}`);
|
||||
} else {
|
||||
parts.push(`${mode} ${numStr}${condition}`);
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function formatSaves(save?: Record<string, string>): string | undefined {
|
||||
if (!save) return undefined;
|
||||
return Object.entries(save)
|
||||
.map(([key, val]) => `${key.toUpperCase()} ${val}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatSkills(skill?: Record<string, string>): string | undefined {
|
||||
if (!skill) return undefined;
|
||||
return Object.entries(skill)
|
||||
.map(([key, val]) => `${capitalize(key)} ${val}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatDamageList(
|
||||
items?: (string | Record<string, unknown>)[],
|
||||
): string | undefined {
|
||||
if (!items || items.length === 0) return undefined;
|
||||
return items
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return capitalize(stripTags(item));
|
||||
if (typeof item.special === "string") return stripTags(item.special);
|
||||
// Handle conditional entries like { vulnerable: [...], note: "..." }
|
||||
const damageTypes = (Object.values(item).find((v) => Array.isArray(v)) ??
|
||||
[]) as string[];
|
||||
const note = typeof item.note === "string" ? ` ${item.note}` : "";
|
||||
return damageTypes.map((d) => capitalize(stripTags(d))).join(", ") + note;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatConditionImmunities(
|
||||
items?: (string | { conditionImmune?: string[]; note?: string })[],
|
||||
): string | undefined {
|
||||
if (!items || items.length === 0) return undefined;
|
||||
return items
|
||||
.flatMap((c) => {
|
||||
if (typeof c === "string") return [capitalize(stripTags(c))];
|
||||
if (c.conditionImmune) {
|
||||
const conds = c.conditionImmune.map((ci) => capitalize(stripTags(ci)));
|
||||
const note = c.note ? ` ${c.note}` : "";
|
||||
return conds.map((ci) => `${ci}${note}`);
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function renderEntries(entries: (string | RawEntryObject)[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === "string") {
|
||||
parts.push(stripTags(entry));
|
||||
} else if (entry.type === "list") {
|
||||
for (const item of entry.items ?? []) {
|
||||
if (typeof item === "string") {
|
||||
parts.push(`• ${stripTags(item)}`);
|
||||
} else if (item.name && item.entries) {
|
||||
parts.push(
|
||||
`• ${stripTags(item.name)}: ${renderEntries(item.entries)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (entry.type === "item" && entry.name && entry.entries) {
|
||||
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
||||
} else if (entry.entries) {
|
||||
parts.push(renderEntries(entry.entries));
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
||||
if (!raw || raw.length === 0) return undefined;
|
||||
return raw.map((t) => ({
|
||||
name: stripTags(t.name),
|
||||
text: renderEntries(t.entries),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeSpellcasting(
|
||||
raw?: RawSpellcasting[],
|
||||
): SpellcastingBlock[] | undefined {
|
||||
if (!raw || raw.length === 0) return undefined;
|
||||
|
||||
return raw.map((sc) => {
|
||||
const block: {
|
||||
name: string;
|
||||
headerText: string;
|
||||
atWill?: string[];
|
||||
daily?: DailySpells[];
|
||||
restLong?: DailySpells[];
|
||||
} = {
|
||||
name: stripTags(sc.name),
|
||||
headerText: sc.headerEntries.map((e) => stripTags(e)).join(" "),
|
||||
};
|
||||
|
||||
const hidden = new Set(sc.hidden ?? []);
|
||||
|
||||
if (sc.will && !hidden.has("will")) {
|
||||
block.atWill = sc.will.map((s) => stripTags(s));
|
||||
}
|
||||
|
||||
if (sc.daily) {
|
||||
block.daily = parseDailyMap(sc.daily);
|
||||
}
|
||||
|
||||
if (sc.rest) {
|
||||
block.restLong = parseDailyMap(sc.rest);
|
||||
}
|
||||
|
||||
return block;
|
||||
});
|
||||
}
|
||||
|
||||
function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
||||
return Object.entries(map).map(([key, spells]) => {
|
||||
const each = key.endsWith("e");
|
||||
const uses = Number.parseInt(each ? key.slice(0, -1) : key, 10);
|
||||
return {
|
||||
uses,
|
||||
each,
|
||||
spells: spells.map((s) => stripTags(s)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLegendary(
|
||||
raw?: RawEntry[],
|
||||
monster?: RawMonster,
|
||||
): LegendaryBlock | undefined {
|
||||
if (!raw || raw.length === 0) return undefined;
|
||||
|
||||
const name = monster?.name ?? "creature";
|
||||
const count = monster?.legendaryActions ?? 3;
|
||||
const preamble = `${name} can take ${count} Legendary Actions, choosing from the options below. Only one Legendary Action can be used at a time and only at the end of another creature's turn. ${name} regains spent Legendary Actions at the start of its turn.`;
|
||||
|
||||
return {
|
||||
preamble,
|
||||
entries: raw.map((e) => ({
|
||||
name: stripTags(e.name),
|
||||
text: renderEntries(e.entries),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function extractCr(cr: string | { cr: string }): string {
|
||||
return typeof cr === "string" ? cr : cr.cr;
|
||||
}
|
||||
|
||||
function makeCreatureId(source: string, name: string): CreatureId {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes raw 5etools bestiary JSON into domain Creature[].
|
||||
*/
|
||||
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||
return raw.monster.map((m) => {
|
||||
const crStr = extractCr(m.cr);
|
||||
const ac = extractAc(m.ac);
|
||||
|
||||
return {
|
||||
id: makeCreatureId(m.source, m.name),
|
||||
name: m.name,
|
||||
source: m.source,
|
||||
sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source,
|
||||
size: formatSize(m.size),
|
||||
type: formatType(m.type),
|
||||
alignment: formatAlignment(m.alignment),
|
||||
ac: ac.value,
|
||||
acSource: ac.source,
|
||||
hp: { average: m.hp.average, formula: m.hp.formula },
|
||||
speed: formatSpeed(m.speed),
|
||||
abilities: {
|
||||
str: m.str,
|
||||
dex: m.dex,
|
||||
con: m.con,
|
||||
int: m.int,
|
||||
wis: m.wis,
|
||||
cha: m.cha,
|
||||
},
|
||||
cr: crStr,
|
||||
proficiencyBonus: proficiencyBonus(crStr),
|
||||
passive: m.passive,
|
||||
savingThrows: formatSaves(m.save),
|
||||
skills: formatSkills(m.skill),
|
||||
resist: formatDamageList(m.resist),
|
||||
immune: formatDamageList(m.immune),
|
||||
vulnerable: formatDamageList(m.vulnerable),
|
||||
conditionImmune: formatConditionImmunities(m.conditionImmune),
|
||||
senses:
|
||||
m.senses && m.senses.length > 0
|
||||
? m.senses.map((s) => stripTags(s)).join(", ")
|
||||
: undefined,
|
||||
languages:
|
||||
m.languages && m.languages.length > 0
|
||||
? m.languages.join(", ")
|
||||
: undefined,
|
||||
traits: normalizeTraits(m.trait),
|
||||
actions: normalizeTraits(m.action),
|
||||
bonusActions: normalizeTraits(m.bonus),
|
||||
reactions: normalizeTraits(m.reaction),
|
||||
legendaryActions: normalizeLegendary(m.legendary, m),
|
||||
spellcasting: normalizeSpellcasting(m.spellcasting),
|
||||
};
|
||||
});
|
||||
}
|
||||
99
apps/web/src/adapters/strip-tags.ts
Normal file
99
apps/web/src/adapters/strip-tags.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
const ABILITY_MAP: Record<string, string> = {
|
||||
str: "Strength",
|
||||
dex: "Dexterity",
|
||||
con: "Constitution",
|
||||
int: "Intelligence",
|
||||
wis: "Wisdom",
|
||||
cha: "Charisma",
|
||||
};
|
||||
|
||||
const ATKR_MAP: Record<string, string> = {
|
||||
m: "Melee Attack Roll:",
|
||||
r: "Ranged Attack Roll:",
|
||||
"m,r": "Melee or Ranged Attack Roll:",
|
||||
"r,m": "Melee or Ranged Attack Roll:",
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
||||
*
|
||||
* Handles 15+ tag types per research.md R-002 tag resolution rules.
|
||||
*/
|
||||
export function stripTags(text: string): string {
|
||||
// Process special tags with specific output formats first
|
||||
let result = text;
|
||||
|
||||
// {@h} → "Hit: "
|
||||
result = result.replace(/\{@h\}/g, "Hit: ");
|
||||
|
||||
// {@hom} → "Hit or Miss: "
|
||||
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
|
||||
|
||||
// {@actTrigger} → "Trigger:"
|
||||
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
|
||||
|
||||
// {@actResponse} → "Response:"
|
||||
result = result.replace(/\{@actResponse\}/g, "Response:");
|
||||
|
||||
// {@actSaveSuccess} → "Success:"
|
||||
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
|
||||
|
||||
// {@actSaveSuccessOrFail} → handled below as parameterized
|
||||
|
||||
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
||||
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
|
||||
|
||||
// {@dc N} → "DC N"
|
||||
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
|
||||
|
||||
// {@hit N} → "+N"
|
||||
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||
|
||||
// {@atkr type} → mapped attack roll text
|
||||
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
|
||||
});
|
||||
|
||||
// {@actSave ability} → "Ability saving throw"
|
||||
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
|
||||
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
||||
return name ? `${name} saving throw` : `${ability} saving throw`;
|
||||
});
|
||||
|
||||
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
|
||||
result = result.replace(
|
||||
/\{@actSaveFail\s+(\d+)\}/g,
|
||||
"Failure by $1 or More:",
|
||||
);
|
||||
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
|
||||
|
||||
// {@actSaveSuccessOrFail} → keep as-is label
|
||||
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
|
||||
|
||||
// {@actSaveFailBy N} → "Failure by N or More:"
|
||||
result = result.replace(
|
||||
/\{@actSaveFailBy\s+(\d+)\}/g,
|
||||
"Failure by $1 or More:",
|
||||
);
|
||||
|
||||
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||
// creature, hazard, status, plus any unknown tags
|
||||
result = result.replace(
|
||||
/\{@(\w+)\s+([^}]+)\}/g,
|
||||
(_, tag: string, content: string) => {
|
||||
// For tags with Display|Source format, extract first segment
|
||||
const segments = content.split("|");
|
||||
|
||||
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
|
||||
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
|
||||
return segments[2];
|
||||
}
|
||||
|
||||
return segments[0];
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,35 +1,143 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { Search } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
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[];
|
||||
bestiaryLoaded: boolean;
|
||||
suggestions: Creature[];
|
||||
onSearchChange: (query: string) => void;
|
||||
onShowStatBlock?: (creature: Creature) => void;
|
||||
}
|
||||
|
||||
export function ActionBar({ onAddCombatant }: ActionBarProps) {
|
||||
export function ActionBar({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
suggestions,
|
||||
onSearchChange,
|
||||
onShowStatBlock,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
|
||||
const handleAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (nameInput.trim() === "") return;
|
||||
onAddCombatant(nameInput);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
onSearchChange(value);
|
||||
};
|
||||
|
||||
const handleSelectCreature = (creature: Creature) => {
|
||||
onAddFromBestiary(creature);
|
||||
setSearchOpen(false);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
onShowStatBlock?.(creature);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (creature: Creature) => {
|
||||
onAddFromBestiary(creature);
|
||||
setNameInput("");
|
||||
onSearchChange("");
|
||||
onShowStatBlock?.(creature);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (suggestions.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestions[suggestionIndex]);
|
||||
} else if (e.key === "Escape") {
|
||||
setSuggestionIndex(-1);
|
||||
onSearchChange("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<form onSubmit={handleAdd} className="flex flex-1 items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
placeholder="Combatant name"
|
||||
className="max-w-xs"
|
||||
{searchOpen ? (
|
||||
<BestiarySearch
|
||||
onSelectCreature={handleSelectCreature}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
searchFn={bestiarySearch}
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleAdd}
|
||||
className="relative flex flex-1 items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Combatant name"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{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}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
i === suggestionIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-accent/10"
|
||||
}`}
|
||||
onClick={() => handleSelectSuggestion(creature)}
|
||||
onMouseEnter={() => setSuggestionIndex(i)}
|
||||
>
|
||||
<span>{creature.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
{bestiaryLoaded && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
129
apps/web/src/components/bestiary-search.tsx
Normal file
129
apps/web/src/components/bestiary-search.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface BestiarySearchProps {
|
||||
onSelectCreature: (creature: Creature) => void;
|
||||
onClose: () => void;
|
||||
searchFn: (query: string) => Creature[];
|
||||
}
|
||||
|
||||
export function BestiarySearch({
|
||||
onSelectCreature,
|
||||
onClose,
|
||||
searchFn,
|
||||
}: BestiarySearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const results = query.length >= 2 ? searchFn(query) : [];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(-1);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && highlightIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onSelectCreature(results[highlightIndex]);
|
||||
}
|
||||
},
|
||||
[results, highlightIndex, onClose, onSelectCreature],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search bestiary..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{query.length >= 2 && (
|
||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
||||
{results.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No creatures found
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-60 overflow-y-auto py-1">
|
||||
{results.map((creature, i) => (
|
||||
<li key={creature.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||
i === highlightIndex
|
||||
? "bg-accent/20 text-foreground"
|
||||
: "text-foreground hover:bg-accent/10"
|
||||
}`}
|
||||
onClick={() => onSelectCreature(creature)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
>
|
||||
<span>{creature.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type ConditionId,
|
||||
deriveHpStatus,
|
||||
} from "@initiative/domain";
|
||||
import { Brain, Shield, X } from "lucide-react";
|
||||
import { BookOpen, Brain, Shield, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConditionPicker } from "./condition-picker";
|
||||
@@ -34,6 +34,7 @@ interface CombatantRowProps {
|
||||
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||
onToggleConcentration: (id: CombatantId) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
}
|
||||
|
||||
function EditableName({
|
||||
@@ -276,6 +277,7 @@ export function CombatantRow({
|
||||
onSetAc,
|
||||
onToggleCondition,
|
||||
onToggleConcentration,
|
||||
onShowStatBlock,
|
||||
}: CombatantRowProps) {
|
||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
@@ -364,8 +366,19 @@ export function CombatantRow({
|
||||
/>
|
||||
|
||||
{/* Name */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<div className={cn("flex items-center gap-1", dimmed && "opacity-50")}>
|
||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||
{onShowStatBlock && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowStatBlock}
|
||||
className="text-muted-foreground hover:text-amber-400 transition-colors"
|
||||
title="View stat block"
|
||||
aria-label="View stat block"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
|
||||
77
apps/web/src/components/stat-block-panel.tsx
Normal file
77
apps/web/src/components/stat-block-panel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creature: Creature | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1024px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
if (!creature) return null;
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Stat Block
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<StatBlock creature={creature} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile drawer
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/50 animate-in fade-in"
|
||||
onClick={onClose}
|
||||
aria-label="Close stat block"
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Stat Block
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||
<StatBlock creature={creature} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
apps/web/src/components/stat-block.tsx
Normal file
244
apps/web/src/components/stat-block.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
|
||||
interface StatBlockProps {
|
||||
creature: Creature;
|
||||
}
|
||||
|
||||
function abilityMod(score: number): string {
|
||||
const mod = Math.floor((score - 10) / 2);
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
function PropertyLine({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">{label}</span> {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlock({ creature }: StatBlockProps) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
{ label: "DEX", score: creature.abilities.dex },
|
||||
{ label: "CON", score: creature.abilities.con },
|
||||
{ label: "INT", score: creature.abilities.int },
|
||||
{ label: "WIS", score: creature.abilities.wis },
|
||||
{ label: "CHA", score: creature.abilities.cha },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{creature.size} {creature.type}, {creature.alignment}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{creature.sourceDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">Armor Class</span> {creature.ac}
|
||||
{creature.acSource && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({creature.acSource})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Hit Points</span>{" "}
|
||||
{creature.hp.average}{" "}
|
||||
<span className="text-muted-foreground">({creature.hp.formula})</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Speed</span> {creature.speed}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Ability scores */}
|
||||
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
||||
{abilities.map(({ label, score }) => (
|
||||
<div key={label}>
|
||||
<div className="font-semibold">{label}</div>
|
||||
<div>
|
||||
{score}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({abilityMod(score)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Properties */}
|
||||
<div className="space-y-0.5">
|
||||
<PropertyLine label="Saving Throws" value={creature.savingThrows} />
|
||||
<PropertyLine label="Skills" value={creature.skills} />
|
||||
<PropertyLine
|
||||
label="Damage Vulnerabilities"
|
||||
value={creature.vulnerable}
|
||||
/>
|
||||
<PropertyLine label="Damage Resistances" value={creature.resist} />
|
||||
<PropertyLine label="Damage Immunities" value={creature.immune} />
|
||||
<PropertyLine
|
||||
label="Condition Immunities"
|
||||
value={creature.conditionImmune}
|
||||
/>
|
||||
<PropertyLine label="Senses" value={creature.senses} />
|
||||
<PropertyLine label="Languages" value={creature.languages} />
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">Challenge</span> {creature.cr}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
(Proficiency Bonus +{creature.proficiencyBonus})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traits */}
|
||||
{creature.traits && creature.traits.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<div className="space-y-2">
|
||||
{creature.traits.map((t) => (
|
||||
<div key={t.name} className="text-sm">
|
||||
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spellcasting */}
|
||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{creature.spellcasting.map((sc) => (
|
||||
<div key={sc.name} className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
||||
{sc.headerText}
|
||||
</div>
|
||||
{sc.atWill && sc.atWill.length > 0 && (
|
||||
<div className="pl-2">
|
||||
<span className="font-semibold">At Will:</span>{" "}
|
||||
{sc.atWill.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{sc.daily?.map((d) => (
|
||||
<div key={`${d.uses}${d.each ? "e" : ""}`} className="pl-2">
|
||||
<span className="font-semibold">
|
||||
{d.uses}/day
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
</div>
|
||||
))}
|
||||
{sc.restLong?.map((d) => (
|
||||
<div
|
||||
key={`rest-${d.uses}${d.each ? "e" : ""}`}
|
||||
className="pl-2"
|
||||
>
|
||||
<span className="font-semibold">
|
||||
{d.uses}/long rest
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.actions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bonus Actions */}
|
||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.bonusActions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{creature.reactions && creature.reactions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.reactions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Legendary Actions */}
|
||||
{creature.legendaryActions && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="text-base font-bold text-amber-400">
|
||||
Legendary Actions
|
||||
</h3>
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{creature.legendaryActions.preamble}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{creature.legendaryActions.entries.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/hooks/use-bestiary.ts
Normal file
62
apps/web/src/hooks/use-bestiary.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { normalizeBestiary } from "../adapters/bestiary-adapter.js";
|
||||
|
||||
interface BestiaryHook {
|
||||
search: (query: string) => Creature[];
|
||||
getCreature: (id: CreatureId) => Creature | undefined;
|
||||
allCreatures: Creature[];
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadAttempted.current) return;
|
||||
loadAttempted.current = 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
|
||||
});
|
||||
}, []);
|
||||
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { search, getCreature, allCreatures: creatures, isLoaded };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type {
|
||||
CombatantId,
|
||||
ConditionId,
|
||||
Creature,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
} from "@initiative/domain";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
isDomainError,
|
||||
resolveCreatureName,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -74,7 +76,10 @@ export function useEncounter() {
|
||||
const makeStore = useCallback((): EncounterStore => {
|
||||
return {
|
||||
get: () => encounterRef.current,
|
||||
save: (e) => setEncounter(e),
|
||||
save: (e) => {
|
||||
encounterRef.current = e;
|
||||
setEncounter(e);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -218,6 +223,57 @@ export function useEncounter() {
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(creature: Creature) => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
creature.name,
|
||||
existingNames,
|
||||
);
|
||||
|
||||
// Apply renames (e.g., "Goblin" → "Goblin 1")
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
editCombatantUseCase(makeStore(), target.id, to);
|
||||
}
|
||||
}
|
||||
|
||||
// Add combatant with resolved name
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, creature.hp.average);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (creature.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, creature.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
),
|
||||
};
|
||||
setEncounter(updated);
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
},
|
||||
[makeStore, editCombatant],
|
||||
);
|
||||
|
||||
return {
|
||||
encounter,
|
||||
events,
|
||||
@@ -232,5 +288,6 @@ export function useEncounter() {
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
translate: 100%;
|
||||
}
|
||||
to {
|
||||
translate: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-slide-in-right {
|
||||
animation: slide-in-right 200ms ease-out;
|
||||
}
|
||||
|
||||
@utility animate-concentration-pulse {
|
||||
animation:
|
||||
concentration-shake 450ms ease-out,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type ConditionId,
|
||||
combatantId,
|
||||
createEncounter,
|
||||
creatureId,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
VALID_CONDITION_IDS,
|
||||
@@ -75,6 +76,13 @@ export function loadEncounter(): Encounter | null {
|
||||
// Validate isConcentrating field
|
||||
const isConcentrating = entry.isConcentrating === true ? true : undefined;
|
||||
|
||||
// Validate creatureId field
|
||||
const rawCreatureId = entry.creatureId;
|
||||
const validCreatureId =
|
||||
typeof rawCreatureId === "string" && rawCreatureId.length > 0
|
||||
? creatureId(rawCreatureId)
|
||||
: undefined;
|
||||
|
||||
// Validate and attach HP fields if valid
|
||||
const maxHp = entry.maxHp;
|
||||
const currentHp = entry.currentHp;
|
||||
@@ -89,12 +97,19 @@ export function loadEncounter(): Encounter | null {
|
||||
ac: validAc,
|
||||
conditions,
|
||||
isConcentrating,
|
||||
creatureId: validCreatureId,
|
||||
maxHp,
|
||||
currentHp: validCurrentHp ? currentHp : maxHp,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...base, ac: validAc, conditions, isConcentrating };
|
||||
return {
|
||||
...base,
|
||||
ac: validAc,
|
||||
conditions,
|
||||
isConcentrating,
|
||||
creatureId: validCreatureId,
|
||||
};
|
||||
});
|
||||
|
||||
const result = createEncounter(
|
||||
|
||||
Reference in New Issue
Block a user