diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css
index d8c3d787..58f34f4f 100644
--- a/public/css/screens/game-panels.css
+++ b/public/css/screens/game-panels.css
@@ -209,6 +209,27 @@
.dhint--merge { color: var(--gold); }
/* a rule the official item text leaves out (items.json `note`: 突变细胞 returns to the hand after each use) */
.dhint--rule { color: var(--text-md); }
+/* 变形同构体's 天赋栏 (detailPanel MorphPairings, DESIGN §21.26): one line per bond — 【bond】 | the items that pair with it;
+ 本局禁用 struck through, the pairing a wearer wears highlighted. Px floors keep it readable on phones (1rem = 40 px). */
+.dmorph { display: flex; flex-direction: column; gap: .05rem; font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-md); }
+.dmorph__lead { margin: 0; color: var(--text-hi); }
+.dmorph__list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 1px; }
+.dmorph__row { display: grid; grid-template-columns: 6.2em minmax(0, 1fr); gap: .06rem; align-items: baseline; padding: .02rem .06rem;
+ background: rgba(255, 255, 255, .025); border-left: 2px solid transparent; }
+.dmorph__bond { font-weight: 700; color: var(--mint-400); white-space: nowrap; }
+.dmorph__items { min-width: 0; overflow-wrap: anywhere; }
+.dmorph__row.is-off .dmorph__bond { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
+.dmorph__row.is-off .dmorph__items { color: var(--text-dim); }
+.dmorph__row.is-worn { background: rgba(78, 216, 175, .14); border-left-color: var(--mint-400); }
+.dmorph__item.is-worn { color: #fff; font-weight: 700; }
+.dmorph__tag { display: inline-block; margin-left: .06rem; padding: 0 .05rem; font-size: max(.11rem, 8px); line-height: 1.5; font-weight: 700; white-space: nowrap; }
+.dmorph__tag.is-on { background: var(--mint-500); color: #062019; }
+.dmorph__tag.is-off { border: 1px dashed rgba(255, 255, 255, .25); color: var(--text-dim); font-weight: 500; }
+.dmorph__none { margin: 0; color: var(--gold-2); }
+/* a bond item's line: what it gives a 变形同构体 wearer (MorphGrantLine) */
+.dhint--morph { font-size: max(.14rem, 9px); color: var(--text-md); }
+.dhint--morph.is-worn { color: var(--mint-400); }
+.dhint--morph.is-off { color: var(--text-dim); }
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
.dsell { margin-left: .06rem; color: #1d1400; }
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
@@ -269,6 +290,17 @@
.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); }
+/* a count that holds 调和's +1 (the server's bond entry `harmony`, DESIGN §21.26): 在场 n(含调和 +1) and the 调和 row that
+ heads the member list, naming the 调和 operators on the board (px floors: readable on phones) */
+.bpop__facts small.bpop__hnote { font-size: max(.12rem, 8px); color: var(--gold-2); }
+.bpop__harmony { display: flex; align-items: center; gap: .08rem; width: 100%; margin: 0; padding: .05rem .1rem; text-align: left;
+ font: inherit; font-size: max(.14rem, 9px); line-height: 1.45; color: var(--text-md); background: rgba(242, 189, 62, .08); border: 1px dashed rgba(242, 189, 62, .5); }
+button.bpop__harmony { cursor: pointer; }
+button.bpop__harmony:hover { border-style: solid; color: #fff; }
+.bpop__harmony b { flex: none; color: var(--gold-2); font-weight: 700; white-space: nowrap; }
+.bpop__harmony span { min-width: 0; }
+.bpop__harmony .bglyph { width: max(.24rem, 14px); height: max(.24rem, 14px); }
+.bpop__harmony .bglyph img { filter: brightness(0) invert(.78) sepia(1) saturate(3) hue-rotate(5deg); }
/* ---- enemy / info drawer ---- */
.edrawer {
diff --git a/public/dev/game-mock.html b/public/dev/game-mock.html
index 4fcdd077..72d98c78 100644
--- a/public/dev/game-mock.html
+++ b/public/dev/game-mock.html
@@ -7,6 +7,7 @@
&variant=reward|bounty|supply|shop|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag
|leaker (联防: you let enemies through — the ×N counter)
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
+ |funny (标准: 本局禁用 bonds) |morph (变形同构体 in the hand / shop, a wearer on the board) |harmony (缪尔赛思 on the board)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
Keyboard: ` toggles the switcher.
-->
diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js
index 293ef97b..d4b3e72f 100644
--- a/public/dev/game-mock.js
+++ b/public/dev/game-mock.js
@@ -66,26 +66,33 @@ function chessPiece(c, golden = false) {
}
function itemPiece(it) { return { uid: nextUid(), kind: 'item', id: it.id, golden: !!it.isGolden, tier: it.tier }; }
+// the mock server's bond counts: BOARD distinct members, plus 调和 (maniShip) like server/match/bondsMeta.js — a 调和
+// operator on the board adds 1 to every core bond that has a member and marks the entry `harmony: 1` (the server sends it);
+// the mode's inactive bonds (标准) are left out, as the server does
function computeBonds(priv) {
const counts = new Map();
const seen = new Set();
+ const inactive = new Set(data.get('config')?.modes?.[S?.pub?.modeId]?.inactiveBondIds || []);
for (const p of priv.board) {
if (p.kind !== 'chess') continue;
const c = data.lookup('chess', p.id);
const base = c?.baseId || p.id;
if (seen.has(base)) continue;
seen.add(base);
- for (const b of c?.bonds || []) counts.set(b, (counts.get(b) || 0) + 1);
+ for (const b of c?.bonds || []) if (!inactive.has(b)) counts.set(b, (counts.get(b) || 0) + 1);
}
+ const harmony = counts.get('maniShip') > 0;
const out = [];
- for (const [bondId, count] of counts) {
+ for (const [bondId, raw] of counts) {
const b = data.lookup('bonds', bondId);
+ const plus = harmony && b?.isCore ? 1 : 0;
+ const count = raw + plus;
const thresholds = b?.thresholds || [2];
let tier = 0;
for (const t of thresholds) if (count >= t) tier++;
const layers = S?.layers?.[bondId] ?? (tier ? Math.floor(rnd() * 40) + 4 : Math.floor(rnd() * 6));
if (S) S.layers[bondId] = layers;
- out.push({ bondId, count, active: tier > 0, tier, layers, thresholds, countsHand: !!b?.countsHand });
+ out.push({ bondId, count, active: tier > 0, tier, layers, ...(plus ? { harmony: plus } : {}), thresholds, countsHand: !!b?.countsHand });
}
return out.sort((a, b) => (b.active - a.active) || (b.layers - a.layers));
}
@@ -206,6 +213,31 @@ function buildState() {
S = { stage, pub, priv, layers: {}, battle: null, pool, requests: [] };
}
+/**
+ * The 0.1.1 gaps (DESIGN §21.26): `funny` — the match is 标准 (its 本局禁用 bonds: 5 of 变形同构体's 14 pairings off);
+ * `morph` — a 变形同构体 in the hand and in the shop's item slot, the first board operator wearing 变形同构体 + 维式重锤 (its
+ * card highlights that pairing); `harmony` — 缪尔赛思 (调和) in place of the second board operator, so the core bonds with a
+ * member count +1 and carry `harmony` (the bond popup's 调和 row).
+ */
+function applyGapVariants() {
+ if (VARIANTS.has('funny')) { S.pub.modeId = 'mode_multi_funny'; S.pub.difficulty = 'FUNNY'; }
+ if (VARIANTS.has('morph')) {
+ const iso = data.list('items').find((i) => i.canGiveBond && !i.isGolden);
+ const hammer = data.list('items').filter((i) => i.giveBondId === 'victoriaShip' && !i.isGolden).sort((a, b) => a.tier - b.tier)[0];
+ if (iso) {
+ S.priv.hand[5] = itemPiece(iso);
+ const slot = S.priv.shop.slots.findIndex((s) => s && s.kind === 'item');
+ if (slot >= 0) S.priv.shop.slots[slot] = { kind: 'item', id: iso.id, price: iso.price, basePrice: iso.price, sold: false };
+ if (S.priv.board[0] && hammer) S.priv.board[0].items = [{ uid: nextUid(), id: iso.id }, { uid: nextUid(), id: hammer.id }];
+ }
+ }
+ if (VARIANTS.has('harmony')) {
+ const mani = visibleChess().find((c) => c.bonds.includes('maniShip'));
+ const at = S.priv.board[1];
+ if (mani && at) S.priv.board[1] = { ...chessPiece(mani), row: at.row, col: at.col };
+ }
+}
+
/**
* `loadout` variant (DESIGN §16): the first board operator and the first shop card fight with a non-default skill, the
* elite board operator with its module unequipped (m.private.loadout). Data without `skills[]` / `modules[]` (before the
@@ -250,6 +282,7 @@ function setPhase(phase, variant) {
for (const v of (variant || '').split(',').filter(Boolean)) VARIANTS.add(v);
if (VARIANTS.has('reward') && !S.priv.shop.rewardOffer) S.priv.shop.rewardOffer = { tier: 5, slots: shuffle(visibleChess(5)).slice(0, 3).map((c) => ({ kind: 'chess', id: c.chessId, price: 0, sold: false })) };
if (VARIANTS.has('temp') && !S.priv.temp.some(Boolean)) { S.priv.temp[0] = chessPiece(pick(S.pool)); S.priv.temp[1] = itemPiece(shopItems()[2]); }
+ applyGapVariants();
const pub = S.pub;
pub.phase = phase;
store.set({ match: emptyMatch(), ticker: [], emotes: [] });
@@ -700,7 +733,7 @@ const SWITCH = [
['INFO_CHECK', PHASE.INFO_CHECK, ''], ['BAND_DRAFT', PHASE.BAND_DRAFT, ''], ['BAND_DRAFT solo', PHASE.BAND_DRAFT, 'solo'],
['BATTLE_CHECK', PHASE.BATTLE_CHECK, ''], ['PREP', PHASE.PREP, ''], ['PREP + reward', PHASE.PREP, 'reward'],
['PREP + temp', PHASE.PREP, 'temp'], ['PREP frozen', PHASE.PREP, 'frozen'], ['PREP dead', PHASE.PREP, 'dead'],
- ['PREP boss (L)', PHASE.PREP, 'boss'], ['PREP boss (R)', PHASE.PREP, 'bossR'],
+ ['PREP boss (L)', PHASE.PREP, 'boss'], ['PREP boss (R)', PHASE.PREP, 'bossR'], ['PREP 标准 同构体 + 调和', PHASE.PREP, 'funny,morph,harmony'],
['SP bounty', PHASE.SP_DRAFT, 'bounty'], ['SP supply', PHASE.SP_DRAFT, 'supply'], ['SP shop', PHASE.SP_DRAFT, 'shop'], ['SP tactic', PHASE.SP_DRAFT, 'tactic'], ['SP solo', PHASE.SP_DRAFT, 'solo'],
['COMBAT', PHASE.COMBAT, ''], ['COMBAT done', PHASE.COMBAT, 'done'], ['UNITE', PHASE.UNITE, ''], ['UNITE leaker', PHASE.UNITE, 'leaker'], ['SETTLE', PHASE.SETTLE, ''],
['FINAL_ASSAULT', PHASE.FINAL_ASSAULT, ''], ['FA overtime soon', PHASE.FINAL_ASSAULT, 'overtime'], ['FA draining', PHASE.FINAL_ASSAULT, 'drain'],
diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js
index 8144255a..2f9c7fb6 100644
--- a/public/js/ui/bondStrip.js
+++ b/public/js/ui/bondStrip.js
@@ -3,8 +3,11 @@
// 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).
+// 本局禁用 — gameLogic modeOffBonds). A core bond whose count holds 调和's +1 (the view entry's `harmony`, sent by the
+// server — server/match/bondsMeta.js; never re-derived here) says so: 在场 n(含调和 +1), and a 调和 row heads the member
+// list naming the 调和 operators on that board (gameLogic harmonyMembers: 缪尔赛思 …; a tap opens the first one's card) —
+// the +1 used to read as a miscount (GitHub issue #1, DESIGN §21.26). 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:
@@ -13,7 +16,7 @@
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
-import { sortBonds, bondMembers, nextThreshold, bondTier } from './gameLogic.js';
+import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND } from './gameLogic.js';
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -46,11 +49,12 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
const rec = data.lookup('bonds', b.bondId);
const th = Array.isArray(b.thresholds) && b.thresholds.length ? b.thresholds : rec?.thresholds || [];
const next = nextThreshold(b.count ?? 0, th);
- return html`
+ return html`
0 ? b.harmony : null}
+ class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0}
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
- title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}`} />
+ title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}${b.harmony > 0 ? `(含调和 +${b.harmony})` : ''}`} />
${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}
`;
})}
@@ -68,7 +72,8 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
* 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)
+ * note, no 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays).
+ * `entry.harmony` (the server's bond views: the +1 调和 added to `count`): 在场 n(含调和 +1) and the 调和 row
*/
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);
@@ -78,11 +83,17 @@ 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), (id) => data.lookup('items', id));
+ const getChess = (id) => data.lookup('chess', id);
+ const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id));
const countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th);
const hasNow = !off && !!(b.effectDescRaw || b.effectDesc);
const at = place || (beside === 'left' ? 'beside' : 'left');
+ // 调和's +1 in this count (the server's word), and the 调和 operators on that board who give it
+ const harmony = !off && Number.isInteger(entry?.harmony) && entry.harmony > 0 ? entry.harmony : 0;
+ const harmonyBy = harmony ? harmonyMembers(priv, getChess) : [];
+ const harmonyName = data.lookup('bonds', HARMONY_BOND)?.name || '调和';
+ const harmonyText = `${harmonyBy.length ? `${harmonyBy.map((x) => x.name).join('、')} 在场` : `${harmonyName}已激活`}:核心盟约激活人数 +${harmony}`;
return html`
<${Icon} name="close" />
@@ -93,7 +104,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
${owner ? html`
<${GIcon} name="eye" />${owner} 的盟约 ` : null}
${b.name}
- ${off ? null : html`在场 ${count} ${next != null ? html`/${next} ` : null}${countsHand ? html`(含整备区) ` : null} `}
+ ${off ? null : html`在场 ${count} ${next != null ? html`/${next} ` : null}${countsHand ? html`(含整备区) ` : null}${harmony ? html`(含${harmonyName} +${harmony}) ` : null} `}
层数 ${layers}
${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}
@@ -113,6 +124,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
成员 ${members.filter((x) => x.onBoard).length}/${members.length}
+ ${harmony ? (harmonyBy.length
+ ? html` onMember?.(harmonyBy[0].id, null)}>
+ <${BondGlyph} bondId=${HARMONY_BOND} />${harmonyName} +${harmony} ${harmonyText} `
+ : html`
+ <${BondGlyph} bondId=${HARMONY_BOND} />${harmonyName} +${harmony} ${harmonyText}
`) : null}
${members.map((mb) => html`
onMember?.(mb.id, mb.granted && Array.isArray(mb.items) ? mb.items : null)} data-granted=${mb.granted ? '1' : null}
diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js
index b17410cf..1b70dd3f 100644
--- a/public/js/ui/detailPanel.js
+++ b/public/js/ui/detailPanel.js
@@ -10,7 +10,10 @@
// info, rich description, 已调配 when not the default), elite module (模组: official type icon from the local-client
// 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
+// effect; 变形同构体 (`canGiveBond`) also its 天赋栏 list (MorphPairings, gameLogic morphPairings: per bond the items that
+// make the wearer its member, 本局禁用 marked; on a wearer's card the pairing it wears highlighted, 生效中 — GitHub issue
+// #1, DESIGN §21.26) and a bond item (`giveBondId`) the line "与变形同构体一同装备时,携带者视为【X】成员" (MorphGrantLine);
+// 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
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
@@ -28,7 +31,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, pieceBondIds } from './gameLogic.js';
+import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } 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';
@@ -158,11 +161,58 @@ function Section({ title, micro, children, class: cls }) {
`;
}
-function ItemRow({ itemId }) {
+const isOffIn = (off, bondId) => !!(off && typeof off.has === 'function' && off.has(bondId));
+
+/**
+ * The 变形同构体's 天赋栏 (gameLogic morphPairings): "搭配以下装备时,携带者视为对应盟约的成员:", then one line per bond — 【bond】
+ * and the items that pair with it; a bond the mode never activates (`off`, gameLogic modeOffBonds) struck through with
+ * 本局禁用. `carried` (the wearer's items — the card shows the item on an operator): the pairing it wears is highlighted
+ * (生效中; 已搭配 when that bond is off), and a wearer without one reads 暂未生效.
+ * @param {{ off?: Set|null, carried?: Array|null }} props
+ */
+export function MorphPairings({ off = null, carried = null }) {
+ const rows = morphPairings(data.list('items'), data.list('bonds'), { off, carried });
+ if (!rows.length) return null;
+ const wearer = Array.isArray(carried);
+ return html`
+
搭配以下装备时,携带者视为对应盟约的成员:
+
+ ${rows.map((r) => html` it.name).join('、')} → 【${r.name}】${r.off ? '(本局禁用)' : ''}`}>
+ 【${r.name}】
+ ${r.items.map((it, i) => html`${i ? '、' : ''}${it.name} `)}${r.worn
+ ? html`${r.off ? '已搭配' : '生效中'} ` : null}${r.off ? html`本局禁用 ` : null}
+ `)}
+
+ ${wearer && !rows.some((r) => r.worn) ? html`
暂未生效:需与上表中的一件装备一同携带
` : null}
+
`;
+}
+
+/**
+ * A bond item's own line (items.json `giveBondId`): "与变形同构体一同装备时,携带者视为【X】成员" — 本局禁用 when the mode never
+ * activates X; on a wearer that also carries a 变形同构体 (`carried`) it is in effect (生效中).
+ * @param {{ item: any, off?: Set|null, carried?: Array|null }} props
+ */
+export function MorphGrantLine({ item, off = null, carried = null }) {
+ const bond = item && !item.canGiveBond && typeof item.giveBondId === 'string' ? data.lookup('bonds', item.giveBondId) : null;
+ if (!bond) return null;
+ const morph = data.list('items').find((r) => r && r.canGiveBond);
+ if (!morph) return null;
+ const isOff = isOffIn(off, item.giveBondId);
+ const worn = grantedBonds(carried, (id) => data.lookup('items', id)).includes(item.giveBondId);
+ return html`
+ <${Icon} name="info" />与${morph.name}一同装备时,携带者视为【${bond.name}】成员${isOff ? html`本局禁用 ` : null}${worn
+ ? html`${isOff ? '已搭配' : '生效中'} ` : null}
+
`;
+}
+
+/** An equipped item of the card: icon, name, effect — and for 变形同构体 / a bond item its pairing (`carried`: the wearer's items). */
+function ItemRow({ itemId, carried = null, off = null }) {
const it = data.lookup('items', itemId);
return html`
<${UnitThumb} kind="item" id=${itemId} size="sm" />
-
${it?.name || itemId} <${RichText} text=${it?.descRaw || it?.desc || ''} class="ditem__desc" />
+
${it?.name || itemId} <${RichText} text=${it?.descRaw || it?.desc || ''} class="ditem__desc" />
+ ${it?.canGiveBond ? html`<${MorphPairings} off=${off} carried=${carried || []} />` : it?.giveBondId ? html`<${MorphGrantLine} item=${it} off=${off} carried=${carried} />` : null}
`;
}
@@ -190,8 +240,10 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
const next = nextThreshold(count, th);
const cap = next ?? th[th.length - 1] ?? null;
const isoTag = iso.has(id) ? '(变形同构体)' : '';
+ // the count holds 调和's +1 (the server's bond entry says so, DESIGN §21.26)
+ const harmonyTag = !isOff && Number.isInteger(e?.harmony) && e.harmony > 0 ? `(含调和 +${e.harmony})` : '';
const label = isOff ? briefingBondTip(rec?.name || id, 'off')
- : `${rec?.name || id}${isoTag}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
+ : `${rec?.name || id}${isoTag}:在场 ${count}${cap != null ? `/${cap}` : ''}${harmonyTag}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
const body = isOff
? html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
@@ -355,12 +407,13 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
${!lo.defaultModule ? html`已调配 ` : null}
/>` : null;
+ // (a 变形同构体 / bond item row shows its pairing against what this operator carries: ItemRow `carried`)
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`拖拽装备至该干员以配发(最多 2 件)
`}
+ ${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} carried=${items} off=${offBonds} />`) : html`拖拽装备至该干员以配发(最多 2 件)
`}
/>`
// 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} />`)}
+ ${carried.map((id, i) => html`<${ItemRow} key=${`${i}:${id}`} itemId=${id} carried=${carried} off=${offBonds} />`)}
/>` : 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`${t.name} <${RichText} text=${t.descRaw || t.desc} class="dtext" />
`)}
@@ -375,11 +428,13 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
}
/**
- * An item's card (hand / temp / shop / equipped). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
+ * An item's card (hand / temp / shop / reward card). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
* 突变细胞 — user playtest #4 item 5) says it is never sold and where it comes from (`shopExcludedBy`); a rule the
* official text leaves out (items.json `note`: 突变细胞 returns to the hand after each use) is shown under the effect.
+ * 变形同构体 lists its pairings (its 天赋栏: MorphPairings, `offBonds` marks 本局禁用); a bond item says which bond it gives
+ * a 变形同构体 wearer (MorphGrantLine). (An equipped item is shown on its wearer's card: ChessDetail's 装备 rows.)
*/
-export function ItemDetail({ item, piece, editable, onDestroy }) {
+export function ItemDetail({ item, piece, editable, onDestroy, offBonds = null }) {
const m = data.get('assets');
return html`
@@ -392,6 +447,8 @@ export function ItemDetail({ item, piece, editable, onDestroy }) {
<${Section} title="效果" micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" />/>
+ ${item.canGiveBond ? html`<${Section} title="天赋" micro="TALENT" class="dsec--morph"><${MorphPairings} off=${offBonds} />/>` : null}
+ ${!item.canGiveBond && item.giveBondId ? html`<${MorphGrantLine} item=${item} off=${offBonds} />` : null}
${item.note ? html`
<${Icon} name="info" />${item.note}
` : null}
${item.itemType === 'MAGIC'
? html`
<${Icon} name="info" />将其拖拽至战场上的格子使用
`
@@ -564,7 +621,7 @@ export function resolveDetail(target, pieces) {
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
* bonds?: any[], offBonds?: Set
|null, loadout?: any, onBond?: (bondId:string)=>void, side?: 'left'|'right', shopOpen?: boolean }} props
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); offBonds: the bonds this mode never activates
- * (gameLogic modeOffBonds — their chips read 本局禁用); loadout: m.private.loadout (DESIGN §16) for
+ * (gameLogic modeOffBonds — their chips and the 变形同构体 pairing lines read 本局禁用); loadout: m.private.loadout (DESIGN §16) for
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
* = the defaults
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
@@ -594,7 +651,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
${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} 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} offBonds=${offBonds} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js
index 5bcc381e..b5f08f50 100644
--- a/public/js/ui/gameLogic.js
+++ b/public/js/ui/gameLogic.js
@@ -404,6 +404,78 @@ export function pieceBondIds(chess, items, getItem = () => null) {
return out;
}
+/**
+ * The 变形同构体 pairings — the list its 天赋栏 shows (the item text: "具体对应关系可在模拟中查看本装备天赋栏"; character_table
+ * trap_1073_acarm073 "“炎国短刀”→【炎】盟约 …", 14 bonds; GitHub issue #1: without it the item read as doing nothing).
+ * Built from the same `giveBondId` the server counts with (server/match/bondsMeta.js pieceBonds, grantedBonds here): every
+ * bond an item grants, in bonds.json order, with the items that grant it (one entry per item family — the normal and 进阶
+ * copies share a name —, by tier, then data order). `off`: the mode never activates the bond (modeOffBonds: 本局禁用 — 标准
+ * leaves 5 of the 14 off). `worn`: the wearer's items (`carried`, the card shows the 变形同构体 on an operator) pair it with
+ * one of the bond's items — the pairing in effect on that operator (that item `worn` too).
+ * @param {Iterable} itemRecs items.json records (data.list('items'))
+ * @param {Iterable} bondRecs bonds.json records in data order (data.list('bonds'))
+ * @param {{ off?: Set|null, carried?: Array|null }} [opts]
+ * @returns {Array<{ bondId: string, name: string, off: boolean, worn: boolean, items: Array<{ id: string, name: string, worn: boolean }> }>}
+ */
+export function morphPairings(itemRecs, bondRecs, { off = null, carried = null } = {}) {
+ const recs = [...(itemRecs || [])].filter(isObj);
+ const byId = new Map(recs.map((r) => [r.id, r]));
+ const getItem = (id) => byId.get(id) || null;
+ const worn = new Set(grantedBonds(carried, getItem));
+ const carriedIds = new Set((Array.isArray(carried) ? carried : []).map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string'));
+ const famOf = (r) => (typeof r.baseId === 'string' && r.baseId ? r.baseId : r.id);
+ /** bondId → family id → { id, name, tier, order, ids } */
+ const groups = new Map();
+ recs.forEach((r, order) => {
+ if (r.canGiveBond || typeof r.giveBondId !== 'string' || !r.giveBondId) return;
+ let g = groups.get(r.giveBondId);
+ if (!g) groups.set(r.giveBondId, (g = new Map()));
+ const fam = famOf(r);
+ const e = g.get(fam);
+ if (e) { e.ids.push(r.id); return; }
+ g.set(fam, { id: fam, name: typeof r.name === 'string' && r.name ? r.name : fam, tier: int(r.tier, 99), order, ids: [r.id] });
+ });
+ const out = [];
+ for (const b of bondRecs || []) {
+ const g = isObj(b) ? groups.get(b.bondId) : null;
+ if (!g) continue;
+ const bondWorn = worn.has(b.bondId);
+ const items = [...g.values()].sort((x, y) => (x.tier - y.tier) || (x.order - y.order))
+ .map((e) => ({ id: e.id, name: e.name, worn: bondWorn && e.ids.some((id) => carriedIds.has(id)) }));
+ out.push({ bondId: b.bondId, name: typeof b.name === 'string' && b.name ? b.name : b.bondId, off: !!(off && typeof off.has === 'function' && off.has(b.bondId)), worn: bondWorn, items });
+ }
+ return out;
+}
+
+/**
+ * Bond id of 调和 (bonds.json maniShip, "激活时使场上核心盟约的激活人数+1") — server/match/bondsMeta.js HARMONY_BOND; a test
+ * pins the two. Only to name the 调和 operators: whether a bond's count holds the +1 is the server's word (the view
+ * entry's `harmony`, DESIGN §21.26).
+ */
+export const HARMONY_BOND = 'maniShip';
+
+/**
+ * The 调和 operators on a board, distinct (normal and elite copies of one operator once): who activates the +1 a bond
+ * entry's `harmony` reports (the bond popup's 调和 row names them — 缪尔赛思, the Pith strategy's 盟约·辅助干员).
+ * @param {any} priv m.private — or a teammate's field operators (ui/watchBonds.js ownerBoard)
+ * @param {(id:string)=>any} [getChess]
+ * @returns {Array<{ id: string, name: string }>} base chess ids
+ */
+export function harmonyMembers(priv, getChess = () => null) {
+ const out = [];
+ const seen = new Set();
+ for (const p of Array.isArray(priv?.board) ? priv.board : []) {
+ if (p?.kind !== 'chess') continue;
+ const c = getChess(p.id);
+ if (!Array.isArray(c?.bonds) || !c.bonds.includes(HARMONY_BOND)) continue;
+ const base = c.baseId || (typeof p.id === 'string' ? p.id.replace(/_b$/, '_a') : p.id);
+ if (seen.has(base)) continue;
+ seen.add(base);
+ out.push({ id: base, name: getChess(base)?.name || c.name || base });
+ }
+ 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
diff --git a/public/js/ui/watchBonds.js b/public/js/ui/watchBonds.js
index 566d4675..69f955ad 100644
--- a/public/js/ui/watchBonds.js
+++ b/public/js/ui/watchBonds.js
@@ -102,7 +102,8 @@ export function playerLayer(field, pub, myId, playerId) {
/**
* A player's bond list as the views carry it: your own m.private bonds (thresholds / countsHand included), a teammate's
- * m.public players[].bonds ({ bondId, count, active, tier, layers }).
+ * m.public players[].bonds ({ bondId, count, active, tier, layers, harmony? } — `harmony`: 调和's +1 is in `count`, the
+ * popup's 调和 row; DESIGN §21.26).
* @returns {any[]}
*/
export function ownerBonds({ pub, priv = null, myId, ownerId }) {
diff --git a/server/match/bondsMeta.js b/server/match/bondsMeta.js
index 2c503cd8..c068c112 100644
--- a/server/match/bondsMeta.js
+++ b/server/match/bondsMeta.js
@@ -9,7 +9,10 @@
// an item that has a `giveBondId` (research 02 §2.1).
// Special rules:
// 调和 (maniShip): while active, every CORE bond that already has ≥ 1 real member on the board gets count +1
-// (one +1 total, research 02 §2.1 [ASSUMED]).
+// (one +1 total, research 02 §2.1 [ASSUMED]). Such a state carries `harmony: 1` — the views send it
+// (bondList: m.private bonds, m.public players[].bonds), so the client's bond popup can say where the
+// extra count comes from without re-deriving the rule (DESIGN §21.26); the battle input does not
+// (bondSnapshot).
// 助力 (deputShip): tier 1 needs `thresholds[0]` distinct operators; the upper tiers count operators that differ
// in name OR elite state (PRTS 修正).
// 独行 (soloShip, count_threshold_downward): active iff 1 ≤ count ≤ maxCount (distinct 独行 operators).
@@ -45,10 +48,10 @@ export function pieceBonds(gd, piece) {
}
/**
- * Compute every bond's state for a player.
+ * Compute every bond's state for a player. `harmony` (only present when it applies) = the +1 that 调和 added to `count`.
* @param {import('./gamedata.js').GameData} gd
* @param {{ board: Map, hand: Array, layers: Record, bondCountBonus?: Record }} ps
- * @returns {Record}
+ * @returns {Record}
*/
export function computeBonds(gd, ps) {
const boardChess = [];
@@ -71,7 +74,7 @@ export function computeBonds(gd, ps) {
}
const goldenOnBoard = boardChess.filter((p) => gd.isGolden(p.id)).length;
- /** @type {Record} */
+ /** @type {Record} */
const out = {};
const layersOf = (id) => {
const v = ps.layers && ps.layers[id];
@@ -101,8 +104,8 @@ export function computeBonds(gd, ps) {
const harmonyActive = harmony && raw[HARMONY_BOND] != null && tierFor(harmony, raw[HARMONY_BOND]) >= 1;
for (const id of Object.keys(raw)) {
const bond = gd.bond(id);
- let count = raw[id];
- if (harmonyActive && bond.isCore && (onBoard.get(id)?.size ?? 0) >= 1) count += 1;
+ const harmonyBonus = harmonyActive && bond.isCore && (onBoard.get(id)?.size ?? 0) >= 1 ? 1 : 0;
+ const count = raw[id] + harmonyBonus;
let tier = tierFor(bond, count);
if (id === DEPUTY_BOND && tier >= 1) {
// upper tiers: operators differing in name OR elite state
@@ -113,6 +116,7 @@ export function computeBonds(gd, ps) {
tier = t;
}
out[id] = { count, active: tier >= 1, tier, layers: layersOf(id) };
+ if (harmonyBonus) out[id].harmony = harmonyBonus;
}
return out;
}
@@ -145,6 +149,7 @@ export function activatedLayers(bonds) {
/**
* View list for m.private / m.public: bonds with members or layers, active first, then layers desc, then data order.
+ * An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise).
* @param {import('./gamedata.js').GameData} gd
* @param {ReturnType} bonds
* @param {{ full?: boolean }} [opts] full → include thresholds/countsHand (m.private)
@@ -155,6 +160,7 @@ export function bondList(gd, bonds, { full = false } = {}) {
for (const [bondId, b] of Object.entries(bonds || {})) {
if (!(b.count > 0 || b.layers > 0 || b.active)) continue;
const e = { bondId, count: b.count, active: b.active, tier: b.tier, layers: b.layers };
+ if (b.harmony > 0) e.harmony = b.harmony;
if (full) {
const bond = gd.bond(bondId);
e.thresholds = bond ? thresholdsOf(bond) : [];