Files
joywayerandClaude Opus 5 2b1dd0d947 二七王:修复出牌操作条/亮牌历史布局越界,补主牌统计条改名遗漏
- I1:Layers.js 群组 206 改名遗漏一处注释(亮牌信息条 → 主牌统计条),全仓复查确认无第二处
- I2:出牌操作条补「提示」项后 anchor:center 导致已有两项挤偏,改用 anchor:left 并反推
  anchorX=446,令「出牌」按钮回到参考图实测 x≈556(求解器验证:446/556/748)
- I3:HIST_FAN/MING_FAN 的 maxWidth 反推算错(旧注释「488」有误,实际总宽恒等于
  maxWidth=500),导致 RIGHT 座位整排超出画布 5px;收窄为 490 使三座位求解结果落在
  0-1280 内,订正注释与清单 §6.9c 对应文字
- M3:同步修正计划文档里遗留的旧名「亮牌信息条/亮牌条」
- M6:test_constants.js 补 items[].key 的三命名空间解析守卫(此前完全无守卫)
- M5/M7:订正 ACC_ROW_TEXT_GRID 与扣底按钮宽度的估值依据说明
- M1/M2:清单 §6.9 子节重新排序为 6.9a→6.9b→6.9c 并统一标题层级,修正脚注星号误渲染

client/tests/run.js 与 server/games/erqiwang/test/run.js 均全绿,精灵总数(380)/
布局节点数(83) 两个钉死断言未变。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:06:01 +08:00

376 lines
20 KiB
JavaScript
Raw Permalink 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, 380);
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, []);
// line + items 节点:每项 key 同样是"指向另一个命名空间对象"的标识(多为精灵键),
// 与 attach.target 同一职责,此前完全没有守卫——写错 key 不会报错,只会在渲染时摆错精灵。
// items 没有 targetKind 字段,按 attach.target 同样的三命名空间并集解析
const missingItemKeys = [];
allNodes.forEach(entry => {
if (!entry.node.items) { return; }
entry.node.items.forEach((it, i) => {
if (!resolvable[it.key]) {
missingItemKeys.push(entry.path + '.items[' + i + '] -> key 不存在: ' + it.key);
}
});
});
if (missingItemKeys.length) { console.log('无法解析的 items[].key:\n ' + missingItemKeys.join('\n ')); }
t.eq('items[].key 在三命名空间并集内可解析', missingItemKeys, []);
// ---- 求解冒烟:每个布局节点都真的能被求解器算出来 ----
// 这是配置与求解器之间契约的最强守卫——字段名写错、缺 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, 83);
// ---- 布局文件是纯数据:不含函数 ----
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);
});
// ---- 多帧图数字样式(配合 SpriteManager.setNumberImage):资源键存在、字段齐全合法 ----
// 与 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 是 setNumberImage 的必需入参(该接口对非正数直接返回 false)
['charWidth', 'charHeight'].forEach(f => {
if (typeof s[f] !== 'number' || s[f] <= 0) { numStyleProblems.push(key + '.' + f + ' 非正数: ' + s[f]); }
});
// 后缀落在资源第 16 帧,只能是 0 或 1 个字符;且必须是 setNumberImage 认识的那三个
// (编码表 /[分倍张]/ → 'g' → 帧16;写别的字会被原样丢给引擎,按未知字符渲染)
if (typeof s.suffix !== 'string') { numStyleProblems.push(key + '.suffix 必须是字符串: ' + s.suffix); }
else if (s.suffix !== '' && ['分', '倍', '张'].indexOf(s.suffix) < 0) {
numStyleProblems.push(key + '.suffix 不是 setNumberImage 支持的后缀(分/倍/张): ' + s.suffix);
}
});
if (numStyleProblems.length) { console.log('数字样式问题:\n ' + numStyleProblems.join('\n ')); }
t.eq('NUM_STYLE 资源键存在且字段合法', numStyleProblems, []);
// 引用 NUM_STYLE 的布局节点必须与样式同源(SSOT:数值只在 LayoutConstants 定义一处,
// 有人在 Layout_Action.js 里手抄一个不同的数就在这里变红)。
// 注意 w 不在此列:setNumberImage 会按【字符数 × charWidth】自动改精灵宽,故 w 由运行时注入
[
['CALL_BTN_SCORE_NUM', 'CALL_SCORE'],
['MAIN_SUIT_COUNT', 'MAIN_SUIT_COUNT'],
['P_COUNTDOWN', 'COUNTDOWN'],
['RESULT_SCORE_NUM', 'RESULT_WIN'],
['RESULT_SCORE_NUM', 'RESULT_LOSE'],
['ACC_TPL_SCORE_NUM', 'RESULT_WIN'],
['ACC_TPL_SCORE_NUM', 'RESULT_LOSE']
].forEach(pair => {
const node = EQW_Layout[pair[0]];
const st = EQW_Layout.NUM_STYLE[pair[1]];
t.eq(pair[0] + '.h 与 NUM_STYLE.' + pair[1] + '.charHeight 同源', node.h, st.charHeight);
t.eq(pair[0] + '.w 留到运行时注入', node.runtime.indexOf('w') >= 0 && typeof node.w === 'undefined', true);
});
process.exit(t.done('constants') ? 0 : 1);