From 016fdb3804fd9e6ac1f699260978f6dfff463aaa Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 15:07:14 +0800 Subject: [PATCH] =?UTF-8?q?feedback1=20gaps:=20=E5=8F=98=E5=BD=A2=E5=90=8C?= =?UTF-8?q?=E6=9E=84=E4=BD=93=20lists=20its=20pairings;=20the=20bond=20pop?= =?UTF-8?q?up=20explains=20=E8=B0=83=E5=92=8C's=20+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 变形同构体 item card gets its 天赋 list (gameLogic morphPairings, from the same giveBondId the server counts with): one line per bond with the items that pair with it, 本局禁用 marked, and on a wearer's card the pairing it wears highlighted. A bond item's card says which bond it gives a 变形同构体 wearer. computeBonds marks a core bond state whose count holds 调和's +1 (harmony: 1) and both bond views send it. The popup reads it: 在场 n (含调和 +1) and a 调和 row naming the 调和 operators on that board, for the own strip and a teammate's; the strip and chip titles say it too. The mock harness gets the funny / morph / harmony variants. --- public/css/screens/game-panels.css | 32 ++++++++++++ public/dev/game-mock.html | 1 + public/dev/game-mock.js | 41 ++++++++++++++-- public/js/ui/bondStrip.js | 32 +++++++++--- public/js/ui/detailPanel.js | 79 +++++++++++++++++++++++++----- public/js/ui/gameLogic.js | 72 +++++++++++++++++++++++++++ public/js/ui/watchBonds.js | 3 +- server/match/bondsMeta.js | 18 ++++--- 8 files changed, 248 insertions(+), 30 deletions(-) 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` ` : 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) : [];