Files
ark-stronghold-protocol/test/match/bot-smarter.test.js
T
sganggs ff4ff3f350 feedback1 integrate: bot reconciled with the merged rules
- plans each unit with the range it is deployed with (loadoutRecord attackRangeGrid, the grid of the server's
  summonRange and the card)
- an attack on every enemy in range (阵法术师 / 轰击术师 rangeAoe) counts double in its DPS value, splash and chain a little
- botbench seeds 1-40 before/after and the decision time (unchanged) in BALANCE.md
2026-10-03 08:09:29 +08:00

319 lines
17 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 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 (both 博士投影, 突变细胞 never on an elite or a pair member), bounty
// Arts kept rather than destroyed (never on a human's seat under AI 托管), a tactician's 援军 inside its attack range,
// pairs completed through the shop freeze, 坎诺特's banked funds, a teammate's bond read from its bond strip, 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 { PHASE } from '../../shared/constants.js';
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec } from '../../server/match/bot.js';
import { parseKey, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, 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 悬赏决策): a card the board likely beats (p ≥ 0.5) whenever a near-sure one (p ≥ 0.9) 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: 信标 on a bench single; both 博士投影 on a normal operator even when an elite is the best one; 突变细胞 never on an elite or a pair member; 拟态物质 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);
// the highest-tier deployed operator becomes an elite: the most valuable piece on the board
const [topKey, top] = [...ps.board.entries()].filter(([, p]) => p.kind === 'chess' && !m.gd.isGolden(p.id))
.sort((a, b) => m.gd.chess(b[1].id).tier - m.gd.chess(a[1].id).tier || a[1].uid - b[1].uid)[0];
ps.returnCopies(top);
ps.board.delete(topKey);
const elite = give(m, ps, m.gd.goldenIdOf(top.id), 'board', parseKey(topKey));
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; the golden one at once — 缪尔赛思's R1 item): a normal operator on the board, never the elite
for (const id of ['chess_item_5_06_e_a', 'chess_item_5_06_e_b']) {
const promo = itemTarget(m, ps, giveItem(m, ps, id));
assert.ok(promo && deployed().includes(promo) && !m.gd.isGolden(promo.id), `${id} on a normal deployed operator`);
assert.notEqual(promo.uid, elite.uid);
}
// 突变细胞 (becomes a random tier + 1 operator after the battle): normal, below VI, never one of the pair (the least
// valuable pieces here) or the elite
const cell = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_08_e_a'));
assert.ok(cell && !m.gd.isGolden(cell.id) && tierOf(cell) < 6, '突变细胞 on a normal operator below VI');
assert.ok(![pairA.uid, pairB.uid].includes(cell.uid), '…never one of a merge pair');
// 拟态物质 (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('bounty Arts (教鞭): kept after a battle with leaks, used after a perfect one; a full hand drops it on a bot\'s seat, never on a human\'s seat under AI 托管', () => {
const WHIP = 'chess_item_6_03_m';
const owns = (ps, id) => [...ps.hand, ...ps.temp].some((p) => p && p.id === id);
const leaky = { killed: 5, total: 6, leaked: [{ counted: true }], perfect: false };
const clean = { killed: 6, total: 6, leaked: [], perfect: true };
{
const h = soloBot({ seed: 4 }).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 === 3);
m.lastResults.set(ps.playerId, leaky);
giveItem(m, ps, WHIP);
h.run(() => m.phase === PHASE.COMBAT && m.round === 3);
assert.ok(owns(ps, WHIP), 'kept after a battle with leaks (not destroyed)');
h.run(() => m.phase === PHASE.PREP && m.round === 4);
m.lastResults.set(ps.playerId, clean);
const bounties = ps.bounties.length;
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
assert.ok(!owns(ps, WHIP) && ps.bounties.length > bounties, 'used after a perfect battle');
assert.equal(m.errorCount, 0);
m.dispose();
}
for (const human of [false, true]) {
const seat = human ? { seat: 0, playerId: 'p_0', name: 'P', isBot: false, connected: true } : { seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true };
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [seat], seed: 4 }).start();
const m = h.m;
const ps = m.order[0];
if (human) assert.ok(m.setAutoplay(ps, true).ok, 'AI 托管 on');
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 3);
m.lastResults.set(ps.playerId, leaky);
// a full hand at the prep end with the 教鞭 in it
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
const fillers = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.left(c.chessId) >= 1 && !owned.has(c.chessId));
giveItem(m, ps, WHIP);
for (let i = 0; ps.hand.some((x) => x == null); i++) give(m, ps, fillers[i].chessId, 'hand');
const chessBefore = ps.allChess().length;
botPrepEnd(m, ps);
assert.ok(ps.hand.some((x) => x == null), 'a hand slot is free');
if (human) {
assert.ok(owns(ps, WHIP), 'a human\'s 教鞭 is never destroyed by AI 托管');
assert.equal(ps.allChess().length, chessBefore - 1, 'a bench chess was sold instead');
} else {
assert.ok(!owns(ps, WHIP), 'the bot drops its kept 教鞭 for the slot');
assert.equal(ps.allChess().length, chessBefore, 'no chess sold');
}
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('坎诺特 (利滚利: leftover funds are kept, +1 at ≥ 5): with a full board the bot banks its interest capital instead of refreshing it away', () => {
let checked = 0;
for (const seed of [5, 7, 9]) {
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;
h.run(() => m.phase === PHASE.PREP && m.round === 7);
ps.bandId = 'band_cannot';
assert.ok(m.gd.leftoverKeptBands.includes(ps.bandId));
ps.funds = 14;
const merges = ps.stats.merges;
h.run(() => m.phase === PHASE.COMBAT && m.round === 7);
// (a merge may spend the reserve; the level-up on the curve is paid for first and leaves enough here)
if (ps.stats.merges === merges) {
checked++;
assert.ok(ps.funds >= 5, `seed ${seed}: ${ps.funds} funds banked`);
}
m.dispose();
}
assert.ok(checked >= 2, `${checked} preps without a merge`);
});
test('bond plan: a teammate\'s main core bond (its bond strip — a human\'s too) counts against the bot\'s focus (the shop pool is shared)', () => {
const SARGON = ['chess_char_1_12_a', 'chess_char_2_06_a', 'chess_char_2_08_a'];
const KAZIMIERZ = ['chess_char_1_19_a', 'chess_char_2_12_a', 'chess_char_2_18_a'];
const focusWhenMateBuilds = (mateIds) => {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P', isBot: false, connected: true }, { seat: 1, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }];
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 3 }).start();
const m = h.m;
const [human, ai] = m.order;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
for (const ps of [human, ai]) {
for (const p of ps.allChess()) ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.layers = {};
}
for (const id of [SARGON[0], SARGON[1], KAZIMIERZ[0], KAZIMIERZ[1]]) give(m, ai, id, 'hand');
for (const id of mateIds) give(m, human, id, 'board', legalTileFor(m, human, id));
human.recompute();
ai.recompute();
ai._botFocus = null;
ai._botFocusId = null;
const { focus } = bondPlan(m, ai);
m.dispose();
return focus;
};
assert.equal(focusWhenMateBuilds(SARGON), 'kazimierzShip', 'the human builds 萨尔贡: the bot takes 卡西米尔');
assert.equal(focusWhenMateBuilds(KAZIMIERZ), 'sargonShip', 'the human builds 卡西米尔: the bot takes 萨尔贡');
});
test('merged rules (0.1.1): an attack on every enemy in range counts double (阵法术师 / 轰击术师 rangeAoe); a unit is planned with the range it is deployed with', () => {
const rec = (id) => DATA.chess[id];
const raw = (r) => (r.stats.atk / (r.stats.bat * 100 / r.stats.aspd));
// 阿罗玛 (轰击术师, attacks every enemy on her line) vs a single-target sniper: effDps without a field model is the raw DPS
// × the crowd factor
const aroma = rec('chess_char_4_10_a'), inside = rec('chess_char_1_01_a');
assert.ok(Math.abs(effDps(null, aroma) - raw(aroma) * 2) < 1e-6, '轰击术师 ×2');
assert.ok(Math.abs(effDps(null, inside) - raw(inside)) < 1e-6, 'a single-target dealer ×1');
// the elite 信仰搅拌机 with SPT-Y (特性 攻击距离 +1): the bot covers the extended grid the server's summonRange and the card use
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 3, fake: true }).start();
const ps = h.ps('p_0');
const mixer = rec('chess_char_4_01_b');
const mod = (mixer.modules || []).find((x) => x.typeName === 'SPT-Y');
if (mod) {
ps.loadout = Object.freeze({ chess_char_4_01_a: Object.freeze({ skill: mixer.skill?.index ?? 2, module: mod.uniEquipId }) });
const r = rangeRec(ps, mixer);
assert.ok(r.rangeGrid.length > mixer.rangeGrid.length, `the deployed grid (${r.rangeGrid.length} tiles) is the extended one (${mixer.rangeGrid.length})`);
}
assert.equal(rangeRec(ps, inside), inside, 'no loadout difference: the record itself');
h.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()) {
// CPU time (process.cpuUsage) rather than wall clock: a loaded host stretches it far less
const u0 = process.cpuUsage();
assert.equal(botPrepBegin(m, ps), null, 'no rehearsal job with rehearsal off');
const u = process.cpuUsage(u0);
worst = Math.max(worst, (u.user + u.system) / 1000);
}
// 7–35 ms of CPU per bot prep R6–R13 (seeds 13–15); ≈ 10× headroom for a loaded host
assert.ok(worst < 300, `worst bot prep ${worst.toFixed(1)} ms of CPU`);
assert.equal(m.errorCount, 0);
m.dispose();
});