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(); });