Files
ark-stronghold-protocol/test/match/merge.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

354 lines
20 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; a full hand sends the elite to a freed board tile', () => {
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) assert.ok(tiles.includes(ps.find(elite.uid).key), 'full hand outside PREP: the elite took a freed board tile, not temp');
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);
const hemo = give(m, ps, 'chess_char_2_02_a');
const [r, c] = legalTileFor(m, ps, 'chess_char_2_02_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();
});