init: config-schema v1 — field definitions, types, utils
This commit is contained in:
@@ -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