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