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