From b4fa21a0a6cea2fdb994503b671ab3b1f5d22a22 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:31:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feedback1b=20WG:=20=E5=A5=A5=E6=9C=AF=20re-?= =?UTF-8?q?measured;=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\)\}/); +}); From 8fa7a85a5208feac3e004c3b3e3605727d7c3578 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:31:44 +0800 Subject: [PATCH 2/3] feedback1b WG: item merges re-checked; returned equipment auto-merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player report after 0.1.0: "源石溶剂两个白的合成一个金色的之后又多给一个白的". Not reproduced: buying next to a white (hand / equipped), buying twice, equip on another or a full carrier, reward pick, 道具补给, 列装, 整备, 画卷 and the sale of a carrier all end with one golden and no white; random intents over all 50 mergeable items conserve the white-equivalents and never leave a pair. The one gap: equipment a promotion (PlayerState._mergeChess) or an effect that removes an operator (ctx.destroyPiece) returns to the hand skipped the auto-merge. Unreachable from a normal state (a pair never exists), closed: both now run checkItemMerges. --- docs/META.md | 4 +- server/match/PlayerState.js | 5 +- server/match/effectsMeta.js | 1 + test/match/feedback1b-items.test.js | 177 ++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 test/match/feedback1b-items.test.js diff --git a/docs/META.md b/docs/META.md index 62ae4972..4d0b0dc5 100644 --- a/docs/META.md +++ b/docs/META.md @@ -331,7 +331,9 @@ Other equipment: 2 slots; a third replaces the equipped item the player picks in equipped on the target ⇒ `BAD_TARGET`, nothing changes) — and the replaced item is destroyed. Equipped items are otherwise locked (research 04 §2 / addendum: they leave the operator only on promotion, merge or sale): `g.destroy` refuses them (`BAD_TARGET 'equipped items are locked'`). A second copy of an equipped normal item merges into the golden -item in the hand. +item in the hand. Every path that hands an item to the player (buy, reward, 机变, grants, equip, the equipment a sale, +a promotion or `ctx.destroyPiece` returns) ends with the auto-merge (`acquireItem` / `checkItemMerges`: "已拥有2件同一初始 +装备时…自动合并"), so a player never holds two identical mergeable normal items (`test/match/feedback1b-items.test.js`). Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds), 紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶 diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index 6a6a433e..73b9d0b8 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -477,7 +477,8 @@ export class PlayerState { * grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in temp * through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区"; * overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is - * destroyed with a log warning, as before the official rule); their summons are removed, and an elite on the board + * destroyed with a log warning, as before the official rule) and an identical normal pair among it merges like any gain + * (checkItemMerges); their summons are removed, and an elite on the board * gets its own summon stack (grantTokensFor: its loadout, like any deployment). Returns the elite piece (or null if * the elite could not be stored). * @param {string} baseId @@ -515,6 +516,8 @@ export class PlayerState { if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; } this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`); } + // the returned equipment follows the auto-merge rule like any other gain ("已拥有2件同一初始装备时…自动合并") + this.checkItemMerges(); // deployed like any operator placed by hand: its manually deployable summons join the hand (after the returned // equipment, which would be lost in temp — a summon stack removed there comes back at the next round start) if (where === 'board') this.grantTokensFor(elite); diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index 14ab7369..3a87af63 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -506,6 +506,7 @@ export function makeCtx(m, ps, source, hook, ev = null) { for (const it of l.piece.items || []) ps.stow(it, { allowTemp: true }); l.piece.items = []; ps.returnCopies(l.piece); + ps.checkItemMerges(); // the returned equipment auto-merges like any gain } ps.recompute(); return true; diff --git a/test/match/feedback1b-items.test.js b/test/match/feedback1b-items.test.js new file mode 100644 index 00000000..8ffc2541 --- /dev/null +++ b/test/match/feedback1b-items.test.js @@ -0,0 +1,177 @@ +// Player report after the public 0.1.0 release (relayed, untested by the user): "源石溶剂两个白的合成一个金色的之后又多给一个白的" +// — after two white 源石溶剂 (chess_item_1_05_e_a) merged into the golden one (_b), an extra white appeared. +// Official rule (PRTS 卫戍协议/帮助 §装备; activity_table act2autochess trapChessDataDict upgradeNum 2 → _b): "已拥有2件同一初始 +// 装备时,该装备将自动合并为1件进阶品质的同名装备,发送至手牌区"; equipment is removed "在失去该干员(干员出售、销毁、合并等)或 +// 装备合并为进阶品质时". Every real PlayerState path that hands the player a second white is replayed here and must end +// with exactly one golden and no white (none duplicates — the report did not reproduce); random intents conserve the +// white-equivalents W + 2·G. The one gap found: equipment a promotion (or an effect that removes an operator) returns +// to the hand skipped the auto-merge — unreachable from a normal state (a pair never exists), closed anyway. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DATA, makeMatch, give, giveItem, legalTileFor, checkInvariants } from './harness.js'; +import { tileKey } from '../../server/match/board.js'; +import { applyCard } from '../../server/match/choices.js'; + +const WHITE = 'chess_item_1_05_e_a'; +const GOLD = 'chess_item_1_05_e_b'; +const OP = 'chess_char_1_17_a'; + +function prep(seed = 5) { + const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed, fake: true }).start(); + h.toPrep(1); + const m = h.m, ps = h.ps('p_0'); + for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p); + ps.board.clear(); + ps.hand.fill(null); + ps.offers.length = 0; + ps.funds = 99; + ps.recompute(); + return { h, m, ps }; +} +function count(ps) { + const c = { white: 0, gold: 0 }; + const see = (p) => { if (p && p.kind === 'item') { if (p.id === WHITE) c.white++; if (p.id === GOLD) c.gold++; } }; + for (const p of [...ps.hand, ...ps.temp, ...ps.board.values()]) { see(p); for (const it of (p && p.items) || []) see(it); } + return c; +} +/** Put item `id` into the shop's first item slot (after the chess slots); returns its slot index. */ +function stockItem(ps, id = WHITE) { + const i = ps.shop.layout ? ps.shop.layout.chess : ps.shop.slots.findIndex((s) => s && s.kind === 'item'); + ps.shop.slots[i] = { kind: 'item', id, basePrice: ps.gd.itemPrice(id), frozen: false, sold: false }; + return i; +} +/** Put chess `id` into shop slot 0. */ +function stockChess(ps, id) { + ps.shop.slots[0] = { kind: 'chess', id, basePrice: ps.gd.chessPrice(id), frozen: false, sold: false }; + return 0; +} +function deploy(m, ps, ids) { + const used = new Set(); + return ids.map((id) => { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); return give(m, ps, id, 'board', t); }); +} + +test('源石溶剂: the data is the official pair (upgradeNum 2 → the golden _b; 40 % / 60 % ATK, −60 HP/s)', () => { + const w = DATA.items[WHITE], g = DATA.items[GOLD]; + assert.equal(w.upgradeNum, 2); + assert.equal(w.upgradeChessId, GOLD); + assert.equal(w.mergeable, true); + assert.equal(g.isGolden, true); + assert.deepEqual([w.params.atk, g.params.atk, w.params.damage, g.params.damage], [0.4, 0.6, 60, 60]); +}); + +test('源石溶剂: every way to get a second white merges the pair into ONE golden and leaves no white (the player report)', () => { + const cases = { + 'buy with a white in the hand': ({ m, ps }) => { giveItem(m, ps, WHITE); return m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) }); }, + 'buy twice from the shop': ({ m, ps }) => { m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) }); return m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) }); }, + 'buy with a white equipped': ({ m, ps }) => { + const [a] = deploy(m, ps, [OP]); + const it = giveItem(m, ps, WHITE); + m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid }); + return m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) }); + }, + 'equip the second on another operator': ({ m, ps }) => { + const [a, b] = deploy(m, ps, [OP, 'chess_char_2_10_a']); + const it = giveItem(m, ps, WHITE); + m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid }); + const it2 = giveItem(m, ps, WHITE); + return m.handle('p_0', { t: 'g.equip', itemUid: it2.uid, targetUid: b.uid }); + }, + 'equip the second on the same full carrier': ({ m, ps }) => { + const [a] = deploy(m, ps, [OP]); + const it = giveItem(m, ps, WHITE); + const other = giveItem(m, ps, 'chess_item_1_01_e_a'); + m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid }); + m.handle('p_0', { t: 'g.equip', itemUid: other.uid, targetUid: a.uid }); + // no twin anywhere else: acquire the second white without the auto-merge (the hand) and equip it on the full carrier + const it2 = ps.newPiece('item', WHITE); + ps.hand[ps.hand.findIndex((x) => x == null)] = it2; + return m.handle('p_0', { t: 'g.equip', itemUid: it2.uid, targetUid: a.uid }); + }, + 'reward offer pick': ({ m, ps }) => { giveItem(m, ps, WHITE); ps.pushItemOffer([WHITE, WHITE, WHITE]); return m.handle('p_0', { t: 'g.reward', idx: 0 }); }, + '道具补给 card': ({ m, ps }) => { giveItem(m, ps, WHITE); applyCard(m, ps, { kind: 'item', id: WHITE, family: 'supply', name: '源石溶剂' }); return { ok: true }; }, + '列装 rolling two whites': ({ m, ps }) => { + const roll = m.rollPool; + m.rollPool = () => ({ kind: 'item', id: WHITE }); + try { applyCard(m, ps, { kind: 'tactic', id: 'allybuff_select_1', family: 'tactic', team: true }); } finally { m.rollPool = roll; } + return { ok: true }; + }, + '整备 then a white bought next to a white': ({ m, ps }) => { + giveItem(m, ps, WHITE); + applyCard(m, ps, { kind: 'tactic', id: 'allybuff_select_5', family: 'tactic' }); + return m.handle('p_0', { t: 'g.buy', slot: stockItem(ps) }); + }, + '画卷 copy of a white carrier': ({ m, ps }) => { + const [a] = deploy(m, ps, [OP]); + const it = giveItem(m, ps, WHITE); + m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid }); + const art = giveItem(m, ps, 'chess_item_6_02_m'); + const [r, c] = [...ps.board.entries()].find(([, p]) => p === a)[0].split(',').map(Number); + return m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: r, col: c, dir: 'RIGHT' }); + }, + }; + for (const [name, run] of Object.entries(cases)) { + const s = prep(); + const res = run(s); + assert.equal(res.ok, true, `${name}: ${JSON.stringify(res)}`); + assert.deepEqual(count(s.ps), { white: 0, gold: 1 }, name); + checkInvariants(s.m); + s.m.dispose(); + } +}); + +test('a promotion that returns equipment merges a returned pair (latent: no normal state holds a pair)', () => { + const { m, ps } = prep(); + const op = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && !m.bannedChess.includes(c.chessId) + && m.gd.mergeCount(c.chessId) === 3 && !m.gd.placeableTokens(c.chessId).length).chessId; + const [a, b] = deploy(m, ps, [op, op]); + // the copies' equipment built directly: a pair never exists in play — every entry path merges it at once + a.items = [ps.newPiece('item', WHITE)]; + b.items = [ps.newPiece('item', WHITE)]; + ps.recompute(); + assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: stockChess(ps, op) }), { ok: true }); + assert.ok([...ps.board.values()].some((p) => p.id === DATA.chess[op].goldenId), 'the elite formed'); + assert.deepEqual(count(ps), { white: 0, gold: 1 }, '"干员晋级后已配发装备会回收至整备区" + the auto-merge'); + checkInvariants(m); +}); + +test('random intents around 源石溶剂 (buy, equip / replace, sell, move, destroy) conserve W + 2·G and never leave a pair', () => { + let seed = 11; + const rnd = () => { seed = (seed * 1103515245 + 12345) >>> 0; return seed / 4294967296; }; + const pick = (arr) => arr[Math.floor(rnd() * arr.length)]; + const worth = (ps) => { const c = count(ps); return { ...c, e: c.white + 2 * c.gold }; }; + let checked = 0; + for (let run = 0; run < 4; run++) { + const { m, ps } = prep(run + 3); + const ops = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && !m.bannedChess.includes(c.chessId)).slice(0, 2).map((c) => c.chessId); + for (let step = 0; step < 150; step++) { + ps.funds = 50; + const before = worth(ps); + const loose = [...ps.hand, ...ps.temp].filter(Boolean); + const chess = [...loose, ...ps.board.values()].filter((p) => p.kind === 'chess'); + const items = loose.filter((p) => p.kind === 'item'); + const r = rnd(); + let msg = null, expect = 0, rep = null; + if (r < 0.35) { msg = { t: 'g.buy', slot: stockItem(ps) }; expect = 1; } + else if (r < 0.5) msg = { t: 'g.buy', slot: stockChess(ps, pick(ops)) }; + else if (r < 0.8 && items.length && chess.length) { + const it = pick(items), tg = pick(chess); + rep = (tg.items || []).length >= 2 ? pick(tg.items) : null; + msg = { t: 'g.equip', itemUid: it.uid, targetUid: tg.uid, ...(rep ? { replaceUid: rep.uid } : {}) }; + } + else if (r < 0.9 && chess.length) msg = { t: 'g.sell', uid: pick(chess).uid }; + else if (items.length) { const it = pick(items); msg = { t: 'g.destroy', uid: it.uid }; expect = it.id === WHITE ? -1 : it.id === GOLD ? -2 : 0; } + if (!msg) continue; + const res = m.handle('p_0', msg); + if (!res.ok) continue; + // a replacement destroys the replaced item unless the equip merged instead (the replaced one is still owned) + if (rep && !ps.find(rep.uid)) expect = rep.id === WHITE ? -1 : rep.id === GOLD ? -2 : 0; + const after = worth(ps); + assert.equal(after.e - before.e, expect, `run ${run} step ${step} ${JSON.stringify(msg)}: ${JSON.stringify(before)} → ${JSON.stringify(after)}`); + assert.ok(after.white <= 1, `run ${run} step ${step}: an unmerged pair`); + checked++; + } + checkInvariants(m); + m.dispose(); + } + assert.ok(checked > 150, `${checked} accepted intents`); +}); From 695ece2653616c1a96ca192cc25bd355a5171083 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:08:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feedback1b=20WG:=20review=20fixes=20-=20off?= =?UTF-8?q?=20popup=20without=20=E5=BD=93=E5=89=8D=E6=95=88=E6=9E=9C,=20de?= =?UTF-8?q?stroyPiece=20merge=20test,=20=E5=A5=A5=E6=9C=AF=20refresh=20not?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The popup of a bond the mode never activates no longer shows the 当前效果 block (its +20 % / +68 % promised an effect 本局禁用 denies); the bond text stays. The popup test asserts it. - ctx.destroyPiece's auto-merge of returned equipment has its own test (constructed pair; fails without the call). - SIM.md and the 奥术 header: an earlier PRTS revision (oldid 386936) forbade a unit re-applying while its own instance lasts; an equal hit here refreshes it - recorded as a known [ASSUMED] deviation, slightly stronger. --- docs/SIM.md | 13 ++++++++----- public/js/ui/bondStrip.js | 7 ++++--- server/sim/content/bonds/addon/battle.js | 8 +++++--- test/match/feedback1b-items.test.js | 17 +++++++++++++++++ test/ui/feedback1b-bonds.test.js | 7 ++++++- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 1e72ed5a..a5e8e019 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -390,11 +390,14 @@ catalogue status the same rule (one invisible buff `key` per target whatever app 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|. 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). +奥术 note "※同一单位仅可对同一目标同时施加1个该盟约法术伤害提升效果" 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 is [ASSUMED]. Known deviation [ASSUMED]: an earlier revision of that note (oldid 386936) read +"同一单位在对同一目标施加的“法术伤害提升”效果的持续期间内,无法对该目标施加新的该盟约的法术伤害提升效果" — no re-application +while the unit's own instance lasts, so it would lapse 3 s after the first hit and return with the next arts hit — +whereas an equal-value hit here refreshes the instance to a fresh 3 s; slightly stronger (at most one attack interval +of coverage per 3 s), kept because the current wording no longer says so (feedback after 0.1.0). | key | effect | value | |---|---|---| diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js index 468cba5f..04d53836 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -60,10 +60,11 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null /** * Bond detail popup. * @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function, onMember?: (chessId:string)=>void, - * place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null }} props — + * place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null, off?: boolean }} props — * `place`: where it opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the * detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js); `off`: the mode - * never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a note + * never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a note, no + * 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays) */ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null, owner = null, off = false }) { const b = data.lookup('bonds', bondId); @@ -76,7 +77,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, const members = bondMembers(b, priv, banned, (id) => data.lookup('chess', id)); const countsHand = entry?.countsHand ?? b.countsHand; const next = nextThreshold(count, th); - const hasNow = !!(b.effectDescRaw || b.effectDesc); + const hasNow = !off && !!(b.effectDescRaw || b.effectDesc); const at = place || (beside === 'left' ? 'beside' : 'left'); return html`