Add manual CR assignment and difficulty breakdown panel
All checks were successful
CI / check (push) Successful in 2m20s
CI / build-image (push) Successful in 17s

Implement issue #21: custom combatants can now have a challenge rating
assigned via a new breakdown panel, opened by tapping the difficulty
indicator. Bestiary-linked combatants show read-only CR with source name;
custom combatants get a CR picker with all standard 5e values. CR persists
across reloads and round-trips through JSON export/import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-02 17:03:33 +02:00
parent 2c643cc98b
commit 1ae9e12cff
26 changed files with 1461 additions and 17 deletions

View File

@@ -21,6 +21,7 @@ export {
} from "./roll-all-initiative-use-case.js";
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
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 { setTempHpUseCase } from "./set-temp-hp-use-case.js";

View File

@@ -0,0 +1,18 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
setCr,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
import { runEncounterAction } from "./run-encounter-action.js";
export function setCrUseCase(
store: EncounterStore,
combatantId: CombatantId,
value: string | undefined,
): DomainEvent[] | DomainError {
return runEncounterAction(store, (encounter) =>
setCr(encounter, combatantId, value),
);
}

View File

@@ -219,6 +219,28 @@ describe("rehydrateCombatant", () => {
}
});
it("preserves valid cr field", () => {
for (const cr of ["5", "1/4", "0", "30"]) {
const result = rehydrateCombatant({ ...minimalCombatant(), cr });
expect(result).not.toBeNull();
expect(result?.cr).toBe(cr);
}
});
it("drops invalid cr field", () => {
for (const cr of ["99", "", 42, null, "abc"]) {
const result = rehydrateCombatant({ ...minimalCombatant(), cr });
expect(result).not.toBeNull();
expect(result?.cr).toBeUndefined();
}
});
it("combatant without cr rehydrates as before", () => {
const result = rehydrateCombatant(minimalCombatant());
expect(result).not.toBeNull();
expect(result?.cr).toBeUndefined();
});
it("drops invalid tempHp — keeps combatant", () => {
for (const tempHp of [-1, 1.5, "3"]) {
const result = rehydrateCombatant({

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import { setCr } from "../set-cr.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
function makeCombatant(name: string, cr?: string): Combatant {
return cr === undefined
? { id: combatantId(name), name }
: { id: combatantId(name), name, cr };
}
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(
encounter: Encounter,
id: string,
value: string | undefined,
) {
const result = setCr(encounter, combatantId(id), value);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setCr", () => {
it("sets CR to a valid integer value", () => {
const e = enc([makeCombatant("A"), makeCombatant("B")]);
const { encounter, events } = successResult(e, "A", "5");
expect(encounter.combatants[0].cr).toBe("5");
expect(events).toEqual([
{
type: "CrSet",
combatantId: combatantId("A"),
previousCr: undefined,
newCr: "5",
},
]);
});
it("sets CR to 0", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", "0");
expect(encounter.combatants[0].cr).toBe("0");
});
it("sets CR to fractional values", () => {
for (const cr of ["1/8", "1/4", "1/2"]) {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", cr);
expect(encounter.combatants[0].cr).toBe(cr);
}
});
it("sets CR to 30", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", "30");
expect(encounter.combatants[0].cr).toBe("30");
});
it("clears CR with undefined", () => {
const e = enc([makeCombatant("A", "5")]);
const { encounter, events } = successResult(e, "A", undefined);
expect(encounter.combatants[0].cr).toBeUndefined();
expect(events[0]).toMatchObject({
previousCr: "5",
newCr: undefined,
});
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = setCr(e, combatantId("nonexistent"), "1");
expectDomainError(result, "combatant-not-found");
});
it("returns error for invalid CR string", () => {
const e = enc([makeCombatant("A")]);
const result = setCr(e, combatantId("A"), "99");
expectDomainError(result, "invalid-cr");
});
it("returns error for empty string CR", () => {
const e = enc([makeCombatant("A")]);
const result = setCr(e, combatantId("A"), "");
expectDomainError(result, "invalid-cr");
});
it("preserves other fields when setting CR", () => {
const combatant: Combatant = {
id: combatantId("A"),
name: "Aria",
initiative: 15,
maxHp: 20,
currentHp: 18,
ac: 14,
};
const e = enc([combatant]);
const { encounter } = successResult(e, "A", "2");
const updated = encounter.combatants[0];
expect(updated.cr).toBe("2");
expect(updated.name).toBe("Aria");
expect(updated.initiative).toBe(15);
expect(updated.maxHp).toBe(20);
expect(updated.currentHp).toBe(18);
expect(updated.ac).toBe(14);
});
it("does not reorder combatants", () => {
const e = enc([makeCombatant("A"), makeCombatant("B")]);
const { encounter } = successResult(e, "B", "1");
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", "1/4");
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));
setCr(e, combatantId("A"), "10");
expect(e).toEqual(original);
});
});

View File

@@ -74,6 +74,9 @@ const XP_BUDGET_PER_CHARACTER: Readonly<
20: { low: 6400, moderate: 13200, high: 22000 },
};
/** All standard 5e challenge rating strings, in ascending order. */
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
export function crToXp(cr: string): number {
return CR_TO_XP[cr] ?? 0;

View File

@@ -94,6 +94,13 @@ export interface AcSet {
readonly newAc: number | undefined;
}
export interface CrSet {
readonly type: "CrSet";
readonly combatantId: CombatantId;
readonly previousCr: string | undefined;
readonly newCr: string | undefined;
}
export interface ConditionAdded {
readonly type: "ConditionAdded";
readonly combatantId: CombatantId;
@@ -153,6 +160,7 @@ export type DomainEvent =
| TurnRetreated
| RoundRetreated
| AcSet
| CrSet
| ConditionAdded
| ConditionRemoved
| ConcentrationStarted

View File

@@ -53,6 +53,7 @@ export {
crToXp,
type DifficultyResult,
type DifficultyTier,
VALID_CR_VALUES,
} from "./encounter-difficulty.js";
export type {
AcSet,
@@ -63,6 +64,7 @@ export type {
ConcentrationStarted,
ConditionAdded,
ConditionRemoved,
CrSet,
CurrentHpAdjusted,
DomainEvent,
EncounterCleared,
@@ -107,6 +109,7 @@ export {
selectRoll,
} from "./roll-initiative.js";
export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetCrSuccess, setCr } from "./set-cr.js";
export { type SetHpSuccess, setHp } from "./set-hp.js";
export {
type SetInitiativeSuccess,

View File

@@ -1,6 +1,7 @@
import type { ConditionId } from "./conditions.js";
import { VALID_CONDITION_IDS } from "./conditions.js";
import { creatureId } from "./creature-types.js";
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
import {
playerCharacterId,
VALID_PLAYER_COLORS,
@@ -69,6 +70,12 @@ function validateNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function validateCr(value: unknown): string | undefined {
return typeof value === "string" && VALID_CR_VALUES.includes(value)
? value
: undefined;
}
function parseOptionalFields(entry: Record<string, unknown>) {
return {
initiative: validateInteger(entry.initiative),
@@ -78,6 +85,7 @@ function parseOptionalFields(entry: Record<string, unknown>) {
creatureId: validateNonEmptyString(entry.creatureId)
? creatureId(entry.creatureId as string)
: undefined,
cr: validateCr(entry.cr),
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
icon: validateSetMember(entry.icon, VALID_PLAYER_ICONS),
playerCharacterId: validateNonEmptyString(entry.playerCharacterId)

View File

@@ -0,0 +1,53 @@
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
import type { DomainEvent } from "./events.js";
import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface SetCrSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
export function setCr(
encounter: Encounter,
combatantId: CombatantId,
value: string | undefined,
): SetCrSuccess | DomainError {
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
if (value !== undefined && !VALID_CR_VALUES.includes(value)) {
return {
kind: "domain-error",
code: "invalid-cr",
message: `CR must be a valid challenge rating, got "${value}"`,
};
}
const previousCr = found.combatant.cr;
const updatedCombatants = encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, cr: value } : c,
);
return {
encounter: {
combatants: updatedCombatants,
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "CrSet",
combatantId,
previousCr,
newCr: value,
},
],
};
}

View File

@@ -20,6 +20,7 @@ export interface Combatant {
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId;
readonly cr?: string;
readonly color?: string;
readonly icon?: string;
readonly playerCharacterId?: PlayerCharacterId;