Files
erqiwang_youle/client/tests/test_constants.js
T
joywayerandClaude Opus 5 d1b60275a4 二七王:建房选项配置补精灵键、bit 统一挂到 item、补 || {} 保护声明
Sprites_CreateRoom.js 的注释说「配置里每个选项声明自己用哪个精灵键」,但 EQW_RoomOptions
的 items 只有 label/value,渲染器只剩「按下标拼字符串」一条路——而那正是同文件明文禁止的;
何况也拼不对(组的 key 是 'rules',精灵却叫 ROOM_BOX_RULE_1)。

- 每个类别加 titleSprite,每个选项加 box/text 精灵键,房卡附注加 noteSprite。
- bit 统一挂到每个 item 上(单选组各项写同一个 bit),拼 roomtype 的循环对
  radio/checkbox 是同一个形状(item → 写 value 到 bit),不再按组/项两种层级分支;
  checkbox 项补上 value:'1'。
- 补上其他常量文件都有的 `|| {}` 保护性声明。
- 守卫新增:精灵键必须存在且互不重复、必须恰好覆盖建房 View 预置的全部选项精灵、
  bit 不得再挂在组上、单选组内 bit 一致、多选组 bit 互异、位号刚好铺满 0–4
  (对齐服务端 class.config.js 的 IDX_ASET..IDX_NOCHECK)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:26:21 +08:00

323 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 常量层机械守卫:号段合规、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, 387);
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, 62);
// ---- 布局文件是纯数据:不含函数 ----
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);
});
process.exit(t.done('constants') ? 0 : 1);