Files
ark-stronghold-protocol/test/match/merge.test.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

367 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Merges (精锐), reward offers, item equip/replace/merge, consume-on-equip items (research 01 §7 + A1, 04 §2).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
function prep(seed = 21, o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...o }).start();
h.toPrep(1);
h.setStage('act2autochess_m04');
const 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.recompute();
ps.funds = 100;
return { h, m: h.m, ps };
}
/** Put chess `id` into shop slot 0 at its normal price. */
function stock(m, ps, id, slot = 0) {
ps.shop.slots[slot] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
}
test('3 copies (board + hand + bought) merge into 1 elite in the hand; equipment returns; reward offer of tier level+1', () => {
const { m, ps } = prep();
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x));
const cap = m.pool.cap(id);
const a = give(m, ps, id, 'board', [9, 3]);
const b = give(m, ps, id);
const it1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const it2 = giveItem(m, ps, 'chess_item_1_02_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it1.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it2.uid, targetUid: b.uid }), { ok: true });
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const golden = DATA.chess[id].goldenId;
assert.ok(!ps.board.has('9,3'), 'the board copy was consumed');
const elite = ps.hand.find((p) => p && p.id === golden);
assert.ok(elite, 'elite in the hand');
assert.equal(elite.poolCopies, 3);
assert.equal(m.pool.left(id), cap - 3);
assert.equal(ps.countCopies(id), 0);
assert.ok(ps.hand.some((p) => p && p.uid === it1.uid) && ps.hand.some((p) => p && p.uid === it2.uid), 'equipment returned to the hand');
assert.equal(elite.items.length, 0);
assert.equal(ps.stats.merges, 1);
const offer = ps.privateView().shop.rewardOffer;
assert.ok(offer);
assert.equal(offer.tier, Math.min(ps.shop.level + 1, 6));
assert.equal(offer.slots.length, 3);
for (const s of offer.slots) { assert.equal(s.price, 0); assert.equal(DATA.chess[s.id].tier <= offer.tier, true); }
// take one reward (free), the offer is consumed
const funds = ps.funds;
const pick = offer.slots[1].id;
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 1 }), { ok: true });
assert.equal(ps.funds, funds);
assert.ok(ps.hand.some((p) => p && p.id === pick) || ps.hand.some((p) => p && p.id === DATA.chess[pick].goldenId));
assert.equal(ps.privateView().shop.rewardOffer, null);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('elites never merge; 风丸 merges with 2 copies; a full hand still buys the merge-completing copy', () => {
const { m, ps } = prep(22);
const id = chessOfTier(2).find((x) => m.pool.has(x) && x !== 'chess_char_2_11_a');
const golden = DATA.chess[id].goldenId;
give(m, ps, golden);
give(m, ps, golden);
stock(m, ps, golden);
m.handle('p_0', { t: 'g.buy', slot: 0 });
assert.equal(ps.hand.filter((p) => p && p.id === golden).length, 3, 'three elites stay three elites');
// 风丸 (chess_char_2_11_a) upgradeNum 2
if (m.pool.has('chess_char_2_11_a')) {
give(m, ps, 'chess_char_2_11_a');
stock(m, ps, 'chess_char_2_11_a');
m.handle('p_0', { t: 'g.buy', slot: 0 });
assert.ok(ps.hand.some((p) => p && p.id === DATA.chess.chess_char_2_11_a.goldenId), '风丸 elite after 2 copies');
}
// full hand + 2 copies in the hand: buying the 3rd completes the merge (net −1)
for (const p of ps.hand) if (p) ps.returnCopies(p);
ps.hand.fill(null);
const x = chessOfTier(1).filter((c) => m.pool.has(c));
const target = x[0];
give(m, ps, target);
give(m, ps, target);
for (let i = 1; ps.hand.some((p) => p == null); i++) give(m, ps, x[i]);
assert.ok(ps.hand.every(Boolean));
stock(m, ps, x[20] || x[x.length - 1], 1);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
stock(m, ps, target, 0);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(ps.hand.some((p) => p && p.id === DATA.chess[target].goldenId));
assert.equal(ps.hand.filter(Boolean).length, 9);
assert.ok(ps.tempEmpty);
checkInvariants(m);
m.dispose();
});
test('merge from two board copies with a full hand overflows the elite into temp (blocks ready until resolved)', () => {
const { m, ps } = prep(23);
const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c));
const id = ids[0];
give(m, ps, id, 'board', [9, 3]);
give(m, ps, id, 'board', [9, 4]);
const others = chessOfTier(2).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(ps.temp.some((p) => p && p.id === DATA.chess[id].goldenId), 'elite overflowed into temp');
assert.equal(ps.board.size, 0);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
checkInvariants(m);
m.dispose();
});
test('reward pick respects the pool and a full hand; offers expire at prep end', () => {
const { h, m, ps } = prep(24);
ps.pushRewardOffer('merge');
const offer = ps.offers[0];
const id0 = offer.slots[0].id;
const taken = m.pool.take(id0, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.SOLD_OUT });
m.pool.give(id0, taken);
const others = chessOfTier(3).filter((c) => m.pool.has(c) && !offer.slots.some((s) => s.id === c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.HAND_FULL });
ps.hand[0] && ps.returnCopies(ps.hand[0]);
ps.hand[0] = null;
ps.recompute();
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
h.sched.runNext();
h.sched.runNext();
assert.equal(ps.offers.length, 0, 'offer gone at prep end');
checkInvariants(m);
m.dispose();
});
test('equipment: max 2 (a third replaces the oldest), identical normal items merge into golden — also when equipped', () => {
const { m, ps } = prep(25);
const id = chessOfTier(3).find((c) => m.pool.has(c));
const a = give(m, ps, id);
const i1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const i2 = giveItem(m, ps, 'chess_item_1_02_e_a');
const i3 = giveItem(m, ps, 'chess_item_3_03_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i1.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i2.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i3.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(a.items.map((x) => x.id), ['chess_item_1_02_e_a', 'chess_item_3_03_e_a'], 'oldest replaced (destroyed)');
assert.ok(!ps.find(i1.uid), 'the replaced item is gone');
// equip a second 坚守盾牌 onto the same carrier → merges into the golden version in the hand
const i4 = giveItem(m, ps, 'chess_item_1_02_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i4.uid, targetUid: a.uid }), { ok: true });
assert.ok(ps.hand.some((p) => p && p.id === 'chess_item_1_02_e_b'), 'golden item in the hand');
assert.ok(!a.items.some((x) => x.id === 'chess_item_1_02_e_a'));
assert.equal(ps.stats.itemMerges, 1);
// buying an item whose twin is in the hand merges immediately (and works with a full hand)
const i5 = giveItem(m, ps, 'chess_item_2_03_e_a');
const others = chessOfTier(1).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
ps.shop.slots[0] = { kind: 'item', id: 'chess_item_2_03_e_a', basePrice: 2, frozen: false, sold: false };
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(ps.hand.some((p) => p && p.id === 'chess_item_2_03_e_b'));
assert.ok(!ps.find(i5.uid));
// golden items never merge again; non-mergeable items (upgradeNum 100) never merge
const g2 = giveItem(m, ps, 'chess_item_2_03_e_b', 'temp');
ps.checkItemMerges();
assert.equal([...ps.hand, ...ps.temp].filter((p) => p && p.id === 'chess_item_2_03_e_b').length, 2);
void g2;
// bad targets
const it = ps.hand.find((p) => p && p.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: it.uid }).error, ERR.BAD_TARGET);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: a.uid, targetUid: a.uid }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('变形同构体 grants the bond of the other equipped item (bond counting)', () => {
const { m, ps } = prep(26);
const id = chessOfTier(1, (c) => !c.bonds.includes('victoriaShip') && c.position === 'MELEE').find((c) => m.pool.has(c));
const a = give(m, ps, id, 'board', [9, 3]);
const iso = giveItem(m, ps, 'chess_item_6_09_e_a');
const hammer = giveItem(m, ps, 'chess_item_1_01_e_a');
const before = ps.bonds.victoriaShip ? ps.bonds.victoriaShip.count : 0;
m.handle('p_0', { t: 'g.equip', itemUid: iso.uid, targetUid: a.uid });
m.handle('p_0', { t: 'g.equip', itemUid: hammer.uid, targetUid: a.uid });
assert.equal(ps.bonds.victoriaShip.count, before + 1, 'wearer counts as a 维多利亚 member');
m.dispose();
});
test('consume-on-equip built-ins: 盟约之币 funds, 随身身份牌 layers, 人事部文档 cap 9, 博士投影 promote; Arts limit', () => {
const { m, ps } = prep(27);
const id = chessOfTier(2).find((c) => m.pool.has(c));
const a = give(m, ps, id);
ps.funds = 0;
const coin = giveItem(m, ps, 'chess_item_1_03_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: coin.uid, targetUid: a.uid }), { ok: true });
assert.equal(ps.funds, 1);
assert.equal(a.items.length, 0, 'consumed, no slot used');
assert.ok(!ps.find(coin.uid));
const tag = giveItem(m, ps, 'chess_item_1_04_e_b');
m.handle('p_0', { t: 'g.equip', itemUid: tag.uid, targetUid: a.uid });
for (const b of DATA.chess[id].bonds) assert.equal(ps.layers[b], 6, `${b} +6 layers (无需激活)`);
const hr = giveItem(m, ps, 'chess_item_6_08_e_a');
m.handle('p_0', { t: 'g.equip', itemUid: hr.uid, targetUid: a.uid });
assert.equal(ps.deployCap, 9);
const holo = giveItem(m, ps, 'chess_item_5_06_e_b');
m.handle('p_0', { t: 'g.equip', itemUid: holo.uid, targetUid: a.uid });
assert.equal(a.id, DATA.chess[id].goldenId, 'golden 博士投影 promotes immediately');
assert.equal(a.poolCopies, 3);
const holo2 = giveItem(m, ps, 'chess_item_5_06_e_b');
assert.equal(m.handle('p_0', { t: 'g.equip', itemUid: holo2.uid, targetUid: a.uid }).error, ERR.BAD_TARGET, 'already elite');
// normal 博士投影: stays equipped, promotes at the next round start
const b = give(m, ps, chessOfTier(3).find((c) => m.pool.has(c)));
const holo3 = giveItem(m, ps, 'chess_item_5_06_e_a');
m.handle('p_0', { t: 'g.equip', itemUid: holo3.uid, targetUid: b.uid });
assert.equal(b.items.length, 1);
ps.hand.forEach((p, i) => { if (p && p.kind === 'item') ps.hand[i] = null; });
checkInvariants(m);
m.handle('p_0', { t: 'g.ready', ready: true });
const h = { sched: m.sched };
h.sched.runUntil(() => m.phase === PHASE.PREP && m.round === 2, { maxSteps: 1e6 });
// the bot-free solo round needs a combat → it ran; now at R2
assert.equal(b.id, DATA.chess[b.id].goldenId ? b.id : b.id);
assert.ok(m.gd.isGolden(b.id), 'promoted at the round start');
assert.equal(b.items.length, 0);
// Arts: at most 2 per round; unimplemented handlers are rejected cleanly
const art = giveItem(m, ps, 'chess_item_6_02_m');
const board = [...ps.board.entries()].find(([, p]) => p.kind === 'chess');
let target = board;
if (!target) { const c = give(m, ps, chessOfTier(1, (x) => x.position === 'MELEE').find((x) => m.pool.has(x)), 'board', [9, 3]); target = ['9,3', c]; }
const [r, c] = target[0].split(',').map(Number);
const res = m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: r, col: c });
assert.deepEqual(res, { ok: true }, '画卷 copies the operator');
assert.ok(!ps.find(art.uid));
const art2 = giveItem(m, ps, 'chess_item_6_02_m');
assert.equal(m.handle('p_0', { t: 'g.art', itemUid: art2.uid, row: 12, col: 10 }).error, ERR.BAD_TARGET, 'no operator in range');
assert.equal(m.handle('p_0', { t: 'g.art', itemUid: art2.uid, row: 1, col: 1 }).error, ERR.BAD_TILE);
assert.equal(m.handle('p_0', { t: 'g.art', itemUid: a.uid, row: r, col: c }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('item merge with a full hand AND a full temp: the golden item takes the equipped twin\'s slot instead of being lost', () => {
const { m, ps } = prep(29);
const id = chessOfTier(3).find((c) => m.pool.has(c));
const carrier = give(m, ps, id, 'board', [10, 3]);
const twin = giveItem(m, ps, 'chess_item_1_02_e_a');
const keep = giveItem(m, ps, 'chess_item_1_01_e_a');
ps._detach(ps.find(twin.uid));
ps._detach(ps.find(keep.uid));
carrier.items.push(keep, twin);
const fill = chessOfTier(1).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, fill[i]);
const fill2 = chessOfTier(2).filter((c) => m.pool.has(c));
for (let i = 0; ps.temp.some((p) => p == null); i++) give(m, ps, fill2[i], 'temp');
ps.recompute();
const got = ps.acquireItem('chess_item_1_02_e_a', { source: 'grant' });
assert.ok(got && got.id === 'chess_item_1_02_e_b', 'the merge produced the golden item');
assert.deepEqual(carrier.items.map((x) => x.id), ['chess_item_1_01_e_a', 'chess_item_1_02_e_b'], 'golden item equipped in the twin\'s slot');
assert.ok(!ps.find(twin.uid), 'the twin was consumed');
assert.equal(ps.stats.itemMerges, 1);
checkInvariants(m);
m.dispose();
});
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; with a full hand the elite waits in temp for that prep', () => {
const X = 'chess_char_2_04_a';
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
for (const onBoard of [false, true]) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 3, fake: true }).start();
const m = h.m;
h.toPrep(1);
const 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.recompute();
// the mutation's tier+1 roll can only give X
const saved = new Map();
for (const [base, e] of m.pool.entries) if (e.tier === 2 && base !== X) { saved.set(base, e.left); e.left = 0; }
const T = chessOfTier(1, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id));
const holder = give(m, ps, T, 'board', legalTileFor(m, ps, T));
holder.items.push(ps.newPiece('item', 'chess_item_5_08_e_a'));
const copies = onBoard ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)];
const tiles = onBoard ? copies.map((p) => ps.find(p.uid).key) : [];
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
let atSettle = null;
const settle = m.settle.bind(m);
m.settle = (...a) => { const r = settle(...a); atSettle = { offers: ps.offers.map((o) => o.source), merges: ps.stats.merges }; return r; };
h.toPrep(2);
for (const [base, left] of saved) m.pool.entries.get(base).left = left;
assert.deepEqual(atSettle, { offers: ['merge'], merges: 1 }, `${onBoard ? 'board' : 'hand'} copies: the transform completed a merge in SETTLE`);
const elite = ps.allChess().find((p) => p.id === m.gd.goldenIdOf(X));
assert.ok(elite, 'the elite survived the round start');
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
if (onBoard) {
// research 01 A1: the elite goes to the hand, never straight to a board tile — the full hand overflows it into
// temp, where it stays (not wiped at the round start) and blocks Ready until the player places it
assert.equal(ps.find(elite.uid).area, 'temp', 'full hand outside PREP: the elite overflowed into temp');
assert.ok(ps.privateView().temp.some((v) => v && v.uid === elite.uid), 'shown in the temp row');
assert.equal(ps.privateView().canReady, false);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
const [r, c] = tiles[0].split(',').map(Number);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: elite.uid, to: { area: 'board', row: r, col: c } }), { ok: true }, 'placed on a freed tile');
assert.ok(ps.tempEmpty && ps.privateView().canReady);
} else {
assert.equal(ps.find(elite.uid).area, 'hand', 'the consumed hand copies freed the slots');
}
assert.ok(ps.tempEmpty);
checkInvariants(m);
// it expires at the end of that prep like any other offer
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
assert.equal(ps.offers.length, 0);
m.dispose();
}
});
test('withdrawing a summoner with a full hand: its own summon stack frees the slot (onto the stack or anywhere)', () => {
for (const onto of ['stack', 'other']) {
const { m, ps } = prep(24);
// 伺夜: its talent summon 狼群 is a hand card (a skill's summon such as 赫默's drone never is)
const hemo = give(m, ps, 'chess_char_3_19_a');
const [r, c] = legalTileFor(m, ps, 'chess_char_3_19_a');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: hemo.uid, to: { area: 'board', row: r, col: c } }), { ok: true });
const stackIdx = ps.hand.findIndex((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
assert.ok(stackIdx >= 0, 'its summon stack went to the hand');
const ids = ['chess_item_1_01_e_a', 'chess_item_1_02_e_a', 'chess_item_1_03_e_a', 'chess_item_1_04_e_a', 'chess_item_1_05_e_a', 'chess_item_2_01_e_a', 'chess_item_2_02_e_a', 'chess_item_2_03_e_a', 'chess_item_2_04_e_a'];
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, ids[i]);
const idx = onto === 'stack' ? stackIdx : ps.hand.findIndex((p) => p && p.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: hemo.uid, to: { area: 'hand', idx } }), { ok: true }, onto);
assert.equal(ps.find(hemo.uid).area, 'hand');
assert.equal(ps.hand[stackIdx], hemo, 'it takes the slot its stack left');
assert.ok(![...ps.hand, ...ps.temp, ...ps.board.values()].some((p) => p && p.kind === 'token'), 'its summons are gone');
assert.equal(ps.hand.filter((p) => p && p.kind === 'item').length, 9, 'no item was displaced');
checkInvariants(m);
m.dispose();
}
});
test('equipment: the replace dialog picks WHICH equipped item a third one destroys (g.equip replaceUid); equipped items are otherwise locked (g.destroy refused)', () => {
const { m, ps } = prep(26);
const a = give(m, ps, chessOfTier(3).find((c) => m.pool.has(c)));
const i1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const i2 = giveItem(m, ps, 'chess_item_1_02_e_a');
const i3 = giveItem(m, ps, 'chess_item_3_03_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i1.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i2.uid, targetUid: a.uid }), { ok: true });
// equipped items cannot be destroyed directly
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: i1.uid }), { error: ERR.BAD_TARGET, detail: 'equipped items are locked' });
assert.deepEqual(a.items.map((x) => x.uid), [i1.uid, i2.uid]);
// a replaceUid that is not on the target changes nothing
assert.equal(m.handle('p_0', { t: 'g.equip', itemUid: i3.uid, targetUid: a.uid, replaceUid: i3.uid }).error, ERR.BAD_TARGET);
assert.ok(ps.hand.includes(i3), 'the new item stays in the hand');
assert.deepEqual(a.items.map((x) => x.uid), [i1.uid, i2.uid]);
// the player keeps the older item and replaces the newer one
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i3.uid, targetUid: a.uid, replaceUid: i2.uid }), { ok: true });
assert.deepEqual(a.items.map((x) => x.uid), [i1.uid, i3.uid]);
assert.ok(!ps.find(i2.uid), 'the picked item was destroyed');
// hand items can still be destroyed
const i4 = giveItem(m, ps, 'chess_item_2_03_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: i4.uid }), { ok: true });
checkInvariants(m);
m.dispose();
});