Compare commits
9 Commits
b39e4923e1
...
0.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75778884bd | ||
|
|
72d4f30e60 | ||
|
|
96b37d4bdd | ||
|
|
76ca78c169 | ||
|
|
b0c27b8ab9 | ||
|
|
458c277e9f | ||
|
|
91703ddebc | ||
|
|
768e7a390f | ||
|
|
7feaf90eab |
@@ -101,6 +101,7 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
|
||||
- `specs/002-turn-tracking/` — rounds, turn order, advance/retreat, top bar
|
||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
@@ -111,3 +112,10 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||
|
||||
## Active Technologies
|
||||
- TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac (005-player-characters)
|
||||
- localStorage (new key `"initiative:player-characters"`) (005-player-characters)
|
||||
|
||||
## Recent Changes
|
||||
- 005-player-characters: Added TypeScript 5.8 (strict mode, `verbatimModuleSyntax`) + React 19, Vite 6, Tailwind CSS v4, Lucide Reac
|
||||
|
||||
@@ -7,7 +7,8 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
||||
- **Initiative tracking** — add combatants (batch-add from bestiary, custom creatures with optional stats), roll initiative (manual or d20), cycle turns and rounds
|
||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -3,9 +3,17 @@ import {
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { CreatePlayerModal } from "./components/create-player-modal";
|
||||
import { PlayerManagement } from "./components/player-management";
|
||||
import { SourceManager } from "./components/source-manager";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { Toast } from "./components/toast";
|
||||
@@ -13,11 +21,50 @@ import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
|
||||
function useActionBarAnimation(combatantCount: number) {
|
||||
const wasEmptyRef = useRef(combatantCount === 0);
|
||||
const [settling, setSettling] = useState(false);
|
||||
const [rising, setRising] = useState(false);
|
||||
const [topBarExiting, setTopBarExiting] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nowEmpty = combatantCount === 0;
|
||||
if (wasEmptyRef.current && !nowEmpty) {
|
||||
setSettling(true);
|
||||
} else if (!wasEmptyRef.current && nowEmpty) {
|
||||
setRising(true);
|
||||
setTopBarExiting(true);
|
||||
}
|
||||
wasEmptyRef.current = nowEmpty;
|
||||
}, [combatantCount]);
|
||||
|
||||
const empty = combatantCount === 0;
|
||||
const risingClass = rising ? " animate-rise-to-center" : "";
|
||||
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
||||
const topBarClass = settling
|
||||
? " animate-slide-down-in"
|
||||
: topBarExiting
|
||||
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||
: "";
|
||||
const showTopBar = !empty || topBarExiting;
|
||||
|
||||
return {
|
||||
risingClass,
|
||||
settlingClass,
|
||||
topBarClass,
|
||||
showTopBar,
|
||||
onSettleEnd: () => setSettling(false),
|
||||
onRiseEnd: () => setRising(false),
|
||||
onTopBarExitEnd: () => setTopBarExiting(false),
|
||||
};
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const {
|
||||
encounter,
|
||||
@@ -34,9 +81,23 @@ export function App() {
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} = useEncounter();
|
||||
|
||||
const {
|
||||
characters: playerCharacters,
|
||||
createCharacter: createPlayerCharacter,
|
||||
editCharacter: editPlayerCharacter,
|
||||
deleteCharacter: deletePlayerCharacter,
|
||||
} = usePlayerCharacters();
|
||||
|
||||
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
const [editingPlayer, setEditingPlayer] = useState<
|
||||
(typeof playerCharacters)[number] | undefined
|
||||
>(undefined);
|
||||
|
||||
const {
|
||||
search,
|
||||
getCreature,
|
||||
@@ -79,14 +140,6 @@ export function App() {
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
addFromBestiary(result);
|
||||
// Derive the creature ID so stat block panel can try to show it
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
setSelectedCreatureId(
|
||||
`${result.source.toLowerCase()}:${slug}` as CreatureId,
|
||||
);
|
||||
},
|
||||
[addFromBestiary],
|
||||
);
|
||||
@@ -160,6 +213,9 @@ export function App() {
|
||||
setPinnedCreatureId(null);
|
||||
}, []);
|
||||
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -182,11 +238,16 @@ export function App() {
|
||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
||||
{/* Turn Navigation — fixed at top */}
|
||||
<div className="shrink-0 pt-8">
|
||||
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
||||
{actionBarAnim.showTopBar && (
|
||||
<div
|
||||
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||
>
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
@@ -196,7 +257,33 @@ export function App() {
|
||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
/* Empty state — ActionBar centered */
|
||||
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
|
||||
<div
|
||||
className={`w-full${actionBarAnim.risingClass}`}
|
||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={handleBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() => setManagementOpen(true)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sourceManagerOpen && (
|
||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
||||
<SourceManager onCacheCleared={refreshCache} />
|
||||
@@ -206,12 +293,7 @@ export function App() {
|
||||
{/* Scrollable area — combatant list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex flex-col px-2 py-2">
|
||||
{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) => (
|
||||
{encounter.combatants.map((c, i) => (
|
||||
<CombatantRow
|
||||
key={c.id}
|
||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||
@@ -234,13 +316,15 @@ export function App() {
|
||||
c.creatureId ? handleRollInitiative : undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Bar — fixed at bottom */}
|
||||
<div className="shrink-0 pb-8">
|
||||
<div
|
||||
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||
>
|
||||
<ActionBar
|
||||
onAddCombatant={addCombatant}
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
@@ -249,8 +333,14 @@ export function App() {
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={handleBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
onManagePlayers={() => setManagementOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pinned Stat Block Panel (left) */}
|
||||
@@ -321,6 +411,45 @@ export function App() {
|
||||
onDismiss={bulkImport.reset}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreatePlayerModal
|
||||
open={createPlayerOpen}
|
||||
onClose={() => {
|
||||
setCreatePlayerOpen(false);
|
||||
setEditingPlayer(undefined);
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
if (editingPlayer) {
|
||||
editPlayerCharacter?.(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
} else {
|
||||
createPlayerCharacter(name, ac, maxHp, color, icon);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
/>
|
||||
|
||||
<PlayerManagement
|
||||
open={managementOpen}
|
||||
onClose={() => setManagementOpen(false)}
|
||||
characters={playerCharacters}
|
||||
onEdit={(pc) => {
|
||||
setEditingPlayer(pc);
|
||||
setCreatePlayerOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
onDelete={(id) => deletePlayerCharacter?.(id)}
|
||||
onCreate={() => {
|
||||
setEditingPlayer(undefined);
|
||||
setCreatePlayerOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ describe("TurnNavigation", () => {
|
||||
expect(container.textContent).not.toContain("—");
|
||||
});
|
||||
|
||||
it("round badge and combatant name are in separate DOM elements", () => {
|
||||
it("round badge and combatant name are siblings in the center area", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge.parentElement).not.toBe(name.parentElement);
|
||||
expect(badge.parentElement).toBe(name.parentElement);
|
||||
});
|
||||
|
||||
it("updates the round badge when round changes", () => {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
|
||||
import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
@@ -20,6 +28,11 @@ interface ActionBarProps {
|
||||
onViewStatBlock?: (result: SearchResult) => void;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
playerCharacters?: readonly PlayerCharacter[];
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
onManagePlayers?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
@@ -34,9 +47,15 @@ export function ActionBar({
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
inputRef,
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
autoFocus,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||
const [customInit, setCustomInit] = useState("");
|
||||
@@ -65,6 +84,7 @@ export function ActionBar({
|
||||
setQueued(null);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
@@ -91,6 +111,7 @@ export function ActionBar({
|
||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
@@ -98,13 +119,22 @@ export function ActionBar({
|
||||
setNameInput(value);
|
||||
setSuggestionIndex(-1);
|
||||
let newSuggestions: SearchResult[] = [];
|
||||
let newPcMatches: PlayerCharacter[] = [];
|
||||
if (value.length >= 2) {
|
||||
newSuggestions = bestiarySearch(value);
|
||||
setSuggestions(newSuggestions);
|
||||
if (playerCharacters && playerCharacters.length > 0) {
|
||||
const lower = value.toLowerCase();
|
||||
newPcMatches = playerCharacters.filter((pc) =>
|
||||
pc.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}
|
||||
setPcMatches(newPcMatches);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
if (newSuggestions.length > 0) {
|
||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||
clearCustomFields();
|
||||
}
|
||||
if (queued) {
|
||||
@@ -133,8 +163,10 @@ export function ActionBar({
|
||||
}
|
||||
};
|
||||
|
||||
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (suggestions.length === 0) return;
|
||||
if (!hasSuggestions) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
@@ -149,6 +181,7 @@ export function ActionBar({
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,16 +255,76 @@ export function ActionBar({
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="+ Add combatants"
|
||||
className="max-w-xs"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
{hasSuggestions && (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
setQueued(null);
|
||||
setSuggestionIndex(-1);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
Esc
|
||||
</kbd>
|
||||
</button>
|
||||
<div className="max-h-48 overflow-y-auto py-1">
|
||||
{pcMatches.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
Players
|
||||
</div>
|
||||
<ul>
|
||||
{pcMatches.map((pc) => {
|
||||
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
||||
const pcColor =
|
||||
PLAYER_COLOR_HEX[
|
||||
pc.color as keyof typeof PLAYER_COLOR_HEX
|
||||
];
|
||||
return (
|
||||
<li key={pc.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onAddFromPlayerCharacter?.(pc);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
}}
|
||||
>
|
||||
{PcIcon && (
|
||||
<PcIcon size={14} style={{ color: pcColor }} />
|
||||
)}
|
||||
<span className="flex-1 truncate">{pc.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Player
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{suggestions.length > 0 && (
|
||||
<ul>
|
||||
{suggestions.map((result, i) => {
|
||||
const key = creatureKey(result);
|
||||
const isQueued =
|
||||
@@ -311,10 +404,12 @@ export function ActionBar({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{nameInput.length >= 2 && suggestions.length === 0 && (
|
||||
{nameInput.length >= 2 && !hasSuggestions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -345,15 +440,32 @@ export function ActionBar({
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
<div className="flex items-center gap-0">
|
||||
{onManagePlayers && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onManagePlayers}
|
||||
title="Player characters"
|
||||
aria-label="Player characters"
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{bestiaryLoaded && onViewStatBlock && (
|
||||
<div ref={viewerRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
|
||||
title="Browse stat blocks"
|
||||
aria-label="Browse stat blocks"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<Eye className="h-5 w-5" />
|
||||
</Button>
|
||||
{viewerOpen && (
|
||||
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
|
||||
@@ -403,14 +515,18 @@ export function ActionBar({
|
||||
{bestiaryLoaded && onBulkImport && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onBulkImport}
|
||||
disabled={bulkImportDisabled}
|
||||
title="Bulk import"
|
||||
aria-label="Bulk import"
|
||||
>
|
||||
<Import className="h-4 w-4" />
|
||||
<Import className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
36
apps/web/src/components/color-palette.tsx
Normal file
36
apps/web/src/components/color-palette.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { VALID_PLAYER_COLORS } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PLAYER_COLOR_HEX } from "./player-icon-map";
|
||||
|
||||
interface ColorPaletteProps {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
const COLORS = [...VALID_PLAYER_COLORS] as string[];
|
||||
|
||||
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onChange(color)}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full transition-all",
|
||||
value === color
|
||||
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
|
||||
: "hover:scale-110",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
PLAYER_COLOR_HEX[color as keyof typeof PLAYER_COLOR_HEX],
|
||||
}}
|
||||
aria-label={color}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
} from "@initiative/domain";
|
||||
import { Brain, X } from "lucide-react";
|
||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -11,6 +12,7 @@ import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
@@ -23,6 +25,8 @@ interface Combatant {
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
interface CombatantRowProps {
|
||||
@@ -45,11 +49,13 @@ function EditableName({
|
||||
combatantId,
|
||||
onRename,
|
||||
onShowStatBlock,
|
||||
color,
|
||||
}: {
|
||||
name: string;
|
||||
combatantId: CombatantId;
|
||||
onRename: (id: CombatantId, newName: string) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
color?: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(name);
|
||||
@@ -139,6 +145,7 @@ function EditableName({
|
||||
onTouchCancel={cancelLongPress}
|
||||
onTouchMove={cancelLongPress}
|
||||
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
@@ -478,6 +485,10 @@ export function CombatantRow({
|
||||
}
|
||||
}, [combatant.isConcentrating]);
|
||||
|
||||
const pcColor = combatant.color
|
||||
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
||||
<div
|
||||
@@ -535,11 +546,28 @@ export function CombatantRow({
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{combatant.icon &&
|
||||
combatant.color &&
|
||||
(() => {
|
||||
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
||||
const pcColor =
|
||||
PLAYER_COLOR_HEX[
|
||||
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
||||
];
|
||||
return PcIcon ? (
|
||||
<PcIcon
|
||||
size={14}
|
||||
style={{ color: pcColor }}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
<EditableName
|
||||
name={name}
|
||||
combatantId={id}
|
||||
onRename={onRename}
|
||||
onShowStatBlock={onShowStatBlock}
|
||||
color={pcColor}
|
||||
/>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
|
||||
186
apps/web/src/components/create-player-modal.tsx
Normal file
186
apps/web/src/components/create-player-modal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
import { IconGrid } from "./icon-grid";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
interface CreatePlayerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
) => void;
|
||||
playerCharacter?: PlayerCharacter;
|
||||
}
|
||||
|
||||
export function CreatePlayerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
playerCharacter,
|
||||
}: CreatePlayerModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [ac, setAc] = useState("10");
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
const [color, setColor] = useState("blue");
|
||||
const [icon, setIcon] = useState("sword");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!playerCharacter;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (playerCharacter) {
|
||||
setName(playerCharacter.name);
|
||||
setAc(String(playerCharacter.ac));
|
||||
setMaxHp(String(playerCharacter.maxHp));
|
||||
setColor(playerCharacter.color);
|
||||
setIcon(playerCharacter.icon);
|
||||
} else {
|
||||
setName("");
|
||||
setAc("10");
|
||||
setMaxHp("10");
|
||||
setColor("blue");
|
||||
setIcon("sword");
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
}, [open, playerCharacter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === "") {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
const acNum = Number.parseInt(ac, 10);
|
||||
if (Number.isNaN(acNum) || acNum < 0) {
|
||||
setError("AC must be a non-negative number");
|
||||
return;
|
||||
}
|
||||
const hpNum = Number.parseInt(maxHp, 10);
|
||||
if (Number.isNaN(hpNum) || hpNum < 1) {
|
||||
setError("Max HP must be at least 1");
|
||||
return;
|
||||
}
|
||||
onSave(trimmed, acNum, hpNum, color, icon);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<span className="mb-1 block text-sm text-muted-foreground">
|
||||
Name
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="Character name"
|
||||
aria-label="Name"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-sm text-muted-foreground">
|
||||
AC
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={ac}
|
||||
onChange={(e) => setAc(e.target.value)}
|
||||
placeholder="AC"
|
||||
aria-label="AC"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="mb-1 block text-sm text-muted-foreground">
|
||||
Max HP
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxHp}
|
||||
onChange={(e) => setMaxHp(e.target.value)}
|
||||
placeholder="Max HP"
|
||||
aria-label="Max HP"
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm text-muted-foreground">
|
||||
Color
|
||||
</span>
|
||||
<ColorPalette value={color} onChange={setColor} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-sm text-muted-foreground">
|
||||
Icon
|
||||
</span>
|
||||
<IconGrid value={icon} onChange={setIcon} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/components/icon-grid.tsx
Normal file
38
apps/web/src/components/icon-grid.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { PlayerIcon } from "@initiative/domain";
|
||||
import { VALID_PLAYER_ICONS } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
|
||||
interface IconGridProps {
|
||||
value: string;
|
||||
onChange: (icon: string) => void;
|
||||
}
|
||||
|
||||
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
|
||||
|
||||
export function IconGrid({ value, onChange }: IconGridProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ICONS.map((iconId) => {
|
||||
const Icon = PLAYER_ICON_MAP[iconId];
|
||||
return (
|
||||
<button
|
||||
key={iconId}
|
||||
type="button"
|
||||
onClick={() => onChange(iconId)}
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||
value === iconId
|
||||
? "bg-primary/20 ring-2 ring-primary text-foreground"
|
||||
: "text-muted-foreground hover:bg-card hover:text-foreground",
|
||||
)}
|
||||
aria-label={iconId}
|
||||
title={iconId}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/player-icon-map.ts
Normal file
50
apps/web/src/components/player-icon-map.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { PlayerColor, PlayerIcon } from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Axe,
|
||||
Crosshair,
|
||||
Crown,
|
||||
Eye,
|
||||
Feather,
|
||||
Flame,
|
||||
Heart,
|
||||
Moon,
|
||||
Shield,
|
||||
Skull,
|
||||
Star,
|
||||
Sun,
|
||||
Sword,
|
||||
Wand,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
export const PLAYER_ICON_MAP: Record<PlayerIcon, LucideIcon> = {
|
||||
sword: Sword,
|
||||
shield: Shield,
|
||||
skull: Skull,
|
||||
heart: Heart,
|
||||
wand: Wand,
|
||||
flame: Flame,
|
||||
crown: Crown,
|
||||
star: Star,
|
||||
moon: Moon,
|
||||
sun: Sun,
|
||||
axe: Axe,
|
||||
crosshair: Crosshair,
|
||||
eye: Eye,
|
||||
feather: Feather,
|
||||
zap: Zap,
|
||||
};
|
||||
|
||||
export const PLAYER_COLOR_HEX: Record<PlayerColor, string> = {
|
||||
red: "#ef4444",
|
||||
blue: "#3b82f6",
|
||||
green: "#22c55e",
|
||||
purple: "#a855f7",
|
||||
orange: "#f97316",
|
||||
pink: "#ec4899",
|
||||
cyan: "#06b6d4",
|
||||
yellow: "#eab308",
|
||||
emerald: "#10b981",
|
||||
indigo: "#6366f1",
|
||||
};
|
||||
123
apps/web/src/components/player-management.tsx
Normal file
123
apps/web/src/components/player-management.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
PlayerIcon,
|
||||
} from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
|
||||
interface PlayerManagementProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
characters: readonly PlayerCharacter[];
|
||||
onEdit: (pc: PlayerCharacter) => void;
|
||||
onDelete: (id: PlayerCharacterId) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export function PlayerManagement({
|
||||
open,
|
||||
onClose,
|
||||
characters,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: PlayerManagementProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Player Characters
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-muted-foreground">No player characters yet</p>
|
||||
<Button onClick={onCreate} size="sm">
|
||||
<Plus size={16} />
|
||||
Create your first player character
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{characters.map((pc) => {
|
||||
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
||||
const color =
|
||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
||||
return (
|
||||
<div
|
||||
key={pc.id}
|
||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-background/50"
|
||||
>
|
||||
{Icon && (
|
||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm text-foreground">
|
||||
{pc.name}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
AC {pc.ac}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
HP {pc.maxHp}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(pc)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 size={14} />}
|
||||
label="Delete player character"
|
||||
onConfirm={() => onDelete(pc.id)}
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button onClick={onCreate} size="sm" variant="ghost">
|
||||
<Plus size={16} />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,12 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
Cached Sources
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={handleClearAll}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hover:text-hover-destructive hover:border-hover-destructive"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Clear All
|
||||
</Button>
|
||||
@@ -69,7 +74,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClearSource(source.sourceCode)}
|
||||
className="text-muted-foreground hover:text-hover-danger"
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Library, StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
@@ -27,11 +27,8 @@ export function TurnNavigation({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
||||
onClick={onRetreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
@@ -39,16 +36,13 @@ export function TurnNavigation({
|
||||
>
|
||||
<StepBack className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold">
|
||||
|
||||
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
|
||||
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
|
||||
R{encounter.roundNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 text-center text-sm">
|
||||
{activeCombatant ? (
|
||||
<span className="truncate block font-medium">
|
||||
{activeCombatant.name}
|
||||
</span>
|
||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No combatants</span>
|
||||
)}
|
||||
@@ -59,7 +53,7 @@ export function TurnNavigation({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-hover-action"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={onRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
@@ -69,25 +63,23 @@ export function TurnNavigation({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
onClick={onOpenSourceManager}
|
||||
title="Manage cached sources"
|
||||
aria-label="Manage cached sources"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
<Library className="h-5 w-5" />
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-foreground border-foreground hover:text-hover-action hover:border-hover-action hover:bg-transparent"
|
||||
onClick={onAdvanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
title="Next turn"
|
||||
|
||||
@@ -97,8 +97,9 @@ export function ConfirmButton({
|
||||
size="icon"
|
||||
className={cn(
|
||||
className,
|
||||
isConfirming &&
|
||||
"bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground",
|
||||
isConfirming
|
||||
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
|
||||
: "hover:text-hover-destructive",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ConditionId,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
@@ -318,6 +319,58 @@ export function useEncounter() {
|
||||
[makeStore, editCombatant],
|
||||
);
|
||||
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
editCombatantUseCase(makeStore(), target.id, to);
|
||||
}
|
||||
}
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (pc.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, pc.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// Set color, icon, and playerCharacterId on the combatant
|
||||
const currentEncounter = store.get();
|
||||
store.save({
|
||||
...currentEncounter,
|
||||
combatants: currentEncounter.combatants.map((c) =>
|
||||
c.id === id
|
||||
? {
|
||||
...c,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
},
|
||||
[makeStore, editCombatant],
|
||||
);
|
||||
|
||||
return {
|
||||
encounter,
|
||||
events,
|
||||
@@ -334,6 +387,7 @@ export function useEncounter() {
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
|
||||
102
apps/web/src/hooks/use-player-characters.ts
Normal file
102
apps/web/src/hooks/use-player-characters.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { PlayerCharacterStore } from "@initiative/application";
|
||||
import {
|
||||
createPlayerCharacterUseCase,
|
||||
deletePlayerCharacterUseCase,
|
||||
editPlayerCharacterUseCase,
|
||||
} from "@initiative/application";
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
loadPlayerCharacters,
|
||||
savePlayerCharacters,
|
||||
} from "../persistence/player-character-storage.js";
|
||||
|
||||
function initializeCharacters(): PlayerCharacter[] {
|
||||
return loadPlayerCharacters();
|
||||
}
|
||||
|
||||
let nextPcId = 0;
|
||||
|
||||
function generatePcId(): PlayerCharacterId {
|
||||
return playerCharacterId(`pc-${++nextPcId}`);
|
||||
}
|
||||
|
||||
interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
export function usePlayerCharacters() {
|
||||
const [characters, setCharacters] =
|
||||
useState<PlayerCharacter[]>(initializeCharacters);
|
||||
const charactersRef = useRef(characters);
|
||||
charactersRef.current = characters;
|
||||
|
||||
useEffect(() => {
|
||||
savePlayerCharacters(characters);
|
||||
}, [characters]);
|
||||
|
||||
const makeStore = useCallback((): PlayerCharacterStore => {
|
||||
return {
|
||||
getAll: () => charactersRef.current,
|
||||
save: (updated) => {
|
||||
charactersRef.current = updated;
|
||||
setCharacters(updated);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const createCharacter = useCallback(
|
||||
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
|
||||
const id = generatePcId();
|
||||
const result = createPlayerCharacterUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const editCharacter = useCallback(
|
||||
(id: PlayerCharacterId, fields: EditFields) => {
|
||||
const result = editPlayerCharacterUseCase(makeStore(), id, fields);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const deleteCharacter = useCallback(
|
||||
(id: PlayerCharacterId) => {
|
||||
const result = deletePlayerCharacterUseCase(makeStore(), id);
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
return {
|
||||
characters,
|
||||
createCharacter,
|
||||
editCharacter,
|
||||
deleteCharacter,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
@@ -80,6 +80,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settle-to-bottom {
|
||||
from {
|
||||
transform: translateY(-40vh);
|
||||
opacity: 0;
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-settle-to-bottom {
|
||||
animation: settle-to-bottom 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes rise-to-center {
|
||||
from {
|
||||
transform: translateY(40vh);
|
||||
opacity: 0;
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-rise-to-center {
|
||||
animation: rise-to-center 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes slide-down-in {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-slide-down-in {
|
||||
animation: slide-down-in 700ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes slide-up-out {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-slide-up-out {
|
||||
animation: slide-up-out 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
@custom-variant pointer-coarse (@media (pointer: coarse));
|
||||
|
||||
@utility animate-confirm-pulse {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
loadPlayerCharacters,
|
||||
savePlayerCharacters,
|
||||
} from "../player-character-storage.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:player-characters";
|
||||
|
||||
function createMockLocalStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (_index: number) => null,
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||
return {
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aragorn",
|
||||
ac: 16,
|
||||
maxHp: 120,
|
||||
color: "green",
|
||||
icon: "sword",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("player-character-storage", () => {
|
||||
let mockStorage: ReturnType<typeof createMockLocalStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockStorage = createMockLocalStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: mockStorage,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip save/load", () => {
|
||||
it("saves and loads a single character", () => {
|
||||
const pc = makePC();
|
||||
savePlayerCharacters([pc]);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded).toEqual([pc]);
|
||||
});
|
||||
|
||||
it("saves and loads multiple characters", () => {
|
||||
const pcs = [
|
||||
makePC({ id: playerCharacterId("pc-1"), name: "Aragorn" }),
|
||||
makePC({
|
||||
id: playerCharacterId("pc-2"),
|
||||
name: "Legolas",
|
||||
ac: 14,
|
||||
maxHp: 90,
|
||||
color: "blue",
|
||||
icon: "eye",
|
||||
}),
|
||||
];
|
||||
savePlayerCharacters(pcs);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded).toEqual(pcs);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty storage", () => {
|
||||
it("returns empty array when no data exists", () => {
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("corrupt JSON", () => {
|
||||
it("returns empty array for invalid JSON", () => {
|
||||
mockStorage.setItem(STORAGE_KEY, "not-json{{{");
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for non-array JSON", () => {
|
||||
mockStorage.setItem(STORAGE_KEY, '{"foo": "bar"}');
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-character validation", () => {
|
||||
it("discards character with missing name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{ id: "pc-1", ac: 10, maxHp: 50, color: "blue", icon: "sword" },
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with empty name", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid color", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "neon",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with invalid icon", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "banana",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with negative AC", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: -1,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards character with maxHp of 0", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 0,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadPlayerCharacters()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps valid characters and discards invalid ones", () => {
|
||||
mockStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Valid",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
{
|
||||
id: "pc-2",
|
||||
name: "",
|
||||
ac: 10,
|
||||
maxHp: 50,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const loaded = loadPlayerCharacters();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].name).toBe("Valid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage errors", () => {
|
||||
it("save silently catches errors", () => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
setItem: () => {
|
||||
throw new Error("QuotaExceeded");
|
||||
},
|
||||
getItem: () => null,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
expect(() => savePlayerCharacters([makePC()])).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
creatureId,
|
||||
type Encounter,
|
||||
isDomainError,
|
||||
playerCharacterId,
|
||||
VALID_CONDITION_IDS,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "@initiative/domain";
|
||||
|
||||
const STORAGE_KEY = "initiative:encounter";
|
||||
@@ -70,12 +73,29 @@ function rehydrateCombatant(c: unknown) {
|
||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
||||
};
|
||||
|
||||
const color =
|
||||
typeof entry.color === "string" && VALID_PLAYER_COLORS.has(entry.color)
|
||||
? entry.color
|
||||
: undefined;
|
||||
const icon =
|
||||
typeof entry.icon === "string" && VALID_PLAYER_ICONS.has(entry.icon)
|
||||
? entry.icon
|
||||
: undefined;
|
||||
const pcId =
|
||||
typeof entry.playerCharacterId === "string" &&
|
||||
entry.playerCharacterId.length > 0
|
||||
? playerCharacterId(entry.playerCharacterId)
|
||||
: undefined;
|
||||
|
||||
const shared = {
|
||||
...base,
|
||||
ac: validateAc(entry.ac),
|
||||
conditions: validateConditions(entry.conditions),
|
||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||
creatureId: validateCreatureId(entry.creatureId),
|
||||
color,
|
||||
icon,
|
||||
playerCharacterId: pcId,
|
||||
};
|
||||
|
||||
const hp = validateHp(entry.maxHp, entry.currentHp);
|
||||
|
||||
72
apps/web/src/persistence/player-character-storage.ts
Normal file
72
apps/web/src/persistence/player-character-storage.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "@initiative/domain";
|
||||
|
||||
const STORAGE_KEY = "initiative:player-characters";
|
||||
|
||||
export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(characters));
|
||||
} catch {
|
||||
// Silently swallow errors (quota exceeded, storage unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof entry.id !== "string" || entry.id.length === 0) return null;
|
||||
if (typeof entry.name !== "string" || entry.name.trim().length === 0)
|
||||
return null;
|
||||
if (
|
||||
typeof entry.ac !== "number" ||
|
||||
!Number.isInteger(entry.ac) ||
|
||||
entry.ac < 0
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
typeof entry.maxHp !== "number" ||
|
||||
!Number.isInteger(entry.maxHp) ||
|
||||
entry.maxHp < 1
|
||||
)
|
||||
return null;
|
||||
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
|
||||
return null;
|
||||
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: playerCharacterId(entry.id),
|
||||
name: entry.name,
|
||||
ac: entry.ac,
|
||||
maxHp: entry.maxHp,
|
||||
color: entry.color as PlayerCharacter["color"],
|
||||
icon: entry.icon as PlayerCharacter["icon"],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPlayerCharacters(): PlayerCharacter[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return [];
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const characters: PlayerCharacter[] = [];
|
||||
for (const item of parsed) {
|
||||
const pc = rehydrateCharacter(item);
|
||||
if (pc !== null) {
|
||||
characters.push(pc);
|
||||
}
|
||||
}
|
||||
return characters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
36
packages/application/src/create-player-character-use-case.ts
Normal file
36
packages/application/src/create-player-character-use-case.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
createPlayerCharacter,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
export function createPlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = createPlayerCharacter(
|
||||
characters,
|
||||
id,
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
23
packages/application/src/delete-player-character-use-case.ts
Normal file
23
packages/application/src/delete-player-character-use-case.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
deletePlayerCharacter,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
export function deletePlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = deletePlayerCharacter(characters, id);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
32
packages/application/src/edit-player-character-use-case.ts
Normal file
32
packages/application/src/edit-player-character-use-case.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
editPlayerCharacter,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
export function editPlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
fields: EditFields,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = editPlayerCharacter(characters, id, fields);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
@@ -2,8 +2,15 @@ export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
|
||||
export { deletePlayerCharacterUseCase } from "./delete-player-character-use-case.js";
|
||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||
export type { BestiarySourceCache, EncounterStore } from "./ports.js";
|
||||
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
|
||||
export type {
|
||||
BestiarySourceCache,
|
||||
EncounterStore,
|
||||
PlayerCharacterStore,
|
||||
} from "./ports.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Creature, CreatureId, Encounter } from "@initiative/domain";
|
||||
import type {
|
||||
Creature,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
|
||||
export interface EncounterStore {
|
||||
get(): Encounter;
|
||||
@@ -9,3 +14,8 @@ export interface BestiarySourceCache {
|
||||
getCreature(creatureId: CreatureId): Creature | undefined;
|
||||
isSourceCached(sourceCode: string): boolean;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterStore {
|
||||
getAll(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
}
|
||||
|
||||
227
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
227
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPlayerCharacter } from "../create-player-character.js";
|
||||
import type { PlayerCharacter } from "../player-character-types.js";
|
||||
import { playerCharacterId } from "../player-character-types.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
|
||||
const id = playerCharacterId("pc-1");
|
||||
|
||||
function success(
|
||||
characters: readonly PlayerCharacter[],
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color = "blue",
|
||||
icon = "sword",
|
||||
) {
|
||||
const result = createPlayerCharacter(
|
||||
characters,
|
||||
id,
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("createPlayerCharacter", () => {
|
||||
it("creates a valid player character", () => {
|
||||
const { characters, events } = success(
|
||||
[],
|
||||
"Aragorn",
|
||||
16,
|
||||
120,
|
||||
"green",
|
||||
"shield",
|
||||
);
|
||||
|
||||
expect(characters).toHaveLength(1);
|
||||
expect(characters[0]).toEqual({
|
||||
id,
|
||||
name: "Aragorn",
|
||||
ac: 16,
|
||||
maxHp: 120,
|
||||
color: "green",
|
||||
icon: "shield",
|
||||
});
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "PlayerCharacterCreated",
|
||||
playerCharacterId: id,
|
||||
name: "Aragorn",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("trims whitespace from name", () => {
|
||||
const { characters } = success([], " Gandalf ", 12, 80);
|
||||
expect(characters[0].name).toBe("Gandalf");
|
||||
});
|
||||
|
||||
it("appends to existing characters", () => {
|
||||
const existing: PlayerCharacter = {
|
||||
id: playerCharacterId("pc-0"),
|
||||
name: "Legolas",
|
||||
ac: 14,
|
||||
maxHp: 90,
|
||||
color: "green",
|
||||
icon: "eye",
|
||||
};
|
||||
const { characters } = success([existing], "Gimli", 18, 100, "red", "axe");
|
||||
expect(characters).toHaveLength(2);
|
||||
expect(characters[0]).toEqual(existing);
|
||||
expect(characters[1].name).toBe("Gimli");
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
const result = createPlayerCharacter([], id, "", 10, 50, "blue", "sword");
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects whitespace-only name", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
" ",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative AC", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
-1,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-ac");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-integer AC", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10.5,
|
||||
50,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-ac");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows AC of 0", () => {
|
||||
const { characters } = success([], "Test", 0, 50);
|
||||
expect(characters[0].ac).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects maxHp of 0", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
0,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative maxHp", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
-5,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-integer maxHp", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50.5,
|
||||
"blue",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid color", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"neon",
|
||||
"sword",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-color");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid icon", () => {
|
||||
const result = createPlayerCharacter(
|
||||
[],
|
||||
id,
|
||||
"Test",
|
||||
10,
|
||||
50,
|
||||
"blue",
|
||||
"banana",
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-icon");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits exactly one event on success", () => {
|
||||
const { events } = success([], "Test", 10, 50);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("PlayerCharacterCreated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deletePlayerCharacter } from "../delete-player-character.js";
|
||||
import type { PlayerCharacter } from "../player-character-types.js";
|
||||
import { playerCharacterId } from "../player-character-types.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
|
||||
const id1 = playerCharacterId("pc-1");
|
||||
const id2 = playerCharacterId("pc-2");
|
||||
|
||||
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||
return {
|
||||
id: id1,
|
||||
name: "Aragorn",
|
||||
ac: 16,
|
||||
maxHp: 120,
|
||||
color: "green",
|
||||
icon: "sword",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deletePlayerCharacter", () => {
|
||||
it("deletes an existing character", () => {
|
||||
const result = deletePlayerCharacter([makePC()], id1);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns error for not-found id", () => {
|
||||
const result = deletePlayerCharacter([makePC()], id2);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("player-character-not-found");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits PlayerCharacterDeleted event", () => {
|
||||
const result = deletePlayerCharacter([makePC()], id1);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0].type).toBe("PlayerCharacterDeleted");
|
||||
});
|
||||
|
||||
it("preserves other characters when deleting one", () => {
|
||||
const pc1 = makePC({ id: id1, name: "Aragorn" });
|
||||
const pc2 = makePC({ id: id2, name: "Legolas" });
|
||||
const result = deletePlayerCharacter([pc1, pc2], id1);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters).toHaveLength(1);
|
||||
expect(result.characters[0].name).toBe("Legolas");
|
||||
});
|
||||
|
||||
it("event includes deleted character name", () => {
|
||||
const result = deletePlayerCharacter([makePC()], id1);
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
const event = result.events[0];
|
||||
if (event.type !== "PlayerCharacterDeleted") throw new Error("wrong event");
|
||||
expect(event.name).toBe("Aragorn");
|
||||
});
|
||||
});
|
||||
117
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
117
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { editPlayerCharacter } from "../edit-player-character.js";
|
||||
import type { PlayerCharacter } from "../player-character-types.js";
|
||||
import { playerCharacterId } from "../player-character-types.js";
|
||||
import { isDomainError } from "../types.js";
|
||||
|
||||
const id = playerCharacterId("pc-1");
|
||||
|
||||
function makePC(overrides?: Partial<PlayerCharacter>): PlayerCharacter {
|
||||
return {
|
||||
id,
|
||||
name: "Aragorn",
|
||||
ac: 16,
|
||||
maxHp: 120,
|
||||
color: "green",
|
||||
icon: "sword",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("editPlayerCharacter", () => {
|
||||
it("edits name successfully", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].name).toBe("Strider");
|
||||
expect(result.events[0].type).toBe("PlayerCharacterUpdated");
|
||||
});
|
||||
|
||||
it("edits multiple fields", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, {
|
||||
name: "Strider",
|
||||
ac: 18,
|
||||
});
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.characters[0].name).toBe("Strider");
|
||||
expect(result.characters[0].ac).toBe(18);
|
||||
});
|
||||
|
||||
it("returns error for not-found id", () => {
|
||||
const result = editPlayerCharacter(
|
||||
[makePC()],
|
||||
playerCharacterId("pc-999"),
|
||||
{ name: "Nope" },
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("player-character-not-found");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "" });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid AC", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { ac: -1 });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-ac");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid maxHp", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { maxHp: 0 });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-max-hp");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid color", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { color: "neon" });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-color");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid icon", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { icon: "banana" });
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-icon");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error when no fields changed", () => {
|
||||
const pc = makePC();
|
||||
const result = editPlayerCharacter([pc], id, {
|
||||
name: pc.name,
|
||||
ac: pc.ac,
|
||||
});
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("no-changes");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits exactly one event on success", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
expect(result.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("event includes old and new name", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
const event = result.events[0];
|
||||
if (event.type !== "PlayerCharacterUpdated") throw new Error("wrong event");
|
||||
expect(event.oldName).toBe("Aragorn");
|
||||
expect(event.newName).toBe("Strider");
|
||||
});
|
||||
});
|
||||
87
packages/domain/src/create-player-character.ts
Normal file
87
packages/domain/src/create-player-character.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface CreatePlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
export function createPlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
): CreatePlayerCharacterSuccess | DomainError {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (trimmed === "") {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-name",
|
||||
message: "Player character name must not be empty",
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(ac) || ac < 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: "AC must be a non-negative integer",
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: "Max HP must be a positive integer",
|
||||
};
|
||||
}
|
||||
|
||||
if (!VALID_PLAYER_COLORS.has(color)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-color",
|
||||
message: `Invalid color: ${color}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!VALID_PLAYER_ICONS.has(icon)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-icon",
|
||||
message: `Invalid icon: ${icon}`,
|
||||
};
|
||||
}
|
||||
|
||||
const newCharacter: PlayerCharacter = {
|
||||
id,
|
||||
name: trimmed,
|
||||
ac,
|
||||
maxHp,
|
||||
color: color as PlayerCharacter["color"],
|
||||
icon: icon as PlayerCharacter["icon"],
|
||||
};
|
||||
|
||||
return {
|
||||
characters: [...characters, newCharacter],
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterCreated",
|
||||
playerCharacterId: id,
|
||||
name: trimmed,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
39
packages/domain/src/delete-player-character.ts
Normal file
39
packages/domain/src/delete-player-character.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface DeletePlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
export function deletePlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
): DeletePlayerCharacterSuccess | DomainError {
|
||||
const index = characters.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "player-character-not-found",
|
||||
message: `Player character not found: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const removed = characters[index];
|
||||
const newList = characters.filter((_, i) => i !== index);
|
||||
|
||||
return {
|
||||
characters: newList,
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterDeleted",
|
||||
playerCharacterId: id,
|
||||
name: removed.name,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
137
packages/domain/src/edit-player-character.ts
Normal file
137
packages/domain/src/edit-player-character.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface EditPlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
function validateFields(fields: EditFields): DomainError | null {
|
||||
if (fields.name !== undefined && fields.name.trim() === "") {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-name",
|
||||
message: "Player character name must not be empty",
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.ac !== undefined &&
|
||||
(!Number.isInteger(fields.ac) || fields.ac < 0)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: "AC must be a non-negative integer",
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.maxHp !== undefined &&
|
||||
(!Number.isInteger(fields.maxHp) || fields.maxHp < 1)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: "Max HP must be a positive integer",
|
||||
};
|
||||
}
|
||||
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-color",
|
||||
message: `Invalid color: ${fields.color}`,
|
||||
};
|
||||
}
|
||||
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-icon",
|
||||
message: `Invalid icon: ${fields.icon}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyFields(
|
||||
existing: PlayerCharacter,
|
||||
fields: EditFields,
|
||||
): PlayerCharacter {
|
||||
return {
|
||||
id: existing.id,
|
||||
name: fields.name !== undefined ? fields.name.trim() : existing.name,
|
||||
ac: fields.ac !== undefined ? fields.ac : existing.ac,
|
||||
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
||||
color:
|
||||
fields.color !== undefined
|
||||
? (fields.color as PlayerCharacter["color"])
|
||||
: existing.color,
|
||||
icon:
|
||||
fields.icon !== undefined
|
||||
? (fields.icon as PlayerCharacter["icon"])
|
||||
: existing.icon,
|
||||
};
|
||||
}
|
||||
|
||||
export function editPlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
fields: EditFields,
|
||||
): EditPlayerCharacterSuccess | DomainError {
|
||||
const index = characters.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "player-character-not-found",
|
||||
message: `Player character not found: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const validationError = validateFields(fields);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const existing = characters[index];
|
||||
const updated = applyFields(existing, fields);
|
||||
|
||||
if (
|
||||
updated.name === existing.name &&
|
||||
updated.ac === existing.ac &&
|
||||
updated.maxHp === existing.maxHp &&
|
||||
updated.color === existing.color &&
|
||||
updated.icon === existing.icon
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "no-changes",
|
||||
message: "No fields changed",
|
||||
};
|
||||
}
|
||||
|
||||
const newList = characters.map((c, i) => (i === index ? updated : c));
|
||||
|
||||
return {
|
||||
characters: newList,
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterUpdated",
|
||||
playerCharacterId: id,
|
||||
oldName: existing.name,
|
||||
newName: updated.name,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type { CombatantId } from "./types.js";
|
||||
|
||||
export interface TurnAdvanced {
|
||||
@@ -103,6 +104,25 @@ export interface EncounterCleared {
|
||||
readonly combatantCount: number;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterCreated {
|
||||
readonly type: "PlayerCharacterCreated";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterUpdated {
|
||||
readonly type: "PlayerCharacterUpdated";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly oldName: string;
|
||||
readonly newName: string;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterDeleted {
|
||||
readonly type: "PlayerCharacterDeleted";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export type DomainEvent =
|
||||
| TurnAdvanced
|
||||
| RoundAdvanced
|
||||
@@ -119,4 +139,7 @@ export type DomainEvent =
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
| ConcentrationEnded
|
||||
| EncounterCleared;
|
||||
| EncounterCleared
|
||||
| PlayerCharacterCreated
|
||||
| PlayerCharacterUpdated
|
||||
| PlayerCharacterDeleted;
|
||||
|
||||
@@ -12,6 +12,10 @@ export {
|
||||
type ConditionId,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
type CreatePlayerCharacterSuccess,
|
||||
createPlayerCharacter,
|
||||
} from "./create-player-character.js";
|
||||
export {
|
||||
type BestiaryIndex,
|
||||
type BestiaryIndexEntry,
|
||||
@@ -25,10 +29,18 @@ export {
|
||||
type SpellcastingBlock,
|
||||
type TraitBlock,
|
||||
} from "./creature-types.js";
|
||||
export {
|
||||
type DeletePlayerCharacterSuccess,
|
||||
deletePlayerCharacter,
|
||||
} from "./delete-player-character.js";
|
||||
export {
|
||||
type EditCombatantSuccess,
|
||||
editCombatant,
|
||||
} from "./edit-combatant.js";
|
||||
export {
|
||||
type EditPlayerCharacterSuccess,
|
||||
editPlayerCharacter,
|
||||
} from "./edit-player-character.js";
|
||||
export type {
|
||||
AcSet,
|
||||
CombatantAdded,
|
||||
@@ -43,6 +55,9 @@ export type {
|
||||
EncounterCleared,
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
PlayerCharacterCreated,
|
||||
PlayerCharacterDeleted,
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
TurnAdvanced,
|
||||
@@ -54,6 +69,16 @@ export {
|
||||
formatInitiativeModifier,
|
||||
type InitiativeResult,
|
||||
} from "./initiative.js";
|
||||
export {
|
||||
type PlayerCharacter,
|
||||
type PlayerCharacterId,
|
||||
type PlayerCharacterList,
|
||||
type PlayerColor,
|
||||
type PlayerIcon,
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
export {
|
||||
type RemoveCombatantSuccess,
|
||||
removeCombatant,
|
||||
|
||||
81
packages/domain/src/player-character-types.ts
Normal file
81
packages/domain/src/player-character-types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/** Branded string type for player character identity. */
|
||||
export type PlayerCharacterId = string & {
|
||||
readonly __brand: "PlayerCharacterId";
|
||||
};
|
||||
|
||||
export function playerCharacterId(id: string): PlayerCharacterId {
|
||||
return id as PlayerCharacterId;
|
||||
}
|
||||
|
||||
export type PlayerColor =
|
||||
| "red"
|
||||
| "blue"
|
||||
| "green"
|
||||
| "purple"
|
||||
| "orange"
|
||||
| "pink"
|
||||
| "cyan"
|
||||
| "yellow"
|
||||
| "emerald"
|
||||
| "indigo";
|
||||
|
||||
export const VALID_PLAYER_COLORS: ReadonlySet<string> = new Set<PlayerColor>([
|
||||
"red",
|
||||
"blue",
|
||||
"green",
|
||||
"purple",
|
||||
"orange",
|
||||
"pink",
|
||||
"cyan",
|
||||
"yellow",
|
||||
"emerald",
|
||||
"indigo",
|
||||
]);
|
||||
|
||||
export type PlayerIcon =
|
||||
| "sword"
|
||||
| "shield"
|
||||
| "skull"
|
||||
| "heart"
|
||||
| "wand"
|
||||
| "flame"
|
||||
| "crown"
|
||||
| "star"
|
||||
| "moon"
|
||||
| "sun"
|
||||
| "axe"
|
||||
| "crosshair"
|
||||
| "eye"
|
||||
| "feather"
|
||||
| "zap";
|
||||
|
||||
export const VALID_PLAYER_ICONS: ReadonlySet<string> = new Set<PlayerIcon>([
|
||||
"sword",
|
||||
"shield",
|
||||
"skull",
|
||||
"heart",
|
||||
"wand",
|
||||
"flame",
|
||||
"crown",
|
||||
"star",
|
||||
"moon",
|
||||
"sun",
|
||||
"axe",
|
||||
"crosshair",
|
||||
"eye",
|
||||
"feather",
|
||||
"zap",
|
||||
]);
|
||||
|
||||
export interface PlayerCharacter {
|
||||
readonly id: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
readonly ac: number;
|
||||
readonly maxHp: number;
|
||||
readonly color: PlayerColor;
|
||||
readonly icon: PlayerIcon;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterList {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
|
||||
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
|
||||
export interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
@@ -18,6 +19,9 @@ export interface Combatant {
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
}
|
||||
|
||||
export interface Encounter {
|
||||
|
||||
34
specs/005-player-characters/checklists/requirements.md
Normal file
34
specs/005-player-characters/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Player Character Management
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-12
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
93
specs/005-player-characters/contracts/ui-contracts.md
Normal file
93
specs/005-player-characters/contracts/ui-contracts.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# UI Contracts: Player Character Management
|
||||
|
||||
**Branch**: `005-player-characters` | **Date**: 2026-03-12
|
||||
|
||||
## Bottom Bar — New "Create Player" Button
|
||||
|
||||
**Location**: Bottom bar, alongside existing search input
|
||||
**Trigger**: Icon button click
|
||||
**Icon**: `Users` (Lucide) or similar group/party icon
|
||||
**Action**: Opens Create Player modal
|
||||
|
||||
## Create/Edit Player Modal
|
||||
|
||||
**Trigger**: "Create Player" button (create) or edit action in management view (edit)
|
||||
**Layout**: Centered modal overlay
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Input Type | Placeholder/Label | Validation |
|
||||
|-------|-----------|-------------------|------------|
|
||||
| Name | Text input | "Character name" | Required, non-empty |
|
||||
| AC | Number input | "AC" | Required, >= 0 |
|
||||
| Max HP | Number input | "Max HP" | Required, > 0 |
|
||||
| Color | Palette grid | — | Required, select one |
|
||||
| Icon | Icon grid | — | Required, select one |
|
||||
|
||||
### Color Palette
|
||||
|
||||
Grid of ~10 color swatches. Selected color has a visible ring/border. Each swatch is a clickable circle or rounded square showing the color.
|
||||
|
||||
### Icon Grid
|
||||
|
||||
Grid of ~15 Lucide icons. Selected icon has a highlight/ring. Each icon is a clickable square with the icon rendered at a readable size.
|
||||
|
||||
### Actions
|
||||
|
||||
- **Save**: Validates and creates/updates. Closes modal on success.
|
||||
- **Cancel**: Discards changes. Closes modal.
|
||||
|
||||
## Search Dropdown — Player Characters Section
|
||||
|
||||
**Location**: Existing ActionBar search dropdown
|
||||
**Position**: Above bestiary results
|
||||
**Visibility**: Only when player characters match the query (hide section when no matches)
|
||||
|
||||
### Result Item
|
||||
|
||||
| Element | Content |
|
||||
|---------|---------|
|
||||
| Icon | Player character's chosen icon (small, tinted with chosen color) |
|
||||
| Name | Player character name |
|
||||
| Label | "Player" (to distinguish from bestiary) |
|
||||
|
||||
### Behavior
|
||||
|
||||
- Clicking a player character result adds it to the encounter (same as bestiary selection)
|
||||
- No count/batch — player characters are added one at a time
|
||||
- Player character results use substring matching (same as bestiary)
|
||||
|
||||
## Combatant Row — Color & Icon Display
|
||||
|
||||
**Location**: Existing combatant row, next to combatant name
|
||||
**Visibility**: Only for combatants with `color` and `icon` fields set
|
||||
|
||||
### Rendering
|
||||
|
||||
- Small icon (matching the player character's chosen icon) displayed to the left of the combatant name
|
||||
- Icon tinted with the player character's chosen color
|
||||
- Subtle color accent on the combatant row (e.g., left border or background tint)
|
||||
|
||||
## Player Character Management View
|
||||
|
||||
**Trigger**: Accessible from bottom bar or a dedicated affordance
|
||||
**Layout**: Modal or slide-over panel
|
||||
|
||||
### Character List
|
||||
|
||||
Each row shows:
|
||||
|
||||
| Element | Content |
|
||||
|---------|---------|
|
||||
| Icon | Chosen icon, tinted with chosen color |
|
||||
| Name | Character name |
|
||||
| AC | Armor class value |
|
||||
| Max HP | Max HP value |
|
||||
| Edit button | Opens edit modal |
|
||||
| Delete button | ConfirmButton (two-step) — removes character |
|
||||
|
||||
### Empty State
|
||||
|
||||
When no player characters exist:
|
||||
- Message: "No player characters yet"
|
||||
- Call-to-action button: "Create your first player character"
|
||||
141
specs/005-player-characters/data-model.md
Normal file
141
specs/005-player-characters/data-model.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Data Model: Player Character Management
|
||||
|
||||
**Branch**: `005-player-characters` | **Date**: 2026-03-12
|
||||
|
||||
## New Entities
|
||||
|
||||
### PlayerCharacterId (branded type)
|
||||
|
||||
```
|
||||
PlayerCharacterId = string & { __brand: "PlayerCharacterId" }
|
||||
```
|
||||
|
||||
Unique identifier for player characters. Generated by the application layer (same pattern as `CombatantId`).
|
||||
|
||||
### PlayerCharacter
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|-------|------|----------|-------------|
|
||||
| id | PlayerCharacterId | yes | Unique, immutable after creation |
|
||||
| name | string | yes | Non-empty after trimming |
|
||||
| ac | number | yes | Non-negative integer |
|
||||
| maxHp | number | yes | Positive integer |
|
||||
| color | PlayerColor | yes | One of predefined color values |
|
||||
| icon | PlayerIcon | yes | One of predefined icon identifiers |
|
||||
|
||||
### PlayerColor (constrained string)
|
||||
|
||||
Predefined set of ~10 distinguishable color identifiers:
|
||||
`"red" | "blue" | "green" | "purple" | "orange" | "pink" | "cyan" | "yellow" | "emerald" | "indigo"`
|
||||
|
||||
Each maps to a specific hex/tailwind value at the adapter layer.
|
||||
|
||||
### PlayerIcon (constrained string)
|
||||
|
||||
Predefined set of ~15 Lucide icon identifiers:
|
||||
`"sword" | "shield" | "skull" | "heart" | "wand" | "flame" | "crown" | "star" | "moon" | "sun" | "axe" | "crosshair" | "eye" | "feather" | "zap"`
|
||||
|
||||
### PlayerCharacterList (aggregate)
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|-------|------|-------------|
|
||||
| characters | readonly PlayerCharacter[] | May be empty. IDs unique within list. |
|
||||
|
||||
## Modified Entities
|
||||
|
||||
### Combatant (extended)
|
||||
|
||||
Three new optional fields added:
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|-------|------|----------|-------------|
|
||||
| color | string | no | Copied from PlayerCharacter at add-time |
|
||||
| icon | string | no | Copied from PlayerCharacter at add-time |
|
||||
| playerCharacterId | PlayerCharacterId | no | Reference to source player character (informational only) |
|
||||
|
||||
These fields are set when a combatant is created from a player character. They are immutable snapshots — editing the source player character does not update existing combatants.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### createPlayerCharacter
|
||||
|
||||
- **Input**: PlayerCharacterList + name + ac + maxHp + color + icon + id
|
||||
- **Output**: Updated PlayerCharacterList + `PlayerCharacterCreated` event | DomainError
|
||||
- **Validation**: name non-empty after trim, ac >= 0 integer, maxHp > 0 integer, color in set, icon in set
|
||||
- **Errors**: `invalid-name`, `invalid-ac`, `invalid-max-hp`, `invalid-color`, `invalid-icon`
|
||||
|
||||
### editPlayerCharacter
|
||||
|
||||
- **Input**: PlayerCharacterList + id + partial fields (name?, ac?, maxHp?, color?, icon?)
|
||||
- **Output**: Updated PlayerCharacterList + `PlayerCharacterUpdated` event | DomainError
|
||||
- **Validation**: Same as create for any provided field. At least one field must change.
|
||||
- **Errors**: `player-character-not-found`, `invalid-name`, `invalid-ac`, `invalid-max-hp`, `invalid-color`, `invalid-icon`
|
||||
|
||||
### deletePlayerCharacter
|
||||
|
||||
- **Input**: PlayerCharacterList + id
|
||||
- **Output**: Updated PlayerCharacterList + `PlayerCharacterDeleted` event | DomainError
|
||||
- **Validation**: ID must exist in list
|
||||
- **Errors**: `player-character-not-found`
|
||||
|
||||
## Domain Events
|
||||
|
||||
### PlayerCharacterCreated
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| type | `"PlayerCharacterCreated"` |
|
||||
| playerCharacterId | PlayerCharacterId |
|
||||
| name | string |
|
||||
|
||||
### PlayerCharacterUpdated
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| type | `"PlayerCharacterUpdated"` |
|
||||
| playerCharacterId | PlayerCharacterId |
|
||||
| oldName | string |
|
||||
| newName | string |
|
||||
|
||||
### PlayerCharacterDeleted
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| type | `"PlayerCharacterDeleted"` |
|
||||
| playerCharacterId | PlayerCharacterId |
|
||||
| name | string |
|
||||
|
||||
## Persistence Schema
|
||||
|
||||
### localStorage key: `"initiative:player-characters"`
|
||||
|
||||
JSON array of serialized `PlayerCharacter` objects:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "pc_abc123",
|
||||
"name": "Aragorn",
|
||||
"ac": 16,
|
||||
"maxHp": 120,
|
||||
"color": "green",
|
||||
"icon": "sword"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Rehydration rules
|
||||
|
||||
- Parse JSON array; discard entire store on parse failure (return empty list)
|
||||
- Per-character validation: discard individual characters that fail validation
|
||||
- Required string fields: must be non-empty strings
|
||||
- Required number fields: must match domain constraints (ac >= 0, maxHp > 0)
|
||||
- Color/icon: must be members of the predefined sets; discard character if invalid
|
||||
|
||||
## Port Interface
|
||||
|
||||
### PlayerCharacterStore
|
||||
|
||||
```
|
||||
getAll(): PlayerCharacter[]
|
||||
save(characters: PlayerCharacter[]): void
|
||||
```
|
||||
|
||||
Synchronous, matching the `EncounterStore` pattern. Implementation: localStorage adapter.
|
||||
100
specs/005-player-characters/plan.md
Normal file
100
specs/005-player-characters/plan.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Implementation Plan: Player Character Management
|
||||
|
||||
**Branch**: `005-player-characters` | **Date**: 2026-03-12 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/005-player-characters/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add persistent player character templates with name, AC, max HP, color, and icon. Player characters are stored independently from encounters and appear in the combatant search dropdown. When added to an encounter, a combatant is created as an independent snapshot with the player character's stats, color, and icon. A management view allows editing and deleting saved characters.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React
|
||||
**Storage**: localStorage (new key `"initiative:player-characters"`)
|
||||
**Testing**: Vitest (unit tests for domain, persistence rehydration)
|
||||
**Target Platform**: Web browser (single-page app, no routing)
|
||||
**Project Type**: Web application (monorepo: domain → application → web adapter)
|
||||
**Performance Goals**: Instant load (synchronous localStorage), <16ms search on small list
|
||||
**Constraints**: Local-first, single-user, offline-capable
|
||||
**Scale/Scope**: Typical party size 3-8 player characters
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | All PC operations are pure functions, no I/O |
|
||||
| II. Layered Architecture | PASS | Domain types/functions → Application use cases/ports → Web adapters/hooks/components |
|
||||
| III. Clarification-First | PASS | No non-trivial assumptions; all decisions documented in research.md |
|
||||
| IV. Escalation Gates | PASS | Feature has its own spec; cross-feature impacts (Combatant type extension) are documented |
|
||||
| V. MVP Baseline Language | PASS | Exclusions use "MVP baseline does not include" language |
|
||||
| VI. No Gameplay Rules | PASS | No game mechanics in spec or plan |
|
||||
|
||||
**Post-Phase 1 re-check**: PASS. Data model uses pure domain types. Color/icon are constrained string sets validated in domain. Storage adapter follows existing localStorage pattern.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/005-player-characters/
|
||||
├── plan.md # This file
|
||||
├── spec.md # Feature specification
|
||||
├── research.md # Phase 0: technical decisions
|
||||
├── data-model.md # Phase 1: entity definitions
|
||||
├── quickstart.md # Phase 1: implementation guide
|
||||
├── contracts/
|
||||
│ └── ui-contracts.md # Phase 1: UI component contracts
|
||||
├── checklists/
|
||||
│ └── requirements.md # Spec quality checklist
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── player-character-types.ts # NEW: PlayerCharacterId, PlayerCharacter, PlayerColor, PlayerIcon
|
||||
├── create-player-character.ts # NEW: Pure create function
|
||||
├── edit-player-character.ts # NEW: Pure edit function
|
||||
├── delete-player-character.ts # NEW: Pure delete function
|
||||
├── types.ts # MODIFIED: Add color?, icon?, playerCharacterId? to Combatant
|
||||
├── events.ts # MODIFIED: Add PC domain events to union
|
||||
├── index.ts # MODIFIED: Re-export new types/functions
|
||||
└── __tests__/
|
||||
├── create-player-character.test.ts # NEW
|
||||
├── edit-player-character.test.ts # NEW
|
||||
└── delete-player-character.test.ts # NEW
|
||||
|
||||
packages/application/src/
|
||||
├── ports.ts # MODIFIED: Add PlayerCharacterStore
|
||||
├── create-player-character-use-case.ts # NEW
|
||||
├── edit-player-character-use-case.ts # NEW
|
||||
├── delete-player-character-use-case.ts # NEW
|
||||
└── index.ts # MODIFIED: Re-export
|
||||
|
||||
apps/web/src/
|
||||
├── persistence/
|
||||
│ ├── player-character-storage.ts # NEW: localStorage adapter
|
||||
│ ├── encounter-storage.ts # MODIFIED: Handle new Combatant fields
|
||||
│ └── __tests__/
|
||||
│ └── player-character-storage.test.ts # NEW
|
||||
├── hooks/
|
||||
│ └── use-player-characters.ts # NEW: React state + persistence
|
||||
├── components/
|
||||
│ ├── create-player-modal.tsx # NEW: Create/edit modal
|
||||
│ ├── player-management.tsx # NEW: List/edit/delete view
|
||||
│ ├── color-palette.tsx # NEW: Color selection grid
|
||||
│ ├── icon-grid.tsx # NEW: Icon selection grid
|
||||
│ ├── action-bar.tsx # MODIFIED: Add "Players" section to dropdown
|
||||
│ └── combatant-row.tsx # MODIFIED: Render color/icon
|
||||
└── App.tsx # MODIFIED: Wire usePlayerCharacters
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing monorepo layered architecture. New domain files for player character operations, new application use cases, new web adapter/hook/components. Modified files extend existing types and UI.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations. No complexity justification needed.
|
||||
62
specs/005-player-characters/quickstart.md
Normal file
62
specs/005-player-characters/quickstart.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Quickstart: Player Character Management
|
||||
|
||||
**Branch**: `005-player-characters` | **Date**: 2026-03-12
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds persistent player character templates that can be reused across encounters. Player characters have a name, AC, max HP, a chosen color, and a preset icon.
|
||||
|
||||
## Implementation Layers
|
||||
|
||||
### Domain (`packages/domain/src/`)
|
||||
|
||||
New files:
|
||||
- `player-character-types.ts` — `PlayerCharacterId`, `PlayerCharacter`, `PlayerColor`, `PlayerIcon`, validation sets
|
||||
- `create-player-character.ts` — Pure create function
|
||||
- `edit-player-character.ts` — Pure edit function
|
||||
- `delete-player-character.ts` — Pure delete function
|
||||
|
||||
Modified files:
|
||||
- `types.ts` — Add `color?`, `icon?`, `playerCharacterId?` to `Combatant`
|
||||
- `events.ts` — Add `PlayerCharacterCreated`, `PlayerCharacterUpdated`, `PlayerCharacterDeleted` to union
|
||||
- `index.ts` — Re-export new types and functions
|
||||
|
||||
### Application (`packages/application/src/`)
|
||||
|
||||
Modified files:
|
||||
- `ports.ts` — Add `PlayerCharacterStore` port interface
|
||||
|
||||
New files:
|
||||
- `create-player-character-use-case.ts`
|
||||
- `edit-player-character-use-case.ts`
|
||||
- `delete-player-character-use-case.ts`
|
||||
|
||||
### Web (`apps/web/`)
|
||||
|
||||
New files:
|
||||
- `src/persistence/player-character-storage.ts` — localStorage adapter
|
||||
- `src/hooks/use-player-characters.ts` — React state + persistence hook
|
||||
- `src/components/create-player-modal.tsx` — Create/edit modal
|
||||
- `src/components/player-management.tsx` — List/edit/delete view
|
||||
- `src/components/color-palette.tsx` — Color selection grid
|
||||
- `src/components/icon-grid.tsx` — Icon selection grid
|
||||
|
||||
Modified files:
|
||||
- `src/components/action-bar.tsx` — Add "Players" section to search dropdown
|
||||
- `src/components/combatant-row.tsx` — Render color/icon for PC combatants
|
||||
- `src/App.tsx` — Wire up `usePlayerCharacters` hook, pass to components
|
||||
- `src/persistence/encounter-storage.ts` — Handle new optional Combatant fields in rehydration
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Domain**: Pure function unit tests for create/edit/delete (validation, error cases, events)
|
||||
- **Persistence**: Rehydration tests (corrupt data, missing fields, invalid color/icon)
|
||||
- **Integration**: Layer boundary check already runs in CI — verify new domain files have no outer-layer imports
|
||||
|
||||
## Key Patterns to Follow
|
||||
|
||||
1. **Branded types**: See `CombatantId` in `types.ts` for pattern
|
||||
2. **Domain operations**: See `add-combatant.ts` for `{result, events} | DomainError` pattern
|
||||
3. **Persistence**: See `encounter-storage.ts` for localStorage + rehydration pattern
|
||||
4. **Hook**: See `use-encounter.ts` for `useState` + `useEffect` persistence pattern
|
||||
5. **Ports**: See `EncounterStore` in `ports.ts` for interface pattern
|
||||
66
specs/005-player-characters/research.md
Normal file
66
specs/005-player-characters/research.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Research: Player Character Management
|
||||
|
||||
**Branch**: `005-player-characters` | **Date**: 2026-03-12
|
||||
|
||||
## Key Decisions
|
||||
|
||||
### 1. PlayerCharacter as a separate domain entity
|
||||
|
||||
- **Decision**: `PlayerCharacter` is a new type in the domain layer, distinct from `Combatant`. When added to an encounter, a snapshot is copied into a `Combatant`.
|
||||
- **Rationale**: Player characters are persistent templates reused across encounters; combatants are ephemeral per-encounter instances. Mixing them would violate the single-responsibility of the `Encounter` aggregate.
|
||||
- **Alternatives considered**: Extending `Combatant` with a `isPlayerCharacter` flag — rejected because combatants belong to encounters and are cleared with them, while player characters must survive encounter clears.
|
||||
|
||||
### 2. Combatant type extension for color and icon
|
||||
|
||||
- **Decision**: Add optional `color?: string` and `icon?: string` fields to the existing `Combatant` interface, plus `playerCharacterId?: PlayerCharacterId` to track origin.
|
||||
- **Rationale**: The combatant row needs to render color/icon. Storing these on the combatant (copied from the player character at add-time) keeps the combatant self-contained and avoids runtime lookups against the player character store.
|
||||
- **Alternatives considered**: Looking up color/icon from the player character store at render time — rejected because the player character might be deleted or edited after the combatant was added, and the spec says combatants are independent copies.
|
||||
|
||||
### 3. Storage: separate localStorage key
|
||||
|
||||
- **Decision**: Use `localStorage` with key `"initiative:player-characters"`, separate from encounter storage (`"initiative:encounter"`).
|
||||
- **Rationale**: Follows existing pattern (encounter uses its own key). Player characters must survive encounter clears. IndexedDB is overkill for a small list of player characters.
|
||||
- **Alternatives considered**: IndexedDB (like bestiary cache) — rejected as overly complex for simple JSON list. Shared key with encounter — rejected because clearing encounter would wipe player characters.
|
||||
|
||||
### 4. Search integration approach
|
||||
|
||||
- **Decision**: The `useBestiary` search hook or a new `usePlayerCharacters` hook provides player character search results. The `ActionBar` dropdown renders a "Players" group above bestiary results.
|
||||
- **Rationale**: Player character search is a simple substring match on a small in-memory list — no index needed. Keeping it separate from bestiary search maintains separation of concerns.
|
||||
- **Alternatives considered**: Merging into the bestiary index — rejected because player characters are user-created, not part of the pre-built index.
|
||||
|
||||
### 5. Color palette and icon set
|
||||
|
||||
- **Decision**: Use a fixed set of 10 distinguishable colors and ~15 Lucide icons already available in the project.
|
||||
- **Rationale**: Lucide React is already a dependency. A fixed palette ensures visual consistency and simplifies the domain model (color is a string enum, not arbitrary hex).
|
||||
- **Alternatives considered**: Arbitrary hex color picker — rejected for MVP as it complicates UX and validation.
|
||||
|
||||
### 6. Domain operations pattern
|
||||
|
||||
- **Decision**: Player character CRUD follows the same pattern as encounter operations: pure functions returning `{result, events} | DomainError`. New domain events: `PlayerCharacterCreated`, `PlayerCharacterUpdated`, `PlayerCharacterDeleted`.
|
||||
- **Rationale**: Consistency with existing domain patterns. Events enable future features (undo, audit).
|
||||
- **Alternatives considered**: Simpler CRUD without events — rejected for consistency with the project's event-driven domain.
|
||||
|
||||
### 7. Management view location
|
||||
|
||||
- **Decision**: A new icon button in the bottom bar (alongside the existing search) opens a player character management panel/modal.
|
||||
- **Rationale**: The bottom bar already serves as the primary action area. A modal keeps the management view accessible without adding routing complexity.
|
||||
- **Alternatives considered**: A separate route/page — rejected because the app is currently a single-page encounter tracker with no routing.
|
||||
|
||||
## Cross-feature impacts
|
||||
|
||||
### Spec 001 (Combatant Management)
|
||||
- `Combatant` type gains three optional fields: `color`, `icon`, `playerCharacterId`
|
||||
- `encounter-storage.ts` rehydration needs to handle new optional fields
|
||||
- Combatant row component needs to render color/icon when present
|
||||
|
||||
### Spec 003 (Combatant State)
|
||||
- No changes needed. AC and HP management already works on optional fields that player characters pre-fill.
|
||||
|
||||
### Spec 004 (Bestiary)
|
||||
- ActionBar dropdown gains a "Players" section above bestiary results
|
||||
- `addFromBestiary` pattern informs the new `addFromPlayerCharacter` flow
|
||||
- No changes to bestiary search itself
|
||||
|
||||
## Unresolved items
|
||||
|
||||
None. All technical decisions are resolved.
|
||||
262
specs/005-player-characters/spec.md
Normal file
262
specs/005-player-characters/spec.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Feature Specification: Player Character Management
|
||||
|
||||
**Feature Branch**: `005-player-characters`
|
||||
**Created**: 2026-03-12
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Allow users to create and manage Player characters via the bottom bar. Each player character has a name, AC, max HP, a chosen color, and a preset icon. Player characters persist across sessions and are searchable when adding combatants to an encounter. A dedicated management view lets users edit and delete existing player characters."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### Creating Player Characters
|
||||
|
||||
**Story PC-1 — Create a new player character (Priority: P1)**
|
||||
|
||||
A game master opens a "Create Player" modal from the bottom bar and fills in the character's name, AC, max HP, selects a color from a palette, and picks an icon from a preset grid. On saving, the player character is persisted and available for future encounters.
|
||||
|
||||
**Why this priority**: Creating player characters is the foundational action — nothing else in this feature works without it.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating a player character and verifying it appears in the saved player list.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the bottom bar is visible, **When** the user clicks the "Create Player" icon button, **Then** a modal opens with fields for Name, AC, Max HP, a color palette, and an icon selection grid.
|
||||
|
||||
2. **Given** the create player modal is open, **When** the user fills in Name "Aragorn", AC 16, Max HP 120, selects the color green, and selects the shield icon, **Then** clicking save creates a player character with those attributes and closes the modal.
|
||||
|
||||
3. **Given** no player characters exist, **When** the user creates their first player character, **Then** it is persisted and appears in the player character list.
|
||||
|
||||
4. **Given** the create player modal is open, **When** the user submits with an empty name, **Then** a validation error is shown and the player character is not created.
|
||||
|
||||
5. **Given** the create player modal is open, **When** the user submits with a whitespace-only name, **Then** a validation error is shown and the player character is not created.
|
||||
|
||||
6. **Given** the create player modal is open, **When** the user clicks cancel or closes the modal, **Then** no player character is created and any entered data is discarded.
|
||||
|
||||
---
|
||||
|
||||
### Player Character Persistence
|
||||
|
||||
**Story PC-2 — Player characters survive page reload (Priority: P1)**
|
||||
|
||||
Player characters are long-lived entities that persist across browser sessions. Unlike encounter combatants which belong to a single encounter, player characters represent recurring party members that the GM reuses across many encounters.
|
||||
|
||||
**Why this priority**: Without persistence, users would need to recreate their party every session, defeating the purpose.
|
||||
|
||||
**Independent Test**: Can be tested by creating a player character, reloading the page, and verifying it still exists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user has created player characters, **When** the page is reloaded, **Then** all player characters are restored with their name, AC, max HP, color, and icon intact.
|
||||
|
||||
2. **Given** saved player character data is corrupt or malformed, **When** the page loads, **Then** the application starts with an empty player character list without crashing.
|
||||
|
||||
3. **Given** no saved player character data exists, **When** the page loads, **Then** the application starts with an empty player character list.
|
||||
|
||||
---
|
||||
|
||||
### Adding Player Characters to Encounters
|
||||
|
||||
**Story PC-3 — Search and add player characters as combatants (Priority: P1)**
|
||||
|
||||
When adding combatants to an encounter, the GM can search for their saved player characters by name. Selecting a player character adds it as a combatant with its saved stats (AC, max HP) pre-filled, along with its color and icon for visual identification.
|
||||
|
||||
**Why this priority**: This is the core value proposition — reusing pre-configured characters instead of re-entering stats every encounter.
|
||||
|
||||
**Independent Test**: Can be tested by creating a player character, then adding it to an encounter via the combatant search.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** player characters "Aragorn" and "Legolas" exist, **When** the user types "Ara" in the combatant search field, **Then** "Aragorn" appears in the search results alongside bestiary creatures.
|
||||
|
||||
2. **Given** player character "Aragorn" (AC 16, Max HP 120) exists, **When** the user selects "Aragorn" from search results, **Then** a combatant is added to the encounter with name "Aragorn", AC 16, max HP 120, current HP 120, and the player character's color and icon.
|
||||
|
||||
3. **Given** player character "Gandalf" exists, **When** the user adds "Gandalf" to two separate encounters (or the same encounter twice), **Then** each combatant is an independent copy — modifying one combatant's HP does not affect the other or the saved player character.
|
||||
|
||||
4. **Given** no player characters exist, **When** the user searches in the combatant search field, **Then** only bestiary creatures appear in results (no empty "Players" section is shown).
|
||||
|
||||
---
|
||||
|
||||
### Displaying Player Characters in Encounters
|
||||
|
||||
**Story PC-4 — Visual distinction for player character combatants (Priority: P2)**
|
||||
|
||||
Combatants originating from player characters display their chosen color and icon next to their name in the combatant row, making it easy to visually distinguish PCs from monsters at a glance.
|
||||
|
||||
**Why this priority**: Color and icon display enhances usability but the feature is functional without it.
|
||||
|
||||
**Independent Test**: Can be tested by adding a player character to an encounter and verifying the color and icon render in the combatant row.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant was added from a player character with color green and the shield icon, **When** the combatant row is rendered, **Then** the player character's icon is displayed next to the name and the color is applied as a visual accent (e.g., colored border, background tint, or icon tint).
|
||||
|
||||
2. **Given** a combatant was added from the bestiary (not a player character), **When** the combatant row is rendered, **Then** no player character icon or color accent is shown.
|
||||
|
||||
---
|
||||
|
||||
### Managing Player Characters
|
||||
|
||||
**Story PC-5 — View all saved player characters (Priority: P2)**
|
||||
|
||||
The GM can access a dedicated management view to see all their saved player characters at a glance, with each character's name, AC, max HP, color, and icon displayed.
|
||||
|
||||
**Why this priority**: Management view is needed for editing and deleting, but basic create/add flow works without it.
|
||||
|
||||
**Independent Test**: Can be tested by creating several player characters and opening the management view to verify all are listed.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** player characters exist, **When** the user opens the player character management view, **Then** all saved player characters are listed showing their name, AC, max HP, color, and icon.
|
||||
|
||||
2. **Given** no player characters exist, **When** the user opens the management view, **Then** an empty state message is shown encouraging the user to create their first player character.
|
||||
|
||||
---
|
||||
|
||||
**Story PC-6 — Edit an existing player character (Priority: P2)**
|
||||
|
||||
The GM realizes a player character's stats have changed (e.g., level up) or wants to fix a typo. They open the management view and edit the character's attributes.
|
||||
|
||||
**Why this priority**: Editing is important for ongoing campaigns but the feature delivers value without it initially.
|
||||
|
||||
**Independent Test**: Can be tested by editing a player character's name and stats and verifying the changes persist.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** player character "Aragorn" exists, **When** the user edits "Aragorn" to change the name to "Strider" and AC to 18, **Then** the changes are saved and reflected in the player character list.
|
||||
|
||||
2. **Given** a player character is being edited, **When** the user submits with an empty name, **Then** a validation error is shown and the changes are not saved.
|
||||
|
||||
3. **Given** a player character is being edited, **When** the user cancels the edit, **Then** the original values are preserved.
|
||||
|
||||
4. **Given** a player character was previously added to an encounter as a combatant, **When** the player character is edited, **Then** existing combatants in the current encounter are not affected (they are independent copies).
|
||||
|
||||
---
|
||||
|
||||
**Story PC-7 — Delete a player character (Priority: P2)**
|
||||
|
||||
The GM no longer needs a player character and wants to remove it from their saved list.
|
||||
|
||||
**Why this priority**: Deletion keeps the list manageable but is not needed for core functionality.
|
||||
|
||||
**Independent Test**: Can be tested by deleting a player character and verifying it no longer appears in the list or search results.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** player character "Boromir" exists, **When** the user deletes "Boromir" with confirmation, **Then** the player character is removed from the saved list.
|
||||
|
||||
2. **Given** player character "Boromir" was previously added to the current encounter as a combatant, **When** the player character is deleted, **Then** the combatant in the encounter is not affected (it is an independent copy).
|
||||
|
||||
3. **Given** the delete action is initiated, **When** the user is asked to confirm, **Then** the confirmation follows the existing ConfirmButton two-step pattern (as defined in spec 001).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Duplicate player character names**: Permitted. Player characters are identified by a unique internal ID, not by name.
|
||||
- **Adding the same player character to an encounter multiple times**: Each addition creates an independent combatant copy. Multiple copies of the same PC in one encounter are allowed.
|
||||
- **Editing a player character while it is also a combatant in the active encounter**: The active combatant is not affected; only future additions use the updated stats.
|
||||
- **Deleting a player character while it is a combatant in the active encounter**: The combatant remains in the encounter unchanged.
|
||||
- **Very long player character names**: The UI should truncate or ellipsize names that exceed the available space.
|
||||
- **Browser storage quota exceeded**: Player character persistence silently fails; the current in-memory session continues.
|
||||
- **Corrupt player character data on load**: The application discards corrupt data and starts with an empty player character list.
|
||||
- **Color/icon rendering on different screen sizes**: Color and icon must remain visible and distinguishable at all supported viewport sizes.
|
||||
- **Search ranking**: When searching, player characters should appear in a distinct group (e.g., "Players" section) above or alongside bestiary results to make them easy to find.
|
||||
|
||||
---
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### FR-001 — Create: Modal via bottom bar
|
||||
The system MUST provide an icon button in the bottom bar that opens a "Create Player" modal.
|
||||
|
||||
#### FR-002 — Create: Required fields
|
||||
The create modal MUST include fields for Name (text), AC (number), and Max HP (number).
|
||||
|
||||
#### FR-003 — Create: Color selection
|
||||
The create modal MUST include a color palette allowing the user to select one color from a predefined set of distinguishable colors.
|
||||
|
||||
#### FR-004 — Create: Icon selection
|
||||
The create modal MUST include a grid of ~10-20 preset icons (e.g., sword, shield, skull, heart, wand) from which the user selects one.
|
||||
|
||||
#### FR-005 — Create: Name validation
|
||||
Creating a player character MUST reject empty or whitespace-only names, showing a validation error.
|
||||
|
||||
#### FR-006 — Create: Unique identity
|
||||
Each player character MUST be assigned a unique internal identifier on creation.
|
||||
|
||||
#### FR-007 — Persistence: Cross-session storage
|
||||
Player characters MUST be persisted to browser storage and restored on page load.
|
||||
|
||||
#### FR-008 — Persistence: Independent from encounter storage
|
||||
Player character storage MUST be separate from encounter storage — clearing an encounter does not affect saved player characters.
|
||||
|
||||
#### FR-009 — Persistence: Graceful degradation
|
||||
The system MUST NOT crash when player character data is missing, corrupt, or storage is unavailable. It MUST fall back to an empty player character list.
|
||||
|
||||
#### FR-010 — Search: Player characters in combatant search
|
||||
Player characters MUST appear in the combatant search results when the user searches for combatants to add to an encounter. Matching is by name substring.
|
||||
|
||||
#### FR-011 — Search: Distinct grouping
|
||||
Player character results MUST be visually distinguishable from bestiary creature results in the search dropdown.
|
||||
|
||||
#### FR-012 — Add to encounter: Pre-filled stats
|
||||
When a player character is added to an encounter as a combatant, the combatant MUST be created with the player character's name, AC, max HP, and current HP set to max HP.
|
||||
|
||||
#### FR-013 — Add to encounter: Color and icon association
|
||||
When a combatant is created from a player character, the combatant MUST carry the player character's color and icon for display purposes.
|
||||
|
||||
#### FR-014 — Add to encounter: Independent copy
|
||||
Combatants created from player characters MUST be independent copies. Changes to the combatant's stats during an encounter do not modify the saved player character, and vice versa.
|
||||
|
||||
#### FR-015 — Display: Color and icon in combatant row
|
||||
Combatant rows for player-character-originating combatants MUST display the chosen icon and color accent.
|
||||
|
||||
#### FR-016 — Management: View all player characters
|
||||
The system MUST provide a view listing all saved player characters with their name, AC, max HP, color, and icon.
|
||||
|
||||
#### FR-017 — Management: Edit player character
|
||||
The system MUST allow editing a player character's name, AC, max HP, color, and icon. Edits MUST be persisted.
|
||||
|
||||
#### FR-018 — Management: Delete player character
|
||||
The system MUST allow deleting a player character with two-step confirmation (ConfirmButton pattern from spec 001).
|
||||
|
||||
#### FR-019 — Management: Delete does not affect active combatants
|
||||
Deleting a player character MUST NOT remove or modify any combatants currently in an encounter.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), and `icon` (string identifier from preset icon set).
|
||||
- **PlayerCharacterStore** (port): Interface for loading, saving, and deleting player characters. Implemented as a browser storage adapter.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can create a player character with name, AC, max HP, color, and icon in under 30 seconds.
|
||||
- **SC-002**: Player characters persist across page reloads with all attributes intact.
|
||||
- **SC-003**: Player characters appear in combatant search results and can be added to an encounter in a single selection.
|
||||
- **SC-004**: Combatants created from player characters display their color and icon in the initiative tracker.
|
||||
- **SC-005**: Users can edit any attribute of a saved player character and see the change persisted immediately.
|
||||
- **SC-006**: Deleting a player character requires two deliberate user interactions (ConfirmButton pattern) and does not affect active encounter combatants.
|
||||
- **SC-007**: All player character domain operations (create, edit, delete) are pure functions with no I/O, consistent with the project's deterministic domain core.
|
||||
- **SC-008**: The player character domain module has zero imports from application, adapter, or UI layers.
|
||||
- **SC-009**: Corrupt or missing player character data never causes a crash — the application gracefully falls back to an empty player character list.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Player character IDs are generated by the caller (application layer), keeping domain functions pure.
|
||||
- The predefined color palette contains 8-12 visually distinct colors suitable for both light and dark backgrounds.
|
||||
- The preset icon set uses Lucide React icons already available in the project, requiring no additional icon dependencies.
|
||||
- Player characters are stored in a separate `localStorage` key from encounter data.
|
||||
- Name validation trims whitespace; a name that is empty after trimming is invalid.
|
||||
- Duplicate player character names are permitted — characters are distinguished by their unique ID.
|
||||
- MVP baseline does not include importing/exporting player characters.
|
||||
- MVP baseline does not include player-character-specific fields beyond name, AC, max HP, color, and icon (e.g., no class, level, or ability scores).
|
||||
- MVP baseline does not include reordering player characters in the management view.
|
||||
- The management view is accessible from the bottom bar or a dedicated UI affordance, separate from the encounter view.
|
||||
- When a player character is added to an encounter, a snapshot of its current stats is copied — future edits to the player character do not retroactively update existing combatants.
|
||||
252
specs/005-player-characters/tasks.md
Normal file
252
specs/005-player-characters/tasks.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Tasks: Player Character Management
|
||||
|
||||
**Input**: Design documents from `/specs/005-player-characters/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ui-contracts.md, quickstart.md
|
||||
|
||||
**Tests**: Included — `pnpm check` merge gate requires passing tests with coverage.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Purpose**: No new project setup needed — existing monorepo structure is used. This phase handles shared type foundations.
|
||||
|
||||
_(No tasks — the project is already set up. Foundational tasks cover all shared work.)_
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Domain types, events, and port interface that ALL user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [x] T001 [P] Define `PlayerCharacterId` branded type, `PlayerCharacter` interface, `PlayerColor` constrained type (10 colors), `PlayerIcon` constrained type (~15 Lucide icon identifiers), validation sets (`VALID_PLAYER_COLORS`, `VALID_PLAYER_ICONS`), and `PlayerCharacterList` aggregate type (`{ readonly characters: readonly PlayerCharacter[] }`) in `packages/domain/src/player-character-types.ts`
|
||||
- [x] T002 [P] Add optional `color?: string`, `icon?: string`, and `playerCharacterId?: PlayerCharacterId` fields to the `Combatant` interface in `packages/domain/src/types.ts`. Import `PlayerCharacterId` from `player-character-types.js`.
|
||||
- [x] T003 [P] Add `PlayerCharacterCreated`, `PlayerCharacterUpdated`, `PlayerCharacterDeleted` event types to the `DomainEvent` union in `packages/domain/src/events.ts` (see data-model.md for field definitions)
|
||||
- [x] T004 [P] Add `PlayerCharacterStore` port interface (`getAll(): PlayerCharacter[]`, `save(characters: PlayerCharacter[]): void`) to `packages/application/src/ports.ts`
|
||||
- [x] T005 Re-export all new types and functions from `packages/domain/src/index.ts` and `packages/application/src/index.ts`
|
||||
- [x] T006 Update encounter storage rehydration in `apps/web/src/persistence/encounter-storage.ts` to handle the new optional `color`, `icon`, and `playerCharacterId` fields on `Combatant` (validate as optional strings, discard invalid values)
|
||||
|
||||
**Checkpoint**: Foundation ready — domain types defined, Combatant extended, events added, port declared.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Create & Persist Player Characters (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Users can create player characters via a modal (name, AC, max HP, color, icon) and they persist across page reloads.
|
||||
|
||||
**Independent Test**: Create a player character, reload the page, verify it still exists with all attributes intact.
|
||||
|
||||
**Maps to**: Spec stories PC-1 (Create) and PC-2 (Persistence)
|
||||
|
||||
### Domain
|
||||
|
||||
- [x] T007 [P] [US1] Implement `createPlayerCharacter` pure function in `packages/domain/src/create-player-character.ts` — accepts `PlayerCharacter[]`, `PlayerCharacterId`, name, ac, maxHp, color, icon; returns updated list + `PlayerCharacterCreated` event or `DomainError`. Validate: name non-empty after trim, ac >= 0 integer, maxHp > 0 integer, color in `VALID_PLAYER_COLORS`, icon in `VALID_PLAYER_ICONS`.
|
||||
- [x] T008 [P] [US1] Write unit tests for `createPlayerCharacter` in `packages/domain/src/__tests__/create-player-character.test.ts` — cover: valid creation, empty name, whitespace name, invalid ac, invalid maxHp, invalid color, invalid icon, event emission.
|
||||
|
||||
### Application
|
||||
|
||||
- [x] T009 [US1] Implement `createPlayerCharacterUseCase` in `packages/application/src/create-player-character-use-case.ts` — accepts `PlayerCharacterStore`, id, name, ac, maxHp, color, icon; calls domain function and `store.save()`.
|
||||
|
||||
### Persistence Adapter
|
||||
|
||||
- [x] T010 [P] [US1] Implement `savePlayerCharacters(characters: PlayerCharacter[]): void` and `loadPlayerCharacters(): PlayerCharacter[]` in `apps/web/src/persistence/player-character-storage.ts` — localStorage key `"initiative:player-characters"`, silent catch on save errors, return empty array on corrupt/missing data. Per-character rehydration with field validation (discard invalid characters).
|
||||
- [x] T011 [P] [US1] Write tests for player character storage in `apps/web/src/persistence/__tests__/player-character-storage.test.ts` — cover: round-trip save/load, corrupt JSON, missing fields, invalid color/icon values, empty storage, storage errors.
|
||||
|
||||
### React Hook
|
||||
|
||||
- [x] T012 [US1] Implement `usePlayerCharacters` hook in `apps/web/src/hooks/use-player-characters.ts` — `useState` initialized from `loadPlayerCharacters()`, `useEffect` to persist on change, expose `characters`, `createCharacter(name, ac, maxHp, color, icon)`, and a `makeStore()` callback returning `PlayerCharacterStore`. Follow the `useEncounter` ref + effect pattern.
|
||||
|
||||
### UI Components
|
||||
|
||||
- [x] T013 [P] [US1] Create `ColorPalette` component in `apps/web/src/components/color-palette.tsx` — renders a grid of color swatches from `VALID_PLAYER_COLORS`, highlights selected color with a ring/border, accepts `value` and `onChange` props.
|
||||
- [x] T014 [P] [US1] Create `IconGrid` component in `apps/web/src/components/icon-grid.tsx` — renders a grid of Lucide icons from `VALID_PLAYER_ICONS`, highlights selected icon, accepts `value` and `onChange` props. Map icon identifiers to Lucide components.
|
||||
- [x] T015 [US1] Create `CreatePlayerModal` component in `apps/web/src/components/create-player-modal.tsx` — modal with Name (text), AC (number), Max HP (number) fields, `ColorPalette`, `IconGrid`, Save and Cancel buttons. Name validation error display. Truncate/ellipsize long names in preview. Props: `open`, `onClose`, `onSave(name, ac, maxHp, color, icon)`.
|
||||
- [x] T016 [US1] Add "Create Player" icon button to the bottom bar in `apps/web/src/components/action-bar.tsx` (e.g., `Users` Lucide icon). Wire it to open the `CreatePlayerModal`.
|
||||
- [x] T017 [US1] Wire `usePlayerCharacters` hook in `apps/web/src/App.tsx` — call hook at app level, pass `createCharacter` to the `CreatePlayerModal` via `ActionBar`.
|
||||
|
||||
**Checkpoint**: Users can create player characters with all attributes, and they persist across page reloads. This is the MVP.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Search & Add to Encounter (Priority: P1)
|
||||
|
||||
**Goal**: Player characters appear in the combatant search dropdown and can be added to an encounter with stats pre-filled.
|
||||
|
||||
**Independent Test**: Create a player character, type its name in the search field, select it, verify combatant is added with correct stats, color, and icon.
|
||||
|
||||
**Maps to**: Spec story PC-3
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T018 [US2] Add player character search to `ActionBar` in `apps/web/src/components/action-bar.tsx` — accept a `playerCharacters` prop (or search function), filter by name substring, render a "Players" group above bestiary results in the dropdown. Hide section when no matches.
|
||||
- [x] T019 [US2] Implement `addFromPlayerCharacter` callback in `apps/web/src/hooks/use-encounter.ts` — accepts a `PlayerCharacter`, creates a combatant with name, ac, maxHp, currentHp=maxHp, color, icon, and playerCharacterId. Use `resolveCreatureName` for name conflict resolution (same pattern as `addFromBestiary`).
|
||||
- [x] T020 [US2] Wire search and add in `apps/web/src/App.tsx` — pass `playerCharacters` list and `addFromPlayerCharacter` handler to `ActionBar`. On player character selection, call `addFromPlayerCharacter`.
|
||||
|
||||
**Checkpoint**: Player characters are searchable and addable to encounters with pre-filled stats.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Visual Distinction in Combatant Row (Priority: P2)
|
||||
|
||||
**Goal**: Combatants from player characters display their color and icon in the initiative tracker.
|
||||
|
||||
**Independent Test**: Add a player character to an encounter, verify the combatant row shows the chosen icon and color accent.
|
||||
|
||||
**Maps to**: Spec story PC-4
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T021 [US3] Update `CombatantRow` in `apps/web/src/components/combatant-row.tsx` — if combatant has `color` and `icon` fields, render the Lucide icon (small, tinted with color) to the left of the name. Apply a subtle color accent (e.g., left border or background tint). Ensure long names truncate with ellipsis. Map icon string identifiers to Lucide components (reuse mapping from `IconGrid`).
|
||||
|
||||
**Checkpoint**: Player character combatants are visually distinct from bestiary/custom combatants.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Management View (Priority: P2)
|
||||
|
||||
**Goal**: Users can view, edit, and delete saved player characters from a dedicated management panel.
|
||||
|
||||
**Independent Test**: Open management view, verify all characters listed, edit one's name and AC, delete another, verify changes persisted.
|
||||
|
||||
**Maps to**: Spec stories PC-5 (View), PC-6 (Edit), PC-7 (Delete)
|
||||
|
||||
### Domain
|
||||
|
||||
- [x] T022 [P] [US4] Implement `editPlayerCharacter` pure function in `packages/domain/src/edit-player-character.ts` — accepts `PlayerCharacter[]`, id, and partial update fields; returns updated list + `PlayerCharacterUpdated` event or `DomainError`. Validate changed fields same as create. Return `DomainError` if no fields actually change (no-op guard).
|
||||
- [x] T023 [P] [US4] Implement `deletePlayerCharacter` pure function in `packages/domain/src/delete-player-character.ts` — accepts `PlayerCharacter[]` and id; returns updated list + `PlayerCharacterDeleted` event or `DomainError`. Error if id not found.
|
||||
- [x] T024 [P] [US4] Write unit tests for `editPlayerCharacter` in `packages/domain/src/__tests__/edit-player-character.test.ts` — cover: valid edit, not-found, invalid fields, no-op edit (no fields changed), event emission.
|
||||
- [x] T025 [P] [US4] Write unit tests for `deletePlayerCharacter` in `packages/domain/src/__tests__/delete-player-character.test.ts` — cover: valid delete, not-found, event emission.
|
||||
|
||||
### Application
|
||||
|
||||
- [x] T026 [P] [US4] Implement `editPlayerCharacterUseCase` in `packages/application/src/edit-player-character-use-case.ts`
|
||||
- [x] T027 [P] [US4] Implement `deletePlayerCharacterUseCase` in `packages/application/src/delete-player-character-use-case.ts`
|
||||
|
||||
### React Hook
|
||||
|
||||
- [x] T028 [US4] Extend `usePlayerCharacters` hook in `apps/web/src/hooks/use-player-characters.ts` — add `editCharacter(id, updates)` and `deleteCharacter(id)` methods using the new use cases.
|
||||
|
||||
### UI Components
|
||||
|
||||
- [x] T029 [US4] Extend `CreatePlayerModal` in `apps/web/src/components/create-player-modal.tsx` to support edit mode — accept optional `playerCharacter` prop to pre-fill fields, change title to "Edit Player", save calls `editCharacter` instead of `createCharacter`.
|
||||
- [x] T030 [US4] Create `PlayerManagement` component in `apps/web/src/components/player-management.tsx` — modal/panel listing all player characters (name, AC, max HP, color icon). Truncate/ellipsize long names. Each row has an edit button (opens modal in edit mode) and a delete button (`ConfirmButton` pattern from spec 001). Empty state with "Create your first player character" CTA.
|
||||
- [x] T031 [US4] Add management view trigger to the UI — icon button in bottom bar or within `ActionBar` that opens `PlayerManagement`. Wire in `apps/web/src/App.tsx` with `editCharacter` and `deleteCharacter` handlers.
|
||||
|
||||
**Checkpoint**: Full CRUD for player characters — create, view, edit, delete all working and persisted.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final integration validation and cleanup.
|
||||
|
||||
- [x] T032 Extract shared icon identifier → Lucide component mapping into a utility (used by `IconGrid`, `CombatantRow`, and `PlayerManagement`) to avoid duplication, e.g., `apps/web/src/components/player-icon-map.ts`
|
||||
- [x] T033 Run `pnpm check` (audit + knip + biome + typecheck + test/coverage + jscpd) and fix any issues
|
||||
- [x] T034 Update `CLAUDE.md` to add `specs/005-player-characters/` to the current feature specs list and document the `PlayerCharacterStore` port
|
||||
- [x] T035 Update `README.md` if it documents user-facing features (per constitution: features that alter what the product does must be reflected)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Foundational (Phase 2)**: No dependencies — can start immediately
|
||||
- **US1 Create & Persist (Phase 3)**: Depends on Phase 2 completion
|
||||
- **US2 Search & Add (Phase 4)**: Depends on Phase 3 (needs characters to exist and hook to be wired)
|
||||
- **US3 Visual Distinction (Phase 5)**: Depends on Phase 4 (needs combatants with color/icon to exist)
|
||||
- **US4 Management (Phase 6)**: Depends on Phase 3 (needs create flow and hook, but NOT on US2/US3)
|
||||
- **Polish (Phase 7)**: Depends on all story phases complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (Create & Persist)**: Depends only on Foundational — can start immediately after Phase 2
|
||||
- **US2 (Search & Add)**: Depends on US1 (needs `usePlayerCharacters` hook and characters to search)
|
||||
- **US3 (Visual Distinction)**: Depends on US2 (needs combatants with color/icon fields populated)
|
||||
- **US4 (Management)**: Depends on US1 only (needs hook and create flow). Can run in parallel with US2/US3.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Domain functions before application use cases
|
||||
- Application use cases before React hooks
|
||||
- React hooks before UI components
|
||||
- Tests can run in parallel with their domain functions (written to same-phase files)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
Within Phase 2: T001, T002, T003, T004 can all run in parallel (different files)
|
||||
Within US1: T007+T008 parallel with T010+T011, parallel with T013+T014
|
||||
Within US4: T022-T027 can all run in parallel (different domain/application files)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 2 (Foundational)
|
||||
|
||||
```
|
||||
Parallel group 1:
|
||||
T001: PlayerCharacter types in packages/domain/src/player-character-types.ts
|
||||
T002: Combatant extension in packages/domain/src/types.ts
|
||||
T003: Domain events in packages/domain/src/events.ts
|
||||
T004: Port interface in packages/application/src/ports.ts
|
||||
|
||||
Sequential after group 1:
|
||||
T005: Re-export from index files (depends on T001-T004)
|
||||
T006: Encounter storage rehydration (depends on T002)
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```
|
||||
Parallel group 1 (after Phase 2):
|
||||
T007+T008: Domain function + tests in packages/domain/src/
|
||||
T010+T011: Storage adapter + tests in apps/web/src/persistence/
|
||||
T013: ColorPalette component
|
||||
T014: IconGrid component
|
||||
|
||||
Sequential after group 1:
|
||||
T009: Application use case (depends on T007)
|
||||
T012: React hook (depends on T009, T010)
|
||||
T015: CreatePlayerModal (depends on T013, T014)
|
||||
T016: ActionBar button (depends on T015)
|
||||
T017: App.tsx wiring (depends on T012, T016)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 2: Foundational types and ports
|
||||
2. Complete Phase 3: US1 — Create + Persist
|
||||
3. **STOP and VALIDATE**: Create a player character, reload page, verify persistence
|
||||
4. This alone delivers value — users can save their party for reuse
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 2 → Foundation ready
|
||||
2. US1 → Create & persist player characters (MVP!)
|
||||
3. US2 → Search and add to encounters (core value unlocked)
|
||||
4. US3 → Visual distinction in rows (UX polish)
|
||||
5. US4 → Edit and delete (full CRUD) — can happen in parallel with US2/US3
|
||||
6. Phase 7 → Polish and validation
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Commit after each phase or logical group of tasks
|
||||
- Run `pnpm check` at each checkpoint to catch regressions early
|
||||
- The icon identifier → Lucide component mapping will be needed in 3 places (T014, T021, T030) — T032 extracts it to avoid duplication, but initial implementations can inline it
|
||||
Reference in New Issue
Block a user