init: config-schema v1 — field definitions, types, utils

This commit is contained in:
Mai
2026-06-18 08:25:25 +08:00
commit 4f67a503dd
5 changed files with 476 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+199
View File
@@ -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
}
}
}
}
}
+18
View File
@@ -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"
}
}
}
+44
View File
@@ -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: <el-select multiple>) */
multi?: boolean;
/** Whether the "all" toggle is available (battleTypes only) */
all?: boolean;
/** Allowed values (key = internal ID, value = display label) */
enum?: Record<string, string>;
// ── 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<string, FieldDef>;
}
/** Full schema shape */
export interface ConfigSchema {
$schema: string;
version: number;
fields: Record<string, FieldDef>;
}
+214
View File
@@ -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<string, RenderMeta>;
}
/** 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<string, RenderMeta> = {};
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<T>(
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<string, unknown>)[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<string, unknown>,
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 };
}