1271 lines
59 KiB
JavaScript
1271 lines
59 KiB
JavaScript
// server/match/bot.js — AI player (DESIGN §6.6). Used for AI teammates and "AI 托管" (g.autoplay; a departed human
|
||
// 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 "查看当前回合即将迎击的敌方单位").
|
||
//
|
||
// Prep routine (botPrep):
|
||
// 1. take a pending reward offer (merge progress, bond synergy, tier)
|
||
// 2. sell bench chess that neither make the lineup nor build toward something (sellJunk), buy toward a full board
|
||
// first (the deploy cap is 8 from round 1; leftover funds are lost at prep end), level the 调度中心 on a curve
|
||
// (free levels always; the −1/round discount is waited for early), then keep buying / refreshing with what is
|
||
// left: merge progress (pairs → elites) > bond thresholds (focus core bond, add-ons 2/3) > role needs
|
||
// (blockers, anti-air when the wave flies, one or two healers) > tier; with a full board a buy must improve the
|
||
// best lineup. Items only when a deployed operator can carry them. A merge that consumes a deployed copy leaves
|
||
// the elite on that copy's tile (PRTS 卫戍协议/帮助, PlayerState._mergeChess): nothing here assumes it in the hand —
|
||
// steps 3–4 plan it like any owned unit (kept, moved or benched).
|
||
// 3. lineup: the deployed set maximizes unit value + activated bond tiers (exact counting via computeBonds) +
|
||
// composition (chooseLineup: greedy seed + swap hill-climbing).
|
||
// 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground
|
||
// routes on the stage's device-aware ground paths, flying routes through their checkpoints; 近地悬浮 enemies walk
|
||
// the ground path but count as flyers) and weighted by their enemies; an exposure model (tile time × DPS of the
|
||
// covering units, blocker hold time, flyers only for anti-air) is maximized greedily — blockers first, then damage
|
||
// dealers by DPS, then healers — over every
|
||
// (legal tile, direction) pair: each unit's range grid is rotated per direction (DESIGN §3; RIGHT is tried first
|
||
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
|
||
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
|
||
// (act2 m01 blowers) the DPS is scaled by the blower ATK bonus of that direction (with / against / across). The last
|
||
// arrangement of a prep REHEARSES up to m.botRehearsal distinct layout variants with the real Battle (rehearsal
|
||
// seed, no meta dispatch, board restored exactly) and keeps the one with the fewest leaks. The default plan is
|
||
// placed first; Match steps the rehearsal in wall-clock-bounded slices (botPrepBegin → job.run → botPrepEnd) so
|
||
// whole simulated battles never block the server's event loop. The prep routine itself is sliced the same way:
|
||
// botPrepBeginSteps / botPrepEndSteps (and planLayoutSteps, arrangeSteps, createRehearsalSteps) are step
|
||
// 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 — the tacticians' 狼群 / 流形 only inside their owner's attack range
|
||
// (PlayerState.summonRange; player report #9 after 0.1.0) —, 凯瑟琳's 支援装置 next to the best operator no device
|
||
// faces yet, facing it.
|
||
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler);
|
||
// 突变细胞 (it comes back after every transformation) on the least valuable single normal operator below 6阶 —
|
||
// never an elite or one of a merge pair (cellTarget)
|
||
// 6. resolve the temp slots (a 突变细胞 left there gets a hand slot made for it — makeHandRoom — instead of being
|
||
// destroyed), keep one hand slot free, then Ready.
|
||
// 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.
|
||
|
||
import { GEO } from '../../shared/constants.js';
|
||
import { deriveSeed } from '../sim/rng.js';
|
||
import { ASPD_MIN } from '../sim/constants.js';
|
||
import { freeSlot, legalTiles, canPlace, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
||
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
|
||
import { itemKey } from './gamedata.js';
|
||
import { computeBonds } from './bondsMeta.js';
|
||
import { withBounties } from './waves.js';
|
||
import { HOVER_KEYS } from '../sim/content/enemies.js';
|
||
|
||
/**
|
||
* Drive a step generator (planLayoutSteps, createRehearsalSteps, arrangeSteps, botPrepBeginSteps …) to its end in one
|
||
* go and return its value — the synchronous form every step generator also has (tools, tests, botPrep).
|
||
*/
|
||
export function runSteps(gen) {
|
||
let r;
|
||
do r = gen.next(); while (!r.done);
|
||
return r.value;
|
||
}
|
||
|
||
/** Shop level the bot aims for at the start of round r (index = round). */
|
||
const LEVEL_TARGET = [1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 6, 6];
|
||
const TIER_POWER = [0, 10, 12.5, 15, 18, 21.5, 25];
|
||
/** Prep-side 特质 that keep adding bond layers every round / every refresh (a player's main layer engine). */
|
||
const RECURRING_TRAIT_EVENTS = new Set(['SERVER_PREP_START', 'SERVER_PREP_FIN', 'SERVER_REFRESH_SHOP']);
|
||
const LAYER_TRAIT_RE = /BOND|LAYER/;
|
||
const ECON_TRAIT_RE = /GOLD|REFRESH|COIN/;
|
||
const DEFAULT_MELEE_RANGE = [[0, 0], [0, 1]];
|
||
|
||
/**
|
||
* Band pick: weighted by starting LP (sturdier strategies are preferred). Alone, a band that withholds the first
|
||
* rounds' funds (老鲤 "资金暂存": no operator in R1–R2, every enemy leaks) is avoided — only 联防 teammates cover that.
|
||
*/
|
||
export function botPickBand(m, ps) {
|
||
const ids = m.gd.bandIds();
|
||
if (!ids.length) return m.gd.defaultBandId;
|
||
const lateFunds = (id) => /暂存/.test(String(m.gd.band(id)?.desc || ''));
|
||
const pairs = ids.map((id) => [id, Math.max(1, (m.gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1)]);
|
||
let total = 0;
|
||
for (const [, w] of pairs) total += w;
|
||
let r = m.rngBots() * total;
|
||
for (const [id, w] of pairs) { r -= w; if (r < 0) return id; }
|
||
return pairs[pairs.length - 1][0];
|
||
}
|
||
|
||
/** 机变 card pick among the untaken indexes: items and team buffs first; bounties (extra enemies) last. */
|
||
export function botPickCard(m, ps, cards, available) {
|
||
let best = available[0];
|
||
let bestScore = -Infinity;
|
||
for (const i of available) {
|
||
const c = cards[i];
|
||
if (!c) continue;
|
||
let s = 0;
|
||
if (c.kind === 'item') s = 8 + (c.tier || 1) * 4;
|
||
else if (c.kind === 'bounty') s = (c.payout === 'kill' ? 4 : 2) + (c.coin || 0) - (c.tier || 1) * 2 - Math.max(0, (c.count || 1) - 1) - (c.rounds >= 90 ? 4 : 0);
|
||
else if (c.kind === 'tactic') s = (c.team ? 12 : 8) + (c.tacticKind === 'ally' ? 4 : 0);
|
||
s += m.rngBots() * 0.5;
|
||
if (s > bestScore) { bestScore = s; best = i; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------
|
||
// evaluation helpers
|
||
|
||
const chessRec = (m, id) => m.gd.chess(id);
|
||
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
|
||
const isBlocker = (c) => !!c && positionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none';
|
||
/** 近地悬浮 enemies walk a ground route but are air units (no block, anti-air only — DESIGN §19). */
|
||
const HOVER = new Set(HOVER_KEYS);
|
||
const hitsFly = (c) => !!c && !!c.canHitFly && !isHealer(c) && c.attackKind !== 'none';
|
||
|
||
/** Distinct owned members per bond (board + hand + temp), and per-bond member sets. */
|
||
function ownedBonds(m, ps, exclude = null) {
|
||
const counts = new Map();
|
||
const seen = new Set();
|
||
for (const p of ps.allChess()) {
|
||
if (exclude && p.uid === exclude) continue;
|
||
const base = m.gd.baseIdOf(p.id);
|
||
if (seen.has(base)) continue;
|
||
seen.add(base);
|
||
const c = chessRec(m, p.id);
|
||
for (const b of (c && c.bonds) || []) counts.set(b, (counts.get(b) || 0) + 1);
|
||
}
|
||
return { counts, bases: seen };
|
||
}
|
||
|
||
/** Remaining pool copies of a bond's members (what the shop can still offer). */
|
||
function bondSupply(m, bondId) {
|
||
let n = 0;
|
||
for (const [id, e] of m.pool.entries) {
|
||
const c = m.gd.chess(id);
|
||
if (c && Array.isArray(c.bonds) && c.bonds.includes(bondId)) n += e.left;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/**
|
||
* The bond the bot builds around: the core bond with the most owned members (≥ 1), ties broken by the copies the
|
||
* shared pool still holds for it (bonds drained by teammates or disabled this match are poor targets). Cached per
|
||
* round (it only changes slowly).
|
||
*/
|
||
function focusBond(m, ps, owned) {
|
||
const key = `${m.round}|${[...owned.counts.entries()].map(([k, v]) => k + v).join()}`;
|
||
if (ps._botFocus && ps._botFocus.key === key) return ps._botFocus.id;
|
||
let best = null;
|
||
let bestS = 0;
|
||
for (const id of m.gd.bondIds) {
|
||
const b = m.gd.bond(id);
|
||
if (!b || !b.isCore || m.gd.modeInactiveBonds.has(id)) continue;
|
||
const k = owned.counts.get(id) || 0;
|
||
if (!k) continue;
|
||
const s = k * 100 + Math.min(99, bondSupply(m, id));
|
||
if (s > bestS) { bestS = s; best = id; }
|
||
}
|
||
ps._botFocus = { key, id: best };
|
||
return best;
|
||
}
|
||
|
||
/** Role census of the owned chess. */
|
||
function roles(m, ps) {
|
||
const r = { blockers: 0, antiAir: 0, healers: 0, total: 0 };
|
||
for (const p of ps.allChess()) {
|
||
const c = chessRec(m, p.id);
|
||
if (!c) continue;
|
||
r.total++;
|
||
if (isBlocker(c)) r.blockers++;
|
||
if (hitsFly(c)) r.antiAir++;
|
||
if (isHealer(c)) r.healers++;
|
||
}
|
||
return r;
|
||
}
|
||
|
||
/** Bond value of adding chess `c` to what is owned (owned counts exclude it). */
|
||
function bondValue(m, c, owned, focus) {
|
||
let v = 0;
|
||
for (const b of (c && c.bonds) || []) {
|
||
const bond = m.gd.bond(b);
|
||
if (!bond || m.gd.modeInactiveBonds.has(b)) continue;
|
||
const n = owned.counts.get(b) || 0;
|
||
const th = Array.isArray(bond.thresholds) && bond.thresholds.length ? bond.thresholds : [bond.activeCount || 2];
|
||
const w = bond.isCore ? 1.4 : 1;
|
||
v += (2 + n * 2) * w;
|
||
if (th.includes(n + 1)) v += 10 * w;
|
||
else if (th.some((t) => t > n + 1 && t - (n + 1) <= 1)) v += 3 * w;
|
||
if (bond.thresholdTemplate === 'count_threshold_downward') v -= n > 0 ? 12 : 0; // 独行 breaks with a second member
|
||
if (b === focus) v += 10;
|
||
}
|
||
return v;
|
||
}
|
||
|
||
/**
|
||
* Power of a chess record. Base stats barely grow with the tier (research 03: T1 E1 Lv55 … T6 E2 Lv1); higher tiers
|
||
* bring better skills and talents, elites (精锐) +25 % stats and skill level 7 — so the tier weight is modest and the
|
||
* elite weight large.
|
||
*/
|
||
function power(c) {
|
||
if (!c) return 0;
|
||
const p = TIER_POWER[Math.max(1, Math.min(6, c.tier || 1))];
|
||
return c.isGolden ? p * 1.9 : p;
|
||
}
|
||
|
||
/**
|
||
* 特质 census of a chess (cached per match): recurring layer traits (every prep / refresh), one-shot layer traits
|
||
* (获得时) and economy traits (funds / free refreshes). Humans build around the layer engines — so does the bot.
|
||
*/
|
||
function traitsOf(m, c) {
|
||
const cache = m._botTraits || (m._botTraits = new Map());
|
||
if (cache.has(c.chessId)) return cache.get(c.chessId);
|
||
const t = { recurring: 0, gain: 0, econ: 0 };
|
||
for (const gid of Array.isArray(c.garrisonIds) ? c.garrisonIds : []) {
|
||
const g = m.gd.garrison(gid);
|
||
if (!g || typeof g.effectKey !== 'string') continue;
|
||
if (LAYER_TRAIT_RE.test(g.effectKey) && RECURRING_TRAIT_EVENTS.has(g.eventType)) t.recurring++;
|
||
else if (LAYER_TRAIT_RE.test(g.effectKey) && g.eventType === 'SERVER_GAIN') t.gain++;
|
||
else if (ECON_TRAIT_RE.test(g.effectKey) && g.eventType !== 'IN_BATTLE') t.econ++;
|
||
}
|
||
cache.set(c.chessId, t);
|
||
return t;
|
||
}
|
||
|
||
/** Stand-alone value of an owned piece (no bond context): power, items, role fit, layer engines. */
|
||
function unitBase(m, piece, ctx) {
|
||
const c = chessRec(m, piece.id);
|
||
if (!c) return 0;
|
||
let v = power(c) + (piece.items ? piece.items.length * 5 : 0);
|
||
if (m.round <= 11) v += traitsOf(m, c).recurring * 4;
|
||
if (c.attackKind === 'none' && !isHealer(c)) v -= 6;
|
||
if (ctx.fly > 0 && hitsFly(c)) v += 2;
|
||
return v;
|
||
}
|
||
|
||
/** Value of an owned piece for bench / sell decisions (its bonds counted against the other owned chess). */
|
||
function pieceValue(m, ps, piece, ctx) {
|
||
const c = chessRec(m, piece.id);
|
||
if (!c) return 0;
|
||
return unitBase(m, piece, ctx) + bondValue(m, c, ownedBonds(m, ps, piece.uid), ctx.focus) * 0.8;
|
||
}
|
||
|
||
/**
|
||
* Score of a deployed set: unit values + the bonds it activates (exact tiers via bondsMeta.computeBonds, hand
|
||
* members still count for BOARD_AND_DECK bonds) + composition (blockers, anti-air when the wave flies, ≤ 2 healers).
|
||
*/
|
||
function lineupScore(m, ps, set, ctx) {
|
||
const gd = m.gd;
|
||
const board = new Map();
|
||
set.forEach((p, i) => board.set(`x${i}`, p));
|
||
const inSet = new Set(set.map((p) => p.uid));
|
||
const hand = ps.hand.map((p) => (p && !inSet.has(p.uid) ? p : null));
|
||
const bonds = computeBonds(gd, { board, hand, layers: ps.layers, bondCountBonus: ps.bondCountBonus });
|
||
let v = 0;
|
||
for (const p of set) v += unitBase(m, p, ctx);
|
||
for (const [id, b] of Object.entries(bonds)) {
|
||
if (!b.tier) continue;
|
||
const bond = gd.bond(id);
|
||
const w = bond && bond.isCore ? 14 : 9;
|
||
v += b.tier * w + Math.min(12, (b.layers || 0) * 0.1) + (id === ctx.focus ? 6 : 0);
|
||
}
|
||
let blockers = 0;
|
||
let air = 0;
|
||
let healers = 0;
|
||
for (const p of set) { const c = chessRec(m, p.id); if (isBlocker(c)) blockers++; if (hitsFly(c)) air++; if (isHealer(c)) healers++; }
|
||
v -= Math.max(0, Math.min(2, ctx.roles.blockers) - blockers) * 15;
|
||
if (ctx.fly > 0 && ctx.roles.antiAir > 0 && air === 0) v -= 12;
|
||
v -= Math.max(0, healers - 2) * 10;
|
||
return v;
|
||
}
|
||
|
||
/** Best deployable set of at most `cap` owned chess: greedy seed + swap hill-climbing on lineupScore. */
|
||
function chooseLineup(m, ps, ctx) {
|
||
const all = ps.allChess();
|
||
const cap = Math.min(ps.deployCap, all.length);
|
||
const seed = all.slice().sort((a, b) => pieceValue(m, ps, b, ctx) - pieceValue(m, ps, a, ctx) || a.uid - b.uid);
|
||
let set = seed.slice(0, cap);
|
||
let bench = seed.slice(cap);
|
||
let score = lineupScore(m, ps, set, ctx);
|
||
for (let iter = 0; iter < 12 && bench.length; iter++) {
|
||
let best = null;
|
||
for (let i = 0; i < set.length; i++) {
|
||
for (let j = 0; j < bench.length; j++) {
|
||
const trial = set.slice();
|
||
trial[i] = bench[j];
|
||
const s = lineupScore(m, ps, trial, ctx);
|
||
if (s > score + 0.5 && (!best || s > best.s)) best = { i, j, s };
|
||
}
|
||
}
|
||
if (!best) break;
|
||
const out = set[best.i];
|
||
set[best.i] = bench[best.j];
|
||
bench[best.j] = out;
|
||
score = best.s;
|
||
}
|
||
return { set, bench, score };
|
||
}
|
||
|
||
/** Shop / reward score of acquiring one copy of chess `id` (0 = not worth it). */
|
||
function buyScore(m, ps, id, ctx) {
|
||
const gd = m.gd;
|
||
const c = chessRec(m, id);
|
||
if (!c) return 0;
|
||
let s = power(c) * 0.6;
|
||
const tr = traitsOf(m, c);
|
||
if (m.round <= 11) s += tr.recurring * 3 + tr.gain * 2 + tr.econ * (m.round <= 7 ? 2 : 0);
|
||
const base = gd.baseIdOf(id);
|
||
if (!c.isGolden) {
|
||
const copies = ps.countCopies(base);
|
||
const need = gd.mergeCount(base) || 3;
|
||
if (copies > 0) s += copies + 1 >= need ? 36 : 10;
|
||
}
|
||
if (!ctx.owned.bases.has(base)) s += bondValue(m, c, ctx.owned, ctx.focus);
|
||
// role needs
|
||
if (isBlocker(c) && ctx.roles.blockers < 2) s += 10;
|
||
if (hitsFly(c) && ctx.fly > 0 && ctx.roles.antiAir < 2) s += 8;
|
||
if (isHealer(c)) s += ctx.roles.healers === 0 && m.round >= 3 ? 6 : ctx.roles.healers >= 2 ? -14 : -3;
|
||
if (c.attackKind === 'none' && !isHealer(c)) s -= 4;
|
||
return s;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------
|
||
// field model: where the round's enemies walk / fly over the own board, and an exposure model of a layout
|
||
|
||
const inRect = (r, c) => r >= FIELD.r0 && r <= FIELD.r1 && c >= 0 && c <= FIELD.c1;
|
||
|
||
/** Integer tiles along a polyline of [row, col] points (inclusive, consecutive duplicates removed). */
|
||
function traceLine(points) {
|
||
const out = [];
|
||
for (let i = 0; i + 1 < points.length; i++) {
|
||
const [r0, c0] = points[i];
|
||
const [r1, c1] = points[i + 1];
|
||
const n = Math.max(1, Math.abs(r1 - r0), Math.abs(c1 - c0));
|
||
for (let k = 0; k <= n; k++) {
|
||
const t = [Math.round(r0 + ((r1 - r0) * k) / n), Math.round(c0 + ((c1 - c0) * k) / n)];
|
||
const last = out[out.length - 1];
|
||
if (!last || last[0] !== t[0] || last[1] !== t[1]) out.push(t);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Boss field → own board: board.js boardTileOf (board rows 9–12 are boss rows 2–5; the right side mirrored). */
|
||
const BOSS_MID_COL = 10;
|
||
/** Dwell (s) the planner assumes on a leader's first own tiles (several leaders fight from their spawn point). */
|
||
const BOSS_DWELL = 30;
|
||
/** A leader is modeled as LEADER_WEIGHT enemies of LEADER_HP each (its route draws the damage dealers). */
|
||
const LEADER_WEIGHT = 10;
|
||
const LEADER_HP = 40000;
|
||
|
||
/** The boss-round wave of a player's group and its side ('L' | 'R'), or null (Match.bossGroupOf). */
|
||
function bossWaveOf(m, ps) {
|
||
const g = typeof m.bossGroupOf === 'function' ? m.bossGroupOf(ps) : null;
|
||
return g && g.wave ? { wave: g.wave, side: g.side } : null;
|
||
}
|
||
|
||
/**
|
||
* The round's routes over the own board (cached per round): [{ n, fly (air units: flyers, and the 近地悬浮 HOVER_KEYS
|
||
* on their ground path), tiles: ['r,c'…] (gate → objective, own region only, objective tile excluded), tileTime (s per
|
||
* tile), hp }], a tile → [[route, index]] index, and the
|
||
* aggregated ground flow per tile. Built from the wave preview (spawn counts per route, enemy HP / speed with the
|
||
* round's multipliers). Boss rounds (pass `ps`): the player's boss-field template mapped onto the own board (rows −7,
|
||
* the right player's half mirrored; only the routes that end on the player's half), the leader weighted heavily with
|
||
* a dwell on its first tiles so damage dealers reach it; missing data falls back to every ground path from the own
|
||
* gates.
|
||
*/
|
||
export function fieldModel(m, ps = null) {
|
||
const boss = m.wave ? null : bossWaveOf(m, ps);
|
||
const wave = m.wave || (boss && boss.wave);
|
||
const cacheKey = `${m.round}|${m.stageId}|${wave ? wave.templateId : 'boss'}|${boss ? boss.side : ''}`;
|
||
if (m._botPath && m._botPath.key === cacheKey) return m._botPath;
|
||
const gd = m.gd;
|
||
const st = m.stage;
|
||
const gpaths = (st && (st.groundPathsWithDevices || st.groundPaths)) || {};
|
||
const routesOut = [];
|
||
const toBoard = boss
|
||
? ([r, c]) => boardTileOf(boss.side === 'R' ? 'bossR' : 'bossL', r, c)
|
||
: (p) => p;
|
||
const pushRoute = (tilesRC, n, fly, hp, speed, dwell = 0) => {
|
||
const own = tilesRC.map(toBoard).filter(([r, c]) => inRect(r, c)).map(([r, c]) => tileKey(r, c));
|
||
if (own.length > 1) own.pop(); // the objective / last tile: an enemy there has already leaked
|
||
if (!own.length) return;
|
||
const tileTime = Math.max(0.4, Math.min(6, 1 / Math.max(0.05, speed * 0.5)));
|
||
routesOut.push({ n, fly, tiles: own, tileTime, hp: Math.max(100, hp), dwell });
|
||
};
|
||
const routes = wave && Array.isArray(wave.routes) ? wave.routes : [];
|
||
const spawns = wave && Array.isArray(wave.spawns) ? wave.spawns : [];
|
||
// boss pair templates route to both goals: keep the routes ending on this player's half (mirrored for 'R')
|
||
const ownRoute = (rt) => {
|
||
if (!boss || !Array.isArray(rt.end)) return true;
|
||
const endCol = rt.end[1];
|
||
return boss.side === 'R' ? endCol > BOSS_MID_COL : endCol <= BOSS_MID_COL;
|
||
};
|
||
const mirrorRoute = (rt) => {
|
||
// the right player fights the mirror image of the routes that end on the left goal (its own copy of the leader)
|
||
if (!boss || boss.side !== 'R' || !Array.isArray(rt.end) || rt.end[1] > BOSS_MID_COL) return rt;
|
||
const mc = ([r, c]) => [r, BOSS_MIRROR_COL - c];
|
||
return { ...rt, start: mc(rt.start), end: mc(rt.end), checkpoints: Array.isArray(rt.checkpoints) ? rt.checkpoints.map(mc) : [] };
|
||
};
|
||
if (routes.length && spawns.length) {
|
||
const per = new Map();
|
||
for (const s of spawns) {
|
||
const e = gd.enemy(s.enemyKey);
|
||
const n = Math.max(1, s.count || 1);
|
||
const isLeader = s.tag === 'boss';
|
||
if (s.tag === 'part') continue;
|
||
// the leader counts like LEADER_WEIGHT tough enemies: its huge pool makes damage on it worth a lot everywhere
|
||
const hp = isLeader ? LEADER_HP : ((e && e.stats && e.stats.maxHp) || 1000) * ((s.mods && s.mods.hpMul) || 1);
|
||
const spd = ((e && e.stats && e.stats.moveSpeed) || 1) * ((s.mods && s.mods.speedMul) || 1);
|
||
const air = HOVER.has(s.enemyKey);
|
||
const key = `${s.routeIndex}|${isLeader ? 'boss' : ''}|${air ? 'air' : ''}`;
|
||
const a = per.get(key) || { ri: s.routeIndex, boss: isLeader, air, n: 0, hp: 0, spd: 0 };
|
||
a.n += isLeader ? LEADER_WEIGHT : n; a.hp += hp * n; a.spd += spd * n;
|
||
per.set(key, a);
|
||
}
|
||
for (const a of per.values()) {
|
||
let rt = routes[a.ri];
|
||
if (!rt || !Array.isArray(rt.start) || !Array.isArray(rt.end)) continue;
|
||
if (boss && !a.boss && !ownRoute(rt)) continue;
|
||
// in a pair each player fights its own copy of the leader (spawned on its side)
|
||
rt = mirrorRoute(rt);
|
||
const cnt = a.boss ? 1 : a.n;
|
||
const dwell = a.boss ? BOSS_DWELL : 0;
|
||
if (rt.motion === 'FLY') {
|
||
pushRoute(traceLine([rt.start, ...(Array.isArray(rt.checkpoints) ? rt.checkpoints : []), rt.end]), a.n, true, a.hp / cnt, a.spd / cnt, dwell);
|
||
} else {
|
||
const key = `${rt.start[0]},${rt.start[1]}->${rt.end[0]},${rt.end[1]}`;
|
||
const path = Array.isArray(gpaths[key]) ? gpaths[key] : traceLine([rt.start, ...(boss && Array.isArray(rt.checkpoints) ? rt.checkpoints : []), rt.end]);
|
||
pushRoute(path, a.n, a.air, a.hp / cnt, a.spd / cnt, dwell); // a hovering enemy: ground tiles, air unit
|
||
}
|
||
}
|
||
}
|
||
if (!routesOut.some((r) => !r.fly)) {
|
||
for (const [k, arr] of Object.entries(gpaths)) {
|
||
if (!Array.isArray(arr) || !arr.length) continue;
|
||
const [sr, sc] = String(k).split('->')[0].split(',').map(Number);
|
||
if (!(sr >= FIELD.r0 && sr <= FIELD.r1 && sc <= FIELD.c1)) continue;
|
||
// stage ground paths are in normal-field coordinates: no boss mapping
|
||
const own = arr.filter(([r, c]) => inRect(r, c)).map(([r, c]) => tileKey(r, c));
|
||
if (own.length > 1) own.pop();
|
||
if (own.length) routesOut.push({ n: 3, fly: false, tiles: own, tileTime: 2, hp: 3000, dwell: 0 });
|
||
}
|
||
}
|
||
const index = new Map();
|
||
const ground = new Map();
|
||
routesOut.forEach((rt, ri) => {
|
||
rt.tiles.forEach((k, i) => {
|
||
if (!index.has(k)) index.set(k, []);
|
||
index.get(k).push([ri, i]);
|
||
if (!rt.fly) {
|
||
const e = ground.get(k) || { flow: 0, prog: 0 };
|
||
e.flow += rt.n;
|
||
e.prog = Math.max(e.prog, rt.tiles.length > 1 ? i / (rt.tiles.length - 1) : 1);
|
||
ground.set(k, e);
|
||
}
|
||
});
|
||
});
|
||
const flyTotal = routesOut.filter((r) => r.fly).reduce((s, r) => s + r.n, 0);
|
||
m._botPath = { key: cacheKey, routes: routesOut, index, ground, flyTotal, airflow: airflowOf(st, toBoard, boss && boss.side === 'R') };
|
||
return m._botPath;
|
||
}
|
||
|
||
/**
|
||
* 气流 (act2 m01 blowers, sim content/devices.js): own-board tile → { dir, bb } for every tile a (data-)active blower
|
||
* blows over, mapped like the routes (boss rounds: rows −7, the right side mirrored — RIGHT ↔ LEFT). An operator on
|
||
* such a tile facing WITH the flow gets ATK × (1 + blower_s_character[equal].atk), against it [opposite], across it
|
||
* [vertical] (DESIGN §3, research 09 §1.1): the planner scales its DPS accordingly when it picks a direction.
|
||
*/
|
||
function airflowOf(st, toBoard, mirrored) {
|
||
const out = new Map();
|
||
for (const d of (st && Array.isArray(st.devices) ? st.devices : [])) {
|
||
if (!d || d.role !== 'blower' || d.active === false || d.hidden) continue;
|
||
const bb = (d.skill && d.skill.bb) || {};
|
||
const dir = mirrored ? mirrorDir(normDir(d.dir, 'UP')) : normDir(d.dir, 'UP');
|
||
for (const p of Array.isArray(d.rangeTiles) ? d.rangeTiles : []) {
|
||
if (!Array.isArray(p)) continue;
|
||
const [r, c] = toBoard(p);
|
||
if (inRect(r, c)) out.set(tileKey(r, c), { dir, bb });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** ATK factor of an operator standing on `key` facing `dir` in the model's airflow (1 off the flow). */
|
||
export function airflowAtkMul(model, key, dir) {
|
||
const f = model && model.airflow instanceof Map ? model.airflow.get(key) : null;
|
||
if (!f) return 1;
|
||
const rel = dir === f.dir ? 'equal' : dir === oppositeDir(f.dir) ? 'opposite' : 'vertical';
|
||
const v = Number(f.bb[`blower_s_character[${rel}].atk`]);
|
||
return Number.isFinite(v) ? Math.max(0, 1 + v) : 1;
|
||
}
|
||
|
||
/** Direction candidates of the planner, in preference order (ties keep the earlier one: RIGHT = toward the gates). */
|
||
export const PLAN_DIRS = Object.freeze(['RIGHT', 'UP', 'DOWN', 'LEFT']);
|
||
|
||
/** Board tile keys covered by a record's range grid standing on (r, c) facing `dir` (grid rotated, DESIGN §3). */
|
||
export function rangeTiles(rec, r, c, dir = 'RIGHT') {
|
||
const grid = rec && Array.isArray(rec.rangeGrid) && rec.rangeGrid.length ? rec.rangeGrid : DEFAULT_MELEE_RANGE;
|
||
return grid.map(([dr, dc]) => { const [a, b] = rotateOffset(dr, dc, dir); return tileKey(r + a, c + b); });
|
||
}
|
||
|
||
/** Damage per second of a record (attack / attack interval; healers and non-attackers 0). */
|
||
function dpsOf(rec) {
|
||
const st = rec && rec.stats;
|
||
if (!st || isHealer(rec)) return 0;
|
||
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(ASPD_MIN, st.aspd || 100));
|
||
const d = (st.atk || 0) / interval;
|
||
return rec.attackKind === 'none' ? d * 0.4 : d;
|
||
}
|
||
|
||
/** Tunables of the exposure model (tuned offline against the real simulation, tools/matchrun.mjs sweeps). */
|
||
export const LAYOUT_PARAMS = Object.freeze({ hold: 4, kill: 1.5, secondHold: 0.5, healHold: 0.5, roadPenalty: 0.15, spread: 99 });
|
||
|
||
/**
|
||
* Exposure model of a layout: every enemy of route ρ spends tileTime on each tile of its route (plus the hold time
|
||
* of blockers standing on it: hold × blockCnt, later blockers on the same route secondHold ×, healer-covered
|
||
* blockers (1 + healHold) ×) and takes the DPS of every unit whose range covers that tile (flyers: anti-air only).
|
||
* Value = Σρ n × (1 − e^(−exposure / (kill × hp))) — the expected enemies killed.
|
||
*/
|
||
class Layout {
|
||
constructor(model, params) {
|
||
this.model = model;
|
||
this.p = params;
|
||
this.units = []; // { rec, key, dps, air, ground, block, heal, cover: Set }
|
||
}
|
||
|
||
value() {
|
||
const { routes } = this.model;
|
||
const p = this.p;
|
||
const cover = new Map(); // key → { g: dps on ground, a: dps on air }
|
||
const healCover = new Map();
|
||
for (const u of this.units) {
|
||
for (const k of u.cover) {
|
||
const e = cover.get(k) || { g: 0, a: 0 };
|
||
if (u.ground) e.g += u.dps;
|
||
if (u.air) e.a += u.dps;
|
||
cover.set(k, e);
|
||
if (u.heal) healCover.set(k, (healCover.get(k) || 0) + 1);
|
||
}
|
||
}
|
||
const blockAt = new Map();
|
||
for (const u of this.units) if (u.block > 0 && this.model.ground.has(u.key)) blockAt.set(u.key, (blockAt.get(u.key) || 0) + u.block);
|
||
let total = 0;
|
||
for (const rt of routes) {
|
||
let exp = 0;
|
||
let lastBlock = -Infinity;
|
||
for (let i = 0; i < rt.tiles.length; i++) {
|
||
const k = rt.tiles[i];
|
||
let t = rt.tileTime + (i < 2 && rt.dwell ? rt.dwell : 0);
|
||
if (!rt.fly && blockAt.has(k)) {
|
||
// a blocker right behind another one mostly holds what slipped past; a separate line holds again
|
||
const fresh = i - lastBlock >= p.spread;
|
||
t += p.hold * blockAt.get(k) * (fresh ? 1 : p.secondHold) * (1 + p.healHold * Math.min(2, healCover.get(k) || 0));
|
||
lastBlock = i;
|
||
}
|
||
const c = cover.get(k);
|
||
if (c) exp += t * (rt.fly ? c.a : c.g);
|
||
}
|
||
total += rt.n * (1 - Math.exp(-exp / (p.kill * rt.hp)));
|
||
}
|
||
// ranged units standing on a road block and get hit: a small penalty per road tile they occupy
|
||
for (const u of this.units) if (!u.block && this.model.ground.has(u.key)) total -= p.roadPenalty * this.model.ground.get(u.key).flow;
|
||
return total;
|
||
}
|
||
}
|
||
|
||
function unitOf(rec, key, r, c, dir = 'RIGHT', model = null) {
|
||
const block = isBlocker(rec) ? Math.max(1, rec.stats?.blockCnt ?? 1) : 0;
|
||
const atkMul = airflowAtkMul(model, key, dir);
|
||
return {
|
||
rec, key, dir, dps: dpsOf(rec) * atkMul, atkMul, air: hitsFly(rec), ground: rec.attackKind !== 'heal' && !isHealer(rec),
|
||
block, heal: isHealer(rec), cover: new Set(rangeTiles(rec, r, c, dir)),
|
||
};
|
||
}
|
||
|
||
/** A layout plan: Map uid → tile key, plus `dirs`: Map uid → direction (UP|RIGHT|DOWN|LEFT). */
|
||
export class LayoutPlan extends Map {
|
||
constructor(entries) { super(entries); this.dirs = new Map(); }
|
||
/** The planned direction of a piece (RIGHT when unplanned). */
|
||
dirOf(uid) { return this.dirs.get(uid) ?? 'RIGHT'; }
|
||
}
|
||
|
||
/**
|
||
* Plan tiles and directions for `pieces` (chess or token pieces with `id`): blockers first, then damage dealers by
|
||
* DPS, then healers and the rest; each takes the legal free (tile, direction) with the best marginal layout value
|
||
* (directions in PLAN_DIRS order, a direction whose rotated range equals an earlier one's is skipped; one rng draw per
|
||
* tile breaks ties between tiles). Returns a LayoutPlan (Map uid → key, `.dirs` uid → dir).
|
||
* @param {import('./Match.js').Match} m
|
||
* @param {import('./PlayerState.js').PlayerState} ps
|
||
*/
|
||
export function planLayout(m, ps, pieces, params = LAYOUT_PARAMS, opts = {}) {
|
||
return runSteps(planLayoutSteps(m, ps, pieces, 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 } = {}) {
|
||
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)));
|
||
const layout = new Layout(model, params);
|
||
const rank = (p) => { const r = rec(p); return isBlocker(r) ? 0 : isHealer(r) ? 2 : 1; };
|
||
const order = pieces.slice().sort((a, b) => rank(a) - rank(b) || dpsOf(rec(b)) - dpsOf(rec(a)) || a.uid - b.uid);
|
||
const taken = new Set(occupied);
|
||
const out = new LayoutPlan();
|
||
for (const p of order) {
|
||
const r0 = rec(p);
|
||
if (!r0) continue;
|
||
let best = null;
|
||
let bestV = -Infinity;
|
||
// a "只能部署在召唤者攻击范围内" summon (伺夜's 狼群, 缪尔赛思's 流形): only the tiles of its owner's range
|
||
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
|
||
for (const [r, c] of legalTiles(map, positionClass(r0))) {
|
||
const k = tileKey(r, c);
|
||
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) {
|
||
const u = unitOf(r0, k, r, c, dir, model);
|
||
// (the same covered tiles under a different airflow ATK factor are a different candidate)
|
||
const sig = `${u.atkMul}|${[...u.cover].sort().join(' ')}`;
|
||
if (seen.has(sig)) continue;
|
||
seen.add(sig);
|
||
layout.units.push(u);
|
||
const v = layout.value() + noise;
|
||
layout.units.pop();
|
||
if (v > bestV) { bestV = v; best = [k, r, c, dir]; }
|
||
}
|
||
}
|
||
if (!best) continue;
|
||
taken.add(best[0]);
|
||
layout.units.push(unitOf(r0, best[0], best[1], best[2], best[3], model));
|
||
out.set(p.uid, best[0]);
|
||
out.dirs.set(p.uid, best[3]);
|
||
yield;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** A plan's direction for a piece (plans without directions ⇒ RIGHT). */
|
||
const planDir = (plan, uid) => (plan && plan.dirs instanceof Map ? plan.dirs.get(uid) : null) ?? 'RIGHT';
|
||
|
||
/** Layout-model variants the rehearsal compares (the first one is the default plan). */
|
||
export const REHEARSAL_VARIANTS = Object.freeze([
|
||
{},
|
||
{ spread: 3, secondHold: 0.2 },
|
||
{ hold: 2 },
|
||
{ hold: 8 },
|
||
{ kill: 3 },
|
||
]);
|
||
|
||
/** Distinct plans (same unit → tile assignment ⇒ one candidate), in order. */
|
||
function distinctPlans(plans) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const plan of plans) {
|
||
const sig = [...plan.entries()].sort((a, b) => a[0] - b[0]).map(([u, k]) => `${u}@${k}:${planDir(plan, u)}`).join(' ');
|
||
if (!seen.has(sig)) { seen.add(sig); out.push(plan); }
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Counted leaks of the bot's player in a (possibly still running) rehearsal battle. */
|
||
function countedLeaks(battle, playerId) {
|
||
const r = battle.result();
|
||
const pp = r && r.perPlayer && r.perPlayer[playerId];
|
||
return pp ? (pp.leaked || []).filter((l) => l && l.counted !== false).length : 0;
|
||
}
|
||
|
||
/**
|
||
* Rehearsal job: a player "tries out" candidate layouts — each distinct plan (up to m.botRehearsal) is simulated once
|
||
* against the round's enemies with the real Battle (a rehearsal seed, never the real battle's; no onBattleStart meta
|
||
* dispatch, so it has no side effect on the match) and the plan with the fewest counted leaks (then most kills) wins.
|
||
* Every candidate's Battle is built right away (each layout is on the board only while its input is taken; the board
|
||
* is restored exactly before this returns), so the job can be stepped later: `job.run(budgetMs)` simulates until the
|
||
* wall-clock budget is used (checked every 4 ticks) and returns true once every candidate is done. A candidate whose
|
||
* counted leaks already exceed the best finished one's stops early (it can no longer win).
|
||
* A whole rehearsal is 0.2–1 s of CPU late in a 4-bot match: Match runs it in bounded slices between other callbacks.
|
||
* @returns {null | { chosen: any[], plans: Array<Map<number, string>>, best: Map<number, string>, done: boolean, run: (budgetMs?: number) => boolean }}
|
||
* null when there is nothing to compare (no wave, < 2 distinct plans, rehearsal off)
|
||
*/
|
||
export function createRehearsal(m, ps, chosen, plans) {
|
||
return runSteps(createRehearsalSteps(m, ps, chosen, plans));
|
||
}
|
||
|
||
/**
|
||
* createRehearsal as a step generator: yields after each candidate's Battle is built. Each candidate puts its layout on
|
||
* the board, takes the battle input and restores the board exactly before the next yield — a bot prep dropped between
|
||
* two steps (the prep ended) never leaves a rehearsal layout on the board.
|
||
*/
|
||
export function* createRehearsalSteps(m, ps, chosen, plans) {
|
||
const wave = m.wave;
|
||
const distinct = distinctPlans(plans);
|
||
if (!wave || distinct.length < 2 || !(m.botRehearsal > 0)) return null;
|
||
const cands = distinct.slice(0, m.botRehearsal);
|
||
const byUid = new Map(chosen.map((p) => [p.uid, p]));
|
||
const battles = [];
|
||
for (const plan of cands) {
|
||
const saved = [...ps.board.entries()];
|
||
// the candidate's directions are set on the pieces while its input is taken; restored exactly afterwards
|
||
const savedDirs = new Map([...byUid.values(), ...saved.map(([, p]) => p)].map((p) => [p, Object.hasOwn(p, 'dir') ? { v: p.dir } : null]));
|
||
let failed = false;
|
||
try {
|
||
ps.board.clear();
|
||
for (const [uid, k] of plan) { const p = byUid.get(uid); if (p) { p.dir = planDir(plan, uid); ps.board.set(k, p); } }
|
||
ps.recompute();
|
||
const spawns = withBounties(m.gd, m.round, wave, ps.bounties, ps.playerId).map((sp) => ({ ...sp, ownerPlayerId: ps.playerId }));
|
||
battles.push(m.newBattle({
|
||
seed: deriveSeed(m.seed, `rehearse:${m.round}:${ps.seat}`), kind: 'normal', modeId: m.modeId, round: m.round,
|
||
stageId: m.stageId, rect: { ...GEO.NORMAL_RECT }, timeLimit: wave.timeLimit, players: [ps.battleInput({ side: 'L', colOffset: 0 })],
|
||
spawns: m._sanitizeSpawns(spawns, ps.playerId), routes: wave.routes, sharedBoss: null,
|
||
flags: { layerGainsEnabled: false, ...m.gd.dp }, fieldId: `r:${ps.playerId}`, enemyOverrides: wave.overrides, waveId: wave.templateId,
|
||
}));
|
||
} catch (e) {
|
||
failed = true;
|
||
m.log.warn?.(`[match ${m.roomCode}] bot rehearsal failed: ${e && e.message}`);
|
||
} finally {
|
||
ps.board.clear();
|
||
for (const [p, d] of savedDirs) { if (d) p.dir = d.v; else delete p.dir; }
|
||
for (const [k, p] of saved) ps.board.set(k, p);
|
||
ps.recompute();
|
||
}
|
||
if (failed) break;
|
||
yield;
|
||
}
|
||
const cap = Math.ceil(((wave.timeLimit || 60) + 5) * 30);
|
||
let i = 0;
|
||
let t = 0;
|
||
let bestScore = -Infinity;
|
||
let bestLeaks = Infinity;
|
||
const job = {
|
||
chosen,
|
||
plans: cands,
|
||
best: cands[0],
|
||
done: false,
|
||
run(budgetMs = Infinity) {
|
||
const timed = Number.isFinite(budgetMs);
|
||
const t0 = timed ? performance.now() : 0;
|
||
let n = 0;
|
||
while (i < battles.length) {
|
||
// a candidate that ended mid-slice (finished, cap, or beaten at a 64-tick check that skipped the budget test):
|
||
// check the budget before stepping the next one, so a slice never exceeds 4 ticks past its budget
|
||
if (timed && n > 0 && performance.now() - t0 >= budgetMs) return false;
|
||
const battle = battles[i];
|
||
try {
|
||
let beaten = false;
|
||
while (t < cap && !battle.finished) {
|
||
battle.step();
|
||
t++;
|
||
n++;
|
||
if ((t & 63) === 0 && bestLeaks < Infinity && countedLeaks(battle, ps.playerId) > bestLeaks) { beaten = true; break; }
|
||
if (timed && (n & 3) === 0 && performance.now() - t0 >= budgetMs) return false;
|
||
}
|
||
if (!beaten) {
|
||
if (!battle.finished) battle.forceEnd('timeout');
|
||
const r = battle.result();
|
||
const pp = r && !r.synthetic && r.perPlayer && r.perPlayer[ps.playerId];
|
||
if (pp) {
|
||
const leaks = (pp.leaked || []).filter((l) => l && l.counted !== false).length;
|
||
const score = -leaks * 1000 + (pp.killed || 0) - i * 0.01;
|
||
if (score > bestScore) { bestScore = score; bestLeaks = leaks; job.best = cands[i]; }
|
||
}
|
||
}
|
||
} catch (e) {
|
||
m.log.warn?.(`[match ${m.roomCode}] bot rehearsal failed: ${e && e.message}`);
|
||
}
|
||
battles[i] = null;
|
||
i++;
|
||
t = 0;
|
||
}
|
||
job.done = true;
|
||
return true;
|
||
},
|
||
};
|
||
return job;
|
||
}
|
||
|
||
/**
|
||
* Synchronous rehearsal (tools, tests): see createRehearsal. The board is restored exactly afterwards.
|
||
* @returns {Map<number, string>} the chosen plan
|
||
*/
|
||
export function rehearse(m, ps, chosen, plans) {
|
||
const job = createRehearsal(m, ps, chosen, plans);
|
||
if (!job) return distinctPlans(plans)[0] || plans[0];
|
||
job.run();
|
||
return job.best;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------
|
||
// prep routine
|
||
|
||
function tryDo(fn) {
|
||
try { const r = fn(); return !!(r && r.ok); } catch { return false; }
|
||
}
|
||
|
||
function canUseItem(m, ps, item) {
|
||
const rec = m.gd.item(item.id);
|
||
if (!rec) return false;
|
||
if (rec.itemType === 'MAGIC') return m.registry.has('item:' + itemKey(item.id));
|
||
const consume = typeof rec.kind === 'string' && rec.kind.startsWith('consume_on_equip');
|
||
if (consume) return m.registry.has('item:' + itemKey(item.id));
|
||
return true;
|
||
}
|
||
|
||
function context(m, ps) {
|
||
const owned = ownedBonds(m, ps);
|
||
const model = fieldModel(m, ps);
|
||
return { owned, focus: focusBond(m, ps, owned), roles: roles(m, ps), fly: model.flyTotal, model };
|
||
}
|
||
|
||
/**
|
||
* Sell the weakest bench chess (temp and hand; the hand only with `handOnly`) that is not part of a merge pair (or
|
||
* anything when keepPairs is false).
|
||
*/
|
||
function sellWeakestHand(m, ps, { keepPairs = true, below = Infinity, handOnly = false } = {}) {
|
||
const ctx = context(m, ps);
|
||
let worst = null;
|
||
let worstV = Infinity;
|
||
for (const p of handOnly ? ps.hand : [...ps.temp, ...ps.hand]) {
|
||
if (!p || p.kind !== 'chess') continue;
|
||
const base = m.gd.baseIdOf(p.id);
|
||
if (keepPairs && !m.gd.isGolden(p.id) && ps.countCopies(base) >= 2) continue;
|
||
const v = pieceValue(m, ps, p, ctx);
|
||
if (v < worstV) { worstV = v; worst = p; }
|
||
}
|
||
if (!worst || worstV >= below) return false;
|
||
return tryDo(() => ps.sell(worst.uid));
|
||
}
|
||
|
||
/** Lineup gain of one copy of chess `id` (full board): best single swap into the current best lineup `cur`. */
|
||
function lineupGain(m, ps, id, ctx, cur) {
|
||
const probe = { uid: -1, kind: 'chess', id, items: [] };
|
||
let best = cur.score;
|
||
for (let i = 0; i < cur.set.length; i++) {
|
||
const trial = cur.set.slice();
|
||
trial[i] = probe;
|
||
best = Math.max(best, lineupScore(m, ps, trial, ctx));
|
||
}
|
||
return best - cur.score;
|
||
}
|
||
|
||
/**
|
||
* Prep start: sell bench chess that neither make the lineup nor build toward something (a pair that can still
|
||
* merge, the focus bond, an elite) — they only block merges; the refund buys more. Keeps a few spare bench units.
|
||
*/
|
||
function sellJunk(m, ps) {
|
||
const ctx = context(m, ps);
|
||
const { bench } = chooseLineup(m, ps, ctx);
|
||
const keep = [];
|
||
const junk = [];
|
||
for (const p of bench) {
|
||
const c = chessRec(m, p.id);
|
||
if (!c) continue;
|
||
const base = m.gd.baseIdOf(p.id);
|
||
const copies = c.isGolden ? 0 : ps.countCopies(base);
|
||
const pairLive = copies >= 2 && (m.pool.left(base) > 0 || !m.pool.has(base)) && (c.tier >= ps.shop.level - 2 || (c.bonds || []).includes(ctx.focus));
|
||
const useful = c.isGolden || pairLive || (ctx.focus && (c.bonds || []).includes(ctx.focus)) || (copies >= 2 && m.round <= 6);
|
||
(useful ? keep : junk).push({ p, v: pieceValue(m, ps, p, ctx) + (useful ? 100 : 0) });
|
||
}
|
||
// bench budget: at most 4 spare units beyond the lineup (pairs first)
|
||
const all = keep.concat(junk).sort((a, b) => b.v - a.v);
|
||
for (const { p } of all.slice(4)) tryDo(() => ps.sell(p.uid));
|
||
for (const { p } of junk.slice(0, 4)) if (m.round >= 5 && ps.find(p.uid)) tryDo(() => ps.sell(p.uid));
|
||
}
|
||
|
||
/** Take the queued pick-one offers (merge rewards, special refreshes — free; they expire at prep end). */
|
||
function takeOffers(m, ps) {
|
||
const gd = m.gd;
|
||
for (let guard = 0; guard < 4 && ps.offers.length; guard++) {
|
||
const offer = ps.offers[0];
|
||
const ctx = context(m, ps);
|
||
let best = -1;
|
||
let bestS = -Infinity;
|
||
offer.slots.forEach((s, i) => {
|
||
if (s.sold) return;
|
||
const sc = s.kind === 'item' ? (canUseItem(m, ps, s) ? 10 : 1) + gd.tierOf(s.id) * 3 : buyScore(m, ps, s.id, ctx);
|
||
if (sc > bestS) { bestS = sc; best = i; }
|
||
});
|
||
if (best < 0) break;
|
||
if (freeSlot(ps.hand) < 0) sellWeakestHand(m, ps, { keepPairs: false });
|
||
if (!tryDo(() => ps.pickReward(best))) {
|
||
// could not take it (sold out / no room): drop the offer
|
||
ps.offers.shift();
|
||
ps.dirty();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Prep routine up to the final arrangement, whose default plan is already on the board. Returns the layout
|
||
* rehearsal still to run (Match steps it in wall-clock-bounded slices, then calls botPrepEnd) or null.
|
||
*/
|
||
export function botPrepBegin(m, ps) {
|
||
return runSteps(botPrepBeginSteps(m, ps));
|
||
}
|
||
|
||
/**
|
||
* botPrepBegin as a step generator (the same actions in the same order, hence the same rng draws and decisions): it
|
||
* yields between whole prep actions — never while the board holds a transient layout — so Match.scheduleBotPrep runs
|
||
* it in wall-clock-bounded slices like the rehearsal (50–120 ms of planning in one callback late in a 4-bot match
|
||
* otherwise). Returns the rehearsal job (or null).
|
||
*/
|
||
export function* botPrepBeginSteps(m, ps) {
|
||
if (!ps.alive || ps.ready) return null;
|
||
// 1. reward offers (free)
|
||
takeOffers(m, ps);
|
||
yield;
|
||
// 2. economy: dead bench weight back to funds, units toward a full board, the level curve, then everything else;
|
||
// merges completed while buying queue reward offers that expire at prep end — take them right away
|
||
sellJunk(m, ps);
|
||
yield;
|
||
yield* buyLoopSteps(m, ps, { fillOnly: true, maxRefreshes: 0 });
|
||
takeOffers(m, ps);
|
||
levelUp(m, ps);
|
||
yield;
|
||
yield* buyLoopSteps(m, ps, { fillOnly: false, maxRefreshes: 8 });
|
||
takeOffers(m, ps);
|
||
levelUp(m, ps, { spare: true });
|
||
yield;
|
||
// 3. placement, 4. items, placement again (item carriers gain value; rehearsed when the match allows it)
|
||
yield* arrangeSteps(m, ps);
|
||
equipItems(m, ps);
|
||
yield;
|
||
return yield* arrangeSteps(m, ps, { final: true, defer: true });
|
||
}
|
||
|
||
/** Prep routine, end: the rehearsed layout (when it beat the default plan), temp, a free hand slot, Ready. */
|
||
export function botPrepEnd(m, ps, job = null) {
|
||
runSteps(botPrepEndSteps(m, ps, job));
|
||
}
|
||
|
||
/** botPrepEnd as a step generator (see botPrepBeginSteps). */
|
||
export function* botPrepEndSteps(m, ps, job = null) {
|
||
if (!ps.alive || ps.ready) return;
|
||
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);
|
||
tryDo(() => ps.setReady(true));
|
||
}
|
||
|
||
/** The whole prep routine in one go (the rehearsal, if any, runs synchronously). */
|
||
export function botPrep(m, ps) {
|
||
const job = botPrepBegin(m, ps);
|
||
if (job) job.run();
|
||
botPrepEnd(m, ps, job);
|
||
}
|
||
|
||
function levelUp(m, ps, { spare = false } = {}) {
|
||
const gd = m.gd;
|
||
for (let guard = 0; guard < 6; guard++) {
|
||
if (ps.shop.level >= gd.maxShopLevel) return;
|
||
const price = Math.max(0, ps.shop.upgradePrice);
|
||
if (ps.funds < price) return;
|
||
const r = m.round;
|
||
const target = LEVEL_TARGET[Math.min(LEVEL_TARGET.length - 1, r)];
|
||
const nextTarget = LEVEL_TARGET[Math.min(LEVEL_TARGET.length - 1, r + 1)];
|
||
// early levels only once the board is full (units first); later a 6-unit core is enough
|
||
const boardReady = ps.allChess().length >= (r <= 4 ? ps.deployCap : Math.min(ps.deployCap, 6));
|
||
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 && ps.funds >= price + 14) want = true;
|
||
if (!want || !tryDo(() => ps.levelUp())) return;
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
for (let guard = 0; guard < 40; guard++) {
|
||
if (guard > 0) yield;
|
||
const ctx = context(m, ps);
|
||
const ownedN = ps.allChess().length;
|
||
const boardFull = ownedN >= ps.deployCap;
|
||
if (fillOnly && boardFull) return;
|
||
// keep room for merges: a crowded bench sheds its weakest single
|
||
const used = ps.hand.filter(Boolean).length;
|
||
if (used >= gd.benchSize - 1) sellWeakestHand(m, ps);
|
||
let best = -1;
|
||
let bestS = 0;
|
||
let cur = null;
|
||
ps.shop.slots.forEach((s, i) => {
|
||
if (!s || s.sold) return;
|
||
const price = ps.priceOf(s);
|
||
if (price > ps.funds) return;
|
||
let sc;
|
||
if (s.kind === 'chess') {
|
||
if (freeSlot(ps.hand) < 0 && !ps.completesChessMerge(s.id)) return;
|
||
const merges = ps.completesChessMerge(s.id);
|
||
sc = buyScore(m, ps, s.id, ctx) - price;
|
||
// with a full board a buy must improve the lineup (or be merge progress)
|
||
if (boardFull && !merges && ps.countCopies(gd.baseIdOf(s.id)) === 0) {
|
||
cur ||= chooseLineup(m, ps, ctx);
|
||
const gain = lineupGain(m, ps, s.id, ctx, cur);
|
||
if (gain <= 2) return;
|
||
sc += Math.min(15, gain * 0.5);
|
||
}
|
||
if (!boardFull) sc += 8;
|
||
} else {
|
||
if (fillOnly) return;
|
||
if (!canUseItem(m, ps, { id: s.id })) return;
|
||
if (freeSlot(ps.hand) < 0 && !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; }
|
||
});
|
||
if (best >= 0 && bestS >= 3) {
|
||
if (!tryDo(() => ps.buy(best))) return;
|
||
continue;
|
||
}
|
||
// leftover funds are lost at prep end: reroll while a purchase stays affordable
|
||
const refreshCost = ps.shop.freeRefreshes > 0 ? 0 : gd.refreshPrice;
|
||
if (refreshes < maxRefreshes && ps.funds >= refreshCost + minPrice) {
|
||
if (!tryDo(() => ps.refresh())) return;
|
||
refreshes++;
|
||
continue;
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Choose the deployed set and put it on the best tiles for this round's enemies (planLayout). `final`: the last
|
||
* arrangement of the prep — candidate plans are rehearsed (see createRehearsal) when the match allows it; with `defer`
|
||
* the default plan is placed now and the rehearsal job is returned (the caller runs it and applies `job.best`).
|
||
* @returns {null | ReturnType<typeof createRehearsal>}
|
||
*/
|
||
export function arrange(m, ps, opts = {}) {
|
||
return runSteps(arrangeSteps(m, ps, opts));
|
||
}
|
||
|
||
/** arrange as a step generator (yields inside the layout planning, see planLayoutSteps). */
|
||
export function* arrangeSteps(m, ps, { final = false, defer = false } = {}) {
|
||
const ctx = context(m, ps);
|
||
const chosen = chooseLineup(m, ps, ctx).set;
|
||
const chosenSet = new Set(chosen.map((p) => p.uid));
|
||
// withdraw board chess that are not chosen (to free cap) when the hand has space
|
||
for (const [, p] of [...ps.board]) {
|
||
if (p.kind !== 'chess' || chosenSet.has(p.uid)) continue;
|
||
const idx = freeSlot(ps.hand);
|
||
if (idx >= 0) tryDo(() => ps.move(p.uid, { area: 'hand', idx }));
|
||
else tryDo(() => ps.sell(p.uid));
|
||
}
|
||
yield;
|
||
const plans = [];
|
||
if (final && m.wave && m.botRehearsal > 0) {
|
||
for (const v of REHEARSAL_VARIANTS) plans.push(yield* planLayoutSteps(m, ps, chosen, { ...LAYOUT_PARAMS, ...v }));
|
||
} else {
|
||
plans.push(yield* planLayoutSteps(m, ps, chosen));
|
||
}
|
||
if (defer && plans.length > 1) {
|
||
const job = yield* createRehearsalSteps(m, ps, chosen, plans);
|
||
yield* applyPlanSteps(m, ps, chosen, plans[0]);
|
||
return job;
|
||
}
|
||
yield* applyPlanSteps(m, ps, chosen, plans.length > 1 ? rehearse(m, ps, chosen, plans) : plans[0]);
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. Placed summons go
|
||
* back to their stacks first: they would sit on the plan's tiles (an operator moved onto one swaps it elsewhere), and a
|
||
* 凯瑟琳 device left beside a tile its operator moved away from would face nothing (QA, playtest #6).
|
||
*/
|
||
function* applyPlanSteps(m, ps, chosen, target) {
|
||
liftTokens(ps);
|
||
// move pieces onto their targets (board → board moves swap; hand → board may swap an occupant back to the hand)
|
||
for (let pass = 0; pass < 3; pass++) {
|
||
let moved = false;
|
||
for (const p of chosen) {
|
||
const k = target.get(p.uid);
|
||
if (!k) continue;
|
||
const loc = ps.find(p.uid);
|
||
const dir = planDir(target, p.uid);
|
||
if (!loc || (loc.area === 'board' && loc.key === k && pieceDir(loc.piece) === dir)) continue;
|
||
const [r, c] = parseKey(k);
|
||
// onto its own tile: an in-place re-orientation (not a move)
|
||
if (tryDo(() => ps.move(p.uid, { area: 'board', row: r, col: c }, dir)) && loc.key !== k) moved = true;
|
||
}
|
||
if (!moved) break;
|
||
}
|
||
// anything chosen still off the board: the best remaining free tile
|
||
const off = chosen.filter((p) => { const loc = ps.find(p.uid); return loc && loc.area !== 'board'; });
|
||
if (off.length && ps.deployCount < ps.deployCap) {
|
||
yield;
|
||
const again = yield* planLayoutSteps(m, ps, off, LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) });
|
||
for (const p of off) {
|
||
const k = again.get(p.uid);
|
||
if (!k || ps.deployCount >= ps.deployCap) continue;
|
||
const [r, c] = parseKey(k);
|
||
tryDo(() => ps.move(p.uid, { area: 'board', row: r, col: c }, planDir(again, p.uid)));
|
||
}
|
||
}
|
||
yield* placeTokensSteps(m, ps);
|
||
}
|
||
|
||
/** Board summons back to their hand stacks (or a free hand slot). */
|
||
function liftTokens(ps) {
|
||
for (const p of [...ps.board.values()]) {
|
||
if (p.kind !== 'token') continue;
|
||
const stack = ps.hand.findIndex((x) => x && x.kind === 'token' && x.ownerUid === p.ownerUid && x.id === p.id);
|
||
const idx = stack >= 0 ? stack : freeSlot(ps.hand);
|
||
if (idx >= 0) tryDo(() => ps.move(p.uid, { area: 'hand', idx }));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Summons that help the operator on the tile they face (range 1-1: their own tile + the one in front) instead of
|
||
* covering the enemy path: 凯瑟琳's 爬行号·防护单元 (a hand piece since user playtest #6).
|
||
*/
|
||
const FRONT_SUPPORT_TOKENS = new Set(['token_10041_cathy_catsld']);
|
||
/** Device tile offsets around an operator (behind it — enemies come from the gates on the right —, above, below, in front) and the facing that points back at it. */
|
||
const SUPPORT_SPOTS = Object.freeze([[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]);
|
||
|
||
/**
|
||
* A tile + direction for a front-support device: next to the most valuable operator no device faces yet (blockers
|
||
* first — they take the hits —, then DPS), pointing at it; null when none is free.
|
||
*/
|
||
function supportSpot(m, ps, p) {
|
||
const rec = m.gd.token(p.id);
|
||
if (!rec) return null;
|
||
const map = ps.deployMap();
|
||
const pos = positionClass(rec);
|
||
const faced = new Set();
|
||
for (const [k, q] of ps.board) {
|
||
if (q.kind !== 'token' || !FRONT_SUPPORT_TOKENS.has(q.id)) continue;
|
||
const [r, c] = parseKey(k);
|
||
const [dr, dc] = rotateOffset(0, 1, pieceDir(q));
|
||
faced.add(tileKey(r + dr, c + dc));
|
||
}
|
||
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, m.gd.chess(q.id)]).filter(([, c]) => c)
|
||
.sort((a, b) => Number(isBlocker(b[1])) - Number(isBlocker(a[1])) || dpsOf(b[1]) - dpsOf(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||
for (const [k] of ops) {
|
||
const [r, c] = parseKey(k);
|
||
for (const [dr, dc, dir] of SUPPORT_SPOTS) {
|
||
const t = tileKey(r + dr, c + dc);
|
||
if (!ps.board.has(t) && canPlace(map, pos, r + dr, c + dc)) return [r + dr, c + dc, dir];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Placeable summons from the hand / temp onto the best free tiles (one per stack count). */
|
||
function* placeTokensSteps(m, ps) {
|
||
for (const p of [...ps.hand, ...ps.temp]) {
|
||
if (!p || p.kind !== 'token') continue;
|
||
for (let n = p.count || 1; n > 0; n--) {
|
||
yield;
|
||
if (FRONT_SUPPORT_TOKENS.has(p.id)) {
|
||
const spot = supportSpot(m, ps, p);
|
||
if (!spot || !tryDo(() => ps.move(p.uid, { area: 'board', row: spot[0], col: spot[1] }, spot[2]))) break;
|
||
continue;
|
||
}
|
||
const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) });
|
||
const k = plan.get(p.uid);
|
||
if (!k) break;
|
||
const [r, c] = parseKey(k);
|
||
if (!tryDo(() => ps.move(p.uid, { area: 'board', row: r, col: c }, planDir(plan, p.uid)))) break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 突变细胞 (buff char_chess_transformation_equip): after the battle its carrier becomes a random operator one tier higher. */
|
||
const isMutationCell = (gd, itemId) => { const rec = gd.item(itemId); return !!(rec && Array.isArray(rec.buffs) && rec.buffs.some((b) => b && b.key === 'char_chess_transformation_equip')); };
|
||
|
||
/**
|
||
* Whom the bot injects with 突变细胞 (after the battle the carrier is replaced by a random NORMAL operator one tier
|
||
* higher and the cell comes back — builtinMeta char_chess_transformation_equip): its least valuable normal operator
|
||
* below 6阶 with a free equip slot — never an elite or one of a merge pair (both 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;
|
||
const cands = ps.allChess().filter((p) => !gd.isGolden(p.id) && gd.tierOf(p.id) < 6 && (p.items || []).length < gd.equipPerChess
|
||
&& !(p.items || []).some((it) => isMutationCell(gd, it.id)) && ps.countCopies(gd.baseIdOf(p.id)) < 2);
|
||
let best = null, bv = Infinity;
|
||
for (const p of cands) {
|
||
const v = pieceValue(m, ps, p, ctx);
|
||
if (v < bv) { bv = v; best = p; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function equipItems(m, ps) {
|
||
const gd = m.gd;
|
||
const ctx = context(m, ps);
|
||
const carriers = () => [...ps.board.values()].filter((p) => p.kind === 'chess').sort((a, b) => pieceValue(m, ps, b, ctx) - pieceValue(m, ps, a, ctx));
|
||
const tried = new Set();
|
||
for (let guard = 0; guard < 12; guard++) {
|
||
const item = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'item' && canUseItem(m, ps, p) && !tried.has(p.uid));
|
||
if (!item) break;
|
||
tried.add(item.uid);
|
||
if (isMutationCell(gd, item.id)) {
|
||
const target = cellTarget(m, ps, ctx);
|
||
if (target) tryDo(() => ps.equip(item.uid, target.uid));
|
||
continue;
|
||
}
|
||
const rec = gd.item(item.id);
|
||
if (rec && rec.itemType === 'MAGIC') {
|
||
const board = [...ps.board.entries()].filter(([, p]) => p.kind === 'chess');
|
||
const [key] = board.length ? board[0] : ['10,4'];
|
||
const [r, c] = parseKey(key);
|
||
tryDo(() => ps.useArt(item.uid, r, c));
|
||
continue;
|
||
}
|
||
const consume = rec && typeof rec.kind === 'string' && rec.kind.startsWith('consume_on_equip');
|
||
const list = carriers();
|
||
// damage dealers carry equipment first (healers / non-attackers last)
|
||
const dealers = list.filter((p) => { const c = chessRec(m, p.id); return c && !isHealer(c) && c.attackKind !== 'none'; });
|
||
const pool = dealers.length ? dealers.concat(list.filter((p) => !dealers.includes(p))) : list;
|
||
const target = consume ? pool[0] : pool.find((p) => (p.items || []).length < gd.equipPerChess);
|
||
if (!target) continue;
|
||
tryDo(() => ps.equip(item.uid, target.uid));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Free a hand slot for an item worth keeping (突变细胞): sell the weakest single hand chess, else destroy the cheapest
|
||
* other hand item, else sell the weakest hand chess even of a pair. false: the hand stays full (summon cards only).
|
||
*/
|
||
function makeHandRoom(m, ps) {
|
||
if (freeSlot(ps.hand) >= 0) return true;
|
||
if (sellWeakestHand(m, ps, { handOnly: true }) && freeSlot(ps.hand) >= 0) return true;
|
||
const gd = m.gd;
|
||
const junk = ps.hand.filter((p) => p && p.kind === 'item' && !isMutationCell(gd, p.id))
|
||
.sort((a, b) => ((gd.item(a.id) || {}).price || 0) - ((gd.item(b.id) || {}).price || 0) || a.uid - b.uid)[0];
|
||
if (junk && tryDo(() => ps.destroy(junk.uid)) && freeSlot(ps.hand) >= 0) return true;
|
||
return sellWeakestHand(m, ps, { handOnly: true, keepPairs: false }) && freeSlot(ps.hand) >= 0;
|
||
}
|
||
|
||
function resolveTemp(m, ps) {
|
||
for (let i = 0; i < ps.temp.length; i++) {
|
||
const p = ps.temp[i];
|
||
if (!p) continue;
|
||
const idx = freeSlot(ps.hand);
|
||
if (idx >= 0 && tryDo(() => ps.move(p.uid, { area: 'hand', idx }))) continue;
|
||
if (p.kind === 'chess') {
|
||
if (!sellWeakestHand(m, ps)) tryDo(() => ps.sell(p.uid));
|
||
else {
|
||
const j = freeSlot(ps.hand);
|
||
if (j >= 0) tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }));
|
||
}
|
||
} else if (p.kind === 'item') {
|
||
// 突变细胞 comes back after every transformation (昆图斯's strategy item): make room in the hand rather than lose it
|
||
if (isMutationCell(m.gd, p.id) && makeHandRoom(m, ps)) {
|
||
const j = freeSlot(ps.hand);
|
||
if (j >= 0 && tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }))) continue;
|
||
}
|
||
tryDo(() => ps.destroy(p.uid));
|
||
}
|
||
}
|
||
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot (and
|
||
// come back at the next round start, PlayerState.startRound)
|
||
for (let i = 0; i < ps.temp.length; i++) {
|
||
const p = ps.temp[i];
|
||
if (!p) continue;
|
||
if (p.kind === 'chess') tryDo(() => ps.sell(p.uid));
|
||
else if (p.kind === 'item') tryDo(() => ps.destroy(p.uid));
|
||
}
|
||
if (!ps.tempEmpty) ps.resolveTemp();
|
||
}
|