Phase 6: character creation & level-up

- Ability generation: standard array, point buy (27-pt, validated), 4d6kh3 roll;
  AbilityGenModal with assign-from-pool + steppers (pure abilityGen lib + tests)
- Level-up flow: increment level, HP gain (average or roll hit die + CON)
- Print / save-to-PDF: print button + @media print hides app chrome and roll tray
- Plan: added Phase 11 (data-driven Assistant) to the roadmap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:56:23 +02:00
parent d5977e4c63
commit 522ff8abce
9 changed files with 331 additions and 4 deletions
+34
View File
@@ -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);
});
});
+39
View File
@@ -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 815; 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<number, number> = { 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);
}