Initial TTRPG character meta-builder with visual sheet editor.
Offline Electron app seeds 10 system presets, supports create-from-scratch designer with palette/drag/resize/bricks, and exports .ttrpg-system.json for the player. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
CharacterValues,
|
||||
CreationRule,
|
||||
SystemDefinition,
|
||||
ValidationIssue,
|
||||
ValidationResult,
|
||||
} from './types.ts';
|
||||
|
||||
function err(path: string, message: string, code: string): ValidationIssue {
|
||||
return { path, message, code };
|
||||
}
|
||||
|
||||
export function validatePointBuy(
|
||||
rule: CreationRule,
|
||||
attributes: Record<string, number | string | boolean>,
|
||||
): ValidationIssue[] {
|
||||
const budget = Number(rule.params.budget ?? 27);
|
||||
const costs = (rule.params.costs as Record<string, number> | 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<string, number | string | boolean>,
|
||||
): 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);
|
||||
}
|
||||
Reference in New Issue
Block a user