init: config-schema v1 — field definitions, types, utils
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user