feedback1 integrate: bot reconciled with the merged rules

- plans each unit with the range it is deployed with (loadoutRecord attackRangeGrid, the grid of the server's
  summonRange and the card)
- an attack on every enemy in range (阵法术师 / 轰击术师 rangeAoe) counts double in its DPS value, splash and chain a little
- botbench seeds 1-40 before/after and the decision time (unchanged) in BALANCE.md
This commit is contained in:
sganggs committed 2026-10-03 08:09:29 +08:00
1 parent 0a15c6e41d
commit ff4ff3f350
4 files changed
+80 -10

No files matched your search

+39 -6
View File
@@ -21,7 +21,8 @@
// over the shared pool) × MERGE_HIT (an elite plus the merge's free pick of the next tier) — else it refreshes. A
// third copy it cannot afford freezes the shop for the next round (maybeFreeze). Purchase scores: merge progress >
// bond thresholds (focus, second) > role needs (blockers, anti-air when the wave flies, one or two healers) >
// tier and armour fit (the share of a dealer's damage the round's DEF / RES lets through, effDps). A merge that
// tier and armour fit (the share of a dealer's damage the round's DEF / RES lets through, effDps; an attack on every
// enemy in range — 阵法术师 / 轰击术师 — counts double, splash and chain a little, CROWD). 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 (tier, elite, items, armour fit) + activated bond tiers (exact
@@ -32,7 +33,8 @@
// the ground path but count as flyers) and weighted by their enemies; an exposure model (tile time × DPS of the
// covering units against the round's DEF / RES, 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
// (legal tile, direction) pair of the server's deploy map (no 深水区): each unit's range grid — the one it is
// deployed with, rangeRec (loadoutRecord attackRangeGrid) — 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
@@ -75,6 +77,7 @@ import { computeBonds } from './bondsMeta.js';
import { withBounties, isFlyKey } from './waves.js';
import { mitigate } from '../sim/damage.js';
import { HOVER_KEYS } from '../sim/content/enemies.js';
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../shared/loadoutRecord.js';
/**
* Drive a step generator (planLayoutSteps, createRehearsalSteps, arrangeSteps, botPrepBeginSteps …) to its end in one
@@ -179,7 +182,7 @@ export function bountyKillChance(m, ps, card) {
const tileTime = Math.max(0.4, Math.min(6, 1 / (speed * 0.5)));
const units = [];
for (const [k, p] of ps.board) {
const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id);
const rec = p.kind === 'token' ? gd.token(p.id) : rangeRec(ps, gd.chess(p.id));
if (!rec) continue;
const [r, c] = parseKey(k);
const u = unitOf(rec, k, r, c, pieceDir(p), model);
@@ -753,12 +756,21 @@ export function rangeTiles(rec, r, c, dir = 'RIGHT') {
return grid.map(([dr, dc]) => { const [a, b] = rotateOffset(dr, dc, dir); return tileKey(r + a, c + b); });
}
/**
* Enemies an attack strikes at once, as a factor on a dealer's DPS (the exposure model gives every unit's DPS to each
* enemy on a covered tile, so a single-target dealer is overrated in a crowd): 阵法术师 / 轰击术师 strike every enemy in
* range (sim professions.js rangeAoe, community report E3 after 0.1.0 — the 阵法术师's skill-off pause is the 0.4 of a
* non-attacker), splash and chain branches a few [ASSUMED values, botbench A/B in BALANCE.md].
*/
const CROWD = Object.freeze({ phalanx: 2, blastcaster: 2, splashcaster: 1.3, aoesniper: 1.3, bombarder: 1.3, chain: 1.4 });
const crowdOf = (rec) => CROWD[rec && rec.subProfessionId] ?? 1;
/** 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;
const d = ((st.atk || 0) / interval) * crowdOf(rec);
return rec.attackKind === 'none' ? d * 0.4 : d;
}
@@ -767,10 +779,31 @@ function dpsVs(rec, def, res) {
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 = mitigate(st.atk || 0, rec.dmgType === 'arts' ? 'arts' : 'phys', { def, res }) / interval;
const d = (mitigate(st.atk || 0, rec.dmgType === 'arts' ? 'arts' : 'phys', { def, res }) / interval) * crowdOf(rec);
return rec.attackKind === 'none' ? d * 0.4 : d;
}
/**
* The record a chess fights with for range purposes: its range grid replaced by the one it is deployed with under the
* player's loadout — a passive 攻击范围扩大 skill, a module's range or 攻击距离 (shared/loadoutRecord.js attackRangeGrid:
* what the server's summonRange, the deploy wheel and the card use since 0.1.1) — else the record itself. Cached per
* player and loadout.
*/
export function rangeRec(ps, rec) {
if (!rec || !rec.chessId || typeof ps.loadoutFor !== 'function') return rec;
const lo = ps.loadoutFor(rec);
const key = `${rec.chessId}|${lo ? `${lo.skill ?? ''}:${lo.module ?? ''}` : ''}`;
const cache = ps._botRangeRecs || (ps._botRangeRecs = new Map());
if (cache.has(key)) return cache.get(key);
let out = rec;
try {
const g = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, lo)));
if (Array.isArray(g) && g.length && JSON.stringify(g) !== JSON.stringify(rec.rangeGrid)) out = Object.freeze({ ...rec, rangeGrid: g });
} catch { out = rec; }
cache.set(key, out);
return out;
}
/**
* DPS of a record against the round's enemies: dpsVs averaged over their DEF / RES weighted by HP (the field model's
* `armor`; cached per round) — physical dealers lose most of their damage on 重甲 waves, arts dealers on high-RES ones.
@@ -882,7 +915,7 @@ export function planLayout(m, ps, pieces, params = LAYOUT_PARAMS, opts = {}) {
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 rec = recOf || ((p) => (p.kind === 'token' ? m.gd.token(p.id) : rangeRec(ps, 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);