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).
358 lines
18 KiB
JavaScript
358 lines
18 KiB
JavaScript
// 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)`);
|
||
});
|