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:
1 parent
679309fc4f
commit
f4e55a7e29
2 files changed
+273
-66
No files matched your search
+138
-50
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user