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

+12
View File
@@ -362,6 +362,18 @@ and 86 / 175 → 83 / 162 ms, CPU 131 / 314 → 124 / 329 ms and 97 / 209 → 91
refreshes); a 机变 pick 0.07 → 0.15 ms (p50). Over the 40-seed A/B the summed prep CPU per match moved −6 % … +2 %. The
tuning sweeps used seeds 101–148 with the rehearsal off; the tables above are separate seed ranges.
**0.1.1 integration (DESIGN §21.6).** With all 18 workstreams merged (the official bounty draft structures, the AoE,
displacement and enemy fixes) the same bot on the same seeds wins 27 / 40 solo 绝境 and 9 / 40 co-op 绝境 (co-op bounty
enemies leaked 120 of 380 picks: 232 drafts offered no card at a kill chance ≥ 0.5 — the R9 boss groups). The bot was
then reconciled with the merged rules: it plans each unit with the range it is deployed with (`rangeRec`:
`attackRangeGrid`, the server's `summonRange` grid — no outcome changed on these seeds) and values an attack on every
enemy in range ×2 (阵法术师 / 轰击术师, now `rangeAoe`), a splash ×1.3 and a chain ×1.4 [ASSUMED] (`CROWD`). Seeds 1–40,
rehearsal 3, before → after: solo 绝境 27 → 27 wins, LP left 17.9 → 18.4, bounty enemies leaked 8 → 5 of 104; co-op 绝境
9 → 11 wins, LP left 14.8 → 14.1, leaks per match 215 → 220 — within the noise of 40 seeds (the ×2-only variant: 25 /
11 wins). Decision time stays at the WF numbers (one thread, back to back, seeds 1–6): heuristics CPU p50 / p95 25 / 59
→ 24 / 57 ms solo and 16 / 36 → 16 / 36 ms co-op; the whole prep with its rehearsal wall clock 80 / 148 → 78 / 153 ms and
74 / 152 → 73 / 151 ms.
---
## 6. Match follow-ups shipped with this pass
+6 -3
View File
@@ -200,7 +200,8 @@ over the remaining pool; an elite also brings the merge's free pick of the next
freezes the shop for the next round (freeze is free). Purchase scores: merge progress > bond thresholds > role needs
(2 blockers, anti-air when the wave flies, ≤ 2 healers) > tier, the 特质 that keep adding layers, and armour fit (the
share of a dealer's damage that gets through the round's DEF / RES — `mitigate` of the sim per hit, HP-weighted over
the wave). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`; every
the wave; an attack on every enemy in range counts ×2 — 阵法术师 / 轰击术师, `rangeAoe` since 0.1.1 —, a splash ×1.3, a
chain ×1.4 [ASSUMED], `CROWD`). The deployed set maximizes unit value + activated bond tiers (exact counting via `computeBonds`; every
deployed focus member counts toward the next threshold). Items by what they do: equipment on the strongest deployed
damage dealers (survival items on blockers first, bond signature items on a member), 信标 on a bench single (never the
lineup when a bench single exists), 拟态物质 on a pair, 博士投影 (both qualities — neither takes an elite) on the strongest
@@ -219,8 +220,10 @@ minus expected LP loss and takes one it is unlikely to beat only when nothing be
is large (in co-op 绝境 most remaining bounty leaks come from drafts with no beatable card at all); tactic cards by what
they act on (a 盟誓 / 驰援 card on its bonds, 升华, …). Placement uses the round's enemy preview: every route is traced
over the own board (ground: the stage's device-aware ground paths; flyers: through their checkpoints) and weighted by
its enemies; an exposure model (time on each tile × DPS against the round's armour of the units covering it, blocker
hold time, anti-air only on flying routes) is maximized greedily (blockers, then damage dealers, then healers); a
its enemies; an exposure model (time on each tile × DPS against the round's armour of the units covering it — each
unit's cover is the range it is deployed with under the player's loadout, `rangeRec` = `attackRangeGrid`, the grid the
server's `summonRange` and the card use —, blocker hold time, anti-air only on flying routes) is maximized greedily
(blockers, then damage dealers, then healers) over the server's deploy map (no 深水区, PlayerState.deployMap); a
tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange`, the server's own rule; player
report #9 after 0.1.0), and a tile the server's `g.move` refuses is skipped for the next best one (the planner reads
`board.js legalTiles` on the deploy map; `g.move` is the judge). Boss
+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);
+23 -1
View File
@@ -7,7 +7,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles } from '../../server/match/bot.js';
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec } from '../../server/match/bot.js';
import { parseKey, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js';
@@ -274,6 +274,28 @@ test('bond plan: a teammate\'s main core bond (its bond strip — a human\'s too
assert.equal(focusWhenMateBuilds(KAZIMIERZ), 'sargonShip', 'the human builds 卡西米尔: the bot takes 萨尔贡');
});
test('merged rules (0.1.1): an attack on every enemy in range counts double (阵法术师 / 轰击术师 rangeAoe); a unit is planned with the range it is deployed with', () => {
const rec = (id) => DATA.chess[id];
const raw = (r) => (r.stats.atk / (r.stats.bat * 100 / r.stats.aspd));
// 阿罗玛 (轰击术师, attacks every enemy on her line) vs a single-target sniper: effDps without a field model is the raw DPS
// × the crowd factor
const aroma = rec('chess_char_4_10_a'), inside = rec('chess_char_1_01_a');
assert.ok(Math.abs(effDps(null, aroma) - raw(aroma) * 2) < 1e-6, '轰击术师 ×2');
assert.ok(Math.abs(effDps(null, inside) - raw(inside)) < 1e-6, 'a single-target dealer ×1');
// the elite 信仰搅拌机 with SPT-Y (特性 攻击距离 +1): the bot covers the extended grid the server's summonRange and the card use
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 3, fake: true }).start();
const ps = h.ps('p_0');
const mixer = rec('chess_char_4_01_b');
const mod = (mixer.modules || []).find((x) => x.typeName === 'SPT-Y');
if (mod) {
ps.loadout = Object.freeze({ chess_char_4_01_a: Object.freeze({ skill: mixer.skill?.index ?? 2, module: mod.uniEquipId }) });
const r = rangeRec(ps, mixer);
assert.ok(r.rangeGrid.length > mixer.rangeGrid.length, `the deployed grid (${r.rangeGrid.length} tiles) is the extended one (${mixer.rangeGrid.length})`);
}
assert.equal(rangeRec(ps, inside), inside, 'no loadout difference: the record itself');
h.m.dispose();
});
test('cost guard: the prep heuristics of a late-round 4-bot match stay cheap (rehearsal off)', () => {
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 13, fake: true }).start();