diff --git a/docs/design/match.md b/docs/design/match.md index b5a5729d..1209b159 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -122,7 +122,9 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno [ASSUMED] Bot purchasing applies one soft 18-point penalty to non-elite, non-DIY tier V/VI shared-pool cards whose active bonds overlap another living teammate's opening strategy. Human and AI teammates are treated equally; it -neither reserves stock nor prevents a stronger purchase. Existing strategy presets and rehearsal counts are unchanged. +neither reserves stock nor prevents a stronger purchase. A living teammate's selected strategy also reserves its bond +for bot strategy selection and focus planning before any member is deployed. The reservation ends when that teammate is +eliminated. Existing strategy presets and rehearsal counts are unchanged. [ASSUMED] AI reactions are cosmetic social choices, enabled by default and disabled with `SP_BOT_EMOTES=0`. Only mixed human/AI matches emit them, through the existing whitelist and cooldown. A human emote gets at most one eligible diff --git a/docs/history/0.2.4.md b/docs/history/0.2.4.md index 9f5c8917..09de9ab6 100644 --- a/docs/history/0.2.4.md +++ b/docs/history/0.2.4.md @@ -274,3 +274,10 @@ enemy: picking emitted a unit click with no definition, instead of the tile clic engine-canvas regression retains the card/100 HP assertions and also covers another field refresh, live HP loss and closing the card when the crate is destroyed. Device metadata supplied by the new entry still wins; changing fields does not carry old devices across. Gameplay and allied targeting rules are unchanged. + +### 29.15 Opening faction reservations for bots + +When a living teammate has already selected a faction strategy, the bot now treats that strategy's bond as an opening +reservation before the teammate has deployed a member. It avoids selecting the same offered strategy and includes the +bond in focus planning and the existing soft penalty for shared high-tier cards. The reservation is removed when the +teammate is eliminated; it does not reserve cards or prevent an immediate merge from winning. diff --git a/server/match/bot.js b/server/match/bot.js index 2caa40ed..877247c6 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -146,7 +146,8 @@ export function botPickBand(m, ps) { 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 reserved = new Set(m.alivePlayers().filter((p) => p !== ps).flatMap((p) => gd.bandBondIds(p.bandId))); + const offBond = (id) => gd.bandBondIds(id).some((b) => gd.modeInactiveBonds.has(b) || reserved.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; @@ -353,7 +354,8 @@ function mainCoreBond(m, p) { * across rounds. */ export function bondPlan(m, ps, owned = ownedBonds(m, ps)) { - const key = `${m.round}|${ps.shop.level}|${[...owned.counts.entries()].map(([k, v]) => k + v).join()}`; + const key = `${m.round}|${ps.shop.level}|${[...owned.counts.entries()].map(([k, v]) => k + v).join()}|${m.alivePlayers() + .filter((p) => p !== ps).flatMap((p) => m.gd.bandBondIds(p.bandId)).sort().join()}`; if (ps._botFocus && ps._botFocus.key === key) return ps._botFocus; const prev = ps._botFocusId ?? null; const mates = new Set(); @@ -361,6 +363,7 @@ export function bondPlan(m, ps, owned = ownedBonds(m, ps)) { if (p === ps) continue; const b = mainCoreBond(m, p); if (b) mates.add(b); + for (const id of m.gd.bandBondIds(p.bandId)) mates.add(id); } let focus = null; let bestS = 0; diff --git a/test/match/bot-smarter.test.js b/test/match/bot-smarter.test.js index 5998c87c..e0306650 100644 --- a/test/match/bot-smarter.test.js +++ b/test/match/bot-smarter.test.js @@ -7,12 +7,50 @@ 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, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js'; +import { botPickBand, botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js'; import { parseKey, tileKey } from '../../server/match/board.js'; import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js'; const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o }); +test('#514: bots respect a human opening faction before deployment and release it after elimination', () => { + const { m } = makeMatch({ humans: 2, difficulty: 'HARD', seed: 11 }).start().toPrep(); + const [human, bot] = m.order; + try { + bot.isBot = true; + human.bandId = 'band_chiave'; + give(m, bot, 'chess_char_1_01_a', 'hand'); // Siracusa, despite no human board yet + give(m, bot, 'chess_char_1_19_a', 'hand'); // Kazimierz alternative + assert.notEqual(bondPlan(m, bot).focus, 'siracusaShip'); + assert.notEqual(bondPlan(m, bot).second, 'siracusaShip'); + human.bandId = 'band_pepe'; + for (let i = 0; i < 100; i++) { + assert.ok(!m.gd.bandBondIds(botPickBand(m, bot)).includes('sargonShip'), 'avoid both Sargon strategies'); + } + human.bandId = 'band_chiave'; + assert.notEqual(bondPlan(m, bot).focus, 'siracusaShip', 'same-round changes invalidate the plan'); + human.alive = false; + assert.notEqual(bondPlan(m, bot).focus, 'siracusaShip', 'the bot still compares its own bond value after elimination'); + } finally { m.dispose(); } +}); + +test('#514: a bot leaves Lappland for a human Chiave even when it could complete its own merge', () => { + for (const reward of [false, true]) { + const { m } = makeMatch({ humans: 2, difficulty: 'HARD', seed: 11 }).start().toPrep(); + const [human, bot] = m.order; + try { + human.bandId = 'band_chiave'; + bot.isBot = true; + const lappland = 'chess_char_6_18_a', alternative = 'chess_char_6_17_a'; + bot.shop.level = 6; + bot.funds = reward ? 0 : 5; + bot.shop.slots = reward ? [] : [lappland, alternative].map((id) => ({ kind: 'chess', id, basePrice: 5, sold: false })); + if (reward) bot.pushRewardOffer('merge', { tier: 6, ids: [lappland, alternative] }); + assert.ok(buyScoreOf(m, bot, alternative) > buyScoreOf(m, bot, lappland), 'prefer the non-reserved high-tier card'); + } finally { m.dispose(); } + } +}); + test('user: Given a faction strategy teammate, When scoring shared high-tier chess, Then humans and bots receive the same preference even with full stock', () => { const { m } = makeMatch({ humans: 3, seed: 11 }).start().toPrep(); const [ps, mate, other] = m.order;