feedback5: merge feedback4 — PR #129, the lobby ERR import

This commit is contained in:
sganggs committed 2026-10-05 16:48:02 +08:00
commit 8ab6d021da
15 files changed
+521 -20

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
// 点击 / 观战队友单位时详情卡显示装备(游玩记录 #2:「观战队友时看不到干员的装备」)。DESIGN §16
// 承诺 "a teammate's unit shows its owner's loadout in the detail card":服务器把 an ally operator 的
// 装备 id 放进战斗快照与侦察 meta 的 UnitInfo.items(server/sim/snapshot.js、Match.prepFieldMeta),
// detailPanel 的 unit 分支也从 `u.items` 取 `unitItems` —— 但 renderInfo(public/js/render/app.js)
// 构建 info 白名单时把 items 丢了,队友单位点开的卡永远没有 装备 区,变形同构体的「同构」pairing
// chips 对队友单位同样失效。renderInfo 在 app.js 里(模块顶层有 canvas 副作用,node 无法 import),
// 按docs-consistency / feedback1d 的惯例对源码断言;消费端行为已由 morph-bonds.test.js 锁住,
// 这里再走一遍 unit → resolveDetail → ChessDetail 的完整用户可见结果。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
const chess = load('chess.json');
const WEARER = 'chess_char_1_01_a'; // 隐现
const ISO = 'chess_item_6_09_e_a'; // 变形同构体
const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 → 维多利亚
// ---- renderInfo: the snapshot UnitInfo reaches the client info object ---------------------------------------------
test('renderInfo passes UnitInfo.items through (the teammate-unit equipment into the detail card)', () => {
const app = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
const start = app.indexOf('export function renderInfo(');
assert.ok(start > 0, 'renderInfo exists');
const body = app.slice(start, app.indexOf('\n}', start));
assert.match(
body,
/items: Array\.isArray\(u\.items\) \? u\.items\.filter\(\(x\) => typeof x === 'string'\) : undefined/,
'the equipped item ids must survive the info whitelist (DESIGN §16: a teammate\'s unit shows its owner\'s loadout)',
);
});
// ---- the user-visible end of the chain: a teammate unit's card with its equipment ----------------------------------
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { data } = await import('../../public/js/data.js');
await data.loadAll('bonds', 'chess', 'items', 'assets', 'garrisons');
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { BondChips } = await import('../../public/js/ui/detailPanel.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
test('a teammate unit carrying items opens with a read-only 装备 section and the worn 同构 pairing', () => {
// the info object exactly as the battle view holds it after renderInfo (unit: info in pieceClick)
const unit = { id: 7, uid: null, kind: 'op', side: 'ally', ownerId: 'p2', defId: WEARER, items: [ISO, HAMMER] };
const d = resolveDetail({ kind: 'unit', unit }, new Map());
assert.equal(d.type, 'chess');
assert.deepEqual(d.unitItems, [ISO, HAMMER], 'no own piece for a teammate: the unit\'s items become unitItems');
const mine = [{ bondId: 'victoriaShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6] }];
const blocks = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null, unitItems: d.unitItems });
const equip = blocks.find((b) => b?.key === 'equip');
assert.ok(equip, 'the 装备 section renders without an own piece');
assert.deepEqual([...walk(equip)].filter((x) => x.props?.itemId).map((x) => x.props.itemId), [ISO, HAMMER]);
const chips = [...walk(blocks)].find((x) => x.type === BondChips);
assert.ok(chips, 'the card has bond chips');
const vic = [...walk(BondChips(chips.props))].find((x) => hasClass(x, 'dbond') && x.props['data-bond'] === 'victoriaShip');
assert.ok(vic && hasClass(vic, 'is-granted'), '变形同构体 marks the wearer as a 维多利亚 member on a teammate\'s card too');
});
test('a unit without items (enemies, summons, plain allies) still opens without a 装备 section', () => {
const d = resolveDetail({ kind: 'unit', unit: { id: 8, kind: 'op', side: 'ally', ownerId: 'p2', defId: WEARER } }, new Map());
assert.equal(d.unitItems, null, 'renderInfo leaves items undefined; resolveDetail hands null on');
const mine = [{ bondId: 'victoriaShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6] }];
const blocks = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null, unitItems: d.unitItems });
assert.equal(blocks.find((b) => b?.key === 'equip'), undefined, 'no empty 装备 section');
});
+51
View File
@@ -0,0 +1,51 @@
// 观战整备区的客户端(游玩记录 #2 item 1):被侦察玩家的手牌是 m.field(prep:true).units 里手牌行
// (row 7)上的单位——干员站立、道具 kind 'item'(浮牌),和自己整备区同一渲染路径;观战棋盘用
// prep 相机(收起商店形态,bench 行进画面)。battleView 为 kind 'item' 建 ItemView(图标/颜色客户端
// 解析,同 pieceInfo),prep 面上给带装备的队友干员挂 item pips。服务器侧:test/match/prep-bench.test.js。
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { data } = await import('../../public/js/data.js');
await data.loadAll('bonds', 'chess', 'items', 'assets');
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
describe('the scouted prep board renders the hand like the own bench', () => {
test('the game screen frames it with the prep camera in its shop-folded form (source)', () => {
const game = read('public/js/screens/game.js');
assert.match(game, /field\.prep \? 'prep' : \(field\.kind === 'hidden' \? 'boss' : field\.kind \|\| 'normal'\)/);
assert.match(game, /field\.prep \? \{ shop: false \} : \{\}/);
assert.match(game, /const scoutPid = watchingOther && field\?\.prep/);
assert.ok(!game.includes('ScoutedBench'), 'the floating strip is gone — the hand rides the board');
assert.ok(!read('public/css/screens/game.css').includes('.sbench'), 'its styles too');
});
test('battleView builds an ItemView for a hand item and item pips for a teammate operator (source)', () => {
const app = read('public/js/render/app.js');
assert.match(app, /info\.kind === 'item' \? new ItemView\(ctx, scoutItemInfo\(info\)\)/);
assert.match(app, /battleMeta\?\.prep && Array\.isArray\(info\.items\) && info\.items\.length/);
assert.match(app, /function scoutItemInfo\(info\)/);
});
test('tapping a held item on the scouted board opens its card (resolveDetail unit → item)', () => {
const IT = 'chess_item_1_01_e_a';
const d = resolveDetail({ kind: 'unit', unit: { id: 3, kind: 'item', side: 'ally', ownerId: 'p2', defId: IT } }, new Map());
assert.equal(d.type, 'item');
assert.equal(d.item.itemId || d.item.id, IT);
assert.equal(resolveDetail({ kind: 'unit', unit: { id: 4, kind: 'item', side: 'ally', ownerId: 'p2', defId: 'nope' } }, new Map()), null);
});
});
+61
View File
@@ -0,0 +1,61 @@
// 观战对象的效果列(游玩记录 #2 追加反馈:「观战队友时右侧的 effect 列也改成观战对象的」):
// * 整备期侦察:m.field 直接带被侦察玩家的 effectsView(server Match.prepFieldMeta,现成显示形状);
// * 战斗观战(显示复刻):runner 从 spec 的原始 playerEffects 客户端还原显示条目
// (battle/observe.js spectateEffects — 策略效果经 bands.json 的 effectId,其余经 effects.json);
// * 联防 / boss 对半场与服务器运行场的 meta 无 effects —— 列留空,不再显示自己的。
// 服务器侧:test/match/prep-bench.test.js。
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { data } = await import('../../public/js/data.js');
await data.loadAll('effects', 'bands', 'assets');
const { spectateEffects } = await import('../../public/js/battle/observe.js');
describe('spectateEffects — the watched battle\'s effects column from the spec', () => {
const spec = { players: [
{ playerId: 'p1', playerEffects: [
{ id: 'aceffect_band_21', source: 'band' }, // 华法琳【重点监护】
{ id: 'aceffect_char_1', source: 'choice', counter: 2 },
{ id: 'effect_not_in_data', source: 'team' },
] },
{ playerId: 'p2', playerEffects: [] },
] };
test('the single member\'s playerEffects resolved: band via bands.json, the rest via effects.json', () => {
const out = spectateEffects(spec, ['p1']);
assert.ok(Array.isArray(out) && out.length === 3);
assert.deepEqual([out[0].name, out[0].iconKind, out[0].iconId], ['重点监护', 'band', 'icon_bldsk']);
assert.match(out[0].desc, /开始作战时/);
assert.deepEqual([out[1].name, out[1].iconKind, out[1].counter], ['未精英化', 'choice', 2]);
assert.equal(out[2].name, 'effect_not_in_data', 'an unknown id falls back to the id');
assert.equal(out[2].iconKind, 'team');
});
test('undefined where whose column would be ambiguous or missing', () => {
assert.equal(spectateEffects(spec, ['p1', 'p2']), undefined, '联防 / boss pairs');
assert.equal(spectateEffects({ players: [{ playerId: 'p1' }] }, ['p1']), undefined, 'no playerEffects on the spec');
assert.equal(spectateEffects(null, ['p1']), undefined);
assert.deepEqual(spectateEffects(spec, ['p2']), [], 'an empty list stays a list (empty column)');
});
test('wiring: the runner derives it for replica fields, the game screen prefers the field\'s list (source)', () => {
assert.match(read('public/js/battle/runner.js'), /effects: spectateEffects\(e\.spec, e\.members\)/);
assert.match(read('public/js/battle/runner.js'), /import \{ spectateEffects \} from '\.\/observe\.js'/);
assert.match(read('public/js/screens/game.js'), /watchingOther && field \? \(field\.effects \?\? null\) : priv\?\.effects/);
});
});
+46
View File
@@ -0,0 +1,46 @@
// 队友的策略(游玩记录 #2 item 2:「观战队友时看不到队友的策略详情」):观战队友的整备区棋盘时,
// 「本局信息」抽屉的策略块从自己的 priv.bandId 切换成被观看玩家的 m.public players[].bandId,标签
// 从「我的策略」变为「XX 的策略」(ui/enemyDrawer.js InfoTab 的 bandId / bandOwner 覆盖;game.js
// scoutPid / scoutBandId)。不进详情卡——策略和自己的查看位置一致。
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
import { ownerBandId } from '../../public/js/ui/gameLogic.js';
describe('ownerBandId — the picked 策略 of a player', () => {
const pub = { players: [
{ playerId: 'p1', name: '博士一', bandId: 'band_bldsk' },
{ playerId: 'p2', name: '博士二' }, // not picked yet (draft running)
] };
test('the bandId of the owner, null for anyone without one or an unknown id', () => {
assert.equal(ownerBandId(pub, 'p1'), 'band_bldsk');
assert.equal(ownerBandId(pub, 'p2'), null);
assert.equal(ownerBandId(pub, 'pX'), null);
assert.equal(ownerBandId(null, 'p1'), null);
assert.equal(ownerBandId({ players: [] }, 'p1'), null);
});
});
describe('the drawer shows the watched player\'s 策略 while scouting', () => {
test('game.js derives the scouted player and hands bandId / bandOwner to the drawer (source)', () => {
const game = read('public/js/screens/game.js');
assert.match(game, /const scoutPid = watchingOther && field\?\.prep && typeof field\.fieldId === 'string' && field\.fieldId\.startsWith\('n:'\) \? field\.fieldId\.slice\(2\) : null/);
assert.match(game, /const scoutBandId = scoutPid \? ownerBandId\(pub, scoutPid\) : null/);
assert.match(game, /bandId=\$\{scoutBandId\} bandOwner=\$\{scoutBandOwner\}/);
});
test('InfoTab overrides the band and labels it with the owner (source); the detail card has no 策略 section', () => {
const drawer = read('public/js/ui/enemyDrawer.js');
assert.match(drawer, /const band = \(bandId \|\| priv\?\.bandId\) \? data\.lookup\('bands', bandId \|\| priv\.bandId\) : null/);
assert.match(drawer, /bandOwner \? `\$\{bandOwner\} 的策略` : '我的策略'/);
const panel = read('public/js/ui/detailPanel.js');
assert.doesNotMatch(panel, /key="band"/, 'no 策略 section on the detail card');
assert.doesNotMatch(panel, /'band'/, "CHESS_SECTIONS has no 'band' block");
});
});