feat: Implement basic bot AI for game actions including mulligan, playing lands, casting creatures, and declaring attackers.
This commit is contained in:
@@ -5,3 +5,4 @@
|
|||||||
|
|
||||||
## Devlog Index
|
## Devlog Index
|
||||||
- [Enable Clear Session](./devlog/2025-12-20-014500_enable_clear_session.md) - Improved UI/UX for session clearing in CubeManager.
|
- [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.
|
||||||
|
|||||||
29
docs/development/devlog/2025-12-22-114000_bot_actions.md
Normal file
29
docs/development/devlog/2025-12-22-114000_bot_actions.md
Normal 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).
|
||||||
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.lnjaj3n52vg"
|
"revision": "0.vopjl6fp8f"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface PlayerState {
|
|||||||
handKept?: boolean; // For Mulligan phase
|
handKept?: boolean; // For Mulligan phase
|
||||||
mulliganCount?: number;
|
mulliganCount?: number;
|
||||||
manaPool: Record<string, number>; // { W: 0, U: 1, ... }
|
manaPool: Record<string, number>; // { W: 0, U: 1, ... }
|
||||||
|
isBot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StackObject {
|
export interface StackObject {
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ const draftInterval = setInterval(() => {
|
|||||||
|
|
||||||
const engine = new RulesEngine(game);
|
const engine = new RulesEngine(game);
|
||||||
engine.startGame();
|
engine.startGame();
|
||||||
|
gameManager.triggerBotCheck(roomId);
|
||||||
io.to(roomId).emit('game_update', game);
|
io.to(roomId).emit('game_update', game);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +365,7 @@ const draftInterval = setInterval(() => {
|
|||||||
// Initialize Game State (Draw Hands)
|
// Initialize Game State (Draw Hands)
|
||||||
const engine = new RulesEngine(game);
|
const engine = new RulesEngine(game);
|
||||||
engine.startGame();
|
engine.startGame();
|
||||||
|
gameManager.triggerBotCheck(roomId);
|
||||||
|
|
||||||
io.to(roomId).emit('game_update', game);
|
io.to(roomId).emit('game_update', game);
|
||||||
}
|
}
|
||||||
@@ -637,6 +639,7 @@ io.on('connection', (socket) => {
|
|||||||
// Initialize Game State (Draw Hands)
|
// Initialize Game State (Draw Hands)
|
||||||
const engine = new RulesEngine(game);
|
const engine = new RulesEngine(game);
|
||||||
engine.startGame();
|
engine.startGame();
|
||||||
|
gameManager.triggerBotCheck(room.id);
|
||||||
|
|
||||||
io.to(room.id).emit('game_update', game);
|
io.to(room.id).emit('game_update', game);
|
||||||
}
|
}
|
||||||
@@ -700,6 +703,7 @@ io.on('connection', (socket) => {
|
|||||||
// Initialize Game State (Draw Hands)
|
// Initialize Game State (Draw Hands)
|
||||||
const engine = new RulesEngine(game);
|
const engine = new RulesEngine(game);
|
||||||
engine.startGame();
|
engine.startGame();
|
||||||
|
gameManager.triggerBotCheck(room.id);
|
||||||
|
|
||||||
io.to(room.id).emit('game_update', game);
|
io.to(room.id).emit('game_update', game);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { RulesEngine } from '../game/RulesEngine';
|
|||||||
export class GameManager {
|
export class GameManager {
|
||||||
public games: Map<string, StrictGameState> = new Map();
|
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
|
// Convert array to map
|
||||||
const playerRecord: Record<string, PlayerState> = {};
|
const playerRecord: Record<string, PlayerState> = {};
|
||||||
@@ -13,6 +13,7 @@ export class GameManager {
|
|||||||
playerRecord[p.id] = {
|
playerRecord[p.id] = {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
isBot: p.isBot,
|
||||||
life: 20,
|
life: 20,
|
||||||
poison: 0,
|
poison: 0,
|
||||||
energy: 0,
|
energy: 0,
|
||||||
@@ -53,6 +54,36 @@ export class GameManager {
|
|||||||
return gameState;
|
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 {
|
getGame(roomId: string): StrictGameState | undefined {
|
||||||
return this.games.get(roomId);
|
return this.games.get(roomId);
|
||||||
}
|
}
|
||||||
@@ -102,9 +133,95 @@ export class GameManager {
|
|||||||
return null;
|
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;
|
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) ---
|
// --- Legacy Sandbox Action Handler (for Admin/Testing) ---
|
||||||
handleAction(roomId: string, action: any, actorId: string): StrictGameState | null {
|
handleAction(roomId: string, action: any, actorId: string): StrictGameState | null {
|
||||||
const game = this.games.get(roomId);
|
const game = this.games.get(roomId);
|
||||||
|
|||||||
Reference in New Issue
Block a user