Files
ark-stronghold-protocol/test/match/bot-smarter.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

358 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.
// AI player after the 0.1.0 player feedback (#10 "目前的人机有点太笨了", server/match/bot.js): bounty picks against the
// own board, item carriers by what the item does (both 博士投影, 突变细胞 never on an elite or a pair member), bounty
// Arts kept rather than destroyed (never on a human's seat under AI 托管), a tactician's 援军 inside its attack range,
// pairs completed through the shop freeze, 坎诺特's banked funds, a teammate's bond read from its bond strip, and a
// cost guard on the prep heuristics. The outcome numbers (old vs new bot on the same seeds) are measured with
// tools/botbench.mjs (docs/META.md §1.5).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec } from '../../server/match/bot.js';
import { parseKey, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js';
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
/** A 悬赏决策 card as the draft builds it (choices.js buildCards), by its enemy's name. */
function bountyCard(enemyName) {
const c = DATA.choices.cards.bounty.find((x) => x.draft && DATA.enemies[x.enemyKey] && DATA.enemies[x.enemyKey].name === enemyName);
assert.ok(c, `a draft bounty with ${enemyName}`);
return { kind: 'bounty', id: c.effectId, name: c.name, tier: c.tier, coin: c.coin, payout: c.payout, rounds: c.rounds >= 90 ? 2 : c.rounds, enemyKey: c.enemyKey, count: c.count };
}
test('bounty pick: the card the own board can beat, never one it cannot (even when that one pays more)', () => {
const h = soloBot({ seed: 5, difficulty: 'HARD' }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 5);
assert.ok(ps.deployCount >= 6, `a board (${ps.deployCount} deployed)`);
const easy = bountyCard('源石虫'); // 550 HP, pays nothing
const hard = bountyCard('泥岩巨像'); // 100000 HP, pays 3
const huge = bountyCard('纠缠藤蔓'); // 90000 HP, pays 3
const pe = bountyKillChance(m, ps, easy);
const ph = bountyKillChance(m, ps, hard);
assert.ok(pe > 0.8, `the board kills a 源石虫 (p ${pe.toFixed(2)})`);
assert.ok(ph < 0.3, `…but not a 泥岩巨像 (p ${ph.toFixed(2)})`);
assert.equal(botPickCard(m, ps, [hard, easy, huge].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 1);
ps.lp = 2;
assert.equal(botPickCard(m, ps, [hard, huge, easy].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 2, 'low LP: still the beatable one');
m.dispose();
});
test('bounty pick in real drafts (绝境 R3 悬赏决策): a card the board likely beats (p ≥ 0.5) whenever a near-sure one (p ≥ 0.9) is offered', () => {
let drafts = 0;
for (const seed of [1, 2, 3, 4]) {
const h = soloBot({ seed, difficulty: 'HARD', fake: true }).start();
const m = h.m;
const apply = m._applyCard.bind(m);
m._applyCard = (ps, idx) => {
const cards = m.sp ? m.sp.cards : [];
if (cards.length && cards.every((c) => c.kind === 'bounty')) {
drafts++;
const p = cards.map((c) => bountyKillChance(m, ps, c));
const best = Math.max(...p);
const picked = p[cards.findIndex((c) => c.idx === idx)];
if (best >= 0.9) assert.ok(picked >= 0.5, `seed ${seed} R${m.round}: picked p ${picked.toFixed(2)} while ${best.toFixed(2)} was offered`);
}
return apply(ps, idx);
};
h.run(() => m.phase === PHASE.PREP && m.round === 1);
m.order[0].lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 4);
assert.equal(m.errorCount, 0);
m.dispose();
}
assert.ok(drafts >= 4, `${drafts} bounty drafts seen`);
});
test('items: 信标 on a bench single; both 博士投影 on a normal operator even when an elite is the best one; 突变细胞 never on an elite or a pair member; 拟态物质 on a pair', () => {
const h = soloBot({ seed: 6 }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.ROUND_START && m.round === 7);
const deployed = () => [...ps.board.values()].filter((p) => p.kind === 'chess');
assert.ok(deployed().length >= 6);
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
const fresh = (tier) => Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === tier && m.pool.left(c.chessId) >= 3 && !owned.has(c.chessId)).chessId;
for (const p of ps.hand) if (p && p.kind === 'chess') ps.returnCopies(p);
ps.hand.fill(null);
// the highest-tier deployed operator becomes an elite: the most valuable piece on the board
const [topKey, top] = [...ps.board.entries()].filter(([, p]) => p.kind === 'chess' && !m.gd.isGolden(p.id))
.sort((a, b) => m.gd.chess(b[1].id).tier - m.gd.chess(a[1].id).tier || a[1].uid - b[1].uid)[0];
ps.returnCopies(top);
ps.board.delete(topKey);
const elite = give(m, ps, m.gd.goldenIdOf(top.id), 'board', parseKey(topKey));
const single = give(m, ps, fresh(4), 'hand');
const pairId = fresh(1);
const pairA = give(m, ps, pairId, 'hand');
const pairB = give(m, ps, pairId, 'hand');
const tierOf = (p) => m.gd.chess(p.id).tier;
// 信标 (destroys its carrier for a pick of two of its tier)
const beacon = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_04_e_a'));
assert.equal(beacon && beacon.uid, single.uid, '信标 on the bench single');
// 博士投影 (promotion; the golden one at once — 缪尔赛思's R1 item): a normal operator on the board, never the elite
for (const id of ['chess_item_5_06_e_a', 'chess_item_5_06_e_b']) {
const promo = itemTarget(m, ps, giveItem(m, ps, id));
assert.ok(promo && deployed().includes(promo) && !m.gd.isGolden(promo.id), `${id} on a normal deployed operator`);
assert.notEqual(promo.uid, elite.uid);
}
// 突变细胞 (becomes a random tier + 1 operator after the battle): normal, below VI, never one of the pair (the least
// valuable pieces here) or the elite
const cell = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_08_e_a'));
assert.ok(cell && !m.gd.isGolden(cell.id) && tierOf(cell) < 6, '突变细胞 on a normal operator below VI');
assert.ok(![pairA.uid, pairB.uid].includes(cell.uid), '…never one of a merge pair');
// 拟态物质 (a third copy when two are owned): the pair
const mimic = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_05_e_a'));
assert.ok(mimic && [pairA.uid, pairB.uid].includes(mimic.uid), '拟态物质 on the pair');
// …but not once the pool has no third copy: the item would give nothing (GitHub #207)
m.pool.take(pairId, m.pool.left(pairId));
const mimic2 = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_05_e_a'));
assert.ok(mimic2 && ![pairA.uid, pairB.uid].includes(mimic2.uid), '拟态物质 not on a pair whose pool is out');
// 盟约之币 (the same on anyone): a full carrier would lose an item to it (the replace rule, GitHub #263) — every
// deployed operator wears two items here, so a bench operator with free slots takes it
for (const p of deployed()) while (p.items.length < m.gd.equipPerChess) p.items.push(ps.newPiece('item', 'chess_item_3_03_e_a'));
const coin = itemTarget(m, ps, giveItem(m, ps, 'chess_item_1_03_e_a'));
assert.ok(coin && [single.uid, pairA.uid, pairB.uid].includes(coin.uid), '盟约之币 on an operator with a free slot');
m.dispose();
});
test('bounty Arts (教鞭): kept after a battle with leaks, used after a perfect one; a full hand drops it on a bot\'s seat, never on a human\'s seat under AI 托管', () => {
const WHIP = 'chess_item_6_03_m';
const owns = (ps, id) => [...ps.hand, ...ps.temp].some((p) => p && p.id === id);
const leaky = { killed: 5, total: 6, leaked: [{ counted: true }], perfect: false };
const clean = { killed: 6, total: 6, leaked: [], perfect: true };
{
const h = soloBot({ seed: 4 }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 3);
m.lastResults.set(ps.playerId, leaky);
giveItem(m, ps, WHIP);
h.run(() => m.phase === PHASE.COMBAT && m.round === 3);
assert.ok(owns(ps, WHIP), 'kept after a battle with leaks (not destroyed)');
h.run(() => m.phase === PHASE.PREP && m.round === 4);
m.lastResults.set(ps.playerId, clean);
const bounties = ps.bounties.length;
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
assert.ok(!owns(ps, WHIP) && ps.bounties.length > bounties, 'used after a perfect battle');
assert.equal(m.errorCount, 0);
m.dispose();
}
for (const human of [false, true]) {
const seat = human ? { seat: 0, playerId: 'p_0', name: 'P', isBot: false, connected: true } : { seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true };
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [seat], seed: 4 }).start();
const m = h.m;
const ps = m.order[0];
if (human) assert.ok(m.setAutoplay(ps, true).ok, 'AI 托管 on');
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 3);
m.lastResults.set(ps.playerId, leaky);
// a full hand at the prep end with the 教鞭 in it
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
const fillers = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.left(c.chessId) >= 1 && !owned.has(c.chessId));
giveItem(m, ps, WHIP);
for (let i = 0; ps.hand.some((x) => x == null); i++) give(m, ps, fillers[i].chessId, 'hand');
const chessBefore = ps.allChess().length;
botPrepEnd(m, ps);
assert.ok(ps.hand.some((x) => x == null), 'a hand slot is free');
if (human) {
assert.ok(owns(ps, WHIP), 'a human\'s 教鞭 is never destroyed by AI 托管');
assert.equal(ps.allChess().length, chessBefore - 1, 'a bench chess was sold instead');
} else {
assert.ok(!owns(ps, WHIP), 'the bot drops its kept 教鞭 for the slot');
assert.equal(ps.allChess().length, chessBefore, 'no chess sold');
}
m.dispose();
}
});
test('a tactician\'s 援军 (伺夜\'s 狼群) is placed on a tactical point inside the tactician\'s attack range', () => {
const VIGIL = 'chess_char_3_19_a';
const WOLF = 'token_10028_vigil_wolf';
for (const seed of [3, 8]) {
const h = soloBot({ seed }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 2);
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();
const vigil = give(m, ps, VIGIL, 'hand');
const melee = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && c.position === 'MELEE' && c.attackKind === 'melee').slice(0, 2);
for (const c of melee) give(m, ps, c.chessId, 'hand');
arrange(m, ps);
const at = [...ps.board.entries()].find(([, p]) => p.uid === vigil.uid);
assert.ok(at, '伺夜 deployed');
const wolf = [...ps.board.entries()].find(([, p]) => p.kind === 'token' && p.id === WOLF);
assert.ok(wolf, `seed ${seed}: the 狼群 is placed`);
const [r, c] = parseKey(at[0]);
assert.ok(rangeTiles(m.gd.chess(VIGIL), r, c, at[1].dir || 'RIGHT').includes(wolf[0]), `seed ${seed}: 狼群 at ${wolf[0]} inside 伺夜's range from ${at[0]}`);
checkInvariants(m);
m.dispose();
}
});
test('economy: a held pair is completed — bought when affordable, else the shop is frozen and the copy bought next round', () => {
const h = soloBot({ seed: 9 }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.ROUND_START && m.round === 6);
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
const id = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 2 && m.pool.left(c.chessId) >= 4 && !owned.has(c.chessId)).chessId;
for (const p of ps.hand) if (p && p.kind === 'chess') ps.returnCopies(p);
ps.hand.fill(null);
give(m, ps, id, 'hand');
give(m, ps, id, 'hand');
h.run(() => m.phase === PHASE.PREP && m.round === 6);
// the third copy in the shop, unaffordable this prep
ps.funds = 0;
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), price: m.gd.chessPrice(id), sold: false, frozen: false };
const merges = ps.stats.merges;
h.run(() => m.phase === PHASE.COMBAT && m.round === 6);
if (ps.stats.merges === merges) {
assert.ok(ps.shop.slots[0] && ps.shop.slots[0].id === id && ps.shop.slots[0].frozen, 'the shop was frozen with the third copy in it');
h.run(() => m.phase === PHASE.COMBAT && m.round === 7);
}
assert.ok(ps.stats.merges > merges, 'the pair was completed');
assert.ok(ps.allChess().some((p) => p.id === m.gd.goldenIdOf(id)), 'the elite is owned');
checkInvariants(m);
m.dispose();
});
test('坎诺特 (利滚利: leftover funds are kept, +1 at ≥ 5): with a full board the bot banks its interest capital instead of refreshing it away', () => {
let checked = 0;
for (const seed of [5, 7, 9]) {
const h = soloBot({ seed }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 7);
ps.bandId = 'band_cannot';
assert.ok(m.gd.leftoverKeptBands.includes(ps.bandId));
ps.funds = 14;
const merges = ps.stats.merges;
// the level-up on the curve is paid for first (its price depends on the rounds the run levelled at: 9 or 10 here);
// after it the bot buys / refreshes nothing that takes it under the 5 capital
const level0 = ps.shop.level, price0 = ps.shop.upgradePrice;
h.run(() => m.phase === PHASE.COMBAT && m.round === 7);
// (a merge may spend the reserve)
if (ps.stats.merges === merges) {
checked++;
const afterLevelUp = 14 - (ps.shop.level > level0 ? price0 : 0);
assert.ok(ps.funds >= Math.min(5, afterLevelUp), `seed ${seed}: ${ps.funds} funds banked (${afterLevelUp} after the level-up)`);
}
m.dispose();
}
assert.ok(checked >= 2, `${checked} preps without a merge`);
});
test('bond plan: a teammate\'s main core bond (its bond strip — a human\'s too) counts against the bot\'s focus (the shop pool is shared)', () => {
const SARGON = ['chess_char_1_12_a', 'chess_char_2_06_a', 'chess_char_2_08_a'];
const KAZIMIERZ = ['chess_char_1_19_a', 'chess_char_2_12_a', 'chess_char_2_18_a'];
const focusWhenMateBuilds = (mateIds) => {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P', isBot: false, connected: true }, { seat: 1, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }];
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 3 }).start();
const m = h.m;
const [human, ai] = m.order;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
for (const ps of [human, ai]) {
for (const p of ps.allChess()) ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.layers = {};
}
for (const id of [SARGON[0], SARGON[1], KAZIMIERZ[0], KAZIMIERZ[1]]) give(m, ai, id, 'hand');
for (const id of mateIds) give(m, human, id, 'board', legalTileFor(m, human, id));
human.recompute();
ai.recompute();
ai._botFocus = null;
ai._botFocusId = null;
const { focus } = bondPlan(m, ai);
m.dispose();
return focus;
};
assert.equal(focusWhenMateBuilds(SARGON), 'kazimierzShip', 'the human builds 萨尔贡: the bot takes 卡西米尔');
assert.equal(focusWhenMateBuilds(KAZIMIERZ), 'sargonShip', 'the human builds 卡西米尔: the bot takes 萨尔贡');
});
test('merged rules (0.1.1): an attack on every enemy in range counts double (阵法术师 / 轰击术师 rangeAoe); a unit is planned with the range it is deployed with', () => {
const rec = (id) => DATA.chess[id];
const raw = (r) => (r.stats.atk / (r.stats.bat * 100 / r.stats.aspd));
// 阿罗玛 (轰击术师, attacks every enemy on her line) vs a single-target sniper: effDps without a field model is the raw DPS
// × the crowd factor
const aroma = rec('chess_char_4_10_a'), inside = rec('chess_char_1_01_a');
assert.ok(Math.abs(effDps(null, aroma) - raw(aroma) * 2) < 1e-6, '轰击术师 ×2');
assert.ok(Math.abs(effDps(null, inside) - raw(inside)) < 1e-6, 'a single-target dealer ×1');
// the elite 信仰搅拌机 with SPT-Y (特性 攻击距离 +1): the bot covers the extended grid the server's summonRange and the card use
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 3, fake: true }).start();
const ps = h.ps('p_0');
const mixer = rec('chess_char_4_01_b');
const mod = (mixer.modules || []).find((x) => x.typeName === 'SPT-Y');
if (mod) {
ps.loadout = Object.freeze({ chess_char_4_01_a: Object.freeze({ skill: mixer.skill?.index ?? 2, module: mod.uniEquipId }) });
const r = rangeRec(ps, mixer);
assert.ok(r.rangeGrid.length > mixer.rangeGrid.length, `the deployed grid (${r.rangeGrid.length} tiles) is the extended one (${mixer.rangeGrid.length})`);
}
assert.equal(rangeRec(ps, inside), inside, 'no loadout difference: the record itself');
h.m.dispose();
});
test('cost guard: the prep heuristics of a late-round 4-bot match stay cheap (rehearsal off)', () => {
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 13, fake: true }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 1);
for (const ps of m.order) ps.lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 10);
let worst = 0;
for (const ps of m.alivePlayers()) {
// CPU time (process.cpuUsage) rather than wall clock: a loaded host stretches it far less
const u0 = process.cpuUsage();
assert.equal(botPrepBegin(m, ps), null, 'no rehearsal job with rehearsal off');
const u = process.cpuUsage(u0);
worst = Math.max(worst, (u.user + u.system) / 1000);
}
// 7–35 ms of CPU per bot prep R6–R13 (seeds 13–15); ≈ 10× headroom for a loaded host
assert.ok(worst < 300, `worst bot prep ${worst.toFixed(1)} ms of CPU`);
assert.equal(m.errorCount, 0);
m.dispose();
});
test('bench shed: the buy loop never sells a piece that came this prep (it used to sell the single it had just bought back for 1)', async () => {
// QA of the 0.1.1 bots: co-op 绝境, 2 humans on AI 托管 + 2 bots, seeds 21–23 — 29 same-prep buy → sell in 6 matches,
// every one the buy loop's pre-emptive bench shed (the buy picks by lineup gain, the shed by piece value)
const { PlayerState } = await import('../../server/match/PlayerState.js');
const oSell = PlayerState.prototype.sell;
const shed = [];
PlayerState.prototype.sell = function (uid) {
const loc = this.find(uid);
const p = loc && loc.piece;
if (p && p.boughtRound === this.m.round && /buyLoopSteps/.test(new Error().stack)) shed.push(`${p.id} R${this.m.round}`);
return oSell.call(this, uid);
};
try {
for (const seed of [21, 22, 23]) {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed, botRehearsal: 0 });
h.start();
h.autoHumans();
const m = h.m;
h.run(() => h.ended != null, { maxSteps: 3e7 });
assert.equal(m.errorCount, 0);
m.dispose();
}
} finally { PlayerState.prototype.sell = oSell; }
assert.deepEqual(shed, [], 'no piece of this prep sold by the shed');
});