feedback1c WK: 变形同构体 wearers in the bond popup's members and on their card

Player report: with 变形同构体 and the matching bond item the wearer gets the
bond's effect, but the bond's member count leaves the converted operator out.

The count that activates the bond already included the wearer on every server
path (bondsMeta.pieceBonds: prep count, views, battle input; support.unitBonds:
the battle). The client did not: the bond popup's member list (成员 x/y) and the
wearer's card read only the chess records' bonds, so the popup said 在场 3 but
成员 2/10 without the wearer, and the card had no chip for the bond.

- gameLogic.grantedBonds / pieceBondIds: the server's 变形同构体 rule on the
  client; bondMembers lists wearers (tagged 同构, one row per operator, on the
  board or owned), so 成员 x/y agrees with 在场.
- detailPanel: the card's chips include the granted bond (dashed, 同构).
- Teammates: UnitInfo carries an ally operator's item ids (sim unitInfo,
  Match.prepFieldMeta); ownerBoard / runner.ownerOps keep them.
- Tests: every pairing on real matches (prep, views, battle input, the real
  battle), bench vs board, half pairs, distinct members; the client rule
  against the server's; popup / card rendering.
This commit is contained in:
sganggs committed 2026-10-03 02:29:28 +08:00
1 parent 7f134737ce
commit 0c898bb524
13 files changed
+464 -34

No files matched your search

+6
View File
@@ -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. 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, * **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. 变形同构体 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 **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 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`), `layerGainRoom` — `PlayerState.addLayers` (all prep-side gains: 特质, items, bands, 机变 cards, bonds; `ctx.addLayers`),
+1 -1
View File
@@ -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]`, `['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), `['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]`. `['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) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data). (`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); - 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);
+4
View File
@@ -256,6 +256,10 @@
.bpop__member.is-owned .bpop__mname { color: var(--text-hi); } .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 { 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; } .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 ---- */ /* ---- enemy / info drawer ---- */
.edrawer { .edrawer {
+4 -3
View File
@@ -45,8 +45,9 @@
// playtest #4 item 7). Read-only: it takes the stats the sim computed last (`unit._s`) and // 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. // 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.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 // battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items? }] that player's operators in the
// screen (a teammate's bond popup: the members in play, DESIGN §20.15) | [] // 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 // createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js drives it under Node). // (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 []; if (!e || typeof ownerId !== 'string' || !ownerId || (fieldId != null && e.fieldId !== fieldId)) return [];
const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : []; 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') 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). */ /** Re-show the current battle (the game screen remounted). */
reshow() { if (cur) show(cur); }, reshow() { if (cur) show(cur); },
+8 -5
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 // 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 // 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 // layer-resolved numbers), then the full description and the member list with owned / on-board state — operators that
// the detail panel's bond chips it docks beside that panel (`beside`: the panel's side). // 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 = // 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 ("层数叠加已禁用"). // 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: // 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 th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
const tier = entry?.tier ?? bondTier(count, th, b.maxCount); const tier = entry?.tier ?? bondTier(count, th, b.maxCount);
const active = entry ? !!entry.active : tier > 0; 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 countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th); const next = nextThreshold(count, th);
const hasNow = !!(b.effectDescRaw || b.effectDesc); const hasNow = !!(b.effectDescRaw || b.effectDesc);
@@ -106,11 +107,13 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
<section class="bpop__sec"> <section class="bpop__sec">
<h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4> <h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4>
<div class="bpop__members"> <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')} ${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)} title=${`${mb.name}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}> onClick=${() => 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} /> <${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
<span class="bpop__mname">${mb.name}</span> <span class="bpop__mname">${mb.name}</span>
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null} ${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">同构</span>` : null}
</button>`)} </button>`)}
</div> </div>
</section> </section>
+23 -13
View File
@@ -1,8 +1,9 @@
// Detail panel (click / right-click a piece, shop card, bond member, battle unit or previewed enemy): // 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 // 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, // 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 (特质 — // reached tier, active state — tap one for its popup; a bond its 变形同构体 pairing grants (gameLogic pieceBondIds: its
// garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description, // 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 // 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 // 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, talents (CHESS_SECTIONS); items — icon, tier,
@@ -21,7 +22,7 @@
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js'; import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.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 { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.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. * 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') : []; const ids = Array.isArray(bondIds) ? bondIds.filter((b) => typeof b === 'string') : [];
if (!ids.length) return null; 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])); const mine = new Map((Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string').map((b) => [b.bondId, b]));
return html`<div class="dbonds dbonds--top" role="list" aria-label="所属盟约"> return html`<div class="dbonds dbonds--top" role="list" aria-label="所属盟约">
${ids.map((id) => { ${ids.map((id) => {
@@ -145,16 +148,18 @@ export function BondChips({ bondIds, bonds = [], onBond = null }) {
const active = e ? !!e.active : tier > 0; const active = e ? !!e.active : tier > 0;
const next = nextThreshold(count, th); const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null; 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` const body = html`
<${BondGlyph} bondId=${id} class="dbond__icon" /> <${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span> <span class="dbond__name">${rec?.name || id}</span>
${iso.has(id) ? html`<span class="dbond__iso">同构</span>` : null}
<span class=${cx('dbond__count', 'num', next == null && count > 0 && 'is-max')}>${count}${cap != null ? html`<small>/${cap}</small>` : null}</span> <span class=${cx('dbond__count', 'num', next == null && count > 0 && 'is-max')}>${count}${cap != null ? html`<small>/${cap}</small>` : null}</span>
${th.length ? html`<span class="dbond__tiers" aria-hidden="true">${th.map((_, i) => html`<i key=${i} class=${i < tier ? 'on' : ''}></i>`)}</span>` : null}`; ${th.length ? html`<span class="dbond__tiers" aria-hidden="true">${th.map((_, i) => html`<i key=${i} class=${i < tier ? 'on' : ''}></i>`)}</span>` : null}`;
return onBond return onBond
? html`<button key=${id} type="button" role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} aria-label=${label} ? html`<button key=${id} type="button" role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core', iso.has(id) && 'is-granted')} title=${label} aria-label=${label}
data-bond=${id} onClick=${() => onBond(id)}>${body}</button>` data-bond=${id} data-granted=${iso.has(id) ? '1' : null} onClick=${() => onBond(id)}>${body}</button>`
: html`<span key=${id} role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} data-bond=${id}>${body}</span>`; : html`<span key=${id} role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core', iso.has(id) && 'is-granted')} title=${label} data-bond=${id}
data-granted=${iso.has(id) ? '1' : null}>${body}</span>`;
})} })}
</div>`; </div>`;
} }
@@ -204,7 +209,7 @@ function GarrisonBlock({ garrison, m }) {
</section>`; </section>`;
} }
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 m = data.get('assets');
const hp = hpOf(live, snapHp); const hp = hpOf(live, snapHp);
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id)); 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 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 garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
const items = Array.isArray(piece?.items) ? piece.items : []; 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 sell = c.sellPrice ?? 1;
const blocks = {}; const blocks = {};
blocks.head = html` blocks.head = html`
@@ -244,7 +254,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span> <span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
</div> </div>
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null} ${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} /> <${BondChips} bondIds=${bondIds} bonds=${bonds} onBond=${onBond} granted=${grantedIds} />
</div> </div>
</div>`; </div>`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null; 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; 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; } 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); 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); const t = data.lookup('tokens', u.defId);
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) }; if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
const en = data.lookup('enemies', u.defId); const en = data.lookup('enemies', u.defId);
@@ -521,7 +531,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button> <button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll"> <div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt} ${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 === '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 === '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} ${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
+58 -8
View File
@@ -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<string|{id:string}>|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<string|{id:string}>|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} 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>|string[]} [banned] banned base chess ids * @param {Set<string>|string[]} [banned] banned base chess ids
* @param {(id:string)=>any} [getChess] * @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 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 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 baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id);
const onBoard = new Set(); const onBoard = new Set();
const owned = 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)); } const memberSet = new Set(members);
for (const p of [...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]) { /** base id → on the board? — operators of the player that join this bond through 变形同构体 */
if (p?.kind === 'chess') owned.add(baseOf(p.id)); 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); 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) }; 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));
} }
/** /**
+4 -3
View File
@@ -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 * (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 * 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 * 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. * when they are on the field. Their operators keep the items the unit info carries (UnitInfo `items`), so a 变形同构体
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId } * 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) { export function ownerBoard(field, ownerId, extra = null) {
const fromField = isObj(field) && Array.isArray(field.units) ? field.units : 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; if (!fromField && !fromBattle) return null;
const board = [...(fromField || []), ...(fromBattle || [])] const board = [...(fromField || []), ...(fromBattle || [])]
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string') .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: [] }; return { board, hand: [], temp: [] };
} }
+2
View File
@@ -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, 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, skillIndex: lo && Number.isInteger(lo.skillIndex) ? lo.skillIndex : undefined,
moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : 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 // `nextEnemies`: the scouted player's coming enemies — their preview pen shows on the scouting board too (research 09
+6 -1
View File
@@ -1,8 +1,10 @@
// server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2). // 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 } // 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). // 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). // flags bits & anim codes come from shared/constants.js (UF / ANIM).
import { UF, ANIM } from '../../shared/constants.js'; 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 // 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) // 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, 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,
}; };
} }
+165
View File
@@ -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');
});
+10
View File
@@ -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(ally.uid, ally.ownerId, 'n:someone_else'), null, 'another field');
assert.equal(r.runner.unitIdOf(null, ally.ownerId), null); assert.equal(r.runner.unitIdOf(null, ally.ownerId), null);
assert.equal(r.runner.unitStats('x'), 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 // enemies too, once one is out
for (let i = 0; i < 60 && !e.battle.enemies.some((x) => x.alive); i++) r.advance(500, 50); 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); const foe = e.battle.enemies.find((x) => x.alive);
+173
View File
@@ -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);
});
});