feat: Implement basic bot AI for game actions including mulligan, playing lands, casting creatures, and declaring attackers.

This commit is contained in:
2025-12-22 13:04:52 +01:00
parent fd7642dded
commit 9c72bd7b8c
6 changed files with 154 additions and 2 deletions

View File

@@ -5,3 +5,4 @@
## Devlog Index
- [Enable Clear Session](./devlog/2025-12-20-014500_enable_clear_session.md) - Improved UI/UX for session clearing in CubeManager.
- [Bot Actions](./devlog/2025-12-22-114000_bot_actions.md) - Implemented simple bot AI for playing lands, casting creatures, and passing priority.

View File

@@ -0,0 +1,29 @@
# Bot Logic Implementation
## Changes
- **Client/Server Types**: Added `isBot` to `PlayerState` interface.
- **GameManager**:
- Updated `createGame` to persist `isBot` flag from room players.
- Implemented `processBotActions` method:
- **Mulligan**: Always keeps hand.
- **Main Phase**: Plays a Land if available and not played yet.
- **Main Phase**: Casts first available Creature card from hand (simplified cost check).
- **Combat**: Attacks with all available creatures.
- **Default**: Passes priority.
- Added `triggerBotCheck` public method to manually trigger bot automation (e.g. at game start).
- Updated `handleStrictAction` to include a `while` loop that processes consecutive bot turns until a human receives priority.
- **Server Entry (index.ts)**:
- Injected `gameManager.triggerBotCheck(roomId)` at all game start points (Normal start, Solo test, Deck timeout, etc.) to ensure bots act immediately if they win the coin flip or during mulligan.
## Bot Behavior
The bots are currently "Aggressive/Linear":
1. They essentially dump their hand (Lands -> Creatures).
2. They always attack with everything.
3. They never block.
4. They pass priority instantly if they can't do anything.
## Future Improvements
- Implement mana cost checking (currently relying on loose engine rules or implicit valid state).
- Implement target selection logic (currently casting only if no targets needed or using empty array).
- Implement blocking logic.
- Implement "Smart" mulligans (currently always keep).

View File

@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.lnjaj3n52vg"
"revision": "0.vopjl6fp8f"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

View File

@@ -76,6 +76,7 @@ export interface PlayerState {
handKept?: boolean; // For Mulligan phase
mulliganCount?: number;
manaPool: Record<string, number>; // { W: 0, U: 1, ... }
isBot?: boolean;
}
export interface StackObject {

View File

@@ -313,6 +313,7 @@ const draftInterval = setInterval(() => {
const engine = new RulesEngine(game);
engine.startGame();
gameManager.triggerBotCheck(roomId);
io.to(roomId).emit('game_update', game);
}
}
@@ -364,6 +365,7 @@ const draftInterval = setInterval(() => {
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
gameManager.triggerBotCheck(roomId);
io.to(roomId).emit('game_update', game);
}
@@ -637,6 +639,7 @@ io.on('connection', (socket) => {
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
gameManager.triggerBotCheck(room.id);
io.to(room.id).emit('game_update', game);
}
@@ -700,6 +703,7 @@ io.on('connection', (socket) => {
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
gameManager.triggerBotCheck(room.id);
io.to(room.id).emit('game_update', game);
}

View File

@@ -5,7 +5,7 @@ import { RulesEngine } from '../game/RulesEngine';
export class GameManager {
public games: Map<string, StrictGameState> = new Map();
createGame(roomId: string, players: { id: string; name: string }[]): StrictGameState {
createGame(roomId: string, players: { id: string; name: string; isBot?: boolean }[]): StrictGameState {
// Convert array to map
const playerRecord: Record<string, PlayerState> = {};
@@ -13,6 +13,7 @@ export class GameManager {
playerRecord[p.id] = {
id: p.id,
name: p.name,
isBot: p.isBot,
life: 20,
poison: 0,
energy: 0,
@@ -53,6 +54,36 @@ export class GameManager {
return gameState;
}
// Helper to trigger bot actions if game is stuck or just started
public triggerBotCheck(roomId: string): StrictGameState | null {
const game = this.games.get(roomId);
if (!game) return null;
const MAX_LOOPS = 50;
let loops = 0;
// Iterate if current priority player is bot, OR if we are in Mulligan and ANY bot needs to act?
// My processBotActions handles priorityPlayerId.
// In Mulligan, does priorityPlayerId matter?
// RulesEngine: resolveMulligan checks playerId.
// We should iterate ALL bots in mulligan phase.
if (game.step === 'mulligan') {
Object.values(game.players).forEach(p => {
if (p.isBot && !p.handKept) {
const engine = new RulesEngine(game);
try { engine.resolveMulligan(p.id, true, []); } catch (e) { }
}
});
// After mulligan, game might auto-advance.
}
while (game.players[game.priorityPlayerId]?.isBot && loops < MAX_LOOPS) {
loops++;
this.processBotActions(game);
}
return game;
}
getGame(roomId: string): StrictGameState | undefined {
return this.games.get(roomId);
}
@@ -102,9 +133,95 @@ export class GameManager {
return null;
}
// Bot Cycle: If priority passed to a bot, or it's a bot's turn to act
const MAX_LOOPS = 50;
let loops = 0;
while (game.players[game.priorityPlayerId]?.isBot && loops < MAX_LOOPS) {
loops++;
this.processBotActions(game);
}
return game;
}
// --- Bot AI Logic ---
private processBotActions(game: StrictGameState) {
const engine = new RulesEngine(game);
const botId = game.priorityPlayerId;
const bot = game.players[botId];
if (!bot || !bot.isBot) return;
// 1. Mulligan: Always Keep
if (game.step === 'mulligan') {
if (!bot.handKept) {
try { engine.resolveMulligan(botId, true, []); } catch (e) { }
}
return;
}
// 2. Play Land (Main Phase, empty stack)
if ((game.phase === 'main1' || game.phase === 'main2') && game.stack.length === 0) {
if (game.landsPlayedThisTurn < 1) {
const hand = Object.values(game.cards).filter(c => c.ownerId === botId && c.zone === 'hand');
const land = hand.find(c => c.typeLine?.includes('Land') || c.types.includes('Land'));
if (land) {
console.log(`[Bot AI] ${bot.name} plays land ${land.name}`);
try {
engine.playLand(botId, land.instanceId);
return;
} catch (e) {
console.warn("Bot failed to play land:", e);
}
}
}
}
// 3. Play Spell (Main Phase, empty stack)
if ((game.phase === 'main1' || game.phase === 'main2') && game.stack.length === 0) {
const hand = Object.values(game.cards).filter(c => c.ownerId === botId && c.zone === 'hand');
const spell = hand.find(c => !c.typeLine?.includes('Land') && !c.types.includes('Land'));
if (spell) {
// Only cast creatures for now to be safe with targets
if (spell.types.includes('Creature')) {
console.log(`[Bot AI] ${bot.name} casts creature ${spell.name}`);
try {
engine.castSpell(botId, spell.instanceId, []);
return;
} catch (e) { console.warn("Bot failed to cast spell:", e); }
}
}
}
// 4. Combat: Declare Attackers (Active Player only)
if (game.step === 'declare_attackers' && game.activePlayerId === botId && !game.attackersDeclared) {
const attackers = Object.values(game.cards).filter(c =>
c.controllerId === botId &&
c.zone === 'battlefield' &&
c.types.includes('Creature') &&
!c.tapped
);
const opponents = game.turnOrder.filter(pid => pid !== botId);
const targetId = opponents[0];
if (attackers.length > 0 && targetId) {
const declaration = attackers.map(c => ({ attackerId: c.instanceId, targetId }));
console.log(`[Bot AI] ${bot.name} attacks with ${attackers.length} creatures.`);
try { engine.declareAttackers(botId, declaration); } catch (e) { }
return;
} else {
console.log(`[Bot AI] ${bot.name} skips combat.`);
try { engine.declareAttackers(botId, []); } catch (e) { }
return;
}
}
// 6. Default: Pass Priority
try { engine.passPriority(botId); } catch (e) { console.warn("Bot failed to pass priority", e); }
}
// --- Legacy Sandbox Action Handler (for Admin/Testing) ---
handleAction(roomId: string, action: any, actorId: string): StrictGameState | null {
const game = this.games.get(roomId);