feedback1 integrate: WF smarter bots (same decision time)

# Conflicts:
#	docs/META.md
#	server/match/bot.js
This commit is contained in:
sganggs committed 2026-10-03 07:07:23 +08:00
commit 88d419b095
5 files changed
+1319 -145

No files matched your search

+29
View File
@@ -333,6 +333,35 @@ Official-waves column: `node tools/balance.mjs --mode all --difficulty ALL --bot
seed), measured before the review's leader-multiplier / 联防-timing fixes (§2; not re-run); the other columns are the
previous pass (old generator), kept for history. \* the bot code of this pass (§5) with the old time reading and no tuning; the originally reported bots (old bot code) survived ≈ 6.5 rounds on 险境 and won 1/20 on 标准.
**Player feedback after 0.1.0 (#10 "人机有点太笨了").** Measured first (`tools/botbench.mjs`): the 0.1.0 bots refreshed
0–2 times a prep and bought side-grade singles they sold again at a loss (every sale returns 1), so they merged ≈ 2
times a match and fielded ≈ 2 elites at R13; in co-op 绝境 ≈ 1 in 2 of their bounty picks leaked (solo 10 of 80; the
old score ignored the enemy); 信标 went on the best operator (it destroys its carrier) and a level-up could spend the
funds a third copy in the shop needed. Changes (server/match/bot.js header, META §1.5): refresh-vs-buy by the shop odds
of completing held pairs, the freeze for an unaffordable third copy, merges before level-ups, a committed focus /
second bond (a teammate's main bond read from its bond strip), armour-aware DPS, bounty picks by expected value from
the exposure model, item carriers by effect, 坎诺特 banking its interest capital, a tactician's 援军 inside its range.
Same seeds (1–40) old → new, the match's default rehearsal (3):
| config | wins | rounds passed | LP left | leaks / match | bounty enemies leaked | merges / bot | elites at R13 |
|---|---|---|---|---|---|---|---|
| 独立 标准 (1 AI) | 35 → 35 / 40 | 8.80 → 8.80 | 29.9 → 28.6 | 2.5 → 3.3 | 1 → 1 | 1.40 → 2.25 | – (R7: 0.7 → 1.0) |
| 独立 绝境 (1 AI) | 19 → 26 / 40 | 13.05 → 13.35 | 14.3 → 16.6 | 26.5 → 19.7 | 10 → 8 of 80 picks | 2.33 → 4.10 | 1.8 → 2.2 |
| 同盟 标准 (4 AI) | 40 → 40 / 40 | 14.00 → 14.00 | 110.0 → 112.2 | 39.4 → 33.1 | 6 → 6 | 2.59 → 4.10 | 1.9 → 2.3 |
| 同盟 绝境 (4 AI) | 5 → 12 / 40 | 11.85 → 12.32 | 5.1 → 12.7 | 224.6 → 212.6 | 155 → 148 of ≈ 310 picks | 2.00 → 2.97 | 2.3 → 2.8 |
绝境 leaks per alive bot in R12 / R13: solo 8.0 / 9.2 → 5.7 / 4.8, co-op 20.5 / 20.7 → 18.5 / 15.8. Co-op bounty leaks
are bound by the drafts, not the pick: in 223 of the 312 co-op 绝境 bounty picks no card still on offer had a kill
chance ≥ 0.5 (solo 22 of 80), and only 5 picks (solo 0) took a card below 0.5 while one ≥ 0.5 was on offer — re-measure
after the bounty-half fix (player report #2). Strategies played alone (solo 绝境, seeds 201–220, forced with `--band`):
坎诺特 9 → 14 wins of 20, 昆图斯 9 → 11, 杜宾 8 → 9 (keeping an unused 教鞭 vs dropping it: 11 vs 11 wins over 24 杜宾
matches — kept, as it costs nothing but a hand slot). Decision time per bot prep is unchanged (one thread, back to back
on a quiet host, seeds 1–6, solo 绝境 / co-op 绝境; the rehearsal included): wall clock p50 / p95 94 / 191 → 89 / 205 ms
and 86 / 175 → 83 / 162 ms, CPU 131 / 314 → 124 / 329 ms and 97 / 209 → 91 / 188 ms; the heuristics alone (CPU) 31 / 74
→ 28 / 70 ms and 17 / 39 → 17 / 38 ms (the lineup search tries identical pieces once and reuses the lineup across
refreshes); a 机变 pick 0.07 → 0.15 ms (p50). Over the 40-seed A/B the summed prep CPU per match moved −6 % … +2 %. The
tuning sweeps used seeds 101–148 with the rehearsal off; the tables above are separate seed ranges.
---
## 6. Match follow-ups shipped with this pass
+44 -15
View File
@@ -185,21 +185,46 @@ The rest of this section is the legacy server-run mode
另一组队友的战场情况" → `BAD_TARGET 'other group hidden'`); eliminated / departed players spectate any field.
### 1.5 AI player (bot.js)
Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), then levels on a curve
(L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free levels always), then
spends the rest on merge progress, bond thresholds around a focus core bond (most owned members, ties → most copies
left in the shared pool), role needs (2 blockers, anti-air when the wave flies, ≤ 2 healers), 特质 that keep adding
layers (every prep / refresh — the layer engines) and items for free carriers (突变细胞 on its least valuable single
normal operator below 6阶 — never an elite or one of a merge pair, `bot.js cellTarget`; a cell left in temp gets a hand
slot made for it, `makeHandRoom`); refreshes while a purchase stays
affordable; sells bench chess that neither make the lineup nor build toward something (a live pair, the focus bond,
an elite). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`). Placement
uses the round's enemy preview: every route is traced over the own board (ground: the stage's device-aware ground
paths; flyers: through their checkpoints) and weighted by its enemies; an exposure model (time on each tile × DPS of
the units covering it, blocker hold time, anti-air only on flying routes) is maximized greedily (blockers, then
damage dealers, then healers). Boss rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the
own board (rows −7, the right player of a pair mirrored, only the routes that end on its half) and the leader counts
as 10 tough enemies with a 30 s dwell on its first tiles, so the damage dealers reach stationary leaders.
Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), completes the merges it can afford,
then levels on a curve (L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free
levels always), then spends the rest (a band that keeps its leftover funds — 坎诺特 利滚利 — holds its interest capital
of 5 back from refreshes, full-board buys that complete nothing, items and spare level-ups). It builds around a
**focus core bond** (owned members × 10 + members the shop can still bring at its level + banked layers + 6 for last
round's focus, − 6 when it is a teammate's main core bond on that teammate's bond strip — humans included: the pool is
shared) and a **second bond** (most owned members, next threshold within reach); its keepers are the deployed
operators, the focus / second members and chess of tier ≥ shop level − 1. With a full board a purchase must be merge
progress or a lineup upgrade **and** be worth more than the refreshes its price would pay for: a refresh is worth
Σ over the held pairs of P(the refresh shows the third copy) × the value of an elite (the shop's copy-weighted odds
over the remaining pool; an elite also brings the merge's free pick of the next tier, so any pair is worth completing)
— otherwise it refreshes. Up to 4 pairs wait on the bench (6 spare units, pairs first); a third copy it cannot afford
freezes the shop for the next round (freeze is free). Purchase scores: merge progress > bond thresholds > role needs
(2 blockers, anti-air when the wave flies, ≤ 2 healers) > tier, the 特质 that keep adding layers, and armour fit (the
share of a dealer's damage that gets through the round's DEF / RES — `mitigate` of the sim per hit, HP-weighted over
the wave). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`; every
deployed focus member counts toward the next threshold). Items by what they do: equipment on the strongest deployed
damage dealers (survival items on blockers first, bond signature items on a member), 信标 on a bench single (never the
lineup when a bench single exists), 拟态物质 on a pair, 博士投影 (both qualities — neither takes an elite) on the strongest
normal operator, 突变细胞 on the least valuable normal operator below 6阶 (deployed or benched — never an elite, never one
of a merge pair, never one already carrying a cell; it comes back after every transformation, and a cell left in temp
gets a hand slot made for it, `makeHandRoom` — on a bot's own seat by destroying the cheapest other hand item if no
chess can be sold, never a human's item under AI 托管), 身份牌 / 通讯机 / 寻呼模块 on a focus member; 画卷 copies the most
valuable deployed operator; 教鞭 / “神秘顾客” are used after a perfect battle and otherwise kept in the hand — a bot drops
a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never. 机变: a
bounty card is scored by its expected payout minus its expected leaks × the value of an LP (2 + 20 / LP), the kill
chance from the exposure model below for that one enemy against the own board as it stands (its HP × the round's
multiplier, DEF / RES, speed, route kind) — an expected-value comparison: it prefers the card with the best payout
minus expected LP loss and takes one it is unlikely to beat only when nothing better is offered or the pay difference
is large (in co-op 绝境 most remaining bounty leaks come from drafts with no beatable card at all); tactic cards by what
they act on (a 盟誓 / 驰援 card on its bonds, 升华, …). Placement uses the round's enemy preview: every route is traced
over the own board (ground: the stage's device-aware ground paths; flyers: through their checkpoints) and weighted by
its enemies; an exposure model (time on each tile × DPS against the round's armour of the units covering it, blocker
hold time, anti-air only on flying routes) is maximized greedily (blockers, then damage dealers, then healers); a
tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange`, the server's own rule; player
report #9 after 0.1.0), and a tile the server's `g.move` refuses is skipped for the next best one (the planner reads
`board.js legalTiles` on the deploy map; `g.move` is the judge). Boss
rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the own board (rows −7, the right player
of a pair mirrored, only the routes that end on its half) and the leader counts as 10 tough enemies with a 30 s dwell
on its first tiles, so the damage dealers reach stationary leaders.
**Rehearsal:** with `opts.botRehearsal = N` (default 3) the bot simulates the N best
distinct layout variants once each with the real `Battle` (a rehearsal seed, no meta dispatch, board restored exactly)
and keeps the one with the fewest leaks; a candidate whose counted leaks already exceed the best finished one's stops
@@ -669,6 +694,10 @@ CUSTOM texts (eliminations, 联防, hidden core).
* `node tools/matchrun.mjs --mode coop --difficulty HARD --players 4 --seeds 20` — per-round balancing summary / aggregate
(`--check` audit, `--errors` per-source error table, `--lp N` / `--layers N` to reach late rounds — the boost stops at
999 per bond —, `--rehearsal N`).
* `node tools/botbench.mjs --configs solo:HARD,coop4:HARD --seeds 40 --jobs 4 --json new.json` — the AI player's
outcomes (wins, rounds passed, LP left, leaks per round, bounties taken / leaked and the kill-chance calibration,
merges, board value, bond tiers, layers, elites) and its decision time per prep (wall clock and CPU, p50 / p95, the
layout rehearsal apart); `--compare old.json new.json` prints the A/B tables (the same seeds on two builds).
* `node tools/balance.mjs --mode multi --difficulty NORMAL` — the competent-board difficulty model (docs/BALANCE.md):
per round leaks / LP after 联防 / clear time of representative boards against the real waves, boss damage by 150 s
and kill time; `--tuning off` (research numbers), `--legacy-time`, `--profile weak|strong`, `--bots N`, `--json`.
+622 -130
View File
File diff suppressed because it is too large. Load diff
+296
View File
@@ -0,0 +1,296 @@
// 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 } 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('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();
});
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env node
// tools/botbench.mjs — measure the AI player (server/match/bot.js): seeded bot-only matches (virtual time, the real
// simulation, the match's default layout rehearsal) with the bot's outcomes, its economy and its decision time.
//
// Usage:
// node tools/botbench.mjs [--configs solo:FUNNY,solo:HARD,coop4:FUNNY,coop4:HARD] [--seeds N] [--seed S]
// [--rehearsal N] [--json out.json] [--quiet]
// node tools/botbench.mjs --compare old.json new.json A/B tables (markdown) of two --json runs
// --configs comma list of <solo|coopN>:<FUNNY|NORMAL|HARD|ABYSS> (coopN = N bot seats, 1–4)
// --seeds N matches per config (seeds S..S+N−1, default 10)
// --rehearsal layouts each bot rehearses per prep (default: the Match default)
// --json F write the raw per-match records to F (the input of --compare)
// --jobs N run the matches in N worker threads (outcomes are identical; the timings are then inflated by the
// parallel load — measure decision time with --jobs 1)
// --band ID solo configs only: the bot plays strategy ID (e.g. band_cannot) instead of its weighted pick — a band's
// mechanic in isolation (坎诺特 banking, 杜宾's 教鞭, 昆图斯's 突变细胞)
//
// Per match: victory, rounds passed, LP left (the alive players at the end; a won Final Assault: the team LP), and per
// bot and round: LP, shop level, deployed units, active bonds and Σ bond tiers, activated layers, board value (Σ shop
// price of the deployed chess, an elite = goldenCopies copies), elites deployed, items equipped, funds lost at the prep
// end (leftover funds are lost, PlayerState.endPrep), leaks (bounty leaks apart) and the bounty cards taken — with the
// bot's own kill-chance estimate when the bot has one (bot.bountyKillChance) for a calibration table, and the best
// estimate among the draft's cards (was a likelier card on offer?).
// Decision time: every bot prep runs in one scheduler callback in virtual time (Match.scheduleBotPrep); the tool times
// those callbacks (Match.later on the instance; wall clock and process CPU time) and splits off the rehearsal battles'
// stepping (Battle.step of the rehearsal fields 'r:<pid>') — "heuristics" = the prep minus the rehearsal. A 机变 pick is
// the SP_DRAFT callback in which a bot's pick landed. Wall-clock numbers depend on the host load: compare runs made
// back to back with --jobs 1.
import { performance } from 'node:perf_hooks';
import { writeFileSync, readFileSync } from 'node:fs';
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import { Match } from '../server/match/Match.js';
import * as bot from '../server/match/bot.js';
import { VirtualScheduler } from '../server/match/scheduler.js';
import { getData } from '../server/data.js';
const argv = isMainThread ? process.argv.slice(2) : workerData.argv;
const opt = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) continue;
const k = a.slice(2);
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
opt[k] = v;
}
const pct = (arr, p) => {
if (!arr.length) return 0;
const s = arr.slice().sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1))];
};
const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0);
if (opt.compare) {
const files = [opt.compare, ...argv.filter((a) => !a.startsWith('--') && a !== opt.compare)];
compare(files.map((f) => JSON.parse(readFileSync(f, 'utf8'))), files.map((f) => f.replace(/^.*\//, '').replace(/\.json$/, '')));
process.exit(0);
}
const data = getData({ log: { warn() {}, error() {}, info() {} } });
const configs = String(opt.configs || 'solo:FUNNY,solo:HARD,coop4:FUNNY,coop4:HARD').split(',').map((s) => {
const [m, d] = s.split(':');
const solo = m === 'solo';
return { name: s, mode: solo ? 'solo' : 'coop', players: solo ? 1 : Math.max(1, Math.min(4, Number(m.replace('coop', '')) || 4)), difficulty: String(d || 'NORMAL').toUpperCase() };
});
const seeds = Math.max(1, Number(opt.seeds) || 10);
const seed0 = Number(opt.seed) || 1;
const rehearsal = opt.rehearsal != null && opt.rehearsal !== true ? Math.max(0, Math.floor(Number(opt.rehearsal) || 0)) : undefined;
function runOne(cfg, seed) {
const sched = new VirtualScheduler();
const seats = [];
for (let i = 0; i < cfg.players; i++) seats.push({ seat: i, playerId: `ai_${i}`, name: `AI-${i + 1}`, isBot: true, connected: true });
let summary = null;
const errors = [];
const log = { info() {}, debug() {}, warn() {}, error: (...a) => errors.push(a.map(String).join(' ')) };
const m = new Match({
roomCode: 'BENCH', mode: cfg.mode, difficulty: cfg.difficulty, seats, seed, data, log, scheduler: sched, botRehearsal: rehearsal,
send: () => true, broadcast: () => {}, onEnd: (s) => { summary = s; },
});
// --band: the only strategy on offer (botPickBand draws from gd.bandIds())
if (opt.band && cfg.mode === 'solo') m.gd.bandIds = () => [String(opt.band)];
const rec = { config: cfg.name, seed, stageId: m.stageId, bossId: m.bossId, preps: [], picks: [], rounds: [], bounties: [] };
// rehearsal stepping time (Battle.step of the rehearsal fields)
let rehMs = 0;
const newBattle = m.newBattle.bind(m);
m.newBattle = (o) => {
const b = newBattle(o);
if (o && typeof o.fieldId === 'string' && o.fieldId.startsWith('r:') && typeof b.step === 'function') {
const step = b.step.bind(b);
b.step = () => { const t0 = performance.now(); try { return step(); } finally { rehMs += performance.now() - t0; } };
}
return b;
};
// every scheduler callback timed; a callback during which a bot became ready in PREP was that bot's prep
const later = m.later.bind(m);
let calMs = 0; // the tool's own calibration work inside a callback (subtracted)
m.later = (ms, fn) => later(ms, () => {
const before = new Map([...m.players.values()].map((p) => [p.playerId, p.ready]));
const picked = m.sp ? Object.keys(m.sp.picks).length : 0;
const phase = m.phase;
const round = m.round;
const r0 = rehMs;
const c0 = calMs;
const u0 = process.cpuUsage();
const t0 = performance.now();
fn();
const wall = performance.now() - t0;
const u = process.cpuUsage(u0);
const dt = wall - (calMs - c0);
// CPU time of the callback (less sensitive to the host load than wall clock; the rehearsal's share by wall ratio)
const cpu = Math.max(0, (u.user + u.system) / 1000 - (calMs - c0));
if (phase === 'SP_DRAFT' && m.sp && Object.keys(m.sp.picks).length > picked) rec.picks.push({ round, ms: dt });
if (phase !== 'PREP') return;
for (const p of m.players.values()) {
if (p.isBot && before.get(p.playerId) === false && p.ready) rec.preps.push({ round, pid: p.playerId, ms: dt, rehMs: rehMs - r0, cpu, rehCpu: dt > 0 ? cpu * ((rehMs - r0) / dt) : 0 });
}
});
// 机变 picks (cheap, timed per call) and bounties taken
const apply = m._applyCard.bind(m);
m._applyCard = (ps, idx) => {
const card = m.sp && m.sp.cards[idx];
if (card && card.kind === 'bounty') {
// the bot's own kill-chance estimate (when this bot version has one), for the calibration table
const c0 = performance.now();
const p = typeof bot.bountyKillChance === 'function' ? bot.bountyKillChance(m, ps, card) : null;
// the best estimate among the draft's bounty cards still untaken (was a likelier one on offer?)
const best = p == null ? null : Math.max(p, ...m.sp.cards.filter((c) => c && c.kind === 'bounty' && m.sp.taken[c.idx] == null).map((c) => bot.bountyKillChance(m, ps, c)));
calMs += performance.now() - c0;
rec.bounties.push({ round: m.round, pid: ps.playerId, enemyKey: card.enemyKey, count: card.count, tier: card.tier, payout: card.payout, battles: card.rounds, p, best });
}
return apply(ps, idx);
};
// funds a player still holds when the prep ends (lost unless its band keeps them)
const lost = {};
for (const ps of m.players.values()) {
const endPrep = ps.endPrep.bind(ps);
ps.endPrep = () => { (lost[m.round] ||= {})[ps.playerId] = m.gd.leftoverKeptBands.includes(ps.bandId) ? 0 : ps.funds; return endPrep(); };
}
let last = '';
const t0 = performance.now();
m.start();
sched.runUntil(() => {
const key = `${m.phase}:${m.round}`;
if (key !== last) {
last = key;
if (m.phase === 'SETTLE' || (m.phase === 'RESULT' && (m.round === m.gd.bossRound || m.round === m.gd.hiddenRound))) {
const row = { round: m.round, players: {} };
for (const ps of m.order) {
const r = m.lastResults.get(ps.playerId);
let value = 0;
let elites = 0;
for (const p of ps.board.values()) {
if (p.kind !== 'chess') continue;
const golden = m.gd.isGolden(p.id);
if (golden) elites++;
value += m.gd.chessPrice(m.gd.baseIdOf(p.id)) * (golden ? m.gd.goldenCopies : 1);
}
const active = Object.values(ps.bonds).filter((b) => b.active);
const leaked = r ? (r.leaked || []).filter((l) => l.counted !== false) : [];
row.players[ps.playerId] = {
lp: ps.lp, alive: ps.alive, level: ps.shop.level, units: ps.deployCount, bonds: active.length,
tiers: active.reduce((s, b) => s + (b.tier || 0), 0), layers: ps.activatedLayers(), value, elites,
items: [...ps.board.values()].reduce((s, p) => s + (p.kind === 'chess' ? (p.items || []).length : 0), 0),
lost: lost[m.round] ? lost[m.round][ps.playerId] ?? null : null,
killed: r ? r.killed : null, total: r ? r.total : null,
leaks: leaked.length, bountyLeaks: leaked.filter((l) => l.tag === 'bounty').length,
bountyKeys: leaked.filter((l) => l.tag === 'bounty').map((l) => l.enemyKey),
};
}
if (m.teamLp != null) row.teamLp = m.teamLp;
rec.rounds.push(row);
}
}
return summary != null;
}, { maxSteps: 5e6 });
rec.ms = performance.now() - t0;
rec.errors = errors.length + m.errorCount;
rec.victory = !!(summary && summary.victory);
rec.roundsPassed = summary ? summary.roundsPassed : 0;
rec.hidden = !!(summary && summary.hiddenReached);
rec.bossRound = m.gd.bossRound;
const alive = m.order.filter((p) => p.alive);
rec.lpLeft = rec.victory && m.teamLp != null ? m.teamLp : alive.reduce((s, p) => s + Math.max(0, p.lpAtFinal ?? p.lp), 0);
rec.players = (summary && summary.players ? summary.players : []).map((p) => ({ pid: p.playerId, band: p.bandId, rounds: p.roundsPassed, merges: p.stats.merges, spent: p.stats.gold }));
m.dispose();
return rec;
}
const tasks = [];
for (const cfg of configs) for (let i = 0; i < seeds; i++) tasks.push([cfg, seed0 + i]);
const jobs = Math.max(1, Math.min(16, Number(opt.jobs) || 1));
if (!isMainThread) {
for (const i of workerData.indexes) parentPort.postMessage({ i, rec: runOne(...tasks[i]) });
} else {
const all = new Array(tasks.length);
const note = (r) => { if (!opt.quiet) process.stderr.write(`${r.config} seed ${r.seed}: ${r.victory ? 'WIN ' : 'loss'} rounds ${r.roundsPassed} lp ${r.lpLeft} · ${Math.round(r.ms)} ms\n`); };
if (jobs === 1) {
tasks.forEach((t, i) => { all[i] = runOne(...t); note(all[i]); });
} else {
await Promise.all(Array.from({ length: jobs }, (_, j) => new Promise((resolve, reject) => {
const indexes = tasks.map((_, i) => i).filter((i) => i % jobs === j);
const w = new Worker(new URL(import.meta.url), { workerData: { argv, indexes } });
w.on('message', ({ i, rec }) => { all[i] = rec; note(rec); });
w.on('error', reject);
w.on('exit', resolve);
})));
}
if (opt.json) writeFileSync(opt.json, JSON.stringify(all));
compare([all], ['run']);
}
// ---- tables ---------------------------------------------------------------------------------------------------
function stats(records) {
const preps = records.flatMap((r) => r.preps);
const heur = preps.map((p) => p.ms - p.rehMs);
const rounds = records.flatMap((r) => r.rounds.flatMap((row) => Object.values(row.players).filter((p) => p.alive || p.leaks != null).map((p) => ({ ...p, round: row.round }))));
const at = (rd, f) => avg(rounds.filter((p) => p.round === rd && p.alive).map(f));
const lostPerPrep = rounds.filter((p) => p.lost != null).map((p) => p.lost);
const merges = records.flatMap((r) => r.players.map((p) => p.merges));
const bountyLeaks = records.reduce((s, r) => s + r.rounds.reduce((t, row) => t + Object.values(row.players).reduce((u, p) => u + (p.bountyLeaks || 0), 0), 0), 0);
const leaksAll = records.reduce((s, r) => s + r.rounds.reduce((t, row) => t + Object.values(row.players).reduce((u, p) => u + (p.leaks || 0), 0), 0), 0);
return {
n: records.length,
wins: records.filter((r) => r.victory).length,
rounds: avg(records.map((r) => r.roundsPassed)),
lpLeft: avg(records.map((r) => r.lpLeft)),
// alive when the Final Assault began: rounds passed ≥ boss round − 1 (results.js: a lost Final Assault = bossRound − 1)
reachedBoss: records.filter((r) => r.roundsPassed >= r.bossRound - 1).length,
hidden: records.filter((r) => r.hidden).length,
lost: avg(lostPerPrep),
merges: avg(merges),
leaks: leaksAll / Math.max(1, records.length),
bounties: records.reduce((s, r) => s + r.bounties.length, 0),
bountyLeaks,
errors: records.reduce((s, r) => s + r.errors, 0),
value: [4, 7, 10, 13].map((rd) => at(rd, (p) => p.value)),
tiers: [4, 7, 10, 13].map((rd) => at(rd, (p) => p.tiers)),
layers: [4, 7, 10, 13].map((rd) => at(rd, (p) => p.layers)),
elites: [4, 7, 10, 13].map((rd) => at(rd, (p) => p.elites)),
level: [4, 7, 10, 13].map((rd) => at(rd, (p) => p.level)),
prepN: preps.length,
prepP50: pct(preps.map((p) => p.ms), 50), prepP95: pct(preps.map((p) => p.ms), 95),
// the same rounds for both bots: R1–R10 (a stronger bot plays more of the expensive late rounds)
early: (() => { const e = preps.filter((p) => p.round <= 10); return { n: e.length, p50: pct(e.map((p) => p.ms), 50), p95: pct(e.map((p) => p.ms), 95), h50: pct(e.map((p) => p.ms - p.rehMs), 50), h95: pct(e.map((p) => p.ms - p.rehMs), 95) }; })(),
cpuP50: pct(preps.map((p) => p.cpu ?? 0), 50), cpuP95: pct(preps.map((p) => p.cpu ?? 0), 95),
hcpuP50: pct(preps.map((p) => (p.cpu ?? 0) - (p.rehCpu ?? 0)), 50), hcpuP95: pct(preps.map((p) => (p.cpu ?? 0) - (p.rehCpu ?? 0)), 95),
cpuSum: preps.reduce((a, p) => a + (p.cpu ?? 0), 0), hcpuSum: preps.reduce((a, p) => a + (p.cpu ?? 0) - (p.rehCpu ?? 0), 0),
pickP50: pct(records.flatMap((r) => r.picks.map((x) => x.ms)), 50), pickP95: pct(records.flatMap((r) => r.picks.map((x) => x.ms)), 95),
pickN: records.reduce((n, r) => n + r.picks.length, 0),
heurP50: pct(heur, 50), heurP95: pct(heur, 95),
rehP50: pct(preps.map((p) => p.rehMs), 50), rehP95: pct(preps.map((p) => p.rehMs), 95),
ms: records.reduce((s, r) => s + r.ms, 0),
};
}
function compare(runs, names) {
const cfgs = [...new Set(runs.flatMap((rs) => rs.map((r) => r.config)))];
const f1 = (x) => x.toFixed(1);
const f2 = (x) => x.toFixed(2);
const four = (a, f = f1) => a.map(f).join(' / ');
console.log('\n| config | run | matches | wins | rounds passed | LP left | reached boss | leaks/match | bounties (leaked) | funds lost/prep | merges/bot | errors |');
console.log('|---|---|---|---|---|---|---|---|---|---|---|---|');
for (const c of cfgs) runs.forEach((rs, i) => {
const s = stats(rs.filter((r) => r.config === c));
if (!s.n) return;
console.log(`| ${c} | ${names[i]} | ${s.n} | ${s.wins} (${Math.round((100 * s.wins) / s.n)} %) | ${f2(s.rounds)} | ${f1(s.lpLeft)} | ${s.reachedBoss} | ${f1(s.leaks)} | ${s.bounties} (${s.bountyLeaks}) | ${f2(s.lost)} | ${f2(s.merges)} | ${s.errors} |`);
});
console.log('\n| config | run | shop level R4/7/10/13 | board value R4/7/10/13 | Σ bond tiers | activated layers | elites deployed |');
console.log('|---|---|---|---|---|---|---|');
for (const c of cfgs) runs.forEach((rs, i) => {
const s = stats(rs.filter((r) => r.config === c));
if (!s.n) return;
console.log(`| ${c} | ${names[i]} | ${four(s.level)} | ${four(s.value)} | ${four(s.tiers)} | ${four(s.layers, (x) => x.toFixed(0))} | ${four(s.elites)} |`);
});
console.log('\n| config | run | leaks per alive bot and round R1…R15 |');
console.log('|---|---|---|');
for (const c of cfgs) runs.forEach((rs, i) => {
const recs = rs.filter((r) => r.config === c);
if (!recs.length) return;
const cells = [];
for (let rd = 1; rd <= 15; rd++) {
const v = recs.flatMap((r) => r.rounds.filter((row) => row.round === rd).flatMap((row) => Object.values(row.players).filter((p) => p.leaks != null && p.total != null)));
if (v.length) cells.push(`${rd}:${avg(v.map((p) => p.leaks)).toFixed(1)}`);
}
console.log(`| ${c} | ${names[i]} | ${cells.join(' ')} |`);
});
const calib = runs.map((rs) => {
const bins = [[0, 0.2], [0.2, 0.5], [0.5, 0.8], [0.8, 0.95], [0.95, 1.01]].map(([lo, hi]) => ({ lo, hi, n: 0, units: 0, leaked: 0 }));
for (const r of rs) for (const b of r.bounties) {
if (b.p == null) continue;
const bin = bins.find((x) => b.p >= x.lo && b.p < x.hi);
const battles = Math.max(1, Math.min(2, b.battles || 1));
let leaked = 0;
for (const row of r.rounds) if (row.round >= b.round && row.round < b.round + battles) leaked += ((row.players[b.pid] || {}).bountyKeys || []).filter((k) => k === b.enemyKey).length;
bin.n++; bin.units += (b.count || 1) * battles; bin.leaked += leaked;
}
return bins;
});
if (calib.some((bins) => bins.some((b) => b.n))) {
console.log('\n| run | bounty kill-chance estimate (bot.bountyKillChance) → share of bounty enemies actually killed, picks per bin |');
console.log('|---|---|');
runs.forEach((rs, i) => { if (calib[i].some((b) => b.n)) console.log(`| ${names[i]} | ${calib[i].filter((b) => b.n).map((b) => `p ${b.lo}–${Math.min(1, b.hi)}: ${(100 * (1 - b.leaked / Math.max(1, b.units))).toFixed(0)} % (${b.n})`).join(' · ')} |`); });
// the picks against what the draft offered (records with `best`)
console.log('\n| config | run | bounty picks | p < 0.5 picked while a p ≥ 0.5 card was offered | drafts with no card p ≥ 0.5 |');
console.log('|---|---|---|---|---|');
for (const c of cfgs) runs.forEach((rs, i) => {
const b = rs.filter((r) => r.config === c).flatMap((r) => r.bounties).filter((x) => x.best != null);
if (b.length) console.log(`| ${c} | ${names[i]} | ${b.length} | ${b.filter((x) => x.p < 0.5 && x.best >= 0.5).length} | ${b.filter((x) => x.best < 0.5).length} |`);
});
}
console.log('\n| config | run | bot preps | prep CPU ms p50 / p95 | heuristics CPU ms p50 / p95 | Σ prep CPU s | Σ heuristics CPU s | per match: prep CPU s |');
console.log('|---|---|---|---|---|---|---|---|');
for (const c of cfgs) runs.forEach((rs, i) => {
const s = stats(rs.filter((r) => r.config === c));
if (!s.n) return;
console.log(`| ${c} | ${names[i]} | ${s.prepN} | ${f1(s.cpuP50)} / ${f1(s.cpuP95)} | ${f1(s.hcpuP50)} / ${f1(s.hcpuP95)} | ${f1(s.cpuSum / 1000)} | ${f1(s.hcpuSum / 1000)} | ${f2(s.cpuSum / 1000 / s.n)} |`);
});
console.log('\n| config | run | bot preps | prep ms p50 / p95 | heuristics ms p50 / p95 | rehearsal ms p50 / p95 | R1–R10 preps: prep / heuristics p50 / p95 | 机变 pick ms p50 / p95 (n) | match wall s |');
console.log('|---|---|---|---|---|---|---|---|---|');
for (const c of cfgs) runs.forEach((rs, i) => {
const s = stats(rs.filter((r) => r.config === c));
if (!s.n) return;
const e = s.early;
console.log(`| ${c} | ${names[i]} | ${s.prepN} | ${f1(s.prepP50)} / ${f1(s.prepP95)} | ${f1(s.heurP50)} / ${f1(s.heurP95)} | ${f1(s.rehP50)} / ${f1(s.rehP95)} | ${e.n}: ${f1(e.p50)} / ${f1(e.p95)} · ${f1(e.h50)} / ${f1(e.h95)} | ${f2(s.pickP50)} / ${f2(s.pickP95)} (${s.pickN}) | ${f1(s.ms / 1000)} |`);
});
}