diff --git a/docs/BALANCE.md b/docs/BALANCE.md index 2fa1d35b..bb8e2b95 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -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 diff --git a/docs/META.md b/docs/META.md index 00e10680..331ba86b 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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 diff --git a/server/match/bot.js b/server/match/bot.js index 75f636f7..99abf007 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -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); diff --git a/test/match/bot-smarter.test.js b/test/match/bot-smarter.test.js index 4701c94c..a019a4f9 100644 --- a/test/match/bot-smarter.test.js +++ b/test/match/bot-smarter.test.js @@ -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();