Atomic addCombatant with optional CombatantInit bag
addCombatant now accepts an optional init parameter for pre-filled stats (HP, AC, initiative, creatureId, color, icon, playerCharacterId), making combatant creation a single atomic operation with domain validation. This eliminates the multi-step store.save() bypass in addFromBestiary and addFromPlayerCharacter, and removes the CombatantOpts/applyCombatantOpts helpers. Also extracts shared initiative sort logic into initiative-sort.ts used by both addCombatant and setInitiative. Closes #15 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
BestiaryIndexEntry,
|
BestiaryIndexEntry,
|
||||||
CombatantId,
|
CombatantId,
|
||||||
|
CombatantInit,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
@@ -61,33 +62,6 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CombatantOpts {
|
|
||||||
initiative?: number;
|
|
||||||
ac?: number;
|
|
||||||
maxHp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCombatantOpts(
|
|
||||||
makeStore: () => EncounterStore,
|
|
||||||
id: ReturnType<typeof combatantId>,
|
|
||||||
opts: CombatantOpts,
|
|
||||||
): DomainEvent[] {
|
|
||||||
const events: DomainEvent[] = [];
|
|
||||||
if (opts.maxHp !== undefined) {
|
|
||||||
const r = setHpUseCase(makeStore(), id, opts.maxHp);
|
|
||||||
if (!isDomainError(r)) events.push(...r);
|
|
||||||
}
|
|
||||||
if (opts.ac !== undefined) {
|
|
||||||
const r = setAcUseCase(makeStore(), id, opts.ac);
|
|
||||||
if (!isDomainError(r)) events.push(...r);
|
|
||||||
}
|
|
||||||
if (opts.initiative !== undefined) {
|
|
||||||
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
|
|
||||||
if (!isDomainError(r)) events.push(...r);
|
|
||||||
}
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||||
@@ -131,21 +105,14 @@ export function useEncounter() {
|
|||||||
const nextId = useRef(deriveNextId(encounter));
|
const nextId = useRef(deriveNextId(encounter));
|
||||||
|
|
||||||
const addCombatant = useCallback(
|
const addCombatant = useCallback(
|
||||||
(name: string, opts?: CombatantOpts) => {
|
(name: string, init?: CombatantInit) => {
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const result = addCombatantUseCase(makeStore(), id, name);
|
const result = addCombatantUseCase(makeStore(), id, name, init);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts) {
|
|
||||||
const optEvents = applyCombatantOpts(makeStore, id, opts);
|
|
||||||
if (optEvents.length > 0) {
|
|
||||||
setEvents((prev) => [...prev, ...optEvents]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
@@ -288,7 +255,6 @@ export function useEncounter() {
|
|||||||
existingNames,
|
existingNames,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply renames (e.g., "Goblin" → "Goblin 1")
|
|
||||||
for (const { from, to } of renames) {
|
for (const { from, to } of renames) {
|
||||||
const target = store.get().combatants.find((c) => c.name === from);
|
const target = store.get().combatants.find((c) => c.name === from);
|
||||||
if (target) {
|
if (target) {
|
||||||
@@ -296,43 +262,22 @@ export function useEncounter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add combatant with resolved name
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
|
||||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
|
||||||
if (isDomainError(addResult)) return null;
|
|
||||||
|
|
||||||
// Set HP
|
|
||||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
|
||||||
if (!isDomainError(hpResult)) {
|
|
||||||
setEvents((prev) => [...prev, ...hpResult]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set AC
|
|
||||||
if (entry.ac > 0) {
|
|
||||||
const acResult = setAcUseCase(makeStore(), id, entry.ac);
|
|
||||||
if (!isDomainError(acResult)) {
|
|
||||||
setEvents((prev) => [...prev, ...acResult]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive creatureId from source + name
|
|
||||||
const slug = entry.name
|
const slug = entry.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
.replaceAll(/(^-|-$)/g, "");
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||||
|
|
||||||
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const currentEncounter = store.get();
|
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||||
store.save({
|
maxHp: entry.hp,
|
||||||
...currentEncounter,
|
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||||
combatants: currentEncounter.combatants.map((c) =>
|
creatureId: cId,
|
||||||
c.id === id ? { ...c, creatureId: cId } : c,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
if (isDomainError(result)) return null;
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
return cId;
|
return cId;
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
@@ -352,40 +297,17 @@ export function useEncounter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||||
if (isDomainError(addResult)) return;
|
maxHp: pc.maxHp,
|
||||||
|
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||||
// Set HP
|
color: pc.color,
|
||||||
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
|
icon: pc.icon,
|
||||||
if (!isDomainError(hpResult)) {
|
playerCharacterId: pc.id,
|
||||||
setEvents((prev) => [...prev, ...hpResult]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set AC
|
|
||||||
if (pc.ac > 0) {
|
|
||||||
const acResult = setAcUseCase(makeStore(), id, pc.ac);
|
|
||||||
if (!isDomainError(acResult)) {
|
|
||||||
setEvents((prev) => [...prev, ...acResult]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set color, icon, and playerCharacterId on the combatant
|
|
||||||
const currentEncounter = store.get();
|
|
||||||
store.save({
|
|
||||||
...currentEncounter,
|
|
||||||
combatants: currentEncounter.combatants.map((c) =>
|
|
||||||
c.id === id
|
|
||||||
? {
|
|
||||||
...c,
|
|
||||||
color: pc.color,
|
|
||||||
icon: pc.icon,
|
|
||||||
playerCharacterId: pc.id,
|
|
||||||
}
|
|
||||||
: c,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
if (isDomainError(result)) return;
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
addCombatant,
|
addCombatant,
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
|
type CombatantInit,
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
@@ -11,9 +12,10 @@ export function addCombatantUseCase(
|
|||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
name: string,
|
name: string,
|
||||||
|
init?: CombatantInit,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
const encounter = store.get();
|
||||||
const result = addCombatant(encounter, id, name);
|
const result = addCombatant(encounter, id, name, init);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { addCombatant } from "../add-combatant.js";
|
import { addCombatant, type CombatantInit } from "../add-combatant.js";
|
||||||
|
import { creatureId } from "../creature-types.js";
|
||||||
|
import { playerCharacterId } from "../player-character-types.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
import { expectDomainError } from "./test-helpers.js";
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
function makeCombatant(name: string): Combatant {
|
function makeCombatant(
|
||||||
return { id: combatantId(name), name };
|
name: string,
|
||||||
|
overrides?: Partial<Combatant>,
|
||||||
|
): Combatant {
|
||||||
|
return { id: combatantId(name), name, ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
const A = makeCombatant("A");
|
const A = makeCombatant("A");
|
||||||
@@ -22,8 +27,13 @@ function enc(
|
|||||||
return { combatants, activeIndex, roundNumber };
|
return { combatants, activeIndex, roundNumber };
|
||||||
}
|
}
|
||||||
|
|
||||||
function successResult(encounter: Encounter, id: string, name: string) {
|
function successResult(
|
||||||
const result = addCombatant(encounter, combatantId(id), name);
|
encounter: Encounter,
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
init?: CombatantInit,
|
||||||
|
) {
|
||||||
|
const result = addCombatant(encounter, combatantId(id), name, init);
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
throw new Error(`Expected success, got error: ${result.message}`);
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
}
|
}
|
||||||
@@ -190,4 +200,152 @@ describe("addCombatant", () => {
|
|||||||
expect(encounter.combatants[1]).toEqual(B);
|
expect(encounter.combatants[1]).toEqual(B);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("with CombatantInit", () => {
|
||||||
|
it("creates combatant with maxHp and currentHp set to maxHp", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const { encounter } = successResult(e, "orc", "Orc", {
|
||||||
|
maxHp: 15,
|
||||||
|
});
|
||||||
|
const c = encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(15);
|
||||||
|
expect(c.currentHp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with ac", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const { encounter } = successResult(e, "orc", "Orc", {
|
||||||
|
ac: 13,
|
||||||
|
});
|
||||||
|
expect(encounter.combatants[0].ac).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with initiative and sorts into position", () => {
|
||||||
|
const hi = makeCombatant("Hi", { initiative: 20 });
|
||||||
|
const lo = makeCombatant("Lo", { initiative: 10 });
|
||||||
|
const e = enc([hi, lo]);
|
||||||
|
|
||||||
|
const { encounter } = successResult(e, "mid", "Mid", {
|
||||||
|
initiative: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(encounter.combatants.map((c) => c.name)).toEqual([
|
||||||
|
"Hi",
|
||||||
|
"Mid",
|
||||||
|
"Lo",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid maxHp (non-integer)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "X", {
|
||||||
|
maxHp: 1.5,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "invalid-max-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid maxHp (zero)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "X", {
|
||||||
|
maxHp: 0,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "invalid-max-hp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid ac (negative)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "X", {
|
||||||
|
ac: -1,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "invalid-ac");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid initiative (non-integer)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "X", {
|
||||||
|
initiative: 3.5,
|
||||||
|
});
|
||||||
|
expectDomainError(result, "invalid-initiative");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with creatureId", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const cId = creatureId("srd:goblin");
|
||||||
|
const { encounter } = successResult(e, "gob", "Goblin", {
|
||||||
|
creatureId: cId,
|
||||||
|
});
|
||||||
|
expect(encounter.combatants[0].creatureId).toBe(cId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with color and icon", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const { encounter } = successResult(e, "pc", "Aria", {
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
});
|
||||||
|
const c = encounter.combatants[0];
|
||||||
|
expect(c.color).toBe("blue");
|
||||||
|
expect(c.icon).toBe("sword");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with playerCharacterId", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const pcId = playerCharacterId("pc-1");
|
||||||
|
const { encounter } = successResult(e, "pc", "Aria", {
|
||||||
|
playerCharacterId: pcId,
|
||||||
|
});
|
||||||
|
expect(encounter.combatants[0].playerCharacterId).toBe(pcId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates combatant with all init fields", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const cId = creatureId("srd:orc");
|
||||||
|
const pcId = playerCharacterId("pc-1");
|
||||||
|
const { encounter } = successResult(e, "orc", "Orc", {
|
||||||
|
maxHp: 15,
|
||||||
|
ac: 13,
|
||||||
|
initiative: 12,
|
||||||
|
creatureId: cId,
|
||||||
|
color: "red",
|
||||||
|
icon: "axe",
|
||||||
|
playerCharacterId: pcId,
|
||||||
|
});
|
||||||
|
const c = encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(15);
|
||||||
|
expect(c.currentHp).toBe(15);
|
||||||
|
expect(c.ac).toBe(13);
|
||||||
|
expect(c.initiative).toBe(12);
|
||||||
|
expect(c.creatureId).toBe(cId);
|
||||||
|
expect(c.color).toBe("red");
|
||||||
|
expect(c.icon).toBe("axe");
|
||||||
|
expect(c.playerCharacterId).toBe(pcId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CombatantAdded event includes init", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const { events } = successResult(e, "orc", "Orc", {
|
||||||
|
maxHp: 15,
|
||||||
|
ac: 13,
|
||||||
|
});
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "CombatantAdded",
|
||||||
|
init: { maxHp: 15, ac: 13 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves activeIndex through initiative sort", () => {
|
||||||
|
const hi = makeCombatant("Hi", { initiative: 20 });
|
||||||
|
const lo = makeCombatant("Lo", { initiative: 10 });
|
||||||
|
// Lo is active (index 1)
|
||||||
|
const e = enc([hi, lo], 1);
|
||||||
|
|
||||||
|
const { encounter } = successResult(e, "mid", "Mid", {
|
||||||
|
initiative: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lo should still be active after sort
|
||||||
|
const loIdx = encounter.combatants.findIndex((c) => c.name === "Lo");
|
||||||
|
expect(encounter.activeIndex).toBe(loIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,94 @@
|
|||||||
|
import type { CreatureId } from "./creature-types.js";
|
||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import { sortByInitiative } from "./initiative-sort.js";
|
||||||
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
|
import type {
|
||||||
|
Combatant,
|
||||||
|
CombatantId,
|
||||||
|
DomainError,
|
||||||
|
Encounter,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export interface CombatantInit {
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly initiative?: number;
|
||||||
|
readonly creatureId?: CreatureId;
|
||||||
|
readonly color?: string;
|
||||||
|
readonly icon?: string;
|
||||||
|
readonly playerCharacterId?: PlayerCharacterId;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddCombatantSuccess {
|
export interface AddCombatantSuccess {
|
||||||
readonly encounter: Encounter;
|
readonly encounter: Encounter;
|
||||||
readonly events: DomainEvent[];
|
readonly events: DomainEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateInit(init: CombatantInit): DomainError | undefined {
|
||||||
|
if (
|
||||||
|
init.maxHp !== undefined &&
|
||||||
|
(!Number.isInteger(init.maxHp) || init.maxHp < 1)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-max-hp",
|
||||||
|
message: `Max HP must be a positive integer, got ${init.maxHp}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (init.ac !== undefined && (!Number.isInteger(init.ac) || init.ac < 0)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-ac",
|
||||||
|
message: `AC must be a non-negative integer, got ${init.ac}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (init.initiative !== undefined && !Number.isInteger(init.initiative)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-initiative",
|
||||||
|
message: `Initiative must be an integer, got ${init.initiative}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCombatant(
|
||||||
|
id: CombatantId,
|
||||||
|
name: string,
|
||||||
|
init?: CombatantInit,
|
||||||
|
): Combatant {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
...(init?.maxHp !== undefined && {
|
||||||
|
maxHp: init.maxHp,
|
||||||
|
currentHp: init.maxHp,
|
||||||
|
}),
|
||||||
|
...(init?.ac !== undefined && { ac: init.ac }),
|
||||||
|
...(init?.initiative !== undefined && { initiative: init.initiative }),
|
||||||
|
...(init?.creatureId !== undefined && { creatureId: init.creatureId }),
|
||||||
|
...(init?.color !== undefined && { color: init.color }),
|
||||||
|
...(init?.icon !== undefined && { icon: init.icon }),
|
||||||
|
...(init?.playerCharacterId !== undefined && {
|
||||||
|
playerCharacterId: init.playerCharacterId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function that adds a combatant to the end of an encounter's list.
|
* Pure function that adds a combatant to the end of an encounter's list.
|
||||||
*
|
*
|
||||||
* FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events.
|
* FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events.
|
||||||
* FR-002: Appends new combatant to end of combatants list.
|
* FR-002: Appends new combatant to end of combatants list.
|
||||||
* FR-004: Rejects empty/whitespace-only names with DomainError.
|
* FR-004: Rejects empty/whitespace-only names with DomainError.
|
||||||
* FR-005: Does not alter activeIndex or roundNumber.
|
* FR-005: Does not alter activeIndex or roundNumber (unless initiative triggers sort).
|
||||||
* FR-006: Events returned as values, not dispatched via side effects.
|
* FR-006: Events returned as values, not dispatched via side effects.
|
||||||
*/
|
*/
|
||||||
export function addCombatant(
|
export function addCombatant(
|
||||||
encounter: Encounter,
|
encounter: Encounter,
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
name: string,
|
name: string,
|
||||||
|
init?: CombatantInit,
|
||||||
): AddCombatantSuccess | DomainError {
|
): AddCombatantSuccess | DomainError {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
||||||
@@ -30,12 +100,35 @@ export function addCombatant(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = encounter.combatants.length;
|
if (init) {
|
||||||
|
const error = validateInit(init);
|
||||||
|
if (error) return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCombatant = buildCombatant(id, trimmed, init);
|
||||||
|
let combatants: readonly Combatant[] = [
|
||||||
|
...encounter.combatants,
|
||||||
|
newCombatant,
|
||||||
|
];
|
||||||
|
let activeIndex = encounter.activeIndex;
|
||||||
|
|
||||||
|
if (init?.initiative !== undefined) {
|
||||||
|
const activeCombatantId =
|
||||||
|
encounter.combatants.length > 0
|
||||||
|
? encounter.combatants[encounter.activeIndex].id
|
||||||
|
: id;
|
||||||
|
|
||||||
|
const result = sortByInitiative(combatants, activeCombatantId);
|
||||||
|
combatants = result.sorted;
|
||||||
|
activeIndex = result.activeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = combatants.findIndex((c) => c.id === id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter: {
|
encounter: {
|
||||||
combatants: [...encounter.combatants, { id, name: trimmed }],
|
combatants,
|
||||||
activeIndex: encounter.activeIndex,
|
activeIndex,
|
||||||
roundNumber: encounter.roundNumber,
|
roundNumber: encounter.roundNumber,
|
||||||
},
|
},
|
||||||
events: [
|
events: [
|
||||||
@@ -44,6 +137,7 @@ export function addCombatant(
|
|||||||
combatantId: id,
|
combatantId: id,
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
position,
|
position,
|
||||||
|
init,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ConditionId } from "./conditions.js";
|
import type { ConditionId } from "./conditions.js";
|
||||||
|
import type { CreatureId } from "./creature-types.js";
|
||||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
import type { CombatantId } from "./types.js";
|
import type { CombatantId } from "./types.js";
|
||||||
|
|
||||||
@@ -19,6 +20,15 @@ export interface CombatantAdded {
|
|||||||
readonly combatantId: CombatantId;
|
readonly combatantId: CombatantId;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly position: number;
|
readonly position: number;
|
||||||
|
readonly init?: {
|
||||||
|
readonly maxHp?: number;
|
||||||
|
readonly ac?: number;
|
||||||
|
readonly initiative?: number;
|
||||||
|
readonly creatureId?: CreatureId;
|
||||||
|
readonly color?: string;
|
||||||
|
readonly icon?: string;
|
||||||
|
readonly playerCharacterId?: PlayerCharacterId;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombatantRemoved {
|
export interface CombatantRemoved {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
|
export {
|
||||||
|
type AddCombatantSuccess,
|
||||||
|
addCombatant,
|
||||||
|
type CombatantInit,
|
||||||
|
} from "./add-combatant.js";
|
||||||
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
|
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
|
||||||
export { advanceTurn } from "./advance-turn.js";
|
export { advanceTurn } from "./advance-turn.js";
|
||||||
export { resolveCreatureName } from "./auto-number.js";
|
export { resolveCreatureName } from "./auto-number.js";
|
||||||
|
|||||||
35
packages/domain/src/initiative-sort.ts
Normal file
35
packages/domain/src/initiative-sort.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Combatant, CombatantId } from "./types.js";
|
||||||
|
|
||||||
|
interface Indexed {
|
||||||
|
readonly c: Combatant;
|
||||||
|
readonly i: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareByInitiative(a: Indexed, b: Indexed): number {
|
||||||
|
const aHas = a.c.initiative !== undefined;
|
||||||
|
const bHas = b.c.initiative !== undefined;
|
||||||
|
if (aHas && bHas) {
|
||||||
|
const diff = b.c.initiative - a.c.initiative;
|
||||||
|
return diff === 0 ? a.i - b.i : diff;
|
||||||
|
}
|
||||||
|
if (aHas && !bHas) return -1;
|
||||||
|
if (!aHas && bHas) return 1;
|
||||||
|
return a.i - b.i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable-sorts combatants by initiative (descending), preserving relative
|
||||||
|
* order for ties and for combatants without initiative. Returns the sorted
|
||||||
|
* list and the new activeIndex that tracks the given active combatant
|
||||||
|
* through the reorder.
|
||||||
|
*/
|
||||||
|
export function sortByInitiative(
|
||||||
|
combatants: readonly Combatant[],
|
||||||
|
activeCombatantId: CombatantId,
|
||||||
|
): { sorted: readonly Combatant[]; activeIndex: number } {
|
||||||
|
const indexed = combatants.map((c, i) => ({ c, i }));
|
||||||
|
indexed.sort(compareByInitiative);
|
||||||
|
const sorted = indexed.map(({ c }) => c);
|
||||||
|
const idx = sorted.findIndex((c) => c.id === activeCombatantId);
|
||||||
|
return { sorted, activeIndex: idx === -1 ? 0 : idx };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DomainEvent } from "./events.js";
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import { sortByInitiative } from "./initiative-sort.js";
|
||||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||||
|
|
||||||
export interface SetInitiativeSuccess {
|
export interface SetInitiativeSuccess {
|
||||||
@@ -44,45 +45,21 @@ export function setInitiative(
|
|||||||
const target = encounter.combatants[targetIdx];
|
const target = encounter.combatants[targetIdx];
|
||||||
const previousValue = target.initiative;
|
const previousValue = target.initiative;
|
||||||
|
|
||||||
// Record active combatant's id before reorder
|
|
||||||
const activeCombatantId =
|
|
||||||
encounter.combatants.length > 0
|
|
||||||
? encounter.combatants[encounter.activeIndex].id
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Create new combatants array with updated initiative
|
// Create new combatants array with updated initiative
|
||||||
const updated = encounter.combatants.map((c) =>
|
const updated = encounter.combatants.map((c) =>
|
||||||
c.id === combatantId ? { ...c, initiative: value } : c,
|
c.id === combatantId ? { ...c, initiative: value } : c,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stable sort: initiative descending, undefined last
|
// Record active combatant's id before reorder
|
||||||
const indexed = updated.map((c, i) => ({ c, i }));
|
const activeCombatantId =
|
||||||
indexed.sort((a, b) => {
|
encounter.combatants.length > 0
|
||||||
const aHas = a.c.initiative !== undefined;
|
? encounter.combatants[encounter.activeIndex].id
|
||||||
const bHas = b.c.initiative !== undefined;
|
: combatantId;
|
||||||
|
|
||||||
if (aHas && bHas) {
|
const { sorted, activeIndex: newActiveIndex } = sortByInitiative(
|
||||||
const aInit = a.c.initiative as number;
|
updated,
|
||||||
const bInit = b.c.initiative as number;
|
activeCombatantId,
|
||||||
const diff = bInit - aInit;
|
);
|
||||||
return diff === 0 ? a.i - b.i : diff;
|
|
||||||
}
|
|
||||||
if (aHas && !bHas) return -1;
|
|
||||||
if (!aHas && bHas) return 1;
|
|
||||||
// Both undefined — preserve relative order
|
|
||||||
return a.i - b.i;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sorted = indexed.map(({ c }) => c);
|
|
||||||
|
|
||||||
// Find active combatant's new index
|
|
||||||
let newActiveIndex = encounter.activeIndex;
|
|
||||||
if (activeCombatantId !== undefined) {
|
|
||||||
const idx = sorted.findIndex((c) => c.id === activeCombatantId);
|
|
||||||
if (idx !== -1) {
|
|
||||||
newActiveIndex = idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter: {
|
encounter: {
|
||||||
|
|||||||
Reference in New Issue
Block a user