feedback6 R21C: the bond popup lists every 自选 pick of the player (m.private.diy, the V and VI slots) in each bond its operator carries — dimmed like an unowned member until a copy is owned (not bought, or not in the shop yet), ✕ like a banned member when all its bonds are off this match (m.private.diyBanned), one row per slot drawn as its normal form with the 「自选」 tag, a tap opening the operator's card; 在场 and the 成员 header count an unowned pick for nothing, the order rule is kept (the owner's report of 2026-10-07 「自选编队的干员在局内对应盟约展开的名单里没有显示出来」); a watched teammate's popup still lists only their 自选 pieces on the field (m.public sends no picks), and such a row now hands the unit's pick to the card (BondPopup onMember → the detail target's diy → resolveDetail) instead of opening the empty 甄选干员 slot's card; golden: unchanged (client)

This commit is contained in:
sganggs committed 2026-10-07 17:43:37 +08:00
1 parent 783bd30afd
commit 41fd3c7e39
7 files changed
+264 -17

No files matched your search

+3
View File
@@ -298,6 +298,9 @@
.bpop__ban .icon { width: .14rem; height: .14rem; }
/* a member through 变形同构体 (the item pairing, not the operator's own bond): a gold 同构 tag on the portrait */
.bpop__iso { position: absolute; top: .02rem; right: .08rem; padding: 0 .04rem; font-size: .11rem; line-height: 1.45; font-weight: 700; color: #141414; background: var(--gold-2); }
/* a 自选 pick all of whose bonds are off this match (m.private.diyBanned): its 「自选」 tag moves down to the portrait's
bottom corner (the 替补 mark's place), clear of the red ✕ and the tier chip */
.bpop__member.is-banned .bpop__diy { top: .42rem; }
.dbonds--top .dbond.is-granted { border-style: dashed; }
.dbond__iso { font-size: .11rem; font-weight: 700; color: var(--gold-2); }
/* a count that holds 调和's +1 (the server's bond entry `harmony`, DESIGN §21.26): 在场 n(含调和 +1) and the 调和 row that
+1 -1
View File
@@ -1389,7 +1389,7 @@ function MatchScreen() {
${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name}
off=${offBonds.has(bondPop.bondId)}
place=${bpPlace} over=${!!resolved && bpPlace === dSide}
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null })} />` : null}
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat}
+10 -4
View File
@@ -21,6 +21,11 @@
// 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so —
// UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row
// opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06).
// 0.2.1 自选编队 (the owner's report of 2026-10-07): each 自选 pick of the player (m.private.diy) is a row of every bond its
// operator carries — the operator's name and portrait with the 「自选」 tag, dimmed until owned (not bought, or not in the
// shop yet), ✕ when all its bonds are off this match (m.private.diyBanned) — and opens the operator's card; a watched
// teammate's 自选 pieces on the field are rows too (their picks are not sent), opening THEIR operator's card (the unit's
// pick goes with onMember: never the empty 甄选干员 slot's card).
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
@@ -86,12 +91,13 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
/**
* Bond detail popup.
* @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function,
* onMember?: (chessId:string, items?:string[]|null, standInFor?:string|null)=>void,
* onMember?: (chessId:string, items?:string[]|null, standInFor?:string|null, diy?:any)=>void,
* place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null, off?: boolean }} props —
* `place`: where it opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the
* detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js); `onMember`
* gets a 变形同构体 row's item ids too (its card shows the pair and the granted chip), null for a plain member, and a
* stand-in row's `standInFor` (0.2.0 补位: a teammate's card then shows the stand-in too); `off`: the
* stand-in row's `standInFor` (0.2.0 补位: a teammate's card then shows the stand-in too), and a teammate's 自选 row's
* pick (`diy`, its unit's: the card composes their operator; the own rows' card reads m.private.diy); `off`: the
* mode never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a
* note, no 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays).
* `entry.harmony` (the server's bond views: the +1 调和 added to `count`): 在场 n(含调和 +1) and the 调和 row
@@ -166,8 +172,8 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
const name = si ? si.name : mb.name;
const items = mb.granted && Array.isArray(mb.items) ? mb.items : null;
return html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted', si && 'is-standin')}
onClick=${() => (si ? onMember?.(mb.id, items, si.standInFor) : onMember?.(mb.id, items))}
data-granted=${mb.granted ? '1' : null} data-standin=${si ? si.charId : null}
onClick=${() => (si ? onMember?.(mb.id, items, si.standInFor) : mb.pick ? onMember?.(mb.id, items, null, mb.pick) : onMember?.(mb.id, items))}
data-granted=${mb.granted ? '1' : null} data-standin=${si ? si.charId : null} data-diy=${mb.diy ? mb.rec?.charId || '1' : null}
title=${`${name}${si ? t('({note})', { note: standInForText(mb.name) }) : ''}${mb.granted ? t('(变形同构体:视为本盟约成员)') : ''}${mb.banned ? t('(本局禁用)') : mb.onBoard ? t('(在场)') : mb.owned ? t('(整备区)') : ''}`}>
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} rec=${si || (mb.diy ? mb.rec : null)} title=${name} />
<span class="bpop__mname">${name}</span>
+5 -2
View File
@@ -644,7 +644,8 @@ function TerrainDetail({ terrain }) {
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
* — resolve with `standIn` (the composed stand-in record the card shows: portrait, name, body, with the chess's
* bonds and 特质); 0.2.0 自选编队: the player's own pieces and cards of a DIY slot it filled (m.private.diy) — and a
* unit carrying `diy` — resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick
* unit carrying `diy`, and a teammate's bond popup 自选 row that hands its unit's pick on (`target.diy`, 0.2.1) —
* resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick
*/
export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) {
if (!target) return null;
@@ -676,7 +677,9 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
// do not read the viewer's 补位 list: a teammate's row shows the stand-in only when its unit says it is one —
// `target.standInFor`, from UnitInfo through ui/watchBonds.js ownerBoard)
const foreign = !!target.foreign || (target.owner != null && !!priv && target.owner !== priv.playerId);
const d = foreign ? null : ownDiy(c);
// (a teammate's 自选 row hands its unit's pick on, `target.diy`: their operator, not the empty 甄选干员 slot — 0.2.1)
const mate = foreign && c && target.diy && typeof target.diy === 'object' ? diyRecordFor(c, target.diy, dd) : null;
const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c);
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) };
const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c);
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : null;
+32 -8
View File
@@ -1,6 +1,7 @@
// ui/gameLogic/bonds.js — bond counts, members, harmony, 本局禁用. Re-exported from ../gameLogic.js.
import { int, isObj } from './shared.js';
import { diyPicks } from './diy.js';
import { t } from '../../../../shared/i18n.js';
@@ -158,10 +159,17 @@ export function harmonyMembers(priv, getChess = () => null) {
* like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount).
* 0.2.0 自选编队: the 自选 pieces of the bond (a DIY slot filled with an operator, whose bonds come from its factions — the
* own pieces through `getChess` (gameLogic/diy.js diyGetter), a teammate's through `pieceRecord` (its UnitInfo `diy`
* pick) are rows too (`diy: true`, the operator's name and record `rec`).
* pick) are rows too (`diy: true`, the operator's name and record `rec`: its normal form, one row per slot like a
* member's; `pick`: a teammate's piece's pick — the card the row opens composes the operator from it). Since 0.2.1
* (the owner's report of 2026-10-07 「自选编队的干员在局内对应盟约展开的名单里没有显示出来」) every 自选 pick of the
* player (m.private.diy: the V and VI slots) is a row of each bond its operator carries, owned or not — an unowned one
* (not bought, or not in the shop yet: the slot's 调度中心 level) dimmed like an unowned member, one whose bonds are all
* off this match (m.private.diyBanned: no stock, it never shows in the shop) `banned` like a banned member. A
* teammate's board carries no picks (m.public sends none): their 自选 pieces on the field only.
* `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count.
* @param {any} bond bonds.json record
* @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard)
* @param {any} priv m.private (hand/board/temp, the 自选 picks `diy` / `diyBanned`) — or a teammate's field operators
* (ui/watchBonds.js ownerBoard)
* @param {Set<string>|string[]} [banned] banned base chess ids
* @param {(id:string)=>any} [getChess]
* @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings)
@@ -178,14 +186,20 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
/** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */
const granted = new Map();
const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId);
/** base id → { on, hand, rec } — the player's 自选 pieces whose operator carries this bond */
const recOf = (p) => (pieceRecord ? pieceRecord(p) : getChess(p.id));
/** a composed 自选 record whose operator carries this bond */
const diyOfBond = (rec) => isObj(rec) && typeof rec.diyFor === 'string' && Array.isArray(rec.bonds) && rec.bonds.includes(bond?.bondId);
/** slot base id → { on, hand, owned, rec, pick } — the player's 自选 pieces and picks whose operator carries this bond */
const diy = new Map();
const diyOf = (p, on, hand) => {
const rec = pieceRecord ? pieceRecord(p) : getChess(p.id);
if (!rec || typeof rec.diyFor !== 'string' || !Array.isArray(rec.bonds) || !rec.bonds.includes(bond?.bondId)) return false;
const rec = recOf(p);
if (!diyOfBond(rec)) return false;
const g = diy.get(rec.diyFor);
if (!g) diy.set(rec.diyFor, { on, hand, rec });
else { g.on = g.on || on; g.hand = g.hand || hand; }
if (g) { g.on = g.on || on; g.hand = g.hand || hand; return true; }
// the row draws the normal form, as a member row its base chess (an elite copy is the same member)
const pick = isObj(p.diy) && typeof p.diy.charId === 'string' ? p.diy : null;
const normal = rec.isGolden ? recOf(pick ? { kind: 'chess', id: rec.diyFor, diy: pick } : { kind: 'chess', id: rec.diyFor }) : rec;
diy.set(rec.diyFor, { on, hand, owned: true, rec: diyOfBond(normal) && normal.diyFor === rec.diyFor ? normal : rec, pick });
return true;
};
const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string');
@@ -209,6 +223,14 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
else if (hand) g.hand = true;
}
}
// the player's picks not owned (the slot's record through the own getter: gameLogic/diy.js diyGetter) — rows like an
// unowned member's (counted by neither 在场 nor the 成员 header)
for (const slotId of Object.keys(diyPicks(priv))) {
if (diy.has(slotId)) continue;
const rec = recOf({ kind: 'chess', id: slotId });
if (diyOfBond(rec) && rec.diyFor === slotId) diy.set(slotId, { on: false, hand: false, owned: false, rec, pick: null });
}
const diyBanned = new Set(Array.isArray(priv?.diyBanned) ? priv.diyBanned : []);
const rows = members.map((id) => {
const c = getChess(id);
return { id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: onBoard.has(id), owned: owned.has(id), inHand: inHand.has(id), banned: bannedSet.has(id) };
@@ -217,7 +239,9 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
const c = getChess(id);
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items });
}
for (const [id, g] of diy) rows.push({ id, tier: g.rec.tier ?? 0, name: g.rec.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, diy: true, rec: g.rec });
for (const [id, g] of diy) {
rows.push({ id, tier: g.rec.tier ?? 0, name: g.rec.name ?? id, onBoard: g.on, owned: g.owned, inHand: g.hand, banned: bannedSet.has(id) || diyBanned.has(id), diy: true, rec: g.rec, ...(g.pick ? { pick: g.pick } : {}) });
}
return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
}
+211
View File
@@ -0,0 +1,211 @@
// feedback6 R21C — the owner's report of 2026-10-07 「自选编队的干员在局内对应盟约展开的名单里没有显示出来」: the bond
// popup's member list (ui/bondStrip.js BondPopup over gameLogic bondMembers) listed every regular member, dimmed while not
// owned, but the player's 自选 picks (m.private.diy) only once owned (board, hand, 临时整备区) — a pick not bought yet, or
// not in the shop yet (the tier-5 / tier-6 slots from 调度中心 level 5 / 6), never showed. Now each pick is a row of every
// bond its operator carries: the operator's name and portrait (its normal-form composed record, one row per slot) with
// the 「自选」 tag, dimmed until owned, counted by neither 在场 nor the 成员 header until it is on the board (or in the
// hand, for a bond that counts the hand), ✕ when all its bonds are off this match (m.private.diyBanned). A watched
// teammate's popup lists their 自选 pieces on the field (their picks are not on the client) and such a row opens THEIR
// operator's card, not the empty 甄选干员 slot's.
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)), '../..');
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 G = await import('../../public/js/ui/gameLogic.js');
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { ownerBoard } = await import('../../public/js/ui/watchBonds.js');
const { data } = await import('../../public/js/data.js');
const { DATA } = await import('../match/harness.js');
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
const D = { chess: DATA.chess, backups: DATA.backups };
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const T5A = 'chess_char_5_diy1_a';
const T5A_ELITE = 'chess_char_5_diy1_b';
const T5B = 'chess_char_5_diy2_a';
const T5B_ELITE = 'chess_char_5_diy2_b';
const SIEGE = 'char_112_siege'; // 推进之王: 维多利亚
const HUANG = 'char_017_huang'; // 煌: 炎 + 维多利亚
const SIEGE_PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const HUANG_PICK = { charId: HUANG, skillIndex: 0, uniEquipId: null };
const SIEGE_NAME = DATA.backups.units[SIEGE].name;
const HUANG_NAME = DATA.backups.units[HUANG].name;
const PIPER = 'chess_char_4_07_a'; // 风笛 (维多利亚), droppable: her stand-in is 预备干员-近卫
const ENTRY = { count: 0, active: false, tier: 0, layers: 0 };
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);
/** Render a component tree one level of function components deep at a time (htm vnodes with function types). */
function expand(v, depth = 6) {
if (Array.isArray(v)) return v.map((x) => expand(x, depth));
if (!v || typeof v !== 'object') return v;
if (typeof v.type === 'function' && depth > 0) {
try { return expand(v.type(v.props || {}), depth - 1); } catch { return v; }
}
const children = v.props?.children;
return children === undefined ? v : { ...v, props: { ...v.props, children: expand(children, depth) } };
}
const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join('');
/** The own m.private: 推进之王 in the first tier-5 slot, 煌 in the second, nothing bought. */
const privOf = (extra = {}) => ({
playerId: 'p_1', diy: { [T5A]: SIEGE_PICK, [T5B]: HUANG_PICK }, diyBanned: [], loadout: {}, standIns: [], board: [], hand: [], temp: [], ...extra,
});
/** The member rows as the own popup reads them (the own getter: gameLogic/diy.js diyGetter). */
const rowsOf = (bondId, priv) => G.bondMembers(DATA.bonds[bondId], priv, [], G.diyGetter(getChess, priv, D));
const popup = (props) => expand(BondPopup({ entry: ENTRY, onClose() {}, ...props }));
const memberButtons = (pop) => [...walk(pop)].filter((v) => hasClass(v, 'bpop__member'));
const piece = (uid, id, at = null) => ({ uid, kind: 'chess', id, items: [], ...(at ? { row: at[0], col: at[1] } : {}) });
describe('feedback6 R21C: the 自选 picks in the bond popup\'s member list', () => {
test('an unowned pick is a dimmed row of its operator\'s bond (not bought, not in the shop yet); 在场 and the 成员 header count it for nothing', () => {
const priv = privOf();
const vic = rowsOf('victoriaShip', priv);
const n = DATA.bonds.victoriaShip.visibleMembers.length;
const siege = vic.find((r) => r.id === T5A);
assert.ok(siege, 'the unbought 推进之王 is a 维多利亚 row');
assert.deepEqual([siege.diy, siege.name, siege.tier, siege.owned, siege.onBoard, siege.inHand, siege.banned, siege.rec.charId],
[true, SIEGE_NAME, 5, false, false, false, false, SIEGE]);
assert.equal(vic.length, n + 2, 'the members + 推进之王 and 煌');
assert.equal(G.memberHeadCount(vic), 0);
assert.equal(G.memberHeadCount(vic, true), 0, 'nor when the bond counts the hand');
// the order rule (on board, owned, tier, id): after the unowned tier-5 members, before the tier-6 ones
const ids = vic.map((r) => r.id);
assert.ok(ids.indexOf('chess_char_5_08_a') < ids.indexOf(T5A) && ids.indexOf(T5A) < ids.indexOf(T5B) && ids.indexOf(T5B) < ids.indexOf('chess_char_6_07_a'), ids.join(' '));
assert.deepEqual(rowsOf('yanShip', priv).filter((r) => r.diy).map((r) => r.id), [T5B], '炎: 煌 only');
assert.deepEqual(rowsOf('emptyShip', priv).filter((r) => r.diy), [], '协防: neither');
assert.equal(rowsOf('victoriaShip', privOf({ diy: {} })).length, n, 'no picks: the members alone');
// the popup: the operator's name and portrait with the 「自选」 tag, dimmed like an unowned member, header 0 / n + 2
const calls = [];
const pop = popup({ bondId: 'victoriaShip', priv, onMember: (...a) => calls.push(a) });
const btn = memberButtons(pop).find((v) => v.props['data-diy'] === SIEGE);
assert.ok(btn, 'the 推进之王 row');
assert.ok(textOf(btn).includes(SIEGE_NAME));
assert.ok(!hasClass(btn, 'is-on') && !hasClass(btn, 'is-owned'));
assert.ok([...walk(btn)].some((v) => hasClass(v, 'uthumb') && hasClass(v, 'is-dim')), 'dimmed');
assert.ok([...walk(btn)].some((v) => hasClass(v, 'bpop__diy')), 'tagged 自选');
const head = [...walk(pop)].find((v) => v.type === 'h4' && textOf(v).startsWith('成员'));
assert.ok(textOf(head).includes(`0/${n + 2}`), textOf(head));
// a tap opens the operator's card, as a member's does
btn.props.onClick();
assert.deepEqual(calls, [[T5A, null]]);
const card = resolveDetail({ kind: 'chess', id: T5A, owner: 'p_1', items: null, standInFor: null, diy: null }, new Map(), { priv, backups: D.backups });
assert.equal(card.chess.charId, SIEGE);
assert.deepEqual(card.diy, SIEGE_PICK);
});
test('owned → on: in the hand, the 临时整备区, on the board (normal or elite) the pick is one row, drawn as its normal form; the header follows', () => {
const steps = [
['not bought', privOf(), { owned: false, onBoard: false, inHand: false }, [0, 0]],
['in the hand', privOf({ hand: [piece(1, T5A)] }), { owned: true, onBoard: false, inHand: true }, [0, 1]],
['in the 临时整备区', privOf({ temp: [piece(1, T5A)] }), { owned: true, onBoard: false, inHand: false }, [0, 0]],
['on the board', privOf({ board: [piece(1, T5A, [10, 4])] }), { owned: true, onBoard: true, inHand: false }, [1, 1]],
['the elite on the board, a normal copy in the hand', privOf({ board: [piece(1, T5A_ELITE, [10, 4])], hand: [piece(2, T5A)] }), { owned: true, onBoard: true, inHand: true }, [1, 1]],
];
for (const [label, priv, want, [head, headHand]] of steps) {
const rows = rowsOf('victoriaShip', priv);
const mine = rows.filter((r) => r.id === T5A);
assert.equal(mine.length, 1, `${label}: one row`);
const [r] = mine;
assert.deepEqual({ owned: r.owned, onBoard: r.onBoard, inHand: r.inHand }, want, label);
assert.deepEqual([r.diy, r.rec.charId, r.rec.isGolden, r.name], [true, SIEGE, false, SIEGE_NAME], `${label}: the operator, normal form (one row per member)`);
assert.equal(G.memberHeadCount(rows), head, `${label}: 成员 header`);
assert.equal(G.memberHeadCount(rows, true), headHand, `${label}: 成员 header of a bond that counts the hand`);
if (r.owned) assert.equal(rows[0].id, T5A, `${label}: owned rows first`);
}
// the popup: on the board → lit, not dimmed
const pop = popup({ bondId: 'victoriaShip', priv: steps[4][1], entry: { ...ENTRY, count: 1 } });
const btn = memberButtons(pop).find((v) => v.props['data-diy'] === SIEGE);
assert.ok(hasClass(btn, 'is-on'));
assert.ok(![...walk(btn)].some((v) => hasClass(v, 'is-dim')));
assert.ok(![...walk(btn)].some((v) => hasClass(v, 'is-golden')), 'the normal portrait, as a member row');
});
test('a pick whose operator carries two bonds (煌: 炎 + 维多利亚) is a row of both; a 补位 stand-in of the same bond keeps its row', () => {
const priv = privOf({ standIns: [PIPER], board: [piece(1, PIPER, [10, 4])] });
for (const b of ['yanShip', 'victoriaShip']) {
const r = rowsOf(b, priv).find((x) => x.id === T5B);
assert.ok(r, `煌 is a ${b} row`);
assert.deepEqual([r.diy, r.name, r.owned, r.onBoard], [true, HUANG_NAME, false, false], b);
}
const pop = popup({ bondId: 'victoriaShip', priv, entry: { ...ENTRY, count: 1 } });
const buttons = memberButtons(pop);
const si = buttons.find((v) => v.props['data-standin'] === 'char_601_cguard');
assert.ok(si && hasClass(si, 'is-on'), '风笛 on the board, drawn as her stand-in');
assert.deepEqual(buttons.filter((v) => v.props['data-diy']).map((v) => v.props['data-diy']).sort(), [HUANG, SIEGE].sort(), 'both picks listed');
// bought and deployed: on in both
const owned = privOf({ board: [piece(3, T5B_ELITE, [10, 5])] });
for (const b of ['yanShip', 'victoriaShip']) {
const rows = rowsOf(b, owned);
assert.deepEqual(rows.filter((x) => x.diy && x.onBoard).map((x) => x.id), [T5B], b);
assert.equal(G.memberHeadCount(rows), 1, b);
}
});
test('a pick whose bonds are all off this match (m.private.diyBanned: never in the shop) is listed like a banned member', () => {
const priv = privOf({ diyBanned: [T5A] });
const rows = rowsOf('victoriaShip', priv);
const r = rows.find((x) => x.id === T5A);
assert.deepEqual([r.diy, r.banned, r.owned], [true, true, false]);
assert.equal(rows.find((x) => x.id === T5B).banned, false, '煌 keeps its stock');
const btn = memberButtons(popup({ bondId: 'victoriaShip', priv })).find((v) => v.props['data-diy'] === SIEGE);
assert.ok(hasClass(btn, 'is-banned'));
assert.ok([...walk(btn)].some((v) => hasClass(v, 'bpop__ban')), 'the red ✕');
assert.ok([...walk(btn)].some((v) => hasClass(v, 'uthumb') && hasClass(v, 'is-dim')));
assert.ok(btn.props.title.endsWith('(本局禁用)'), btn.props.title);
});
test('a watched teammate: their 自选 pieces on the field are rows (their picks are not sent), ours never are; the row opens THEIR operator\'s card', () => {
// their elite 煌 on the field (UnitInfo diy, kept by ui/watchBonds.js ownerBoard); the board BondPopup gets for them
const field = { units: [{ id: 9, kind: 'op', ownerId: 'p_2', defId: T5B_ELITE, diy: HUANG_PICK }] };
const mate = ownerBoard(field, 'p_2');
const pieceRecord = (p) => (p.diy ? G.diyRecordFor(getChess(p.id), p.diy, D) : null) || getChess(p.id);
const get = G.diyGetter(getChess, mate, D);
const yan = G.bondMembers(DATA.bonds.yanShip, mate, [], get, () => null, pieceRecord);
const r = yan.find((x) => x.diy);
assert.deepEqual([r.id, r.name, r.onBoard, r.owned, r.rec.charId, r.rec.isGolden], [T5B, HUANG_NAME, true, true, HUANG, false]);
assert.deepEqual(r.pick, HUANG_PICK, 'the unit\'s pick, for the card');
const vic = G.bondMembers(DATA.bonds.victoriaShip, mate, [], get, () => null, pieceRecord);
assert.deepEqual(vic.filter((x) => x.diy).map((x) => x.id), [T5B], 'only what their field shows — never our own picks');
assert.equal(G.bondMembers(DATA.bonds.victoriaShip, null, [], getChess).length, DATA.bonds.victoriaShip.visibleMembers.length, 'no board on screen');
// the popup row hands the unit's pick on; the card composes their 煌, not the empty 甄选干员 slot
const calls = [];
const pop = popup({ bondId: 'yanShip', priv: mate, owner: 'Mate', entry: { ...ENTRY, count: 1 }, onMember: (...a) => calls.push(a) });
const btn = memberButtons(pop).find((v) => v.props['data-diy'] === HUANG);
assert.ok(btn && hasClass(btn, 'is-on'));
btn.props.onClick();
assert.deepEqual(calls, [[T5B, null, null, HUANG_PICK]]);
const own = privOf(); // our own 煌 pick is in the same slot: the card must still be theirs
const card = resolveDetail({ kind: 'chess', id: T5B, owner: 'p_2', items: null, standInFor: null, diy: HUANG_PICK }, new Map(), { priv: own, backups: D.backups });
assert.deepEqual([card.chess.charId, card.chess.name, card.diy], [HUANG, HUANG_NAME, HUANG_PICK]);
const alien = resolveDetail({ kind: 'chess', id: T5A, owner: 'p_2', diy: HUANG_PICK }, new Map(), { priv: own, backups: D.backups });
assert.equal(alien.chess.charId, HUANG, 'their pick, whatever our own slot holds');
const bare = resolveDetail({ kind: 'chess', id: T5B, owner: 'p_2' }, new Map(), { priv: own, backups: D.backups });
assert.equal(bare.diy, undefined, 'without a pick (the banned list, an older caller) the slot\'s own card, as before');
// the game screen hands the pick on to the card
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(src, /onMember=\$\{\(id, items, standInFor, diy\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null, standInFor: standInFor \|\| null, diy: diy \|\| null \}\)\}/);
});
});
+2 -2
View File
@@ -357,8 +357,8 @@ test('§20.15 wiring: the game screen feeds the strip, the popup and the detail
assert.match(src, /onOpen=\$\{\(id\) => openBond\(id, strip\.ownerId, 'strip'\)\}/, 'the strip opens its owner\'s bond');
assert.match(src, /const detailOwner = detailBondOwner\(detailTarget, \{ pub, myId, stripOwnerId: strip\.ownerId \}\)/);
assert.match(src, /onBond=\$\{\(id\) => openBond\(id, detailOwner, 'detail'\)\}/, 'a card\'s chip opens the same owner\'s bond as its chips show');
assert.match(src, /onMember=\$\{\(id, items, standInFor\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null, standInFor: standInFor \|\| null \}\)\}/,
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items, a 补位 row\'s standInFor)');
assert.match(src, /onMember=\$\{\(id, items, standInFor, diy\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null, standInFor: standInFor \|\| null, diy: diy \|\| null \}\)\}/,
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items, a 补位 row\'s standInFor, a teammate\'s 自选 row\'s pick)');
assert.match(src, /openId=\$\{bondPop && bondPop\.ownerId === strip\.ownerId \? bondPop\.bondId : null\}/, 'the strip marks only its own owner\'s popup');
assert.match(src, /setBondOpen\(\(b\) => \(b && b\.from === 'strip' \? null : b\)\)/, 'a strip popup closes when the strip changes hands');
assert.ok(!/stripBonds\.find\(\(b\) => b\.bondId === bondOpen\)/.test(src), 'no popup entry read from the strip regardless of its owner');