diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index 4257158..f136d17 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -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. diff --git a/docs/development/devlog/2025-12-22-114000_bot_actions.md b/docs/development/devlog/2025-12-22-114000_bot_actions.md new file mode 100644 index 0000000..1495981 --- /dev/null +++ b/docs/development/devlog/2025-12-22-114000_bot_actions.md @@ -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). diff --git a/src/client/dev-dist/sw.js b/src/client/dev-dist/sw.js index ceac523..c0dee49 100644 --- a/src/client/dev-dist/sw.js +++ b/src/client/dev-dist/sw.js @@ -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"), { diff --git a/src/server/game/types.ts b/src/server/game/types.ts index 7ce4101..e76c7fa 100644 --- a/src/server/game/types.ts +++ b/src/server/game/types.ts @@ -76,6 +76,7 @@ export interface PlayerState { handKept?: boolean; // For Mulligan phase mulliganCount?: number; manaPool: Record; // { W: 0, U: 1, ... } + isBot?: boolean; } export interface StackObject { diff --git a/src/server/index.ts b/src/server/index.ts index 14b4e76..1b65058 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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); } diff --git a/src/server/managers/GameManager.ts b/src/server/managers/GameManager.ts index 2fb77a9..24c2c54 100644 --- a/src/server/managers/GameManager.ts +++ b/src/server/managers/GameManager.ts @@ -5,7 +5,7 @@ import { RulesEngine } from '../game/RulesEngine'; export class GameManager { public games: Map = 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 = {}; @@ -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);