From 3e5bdc06e2189431a586e6cdb84dbb1dda7c1df4 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 01:23:13 +0200 Subject: [PATCH] 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) --- e2e/combat-depth.spec.ts | 44 ++++++ e2e/smoke.spec.ts | 2 +- src/features/combat/EncounterTracker.tsx | 127 +++++++++++++++--- src/features/compendium/CompendiumPage.tsx | 7 +- src/features/compendium/registry.tsx | 9 +- src/lib/combat/budget.test.ts | 45 +++++++ src/lib/combat/budget.ts | 149 +++++++++++++++++++++ src/lib/combat/engine.test.ts | 32 ++++- src/lib/combat/engine.ts | 64 +++++++-- src/lib/db/db.ts | 10 ++ src/lib/db/repositories.test.ts | 2 +- src/lib/schemas/encounter.ts | 14 ++ tsconfig.app.tsbuildinfo | 2 +- 13 files changed, 472 insertions(+), 35 deletions(-) create mode 100644 e2e/combat-depth.spec.ts create mode 100644 src/lib/combat/budget.test.ts create mode 100644 src/lib/combat/budget.ts diff --git a/e2e/combat-depth.spec.ts b/e2e/combat-depth.spec.ts new file mode 100644 index 0000000..5789dfe --- /dev/null +++ b/e2e/combat-depth.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(async () => { + indexedDB.deleteDatabase('ttrpg-manager'); + localStorage.clear(); + }); + await page.reload(); +}); + +test('combat depth: timed condition auto-expires, log records events, roll-all works', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Depth'); + await page.getByRole('button', { name: 'Create' }).click(); + + await page.getByRole('link', { name: 'Combat' }).click(); + await page.getByRole('button', { name: '+ New encounter' }).first().click(); + await page.locator('input[data-autofocus]').fill('Brawl'); + await page.getByRole('button', { name: 'Create' }).click(); + + await page.getByLabel('Name').fill('Goblin'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + const row = page.locator('li', { hasText: 'Goblin' }); + + // Add Prone with a 1-round duration + await row.getByLabel('Condition duration in rounds').fill('1'); + await row.getByLabel('Add condition').selectOption('Prone'); + await expect(row.getByRole('button', { name: /Prone \(1r\)/ })).toBeVisible(); + + // Roll-all initiative + await page.getByRole('button', { name: /Roll all/ }).click(); + + // Start combat, then advance a turn — Prone should expire on Goblin's next turn + await page.getByRole('button', { name: 'Start combat' }).click(); + await expect(page.getByRole('button', { name: 'End' })).toBeVisible(); // combat is active + await page.getByRole('button', { name: /Next turn/ }).click(); + await expect(row.getByRole('button', { name: /Prone/ })).toHaveCount(0); + + // Combat log captured the events + await expect(page.getByText('Combat Log')).toBeVisible(); + await expect(page.getByText(/Prone wore off/)).toBeVisible(); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index c332757..17a5527 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -48,7 +48,7 @@ test('full core flow: campaign → character → dice → combat → compendium' await page.getByRole('button', { name: 'Add', exact: true }).click(); await expect(page.getByText('Goblin')).toBeVisible(); await page.getByRole('button', { name: 'Start combat' }).click(); - await expect(page.getByText(/Round 1/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'End' })).toBeVisible(); // combat active // --- Compendium --- await page.getByRole('link', { name: 'Compendium' }).click(); diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 48895f0..c019638 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -1,18 +1,21 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import type { Campaign, Character, Combatant, Condition, Encounter } from '@/lib/schemas'; import { encountersRepo } from '@/lib/db/repositories'; import { newId } from '@/lib/ids'; import { createRng } from '@/lib/rng'; import { rollDice } from '@/lib/dice/notation'; import { getSystem, getConditions, type SystemId } from '@/lib/rules'; +import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget'; import { useCharacters } from '@/features/characters/hooks'; import { useConditionGlossary } from './useConditionGlossary'; import { addCombatant, applyDamage, applyHealing, + applyInitiatives, currentCombatant, endEncounter, + logEvent, moveCombatant, nextTurn, previousTurn, @@ -20,6 +23,7 @@ import { startEncounter, updateCombatant, } from '@/lib/combat/engine'; +import { cn } from '@/lib/cn'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; import { NumberField } from '@/components/ui/NumberField'; @@ -28,9 +32,32 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter const characters = useCharacters(campaign.id); const glossary = useConditionGlossary(campaign.system); + const undoStack = useRef([]); const mutate = (fn: (e: Encounter) => Encounter) => { + undoStack.current.push(encounter); + if (undoStack.current.length > 50) undoStack.current.shift(); void encountersRepo.save(fn(encounter)); }; + const undo = () => { + const prev = undoStack.current.pop(); + if (prev) void encountersRepo.save(prev); + }; + + const rollAllInitiative = () => { + const rolls: Record = {}; + for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus; + mutate((e) => logEvent(applyInitiatives(e, rolls), 'Rolled initiative for all combatants')); + }; + + // Difficulty budget from monster combatants vs the campaign's PCs. + const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level); + const monsters = encounter.combatants + .filter((c) => c.kind === 'monster') + .map((c) => ({ cr: c.cr, level: c.level })); + const budget = + monsters.length > 0 && partyLevels.length > 0 + ? computeBudget(campaign.system, partyLevels, monsters) + : null; const current = currentCombatant(encounter); const isActive = encounter.status === 'active'; @@ -46,7 +73,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter {current && isActive ? ` · ${current.name}'s turn` : ''} -
+ {budget && ( +
+
+ {budget.difficulty} +
+
+ {budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC +
+
+ )} +
+ {undoStack.current.length > 0 && ( + + )} + {encounter.combatants.length > 0 && ( + + )} {!isActive && encounter.status !== 'ended' && (
+ ); +} + +function CombatLog({ log, onClear }: { log: { round: number; text: string }[]; onClear: () => void }) { + if (log.length === 0) return null; + const recent = log.slice(-40).reverse(); + return ( +
+
+

Combat Log

+ +
+
    + {recent.map((l, i) => ( +
  • + R{l.round} + {l.text} +
  • + ))} +
); } @@ -126,20 +194,26 @@ function AddCombatantBar({ const [init, setInit] = useState(10); const [ac, setAc] = useState(12); const [hp, setHp] = useState(10); + const [qty, setQty] = useState(1); const addCustom = () => { if (name.trim() === '') return; - onAdd({ - id: newId(), - name: name.trim(), - kind: 'monster', - initiative: init, - ac, - hp: { current: hp, max: hp, temp: 0 }, - conditions: [], - notes: '', - }); + const n = Math.max(1, qty); + for (let i = 0; i < n; i++) { + onAdd({ + id: newId(), + name: n > 1 ? `${name.trim()} ${i + 1}` : name.trim(), + kind: 'monster', + initiative: init, + initBonus: 0, + ac, + hp: { current: hp, max: hp, temp: 0 }, + conditions: [], + notes: '', + }); + } setName(''); + setQty(1); }; const addCharacter = (charId: string) => { @@ -152,6 +226,7 @@ function AddCombatantBar({ kind: ch.kind, characterId: ch.id, initiative: rollInitiativeFor(ch), + initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }), ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus }), hp: { ...ch.hp }, conditions: [], @@ -178,6 +253,10 @@ function AddCombatantBar({ HP + @@ -260,7 +339,8 @@ function CombatantRow({ onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })} > {cond.name} - {cond.value ? ` ${cond.value}` : ''} ✕ + {cond.value ? ` ${cond.value}` : ''} + {cond.duration ? ` (${cond.duration}r)` : ''} ✕ ))}
@@ -319,6 +399,7 @@ function ConditionPicker({ }) { const [sel, setSel] = useState(''); const [value, setValue] = useState(1); + const [dur, setDur] = useState(0); const [custom, setCustom] = useState(''); const conditions = getConditions(system); @@ -329,18 +410,21 @@ function ConditionPicker({ const reset = () => { setSel(''); setValue(1); + setDur(0); setCustom(''); }; + const withDuration = (cond: Condition): Condition => (dur > 0 ? { ...cond, duration: dur } : cond); + const addCustom = () => { if (custom.trim() === '') return; - onAdd({ name: custom.trim() }); + onAdd(withDuration({ name: custom.trim() })); reset(); }; const addValued = () => { if (!selected) return; - onAdd({ name: selected.name, value }); + onAdd(withDuration({ name: selected.name, value })); reset(); }; @@ -350,7 +434,7 @@ function ConditionPicker({ if (name === '' || name === '__custom__') return; const def = conditions.find((c) => c.name === name); if (def && !def.valued) { - onAdd({ name: def.name }); + onAdd(withDuration({ name: def.name })); reset(); } }; @@ -374,6 +458,11 @@ function ConditionPicker({ + + {selected?.valued && ( <> diff --git a/src/features/compendium/CompendiumPage.tsx b/src/features/compendium/CompendiumPage.tsx index c9f6e05..34c7c2d 100644 --- a/src/features/compendium/CompendiumPage.tsx +++ b/src/features/compendium/CompendiumPage.tsx @@ -248,7 +248,7 @@ function DetailActions({ category, entry, system }: { category: CategoryDef; ent ); } -function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number } }) { +function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) { const activeEncounterId = useUiStore((s) => s.activeEncounterId); const [msg, setMsg] = useState(null); @@ -260,8 +260,11 @@ function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; await encountersRepo.save( addCombatant(enc, { id: newId(), name: stats.name, kind: 'monster', - initiative, ac: stats.ac, hp: { current: stats.hp, max: stats.hp, temp: 0 }, + initiative, initBonus: stats.initBonus, ac: stats.ac, + hp: { current: stats.hp, max: stats.hp, temp: 0 }, conditions: [], notes: '', + ...(stats.cr !== undefined ? { cr: stats.cr } : {}), + ...(stats.level !== undefined ? { level: stats.level } : {}), }), ); setMsg(`Added to "${enc.name}" (init ${initiative}).`); diff --git a/src/features/compendium/registry.tsx b/src/features/compendium/registry.tsx index 3a9c9c2..8274aca 100644 --- a/src/features/compendium/registry.tsx +++ b/src/features/compendium/registry.tsx @@ -46,7 +46,7 @@ export interface CategoryDef { /** enables "add to character" for spells/items */ linkAs?: 'spell' | 'item'; /** enables "add to combat" for monsters/creatures */ - toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number }; + toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number }; /** optional numeric sort axis (e.g. CR, spell level) in addition to name */ numericSort?: { label: string; get: (e: Entry) => number }; } @@ -151,7 +151,11 @@ export const CATEGORIES: CategoryDef[] = [ detail: (e) => , toCombatant: (e) => { const m = e as unknown as Monster; - return { name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1, initBonus: abilityModifier(m.dexterity ?? 10) }; + return { + name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1, + initBonus: abilityModifier(m.dexterity ?? 10), + ...(m.cr !== undefined ? { cr: m.cr } : {}), + }; }, numericSort: { label: 'CR', get: (e) => Number(e.cr) }, filters: [ @@ -228,6 +232,7 @@ export const CATEGORIES: CategoryDef[] = [ ac: Number(e.ac) || 10, hp: Number(e.hp) || 1, initBonus: Number(e.perception) || 0, + ...(e.level !== undefined ? { level: Number(e.level) } : {}), }), }, pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }), diff --git a/src/lib/combat/budget.test.ts b/src/lib/combat/budget.test.ts new file mode 100644 index 0000000..851ce4d --- /dev/null +++ b/src/lib/combat/budget.test.ts @@ -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); + }); +}); diff --git a/src/lib/combat/budget.ts b/src/lib/combat/budget.ts new file mode 100644 index 0000000..feb0f59 --- /dev/null +++ b/src/lib/combat/budget.ts @@ -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 = { + 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 = { + 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 = { + [-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 = { + 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', +}; diff --git a/src/lib/combat/engine.test.ts b/src/lib/combat/engine.test.ts index e20f9c4..020f6e0 100644 --- a/src/lib/combat/engine.test.ts +++ b/src/lib/combat/engine.test.ts @@ -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'); + }); +}); diff --git a/src/lib/combat/engine.ts b/src/lib/combat/engine.ts index 78af2a6..e0cde29 100644 --- a/src/lib/combat/engine.ts +++ b/src/lib/combat/engine.ts @@ -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): 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). */ diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index a0088e6..612d09c 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -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) => { + if (e.log === undefined) e.log = []; + }); + }); } } diff --git a/src/lib/db/repositories.test.ts b/src/lib/db/repositories.test.ts index 678c121..0faf961 100644 --- a/src/lib/db/repositories.test.ts +++ b/src/lib/db/repositories.test.ts @@ -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(); diff --git a/src/lib/schemas/encounter.ts b/src/lib/schemas/encounter.ts index 03900e7..fdf383e 100644 --- a/src/lib/schemas/encounter.ts +++ b/src/lib/schemas/encounter.ts @@ -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; +export const logEntrySchema = z.object({ + round: int.nonnegative(), + text: z.string(), +}); +export type LogEntry = z.infer; + 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(), }); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 9697628..492986c 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file