feat: кубики/таймер/кисть/спонсоры и задел игровых систем
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { evaluateFormula, buildFormulaContext } from './formula';
|
||||
import { computeDerived, sortDerived, CycleError } from './derived';
|
||||
import { validatePointBuy, validateArray, rollDice, sum, validateCreationRules } from './creation';
|
||||
import { snapshotHash, canonicalJson } from './snapshot';
|
||||
import { validateSystemDefinition } from './systemValidate';
|
||||
import type { SystemDefinition } from './types';
|
||||
import { FORMAT_VERSION, SYSTEM_FORMAT } from './types';
|
||||
|
||||
test('evaluateFormula: D&D ability modifier', () => {
|
||||
const ctx = { 'attr.str': 16 };
|
||||
assert.equal(evaluateFormula('floor((attr.str - 10) / 2)', ctx), 3);
|
||||
assert.equal(evaluateFormula('(attr.str - 10) / 2', ctx, 'floor'), 3);
|
||||
});
|
||||
|
||||
test('evaluateFormula: clamp min max', () => {
|
||||
assert.equal(evaluateFormula('clamp(attr.str, 1, 20)', { 'attr.str': 25 }), 20);
|
||||
});
|
||||
|
||||
test('computeDerived topo order', () => {
|
||||
const derived = computeDerived(
|
||||
[
|
||||
{
|
||||
key: 'strMod',
|
||||
label: 'STR mod',
|
||||
formula: 'floor((attr.str - 10) / 2)',
|
||||
dependsOn: ['attr.str'],
|
||||
round: 'none',
|
||||
},
|
||||
{
|
||||
key: 'carry',
|
||||
label: 'Carry',
|
||||
formula: 'attr.str * 15',
|
||||
dependsOn: ['attr.str'],
|
||||
},
|
||||
],
|
||||
{ attributes: { str: 12 } },
|
||||
);
|
||||
assert.equal(derived.strMod, 1);
|
||||
assert.equal(derived.carry, 180);
|
||||
});
|
||||
|
||||
test('sortDerived detects cycles', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
sortDerived([
|
||||
{ key: 'a', label: 'a', formula: 'derived.b', dependsOn: ['derived.b'] },
|
||||
{ key: 'b', label: 'b', formula: 'derived.a', dependsOn: ['derived.a'] },
|
||||
]),
|
||||
CycleError,
|
||||
);
|
||||
});
|
||||
|
||||
test('point buy budget', () => {
|
||||
const rule = {
|
||||
id: 'pb',
|
||||
type: 'point_buy' as const,
|
||||
appliesTo: 'attribute' as const,
|
||||
params: {
|
||||
budget: 27,
|
||||
keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
|
||||
},
|
||||
};
|
||||
const ok = validatePointBuy(rule, { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 });
|
||||
assert.equal(ok.length, 0);
|
||||
const bad = validatePointBuy(rule, { str: 15, dex: 15, con: 15, int: 15, wis: 15, cha: 15 });
|
||||
assert.ok(bad.some((e) => e.code === 'point_buy_budget'));
|
||||
});
|
||||
|
||||
test('array permutation', () => {
|
||||
const rule = {
|
||||
id: 'arr',
|
||||
type: 'array' as const,
|
||||
appliesTo: 'attribute' as const,
|
||||
params: { array: [15, 14, 13, 12, 10, 8], keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'] },
|
||||
};
|
||||
assert.equal(
|
||||
validateArray(rule, { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 }).length,
|
||||
0,
|
||||
);
|
||||
assert.ok(validateArray(rule, { str: 15, dex: 15, con: 13, int: 12, wis: 10, cha: 8 }).length > 0);
|
||||
});
|
||||
|
||||
test('rollDice 4d6dl', () => {
|
||||
let i = 0;
|
||||
const seq = [0.0, 0.9, 0.5, 0.2]; // -> 1,6,4,2 drop 1 => 6+4+2=12
|
||||
const rolls = rollDice('4d6dl', () => seq[i++] ?? 0);
|
||||
assert.equal(sum(rolls), 12);
|
||||
});
|
||||
|
||||
test('snapshotHash stable', () => {
|
||||
const system = minimalSystem();
|
||||
const a = snapshotHash(system);
|
||||
const b = snapshotHash({ ...system, name: 'Other display name' });
|
||||
// name is not part of hash payload — hash equal
|
||||
assert.equal(a, b);
|
||||
const c = snapshotHash({ ...system, revision: 2 });
|
||||
assert.notEqual(a, c);
|
||||
});
|
||||
|
||||
test('canonicalJson sorts keys', () => {
|
||||
assert.equal(canonicalJson({ b: 1, a: 2 }), '{"a":2,"b":1}');
|
||||
});
|
||||
|
||||
test('validateSystemDefinition', () => {
|
||||
const r = validateSystemDefinition(minimalSystem());
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test('validateCreationRules integrates point_buy', () => {
|
||||
const system = minimalSystem();
|
||||
const result = validateCreationRules(
|
||||
system,
|
||||
{ attributes: { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8 } },
|
||||
'point_buy',
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test('buildFormulaContext', () => {
|
||||
const ctx = buildFormulaContext({ attributes: { str: 10 }, resources: { hp: 8 } });
|
||||
assert.equal(ctx['attr.str'], 10);
|
||||
assert.equal(ctx['resource.hp'], 8);
|
||||
});
|
||||
|
||||
function minimalSystem(): SystemDefinition {
|
||||
return {
|
||||
format: SYSTEM_FORMAT,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
bricks: {
|
||||
attributes: [
|
||||
{ key: 'str', label: 'STR', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'dex', label: 'DEX', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'con', label: 'CON', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'int', label: 'INT', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'wis', label: 'WIS', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
{ key: 'cha', label: 'CHA', scale: 'numeric', min: 8, max: 15, default: 10 },
|
||||
],
|
||||
derived: [
|
||||
{
|
||||
key: 'strMod',
|
||||
label: 'STR Mod',
|
||||
formula: 'floor((attr.str - 10) / 2)',
|
||||
dependsOn: ['attr.str'],
|
||||
},
|
||||
],
|
||||
},
|
||||
creationRules: [
|
||||
{
|
||||
id: 'pb',
|
||||
type: 'point_buy',
|
||||
appliesTo: 'attribute',
|
||||
params: {
|
||||
budget: 27,
|
||||
keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
|
||||
},
|
||||
},
|
||||
],
|
||||
sheetLayout: {
|
||||
pageSize: 'fluid',
|
||||
grid: { size: 8 },
|
||||
pages: [{ id: 'main', label: 'Main', widgets: [] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DerivedDefinition } from './types';
|
||||
import { buildFormulaContext, evaluateFormula } from './formula';
|
||||
|
||||
export class CycleError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CycleError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Kahn topological sort of derived definitions by dependsOn keys (derived.* or attr.* etc.). */
|
||||
export function sortDerived(defs: DerivedDefinition[]): DerivedDefinition[] {
|
||||
const byKey = new Map(defs.map((d) => [d.key, d]));
|
||||
const indeg = new Map<string, number>();
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
for (const d of defs) {
|
||||
indeg.set(d.key, 0);
|
||||
adj.set(d.key, []);
|
||||
}
|
||||
|
||||
for (const d of defs) {
|
||||
for (const dep of d.dependsOn) {
|
||||
const depKey = dep.startsWith('derived.') ? dep.slice('derived.'.length) : null;
|
||||
if (depKey && byKey.has(depKey)) {
|
||||
adj.get(depKey)!.push(d.key);
|
||||
indeg.set(d.key, (indeg.get(d.key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queue = [...indeg.entries()].filter(([, n]) => n === 0).map(([k]) => k);
|
||||
const ordered: DerivedDefinition[] = [];
|
||||
while (queue.length) {
|
||||
const k = queue.shift()!;
|
||||
const def = byKey.get(k);
|
||||
if (def) ordered.push(def);
|
||||
for (const next of adj.get(k) ?? []) {
|
||||
const n = (indeg.get(next) ?? 0) - 1;
|
||||
indeg.set(next, n);
|
||||
if (n === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
if (ordered.length !== defs.length) {
|
||||
throw new CycleError('Derived definitions contain a cycle');
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function computeDerived(
|
||||
defs: DerivedDefinition[],
|
||||
values: {
|
||||
attributes?: Record<string, number | string | boolean>;
|
||||
skills?: Record<string, number | string | boolean>;
|
||||
resources?: Record<string, number>;
|
||||
},
|
||||
): Record<string, number> {
|
||||
const ordered = sortDerived(defs);
|
||||
const derived: Record<string, number> = {};
|
||||
for (const d of ordered) {
|
||||
const ctx = buildFormulaContext({ ...values, derived });
|
||||
derived[d.key] = evaluateFormula(d.formula, ctx, d.round ?? 'none');
|
||||
}
|
||||
return derived;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/** Minimal safe formula DSL for derived stats. */
|
||||
|
||||
export type FormulaRound = 'floor' | 'ceil' | 'round' | 'none';
|
||||
|
||||
export type FormulaContext = Record<string, number>;
|
||||
|
||||
const IDENT = /^(attr|skill|resource|derived|val)\.[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function tokenize(input: string): string[] {
|
||||
const src = input.replace(/\s+/g, '');
|
||||
const tokens: string[] = [];
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i]!;
|
||||
if ('+-*/%(),'.includes(ch)) {
|
||||
tokens.push(ch);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (/[0-9.]/.test(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[0-9.]/.test(src[j]!)) j += 1;
|
||||
tokens.push(src.slice(i, j));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[A-Za-z0-9_.]/.test(src[j]!)) j += 1;
|
||||
tokens.push(src.slice(i, j));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unexpected character '${ch}' in formula`);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
type Parser = {
|
||||
tokens: string[];
|
||||
i: number;
|
||||
ctx: FormulaContext;
|
||||
};
|
||||
|
||||
function peek(p: Parser): string | undefined {
|
||||
return p.tokens[p.i];
|
||||
}
|
||||
|
||||
function consume(p: Parser, expected?: string): string {
|
||||
const t = p.tokens[p.i];
|
||||
if (t === undefined) throw new Error('Unexpected end of formula');
|
||||
if (expected !== undefined && t !== expected) throw new Error(`Expected '${expected}', got '${t}'`);
|
||||
p.i += 1;
|
||||
return t;
|
||||
}
|
||||
|
||||
function parseExpression(p: Parser): number {
|
||||
let left = parseTerm(p);
|
||||
while (peek(p) === '+' || peek(p) === '-') {
|
||||
const op = consume(p);
|
||||
const right = parseTerm(p);
|
||||
left = op === '+' ? left + right : left - right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function parseTerm(p: Parser): number {
|
||||
let left = parseUnary(p);
|
||||
while (peek(p) === '*' || peek(p) === '/' || peek(p) === '%') {
|
||||
const op = consume(p);
|
||||
const right = parseUnary(p);
|
||||
if (op === '*') left *= right;
|
||||
else if (op === '/') left /= right;
|
||||
else left %= right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function parseUnary(p: Parser): number {
|
||||
if (peek(p) === '-') {
|
||||
consume(p);
|
||||
return -parseUnary(p);
|
||||
}
|
||||
if (peek(p) === '+') {
|
||||
consume(p);
|
||||
return parseUnary(p);
|
||||
}
|
||||
return parsePrimary(p);
|
||||
}
|
||||
|
||||
function parsePrimary(p: Parser): number {
|
||||
const t = peek(p);
|
||||
if (t === undefined) throw new Error('Unexpected end of formula');
|
||||
if (t === '(') {
|
||||
consume(p, '(');
|
||||
const v = parseExpression(p);
|
||||
consume(p, ')');
|
||||
return v;
|
||||
}
|
||||
if (/^[0-9]+(\.[0-9]+)?$/.test(t)) {
|
||||
consume(p);
|
||||
return Number(t);
|
||||
}
|
||||
if (IDENT.test(t)) {
|
||||
consume(p);
|
||||
const v = p.ctx[t];
|
||||
if (typeof v !== 'number' || Number.isNaN(v)) {
|
||||
throw new Error(`Unknown or non-numeric identifier '${t}'`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
// function call
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && p.tokens[p.i + 1] === '(') {
|
||||
const name = consume(p);
|
||||
consume(p, '(');
|
||||
const args: number[] = [];
|
||||
if (peek(p) !== ')') {
|
||||
args.push(parseExpression(p));
|
||||
while (peek(p) === ',') {
|
||||
consume(p, ',');
|
||||
args.push(parseExpression(p));
|
||||
}
|
||||
}
|
||||
consume(p, ')');
|
||||
return callFn(name, args);
|
||||
}
|
||||
throw new Error(`Unexpected token '${t}'`);
|
||||
}
|
||||
|
||||
function callFn(name: string, args: number[]): number {
|
||||
switch (name) {
|
||||
case 'floor':
|
||||
return Math.floor(args[0] ?? 0);
|
||||
case 'ceil':
|
||||
return Math.ceil(args[0] ?? 0);
|
||||
case 'round':
|
||||
return Math.round(args[0] ?? 0);
|
||||
case 'abs':
|
||||
return Math.abs(args[0] ?? 0);
|
||||
case 'min':
|
||||
return Math.min(...args);
|
||||
case 'max':
|
||||
return Math.max(...args);
|
||||
case 'clamp': {
|
||||
const [v = 0, lo = 0, hi = 0] = args;
|
||||
return Math.min(hi, Math.max(lo, v));
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown function '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateFormula(
|
||||
formula: string,
|
||||
ctx: FormulaContext,
|
||||
round: FormulaRound = 'none',
|
||||
): number {
|
||||
const p: Parser = { tokens: tokenize(formula), i: 0, ctx };
|
||||
const value = parseExpression(p);
|
||||
if (p.i !== p.tokens.length) throw new Error('Trailing tokens in formula');
|
||||
if (round === 'floor') return Math.floor(value);
|
||||
if (round === 'ceil') return Math.ceil(value);
|
||||
if (round === 'round') return Math.round(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildFormulaContext(values: {
|
||||
attributes?: Record<string, number | string | boolean>;
|
||||
skills?: Record<string, number | string | boolean>;
|
||||
resources?: Record<string, number>;
|
||||
derived?: Record<string, number>;
|
||||
}): FormulaContext {
|
||||
const ctx: FormulaContext = {};
|
||||
for (const [k, v] of Object.entries(values.attributes ?? {})) {
|
||||
if (typeof v === 'number') ctx[`attr.${k}`] = v;
|
||||
}
|
||||
for (const [k, v] of Object.entries(values.skills ?? {})) {
|
||||
if (typeof v === 'number') ctx[`skill.${k}`] = v;
|
||||
}
|
||||
for (const [k, v] of Object.entries(values.resources ?? {})) {
|
||||
ctx[`resource.${k}`] = v;
|
||||
}
|
||||
for (const [k, v] of Object.entries(values.derived ?? {})) {
|
||||
ctx[`derived.${k}`] = v;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types';
|
||||
export * from './formula';
|
||||
export * from './derived';
|
||||
export * from './creation';
|
||||
export * from './snapshot';
|
||||
export * from './systemValidate';
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { SystemDefinition } from './types';
|
||||
|
||||
/** Canonical JSON for stable hashing (sorted keys). */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(sortKeys(value));
|
||||
}
|
||||
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
if (value && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj).sort()) {
|
||||
out[key] = sortKeys(obj[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function snapshotHash(system: SystemDefinition): string {
|
||||
const payload = {
|
||||
id: system.id,
|
||||
schemaVersion: system.schemaVersion,
|
||||
revision: system.revision,
|
||||
bricks: system.bricks,
|
||||
creationRules: system.creationRules,
|
||||
sheetLayout: system.sheetLayout,
|
||||
compendium: system.compendium,
|
||||
};
|
||||
return createHash('sha256').update(canonicalJson(payload)).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SystemDefinition, ValidationIssue, ValidationResult } from './types';
|
||||
import { SYSTEM_FORMAT, FORMAT_VERSION } from './types';
|
||||
|
||||
function err(path: string, message: string, code: string): ValidationIssue {
|
||||
return { path, message, code };
|
||||
}
|
||||
|
||||
export function validateSystemDefinition(system: unknown): ValidationResult {
|
||||
const errors: ValidationIssue[] = [];
|
||||
if (!system || typeof system !== 'object') {
|
||||
return { ok: false, errors: [err('$', 'System must be an object', 'type')] };
|
||||
}
|
||||
const s = system as Partial<SystemDefinition>;
|
||||
if (s.format !== SYSTEM_FORMAT) errors.push(err('format', `Expected ${SYSTEM_FORMAT}`, 'format'));
|
||||
if (s.formatVersion !== FORMAT_VERSION) {
|
||||
errors.push(err('formatVersion', `Expected ${FORMAT_VERSION}`, 'formatVersion'));
|
||||
}
|
||||
if (!s.id || typeof s.id !== 'string') errors.push(err('id', 'id required', 'required'));
|
||||
if (!s.name || typeof s.name !== 'string') errors.push(err('name', 'name required', 'required'));
|
||||
if (typeof s.schemaVersion !== 'number') errors.push(err('schemaVersion', 'number required', 'type'));
|
||||
if (typeof s.revision !== 'number') errors.push(err('revision', 'number required', 'type'));
|
||||
if (!s.bricks || typeof s.bricks !== 'object') errors.push(err('bricks', 'bricks required', 'required'));
|
||||
if (!Array.isArray(s.creationRules)) errors.push(err('creationRules', 'array required', 'type'));
|
||||
if (!s.sheetLayout || !Array.isArray(s.sheetLayout.pages)) {
|
||||
errors.push(err('sheetLayout.pages', 'pages required', 'required'));
|
||||
}
|
||||
|
||||
const derived = s.bricks?.derived ?? [];
|
||||
const keys = new Set(derived.map((d) => d.key));
|
||||
for (const d of derived) {
|
||||
if (!d.formula) errors.push(err(`derived.${d.key}`, 'formula required', 'required'));
|
||||
for (const dep of d.dependsOn ?? []) {
|
||||
if (dep.startsWith('derived.') && !keys.has(dep.slice('derived.'.length))) {
|
||||
// allow forward refs; cycle checked elsewhere
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/** Portable TTRPG system / character contract (builder + player + future mobile). */
|
||||
|
||||
export const SYSTEM_FORMAT = 'ttrpg-system' as const;
|
||||
export const CHARACTER_FORMAT = 'ttrpg-character' as const;
|
||||
export const FORMAT_VERSION = 1 as const;
|
||||
|
||||
export type ScaleKind = 'numeric' | 'dice' | 'percent' | 'rank' | 'pool' | 'boolean';
|
||||
|
||||
export type AttributeDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
scale: ScaleKind;
|
||||
min?: number;
|
||||
max?: number;
|
||||
default?: number | string | boolean;
|
||||
category?: 'physical' | 'mental' | 'social' | 'other';
|
||||
costPerPoint?: number;
|
||||
};
|
||||
|
||||
export type SkillDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
linkedAttribute?: string;
|
||||
scale: ScaleKind;
|
||||
min?: number;
|
||||
max?: number;
|
||||
default?: number | string | boolean;
|
||||
};
|
||||
|
||||
export type ResourceDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
min?: number;
|
||||
max?: number | string;
|
||||
default?: number;
|
||||
track?: boolean;
|
||||
};
|
||||
|
||||
export type TraitDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
prerequisites?: string[];
|
||||
};
|
||||
|
||||
export type DerivedDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
formula: string;
|
||||
dependsOn: string[];
|
||||
round?: 'floor' | 'ceil' | 'round' | 'none';
|
||||
};
|
||||
|
||||
export type ProgressionNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'career' | 'feat' | 'talent' | 'other';
|
||||
traitKey?: string;
|
||||
position?: { x: number; y: number };
|
||||
};
|
||||
|
||||
export type ProgressionEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
kind?: 'prerequisite' | 'exit' | 'unlock';
|
||||
};
|
||||
|
||||
export type ProgressionDefinition = {
|
||||
label?: string;
|
||||
graph: {
|
||||
nodes: ProgressionNode[];
|
||||
edges: ProgressionEdge[];
|
||||
};
|
||||
};
|
||||
|
||||
export type CreationStrategy =
|
||||
| 'point_buy'
|
||||
| 'roll'
|
||||
| 'array'
|
||||
| 'priority'
|
||||
| 'life_path'
|
||||
| 'funnel';
|
||||
|
||||
export type CreationRule = {
|
||||
id: string;
|
||||
type: CreationStrategy;
|
||||
appliesTo: 'attribute' | 'skill' | 'any';
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WidgetType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'modBadge'
|
||||
| 'skillRow'
|
||||
| 'skillList'
|
||||
| 'resourceBar'
|
||||
| 'checkbox'
|
||||
| 'tagList'
|
||||
| 'section'
|
||||
| 'portrait'
|
||||
| 'notes'
|
||||
| 'table'
|
||||
| 'repeatingTable'
|
||||
| 'statBlock'
|
||||
| 'dots'
|
||||
| 'percent'
|
||||
| 'rankEnum'
|
||||
| 'track'
|
||||
| 'clock'
|
||||
| 'slotMatrix'
|
||||
| 'diceStep'
|
||||
| 'locationBar'
|
||||
| 'spellCards'
|
||||
| 'unknownWidget';
|
||||
|
||||
export type WidgetFrame = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export type SheetWidget = {
|
||||
id: string;
|
||||
type: WidgetType;
|
||||
bind?: string;
|
||||
frame: WidgetFrame;
|
||||
responsive?: { sm?: Partial<WidgetFrame> & { hide?: boolean; stack?: boolean } };
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SheetPage = {
|
||||
id: string;
|
||||
label: string;
|
||||
widgets: SheetWidget[];
|
||||
};
|
||||
|
||||
export type SheetLayout = {
|
||||
pageSize: 'letter' | 'a4' | 'fluid' | { w: number; h: number };
|
||||
grid: { size: number; columns?: number };
|
||||
pages: SheetPage[];
|
||||
};
|
||||
|
||||
export type CompendiumSpell = {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
school?: string;
|
||||
source?: string;
|
||||
classes?: string[];
|
||||
castingTime?: string;
|
||||
range?: string;
|
||||
components?: string;
|
||||
duration?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type SystemBricks = {
|
||||
attributes?: AttributeDefinition[];
|
||||
skills?: SkillDefinition[];
|
||||
resources?: ResourceDefinition[];
|
||||
traits?: TraitDefinition[];
|
||||
derived?: DerivedDefinition[];
|
||||
progression?: ProgressionDefinition;
|
||||
constraints?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export type SystemDefinition = {
|
||||
format: typeof SYSTEM_FORMAT;
|
||||
formatVersion: typeof FORMAT_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
edition?: string;
|
||||
schemaVersion: number;
|
||||
revision: number;
|
||||
bricks: SystemBricks;
|
||||
creationRules: CreationRule[];
|
||||
sheetLayout: SheetLayout;
|
||||
compendium?: {
|
||||
spells?: CompendiumSpell[];
|
||||
};
|
||||
theme?: { tokenOverrides?: Record<string, string> };
|
||||
};
|
||||
|
||||
export type CharacterValues = {
|
||||
meta?: Record<string, unknown>;
|
||||
attributes?: Record<string, number | string | boolean>;
|
||||
skills?: Record<string, number | string | boolean>;
|
||||
resources?: Record<string, number>;
|
||||
traits?: string[];
|
||||
ownedTraitIds?: string[];
|
||||
spells?: Array<{ id: string; prepared?: boolean; notes?: string }>;
|
||||
inventory?: Array<Record<string, unknown>>;
|
||||
derived?: Record<string, number>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type CharacterSheet = {
|
||||
format: typeof CHARACTER_FORMAT;
|
||||
formatVersion: typeof FORMAT_VERSION;
|
||||
id: string;
|
||||
name: string;
|
||||
systemId: string;
|
||||
schemaVersion: number;
|
||||
revisionAtCreate: number;
|
||||
values: CharacterValues;
|
||||
creation?: {
|
||||
strategy?: CreationStrategy;
|
||||
seed?: number;
|
||||
rolls?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type ValidationIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type ValidationResult = {
|
||||
ok: boolean;
|
||||
errors: ValidationIssue[];
|
||||
};
|
||||
Reference in New Issue
Block a user