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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user