Add player character management feature
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s

Persistent player character templates (name, AC, HP, color, icon) with
full CRUD, bestiary-style search to add PCs to encounters with pre-filled
stats, and color/icon visual distinction in combatant rows. Also stops
the stat block panel from auto-opening when adding a creature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-12 18:11:08 +01:00
parent 768e7a390f
commit 91703ddebc
38 changed files with 3055 additions and 96 deletions

View 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");
});
});

View File

@@ -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");
});
});

View 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");
});
});