Files
ark-stronghold-protocol/test/match/bot.test.js
T
Sukvii 56a7b918a9 feedback7 dober: 教鞭 is the official personal choice of three 战术特训 cards again, not a random one (PR #353 by @Sukvii, the owner's decision of 2026-10-08); golden: coop2-NORMAL-8-serverrun and coop2-ABYSS-10-boosted move
Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md
§2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess
choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The
owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules.
Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 /
险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a
reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without
a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while
it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty
scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback
picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over
a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer
goes with it; invariants.js flags an offer outside its owner's own open prep.
Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits
of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys.
Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs
121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs
121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario
and family is unchanged; node tools/golden.mjs --twice is identical.
Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps
the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before
endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline
player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's
pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js,
unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason,
ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
2026-10-08 20:45:54 +08:00

358 lines
18 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 (server/match/bot.js): field model, layout planner, rehearsal, buying / leveling / bench management.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { fieldModel, planLayout, rehearse, rangeTiles, REHEARSAL_VARIANTS, LAYOUT_PARAMS, botPickCard, botPickBand } from '../../server/match/bot.js';
import { FIELD, canPlace, placeClass, parseKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, 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 });
test('field model: the round\'s routes traced over the own board, weighted by the enemies that use them', () => {
const h = soloBot({ seed: 2 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
const model = fieldModel(m);
assert.ok(model.routes.length > 0);
const enemies = m.wave.spawns.reduce((s, x) => s + Math.max(1, x.count || 1), 0);
assert.equal(model.routes.reduce((s, r) => s + r.n, 0), enemies, 'every spawned enemy is on one traced route');
for (const rt of model.routes) {
assert.ok(rt.tiles.length > 0 && rt.tileTime > 0 && rt.hp > 0);
for (const k of rt.tiles) {
const [r, c] = parseKey(k);
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= 0 && c <= FIELD.c1, `route tile ${k} inside the own board`);
}
}
// ground routes follow the stage's device-aware ground paths
const st = m.stage;
const paths = st.groundPathsWithDevices || st.groundPaths;
const ground = m.wave.routes.filter((r) => r.motion !== 'FLY');
for (const rt of ground) {
const path = paths[`${rt.start[0]},${rt.start[1]}->${rt.end[0]},${rt.end[1]}`];
if (!path) continue;
for (const [r, c] of path.slice(0, -1)) if (r >= 9 && r <= 12 && c <= 10) assert.ok(model.ground.has(`${r},${c}`), `ground path tile ${r},${c}`);
}
assert.equal(fieldModel(m), model, 'cached per round');
m.dispose();
});
test('field model: 近地悬浮 enemies (掠海漂移体) walk the ground path but count as air units (no blocker credit, anti-air needed)', () => {
const h = soloBot({ seed: 2 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
const groundRi = new Set(m.wave.routes.map((r, i) => (r.motion !== 'FLY' ? i : -1)).filter((i) => i >= 0));
const before = fieldModel(m);
assert.ok(before.ground.size > 0);
m.wave = { ...m.wave, spawns: m.wave.spawns.map((s) => (groundRi.has(s.routeIndex) && s.tag !== 'boss' ? { ...s, enemyKey: 'enemy_2025_syufo' } : s)) };
m._botPath = null;
const model = fieldModel(m);
const hover = m.wave.spawns.filter((s) => s.enemyKey === 'enemy_2025_syufo').reduce((a, s) => a + Math.max(1, s.count || 1), 0);
assert.ok(hover > 0);
assert.ok(model.flyTotal >= hover, `${model.flyTotal} ≥ ${hover}`);
const hov = model.routes.filter((r) => r.fly && r.tiles.some((k) => before.ground.has(k)));
assert.ok(hov.length > 0, 'an air route on the ground path tiles');
m.dispose();
});
test('layout planner: legal distinct tiles; blockers on the enemy road, ranged units cover it without standing on it', () => {
const h = soloBot({ seed: 3 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
const ps = m.order[0];
// a controlled lineup: 2 blockers, 3 ranged
for (const p of [...ps.board.values()]) ps.board.delete([...ps.board.entries()].find(([, x]) => x === p)[0]);
ps.hand.fill(null);
ps.recompute();
const melee = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && c.position === 'MELEE' && c.attackKind === 'melee' && c.stats.blockCnt >= 1).slice(0, 2);
const ranged = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && c.position === 'RANGED' && c.attackKind === 'ranged').slice(0, 3);
const pieces = [...melee, ...ranged].map((c) => give(m, ps, c.chessId, 'hand'));
const plan = planLayout(m, ps, pieces);
assert.equal(plan.size, pieces.length);
assert.equal(new Set(plan.values()).size, pieces.length, 'distinct tiles');
const map = ps.deployMap();
const model = fieldModel(m);
for (const p of pieces) {
const [r, c] = parseKey(plan.get(p.uid));
assert.ok(canPlace(map, placeClass(ps, m.gd.chess(p.id)), r, c), `${p.id} legal at ${r},${c}`);
}
for (const p of pieces.slice(0, 2)) assert.ok(model.ground.has(plan.get(p.uid)), 'a blocker stands on the enemy road');
for (const p of pieces.slice(2)) {
const rec = m.gd.chess(p.id);
const [r, c] = parseKey(plan.get(p.uid));
// the planned direction rotates the range (DESIGN §3)
const dir = plan.dirs.get(p.uid);
assert.ok(['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(dir), `${rec.name} has a planned direction`);
const covered = rangeTiles(rec, r, c, dir).some((k) => model.ground.has(k) || model.routes.some((rt) => rt.fly && rt.tiles.includes(k)));
assert.ok(covered, `${rec.name} covers the enemy route from ${r},${c} facing ${dir}`);
}
m.dispose();
});
test('rehearsal: candidate layouts are simulated without side effects; the chosen plan is one of them', () => {
const h = soloBot({ seed: 4, botRehearsal: 3 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 3);
const ps = m.order[0];
const chosen = ps.allChess().slice(0, ps.deployCap);
assert.ok(chosen.length >= 3);
const snap = () => JSON.stringify({ board: [...ps.board.entries()].map(([k, p]) => [k, p.uid, p.dir]), hand: ps.hand.map((p) => p && p.uid), bonds: ps.bonds, pool: m.pool.snapshot(), lp: ps.lp, funds: ps.funds, layers: ps.layers, round: m.round, phase: m.phase, uid: m.uidSeq });
const before = snap();
const plans = REHEARSAL_VARIANTS.map((v) => planLayout(m, ps, chosen, { ...LAYOUT_PARAMS, ...v }));
const best = rehearse(m, ps, chosen, plans);
assert.ok(plans.includes(best));
assert.equal(snap(), before, 'board, hand, bonds, pool, economy untouched');
assert.equal(m.errorCount, 0);
checkInvariants(m);
// the whole match still runs deterministically with rehearsal on
const run = () => { const x = soloBot({ seed: 11, botRehearsal: 3, difficulty: 'FUNNY' }).start(); return JSON.stringify({ ...x.runToEnd(), durationMs: 0 }); };
assert.equal(run(), run());
m.dispose();
});
test('rehearsal runs in bounded slices between scheduler callbacks and plays exactly like the one-shot run', () => {
const run = (botSliceMs) => {
const h = soloBot({ seed: 11, botRehearsal: 3, difficulty: 'FUNNY', botSliceMs }).start();
const m = h.m;
/** scheduler callback → rehearsal ticks stepped in it */
const perCallback = new Map();
const newBattle = m.newBattle.bind(m);
m.newBattle = (opts) => {
const b = newBattle(opts);
if (String(opts.fieldId).startsWith('r:')) {
const step = b.step.bind(b);
b.step = () => { perCallback.set(h.sched.executed, (perCallback.get(h.sched.executed) || 0) + 1); step(); };
}
return b;
};
const end = h.runToEnd();
assert.equal(m.errorCount, 0);
m.dispose();
return { result: JSON.stringify({ ...end, durationMs: 0 }), perCallback };
};
const whole = run(undefined);
const sliced = run(1e-6);
assert.ok(whole.perCallback.size > 0, 'sanity: the bot rehearsed');
assert.ok(Math.max(...whole.perCallback.values()) > 500, 'unbounded: a whole rehearsal in one callback (virtual time)');
assert.ok(Math.max(...sliced.perCallback.values()) <= 4, `sliced: ≤ 4 ticks per callback (got ${Math.max(...sliced.perCallback.values())})`);
assert.ok(sliced.perCallback.size > whole.perCallback.size * 10);
assert.equal(sliced.result, whole.result, 'same decisions, same match');
});
test('the prep routine itself (shop, lineup, layout planning) runs in bounded slices and plays exactly like the one-shot run', () => {
const seats = [0, 1].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const run = (botSliceMs) => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 13, fake: true, botRehearsal: 3, botSliceMs }).start();
const m = h.m;
/** `${bot}:${round}` → the scheduler callbacks in which the bot acted */
const cbs = new Map();
for (const ps of m.players.values()) {
for (const k of ['buy', 'move', 'sell', 'levelUp', 'refresh', 'equip', 'pickReward']) {
const f = ps[k].bind(ps);
ps[k] = (...a) => {
const key = `${ps.playerId}:${m.round}`;
if (!cbs.has(key)) cbs.set(key, new Set());
cbs.get(key).add(h.sched.executed);
return f(...a);
};
}
}
h.run(() => h.ended != null || (m.phase === PHASE.SETTLE && m.round === 6), { maxSteps: 5e6 });
assert.equal(m.errorCount, 0);
checkInvariants(m);
const state = JSON.stringify([...m.players.values()].map((ps) => ({
board: [...ps.board.entries()].map(([k, p]) => [k, p.id, p.dir ?? null, (p.items || []).map((x) => x.id)]),
hand: ps.hand.map((p) => p && p.id), funds: ps.funds, level: ps.shop.level, lp: ps.lp, bonds: ps.bonds,
})));
m.dispose();
return { state, perPrep: [...cbs.values()].map((s) => s.size) };
};
const whole = run(undefined);
const sliced = run(1e-6);
assert.ok(Math.max(...whole.perPrep) <= 1, `unbounded (virtual time): a whole prep in one callback (${whole.perPrep})`);
assert.ok(Math.min(...sliced.perPrep) >= 3 && Math.max(...sliced.perPrep) >= 8, `sliced: every prep spread over many callbacks (${sliced.perPrep})`);
assert.equal(sliced.state, whole.state, 'same decisions, same boards');
});
test('a bot prep dropped mid-planning (the prep ended) leaves a legal board and no rehearsal layout behind', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 14, fake: true, botRehearsal: 3, botSliceMs: 1e-6 }).start();
const m = h.m;
h.toPrep(4);
const bot = h.ps('ai_0');
let moves = 0;
const move = bot.move.bind(bot);
bot.move = (...a) => { moves++; return move(...a); };
// stop right after the bot started placing (mid-way through its sliced prep)
h.run(() => moves > 0);
assert.equal(bot.ready, false, 'mid-prep');
m.prepDeadline();
assert.equal(m.phase, PHASE.COMBAT);
const at = moves;
h.run(() => m.phase === PHASE.SETTLE);
assert.equal(moves, at, 'the pending steps were dropped');
assert.ok(bot.deployCount <= bot.deployCap);
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
test('a sliced rehearsal is dropped when the prep ends first; the default layout is already on the board', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 12, fake: true, botRehearsal: 3, botSliceMs: 1e-6 }).start();
const m = h.m;
h.toPrep(3);
const bot = h.ps('ai_0');
let ticks = 0;
const newBattle = m.newBattle.bind(m);
m.newBattle = (opts) => {
const b = newBattle(opts);
if (String(opts.fieldId).startsWith('r:')) { const step = b.step.bind(b); b.step = () => { ticks++; step(); }; }
return b;
};
h.run(() => ticks > 0);
assert.equal(bot.ready, false, 'mid-rehearsal');
const layout = JSON.stringify([...bot.board.entries()].map(([k, p]) => [k, p.uid]));
assert.ok(bot.deployCount > 0, 'the default layout already stands');
m.prepDeadline();
assert.equal(m.phase, PHASE.COMBAT);
const at = ticks;
h.run(() => m.phase === PHASE.SETTLE);
assert.equal(ticks, at, 'the pending slices dropped the job');
assert.equal(JSON.stringify([...bot.board.entries()].map(([k, p]) => [k, p.uid])), layout, 'the board was left alone');
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
test('AI takeover resolves a personal choice in untimed PREP, including generator-error Ready cleanup', () => {
for (const broken of [false, true]) {
const h = makeMatch({ mode: 'coop', humans: 1, seed: 6, fake: true, botSliceMs: 0 }).start();
h.toPrep(1);
const m = h.m, ps = h.ps('p_0');
assert.equal(m.deadline, 0, 'single-human co-op is untimed');
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
const pick = m.autoPickPersonalChoice.bind(m), modes = [];
m.autoPickPersonalChoice = (p, mode) => {
if (p.personalChoice) modes.push(mode);
if (broken && mode === 'bot') throw new Error('scripted choice scoring failure');
return pick(p, mode);
};
assert.deepEqual(m.handle(ps.playerId, { t: 'g.autoplay', on: true }), { ok: true });
assert.ok(h.run(() => m.phase !== PHASE.PREP));
assert.equal(ps.personalChoice, null);
assert.equal(ps.bounties.length, 1);
assert.equal(ps.round.arts, 1);
assert.ok(ps.ready);
assert.ok(modes.includes(broken ? 'random' : 'bot'));
assert.equal(m.errorCount > 0, broken);
m.dispose();
}
});
test('economy: the bot fills the board first (8 units by round 4), levels on its curve and keeps a hand slot free', () => {
for (const seed of [5, 8]) {
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;
const funds1 = ps.funds; // 老鲤 (band_lmlee) withholds R1–R2 income until R3
h.run(() => m.phase === PHASE.COMBAT && m.round === 1);
assert.equal(ps.shop.level, 1, 'no level-up in round 1 (units first)');
assert.equal(ps.deployCount, Math.floor(funds1 / 2), `R1: ${funds1} funds → tier-I operators deployed`);
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
assert.equal(ps.deployCount, ps.deployCap, 'full board by round 4');
h.run(() => m.phase === PHASE.COMBAT && m.round === 6);
assert.ok(ps.shop.level >= 2, `level ${ps.shop.level} by round 6`);
h.run(() => m.phase === PHASE.COMBAT && m.round === 10);
assert.ok(ps.shop.level >= 4, `level ${ps.shop.level} by round 10`);
assert.ok(ps.hand.some((x) => x == null), 'a hand slot is kept free for merges');
checkInvariants(m);
m.dispose();
}
});
test('bench management: stale low-tier singles are sold at a high shop level; pairs that can still merge are kept', () => {
const h = soloBot({ seed: 6 }).start();
const m = h.m;
const ps = m.order[0];
ps.lp = 999;
h.run(() => m.phase === PHASE.ROUND_START && m.round === 7);
// a crowded bench of tier-I singles at shop level 5, plus a live pair of a tier-V chess
ps.shop.level = 5;
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
const t5 = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 5 && m.pool.has(c.chessId) && m.pool.left(c.chessId) >= 3 && !owned.has(c.chessId));
const freeIdx = () => ps.hand.findIndex((x) => x == null);
assert.ok(freeIdx() >= 0);
const pairA = give(m, ps, t5.chessId, 'hand', freeIdx());
assert.ok(freeIdx() >= 0);
const pairB = give(m, ps, t5.chessId, 'hand', freeIdx());
const t1 = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && !owned.has(c.chessId));
const junk = [];
for (const c of t1) if (freeIdx() >= 0) junk.push(give(m, ps, c.chessId, 'hand', freeIdx()));
assert.ok(junk.length >= 2, 'the bench is crowded with tier-I singles');
h.run(() => m.phase === PHASE.COMBAT && m.round === 7);
const bench = ps.hand.filter((p) => p && p.kind === 'chess');
assert.ok(bench.length <= 6, `bench trimmed to ${bench.length}`);
const stillOwned = junk.filter((p) => ps.find(p.uid)).length;
assert.ok(stillOwned < junk.length, `sold stale singles (${junk.length - stillOwned}/${junk.length})`);
const kept = ps.allChess().filter((p) => m.gd.baseIdOf(p.id) === t5.chessId);
assert.ok(kept.length >= 2 || kept.some((p) => m.gd.isGolden(p.id)) || (ps.find(pairA.uid) && ps.find(pairB.uid)), 'the tier-V pair is kept (or merged)');
checkInvariants(m);
m.dispose();
});
test('机变 pick: items and team buffs before bounties (bounties add enemies to the bot\'s own battle)', () => {
const h = soloBot({ seed: 7 }).start();
const m = h.m;
const ps = m.order[0];
const cards = [
{ idx: 0, kind: 'bounty', payout: 'kill', coin: 3, tier: 2, count: 3, rounds: 1 },
{ idx: 1, kind: 'item', id: 'x', tier: 2 },
{ idx: 2, kind: 'tactic', team: true, tacticKind: 'ally' },
];
assert.equal(botPickCard(m, ps, cards, [0, 1, 2]), 2);
assert.equal(botPickCard(m, ps, cards, [0, 1]), 1);
assert.equal(botPickCard(m, ps, cards, [0]), 0);
m.dispose();
});
test('merge rewards: a merge completed while buying is followed by taking its free reward offer the same prep', () => {
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 === 3);
// two copies owned, the third in the shop: the bot buys it (merge) and must pick the reward offer it queues
const id = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && m.pool.left(c.chessId) >= 3 && ps.countCopies(c.chessId) === 0).chessId;
const freeIdx = () => ps.hand.findIndex((x) => x == null);
give(m, ps, id, 'hand', freeIdx());
give(m, ps, id, 'hand', freeIdx());
ps.funds = 20;
h.run(() => m.phase === PHASE.PREP && m.round === 3);
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), price: m.gd.chessPrice(id), sold: false, frozen: false };
const picks = [];
const orig = ps.pickReward.bind(ps);
ps.pickReward = (i) => { const r = orig(i); picks.push(r); return r; };
const merges = ps.stats.merges;
h.run(() => m.phase === PHASE.COMBAT && m.round === 3);
assert.ok(ps.stats.merges > merges, 'the third copy was bought (merge)');
assert.ok(picks.some((r) => r && r.ok), 'the merge reward was taken in the same prep');
assert.ok(ps.allChess().some((p) => m.gd.goldenIdOf(id) === p.id), 'the elite is owned');
checkInvariants(m);
m.dispose();
});
test('band pick: alone, the bot avoids a band that withholds the first rounds\' funds (老鲤); in co-op it may take it', () => {
const counts = { solo: 0, coop: 0 };
for (let seed = 1; seed <= 60; seed++) {
for (const mode of ['solo', 'coop']) {
const h = makeMatch({ mode, difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed, fake: true });
if (botPickBand(h.m, h.m.order[0]) === 'band_lmlee') counts[mode]++;
h.m.dispose();
}
}
assert.ok(counts.solo <= 1, `solo picks 老鲤 ${counts.solo}/60`);
assert.ok(counts.coop >= 1, `co-op may pick it (${counts.coop}/60)`);
});