Files
ark-stronghold-protocol/server/match/bot.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

1214 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
// 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, 凯瑟琳'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)
// 6. resolve the temp slots, 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;
for (const [r, c] of legalTiles(map, positionClass(r0))) {
const k = tileKey(r, c);
if (taken.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 that is not part of a merge pair (or anything when keepPairs is false). */
function sellWeakestHand(m, ps, { keepPairs = true, below = Infinity } = {}) {
const ctx = context(m, ps);
let worst = null;
let worstV = Infinity;
for (const p of [...ps.temp, ...ps.hand]) {
if (!p || p.kind !== 'chess') continue;
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;
}
}
}
function equipItems(m, ps) {
const gd = m.gd;
const ctx = context(m, ps);
const carriers = () => [...ps.board.values()].filter((p) => p.kind === 'chess').sort((a, b) => pieceValue(m, ps, b, ctx) - pieceValue(m, ps, a, ctx));
const tried = new Set();
for (let guard = 0; guard < 12; guard++) {
const item = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'item' && canUseItem(m, ps, p) && !tried.has(p.uid));
if (!item) break;
tried.add(item.uid);
const 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));
}
}
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') {
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();
}