Phase 3: combat depth

- Condition durations + auto-expiry: timed conditions tick down on the affected
  combatant's turn and drop at 0 (engine tickConditions; UI rounds field + (Nr) chip)
- Initiative: Roll-all (d20 + per-combatant bonus, re-sort anchored) + group/quantity add
- Combat log: per-round event feed (turns, round changes, damage/heal, expiries,
  removals) + in-memory Undo of the last change
- Encounter difficulty budget: 5e XP thresholds + PF2e level budget (pure lib +
  tests); live difficulty/XP-award readout from monster combatants vs the party
- Combatants carry initBonus + cr/level; compendium add-to-combat passes them

18 new unit tests (durations, applyInitiatives, budget), new combat-depth e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:23:13 +02:00
parent 1c87b0e3aa
commit 3e5bdc06e2
13 changed files with 472 additions and 35 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { computeBudget } from './budget';
describe('5e encounter budget', () => {
it('rates a single CR 1/4 vs a level-1 party of 4 as easy-ish', () => {
const r = computeBudget('5e', [1, 1, 1, 1], [{ cr: 0.25 }]);
expect(r.totalXp).toBe(50);
expect(r.ratingXp).toBe(50); // single monster, x1 multiplier
expect(['trivial', 'easy']).toContain(r.difficulty);
});
it('applies the count multiplier to the rating but not the award', () => {
const r = computeBudget('5e', [3, 3, 3, 3], [{ cr: 1 }, { cr: 1 }, { cr: 1 }]);
expect(r.totalXp).toBe(600); // 3 * 200
expect(r.ratingXp).toBe(1200); // x2 for 3 monsters
expect(r.awardPerCharacter).toBe(150); // 600 / 4
});
it('flags a deadly fight', () => {
const r = computeBudget('5e', [1, 1, 1, 1], [{ cr: 3 }, { cr: 3 }]);
expect(r.difficulty).toBe('deadly');
});
});
describe('pf2e encounter budget', () => {
it('a same-level creature is worth 40 XP', () => {
const r = computeBudget('pf2e', [3, 3, 3, 3], [{ level: 3 }]);
expect(r.totalXp).toBe(40);
expect(r.awardPerCharacter).toBe(40);
});
it('rates a moderate encounter', () => {
const r = computeBudget('pf2e', [5, 5, 5, 5], [{ level: 5 }, { level: 5 }]);
expect(r.totalXp).toBe(80);
expect(r.difficulty).toBe('moderate');
});
it('adjusts thresholds for party size', () => {
const four = computeBudget('pf2e', [1, 1, 1, 1], []);
const five = computeBudget('pf2e', [1, 1, 1, 1, 1], []);
const sevFour = four.thresholds.find((t) => t.label === 'Severe')!.value;
const sevFive = five.thresholds.find((t) => t.label === 'Severe')!.value;
expect(sevFive - sevFour).toBe(30);
});
});
+149
View File
@@ -0,0 +1,149 @@
import type { SystemId } from '@/lib/rules';
/**
* Encounter difficulty math for both systems. Pure and unit-tested.
* - 5e: DMG XP thresholds by character level + encounter multiplier by monster count.
* - PF2e: GMG XP budget by creature level relative to party level.
*/
export interface BudgetMonster {
cr?: number | undefined; // 5e
level?: number | undefined; // pf2e
}
export type Difficulty5e = 'trivial' | 'easy' | 'medium' | 'hard' | 'deadly';
export type DifficultyPf2e = 'trivial' | 'low' | 'moderate' | 'severe' | 'extreme';
export interface BudgetResult {
system: SystemId;
difficulty: Difficulty5e | DifficultyPf2e;
/** number compared against the thresholds (5e: multiplier-adjusted XP) */
ratingXp: number;
/** raw XP awarded for defeating the monsters */
totalXp: number;
awardPerCharacter: number;
thresholds: { label: string; value: number }[];
monstersCounted: number;
}
// ---------------- 5e ----------------
const CR_XP: Record<number, number> = {
0: 10, 0.125: 25, 0.25: 50, 0.5: 100, 1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800,
6: 2300, 7: 2900, 8: 3900, 9: 5000, 10: 5900, 11: 7200, 12: 8400, 13: 10000,
14: 11500, 15: 13000, 16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000,
21: 33000, 22: 41000, 23: 50000, 24: 62000, 25: 75000, 26: 90000, 27: 105000,
28: 120000, 29: 135000, 30: 155000,
};
// [easy, medium, hard, deadly] per character level (DMG p.82)
const XP_THRESHOLDS: Record<number, [number, number, number, number]> = {
1: [25, 50, 75, 100], 2: [50, 100, 150, 200], 3: [75, 150, 225, 400],
4: [125, 250, 375, 500], 5: [250, 500, 750, 1100], 6: [300, 600, 900, 1400],
7: [350, 750, 1100, 1700], 8: [450, 900, 1400, 2100], 9: [550, 1100, 1600, 2400],
10: [600, 1200, 1900, 2800], 11: [800, 1600, 2400, 3600], 12: [1000, 2000, 3000, 4500],
13: [1100, 2200, 3400, 5100], 14: [1250, 2500, 3800, 5700], 15: [1400, 2800, 4300, 6400],
16: [1600, 3200, 4800, 7200], 17: [2000, 3900, 5900, 8800], 18: [2100, 4200, 6300, 9500],
19: [2400, 4900, 7300, 10900], 20: [2800, 5700, 8500, 12700],
};
function crXp(cr: number): number {
if (cr in CR_XP) return CR_XP[cr]!;
// nearest known CR
const keys = Object.keys(CR_XP).map(Number).sort((a, b) => a - b);
let best = keys[0]!;
for (const k of keys) if (Math.abs(k - cr) < Math.abs(best - cr)) best = k;
return CR_XP[best]!;
}
function encounterMultiplier(count: number): number {
if (count <= 1) return 1;
if (count === 2) return 1.5;
if (count <= 6) return 2;
if (count <= 10) return 2.5;
if (count <= 14) return 3;
return 4;
}
function budget5e(partyLevels: number[], monsters: BudgetMonster[]): BudgetResult {
const mons = monsters.filter((m) => m.cr !== undefined);
const totalXp = mons.reduce((s, m) => s + crXp(m.cr!), 0);
const ratingXp = Math.round(totalXp * encounterMultiplier(mons.length));
const sum = [0, 0, 0, 0];
for (const lvl of partyLevels) {
const t = XP_THRESHOLDS[Math.max(1, Math.min(20, Math.round(lvl)))] ?? XP_THRESHOLDS[1]!;
for (let i = 0; i < 4; i++) sum[i]! += t[i as 0 | 1 | 2 | 3];
}
const [easy, medium, hard, deadly] = sum;
let difficulty: Difficulty5e = 'trivial';
if (ratingXp >= deadly!) difficulty = 'deadly';
else if (ratingXp >= hard!) difficulty = 'hard';
else if (ratingXp >= medium!) difficulty = 'medium';
else if (ratingXp >= easy!) difficulty = 'easy';
return {
system: '5e', difficulty, ratingXp, totalXp,
awardPerCharacter: partyLevels.length ? Math.floor(totalXp / partyLevels.length) : totalXp,
thresholds: [
{ label: 'Easy', value: easy! }, { label: 'Medium', value: medium! },
{ label: 'Hard', value: hard! }, { label: 'Deadly', value: deadly! },
],
monstersCounted: mons.length,
};
}
// ---------------- PF2e ----------------
const PF2E_CREATURE_XP: Record<number, number> = {
[-4]: 10, [-3]: 15, [-2]: 20, [-1]: 30, 0: 40, 1: 60, 2: 80, 3: 120, 4: 160,
};
const PF2E_BASE = { trivial: 40, low: 60, moderate: 80, severe: 120, extreme: 160 };
const PF2E_ADJ = { trivial: 10, low: 15, moderate: 20, severe: 30, extreme: 40 };
function pf2eCreatureXp(diff: number): number {
const clamped = Math.max(-4, Math.min(4, diff));
return PF2E_CREATURE_XP[clamped] ?? 0;
}
function budgetPf2e(partyLevels: number[], monsters: BudgetMonster[]): BudgetResult {
const partyLevel = partyLevels.length
? Math.round(partyLevels.reduce((a, b) => a + b, 0) / partyLevels.length)
: 1;
const partySize = partyLevels.length || 4;
const mons = monsters.filter((m) => m.level !== undefined);
const totalXp = mons.reduce((s, m) => s + pf2eCreatureXp(m.level! - partyLevel), 0);
const adj = partySize - 4;
const thr = {
trivial: PF2E_BASE.trivial + adj * PF2E_ADJ.trivial,
low: PF2E_BASE.low + adj * PF2E_ADJ.low,
moderate: PF2E_BASE.moderate + adj * PF2E_ADJ.moderate,
severe: PF2E_BASE.severe + adj * PF2E_ADJ.severe,
extreme: PF2E_BASE.extreme + adj * PF2E_ADJ.extreme,
};
let difficulty: DifficultyPf2e = 'trivial';
if (totalXp >= thr.extreme) difficulty = 'extreme';
else if (totalXp >= thr.severe) difficulty = 'severe';
else if (totalXp >= thr.moderate) difficulty = 'moderate';
else if (totalXp >= thr.low) difficulty = 'low';
return {
system: 'pf2e', difficulty, ratingXp: totalXp, totalXp,
awardPerCharacter: totalXp, // PF2e XP budget is already per-character
thresholds: [
{ label: 'Trivial', value: thr.trivial }, { label: 'Low', value: thr.low },
{ label: 'Moderate', value: thr.moderate }, { label: 'Severe', value: thr.severe },
{ label: 'Extreme', value: thr.extreme },
],
monstersCounted: mons.length,
};
}
export function computeBudget(system: SystemId, partyLevels: number[], monsters: BudgetMonster[]): BudgetResult {
return system === 'pf2e' ? budgetPf2e(partyLevels, monsters) : budget5e(partyLevels, monsters);
}
export const DIFFICULTY_COLOR: Record<string, string> = {
trivial: 'text-muted', easy: 'text-success', low: 'text-success',
medium: 'text-info', moderate: 'text-info', hard: 'text-warning',
severe: 'text-warning', deadly: 'text-danger', extreme: 'text-danger',
};
+31 -1
View File
@@ -4,6 +4,7 @@ import {
addCombatant,
applyDamage,
applyHealing,
applyInitiatives,
currentCombatant,
endEncounter,
moveCombatant,
@@ -12,6 +13,7 @@ import {
removeCombatant,
setTempHp,
startEncounter,
tickConditions,
updateCombatant,
} from './engine';
@@ -21,6 +23,7 @@ function mk(id: string, initiative: number, hp = 10): Combatant {
name: id,
kind: 'monster',
initiative,
initBonus: 0,
ac: 12,
hp: { current: hp, max: hp, temp: 0 },
conditions: [],
@@ -31,7 +34,7 @@ function mk(id: string, initiative: number, hp = 10): Combatant {
function encounter(combatants: Combatant[]): Encounter {
return {
id: 'e1', campaignId: 'c1', name: 'Test', status: 'planning',
round: 0, turnIndex: 0, combatants,
round: 0, turnIndex: 0, combatants, log: [],
createdAt: '', updatedAt: '',
};
}
@@ -148,3 +151,30 @@ describe('endEncounter', () => {
expect(endEncounter(encounter([])).status).toBe('ended');
});
});
describe('condition durations', () => {
it('ticks duration down and removes expired conditions', () => {
const c = { ...mk('a', 0), conditions: [{ name: 'Frightened', value: 2, duration: 1 }, { name: 'Prone' }] };
const { combatant, expired } = tickConditions(c);
expect(expired).toEqual(['Frightened']);
expect(combatant.conditions.map((x) => x.name)).toEqual(['Prone']); // indefinite stays
});
it('nextTurn ticks the newly-active combatant and logs expiry', () => {
let e = startEncounter(encounter([mk('a', 20), mk('b', 10)]));
e = updateCombatant(e, 'b', { conditions: [{ name: 'Dazed', duration: 1 }] });
e = nextTurn(e); // b's turn begins -> Dazed expires
expect(currentCombatant(e)?.conditions).toHaveLength(0);
expect(e.log.some((l) => /Dazed wore off/.test(l.text))).toBe(true);
});
});
describe('applyInitiatives', () => {
it('reassigns initiative, re-sorts, and keeps the current combatant anchored', () => {
let e = startEncounter(encounter([mk('a', 20), mk('b', 10), mk('c', 5)]));
e = nextTurn(e); // current = b
e = applyInitiatives(e, { a: 1, b: 2, c: 30 });
expect(e.combatants.map((x) => x.id)).toEqual(['c', 'b', 'a']);
expect(currentCombatant(e)?.id).toBe('b');
});
});
+56 -8
View File
@@ -21,30 +21,78 @@ function indexOfId(combatants: Combatant[], id: string | undefined): number {
return combatants.findIndex((c) => c.id === id);
}
/** Append an event to the encounter log (immutably). */
export function logEvent(enc: Encounter, text: string): Encounter {
return { ...enc, log: [...(enc.log ?? []), { round: enc.round, text }] };
}
/**
* Tick a combatant's timed conditions down by one round and drop any that hit 0.
* Returns the updated combatant and the names of conditions that expired.
*/
export function tickConditions(c: Combatant): { combatant: Combatant; expired: string[] } {
const expired: string[] = [];
const conditions = c.conditions.flatMap((cond) => {
if (cond.duration === undefined) return [cond];
const remaining = cond.duration - 1;
if (remaining <= 0) {
expired.push(cond.name);
return [];
}
return [{ ...cond, duration: remaining }];
});
return { combatant: { ...c, conditions }, expired };
}
/** Reorder by initiative and start at the top of the order, round 1. */
export function startEncounter(enc: Encounter): Encounter {
return {
const started: Encounter = {
...enc,
status: 'active',
round: enc.combatants.length > 0 ? 1 : 0,
turnIndex: 0,
combatants: sortByInitiative(enc.combatants),
};
const first = currentCombatant(started);
return logEvent(logEvent(started, '⚔ Combat started — Round 1'), first ? `${first.name}'s turn` : '');
}
export function endEncounter(enc: Encounter): Encounter {
return { ...enc, status: 'ended' };
return logEvent({ ...enc, status: 'ended' }, '⚑ Combat ended');
}
/** Advance to the next combatant; wrapping past the end increments the round. */
/**
* Advance to the next combatant; wrapping past the end increments the round.
* Ticks the newly-active combatant's timed conditions and logs expirations.
*/
export function nextTurn(enc: Encounter): Encounter {
if (enc.combatants.length === 0) return { ...enc, turnIndex: 0 };
const atEnd = enc.turnIndex >= enc.combatants.length - 1;
return {
...enc,
turnIndex: atEnd ? 0 : enc.turnIndex + 1,
round: atEnd ? enc.round + 1 : enc.round,
};
const turnIndex = atEnd ? 0 : enc.turnIndex + 1;
const round = atEnd ? enc.round + 1 : enc.round;
let next: Encounter = { ...enc, turnIndex, round };
if (atEnd) next = logEvent(next, `— Round ${round}`);
// Tick the combatant whose turn is now beginning.
const active = next.combatants[turnIndex];
if (active) {
const { combatant, expired } = tickConditions(active);
const combatants = next.combatants.map((c, i) => (i === turnIndex ? combatant : c));
next = { ...next, combatants };
next = logEvent(next, `${combatant.name}'s turn`);
for (const name of expired) next = logEvent(next, ` ${combatant.name}: ${name} wore off`);
}
return next;
}
/** Re-roll/assign initiative for combatants and re-sort, anchoring the current turn. */
export function applyInitiatives(enc: Encounter, rolls: Record<string, number>): Encounter {
const currentId = currentCombatant(enc)?.id;
const updated = enc.combatants.map((c) => (c.id in rolls ? { ...c, initiative: rolls[c.id]! } : c));
const combatants = sortByInitiative(updated);
const turnIndex = enc.status === 'active' ? Math.max(0, indexOfId(combatants, currentId)) : 0;
return { ...enc, combatants, turnIndex };
}
/** Step back a turn; wrapping before the start decrements the round (min 1). */
+10
View File
@@ -39,6 +39,16 @@ export class TtrpgDatabase extends Dexie {
}
});
});
// v3 — Phase 3 combat depth: backfill the encounter event log.
this.version(3).stores({}).upgrade(async (tx) => {
await tx
.table('encounters')
.toCollection()
.modify((e: Record<string, unknown>) => {
if (e.log === undefined) e.log = [];
});
});
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ describe('validation on write', () => {
const c = await campaignsRepo.create({ name: 'C', system: '5e', description: '' });
const enc = await encountersRepo.create(c.id, 'E');
enc.combatants.push({
id: 'x', name: 'Goblin', kind: 'monster', initiative: 12, ac: 15,
id: 'x', name: 'Goblin', kind: 'monster', initiative: 12, initBonus: 0, ac: 15,
hp: { current: Number.NaN, max: 7, temp: 0 }, conditions: [], notes: '',
});
await expect(encountersRepo.save(enc)).rejects.toThrow();
+14
View File
@@ -13,14 +13,26 @@ export const combatantSchema = z.object({
monsterRef: z.string().optional(),
initiative: int,
/** modifier added when (re)rolling initiative */
initBonus: int.default(0),
ac: int,
hp: hpSchema,
conditions: z.array(conditionSchema).default([]),
notes: z.string().max(2000).default(''),
/** 5e challenge rating (for difficulty budget), if known */
cr: z.number().finite().nonnegative().optional(),
/** PF2e creature level (for difficulty budget), if known */
level: int.optional(),
});
export type Combatant = z.infer<typeof combatantSchema>;
export const logEntrySchema = z.object({
round: int.nonnegative(),
text: z.string(),
});
export type LogEntry = z.infer<typeof logEntrySchema>;
export const encounterStatusSchema = z.enum(['planning', 'active', 'ended']);
export const encounterSchema = z.object({
@@ -32,6 +44,8 @@ export const encounterSchema = z.object({
/** index into combatants of whose turn it is */
turnIndex: int.nonnegative().default(0),
combatants: z.array(combatantSchema).default([]),
/** newest-last event feed for the fight */
log: z.array(logEntrySchema).default([]),
createdAt: z.string(),
updatedAt: z.string(),
});