712086ddca
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet. Co-authored-by: Cursor <cursoragent@cursor.com>
144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
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<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);
|
|
}
|