8 Commits

Author SHA1 Message Date
Lukas
63e233bd8d Switch side panel to stat block when advancing turns
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s
Previously the import/sources view would stay open when navigating to
the next combatant. Now advancing turns clears those modes so the active
creature's stat block is shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:03:11 +01:00
Lukas
8c62ec28f2 Rename "Bulk Import" to "Import All Sources" and remove file size mention
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:01:03 +01:00
Lukas
72195e90f6 Show toast when roll-all-initiative skips combatants without loaded sources
Previously the button silently did nothing for creatures whose bestiary
source wasn't loaded. Now it reports how many were skipped and why. Also
keeps the roll-all button visible (but disabled) when there's nothing
left to roll, and moves toasts to the bottom-left corner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:58:25 +01:00
Lukas
6ac8e67970 Move source manager from combatant area to side panel
Source management now opens in the right side panel (like bulk import
and stat blocks) instead of rendering inline above the combatant list.
All three panel modes properly clear each other on activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:40:28 +01:00
Lukas
a4797d5b15 Unfold side panel when triggering bulk import from overflow menu
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:33:05 +01:00
Lukas
d48e39ced4 Fix input not clearing after adding player character from suggestions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:30:24 +01:00
Lukas
b7406c4b54 Make player character color and icon optional
Clicking an already-selected color or icon in the create/edit form now
deselects it. PCs without a color use the default combatant styling;
PCs without an icon show no icon. Domain, application, persistence,
and display layers all updated to handle the optional fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:01:20 +01:00
Lukas
07cdd4867a Fix custom combatant form disappearing when dismissing suggestions
All checks were successful
CI / check (push) Successful in 46s
CI / build-image (push) Successful in 18s
The "Add as custom" button and Escape key were clearing the name input
along with the suggestions, preventing the custom fields (Init, AC,
MaxHP) from ever appearing. Now only the suggestions are dismissed,
keeping the typed name intact so the custom combatant form renders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:30:17 +01:00
20 changed files with 219 additions and 74 deletions

View File

@@ -2,7 +2,12 @@ import {
rollAllInitiativeUseCase, rollAllInitiativeUseCase,
rollInitiativeUseCase, rollInitiativeUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import {
type CombatantId,
type Creature,
type CreatureId,
isDomainError,
} from "@initiative/domain";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -14,7 +19,6 @@ import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row"; import { CombatantRow } from "./components/combatant-row";
import { CreatePlayerModal } from "./components/create-player-modal"; import { CreatePlayerModal } from "./components/create-player-modal";
import { PlayerManagement } from "./components/player-management"; import { PlayerManagement } from "./components/player-management";
import { SourceManager } from "./components/source-manager";
import { StatBlockPanel } from "./components/stat-block-panel"; import { StatBlockPanel } from "./components/stat-block-panel";
import { Toast } from "./components/toast"; import { Toast } from "./components/toast";
import { TurnNavigation } from "./components/turn-navigation"; import { TurnNavigation } from "./components/turn-navigation";
@@ -110,10 +114,12 @@ export function App() {
const bulkImport = useBulkImport(); const bulkImport = useBulkImport();
const [rollSkippedCount, setRollSkippedCount] = useState(0);
const [selectedCreatureId, setSelectedCreatureId] = const [selectedCreatureId, setSelectedCreatureId] =
useState<CreatureId | null>(null); useState<CreatureId | null>(null);
const [bulkImportMode, setBulkImportMode] = useState(false); const [bulkImportMode, setBulkImportMode] = useState(false);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false); const [sourceManagerMode, setSourceManagerMode] = useState(false);
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false); const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>( const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
null, null,
@@ -146,6 +152,8 @@ export function App() {
const handleCombatantStatBlock = useCallback((creatureId: string) => { const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreatureId(creatureId as CreatureId); setSelectedCreatureId(creatureId as CreatureId);
setBulkImportMode(false);
setSourceManagerMode(false);
setIsRightPanelFolded(false); setIsRightPanelFolded(false);
}, []); }, []);
@@ -157,7 +165,10 @@ export function App() {
); );
const handleRollAllInitiative = useCallback(() => { const handleRollAllInitiative = useCallback(() => {
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
if (!isDomainError(result) && result.skippedNoSource > 0) {
setRollSkippedCount(result.skippedNoSource);
}
}, [makeStore, getCreature]); }, [makeStore, getCreature]);
const handleViewStatBlock = useCallback((result: SearchResult) => { const handleViewStatBlock = useCallback((result: SearchResult) => {
@@ -167,12 +178,23 @@ export function App() {
.replace(/(^-|-$)/g, ""); .replace(/(^-|-$)/g, "");
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
setSelectedCreatureId(cId); setSelectedCreatureId(cId);
setBulkImportMode(false);
setSourceManagerMode(false);
setIsRightPanelFolded(false); setIsRightPanelFolded(false);
}, []); }, []);
const handleBulkImport = useCallback(() => { const handleBulkImport = useCallback(() => {
setBulkImportMode(true); setBulkImportMode(true);
setSourceManagerMode(false);
setSelectedCreatureId(null); setSelectedCreatureId(null);
setIsRightPanelFolded(false);
}, []);
const handleOpenSourceManager = useCallback(() => {
setSourceManagerMode(true);
setBulkImportMode(false);
setSelectedCreatureId(null);
setIsRightPanelFolded(false);
}, []); }, []);
const handleStartBulkImport = useCallback( const handleStartBulkImport = useCallback(
@@ -195,6 +217,7 @@ export function App() {
const handleDismissBrowsePanel = useCallback(() => { const handleDismissBrowsePanel = useCallback(() => {
setSelectedCreatureId(null); setSelectedCreatureId(null);
setBulkImportMode(false); setBulkImportMode(false);
setSourceManagerMode(false);
}, []); }, []);
const handleToggleFold = useCallback(() => { const handleToggleFold = useCallback(() => {
@@ -236,10 +259,15 @@ export function App() {
const active = encounter.combatants[encounter.activeIndex]; const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return; if (!active?.creatureId || !isLoaded) return;
setSelectedCreatureId(active.creatureId as CreatureId); setSelectedCreatureId(active.creatureId as CreatureId);
setBulkImportMode(false);
setSourceManagerMode(false);
}, [encounter.activeIndex, encounter.combatants, isLoaded]); }, [encounter.activeIndex, encounter.combatants, isLoaded]);
const isEmpty = encounter.combatants.length === 0; const isEmpty = encounter.combatants.length === 0;
const showRollAllInitiative = encounter.combatants.some( const hasCreatureCombatants = encounter.combatants.some(
(c) => c.creatureId != null,
);
const canRollAllInitiative = encounter.combatants.some(
(c) => c.creatureId != null && c.initiative == null, (c) => c.creatureId != null && c.initiative == null,
); );
@@ -280,20 +308,15 @@ export function App() {
onAddFromPlayerCharacter={addFromPlayerCharacter} onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)} onManagePlayers={() => setManagementOpen(true)}
onRollAllInitiative={handleRollAllInitiative} onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative} showRollAllInitiative={hasCreatureCombatants}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={handleOpenSourceManager}
autoFocus autoFocus
/> />
</div> </div>
</div> </div>
) : ( ) : (
<> <>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Scrollable area — combatant list */} {/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2"> <div className="flex flex-col px-2 py-2">
@@ -342,8 +365,9 @@ export function App() {
onAddFromPlayerCharacter={addFromPlayerCharacter} onAddFromPlayerCharacter={addFromPlayerCharacter}
onManagePlayers={() => setManagementOpen(true)} onManagePlayers={() => setManagementOpen(true)}
onRollAllInitiative={handleRollAllInitiative} onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={showRollAllInitiative} showRollAllInitiative={hasCreatureCombatants}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={handleOpenSourceManager}
/> />
</div> </div>
</> </>
@@ -390,6 +414,7 @@ export function App() {
bulkImportState={bulkImport.state} bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport} onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone} onBulkImportDone={handleBulkImportDone}
sourceManagerMode={sourceManagerMode}
/> />
{/* Toast for bulk import progress when panel is closed */} {/* Toast for bulk import progress when panel is closed */}
@@ -419,6 +444,14 @@ export function App() {
/> />
)} )}
{rollSkippedCount > 0 && (
<Toast
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
onDismiss={() => setRollSkippedCount(0)}
autoDismissMs={4000}
/>
)}
<CreatePlayerModal <CreatePlayerModal
open={createPlayerOpen} open={createPlayerOpen}
onClose={() => { onClose={() => {
@@ -431,8 +464,8 @@ export function App() {
name, name,
ac, ac,
maxHp, maxHp,
color, color: color ?? null,
icon, icon: icon ?? null,
}); });
} else { } else {
createPlayerCharacter(name, ac, maxHp, color, icon); createPlayerCharacter(name, ac, maxHp, color, icon);

View File

@@ -40,6 +40,7 @@ interface ActionBarProps {
onManagePlayers?: () => void; onManagePlayers?: () => void;
onRollAllInitiative?: () => void; onRollAllInitiative?: () => void;
showRollAllInitiative?: boolean; showRollAllInitiative?: boolean;
rollAllInitiativeDisabled?: boolean;
onOpenSourceManager?: () => void; onOpenSourceManager?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
} }
@@ -60,6 +61,7 @@ function AddModeSuggestions({
onSetQueued, onSetQueued,
onConfirmQueued, onConfirmQueued,
onAddFromPlayerCharacter, onAddFromPlayerCharacter,
onClear,
}: { }: {
nameInput: string; nameInput: string;
suggestions: SearchResult[]; suggestions: SearchResult[];
@@ -67,6 +69,7 @@ function AddModeSuggestions({
suggestionIndex: number; suggestionIndex: number;
queued: QueuedCreature | null; queued: QueuedCreature | null;
onDismiss: () => void; onDismiss: () => void;
onClear: () => void;
onClickSuggestion: (result: SearchResult) => void; onClickSuggestion: (result: SearchResult) => void;
onSetSuggestionIndex: (i: number) => void; onSetSuggestionIndex: (i: number) => void;
onSetQueued: (q: QueuedCreature | null) => void; onSetQueued: (q: QueuedCreature | null) => void;
@@ -95,9 +98,12 @@ function AddModeSuggestions({
</div> </div>
<ul> <ul>
{pcMatches.map((pc) => { {pcMatches.map((pc) => {
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon]; const PcIcon = pc.icon
const pcColor = ? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]; : undefined;
const pcColor = pc.color
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
: undefined;
return ( return (
<li key={pc.id}> <li key={pc.id}>
<button <button
@@ -106,7 +112,7 @@ function AddModeSuggestions({
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
onAddFromPlayerCharacter?.(pc); onAddFromPlayerCharacter?.(pc);
onDismiss(); onClear();
}} }}
> >
{PcIcon && ( {PcIcon && (
@@ -235,7 +241,7 @@ function buildOverflowItems(opts: {
if (opts.bestiaryLoaded && opts.onBulkImport) { if (opts.bestiaryLoaded && opts.onBulkImport) {
items.push({ items.push({
icon: <Import className="h-4 w-4" />, icon: <Import className="h-4 w-4" />,
label: "Bulk Import", label: "Import All Sources",
onClick: opts.onBulkImport, onClick: opts.onBulkImport,
disabled: opts.bulkImportDisabled, disabled: opts.bulkImportDisabled,
}); });
@@ -257,6 +263,7 @@ export function ActionBar({
onManagePlayers, onManagePlayers,
onRollAllInitiative, onRollAllInitiative,
showRollAllInitiative, showRollAllInitiative,
rollAllInitiativeDisabled,
onOpenSourceManager, onOpenSourceManager,
autoFocus, autoFocus,
}: ActionBarProps) { }: ActionBarProps) {
@@ -284,6 +291,13 @@ export function ActionBar({
setSuggestionIndex(-1); setSuggestionIndex(-1);
}; };
const dismissSuggestions = () => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
};
const confirmQueued = () => { const confirmQueued = () => {
if (!queued) return; if (!queued) return;
for (let i = 0; i < queued.count; i++) { for (let i = 0; i < queued.count; i++) {
@@ -395,7 +409,7 @@ export function ActionBar({
e.preventDefault(); e.preventDefault();
handleEnter(); handleEnter();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
clearInput(); dismissSuggestions();
} }
}; };
@@ -514,7 +528,8 @@ export function ActionBar({
pcMatches={pcMatches} pcMatches={pcMatches}
suggestionIndex={suggestionIndex} suggestionIndex={suggestionIndex}
queued={queued} queued={queued}
onDismiss={clearInput} onDismiss={dismissSuggestions}
onClear={clearInput}
onClickSuggestion={handleClickSuggestion} onClickSuggestion={handleClickSuggestion}
onSetSuggestionIndex={setSuggestionIndex} onSetSuggestionIndex={setSuggestionIndex}
onSetQueued={setQueued} onSetQueued={setQueued}
@@ -562,6 +577,7 @@ export function ActionBar({
variant="ghost" variant="ghost"
className="text-muted-foreground hover:text-hover-action" className="text-muted-foreground hover:text-hover-action"
onClick={onRollAllInitiative} onClick={onRollAllInitiative}
disabled={rollAllInitiativeDisabled}
title="Roll all initiative" title="Roll all initiative"
aria-label="Roll all initiative" aria-label="Roll all initiative"
> >

View File

@@ -75,11 +75,10 @@ export function BulkImportPrompt({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h3 className="text-sm font-semibold text-foreground"> <h3 className="text-sm font-semibold text-foreground">
Bulk Import Sources Import All Sources
</h3> </h3>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Load stat block data for all {totalSources} sources at once. This will Load stat block data for all {totalSources} sources at once.
download approximately 12.5 MB of data.
</p> </p>
</div> </div>

View File

@@ -16,7 +16,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
<button <button
key={color} key={color}
type="button" type="button"
onClick={() => onChange(color)} onClick={() => onChange(value === color ? "" : color)}
className={cn( className={cn(
"h-8 w-8 rounded-full transition-all", "h-8 w-8 rounded-full transition-all",
value === color value === color

View File

@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
name: string, name: string,
ac: number, ac: number,
maxHp: number, maxHp: number,
color: string, color: string | undefined,
icon: string, icon: string | undefined,
) => void; ) => void;
playerCharacter?: PlayerCharacter; playerCharacter?: PlayerCharacter;
} }
@@ -40,14 +40,14 @@ export function CreatePlayerModal({
setName(playerCharacter.name); setName(playerCharacter.name);
setAc(String(playerCharacter.ac)); setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp)); setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color); setColor(playerCharacter.color ?? "");
setIcon(playerCharacter.icon); setIcon(playerCharacter.icon ?? "");
} else { } else {
setName(""); setName("");
setAc("10"); setAc("10");
setMaxHp("10"); setMaxHp("10");
setColor("blue"); setColor("");
setIcon("sword"); setIcon("");
} }
setError(""); setError("");
} }
@@ -81,7 +81,7 @@ export function CreatePlayerModal({
setError("Max HP must be at least 1"); setError("Max HP must be at least 1");
return; return;
} }
onSave(trimmed, acNum, hpNum, color, icon); onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
onClose(); onClose();
}; };

View File

@@ -19,7 +19,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
<button <button
key={iconId} key={iconId}
type="button" type="button"
onClick={() => onChange(iconId)} onClick={() => onChange(value === iconId ? "" : iconId)}
className={cn( className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-all", "flex h-9 w-9 items-center justify-center rounded-md transition-all",
value === iconId value === iconId

View File

@@ -1,8 +1,4 @@
import type { import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
PlayerCharacter,
PlayerCharacterId,
PlayerIcon,
} from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react"; import { Pencil, Plus, Trash2, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
@@ -73,9 +69,8 @@ export function PlayerManagement({
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{characters.map((pc) => { {characters.map((pc) => {
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon]; const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const color = const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
return ( return (
<div <div
key={pc.id} key={pc.id}

View File

@@ -7,6 +7,7 @@ import type { BulkImportState } from "../hooks/use-bulk-import.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js"; import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { BulkImportPrompt } from "./bulk-import-prompt.js"; import { BulkImportPrompt } from "./bulk-import-prompt.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
import { StatBlock } from "./stat-block.js"; import { StatBlock } from "./stat-block.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
@@ -32,6 +33,7 @@ interface StatBlockPanelProps {
bulkImportState?: BulkImportState; bulkImportState?: BulkImportState;
onStartBulkImport?: (baseUrl: string) => void; onStartBulkImport?: (baseUrl: string) => void;
onBulkImportDone?: () => void; onBulkImportDone?: () => void;
sourceManagerMode?: boolean;
} }
function extractSourceCode(cId: CreatureId): string { function extractSourceCode(cId: CreatureId): string {
@@ -236,6 +238,7 @@ export function StatBlockPanel({
bulkImportState, bulkImportState,
onStartBulkImport, onStartBulkImport,
onBulkImportDone, onBulkImportDone,
sourceManagerMode,
}: StatBlockPanelProps) { }: StatBlockPanelProps) {
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches, () => window.matchMedia("(min-width: 1024px)").matches,
@@ -269,7 +272,7 @@ export function StatBlockPanel({
}); });
}, [creatureId, creature, isSourceCached]); }, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode) return null; if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : ""; const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
@@ -279,6 +282,10 @@ export function StatBlockPanel({
}; };
const renderContent = () => { const renderContent = () => {
if (sourceManagerMode) {
return <SourceManager onCacheCleared={refreshCache} />;
}
if ( if (
bulkImportMode && bulkImportMode &&
bulkImportState && bulkImportState &&
@@ -324,7 +331,12 @@ export function StatBlockPanel({
}; };
const creatureName = const creatureName =
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature"); creature?.name ??
(sourceManagerMode
? "Sources"
: bulkImportMode
? "Import All Sources"
: "Creature");
if (isDesktop) { if (isDesktop) {
return ( return (

View File

@@ -23,7 +23,7 @@ export function Toast({
}, [autoDismissMs, onDismiss]); }, [autoDismissMs, onDismiss]);
return createPortal( return createPortal(
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2"> <div className="fixed bottom-4 left-4 z-50">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg"> <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
<span className="text-sm text-foreground">{message}</span> <span className="text-sm text-foreground">{message}</span>
{progress !== undefined && ( {progress !== undefined && (

View File

@@ -26,8 +26,8 @@ interface EditFields {
readonly name?: string; readonly name?: string;
readonly ac?: number; readonly ac?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly color?: string; readonly color?: string | null;
readonly icon?: string; readonly icon?: string | null;
} }
export function usePlayerCharacters() { export function usePlayerCharacters() {
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
}, []); }, []);
const createCharacter = useCallback( const createCharacter = useCallback(
(name: string, ac: number, maxHp: number, color: string, icon: string) => { (
name: string,
ac: number,
maxHp: number,
color: string | undefined,
icon: string | undefined,
) => {
const id = generatePcId(); const id = generatePcId();
const result = createPlayerCharacterUseCase( const result = createPlayerCharacterUseCase(
makeStore(), makeStore(),

View File

@@ -15,6 +15,13 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
} }
} }
function isValidOptionalMember(
value: unknown,
valid: ReadonlySet<string>,
): boolean {
return value === undefined || (typeof value === "string" && valid.has(value));
}
function rehydrateCharacter(raw: unknown): PlayerCharacter | null { function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null; return null;
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
entry.maxHp < 1 entry.maxHp < 1
) )
return null; return null;
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color)) if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
return null; if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
return null;
return { return {
id: playerCharacterId(entry.id), id: playerCharacterId(entry.id),

View File

@@ -13,8 +13,8 @@ export function createPlayerCharacterUseCase(
name: string, name: string,
ac: number, ac: number,
maxHp: number, maxHp: number,
color: string, color: string | undefined,
icon: string, icon: string | undefined,
): DomainEvent[] | DomainError { ): DomainEvent[] | DomainError {
const characters = store.getAll(); const characters = store.getAll();
const result = createPlayerCharacter( const result = createPlayerCharacter(

View File

@@ -11,8 +11,8 @@ interface EditFields {
readonly name?: string; readonly name?: string;
readonly ac?: number; readonly ac?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly color?: string; readonly color?: string | null;
readonly icon?: string; readonly icon?: string | null;
} }
export function editPlayerCharacterUseCase( export function editPlayerCharacterUseCase(

View File

@@ -13,7 +13,10 @@ export type {
} from "./ports.js"; } from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js"; export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { retreatTurnUseCase } from "./retreat-turn-use-case.js"; export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js"; export {
type RollAllResult,
rollAllInitiativeUseCase,
} from "./roll-all-initiative-use-case.js";
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js"; export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js"; export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js"; export { setHpUseCase } from "./set-hp-use-case.js";

View File

@@ -10,20 +10,29 @@ import {
} from "@initiative/domain"; } from "@initiative/domain";
import type { EncounterStore } from "./ports.js"; import type { EncounterStore } from "./ports.js";
export interface RollAllResult {
events: DomainEvent[];
skippedNoSource: number;
}
export function rollAllInitiativeUseCase( export function rollAllInitiativeUseCase(
store: EncounterStore, store: EncounterStore,
rollDice: () => number, rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined, getCreature: (id: CreatureId) => Creature | undefined,
): DomainEvent[] | DomainError { ): RollAllResult | DomainError {
let encounter = store.get(); let encounter = store.get();
const allEvents: DomainEvent[] = []; const allEvents: DomainEvent[] = [];
let skippedNoSource = 0;
for (const combatant of encounter.combatants) { for (const combatant of encounter.combatants) {
if (!combatant.creatureId) continue; if (!combatant.creatureId) continue;
if (combatant.initiative !== undefined) continue; if (combatant.initiative !== undefined) continue;
const creature = getCreature(combatant.creatureId); const creature = getCreature(combatant.creatureId);
if (!creature) continue; if (!creature) {
skippedNoSource++;
continue;
}
const { modifier } = calculateInitiative({ const { modifier } = calculateInitiative({
dexScore: creature.abilities.dex, dexScore: creature.abilities.dex,
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
} }
store.save(encounter); store.save(encounter);
return allEvents; return { events: allEvents, skippedNoSource };
} }

View File

@@ -219,6 +219,49 @@ describe("createPlayerCharacter", () => {
} }
}); });
it("allows undefined color", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
undefined,
"sword",
);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].color).toBeUndefined();
});
it("allows undefined icon", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
undefined,
);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].icon).toBeUndefined();
});
it("allows both color and icon undefined", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
undefined,
undefined,
);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].color).toBeUndefined();
expect(result.characters[0].icon).toBeUndefined();
});
it("emits exactly one event on success", () => { it("emits exactly one event on success", () => {
const { events } = success([], "Test", 10, 50); const { events } = success([], "Test", 10, 50);
expect(events).toHaveLength(1); expect(events).toHaveLength(1);

View File

@@ -106,6 +106,22 @@ describe("editPlayerCharacter", () => {
expect(result.events).toHaveLength(1); expect(result.events).toHaveLength(1);
}); });
it("clears color when set to null", () => {
const result = editPlayerCharacter([makePC({ color: "green" })], id, {
color: null,
});
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].color).toBeUndefined();
});
it("clears icon when set to null", () => {
const result = editPlayerCharacter([makePC({ icon: "sword" })], id, {
icon: null,
});
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].icon).toBeUndefined();
});
it("event includes old and new name", () => { it("event includes old and new name", () => {
const result = editPlayerCharacter([makePC()], id, { name: "Strider" }); const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
if (isDomainError(result)) throw new Error(result.message); if (isDomainError(result)) throw new Error(result.message);

View File

@@ -20,8 +20,8 @@ export function createPlayerCharacter(
name: string, name: string,
ac: number, ac: number,
maxHp: number, maxHp: number,
color: string, color: string | undefined,
icon: string, icon: string | undefined,
): CreatePlayerCharacterSuccess | DomainError { ): CreatePlayerCharacterSuccess | DomainError {
const trimmed = name.trim(); const trimmed = name.trim();
@@ -49,7 +49,7 @@ export function createPlayerCharacter(
}; };
} }
if (!VALID_PLAYER_COLORS.has(color)) { if (color !== undefined && !VALID_PLAYER_COLORS.has(color)) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-color", code: "invalid-color",
@@ -57,7 +57,7 @@ export function createPlayerCharacter(
}; };
} }
if (!VALID_PLAYER_ICONS.has(icon)) { if (icon !== undefined && !VALID_PLAYER_ICONS.has(icon)) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-icon", code: "invalid-icon",

View File

@@ -18,8 +18,8 @@ interface EditFields {
readonly name?: string; readonly name?: string;
readonly ac?: number; readonly ac?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly color?: string; readonly color?: string | null;
readonly icon?: string; readonly icon?: string | null;
} }
function validateFields(fields: EditFields): DomainError | null { function validateFields(fields: EditFields): DomainError | null {
@@ -50,14 +50,22 @@ function validateFields(fields: EditFields): DomainError | null {
message: "Max HP must be a positive integer", message: "Max HP must be a positive integer",
}; };
} }
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) { if (
fields.color !== undefined &&
fields.color !== null &&
!VALID_PLAYER_COLORS.has(fields.color)
) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-color", code: "invalid-color",
message: `Invalid color: ${fields.color}`, message: `Invalid color: ${fields.color}`,
}; };
} }
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) { if (
fields.icon !== undefined &&
fields.icon !== null &&
!VALID_PLAYER_ICONS.has(fields.icon)
) {
return { return {
kind: "domain-error", kind: "domain-error",
code: "invalid-icon", code: "invalid-icon",
@@ -78,11 +86,11 @@ function applyFields(
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp, maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
color: color:
fields.color !== undefined fields.color !== undefined
? (fields.color as PlayerCharacter["color"]) ? ((fields.color as PlayerCharacter["color"]) ?? undefined)
: existing.color, : existing.color,
icon: icon:
fields.icon !== undefined fields.icon !== undefined
? (fields.icon as PlayerCharacter["icon"]) ? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
: existing.icon, : existing.icon,
}; };
} }

View File

@@ -72,8 +72,8 @@ export interface PlayerCharacter {
readonly name: string; readonly name: string;
readonly ac: number; readonly ac: number;
readonly maxHp: number; readonly maxHp: number;
readonly color: PlayerColor; readonly color?: PlayerColor;
readonly icon: PlayerIcon; readonly icon?: PlayerIcon;
} }
export interface PlayerCharacterList { export interface PlayerCharacterList {