Phase 1: player character depth

- Inventory + currency + encumbrance vs carrying capacity
- Class resources with correct short/long/daily rest recovery (applyRest)
- Spellcasting: slots (+pact), known/prepared spells, derived DC + attack
- Defenses: 5e death saves/inspiration/exhaustion, pf2e dying/wounded/hero points
- Derived weapon attacks + passive perception via extended RulesSystem
- Dexie v2 migration backfills new fields; debounced save flushes on pagehide
- 9 new unit tests, new character-depth e2e; all green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:30:34 +02:00
parent 1a9e5e2c18
commit 9ecd817bc6
20 changed files with 1069 additions and 25 deletions
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { dnd5e } from './dnd5e';
import { pf2e } from './pf2e';
import { applyRest } from './rest';
import type { CharacterRulesInput } from './types';
import type { Character } from '@/lib/schemas/character';
import { characterDefaults } from '@/lib/schemas/character';
const fighter5e: CharacterRulesInput = {
level: 5,
abilities: { str: 18, dex: 14, con: 16, int: 10, wis: 12, cha: 8 },
skillRanks: { perception: 'trained' },
};
describe('5e derived combat math', () => {
it('weapon attack: ability + proficiency + item, ability added to damage', () => {
const r = dnd5e.weaponAttack(fighter5e, { ability: 'str', rank: 'trained', damageDice: '1d8', itemBonus: 1 });
expect(r.toHit).toBe(4 + 3 + 1); // STR +4, PB +3, item +1
expect(r.damage).toBe('1d8+5'); // STR +4 + item +1
});
it('off-hand damage omits the ability modifier', () => {
const r = dnd5e.weaponAttack(fighter5e, { ability: 'str', rank: 'trained', damageDice: '1d6', addAbilityToDamage: false });
expect(r.damage).toBe('1d6');
});
it('spell attack and passive perception', () => {
expect(dnd5e.spellAttackBonus(fighter5e, 'wis')).toBe(3 + 1);
expect(dnd5e.passivePerception(fighter5e)).toBe(10 + 1 + 3); // 10 + WIS + PB
});
it('carrying capacity is STR*15', () => {
expect(dnd5e.carryingCapacity(fighter5e)).toEqual({ encumbered: 90, max: 270, unit: 'lb' });
});
});
describe('pf2e derived combat math', () => {
const pcg: CharacterRulesInput = {
level: 3,
abilities: { str: 18, dex: 12, con: 14, int: 10, wis: 14, cha: 10 },
perceptionRank: 'expert',
spellcastingRank: 'trained',
};
it('weapon attack adds level + rank bonus', () => {
const r = pf2e.weaponAttack(pcg, { ability: 'str', rank: 'trained', damageDice: '1d8' });
expect(r.toHit).toBe(4 + (3 + 2)); // STR +4, trained = level3 + 2
expect(r.damage).toBe('1d8+4');
});
it('passive perception uses perception proficiency', () => {
expect(pf2e.passivePerception(pcg)).toBe(10 + 2 + (3 + 4)); // 10 + WIS + (level + expert)
});
it('carrying capacity is Bulk', () => {
expect(pf2e.carryingCapacity(pcg)).toEqual({ encumbered: 5 + 4, max: 10 + 4, unit: 'Bulk' });
});
});
function makeCharacter(): Character {
return {
id: 'c', campaignId: 'camp', system: '5e', kind: 'pc',
name: 'Test', ancestry: '', className: 'Wizard', level: 5,
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
hp: { current: 4, max: 30, temp: 0 }, speed: 30, armorBonus: 0,
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
...characterDefaults(),
resources: [
{ id: 'r1', name: 'Sorcery Points', current: 0, max: 5, recovery: 'long' },
{ id: 'r2', name: 'Channel Divinity', current: 0, max: 1, recovery: 'short' },
],
spellcasting: {
slots: [{ level: 1, max: 4, current: 0 }, { level: 2, max: 3, current: 1 }],
pact: { level: 1, max: 2, current: 0 },
spells: [],
},
defenses: { ...characterDefaults().defenses, exhaustion: 2 },
notes: '', createdAt: '', updatedAt: '',
};
}
describe('applyRest', () => {
it('short rest restores short resources + pact, not HP/slots/long resources', () => {
const c = makeCharacter();
const short = dnd5e.restOptions.find((o) => o.id === 'short')!;
const patch = applyRest(c, short);
expect(patch.hp).toBeUndefined();
expect(patch.resources!.find((r) => r.id === 'r2')!.current).toBe(1); // short resource restored
expect(patch.resources!.find((r) => r.id === 'r1')!.current).toBe(0); // long resource untouched
expect(patch.spellcasting!.pact!.current).toBe(2); // pact restored
expect(patch.spellcasting!.slots[0]!.current).toBe(0); // slots NOT restored on short rest
});
it('long rest restores HP, all slots, all resources, steps exhaustion down, clears death saves', () => {
const c = makeCharacter();
const long = dnd5e.restOptions.find((o) => o.id === 'long')!;
const patch = applyRest(c, long);
expect(patch.hp!.current).toBe(30);
expect(patch.resources!.every((r) => r.current === r.max)).toBe(true);
expect(patch.spellcasting!.slots.every((s) => s.current === s.max)).toBe(true);
expect(patch.defenses!.exhaustion).toBe(1);
expect(patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
});
});