Seen: 蛇屠箱 and the seven other operators with the range 0-1 (角峰, 古米, 泡泡, 折桠, 菲莱, 塞雷娅, 余 — 16 chess records, all melee blockers with block 3, the elite 角峰 4) block and hit only on the tile they stand on. The layout planner maximizes an exposure model whose kill estimate saturates in a full layout; the tile then falls to a 1e-6 rng tie-break, so a guard stood off the road, where it neither blocks nor hits (reproduced: planLayout with six of them on act2autochess_m04, seed 11 — 蛇屠箱 off the road; over 40 seeds of the eight stages 16 of 320 guards stood off the road: 11 because the eight guards outnumber the 7 free road tiles of act1autochess_m03 / act2autochess_m02, 5 avoidably — after the change only the 11 are left). Official: excel range_table "0-1" is the single grid (0,0). Now: planLayoutSteps keeps the best road tile (model.ground) for a blocker whose deployed range grid (rangeRec: its loadout's) is exactly one (0,0) grid, and takes it before the overall best; with no free road tile the best legal tile as before (a high-ground 「可以放置于远程位」 blocker keeps its own preference first). Not taken from the PR: the scarce-card penalty of buyScore, the item scoring (人事部文档 "only with nine 炎" contradicts the official 部署上限变为 9), the 神秘 morph fit and the draft yield — AI heuristics with no official text, and the golden swing they caused (a solo match that cleared the Hidden Core no longer did) came from them, not from the placement. Golden (matches only; every other family unchanged, --twice identical): solo-FUNNY-1 (from R7: boss pool left 74740 -> 71550, still a loss in R8), solo-NORMAL-1 (from R4: same win, Hidden Core cleared, team LP 26), solo-HARD-1 (from R4: boss pool left 3046117 -> 2940542), solo-ABYSS-1 (from R4: 2303334 -> 2281356, still a loss in R13), coop2-NORMAL-3 (from R4), coop2-HARD-4 (from R4), coop4-FUNNY-6 (from R3: virtualMs 128500 -> 127000), coop2-NORMAL-8-serverrun (from R3), coop2-NORMAL-70-diy (from R6): rows, stats, lineups and rng draws differ, wins, losses and rounds passed do not. botbench A/B on 12 seeds each (solo HARD / ABYSS, coop4 NORMAL / HARD): wins 1 / 0 / 11 / 3 both before and after, rounds passed 13.00 / 11.92 / 14.00 / 13.00 -> 12.92 / 11.92 / 14.00 / 13.17, LP left 18.7 / 6.5 / 80.1 / 27.9 -> 18.8 / 7.4 / 83.7 / 28.0. Tests: bot.test.js — the PR's saturated-layout case (蛇屠箱 blocks and hits only on a road tile, a real battle on both tiles; the road full: a legal fallback) and every stage over 40 seeds (the eight guards fill min(8, free road tiles) road tiles); both fail on the parent.
409 lines
21 KiB
JavaScript
409 lines
21 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, legalTiles, tileKey } from '../../server/match/board.js';
|
||
import { makeMatch, checkInvariants, give, giveItem, DATA } from './harness.js';
|
||
import { makeBattle } from '../helpers/battleHarness.js';
|
||
|
||
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||
|
||
test('user: Given a saturated layout, When placing a self-range defender, Then prefer a free enemy path tile', () => {
|
||
const h = makeMatch({ mode: 'solo', seed: 11 }).start().toPrep();
|
||
const m = h.m;
|
||
const ps = m.order[0];
|
||
try {
|
||
const pieces = ['chess_char_6_03_a', 'chess_char_5_11_a', 'chess_char_1_02_a',
|
||
'chess_char_1_10_a', 'chess_char_2_08_a', 'chess_char_3_16_a'].map((id) => give(m, ps, id, 'hand'));
|
||
const plan = planLayout(m, ps, pieces);
|
||
// act2autochess_m04, first wave: the ground route crosses these deployable tiles.
|
||
const road = new Set(['9,9', '9,8', '9,7', '9,6', '11,5', '11,4', '10,4', '9,4', '9,3']);
|
||
assert.equal(m.stageId, 'act2autochess_m04');
|
||
assert.ok(road.has(plan.get(pieces.at(-1).uid)), `Cuora must block the route, got ${plan.get(pieces.at(-1).uid)}`);
|
||
for (const [key, expectedToBlock] of [[plan.get(pieces.at(-1).uid), true], ['12,5', false]]) {
|
||
const [row, col] = parseKey(key);
|
||
const battle = makeBattle({ stageId: m.stageId, waveTemplate: 'act1autochess_01',
|
||
units: [{ chessId: 'chess_char_3_16_a', row, col }] });
|
||
battle.runToEnd();
|
||
assert.equal(battle.hooksOf('blocked').length > 0, expectedToBlock, `blocking at ${key}`);
|
||
assert.equal(battle.result().perPlayer.p1.damageDealt > 0, expectedToBlock, `damage at ${key}`);
|
||
}
|
||
// When every path position is occupied, a legal fallback remains available.
|
||
const fallback = planLayout(m, ps, [pieces.at(-1)], undefined, { occupied: road });
|
||
const key = fallback.get(pieces.at(-1).uid);
|
||
assert.ok(key && !road.has(key));
|
||
assert.equal(ps.deployMap().get(key), 'melee');
|
||
} finally { m.dispose(); }
|
||
});
|
||
|
||
test('self-range blockers (range 0-1, only their own tile) fill the free enemy-road tiles before any other tile, on every stage and seed', () => {
|
||
// official range_table "0-1": the one grid (0,0) — 角峰 古米 泡泡 折桠 菲莱 蛇屠箱 塞雷娅 余
|
||
const guards = Object.values(DATA.chess).filter((c) => c && !c.isGolden && c.rangeGrid && c.rangeGrid.length === 1 && c.rangeGrid[0].every((v) => v === 0));
|
||
assert.equal(guards.length, 8);
|
||
const stages = new Set();
|
||
for (let seed = 1; seed <= 40; seed++) {
|
||
const h = makeMatch({ mode: 'solo', seed }).start().toPrep();
|
||
const m = h.m;
|
||
const ps = m.order[0];
|
||
try {
|
||
stages.add(m.stageId);
|
||
const pieces = guards.map((c) => give(m, ps, c.chessId, 'hand'));
|
||
const plan = planLayout(m, ps, pieces);
|
||
const model = fieldModel(m, ps);
|
||
const road = legalTiles(ps.deployMap(), 'melee').filter(([r, c]) => model.ground.has(tileKey(r, c)));
|
||
const onRoad = pieces.filter((p) => model.ground.has(plan.get(p.uid))).length;
|
||
assert.equal(onRoad, Math.min(pieces.length, road.length), `seed ${seed}, ${m.stageId}: ${onRoad} of ${pieces.length} guards on the ${road.length} road tiles`);
|
||
} finally { m.dispose(); }
|
||
}
|
||
assert.equal(stages.size, 8, 'every stage was tried');
|
||
});
|
||
|
||
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)`);
|
||
});
|