Proper guided character builder + level-up wizard

The bare name/class/level form left new characters as shells (1 HP, all-10
abilities, no proficiencies). Now creation and level-up are guided and auto-populate.

- Hand-authored progression tables (no SRD class/slot data exists on disk):
  src/lib/rules/{dnd5e,pf2e}/progression.ts — core classes' hit die/HP, key
  abilities, save & perception proficiencies, trained-skill counts, caster type;
  5e full/half/pact spell-slot tables, pf2e slot approximation; ASI/boost/skill
  schedules.
- src/lib/rules/progression.ts: getClassDefs/getClassDef, classSkillChoices/Count,
  buildCharacter() (HP from hit die + CON over levels, trained saves/skills, spell
  slots + ability), planLevelUp() (HP gain, new slots, ASI/boost/skill choices),
  applyIncreases/bumpRank. Pure + 12 unit tests.
- Multi-step CreationWizard (Basics → Abilities → Skills → Review) replaces the
  old form; pulls PF2e ancestry HP/speed from the bestiary; review previews HP/AC/
  saves/skills/slots; lands on a ready-to-play sheet.
- LevelUpModal reworked into a guided wizard: auto HP + spell slots, presents the
  level's actual choices (5e ASI/feat, pf2e ability boosts + skill increase) and
  applies them; keeps the strategic build-route advisor.

e2e helper drives the wizard; updated 7 specs to the new flow; new character-wizard
spec asserts a complete level-3 Wizard (HP 17, slots 4xL1 2xL2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:55:48 +02:00
parent 7271978d7d
commit 04fa90e580
18 changed files with 946 additions and 147 deletions
+5 -10
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -14,18 +15,12 @@ test('generate ability scores and level up', async ({ page }) => {
await page.locator('input[data-autofocus]').fill('Build');
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('Builder');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: /Builder/ }).click();
// Generate scores via standard array (default assignment 15,14,13,12,10,8)
await page.getByRole('button', { name: 'Generate' }).click();
await page.getByRole('button', { name: 'Apply' }).click();
// Guided wizard builds a complete level-1 character (standard array → STR 15).
await createCharacter(page, 'Builder');
await expect(page.getByLabel('STR score')).toHaveValue('15');
// Level up from 1 -> 2
// Guided level-up from 1 -> 2 (HP + any choices applied automatically)
await page.getByRole('button', { name: 'Level up' }).click();
await page.getByRole('button', { name: 'Level up' }).last().click();
await page.getByRole('button', { name: /Apply level 2/ }).click();
await expect(page.getByRole('spinbutton', { name: 'Level', exact: true })).toHaveValue('2');
});
+2 -4
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -15,10 +16,7 @@ test('character depth: inventory, spellcasting, attacks, resources + rest', asyn
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();
await createCharacter(page, 'Gandalf');
// Set INT high so spell DC is computable
await page.getByLabel('INT score').fill('18');
+42
View File
@@ -0,0 +1,42 @@
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('creation wizard builds a complete character (HP, skills, spell slots)', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Wizard Camp');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click();
// Basics: a level-3 Wizard
await page.getByLabel('Name').fill('Mira');
await page.getByLabel('Class').selectOption('Wizard');
await page.getByLabel('Level', { exact: true }).fill('3');
await page.getByRole('button', { name: 'Next' }).click();
// Abilities: standard array assignment is valid by default (CON 13 → +1)
await page.getByRole('button', { name: 'Next' }).click();
// Skills: Wizard chooses 2
const boxes = page.locator('input[type="checkbox"]');
await boxes.nth(0).check();
await boxes.nth(1).check();
await page.getByRole('button', { name: 'Next' }).click();
// Review shows derived HP + spell slots before finishing
await expect(page.getByText('Max HP')).toBeVisible();
await expect(page.getByText('Spell slots')).toBeVisible();
await page.getByRole('button', { name: 'Create character' }).click();
// Lands on the sheet with real HP (d6 + CON over 3 levels = 17), not the old 1/1
await expect(page.getByText('Hit Points')).toBeVisible();
await expect(page.getByText(/\/\s*17/)).toBeVisible();
});
+2 -3
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -15,9 +16,7 @@ test('compendium: browse 5e categories, switch to PF2e, cross-link a spell', asy
await page.locator('input[data-autofocus]').fill('Saltmarsh');
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('Wizard Wendy');
await page.getByRole('button', { name: 'Create' }).click();
await createCharacter(page, 'Wizard Wendy');
// Compendium → 5e categories exist (Feats is a new one)
await page.getByRole('link', { name: 'Compendium' }).click();
+3 -3
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -66,9 +67,8 @@ test('combat condition picker adds preset and valued conditions as tags', async
test('character can be deleted', async ({ page }) => {
await makeCampaign(page, 'Delete Test');
await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click();
await page.locator('input[data-autofocus]').fill('Doomed');
await page.getByRole('button', { name: 'Create' }).click();
await createCharacter(page, 'Doomed'); // lands on the sheet
await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click();
await expect(page.getByRole('heading', { name: 'Doomed' })).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
+22
View File
@@ -0,0 +1,22 @@
import { type Page } from '@playwright/test';
/**
* Walk the guided creation wizard and land on the new character's sheet.
* Uses the default class; selects the minimum required trained skills.
*/
export async function createCharacter(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: '+ New character' }).first().click();
await page.getByLabel('Name').fill(name);
await page.getByRole('button', { name: 'Next' }).click(); // → Abilities (standard array is valid by default)
await page.getByRole('button', { name: 'Next' }).click(); // → Skills
const next = page.getByRole('button', { name: 'Next' });
const boxes = page.locator('input[type="checkbox"]');
const count = await boxes.count();
for (let i = 0; i < count; i++) {
if (!(await next.isDisabled())) break;
await boxes.nth(i).check();
}
await next.click(); // → Review
await page.getByRole('button', { name: 'Create character' }).click();
}
+2 -4
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -14,10 +15,7 @@ test('rolling a skill from the sheet shows the roll tray', async ({ page }) => {
await page.locator('input[data-autofocus]').fill('Dice Camp');
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('Roller');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: /Roller/ }).click();
await createCharacter(page, 'Roller');
// Click the Acrobatics modifier to roll it
await page.getByTitle('Roll Acrobatics (DEX)').click();
+2 -3
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -16,9 +17,7 @@ test('player view shows the party and hides enemy HP numbers', async ({ page })
// A player character
await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click();
await page.locator('input[data-autofocus]').fill('Hero');
await page.getByRole('button', { name: 'Create' }).click();
await createCharacter(page, 'Hero');
// An active encounter with the hero + a monster
await page.getByRole('link', { name: 'Combat' }).click();
+2 -4
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
// Each test starts from a clean IndexedDB so runs are deterministic.
test.beforeEach(async ({ page }) => {
@@ -24,10 +25,7 @@ test('full core flow: campaign → character → dice → combat → compendium'
// --- Character ---
await page.getByRole('link', { name: 'Characters' }).click();
await expect(page.getByRole('heading', { name: 'Characters' })).toBeVisible();
await page.getByRole('button', { name: '+ New character' }).first().click();
await page.locator('input[data-autofocus]').fill('Ireena');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: /Ireena/ }).click();
await createCharacter(page, 'Ireena');
// On the sheet: set STR to 16 and expect +3 modifier
await page.getByLabel('STR score').fill('16');