fix(bot): respect teammates opening factions

This commit is contained in:
IceCodeNew committed 2026-10-11 12:38:34 +00:00
1 parent bc50cac01f
commit e376f25407
4 files changed
+54 -4

No files matched your search

+3 -1
View File
@@ -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 [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 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 [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 mixed human/AI matches emit them, through the existing whitelist and cooldown. A human emote gets at most one eligible
+7
View File
@@ -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 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 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. 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.
+5 -2
View File
@@ -146,7 +146,8 @@ export function botPickBand(m, ps) {
const ids = gd.bandIds(); const ids = gd.bandIds();
if (!ids.length) return gd.defaultBandId; if (!ids.length) return gd.defaultBandId;
const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || '')); 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)]); 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; let total = 0;
for (const [, w] of pairs) total += w; for (const [, w] of pairs) total += w;
@@ -353,7 +354,8 @@ function mainCoreBond(m, p) {
* across rounds. * across rounds.
*/ */
export function bondPlan(m, ps, owned = ownedBonds(m, ps)) { 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; if (ps._botFocus && ps._botFocus.key === key) return ps._botFocus;
const prev = ps._botFocusId ?? null; const prev = ps._botFocusId ?? null;
const mates = new Set(); const mates = new Set();
@@ -361,6 +363,7 @@ export function bondPlan(m, ps, owned = ownedBonds(m, ps)) {
if (p === ps) continue; if (p === ps) continue;
const b = mainCoreBond(m, p); const b = mainCoreBond(m, p);
if (b) mates.add(b); if (b) mates.add(b);
for (const id of m.gd.bandBondIds(p.bandId)) mates.add(id);
} }
let focus = null; let focus = null;
let bestS = 0; let bestS = 0;
+39 -1
View File
@@ -7,12 +7,50 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js'; 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 { parseKey, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.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 }); 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', () => { 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 { m } = makeMatch({ humans: 3, seed: 11 }).start().toPrep();
const [ps, mate, other] = m.order; const [ps, mate, other] = m.order;