Files
ark-stronghold-protocol/test/match/merge.test.js
T
sganggs 8cef750e14 feedback6 R21D: a consume-on-equip item on a full carrier opens the replace dialog and replaces the picked item first (GitHub #263, PR #264); golden: matches 10 of 18 moved (a targeted consumable on a full carrier now destroys the oldest item), the rest unchanged
PRTS 卫戍协议/帮助 §装备: 「达到上限强行佩戴会改为替换装备:指定一件已佩戴装备替换为将要佩戴的装备,并销毁被指定的装备」,
no exception for the 装备时销毁 items. Before: a normal 博士投影 opened no dialog and its kept item pushed the OLDEST
equipped item out (replaceUid ignored); 盟约之币 and the other consumables kept both items.

- server PlayerState.equip (player/items.js): on a full carrier the consume path takes the pick (replaceUid, else the
  oldest) off before onEquip (_takeReplaced), destroys it (onDestroy reason replace) once the effect went through —
  the normal 博士投影 takes the freed slot —, and puts it back at its index when the effect refuses (博士投影 on an elite:
  nothing destroyed)
- client gameLogic equipReplaces: the replace dialog opens for every item on a full carrier, consumables included,
  except an attaching item that completes an item merge and a 博士投影 on an elite; itemAttaches removed
- bots (itemTarget): a consumable whose effect is the same on anyone goes to a carrier with a free slot first (PR #264)
- docs: design/match.md equipment rules, META §2.5, PLAYING 装备 row, research 04

golden (matches only; each traced equip by equip against the parent — the first change of state is a bot's, or the
AI 托管 human seat's, targeted consumable on a full carrier destroying its oldest item): solo-FUNNY-1 (r9 ai_0 随身身份牌),
solo-FUNNY-2 (r9 ai_0 寻呼模块), solo-HARD-2 (r9 ai_0 随身身份牌, r12 简易通讯机), coop2-NORMAL-3 (r9 ai_1 随身身份牌),
coop3-ABYSS-5 (r11 ai_1 随身身份牌), coop4-FUNNY-6 (r4 ai_1 随身身份牌), solo-HARD-9-boosted (r14 ai_0 随身身份牌),
coop2-ABYSS-10-boosted (r13 ai_0 随身身份牌, r14 ai_1 简易通讯机), coop2-NORMAL-14-standins (r9 ai_0 随身身份牌; now won,
14 rounds), coop2-NORMAL-70-diy (r14 h_0 随身身份牌); the bots' free-slot carrier for target-agnostic consumables moves
no scenario by itself; roster / bonds / fields / standins / diy unchanged
2026-10-07 18:03:53 +08:00

508 lines
31 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';
import { canPlace, placeClass } from '../../server/match/board.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 on the board copy\'s tile; 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]);
a.dir = 'UP';
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;
// PRTS 卫戍协议/帮助: "若消耗已部署至作战区的干员,则发送至作战区对应位置"
const elite = ps.board.get('9,3');
assert.ok(elite && elite.id === golden, 'the elite took the consumed board copy\'s tile');
assert.notEqual(elite.uid, a.uid, 'a new piece (the copy was destroyed)');
assert.equal(elite.dir, 'UP', 'with that copy\'s facing');
assert.ok(!ps.hand.some((p) => p && p.kind === 'chess'), 'no chess left in the hand');
assert.equal(ps.deployCount, 1, 'the deploy count is unchanged');
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 refuses the merge-completing copy too (GitHub #82)', () => {
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: the 3rd copy is refused like any purchase (PRTS 卫戍协议/帮助 §手牌区 "当常规手牌区
// 全满无空位时,玩家将无法执行使手牌溢出的操作" — "例如招募/购入"; GitHub #82); a sale frees a slot, then the purchase
// completes the merge (the elite in the hand)
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 }), { error: ERR.HAND_FULL }, 'the merge-completing copy too');
assert.ok(!ps.hand.some((p) => p && p.id === DATA.chess[target].goldenId));
const filler = ps.hand.find((p) => p && p.id !== target);
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: filler.uid }), { ok: true });
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, 8, '10 − the sold filler − 2 copies + the elite');
assert.ok(ps.tempEmpty);
checkInvariants(m);
m.dispose();
});
test('merge from two board copies with a full hand (a gained copy; a purchase is refused): the elite takes the copy that deploys first (no overflow, one tile freed)', () => {
const { m, ps } = prep(23);
const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c));
const id = ids[0];
const a = give(m, ps, id, 'board', [9, 4]);
const b = give(m, ps, id, 'board', [9, 3]);
a.dir = 'LEFT';
b.dir = 'DOWN';
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.equal(ps.deployCount, 2);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.HAND_FULL }, 'a full hand refuses the purchase (GitHub #82)');
assert.ok(ps.acquireChess(id, { source: 'grant' }), 'a gain (an effect\'s grant) completes the merge');
// deploy order = by column from the left (col asc, then row desc; Battle.start): (9,3) before (9,4) — [ASSUMED] the copy
// the battle deploys first
const elite = ps.board.get('9,3');
assert.ok(elite && elite.id === DATA.chess[id].goldenId, 'the elite stands on (9,3)');
assert.equal(elite.dir, 'DOWN', 'with the facing of the copy that stood there');
assert.ok(!ps.board.has('9,4'), 'the other copy\'s tile is free');
assert.equal(ps.deployCount, 1, 'two deployed copies became one deployed elite');
assert.ok(ps.tempEmpty, 'nothing overflowed');
assert.equal(ps.hand.filter(Boolean).length, 10, 'the full hand is untouched');
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
checkInvariants(m);
m.dispose();
});
test('merge whose copies are not deployed (temp) 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];
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]);
// with the hand full they wait in temp (a free hand slot would pull them in, PRTS 卫戍协议/帮助 §手牌区)
give(m, ps, id, 'temp', 0);
give(m, ps, id, 'temp', 1);
assert.equal(ps.temp.filter(Boolean).length, 2);
// a gain completes the merge (a purchase is refused with a full hand)
assert.ok(ps.acquireChess(id, { source: 'grant' }));
assert.ok(ps.temp.some((p) => p && p.id === DATA.chess[id].goldenId), 'elite overflowed into temp');
assert.equal(ps.board.size, 0, 'nothing was deployed');
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 — but a full hand refuses the purchase all the same
// (PRTS 卫戍协议/帮助 §手牌区, GitHub #82)
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 }), { error: ERR.HAND_FULL });
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: ps.hand.find((p) => p && p.kind === 'chess' && p !== a).uid }), { ok: true });
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();
});
// The 调和 operator herself as the wearer (case from PR #40 by @shanzhaikabi; GitHub issue #1): the granted membership
// counts once and, 维多利亚 being a core bond with a member on the board, 调和 adds its +1 (bondsMeta `harmony`).
test('缪尔赛思 wearing 变形同构体 + 维式重锤 counts twice for 维多利亚: the granted member + 调和\'s +1', () => {
const MIRA = 'chess_char_6_11_a'; // 缪尔赛思: own bond 调和 (maniShip, thresholds [1])
const { m, ps } = prep(28);
const tile = legalTileFor(m, ps, MIRA);
assert.ok(tile, 'a legal tile for 缪尔赛思');
const mira = give(m, ps, MIRA, 'board', tile);
const iso = giveItem(m, ps, 'chess_item_6_09_e_a');
const hammer = giveItem(m, ps, 'chess_item_1_01_e_a');
assert.equal(ps.bonds.maniShip.active, true, '调和 is active with one 调和 operator on the board');
assert.equal(ps.bonds.victoriaShip?.count ?? 0, 0, 'no 维多利亚 member before the second item');
m.handle('p_0', { t: 'g.equip', itemUid: iso.uid, targetUid: mira.uid });
m.handle('p_0', { t: 'g.equip', itemUid: hammer.uid, targetUid: mira.uid });
assert.equal(ps.bonds.victoriaShip.count, 2, 'one from the granted membership, one from 调和');
assert.equal(ps.bonds.victoriaShip.harmony, 1, 'the state says which +1 came from 调和');
assert.equal(ps.bonds.victoriaShip.active, false, 'tier 1 still needs 3');
checkInvariants(m);
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; the elite takes a deployed copy\'s tile (never the carrier\'s) or goes to the hand', () => {
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);
// where the carrier of 突变细胞 and the two copies of X are: hand/hand, board/hand, board/board
for (const [carrierAt, copiesAt] of [['hand', 'hand'], ['board', 'hand'], ['board', 'board']]) {
const label = `carrier ${carrierAt}, copies ${copiesAt}`;
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 = carrierAt === 'board' ? give(m, ps, T, 'board', legalTileFor(m, ps, T)) : give(m, ps, T);
holder.items.push(ps.newPiece('item', 'chess_item_5_08_e_a'));
const copies = copiesAt === 'board' ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)];
// the carrier is destroyed before its gain (PRTS 备注 "原干员销毁,获得一名…"): only the copies' tiles count
const holderTile = ps.find(holder.uid).key || null;
const deployedTiles = copies.map((p) => ps.find(p.uid)).filter((l) => l.area === 'board').map((l) => l.key);
const deployed0 = ps.deployCount;
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
const fillers0 = ps.hand.filter((p) => p && p.kind === 'item').length;
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 }, `${label}: 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');
const loc = ps.find(elite.uid);
if (holderTile) assert.ok(!ps.board.has(holderTile), `${label}: the carrier's tile is empty`);
if (deployedTiles.length) {
// PRTS 卫戍协议/帮助: a merge consuming a deployed copy sends the elite to that copy's tile — outside PREP too; of
// several, the first in deploy order (col asc, then row desc — by column from the left, Battle.start)
const map = ps.deployMap();
const pos = placeClass(ps, m.gd.chess(elite.id));
const legal = deployedTiles.map((k) => k.split(',').map(Number)).filter(([r, c]) => canPlace(map, pos, r, c)).sort((a, b) => a[1] - b[1] || b[0] - a[0]);
assert.ok(legal.length, `${label}: a legal deployed tile exists`);
assert.equal(loc.area, 'board', `${label}: the elite stands on the board`);
assert.equal(loc.key, `${legal[0][0]},${legal[0][1]}`, `${label}: on the first deployed tile in deploy order`);
assert.ok(ps.deployCount <= deployed0, `${label}: the deploy count did not grow (${ps.deployCount} ≤ ${deployed0})`);
} else {
assert.equal(loc.area, 'hand', `${label}: the consumed hand pieces freed the slots`);
}
// the carrier's deployment is gone; deployed copies were replaced by at most the one elite
assert.equal(ps.deployCount, deployed0 - (holderTile ? 1 : 0) - deployedTiles.length + (loc.area === 'board' ? 1 : 0), `${label}: deploy count`);
const CELL = 'chess_item_5_08_e_a';
assert.equal(ps.hand.filter((p) => p && p.kind === 'item' && p.id !== CELL).length, fillers0, `${label}: the hand's equipment stays`);
// the cell is not consumed (player feedback after 0.1.0): it came back to the hand — or temp, the hand being full
assert.equal([...ps.hand, ...ps.temp].filter((p) => p && p.id === CELL).length, 1, `${label}: the cell came back`);
assert.ok(ps.temp.every((p) => !p || p.id === CELL), `${label}: nothing else waits in temp`);
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();
});
test('equipment: a consume-on-equip item on a full carrier replaces the picked item first and leaves the slot free; a refused 博士投影 keeps both (GitHub #263)', () => {
// PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备:指定一件已佩戴装备替换为将要佩戴的装备,并销毁被指定的装备"
const { m, ps } = prep(27);
const pool = chessOfTier(2).filter((c) => m.pool.has(c) && DATA.chess[c].bonds.length);
let n = 0;
/** A normal operator in the hand wearing two different plain items (older, newer). */
const fullCarrier = (id = pool[n]) => {
const a = give(m, ps, id);
const [older, newer] = [['chess_item_1_01_e_a', 'chess_item_1_02_e_a'], ['chess_item_3_03_e_a', 'chess_item_1_05_e_a'], ['chess_item_3_06_e_a', 'chess_item_2_04_e_a'], ['chess_item_3_05_e_a', 'chess_item_3_02_e_a'], ['chess_item_2_01_e_a', 'chess_item_3_07_e_a']][n++]
.map((itemId) => giveItem(m, ps, itemId));
for (const it of [older, newer]) assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(a.items.map((x) => x.uid), [older.uid, newer.uid]);
return { a, older, newer };
};
const equip = (it, a, rep) => m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid, ...(rep ? { replaceUid: rep.uid } : {}) });
// the normal 博士投影 takes the slot of the item the player picked (the NEWER one), not the oldest
let { a, older, newer } = fullCarrier();
const holo = giveItem(m, ps, 'chess_item_5_06_e_a');
assert.deepEqual(equip(holo, a, newer), { ok: true });
assert.deepEqual(a.items.map((x) => x.uid), [older.uid, holo.uid], 'the pick destroyed, the older kept, 博士投影 equipped');
assert.equal(ps.find(newer.uid), null);
// 盟约之币: the pick is destroyed, the coin pays and is consumed — one item and a free slot are left
({ a, older, newer } = fullCarrier());
const coin = giveItem(m, ps, 'chess_item_1_03_e_a');
const funds = ps.funds;
assert.deepEqual(equip(coin, a, newer), { ok: true });
assert.equal(ps.funds, funds + 1);
assert.deepEqual(a.items.map((x) => x.uid), [older.uid], 'a free slot is left');
assert.equal(ps.find(newer.uid), null);
assert.equal(ps.find(coin.uid), null, 'the coin is consumed');
// 随身身份牌 (picking the OLDER item): the layers go to the carrier's bonds, the newer item stays
({ a, older, newer } = fullCarrier());
const card = giveItem(m, ps, 'chess_item_1_04_e_a');
const bond = DATA.chess[a.id].bonds[0];
const layers = ps.layers[bond] || 0;
assert.deepEqual(equip(card, a, older), { ok: true });
assert.equal(ps.layers[bond], layers + 3);
assert.deepEqual(a.items.map((x) => x.uid), [newer.uid]);
assert.equal(ps.find(older.uid), null);
// the golden 博士投影 promotes at once; the item not picked stays on the elite
({ a, older, newer } = fullCarrier());
const gold = giveItem(m, ps, 'chess_item_5_06_e_b');
assert.deepEqual(equip(gold, a, older), { ok: true });
assert.ok(DATA.chess[a.id].isGolden, 'promoted');
assert.deepEqual(a.items.map((x) => x.uid), [newer.uid]);
// refused (博士投影 — either quality — on an elite): nothing is destroyed, the order is kept, the item stays in the hand
({ a, older, newer } = fullCarrier(DATA.chess[pool[n]].goldenId));
for (const id of ['chess_item_5_06_e_a', 'chess_item_5_06_e_b']) {
const it = giveItem(m, ps, id);
for (const rep of [older, newer, null]) {
assert.deepEqual(equip(it, a, rep), { error: ERR.BAD_TARGET, detail: 'already elite' }, id);
assert.deepEqual(a.items.map((x) => x.uid), [older.uid, newer.uid], `${id}: both kept`);
}
assert.equal(ps.find(it.uid).area, 'hand');
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: it.uid }), { ok: true });
}
checkInvariants(m);
m.dispose();
});