<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === 'lv'} onTap=${tapLevel} />
${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater}
- armed=${armed} onTap=${tapCard} />`
+ armed=${armed} onTap=${tapCard} offBonds=${offBonds} />`
: html`
${chessSlots.map(({ s, i }) => {
if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
- return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail}
+ return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds}
reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
})}
`}
diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js
index a656405c..716ce469 100644
--- a/server/match/PlayerState.js
+++ b/server/match/PlayerState.js
@@ -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);
diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js
index db957722..720d1702 100644
--- a/server/match/effectsMeta.js
+++ b/server/match/effectsMeta.js
@@ -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;
diff --git a/server/sim/content/bonds/addon/battle.js b/server/sim/content/bonds/addon/battle.js
index d3eadc53..5339f2d4 100644
--- a/server/sim/content/bonds/addon/battle.js
+++ b/server/sim/content/bonds/addon/battle.js
@@ -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;
diff --git a/test/match/feedback1b-items.test.js b/test/match/feedback1b-items.test.js
new file mode 100644
index 00000000..97a9b352
--- /dev/null
+++ b/test/match/feedback1b-items.test.js
@@ -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`);
+});
diff --git a/test/sim/feedback1b-arcane.test.js b/test/sim/feedback1b-arcane.test.js
new file mode 100644
index 00000000..1296d9af
--- /dev/null
+++ b/test/sim/feedback1b-arcane.test.js
@@ -0,0 +1,86 @@
+// Player report after the public 0.1.0 release (relayed, untested by the user): "奥术盟约不生效". Measured on the real
+// product path — a 险境 match's own normal battle (Match._normalOpts: the player's board, bonds, loadouts, the round's
+// wave) and the same battle built from its BattleSpec as the browser does (spec.js createBattleFromSpec) — the bond
+// works: every member's arts damage puts the 奥术 debuff on its target (×(1.2 + 0.01·L) arts damage taken for 3 s, one
+// instance per target, DESIGN §5.3 / §20.10), later arts hits land multiplied, the 3-member tier raises it ×1.4 on
+// targets below 50 % HP, and the browser battle is identical. (What a player could see instead — 标准 never activates
+// 奥术 — is test/ui/feedback1b-bonds.test.js.)
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { makeMatch, give, legalTileFor } from '../match/harness.js';
+import { tileKey } from '../../server/match/board.js';
+import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
+import { bondBb } from '../../server/sim/content/bonds/addon/battle.js';
+
+const BB = bondBb('arcaneShip');
+const MEMBERS = ['chess_char_1_17_a', 'chess_char_2_10_a', 'chess_char_3_09_a']; // 深靛, 洛洛, 海霓
+
+function setup(ids, seed = 7, round = 5) {
+ const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed }).start();
+ h.toPrep(1);
+ h.ps('p_0').lp = 9999;
+ h.toPrep(round);
+ const m = h.m, ps = h.ps('p_0');
+ for (const p of [...ps.board.values()]) if (p.kind === 'chess') ps.returnCopies(p);
+ ps.board.clear();
+ const used = new Set();
+ for (const id of ids) { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); give(m, ps, id, 'board', t); }
+ ps.recompute();
+ return { m, ps };
+}
+
+/** Run a battle; per arts hit of an operator: the 奥术 multiplier on the target when it landed (0 = none). */
+function run(b, pid) {
+ const hits = [];
+ b.on('hit', (c) => {
+ if (!c.dmg || c.dmg.type !== 'arts' || !c.source || c.source.kind !== 'op') return;
+ const x = c.target.buffs.find((y) => y.key === 'bond:arcaneShip');
+ c.dmg.arcane = x ? x.data.value : 0;
+ c.dmg.taken = c.target.s.artsTakenMul;
+ });
+ b.on('damaged', (c) => { if (c.type === 'arts' && c.source && c.source.kind === 'op') hits.push({ mul: c.dmg.arcane, taken: c.dmg.taken, amount: c.amount }); });
+ for (let n = 0; !b.finished && n < 30 * 400; n++) b.step();
+ const pp = b.result().perPlayer[pid];
+ return { hits, killed: pp.killed, dealt: Math.round(pp.damageDealt) };
+}
+
+test('奥术 on a real 险境 battle: two members debuff their targets and later arts hits land ×(1.2 + 0.01·L); the browser battle is identical', () => {
+ const { m, ps } = setup(MEMBERS.slice(0, 2));
+ assert.deepEqual(ps.bonds.arcaneShip, { count: 2, active: true, tier: 1, layers: 0 });
+ for (const L of [0, 50]) {
+ const opts = m._normalOpts(ps);
+ opts.players[0].bonds.arcaneShip.layers = L;
+ const srv = run(m.newBattle(opts), ps.playerId);
+ const want = BB.base_damage_scale + BB.damage_scale_per_stack * L;
+ const boosted = srv.hits.filter((x) => x.mul > 0);
+ assert.ok(srv.hits.length >= 30 && boosted.length >= srv.hits.length * 0.6, `L${L}: ${boosted.length}/${srv.hits.length} arts hits on a debuffed target`);
+ for (const x of boosted) {
+ assert.ok(Math.abs(x.mul - want) < 1e-9, `L${L}: debuff ×${x.mul}, want ×${want}`);
+ assert.ok(x.taken >= want - 1e-9, `L${L}: the target took arts ×${x.taken}`);
+ }
+ // the browser path: the same spec, the same battle
+ const spec = buildBattleSpec({ ...opts, stageId: m.stageId, battleId: 't', boss: null });
+ const cli = run(createBattleFromSpec(spec, m.ds), ps.playerId);
+ assert.deepEqual([cli.killed, cli.dealt, cli.hits.length], [srv.killed, srv.dealt, srv.hits.length], `L${L}: browser = server`);
+ // without the bond the same battle has no debuffed hit
+ const offOpts = m._normalOpts(ps);
+ offOpts.players[0].bonds.arcaneShip = { ...offOpts.players[0].bonds.arcaneShip, active: false, tier: 0 };
+ assert.equal(run(m.newBattle(offOpts), ps.playerId).hits.filter((x) => x.mul > 0).length, 0);
+ }
+ m.dispose();
+});
+
+test('奥术 with 3 different members: targets below 50 % HP take ×1.4·(1.2 + 0.01·L) (+68 % at 0 layers); 海霓 adds layers live', () => {
+ const { m, ps } = setup(MEMBERS);
+ assert.equal(ps.bonds.arcaneShip.tier, 2);
+ const res = run(m.newBattle(m._normalOpts(ps)), ps.playerId);
+ const base = (L) => BB.base_damage_scale + BB.damage_scale_per_stack * L;
+ const seen = res.hits.filter((x) => x.mul > 0).map((x) => x.mul);
+ // L = 0 at the start; 海霓 "<战斗中>首次击倒敌人…【奥术】层数+3" raises it during the fight (bonds/addon live layers)
+ const match = (v) => [0, 3].flatMap((L) => [['base', L, base(L)], ['low', L, BB.power_weak_scale * base(L)]]).find(([, , w]) => Math.abs(v - w) < 1e-9);
+ for (const v of seen) assert.ok(match(v), `unexpected multiplier ×${v}`);
+ const kinds = new Set(seen.map((v) => match(v).slice(0, 2).join(':')));
+ for (const k of ['base:0', 'low:0', 'base:3', 'low:3']) assert.ok(kinds.has(k), `${k} seen (${[...kinds]})`);
+ assert.ok(Math.abs(BB.power_weak_scale * base(0) - 1 - BB.base_damage_scale_show_ex) < 1e-9, 'the shown +68 %');
+ m.dispose();
+});
diff --git a/test/ui/feedback1b-bonds.test.js b/test/ui/feedback1b-bonds.test.js
new file mode 100644
index 00000000..fc30b409
--- /dev/null
+++ b/test/ui/feedback1b-bonds.test.js
@@ -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\)\}/);
+});