commit 4f67a503dddce852e631dc444137eec9633cc101 Author: Mai Date: Thu Jun 18 08:25:25 2026 +0800 init: config-schema v1 — field definitions, types, utils diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/config-schema.json b/config-schema.json new file mode 100644 index 0000000..5005af8 --- /dev/null +++ b/config-schema.json @@ -0,0 +1,199 @@ +{ + "$schema": "fox-maze-config-schema-v1", + "version": 1, + "fields": { + "algorithm": { + "scope": "maze", + "type": "string", + "multi": true, + "label": "生成算法", + "default": "recursive-backtracker", + "enum": { + "recursive-backtracker": "递归回溯", + "prim": "Prim随机", + "kruskal": "Kruskal随机", + "recursive-division": "递归分治", + "dungeon": "地下城", + "ca": "洞穴", + "circular": "环形", + "polar-segment": "环形-极坐标线段", + "hex-recursive-backtracker": "六边形-递归回溯", + "hex-prim": "六边形-Prim", + "torus-rb": "环面-递归回溯", + "torus-prim": "环面-Prim", + "torus-kruskal": "环面-Kruskal", + "tri-rb": "三角-递归回溯", + "tri-prim": "三角-Prim", + "wilson": "Wilson随机游走", + "hunt-and-kill": "狩猎与杀戮", + "aldous-broder": "Aldous-Broder随机", + "true-prim": "真Prim权重" + } + }, + "variant": { + "scope": "maze", + "type": "string", + "multi": true, + "label": "变体", + "default": null, + "nullable": true, + "enum": { + "dark": "暗黑", + "portal": "传送门", + "key": "钥匙", + "timed": "限时" + } + }, + "weather": { + "scope": "maze", + "type": "string", + "multi": false, + "label": "天气", + "default": null, + "nullable": true, + "enum": { + "none": "无", + "rain": "雨", + "snow": "雪", + "leaves": "落叶", + "fireflies": "萤火虫", + "fog": "雾" + } + }, + "bgm": { + "scope": "maze", + "type": "string", + "multi": false, + "label": "背景音乐", + "default": null, + "nullable": true + }, + "theme": { + "scope": "maze", + "type": "string", + "multi": false, + "label": "视觉主题", + "default": null, + "nullable": true, + "enum": { + "deep-sea": "深海", + "forest": "森林", + "molten": "熔岩", + "sakura": "樱花", + "frost": "冰峰", + "galaxy": "星云" + } + }, + "trapCount": { + "scope": "template", + "type": "number", + "label": "陷阱数", + "default": 6, + "min": 0, + "max": 50 + }, + "effectTrapCount": { + "scope": "template", + "type": "number", + "label": "效果陷阱数", + "default": 3, + "min": 0, + "max": 20 + }, + "spiritCount": { + "scope": "template", + "type": "number", + "label": "精灵数", + "default": 2, + "min": 0, + "max": 10 + }, + "spiritSpeed": { + "scope": "template", + "type": "number", + "label": "精灵速度", + "default": 2.0, + "min": 0.5, + "max": 5.0, + "step": 0.5 + }, + "spiritAggro": { + "scope": "template", + "type": "number", + "label": "精灵索敌距离", + "default": 4, + "min": 0, + "max": 10 + }, + "battleTypes": { + "scope": "template", + "type": "string", + "multi": true, + "all": true, + "label": "战斗题型", + "default": ["choice", "fill", "spell", "match", "rush", "scramble"], + "enum": { + "choice": "选择", + "fill": "填空", + "spell": "拼写", + "match": "匹配", + "rush": "速答", + "scramble": "拼词" + } + }, + "wordDifficulty": { + "scope": "template", + "type": "number", + "label": "单词难度", + "default": 3, + "min": 1, + "max": 5 + }, + "lives": { + "scope": "template", + "type": "number", + "label": "生命值", + "default": 3, + "min": 1, + "max": 10 + }, + "rush": { + "scope": "template", + "type": "object", + "label": "Rush模式", + "default": null, + "nullable": true, + "children": { + "timeSec": { + "type": "number", + "label": "总时长 (秒)", + "default": 30, + "min": 10, + "max": 120 + }, + "questionTimeSec": { + "type": "number", + "label": "每题时限 (秒)", + "default": 3, + "min": 1, + "max": 30 + }, + "accuracy": { + "type": "number", + "label": "正确率要求", + "default": 0.7, + "min": 0.1, + "max": 1.0, + "step": 0.05 + }, + "minTotal": { + "type": "number", + "label": "最低题数", + "default": 3, + "min": 1, + "max": 20 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b533495 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "fox-maze-schema", + "version": "1.0.0", + "description": "Single source of truth for fox-maze config field definitions. Shared via npm file: dependency.", + "private": true, + "type": "module", + "exports": { + "./config-schema.json": "./config-schema.json", + "./utils": { + "import": "./src/utils.ts", + "types": "./src/utils.ts" + }, + "./types": { + "import": "./src/types.ts", + "types": "./src/types.ts" + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4c81d2f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,44 @@ +/** A single field definition in config-schema.json */ +export interface FieldDef { + scope: 'maze' | 'template'; + type: 'string' | 'number' | 'object'; + label: string; + + /** Default value — fallback in the maze→template→default merge chain */ + default: unknown; + + /** Whether null is a valid value (for nullable fields like variant, weather, bgm) */ + nullable?: boolean; + + // ── string fields ── + + /** Whether multiple values are allowed (editor: ) */ + multi?: boolean; + + /** Whether the "all" toggle is available (battleTypes only) */ + all?: boolean; + + /** Allowed values (key = internal ID, value = display label) */ + enum?: Record; + + // ── number fields ── + + /** Minimum value (number fields) */ + min?: number; + /** Maximum value (number fields) */ + max?: number; + /** Slider step (number fields, default 1) */ + step?: number; + + // ── object fields ── + + /** Nested child fields (rush mode children) */ + children?: Record; +} + +/** Full schema shape */ +export interface ConfigSchema { + $schema: string; + version: number; + fields: Record; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..2ebdb30 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,214 @@ +import type { ConfigSchema, FieldDef } from './types'; +import _schema from '../config-schema.json'; + +const schema = _schema as ConfigSchema; + +// ============ METADATA LOOKUP ============ + +/** Get all fields scoped to a specific editor level */ +export function getFields(scope: 'template' | 'maze'): [string, FieldDef][] { + return Object.entries(schema.fields).filter( + ([, def]) => def.scope === scope, + ); +} + +/** Get a single field definition by name */ +export function getField(name: string): FieldDef | undefined { + return schema.fields[name]; +} + +// ============ RENDER METADATA ============ + +export interface RenderMeta { + component: 'el-slider' | 'el-select' | 'el-checkbox-group' | 'el-switch' | 'el-input-number' | 'el-collapse'; + min?: number; + max?: number; + step?: number; + options?: { label: string; value: string }[]; + isMulti?: boolean; + isAllToggle?: boolean; + children?: Record; +} + +/** Determine the UI component and props for a field */ +export function getRenderMeta(field: string): RenderMeta | null { + const def = getField(field); + if (!def) return null; + + if (def.type === 'object' && def.children) { + const childMeta: Record = {}; + for (const [key, child] of Object.entries(def.children)) { + childMeta[key] = buildLeafMeta(child); + } + return { component: 'el-collapse', children: childMeta }; + } + + return buildLeafMeta(def); +} + +function buildLeafMeta(def: FieldDef): RenderMeta { + if (def.type === 'number') { + // Use input-number when min/max are absent (boundless number) + if (def.min === undefined || def.max === undefined) { + return { component: 'el-input-number' }; + } + return { + component: 'el-slider', + min: def.min, + max: def.max, + step: def.step ?? 1, + }; + } + + // string type + if (def.all) { + return { + component: 'el-switch', + isAllToggle: true, + }; + } + + if (def.enum) { + const options = Object.entries(def.enum).map(([value, label]) => ({ + label, + value, + })); + return { + component: def.multi ? 'el-select' : 'el-select', + options, + isMulti: !!def.multi, + }; + } + + // Free-text string (bgm) + return { + component: 'el-select', + isMulti: false, + }; +} + +// ============ VALUE RESOLUTION ============ + +/** + * Three-chain merge: maze override → template default → schema default. + * Returns the final value for a given field. + */ +export function resolveValue( + field: string, + mazeVal: T | undefined, + tmplVal: T | undefined, +): T | null { + const def = getField(field); + if (!def) return null; + return mazeVal ?? tmplVal ?? (def.default as T) ?? null; +} + +// ============ VALIDATION ============ + +export interface ValidationResult { + valid: boolean; + errors: { field: string; message: string; value?: unknown }[]; +} + +function validateValueInternal( + field: string, + value: unknown, + def: FieldDef, + errors: { field: string; message: string; value?: unknown }[], +): void { + if (value === null || value === undefined) { + if (def.nullable) return; // null is allowed + errors.push({ field, message: `${field} cannot be null/undefined`, value }); + return; + } + + if (def.type === 'number') { + if (typeof value !== 'number') { + errors.push({ field, message: `Must be a number`, value }); + return; + } + if (def.min !== undefined && value < def.min) { + errors.push({ field, message: `Minimum is ${def.min}`, value }); + } + if (def.max !== undefined && value > def.max) { + errors.push({ field, message: `Maximum is ${def.max}`, value }); + } + return; + } + + if (def.type === 'string') { + // Handle multi values (arrays of strings) + if (Array.isArray(value) && def.multi) { + for (const item of value) { + if (typeof item !== 'string') { + errors.push({ field, message: `Array item must be a string`, value: item }); + continue; + } + if (def.enum && !(item in def.enum)) { + errors.push({ field, message: `Invalid value: ${item}`, value: item }); + } + } + return; + } + + // Handle 'all' special value (battleTypes) + if (value === 'all' && def.all) { + return; // 'all' is valid for all-toggle fields + } + + // Handle single string + if (typeof value !== 'string') { + errors.push({ field, message: `Must be a string`, value }); + return; + } + if (def.enum && !(value in def.enum)) { + errors.push({ field, message: `Invalid value: ${value}`, value }); + } + return; + } + + if (def.type === 'object' && def.children) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + errors.push({ field, message: `Must be an object`, value }); + return; + } + if (def.nullable) { + return; // null object is valid (e.g. rush: null means disabled) + } + for (const [childKey, childDef] of Object.entries(def.children)) { + const childVal = (value as Record)[childKey]; + if (childVal !== undefined) { + validateValueInternal(`${field}.${childKey}`, childVal, childDef, errors); + } + } + return; + } +} + +/** Validate a single field value against the schema */ +export function validateValue(field: string, value: unknown): string | null { + const def = getField(field); + if (!def) return `Unknown field: ${field}`; + + const errors: { field: string; message: string; value?: unknown }[] = []; + validateValueInternal(field, value, def, errors); + return errors.length > 0 ? errors[0].message : null; +} + +/** Validate a full config object against the schema */ +export function validateObject( + config: Record, + scope?: 'template' | 'maze', +): ValidationResult { + const errors: { field: string; message: string; value?: unknown }[] = []; + const entries = scope ? getFields(scope) : Object.entries(schema.fields); + + for (const [field, def] of entries) { + const value = config[field]; + if (value !== undefined) { + validateValueInternal(field, value, def, errors); + } + } + + return { valid: errors.length === 0, errors }; +}