feedback1 integrate: WK bond popup and card chips include 变形同构体 wearers

# Conflicts:
#	docs/SIM.md
#	public/js/ui/bondStrip.js
#	public/js/ui/detailPanel.js
#	server/sim/snapshot.js
This commit is contained in:
sganggs committed 2026-10-03 06:56:27 +08:00
commit ff0f8d0211
15 files changed
+526 -45

No files matched your search

+8
View File
@@ -475,6 +475,14 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
the same tiles (`ui/gameLogic.js summonRange`), the bot plans inside them. 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), and a
card without an own piece (a teammate's unit, the card a 同构 row opens) lists those items read-only under 装备.
Not covered [ASSUMED]: operator talents that test "【拉特兰】/【卡西米尔】/… 干员" in the kits read the record's bonds.
**Layer cap** (research 11 §1; the client's `MAX_GARRISON_STACK` / `AddBondCount` = min(L + n, 999)): each bond's
layers stop at `BOND_LAYER_CAP` = 999 (shared/constants.js; 0 / Infinity = off). Every writer clamps with
`layerGainRoom` — `PlayerState.addLayers` (all prep-side gains: 特质, items, bands, 机变 cards, bonds; `ctx.addLayers`),
+1 -1
View File
@@ -1034,7 +1034,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing;
`extra.form` = the enemy's model form from then on, `content/enemies.js setForm` / `shared/protocol.js fxForm`),
`['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?, form?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms … — so a view built mid-battle from `fieldMeta()` starts on that clip set)
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms … — so a view built mid-battle from `fieldMeta()` starts on that clip set; `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 — 隐匿 (an enemy's only while not blocked / revealed) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
+4
View File
@@ -264,6 +264,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 {
+4 -3
View File
@@ -50,8 +50,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).
@@ -720,7 +721,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); },
+1 -1
View File
@@ -1208,7 +1208,7 @@ function MatchScreen() {
${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name}
off=${offBonds.has(bondPop.bondId)}
place=${bpPlace} over=${!!resolved && bpPlace === dSide}
onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId })} />` : null}
onClose=${() => setBondOpen(null)} onMember=${(id, items) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
+16 -10
View File
@@ -1,8 +1,10 @@
// 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 (a bond the
// mode never activates says 本局禁用 — gameLogic modeOffBonds). 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; such a row opens the wearer's card with its
// items: the pair and the granted chip), so the list's count agrees with 在场 (a bond the mode never activates says
// 本局禁用 — gameLogic modeOffBonds). 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:
@@ -59,12 +61,14 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
/**
* Bond detail popup.
* @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function, onMember?: (chessId:string)=>void,
* @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function,
* onMember?: (chessId:string, items?:string[]|null)=>void,
* place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null, off?: boolean }} props —
* `place`: where it opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the
* detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js); `off`: the mode
* never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a note, no
* 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays)
* detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js); `onMember`
* gets a 变形同构体 row's item ids too (its card shows the pair and the granted chip), null for a plain member; `off`: the
* mode never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a
* note, no 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays)
*/
export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null, owner = null, off = false }) {
const b = data.lookup('bonds', bondId);
@@ -74,7 +78,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 = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount);
const active = !off && (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 = !off && !!(b.effectDescRaw || b.effectDesc);
@@ -110,11 +114,13 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
<section class="bpop__sec">
<h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4>
<div class="bpop__members">
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned')}
onClick=${() => onMember?.(mb.id)} title=${`${mb.name}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
${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, mb.granted && Array.isArray(mb.items) ? mb.items : null)} data-granted=${mb.granted ? '1' : null}
title=${`${mb.name}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
<span class="bpop__mname">${mb.name}</span>
${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>`)}
</div>
</section>
+38 -15
View File
@@ -2,11 +2,14 @@
// operators — portrait, name, tier, elite, class/subclass and, right under them in the header's right column (no
// scrolling, user playtest #2 item 9), the unit's bonds (阵营 / 盟约: icon, name, member count / next threshold,
// reached tier, active state — tap one for its popup; a bond the mode never activates reads 本局禁用, gameLogic
// modeOffBonds); right under the header the operator's own effect (特质 —
// modeOffBonds; a bond its 变形同构体 pairing grants (gameLogic pieceBondIds: its piece's items, a teammate's unit:
// UnitInfo `items`, a bond popup's 同构 row: the wearer's) has a dashed chip tagged 同构 — the wearer counts for it);
// right under the header the operator's own effect (特质 —
// garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description,
// compact, visible without scrolling: user playtest #3 items 8 / 9), the class trait (特性), stats + range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP
// info, rich description, 已调配 when not the default), elite module (模组: official type icon from the local-client
// art, else its letter), equipped items, talents (CHESS_SECTIONS); items — icon, tier,
// art, else its letter), equipped items (read-only from those `items` when the card has no own piece), talents
// (CHESS_SECTIONS); items — icon, tier,
// effect; tokens — the owner's variant (a golden owner's summon: its `_b` stats / skill), how a placed summon takes
// the field (shared/constants.js SKILL_SUMMON_START_DEPLOY), its token skill and talents; enemies — stats, rank,
// faction tags, abilities. Selling / destroying is the underframe's job in the
@@ -22,7 +25,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, briefingBondTip } from './gameLogic.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, 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';
@@ -130,12 +133,14 @@ 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, off?: Set<string>|null }} props — off: the
* bonds the mode never activates (gameLogic modeOffBonds): 本局禁用 instead of the count
* `granted`: the bonds among them the unit holds through 变形同构体 (dashed chip, 同构 tag).
* @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void, off?: Set<string>|null, granted?: string[] }} props —
* off: the bonds the mode never activates (gameLogic modeOffBonds): 本局禁用 instead of the count
*/
export function BondChips({ bondIds, bonds = [], onBond = null, off = null }) {
export function BondChips({ bondIds, bonds = [], onBond = null, off = 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`<div class="dbonds dbonds--top" role="list" aria-label="所属盟约">
${ids.map((id) => {
@@ -149,23 +154,26 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null }) {
const active = !isOff && (e ? !!e.active : tier > 0);
const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null;
const isoTag = iso.has(id) ? '(变形同构体)' : '';
const label = isOff ? briefingBondTip(rec?.name || id, 'off')
: `${rec?.name || id}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
: `${rec?.name || id}${isoTag}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
const body = isOff
? html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span>
${iso.has(id) ? html`<span class="dbond__iso">同构</span>` : null}
<span class="dbond__off">本局禁用</span>`
: html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
<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>
${th.length ? html`<span class="dbond__tiers" aria-hidden="true">${th.map((_, i) => html`<i key=${i} class=${i < tier ? 'on' : ''}></i>`)}</span>` : null}`;
const cls = cx('dbond', active && 'is-active', isOff && 'is-off', rec?.isCore && 'is-core');
const cls = cx('dbond', active && 'is-active', isOff && 'is-off', rec?.isCore && 'is-core', iso.has(id) && 'is-granted');
return onBond
? html`<button key=${id} type="button" role="listitem" class=${cls} title=${label} aria-label=${label}
data-bond=${id} onClick=${() => onBond(id)}>${body}</button>`
: html`<span key=${id} role="listitem" class=${cls} title=${label} data-bond=${id}>${body}</span>`;
data-bond=${id} data-granted=${iso.has(id) ? '1' : null} onClick=${() => onBond(id)}>${body}</button>`
: html`<span key=${id} role="listitem" class=${cls} title=${label} data-bond=${id} data-granted=${iso.has(id) ? '1' : null}>${body}</span>`;
})}
</div>`;
}
@@ -215,7 +223,7 @@ function GarrisonBlock({ garrison, m }) {
</section>`;
}
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null }) {
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, 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));
@@ -231,6 +239,12 @@ 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; without one: `unitItems` — a
// teammate's unit's UnitInfo items, a bond popup 同构 row's wearer's)
const carried = piece ? items : (Array.isArray(unitItems) ? unitItems : []);
const getItem = (id) => data.lookup('items', id);
const bondIds = pieceBondIds(c, carried, getItem);
const grantedIds = bondIds.filter((b) => !(Array.isArray(c.bonds) && c.bonds.includes(b)));
const sell = c.sellPrice ?? 1;
const blocks = {};
blocks.head = html`
@@ -255,7 +269,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
</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}
<${BondChips} bondIds=${c.bonds} bonds=${bonds} off=${offBonds} onBond=${onBond} />
<${BondChips} bondIds=${bondIds} bonds=${bonds} off=${offBonds} onBond=${onBond} granted=${grantedIds} />
</div>
</div>`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
@@ -308,6 +322,10 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
<//>` : null;
blocks.equip = piece?.kind === 'chess' ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${items.length}/2`} class="dsec--equip">
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
<//>`
// no own piece (a teammate's unit, a bond popup's 变形同构体 row): what it carries, read-only
: !piece && carried.length ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${carried.length}/2`} class="dsec--equip">
${carried.map((id, i) => html`<${ItemRow} key=${`${i}:${id}`} itemId=${id} />`)}
<//>` : null;
blocks.talents = Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
@@ -483,7 +501,12 @@ export function resolveDetail(target, pieces) {
const c = data.lookup('chess', p.id);
return c ? { type: 'chess', chess: c, piece: p } : null;
}
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c, hint: target.hint || null } : null; }
if (target.kind === 'chess') {
// a bond popup's 变形同构体 row hands the wearer's item ids on (bondStrip onMember): the card shows the pair and the chip
const c = data.lookup('chess', target.id);
const items = Array.isArray(target.items) ? target.items.filter((x) => typeof x === 'string') : [];
return c ? { type: 'chess', chess: c, hint: target.hint || null, ...(items.length ? { unitItems: items } : {}) } : null;
}
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
@@ -492,7 +515,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);
@@ -535,7 +558,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} />` : null}
bonds=${bonds} offBonds=${offBonds} 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}
+60 -8
View File
@@ -375,26 +375,78 @@ 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` and `items`: the item ids of the copy that wears the
* pair — the card the row opens shows them; one row per operator — normal and elite copies are one member, like the
* count's distinct members), so "成员 x/y" agrees with the count the server reports (在场).
* @param {any} bond bonds.json record
* @param {any} priv m.private (hand/board/temp)
* @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 {(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: on the board?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */
const granted = new Map();
const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId);
const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string');
for (const p of Array.isArray(priv?.board) ? priv.board : []) {
if (p?.kind !== 'chess') continue;
const base = baseOf(p.id);
onBoard.add(base); owned.add(base);
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, items: itemIds(p) });
}
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, { on: false, items: itemIds(p) });
}
const rows = members.map((id) => {
const c = getChess(id);
return { id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: onBoard.has(id), owned: owned.has(id), banned: bannedSet.has(id) };
}).sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
});
for (const [id, g] of granted) {
const c = getChess(id);
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, banned: false, granted: true, items: g.items });
}
return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
}
/**
+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
* 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: [] };
}
+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,
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
+7 -3
View File
@@ -2,10 +2,11 @@
//
// 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, motion?, boss?, uid?,
// form?, skillIndex?, moduleId? } (form = an enemy's current model form, content/enemies.js setForm — 掠海漂移体 'crawl',
// 暴鸰 'bombed', 转译基底·α's forms …: a view built after the change, a field opened mid-battle, draws it — render/units.js
// FORMS)
// form?, skillIndex?, moduleId?, items? } (form = an enemy's current model form, content/enemies.js setForm — 掠海漂移体
// 'crawl', 暴鸰 'bombed', 转译基底·α's forms …: a view built after the change, a field opened mid-battle, draws it —
// render/units.js FORMS)
// 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); an enemy's stealth bit = its 隐匿 is on (not while it
// is blocked or revealed), an ally's = 隐匿 / 迷彩 whatever it blocks.
@@ -45,6 +46,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,
};
}
+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(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);
+204
View File
@@ -0,0 +1,204 @@
// 变形同构体 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);
assert.deepEqual(w.items, [ISO, HAMMER], 'the row carries the wearer\'s item ids (its card shows the pair)');
assert.ok(rows.filter((r) => !r.granted).every((r) => !('items' in r)), 'plain members carry none');
// not a member of an unrelated bond, and not with the bond item alone
assert.equal(bondMembers(bonds.steadShip, priv, [], getChess, getItem).some((r) => r.id === WEARER), false);
const plain = privWith({ board: [{ ...piece(WEARER, [HAMMER]), row: 9, col: 5 }] });
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', 'garrisons');
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { BondChips, ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
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);
});
test('a 同构 row opens the wearer\'s card with its items: the pair (read-only 装备) and the granted chip', () => {
const priv = privWith({ board: [{ ...piece(VIC[0]), row: 9, col: 3 }, { ...piece(WEARER, [ISO, HAMMER]), row: 9, col: 5 }] });
const entry = { bondId: 'victoriaShip', count: 2, active: false, tier: 0, layers: 0, thresholds: [3, 6], countsHand: false };
const calls = [];
const v = BondPopup({ bondId: 'victoriaShip', entry, priv, onClose() {}, onMember: (...a) => calls.push(a) });
const rows = [...walk(v)].filter((x) => hasClass(x, 'bpop__member'));
rows.find((x) => hasClass(x, 'is-granted')).props.onClick();
rows.find((x) => !hasClass(x, 'is-granted') && hasClass(x, 'is-on')).props.onClick();
assert.deepEqual(calls, [[WEARER, [ISO, HAMMER]], [VIC[0], null]]);
// the screen turns the click into { kind: 'chess', id, owner, items } (screens/game.js) → resolveDetail
const d = resolveDetail({ kind: 'chess', id: WEARER, owner: 'p1', items: [ISO, HAMMER] }, new Map());
assert.deepEqual(d.unitItems, [ISO, HAMMER]);
assert.equal('unitItems' in resolveDetail({ kind: 'chess', id: WEARER, owner: 'p1', items: null }, new Map()), false, 'a plain member card: no items');
const mine = [{ bondId: 'victoriaShip', count: 2, active: false, tier: 0, layers: 0, thresholds: [3, 6] }];
const blocks = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null, unitItems: d.unitItems });
const chips = [...walk(blocks)].find((x) => x.type === BondChips);
assert.ok(chips.props.bondIds.includes('victoriaShip') && chips.props.granted.includes('victoriaShip'), 'the granted chip');
const vic = [...walk(BondChips(chips.props))].find((x) => hasClass(x, 'dbond') && x.props['data-bond'] === 'victoriaShip');
assert.ok(vic && hasClass(vic, 'is-granted'));
const equip = blocks.find((b) => b?.key === 'equip');
assert.ok(equip, 'a read-only 装备 section without an own piece');
assert.deepEqual([...walk(equip)].filter((x) => x.props?.itemId).map((x) => x.props.itemId), [ISO, HAMMER]);
assert.ok(![...walk(equip)].some((x) => hasClass(x, 'dempty')), 'no "drag to equip" hint on a read-only list');
// a plain record card (shop, a plain member) keeps no 装备 section and no granted chip
const bare = ChessDetail({ chess: d.chess, piece: null, editable: false, bonds: mine, loadout: null });
assert.equal(bare.some((b) => b?.key === 'equip'), false);
assert.deepEqual([...walk(bare)].find((x) => x.type === BondChips).props.granted, []);
});
});
+2 -1
View File
@@ -357,7 +357,8 @@ test('§20.15 wiring: the game screen feeds the strip, the popup and the detail
assert.match(src, /onOpen=\$\{\(id\) => openBond\(id, strip\.ownerId, 'strip'\)\}/, 'the strip opens its owner\'s bond');
assert.match(src, /const detailOwner = detailBondOwner\(detailTarget, \{ pub, myId, stripOwnerId: strip\.ownerId \}\)/);
assert.match(src, /onBond=\$\{\(id\) => openBond\(id, detailOwner, 'detail'\)\}/, 'a card\'s chip opens the same owner\'s bond as its chips show');
assert.match(src, /onMember=\$\{\(id\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId \}\)\}/, 'a member card keeps the popup\'s player');
assert.match(src, /onMember=\$\{\(id, items\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null \}\)\}/,
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items)');
assert.match(src, /openId=\$\{bondPop && bondPop\.ownerId === strip\.ownerId \? bondPop\.bondId : null\}/, 'the strip marks only its own owner\'s popup');
assert.match(src, /setBondOpen\(\(b\) => \(b && b\.from === 'strip' \? null : b\)\)/, 'a strip popup closes when the strip changes hands');
assert.ok(!/stripBonds\.find\(\(b\) => b\.bondId === bondOpen\)/.test(src), 'no popup entry read from the strip regardless of its owner');