177 lines
9.7 KiB
JavaScript
177 lines
9.7 KiB
JavaScript
// 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, a tactician's 援军 inside its attack range, pairs completed through
|
||
// the shop freeze, 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 { performance } from 'node:perf_hooks';
|
||
import { PHASE } from '../../shared/constants.js';
|
||
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, rangeTiles } from '../../server/match/bot.js';
|
||
import { 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 });
|
||
|
||
/** 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 悬赏决策): never a card the board cannot beat while a beatable one 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: 信标 never on the lineup when a bench single can take it; 博士投影 on a normal deployed operator; 突变细胞 on the weakest one below VI; 拟态物质 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);
|
||
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): a normal operator on the board
|
||
const promo = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_06_e_a'));
|
||
assert.ok(promo && deployed().includes(promo) && !m.gd.isGolden(promo.id), '博士投影 on a normal deployed operator');
|
||
// 突变细胞 (becomes a random tier + 1 operator after the battle): deployed, normal, below VI, not the strongest
|
||
const cell = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_08_e_a'));
|
||
assert.ok(cell && deployed().includes(cell) && !m.gd.isGolden(cell.id) && tierOf(cell) < 6, '突变细胞 on a normal deployed operator below VI');
|
||
assert.notEqual(cell.uid, promo.uid, 'the promotion target (the strongest) is not the one mutated');
|
||
// 拟态物质 (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');
|
||
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('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()) {
|
||
const t0 = performance.now();
|
||
assert.equal(botPrepBegin(m, ps), null, 'no rehearsal job with rehearsal off');
|
||
worst = Math.max(worst, performance.now() - t0);
|
||
}
|
||
// ≈ 10–40 ms on a desktop; the bound only catches an accidental blow-up (it is load-sensitive like test/sim/robustness)
|
||
assert.ok(worst < 400, `worst bot prep ${worst.toFixed(1)} ms`);
|
||
assert.equal(m.errorCount, 0);
|
||
m.dispose();
|
||
});
|