diff --git a/docs/META.md b/docs/META.md
index 62ae4972..43aee5b2 100644
--- a/docs/META.md
+++ b/docs/META.md
@@ -434,6 +434,12 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
to the deploy limit, `grantTokensFor`). Battle side: SIM.md §1.1 token pieces.
* **Bonds**: bondsMeta.js (BOARD distinct, BOARD_AND_DECK, 绝技 elites, 调和 +1, 独行 downward, 助力 upper tiers,
变形同构体 grants). Σ activated layers for the hidden core = Σ layers of active bonds at the boss round's prep end.
+ **变形同构体** (变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员", 缪尔赛思's 特质 hands it out): its wearer
+ with a bond item (`giveBondId`, the 14 pairings of the item's official talent) is a member of that bond — counted like
+ 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).
**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`),
diff --git a/docs/SIM.md b/docs/SIM.md
index 3894bb29..ca2dcb15 100644
--- a/docs/SIM.md
+++ b/docs/SIM.md
@@ -957,7 +957,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing),
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
-- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16)
+- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css
index 394a7d5f..4833166d 100644
--- a/public/css/screens/game-panels.css
+++ b/public/css/screens/game-panels.css
@@ -256,6 +256,10 @@
.bpop__member.is-owned .bpop__mname { color: var(--text-hi); }
.bpop__ban { position: absolute; top: .02rem; right: .1rem; width: .2rem; height: .2rem; display: grid; place-items: center; background: var(--red); color: #fff; border-radius: 50%; }
.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); }
+.dbonds--top .dbond.is-granted { border-style: dashed; }
+.dbond__iso { font-size: .11rem; font-weight: 700; color: var(--gold-2); }
/* ---- enemy / info drawer ---- */
.edrawer {
diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js
index 58f2a0fc..bf08bb83 100644
--- a/public/js/battle/runner.js
+++ b/public/js/battle/runner.js
@@ -45,8 +45,9 @@
// playtest #4 item 7). Read-only: it takes the stats the sim computed last (`unit._s`) and
// never makes the unit recompute them, so looking never changes the battle's floats.
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
-// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId }] that player's operators in the battle on
-// screen (a teammate's bond popup: the members in play, DESIGN §20.15) | []
+// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items? }] that player's operators in the
+// battle on screen with their equipment (a teammate's bond popup: the members in play, DESIGN
+// §20.15, 变形同构体 wearers included) | []
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js drives it under Node).
@@ -686,7 +687,7 @@ export function createBattleRunner(deps) {
if (!e || typeof ownerId !== 'string' || !ownerId || (fieldId != null && e.fieldId !== fieldId)) return [];
const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : [];
return list.filter((u) => u && u.kind === 'op' && u.ownerId === ownerId && typeof u.defId === 'string')
- .map((u) => ({ kind: 'op', ownerId, defId: u.defId }));
+ .map((u) => (Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId }));
},
/** Re-show the current battle (the game screen remounted). */
reshow() { if (cur) show(cur); },
diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js
index 8ce5628e..9817d91e 100644
--- a/public/js/ui/bondStrip.js
+++ b/public/js/ui/bondStrip.js
@@ -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. Opened from
-// the detail panel's bond chips it docks beside that panel (`beside`: the panel's side).
+// 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
+// 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 ("层数叠加已禁用").
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
@@ -71,7 +72,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
const tier = entry?.tier ?? bondTier(count, th, b.maxCount);
const active = entry ? !!entry.active : tier > 0;
- const members = bondMembers(b, priv, banned, (id) => data.lookup('chess', id));
+ const members = bondMembers(b, priv, banned, (id) => data.lookup('chess', id), (id) => data.lookup('items', id));
const countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th);
const hasNow = !!(b.effectDescRaw || b.effectDesc);
@@ -106,11 +107,13 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
成员 ${members.filter((x) => x.onBoard).length}/${members.length}
- ${members.map((mb) => html` onMember?.(mb.id)} title=${`${mb.name}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
+ ${members.map((mb) => html` onMember?.(mb.id)} 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} />
${mb.name}
${mb.banned ? html`<${Icon} name="close" /> ` : null}
+ ${mb.granted ? html`同构 ` : null}
`)}
diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js
index 9b2ae5de..8c28dcf2 100644
--- a/public/js/ui/detailPanel.js
+++ b/public/js/ui/detailPanel.js
@@ -1,8 +1,9 @@
// Detail panel (click / right-click a piece, shop card, bond member, battle unit or previewed enemy):
// 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); right under the header the operator's own effect (特质 —
-// garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description,
+// 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
+// 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,
@@ -21,7 +22,7 @@
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
-import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js';
+import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, pieceBondIds } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
@@ -129,11 +130,13 @@ function ItemRow({ itemId }) {
/**
* The unit's bonds right under the header: icon, name, the player's member count / next threshold, reached tier.
- * @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void }} props
+ * `granted`: the bonds among them the unit holds through 变形同构体 (dashed chip, 同构 tag).
+ * @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void, granted?: string[] }} props
*/
-export function BondChips({ bondIds, bonds = [], onBond = null }) {
+export function BondChips({ bondIds, bonds = [], onBond = null, granted = [] }) {
const ids = Array.isArray(bondIds) ? bondIds.filter((b) => typeof b === 'string') : [];
if (!ids.length) return null;
+ const iso = new Set(Array.isArray(granted) ? granted : []);
const mine = new Map((Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string').map((b) => [b.bondId, b]));
return html`
${ids.map((id) => {
@@ -145,16 +148,18 @@ export function BondChips({ bondIds, bonds = [], onBond = null }) {
const active = e ? !!e.active : tier > 0;
const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null;
- const label = `${rec?.name || id}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
+ const label = `${rec?.name || id}${iso.has(id) ? '(变形同构体)' : ''}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
const body = html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
${rec?.name || id}
+ ${iso.has(id) ? html`同构 ` : null}
0 && 'is-max')}>${count}${cap != null ? html`/${cap} ` : null}
${th.length ? html`${th.map((_, i) => html` `)} ` : null}`;
return onBond
- ? html` onBond(id)}>${body} `
- : html`${body} `;
+ ? html` onBond(id)}>${body} `
+ : html`${body} `;
})}
`;
}
@@ -204,7 +209,7 @@ function GarrisonBlock({ garrison, m }) {
`;
}
-export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null, hint = null }) {
+export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null, hint = null, unitItems = null }) {
const m = data.get('assets');
const hp = hpOf(live, snapHp);
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
@@ -220,6 +225,11 @@ 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)
+ const carried = piece ? items : (Array.isArray(unitItems) ? unitItems : []);
+ const getItem = (id) => data.lookup('items', id);
+ const bondIds = pieceBondIds(c, carried, getItem);
+ const grantedIds = bondIds.filter((b) => !(Array.isArray(c.bonds) && c.bonds.includes(b)));
const sell = c.sellPrice ?? 1;
const blocks = {};
blocks.head = html`
@@ -244,7 +254,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
${c.position === 'MELEE' ? '近战位' : '远程位'}
${hp ? html`${fmtNum(hp.hp)} / ${fmtNum(hp.max)}
` : null}
- <${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
+ <${BondChips} bondIds=${bondIds} bonds=${bonds} onBond=${onBond} granted=${grantedIds} />
`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
@@ -479,7 +489,7 @@ export function resolveDetail(target, pieces) {
const own = Number.isInteger(u.uid) ? pieces?.get(u.uid) : null;
if (u.side === 'enemy') { const en = data.lookup('enemies', u.defId); return en ? { type: 'enemy', enemy: en, unitId: u.id } : null; }
const c = data.lookup('chess', u.defId);
- if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id };
+ if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null };
const t = data.lookup('tokens', u.defId);
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
const en = data.lookup('enemies', u.defId);
@@ -521,7 +531,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<${Icon} name="close" />
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
- bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} />` : null}
+ bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js
index e520febc..555d62c6 100644
--- a/public/js/ui/gameLogic.js
+++ b/public/js/ui/gameLogic.js
@@ -371,26 +371,76 @@ export function nextThreshold(count, thresholds) {
}
/**
- * Member rows of a bond popup: every visible member with owned / on-board / banned state.
+ * Bonds granted by 变形同构体: an item with `canGiveBond` worn together with an item that has a `giveBondId` makes the
+ * wearer a member of that other item's bond (band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's
+ * talent, character_table trap_1073_acarm073, lists the 14 pairings). The same rule as the server's count
+ * (server/match/bondsMeta.js pieceBonds) and the battle's (server/sim/content/support unitBonds).
+ * @param {Array|null|undefined} items the carried items (ids or { id } pieces)
+ * @param {(id:string)=>any} [getItem] items.json lookup
+ * @returns {string[]}
+ */
+export function grantedBonds(items, getItem = () => null) {
+ const list = Array.isArray(items) ? items : [];
+ if (list.length < 2) return [];
+ const recs = list.map((it) => getItem(typeof it === 'string' ? it : it?.id)).filter(isObj);
+ if (!recs.some((r) => r.canGiveBond)) return [];
+ const out = [];
+ for (const r of recs) if (!r.canGiveBond && typeof r.giveBondId === 'string' && r.giveBondId && !out.includes(r.giveBondId)) out.push(r.giveBondId);
+ return out;
+}
+
+/**
+ * A chess piece's bonds: its record's + those its 变形同构体 pairing grants (grantedBonds) — the detail card's chips.
+ * @param {any} chess chess.json record @param {Array|null|undefined} items @param {(id:string)=>any} [getItem]
+ * @returns {string[]}
+ */
+export function pieceBondIds(chess, items, getItem = () => null) {
+ const out = (Array.isArray(chess?.bonds) ? chess.bonds : []).filter((b) => typeof b === 'string');
+ for (const b of grantedBonds(items, getItem)) if (!out.includes(b)) out.push(b);
+ return out;
+}
+
+/**
+ * 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 (在场).
* @param {any} bond bonds.json record
- * @param {any} priv m.private (hand/board/temp)
+ * @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard)
* @param {Set|string[]} [banned] banned base chess ids
* @param {(id:string)=>any} [getChess]
+ * @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings)
*/
-export function bondMembers(bond, priv, banned = [], getChess = () => null) {
+export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) {
const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []);
const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []);
const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id);
const onBoard = new Set();
const owned = new Set();
- for (const p of Array.isArray(priv?.board) ? priv.board : []) if (p?.kind === 'chess') { onBoard.add(baseOf(p.id)); owned.add(baseOf(p.id)); }
- for (const p of [...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]) {
- if (p?.kind === 'chess') owned.add(baseOf(p.id));
+ const memberSet = new Set(members);
+ /** base id → on the board? — 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);
+ 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);
}
- return members.map((id) => {
+ 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);
+ }
+ 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) };
- }).sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
+ });
+ for (const [id, on] 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 });
+ }
+ return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
}
/**
diff --git a/public/js/ui/watchBonds.js b/public/js/ui/watchBonds.js
index 814e6f66..566d4675 100644
--- a/public/js/ui/watchBonds.js
+++ b/public/js/ui/watchBonds.js
@@ -151,8 +151,9 @@ export function withLiveLayers(bonds, live) {
* (gameLogic bondMembers): their operators on the field meta on screen (the prep scouting board, a server-run battle
* field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta
* the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only
- * when they are on the field. null without a field or battle.
- * @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId }
+ * when they are on the field. Their operators keep the items the unit info carries (UnitInfo `items`), so a 变形同构体
+ * wearer is listed as a member of the bond it grants (gameLogic grantedBonds). null without a field or battle.
+ * @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, items? }
*/
export function ownerBoard(field, ownerId, extra = null) {
const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null;
@@ -160,7 +161,7 @@ export function ownerBoard(field, ownerId, extra = null) {
if (!fromField && !fromBattle) return null;
const board = [...(fromField || []), ...(fromBattle || [])]
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
- .map((u) => ({ kind: 'chess', id: u.defId }));
+ .map((u) => (Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId }));
return { board, hand: [], temp: [] };
}
diff --git a/server/match/Match.js b/server/match/Match.js
index 1181bf42..b3b8e893 100644
--- a/server/match/Match.js
+++ b/server/match/Match.js
@@ -883,6 +883,8 @@ export class Match {
x: c, y: r, dir: pieceDir(piece), facing: pieceDir(piece) === 'LEFT' ? -1 : 1, maxHp: rec && rec.stats && Number.isFinite(rec.stats.maxHp) ? rec.stats.maxHp : 1,
skillIndex: lo && Number.isInteger(lo.skillIndex) ? lo.skillIndex : undefined,
moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined,
+ // the equipped items (like the sim's UnitInfo): a 变形同构体 wearer shows as a member of the bond it grants
+ items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
});
}
// `nextEnemies`: the scouted player's coming enemies — their preview pen shows on the scouting board too (research 09
diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js
index 5525bfc3..5d7c7052 100644
--- a/server/sim/snapshot.js
+++ b/server/sim/snapshot.js
@@ -1,8 +1,10 @@
// server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2).
//
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
-// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? }
+// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?,
+// items? }
// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1).
+// items = an ally operator's equipped item ids (absent without any).
// flags bits & anim codes come from shared/constants.js (UF / ANIM).
import { UF, ANIM } from '../../shared/constants.js';
@@ -38,6 +40,9 @@ export function unitInfo(u) {
// DESIGN §16: an elite ally's equipped module (uniEquipId | 'none'; display only — a teammate's unit in a shared
// field shows its owner's module in the detail card)
moduleId: u.side === 'ally' && d.golden && typeof d.loadout?.moduleId === 'string' ? d.loadout.moduleId : undefined,
+ // an ally operator's equipped item ids (display: a 变形同构体 wearer counts for the bond it grants — the bond popup's
+ // member list and the detail card's bond chips of a teammate's unit)
+ items: u.side === 'ally' && u.kind === 'op' && Array.isArray(u.items) && u.items.length ? [...u.items] : undefined,
};
}
diff --git a/test/match/morph-bonds.test.js b/test/match/morph-bonds.test.js
new file mode 100644
index 00000000..77b6e3ed
--- /dev/null
+++ b/test/match/morph-bonds.test.js
@@ -0,0 +1,165 @@
+// 变形同构体 and the bond member count on the real match paths (player report after 0.1.0: "缪尔赛思的变形同构体配上对应
+// 职业转职的装备能使装备的干员吃到对应盟约的加成,但是那个盟约的人口数量没有把转职之后的干员算进去").
+// Official rule: band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's talent (character_table
+// trap_1073_acarm073) lists the 14 pairings ("“维式重锤”系列装备→【维多利亚】盟约" …); 缪尔赛思's 特质 garrison_76
+// "<获得时>获得1个“变形同构体”". The wearer is a member: it counts for the bond's activation like any member — on the
+// board only for these BOARD bonds, once per operator (distinct members, normal and elite alike).
+// The server already counted it (prep, battle input, views, the battle); what the player saw was the client's bond
+// popup and card (test/ui/morph-bonds.test.js). This suite pins the count on every path, and the item ids the
+// client now gets with a teammate's units (prep scouting UnitInfo, the battle's UnitInfo).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { makeMatch, give, giveItem, DATA, legalTileFor } from './harness.js';
+import { unitBonds, isMember } from '../../server/sim/content/support/index.js';
+import { unitInfo } from '../../server/sim/snapshot.js';
+
+const ISO = 'chess_item_6_09_e_a';
+const ISO_B = 'chess_item_6_09_e_b';
+const HAMMER = 'chess_item_1_01_e_a';
+const GRANTS = Object.values(DATA.items).filter((r) => r.giveBondId).map((r) => r.id).sort();
+
+/** A target chess outside `bond` (and not 调和: its +1 would blur the count). */
+const outsider = (bond) => Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier <= 2 && !c.bonds.includes(bond) && !c.bonds.includes('maniShip')).chessId;
+const members = (bond) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond) && !c.bonds.includes('maniShip')).map((c) => c.chessId).sort();
+
+/** PREP R1 of a real match (all 23 bonds: 绝境), the player's pieces cleared. */
+function prep({ mode = 'solo', humans = 1, fake = false, seed = 21 } = {}) {
+ const h = makeMatch({ mode, difficulty: 'HARD', humans, seed, fake }).start();
+ h.toPrep(1);
+ for (const ps of h.m.players.values()) {
+ for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
+ ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0;
+ ps.funds = 50; ps.bandId = null;
+ ps.recompute();
+ }
+ return h;
+}
+const ok = (r) => assert.deepEqual(r, { ok: true });
+/** A chess with `items` (equipped through g.equip, in this order), from the hand, then onto the board when `board`. */
+function wearer(h, pid, chessId, itemIds, { board = true } = {}) {
+ const { m } = h; const ps = h.ps(pid);
+ const p = give(m, ps, chessId, 'hand');
+ for (const id of itemIds) ok(m.handle(pid, { t: 'g.equip', itemUid: giveItem(m, ps, id).uid, targetUid: p.uid }));
+ if (board) {
+ const at = legalTileFor(m, ps, chessId);
+ ok(m.handle(pid, { t: 'g.move', uid: p.uid, to: { area: 'board', row: at[0], col: at[1] } }));
+ }
+ return p;
+}
+const place = (h, pid, chessId) => {
+ const { m } = h; const ps = h.ps(pid);
+ const p = give(m, ps, chessId, 'hand');
+ const at = legalTileFor(m, ps, chessId);
+ ok(m.handle(pid, { t: 'g.move', uid: p.uid, to: { area: 'board', row: at[0], col: at[1] } }));
+ return p;
+};
+const count = (ps, bond) => ps.bonds[bond]?.count ?? 0;
+
+test('缪尔赛思 hands out 变形同构体 on purchase (garrison_76), the bought copy pairs with any bond item', () => {
+ const h = prep();
+ const { m } = h; const ps = h.ps('p_0');
+ ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_6_11_a', price: 6, basePrice: 6, sold: false, frozen: false };
+ ok(m.handle('p_0', { t: 'g.buy', slot: 0 }));
+ const iso = ps.hand.find((p) => p && p.id === ISO);
+ assert.ok(iso, 'the 变形同构体 is in the hand');
+ const t = give(m, ps, outsider('victoriaShip'), 'hand');
+ ok(m.handle('p_0', { t: 'g.equip', itemUid: iso.uid, targetUid: t.uid }));
+ ok(m.handle('p_0', { t: 'g.equip', itemUid: giveItem(m, ps, HAMMER).uid, targetUid: t.uid }));
+ assert.equal(count(ps, 'victoriaShip'), 0, 'on the bench: BOARD bonds count the board only');
+ const at = legalTileFor(m, ps, t.id);
+ ok(m.handle('p_0', { t: 'g.move', uid: t.uid, to: { area: 'board', row: at[0], col: at[1] } }));
+ assert.equal(count(ps, 'victoriaShip'), 1);
+ h.invariants();
+});
+
+test('every pairing (18 bond items × normal / golden, both 变形同构体): the wearer counts in prep, in the views, in the battle input and in the real battle', () => {
+ const grants = Object.values(DATA.items).filter((r) => r.giveBondId);
+ assert.equal(grants.length, 36);
+ assert.equal(new Set(grants.map((r) => r.giveBondId)).size, 14, '14 bonds have a bond item (the official talent\'s list)');
+ for (const r of grants) {
+ const bond = r.giveBondId;
+ const iso = r.isGolden ? ISO_B : ISO; // golden bond items with the golden 变形同构体, normal with normal
+ const h = prep({ seed: 31 });
+ const { m } = h; const ps = h.ps('p_0');
+ const [a, b] = members(bond);
+ place(h, 'p_0', a); place(h, 'p_0', b);
+ assert.equal(count(ps, bond), 2, `${bond}: two members`);
+ // the bond item first, then the 变形同构体 (the other order: the next test)
+ const w = wearer(h, 'p_0', outsider(bond), [r.id, iso]);
+ assert.equal(count(ps, bond), 3, `${r.id}: the wearer is the third member`);
+ assert.equal(ps.bonds[bond].active, true, `${r.id}: the threshold of 3 (2 for add-on bonds) is reached with it`);
+ assert.equal(ps.battleInput().bonds[bond].count, 3, `${r.id}: battle input`);
+ assert.equal(ps.privateView().bonds.find((x) => x.bondId === bond).count, 3, `${r.id}: m.private`);
+ let battle = null;
+ const orig = m.newBattle.bind(m);
+ m.newBattle = (o) => { const bt = orig(o); battle ??= bt; return bt; };
+ assert.ok(h.drive(() => battle != null), 'combat starts');
+ assert.equal(battle.getPlayer('p_0').bonds[bond].count, 3, `${r.id}: the battle's count`);
+ const u = battle.allyUnits.find((x) => x.kind === 'op' && x.uid === w.uid);
+ assert.ok(unitBonds(u).includes(bond) && isMember(battle, u, bond), `${r.id}: the battle's member`);
+ }
+});
+
+test('bench vs board, half pairs, two 变形同构体, the equip order, distinct members', () => {
+ const h = prep({ fake: true });
+ const { m } = h; const ps = h.ps('p_0');
+ const bond = 'victoriaShip';
+ const target = outsider(bond);
+ // half pairs grant nothing
+ wearer(h, 'p_0', target, [HAMMER]);
+ assert.equal(count(ps, bond), 0, 'the bond item alone');
+ const h2 = prep({ fake: true });
+ wearer(h2, 'p_0', target, [ISO]);
+ const other = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier <= 2 && !c.bonds.includes(bond) && c.chessId !== target).chessId;
+ wearer(h2, 'p_0', other, [ISO, ISO_B]);
+ assert.equal(count(h2.ps('p_0'), bond), 0, '变形同构体 alone / two of them');
+ // order: 变形同构体 first
+ const h3 = prep({ fake: true });
+ const w = wearer(h3, 'p_0', target, [ISO, HAMMER], { board: false });
+ assert.equal(count(h3.ps('p_0'), bond), 0, 'the pair on the bench');
+ const at = legalTileFor(h3.m, h3.ps('p_0'), target);
+ ok(h3.m.handle('p_0', { t: 'g.move', uid: w.uid, to: { area: 'board', row: at[0], col: at[1] } }));
+ assert.equal(count(h3.ps('p_0'), bond), 1, 'deployed');
+ ok(h3.m.handle('p_0', { t: 'g.move', uid: w.uid, to: { area: 'hand', idx: 9 } }));
+ assert.equal(count(h3.ps('p_0'), bond), 0, 'back to the bench');
+ // distinct members: two converted copies of one operator, or a converted copy beside a plain one, are one member
+ const h4 = prep({ fake: true });
+ const ps4 = h4.ps('p_0');
+ wearer(h4, 'p_0', target, [ISO, HAMMER]);
+ const plain = place(h4, 'p_0', target); // (a third copy would merge into the elite, the items back to the hand)
+ assert.equal(count(ps4, bond), 1, 'converted + plain copy: one member');
+ // (another 维式重锤 would merge with the equipped one into the golden hammer: the 战栗 one of the series)
+ for (const id of [ISO_B, 'chess_item_2_03_e_a']) ok(h4.m.handle('p_0', { t: 'g.equip', itemUid: giveItem(h4.m, ps4, id).uid, targetUid: plain.uid }));
+ assert.equal(plain.items.length, 2);
+ assert.equal(count(ps4, bond), 1, 'two converted copies: one member');
+ // and it never doubles a real member: a member wearing the pair still counts once
+ const h5 = prep({ fake: true });
+ wearer(h5, 'p_0', members(bond)[0], [ISO, HAMMER]);
+ assert.equal(count(h5.ps('p_0'), bond), 1);
+ for (const x of [h, h2, h3, h4, h5]) x.invariants();
+});
+
+test('a teammate\'s views: m.public counts the wearer; the scouting UnitInfo and the battle\'s UnitInfo carry the items', () => {
+ const h = prep({ mode: 'coop', humans: 2, seed: 41 });
+ const { m } = h;
+ const ps = h.ps('p_0');
+ const bond = 'victoriaShip';
+ const [a, b] = members(bond);
+ place(h, 'p_0', a); place(h, 'p_0', b);
+ const w = wearer(h, 'p_0', outsider(bond), [ISO, HAMMER]);
+ const pub = m.publicView().players.find((p) => p.playerId === 'p_0');
+ assert.equal(pub.bonds.find((x) => x.bondId === bond).count, 3, 'm.public players[].bonds');
+ const meta = m.prepFieldMeta(ps);
+ const u = meta.units.find((x) => x.uid === w.uid);
+ assert.deepEqual(u.items, [ISO, HAMMER], 'prep scouting UnitInfo');
+ assert.equal(meta.units.find((x) => x.defId === a).items, undefined, 'no items: no field');
+ let battle = null;
+ const orig = m.newBattle.bind(m);
+ m.newBattle = (o) => { const bt = orig(o); if (o.players?.some((p) => p.playerId === 'p_0')) battle ??= bt; return bt; };
+ assert.ok(h.drive(() => battle != null), 'combat starts');
+ const su = battle.allyUnits.find((x) => x.kind === 'op' && x.uid === w.uid);
+ assert.deepEqual(unitInfo(su).items, [ISO, HAMMER], 'the battle\'s UnitInfo (m.field / spawn events)');
+ const plain = battle.allyUnits.find((x) => x.kind === 'op' && x.defId === a);
+ assert.equal(unitInfo(plain).items, undefined, 'no items: no field');
+ assert.equal(battle.enemies.every((e) => unitInfo(e).items === undefined), true, 'enemies: never');
+});
diff --git a/test/match/runner.test.js b/test/match/runner.test.js
index 70f122be..7ebf1318 100644
--- a/test/match/runner.test.js
+++ b/test/match/runner.test.js
@@ -444,6 +444,16 @@ test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle
assert.equal(r.runner.unitIdOf(ally.uid, ally.ownerId, 'n:someone_else'), null, 'another field');
assert.equal(r.runner.unitIdOf(null, ally.ownerId), null);
assert.equal(r.runner.unitStats('x'), null);
+ // a teammate's bond popup reads the owner's operators with their equipment (a 变形同构体 wearer is a member of the bond
+ // it grants: test/ui/morph-bonds.test.js); an operator without items keeps the plain shape
+ const saved = ally.items;
+ ally.items = ['chess_item_6_09_e_a', 'chess_item_1_01_e_a'];
+ const op = r.runner.ownerOps(ally.ownerId, start.fieldId).find((o) => o.defId === ally.defId);
+ assert.deepEqual(op, { kind: 'op', ownerId: ally.ownerId, defId: ally.defId, items: ['chess_item_6_09_e_a', 'chess_item_1_01_e_a'] });
+ ally.items = [];
+ assert.deepEqual(r.runner.ownerOps(ally.ownerId, start.fieldId).find((o) => o.defId === ally.defId), { kind: 'op', ownerId: ally.ownerId, defId: ally.defId });
+ ally.items = saved;
+ assert.deepEqual(r.runner.ownerOps(ally.ownerId, 'n:someone_else'), [], 'another field');
// enemies too, once one is out
for (let i = 0; i < 60 && !e.battle.enemies.some((x) => x.alive); i++) r.advance(500, 50);
const foe = e.battle.enemies.find((x) => x.alive);
diff --git a/test/ui/morph-bonds.test.js b/test/ui/morph-bonds.test.js
new file mode 100644
index 00000000..0d28abe8
--- /dev/null
+++ b/test/ui/morph-bonds.test.js
@@ -0,0 +1,173 @@
+// 变形同构体 on the client (player report after 0.1.0: "变形同构体配上对应装备能吃到盟约加成,但那个盟约的人口数量没有把
+// 转职之后的干员算进去"). The server counts the wearer (bondsMeta.pieceBonds; test/match/morph-bonds.test.js), so the
+// strip's "在场 n" was right — but the bond popup's member list (gameLogic bondMembers: "成员 x/y") and the wearer's own
+// card (detailPanel BondChips) only read the chess records' bonds, so the converted operator was missing from both.
+// Official rule: band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's talent (character_table
+// trap_1073_acarm073) lists the 14 pairings "“维式重锤”系列装备→【维多利亚】盟约 …".
+
+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';
+import { bondMembers, grantedBonds, pieceBondIds } from '../../public/js/ui/gameLogic.js';
+import { ownerBoard } from '../../public/js/ui/watchBonds.js';
+import { pieceBonds } from '../../server/match/bondsMeta.js';
+import { GameData } from '../../server/match/gamedata.js';
+import { getData } from '../../server/data.js';
+import { GEO } from '../../shared/constants.js';
+
+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 items = load('items.json');
+const bonds = load('bonds.json');
+const getChess = (id) => chess[id] || null;
+const getItem = (id) => items[id] || null;
+
+const ISO = 'chess_item_6_09_e_a'; // 变形同构体
+const ISO_B = 'chess_item_6_09_e_b';
+const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 → 维多利亚
+const SHIELD = 'chess_item_1_02_e_a'; // 坚守盾牌 → 坚守
+const WEARER = 'chess_char_1_01_a'; // 隐现 (拉特兰 / 迅捷), not 维多利亚
+const VIC = Object.values(chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes('victoriaShip')).map((c) => c.chessId).sort();
+
+let uid = 0;
+const itemPiece = (id) => ({ uid: ++uid, id });
+const piece = (id, its = [], extra = {}) => ({ uid: ++uid, kind: 'chess', id, golden: !!chess[id]?.isGolden, tier: chess[id]?.tier ?? 1, items: its.map(itemPiece), ...extra });
+function privWith({ board = [], hand = [] } = {}) {
+ const h = new Array(GEO.HAND_SIZE).fill(null);
+ hand.forEach((p, i) => { h[i] = p; });
+ return { board, hand: h, temp: new Array(GEO.TEMP_SIZE).fill(null) };
+}
+
+describe('变形同构体 — the client rule', () => {
+ test('grantedBonds: the other item\'s giveBondId while a 变形同构体 is worn; nothing otherwise', () => {
+ assert.deepEqual(grantedBonds([itemPiece(ISO), itemPiece(HAMMER)], getItem), ['victoriaShip']);
+ assert.deepEqual(grantedBonds([HAMMER, ISO_B], getItem), ['victoriaShip'], 'item ids, the golden 变形同构体, any order');
+ assert.deepEqual(grantedBonds([itemPiece(HAMMER)], getItem), [], 'the bond item alone grants nothing');
+ assert.deepEqual(grantedBonds([itemPiece(ISO)], getItem), []);
+ assert.deepEqual(grantedBonds([ISO, ISO_B], getItem), [], 'two 变形同构体: no partner bond');
+ assert.deepEqual(grantedBonds(null, getItem), []);
+ assert.deepEqual(pieceBondIds(chess[WEARER], [ISO, SHIELD], getItem), ['lateranoShip', 'swiftShip', 'steadShip']);
+ assert.deepEqual(pieceBondIds(chess[WEARER], [], getItem), ['lateranoShip', 'swiftShip']);
+ });
+
+ test('the client rule equals the server\'s (bondsMeta.pieceBonds) for every bond item, normal and golden', () => {
+ const gd = new GameData(getData({ log: { warn() {}, error() {}, info() {} } }), 'mode_multi_hard');
+ const grants = Object.values(items).filter((r) => r.giveBondId);
+ assert.equal(grants.length, 36, '18 bond items × normal / golden');
+ for (const r of grants) {
+ for (const iso of [ISO, ISO_B]) {
+ const p = { id: WEARER, items: [{ id: r.id }, { id: iso }] };
+ assert.deepEqual(pieceBondIds(chess[WEARER], p.items, getItem), pieceBonds(gd, p), `${r.id} + ${iso}`);
+ }
+ }
+ });
+});
+
+describe('变形同构体 — the bond popup\'s member list', () => {
+ test('the wearer on the board is an on-board member of the granted bond (成员 x/y agrees with 在场)', () => {
+ const b = bonds.victoriaShip;
+ const priv = privWith({ board: [{ ...piece(VIC[0]), row: 9, col: 3 }, { ...piece(VIC[1]), row: 9, col: 4 }, { ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }] });
+ const rows = bondMembers(b, priv, [], getChess, getItem);
+ const on = rows.filter((r) => r.onBoard).map((r) => r.id).sort();
+ assert.deepEqual(on, [VIC[0], VIC[1], WEARER].sort(), 'three in play, like the server\'s count');
+ const w = rows.find((r) => r.id === WEARER);
+ assert.equal(w.granted, true, 'marked as converted');
+ assert.equal(w.owned, true);
+ 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);
+ // 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 }] });
+ assert.equal(bondMembers(b, plain, [], getChess, getItem).some((r) => r.id === WEARER), false);
+ });
+
+ test('a wearer on the bench is owned, not in play; copies of one operator are one member (distinct members)', () => {
+ const b = bonds.victoriaShip;
+ const bench = privWith({ hand: [piece(WEARER, [ISO, HAMMER])] });
+ const w = bondMembers(b, bench, [], getChess, getItem).find((r) => r.id === WEARER);
+ assert.ok(w && w.granted && w.owned && !w.onBoard, 'bench: owned only (BOARD bonds count the board)');
+ const two = privWith({
+ board: [{ ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }, { ...piece(chess[WEARER].goldenId, [ISO_B, HAMMER]), row: 9, col: 6 }],
+ hand: [piece(WEARER, [ISO, HAMMER])],
+ });
+ const rows = bondMembers(b, two, [], getChess, getItem).filter((r) => r.id === WEARER);
+ assert.equal(rows.length, 1, 'one entry per operator (normal and elite share it)');
+ assert.equal(rows[0].onBoard, true);
+ });
+
+ test('a teammate\'s operators on the field (ownerBoard) carry their items, so their popup lists the wearer too', () => {
+ const field = { units: [
+ { kind: 'op', ownerId: 'p2', defId: WEARER, items: [ISO, HAMMER] },
+ { kind: 'op', ownerId: 'p2', defId: VIC[0] },
+ { kind: 'op', ownerId: 'p1', defId: VIC[1], items: [ISO, SHIELD] },
+ ] };
+ const board = ownerBoard(field, 'p2');
+ const on = bondMembers(bonds.victoriaShip, board, [], getChess, getItem).filter((r) => r.onBoard).map((r) => r.id).sort();
+ assert.deepEqual(on, [VIC[0], WEARER].sort());
+ });
+});
+
+// ---- the components (htm vnodes walked like test/ui/watch-bonds.test.js) -------------------------------------------
+// the browser data store reads the real data files from disk
+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 { BondPopup } = await import('../../public/js/ui/bondStrip.js');
+const { BondChips, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
+
+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);
+const textOf = (v) => {
+ if (v == null || typeof v === 'boolean') return '';
+ if (typeof v === 'string' || typeof v === 'number') return String(v);
+ if (Array.isArray(v)) return v.map(textOf).join('');
+ return textOf(v.props?.children);
+};
+
+describe('变形同构体 — what the popup and the card show', () => {
+ test('the popup: 在场 3 and 成员 3/11 — the wearer lit, tagged 同构, titled 变形同构体', () => {
+ const priv = privWith({ board: [{ ...piece(VIC[0]), row: 9, col: 3 }, { ...piece(VIC[1]), row: 9, col: 4 }, { ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }] });
+ const entry = { bondId: 'victoriaShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6], countsHand: false };
+ const v = BondPopup({ bondId: 'victoriaShip', entry, priv, onClose() {} });
+ const head = [...walk(v)].filter((x) => x.type === 'h4').map(textOf).find((t) => t.startsWith('成员'));
+ assert.equal(head.replace(/\s+/g, ''), `成员3/${bonds.victoriaShip.visibleMembers.length + 1}`);
+ assert.match(textOf([...walk(v)].find((x) => hasClass(x, 'bpop__facts'))), /在场\s*3/);
+ const iso = [...walk(v)].filter((x) => hasClass(x, 'bpop__member') && hasClass(x, 'is-granted'));
+ assert.equal(iso.length, 1);
+ assert.ok(hasClass(iso[0], 'is-on'));
+ assert.match(iso[0].props.title, /变形同构体/);
+ assert.ok([...walk(iso[0])].some((x) => hasClass(x, 'bpop__iso') && textOf(x) === '同构'));
+ });
+
+ test('the card\'s bond chips: the granted bond with its count, dashed and tagged; resolveDetail hands a teammate unit\'s items on', () => {
+ const ids = pieceBondIds(chess[WEARER], [ISO, HAMMER], getItem);
+ const mine = [{ bondId: 'victoriaShip', count: 3, active: true, tier: 1, layers: 0, thresholds: [3, 6] }];
+ const v = BondChips({ bondIds: ids, bonds: mine, granted: ['victoriaShip'], onBond() {} });
+ const chips = [...walk(v)].filter((x) => hasClass(x, 'dbond'));
+ assert.deepEqual(chips.map((x) => x.props['data-bond']), ['lateranoShip', 'swiftShip', 'victoriaShip']);
+ const vic = chips[2];
+ assert.ok(hasClass(vic, 'is-granted') && hasClass(vic, 'is-active'));
+ assert.match(vic.props.title, /维多利亚(变形同构体):在场 3\/6/);
+ assert.ok(!hasClass(chips[0], 'is-granted'));
+ const d = resolveDetail({ kind: 'unit', unit: { id: 7, side: 'ally', ownerId: 'p2', defId: WEARER, items: [ISO, HAMMER] } }, new Map());
+ assert.deepEqual(d.unitItems, [ISO, HAMMER]);
+ assert.equal(resolveDetail({ kind: 'unit', unit: { id: 8, side: 'ally', ownerId: 'p2', defId: WEARER } }, new Map()).unitItems, null);
+ });
+});