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,
|
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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user