feedback1 WF: review fixes — golden 博士投影 and 突变细胞 carriers, bounty Arts kept, 坎诺特 banks its capital, teammates' bonds from their bond strips, server summon range, tighter cost guard

This commit is contained in:
sganggs committed 2026-10-03 03:30:02 +08:00
1 parent 679309fc4f
commit f4e55a7e29
2 files changed
+273 -66

No files matched your search

+138 -50
View File
@@ -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()];
+135 -16
View File
@@ -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();
});