feedback1 integrate: WG 奥术 shown 本局禁用 where the mode disables it; returned equipment auto-merges
# Conflicts: # public/js/ui/shopBar.js
This commit is contained in:
commit
1041b6ed7e
15 files changed
+510
-43
No files matched your search
+3
-1
@@ -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
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>`}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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\)\}/);
|
||||
});
|
||||
Reference in new issue
Block a user