Add combatant side assignment for encounter difficulty
Combatants can now be assigned to party or enemy side via a toggle in the difficulty breakdown panel. Party-side NPCs subtract their XP from the encounter total, letting allied NPCs reduce difficulty. PCs default to party, non-PCs to enemy — users who don't use sides see no change. Side persists across reload and export/import. Closes #22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setSideUseCase } from "./set-side-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||
|
||||
18
packages/application/src/set-side-use-case.ts
Normal file
18
packages/application/src/set-side-use-case.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
setSide,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setSideUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
value: "party" | "enemy",
|
||||
): DomainEvent[] | DomainError {
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setSide(encounter, combatantId, value),
|
||||
);
|
||||
}
|
||||
@@ -36,11 +36,27 @@ describe("crToXp", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Helper to build party-side descriptors with level. */
|
||||
function party(level: number) {
|
||||
return { level, side: "party" as const };
|
||||
}
|
||||
|
||||
/** Helper to build enemy-side descriptors with CR. */
|
||||
function enemy(cr: string) {
|
||||
return { cr, side: "enemy" as const };
|
||||
}
|
||||
|
||||
describe("calculateEncounterDifficulty", () => {
|
||||
it("returns trivial when monster XP is below Low threshold", () => {
|
||||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||
// 1x CR 0 = 0 XP → trivial
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["0"]);
|
||||
// 1x CR 0 = 0 XP -> trivial
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("0"),
|
||||
]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.partyBudget).toEqual({
|
||||
@@ -51,20 +67,29 @@ describe("calculateEncounterDifficulty", () => {
|
||||
});
|
||||
|
||||
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
// DMG example: 4x level 1 PCs vs 1 Bugbear (CR 1, 200 XP)
|
||||
// Low = 200, Moderate = 300 → 200 >= 200 but < 300 → Low
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1"]);
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
]);
|
||||
expect(result.tier).toBe("low");
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("returns moderate for 5x level 3 vs 1125 XP", () => {
|
||||
it("returns moderate for 5x level 3 vs 1150 XP", () => {
|
||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||
// 1125 XP >= 1125 Moderate but < 2000 High → Moderate
|
||||
// Using CR 3 (700) + CR 2 (450) = 1150 XP ≈ 1125 threshold
|
||||
// Let's use exact: 5 * 225 = 1125 moderate budget
|
||||
// Need monsters that sum exactly to 1125: CR 3 (700) + CR 2 (450) = 1150
|
||||
const result = calculateEncounterDifficulty([3, 3, 3, 3, 3], ["3", "2"]);
|
||||
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
enemy("3"),
|
||||
enemy("2"),
|
||||
]);
|
||||
expect(result.tier).toBe("moderate");
|
||||
expect(result.totalMonsterXp).toBe(1150);
|
||||
expect(result.partyBudget.moderate).toBe(1125);
|
||||
@@ -72,26 +97,41 @@ describe("calculateEncounterDifficulty", () => {
|
||||
|
||||
it("returns high when XP meets High threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// 2x CR 1 = 400 XP → High
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1", "1"]);
|
||||
// 2x CR 1 = 400 XP -> High
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(400);
|
||||
});
|
||||
|
||||
it("caps at high when XP far exceeds threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// CR 30 = 155000 XP → still High (no tier above)
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["30"]);
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("30"),
|
||||
]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(155000);
|
||||
});
|
||||
|
||||
it("handles mixed party levels", () => {
|
||||
// 3x level 3 + 1x level 2
|
||||
// level 3: low=150, mod=225, high=400 (x3 = 450, 675, 1200)
|
||||
// level 2: low=100, mod=150, high=200 (x1 = 100, 150, 200)
|
||||
// Total: low=550, mod=825, high=1400
|
||||
const result = calculateEncounterDifficulty([3, 3, 3, 2], ["3"]);
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(2),
|
||||
enemy("3"),
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 550,
|
||||
moderate: 825,
|
||||
@@ -101,33 +141,110 @@ describe("calculateEncounterDifficulty", () => {
|
||||
expect(result.tier).toBe("low");
|
||||
});
|
||||
|
||||
it("returns trivial with empty monster array", () => {
|
||||
const result = calculateEncounterDifficulty([5, 5], []);
|
||||
it("returns trivial with no enemies", () => {
|
||||
const result = calculateEncounterDifficulty([party(5), party(5)]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
});
|
||||
|
||||
it("returns high with empty party array (zero budget thresholds)", () => {
|
||||
// Domain function treats empty party as zero budgets — any XP exceeds all thresholds.
|
||||
// The useDifficulty hook guards this path by returning null when no leveled PCs exist.
|
||||
const result = calculateEncounterDifficulty([], ["1"]);
|
||||
it("returns high with no party levels (zero budget thresholds)", () => {
|
||||
const result = calculateEncounterDifficulty([enemy("1")]);
|
||||
expect(result.tier).toBe("high");
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
||||
});
|
||||
|
||||
it("handles fractional CRs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[1, 1, 1, 1],
|
||||
["1/8", "1/4", "1/2"],
|
||||
);
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/4"),
|
||||
enemy("1/2"),
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
||||
});
|
||||
|
||||
it("ignores unknown CRs (0 XP)", () => {
|
||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["unknown"]);
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("unknown"),
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
});
|
||||
|
||||
it("subtracts XP for party-side combatant with CR", () => {
|
||||
// 4x level 1 party, 1 enemy CR 2 (450 XP), 1 party CR 1 (200 XP)
|
||||
// Net = 450 - 200 = 250
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("2"),
|
||||
{ cr: "1", side: "party" },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(250);
|
||||
expect(result.tier).toBe("low"); // 250 >= 200 Low, < 300 Moderate
|
||||
});
|
||||
|
||||
it("floors net monster XP at 0", () => {
|
||||
// Party ally has more XP than enemy
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
{ cr: "5", side: "party" }, // 1800 XP subtracted
|
||||
enemy("1"), // 200 XP added
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
});
|
||||
|
||||
it("dual contribution: combatant with both level and CR on party side", () => {
|
||||
// Party combatant with level 1 AND CR 1 on party side
|
||||
// Level contributes to budget, CR subtracts from monster XP
|
||||
const result = calculateEncounterDifficulty([
|
||||
{ level: 1, cr: "1", side: "party" }, // budget += lv1, monsterXp -= 200
|
||||
enemy("2"), // monsterXp += 450
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({ low: 50, moderate: 75, high: 100 });
|
||||
expect(result.totalMonsterXp).toBe(250); // 450 - 200
|
||||
});
|
||||
|
||||
it("enemy-side combatant with level does NOT contribute to budget", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
{ level: 5, side: "enemy" }, // should not add to budget
|
||||
enemy("1"),
|
||||
]);
|
||||
// Only level 1 party contributes to budget
|
||||
expect(result.partyBudget).toEqual({ low: 50, moderate: 75, high: 100 });
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("mixed sides calculate correctly", () => {
|
||||
// 2 party PCs (level 3), 1 party ally (CR 1, 200 XP), 2 enemies (CR 2, 450 each)
|
||||
// Budget: 2x level 3 = low 300, mod 450, high 800
|
||||
// Monster XP: 900 - 200 = 700
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
{ cr: "1", side: "party" },
|
||||
enemy("2"),
|
||||
enemy("2"),
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 300,
|
||||
moderate: 450,
|
||||
high: 800,
|
||||
});
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe("moderate"); // 700 >= 450 Moderate, < 800 High
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +241,28 @@ describe("rehydrateCombatant", () => {
|
||||
expect(result?.cr).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid side field", () => {
|
||||
for (const side of ["party", "enemy"]) {
|
||||
const result = rehydrateCombatant({ ...minimalCombatant(), side });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.side).toBe(side);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid side field", () => {
|
||||
for (const side of ["ally", "", 42, null, true]) {
|
||||
const result = rehydrateCombatant({ ...minimalCombatant(), side });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.side).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("combatant without side rehydrates as before", () => {
|
||||
const result = rehydrateCombatant(minimalCombatant());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.side).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops invalid tempHp — keeps combatant", () => {
|
||||
for (const tempHp of [-1, 1.5, "3"]) {
|
||||
const result = rehydrateCombatant({
|
||||
|
||||
115
packages/domain/src/__tests__/set-side.test.ts
Normal file
115
packages/domain/src/__tests__/set-side.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setSide } from "../set-side.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(name: string, side?: "party" | "enemy"): Combatant {
|
||||
return side === undefined
|
||||
? { id: combatantId(name), name }
|
||||
: { id: combatantId(name), name, side };
|
||||
}
|
||||
|
||||
function enc(
|
||||
combatants: Combatant[],
|
||||
activeIndex = 0,
|
||||
roundNumber = 1,
|
||||
): Encounter {
|
||||
return { combatants, activeIndex, roundNumber };
|
||||
}
|
||||
|
||||
function successResult(
|
||||
encounter: Encounter,
|
||||
id: string,
|
||||
value: "party" | "enemy",
|
||||
) {
|
||||
const result = setSide(encounter, combatantId(id), value);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("setSide", () => {
|
||||
it("sets side to party", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||
const { encounter, events } = successResult(e, "A", "party");
|
||||
|
||||
expect(encounter.combatants[0].side).toBe("party");
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "SideSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousSide: undefined,
|
||||
newSide: "party",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets side to enemy", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const { encounter } = successResult(e, "A", "enemy");
|
||||
|
||||
expect(encounter.combatants[0].side).toBe("enemy");
|
||||
});
|
||||
|
||||
it("records previous side in event", () => {
|
||||
const e = enc([makeCombatant("A", "party")]);
|
||||
const { events } = successResult(e, "A", "enemy");
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
previousSide: "party",
|
||||
newSide: "enemy",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setSide(e, combatantId("nonexistent"), "party");
|
||||
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("preserves other fields when setting side", () => {
|
||||
const combatant: Combatant = {
|
||||
id: combatantId("A"),
|
||||
name: "Aria",
|
||||
initiative: 15,
|
||||
maxHp: 20,
|
||||
currentHp: 18,
|
||||
ac: 14,
|
||||
cr: "2",
|
||||
};
|
||||
const e = enc([combatant]);
|
||||
const { encounter } = successResult(e, "A", "party");
|
||||
|
||||
const updated = encounter.combatants[0];
|
||||
expect(updated.side).toBe("party");
|
||||
expect(updated.name).toBe("Aria");
|
||||
expect(updated.initiative).toBe(15);
|
||||
expect(updated.cr).toBe("2");
|
||||
});
|
||||
|
||||
it("does not reorder combatants", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||
const { encounter } = successResult(e, "B", "party");
|
||||
|
||||
expect(encounter.combatants[0].id).toBe(combatantId("A"));
|
||||
expect(encounter.combatants[1].id).toBe(combatantId("B"));
|
||||
});
|
||||
|
||||
it("preserves activeIndex and roundNumber", () => {
|
||||
const e = enc([makeCombatant("A"), makeCombatant("B")], 1, 5);
|
||||
const { encounter } = successResult(e, "A", "party");
|
||||
|
||||
expect(encounter.activeIndex).toBe(1);
|
||||
expect(encounter.roundNumber).toBe(5);
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const original = JSON.parse(JSON.stringify(e));
|
||||
setSide(e, combatantId("A"), "party");
|
||||
expect(e).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -82,43 +82,61 @@ export function crToXp(cr: string): number {
|
||||
return CR_TO_XP[cr] ?? 0;
|
||||
}
|
||||
|
||||
export interface CombatantDescriptor {
|
||||
readonly level?: number;
|
||||
readonly cr?: string;
|
||||
readonly side: "party" | "enemy";
|
||||
}
|
||||
|
||||
function determineTier(
|
||||
xp: number,
|
||||
low: number,
|
||||
moderate: number,
|
||||
high: number,
|
||||
): DifficultyTier {
|
||||
if (xp >= high) return "high";
|
||||
if (xp >= moderate) return "moderate";
|
||||
if (xp >= low) return "low";
|
||||
return "trivial";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates encounter difficulty from party levels and monster CRs.
|
||||
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
|
||||
* Calculates encounter difficulty from combatant descriptors.
|
||||
* Party-side combatants with level contribute to the budget.
|
||||
* Enemy-side combatants with CR add XP; party-side with CR subtract XP (floored at 0).
|
||||
*/
|
||||
export function calculateEncounterDifficulty(
|
||||
partyLevels: readonly number[],
|
||||
monsterCrs: readonly string[],
|
||||
combatants: readonly CombatantDescriptor[],
|
||||
): DifficultyResult {
|
||||
let budgetLow = 0;
|
||||
let budgetModerate = 0;
|
||||
let budgetHigh = 0;
|
||||
let totalMonsterXp = 0;
|
||||
|
||||
for (const level of partyLevels) {
|
||||
const budget = XP_BUDGET_PER_CHARACTER[level];
|
||||
if (budget) {
|
||||
budgetLow += budget.low;
|
||||
budgetModerate += budget.moderate;
|
||||
budgetHigh += budget.high;
|
||||
for (const c of combatants) {
|
||||
if (c.level !== undefined && c.side === "party") {
|
||||
const budget = XP_BUDGET_PER_CHARACTER[c.level];
|
||||
if (budget) {
|
||||
budgetLow += budget.low;
|
||||
budgetModerate += budget.moderate;
|
||||
budgetHigh += budget.high;
|
||||
}
|
||||
}
|
||||
|
||||
if (c.cr !== undefined) {
|
||||
const xp = crToXp(c.cr);
|
||||
if (c.side === "enemy") {
|
||||
totalMonsterXp += xp;
|
||||
} else {
|
||||
totalMonsterXp -= xp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let totalMonsterXp = 0;
|
||||
for (const cr of monsterCrs) {
|
||||
totalMonsterXp += crToXp(cr);
|
||||
}
|
||||
|
||||
let tier: DifficultyTier = "trivial";
|
||||
if (totalMonsterXp >= budgetHigh) {
|
||||
tier = "high";
|
||||
} else if (totalMonsterXp >= budgetModerate) {
|
||||
tier = "moderate";
|
||||
} else if (totalMonsterXp >= budgetLow) {
|
||||
tier = "low";
|
||||
}
|
||||
totalMonsterXp = Math.max(0, totalMonsterXp);
|
||||
|
||||
return {
|
||||
tier,
|
||||
tier: determineTier(totalMonsterXp, budgetLow, budgetModerate, budgetHigh),
|
||||
totalMonsterXp,
|
||||
partyBudget: {
|
||||
low: budgetLow,
|
||||
|
||||
@@ -101,6 +101,13 @@ export interface CrSet {
|
||||
readonly newCr: string | undefined;
|
||||
}
|
||||
|
||||
export interface SideSet {
|
||||
readonly type: "SideSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly previousSide: "party" | "enemy" | undefined;
|
||||
readonly newSide: "party" | "enemy";
|
||||
}
|
||||
|
||||
export interface ConditionAdded {
|
||||
readonly type: "ConditionAdded";
|
||||
readonly combatantId: CombatantId;
|
||||
@@ -161,6 +168,7 @@ export type DomainEvent =
|
||||
| RoundRetreated
|
||||
| AcSet
|
||||
| CrSet
|
||||
| SideSet
|
||||
| ConditionAdded
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
|
||||
@@ -49,6 +49,7 @@ export {
|
||||
editPlayerCharacter,
|
||||
} from "./edit-player-character.js";
|
||||
export {
|
||||
type CombatantDescriptor,
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
type DifficultyResult,
|
||||
@@ -75,6 +76,7 @@ export type {
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
SideSet,
|
||||
TempHpSet,
|
||||
TurnAdvanced,
|
||||
TurnRetreated,
|
||||
@@ -115,6 +117,7 @@ export {
|
||||
type SetInitiativeSuccess,
|
||||
setInitiative,
|
||||
} from "./set-initiative.js";
|
||||
export { type SetSideSuccess, setSide } from "./set-side.js";
|
||||
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||
export {
|
||||
type ToggleConcentrationSuccess,
|
||||
|
||||
@@ -76,6 +76,14 @@ function validateCr(value: unknown): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const VALID_SIDES = new Set(["party", "enemy"]);
|
||||
|
||||
function validateSide(value: unknown): "party" | "enemy" | undefined {
|
||||
return typeof value === "string" && VALID_SIDES.has(value)
|
||||
? (value as "party" | "enemy")
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
return {
|
||||
initiative: validateInteger(entry.initiative),
|
||||
@@ -86,6 +94,7 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
? creatureId(entry.creatureId as string)
|
||||
: undefined,
|
||||
cr: validateCr(entry.cr),
|
||||
side: validateSide(entry.side),
|
||||
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
||||
icon: validateSetMember(entry.icon, VALID_PLAYER_ICONS),
|
||||
playerCharacterId: validateNonEmptyString(entry.playerCharacterId)
|
||||
|
||||
54
packages/domain/src/set-side.ts
Normal file
54
packages/domain/src/set-side.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
findCombatant,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
|
||||
export interface SetSideSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
const VALID_SIDES = new Set(["party", "enemy"]);
|
||||
|
||||
export function setSide(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
value: "party" | "enemy",
|
||||
): SetSideSuccess | DomainError {
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
|
||||
if (!VALID_SIDES.has(value)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-side",
|
||||
message: `Side must be "party" or "enemy", got "${value}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const previousSide = found.combatant.side;
|
||||
|
||||
const updatedCombatants = encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, side: value } : c,
|
||||
);
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: updatedCombatants,
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "SideSet",
|
||||
combatantId,
|
||||
previousSide,
|
||||
newSide: value,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface Combatant {
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly cr?: string;
|
||||
readonly side?: "party" | "enemy";
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
|
||||
Reference in New Issue
Block a user