// 常量层机械守卫:号段合规、ID 不重复、布局引用可解析 const { load } = require('./_load'); const t = require('./_assert')(); load('client/js/01_SubGame/codes/config/Layers.js'); load('client/js/01_SubGame/codes/config/Groups.js'); load('client/js/01_SubGame/codes/config/ImageResources.js'); load('client/js/01_SubGame/codes/config/SoundResources.js'); load('client/js/01_SubGame/codes/config/Sprites_Table.js'); load('client/js/01_SubGame/codes/config/Sprites_Cards.js'); load('client/js/01_SubGame/codes/config/Sprites_Action.js'); load('client/js/01_SubGame/codes/config/Sprites_Result.js'); load('client/js/01_SubGame/codes/config/Sprites_CreateRoom.js'); load('client/js/01_SubGame/codes/config/LayoutConstants.js'); load('client/js/01_SubGame/codes/config/Layout_Table.js'); load('client/js/01_SubGame/codes/config/Layout_Cards.js'); load('client/js/01_SubGame/codes/config/Layout_Action.js'); load('client/js/01_SubGame/codes/config/Layout_Result.js'); load('client/js/01_SubGame/codes/config/Layout_CreateRoom.js'); load('client/js/01_SubGame/codes/config/AnimConstants.js'); load('client/js/01_SubGame/codes/core/SpriteIndex.js'); // ---- 图层号段:101–200 / 301–400 / 501–600 / 701+(client 02 §1)---- // 平台图层 27(建房)是引用而非子游戏自建,单独排除 const layerVals = Object.keys(EQW_Layers).filter(k => k !== 'PLATFORM_CREATE_ROOM').map(k => EQW_Layers[k]); const layerOk = layerVals.every(v => (v >= 101 && v <= 200) || (v >= 301 && v <= 400) || (v >= 501 && v <= 600) || v >= 701); t.eq('图层落在子游戏段', layerOk, true); t.eq('图层 27 是平台的建房界面', EQW_Layers.PLATFORM_CREATE_ROOM, 27); // ---- 群组 ≥201 ---- const groupVals = Object.keys(EQW_Groups).map(k => EQW_Groups[k]); t.eq('群组均 >= 201', groupVals.every(v => v >= 201), true); // ---- 图片资源 ≥501;声音 ≥101 ---- const imgVals = Object.keys(EQW_Images).map(k => EQW_Images[k]); t.eq('图片资源均 >= 501', imgVals.every(v => v >= 501), true); const soundVals = Object.keys(EQW_Sounds).map(k => EQW_Sounds[k]); t.eq('声音资源均 >= 101', soundVals.every(v => v >= 101), true); // T-20 未定时为空数组,恒真 // ---- 精灵 ID 1001–2999(段内 3000 已被平台占用,清单 §0.3)---- const index = EQW_SpriteIndex.build(EQW_Sprites); const spriteVals = Object.keys(index).map(k => index[k]); // 钉住精确数量:松下界(如 >200)删掉整个 View 都还能通过。 // 增删精灵时连同本数字一起改——有意的顺手更新,不是有意的就红给你看 t.eq('精灵总数', spriteVals.length, 378); const spriteOk = spriteVals.every(v => v >= 1001 && v <= 2999); if (!spriteOk) { console.log('越界精灵: ' + JSON.stringify(spriteVals.filter(v => v < 1001 || v > 2999))); } t.eq('精灵落在 1001-2999', spriteOk, true); // ---- 手牌标记与手牌一一对应(清单 §1.6:三种标记互斥、【每张牌】至多一个)---- // core/CardMark.js 也是按每张牌返回标记的:一手 28 张常带 13 个以上标记、36 张埋牌手更多, // 标记精灵必须与手牌等量,否则渲染时必然有牌的标记画不出来 const groupMap = EQW_SpriteIndex.buildGroupMap(EQW_Sprites); const handPairing = []; for (let i = 1; i <= 36; i++) { if (!index.hasOwnProperty('HAND_CARD_' + i)) { handPairing.push('缺 HAND_CARD_' + i); } if (!index.hasOwnProperty('HAND_MARK_' + i)) { handPairing.push('缺 HAND_MARK_' + i); } else if (groupMap['HAND_MARK_' + i] !== groupMap['HAND_CARD_' + i]) { handPairing.push('HAND_MARK_' + i + ' 与手牌不同群组'); } } if (handPairing.length) { console.log('手牌标记配对问题:\n ' + handPairing.join('\n ')); } t.eq('手牌标记与手牌 1..36 一一对应', handPairing, []); t.eq('手牌标记恰好 36 个(无第 37 个)', index.hasOwnProperty('HAND_MARK_37'), false); // ---- 各类 ID 全局不重复 ---- function dupsOf(vals) { const seen = {}, dups = []; vals.forEach(v => { if (seen[v]) { dups.push(v); } seen[v] = true; }); return dups; } t.eq('精灵 ID 无重复', dupsOf(spriteVals), []); t.eq('图层 ID 无重复', dupsOf(layerVals), []); t.eq('群组 ID 无重复', dupsOf(groupVals), []); t.eq('图片资源 ID 无重复', dupsOf(imgVals), []); // ---- attach.target 的来源判别:targetKind 指明去哪个命名空间取矩形 ---- // 'sprite' 子游戏精灵键名(EQW_Sprites 索引) // 'layout' EQW_Layout 的布局节点名(矩形来自该节点的求解结果,可能是其中第 N 项) // 'platform' 平台精灵键名(如平台头像框,落在框架保留段 1–1000,不参与子游戏号段校验) // 只在【对应的那一个】命名空间里校验存在性——查三者并集范围太宽,几乎任何标识符都能"解析"。 const NAMESPACES = { sprite: index, layout: EQW_Layout, platform: EQW_PlatformSprites }; const resolvable = {}; Object.keys(NAMESPACES).forEach(kind => { Object.keys(NAMESPACES[kind]).forEach(k => { resolvable[k] = true; }); }); // 收集所有布局节点,供解析检查与求解冒烟共用 const allNodes = []; // { path, node } function collectNodes(node, path) { if (!node || typeof node !== 'object' || Array.isArray(node)) { return; } if (node.kind) { allNodes.push({ path, node }); } Object.keys(node).forEach(k => { if (k !== 'bySeat' && node[k] && typeof node[k] === 'object' && !Array.isArray(node[k])) { collectNodes(node[k], path + '.' + k); } }); } Object.keys(EQW_Layout).forEach(k => collectNodes(EQW_Layout[k], k)); // 每个 attach 节点必须声明合法 targetKind;非 attach 节点不得声明(避免复制粘贴留下的无意义字段) const kindDecl = []; allNodes.forEach(entry => { const tk = entry.node.targetKind; if (entry.node.kind === 'attach') { if (!NAMESPACES.hasOwnProperty(tk)) { kindDecl.push(entry.path + ' -> targetKind=' + tk); } } else if (typeof tk !== 'undefined') { kindDecl.push(entry.path + ' -> 非 attach 却声明了 targetKind=' + tk); } }); if (kindDecl.length) { console.log('targetKind 声明有问题的节点:\n ' + kindDecl.join('\n ')); } t.eq('attach 节点均声明合法 targetKind', kindDecl, []); // 配置里写死的 target 必须存在于 targetKind 指定的那个命名空间 const missing = []; allNodes.forEach(entry => { const ns = NAMESPACES[entry.node.targetKind]; if (!ns) { return; } // targetKind 非法已由上一条断言报出 const targets = []; if (entry.node.target) { targets.push([entry.path, entry.node.target]); } if (entry.node.bySeat) { Object.keys(entry.node.bySeat).forEach(s => { const v = entry.node.bySeat[s]; if (v && v.target) { targets.push([entry.path + '.bySeat.' + s, v.target]); } }); } targets.forEach(pair => { if (!Object.prototype.hasOwnProperty.call(ns, pair[1])) { missing.push(pair[0] + ' -> ' + pair[1] + '(不在 ' + entry.node.targetKind + ' 命名空间里)'); } }); }); if (missing.length) { console.log('无法解析的 attach.target:\n ' + missing.join('\n ')); } t.eq('attach.target 在其 targetKind 命名空间内可解析', missing, []); // ---- 求解冒烟:每个布局节点都真的能被求解器算出来 ---- // 这是配置与求解器之间契约的最强守卫——字段名写错、缺 w/h、kind 不认识、 // bySeat 缺座位,全都会在这里当场抛错,而不是等到界面上静默摆歪。 load('client/js/gameabc-framework/ui/AlignmentUtils.js'); load('client/js/01_SubGame/codes/ui/LayoutSolver.js'); // 合成一个万能 ctx:所有可解析的键都给一个矩形,count 给一个合理值 const fakeRects = {}; Object.keys(resolvable).forEach(k => { fakeRects[k] = { x: 100, y: 100, width: 80, height: 40 }; }); // 运行时注入字段的假值表:【只对节点 runtime 里显式声明过的键】注入。 // 没声明却缺字段的节点,求解器照常抛错、本守卫照常变红——这正是 runtime 声明的意义: // 区分「故意延后到运行时」与「忘了写」,守卫不再无差别地自动补齐缺失字段。 const RUNTIME_FAKE = { target: 'TOP_INFO_BG', x: 10, y: 20, w: 60, h: 24, anchorX: 30, anchorY: 40, rows: 2, itemWidth: 150, itemHeight: 32 }; // 求解器新开了注入通道却忘了给假值 → 这里立刻红 t.eq('运行时假值表覆盖全部可注入字段', EQW_LayoutSolver.INJECT_KEYS.filter(k => !RUNTIME_FAKE.hasOwnProperty(k)), []); t.eq('假值表里的 target 是真实可解析键', resolvable[RUNTIME_FAKE.target], true); // bySeat 覆盖后的有效配置(bySeat 可覆盖包括 kind 在内的任意字段) function effectiveNode(node, seat) { if (!node.bySeat || !seat) { return node; } const out = {}; Object.keys(node).forEach(k => { if (k !== 'bySeat') { out[k] = node[k]; } }); Object.keys(node.bySeat[seat]).forEach(k => { out[k] = node.bySeat[seat][k]; }); return out; } // runtime 声明本身要合法:必须是字符串数组、键名在求解器的可注入清单里、 // 且该字段在配置里确实留空(配置已经写了值还声明 runtime,说明声明是过期的) const runtimeDecl = []; allNodes.forEach(entry => { const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null]; seats.forEach(seat => { const eff = effectiveNode(entry.node, seat); const label = entry.path + (seat ? '[' + seat + ']' : ''); if (typeof eff.runtime === 'undefined') { return; } if (!Array.isArray(eff.runtime)) { runtimeDecl.push(label + ' -> runtime 不是数组'); return; } eff.runtime.forEach(k => { if (EQW_LayoutSolver.INJECT_KEYS.indexOf(k) < 0) { runtimeDecl.push(label + ' -> runtime 含不可注入的字段: ' + k); } else if (typeof eff[k] !== 'undefined') { runtimeDecl.push(label + ' -> runtime 声明了 ' + k + ',但配置里已写了值'); } }); }); }); if (runtimeDecl.length) { console.log('runtime 声明有问题的节点:\n ' + runtimeDecl.join('\n ')); } t.eq('runtime 声明合法且确实留空', runtimeDecl, []); const SMOKE_COUNT = 8; const solveFailures = []; allNodes.forEach(entry => { // 有 bySeat 的按三个显示位各求一次,没有的求一次 const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null]; seats.forEach(seat => { const eff = effectiveNode(entry.node, seat); const ctx = { rects: fakeRects }; if (seat) { ctx.seat = seat; } (eff.runtime || []).forEach(k => { ctx[k] = RUNTIME_FAKE[k]; }); // 期望产出多少个矩形——只断言"是数组、坐标是数字"的话,返回空数组也照样通过, // 「少画了几张牌」这类缺陷对守卫完全不可见(曾经 grid 缺 rows 就是这么漏过去的) let expected; if (eff.kind === 'grid') { const rows = (typeof ctx.rows === 'number') ? ctx.rows : eff.rows; const capacity = eff.cols * rows; ctx.count = Math.min(SMOKE_COUNT, capacity); // 不制造超载(超载抛错另有专门用例) expected = ctx.count; } else if (eff.kind === 'line' || eff.kind === 'fan') { ctx.count = SMOKE_COUNT; expected = eff.items ? eff.items.length : SMOKE_COUNT; } else { expected = 1; // point / attach 恒为 1 } const label = entry.path + (seat ? '[' + seat + ']' : ''); try { const r = EQW_LayoutSolver.solve(entry.node, ctx); if (!Array.isArray(r)) { solveFailures.push(label + ' -> 返回值不是数组'); return; } if (r.length !== expected) { solveFailures.push(label + ' -> 矩形数 ' + r.length + ',期望 ' + expected); return; } r.forEach((rect, i) => { if (typeof rect.x !== 'number' || isNaN(rect.x) || typeof rect.y !== 'number' || isNaN(rect.y)) { solveFailures.push(label + ' -> 第' + i + '项坐标非法: ' + JSON.stringify(rect)); } }); } catch (e) { solveFailures.push(label + ' -> ' + e.message); } }); }); if (solveFailures.length) { console.log('求解失败的节点:\n ' + solveFailures.join('\n ')); } t.eq('全部布局节点可求解、矩形数与坐标都对', solveFailures, []); // 钉住节点总数:松下界(如 >40)删掉整个 View 都还能通过,任何意外的增删都要在这里可见。 // 增删布局节点时,连同本数字一起改——改动是有意的就顺手更新,不是有意的就红给你看 t.eq('布局节点总数', allNodes.length, 61); // ---- 布局文件是纯数据:不含函数 ---- const funcs = []; function checkPure(node, path) { if (!node || typeof node !== 'object') { return; } Object.keys(node).forEach(k => { const v = node[k]; if (typeof v === 'function') { funcs.push(path + '.' + k); } else if (v && typeof v === 'object') { checkPure(v, path + '.' + k); } }); } Object.keys(EQW_Layout).forEach(k => checkPure(EQW_Layout[k], k)); t.eq('布局配置不含函数', funcs, []); // ---- 每个布局节点的 kind 合法 ---- const badKinds = []; function checkKind(node, path) { if (!node || typeof node !== 'object') { return; } if (node.kind && ['point', 'line', 'fan', 'grid', 'attach'].indexOf(node.kind) < 0) { badKinds.push(path + ' -> ' + node.kind); } Object.keys(node).forEach(k => { if (node[k] && typeof node[k] === 'object' && !Array.isArray(node[k])) { checkKind(node[k], path + '.' + k); } }); } Object.keys(EQW_Layout).forEach(k => checkKind(EQW_Layout[k], k)); t.eq('布局 kind 全部合法', badKinds, []); // ---- 建房选项配置:精灵键由配置给出,渲染器不按下标拼字符串 ---- const optProblems = []; const usedOptSprites = {}; function useSprite(where, key) { if (!key) { optProblems.push(where + ' 未声明精灵键'); return; } if (!index.hasOwnProperty(key)) { optProblems.push(where + ' -> 精灵键不存在: ' + key); return; } if (usedOptSprites[key]) { optProblems.push(where + ' -> 精灵键被重复引用: ' + key); } usedOptSprites[key] = true; } const bitsSeen = {}; EQW_RoomOptions.categories.forEach((cat, ci) => { useSprite('类别' + ci + ' titleSprite', cat.titleSprite); cat.groups.forEach(g => { if (g.hasOwnProperty('bit')) { optProblems.push('组 ' + g.key + ' 仍把 bit 挂在组上(应统一到 item)'); } if (g.note) { useSprite('组 ' + g.key + ' noteSprite', g.noteSprite); } if (['radio', 'checkbox', 'radioOptional'].indexOf(g.type) < 0) { optProblems.push('组 ' + g.key + ' -> 未知 type: ' + g.type); } g.items.forEach((it, ii) => { const where = '组 ' + g.key + ' 第' + ii + '项'; useSprite(where + ' box', it.box); useSprite(where + ' text', it.text); if (typeof it.bit !== 'number') { optProblems.push(where + ' 缺 bit'); } if (it.value !== '0' && it.value !== '1') { optProblems.push(where + ' value 非 0/1: ' + it.value); } bitsSeen[it.bit] = true; // 单选组内各项必须共用同一个 bit(同一位互斥取值) if (g.type !== 'checkbox' && it.bit !== g.items[0].bit) { optProblems.push(where + ' 与同组首项 bit 不一致'); } }); // 多选组各项必须占用互不相同的位 if (g.type === 'checkbox') { const bs = g.items.map(it => it.bit); if (new Set(bs).size !== bs.length) { optProblems.push('组 ' + g.key + ' 多选各项 bit 重复'); } } }); }); // 配置声明的精灵必须恰好覆盖建房 View 里预置的全部选项精灵(多一个少一个都说明两边不同步) Object.keys(EQW_Sprites.CreateRoomView.CreateRoom).forEach(k => { if (k === 'id') { return; } if (!usedOptSprites[k]) { optProblems.push('精灵 ' + k + ' 没有任何配置项引用它'); } }); if (optProblems.length) { console.log('建房选项配置问题:\n ' + optProblems.join('\n ')); } t.eq('建房选项精灵键与 bit 声明完整', optProblems, []); // roomtype 是 5 位串(服务端 class.config.js IDX_ASET..IDX_NOCHECK = 0..4),位号必须刚好铺满 t.eq('roomtype 位号铺满 0-4', Object.keys(bitsSeen).map(Number).sort((a, b) => a - b), [0, 1, 2, 3, 4]); // ---- 牌尺寸档引用的资源键存在 ---- ['L', 'M', 'S'].forEach(size => { t.eq('CARD_SIZE.' + size + ' 的资源存在', EQW_Images.hasOwnProperty(EQW_Layout.CARD_SIZE[size].res), true); }); // ---- 数字样式(图集叠绘):资源键存在、字段齐全合法 ---- // 与 CARD_SIZE 同一约定:纯数据配置只存资源【键名】,调用方用 EQW_Images[res] 取真实 ID const numStyleProblems = []; Object.keys(EQW_Layout.NUM_STYLE).forEach(key => { const s = EQW_Layout.NUM_STYLE[key]; if (!EQW_Images.hasOwnProperty(s.res)) { numStyleProblems.push(key + ' 的资源键不存在: ' + s.res); } ['charWidth', 'charHeight', 'width'].forEach(f => { if (typeof s[f] !== 'number' || s[f] <= 0) { numStyleProblems.push(key + '.' + f + ' 非正数: ' + s[f]); } }); if (typeof s.spacing !== 'number') { numStyleProblems.push(key + '.spacing 非数字: ' + s.spacing); } if (['left', 'center', 'right'].indexOf(s.align) < 0) { numStyleProblems.push(key + '.align 非法: ' + s.align); } // 绘制区至少放得下一个字符,否则这套样式一开始就画不出东西 if (s.width < s.charWidth) { numStyleProblems.push(key + ' 绘制区比单字还窄'); } }); if (numStyleProblems.length) { console.log('数字样式问题:\n ' + numStyleProblems.join('\n ')); } t.eq('NUM_STYLE 资源键存在且字段合法', numStyleProblems, []); // 引用 NUM_STYLE 的布局节点必须与样式同源(SSOT:数值只在 LayoutConstants 定义一处, // 有人在 Layout_Action.js 里手抄一个不同的数就在这里变红) [['CALL_BTN_SCORE_TEXT', 'CALL_SCORE'], ['MAIN_SUIT_COUNT', 'MAIN_SUIT_COUNT']].forEach(pair => { const node = EQW_Layout[pair[0]]; const st = EQW_Layout.NUM_STYLE[pair[1]]; t.eq(pair[0] + '.w 与 NUM_STYLE.' + pair[1] + '.width 同源', node.w, st.width); t.eq(pair[0] + '.h 与 NUM_STYLE.' + pair[1] + '.charHeight 同源', node.h, st.charHeight); }); process.exit(t.done('constants') ? 0 : 1);