feedback1 integrate: WF smarter bots (same decision time)
# Conflicts: # docs/META.md # server/match/bot.js
This commit is contained in:
commit
88d419b095
5 files changed
+1319
-145
No files matched your search
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
});
|
||||
@@ -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)} |`);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user