feedback1 WF: smarter AI players (merge hunting, bond plan, bounty threat model, item carriers, armour-aware DPS, tactician points)
This commit is contained in:
1 parent
7f134737ce
commit
7f20776b9e
4 files changed
+1077
-113
No files matched your search
+35
-13
@@ -170,19 +170,37 @@ The rest of this section is the legacy server-run mode
|
|||||||
另一组队友的战场情况" → `BAD_TARGET 'other group hidden'`); eliminated / departed players spectate any field.
|
另一组队友的战场情况" → `BAD_TARGET 'other group hidden'`); eliminated / departed players spectate any field.
|
||||||
|
|
||||||
### 1.5 AI player (bot.js)
|
### 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
|
Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), completes the merges it can afford,
|
||||||
(L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free levels always), then
|
then levels on a curve (L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free
|
||||||
spends the rest on merge progress, bond thresholds around a focus core bond (most owned members, ties → most copies
|
levels always), then spends the rest. It builds around a **focus core bond** (owned members × 10 + members the shop
|
||||||
left in the shared pool), role needs (2 blockers, anti-air when the wave flies, ≤ 2 healers), 特质 that keep adding
|
can still bring at its level + banked layers + 6 for last round's focus, − 6 when a teammate already builds it: the
|
||||||
layers (every prep / refresh — the layer engines) and items for free carriers; refreshes while a purchase stays
|
pool is shared) and a **second bond** (most owned members, next threshold within reach); its keepers are the deployed
|
||||||
affordable; sells bench chess that neither make the lineup nor build toward something (a live pair, the focus bond,
|
operators, the focus / second members and chess of tier ≥ shop level − 1. With a full board a purchase must be merge
|
||||||
an elite). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`). Placement
|
progress or a lineup upgrade **and** be worth more than the refreshes its price would pay for: a refresh is worth
|
||||||
uses the round's enemy preview: every route is traced over the own board (ground: the stage's device-aware ground
|
Σ over the held pairs of P(the refresh shows the third copy) × the value of an elite (the shop's copy-weighted odds
|
||||||
paths; flyers: through their checkpoints) and weighted by its enemies; an exposure model (time on each tile × DPS of
|
over the remaining pool; an elite also brings the merge's free pick of the next tier, so any pair is worth completing)
|
||||||
the units covering it, blocker hold time, anti-air only on flying routes) is maximized greedily (blockers, then
|
— otherwise it refreshes. Up to 4 pairs wait on the bench (6 spare units, pairs first); a third copy it cannot afford
|
||||||
damage dealers, then healers). Boss rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the
|
freezes the shop for the next round (freeze is free). Purchase scores: merge progress > bond thresholds > role needs
|
||||||
own board (rows −7, the right player of a pair mirrored, only the routes that end on its half) and the leader counts
|
(2 blockers, anti-air when the wave flies, ≤ 2 healers) > tier, the 特质 that keep adding layers, and armour fit (the
|
||||||
as 10 tough enemies with a 30 s dwell on its first tiles, so the damage dealers reach stationary leaders.
|
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, 博士投影 on the strongest normal operator, 突变细胞 on the weakest
|
||||||
|
normal one below VI, 身份牌 / 通讯机 / 寻呼模块 on a focus member; 画卷 copies the most valuable deployed operator; 教鞭 /
|
||||||
|
“神秘顾客” are used only after a perfect battle (otherwise dropped / destroyed for funds). 机变: 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) — the bot never takes a card its board cannot beat while a beatable one is offered; 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, and a tile the server's `g.move` refuses is skipped for
|
||||||
|
the next best one (legality is never duplicated: the planner reads `board.js legalTiles` on the deploy map). 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
|
**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)
|
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
|
and keeps the one with the fewest leaks; a candidate whose counted leaks already exceed the best finished one's stops
|
||||||
@@ -616,6 +634,10 @@ receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
|
|||||||
* `node tools/matchrun.mjs --mode coop --difficulty HARD --players 4 --seeds 20` — per-round balancing summary / aggregate
|
* `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
|
(`--check` audit, `--errors` per-source error table, `--lp N` / `--layers N` to reach late rounds — the boost stops at
|
||||||
999 per bond —, `--rehearsal N`).
|
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):
|
* `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
|
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`.
|
and kill time; `--tuning off` (research numbers), `--legacy-time`, `--profile weak|strong`, `--bots N`, `--json`.
|
||||||
|
|||||||
+554
-100
@@ -7,21 +7,30 @@
|
|||||||
//
|
//
|
||||||
// Prep routine (botPrep):
|
// Prep routine (botPrep):
|
||||||
// 1. take a pending reward offer (merge progress, bond synergy, tier)
|
// 1. take a pending reward offer (merge progress, bond synergy, tier)
|
||||||
// 2. sell bench chess that neither make the lineup nor build toward something (sellJunk), buy toward a full board
|
// 2. economy. The bond plan (bondPlan): a focus core bond — owned members, members the shop can still bring at this
|
||||||
// first (the deploy cap is 8 from round 1; leftover funds are lost at prep end), level the 调度中心 on a curve
|
// level, banked layers, last round's focus (commit instead of flip-flopping), minus a teammate's focus (the shop
|
||||||
// (free levels always; the −1/round discount is waited for early), then keep buying / refreshing with what is
|
// pool is shared) — and a second bond (most owned members, next threshold within reach). Keepers: the deployed
|
||||||
// left: merge progress (pairs → elites) > bond thresholds (focus core bond, add-ons 2/3) > role needs
|
// operators, focus / second members, chess of tier ≥ shop level − 1. Sell bench chess that neither make the
|
||||||
// (blockers, anti-air when the wave flies, one or two healers) > tier; with a full board a buy must improve the
|
// lineup nor build toward something (a pair whose third copy can still come, a keeper, an elite; ≤ BENCH_BUDGET
|
||||||
// best lineup. Items only when a deployed operator can carry them. A merge that consumes a deployed copy leaves
|
// spare units, pairs first), buy toward a full board (the deploy cap is 8 from round 1), complete every merge it
|
||||||
// the elite on that copy's tile (PRTS 卫戍协议/帮助, PlayerState._mergeChess): nothing here assumes it in the hand —
|
// can afford — before a level-up can spend the funds —, level the 调度中心 on a curve (free levels always; the
|
||||||
// steps 3–4 plan it like any owned unit (kept, moved or benched).
|
// −1/round discount is waited for early), then spend the rest (leftover funds are lost at prep end): with a full
|
||||||
// 3. lineup: the deployed set maximizes unit value + activated bond tiers (exact counting via computeBonds) +
|
// board a purchase must be merge progress or a lineup upgrade AND worth more than the refreshes its price would
|
||||||
// composition (chooseLineup: greedy seed + swap hill-climbing).
|
// pay for — refreshValue: Σ over the held pairs of P(a refresh shows the third copy, the shop's copy-weighted odds
|
||||||
|
// over the shared pool) × MERGE_HIT (an elite plus the merge's free pick of the next tier) — else it refreshes. A
|
||||||
|
// third copy it cannot afford freezes the shop for the next round (maybeFreeze). Purchase scores: merge progress >
|
||||||
|
// bond thresholds (focus, second) > role needs (blockers, anti-air when the wave flies, one or two healers) >
|
||||||
|
// tier and armour fit (the share of a dealer's damage the round's DEF / RES lets through, effDps). A merge that
|
||||||
|
// consumes a deployed copy leaves the elite on that copy's tile (PRTS 卫戍协议/帮助, PlayerState._mergeChess):
|
||||||
|
// nothing here assumes it in the hand — steps 3–4 plan it like any owned unit (kept, moved or benched).
|
||||||
|
// 3. lineup: the deployed set maximizes unit value (tier, elite, items, armour fit) + activated bond tiers (exact
|
||||||
|
// counting via computeBonds; every deployed focus member counts toward the next threshold) + composition
|
||||||
|
// (chooseLineup: greedy seed + swap hill-climbing).
|
||||||
// 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground
|
// 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground
|
||||||
// routes on the stage's device-aware ground paths, flying routes through their checkpoints; 近地悬浮 enemies walk
|
// routes on the stage's device-aware ground paths, flying routes through their checkpoints; 近地悬浮 enemies walk
|
||||||
// the ground path but count as flyers) and weighted by their enemies; an exposure model (tile time × DPS of the
|
// the ground path but count as flyers) and weighted by their enemies; an exposure model (tile time × DPS of the
|
||||||
// covering units, blocker hold time, flyers only for anti-air) is maximized greedily — blockers first, then damage
|
// covering units against the round's DEF / RES, blocker hold time, flyers only for anti-air) is maximized
|
||||||
// dealers by DPS, then healers — over every
|
// greedily — blockers first, then damage dealers by DPS, then healers — over every
|
||||||
// (legal tile, direction) pair: each unit's range grid is rotated per direction (DESIGN §3; RIGHT is tried first
|
// (legal tile, direction) pair: each unit's range grid is rotated per direction (DESIGN §3; RIGHT is tried first
|
||||||
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
|
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
|
||||||
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
|
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
|
||||||
@@ -34,20 +43,31 @@
|
|||||||
// generators that yield between whole actions (never with a transient board) — the same actions in the same
|
// generators that yield between whole actions (never with a transient board) — the same actions in the same
|
||||||
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
|
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
|
||||||
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
|
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
|
||||||
// them on the best remaining tiles, 凯瑟琳's 支援装置 next to the best operator no device faces yet, facing it.
|
// them on the best remaining tiles — a tactician's 援军 on a tile of its attack range (its tactical point) —, 凯瑟琳's
|
||||||
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler)
|
// 支援装置 next to the best operator no device faces yet, facing it. Legality is the server's: the bot plans on the
|
||||||
|
// deploy map (board.js legalTiles) and a tile g.move refuses is skipped for the next best one.
|
||||||
|
// 5. items by what they do (itemTarget): equipment on the strongest deployed damage dealers (survival items on
|
||||||
|
// blockers, bond signature items on a member), 信标 on a bench single, 拟态物质 on a pair, 博士投影 on the strongest
|
||||||
|
// normal operator, 突变细胞 on the weakest one below VI, bond items on a focus member; Arts (useArt): 画卷 copies the
|
||||||
|
// most valuable deployed operator, 教鞭 / “神秘顾客” only after a perfect battle (consume-on-equip items / Arts
|
||||||
|
// only with a handler)
|
||||||
// 6. resolve the temp slots, keep one hand slot free, then Ready.
|
// 6. resolve the temp slots, keep one hand slot free, then Ready.
|
||||||
|
// 机变 (botPickCard): a bounty by its expected payout minus the expected LP lost — bountyKillChance runs the exposure
|
||||||
|
// model for that one enemy against the own board, so a card the board cannot beat is the last pick —; tactic cards by
|
||||||
|
// what they act on (a 盟誓 / 驰援 card on the own bonds, 升华 …); items by tier and use.
|
||||||
// Placement quality (tools/matchrun sweeps, research-faithful waves): the planner beats random layouts by ≈ 8 points
|
// Placement quality (tools/matchrun sweeps, research-faithful waves): the planner beats random layouts by ≈ 8 points
|
||||||
// of kill rate and rehearsal adds ≈ 5 more; see docs/META.md §1.5.
|
// of kill rate and rehearsal adds ≈ 5 more; see docs/META.md §1.5. Old vs new decisions on the same seeds:
|
||||||
|
// tools/botbench.mjs.
|
||||||
|
|
||||||
import { GEO } from '../../shared/constants.js';
|
import { GEO } from '../../shared/constants.js';
|
||||||
import { deriveSeed } from '../sim/rng.js';
|
import { deriveSeed } from '../sim/rng.js';
|
||||||
import { ASPD_MIN } from '../sim/constants.js';
|
import { ASPD_MIN } from '../sim/constants.js';
|
||||||
import { freeSlot, legalTiles, canPlace, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
import { freeSlot, countFree, legalTiles, canPlace, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
||||||
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
|
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
|
||||||
import { itemKey } from './gamedata.js';
|
import { itemKey } from './gamedata.js';
|
||||||
import { computeBonds } from './bondsMeta.js';
|
import { computeBonds } from './bondsMeta.js';
|
||||||
import { withBounties } from './waves.js';
|
import { withBounties, isFlyKey } from './waves.js';
|
||||||
|
import { mitigate } from '../sim/damage.js';
|
||||||
import { HOVER_KEYS } from '../sim/content/enemies.js';
|
import { HOVER_KEYS } from '../sim/content/enemies.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +80,25 @@ export function runSteps(gen) {
|
|||||||
return r.value;
|
return r.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lineup value of each deployed member of the focus bond (up to its top threshold). */
|
||||||
|
const FOCUS_MEMBER = 4;
|
||||||
|
/** Value per unit of armour fit (the share of a dealer's damage the round's DEF / RES lets through, armorFit). */
|
||||||
|
const ARMOR_WEIGHT = 10;
|
||||||
|
/** Least buy score of a purchase once the board is full (below it the bot refreshes instead). */
|
||||||
|
const BUY_FULL_MIN = 8;
|
||||||
|
/**
|
||||||
|
* Value of completing an elite (+25 % stats, skill level 7, and the merge's free pick of three tier min(level + 1, 6)
|
||||||
|
* chess — worth having for any pair, a cheap one included) and of making a keeper's pair; refreshValue weighs them by
|
||||||
|
* the shop odds.
|
||||||
|
*/
|
||||||
|
const MERGE_HIT = 36;
|
||||||
|
const PAIR_HIT = 8;
|
||||||
|
/** Pairs the bench holds at once while refreshes hunt their third copies (each takes a hand slot or two). */
|
||||||
|
const MAX_PAIRS = 4;
|
||||||
|
/** Refreshes per prep at most (funds bound them first). */
|
||||||
|
const MAX_REFRESHES = 16;
|
||||||
|
/** Spare bench units kept beyond the lineup at the prep start (pairs first). */
|
||||||
|
const BENCH_BUDGET = 6;
|
||||||
/** Shop level the bot aims for at the start of round r (index = round). */
|
/** Shop level the bot aims for at the start of round r (index = round). */
|
||||||
const LEVEL_TARGET = [1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 6];
|
const LEVEL_TARGET = [1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 6];
|
||||||
const TIER_POWER = [0, 10, 12.5, 15, 18, 21.5, 25];
|
const TIER_POWER = [0, 10, 12.5, 15, 18, 21.5, 25];
|
||||||
@@ -85,7 +124,13 @@ export function botPickBand(m, ps) {
|
|||||||
return pairs[pairs.length - 1][0];
|
return pairs[pairs.length - 1][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 机变 card pick among the untaken indexes: items and team buffs first; bounties (extra enemies) last. */
|
/**
|
||||||
|
* 机变 card pick among the untaken indexes (a draft is one family: generateDraft). Bounties (extra enemies in the own
|
||||||
|
* next battles) score their expected payout minus the expected LP lost (bountyScore: the enemy against the own board,
|
||||||
|
* so an unbeatable one is the last pick); items by tier and use (an item the bot cannot use is worth little); tactic
|
||||||
|
* cards by what they act on (tacticScore: a 盟誓 / 驰援 card on the own bonds, 升华 …). Across families items and team
|
||||||
|
* buffs come before bounties.
|
||||||
|
*/
|
||||||
export function botPickCard(m, ps, cards, available) {
|
export function botPickCard(m, ps, cards, available) {
|
||||||
let best = available[0];
|
let best = available[0];
|
||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
@@ -93,15 +138,109 @@ export function botPickCard(m, ps, cards, available) {
|
|||||||
const c = cards[i];
|
const c = cards[i];
|
||||||
if (!c) continue;
|
if (!c) continue;
|
||||||
let s = 0;
|
let s = 0;
|
||||||
if (c.kind === 'item') s = 8 + (c.tier || 1) * 4;
|
if (c.kind === 'item') s = 8 + (c.tier || 1) * 4 + (canUseItem(m, ps, { id: c.id }) ? 0 : -8);
|
||||||
else if (c.kind === 'bounty') s = (c.payout === 'kill' ? 4 : 2) + (c.coin || 0) - (c.tier || 1) * 2 - Math.max(0, (c.count || 1) - 1) - (c.rounds >= 90 ? 4 : 0);
|
else if (c.kind === 'bounty') s = bountyScore(m, ps, c);
|
||||||
else if (c.kind === 'tactic') s = (c.team ? 12 : 8) + (c.tacticKind === 'ally' ? 4 : 0);
|
else if (c.kind === 'tactic') s = tacticScore(m, ps, c);
|
||||||
s += m.rngBots() * 0.5;
|
s += m.rngBots() * 0.5;
|
||||||
if (s > bestScore) { bestScore = s; best = i; }
|
if (s > bestScore) { bestScore = s; best = i; }
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chance that the own board as it stands (the 机变 draft comes before the prep) kills one enemy of a bounty card: the
|
||||||
|
* planner's exposure model (Layout) for that single enemy — its HP × the round's multiplier against Σ over the tiles of
|
||||||
|
* its route (the busiest route of its kind: flyers the flying routes, else the ground ones) of the time it spends there
|
||||||
|
* × the DPS of every deployed unit covering the tile against its DEF / RES (dpsVs; flyers anti-air only), plus the
|
||||||
|
* first blocker's hold on the ground. 0.5 without data.
|
||||||
|
*/
|
||||||
|
export function bountyKillChance(m, ps, card) {
|
||||||
|
const gd = m.gd;
|
||||||
|
const e = card && gd.enemy(card.enemyKey);
|
||||||
|
if (!e || !e.stats) return 0.5;
|
||||||
|
const model = fieldModel(m, ps);
|
||||||
|
const sc = gd.enemyScale(m.round);
|
||||||
|
const hp = Math.max(1, (e.stats.maxHp || 1000) * sc.hpMul);
|
||||||
|
const def = e.stats.def || 0;
|
||||||
|
const res = e.stats.res || 0;
|
||||||
|
const fly = isFlyKey(gd, card.enemyKey) || HOVER.has(card.enemyKey);
|
||||||
|
const same = model.routes.filter((r) => !!r.fly === fly);
|
||||||
|
const pool = same.length ? same : model.routes;
|
||||||
|
if (!pool.length) return 0.5;
|
||||||
|
const route = pool.reduce((a, b) => (b.n > a.n ? b : a));
|
||||||
|
const speed = Math.max(0.05, (e.stats.moveSpeed || 1) * sc.speedMul);
|
||||||
|
const tileTime = Math.max(0.4, Math.min(6, 1 / (speed * 0.5)));
|
||||||
|
const units = [];
|
||||||
|
for (const [k, p] of ps.board) {
|
||||||
|
const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id);
|
||||||
|
if (!rec) continue;
|
||||||
|
const [r, c] = parseKey(k);
|
||||||
|
const u = unitOf(rec, k, r, c, pieceDir(p), model);
|
||||||
|
u.vs = dpsVs(rec, def, res) * u.atkMul;
|
||||||
|
units.push(u);
|
||||||
|
}
|
||||||
|
let exp = 0;
|
||||||
|
let held = false;
|
||||||
|
for (const k of route.tiles) {
|
||||||
|
let t = tileTime;
|
||||||
|
if (!fly && !held && units.some((u) => u.block > 0 && u.key === k)) { t += LAYOUT_PARAMS.hold; held = true; }
|
||||||
|
let dps = 0;
|
||||||
|
for (const u of units) if (u.cover.has(k) && (fly ? u.air : u.ground)) dps += u.vs;
|
||||||
|
exp += t * dps;
|
||||||
|
}
|
||||||
|
return 1 - Math.exp(-exp / (BOUNTY_KILL * hp));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposure (× HP) a bounty enemy needs for a 63 % kill chance (conservative: it fights beside the wave). */
|
||||||
|
const BOUNTY_KILL = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected value of a bounty card: per battle it lasts, P(kill) × coin (kill payout) or P(kill)^n × coin (perfect
|
||||||
|
* payout) minus the expected leaks × the value of an LP (2 + 20 / LP: dearer when low).
|
||||||
|
*/
|
||||||
|
function bountyScore(m, ps, card) {
|
||||||
|
const p = bountyKillChance(m, ps, card);
|
||||||
|
const n = Math.max(1, card.count || 1);
|
||||||
|
const battles = Math.max(1, Math.min(3, card.rounds || 1));
|
||||||
|
const lpCost = 2 + 20 / Math.max(1, ps.lp);
|
||||||
|
const coin = Math.max(0, card.coin || 0);
|
||||||
|
const gain = card.payout === 'perfect' ? p ** n * coin : p * n * coin;
|
||||||
|
return battles * (gain - (1 - p) * n * lpCost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 机变 tactic card value by its effect (data/effects.json buffs), team cards a little more (teammates get it too). */
|
||||||
|
function tacticScore(m, ps, card) {
|
||||||
|
const eff = m.gd.effect(card.id);
|
||||||
|
const buffs = eff && Array.isArray(eff.buffs) ? eff.buffs.filter(Boolean) : [];
|
||||||
|
const owned = ownedBonds(m, ps);
|
||||||
|
const plan = bondPlan(m, ps, owned);
|
||||||
|
const rel = (b) => (b === plan.focus ? 1 : b === plan.second ? 0.7 : (ps.bonds[b] && ps.bonds[b].active) ? 0.5 : owned.counts.get(b) ? 0.25 : 0.05);
|
||||||
|
let s = 8;
|
||||||
|
for (const b of buffs) {
|
||||||
|
const bb = b.bb || {};
|
||||||
|
const bs = b.bbStr || {};
|
||||||
|
const count = Number.isFinite(bb.count) ? bb.count : 1;
|
||||||
|
switch (b.key) {
|
||||||
|
case 'global_special_choice_bond_addlayer': {
|
||||||
|
let r = 0;
|
||||||
|
for (const id of String(bs.bond_list || '').split(',').map((x) => x.trim()).filter(Boolean)) r = Math.max(r, rel(id));
|
||||||
|
s = 4 + r * count * 1.2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'single_special_choice_gain_bond_chess': s = 5 + rel(bs.bond) * 10; break;
|
||||||
|
case 'single_special_choice_gloden_char_chess': s = 16; break;
|
||||||
|
case 'single_special_choice_gloden_equip_chess': s = 9; break;
|
||||||
|
case 'global_special_choice_gain_equip': s = 10; break;
|
||||||
|
case 'global_special_choice_refresh_free': s = 7 + count * 0.5; break;
|
||||||
|
case 'global_special_choice_gain_coin': s = 4 + count; break;
|
||||||
|
// 锐利 needs an empty hand at the prep end — the bot keeps a bench
|
||||||
|
case 'global_special_choice_prep_finish_bench_at_most': s = 3; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s + (card.team ? 3 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------------
|
||||||
// evaluation helpers
|
// evaluation helpers
|
||||||
|
|
||||||
@@ -127,36 +266,88 @@ function ownedBonds(m, ps, exclude = null) {
|
|||||||
return { counts, bases: seen };
|
return { counts, bases: seen };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remaining pool copies of a bond's members (what the shop can still offer). */
|
/**
|
||||||
function bondSupply(m, bondId) {
|
* Per bond, from one pass over the shared pool: `reach` = distinct unowned members of tier ≤ shop level + 1 with copies
|
||||||
let n = 0;
|
* left (what the shop can still bring soon — a teammate draining a bond, or a bond of high-tier members only, makes it
|
||||||
|
* a poor target), `supply` = the copies left of all its members.
|
||||||
|
*/
|
||||||
|
function bondPoolStats(m, ps, owned) {
|
||||||
|
const reach = new Map();
|
||||||
|
const supply = new Map();
|
||||||
|
const maxTier = Math.min(6, ps.shop.level + 1);
|
||||||
for (const [id, e] of m.pool.entries) {
|
for (const [id, e] of m.pool.entries) {
|
||||||
|
if (!(e.left > 0)) continue;
|
||||||
const c = m.gd.chess(id);
|
const c = m.gd.chess(id);
|
||||||
if (c && Array.isArray(c.bonds) && c.bonds.includes(bondId)) n += e.left;
|
if (!c || !Array.isArray(c.bonds)) continue;
|
||||||
|
const reachable = c.tier <= maxTier && !owned.bases.has(m.gd.baseIdOf(id));
|
||||||
|
for (const b of c.bonds) {
|
||||||
|
supply.set(b, (supply.get(b) || 0) + e.left);
|
||||||
|
if (reachable) reach.set(b, (reach.get(b) || 0) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return n;
|
return { reach, supply };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bond the bot builds around: the core bond with the most owned members (≥ 1), ties broken by the copies the
|
* The bonds the bot builds around (cached per round and owned set): `focus` = the core bond with the best
|
||||||
* shared pool still holds for it (bonds drained by teammates or disabled this match are poor targets). Cached per
|
* owned members × 10 + reachable members (bondPoolStats, ≤ 4) + layers already banked (≤ 6) + 6 for last round's focus
|
||||||
* round (it only changes slowly).
|
* (commit instead of flip-flopping between equal bonds) − 6 when a teammate already builds it (the shop pool is
|
||||||
|
* shared); `second` = the other live bond (core or add-on) with the most owned members whose next threshold is
|
||||||
|
* within reach (≥ 1 owned member). `ps._botFocusId` keeps the focus across rounds; teammates read it.
|
||||||
*/
|
*/
|
||||||
function focusBond(m, ps, owned) {
|
function bondPlan(m, ps, owned) {
|
||||||
const key = `${m.round}|${[...owned.counts.entries()].map(([k, v]) => k + v).join()}`;
|
const key = `${m.round}|${ps.shop.level}|${[...owned.counts.entries()].map(([k, v]) => k + v).join()}`;
|
||||||
if (ps._botFocus && ps._botFocus.key === key) return ps._botFocus.id;
|
if (ps._botFocus && ps._botFocus.key === key) return ps._botFocus;
|
||||||
let best = null;
|
const prev = ps._botFocusId ?? null;
|
||||||
|
const mates = new Set();
|
||||||
|
for (const p of m.alivePlayers()) if (p !== ps && p._botFocusId) mates.add(p._botFocusId);
|
||||||
|
let focus = null;
|
||||||
let bestS = 0;
|
let bestS = 0;
|
||||||
|
const pool = bondPoolStats(m, ps, owned);
|
||||||
for (const id of m.gd.bondIds) {
|
for (const id of m.gd.bondIds) {
|
||||||
const b = m.gd.bond(id);
|
const b = m.gd.bond(id);
|
||||||
if (!b || !b.isCore || m.gd.modeInactiveBonds.has(id)) continue;
|
if (!b || !b.isCore || m.gd.modeInactiveBonds.has(id)) continue;
|
||||||
const k = owned.counts.get(id) || 0;
|
const k = owned.counts.get(id) || 0;
|
||||||
if (!k) continue;
|
if (!k) continue;
|
||||||
const s = k * 100 + Math.min(99, bondSupply(m, id));
|
const s = k * 10 + Math.min(4, pool.reach.get(id) || 0) + Math.min(6, (ps.layers[id] || 0) / 10)
|
||||||
if (s > bestS) { bestS = s; best = id; }
|
+ (id === prev ? 6 : 0) - (mates.has(id) ? 6 : 0) + Math.min(0.9, (pool.supply.get(id) || 0) / 100);
|
||||||
|
if (s > bestS) { bestS = s; focus = id; }
|
||||||
}
|
}
|
||||||
ps._botFocus = { key, id: best };
|
let second = null;
|
||||||
return best;
|
let bestK = 0;
|
||||||
|
for (const id of m.gd.bondIds) {
|
||||||
|
if (id === focus || m.gd.modeInactiveBonds.has(id)) continue;
|
||||||
|
const b = m.gd.bond(id);
|
||||||
|
if (!b || b.thresholdTemplate === 'count_threshold_downward' || !Array.isArray(b.thresholds) || !b.thresholds.length) continue;
|
||||||
|
const k = owned.counts.get(id) || 0;
|
||||||
|
if (!k) continue;
|
||||||
|
const next = b.thresholds.find((t) => t > k);
|
||||||
|
const s = k * 10 + (next != null && next - k <= 1 ? 5 : 0) + (b.isCore ? 0 : 2);
|
||||||
|
if (s > bestK) { bestK = s; second = id; }
|
||||||
|
}
|
||||||
|
if (focus) ps._botFocusId = focus;
|
||||||
|
ps._botFocus = { key, focus, second };
|
||||||
|
return ps._botFocus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies a merge takes (3; 风丸 2). */
|
||||||
|
const mergeNeed = (m, base) => m.gd.mergeCount(base) || 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bases worth merging ("keepers"): the deployed operators, the owned members of the focus / second bond, and owned
|
||||||
|
* chess of a tier ≥ shop level − 1. A pair of anything else only clutters the bench and is sold later at a loss
|
||||||
|
* (every sale returns 1).
|
||||||
|
*/
|
||||||
|
function keeperBases(m, ps, plan) {
|
||||||
|
const out = new Set();
|
||||||
|
for (const p of ps.board.values()) if (p.kind === 'chess') out.add(m.gd.baseIdOf(p.id));
|
||||||
|
for (const p of ps.allChess()) {
|
||||||
|
const c = chessRec(m, p.id);
|
||||||
|
if (!c) continue;
|
||||||
|
const bonds = c.bonds || [];
|
||||||
|
if ((plan.focus && bonds.includes(plan.focus)) || (plan.second && bonds.includes(plan.second)) || c.tier >= ps.shop.level - 1) out.add(m.gd.baseIdOf(p.id));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Role census of the owned chess. */
|
/** Role census of the owned chess. */
|
||||||
@@ -173,9 +364,14 @@ function roles(m, ps) {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bond value of adding chess `c` to what is owned (owned counts exclude it). */
|
/**
|
||||||
function bondValue(m, c, owned, focus) {
|
* Bond value of adding chess `c` to what is owned (owned counts exclude it). `second`: the second bond (bondPlan);
|
||||||
|
* once the focus has 3 owned members a member of another core bond is worth less unless it reaches a threshold (the
|
||||||
|
* deploy cap holds about 6 members of one core bond + 2 others).
|
||||||
|
*/
|
||||||
|
function bondValue(m, c, owned, focus, second = null) {
|
||||||
let v = 0;
|
let v = 0;
|
||||||
|
const committed = focus && (owned.counts.get(focus) || 0) >= 3;
|
||||||
for (const b of (c && c.bonds) || []) {
|
for (const b of (c && c.bonds) || []) {
|
||||||
const bond = m.gd.bond(b);
|
const bond = m.gd.bond(b);
|
||||||
if (!bond || m.gd.modeInactiveBonds.has(b)) continue;
|
if (!bond || m.gd.modeInactiveBonds.has(b)) continue;
|
||||||
@@ -183,10 +379,13 @@ function bondValue(m, c, owned, focus) {
|
|||||||
const th = Array.isArray(bond.thresholds) && bond.thresholds.length ? bond.thresholds : [bond.activeCount || 2];
|
const th = Array.isArray(bond.thresholds) && bond.thresholds.length ? bond.thresholds : [bond.activeCount || 2];
|
||||||
const w = bond.isCore ? 1.4 : 1;
|
const w = bond.isCore ? 1.4 : 1;
|
||||||
v += (2 + n * 2) * w;
|
v += (2 + n * 2) * w;
|
||||||
if (th.includes(n + 1)) v += 10 * w;
|
const hits = th.includes(n + 1);
|
||||||
|
if (hits) v += 10 * w;
|
||||||
else if (th.some((t) => t > n + 1 && t - (n + 1) <= 1)) v += 3 * w;
|
else if (th.some((t) => t > n + 1 && t - (n + 1) <= 1)) v += 3 * w;
|
||||||
if (bond.thresholdTemplate === 'count_threshold_downward') v -= n > 0 ? 12 : 0; // 独行 breaks with a second member
|
if (bond.thresholdTemplate === 'count_threshold_downward') v -= n > 0 ? 12 : 0; // 独行 breaks with a second member
|
||||||
if (b === focus) v += 10;
|
if (b === focus) v += 10;
|
||||||
|
else if (b === second) v += 4;
|
||||||
|
else if (committed && bond.isCore && !hits) v -= 4;
|
||||||
}
|
}
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -229,6 +428,7 @@ function unitBase(m, piece, ctx) {
|
|||||||
if (m.round <= 11) v += traitsOf(m, c).recurring * 4;
|
if (m.round <= 11) v += traitsOf(m, c).recurring * 4;
|
||||||
if (c.attackKind === 'none' && !isHealer(c)) v -= 6;
|
if (c.attackKind === 'none' && !isHealer(c)) v -= 6;
|
||||||
if (ctx.fly > 0 && hitsFly(c)) v += 2;
|
if (ctx.fly > 0 && hitsFly(c)) v += 2;
|
||||||
|
if (ctx.model && !isHealer(c)) v += ARMOR_WEIGHT * (armorFit(ctx.model, c) - 0.6);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +436,7 @@ function unitBase(m, piece, ctx) {
|
|||||||
function pieceValue(m, ps, piece, ctx) {
|
function pieceValue(m, ps, piece, ctx) {
|
||||||
const c = chessRec(m, piece.id);
|
const c = chessRec(m, piece.id);
|
||||||
if (!c) return 0;
|
if (!c) return 0;
|
||||||
return unitBase(m, piece, ctx) + bondValue(m, c, ownedBonds(m, ps, piece.uid), ctx.focus) * 0.8;
|
return unitBase(m, piece, ctx) + bondValue(m, c, ownedBonds(m, ps, piece.uid), ctx.focus, ctx.second) * 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -253,6 +453,12 @@ function lineupScore(m, ps, set, ctx) {
|
|||||||
let v = 0;
|
let v = 0;
|
||||||
for (const p of set) v += unitBase(m, p, ctx);
|
for (const p of set) v += unitBase(m, p, ctx);
|
||||||
for (const [id, b] of Object.entries(bonds)) {
|
for (const [id, b] of Object.entries(bonds)) {
|
||||||
|
// every deployed focus member counts on the way to the next threshold (a swap alone never reaches the 6-member tier)
|
||||||
|
if (id === ctx.focus) {
|
||||||
|
const bond = gd.bond(id);
|
||||||
|
const top = bond && Array.isArray(bond.thresholds) && bond.thresholds.length ? bond.thresholds[bond.thresholds.length - 1] : 3;
|
||||||
|
v += FOCUS_MEMBER * Math.min(b.count || 0, top);
|
||||||
|
}
|
||||||
if (!b.tier) continue;
|
if (!b.tier) continue;
|
||||||
const bond = gd.bond(id);
|
const bond = gd.bond(id);
|
||||||
const w = bond && bond.isCore ? 14 : 9;
|
const w = bond && bond.isCore ? 14 : 9;
|
||||||
@@ -272,18 +478,26 @@ function lineupScore(m, ps, set, ctx) {
|
|||||||
function chooseLineup(m, ps, ctx) {
|
function chooseLineup(m, ps, ctx) {
|
||||||
const all = ps.allChess();
|
const all = ps.allChess();
|
||||||
const cap = Math.min(ps.deployCap, all.length);
|
const cap = Math.min(ps.deployCap, all.length);
|
||||||
const seed = all.slice().sort((a, b) => pieceValue(m, ps, b, ctx) - pieceValue(m, ps, a, ctx) || a.uid - b.uid);
|
const value = new Map(all.map((p) => [p.uid, pieceValue(m, ps, p, ctx)]));
|
||||||
|
const seed = all.slice().sort((a, b) => value.get(b.uid) - value.get(a.uid) || a.uid - b.uid);
|
||||||
let set = seed.slice(0, cap);
|
let set = seed.slice(0, cap);
|
||||||
let bench = seed.slice(cap);
|
let bench = seed.slice(cap);
|
||||||
let score = lineupScore(m, ps, set, ctx);
|
let score = lineupScore(m, ps, set, ctx);
|
||||||
|
// identical pieces (same chess, same number of items) score alike: only the first of them is tried
|
||||||
|
const sig = (p) => `${p.id}|${(p.items || []).length}`;
|
||||||
for (let iter = 0; iter < 12 && bench.length; iter++) {
|
for (let iter = 0; iter < 12 && bench.length; iter++) {
|
||||||
let best = null;
|
let best = null;
|
||||||
for (let i = 0; i < set.length; i++) {
|
const tried = new Set();
|
||||||
for (let j = 0; j < bench.length; j++) {
|
for (let j = 0; j < bench.length; j++) {
|
||||||
|
const sj = sig(bench[j]);
|
||||||
|
if (tried.has(sj)) continue;
|
||||||
|
tried.add(sj);
|
||||||
|
for (let i = 0; i < set.length; i++) {
|
||||||
|
if (sig(set[i]) === sj) continue;
|
||||||
const trial = set.slice();
|
const trial = set.slice();
|
||||||
trial[i] = bench[j];
|
trial[i] = bench[j];
|
||||||
const s = lineupScore(m, ps, trial, ctx);
|
const s = lineupScore(m, ps, trial, ctx);
|
||||||
if (s > score + 0.5 && (!best || s > best.s)) best = { i, j, s };
|
if (s > score + 0.5 && (!best || s > best.s || (s === best.s && (i < best.i || (i === best.i && j < best.j))))) best = { i, j, s };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!best) break;
|
if (!best) break;
|
||||||
@@ -305,16 +519,22 @@ function buyScore(m, ps, id, ctx) {
|
|||||||
if (m.round <= 11) s += tr.recurring * 3 + tr.gain * 2 + tr.econ * (m.round <= 7 ? 2 : 0);
|
if (m.round <= 11) s += tr.recurring * 3 + tr.gain * 2 + tr.econ * (m.round <= 7 ? 2 : 0);
|
||||||
const base = gd.baseIdOf(id);
|
const base = gd.baseIdOf(id);
|
||||||
if (!c.isGolden) {
|
if (!c.isGolden) {
|
||||||
const copies = ps.countCopies(base);
|
const copies = ctx.copies.get(base) || 0;
|
||||||
const need = gd.mergeCount(base) || 3;
|
if (copies > 0) {
|
||||||
if (copies > 0) s += copies + 1 >= need ? 36 : 10;
|
// merge progress: an elite plus the merge's free next-tier reward (MERGE_HIT) — for any pair; a new pair of a
|
||||||
|
// non-keeper only while the bench has room for it (cheap tiers: the third copy is hunted with refreshes)
|
||||||
|
const keep = ctx.keep.has(base);
|
||||||
|
if (copies + 1 >= mergeNeed(m, base)) s += keep ? 40 : 30;
|
||||||
|
else s += keep ? 14 : ctx.pairs < MAX_PAIRS && c.tier <= 3 ? 6 : 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!ctx.owned.bases.has(base)) s += bondValue(m, c, ctx.owned, ctx.focus);
|
if (!ctx.owned.bases.has(base)) s += bondValue(m, c, ctx.owned, ctx.focus, ctx.second);
|
||||||
// role needs
|
// role needs
|
||||||
if (isBlocker(c) && ctx.roles.blockers < 2) s += 10;
|
if (isBlocker(c) && ctx.roles.blockers < 2) s += 10;
|
||||||
if (hitsFly(c) && ctx.fly > 0 && ctx.roles.antiAir < 2) s += 8;
|
if (hitsFly(c) && ctx.fly > 0 && ctx.roles.antiAir < 2) s += 8;
|
||||||
if (isHealer(c)) s += ctx.roles.healers === 0 && m.round >= 3 ? 6 : ctx.roles.healers >= 2 ? -14 : -3;
|
if (isHealer(c)) s += ctx.roles.healers === 0 && m.round >= 3 ? 6 : ctx.roles.healers >= 2 ? -14 : -3;
|
||||||
if (c.attackKind === 'none' && !isHealer(c)) s -= 4;
|
if (c.attackKind === 'none' && !isHealer(c)) s -= 4;
|
||||||
|
if (ctx.model && !isHealer(c)) s += ARMOR_WEIGHT * 0.6 * (armorFit(ctx.model, c) - 0.6);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +616,7 @@ export function fieldModel(m, ps = null) {
|
|||||||
const mc = ([r, c]) => [r, BOSS_MIRROR_COL - c];
|
const mc = ([r, c]) => [r, BOSS_MIRROR_COL - c];
|
||||||
return { ...rt, start: mc(rt.start), end: mc(rt.end), checkpoints: Array.isArray(rt.checkpoints) ? rt.checkpoints.map(mc) : [] };
|
return { ...rt, start: mc(rt.start), end: mc(rt.end), checkpoints: Array.isArray(rt.checkpoints) ? rt.checkpoints.map(mc) : [] };
|
||||||
};
|
};
|
||||||
|
const foes = new Map(); // DEF|RES → HP share of the round's enemies (effDps)
|
||||||
if (routes.length && spawns.length) {
|
if (routes.length && spawns.length) {
|
||||||
const per = new Map();
|
const per = new Map();
|
||||||
for (const s of spawns) {
|
for (const s of spawns) {
|
||||||
@@ -403,6 +624,10 @@ export function fieldModel(m, ps = null) {
|
|||||||
const n = Math.max(1, s.count || 1);
|
const n = Math.max(1, s.count || 1);
|
||||||
const isLeader = s.tag === 'boss';
|
const isLeader = s.tag === 'boss';
|
||||||
if (s.tag === 'part') continue;
|
if (s.tag === 'part') continue;
|
||||||
|
if (e && e.stats) {
|
||||||
|
const k = `${e.stats.def || 0}|${e.stats.res || 0}`;
|
||||||
|
foes.set(k, (foes.get(k) || 0) + (isLeader ? LEADER_HP * LEADER_WEIGHT : (e.stats.maxHp || 1000) * n));
|
||||||
|
}
|
||||||
// the leader counts like LEADER_WEIGHT tough enemies: its huge pool makes damage on it worth a lot everywhere
|
// the leader counts like LEADER_WEIGHT tough enemies: its huge pool makes damage on it worth a lot everywhere
|
||||||
const hp = isLeader ? LEADER_HP : ((e && e.stats && e.stats.maxHp) || 1000) * ((s.mods && s.mods.hpMul) || 1);
|
const hp = isLeader ? LEADER_HP : ((e && e.stats && e.stats.maxHp) || 1000) * ((s.mods && s.mods.hpMul) || 1);
|
||||||
const spd = ((e && e.stats && e.stats.moveSpeed) || 1) * ((s.mods && s.mods.speedMul) || 1);
|
const spd = ((e && e.stats && e.stats.moveSpeed) || 1) * ((s.mods && s.mods.speedMul) || 1);
|
||||||
@@ -455,7 +680,10 @@ export function fieldModel(m, ps = null) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const flyTotal = routesOut.filter((r) => r.fly).reduce((s, r) => s + r.n, 0);
|
const flyTotal = routesOut.filter((r) => r.fly).reduce((s, r) => s + r.n, 0);
|
||||||
m._botPath = { key: cacheKey, routes: routesOut, index, ground, flyTotal, airflow: airflowOf(st, toBoard, boss && boss.side === 'R') };
|
let wsum = 0;
|
||||||
|
for (const w of foes.values()) wsum += w;
|
||||||
|
const armor = [...foes.entries()].map(([k, w]) => { const [def, res] = k.split('|').map(Number); return { def, res, w: w / wsum }; });
|
||||||
|
m._botPath = { key: cacheKey, routes: routesOut, index, ground, flyTotal, airflow: airflowOf(st, toBoard, boss && boss.side === 'R'), armor, dps: new Map() };
|
||||||
return m._botPath;
|
return m._botPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +735,38 @@ function dpsOf(rec) {
|
|||||||
return rec.attackKind === 'none' ? d * 0.4 : d;
|
return rec.attackKind === 'none' ? d * 0.4 : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Damage per second of a record against one enemy's DEF / RES (one hit mitigated like the sim, damage.js mitigate). */
|
||||||
|
function dpsVs(rec, def, res) {
|
||||||
|
const st = rec && rec.stats;
|
||||||
|
if (!st || isHealer(rec)) return 0;
|
||||||
|
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(ASPD_MIN, st.aspd || 100));
|
||||||
|
const d = mitigate(st.atk || 0, rec.dmgType === 'arts' ? 'arts' : 'phys', { def, res }) / interval;
|
||||||
|
return rec.attackKind === 'none' ? d * 0.4 : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DPS of a record against the round's enemies: dpsVs averaged over their DEF / RES weighted by HP (the field model's
|
||||||
|
* `armor`; cached per round) — physical dealers lose most of their damage on 重甲 waves, arts dealers on high-RES ones.
|
||||||
|
* Without a model (or enemies) the raw dpsOf.
|
||||||
|
*/
|
||||||
|
export function effDps(model, rec) {
|
||||||
|
if (!rec) return 0;
|
||||||
|
if (!model || !Array.isArray(model.armor) || !model.armor.length) return dpsOf(rec);
|
||||||
|
const id = rec.chessId || rec.tokenId || rec.id || rec.name;
|
||||||
|
const cache = model.dps instanceof Map ? model.dps : null;
|
||||||
|
if (cache && cache.has(id)) return cache.get(id);
|
||||||
|
let v = 0;
|
||||||
|
for (const a of model.armor) v += a.w * dpsVs(rec, a.def, a.res);
|
||||||
|
if (cache) cache.set(id, v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share of a dealer's raw DPS that gets through the round's armour (1 for healers / no enemies). */
|
||||||
|
function armorFit(model, rec) {
|
||||||
|
const raw = dpsOf(rec);
|
||||||
|
return raw > 0 ? effDps(model, rec) / raw : 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Tunables of the exposure model (tuned offline against the real simulation, tools/matchrun.mjs sweeps). */
|
/** Tunables of the exposure model (tuned offline against the real simulation, tools/matchrun.mjs sweeps). */
|
||||||
export const LAYOUT_PARAMS = Object.freeze({ hold: 4, kill: 1.5, secondHold: 0.5, healHold: 0.5, roadPenalty: 0.15, spread: 99 });
|
export const LAYOUT_PARAMS = Object.freeze({ hold: 4, kill: 1.5, secondHold: 0.5, healHold: 0.5, roadPenalty: 0.15, spread: 99 });
|
||||||
|
|
||||||
@@ -567,7 +827,7 @@ function unitOf(rec, key, r, c, dir = 'RIGHT', model = null) {
|
|||||||
const block = isBlocker(rec) ? Math.max(1, rec.stats?.blockCnt ?? 1) : 0;
|
const block = isBlocker(rec) ? Math.max(1, rec.stats?.blockCnt ?? 1) : 0;
|
||||||
const atkMul = airflowAtkMul(model, key, dir);
|
const atkMul = airflowAtkMul(model, key, dir);
|
||||||
return {
|
return {
|
||||||
rec, key, dir, dps: dpsOf(rec) * atkMul, atkMul, air: hitsFly(rec), ground: rec.attackKind !== 'heal' && !isHealer(rec),
|
rec, key, dir, dps: effDps(model, rec) * atkMul, atkMul, air: hitsFly(rec), ground: rec.attackKind !== 'heal' && !isHealer(rec),
|
||||||
block, heal: isHealer(rec), cover: new Set(rangeTiles(rec, r, c, dir)),
|
block, heal: isHealer(rec), cover: new Set(rangeTiles(rec, r, c, dir)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -592,7 +852,7 @@ export function planLayout(m, ps, pieces, params = LAYOUT_PARAMS, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** planLayout as a step generator: yields after each placed piece (Match slices a bot's prep, see botPrepBeginSteps). */
|
/** planLayout as a step generator: yields after each placed piece (Match slices a bot's prep, see botPrepBeginSteps). */
|
||||||
export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupied = new Set(), recOf = null } = {}) {
|
export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupied = new Set(), recOf = null, allowed = null } = {}) {
|
||||||
const model = fieldModel(m, ps);
|
const model = fieldModel(m, ps);
|
||||||
const map = ps.deployMap();
|
const map = ps.deployMap();
|
||||||
const rec = recOf || ((p) => (p.kind === 'token' ? m.gd.token(p.id) : m.gd.chess(p.id)));
|
const rec = recOf || ((p) => (p.kind === 'token' ? m.gd.token(p.id) : m.gd.chess(p.id)));
|
||||||
@@ -608,7 +868,7 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
|
|||||||
let bestV = -Infinity;
|
let bestV = -Infinity;
|
||||||
for (const [r, c] of legalTiles(map, positionClass(r0))) {
|
for (const [r, c] of legalTiles(map, positionClass(r0))) {
|
||||||
const k = tileKey(r, c);
|
const k = tileKey(r, c);
|
||||||
if (taken.has(k)) continue;
|
if (taken.has(k) || (allowed && !allowed.has(k))) continue;
|
||||||
const noise = m.rngBots() * 1e-6;
|
const noise = m.rngBots() * 1e-6;
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const dir of PLAN_DIRS) {
|
for (const dir of PLAN_DIRS) {
|
||||||
@@ -801,7 +1061,44 @@ function canUseItem(m, ps, item) {
|
|||||||
function context(m, ps) {
|
function context(m, ps) {
|
||||||
const owned = ownedBonds(m, ps);
|
const owned = ownedBonds(m, ps);
|
||||||
const model = fieldModel(m, ps);
|
const model = fieldModel(m, ps);
|
||||||
return { owned, focus: focusBond(m, ps, owned), roles: roles(m, ps), fly: model.flyTotal, model };
|
const plan = bondPlan(m, ps, owned);
|
||||||
|
const copies = copyCounts(m, ps);
|
||||||
|
let pairs = 0;
|
||||||
|
for (const [b, k] of copies) if (k + 1 >= mergeNeed(m, b)) pairs++;
|
||||||
|
return { owned, focus: plan.focus, second: plan.second, keep: keeperBases(m, ps, plan), copies, pairs, roles: roles(m, ps), fly: model.flyTotal, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normal copies owned per base (board, hand, temp). */
|
||||||
|
function copyCounts(m, ps) {
|
||||||
|
const out = new Map();
|
||||||
|
for (const p of ps.allChess()) {
|
||||||
|
if (m.gd.isGolden(p.id)) continue;
|
||||||
|
const b = m.gd.baseIdOf(p.id);
|
||||||
|
out.set(b, (out.get(b) || 0) + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected value of one shop refresh: Σ over the owned bases one copy short of an elite of P(the refresh shows a copy)
|
||||||
|
* × MERGE_HIT, plus the keeper singles × PAIR_HIT. P = 1 − (1 − left / eligible copies)^chess slots — the shop's
|
||||||
|
* copy-weighted roll over the shared pool's tiers ≤ shop level (PlayerState._rollChessSlot, pool.js roll).
|
||||||
|
*/
|
||||||
|
function refreshValue(m, ps, ctx) {
|
||||||
|
const L = ps.shop.level;
|
||||||
|
let total = 0;
|
||||||
|
for (const e of m.pool.entries.values()) if (e.left > 0 && e.tier <= L) total += e.left;
|
||||||
|
if (!(total > 0)) return 0;
|
||||||
|
const slots = m.gd.shopSlots(L).chess;
|
||||||
|
let v = 0;
|
||||||
|
for (const [b, k] of ctx.copies) {
|
||||||
|
const e = m.pool.entries.get(b);
|
||||||
|
if (!e || e.left <= 0 || e.tier > L) continue;
|
||||||
|
const pShop = 1 - (1 - e.left / total) ** slots;
|
||||||
|
if (k + 1 >= mergeNeed(m, b)) v += pShop * MERGE_HIT;
|
||||||
|
else if (ctx.keep.has(b)) v += pShop * PAIR_HIT;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sell the weakest bench chess that is not part of a merge pair (or anything when keepPairs is false). */
|
/** Sell the weakest bench chess that is not part of a merge pair (or anything when keepPairs is false). */
|
||||||
@@ -812,7 +1109,7 @@ function sellWeakestHand(m, ps, { keepPairs = true, below = Infinity } = {}) {
|
|||||||
for (const p of [...ps.temp, ...ps.hand]) {
|
for (const p of [...ps.temp, ...ps.hand]) {
|
||||||
if (!p || p.kind !== 'chess') continue;
|
if (!p || p.kind !== 'chess') continue;
|
||||||
const base = m.gd.baseIdOf(p.id);
|
const base = m.gd.baseIdOf(p.id);
|
||||||
if (keepPairs && !m.gd.isGolden(p.id) && ps.countCopies(base) >= 2) continue;
|
if (keepPairs && !m.gd.isGolden(p.id) && (ctx.copies.get(base) || 0) >= 2) continue;
|
||||||
const v = pieceValue(m, ps, p, ctx);
|
const v = pieceValue(m, ps, p, ctx);
|
||||||
if (v < worstV) { worstV = v; worst = p; }
|
if (v < worstV) { worstV = v; worst = p; }
|
||||||
}
|
}
|
||||||
@@ -845,14 +1142,15 @@ function sellJunk(m, ps) {
|
|||||||
const c = chessRec(m, p.id);
|
const c = chessRec(m, p.id);
|
||||||
if (!c) continue;
|
if (!c) continue;
|
||||||
const base = m.gd.baseIdOf(p.id);
|
const base = m.gd.baseIdOf(p.id);
|
||||||
const copies = c.isGolden ? 0 : ps.countCopies(base);
|
const copies = c.isGolden ? 0 : ctx.copies.get(base) || 0;
|
||||||
const pairLive = copies >= 2 && (m.pool.left(base) > 0 || !m.pool.has(base)) && (c.tier >= ps.shop.level - 2 || (c.bonds || []).includes(ctx.focus));
|
// a pair whose third copy can still come (the merge's reward is worth it for any pair)
|
||||||
const useful = c.isGolden || pairLive || (ctx.focus && (c.bonds || []).includes(ctx.focus)) || (copies >= 2 && m.round <= 6);
|
const pairLive = copies + 1 >= mergeNeed(m, base) && (m.pool.left(base) > 0 || !m.pool.has(base));
|
||||||
(useful ? keep : junk).push({ p, v: pieceValue(m, ps, p, ctx) + (useful ? 100 : 0) });
|
const useful = c.isGolden || pairLive || ctx.keep.has(base) || (copies >= 2 && m.round <= 6);
|
||||||
|
(useful ? keep : junk).push({ p, v: pieceValue(m, ps, p, ctx) + (pairLive ? 150 : useful ? 100 : 0) });
|
||||||
}
|
}
|
||||||
// bench budget: at most 4 spare units beyond the lineup (pairs first)
|
// bench budget: at most BENCH_BUDGET spare units beyond the lineup (pairs first)
|
||||||
const all = keep.concat(junk).sort((a, b) => b.v - a.v);
|
const all = keep.concat(junk).sort((a, b) => b.v - a.v);
|
||||||
for (const { p } of all.slice(4)) tryDo(() => ps.sell(p.uid));
|
for (const { p } of all.slice(BENCH_BUDGET)) tryDo(() => ps.sell(p.uid));
|
||||||
for (const { p } of junk.slice(0, 4)) if (m.round >= 5 && ps.find(p.uid)) tryDo(() => ps.sell(p.uid));
|
for (const { p } of junk.slice(0, 4)) if (m.round >= 5 && ps.find(p.uid)) tryDo(() => ps.sell(p.uid));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,9 +1204,10 @@ export function* botPrepBeginSteps(m, ps) {
|
|||||||
takeOffers(m, ps);
|
takeOffers(m, ps);
|
||||||
levelUp(m, ps);
|
levelUp(m, ps);
|
||||||
yield;
|
yield;
|
||||||
yield* buyLoopSteps(m, ps, { fillOnly: false, maxRefreshes: 8 });
|
yield* buyLoopSteps(m, ps, { fillOnly: false, maxRefreshes: MAX_REFRESHES });
|
||||||
takeOffers(m, ps);
|
takeOffers(m, ps);
|
||||||
levelUp(m, ps, { spare: true });
|
levelUp(m, ps, { spare: true });
|
||||||
|
maybeFreeze(m, ps);
|
||||||
yield;
|
yield;
|
||||||
// 3. placement, 4. items, placement again (item carriers gain value; rehearsed when the match allows it)
|
// 3. placement, 4. items, placement again (item carriers gain value; rehearsed when the match allows it)
|
||||||
yield* arrangeSteps(m, ps);
|
yield* arrangeSteps(m, ps);
|
||||||
@@ -939,6 +1238,15 @@ export function botPrep(m, ps) {
|
|||||||
botPrepEnd(m, ps, job);
|
botPrepEnd(m, ps, job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freeze the shop (free) when it shows a copy that would complete a held pair and the bot cannot afford it: the unsold
|
||||||
|
* slots are kept at the next round start (PlayerState.startRound rollShop keepFrozen), the rest rerolled.
|
||||||
|
*/
|
||||||
|
function maybeFreeze(m, ps) {
|
||||||
|
if (ps.shop.frozen) return;
|
||||||
|
if (ps.shop.slots.some((s) => s && !s.sold && s.kind === 'chess' && ps.priceOf(s) > ps.funds && ps.completesChessMerge(s.id))) tryDo(() => ps.freeze());
|
||||||
|
}
|
||||||
|
|
||||||
function levelUp(m, ps, { spare = false } = {}) {
|
function levelUp(m, ps, { spare = false } = {}) {
|
||||||
const gd = m.gd;
|
const gd = m.gd;
|
||||||
for (let guard = 0; guard < 6; guard++) {
|
for (let guard = 0; guard < 6; guard++) {
|
||||||
@@ -964,17 +1272,22 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
|
|||||||
const gd = m.gd;
|
const gd = m.gd;
|
||||||
let refreshes = 0;
|
let refreshes = 0;
|
||||||
const minPrice = 2;
|
const minPrice = 2;
|
||||||
|
// the best lineup is reused while the owned chess stay the same (a refresh changes only the shop)
|
||||||
|
let memo = null;
|
||||||
for (let guard = 0; guard < 40; guard++) {
|
for (let guard = 0; guard < 40; guard++) {
|
||||||
if (guard > 0) yield;
|
if (guard > 0) yield;
|
||||||
const ctx = context(m, ps);
|
const ctx = context(m, ps);
|
||||||
const ownedN = ps.allChess().length;
|
const ownedN = ps.allChess().length;
|
||||||
|
const ownKey = `${ps.allChess().map((p) => `${p.uid}:${p.id}:${(p.items || []).length}`).join()}|${ctx.focus}|${ctx.second}`;
|
||||||
const boardFull = ownedN >= ps.deployCap;
|
const boardFull = ownedN >= ps.deployCap;
|
||||||
if (fillOnly && boardFull) return;
|
// the first pass fills the board, then completes the merges it can afford — before any level-up spends the funds
|
||||||
|
const mergesOnly = fillOnly && boardFull;
|
||||||
// keep room for merges: a crowded bench sheds its weakest single
|
// keep room for merges: a crowded bench sheds its weakest single
|
||||||
const used = ps.hand.filter(Boolean).length;
|
const used = ps.hand.filter(Boolean).length;
|
||||||
if (used >= gd.benchSize - 1) sellWeakestHand(m, ps);
|
if (used >= gd.benchSize - 1) sellWeakestHand(m, ps);
|
||||||
let best = -1;
|
let best = -1;
|
||||||
let bestS = 0;
|
let bestS = 0;
|
||||||
|
let bestMerges = false;
|
||||||
let cur = null;
|
let cur = null;
|
||||||
ps.shop.slots.forEach((s, i) => {
|
ps.shop.slots.forEach((s, i) => {
|
||||||
if (!s || s.sold) return;
|
if (!s || s.sold) return;
|
||||||
@@ -982,15 +1295,24 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
|
|||||||
if (price > ps.funds) return;
|
if (price > ps.funds) return;
|
||||||
let sc;
|
let sc;
|
||||||
if (s.kind === 'chess') {
|
if (s.kind === 'chess') {
|
||||||
if (freeSlot(ps.hand) < 0 && !ps.completesChessMerge(s.id)) return;
|
|
||||||
const merges = ps.completesChessMerge(s.id);
|
const merges = ps.completesChessMerge(s.id);
|
||||||
|
if ((freeSlot(ps.hand) < 0 || mergesOnly) && !merges) return;
|
||||||
|
const base = gd.baseIdOf(s.id);
|
||||||
sc = buyScore(m, ps, s.id, ctx) - price;
|
sc = buyScore(m, ps, s.id, ctx) - price;
|
||||||
// with a full board a buy must improve the lineup (or be merge progress)
|
if (boardFull && !merges) {
|
||||||
if (boardFull && !merges && ps.countCopies(gd.baseIdOf(s.id)) === 0) {
|
if (!ctx.copies.get(base)) {
|
||||||
cur ||= chooseLineup(m, ps, ctx);
|
// with a full board a new operator must improve the lineup
|
||||||
const gain = lineupGain(m, ps, s.id, ctx, cur);
|
if (!cur) {
|
||||||
if (gain <= 2) return;
|
if (!memo || memo.key !== ownKey) memo = { key: ownKey, cur: chooseLineup(m, ps, ctx) };
|
||||||
sc += Math.min(15, gain * 0.5);
|
cur = memo.cur;
|
||||||
|
}
|
||||||
|
const gain = lineupGain(m, ps, s.id, ctx, cur);
|
||||||
|
if (gain <= 2) return;
|
||||||
|
sc += Math.min(15, gain * 0.5);
|
||||||
|
} else if (gd.isGolden(s.id) || (!ctx.keep.has(base) && !(ctx.pairs < MAX_PAIRS && chessRec(m, s.id).tier <= 3 && countFree(ps.hand) >= 3))) {
|
||||||
|
// a second copy of a non-keeper with no bench room for another pair is clutter, sold later at a loss
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!boardFull) sc += 8;
|
if (!boardFull) sc += 8;
|
||||||
} else {
|
} else {
|
||||||
@@ -1001,15 +1323,23 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
|
|||||||
if (!carriers) return;
|
if (!carriers) return;
|
||||||
sc = 6 + (gd.tierOf(s.id) || 1) * 3 - price + (ps.completesItemMerge(s.id) ? 10 : 0);
|
sc = 6 + (gd.tierOf(s.id) || 1) * 3 - price + (ps.completesItemMerge(s.id) ? 10 : 0);
|
||||||
}
|
}
|
||||||
if (sc > bestS) { bestS = sc; best = i; }
|
if (sc > bestS) { bestS = sc; best = i; bestMerges = s.kind === 'chess' && ps.completesChessMerge(s.id); }
|
||||||
});
|
});
|
||||||
if (best >= 0 && bestS >= 3) {
|
// leftover funds are lost at prep end: reroll while a purchase stays affordable
|
||||||
|
const refreshCost = ps.shop.freeRefreshes > 0 ? 0 : gd.refreshPrice;
|
||||||
|
const canRefresh = refreshes < maxRefreshes && ps.funds >= refreshCost + minPrice;
|
||||||
|
// a full board buys only what improves it (merge progress, a lineup upgrade), and only when it is worth more than
|
||||||
|
// the refreshes its price would pay for (each may show the third copy of a held pair, refreshValue)
|
||||||
|
let buy = best >= 0 && bestS >= (boardFull ? BUY_FULL_MIN : 3);
|
||||||
|
if (buy && boardFull && canRefresh && !bestMerges) {
|
||||||
|
const price = Math.max(1, ps.priceOf(ps.shop.slots[best]));
|
||||||
|
if (bestS < refreshValue(m, ps, ctx) * price / Math.max(1, refreshCost)) buy = false;
|
||||||
|
}
|
||||||
|
if (buy) {
|
||||||
if (!tryDo(() => ps.buy(best))) return;
|
if (!tryDo(() => ps.buy(best))) return;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// leftover funds are lost at prep end: reroll while a purchase stays affordable
|
if (canRefresh) {
|
||||||
const refreshCost = ps.shop.freeRefreshes > 0 ? 0 : gd.refreshPrice;
|
|
||||||
if (refreshes < maxRefreshes && ps.funds >= refreshCost + minPrice) {
|
|
||||||
if (!tryDo(() => ps.refresh())) return;
|
if (!tryDo(() => ps.refresh())) return;
|
||||||
refreshes++;
|
refreshes++;
|
||||||
continue;
|
continue;
|
||||||
@@ -1079,16 +1409,20 @@ function* applyPlanSteps(m, ps, chosen, target) {
|
|||||||
if (!moved) break;
|
if (!moved) break;
|
||||||
}
|
}
|
||||||
// anything chosen still off the board: the best remaining free tile
|
// anything chosen still off the board: the best remaining free tile
|
||||||
const off = chosen.filter((p) => { const loc = ps.find(p.uid); return loc && loc.area !== 'board'; });
|
// (the server's g.move decides legality: a refused tile is excluded on the second pass)
|
||||||
if (off.length && ps.deployCount < ps.deployCap) {
|
const refused = new Set();
|
||||||
|
for (let pass = 0; pass < 2; pass++) {
|
||||||
|
const off = chosen.filter((p) => { const loc = ps.find(p.uid); return loc && loc.area !== 'board'; });
|
||||||
|
if (!off.length || ps.deployCount >= ps.deployCap) break;
|
||||||
yield;
|
yield;
|
||||||
const again = yield* planLayoutSteps(m, ps, off, LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) });
|
const again = yield* planLayoutSteps(m, ps, off, LAYOUT_PARAMS, { occupied: new Set([...ps.board.keys(), ...refused]) });
|
||||||
for (const p of off) {
|
for (const p of off) {
|
||||||
const k = again.get(p.uid);
|
const k = again.get(p.uid);
|
||||||
if (!k || ps.deployCount >= ps.deployCap) continue;
|
if (!k || ps.deployCount >= ps.deployCap) continue;
|
||||||
const [r, c] = parseKey(k);
|
const [r, c] = parseKey(k);
|
||||||
tryDo(() => ps.move(p.uid, { area: 'board', row: r, col: c }, planDir(again, p.uid)));
|
if (!tryDo(() => ps.move(p.uid, { area: 'board', row: r, col: c }, planDir(again, p.uid)))) refused.add(k);
|
||||||
}
|
}
|
||||||
|
if (!refused.size) break;
|
||||||
}
|
}
|
||||||
yield* placeTokensSteps(m, ps);
|
yield* placeTokensSteps(m, ps);
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1473,29 @@ function supportSpot(m, ps, p) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Placeable summons from the hand / temp onto the best free tiles (one per stack count). */
|
/**
|
||||||
|
* Tiles a summon may take: a tactician's 援军 (狼群, 流形) stands on its tactical point, which lies in the tactician's
|
||||||
|
* attack range (trait "可以在攻击范围内选择一次战术点来召唤援军") — the owner's range tiles as it stands; null = any.
|
||||||
|
*/
|
||||||
|
function summonTiles(m, ps, p) {
|
||||||
|
if (p.ownerUid == null) return null;
|
||||||
|
for (const [k, q] of ps.board) {
|
||||||
|
if (q.uid !== p.ownerUid) continue;
|
||||||
|
const rec = m.gd.chess(q.id);
|
||||||
|
if (!rec || rec.subProfessionId !== 'tactician') return null;
|
||||||
|
const [r, c] = parseKey(k);
|
||||||
|
return new Set(rangeTiles(rec, r, c, pieceDir(q)));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiles a summon placement tries before giving up (a refused tile is skipped and the next best one taken). */
|
||||||
|
const SUMMON_TRIES = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeable summons from the hand / temp onto the best free tiles (one per stack count). The server's g.move decides
|
||||||
|
* legality: a refused tile is excluded and the next best one tried (SUMMON_TRIES).
|
||||||
|
*/
|
||||||
function* placeTokensSteps(m, ps) {
|
function* placeTokensSteps(m, ps) {
|
||||||
for (const p of [...ps.hand, ...ps.temp]) {
|
for (const p of [...ps.hand, ...ps.temp]) {
|
||||||
if (!p || p.kind !== 'token') continue;
|
if (!p || p.kind !== 'token') continue;
|
||||||
@@ -1150,38 +1506,136 @@ function* placeTokensSteps(m, ps) {
|
|||||||
if (!spot || !tryDo(() => ps.move(p.uid, { area: 'board', row: spot[0], col: spot[1] }, spot[2]))) break;
|
if (!spot || !tryDo(() => ps.move(p.uid, { area: 'board', row: spot[0], col: spot[1] }, spot[2]))) break;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) });
|
const allowed = summonTiles(m, ps, p);
|
||||||
const k = plan.get(p.uid);
|
const refused = new Set();
|
||||||
if (!k) break;
|
let placed = false;
|
||||||
const [r, c] = parseKey(k);
|
for (let t = 0; t < SUMMON_TRIES && !placed; t++) {
|
||||||
if (!tryDo(() => ps.move(p.uid, { area: 'board', row: r, col: c }, planDir(plan, p.uid)))) break;
|
const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set([...ps.board.keys(), ...refused]), allowed });
|
||||||
|
const k = plan.get(p.uid);
|
||||||
|
if (!k) break;
|
||||||
|
const [r, c] = parseKey(k);
|
||||||
|
if (tryDo(() => ps.move(p.uid, { area: 'board', row: r, col: c }, planDir(plan, p.uid)))) placed = true;
|
||||||
|
else refused.add(k);
|
||||||
|
}
|
||||||
|
if (!placed) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What an item does: the key of its first buff (data/items.json `buffs`, e.g. use_equip_reward_char_chess). */
|
||||||
|
const itemEffect = (rec) => (rec && Array.isArray(rec.buffs) && rec.buffs[0] && typeof rec.buffs[0].key === 'string' ? rec.buffs[0].key : '');
|
||||||
|
|
||||||
|
/** Whether the bot's own last battle was perfect (no counted leak). */
|
||||||
|
function lastPerfect(m, ps) {
|
||||||
|
const r = m.lastResults && m.lastResults.get(ps.playerId);
|
||||||
|
return !!r && r.perfect !== false && !(r.leaked || []).some((l) => l && l.counted !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The carrier of an item (null = keep it), by what the item does (itemEffect):
|
||||||
|
* 信标 (destroys its carrier for a pick of two chess of the same tier): the highest-tier bench single, else the
|
||||||
|
* weakest normal deployed operator;
|
||||||
|
* 拟态物质 (a third copy of a pair, else a member of a bond): a pair (highest tier), else a focus member;
|
||||||
|
* 博士投影 (promotes to elite): the strongest normal deployed operator;
|
||||||
|
* 突变细胞 (the carrier becomes a random tier + 1 operator after the battle): the weakest normal deployed one below VI;
|
||||||
|
* 随身身份牌 / 简易通讯机 / 寻呼模块 (act on the carrier's bonds): the operator whose bonds matter most (focus first);
|
||||||
|
* funds, 紧急调度券, 人事部文档 …: anyone; a bond signature item (requiresBondId): a member of that bond;
|
||||||
|
* other equipment: the strongest deployed damage dealers with a free slot (SURVIVAL items blockers first).
|
||||||
|
*/
|
||||||
|
export function itemTarget(m, ps, item, ctx = context(m, ps)) {
|
||||||
|
const gd = m.gd;
|
||||||
|
const rec = gd.item(item.id);
|
||||||
|
if (!rec) return null;
|
||||||
|
const key = itemEffect(rec);
|
||||||
|
const value = new Map();
|
||||||
|
const val = (p) => { if (!value.has(p.uid)) value.set(p.uid, pieceValue(m, ps, p, ctx)); return value.get(p.uid); };
|
||||||
|
const deployed = [...ps.board.values()].filter((p) => p.kind === 'chess');
|
||||||
|
const owned = ps.allChess();
|
||||||
|
const normal = (p) => !gd.isGolden(p.id);
|
||||||
|
const byVal = (list, dir = -1) => list.slice().sort((a, b) => dir * (val(a) - val(b)) || a.uid - b.uid);
|
||||||
|
const rel = (p) => { let r = 0; for (const b of chessRec(m, p.id)?.bonds || []) r += b === ctx.focus ? 3 : b === ctx.second ? 2 : ps.bonds[b] && ps.bonds[b].active ? 1 : 0; return r; };
|
||||||
|
switch (key) {
|
||||||
|
case 'use_equip_recruit_new_char_and_give_char_to_player_most_bond': {
|
||||||
|
// a bench single of the highest tier (a pick of two of its tier), else the weakest deployed normal operator
|
||||||
|
const counts = copyCounts(m, ps);
|
||||||
|
const bench = owned.filter((p) => normal(p) && ps.find(p.uid)?.area !== 'board' && (counts.get(gd.baseIdOf(p.id)) || 0) < 2)
|
||||||
|
.sort((a, b) => (chessRec(m, b.id)?.tier || 0) - (chessRec(m, a.id)?.tier || 0) || val(a) - val(b) || a.uid - b.uid);
|
||||||
|
return bench[0] || byVal(deployed.filter(normal), 1)[0] || null;
|
||||||
|
}
|
||||||
|
case 'use_equip_reward_char_chess': {
|
||||||
|
const counts = copyCounts(m, ps);
|
||||||
|
const pair = owned.filter((p) => normal(p) && (counts.get(gd.baseIdOf(p.id)) || 0) + 1 >= mergeNeed(m, gd.baseIdOf(p.id)))
|
||||||
|
.sort((a, b) => (chessRec(m, b.id)?.tier || 0) - (chessRec(m, a.id)?.tier || 0) || a.uid - b.uid);
|
||||||
|
return pair[0] || owned.slice().sort((a, b) => rel(b) - rel(a) || val(b) - val(a) || a.uid - b.uid)[0] || null;
|
||||||
|
}
|
||||||
|
case 'equip_round_start_upgrade_char':
|
||||||
|
return byVal(deployed.filter(normal))[0] || byVal(owned.filter(normal))[0] || null;
|
||||||
|
case 'char_chess_transformation_equip':
|
||||||
|
return byVal(deployed.filter((p) => normal(p) && (chessRec(m, p.id)?.tier || 6) < 6 && (p.items || []).length < gd.equipPerChess), 1)[0] || null;
|
||||||
|
case 'use_equip_reward_char_chess_bond_layer':
|
||||||
|
case 'use_equip_reward_char_chess_with_same_bond':
|
||||||
|
case 'use_equip_reward_special_goods_char_chess':
|
||||||
|
return owned.slice().sort((a, b) => rel(b) - rel(a) || val(b) - val(a) || a.uid - b.uid)[0] || null;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const consume = typeof rec.kind === 'string' && rec.kind.startsWith('consume_on_equip');
|
||||||
|
const list = byVal(deployed);
|
||||||
|
if (consume) return list[0] || owned[0] || null;
|
||||||
|
const free = (p) => (p.items || []).length < gd.equipPerChess;
|
||||||
|
if (rec.requiresBondId) {
|
||||||
|
const member = list.find((p) => free(p) && (chessRec(m, p.id)?.bonds || []).includes(rec.requiresBondId));
|
||||||
|
if (member) return member;
|
||||||
|
}
|
||||||
|
// damage dealers carry equipment first (healers / non-attackers last); survival items on blockers first
|
||||||
|
const dealer = (p) => { const c = chessRec(m, p.id); return c && !isHealer(c) && c.attackKind !== 'none'; };
|
||||||
|
const blocker = (p) => isBlocker(chessRec(m, p.id));
|
||||||
|
const order = rec.category === 'SURVIVAL'
|
||||||
|
? [...list.filter(blocker), ...list.filter((p) => !blocker(p) && dealer(p)), ...list.filter((p) => !blocker(p) && !dealer(p))]
|
||||||
|
: [...list.filter(dealer), ...list.filter((p) => !dealer(p))];
|
||||||
|
return order.find(free) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use an Art: 画卷 copies the operator on its tile (its range is the tile + the one in front) — the most valuable
|
||||||
|
* deployed operator, with a free hand slot for the copy; 教鞭 / “神秘顾客” add a bounty to the next battle — only after a
|
||||||
|
* perfect battle with LP to spare, else “神秘顾客” is destroyed for its funds (it passes to a teammate) and 教鞭 dropped.
|
||||||
|
*/
|
||||||
|
function useArt(m, ps, item, ctx) {
|
||||||
|
const rec = m.gd.item(item.id);
|
||||||
|
const keys = (rec && Array.isArray(rec.buffs) ? rec.buffs : []).map((b) => b && b.key);
|
||||||
|
if (keys.includes('trap_copy_front_char')) {
|
||||||
|
if (freeSlot(ps.hand) < 0) return;
|
||||||
|
const best = [...ps.board.entries()].filter(([, p]) => p.kind === 'chess').sort((a, b) => pieceValue(m, ps, b[1], ctx) - pieceValue(m, ps, a[1], ctx) || a[1].uid - b[1].uid)[0];
|
||||||
|
if (!best) return;
|
||||||
|
const [r, c] = parseKey(best[0]);
|
||||||
|
tryDo(() => ps.useArt(item.uid, r, c));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keys.includes('trap_create_self_choice')) {
|
||||||
|
if (lastPerfect(m, ps) && ps.lp >= 10) {
|
||||||
|
const [key] = [...ps.board.keys()];
|
||||||
|
if (key) { const [r, c] = parseKey(key); tryDo(() => ps.useArt(item.uid, r, c)); }
|
||||||
|
} else {
|
||||||
|
tryDo(() => ps.destroy(item.uid));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [key] = [...ps.board.keys()];
|
||||||
|
if (key) { const [r, c] = parseKey(key); tryDo(() => ps.useArt(item.uid, r, c)); }
|
||||||
|
}
|
||||||
|
|
||||||
function equipItems(m, ps) {
|
function equipItems(m, ps) {
|
||||||
const gd = m.gd;
|
const gd = m.gd;
|
||||||
const ctx = context(m, ps);
|
|
||||||
const carriers = () => [...ps.board.values()].filter((p) => p.kind === 'chess').sort((a, b) => pieceValue(m, ps, b, ctx) - pieceValue(m, ps, a, ctx));
|
|
||||||
const tried = new Set();
|
const tried = new Set();
|
||||||
for (let guard = 0; guard < 12; guard++) {
|
for (let guard = 0; guard < 12; guard++) {
|
||||||
const item = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'item' && canUseItem(m, ps, p) && !tried.has(p.uid));
|
const item = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'item' && canUseItem(m, ps, p) && !tried.has(p.uid));
|
||||||
if (!item) break;
|
if (!item) break;
|
||||||
tried.add(item.uid);
|
tried.add(item.uid);
|
||||||
|
const ctx = context(m, ps);
|
||||||
const rec = gd.item(item.id);
|
const rec = gd.item(item.id);
|
||||||
if (rec && rec.itemType === 'MAGIC') {
|
if (rec && rec.itemType === 'MAGIC') { useArt(m, ps, item, ctx); continue; }
|
||||||
const board = [...ps.board.entries()].filter(([, p]) => p.kind === 'chess');
|
const target = itemTarget(m, ps, item, ctx);
|
||||||
const [key] = board.length ? board[0] : ['10,4'];
|
|
||||||
const [r, c] = parseKey(key);
|
|
||||||
tryDo(() => ps.useArt(item.uid, r, c));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const consume = rec && typeof rec.kind === 'string' && rec.kind.startsWith('consume_on_equip');
|
|
||||||
const list = carriers();
|
|
||||||
// damage dealers carry equipment first (healers / non-attackers last)
|
|
||||||
const dealers = list.filter((p) => { const c = chessRec(m, p.id); return c && !isHealer(c) && c.attackKind !== 'none'; });
|
|
||||||
const pool = dealers.length ? dealers.concat(list.filter((p) => !dealers.includes(p))) : list;
|
|
||||||
const target = consume ? pool[0] : pool.find((p) => (p.items || []).length < gd.equipPerChess);
|
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
tryDo(() => ps.equip(item.uid, target.uid));
|
tryDo(() => ps.equip(item.uid, target.uid));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// AI player after the 0.1.0 player feedback (#10 "目前的人机有点太笨了", server/match/bot.js): bounty picks against the
|
||||||
|
// own board, item carriers by what the item does, a tactician's 援军 inside its attack range, pairs completed through
|
||||||
|
// the shop freeze, and a cost guard on the prep heuristics. The outcome numbers (old vs new bot on the same seeds) are
|
||||||
|
// measured with tools/botbench.mjs (docs/META.md §1.5).
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { PHASE } from '../../shared/constants.js';
|
||||||
|
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, rangeTiles } from '../../server/match/bot.js';
|
||||||
|
import { parseKey } from '../../server/match/board.js';
|
||||||
|
import { makeMatch, checkInvariants, give, giveItem, DATA } from './harness.js';
|
||||||
|
|
||||||
|
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||||||
|
|
||||||
|
/** A 悬赏决策 card as the draft builds it (choices.js buildCards), by its enemy's name. */
|
||||||
|
function bountyCard(enemyName) {
|
||||||
|
const c = DATA.choices.cards.bounty.find((x) => x.draft && DATA.enemies[x.enemyKey] && DATA.enemies[x.enemyKey].name === enemyName);
|
||||||
|
assert.ok(c, `a draft bounty with ${enemyName}`);
|
||||||
|
return { kind: 'bounty', id: c.effectId, name: c.name, tier: c.tier, coin: c.coin, payout: c.payout, rounds: c.rounds >= 90 ? 2 : c.rounds, enemyKey: c.enemyKey, count: c.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bounty pick: the card the own board can beat, never one it cannot (even when that one pays more)', () => {
|
||||||
|
const h = soloBot({ seed: 5, difficulty: 'HARD' }).start();
|
||||||
|
const m = h.m;
|
||||||
|
const ps = m.order[0];
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 1);
|
||||||
|
ps.lp = 999;
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 5);
|
||||||
|
assert.ok(ps.deployCount >= 6, `a board (${ps.deployCount} deployed)`);
|
||||||
|
const easy = bountyCard('源石虫'); // 550 HP, pays nothing
|
||||||
|
const hard = bountyCard('泥岩巨像'); // 100000 HP, pays 3
|
||||||
|
const huge = bountyCard('纠缠藤蔓'); // 90000 HP, pays 3
|
||||||
|
const pe = bountyKillChance(m, ps, easy);
|
||||||
|
const ph = bountyKillChance(m, ps, hard);
|
||||||
|
assert.ok(pe > 0.8, `the board kills a 源石虫 (p ${pe.toFixed(2)})`);
|
||||||
|
assert.ok(ph < 0.3, `…but not a 泥岩巨像 (p ${ph.toFixed(2)})`);
|
||||||
|
assert.equal(botPickCard(m, ps, [hard, easy, huge].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 1);
|
||||||
|
ps.lp = 2;
|
||||||
|
assert.equal(botPickCard(m, ps, [hard, huge, easy].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 2, 'low LP: still the beatable one');
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bounty pick in real drafts (绝境 R3 悬赏决策): never a card the board cannot beat while a beatable one is offered', () => {
|
||||||
|
let drafts = 0;
|
||||||
|
for (const seed of [1, 2, 3, 4]) {
|
||||||
|
const h = soloBot({ seed, difficulty: 'HARD', fake: true }).start();
|
||||||
|
const m = h.m;
|
||||||
|
const apply = m._applyCard.bind(m);
|
||||||
|
m._applyCard = (ps, idx) => {
|
||||||
|
const cards = m.sp ? m.sp.cards : [];
|
||||||
|
if (cards.length && cards.every((c) => c.kind === 'bounty')) {
|
||||||
|
drafts++;
|
||||||
|
const p = cards.map((c) => bountyKillChance(m, ps, c));
|
||||||
|
const best = Math.max(...p);
|
||||||
|
const picked = p[cards.findIndex((c) => c.idx === idx)];
|
||||||
|
if (best >= 0.9) assert.ok(picked >= 0.5, `seed ${seed} R${m.round}: picked p ${picked.toFixed(2)} while ${best.toFixed(2)} was offered`);
|
||||||
|
}
|
||||||
|
return apply(ps, idx);
|
||||||
|
};
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 1);
|
||||||
|
m.order[0].lp = 999;
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 4);
|
||||||
|
assert.equal(m.errorCount, 0);
|
||||||
|
m.dispose();
|
||||||
|
}
|
||||||
|
assert.ok(drafts >= 4, `${drafts} bounty drafts seen`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('items: 信标 never on the lineup when a bench single can take it; 博士投影 on a normal deployed operator; 突变细胞 on the weakest one below VI; 拟态物质 on a pair', () => {
|
||||||
|
const h = soloBot({ seed: 6 }).start();
|
||||||
|
const m = h.m;
|
||||||
|
const ps = m.order[0];
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 1);
|
||||||
|
ps.lp = 999;
|
||||||
|
h.run(() => m.phase === PHASE.ROUND_START && m.round === 7);
|
||||||
|
const deployed = () => [...ps.board.values()].filter((p) => p.kind === 'chess');
|
||||||
|
assert.ok(deployed().length >= 6);
|
||||||
|
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
|
||||||
|
const fresh = (tier) => Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === tier && m.pool.left(c.chessId) >= 3 && !owned.has(c.chessId)).chessId;
|
||||||
|
for (const p of ps.hand) if (p && p.kind === 'chess') ps.returnCopies(p);
|
||||||
|
ps.hand.fill(null);
|
||||||
|
const single = give(m, ps, fresh(4), 'hand');
|
||||||
|
const pairId = fresh(1);
|
||||||
|
const pairA = give(m, ps, pairId, 'hand');
|
||||||
|
const pairB = give(m, ps, pairId, 'hand');
|
||||||
|
const tierOf = (p) => m.gd.chess(p.id).tier;
|
||||||
|
// 信标 (destroys its carrier for a pick of two of its tier)
|
||||||
|
const beacon = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_04_e_a'));
|
||||||
|
assert.equal(beacon && beacon.uid, single.uid, '信标 on the bench single');
|
||||||
|
// 博士投影 (promotion): a normal operator on the board
|
||||||
|
const promo = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_06_e_a'));
|
||||||
|
assert.ok(promo && deployed().includes(promo) && !m.gd.isGolden(promo.id), '博士投影 on a normal deployed operator');
|
||||||
|
// 突变细胞 (becomes a random tier + 1 operator after the battle): deployed, normal, below VI, not the strongest
|
||||||
|
const cell = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_08_e_a'));
|
||||||
|
assert.ok(cell && deployed().includes(cell) && !m.gd.isGolden(cell.id) && tierOf(cell) < 6, '突变细胞 on a normal deployed operator below VI');
|
||||||
|
assert.notEqual(cell.uid, promo.uid, 'the promotion target (the strongest) is not the one mutated');
|
||||||
|
// 拟态物质 (a third copy when two are owned): the pair
|
||||||
|
const mimic = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_05_e_a'));
|
||||||
|
assert.ok(mimic && [pairA.uid, pairB.uid].includes(mimic.uid), '拟态物质 on the pair');
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tactician\'s 援军 (伺夜\'s 狼群) is placed on a tactical point inside the tactician\'s attack range', () => {
|
||||||
|
const VIGIL = 'chess_char_3_19_a';
|
||||||
|
const WOLF = 'token_10028_vigil_wolf';
|
||||||
|
for (const seed of [3, 8]) {
|
||||||
|
const h = soloBot({ seed }).start();
|
||||||
|
const m = h.m;
|
||||||
|
const ps = m.order[0];
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 2);
|
||||||
|
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||||
|
ps.board.clear();
|
||||||
|
ps.hand.fill(null);
|
||||||
|
ps.recompute();
|
||||||
|
const vigil = give(m, ps, VIGIL, 'hand');
|
||||||
|
const melee = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && c.position === 'MELEE' && c.attackKind === 'melee').slice(0, 2);
|
||||||
|
for (const c of melee) give(m, ps, c.chessId, 'hand');
|
||||||
|
arrange(m, ps);
|
||||||
|
const at = [...ps.board.entries()].find(([, p]) => p.uid === vigil.uid);
|
||||||
|
assert.ok(at, '伺夜 deployed');
|
||||||
|
const wolf = [...ps.board.entries()].find(([, p]) => p.kind === 'token' && p.id === WOLF);
|
||||||
|
assert.ok(wolf, `seed ${seed}: the 狼群 is placed`);
|
||||||
|
const [r, c] = parseKey(at[0]);
|
||||||
|
assert.ok(rangeTiles(m.gd.chess(VIGIL), r, c, at[1].dir || 'RIGHT').includes(wolf[0]), `seed ${seed}: 狼群 at ${wolf[0]} inside 伺夜's range from ${at[0]}`);
|
||||||
|
checkInvariants(m);
|
||||||
|
m.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('economy: a held pair is completed — bought when affordable, else the shop is frozen and the copy bought next round', () => {
|
||||||
|
const h = soloBot({ seed: 9 }).start();
|
||||||
|
const m = h.m;
|
||||||
|
const ps = m.order[0];
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 1);
|
||||||
|
ps.lp = 999;
|
||||||
|
h.run(() => m.phase === PHASE.ROUND_START && m.round === 6);
|
||||||
|
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
|
||||||
|
const id = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 2 && m.pool.left(c.chessId) >= 4 && !owned.has(c.chessId)).chessId;
|
||||||
|
for (const p of ps.hand) if (p && p.kind === 'chess') ps.returnCopies(p);
|
||||||
|
ps.hand.fill(null);
|
||||||
|
give(m, ps, id, 'hand');
|
||||||
|
give(m, ps, id, 'hand');
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 6);
|
||||||
|
// the third copy in the shop, unaffordable this prep
|
||||||
|
ps.funds = 0;
|
||||||
|
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), price: m.gd.chessPrice(id), sold: false, frozen: false };
|
||||||
|
const merges = ps.stats.merges;
|
||||||
|
h.run(() => m.phase === PHASE.COMBAT && m.round === 6);
|
||||||
|
if (ps.stats.merges === merges) {
|
||||||
|
assert.ok(ps.shop.slots[0] && ps.shop.slots[0].id === id && ps.shop.slots[0].frozen, 'the shop was frozen with the third copy in it');
|
||||||
|
h.run(() => m.phase === PHASE.COMBAT && m.round === 7);
|
||||||
|
}
|
||||||
|
assert.ok(ps.stats.merges > merges, 'the pair was completed');
|
||||||
|
assert.ok(ps.allChess().some((p) => p.id === m.gd.goldenIdOf(id)), 'the elite is owned');
|
||||||
|
checkInvariants(m);
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cost guard: the prep heuristics of a late-round 4-bot match stay cheap (rehearsal off)', () => {
|
||||||
|
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
|
||||||
|
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 13, fake: true }).start();
|
||||||
|
const m = h.m;
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 1);
|
||||||
|
for (const ps of m.order) ps.lp = 999;
|
||||||
|
h.run(() => m.phase === PHASE.PREP && m.round === 10);
|
||||||
|
let worst = 0;
|
||||||
|
for (const ps of m.alivePlayers()) {
|
||||||
|
const t0 = performance.now();
|
||||||
|
assert.equal(botPrepBegin(m, ps), null, 'no rehearsal job with rehearsal off');
|
||||||
|
worst = Math.max(worst, performance.now() - t0);
|
||||||
|
}
|
||||||
|
// ≈ 10–40 ms on a desktop; the bound only catches an accidental blow-up (it is load-sensitive like test/sim/robustness)
|
||||||
|
assert.ok(worst < 400, `worst bot prep ${worst.toFixed(1)} ms`);
|
||||||
|
assert.equal(m.errorCount, 0);
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
#!/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)
|
||||||
|
//
|
||||||
|
// 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, funds lost at the prep end (leftover
|
||||||
|
// funds are lost, PlayerState.endPrep), leaks (bounty leaks apart) and the bounty cards taken.
|
||||||
|
// 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) and splits off the rehearsal battles' stepping (Battle.step of the
|
||||||
|
// rehearsal fields 'r:<pid>') — "heuristics" = the prep minus the rehearsal. The 机变 / band picks are timed per call
|
||||||
|
// (they are cheap). Wall-clock numbers depend on the host load: compare runs made back to back.
|
||||||
|
|
||||||
|
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; },
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
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(' · ')} |`); });
|
||||||
|
}
|
||||||
|
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