Watching a teammate shows their hand, 临时整备区, equipment, strategy and effects (#129)

The prep scout of another player's board carries their 整备区 hand and 临时整备区 as units on the bench rows (like the own prep bench), the equipped items of their operators, their strategy in the drawer and their effects column (the official mode shows a teammate's hand too). The scout signature includes the hand / temp slots, so a moved card refreshes the watcher. Closes #128. Contributed by @2321Robin.
This commit is contained in:
Luo Xingyu authored and GitHub committed 2026-10-05 16:47:33 +08:00
1 parent bd892a4dd6
commit bce182703b
15 files changed
+521 -20

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
// 观战队友的整备区(游玩记录 #2 item 1「观战队友时看不到队友的未上场的干员」,建议见 GitHub #44
// 「看不见队友的待战栏位」):prep 侦察的 m.field(`Match.prepFieldMeta`)把被侦察玩家的手牌当作
// 单位放进手牌行(row 7,col = 手牌槽位,道具是 kind 'item')——客户端按自己整备区的同一渲染路径
// 画出这一行(app.js battleView / ItemView)。手牌变化(部署 / 购入 / 装备)跟棋盘变化一样触发
// 推送(`_prepScoutSig`);观战席副本与玩家完全一致(test/match/spectator.test.js)。客户端:
// test/ui/prep-bench.test.js。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GEO, PHASE } from '../../shared/constants.js';
import { makeMatch, give, giveItem, legalTileFor, chessOfTier } from './harness.js';
const MELEE = (c) => c.position === 'MELEE' && c.profession === 'TANK';
test('#44 prep scout: held pieces are units on the hand row; deploying moves them onto the field', () => {
const S = 's_spec';
const h = makeMatch({ mode: 'coop', humans: 2, seed: 44, fake: true, spectators: [S] }).start();
h.toPrep(1);
const m = h.m;
assert.equal(m.phase, PHASE.PREP);
const b = h.ps('p_1');
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true });
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) {
if (p.kind === 'chess') b.returnCopies(p);
}
b.board.clear();
b.hand.fill(null);
b.temp.fill(null);
b.recompute();
const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x));
const piece = give(m, b, id); // into the hand
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
for (const pid of ['p_0', S]) {
const first = h.lastTo(pid, 'm.field');
assert.equal(first.prep, true);
const u = first.units.find((x) => x.uid === piece.uid);
assert.ok(u, `${pid}: the held operator is a unit of the scout`);
assert.deepEqual([u.x, u.y], [0, GEO.HAND_ROW], `${pid}: standing on the first hand slot`);
assert.equal(u.kind, 'op');
assert.equal(u.items, undefined, 'no items equipped yet');
}
assert.deepEqual(h.lastTo('p_0', 'm.field').effects, h.ps('p_1').effectsView(), 'the scouted effects column rides the meta');
// deploying it is a hand AND a board change: one new push with the piece on the field and off the hand row
const [r, c] = legalTileFor(m, b, id);
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: r, col: c }, dir: 'LEFT' }), { ok: true });
const last = h.lastTo('p_0', 'm.field');
const u = last.units.find((x) => x.uid === piece.uid);
assert.deepEqual([u.x, u.y], [c, r], 'deployed onto the watched board');
assert.equal(last.units.some((x) => x.uid === piece.uid && x.y === GEO.HAND_ROW), false, 'and gone from the hand row');
m.dispose();
});
test('#44 prep scout: a held item is a kind-item unit on the hand row; equipped items ride their operator', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 46, fake: true }).start();
h.toPrep(1);
const m = h.m;
const b = h.ps('p_1');
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true });
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) {
if (p.kind === 'chess') b.returnCopies(p);
}
b.board.clear();
b.hand.fill(null);
b.temp.fill(null);
b.recompute();
const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x));
const op = give(m, b, id);
const item = giveItem(m, b, 'chess_item_1_01_e_a'); // 维式重锤, into the hand
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const first = h.lastTo('p_0', 'm.field');
const iu = first.units.find((x) => x.uid === item.uid);
assert.ok(iu, 'the held item is a unit of the scout');
assert.equal(iu.kind, 'item');
assert.equal(iu.y, GEO.HAND_ROW);
// equip the item onto the operator (still in the hand): the operator's unit carries the item id
assert.deepEqual(m.handle('p_1', { t: 'g.equip', itemUid: item.uid, targetUid: op.uid }), { ok: true });
const after = h.lastTo('p_0', 'm.field');
assert.equal(after.units.some((x) => x.uid === item.uid), false, 'the equipped item left the hand row');
const ou = after.units.find((x) => x.uid === op.uid);
assert.deepEqual(ou.items, ['chess_item_1_01_e_a'], 'the operator scouts with its equipment');
m.dispose();
});
test("PR #129 review: moving a hand piece to another slot is a scout change (x follows the slot), and the temp row rides along", () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 47, fake: true }).start();
h.toPrep(1);
const m = h.m;
const b = h.ps('p_1');
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true });
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) {
if (p.kind === 'chess') b.returnCopies(p);
}
b.board.clear();
b.hand.fill(null);
b.temp.fill(null);
b.recompute();
const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x));
const a = give(m, b, id, 'hand', 0);
const c = give(m, b, id, 'temp', 0); // 临时整备区: PRTS 帮助把它算进手牌区,一起侦察
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const first = h.lastTo('p_0', 'm.field');
const tu = first.units.find((x) => x.uid === c.uid);
assert.deepEqual([tu.x, tu.y], [GEO.TEMP_C0, GEO.TEMP_ROW], 'the temp piece scouts on the temp row (first slot, col 4)');
assert.equal(first.units.find((x) => x.uid === a.uid).x, 0);
// a hand piece moved to another slot keeps uid and id — the slot must be in the signature for the push to fire
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: a.uid, to: { area: 'hand', idx: 3 } }), { ok: true });
const last = h.lastTo('p_0', 'm.field');
assert.ok(last, 'a new scout push arrived');
assert.equal(last.units.find((x) => x.uid === a.uid).x, 3, 'the piece stands on its new hand slot');
m.dispose();
});
test('#44 prep scout: an empty hand adds no units and never an m.private to a spectator seat', () => {
const S = 's_spec';
const h = makeMatch({ mode: 'coop', humans: 2, seed: 45, fake: true, spectators: [S] }).start();
h.toPrep(1);
const m = h.m;
const b = h.ps('p_1');
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true });
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) {
if (p.kind === 'chess') b.returnCopies(p);
}
b.board.clear();
b.hand.fill(null);
b.temp.fill(null);
b.recompute();
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const first = h.lastTo('p_0', 'm.field');
assert.equal(first.units.length, 0, 'nothing held, nothing deployed: no units');
assert.equal(first.bench, undefined, 'no separate bench field (the hand rides units)');
assert.equal(h.allTo(S, 'm.private').length, 0, 'still no private state to a scout');
m.dispose();
});
+4 -2
View File
@@ -1,7 +1,7 @@
// GitHub #87: watching another board during prep follows its moves. A spectator uses the same prep scout.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { GEO, PHASE } from '../../shared/constants.js';
import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js';
const MELEE = (c) => c.position === 'MELEE' && c.profession === 'TANK';
@@ -31,7 +31,9 @@ test('#87 prep watch: seat B moves during prep and seat A\'s view of B changes b
const first = h.lastTo(pid, 'm.field');
assert.equal(first.fieldId, 'n:p_1');
assert.equal(first.prep, true);
assert.equal(first.units.some((u) => u.uid === piece.uid), false, `${pid}: the piece is still in the hand`);
const hu = first.units.find((u) => u.uid === piece.uid);
assert.ok(hu, `${pid}: the piece is held — it scouts as a unit on the hand row`);
assert.equal(hu.y, GEO.HAND_ROW, `${pid}: on the bench (row 7), not on the field`);
}
assert.equal(h.allTo(S, 'm.private').length, 0);
const beforeA = h.allTo('p_0', 'm.field').length;
+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");
});
});