From 4a5659b7ec8bd030b537aa652a5aed425d49813b Mon Sep 17 00:00:00 2001 From: panFD Date: Sun, 21 Jun 2026 09:11:47 +0800 Subject: [PATCH] feat(config-editor): add validation rules (dup/required/enum/ref/overrides/herolist) with tests --- .../__tests__/validation.test.ts | 67 +++++++++++ .../src/shared/validation/index.ts | 107 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 extensions/pixelhero-config-editor/__tests__/validation.test.ts create mode 100644 extensions/pixelhero-config-editor/src/shared/validation/index.ts diff --git a/extensions/pixelhero-config-editor/__tests__/validation.test.ts b/extensions/pixelhero-config-editor/__tests__/validation.test.ts new file mode 100644 index 00000000..5def95d3 --- /dev/null +++ b/extensions/pixelhero-config-editor/__tests__/validation.test.ts @@ -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(['5011']), +}; + +function heroObj(extra: Record = {}): 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([]) }; + 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')); +}); diff --git a/extensions/pixelhero-config-editor/src/shared/validation/index.ts b/extensions/pixelhero-config-editor/src/shared/validation/index.ts new file mode 100644 index 00000000..d40b6f20 --- /dev/null +++ b/extensions/pixelhero-config-editor/src/shared/validation/index.ts @@ -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; +} + +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, ctx: ValidationContext): Issue[] { + const issues: Issue[] = []; + // 跟踪 uuid 字段值(而非 Map 键)—— JS Map 会折叠重复键,但两条记录可能携带相同的 uuid 字段值。 + const seenUuids = new Set(); + + 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 = { + 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; +}