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>
This commit is contained in:
Lukas
2026-03-13 18:01:20 +01:00
parent 07cdd4867a
commit b7406c4b54
15 changed files with 121 additions and 45 deletions

View File

@@ -431,8 +431,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

@@ -95,9 +95,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

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

@@ -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

@@ -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 {