From 9ecd817bc6b775ebe8425b0291c704ff42cb52d6 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 00:30:34 +0200 Subject: [PATCH] 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) --- e2e/character-depth.spec.ts | 53 +++++++ src/features/characters/CharacterSheet.tsx | 42 +++--- .../characters/sheet/AttacksSection.tsx | 81 ++++++++++ .../characters/sheet/DefensesSection.tsx | 86 +++++++++++ .../characters/sheet/InventorySection.tsx | 128 ++++++++++++++++ .../characters/sheet/ResourcesSection.tsx | 83 +++++++++++ .../characters/sheet/SpellcastingSection.tsx | 141 ++++++++++++++++++ src/features/characters/sheet/common.tsx | 28 ++++ src/lib/db/db.ts | 13 ++ src/lib/rules/abilities.ts | 7 + src/lib/rules/depth.test.ts | 101 +++++++++++++ src/lib/rules/dnd5e/index.ts | 31 ++++ src/lib/rules/index.ts | 3 +- src/lib/rules/pf2e/index.ts | 34 ++++- src/lib/rules/rest.ts | 54 +++++++ src/lib/rules/types.ts | 57 +++++++ src/lib/schemas/character.ts | 123 +++++++++++++++ src/lib/schemas/common.ts | 12 ++ src/lib/useDebouncedCallback.ts | 15 +- tsconfig.app.tsbuildinfo | 2 +- 20 files changed, 1069 insertions(+), 25 deletions(-) create mode 100644 e2e/character-depth.spec.ts create mode 100644 src/features/characters/sheet/AttacksSection.tsx create mode 100644 src/features/characters/sheet/DefensesSection.tsx create mode 100644 src/features/characters/sheet/InventorySection.tsx create mode 100644 src/features/characters/sheet/ResourcesSection.tsx create mode 100644 src/features/characters/sheet/SpellcastingSection.tsx create mode 100644 src/features/characters/sheet/common.tsx create mode 100644 src/lib/rules/depth.test.ts create mode 100644 src/lib/rules/rest.ts diff --git a/e2e/character-depth.spec.ts b/e2e/character-depth.spec.ts new file mode 100644 index 0000000..964fc57 --- /dev/null +++ b/e2e/character-depth.spec.ts @@ -0,0 +1,53 @@ +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('character depth: inventory, spellcasting, attacks, resources + rest', async ({ page }) => { + // Campaign + character + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Phandelver'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: 'Characters' }).click(); + await page.getByRole('button', { name: '+ New character' }).first().click(); + await page.locator('input[data-autofocus]').fill('Gandalf'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: /Gandalf/ }).click(); + + // Set INT high so spell DC is computable + await page.getByLabel('INT score').fill('18'); + + // Inventory — add an item via Enter + await page.getByPlaceholder('Longsword', { exact: true }).fill('Staff of Power'); + await page.getByPlaceholder('Longsword', { exact: true }).press('Enter'); + await expect(page.locator('input[aria-label="Item name"]')).toHaveValue('Staff of Power'); + + // Spellcasting — choose ability, expect a derived DC to appear + await page.getByLabel('Casting ability').selectOption('int'); + await expect(page.getByText('Spell DC')).toBeVisible(); + + // Attacks — add one, expect a computed to-hit + await page.getByPlaceholder('Longsword, Shortbow…').fill('Quarterstaff'); + await page.getByPlaceholder('Longsword, Shortbow…').press('Enter'); + await expect(page.getByText('to hit')).toBeVisible(); + + // Resources — add one, spend it, long rest restores it + await page.getByPlaceholder('Ki Points, Rage, Focus…').fill('Sorcery Points'); + await page.getByPlaceholder('Ki Points, Rage, Focus…').press('Enter'); + // Only one resource exists, so the controls are unambiguous. + await page.getByRole('button', { name: 'Spend' }).click(); + await expect(page.getByText('0/1')).toBeVisible(); + await page.getByRole('button', { name: 'Long Rest' }).click(); + await expect(page.getByText('1/1')).toBeVisible(); + + // Reload — persistence survived (autosave). Wait for the debounce + beforeunload flush. + await page.waitForTimeout(500); + await page.reload(); + await expect(page.locator('input[aria-label="Item name"]')).toHaveValue('Staff of Power'); +}); diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 4c30b5e..d9239bd 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -11,6 +11,11 @@ import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; import { NumberField } from '@/components/ui/NumberField'; import { formatModifier } from '@/lib/format'; +import { InventorySection } from './sheet/InventorySection'; +import { AttacksSection } from './sheet/AttacksSection'; +import { ResourcesSection } from './sheet/ResourcesSection'; +import { SpellcastingSection } from './sheet/SpellcastingSection'; +import { DefensesSection } from './sheet/DefensesSection'; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; @@ -28,23 +33,9 @@ export function CharacterSheet({ character }: { character: Character }) { // Local editable copy; write-through to the DB on change (debounced + flush on unmount). const [c, setC] = useState(character); const save = useDebouncedCallback((next: Character) => { - void charactersRepo.update(next.id, { - name: next.name, - ancestry: next.ancestry, - className: next.className, - level: next.level, - kind: next.kind, - abilities: next.abilities, - hp: next.hp, - speed: next.speed, - armorBonus: next.armorBonus, - skillRanks: next.skillRanks, - saveRanks: next.saveRanks, - perceptionRank: next.perceptionRank, - ...(next.spellcastingAbility ? { spellcastingAbility: next.spellcastingAbility } : {}), - ...(next.spellcastingRank ? { spellcastingRank: next.spellcastingRank } : {}), - notes: next.notes, - }); + // Persist everything except identity/timestamps (update() stamps updatedAt). + const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next; + void charactersRepo.update(id, rest); }, 350); const update = (patch: Partial) => { @@ -192,13 +183,28 @@ export function CharacterSheet({ character }: { character: Character }) { + {/* Status & defenses (system-specific) */} + + + {/* Attacks + passive perception */} + + + {/* Spellcasting */} + + + {/* Class resources + rest */} + + + {/* Inventory, currency, encumbrance */} + + {/* Notes */}
Notes