feedback1 integrate: WG 奥术 shown 本局禁用 where the mode disables it; returned equipment auto-merges

# Conflicts:
#	public/js/ui/shopBar.js
This commit is contained in:
sganggs committed 2026-10-03 06:53:18 +08:00
commit 1041b6ed7e
15 files changed
+510 -43

No files matched your search

+3 -1
View File
@@ -353,7 +353,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), 见钱眼开玩偶
+9 -1
View File
@@ -439,7 +439,15 @@ 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个该盟约法术伤害提升效果" 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 |
|---|---|---|
+6
View File
@@ -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; }
@@ -240,6 +245,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; }
+1
View File
@@ -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; }
+7 -4
View File
@@ -88,7 +88,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';
@@ -99,7 +99,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';
@@ -1140,6 +1140,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`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
@@ -1174,7 +1176,7 @@ function MatchScreen() {
</div>` : 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))}
@@ -1204,11 +1206,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}
+13 -9
View File
@@ -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 ("层数叠加已禁用").
@@ -59,22 +60,24 @@ 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)
* detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js); `off`: the mode
* never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a note, no
* 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays)
*/
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);
const hasNow = !!(b.effectDescRaw || b.effectDesc);
const hasNow = !off && !!(b.effectDescRaw || b.effectDesc);
const at = place || (beside === 'left' ? 'beside' : 'left');
return html`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over', owner && 'is-other')} data-place=${at} data-owner=${owner || null}
role="dialog" aria-label=${owner ? `${owner} 的盟约:${b.name}` : `盟约:${b.name}`}>
@@ -86,12 +89,13 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
${owner ? html`<span class="bpop__owner"><${GIcon} name="eye" /><b>${owner}</b> 的盟约</span>` : null}
<h3 class="bpop__name">${b.name}</h3>
<div class="bpop__facts">
<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}</span>
${off ? null : html`<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}</span>`}
<span>层数 <b class="num t-mint">${layers}</b></span>
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : '未激活'}</span>
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}</span>
</div>
</div>
</header>
${off ? html`<p class="bpop__off" role="note">本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)</p>` : null}
<div class="bpop__tiers">
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? '名及以下' : '名'}</small></span>`)}
</div>
+28 -16
View File
@@ -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<string>|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`<div class="dbonds dbonds--top" role="list" aria-label="所属盟约">
${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" />
<span class="dbond__name">${rec?.name || id}</span>
<span class="dbond__off">本局禁用</span>`
: html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
<span class="dbond__name">${rec?.name || id}</span>
<span class=${cx('dbond__count', 'num', next == null && count > 0 && 'is-max')}>${count}${cap != null ? html`<small>/${cap}</small>` : null}</span>
${th.length ? html`<span class="dbond__tiers" aria-hidden="true">${th.map((_, i) => html`<i key=${i} class=${i < tier ? 'on' : ''}></i>`)}</span>` : null}`;
const cls = cx('dbond', active && 'is-active', isOff && 'is-off', rec?.isCore && 'is-core');
return onBond
? html`<button key=${id} type="button" role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} aria-label=${label}
? html`<button key=${id} type="button" role="listitem" class=${cls} title=${label} aria-label=${label}
data-bond=${id} onClick=${() => onBond(id)}>${body}</button>`
: html`<span key=${id} role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} data-bond=${id}>${body}</span>`;
: html`<span key=${id} role="listitem" class=${cls} title=${label} data-bond=${id}>${body}</span>`;
})}
</div>`;
}
@@ -204,7 +215,7 @@ function GarrisonBlock({ garrison, m }) {
</section>`;
}
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
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
</div>
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
<${BondChips} bondIds=${c.bonds} bonds=${bonds} off=${offBonds} onBond=${onBond} />
</div>
</div>`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
@@ -493,14 +504,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<string>|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;
@@ -523,7 +535,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} 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}
+15
View File
@@ -443,6 +443,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<string>}
*/
export function modeOffBonds(mode) {
const list = isObj(mode) && Array.isArray(mode.inactiveBondIds) ? mode.inactiveBondIds : [];
return new Set(list.filter((b) => typeof b === 'string'));
}
// ---- shop ---------------------------------------------------------------------------------------------
/**
+16 -10
View File
@@ -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 确认购买
@@ -21,7 +22,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, offerHeader } from './gameLogic.js';
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -60,7 +61,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;
@@ -88,7 +89,11 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} />` : null}
<span class="scard__name">${c?.name || slot.id}</span>
<span class="scard__bonds">
${bonds.slice(0, 3).map((b) => html`<span key=${b} class="scard__bond"><${BondGlyph} bondId=${b} /><span>${data.lookup('bonds', b)?.name || b}</span></span>`)}
${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`<span key=${b} class=${cx('scard__bond', off && 'is-off')} title=${off ? briefingBondTip(name, 'off') : undefined}><${BondGlyph} bondId=${b} /><span>${name}</span></span>`;
})}
</span>
<span class="scard__class">
<${Img} src=${profIconUrl(m, c?.profession)} class="scard__prof" />
@@ -219,7 +224,7 @@ export function useTwoTap({ editable, keys }) {
* report #6 after 0.1.0, the items used to be drawn as nameless operator cards — and the header names the offer
* (gameLogic.offerHeader: 晋升奖励 / the strategy's effect name …; "之后还有 N 项" while more offers wait behind it).
*/
export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) {
export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap, offBonds = null }) {
const head = offerHeader(offer);
return html`<div class=${cx('shopbar__reward', head.items && 'is-items')} role="group" aria-label=${head.title}>
<div class="rwtag">
@@ -237,7 +242,7 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
const reason = shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS });
const props = { slot: { ...s, price: 0 }, idx: i, free: true, reason, armed: armed === armKey('r', i, s), onBuy: onPick, onDetail,
onTap: (idx) => onTap('r', idx, s, kind, reason, onPick) };
return kind === 'item' ? html`<${ItemCard} key=${`rw${i}:${s.id}`} ...${props} />` : html`<${ChessCard} key=${`rw${i}:${s.id}`} priv=${priv} ...${props} />`;
return kind === 'item' ? html`<${ItemCard} key=${`rw${i}:${s.id}`} ...${props} />` : html`<${ChessCard} key=${`rw${i}:${s.id}`} priv=${priv} offBonds=${offBonds} ...${props} />`;
})}
</div>
</div>`;
@@ -248,10 +253,11 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
* @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<string>|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');
@@ -319,12 +325,12 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
<div class="shopbar__row">
<${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`<div class="shopbar__cards">
${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} />`;
})}
</div>`}
+4 -1
View File
@@ -512,7 +512,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
@@ -556,6 +557,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);
+1
View File
@@ -527,6 +527,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;
+8 -1
View File
@@ -16,7 +16,14 @@
// 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 is [ASSUMED]. Known deviation [ASSUMED]: an earlier revision
// (oldid 386936) forbade a unit re-applying while its own instance lasts; an equal hit here
// refreshes it to a fresh 3 s (slightly stronger). 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;
+194
View File
@@ -0,0 +1,194 @@
// 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';
import { makeCtx } from '../../server/match/effectsMeta.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('an effect that removes an operator (ctx.destroyPiece) merges a returned pair (latent: no normal state holds a pair)', () => {
const { m, ps } = prep();
const [a] = deploy(m, ps, [OP]);
// built directly: the carrier's white and a white in the hand — every entry path would have merged them at once
a.items = [ps.newPiece('item', WHITE)];
ps.hand[ps.hand.findIndex((x) => x == null)] = ps.newPiece('item', WHITE);
ps.recompute();
// the path of 突变细胞 / the recruit-and-gift item / the round-start promotion item (builtinMeta, content/items/meta.js)
const ctx = makeCtx(m, ps, { kind: 'item', key: 'item:test' }, 'onRoundStart');
assert.equal(ctx.destroyPiece(a.uid), true);
assert.equal(ps.find(a.uid), null, 'the operator is gone');
assert.deepEqual(count(ps), { white: 0, gold: 1 }, '"在失去该干员…" returns the equipment, then the auto-merge');
checkInvariants(m);
m.dispose();
});
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`);
});
+86
View File
@@ -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();
});
+119
View File
@@ -0,0 +1,119 @@
// 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 or 当前效果 numbers', () => {
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, /在场/);
// the 当前效果 block ("提升20% … 68%") would promise an effect the mode never gives; the bond text (盟约效果) stays
assert.doesNotMatch(text, /当前效果/);
assert.ok(![...walk(pop)].some((v) => hasClass(v, 'bpop__sec--now')), 'no current-effect section');
assert.match(text, /盟约效果/);
const plain = textOf(BondPopup({ bondId: 'arcaneShip', entry: null, priv: { board: [], hand: [] }, onClose() {} }));
assert.match(plain, /未激活/);
assert.match(plain, /在场/);
assert.match(plain, /当前效果/, 'an enabled bond keeps its current-effect block');
});
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\)\}/);
});