import type { CharacterValues, CreationRule, SystemDefinition, ValidationIssue, ValidationResult, } from './types'; function err(path: string, message: string, code: string): ValidationIssue { return { path, message, code }; } export function validatePointBuy( rule: CreationRule, attributes: Record, ): ValidationIssue[] { const budget = Number(rule.params.budget ?? 27); const costs = (rule.params.costs as Record | undefined) ?? { '8': 0, '9': 1, '10': 2, '11': 3, '12': 4, '13': 5, '14': 7, '15': 9, }; const keys = (rule.params.keys as string[] | undefined) ?? Object.keys(attributes); let spent = 0; const issues: ValidationIssue[] = []; for (const key of keys) { const raw = attributes[key]; if (typeof raw !== 'number') { issues.push(err(`attributes.${key}`, 'Point buy requires numeric attribute', 'point_buy_type')); continue; } const c = costs[String(raw)]; if (c === undefined) { issues.push(err(`attributes.${key}`, `Value ${raw} is not in point-buy cost table`, 'point_buy_value')); continue; } spent += c; } if (spent > budget) { issues.push(err('creation.point_buy', `Spent ${spent} exceeds budget ${budget}`, 'point_buy_budget')); } return issues; } export function validateRoll( rule: CreationRule, values: CharacterValues, ): ValidationIssue[] { const issues: ValidationIssue[] = []; const keys = (rule.params.keys as string[] | undefined) ?? []; const min = Number(rule.params.min ?? 3); const max = Number(rule.params.max ?? 18); for (const key of keys) { const v = values.attributes?.[key]; if (typeof v !== 'number') { issues.push(err(`attributes.${key}`, 'Roll strategy expects a number', 'roll_type')); continue; } if (v < min || v > max) { issues.push(err(`attributes.${key}`, `Value ${v} out of roll range ${min}-${max}`, 'roll_range')); } } return issues; } export function validateArray( rule: CreationRule, attributes: Record, ): ValidationIssue[] { const array = (rule.params.array as number[] | undefined) ?? [15, 14, 13, 12, 10, 8]; const keys = (rule.params.keys as string[] | undefined) ?? Object.keys(attributes); const used = keys.map((k) => attributes[k]).filter((v): v is number => typeof v === 'number'); const sortedUsed = [...used].sort((a, b) => b - a); const sortedArray = [...array].sort((a, b) => b - a); if (sortedUsed.length !== sortedArray.length || sortedUsed.some((v, i) => v !== sortedArray[i])) { return [err('creation.array', 'Attributes must be a permutation of the fixed array', 'array_mismatch')]; } return []; } export function validateCreationRules( system: SystemDefinition, values: CharacterValues, activeStrategy?: string, ): ValidationResult { const errors: ValidationIssue[] = []; const rules = activeStrategy ? system.creationRules.filter((r) => r.type === activeStrategy) : system.creationRules; for (const rule of rules) { if (rule.type === 'point_buy') errors.push(...validatePointBuy(rule, values.attributes ?? {})); if (rule.type === 'roll') errors.push(...validateRoll(rule, values)); if (rule.type === 'array') errors.push(...validateArray(rule, values.attributes ?? {})); } for (const attr of system.bricks.attributes ?? []) { const v = values.attributes?.[attr.key]; if (v === undefined) continue; if (typeof v === 'number') { if (attr.min !== undefined && v < attr.min) { errors.push(err(`attributes.${attr.key}`, `Below min ${attr.min}`, 'attr_min')); } if (attr.max !== undefined && v > attr.max) { errors.push(err(`attributes.${attr.key}`, `Above max ${attr.max}`, 'attr_max')); } } } return { ok: errors.length === 0, errors }; } /** Deterministic dice roller for tests / roll strategy. */ export function rollDice(expr: string, rng: () => number = Math.random): number[] { // formats: NdM, NdMdl (drop lowest), NdMdh (drop highest) const m = /^(\d+)d(\d+)(dl|dh)?$/i.exec(expr.trim()); if (!m) throw new Error(`Unsupported dice expression '${expr}'`); const n = Number(m[1]); const sides = Number(m[2]); const drop = m[3]?.toLowerCase(); const rolls: number[] = []; for (let i = 0; i < n; i += 1) { rolls.push(1 + Math.floor(rng() * sides)); } if (drop === 'dl') { const idx = rolls.indexOf(Math.min(...rolls)); return rolls.filter((_, i) => i !== idx); } if (drop === 'dh') { const idx = rolls.indexOf(Math.max(...rolls)); return rolls.filter((_, i) => i !== idx); } return rolls; } export function sum(nums: number[]): number { return nums.reduce((a, b) => a + b, 0); }