From 7f20776b9e45dc0c62b4644ab41d416f21eaed4d Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:15:31 +0800 Subject: [PATCH 1/8] feedback1 WF: smarter AI players (merge hunting, bond plan, bounty threat model, item carriers, armour-aware DPS, tactician points) --- docs/META.md | 48 ++- server/match/bot.js | 654 ++++++++++++++++++++++++++++----- test/match/bot-smarter.test.js | 176 +++++++++ tools/botbench.mjs | 312 ++++++++++++++++ 4 files changed, 1077 insertions(+), 113 deletions(-) create mode 100644 test/match/bot-smarter.test.js create mode 100644 tools/botbench.mjs diff --git a/docs/META.md b/docs/META.md index 62ae4972..d73ee975 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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. ### 1.5 AI player (bot.js) -Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), then levels on a curve -(L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free levels always), then -spends the rest on merge progress, bond thresholds around a focus core bond (most owned members, ties → most copies -left in the shared pool), role needs (2 blockers, anti-air when the wave flies, ≤ 2 healers), 特质 that keep adding -layers (every prep / refresh — the layer engines) and items for free carriers; refreshes while a purchase stays -affordable; sells bench chess that neither make the lineup nor build toward something (a live pair, the focus bond, -an elite). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`). Placement -uses the round's enemy preview: every route is traced over the own board (ground: the stage's device-aware ground -paths; flyers: through their checkpoints) and weighted by its enemies; an exposure model (time on each tile × DPS of -the units covering it, blocker hold time, anti-air only on flying routes) is maximized greedily (blockers, then -damage dealers, then healers). Boss rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the -own board (rows −7, the right player of a pair mirrored, only the routes that end on its half) and the leader counts -as 10 tough enemies with a 30 s dwell on its first tiles, so the damage dealers reach stationary leaders. +Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), completes the merges it can afford, +then levels on a curve (L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free +levels always), then spends the rest. It builds around a **focus core bond** (owned members × 10 + members the shop +can still bring at its level + banked layers + 6 for last round's focus, − 6 when a teammate already builds it: the +pool is shared) and a **second bond** (most owned members, next threshold within reach); its keepers are the deployed +operators, the focus / second members and chess of tier ≥ shop level − 1. With a full board a purchase must be merge +progress or a lineup upgrade **and** be worth more than the refreshes its price would pay for: a refresh is worth +Σ over the held pairs of P(the refresh shows the third copy) × the value of an elite (the shop's copy-weighted odds +over the remaining pool; an elite also brings the merge's free pick of the next tier, so any pair is worth completing) +— otherwise it refreshes. Up to 4 pairs wait on the bench (6 spare units, pairs first); a third copy it cannot afford +freezes the shop for the next round (freeze is free). Purchase scores: merge progress > bond thresholds > role needs +(2 blockers, anti-air when the wave flies, ≤ 2 healers) > tier, the 特质 that keep adding layers, and armour fit (the +share of a dealer's damage that gets through the round's DEF / RES — `mitigate` of the sim per hit, HP-weighted over +the wave). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`; every +deployed focus member counts toward the next threshold). Items by what they do: equipment on the strongest deployed +damage dealers (survival items on blockers first, bond signature items on a member), 信标 on a bench single (never the +lineup when a bench single exists), 拟态物质 on a pair, 博士投影 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 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 @@ -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 (`--check` audit, `--errors` per-source error table, `--lp N` / `--layers N` to reach late rounds — the boost stops at 999 per bond —, `--rehearsal N`). +* `node tools/botbench.mjs --configs solo:HARD,coop4:HARD --seeds 40 --jobs 4 --json new.json` — the AI player's + outcomes (wins, rounds passed, LP left, leaks per round, bounties taken / leaked and the kill-chance calibration, + merges, board value, bond tiers, layers, elites) and its decision time per prep (wall clock and CPU, p50 / p95, the + layout rehearsal apart); `--compare old.json new.json` prints the A/B tables (the same seeds on two builds). * `node tools/balance.mjs --mode multi --difficulty NORMAL` — the competent-board difficulty model (docs/BALANCE.md): per round leaks / LP after 联防 / clear time of representative boards against the real waves, boss damage by 150 s and kill time; `--tuning off` (research numbers), `--legacy-time`, `--profile weak|strong`, `--bots N`, `--json`. diff --git a/server/match/bot.js b/server/match/bot.js index 3e45a16b..ed14bf89 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -7,21 +7,30 @@ // // Prep routine (botPrep): // 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 -// first (the deploy cap is 8 from round 1; leftover funds are lost at prep end), level the 调度中心 on a curve -// (free levels always; the −1/round discount is waited for early), then keep buying / refreshing with what is -// left: merge progress (pairs → elites) > bond thresholds (focus core bond, add-ons 2/3) > role needs -// (blockers, anti-air when the wave flies, one or two healers) > tier; with a full board a buy must improve the -// best lineup. Items only when a deployed operator can carry them. 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 + activated bond tiers (exact counting via computeBonds) + -// composition (chooseLineup: greedy seed + swap hill-climbing). +// 2. economy. The bond plan (bondPlan): a focus core bond — owned members, members the shop can still bring at this +// level, banked layers, last round's focus (commit instead of flip-flopping), minus a teammate's focus (the shop +// pool is shared) — and a second bond (most owned members, next threshold within reach). Keepers: the deployed +// operators, focus / second members, chess of tier ≥ shop level − 1. Sell bench chess that neither make the +// lineup nor build toward something (a pair whose third copy can still come, a keeper, an elite; ≤ BENCH_BUDGET +// spare units, pairs first), buy toward a full board (the deploy cap is 8 from round 1), complete every merge it +// can afford — before a level-up can spend the funds —, level the 调度中心 on a curve (free levels always; the +// −1/round discount is waited for early), then spend the rest (leftover funds are lost at prep end): with a full +// board a purchase must be merge progress or a lineup upgrade AND worth more than the refreshes its price would +// 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 // 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 -// covering units, blocker hold time, flyers only for anti-air) is maximized greedily — blockers first, then damage -// dealers by DPS, then healers — over every +// covering units against the round's DEF / RES, blocker hold time, flyers only for anti-air) is maximized +// 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 // 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 @@ -34,20 +43,31 @@ // 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. // 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. -// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler) +// them on the best remaining tiles — a tactician's 援军 on a tile of its attack range (its tactical point) —, 凯瑟琳's +// 支援装置 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. +// 机变 (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 -// 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 { deriveSeed } from '../sim/rng.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 { itemKey } from './gamedata.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'; /** @@ -60,6 +80,25 @@ export function runSteps(gen) { 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). */ 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]; @@ -85,7 +124,13 @@ export function botPickBand(m, ps) { 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) { let best = available[0]; let bestScore = -Infinity; @@ -93,15 +138,109 @@ export function botPickCard(m, ps, cards, available) { const c = cards[i]; if (!c) continue; let s = 0; - if (c.kind === 'item') s = 8 + (c.tier || 1) * 4; - 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 === 'tactic') s = (c.team ? 12 : 8) + (c.tacticKind === 'ally' ? 4 : 0); + if (c.kind === 'item') s = 8 + (c.tier || 1) * 4 + (canUseItem(m, ps, { id: c.id }) ? 0 : -8); + else if (c.kind === 'bounty') s = bountyScore(m, ps, c); + else if (c.kind === 'tactic') s = tacticScore(m, ps, c); s += m.rngBots() * 0.5; if (s > bestScore) { bestScore = s; best = i; } } 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 @@ -127,36 +266,88 @@ function ownedBonds(m, ps, exclude = null) { return { counts, bases: seen }; } -/** Remaining pool copies of a bond's members (what the shop can still offer). */ -function bondSupply(m, bondId) { - let n = 0; +/** + * Per bond, from one pass over the shared pool: `reach` = distinct unowned members of tier ≤ shop level + 1 with copies + * 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) { + if (!(e.left > 0)) continue; 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 - * shared pool still holds for it (bonds drained by teammates or disabled this match are poor targets). Cached per - * round (it only changes slowly). + * The bonds the bot builds around (cached per round and owned set): `focus` = the core bond with the best + * owned members × 10 + reachable members (bondPoolStats, ≤ 4) + layers already banked (≤ 6) + 6 for last round's focus + * (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) { - const key = `${m.round}|${[...owned.counts.entries()].map(([k, v]) => k + v).join()}`; - if (ps._botFocus && ps._botFocus.key === key) return ps._botFocus.id; - let best = null; +function bondPlan(m, ps, owned) { + 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; + 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; + const pool = bondPoolStats(m, ps, owned); for (const id of m.gd.bondIds) { const b = m.gd.bond(id); if (!b || !b.isCore || m.gd.modeInactiveBonds.has(id)) continue; const k = owned.counts.get(id) || 0; if (!k) continue; - const s = k * 100 + Math.min(99, bondSupply(m, id)); - if (s > bestS) { bestS = s; best = id; } + const s = k * 10 + Math.min(4, pool.reach.get(id) || 0) + Math.min(6, (ps.layers[id] || 0) / 10) + + (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 }; - return best; + let second = null; + 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. */ @@ -173,9 +364,14 @@ function roles(m, ps) { 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; + const committed = focus && (owned.counts.get(focus) || 0) >= 3; for (const b of (c && c.bonds) || []) { const bond = m.gd.bond(b); 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 w = bond.isCore ? 1.4 : 1; 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; if (bond.thresholdTemplate === 'count_threshold_downward') v -= n > 0 ? 12 : 0; // 独行 breaks with a second member if (b === focus) v += 10; + else if (b === second) v += 4; + else if (committed && bond.isCore && !hits) v -= 4; } return v; } @@ -229,6 +428,7 @@ function unitBase(m, piece, ctx) { if (m.round <= 11) v += traitsOf(m, c).recurring * 4; if (c.attackKind === 'none' && !isHealer(c)) v -= 6; if (ctx.fly > 0 && hitsFly(c)) v += 2; + if (ctx.model && !isHealer(c)) v += ARMOR_WEIGHT * (armorFit(ctx.model, c) - 0.6); return v; } @@ -236,7 +436,7 @@ function unitBase(m, piece, ctx) { function pieceValue(m, ps, piece, ctx) { const c = chessRec(m, piece.id); 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; for (const p of set) v += unitBase(m, p, ctx); 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; const bond = gd.bond(id); const w = bond && bond.isCore ? 14 : 9; @@ -272,18 +478,26 @@ function lineupScore(m, ps, set, ctx) { function chooseLineup(m, ps, ctx) { const all = ps.allChess(); 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 bench = seed.slice(cap); 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++) { let best = null; - for (let i = 0; i < set.length; i++) { - for (let j = 0; j < bench.length; j++) { + const tried = new Set(); + 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(); trial[i] = bench[j]; 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; @@ -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); const base = gd.baseIdOf(id); if (!c.isGolden) { - const copies = ps.countCopies(base); - const need = gd.mergeCount(base) || 3; - if (copies > 0) s += copies + 1 >= need ? 36 : 10; + const copies = ctx.copies.get(base) || 0; + if (copies > 0) { + // 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 if (isBlocker(c) && ctx.roles.blockers < 2) s += 10; 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 (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; } @@ -396,6 +616,7 @@ export function fieldModel(m, ps = null) { 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) : [] }; }; + const foes = new Map(); // DEF|RES → HP share of the round's enemies (effDps) if (routes.length && spawns.length) { const per = new Map(); for (const s of spawns) { @@ -403,6 +624,10 @@ export function fieldModel(m, ps = null) { const n = Math.max(1, s.count || 1); const isLeader = s.tag === 'boss'; 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 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); @@ -455,7 +680,10 @@ export function fieldModel(m, ps = null) { }); }); 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; } @@ -507,6 +735,38 @@ function dpsOf(rec) { 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). */ 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 atkMul = airflowAtkMul(model, key, dir); 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)), }; } @@ -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). */ -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 map = ps.deployMap(); 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; for (const [r, c] of legalTiles(map, positionClass(r0))) { 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 seen = new Set(); for (const dir of PLAN_DIRS) { @@ -801,7 +1061,44 @@ function canUseItem(m, ps, item) { function context(m, ps) { const owned = ownedBonds(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). */ @@ -812,7 +1109,7 @@ function sellWeakestHand(m, ps, { keepPairs = true, below = Infinity } = {}) { for (const p of [...ps.temp, ...ps.hand]) { if (!p || p.kind !== 'chess') continue; 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); if (v < worstV) { worstV = v; worst = p; } } @@ -845,14 +1142,15 @@ function sellJunk(m, ps) { const c = chessRec(m, p.id); if (!c) continue; const base = m.gd.baseIdOf(p.id); - const copies = c.isGolden ? 0 : ps.countCopies(base); - const pairLive = copies >= 2 && (m.pool.left(base) > 0 || !m.pool.has(base)) && (c.tier >= ps.shop.level - 2 || (c.bonds || []).includes(ctx.focus)); - const useful = c.isGolden || pairLive || (ctx.focus && (c.bonds || []).includes(ctx.focus)) || (copies >= 2 && m.round <= 6); - (useful ? keep : junk).push({ p, v: pieceValue(m, ps, p, ctx) + (useful ? 100 : 0) }); + const copies = c.isGolden ? 0 : ctx.copies.get(base) || 0; + // a pair whose third copy can still come (the merge's reward is worth it for any pair) + const pairLive = copies + 1 >= mergeNeed(m, base) && (m.pool.left(base) > 0 || !m.pool.has(base)); + 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); - 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)); } @@ -906,9 +1204,10 @@ export function* botPrepBeginSteps(m, ps) { takeOffers(m, ps); levelUp(m, ps); yield; - yield* buyLoopSteps(m, ps, { fillOnly: false, maxRefreshes: 8 }); + yield* buyLoopSteps(m, ps, { fillOnly: false, maxRefreshes: MAX_REFRESHES }); takeOffers(m, ps); levelUp(m, ps, { spare: true }); + maybeFreeze(m, ps); yield; // 3. placement, 4. items, placement again (item carriers gain value; rehearsed when the match allows it) yield* arrangeSteps(m, ps); @@ -939,6 +1238,15 @@ export function botPrep(m, ps) { 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 } = {}) { const gd = m.gd; for (let guard = 0; guard < 6; guard++) { @@ -964,17 +1272,22 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { const gd = m.gd; let refreshes = 0; 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++) { if (guard > 0) yield; const ctx = context(m, ps); 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; - 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 const used = ps.hand.filter(Boolean).length; if (used >= gd.benchSize - 1) sellWeakestHand(m, ps); let best = -1; let bestS = 0; + let bestMerges = false; let cur = null; ps.shop.slots.forEach((s, i) => { if (!s || s.sold) return; @@ -982,15 +1295,24 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { if (price > ps.funds) return; let sc; if (s.kind === 'chess') { - if (freeSlot(ps.hand) < 0 && !ps.completesChessMerge(s.id)) return; 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; - // with a full board a buy must improve the lineup (or be merge progress) - if (boardFull && !merges && ps.countCopies(gd.baseIdOf(s.id)) === 0) { - cur ||= chooseLineup(m, ps, ctx); - const gain = lineupGain(m, ps, s.id, ctx, cur); - if (gain <= 2) return; - sc += Math.min(15, gain * 0.5); + if (boardFull && !merges) { + if (!ctx.copies.get(base)) { + // with a full board a new operator must improve the lineup + if (!cur) { + if (!memo || memo.key !== ownKey) memo = { key: ownKey, cur: chooseLineup(m, ps, ctx) }; + 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; } else { @@ -1001,15 +1323,23 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { if (!carriers) return; 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; continue; } - // leftover funds are lost at prep end: reroll while a purchase stays affordable - const refreshCost = ps.shop.freeRefreshes > 0 ? 0 : gd.refreshPrice; - if (refreshes < maxRefreshes && ps.funds >= refreshCost + minPrice) { + if (canRefresh) { if (!tryDo(() => ps.refresh())) return; refreshes++; continue; @@ -1079,16 +1409,20 @@ function* applyPlanSteps(m, ps, chosen, target) { if (!moved) break; } // 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'; }); - if (off.length && ps.deployCount < ps.deployCap) { + // (the server's g.move decides legality: a refused tile is excluded on the second pass) + 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; - 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) { const k = again.get(p.uid); if (!k || ps.deployCount >= ps.deployCap) continue; 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); } @@ -1139,7 +1473,29 @@ function supportSpot(m, ps, p) { 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) { for (const p of [...ps.hand, ...ps.temp]) { 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; continue; } - const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) }); - 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)))) break; + const allowed = summonTiles(m, ps, p); + const refused = new Set(); + let placed = false; + for (let t = 0; t < SUMMON_TRIES && !placed; t++) { + 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) { 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(); 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)); if (!item) break; tried.add(item.uid); + const ctx = context(m, ps); const rec = gd.item(item.id); - if (rec && rec.itemType === 'MAGIC') { - const board = [...ps.board.entries()].filter(([, p]) => p.kind === 'chess'); - 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 (rec && rec.itemType === 'MAGIC') { useArt(m, ps, item, ctx); continue; } + const target = itemTarget(m, ps, item, ctx); if (!target) continue; tryDo(() => ps.equip(item.uid, target.uid)); } diff --git a/test/match/bot-smarter.test.js b/test/match/bot-smarter.test.js new file mode 100644 index 00000000..84ce13f5 --- /dev/null +++ b/test/match/bot-smarter.test.js @@ -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(); +}); diff --git a/tools/botbench.mjs b/tools/botbench.mjs new file mode 100644 index 00000000..17cab862 --- /dev/null +++ b/tools/botbench.mjs @@ -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 : (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:') — "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)} |`); + }); +} From 3f104168fefdb7c97109891f3e5ed672503d7dc4 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:46:20 +0800 Subject: [PATCH 2/8] feedback1 WF: bot A/B numbers in BALANCE.md; guard a record lookup in the buy loop --- docs/BALANCE.md | 22 ++++++++++++++++++++++ server/match/bot.js | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/BALANCE.md b/docs/BALANCE.md index 4c1fc7ed..654879c1 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -333,6 +333,28 @@ Official-waves column: `node tools/balance.mjs --mode all --difficulty ALL --bot seed), measured before the review's leader-multiplier / 联防-timing fixes (§2; not re-run); the other columns are the previous pass (old generator), kept for history. \* the bot code of this pass (§5) with the old time reading and no tuning; the originally reported bots (old bot code) survived ≈ 6.5 rounds on 险境 and won 1/20 on 标准. +**Player feedback after 0.1.0 (#10 "人机有点太笨了").** Measured first (`tools/botbench.mjs`): the 0.1.0 bots refreshed +0–2 times a prep and bought side-grade singles they sold again at a loss (every sale returns 1), so they merged ≈ 2 +times a match and fielded ≈ 2 elites at R13; at 绝境 ≈ 1 in 2 of their bounty picks leaked (the old score ignored the +enemy); 信标 went on the best operator (it destroys its carrier) and a level-up could spend the funds a third copy in +the shop needed. Changes (server/match/bot.js header, META §1.5): refresh-vs-buy by the shop odds of completing held +pairs, the freeze for an unaffordable third copy, merges before level-ups, a committed focus / second bond, armour-aware +DPS, bounty picks from the exposure model, item carriers by effect, a tactician's 援军 inside its range. Same seeds +(1–40) old → new, the match's default rehearsal (3): + +| config | wins | rounds passed | LP left | leaks / match | bounty enemies leaked | merges / bot | elites at R13 | +|---|---|---|---|---|---|---|---| +| 独立 标准 (1 AI) | 35 → 35 / 40 | 8.80 → 8.80 | 29.9 → 28.8 | 2.5 → 2.9 | 1 → 1 | 1.40 → 2.23 | – (R7: 0.7 → 1.0) | +| 独立 绝境 (1 AI) | 19 → 28 / 40 | 13.05 → 13.47 | 14.3 → 16.4 | 26.5 → 19.6 | 10 → 5 of 80 picks | 2.33 → 4.17 | 1.8 → 2.2 | +| 同盟 标准 (4 AI) | 40 → 40 / 40 | 14.00 → 14.00 | 110.0 → 113.5 | 39.4 → 32.8 | 6 → 5 | 2.59 → 4.17 | 1.9 → 2.3 | +| 同盟 绝境 (4 AI) | 5 → 11 / 40 | 11.85 → 12.22 | 5.1 → 10.9 | 224.6 → 215.2 | 155 → 142 of ≈ 310 picks | 2.00 → 3.05 | 2.3 → 2.4 | + +绝境 leaks per alive bot in R12 / R13: solo 8.0 / 9.2 → 5.7 / 4.9, co-op 20.5 / 20.7 → 17.6 / 17.8. Decision time per +bot prep is unchanged (CPU p50 / p95, the rehearsal included: solo 绝境 520 / 1302 → 531 / 1216 ms, co-op 绝境 +405 / 884 → 404 / 910 ms; the heuristics alone 94 / 234 → 93 / 212 and 71 / 186 → 71 / 185 ms — a heavily loaded host, +so the absolute values are ≈ 3–4× a quiet one; Σ per match within ± 2 %). The tuning sweeps used seeds 101–148 with the +rehearsal off; the table is a separate seed range. + --- ## 6. Match follow-ups shipped with this pass diff --git a/server/match/bot.js b/server/match/bot.js index ed14bf89..25607c41 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -1309,7 +1309,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { 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))) { + } else if (gd.isGolden(s.id) || (!ctx.keep.has(base) && !(ctx.pairs < MAX_PAIRS && (chessRec(m, s.id)?.tier ?? 6) <= 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; } From 344bb80ffa9388781bb10aac3c2f5781a355aa66 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:52:06 +0800 Subject: [PATCH 3/8] feedback1 WF: CPU-time cost guard; quieter decision-time numbers in BALANCE.md --- docs/BALANCE.md | 9 +++++---- test/match/bot-smarter.test.js | 11 ++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/BALANCE.md b/docs/BALANCE.md index 654879c1..bf8feb70 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -350,10 +350,11 @@ DPS, bounty picks from the exposure model, item carriers by effect, a tactician' | 同盟 绝境 (4 AI) | 5 → 11 / 40 | 11.85 → 12.22 | 5.1 → 10.9 | 224.6 → 215.2 | 155 → 142 of ≈ 310 picks | 2.00 → 3.05 | 2.3 → 2.4 | 绝境 leaks per alive bot in R12 / R13: solo 8.0 / 9.2 → 5.7 / 4.9, co-op 20.5 / 20.7 → 17.6 / 17.8. Decision time per -bot prep is unchanged (CPU p50 / p95, the rehearsal included: solo 绝境 520 / 1302 → 531 / 1216 ms, co-op 绝境 -405 / 884 → 404 / 910 ms; the heuristics alone 94 / 234 → 93 / 212 and 71 / 186 → 71 / 185 ms — a heavily loaded host, -so the absolute values are ≈ 3–4× a quiet one; Σ per match within ± 2 %). The tuning sweeps used seeds 101–148 with the -rehearsal off; the table is a separate seed range. +bot prep is unchanged (one thread, back to back, seeds 1–6 — solo 绝境 / co-op 绝境; the rehearsal included): wall clock +p50 / p95 87 / 175 → 82 / 191 ms and 84 / 172 → 80 / 157 ms, CPU 125 / 315 → 120 / 305 ms and 92 / 198 → 90 / 180 ms; +the heuristics alone (CPU) 30 / 74 → 26 / 64 ms and 17 / 41 → 16 / 35 ms (the lineup search tries identical pieces +once and reuses the lineup across refreshes); a 机变 pick 0.07 → 0.14 ms (p50). The tuning sweeps used seeds 101–148 +with the rehearsal off; the table above is a separate seed range. --- diff --git a/test/match/bot-smarter.test.js b/test/match/bot-smarter.test.js index 84ce13f5..88e951e0 100644 --- a/test/match/bot-smarter.test.js +++ b/test/match/bot-smarter.test.js @@ -4,7 +4,6 @@ // 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'; @@ -165,12 +164,14 @@ test('cost guard: the prep heuristics of a late-round 4-bot match stay cheap (re h.run(() => m.phase === PHASE.PREP && m.round === 10); let worst = 0; for (const ps of m.alivePlayers()) { - const t0 = performance.now(); + // CPU time (process.cpuUsage) rather than wall clock: a loaded host stretches it far less + const u0 = process.cpuUsage(); assert.equal(botPrepBegin(m, ps), null, 'no rehearsal job with rehearsal off'); - worst = Math.max(worst, performance.now() - t0); + const u = process.cpuUsage(u0); + worst = Math.max(worst, (u.user + u.system) / 1000); } - // ≈ 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`); + // ≈ 10–40 ms on a quiet desktop (tools/botbench.mjs); the bound only catches an accidental blow-up + assert.ok(worst < 1500, `worst bot prep ${worst.toFixed(1)} ms of CPU`); assert.equal(m.errorCount, 0); m.dispose(); }); From 679309fc4f9258a6265a4469cdb491160a7375f3 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:52:35 +0800 Subject: [PATCH 4/8] feedback1 WF: botbench header --- tools/botbench.mjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tools/botbench.mjs b/tools/botbench.mjs index 17cab862..f4683a0c 100644 --- a/tools/botbench.mjs +++ b/tools/botbench.mjs @@ -15,12 +15,14 @@ // // 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. +// price of the deployed chess, an elite = goldenCopies copies), elites deployed, items equipped, funds lost at the prep +// end (leftover funds are lost, PlayerState.endPrep), leaks (bounty leaks apart) and the bounty cards taken — with the +// bot's own kill-chance estimate when the bot has one (bot.bountyKillChance), for a calibration table. // 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:') — "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. +// those callbacks (Match.later on the instance; wall clock and process CPU time) and splits off the rehearsal battles' +// stepping (Battle.step of the rehearsal fields 'r:') — "heuristics" = the prep minus the rehearsal. A 机变 pick is +// the SP_DRAFT callback in which a bot's pick landed. Wall-clock numbers depend on the host load: compare runs made +// back to back with --jobs 1. import { performance } from 'node:perf_hooks'; import { writeFileSync, readFileSync } from 'node:fs'; From f4e55a7e29052cfc3b931d6f4500006411bd30fd Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:30:02 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feedback1=20WF:=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20golden=20=E5=8D=9A=E5=A3=AB=E6=8A=95=E5=BD=B1=20and?= =?UTF-8?q?=20=E7=AA=81=E5=8F=98=E7=BB=86=E8=83=9E=20carriers,=20bounty=20?= =?UTF-8?q?Arts=20kept,=20=E5=9D=8E=E8=AF=BA=E7=89=B9=20banks=20its=20capi?= =?UTF-8?q?tal,=20teammates'=20bonds=20from=20their=20bond=20strips,=20ser?= =?UTF-8?q?ver=20summon=20range,=20tighter=20cost=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/match/bot.js | 188 ++++++++++++++++++++++++--------- test/match/bot-smarter.test.js | 151 +++++++++++++++++++++++--- 2 files changed, 273 insertions(+), 66 deletions(-) diff --git a/server/match/bot.js b/server/match/bot.js index 25607c41..e93aa79e 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -2,19 +2,20 @@ // is eliminated, Match._quit). Every action goes through the same validated // PlayerState handlers a human uses; randomness only from the match's bot rng (deterministic per seed). // -// It reads only what a player can see: its own state, the shop, and the round's enemy preview (composition and -// routes, research 06 §4.3 "查看当前回合即将迎击的敌方单位"). +// It reads only what a player can see: its own state, the shop, the shared pool's copies left, the teammates' bond +// strips, and the round's enemy preview (composition and routes, research 06 §4.3 "查看当前回合即将迎击的敌方单位"). // // Prep routine (botPrep): // 1. take a pending reward offer (merge progress, bond synergy, tier) // 2. economy. The bond plan (bondPlan): a focus core bond — owned members, members the shop can still bring at this -// level, banked layers, last round's focus (commit instead of flip-flopping), minus a teammate's focus (the shop -// pool is shared) — and a second bond (most owned members, next threshold within reach). Keepers: the deployed -// operators, focus / second members, chess of tier ≥ shop level − 1. Sell bench chess that neither make the -// lineup nor build toward something (a pair whose third copy can still come, a keeper, an elite; ≤ BENCH_BUDGET -// spare units, pairs first), buy toward a full board (the deploy cap is 8 from round 1), complete every merge it -// can afford — before a level-up can spend the funds —, level the 调度中心 on a curve (free levels always; the -// −1/round discount is waited for early), then spend the rest (leftover funds are lost at prep end): with a full +// level, banked layers, last round's focus (commit instead of flip-flopping), minus a teammate's main core bond +// (its bond strip; the shop pool is shared) — and a second bond (most owned members, next threshold within +// reach). Keepers: the deployed operators, focus / second members, chess of tier ≥ shop level − 1. Sell bench +// chess that neither make the lineup nor build toward something (a pair whose third copy can still come, a +// keeper, an elite; ≤ BENCH_BUDGET spare units, pairs first), buy toward a full board (the deploy cap is 8 from +// round 1), complete every merge it can afford — before a level-up can spend the funds —, level the 调度中心 on a +// curve (free levels always; the −1/round discount is waited for early), then spend the rest (leftover funds are +// lost at prep end; a band that keeps them, 坎诺特, holds its interest capital back — fundsReserve): with a full // board a purchase must be merge progress or a lineup upgrade AND worth more than the refreshes its price would // 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 @@ -43,18 +44,22 @@ // 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. // The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after -// them on the best remaining tiles — a tactician's 援军 on a tile of its attack range (its tactical point) —, 凯瑟琳's -// 支援装置 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. +// them on the best remaining tiles — a tactician's 援军 on a tile of its attack range (its tactical point; +// summonRange: PlayerState.summonRange when the server has it) —, 凯瑟琳's 支援装置 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. +// blockers, bond signature items on a member), 信标 on a bench single, 拟态物质 on a pair, 博士投影 (both +// qualities) on the strongest normal operator, 突变细胞 on the least valuable normal single below 6阶 (cellTarget: +// never an elite or a pair member), bond items on a focus member; Arts (useArt): 画卷 copies the most valuable +// deployed operator, 教鞭 / “神秘顾客” are used after a perfect battle and kept otherwise (consume-on-equip items / +// Arts only with a handler) +// 6. resolve the temp slots, keep one hand slot free (freeHandSlot: a kept bounty Art goes before a chess on a bot's +// own seat, never on a human's seat under AI 托管), 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. +// model for that one enemy against the own board; a card the board is unlikely to beat wins only when nothing better +// is offered or it pays much more —; 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 // 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. @@ -126,8 +131,9 @@ export function botPickBand(m, ps) { /** * 机变 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 + * next battles) score their expected payout minus the expected LP lost (bountyScore: the enemy against the own board — + * an expected-value comparison, so a card the board is unlikely to beat is taken only when no better one is offered or + * its pay outweighs the expected leaks); 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. */ @@ -288,19 +294,39 @@ function bondPoolStats(m, ps, owned) { return { reach, supply }; } +/** + * A teammate's main core bond as every player sees it (its public bond strip, ps.bonds): the core bond with the most + * counted members, ≥ 2 (ties: data order); null when it builds none yet. Humans and bots alike. + */ +function mainCoreBond(m, p) { + let top = null; + let topN = 1; + for (const id of m.gd.bondIds) { + const b = p.bonds && p.bonds[id]; + const n = b ? b.count || 0 : 0; + if (n > topN && m.gd.bond(id)?.isCore) { topN = n; top = id; } + } + return top; +} + /** * The bonds the bot builds around (cached per round and owned set): `focus` = the core bond with the best * owned members × 10 + reachable members (bondPoolStats, ≤ 4) + layers already banked (≤ 6) + 6 for last round's focus - * (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. + * (commit instead of flip-flopping between equal bonds) − 6 when a teammate already builds it (its main core bond on + * its public bond strip, mainCoreBond — 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. */ -function bondPlan(m, ps, owned) { +export function bondPlan(m, ps, owned = ownedBonds(m, ps)) { 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; const prev = ps._botFocusId ?? null; const mates = new Set(); - for (const p of m.alivePlayers()) if (p !== ps && p._botFocusId) mates.add(p._botFocusId); + for (const p of m.alivePlayers()) { + if (p === ps) continue; + const b = mainCoreBond(m, p); + if (b) mates.add(b); + } let focus = null; let bestS = 0; const pool = bondPoolStats(m, ps, owned); @@ -483,8 +509,8 @@ function chooseLineup(m, ps, ctx) { let set = seed.slice(0, cap); let bench = seed.slice(cap); 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}`; + // identical pieces (same chess, same items — a bond can hang on an item pair) score alike: only the first is tried + const sig = (p) => `${p.id}|${(p.items || []).map((it) => it.id).sort().join('+')}`; for (let iter = 0; iter < 12 && bench.length; iter++) { let best = null; const tried = new Set(); @@ -852,7 +878,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). */ -export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupied = new Set(), recOf = null, allowed = null } = {}) { +export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupied = new Set(), recOf = null } = {}) { const model = fieldModel(m, ps); const map = ps.deployMap(); const rec = recOf || ((p) => (p.kind === 'token' ? m.gd.token(p.id) : m.gd.chess(p.id))); @@ -866,9 +892,11 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi if (!r0) continue; let best = null; let bestV = -Infinity; + // a summon bound to its owner's attack range (a tactician's 援军 — its tactical point): only those tiles + const within = p.kind === 'token' ? summonRange(m, ps, p) : null; for (const [r, c] of legalTiles(map, positionClass(r0))) { const k = tileKey(r, c); - if (taken.has(k) || (allowed && !allowed.has(k))) continue; + if (taken.has(k) || (within && !within.has(k))) continue; const noise = m.rngBots() * 1e-6; const seen = new Set(); for (const dir of PLAN_DIRS) { @@ -1227,10 +1255,23 @@ export function* botPrepEndSteps(m, ps, job = null) { if (job && job.done && job.best !== job.plans[0]) yield* applyPlanSteps(m, ps, job.chosen, job.best); // 5. temp → hand / sell / destroy; keep one hand slot free for next round's merges resolveTemp(m, ps); - if (freeSlot(ps.hand) < 0) sellWeakestHand(m, ps); + if (freeSlot(ps.hand) < 0) freeHandSlot(m, ps); tryDo(() => ps.setReady(true)); } +/** + * Free one hand slot: on a bot's own seat a kept bounty Art goes first (“神秘顾客” pays its fund and passes to a + * teammate when destroyed, 教鞭 gives nothing — its chance of a perfect-battle payout is worth less than a slot for a + * merge), else the weakest bench single is sold. A human's seat under AI 托管 never loses an item this way. + */ +function freeHandSlot(m, ps) { + if (ps.isBot && !ps.autoplay) { + const art = ps.hand.find((p) => p && p.kind === 'item' && isBountyArt(m.gd, p.id)); + if (art && tryDo(() => ps.destroy(art.uid))) return true; + } + return sellWeakestHand(m, ps); +} + /** The whole prep routine in one go (the rehearsal, if any, runs synchronously). */ export function botPrep(m, ps) { const job = botPrepBegin(m, ps); @@ -1261,17 +1302,32 @@ function levelUp(m, ps, { spare = false } = {}) { let want = price === 0; if (!want && ps.shop.level < target && (boardReady || r >= 6)) want = true; if (!want && ps.shop.level < nextTarget && price <= 2 && boardReady) want = true; - if (!want && spare && ps.funds >= price + 1 && ps.shop.level < nextTarget + 1 && boardReady) want = true; + if (!want && spare && ps.funds >= price + 1 + fundsReserve(m, ps) && ps.shop.level < nextTarget + 1 && boardReady) want = true; if (!want && ps.funds >= price + 14) want = true; if (!want || !tryDo(() => ps.levelUp())) return; } } +/** + * Funds a band that keeps its leftover (gd.leftoverKeptBands: 坎诺特 利滚利 "每回合剩余的资金可以继承,剩余至少5资金时, + * 每回合额外获得1资金") holds back from discretionary spending — refreshes, full-board purchases that complete nothing, + * items, spare level-ups: the interest capital (coin_carry_over bb.capital, 5). 0 for every other band (their + * leftover is lost at the prep end, so spending it all is free value). + */ +function fundsReserve(m, ps) { + if (!m.gd.leftoverKeptBands.includes(ps.bandId)) return 0; + const band = m.gd.band(ps.bandId); + const buff = band && Array.isArray(band.buffs) ? band.buffs.find((b) => b && b.key === 'coin_carry_over') : null; + const cap = Number(buff && buff.bb && buff.bb.capital); + return Number.isFinite(cap) && cap > 0 ? cap : 5; +} + /** Buying / rerolling toward the lineup; a step generator (yields after each purchase or reroll). */ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { const gd = m.gd; let refreshes = 0; const minPrice = 2; + const reserve = fundsReserve(m, ps); // 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++) { @@ -1298,6 +1354,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { const merges = ps.completesChessMerge(s.id); if ((freeSlot(ps.hand) < 0 || mergesOnly) && !merges) return; const base = gd.baseIdOf(s.id); + if (boardFull && !merges && ps.funds - price < reserve) return; // banked (坎诺特) sc = buyScore(m, ps, s.id, ctx) - price; if (boardFull && !merges) { if (!ctx.copies.get(base)) { @@ -1319,15 +1376,17 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { if (fillOnly) return; if (!canUseItem(m, ps, { id: s.id })) return; if (freeSlot(ps.hand) < 0 && !ps.completesItemMerge(s.id)) return; + if (ps.funds - price < reserve && !ps.completesItemMerge(s.id)) return; const carriers = [...ps.board.values()].filter((p) => p.kind === 'chess' && (p.items || []).length < gd.equipPerChess).length; if (!carriers) return; sc = 6 + (gd.tierOf(s.id) || 1) * 3 - price + (ps.completesItemMerge(s.id) ? 10 : 0); } if (sc > bestS) { bestS = sc; best = i; bestMerges = s.kind === 'chess' && ps.completesChessMerge(s.id); } }); - // leftover funds are lost at prep end: reroll while a purchase stays affordable + // leftover funds are lost at prep end: reroll while a purchase stays affordable (a band that keeps them: down to its + // reserve — a free refresh is always taken) const refreshCost = ps.shop.freeRefreshes > 0 ? 0 : gd.refreshPrice; - const canRefresh = refreshes < maxRefreshes && ps.funds >= refreshCost + minPrice; + const canRefresh = refreshes < maxRefreshes && ps.funds >= refreshCost + (refreshCost > 0 ? Math.max(minPrice, reserve) : 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); @@ -1474,10 +1533,13 @@ function supportSpot(m, ps, p) { } /** - * 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. + * The tiles ('r,c' keys) a range-bound summon may take, null = any: the server's own rule when PlayerState has one + * (summonRange — what g.move enforces), else the trait's: a tactician's 援军 (伺夜's 狼群, 缪尔赛思's 流形) stands on its + * tactical point, inside the tactician's attack range ("可以在攻击范围内选择一次战术点来召唤援军") — the owner's range + * grid rotated by its facing around its tile. */ -function summonTiles(m, ps, p) { +function summonRange(m, ps, p) { + if (typeof ps.summonRange === 'function') return ps.summonRange(p); if (p.ownerUid == null) return null; for (const [k, q] of ps.board) { if (q.uid !== p.ownerUid) continue; @@ -1506,11 +1568,10 @@ function* placeTokensSteps(m, ps) { if (!spot || !tryDo(() => ps.move(p.uid, { area: 'board', row: spot[0], col: spot[1] }, spot[2]))) break; continue; } - const allowed = summonTiles(m, ps, p); const refused = new Set(); let placed = false; for (let t = 0; t < SUMMON_TRIES && !placed; t++) { - const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set([...ps.board.keys(), ...refused]), allowed }); + const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set([...ps.board.keys(), ...refused]) }); const k = plan.get(p.uid); if (!k) break; const [r, c] = parseKey(k); @@ -1525,6 +1586,28 @@ function* placeTokensSteps(m, ps) { /** 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 : ''); +/** 突变细胞 (buff char_chess_transformation_equip). */ +const isMutationCell = (gd, itemId) => itemEffect(gd.item(itemId)) === 'char_chess_transformation_equip'; + +/** + * Whom the bot injects with 突变细胞 (after the battle its carrier — deployed or on the bench: every owned carrier's item + * hooks run — becomes a random operator one tier higher; items meta char_chess_transformation_equip): the least + * valuable normal operator below 6阶 with a free equip slot — never an elite, never one of a merge pair (the merge + * progress would be lost), nobody already carrying a cell. null: the cell waits in the hand. + */ +export function cellTarget(m, ps, ctx = context(m, ps)) { + const gd = m.gd; + let best = null; + let bestV = Infinity; + for (const p of ps.allChess()) { + if (gd.isGolden(p.id) || gd.tierOf(p.id) >= 6 || (p.items || []).length >= gd.equipPerChess) continue; + if ((p.items || []).some((it) => isMutationCell(gd, it.id)) || (ctx.copies.get(gd.baseIdOf(p.id)) || 0) >= 2) continue; + const v = pieceValue(m, ps, p, ctx); + if (v < bestV || (v === bestV && p.uid < best.uid)) { bestV = v; best = p; } + } + return best; +} + /** 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); @@ -1536,8 +1619,9 @@ function lastPerfect(m, ps) { * 信标 (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; + * 博士投影 (promotes to elite; the normal one at the next round start, the golden one at once): the strongest normal + * deployed operator; + * 突变细胞 (the carrier becomes a random tier + 1 operator after the battle): cellTarget; * 随身身份牌 / 简易通讯机 / 寻呼模块 (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). @@ -1568,10 +1652,13 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) { .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; } + // 博士投影: the normal one promotes at the next round start, the golden one (缪尔赛思's R1 item, item merges) at once; + // both refuse an elite (builtinMeta: BAD_TARGET 'already elite') case 'equip_round_start_upgrade_char': + case 'use_equip_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; + return cellTarget(m, ps, ctx); 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': @@ -1596,10 +1683,14 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) { return order.find(free) || null; } +/** 教鞭 / “神秘顾客”: an Art that adds a bounty to the own next battle (trap_create_self_choice). */ +const isBountyArt = (gd, itemId) => { const rec = gd.item(itemId); return !!rec && Array.isArray(rec.buffs) && rec.buffs.some((b) => b && b.key === 'trap_create_self_choice'); }; + /** * 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. + * deployed operator, with a free hand slot for the copy; 教鞭 / “神秘顾客” add a bounty to the next battle (one the engine + * draws, not chosen) — only after a perfect battle with LP to spare, else the Art stays in the hand for a later round + * (destroying 教鞭 gives nothing; a full hand at the prep end: freeHandSlot). */ function useArt(m, ps, item, ctx) { const rec = m.gd.item(item.id); @@ -1613,12 +1704,9 @@ function useArt(m, ps, item, ctx) { 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)); - } + if (!lastPerfect(m, ps) || ps.lp < 10) return; // kept + const [key] = [...ps.board.keys()]; + if (key) { const [r, c] = parseKey(key); tryDo(() => ps.useArt(item.uid, r, c)); } return; } const [key] = [...ps.board.keys()]; diff --git a/test/match/bot-smarter.test.js b/test/match/bot-smarter.test.js index 88e951e0..4701c94c 100644 --- a/test/match/bot-smarter.test.js +++ b/test/match/bot-smarter.test.js @@ -1,13 +1,15 @@ // 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). +// own board, item carriers by what the item does (both 博士投影, 突变细胞 never on an elite or a pair member), bounty +// Arts kept rather than destroyed (never on a human's seat under AI 托管), a tactician's 援军 inside its attack range, +// pairs completed through the shop freeze, 坎诺特's banked funds, a teammate's bond read from its bond strip, and a +// cost guard on the prep heuristics. The outcome numbers (old vs new bot on the same seeds) are measured with +// tools/botbench.mjs (docs/META.md §1.5). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { PHASE } from '../../shared/constants.js'; -import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, rangeTiles } from '../../server/match/bot.js'; -import { parseKey } from '../../server/match/board.js'; -import { makeMatch, checkInvariants, give, giveItem, DATA } from './harness.js'; +import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles } from '../../server/match/bot.js'; +import { parseKey, tileKey } from '../../server/match/board.js'; +import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js'; const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o }); @@ -39,7 +41,7 @@ test('bounty pick: the card the own board can beat, never one it cannot (even wh m.dispose(); }); -test('bounty pick in real drafts (绝境 R3 悬赏决策): never a card the board cannot beat while a beatable one is offered', () => { +test('bounty pick in real drafts (绝境 R3 悬赏决策): a card the board likely beats (p ≥ 0.5) whenever a near-sure one (p ≥ 0.9) is offered', () => { let drafts = 0; for (const seed of [1, 2, 3, 4]) { const h = soloBot({ seed, difficulty: 'HARD', fake: true }).start(); @@ -65,7 +67,7 @@ test('bounty pick in real drafts (绝境 R3 悬赏决策): never a card the boar 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', () => { +test('items: 信标 on a bench single; both 博士投影 on a normal operator even when an elite is the best one; 突变细胞 never on an elite or a pair member; 拟态物质 on a pair', () => { const h = soloBot({ seed: 6 }).start(); const m = h.m; const ps = m.order[0]; @@ -78,6 +80,12 @@ test('items: 信标 never on the lineup when a bench single can take it; 博士 const fresh = (tier) => Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === tier && m.pool.left(c.chessId) >= 3 && !owned.has(c.chessId)).chessId; for (const p of ps.hand) if (p && p.kind === 'chess') ps.returnCopies(p); ps.hand.fill(null); + // the highest-tier deployed operator becomes an elite: the most valuable piece on the board + const [topKey, top] = [...ps.board.entries()].filter(([, p]) => p.kind === 'chess' && !m.gd.isGolden(p.id)) + .sort((a, b) => m.gd.chess(b[1].id).tier - m.gd.chess(a[1].id).tier || a[1].uid - b[1].uid)[0]; + ps.returnCopies(top); + ps.board.delete(topKey); + const elite = give(m, ps, m.gd.goldenIdOf(top.id), 'board', parseKey(topKey)); const single = give(m, ps, fresh(4), 'hand'); const pairId = fresh(1); const pairA = give(m, ps, pairId, 'hand'); @@ -86,19 +94,76 @@ test('items: 信标 never on the lineup when a bench single can take it; 博士 // 信标 (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 + // 博士投影 (promotion; the golden one at once — 缪尔赛思's R1 item): a normal operator on the board, never the elite + for (const id of ['chess_item_5_06_e_a', 'chess_item_5_06_e_b']) { + const promo = itemTarget(m, ps, giveItem(m, ps, id)); + assert.ok(promo && deployed().includes(promo) && !m.gd.isGolden(promo.id), `${id} on a normal deployed operator`); + assert.notEqual(promo.uid, elite.uid); + } + // 突变细胞 (becomes a random tier + 1 operator after the battle): normal, below VI, never one of the pair (the least + // valuable pieces here) or the elite const cell = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_08_e_a')); - assert.ok(cell && 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'); + assert.ok(cell && !m.gd.isGolden(cell.id) && tierOf(cell) < 6, '突变细胞 on a normal operator below VI'); + assert.ok(![pairA.uid, pairB.uid].includes(cell.uid), '…never one of a merge pair'); // 拟态物质 (a third copy when two are owned): the pair const mimic = itemTarget(m, ps, giveItem(m, ps, 'chess_item_5_05_e_a')); assert.ok(mimic && [pairA.uid, pairB.uid].includes(mimic.uid), '拟态物质 on the pair'); m.dispose(); }); +test('bounty Arts (教鞭): kept after a battle with leaks, used after a perfect one; a full hand drops it on a bot\'s seat, never on a human\'s seat under AI 托管', () => { + const WHIP = 'chess_item_6_03_m'; + const owns = (ps, id) => [...ps.hand, ...ps.temp].some((p) => p && p.id === id); + const leaky = { killed: 5, total: 6, leaked: [{ counted: true }], perfect: false }; + const clean = { killed: 6, total: 6, leaked: [], perfect: true }; + { + const h = soloBot({ seed: 4 }).start(); + const m = h.m; + const ps = m.order[0]; + h.run(() => m.phase === PHASE.PREP && m.round === 1); + ps.lp = 999; + h.run(() => m.phase === PHASE.PREP && m.round === 3); + m.lastResults.set(ps.playerId, leaky); + giveItem(m, ps, WHIP); + h.run(() => m.phase === PHASE.COMBAT && m.round === 3); + assert.ok(owns(ps, WHIP), 'kept after a battle with leaks (not destroyed)'); + h.run(() => m.phase === PHASE.PREP && m.round === 4); + m.lastResults.set(ps.playerId, clean); + const bounties = ps.bounties.length; + h.run(() => m.phase === PHASE.COMBAT && m.round === 4); + assert.ok(!owns(ps, WHIP) && ps.bounties.length > bounties, 'used after a perfect battle'); + assert.equal(m.errorCount, 0); + m.dispose(); + } + for (const human of [false, true]) { + const seat = human ? { seat: 0, playerId: 'p_0', name: 'P', isBot: false, connected: true } : { seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }; + const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [seat], seed: 4 }).start(); + const m = h.m; + const ps = m.order[0]; + if (human) assert.ok(m.setAutoplay(ps, true).ok, 'AI 托管 on'); + h.run(() => m.phase === PHASE.PREP && m.round === 1); + ps.lp = 999; + h.run(() => m.phase === PHASE.PREP && m.round === 3); + m.lastResults.set(ps.playerId, leaky); + // a full hand at the prep end with the 教鞭 in it + const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id))); + const fillers = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.left(c.chessId) >= 1 && !owned.has(c.chessId)); + giveItem(m, ps, WHIP); + for (let i = 0; ps.hand.some((x) => x == null); i++) give(m, ps, fillers[i].chessId, 'hand'); + const chessBefore = ps.allChess().length; + botPrepEnd(m, ps); + assert.ok(ps.hand.some((x) => x == null), 'a hand slot is free'); + if (human) { + assert.ok(owns(ps, WHIP), 'a human\'s 教鞭 is never destroyed by AI 托管'); + assert.equal(ps.allChess().length, chessBefore - 1, 'a bench chess was sold instead'); + } else { + assert.ok(!owns(ps, WHIP), 'the bot drops its kept 教鞭 for the slot'); + assert.equal(ps.allChess().length, chessBefore, 'no chess sold'); + } + m.dispose(); + } +}); + test('a tactician\'s 援军 (伺夜\'s 狼群) is placed on a tactical point inside the tactician\'s attack range', () => { const VIGIL = 'chess_char_3_19_a'; const WOLF = 'token_10028_vigil_wolf'; @@ -155,6 +220,60 @@ test('economy: a held pair is completed — bought when affordable, else the sho m.dispose(); }); +test('坎诺特 (利滚利: leftover funds are kept, +1 at ≥ 5): with a full board the bot banks its interest capital instead of refreshing it away', () => { + let checked = 0; + for (const seed of [5, 7, 9]) { + const h = soloBot({ seed }).start(); + const m = h.m; + const ps = m.order[0]; + h.run(() => m.phase === PHASE.PREP && m.round === 1); + ps.lp = 999; + h.run(() => m.phase === PHASE.PREP && m.round === 7); + ps.bandId = 'band_cannot'; + assert.ok(m.gd.leftoverKeptBands.includes(ps.bandId)); + ps.funds = 14; + const merges = ps.stats.merges; + h.run(() => m.phase === PHASE.COMBAT && m.round === 7); + // (a merge may spend the reserve; the level-up on the curve is paid for first and leaves enough here) + if (ps.stats.merges === merges) { + checked++; + assert.ok(ps.funds >= 5, `seed ${seed}: ${ps.funds} funds banked`); + } + m.dispose(); + } + assert.ok(checked >= 2, `${checked} preps without a merge`); +}); + +test('bond plan: a teammate\'s main core bond (its bond strip — a human\'s too) counts against the bot\'s focus (the shop pool is shared)', () => { + const SARGON = ['chess_char_1_12_a', 'chess_char_2_06_a', 'chess_char_2_08_a']; + const KAZIMIERZ = ['chess_char_1_19_a', 'chess_char_2_12_a', 'chess_char_2_18_a']; + const focusWhenMateBuilds = (mateIds) => { + const seats = [{ seat: 0, playerId: 'p_0', name: 'P', isBot: false, connected: true }, { seat: 1, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }]; + const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 3 }).start(); + const m = h.m; + const [human, ai] = m.order; + h.run(() => m.phase === PHASE.PREP && m.round === 2); + for (const ps of [human, ai]) { + for (const p of ps.allChess()) ps.returnCopies(p); + ps.board.clear(); + ps.hand.fill(null); + ps.temp.fill(null); + ps.layers = {}; + } + for (const id of [SARGON[0], SARGON[1], KAZIMIERZ[0], KAZIMIERZ[1]]) give(m, ai, id, 'hand'); + for (const id of mateIds) give(m, human, id, 'board', legalTileFor(m, human, id)); + human.recompute(); + ai.recompute(); + ai._botFocus = null; + ai._botFocusId = null; + const { focus } = bondPlan(m, ai); + m.dispose(); + return focus; + }; + assert.equal(focusWhenMateBuilds(SARGON), 'kazimierzShip', 'the human builds 萨尔贡: the bot takes 卡西米尔'); + assert.equal(focusWhenMateBuilds(KAZIMIERZ), 'sargonShip', 'the human builds 卡西米尔: the bot takes 萨尔贡'); +}); + test('cost guard: the prep heuristics of a late-round 4-bot match stay cheap (rehearsal off)', () => { const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true })); const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 13, fake: true }).start(); @@ -170,8 +289,8 @@ test('cost guard: the prep heuristics of a late-round 4-bot match stay cheap (re const u = process.cpuUsage(u0); worst = Math.max(worst, (u.user + u.system) / 1000); } - // ≈ 10–40 ms on a quiet desktop (tools/botbench.mjs); the bound only catches an accidental blow-up - assert.ok(worst < 1500, `worst bot prep ${worst.toFixed(1)} ms of CPU`); + // 7–35 ms of CPU per bot prep R6–R13 (seeds 13–15); ≈ 10× headroom for a loaded host + assert.ok(worst < 300, `worst bot prep ${worst.toFixed(1)} ms of CPU`); assert.equal(m.errorCount, 0); m.dispose(); }); From 774c59c1d405ac4e720ebe26aaea6640e864e3f5 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:32:53 +0800 Subject: [PATCH 6/8] feedback1 WF: a bench chess carrying a Mutation Cell is not sold before its transformation --- server/match/bot.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/match/bot.js b/server/match/bot.js index e93aa79e..cd0350a5 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -1129,13 +1129,16 @@ function refreshValue(m, ps, ctx) { 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) and does not + * carry a 突变细胞 (its transformation after the battle is the point). + */ function sellWeakestHand(m, ps, { keepPairs = true, below = Infinity } = {}) { const ctx = context(m, ps); let worst = null; let worstV = Infinity; for (const p of [...ps.temp, ...ps.hand]) { - if (!p || p.kind !== 'chess') continue; + if (!p || p.kind !== 'chess' || (p.items || []).some((it) => isMutationCell(m.gd, it.id))) continue; const base = m.gd.baseIdOf(p.id); if (keepPairs && !m.gd.isGolden(p.id) && (ctx.copies.get(base) || 0) >= 2) continue; const v = pieceValue(m, ps, p, ctx); From b9545f9c2c58f7c557498999c371b74dabd859fa Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:34:19 +0800 Subject: [PATCH 7/8] feedback1 WF: botbench --band (a strategy's mechanic in isolation) --- tools/botbench.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/botbench.mjs b/tools/botbench.mjs index f4683a0c..9cef7525 100644 --- a/tools/botbench.mjs +++ b/tools/botbench.mjs @@ -12,6 +12,8 @@ // --json F write the raw per-match records to F (the input of --compare) // --jobs N run the matches in N worker threads (outcomes are identical; the timings are then inflated by the // parallel load — measure decision time with --jobs 1) +// --band ID solo configs only: the bot plays strategy ID (e.g. band_cannot) instead of its weighted pick — a band's +// mechanic in isolation (坎诺特 banking, 杜宾's 教鞭, 昆图斯's 突变细胞) // // Per match: victory, rounds passed, LP left (the alive players at the end; a won Final Assault: the team LP), and per // bot and round: LP, shop level, deployed units, active bonds and Σ bond tiers, activated layers, board value (Σ shop @@ -76,6 +78,8 @@ function runOne(cfg, seed) { roomCode: 'BENCH', mode: cfg.mode, difficulty: cfg.difficulty, seats, seed, data, log, scheduler: sched, botRehearsal: rehearsal, send: () => true, broadcast: () => {}, onEnd: (s) => { summary = s; }, }); + // --band: the only strategy on offer (botPickBand draws from gd.bandIds()) + if (opt.band && cfg.mode === 'solo') m.gd.bandIds = () => [String(opt.band)]; const rec = { config: cfg.name, seed, stageId: m.stageId, bossId: m.bossId, preps: [], picks: [], rounds: [], bounties: [] }; // rehearsal stepping time (Battle.step of the rehearsal fields) let rehMs = 0; From e065cda0a48e1a89abd85178c54deaefd4b921e4 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:50:52 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feedback1=20WF:=20final=20A/B=20numbers;=20?= =?UTF-8?q?botbench=20records=20the=20best=20bounty=20card=20still=20on=20?= =?UTF-8?q?offer;=20META=20=C2=A71.5=20for=20the=20review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BALANCE.md | 38 ++++++++++++++++++++++---------------- docs/META.md | 29 ++++++++++++++++++----------- tools/botbench.mjs | 14 ++++++++++++-- 3 files changed, 52 insertions(+), 29 deletions(-) diff --git a/docs/BALANCE.md b/docs/BALANCE.md index bf8feb70..2fa1d35b 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -335,26 +335,32 @@ previous pass (old generator), kept for history. \* the bot code of this pass ( **Player feedback after 0.1.0 (#10 "人机有点太笨了").** Measured first (`tools/botbench.mjs`): the 0.1.0 bots refreshed 0–2 times a prep and bought side-grade singles they sold again at a loss (every sale returns 1), so they merged ≈ 2 -times a match and fielded ≈ 2 elites at R13; at 绝境 ≈ 1 in 2 of their bounty picks leaked (the old score ignored the -enemy); 信标 went on the best operator (it destroys its carrier) and a level-up could spend the funds a third copy in -the shop needed. Changes (server/match/bot.js header, META §1.5): refresh-vs-buy by the shop odds of completing held -pairs, the freeze for an unaffordable third copy, merges before level-ups, a committed focus / second bond, armour-aware -DPS, bounty picks from the exposure model, item carriers by effect, a tactician's 援军 inside its range. Same seeds -(1–40) old → new, the match's default rehearsal (3): +times a match and fielded ≈ 2 elites at R13; in co-op 绝境 ≈ 1 in 2 of their bounty picks leaked (solo 10 of 80; the +old score ignored the enemy); 信标 went on the best operator (it destroys its carrier) and a level-up could spend the +funds a third copy in the shop needed. Changes (server/match/bot.js header, META §1.5): refresh-vs-buy by the shop odds +of completing held pairs, the freeze for an unaffordable third copy, merges before level-ups, a committed focus / +second bond (a teammate's main bond read from its bond strip), armour-aware DPS, bounty picks by expected value from +the exposure model, item carriers by effect, 坎诺特 banking its interest capital, a tactician's 援军 inside its range. +Same seeds (1–40) old → new, the match's default rehearsal (3): | config | wins | rounds passed | LP left | leaks / match | bounty enemies leaked | merges / bot | elites at R13 | |---|---|---|---|---|---|---|---| -| 独立 标准 (1 AI) | 35 → 35 / 40 | 8.80 → 8.80 | 29.9 → 28.8 | 2.5 → 2.9 | 1 → 1 | 1.40 → 2.23 | – (R7: 0.7 → 1.0) | -| 独立 绝境 (1 AI) | 19 → 28 / 40 | 13.05 → 13.47 | 14.3 → 16.4 | 26.5 → 19.6 | 10 → 5 of 80 picks | 2.33 → 4.17 | 1.8 → 2.2 | -| 同盟 标准 (4 AI) | 40 → 40 / 40 | 14.00 → 14.00 | 110.0 → 113.5 | 39.4 → 32.8 | 6 → 5 | 2.59 → 4.17 | 1.9 → 2.3 | -| 同盟 绝境 (4 AI) | 5 → 11 / 40 | 11.85 → 12.22 | 5.1 → 10.9 | 224.6 → 215.2 | 155 → 142 of ≈ 310 picks | 2.00 → 3.05 | 2.3 → 2.4 | +| 独立 标准 (1 AI) | 35 → 35 / 40 | 8.80 → 8.80 | 29.9 → 28.6 | 2.5 → 3.3 | 1 → 1 | 1.40 → 2.25 | – (R7: 0.7 → 1.0) | +| 独立 绝境 (1 AI) | 19 → 26 / 40 | 13.05 → 13.35 | 14.3 → 16.6 | 26.5 → 19.7 | 10 → 8 of 80 picks | 2.33 → 4.10 | 1.8 → 2.2 | +| 同盟 标准 (4 AI) | 40 → 40 / 40 | 14.00 → 14.00 | 110.0 → 112.2 | 39.4 → 33.1 | 6 → 6 | 2.59 → 4.10 | 1.9 → 2.3 | +| 同盟 绝境 (4 AI) | 5 → 12 / 40 | 11.85 → 12.32 | 5.1 → 12.7 | 224.6 → 212.6 | 155 → 148 of ≈ 310 picks | 2.00 → 2.97 | 2.3 → 2.8 | -绝境 leaks per alive bot in R12 / R13: solo 8.0 / 9.2 → 5.7 / 4.9, co-op 20.5 / 20.7 → 17.6 / 17.8. Decision time per -bot prep is unchanged (one thread, back to back, seeds 1–6 — solo 绝境 / co-op 绝境; the rehearsal included): wall clock -p50 / p95 87 / 175 → 82 / 191 ms and 84 / 172 → 80 / 157 ms, CPU 125 / 315 → 120 / 305 ms and 92 / 198 → 90 / 180 ms; -the heuristics alone (CPU) 30 / 74 → 26 / 64 ms and 17 / 41 → 16 / 35 ms (the lineup search tries identical pieces -once and reuses the lineup across refreshes); a 机变 pick 0.07 → 0.14 ms (p50). The tuning sweeps used seeds 101–148 -with the rehearsal off; the table above is a separate seed range. +绝境 leaks per alive bot in R12 / R13: solo 8.0 / 9.2 → 5.7 / 4.8, co-op 20.5 / 20.7 → 18.5 / 15.8. Co-op bounty leaks +are bound by the drafts, not the pick: in 223 of the 312 co-op 绝境 bounty picks no card still on offer had a kill +chance ≥ 0.5 (solo 22 of 80), and only 5 picks (solo 0) took a card below 0.5 while one ≥ 0.5 was on offer — re-measure +after the bounty-half fix (player report #2). Strategies played alone (solo 绝境, seeds 201–220, forced with `--band`): +坎诺特 9 → 14 wins of 20, 昆图斯 9 → 11, 杜宾 8 → 9 (keeping an unused 教鞭 vs dropping it: 11 vs 11 wins over 24 杜宾 +matches — kept, as it costs nothing but a hand slot). Decision time per bot prep is unchanged (one thread, back to back +on a quiet host, seeds 1–6, solo 绝境 / co-op 绝境; the rehearsal included): wall clock p50 / p95 94 / 191 → 89 / 205 ms +and 86 / 175 → 83 / 162 ms, CPU 131 / 314 → 124 / 329 ms and 97 / 209 → 91 / 188 ms; the heuristics alone (CPU) 31 / 74 +→ 28 / 70 ms and 17 / 39 → 17 / 38 ms (the lineup search tries identical pieces once and reuses the lineup across +refreshes); a 机变 pick 0.07 → 0.15 ms (p50). Over the 40-seed A/B the summed prep CPU per match moved −6 % … +2 %. The +tuning sweeps used seeds 101–148 with the rehearsal off; the tables above are separate seed ranges. --- diff --git a/docs/META.md b/docs/META.md index d73ee975..3e843e8a 100644 --- a/docs/META.md +++ b/docs/META.md @@ -172,9 +172,11 @@ The rest of this section is the legacy server-run mode ### 1.5 AI player (bot.js) Buys toward a full board first (the cap is 8 from R1; leftover funds are lost), completes the merges it can afford, then levels on a curve (L2 ≈ R3, L3 ≈ R5, L4 ≈ R7, L5 ≈ R10, L6 ≈ R12 — the competent curve of docs/BALANCE.md; free -levels always), then spends the rest. It builds around a **focus core bond** (owned members × 10 + members the shop -can still bring at its level + banked layers + 6 for last round's focus, − 6 when a teammate already builds it: the -pool is shared) and a **second bond** (most owned members, next threshold within reach); its keepers are the deployed +levels always), then spends the rest (a band that keeps its leftover funds — 坎诺特 利滚利 — holds its interest capital +of 5 back from refreshes, full-board buys that complete nothing, items and spare level-ups). It builds around a +**focus core bond** (owned members × 10 + members the shop can still bring at its level + banked layers + 6 for last +round's focus, − 6 when it is a teammate's main core bond on that teammate's bond strip — humans included: the pool is +shared) and a **second bond** (most owned members, next threshold within reach); its keepers are the deployed operators, the focus / second members and chess of tier ≥ shop level − 1. With a full board a purchase must be merge progress or a lineup upgrade **and** be worth more than the refreshes its price would pay for: a refresh is worth Σ over the held pairs of P(the refresh shows the third copy) × the value of an elite (the shop's copy-weighted odds @@ -186,18 +188,23 @@ share of a dealer's damage that gets through the round's DEF / RES — `mitigate 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 +lineup when a bench single exists), 拟态物质 on a pair, 博士投影 (both qualities — neither takes an elite) on the strongest +normal operator, 突变细胞 on the least valuable normal operator below 6阶 (deployed or benched — never an elite, never one +of a merge pair, never one already carrying a cell), 身份牌 / 通讯机 / 寻呼模块 on a focus member; 画卷 copies the most +valuable deployed operator; 教鞭 / “神秘顾客” are used after a perfect battle and otherwise kept in the hand — a bot drops +a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never. 机变: a +bounty card is scored by its expected payout minus its expected leaks × the value of an LP (2 + 20 / LP), the kill +chance from the exposure model below for that one enemy against the own board as it stands (its HP × the round's +multiplier, DEF / RES, speed, route kind) — an expected-value comparison: it prefers the card with the best payout +minus expected LP loss and takes one it is unlikely to beat only when nothing better is offered or the pay difference +is large (in co-op 绝境 most remaining bounty leaks come from drafts with no beatable card at all); tactic cards by what they act on (a 盟誓 / 驰援 card on its bonds, 升华, …). Placement uses the round's enemy preview: every route is traced over the own board (ground: the stage's device-aware ground paths; flyers: through their checkpoints) and weighted by its enemies; an exposure model (time on each tile × DPS against the round's armour of the units covering it, blocker hold time, anti-air only on flying routes) is maximized greedily (blockers, then damage dealers, then healers); a -tactician's 援军 goes on a tile of the tactician's attack range, 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 +tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange` when the server has it, else +the owner's range grid), and a tile the server's `g.move` refuses is skipped for the next best one (the planner reads +`board.js legalTiles` on the deploy map; `g.move` is the judge). Boss rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the own board (rows −7, the right player of a pair mirrored, only the routes that end on its half) and the leader counts as 10 tough enemies with a 30 s dwell on its first tiles, so the damage dealers reach stationary leaders. diff --git a/tools/botbench.mjs b/tools/botbench.mjs index 9cef7525..a39fba65 100644 --- a/tools/botbench.mjs +++ b/tools/botbench.mjs @@ -19,7 +19,8 @@ // bot and round: LP, shop level, deployed units, active bonds and Σ bond tiers, activated layers, board value (Σ shop // price of the deployed chess, an elite = goldenCopies copies), elites deployed, items equipped, funds lost at the prep // end (leftover funds are lost, PlayerState.endPrep), leaks (bounty leaks apart) and the bounty cards taken — with the -// bot's own kill-chance estimate when the bot has one (bot.bountyKillChance), for a calibration table. +// bot's own kill-chance estimate when the bot has one (bot.bountyKillChance) for a calibration table, and the best +// estimate among the draft's cards (was a likelier card on offer?). // Decision time: every bot prep runs in one scheduler callback in virtual time (Match.scheduleBotPrep); the tool times // those callbacks (Match.later on the instance; wall clock and process CPU time) and splits off the rehearsal battles' // stepping (Battle.step of the rehearsal fields 'r:') — "heuristics" = the prep minus the rehearsal. A 机变 pick is @@ -124,8 +125,10 @@ function runOne(cfg, seed) { // the bot's own kill-chance estimate (when this bot version has one), for the calibration table const c0 = performance.now(); const p = typeof bot.bountyKillChance === 'function' ? bot.bountyKillChance(m, ps, card) : null; + // the best estimate among the draft's bounty cards still untaken (was a likelier one on offer?) + const best = p == null ? null : Math.max(p, ...m.sp.cards.filter((c) => c && c.kind === 'bounty' && m.sp.taken[c.idx] == null).map((c) => bot.bountyKillChance(m, ps, c))); calMs += performance.now() - c0; - rec.bounties.push({ round: m.round, pid: ps.playerId, enemyKey: card.enemyKey, count: card.count, tier: card.tier, payout: card.payout, battles: card.rounds, p }); + rec.bounties.push({ round: m.round, pid: ps.playerId, enemyKey: card.enemyKey, count: card.count, tier: card.tier, payout: card.payout, battles: card.rounds, p, best }); } return apply(ps, idx); }; @@ -299,6 +302,13 @@ function compare(runs, names) { console.log('\n| run | bounty kill-chance estimate (bot.bountyKillChance) → share of bounty enemies actually killed, picks per bin |'); console.log('|---|---|'); runs.forEach((rs, i) => { if (calib[i].some((b) => b.n)) console.log(`| ${names[i]} | ${calib[i].filter((b) => b.n).map((b) => `p ${b.lo}–${Math.min(1, b.hi)}: ${(100 * (1 - b.leaked / Math.max(1, b.units))).toFixed(0)} % (${b.n})`).join(' · ')} |`); }); + // the picks against what the draft offered (records with `best`) + console.log('\n| config | run | bounty picks | p < 0.5 picked while a p ≥ 0.5 card was offered | drafts with no card p ≥ 0.5 |'); + console.log('|---|---|---|---|---|'); + for (const c of cfgs) runs.forEach((rs, i) => { + const b = rs.filter((r) => r.config === c).flatMap((r) => r.bounties).filter((x) => x.best != null); + if (b.length) console.log(`| ${c} | ${names[i]} | ${b.length} | ${b.filter((x) => x.p < 0.5 && x.best >= 0.5).length} | ${b.filter((x) => x.best < 0.5).length} |`); + }); } console.log('\n| config | run | bot preps | prep CPU ms p50 / p95 | heuristics CPU ms p50 / p95 | Σ prep CPU s | Σ heuristics CPU s | per match: prep CPU s |'); console.log('|---|---|---|---|---|---|---|---|');