diff --git a/e2e/character-build.spec.ts b/e2e/character-build.spec.ts
new file mode 100644
index 0000000..748ef5f
--- /dev/null
+++ b/e2e/character-build.spec.ts
@@ -0,0 +1,31 @@
+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('generate ability scores and level up', async ({ page }) => {
+ await page.getByRole('button', { name: '+ New campaign' }).first().click();
+ 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();
+ await expect(page.getByLabel('STR score')).toHaveValue('15');
+
+ // Level up from 1 -> 2
+ await page.getByRole('button', { name: 'Level up' }).click();
+ await page.getByRole('button', { name: 'Level up' }).last().click();
+ await expect(page.getByRole('spinbutton', { name: 'Level', exact: true })).toHaveValue('2');
+});
diff --git a/src/components/ui/RollTray.tsx b/src/components/ui/RollTray.tsx
index e8f5611..097c2bd 100644
--- a/src/components/ui/RollTray.tsx
+++ b/src/components/ui/RollTray.tsx
@@ -9,7 +9,7 @@ export function RollTray() {
if (!last) return null;
return (
-
+
= {
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 [levelUp, setLevelUp] = useState(false);
+ const [genScores, setGenScores] = useState(false);
const save = useDebouncedCallback((next: Character) => {
// Persist everything except identity/timestamps (update() stamps updatedAt).
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
@@ -84,7 +88,11 @@ export function CharacterSheet({ character }: { character: Character }) {
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
- {profLabel}
+
+ {profLabel}
+
+
+
@@ -118,7 +126,10 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Abilities */}
- Ability Scores
+
+ Ability Scores
+
+
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
@@ -212,6 +223,13 @@ export function CharacterSheet({ character }: { character: Character }) {
className="min-h-32 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
+
+ {genScores && (
+
setGenScores(false)} onApply={(abilities) => update({ abilities })} />
+ )}
+ {levelUp && (
+ setLevelUp(false)} onApply={(patch) => update(patch)} />
+ )}
);
}
diff --git a/src/features/characters/sheet/AbilityGenModal.tsx b/src/features/characters/sheet/AbilityGenModal.tsx
new file mode 100644
index 0000000..0349324
--- /dev/null
+++ b/src/features/characters/sheet/AbilityGenModal.tsx
@@ -0,0 +1,123 @@
+import { useState } from 'react';
+import type { AbilityKey, AbilityScores } from '@/lib/rules';
+import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
+import { createRng } from '@/lib/rng';
+import {
+ STANDARD_ARRAY,
+ rollAbilityScores,
+ pointBuyRemaining,
+ POINT_BUY_MIN,
+ POINT_BUY_MAX,
+} from '@/lib/rules/abilityGen';
+import { formatModifier } from '@/lib/format';
+import { Modal } from '@/components/ui/Modal';
+import { Button } from '@/components/ui/Button';
+import { Select } from '@/components/ui/Input';
+import { NumberField } from '@/components/ui/NumberField';
+import { cn } from '@/lib/cn';
+
+const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
+type Method = 'standard' | 'roll' | 'pointbuy';
+
+export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: AbilityScores) => void; onClose: () => void }) {
+ const [method, setMethod] = useState('standard');
+ const [pool, setPool] = useState([...STANDARD_ARRAY]);
+ // assignment[i] = pool index assigned to ABILITIES[i], or -1
+ const [assignment, setAssignment] = useState(ABILITIES.map((_, i) => i));
+ const [pb, setPb] = useState([8, 8, 8, 8, 8, 8]);
+
+ const chooseMethod = (m: Method) => {
+ setMethod(m);
+ if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
+ if (m === 'roll') { const p = rollAbilityScores(createRng()); setPool(p); setAssignment(ABILITIES.map((_, i) => i)); }
+ };
+
+ const reroll = () => { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); };
+
+ const usesPool = method === 'standard' || method === 'roll';
+ const remaining = pointBuyRemaining(pb);
+
+ const toScores = (): AbilityScores => {
+ const out = {} as AbilityScores;
+ ABILITIES.forEach((a, i) => {
+ out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!;
+ });
+ return out;
+ };
+
+ const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
+ const pbValid = method !== 'pointbuy' || remaining >= 0;
+ const valid = poolValid && pbValid;
+
+ return (
+
+
+
+ >
+ }
+ >
+
+ {([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => (
+
+ ))}
+
+
+ {method === 'roll' && (
+
+ )}
+ {method === 'pointbuy' && (
+
+ Points remaining: {remaining} / 27
+
+ )}
+ {usesPool && !poolValid && Assign each value to a different ability.
}
+
+
+ {ABILITIES.map((a, i) => {
+ const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
+ return (
+
+
{ABILITY_ABBR[a]}
+ {usesPool ? (
+
+ ) : (
+
setPb((prev) => prev.map((x, j) => (j === i ? Math.max(POINT_BUY_MIN, Math.min(POINT_BUY_MAX, v)) : x)))}
+ />
+ )}
+ {formatModifier(abilityModifier(value))}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/features/characters/sheet/LevelUpModal.tsx b/src/features/characters/sheet/LevelUpModal.tsx
new file mode 100644
index 0000000..9062da5
--- /dev/null
+++ b/src/features/characters/sheet/LevelUpModal.tsx
@@ -0,0 +1,67 @@
+import { useState } from 'react';
+import type { Character } from '@/lib/schemas';
+import { abilityModifier } from '@/lib/rules';
+import { rollDice } from '@/lib/dice/notation';
+import { createRng } from '@/lib/rng';
+import { Modal } from '@/components/ui/Modal';
+import { Button } from '@/components/ui/Button';
+import { Select } from '@/components/ui/Input';
+
+const HIT_DICE = [6, 8, 10, 12] as const;
+
+export function LevelUpModal({ character, onApply, onClose }: {
+ character: Character;
+ onApply: (patch: Partial) => void;
+ onClose: () => void;
+}) {
+ const [die, setDie] = useState(8);
+ const [method, setMethod] = useState<'average' | 'roll'>('average');
+ const conMod = abilityModifier(character.abilities.con);
+
+ const average = Math.ceil(die / 2) + 1; // 5e fixed value per level
+ const apply = () => {
+ const base = method === 'average' ? average : rollDice(`1d${die}`, createRng()).total;
+ const gain = Math.max(1, base + conMod);
+ onApply({
+ level: Math.min(20, character.level + 1),
+ hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
+ });
+ onClose();
+ };
+
+ const preview = (method === 'average' ? average : `1d${die}`) + (conMod !== 0 ? ` ${conMod >= 0 ? '+' : ''}${conMod}` : '');
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+
+
+ HP gained: {preview} (CON {conMod >= 0 ? '+' : ''}{conMod})
+
+ {character.level >= 20 &&
Already at level 20.
}
+
+
+ );
+}
diff --git a/src/lib/rules/abilityGen.test.ts b/src/lib/rules/abilityGen.test.ts
new file mode 100644
index 0000000..3690983
--- /dev/null
+++ b/src/lib/rules/abilityGen.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import { rollAbilityScores, pointBuySpent, pointBuyRemaining, pointCost, STANDARD_ARRAY } from './abilityGen';
+import { createRng } from '@/lib/rng';
+
+describe('ability generation', () => {
+ it('standard array is the canonical spread', () => {
+ expect([...STANDARD_ARRAY]).toEqual([15, 14, 13, 12, 10, 8]);
+ });
+
+ it('rolls six scores each in 3..18', () => {
+ const scores = rollAbilityScores(createRng('chargen'));
+ expect(scores).toHaveLength(6);
+ for (const s of scores) {
+ expect(s).toBeGreaterThanOrEqual(3);
+ expect(s).toBeLessThanOrEqual(18);
+ }
+ });
+
+ it('point-buy costs match the standard table', () => {
+ expect(pointCost(8)).toBe(0);
+ expect(pointCost(13)).toBe(5);
+ expect(pointCost(15)).toBe(9);
+ expect(pointCost(16)).toBeNull();
+ expect(pointCost(7)).toBeNull();
+ });
+
+ it('27-point standard build is exactly spent', () => {
+ // all 13s costs 30 (>27), but 15,15,15,8,8,8 = 9+9+9 = 27
+ expect(pointBuySpent([15, 15, 15, 8, 8, 8])).toBe(27);
+ expect(pointBuyRemaining([15, 15, 15, 8, 8, 8])).toBe(0);
+ expect(pointBuyRemaining([8, 8, 8, 8, 8, 8])).toBe(27);
+ expect(pointBuySpent([16, 8, 8, 8, 8, 8])).toBe(Infinity);
+ });
+});
diff --git a/src/lib/rules/abilityGen.ts b/src/lib/rules/abilityGen.ts
new file mode 100644
index 0000000..031fc89
--- /dev/null
+++ b/src/lib/rules/abilityGen.ts
@@ -0,0 +1,39 @@
+import type { Rng } from '@/lib/rng';
+import { createRng } from '@/lib/rng';
+import { rollDice } from '@/lib/dice/notation';
+
+/** The classic 5e/PF2e-ish standard array. */
+export const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8] as const;
+
+/** Roll six ability scores as 4d6, drop the lowest. */
+export function rollAbilityScores(rng: Rng = createRng()): number[] {
+ return Array.from({ length: 6 }, () => rollDice('4d6kh3', rng).total);
+}
+
+/**
+ * Point-buy (27-point standard). Scores 8–15; costs 0,1,2,3,4,5,7,9 for 8..15.
+ * Returns the cost of a single score, or null if out of the buyable range.
+ */
+const POINT_COST: Record = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 };
+export const POINT_BUY_BUDGET = 27;
+export const POINT_BUY_MIN = 8;
+export const POINT_BUY_MAX = 15;
+
+export function pointCost(score: number): number | null {
+ return score in POINT_COST ? POINT_COST[score]! : null;
+}
+
+/** Total points spent across six scores (Infinity if any score is out of range). */
+export function pointBuySpent(scores: number[]): number {
+ let total = 0;
+ for (const s of scores) {
+ const c = pointCost(s);
+ if (c === null) return Infinity;
+ total += c;
+ }
+ return total;
+}
+
+export function pointBuyRemaining(scores: number[]): number {
+ return POINT_BUY_BUDGET - pointBuySpent(scores);
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index b1e081d..7e0da57 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -85,6 +85,21 @@ body {
scrollbar-color: var(--app-line) transparent;
}
+/* Print: hide app chrome so a character sheet prints (or saves to PDF) cleanly. */
+@media print {
+ header,
+ [data-roll-tray] {
+ display: none !important;
+ }
+ main {
+ overflow: visible !important;
+ }
+ body {
+ background: #fff;
+ color: #000;
+ }
+}
+
/* Dice roll animations */
@keyframes dice-tumble {
0% {
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
index dca8725..984060a 100644
--- a/tsconfig.app.tsbuildinfo
+++ b/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
\ No newline at end of file