From b4fa21a0a6cea2fdb994503b671ab3b1f5d22a22 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:31:35 +0800 Subject: [PATCH] =?UTF-8?q?feedback1b=20WG:=20=E5=A5=A5=E6=9C=AF=20re-meas?= =?UTF-8?q?ured;=20bonds=20the=20mode=20never=20activates=20read=20?= =?UTF-8?q?=E6=9C=AC=E5=B1=80=E7=A6=81=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player report after 0.1.0: "奥术盟约不生效". Measured on a real 险境 battle (Match._normalOpts) and the same battle from its BattleSpec (browser path): the bond works - member arts hits debuff the target x(1.2 + 0.01*L) for 3 s, later arts hits land multiplied, the 3-member tier gives x1.4 below 50 % HP, in-battle layer gains apply live, browser = server. What a player sees instead: 标准模拟 switches 奥术 off (official inactiveBondIdList, with 9 other bonds) while 深靛 洛洛 阿罗玛 夕 圣聆初雪 stay in the pool through their other bond; their cards showed 奥术 like any bond and the detail chip read "0/2 未激活" with three deployed. Shop / reward cards, the detail card's bond chips and the bond popup now mark such a bond 本局禁用 (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds). The PRTS 盟约记录 奥术 note (one instance per unit and target) is documented next to the one-effective-instance rule it fits (SIM.md, bond header). --- docs/SIM.md | 7 +- public/css/screens/game-panels.css | 6 ++ public/css/screens/game-shop.css | 1 + public/js/screens/game.js | 11 ++- public/js/ui/bondStrip.js | 17 ++-- public/js/ui/detailPanel.js | 44 +++++---- public/js/ui/gameLogic.js | 15 +++ public/js/ui/shopBar.js | 26 ++++-- server/sim/content/bonds/addon/battle.js | 7 +- test/sim/feedback1b-arcane.test.js | 86 +++++++++++++++++ test/ui/feedback1b-bonds.test.js | 114 +++++++++++++++++++++++ 11 files changed, 295 insertions(+), 39 deletions(-) create mode 100644 test/sim/feedback1b-arcane.test.js create mode 100644 test/ui/feedback1b-bonds.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..1e72ed5a 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -389,7 +389,12 @@ if it outlasts it, resumes when the strong one expires (pass `refresh` to opt ou catalogue status the same rule (one invisible buff `key` per target whatever applies it — no immunity, 抵抗, status hooks or icon): the engine default for two same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). 奥术 uses it, so the two players of a pair field compete for one instance instead of multiplying, and so do 灵知's 坚冰 and the -莱恩哈特 / 缄默德克萨斯 RES cuts, once keyed per unit (DESIGN §20.10). "Strongest" = the largest |value|. +莱恩哈特 / 缄默德克萨斯 RES cuts, once keyed per unit (DESIGN §20.10). "Strongest" = the largest |value|. PRTS 盟约记录's +奥术 note "※同一单位仅可对同一目标同时施加1个该盟约法术伤害提升效果" (2026-03-30 revision: "…持续期间内,无法对该目标施加新的…") +limits each unit to one instance per target; read with the engine default — a newer same-named buff waits inert until +the earlier ones end (PRTS 常见同名状态 "默认叠加方式") — and 巴哈姆特 12316 ("共享型buff會跟對面搶"), one instance is effective +at a time, which is what applyStrongest keeps; strongest rather than earliest, and a unit refreshing its own instance, +stay [ASSUMED] (feedback after 0.1.0). | key | effect | value | |---|---|---| diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index 394a7d5f..8cba87d8 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -182,6 +182,11 @@ .dbond__tiers { display: inline-flex; gap: 2px; margin-left: .02rem; } .dbond__tiers i { width: .05rem; height: .12rem; background: rgba(255, 255, 255, .16); transform: skewX(-18deg); } .dbond__tiers i.on { background: var(--mint-400); box-shadow: 0 0 .04rem rgba(78, 216, 175, .8); } +/* a bond the mode never activates (标准: 10 of 23 — gameLogic modeOffBonds) */ +.dbonds--top .dbond.is-off { border-style: dashed; border-color: rgba(255, 255, 255, .16); background: transparent; color: var(--text-dim); } +.dbond.is-off .dbond__name { text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); } +.dbond.is-off .dbond__icon img { filter: grayscale(1) brightness(.5); } +.dbond__off { font-size: .12rem; color: var(--text-dim); white-space: nowrap; } .dtag-loadout { display: inline-block; margin-left: .08rem; vertical-align: .02rem; font-size: .11rem; font-weight: 700; letter-spacing: .06em; padding: 0 .06rem; line-height: 1.6; background: var(--mint-500); color: #062019; } .dmodule { display: flex; align-items: center; flex-wrap: wrap; gap: .08rem; font-size: .15rem; } @@ -238,6 +243,7 @@ .bpop__facts { display: flex; gap: .16rem; font-size: .15rem; color: var(--text-lo); } .bpop__facts b { color: #fff; font-size: .18rem; } .bpop__facts small { font-size: .12rem; } +.bpop__off { margin: .08rem 0 0; font-size: .14rem; color: var(--text-lo); border-left: 2px solid rgba(255, 255, 255, .2); padding-left: .08rem; } .bpop__tiers { display: flex; gap: .06rem; } .bpop__tier { display: flex; align-items: baseline; gap: .02rem; padding: .02rem .12rem; border: 1px solid var(--line-2); color: var(--text-dim); font-size: .13rem; } .bpop__tier b { font-size: .2rem; } diff --git a/public/css/screens/game-shop.css b/public/css/screens/game-shop.css index 0f7c9454..6225a8b6 100644 --- a/public/css/screens/game-shop.css +++ b/public/css/screens/game-shop.css @@ -126,6 +126,7 @@ .scard__bonds { display: flex; flex-wrap: wrap; gap: .02rem .08rem; } .scard__bond { display: inline-flex; align-items: center; gap: .03rem; font-size: .13rem; color: var(--mint-400); } .scard__bond .bglyph { width: .18rem; height: .18rem; } +.scard__bond.is-off { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); } /* the mode never activates it (标准) */ .scard__class { display: flex; align-items: center; gap: .05rem; font-size: .13rem; color: var(--text-lo); } .scard__prof { width: .18rem; height: .18rem; object-fit: contain; opacity: .85; } .scard__mergetag { position: absolute; z-index: 3; right: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; background: var(--gold); color: #1d1400; letter-spacing: .08em; } diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 55c855a2..4041db14 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -87,7 +87,7 @@ import { phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent, snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, - previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, mergeTarget, + previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, mergeTarget, modeOffBonds, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; import { BriefingScreen } from './briefing.js'; @@ -98,7 +98,7 @@ import { store, useStore, shallowEqual, serverNow, emptyMatch } from '../store.j import { battleRunner } from '../battle/runner.js'; import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js'; import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js'; -import { data, localAsset } from '../data.js'; +import { data, localAsset, getMode } from '../data.js'; import { audio } from '../audio.js'; import { useDocClass, FullscreenButton } from '../ui/device.js'; @@ -1135,6 +1135,8 @@ function MatchScreen() { // cards read the strip's (ui/watchBonds.js detailBondOwner) const detailOwner = detailBondOwner(detailTarget, { pub, myId, stripOwnerId: strip.ownerId }); const detailBonds = detailOwner === strip.ownerId ? stripBonds : playerBonds({ pub, priv, myId, ownerId: detailOwner, live: liveLayers }); + // bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup + const offBonds = modeOffBonds(getMode(pub?.modeId)); return html`
@@ -1169,7 +1171,7 @@ function MatchScreen() {
` : null} ${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed} - barRef=${barRef} + barRef=${barRef} offBonds=${offBonds} onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()} onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })} onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))} @@ -1199,11 +1201,12 @@ function MatchScreen() { onEnemy=${(k, n) => setDetail({ kind: 'enemy', id: k, count: n })} onChess=${(id) => setDetail({ kind: 'chess', id })} />` : null} ${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} ${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }} - bonds=${detailBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} + bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null} ${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid} diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js index 8ce5628e..468cba5f 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -1,6 +1,7 @@ // Bond strip (active-bond discs under the top bar) and the bond detail popup: the key facts first (user playtest #2 // item 9: name, members in play / next threshold, layers, reached tier and its threshold row, the current effect with -// layer-resolved numbers), then the full description and the member list with owned / on-board state. Opened from +// 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). // 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 ("层数叠加已禁用"). @@ -61,16 +62,17 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null * @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function, onMember?: (chessId:string)=>void, * place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null }} 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) + * 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 */ -export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null, owner = null }) { +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); if (!b) return null; const count = entry?.count ?? 0; const layers = entry?.layers ?? 0; const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || []; - const tier = entry?.tier ?? bondTier(count, th, b.maxCount); - const active = entry ? !!entry.active : tier > 0; + const 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 countsHand = entry?.countsHand ?? b.countsHand; const next = nextThreshold(count, th); @@ -86,12 +88,13 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, ${owner ? html`<${GIcon} name="eye" />${owner} 的盟约` : null}

${b.name}

- 在场 ${count}${next != null ? html`/${next}` : null}${countsHand ? html`(含整备区)` : null} + ${off ? null : html`在场 ${count}${next != null ? html`/${next}` : null}${countsHand ? html`(含整备区)` : null}`} 层数 ${layers} - ${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : '未激活'} + ${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}
+ ${off ? html`

本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)

` : null}
${th.map((n, i) => html`${n}${b.maxCount != null ? '名及以下' : '名'}`)}
diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 9b2ae5de..b432fc17 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -1,7 +1,8 @@ // Detail panel (click / right-click a piece, shop card, bond member, battle unit or previewed enemy): // operators — portrait, name, tier, elite, class/subclass and, right under them in the header's right column (no // scrolling, user playtest #2 item 9), the unit's bonds (阵营 / 盟约: icon, name, member count / next threshold, -// reached tier, active state — tap one for its popup); right under the header the operator's own effect (特质 — +// 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 (特质 — // 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 @@ -21,7 +22,7 @@ import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js'; import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; -import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js'; +import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip } from './gameLogic.js'; import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js'; import { data } from '../data.js'; import { attackRangeGrid } from '../../../shared/loadoutRecord.js'; @@ -129,32 +130,42 @@ function ItemRow({ itemId }) { /** * The unit's bonds right under the header: icon, name, the player's member count / next threshold, reached tier. - * @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void }} props + * @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void, off?: Set|null }} props — off: the + * bonds the mode never activates (gameLogic modeOffBonds): 本局禁用 instead of the count */ -export function BondChips({ bondIds, bonds = [], onBond = null }) { +export function BondChips({ bondIds, bonds = [], onBond = null, off = null }) { const ids = Array.isArray(bondIds) ? bondIds.filter((b) => typeof b === 'string') : []; if (!ids.length) return null; const mine = new Map((Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string').map((b) => [b.bondId, b])); return html`
${ids.map((id) => { const rec = data.lookup('bonds', id); + // a bond this mode never activates (gameLogic modeOffBonds): 本局禁用, no count or tier pips + const isOff = !!(off && typeof off.has === 'function' && off.has(id)); const e = mine.get(id) || null; const th = Array.isArray(e?.thresholds) && e.thresholds.length ? e.thresholds : Array.isArray(rec?.thresholds) ? rec.thresholds : []; const count = Number.isFinite(e?.count) ? e.count : 0; - const tier = Number.isFinite(e?.tier) ? e.tier : bondTier(count, th, rec?.maxCount ?? null); - const active = e ? !!e.active : tier > 0; + const tier = isOff ? 0 : Number.isFinite(e?.tier) ? e.tier : bondTier(count, th, rec?.maxCount ?? null); + const active = !isOff && (e ? !!e.active : tier > 0); const next = nextThreshold(count, th); const cap = next ?? th[th.length - 1] ?? null; - const label = `${rec?.name || id}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`; - const body = html` + const label = isOff ? briefingBondTip(rec?.name || id, 'off') + : `${rec?.name || id}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`; + const body = isOff + ? html` + <${BondGlyph} bondId=${id} class="dbond__icon" /> + ${rec?.name || id} + 本局禁用` + : html` <${BondGlyph} bondId=${id} class="dbond__icon" /> ${rec?.name || id} 0 && 'is-max')}>${count}${cap != null ? html`/${cap}` : null} ${th.length ? html`` : null}`; + const cls = cx('dbond', active && 'is-active', isOff && 'is-off', rec?.isCore && 'is-core'); return onBond - ? html`` - : html`${body}`; + : html`${body}`; })}
`; } @@ -204,7 +215,7 @@ function GarrisonBlock({ garrison, m }) { `; } -export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null, hint = null }) { +export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null }) { const m = data.get('assets'); const hp = hpOf(live, snapHp); const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id)); @@ -244,7 +255,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond ${c.position === 'MELEE' ? '近战位' : '远程位'} ${hp ? html`
${fmtNum(hp.hp)} / ${fmtNum(hp.max)}
` : null} - <${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} /> + <${BondChips} bondIds=${c.bonds} bonds=${bonds} off=${offBonds} onBond=${onBond} /> `; blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null; @@ -491,14 +502,15 @@ export function resolveDetail(target, pieces) { /** * The panel. * @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void, - * bonds?: any[], 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); loadout: m.private.loadout (DESIGN §16) for + * 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 * 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× * a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers */ -export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false, live = null }) { +export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null }) { const getter = typeof live === 'function' ? live : null; useTicker(detail && getter ? 250 : 0); if (!detail) return null; @@ -521,7 +533,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} 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} />` : null} ${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null} ${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null} ${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null} diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index e520febc..9c87405e 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -439,6 +439,21 @@ export function briefingBondTip(name, state, bannedN = 0) { return name; } +/** + * The bonds a mode never activates (config.json modes[modeId].inactiveBondIds = the official modeDataDict + * inactiveBondIdList: 标准模拟 leaves 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 off). Operators that also carry an + * enabled bond stay in the pool (server pool.js drawDisabledBonds) — 标准's 深靛 洛洛 阿罗玛 夕 圣聆初雪 still show 奥术 — and + * the server leaves such a bond out of m.private.bonds, so without a mark a card read "奥术 0/2 未激活" with three 奥术 + * operators deployed (player report after 0.1.0: "奥术盟约不生效"). The shop / reward cards, the detail card's bond chips + * and the bond popup mark these 本局禁用 (briefingBondTip 'off'). + * @param {any} mode config.json modes[modeId] (data.js getMode), or null + * @returns {Set} + */ +export function modeOffBonds(mode) { + const list = isObj(mode) && Array.isArray(mode.inactiveBondIds) ? mode.inactiveBondIds : []; + return new Set(list.filter((b) => typeof b === 'string')); +} + // ---- shop --------------------------------------------------------------------------------------------- /** diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index 4f416ae5..e2eaebc0 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -1,5 +1,6 @@ // Bottom shop bar (research 06 §11.2 / D3): LEVEL card (upgrade price hex, 升级), 3–5 operator cards -// (tier chip, price hex with discount/markup colours, portrait, bonds, class, frozen overlay, sold state, +// (tier chip, price hex with discount/markup colours, portrait, bonds — a bond the mode never activates struck through, +// 本局禁用 — class, frozen overlay, sold state, // merge progress), the item card, the funds card with ✕ 收起; above it 剩余可放置角色, 冻结/解冻 and 刷新. // Buying and upgrading take two taps (research 09 §5 / §6.5, official `EventOnFirstClick` → `EventOnConfirm` / // `EventOnUpgrade`): the first tap selects a card — it lifts and enlarges, its detail opens and it shows 确认购买 @@ -19,7 +20,7 @@ import { useEffect, useState } from '../../vendor/hooks.module.js'; import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js'; import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js'; -import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout } from './gameLogic.js'; +import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, briefingBondTip } from './gameLogic.js'; import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js'; import { data } from '../data.js'; @@ -58,7 +59,7 @@ function ArmedTag({ reason, free }) { * @param {{ slot:any, idx:number, priv:any, frozen?:boolean, reason?:string|null, free?:boolean, armed?:boolean, * onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props */ -export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail }) { +export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail, offBonds = null }) { const c = data.lookup('chess', slot.id); const m = data.get('assets'); const tier = c?.tier ?? 1; @@ -86,7 +87,11 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free ${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} />` : null} ${c?.name || slot.id} - ${bonds.slice(0, 3).map((b) => html`<${BondGlyph} bondId=${b} />${data.lookup('bonds', b)?.name || b}`)} + ${bonds.slice(0, 3).map((b) => { + const name = data.lookup('bonds', b)?.name || b; + const off = !!(offBonds && offBonds.has(b)); // a bond this mode never activates (gameLogic modeOffBonds) + return html`<${BondGlyph} bondId=${b} />${name}`; + })} <${Img} src=${profIconUrl(m, c?.profession)} class="scard__prof" /> @@ -213,7 +218,7 @@ export function useTwoTap({ editable, keys }) { * Promotion reward (research 00 §3): after a merge the operator cards of the bar are replaced by 3 free operators of * tier min(level+1, 6); pick 1 (g.reward idx) or put it off (稍后选择 → the normal shop + a reminder pill). */ -function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) { +function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap, offBonds = null }) { return html`
<${Icon} name="crown" class="rwtag__icon" /> @@ -224,7 +229,7 @@ function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed,
${offer.slots.map((s, i) => (s && !s.sold - ? html`<${ChessCard} key=${`rw${i}:${s.id}`} slot=${{ ...s, price: 0 }} idx=${i} priv=${priv} free=${true} + ? html`<${ChessCard} key=${`rw${i}:${s.id}`} slot=${{ ...s, price: 0 }} idx=${i} priv=${priv} free=${true} offBonds=${offBonds} armed=${armed === armKey('r', i, s)} onTap=${(idx) => onTap('r', idx, s, 'chess', shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS }), onPick)} reason=${shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS })} onBuy=${onPick} onDetail=${onDetail} />` : html`
已选择
`))} @@ -237,10 +242,11 @@ function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, * @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void, * onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void, * onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any, - * reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function }} props + * reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set|null }} props — offBonds: + * the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards */ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef, - reward = null, onReward, onRewardLater, onArm = null }) { + reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) { const shop = priv?.shop || {}; const slots = Array.isArray(shop.slots) ? shop.slots : []; const chessSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => !s || s.kind !== 'item'); @@ -308,12 +314,12 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === 'lv'} onTap=${tapLevel} /> ${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater} - armed=${armed} onTap=${tapCard} />` + armed=${armed} onTap=${tapCard} offBonds=${offBonds} />` : html`
${chessSlots.map(({ s, i }) => { if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); - return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} + return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds} reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`; })}
`} diff --git a/server/sim/content/bonds/addon/battle.js b/server/sim/content/bonds/addon/battle.js index c2b27ea9..f7a94291 100644 --- a/server/sim/content/bonds/addon/battle.js +++ b/server/sim/content/bonds/addon/battle.js @@ -16,7 +16,12 @@ // two players of a pair field compete for it, the strongest wins (battle.applyStrongest, 同名效果取最高: // PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 first-hand: "共享型buff會跟對面搶 // 如果對面層數比你高就不需要再特別激活直接吃他的奧術buff"). v2.5 kept one per player, so two players' -// instances multiplied (×5.4 × ×5.6 on a leader at ~250 layers, DESIGN §20.10) +// instances multiplied (×5.4 × ×5.6 on a leader at ~250 layers, DESIGN §20.10). PRTS 盟约记录's own +// note "※同一单位仅可对同一目标同时施加1个该盟约法术伤害提升效果" (one instance per unit and target) +// fits it under the engine default (a newer same-named buff waits until the earlier ends, PRTS +// 常见同名状态); strongest vs earliest and a unit refreshing its own stay [ASSUMED]. Re-measured after +// the 0.1.0 report "奥术盟约不生效": it works on the real and the browser path +// (test/sim/feedback1b-arcane.test.js); 标准 never activates it (modeDataDict inactiveBondIdList) // 坚守 steadShip all operators max HP +(base + per·L); tier 2: 40 % of a non-member operator's damage is borne by // the members on the field (split evenly, sourceless true damage — already mitigated), members’ thorns // (base + per·L arts, sourceless but credited to the member hit, ≤ 1 per cd_duration per member) diff --git a/test/sim/feedback1b-arcane.test.js b/test/sim/feedback1b-arcane.test.js new file mode 100644 index 00000000..1296d9af --- /dev/null +++ b/test/sim/feedback1b-arcane.test.js @@ -0,0 +1,86 @@ +// Player report after the public 0.1.0 release (relayed, untested by the user): "奥术盟约不生效". Measured on the real +// product path — a 险境 match's own normal battle (Match._normalOpts: the player's board, bonds, loadouts, the round's +// wave) and the same battle built from its BattleSpec as the browser does (spec.js createBattleFromSpec) — the bond +// works: every member's arts damage puts the 奥术 debuff on its target (×(1.2 + 0.01·L) arts damage taken for 3 s, one +// instance per target, DESIGN §5.3 / §20.10), later arts hits land multiplied, the 3-member tier raises it ×1.4 on +// targets below 50 % HP, and the browser battle is identical. (What a player could see instead — 标准 never activates +// 奥术 — is test/ui/feedback1b-bonds.test.js.) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch, give, legalTileFor } from '../match/harness.js'; +import { tileKey } from '../../server/match/board.js'; +import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js'; +import { bondBb } from '../../server/sim/content/bonds/addon/battle.js'; + +const BB = bondBb('arcaneShip'); +const MEMBERS = ['chess_char_1_17_a', 'chess_char_2_10_a', 'chess_char_3_09_a']; // 深靛, 洛洛, 海霓 + +function setup(ids, seed = 7, round = 5) { + const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed }).start(); + h.toPrep(1); + h.ps('p_0').lp = 9999; + h.toPrep(round); + const m = h.m, ps = h.ps('p_0'); + for (const p of [...ps.board.values()]) if (p.kind === 'chess') ps.returnCopies(p); + ps.board.clear(); + const used = new Set(); + for (const id of ids) { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); give(m, ps, id, 'board', t); } + ps.recompute(); + return { m, ps }; +} + +/** Run a battle; per arts hit of an operator: the 奥术 multiplier on the target when it landed (0 = none). */ +function run(b, pid) { + const hits = []; + b.on('hit', (c) => { + if (!c.dmg || c.dmg.type !== 'arts' || !c.source || c.source.kind !== 'op') return; + const x = c.target.buffs.find((y) => y.key === 'bond:arcaneShip'); + c.dmg.arcane = x ? x.data.value : 0; + c.dmg.taken = c.target.s.artsTakenMul; + }); + b.on('damaged', (c) => { if (c.type === 'arts' && c.source && c.source.kind === 'op') hits.push({ mul: c.dmg.arcane, taken: c.dmg.taken, amount: c.amount }); }); + for (let n = 0; !b.finished && n < 30 * 400; n++) b.step(); + const pp = b.result().perPlayer[pid]; + return { hits, killed: pp.killed, dealt: Math.round(pp.damageDealt) }; +} + +test('奥术 on a real 险境 battle: two members debuff their targets and later arts hits land ×(1.2 + 0.01·L); the browser battle is identical', () => { + const { m, ps } = setup(MEMBERS.slice(0, 2)); + assert.deepEqual(ps.bonds.arcaneShip, { count: 2, active: true, tier: 1, layers: 0 }); + for (const L of [0, 50]) { + const opts = m._normalOpts(ps); + opts.players[0].bonds.arcaneShip.layers = L; + const srv = run(m.newBattle(opts), ps.playerId); + const want = BB.base_damage_scale + BB.damage_scale_per_stack * L; + const boosted = srv.hits.filter((x) => x.mul > 0); + assert.ok(srv.hits.length >= 30 && boosted.length >= srv.hits.length * 0.6, `L${L}: ${boosted.length}/${srv.hits.length} arts hits on a debuffed target`); + for (const x of boosted) { + assert.ok(Math.abs(x.mul - want) < 1e-9, `L${L}: debuff ×${x.mul}, want ×${want}`); + assert.ok(x.taken >= want - 1e-9, `L${L}: the target took arts ×${x.taken}`); + } + // the browser path: the same spec, the same battle + const spec = buildBattleSpec({ ...opts, stageId: m.stageId, battleId: 't', boss: null }); + const cli = run(createBattleFromSpec(spec, m.ds), ps.playerId); + assert.deepEqual([cli.killed, cli.dealt, cli.hits.length], [srv.killed, srv.dealt, srv.hits.length], `L${L}: browser = server`); + // without the bond the same battle has no debuffed hit + const offOpts = m._normalOpts(ps); + offOpts.players[0].bonds.arcaneShip = { ...offOpts.players[0].bonds.arcaneShip, active: false, tier: 0 }; + assert.equal(run(m.newBattle(offOpts), ps.playerId).hits.filter((x) => x.mul > 0).length, 0); + } + m.dispose(); +}); + +test('奥术 with 3 different members: targets below 50 % HP take ×1.4·(1.2 + 0.01·L) (+68 % at 0 layers); 海霓 adds layers live', () => { + const { m, ps } = setup(MEMBERS); + assert.equal(ps.bonds.arcaneShip.tier, 2); + const res = run(m.newBattle(m._normalOpts(ps)), ps.playerId); + const base = (L) => BB.base_damage_scale + BB.damage_scale_per_stack * L; + const seen = res.hits.filter((x) => x.mul > 0).map((x) => x.mul); + // L = 0 at the start; 海霓 "<战斗中>首次击倒敌人…【奥术】层数+3" raises it during the fight (bonds/addon live layers) + const match = (v) => [0, 3].flatMap((L) => [['base', L, base(L)], ['low', L, BB.power_weak_scale * base(L)]]).find(([, , w]) => Math.abs(v - w) < 1e-9); + for (const v of seen) assert.ok(match(v), `unexpected multiplier ×${v}`); + const kinds = new Set(seen.map((v) => match(v).slice(0, 2).join(':'))); + for (const k of ['base:0', 'low:0', 'base:3', 'low:3']) assert.ok(kinds.has(k), `${k} seen (${[...kinds]})`); + assert.ok(Math.abs(BB.power_weak_scale * base(0) - 1 - BB.base_damage_scale_show_ex) < 1e-9, 'the shown +68 %'); + m.dispose(); +}); diff --git a/test/ui/feedback1b-bonds.test.js b/test/ui/feedback1b-bonds.test.js new file mode 100644 index 00000000..9f05b9d9 --- /dev/null +++ b/test/ui/feedback1b-bonds.test.js @@ -0,0 +1,114 @@ +// Player report after the public 0.1.0 release (relayed, untested by the user): "奥术盟约不生效". +// The bond works in battle (test/sim/feedback1b-arcane.test.js). What a player can see instead: 标准模拟 (FUNNY) is one +// of the modes whose official modeDataDict inactiveBondIdList switches 奥术 off (with 拉特兰 阿戈尔 卡西米尔 灵巧 奇迹 投资人 +// 突袭 独行 绝技), yet 深靛 洛洛 阿罗玛 夕 圣聆初雪 stay in the pool through their other bond (server pool.js) and their cards +// showed 奥术 like any bond: the detail card's chip read "奥术 0/2 未激活" with three 奥术 operators deployed (the server +// leaves an inactive bond out of m.private.bonds) and the bond popup "在场 0/2 未激活". The cards, chips and popup now say +// 本局禁用 for a bond the mode never activates (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (p) => readFileSync(path.join(ROOT, p), 'utf8'); + +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 { modeOffBonds } = await import('../../public/js/ui/gameLogic.js'); +const { BondChips } = await import('../../public/js/ui/detailPanel.js'); +const { ChessCard } = await import('../../public/js/ui/shopBar.js'); +const { BondPopup } = await import('../../public/js/ui/bondStrip.js'); +const { data, getMode } = await import('../../public/js/data.js'); +const { DATA, makeMatch, give, legalTileFor } = await import('../match/harness.js'); +const { tileKey } = await import('../../server/match/board.js'); + +await data.loadAll('chess', 'bonds', 'assets', 'config'); + +function* walk(v) { + if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; } + if (!v || typeof v !== 'object') return; + yield v; + if (typeof v.type === 'function' && !v.props?.children) return; + yield* walk(v.props?.children); +} +const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c); +const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join(''); +const chipOf = (vnode, bondId) => [...walk(vnode)].find((v) => v.props?.['data-bond'] === bondId); + +const OFF_FUNNY = ['lateranoShip', 'egirShip', 'kazimierzShip', 'skillfulShip', 'arcaneShip', 'miraShip', 'investShip', 'raidShip', 'soloShip', 'suntShip']; + +test('modeOffBonds: 标准 (single and multi) switches off the 10 official inactive bonds, 奥术 included; 险境+ none', () => { + for (const id of ['mode_single_funny', 'mode_multi_funny']) assert.deepEqual([...modeOffBonds(getMode(id))].sort(), [...OFF_FUNNY].sort(), id); + for (const id of ['mode_single_normal', 'mode_multi_hard', 'mode_single_abyss']) assert.equal(modeOffBonds(getMode(id)).size, 0, id); + assert.equal(modeOffBonds(null).size, 0); +}); + +test('a real 标准 match: 奥术 operators are buyable, three on the board never activate the bond — and the card chips say so', () => { + const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 3 }).start(); + h.toPrep(1); + const m = h.m, ps = h.ps('p_0'); + const members = DATA.bonds.arcaneShip.visibleMembers.filter((id) => m.pool.has(id) && !m.bannedChess.includes(id)); + assert.deepEqual(members.map((id) => DATA.chess[id].name), ['深靛', '洛洛', '阿罗玛', '夕', '圣聆初雪'], 'members kept in the pool by their other bond'); + ps.board.clear(); + const used = new Set(); + for (const id of members.slice(0, 3)) { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); give(m, ps, id, 'board', t); } + ps.recompute(); + assert.equal(ps.bonds.arcaneShip, undefined, 'official: the mode never activates 奥术'); + const priv = ps.privateView(); + assert.equal(priv.bonds.find((b) => b.bondId === 'arcaneShip'), undefined, 'no 奥术 entry in m.private.bonds'); + const off = modeOffBonds(getMode(m.modeId)); + // before: the chip computed 0 members from the missing entry — "奥术:在场 0/2,未激活" with 3 deployed + const before = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds }), 'arcaneShip'); + assert.match(before.props.title, /在场 0\/2,未激活/); + const chip = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds, off }), 'arcaneShip'); + assert.ok(hasClass(chip, 'is-off') && !hasClass(chip, 'is-active')); + assert.equal(chip.props.title, '奥术:本局禁用(该盟约不会激活)'); + assert.match(textOf(chip), /本局禁用/); + assert.ok(![...walk(chip)].some((v) => hasClass(v, 'dbond__count')), 'no member count'); + // the operator's enabled bond (精准) keeps its normal chip + const preci = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds, off }), 'preciShip'); + assert.ok(!hasClass(preci, 'is-off')); + m.dispose(); +}); + +test('shop / reward card: a mode-disabled bond tag is struck through with the 本局禁用 title; others unchanged', () => { + const id = 'chess_char_1_17_a'; // 深靛: 奥术 / 精准 + const slot = { kind: 'chess', id, price: 1, basePrice: 1 }; + const tags = (offBonds) => [...walk(ChessCard({ slot, idx: 0, priv: { funds: 9, hand: [], board: [], temp: [] }, onBuy() {}, onDetail() {}, offBonds }))].filter((v) => hasClass(v, 'scard__bond')); + const on = tags(modeOffBonds(getMode('mode_single_funny'))); + assert.equal(on.length, 2); + assert.ok(hasClass(on[0], 'is-off'), '奥术 struck'); + assert.equal(on[0].props.title, '奥术:本局禁用(该盟约不会激活)'); + assert.ok(!hasClass(on[1], 'is-off'), '精准 enabled'); + assert.ok(tags(modeOffBonds(getMode('mode_single_normal'))).every((v) => !hasClass(v, 'is-off')), '险境: nothing off'); + assert.ok(tags(null).every((v) => !hasClass(v, 'is-off')), 'no set: as before'); +}); + +test('bond popup: 本局禁用 instead of 未激活, a note, and no misleading member count', () => { + const pop = BondPopup({ bondId: 'arcaneShip', entry: null, priv: { board: [], hand: [] }, onClose() {}, off: true }); + const text = textOf(pop); + assert.match(text, /本局禁用/); + assert.match(text, /本模式下该盟约不会激活/); + assert.doesNotMatch(text, /在场/); + const plain = textOf(BondPopup({ bondId: 'arcaneShip', entry: null, priv: { board: [], hand: [] }, onClose() {} })); + assert.match(plain, /未激活/); + assert.match(plain, /在场/); +}); + +test('the game screen hands the mode-disabled set to the shop bar, the detail card and the bond popup', () => { + const src = read('public/js/screens/game.js'); + assert.match(src, /const offBonds = modeOffBonds\(getMode\(pub\?\.modeId\)\)/); + assert.match(src, /<\$\{ShopBar\}[\s\S]*?offBonds=\$\{offBonds\}/); + assert.match(src, /<\$\{DetailPanel\}[\s\S]*?offBonds=\$\{offBonds\}/); + assert.match(src, /<\$\{BondPopup\}[\s\S]*?off=\$\{offBonds\.has\(bondPop\.bondId\)\}/); +});