feedback1c WK: a 同构 row opens the wearer's card with its pair

Review follow-up. A teammate's card showed the dashed 同构 chip but not the
items behind it, and the card a bond popup's 同构 row opened was the plain
record card, without the chip.

- gameLogic.bondMembers: a 变形同构体 row carries the wearer's item ids.
- BondPopup passes them to onMember; the screen hands them to the card
  ({ kind: 'chess', id, owner, items }), resolveDetail turns them into
  unitItems.
- ChessDetail: a card without an own piece lists the carried items read-only
  under 装备 (a teammate's unit, the card a 同构 row opens).
- META: the card side, and the operator-talent checks that still read the
  record's bonds [ASSUMED].
This commit is contained in:
sganggs committed 2026-10-03 02:52:38 +08:00
1 parent 0c898bb524
commit b9bbf3a040
7 files changed
+71 -20

No files matched your search

+3 -1
View File
@@ -439,7 +439,9 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
any member (on the board only: the 14 bonds are BOARD; one member per operator, normal and elite copies alike), and
so in the battle input / battle, the views, and on the client: the bond popup lists it among the members (tagged
同构, `gameLogic.bondMembers` / `grantedBonds`, the same rule as `pieceBonds`) and its card shows the granted bond
chip; a teammate's units carry their item ids for that (UnitInfo `items`, `Match.prepFieldMeta`, SIM.md §9).
chip; a teammate's units carry their item ids for that (UnitInfo `items`, `Match.prepFieldMeta`, SIM.md §9), and a
card without an own piece (a teammate's unit, the card a 同构 row opens) lists those items read-only under 装备.
Not covered [ASSUMED]: operator talents that test "【拉特兰】/【卡西米尔】/… 干员" in the kits read the record's bonds.
**Layer cap** (research 11 §1; the client's `MAX_GARRISON_STACK` / `AddBondCount` = min(L + n, 999)): each bond's
layers stop at `BOND_LAYER_CAP` = 999 (shared/constants.js; 0 / Infinity = off). Every writer clamps with
`layerGainRoom` — `PlayerState.addLayers` (all prep-side gains: 特质, items, bands, 机变 cards, bonds; `ctx.addLayers`),
+1 -1
View File
@@ -1200,7 +1200,7 @@ function MatchScreen() {
${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name}
place=${bpPlace} over=${!!resolved && bpPlace === dSide}
onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId })} />` : null}
onClose=${() => setBondOpen(null)} onMember=${(id, items) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
+7 -4
View File
@@ -1,7 +1,8 @@
// Bond strip (active-bond discs under the top bar) and the bond detail popup: the key facts first (user playtest #2
// item 9: name, members in play / next threshold, layers, reached tier and its threshold row, the current effect with
// layer-resolved numbers), then the full description and the member list with owned / on-board state — operators that
// are members through 变形同构体 included (tagged 同构, gameLogic bondMembers), so the list's count agrees with 在场. Opened
// are members through 变形同构体 included (tagged 同构, gameLogic bondMembers; such a row opens the wearer's card with its
// items: the pair and the granted chip), so the list's count agrees with 在场. Opened
// from the detail panel's bond chips it docks beside that panel (`beside`: the panel's side).
// Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey =
// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用").
@@ -59,10 +60,12 @@ 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)=>void,
* @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function,
* onMember?: (chessId:string, items?:string[]|null)=>void,
* place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null }} 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)
* 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
*/
export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null, owner = null }) {
const b = data.lookup('bonds', bondId);
@@ -108,7 +111,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
<h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4>
<div class="bpop__members">
${members.map((mb) => 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')}
onClick=${() => onMember?.(mb.id)} data-granted=${mb.granted ? '1' : null}
onClick=${() => onMember?.(mb.id, mb.granted && Array.isArray(mb.items) ? mb.items : null)} data-granted=${mb.granted ? '1' : null}
title=${`${mb.name}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
<span class="bpop__mname">${mb.name}</span>
+16 -4
View File
@@ -2,11 +2,13 @@
// operators — portrait, name, tier, elite, class/subclass and, right under them in the header's right column (no
// scrolling, user playtest #2 item 9), the unit's bonds (阵营 / 盟约: icon, name, member count / next threshold,
// reached tier, active state — tap one for its popup; a bond its 变形同构体 pairing grants (gameLogic pieceBondIds: its
// piece's items, a teammate's unit: UnitInfo `items`) has a dashed chip tagged 同构 — the wearer counts for it); right
// piece's items, a teammate's unit: UnitInfo `items`, a bond popup's 同构 row: the wearer's) has a dashed chip tagged
// 同构 — the wearer counts for it); right
// under the header the operator's own effect (特质 — garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description,
// compact, visible without scrolling: user playtest #3 items 8 / 9), the class trait (特性), stats + range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP
// info, rich description, 已调配 when not the default), elite module (模组: official type icon from the local-client
// art, else its letter), equipped items, talents (CHESS_SECTIONS); items — icon, tier,
// art, else its letter), equipped items (read-only from those `items` when the card has no own piece), talents
// (CHESS_SECTIONS); items — icon, tier,
// effect; tokens — the owner's variant (a golden owner's summon: its `_b` stats / skill), how a placed summon takes
// the field (shared/constants.js SKILL_SUMMON_START_DEPLOY), its token skill and talents; enemies — stats, rank,
// faction tags, abilities. Selling / destroying is the underframe's job in the
@@ -225,7 +227,8 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
const skSlot = sk && Number.isInteger(sk.index) ? `S${sk.index + 1}` : null;
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
const items = Array.isArray(piece?.items) ? piece.items : [];
// the bonds the unit counts for: its own + a 变形同构体 pairing's (its piece's items; a teammate's unit: UnitInfo items)
// the bonds the unit counts for: its own + a 变形同构体 pairing's (its piece's items; without one: `unitItems` — a
// teammate's unit's UnitInfo items, a bond popup 同构 row's wearer's)
const carried = piece ? items : (Array.isArray(unitItems) ? unitItems : []);
const getItem = (id) => data.lookup('items', id);
const bondIds = pieceBondIds(c, carried, getItem);
@@ -307,6 +310,10 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
<//>` : null;
blocks.equip = piece?.kind === 'chess' ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${items.length}/2`} class="dsec--equip">
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
<//>`
// no own piece (a teammate's unit, a bond popup's 变形同构体 row): what it carries, read-only
: !piece && carried.length ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${carried.length}/2`} class="dsec--equip">
${carried.map((id, i) => html`<${ItemRow} key=${`${i}:${id}`} itemId=${id} />`)}
<//>` : null;
blocks.talents = Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
@@ -480,7 +487,12 @@ export function resolveDetail(target, pieces) {
const c = data.lookup('chess', p.id);
return c ? { type: 'chess', chess: c, piece: p } : null;
}
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c, hint: target.hint || null } : null; }
if (target.kind === 'chess') {
// a bond popup's 变形同构体 row hands the wearer's item ids on (bondStrip onMember): the card shows the pair and the chip
const c = data.lookup('chess', target.id);
const items = Array.isArray(target.items) ? target.items.filter((x) => typeof x === 'string') : [];
return c ? { type: 'chess', chess: c, hint: target.hint || null, ...(items.length ? { unitItems: items } : {}) } : null;
}
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
+9 -7
View File
@@ -402,8 +402,9 @@ export function pieceBondIds(chess, items, getItem = () => null) {
/**
* Member rows of a bond popup: every visible member with owned / on-board / banned state, plus the player's operators
* that are members through 变形同构体 (grantedBonds; `granted: true`, one row per operator — normal and elite copies are
* one member, like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场).
* that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that wears the
* pair — the card the row opens shows them; one row per operator — normal and elite copies are one member, like the
* count's distinct members), so "成员 x/y" agrees with the count the server reports (在场).
* @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 {Set<string>|string[]} [banned] banned base chess ids
@@ -417,28 +418,29 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
const onBoard = new Set();
const owned = new Set();
const memberSet = new Set(members);
/** base id → on the board? — operators of the player that join this bond through 变形同构体 */
/** base id → { on: on the board?, 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);
const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string');
for (const p of Array.isArray(priv?.board) ? priv.board : []) {
if (p?.kind !== 'chess') continue;
const base = baseOf(p.id);
onBoard.add(base); owned.add(base);
if (!memberSet.has(base) && grants(p)) granted.set(base, true);
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, items: itemIds(p) });
}
for (const p of [...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]) {
if (p?.kind !== 'chess') continue;
const base = baseOf(p.id);
owned.add(base);
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, false);
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: false, items: itemIds(p) });
}
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), banned: bannedSet.has(id) };
});
for (const [id, on] of granted) {
for (const [id, g] of granted) {
const c = getChess(id);
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: on, owned: true, banned: false, granted: true });
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, banned: false, granted: true, items: g.items });
}
return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
}
+33 -2
View File
@@ -79,6 +79,8 @@ describe('变形同构体 — the bond popup\'s member list', () => {
assert.equal(w.name, chess[WEARER].name);
assert.equal(rows.length, b.visibleMembers.length + 1, 'the converted operator joins the list');
assert.equal(rows.filter((r) => r.granted).length, 1);
assert.deepEqual(w.items, [ISO, HAMMER], 'the row carries the wearer\'s item ids (its card shows the pair)');
assert.ok(rows.filter((r) => !r.granted).every((r) => !('items' in r)), 'plain members carry none');
// not a member of an unrelated bond, and not with the bond item alone
assert.equal(bondMembers(bonds.steadShip, priv, [], getChess, getItem).some((r) => r.id === WEARER), false);
const plain = privWith({ board: [{ ...piece(WEARER, [HAMMER]), row: 9, col: 5 }] });
@@ -123,9 +125,9 @@ globalThis.fetch = async (url) => {
}
};
const { data } = await import('../../public/js/data.js');
await data.loadAll('bonds', 'chess', 'items', 'assets');
await data.loadAll('bonds', 'chess', 'items', 'assets', 'garrisons');
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { BondChips, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { BondChips, ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
@@ -170,4 +172,33 @@ describe('变形同构体 — what the popup and the card show', () => {
assert.deepEqual(d.unitItems, [ISO, HAMMER]);
assert.equal(resolveDetail({ kind: 'unit', unit: { id: 8, side: 'ally', ownerId: 'p2', defId: WEARER } }, new Map()).unitItems, null);
});
test('a 同构 row opens the wearer\'s card with its items: the pair (read-only 装备) and the granted chip', () => {
const priv = privWith({ board: [{ ...piece(VIC[0]), row: 9, col: 3 }, { ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }] });
const entry = { bondId: 'victoriaShip', count: 2, active: false, tier: 0, layers: 0, thresholds: [3, 6], countsHand: false };
const calls = [];
const v = BondPopup({ bondId: 'victoriaShip', entry, priv, onClose() {}, onMember: (...a) => calls.push(a) });
const rows = [...walk(v)].filter((x) => hasClass(x, 'bpop__member'));
rows.find((x) => hasClass(x, 'is-granted')).props.onClick();
rows.find((x) => !hasClass(x, 'is-granted') && hasClass(x, 'is-on')).props.onClick();
assert.deepEqual(calls, [[WEARER, [ISO, HAMMER]], [VIC[0], null]]);
// the screen turns the click into { kind: 'chess', id, owner, items } (screens/game.js) → resolveDetail
const d = resolveDetail({ kind: 'chess', id: WEARER, owner: 'p1', items: [ISO, HAMMER] }, new Map());
assert.deepEqual(d.unitItems, [ISO, HAMMER]);
assert.equal('unitItems' in resolveDetail({ kind: 'chess', id: WEARER, owner: 'p1', items: null }, new Map()), false, 'a plain member card: no items');
const mine = [{ bondId: 'victoriaShip', count: 2, active: false, tier: 0, layers: 0, thresholds: [3, 6] }];
const blocks = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null, unitItems: d.unitItems });
const chips = [...walk(blocks)].find((x) => x.type === BondChips);
assert.ok(chips.props.bondIds.includes('victoriaShip') && chips.props.granted.includes('victoriaShip'), 'the granted chip');
const vic = [...walk(BondChips(chips.props))].find((x) => hasClass(x, 'dbond') && x.props['data-bond'] === 'victoriaShip');
assert.ok(vic && hasClass(vic, 'is-granted'));
const equip = blocks.find((b) => b?.key === 'equip');
assert.ok(equip, 'a read-only 装备 section without an own piece');
assert.deepEqual([...walk(equip)].filter((x) => x.props?.itemId).map((x) => x.props.itemId), [ISO, HAMMER]);
assert.ok(![...walk(equip)].some((x) => hasClass(x, 'dempty')), 'no "drag to equip" hint on a read-only list');
// a plain record card (shop, a plain member) keeps no 装备 section and no granted chip
const bare = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null });
assert.equal(bare.some((b) => b?.key === 'equip'), false);
assert.deepEqual([...walk(bare)].find((x) => x.type === BondChips).props.granted, []);
});
});
+2 -1
View File
@@ -357,7 +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\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId \}\)\}/, 'a member card keeps the popup\'s player');
assert.match(src, /onMember=\$\{\(id, items\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null \}\)\}/,
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items)');
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');