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:
@@ -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();
|
||||
});
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -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<Encounter[]>([]);
|
||||
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<string, number> = {};
|
||||
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` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{budget && (
|
||||
<div className="rounded-md border border-line bg-surface px-3 py-1 text-center text-xs">
|
||||
<div className={cn('font-display text-sm font-semibold capitalize', DIFFICULTY_COLOR[budget.difficulty])}>
|
||||
{budget.difficulty}
|
||||
</div>
|
||||
<div className="text-muted">
|
||||
{budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
{undoStack.current.length > 0 && (
|
||||
<Button variant="ghost" onClick={undo} title="Undo last change">↺ Undo</Button>
|
||||
)}
|
||||
{encounter.combatants.length > 0 && (
|
||||
<Button variant="secondary" onClick={rollAllInitiative} title="Roll initiative for everyone">
|
||||
🎲 Roll all
|
||||
</Button>
|
||||
)}
|
||||
{!isActive && encounter.status !== 'ended' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -92,14 +137,37 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
glossary={glossary}
|
||||
isCurrent={isActive && idx === encounter.turnIndex}
|
||||
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
|
||||
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
|
||||
onHeal={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }))}
|
||||
onDamage={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }), `${c.name} takes ${amt} damage`))}
|
||||
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
|
||||
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
|
||||
onRemove={() => mutate((e) => removeCombatant(e, c.id))}
|
||||
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<CombatLog log={encounter.log ?? []} onClear={() => mutate((e) => ({ ...e, log: [] }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CombatLog({ log, onClear }: { log: { round: number; text: string }[]; onClear: () => void }) {
|
||||
if (log.length === 0) return null;
|
||||
const recent = log.slice(-40).reverse();
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Combat Log</h3>
|
||||
<Button size="sm" variant="ghost" onClick={onClear}>Clear</Button>
|
||||
</div>
|
||||
<ul className="max-h-48 space-y-0.5 overflow-auto rounded-md border border-line bg-surface p-2 text-xs">
|
||||
{recent.map((l, i) => (
|
||||
<li key={i} className="text-muted">
|
||||
<span className="mr-2 text-accent/70">R{l.round}</span>
|
||||
{l.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<NumberField className="w-20" value={hp} min={0} onChange={setHp} aria-label="Hit points" />
|
||||
</label>
|
||||
<label className="text-xs text-muted">
|
||||
Qty
|
||||
<NumberField className="w-14" value={qty} min={1} onChange={setQty} aria-label="Quantity" />
|
||||
</label>
|
||||
<Button variant="primary" onClick={addCustom}>
|
||||
Add
|
||||
</Button>
|
||||
@@ -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)` : ''} ✕
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -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({
|
||||
<option value="__custom__">Custom…</option>
|
||||
</Select>
|
||||
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted" title="Auto-expires after N rounds (0 = indefinite)">
|
||||
<NumberField className="w-12" value={dur} min={0} onChange={setDur} aria-label="Condition duration in rounds" />
|
||||
rds
|
||||
</label>
|
||||
|
||||
{selected?.valued && (
|
||||
<>
|
||||
<NumberField className="w-14" value={value} min={1} onChange={setValue} aria-label="Condition value" />
|
||||
|
||||
@@ -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<string | null>(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}).`);
|
||||
|
||||
@@ -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) => <MonsterDetail monster={e as unknown as Monster} />,
|
||||
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' }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 = [];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user