Add combatant side assignment for encounter difficulty
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s

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:
Lukas
2026-04-03 14:15:12 +02:00
parent 30e7ed4121
commit 94e1806112
23 changed files with 1359 additions and 455 deletions

View File

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

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

View File

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

View File

@@ -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({

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

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View 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,
},
],
};
}

View File

@@ -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;