diff --git a/server/match/bot.js b/server/match/bot.js index 0109b4f0..73b2e4e9 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -59,6 +59,9 @@ // 6. resolve the temp slots (a 突变细胞 left there — it comes back after every transformation — gets a hand slot made // for it, makeHandRoom, instead of being destroyed), keep one hand slot free (freeHandSlot: a kept bounty Art goes // before a chess on a bot's own seat, never on a human's seat under AI 托管), then Ready. +// Strategy (botPickBand): weighted by starting LP among the offered bands; never one whose mechanic rides on a bond the +// mode switches off (gd.bandBondIds: the bond its text names in <…> or its effect's blackboards name — 标准's 潘格尼尼, +// 克莱门莎, 玛恩纳; DESIGN §21.26); alone, 老鲤's withheld first-round funds only rarely (× 0.02). // 机变 (botPickCard): a bounty by its expected payout minus the expected LP lost — bountyKillChance runs the exposure // model for that one enemy against the own board; a card the board is unlikely to beat wins only when nothing better // is offered or it pays much more —; tactic cards by what they act on (a 盟誓 / 驰援 card on the own bonds, 升华 …); @@ -118,19 +121,32 @@ 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. + * Band pick among the strategies the mode offers (gd.bandIds): 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. A band whose mechanic rides on a bond the mode switches off + * (gd.bandBondIds ∩ gd.modeInactiveBonds — 标准: 潘格尼尼 <拉特兰>, 克莱门莎 <阿戈尔>, 玛恩纳 <卡西米尔>) weighs 0, never taken + * (DESIGN §21.26); with every band excluded, the default band. One rng draw per call (deterministic per seed); modes + * without inactive bonds keep exactly the earlier picks. */ 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)]); + const gd = m.gd; + const ids = gd.bandIds(); + if (!ids.length) return gd.defaultBandId; + const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || '')); + const offBond = (id) => gd.bandBondIds(id).some((b) => gd.modeInactiveBonds.has(b)); + const pairs = ids.map((id) => [id, offBond(id) ? 0 : Math.max(1, (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]; + if (!(total > 0)) return gd.defaultBandId; + let last = null; + for (const [id, w] of pairs) { + if (!(w > 0)) continue; + last = id; + r -= w; + if (r < 0) return id; + } + return last; } /** diff --git a/server/match/gamedata.js b/server/match/gamedata.js index 999c1e0b..dfacbb1a 100644 --- a/server/match/gamedata.js +++ b/server/match/gamedata.js @@ -90,6 +90,9 @@ export class GameData { for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort(); this.bondIds = Object.keys(this._bonds).sort((a, b) => (numOr(this._bonds[a].identifier, 99) - numOr(this._bonds[b].identifier, 99)) || (a < b ? -1 : 1)); this.modeInactiveBonds = new Set(Array.isArray(this.mode.inactiveBondIds) ? this.mode.inactiveBondIds : []); + /** bond name → id (bandBondIds) and its memo */ + this._bondByName = new Map(Object.entries(this._bonds).filter(([, r]) => r && typeof r.name === 'string').map(([id, r]) => [r.name, id])); + this._bandBonds = new Map(); this.inactiveEnemies = new Set(Array.isArray(this.mode.inactiveEnemyKeys) ? this.mode.inactiveEnemyKeys : []); /** data/tuning.json (titles only, see the header) */ this.tuning = this.raw.tuning && typeof this.raw.tuning === 'object' ? this.raw.tuning : {}; @@ -458,6 +461,46 @@ export class GameData { return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp; } + /** + * The bonds a strategy's mechanic is built around, read from its own data (DESIGN §21.26): the bond names its text puts + * in <…> — the official note "在部分干员缺席时体验可能不完整" names that bond too — and the bond ids and bond pools its + * effect's blackboards name (bb / bbStr values: 克莱门莎's `bond_id` egirShip, 玛恩纳's `bond` kazimierzShip, 潘格尼尼's + * `pool` pool_char_later → choices.json pools[pool].bond lateranoShip). Names map to ids through bonds.json; a bracketed + * name that is no bond (<寻呼模块>, <画卷>, <鸭爵> …) and a pool without a bond add nothing. Bond ids in data order, + * [] for an unknown band or one tied to no bond (华法琳, 阿米娅 …). The bot never picks a strategy tied to a bond the mode + * switches off (bot.js botPickBand: 标准's 潘格尼尼 / 克莱门莎 / 玛恩纳). + * @param {string} bandId + * @returns {string[]} + */ + bandBondIds(bandId) { + if (this._bandBonds.has(bandId)) return this._bandBonds.get(bandId); + const b = this.band(bandId); + const found = new Set(); + if (b) { + for (const [, name] of String(b.desc || '').matchAll(/<([^<>]+)>/g)) { + const id = this._bondByName.get(name.trim()); + if (id) found.add(id); + } + const pools = this.choices.pools && typeof this.choices.pools === 'object' ? this.choices.pools : {}; + for (const buff of Array.isArray(b.buffs) ? b.buffs : []) { + for (const board of [buff && buff.bb, buff && buff.bbStr]) { + if (!board || typeof board !== 'object') continue; + for (const v of Object.values(board)) { + if (typeof v !== 'string') continue; + for (const part of v.split(',').map((s) => s.trim())) { + if (this.bond(part)) found.add(part); + const pool = own(pools, part); + if (pool && typeof pool.bond === 'string' && this.bond(pool.bond)) found.add(pool.bond); + } + } + } + } + } + const out = Object.freeze(this.bondIds.filter((id) => found.has(id))); + this._bandBonds.set(bandId, out); + return out; + } + /** * Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its manually * deployable summons (tokens.json `placeable`: 医疗探机, 诅咒娃娃, 海嗣, 狼群, 流形, 爬行号·防护单元; user playtest #6)