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