feat(config-editor): add validation rules (dup/required/enum/ref/overrides/herolist) with tests
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { validate } from '../src/shared/validation';
|
||||||
|
import { RecordValue } from '../src/io/recordValue';
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
hasSkill: (u: number) => [6001, 6301, 6501].includes(u),
|
||||||
|
hasField: (u: number) => [7015].includes(u),
|
||||||
|
hasHero: (u: number) => [5011, 6001].includes(u),
|
||||||
|
heroListKeys: new Set<string>(['5011']),
|
||||||
|
};
|
||||||
|
|
||||||
|
function heroObj(extra: Record<string, RecordValue> = {}): RecordValue {
|
||||||
|
return { kind: 'obj', props: {
|
||||||
|
uuid: { kind: 'num', value: 5011 }, name: { kind: 'str', value: 'x' }, path: { kind: 'str', value: 'p' },
|
||||||
|
fac: { kind: 'enumRef', qualifier: 'FacSet', member: 'HERO' }, lv: { kind: 'num', value: 1 },
|
||||||
|
type: { kind: 'enumRef', qualifier: 'HType', member: 'Melee' }, hp: { kind: 'num', value: 400 },
|
||||||
|
ap: { kind: 'num', value: 20 },
|
||||||
|
skills: { kind: 'obj', props: { 6001: { kind: 'obj', props: { uuid: { kind: 'num', value: 6001 }, lv: { kind: 'num', value: 1 }, cd: { kind: 'speed', level: 'Slow3' }, ccd: { kind: 'num', value: 0 } } } } },
|
||||||
|
info: { kind: 'str', value: 'ok' },
|
||||||
|
...extra,
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('valid hero → no errors', () => {
|
||||||
|
const issues = validate('hero', new Map([['5011', heroObj()]]), ctx);
|
||||||
|
assert.equal(issues.filter(i => i.severity === 'error').length, 0);
|
||||||
|
});
|
||||||
|
test('duplicate uuid → error', () => {
|
||||||
|
// 两条记录携带相同的 uuid 字段值(Map 键不同,uuid 字段相同)
|
||||||
|
const dup = heroObj(); // uuid=5011
|
||||||
|
const issues = validate('hero', new Map([['5011', heroObj()], ['5012', dup]]), ctx);
|
||||||
|
assert.ok(issues.some(i => i.severity === 'error' && i.code === 'dup-uuid'));
|
||||||
|
});
|
||||||
|
test('missing required field → error', () => {
|
||||||
|
const h = heroObj(); delete h.props['name'];
|
||||||
|
const issues = validate('hero', new Map([['5011', h]]), ctx);
|
||||||
|
assert.ok(issues.some(i => i.code === 'missing-required' && i.fieldPath === 'name'));
|
||||||
|
});
|
||||||
|
test('trigger slot references missing skill → error', () => {
|
||||||
|
const h = heroObj({ atked: { kind: 'arr', items: [{ kind: 'obj', props: {
|
||||||
|
s_uuid: { kind: 'num', value: 9999 }, t_num: { kind: 'num', value: 3 } } }] } });
|
||||||
|
const issues = validate('hero', new Map([['5011', h]]), ctx);
|
||||||
|
assert.ok(issues.some(i => i.code === 'dangling-ref'));
|
||||||
|
});
|
||||||
|
test('field list references missing field skill → error', () => {
|
||||||
|
const h = heroObj({ field: { kind: 'arr', items: [{ kind: 'num', value: 8888 }] } });
|
||||||
|
const issues = validate('hero', new Map([['5011', h]]), ctx);
|
||||||
|
assert.ok(issues.some(i => i.code === 'dangling-ref'));
|
||||||
|
});
|
||||||
|
test('HeroList/hero consistency: fac=HERO entry missing from HeroList → error', () => {
|
||||||
|
const ctx2 = { ...ctx, heroListKeys: new Set<string>([]) };
|
||||||
|
const issues = validate('hero', new Map([['5011', heroObj()]]), ctx2);
|
||||||
|
assert.ok(issues.some(i => i.code === 'herolist-inconsistent'));
|
||||||
|
});
|
||||||
|
test('invalid enum value → error', () => {
|
||||||
|
const h = heroObj({ fac: { kind: 'enumRef', qualifier: 'FacSet', member: 'NOPE' } });
|
||||||
|
const issues = validate('hero', new Map([['5011', h]]), ctx);
|
||||||
|
assert.ok(issues.some(i => i.code === 'bad-enum'));
|
||||||
|
});
|
||||||
|
test('unknown overrides key → error', () => {
|
||||||
|
const h = heroObj({ atked: { kind: 'arr', items: [{ kind: 'obj', props: {
|
||||||
|
s_uuid: { kind: 'num', value: 6301 }, t_num: { kind: 'num', value: 3 },
|
||||||
|
overrides: { kind: 'obj', props: { bogus: { kind: 'num', value: 1 } } } } }] } });
|
||||||
|
const issues = validate('hero', new Map([['5011', h]]), ctx);
|
||||||
|
assert.ok(issues.some(i => i.code === 'bad-override-key'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { RecordValue } from '../../io/recordValue';
|
||||||
|
import { TableId } from '../schema/types';
|
||||||
|
import { ENUMS } from '../schema/enums';
|
||||||
|
|
||||||
|
export type Severity = 'error' | 'warn';
|
||||||
|
export interface Issue {
|
||||||
|
tableId: TableId; key: string; fieldPath: string;
|
||||||
|
severity: Severity; code: string; message: string;
|
||||||
|
}
|
||||||
|
export interface ValidationContext {
|
||||||
|
hasSkill: (u: number) => boolean;
|
||||||
|
hasField: (u: number) => boolean;
|
||||||
|
hasHero: (u: number) => boolean;
|
||||||
|
heroListKeys: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERRIDE_KEYS = new Set(['TGroup', 'ap', 'gold', 'hit_count', 'hitcd', 'crt', 'frz', 'stun', 'bck', 'buff_type', 'call_hero']);
|
||||||
|
const TRIGGER_ARRAY_KEYS = ['call', 'dead', 'fstart', 'fend', 'atking', 'atked'];
|
||||||
|
|
||||||
|
function num(v: RecordValue | undefined): number | undefined {
|
||||||
|
return v && v.kind === 'num' ? v.value : undefined;
|
||||||
|
}
|
||||||
|
function isKnownEnum(qualifier: string, member: string): boolean {
|
||||||
|
const def = ENUMS[qualifier] ?? Object.values(ENUMS).find(e => e.qualifier === qualifier);
|
||||||
|
return !!def && def.members.some(m => String(m.value) === member || m.label.split(' ').pop() === member);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(tableId: TableId, records: Map<string, RecordValue>, ctx: ValidationContext): Issue[] {
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
// 跟踪 uuid 字段值(而非 Map 键)—— JS Map 会折叠重复键,但两条记录可能携带相同的 uuid 字段值。
|
||||||
|
const seenUuids = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, rec] of records) {
|
||||||
|
const push = (fieldPath: string, severity: Severity, code: string, message: string) =>
|
||||||
|
issues.push({ tableId, key, fieldPath, severity, code, message });
|
||||||
|
|
||||||
|
if (rec.kind !== 'obj') { push('', 'error', 'not-object', '记录不是对象'); continue; }
|
||||||
|
const p = rec.props;
|
||||||
|
|
||||||
|
const uVal = num(p['uuid']);
|
||||||
|
if (uVal !== undefined) {
|
||||||
|
const uKey = String(uVal);
|
||||||
|
if (seenUuids.has(uKey)) push('uuid', 'error', 'dup-uuid', `重复 uuid: ${uKey}`);
|
||||||
|
seenUuids.add(uKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必填(按表最小集合)
|
||||||
|
const required: Record<TableId, string[]> = {
|
||||||
|
hero: ['uuid', 'name', 'path', 'fac', 'lv', 'type', 'hp', 'ap', 'skills', 'info'],
|
||||||
|
skill: ['uuid', 'name', 'sp_name', 'icon', 'act', 'TGroup', 'DTType', 'IType', 'RType', 'EType', 'ap', 'hit_count', 'hitcd', 'speed', 'ready', 'info'],
|
||||||
|
field: ['uuid', 'name', 'icon', 'type', 'value', 'info'],
|
||||||
|
};
|
||||||
|
for (const f of required[tableId]) {
|
||||||
|
if (p[f] === undefined) push(f, 'error', 'missing-required', `缺少必填字段 ${f}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 枚举值合法
|
||||||
|
for (const [f, v] of Object.entries(p)) {
|
||||||
|
if (v.kind === 'enumRef' && !isKnownEnum(v.qualifier, v.member)) {
|
||||||
|
push(f, 'error', 'bad-enum', `非法枚举值 ${v.qualifier}.${v.member}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableId === 'hero') {
|
||||||
|
// 触发槽数组引用 + overrides 键
|
||||||
|
for (const tk of TRIGGER_ARRAY_KEYS) {
|
||||||
|
const arr = p[tk];
|
||||||
|
if (!arr || arr.kind !== 'arr') continue;
|
||||||
|
for (const it of arr.items) {
|
||||||
|
if (it.kind !== 'obj') continue;
|
||||||
|
const su = num(it.props['s_uuid']);
|
||||||
|
if (su !== undefined && !ctx.hasSkill(su)) push(tk, 'error', 'dangling-ref', `引用了不存在的技能 ${su}`);
|
||||||
|
const ov = it.props['overrides'];
|
||||||
|
if (ov && ov.kind === 'obj') {
|
||||||
|
for (const k of Object.keys(ov.props)) {
|
||||||
|
if (!OVERRIDE_KEYS.has(k)) push(`${tk}.overrides`, 'error', 'bad-override-key', `非法覆盖键 ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// field 列表
|
||||||
|
const fl = p['field'];
|
||||||
|
if (fl && fl.kind === 'arr') {
|
||||||
|
for (const it of fl.items) { const u = num(it); if (u !== undefined && !ctx.hasField(u)) push('field', 'error', 'dangling-ref', `引用了不存在的驻场技能 ${u}`); }
|
||||||
|
}
|
||||||
|
// revive
|
||||||
|
const rv = p['revive'];
|
||||||
|
if (rv && rv.kind === 'obj') {
|
||||||
|
const su = num(rv.props['s_uuid']); if (su !== undefined && !ctx.hasSkill(su)) push('revive', 'error', 'dangling-ref', `引用了不存在的技能 ${su}`);
|
||||||
|
}
|
||||||
|
// call_hero(技能表里的 ref 也用 hasHero)
|
||||||
|
}
|
||||||
|
if (tableId === 'skill') {
|
||||||
|
const ch = num(p['call_hero']); if (ch !== undefined && !ctx.hasHero(ch)) push('call_hero', 'error', 'dangling-ref', `引用了不存在的英雄 ${ch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeroList 一致性(仅 hero 表)
|
||||||
|
if (tableId === 'hero') {
|
||||||
|
const u = num(p['uuid']); const fac = p['fac'];
|
||||||
|
const isHero = fac && fac.kind === 'enumRef' && fac.member === 'HERO';
|
||||||
|
if (isHero && u !== undefined && !ctx.heroListKeys.has(String(u))) {
|
||||||
|
push('uuid', 'error', 'herolist-inconsistent', `英雄 ${u} 不在 HeroList 中`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user