Add player character management feature
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:
227
packages/domain/src/__tests__/create-player-character.test.ts
Normal file
227
packages/domain/src/__tests__/create-player-character.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user