二七王:布局求解器补齐显式失败与运行时注入契约,配置补 runtime/targetKind 声明

求解器(ui/LayoutSolver.js):
- 按方向校验 anchor:竖排只收 top/bottom/center、横排只收 left/right/center,
  未知 direction 一并拒绝。框架 AlignmentUtils 的 switch 对写错方向的 anchor 落到
  default(居中),笔误会被静默吞掉——ROOM_CATEGORY_COLUMN 就是这么把前两行摆到画布外的。
- grid 校验合并 ctx 后的 cols/rows 必须是数字:缺 rows 时 capacity=NaN 让超载守卫失效、
  循环一次不跑,静默返回空数组(一个矩形都不产出)。
- line/fan/grid 要求 anchorX/anchorY 必须是数字(框架的 anchorY||0 会把缺失变成 0);
  point 要求 x/y 必须是数字。
- apply() 精灵数与矩形数不等时抛错,不再静默截断:截断会让多余精灵停在上一手牌的旧坐标上。
- 统一运行时注入:ctx 里的 INJECT_KEYS(target/x/y/w/h/anchorX/anchorY/rows/
  itemWidth/itemHeight)覆盖配置同名字段,优先级 ctx > bySeat > base。
- line + items 竖排显式拒绝(只实现了水平)。

配置:
- ROOM_CATEGORY_COLUMN anchor 由 'left' 改为 'top'(竖排语义)。
- ROOM_OPTION_OVERFLOW_GRID 补 rows(runtime)、槽尺寸与 anchor 沿用 ROOM_OPTION_ROW,
  cols 直接引用 ROOM_OPTION_MAX_PER_ROW;ROOM_OPTION_ROW 补上注释里已声明的 itemHeight。
- 所有依赖运行时注入的节点加 runtime 声明,所有 attach 节点加 targetKind
  (sprite / layout / platform),显式区分「忘了写」与「故意延后到运行时」。

守卫(tests/test_constants.js):
- 求解冒烟改为只对 runtime 声明过的键注入假值,没声明却缺字段的照常抛错变红。
- 补矩形数量断言(line/fan/grid 按项数、point/attach 恒 1)——此前只查数组与坐标类型,
  空数组照样通过,「少画了几张牌」对守卫完全不可见。
- attach.target 改为只在 targetKind 指定的那一个命名空间里校验存在。
- 补 runtime 声明自身的合法性检查与布局节点总数(62)钉死。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:24:16 +08:00
co-authored by Claude Opus 5
parent b81be36f6e
commit 92edb6a3f5
8 changed files with 374 additions and 63 deletions
+103 -24
View File
@@ -57,13 +57,17 @@ t.eq('图层 ID 无重复', dupsOf(layerVals), []);
t.eq('群组 ID 无重复', dupsOf(groupVals), []);
t.eq('图片资源 ID 无重复', dupsOf(imgVals), []);
// ---- 布局配置里每个 attach.target 都能解析 ----
// target 可指向三类之一:子游戏精灵键名 / EQW_Layout 的布局节点名 / 平台精灵键名
// (平台头像框落在框架保留段 1–1000,独立登记、不参与子游戏号段校验)
// ---- 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(index).forEach(k => { resolvable[k] = true; });
Object.keys(EQW_Layout).forEach(k => { resolvable[k] = true; });
Object.keys(EQW_PlatformSprites).forEach(k => { resolvable[k] = true; });
Object.keys(NAMESPACES).forEach(kind => {
Object.keys(NAMESPACES[kind]).forEach(k => { resolvable[k] = true; });
});
// 收集所有布局节点,供解析检查与求解冒烟共用
const allNodes = []; // { path, node }
@@ -78,8 +82,24 @@ function collectNodes(node, path) {
}
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) {
@@ -88,10 +108,14 @@ allNodes.forEach(entry => {
if (v && v.target) { targets.push([entry.path + '.bySeat.' + s, v.target]); }
});
}
targets.forEach(pair => { if (!resolvable[pair[1]]) { missing.push(pair[0] + ' -> ' + pair[1]); } });
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 全部可解析', missing, []);
t.eq('attach.target 在其 targetKind 命名空间内可解析', missing, []);
// ---- 求解冒烟:每个布局节点都真的能被求解器算出来 ----
// 这是配置与求解器之间契约的最强守卫——字段名写错、缺 w/h、kind 不认识、
@@ -103,31 +127,83 @@ load('client/js/01_SubGame/codes/ui/LayoutSolver.js');
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 ctx = { rects: fakeRects, count: 8 };
const eff = effectiveNode(entry.node, seat);
const ctx = { rects: fakeRects };
if (seat) { ctx.seat = seat; }
// 无 target/w/h 的 attach 模板节点:运行时由调用方注入,这里补假值——
// 只在配置(含 bySeat 覆盖后)确实没写时才补,不覆盖配置里已有的值,
// 否则冒烟就测不到「配置自带尺寸」那条真实路径
const seatOverride = seat && entry.node.bySeat[seat] ? entry.node.bySeat[seat] : null;
const seatTarget = seatOverride ? seatOverride.target : null;
const seatW = seatOverride ? seatOverride.w : undefined;
const seatH = seatOverride ? seatOverride.h : undefined;
const effW = (seatW !== undefined) ? seatW : entry.node.w;
const effH = (seatH !== undefined) ? seatH : entry.node.h;
if (entry.node.kind === 'attach' && !entry.node.target && !seatTarget) {
ctx.target = Object.keys(fakeRects)[0];
(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
}
if (entry.node.kind === 'attach' && typeof effW !== 'number') { ctx.w = 60; }
if (entry.node.kind === 'attach' && typeof effH !== 'number') { ctx.h = 24; }
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)) {
@@ -140,8 +216,11 @@ allNodes.forEach(entry => {
});
});
if (solveFailures.length) { console.log('求解失败的节点:\n ' + solveFailures.join('\n ')); }
t.eq('全部布局节点可求解且坐标合法', solveFailures, []);
t.eq('布局节点数量 > 40', allNodes.length > 40, true);
t.eq('全部布局节点可求解、矩形数与坐标都对', solveFailures, []);
// 钉住节点总数:松下界(如 >40)删掉整个 View 都还能通过,任何意外的增删都要在这里可见。
// 增删布局节点时,连同本数字一起改——改动是有意的就顺手更新,不是有意的就红给你看
t.eq('布局节点总数', allNodes.length, 62);
// ---- 布局文件是纯数据:不含函数 ----
const funcs = [];
+86
View File
@@ -247,6 +247,92 @@ t.eq('bySeat 未知 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, {
t.eq('node 为空抛错', throws(() => EQW_LayoutSolver.solve(null, {})), true);
t.eq('未知 corner 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'middle', w: 1, h: 1 }, { rects: rects })), true);
// ============ 方向 × 对齐基准:写错方向的 anchor 必须抛错,不被框架 default 静默吞掉 ============
// AlignmentUtils.distributeVertically 只认 top/bottom/center,'left' 会落到 default(居中)——
// 3 项 32 高 8 间距时算出 y=[-56,-16,24],前两行跑到画布外,界面上却看不出是配置写错了
const vLine = (anchor) => ({ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemWidth: 200, itemHeight: 32, spacing: 8, anchor: anchor });
const hLine = (anchor) => ({ kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 200, itemHeight: 32, spacing: 8, anchor: anchor });
t.eq('竖排 anchor=left 抛错', throws(() => EQW_LayoutSolver.solve(vLine('left'), { count: 3 })), true);
t.eq('竖排 anchor=right 抛错', throws(() => EQW_LayoutSolver.solve(vLine('right'), { count: 3 })), true);
t.eq('竖排 anchor=top 正常', throws(() => EQW_LayoutSolver.solve(vLine('top'), { count: 3 })), false);
t.eq('竖排 anchor=bottom 正常', throws(() => EQW_LayoutSolver.solve(vLine('bottom'), { count: 3 })), false);
t.eq('竖排 anchor=center 正常', throws(() => EQW_LayoutSolver.solve(vLine('center'), { count: 3 })), false);
t.eq('竖排 不写 anchor 正常(框架默认 center)', throws(() => EQW_LayoutSolver.solve(vLine(undefined), { count: 3 })), false);
t.eq('横排 anchor=top 抛错', throws(() => EQW_LayoutSolver.solve(hLine('top'), { count: 3 })), true);
t.eq('横排 anchor=bottom 抛错', throws(() => EQW_LayoutSolver.solve(hLine('bottom'), { count: 3 })), true);
t.eq('横排 anchor=left 正常', throws(() => EQW_LayoutSolver.solve(hLine('left'), { count: 3 })), false);
t.eq('横排 anchor=right 正常', throws(() => EQW_LayoutSolver.solve(hLine('right'), { count: 3 })), false);
t.eq('未知 direction 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'line', direction: 'diagonal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 3 })), true);
// fan 走同一条 distribute,校验一致
t.eq('fan 竖排 anchor=left 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'fan', direction: 'vertical', anchorX: 0, anchorY: 0, maxWidth: 100, itemWidth: 10, itemHeight: 10, spacingMax: 0, spacingMin: -5, anchor: 'left' }, { count: 3 })), true);
// grid 逐行走水平分布,竖排语义的 anchor 同样拒绝
t.eq('grid anchor=top 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'grid', anchorX: 0, anchorY: 0, cols: 2, rows: 2, itemWidth: 10, itemHeight: 10, anchor: 'top' }, { count: 4 })), true);
// line + items 只实现了水平:竖排显式拒绝,不按水平硬算
t.eq('line+items 竖排抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemHeight: 10, anchor: 'top', items: [{ key: 'A', width: 10 }] }, {})), true);
// ============ 对齐基准点必填:框架的 anchorY||0 会把缺失静默变成 0 ============
t.eq('line 缺 anchorY 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'horizontal', anchorX: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 2 })), true);
t.eq('line 由 ctx.anchorY 补上则正常', EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'horizontal', anchorX: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' },
{ count: 2, anchorY: 77 })[0].y, 77);
t.eq('ctx.anchorX 覆盖配置 anchorX', EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' },
{ count: 2, anchorX: 500 })[0].x, 500);
t.eq('fan 缺 anchorX 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'fan', direction: 'horizontal', anchorY: 0, maxWidth: 100, itemWidth: 10, itemHeight: 10, spacingMax: 0, spacingMin: -5, anchor: 'left' }, { count: 3 })), true);
// ============ grid:cols/rows 缺失必须抛错(曾静默返回空数组)============
// cols*undefined = NaN → count > NaN 恒 false(超载守卫失效)、for(row<undefined) 一次不跑 → 返回 []
const gridNoRows = { kind: 'grid', anchorX: 0, anchorY: 0, cols: 4, itemWidth: 10, itemHeight: 10, anchor: 'left' };
t.eq('grid 缺 rows 抛错', throws(() => EQW_LayoutSolver.solve(gridNoRows, { count: 3 })), true);
t.eq('grid 缺 cols 抛错', throws(() => EQW_LayoutSolver.solve(
{ kind: 'grid', anchorX: 0, anchorY: 0, rows: 2, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 3 })), true);
// ctx.rows 运行时注入:行数由实际项数决定的场景
t.eq('ctx.rows 注入后正常求解', EQW_LayoutSolver.solve(gridNoRows, { count: 6, rows: 2 }).length, 6);
t.eq('ctx.rows 参与容量计算(满格)', EQW_LayoutSolver.solve(gridNoRows, { count: 8, rows: 2 }).length, 8);
t.eq('ctx.rows 参与容量计算(超载仍抛错)', throws(() => EQW_LayoutSolver.solve(gridNoRows, { count: 9, rows: 2 })), true);
t.eq('ctx.rows 覆盖配置 rows', throws(() => EQW_LayoutSolver.solve(
{ kind: 'grid', anchorX: 0, anchorY: 0, cols: 4, rows: 4, itemWidth: 10, itemHeight: 10, anchor: 'left' },
{ count: 9, rows: 2 })), true);
// ============ point:x/y 缺失抛错 + 运行时注入 ============
t.eq('point 缺 x/y 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'point', w: 10, h: 10 }, {})), true);
t.eq('point 缺 y 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'point', x: 1, w: 10, h: 10 }, {})), true);
t.eq('point 由 ctx.x/ctx.y 注入', EQW_LayoutSolver.solve({ kind: 'point', w: 10, h: 10 }, { x: 5, y: 6 }),
[{ x: 5, y: 6, width: 10, height: 10 }]);
t.eq('point 的 ctx.w/ctx.h 也生效', EQW_LayoutSolver.solve({ kind: 'point', x: 1, y: 2 }, { w: 33, h: 44 }),
[{ x: 1, y: 2, width: 33, height: 44 }]);
// ============ 运行时注入通道清单 ============
t.eq('INJECT_KEYS 覆盖 target/x/y/w/h/anchorX/anchorY/rows/itemWidth/itemHeight',
EQW_LayoutSolver.INJECT_KEYS.slice().sort(),
['anchorX', 'anchorY', 'h', 'itemHeight', 'itemWidth', 'rows', 'target', 'w', 'x', 'y']);
t.eq('ctx.itemWidth 注入生效', EQW_LayoutSolver.solve(
{ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemHeight: 32, spacing: 8, anchor: 'top' },
{ count: 2, itemWidth: 240 })[0].width, 240);
// runtime 声明只是给人和守卫看的契约,不影响求解(求解器对所有 INJECT_KEYS 一视同仁)
t.eq('runtime 声明不影响求解', EQW_LayoutSolver.solve(
{ kind: 'point', runtime: ['x', 'y'] }, { x: 7, y: 8 }), [{ x: 7, y: 8, width: undefined, height: undefined }]);
// ============ apply:精灵数与矩形数不一致必须抛错,不静默截断 ============
// 少摆的精灵会停在上一手牌的旧坐标上(陈旧精灵 + 陈旧坐标),是最难反查的一类错位
const moved = [];
global.SpriteManager = { setPosition: function (id, x, y) { moved.push([id, x, y]); } };
const applyLine = { kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, spacing: 0, anchor: 'left' };
moved.length = 0;
EQW_LayoutSolver.apply(applyLine, [11, 12, 13], { count: 3 });
t.eq('apply 数量相等时逐个摆位', moved, [[11, 0, 0], [12, 10, 0], [13, 20, 0]]);
moved.length = 0;
t.eq('apply 精灵多于矩形抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [11, 12, 13], { count: 2 })), true);
t.eq('apply 精灵少于矩形抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [11, 12], { count: 3 })), true);
t.eq('apply 抛错时一个都没摆', moved, []);
t.eq('apply 未传 spriteIds 抛错', throws(() => EQW_LayoutSolver.apply(applyLine, null, { count: 3 })), true);
t.eq('apply 空数组 + 零矩形不抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [], { count: 0 })), false);
// ============ 纯函数:不修改入参 ============
const before = JSON.stringify(bySeat);
EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 3 });