feat(config-editor): add validation rules (dup/required/enum/ref/overrides/herolist) with tests

This commit is contained in:
panFD
2026-06-21 09:11:47 +08:00
parent 4df88c1c90
commit 4a5659b7ec
2 changed files with 174 additions and 0 deletions

View File

@@ -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'));
});

View File

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