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:
36
packages/application/src/create-player-character-use-case.ts
Normal file
36
packages/application/src/create-player-character-use-case.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
createPlayerCharacter,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
export function createPlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = createPlayerCharacter(
|
||||
characters,
|
||||
id,
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
23
packages/application/src/delete-player-character-use-case.ts
Normal file
23
packages/application/src/delete-player-character-use-case.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
deletePlayerCharacter,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
export function deletePlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = deletePlayerCharacter(characters, id);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
32
packages/application/src/edit-player-character-use-case.ts
Normal file
32
packages/application/src/edit-player-character-use-case.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
editPlayerCharacter,
|
||||
isDomainError,
|
||||
type PlayerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import type { PlayerCharacterStore } from "./ports.js";
|
||||
|
||||
interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
export function editPlayerCharacterUseCase(
|
||||
store: PlayerCharacterStore,
|
||||
id: PlayerCharacterId,
|
||||
fields: EditFields,
|
||||
): DomainEvent[] | DomainError {
|
||||
const characters = store.getAll();
|
||||
const result = editPlayerCharacter(characters, id, fields);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save([...result.characters]);
|
||||
return result.events;
|
||||
}
|
||||
@@ -2,8 +2,15 @@ export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
|
||||
export { deletePlayerCharacterUseCase } from "./delete-player-character-use-case.js";
|
||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||
export type { BestiarySourceCache, EncounterStore } from "./ports.js";
|
||||
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
|
||||
export type {
|
||||
BestiarySourceCache,
|
||||
EncounterStore,
|
||||
PlayerCharacterStore,
|
||||
} from "./ports.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Creature, CreatureId, Encounter } from "@initiative/domain";
|
||||
import type {
|
||||
Creature,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
|
||||
export interface EncounterStore {
|
||||
get(): Encounter;
|
||||
@@ -9,3 +14,8 @@ export interface BestiarySourceCache {
|
||||
getCreature(creatureId: CreatureId): Creature | undefined;
|
||||
isSourceCached(sourceCode: string): boolean;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterStore {
|
||||
getAll(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
117
packages/domain/src/__tests__/edit-player-character.test.ts
Normal file
117
packages/domain/src/__tests__/edit-player-character.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
87
packages/domain/src/create-player-character.ts
Normal file
87
packages/domain/src/create-player-character.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface CreatePlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
export function createPlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string,
|
||||
icon: string,
|
||||
): CreatePlayerCharacterSuccess | DomainError {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (trimmed === "") {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-name",
|
||||
message: "Player character name must not be empty",
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(ac) || ac < 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: "AC must be a non-negative integer",
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(maxHp) || maxHp < 1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: "Max HP must be a positive integer",
|
||||
};
|
||||
}
|
||||
|
||||
if (!VALID_PLAYER_COLORS.has(color)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-color",
|
||||
message: `Invalid color: ${color}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!VALID_PLAYER_ICONS.has(icon)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-icon",
|
||||
message: `Invalid icon: ${icon}`,
|
||||
};
|
||||
}
|
||||
|
||||
const newCharacter: PlayerCharacter = {
|
||||
id,
|
||||
name: trimmed,
|
||||
ac,
|
||||
maxHp,
|
||||
color: color as PlayerCharacter["color"],
|
||||
icon: icon as PlayerCharacter["icon"],
|
||||
};
|
||||
|
||||
return {
|
||||
characters: [...characters, newCharacter],
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterCreated",
|
||||
playerCharacterId: id,
|
||||
name: trimmed,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
39
packages/domain/src/delete-player-character.ts
Normal file
39
packages/domain/src/delete-player-character.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface DeletePlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
export function deletePlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
): DeletePlayerCharacterSuccess | DomainError {
|
||||
const index = characters.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "player-character-not-found",
|
||||
message: `Player character not found: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const removed = characters[index];
|
||||
const newList = characters.filter((_, i) => i !== index);
|
||||
|
||||
return {
|
||||
characters: newList,
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterDeleted",
|
||||
playerCharacterId: id,
|
||||
name: removed.name,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
137
packages/domain/src/edit-player-character.ts
Normal file
137
packages/domain/src/edit-player-character.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type {
|
||||
PlayerCharacter,
|
||||
PlayerCharacterId,
|
||||
} from "./player-character-types.js";
|
||||
import {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
import type { DomainError } from "./types.js";
|
||||
|
||||
export interface EditPlayerCharacterSuccess {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
interface EditFields {
|
||||
readonly name?: string;
|
||||
readonly ac?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
function validateFields(fields: EditFields): DomainError | null {
|
||||
if (fields.name !== undefined && fields.name.trim() === "") {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-name",
|
||||
message: "Player character name must not be empty",
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.ac !== undefined &&
|
||||
(!Number.isInteger(fields.ac) || fields.ac < 0)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-ac",
|
||||
message: "AC must be a non-negative integer",
|
||||
};
|
||||
}
|
||||
if (
|
||||
fields.maxHp !== undefined &&
|
||||
(!Number.isInteger(fields.maxHp) || fields.maxHp < 1)
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-max-hp",
|
||||
message: "Max HP must be a positive integer",
|
||||
};
|
||||
}
|
||||
if (fields.color !== undefined && !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)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-icon",
|
||||
message: `Invalid icon: ${fields.icon}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyFields(
|
||||
existing: PlayerCharacter,
|
||||
fields: EditFields,
|
||||
): PlayerCharacter {
|
||||
return {
|
||||
id: existing.id,
|
||||
name: fields.name !== undefined ? fields.name.trim() : existing.name,
|
||||
ac: fields.ac !== undefined ? fields.ac : existing.ac,
|
||||
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
||||
color:
|
||||
fields.color !== undefined
|
||||
? (fields.color as PlayerCharacter["color"])
|
||||
: existing.color,
|
||||
icon:
|
||||
fields.icon !== undefined
|
||||
? (fields.icon as PlayerCharacter["icon"])
|
||||
: existing.icon,
|
||||
};
|
||||
}
|
||||
|
||||
export function editPlayerCharacter(
|
||||
characters: readonly PlayerCharacter[],
|
||||
id: PlayerCharacterId,
|
||||
fields: EditFields,
|
||||
): EditPlayerCharacterSuccess | DomainError {
|
||||
const index = characters.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "player-character-not-found",
|
||||
message: `Player character not found: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const validationError = validateFields(fields);
|
||||
if (validationError) return validationError;
|
||||
|
||||
const existing = characters[index];
|
||||
const updated = applyFields(existing, fields);
|
||||
|
||||
if (
|
||||
updated.name === existing.name &&
|
||||
updated.ac === existing.ac &&
|
||||
updated.maxHp === existing.maxHp &&
|
||||
updated.color === existing.color &&
|
||||
updated.icon === existing.icon
|
||||
) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "no-changes",
|
||||
message: "No fields changed",
|
||||
};
|
||||
}
|
||||
|
||||
const newList = characters.map((c, i) => (i === index ? updated : c));
|
||||
|
||||
return {
|
||||
characters: newList,
|
||||
events: [
|
||||
{
|
||||
type: "PlayerCharacterUpdated",
|
||||
playerCharacterId: id,
|
||||
oldName: existing.name,
|
||||
newName: updated.name,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type { CombatantId } from "./types.js";
|
||||
|
||||
export interface TurnAdvanced {
|
||||
@@ -103,6 +104,25 @@ export interface EncounterCleared {
|
||||
readonly combatantCount: number;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterCreated {
|
||||
readonly type: "PlayerCharacterCreated";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterUpdated {
|
||||
readonly type: "PlayerCharacterUpdated";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly oldName: string;
|
||||
readonly newName: string;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterDeleted {
|
||||
readonly type: "PlayerCharacterDeleted";
|
||||
readonly playerCharacterId: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export type DomainEvent =
|
||||
| TurnAdvanced
|
||||
| RoundAdvanced
|
||||
@@ -119,4 +139,7 @@ export type DomainEvent =
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
| ConcentrationEnded
|
||||
| EncounterCleared;
|
||||
| EncounterCleared
|
||||
| PlayerCharacterCreated
|
||||
| PlayerCharacterUpdated
|
||||
| PlayerCharacterDeleted;
|
||||
|
||||
@@ -12,6 +12,10 @@ export {
|
||||
type ConditionId,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
type CreatePlayerCharacterSuccess,
|
||||
createPlayerCharacter,
|
||||
} from "./create-player-character.js";
|
||||
export {
|
||||
type BestiaryIndex,
|
||||
type BestiaryIndexEntry,
|
||||
@@ -25,10 +29,18 @@ export {
|
||||
type SpellcastingBlock,
|
||||
type TraitBlock,
|
||||
} from "./creature-types.js";
|
||||
export {
|
||||
type DeletePlayerCharacterSuccess,
|
||||
deletePlayerCharacter,
|
||||
} from "./delete-player-character.js";
|
||||
export {
|
||||
type EditCombatantSuccess,
|
||||
editCombatant,
|
||||
} from "./edit-combatant.js";
|
||||
export {
|
||||
type EditPlayerCharacterSuccess,
|
||||
editPlayerCharacter,
|
||||
} from "./edit-player-character.js";
|
||||
export type {
|
||||
AcSet,
|
||||
CombatantAdded,
|
||||
@@ -43,6 +55,9 @@ export type {
|
||||
EncounterCleared,
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
PlayerCharacterCreated,
|
||||
PlayerCharacterDeleted,
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
TurnAdvanced,
|
||||
@@ -54,6 +69,16 @@ export {
|
||||
formatInitiativeModifier,
|
||||
type InitiativeResult,
|
||||
} from "./initiative.js";
|
||||
export {
|
||||
type PlayerCharacter,
|
||||
type PlayerCharacterId,
|
||||
type PlayerCharacterList,
|
||||
type PlayerColor,
|
||||
type PlayerIcon,
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
export {
|
||||
type RemoveCombatantSuccess,
|
||||
removeCombatant,
|
||||
|
||||
81
packages/domain/src/player-character-types.ts
Normal file
81
packages/domain/src/player-character-types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/** Branded string type for player character identity. */
|
||||
export type PlayerCharacterId = string & {
|
||||
readonly __brand: "PlayerCharacterId";
|
||||
};
|
||||
|
||||
export function playerCharacterId(id: string): PlayerCharacterId {
|
||||
return id as PlayerCharacterId;
|
||||
}
|
||||
|
||||
export type PlayerColor =
|
||||
| "red"
|
||||
| "blue"
|
||||
| "green"
|
||||
| "purple"
|
||||
| "orange"
|
||||
| "pink"
|
||||
| "cyan"
|
||||
| "yellow"
|
||||
| "emerald"
|
||||
| "indigo";
|
||||
|
||||
export const VALID_PLAYER_COLORS: ReadonlySet<string> = new Set<PlayerColor>([
|
||||
"red",
|
||||
"blue",
|
||||
"green",
|
||||
"purple",
|
||||
"orange",
|
||||
"pink",
|
||||
"cyan",
|
||||
"yellow",
|
||||
"emerald",
|
||||
"indigo",
|
||||
]);
|
||||
|
||||
export type PlayerIcon =
|
||||
| "sword"
|
||||
| "shield"
|
||||
| "skull"
|
||||
| "heart"
|
||||
| "wand"
|
||||
| "flame"
|
||||
| "crown"
|
||||
| "star"
|
||||
| "moon"
|
||||
| "sun"
|
||||
| "axe"
|
||||
| "crosshair"
|
||||
| "eye"
|
||||
| "feather"
|
||||
| "zap";
|
||||
|
||||
export const VALID_PLAYER_ICONS: ReadonlySet<string> = new Set<PlayerIcon>([
|
||||
"sword",
|
||||
"shield",
|
||||
"skull",
|
||||
"heart",
|
||||
"wand",
|
||||
"flame",
|
||||
"crown",
|
||||
"star",
|
||||
"moon",
|
||||
"sun",
|
||||
"axe",
|
||||
"crosshair",
|
||||
"eye",
|
||||
"feather",
|
||||
"zap",
|
||||
]);
|
||||
|
||||
export interface PlayerCharacter {
|
||||
readonly id: PlayerCharacterId;
|
||||
readonly name: string;
|
||||
readonly ac: number;
|
||||
readonly maxHp: number;
|
||||
readonly color: PlayerColor;
|
||||
readonly icon: PlayerIcon;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterList {
|
||||
readonly characters: readonly PlayerCharacter[];
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
|
||||
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
|
||||
export interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
@@ -18,6 +19,9 @@ export interface Combatant {
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
}
|
||||
|
||||
export interface Encounter {
|
||||
|
||||
Reference in New Issue
Block a user