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
+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 };
}