feedback1 WF: smarter AI players (merge hunting, bond plan, bounty threat model, item carriers, armour-aware DPS, tactician points)

This commit is contained in:
sganggs committed 2026-10-03 02:15:31 +08:00
1 parent 7f134737ce
commit 7f20776b9e
4 files changed
+1077 -113

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
// 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();
});