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:
@@ -431,8 +431,8 @@ export function App() {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
color: color ?? null,
|
||||
icon: icon ?? null,
|
||||
});
|
||||
} else {
|
||||
createPlayerCharacter(name, ac, maxHp, color, icon);
|
||||
|
||||
@@ -95,9 +95,12 @@ function AddModeSuggestions({
|
||||
</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];
|
||||
const PcIcon = pc.icon
|
||||
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
|
||||
: undefined;
|
||||
const pcColor = pc.color
|
||||
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
|
||||
: undefined;
|
||||
return (
|
||||
<li key={pc.id}>
|
||||
<button
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => onChange(color)}
|
||||
onClick={() => onChange(value === color ? "" : color)}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full transition-all",
|
||||
value === color
|
||||
|
||||
@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
) => void;
|
||||
playerCharacter?: PlayerCharacter;
|
||||
}
|
||||
@@ -40,14 +40,14 @@ export function CreatePlayerModal({
|
||||
setName(playerCharacter.name);
|
||||
setAc(String(playerCharacter.ac));
|
||||
setMaxHp(String(playerCharacter.maxHp));
|
||||
setColor(playerCharacter.color);
|
||||
setIcon(playerCharacter.icon);
|
||||
setColor(playerCharacter.color ?? "");
|
||||
setIcon(playerCharacter.icon ?? "");
|
||||
} else {
|
||||
setName("");
|
||||
setAc("10");
|
||||
setMaxHp("10");
|
||||
setColor("blue");
|
||||
setIcon("sword");
|
||||
setColor("");
|
||||
setIcon("");
|
||||
}
|
||||
setError("");
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export function CreatePlayerModal({
|
||||
setError("Max HP must be at least 1");
|
||||
return;
|
||||
}
|
||||
onSave(trimmed, acNum, hpNum, color, icon);
|
||||
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
|
||||
<button
|
||||
key={iconId}
|
||||
type="button"
|
||||
onClick={() => onChange(iconId)}
|
||||
onClick={() => onChange(value === iconId ? "" : iconId)}
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||
value === iconId
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
PlayerIcon,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacter, PlayerCharacterId } 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";
|
||||
@@ -73,9 +69,8 @@ export function PlayerManagement({
|
||||
) : (
|
||||
<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];
|
||||
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||
return (
|
||||
<div
|
||||
key={pc.id}
|
||||
|
||||
@@ -26,8 +26,8 @@ interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
}
|
||||
|
||||
export function usePlayerCharacters() {
|
||||
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
|
||||
}, []);
|
||||
|
||||
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 result = createPlayerCharacterUseCase(
|
||||
makeStore(),
|
||||
|
||||
@@ -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 {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
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;
|
||||
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||
|
||||
return {
|
||||
id: playerCharacterId(entry.id),
|
||||
|
||||
@@ -13,8 +13,8 @@ export function createPlayerCharacterUseCase(
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = createPlayerCharacter(
|
||||
|
||||
@@ -11,8 +11,8 @@ interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | null;
|
||||
}
|
||||
|
||||
export function editPlayerCharacterUseCase(
|
||||
|
||||
@@ -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", () => {
|
||||
const { events } = success([], "Test", 10, 50);
|
||||
expect(events).toHaveLength(1);
|
||||
|
||||
@@ -106,6 +106,22 @@ describe("editPlayerCharacter", () => {
|
||||
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", () => {
|
||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||
if (isDomainError(result)) throw new Error(result.message);
|
||||
|
||||
@@ -20,8 +20,8 @@ export function createPlayerCharacter(
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
): CreatePlayerCharacterSuccess | DomainError {
|
||||
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 {
|
||||
kind: "domain-error",
|
||||
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 {
|
||||
kind: "domain-error",
|
||||
code: "invalid-icon",
|
||||
|
||||
@@ -18,8 +18,8 @@ interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly color?: string | null;
|
||||
readonly icon?: string | 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",
|
||||
};
|
||||
}
|
||||
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
|
||||
if (
|
||||
fields.color !== undefined &&
|
||||
fields.color !== null &&
|
||||
!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)) {
|
||||
if (
|
||||
fields.icon !== undefined &&
|
||||
fields.icon !== null &&
|
||||
!VALID_PLAYER_ICONS.has(fields.icon)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-icon",
|
||||
@@ -78,11 +86,11 @@ function applyFields(
|
||||
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
||||
color:
|
||||
fields.color !== undefined
|
||||
? (fields.color as PlayerCharacter["color"])
|
||||
? ((fields.color as PlayerCharacter["color"]) ?? undefined)
|
||||
: existing.color,
|
||||
icon:
|
||||
fields.icon !== undefined
|
||||
? (fields.icon as PlayerCharacter["icon"])
|
||||
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
|
||||
: existing.icon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ export interface PlayerCharacter {
|
||||
readonly name: string;
|
||||
readonly ac: number;
|
||||
readonly maxHp: number;
|
||||
readonly color: PlayerColor;
|
||||
readonly icon: PlayerIcon;
|
||||
readonly color?: PlayerColor;
|
||||
readonly icon?: PlayerIcon;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterList {
|
||||
|
||||
Reference in New Issue
Block a user