feat: Implement solo draft mode with bot players and automated deck building.

This commit is contained in:
2025-12-20 14:48:06 +01:00
parent fd20c3cfb2
commit a3e45b13ce
7 changed files with 301 additions and 86 deletions

View File

@@ -423,6 +423,30 @@ io.on('connection', (socket) => {
}
});
socket.on('add_bot', ({ roomId }) => {
const context = getContext();
if (!context || !context.player.isHost) return; // Verify host
const updatedRoom = roomManager.addBot(roomId);
if (updatedRoom) {
io.to(roomId).emit('room_update', updatedRoom);
console.log(`Bot added to room ${roomId}`);
} else {
socket.emit('error', { message: 'Failed to add bot (Room full?)' });
}
});
socket.on('remove_bot', ({ roomId, botId }) => {
const context = getContext();
if (!context || !context.player.isHost) return; // Verify host
const updatedRoom = roomManager.removeBot(roomId, botId);
if (updatedRoom) {
io.to(roomId).emit('room_update', updatedRoom);
console.log(`Bot ${botId} removed from room ${roomId}`);
}
});
// Secure helper to get player context
const getContext = () => roomManager.getPlayerBySocket(socket.id);
@@ -441,7 +465,7 @@ io.on('connection', (socket) => {
// return;
}
const draft = draftManager.createDraft(room.id, room.players.map(p => p.id), room.packs);
const draft = draftManager.createDraft(room.id, room.players.map(p => ({ id: p.id, isBot: !!p.isBot })), room.packs, room.basicLands);
room.status = 'drafting';
io.to(room.id).emit('room_update', room);
@@ -461,6 +485,24 @@ io.on('connection', (socket) => {
if (draft.status === 'deck_building') {
room.status = 'deck_building';
io.to(room.id).emit('room_update', room);
// Logic to Sync Bot Readiness (Decks built by DraftManager)
const currentRoom = roomManager.getRoom(room.id); // Get latest room state
if (currentRoom) {
Object.values(draft.players).forEach(draftPlayer => {
if (draftPlayer.isBot && draftPlayer.deck) {
const roomPlayer = currentRoom.players.find(rp => rp.id === draftPlayer.id);
if (roomPlayer && !roomPlayer.ready) {
// Mark Bot Ready!
const updatedRoom = roomManager.setPlayerReady(room.id, draftPlayer.id, draftPlayer.deck);
if (updatedRoom) {
io.to(room.id).emit('room_update', updatedRoom);
console.log(`Bot ${draftPlayer.id} marked ready with deck (${draftPlayer.deck.length} cards).`);
}
}
}
});
}
}
}
});
@@ -511,40 +553,25 @@ io.on('connection', (socket) => {
}
});
socket.on('start_solo_test', ({ playerId, playerName, deck }, callback) => {
// Solo test is a separate creation flow, doesn't require existing context
const room = roomManager.createRoom(playerId, playerName, []);
room.status = 'playing';
socket.on('start_solo_test', ({ playerId, playerName, packs, basicLands }, callback) => { // Updated signature
// Solo test -> 1 Human + 7 Bots + Start Draft
console.log(`Starting Solo Draft for ${playerName}`);
const room = roomManager.createRoom(playerId, playerName, packs, basicLands || [], socket.id);
socket.join(room.id);
const game = gameManager.createGame(room.id, room.players);
if (Array.isArray(deck)) {
deck.forEach((card: any) => {
gameManager.addCardToGame(room.id, {
ownerId: playerId,
controllerId: playerId,
oracleId: card.id,
name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
zone: 'library',
typeLine: card.typeLine || card.type_line || '',
oracleText: card.oracleText || card.oracle_text || '',
manaCost: card.manaCost || card.mana_cost || '',
keywords: card.keywords || [],
power: card.power,
toughness: card.toughness,
damageMarked: 0,
controlledSinceTurn: 0
});
});
// Add 7 Bots
for (let i = 0; i < 7; i++) {
roomManager.addBot(room.id);
}
// Initialize Game State (Draw Hands)
const engine = new RulesEngine(game);
engine.startGame();
// Start Draft
const draft = draftManager.createDraft(room.id, room.players.map(p => ({ id: p.id, isBot: !!p.isBot })), room.packs, room.basicLands);
room.status = 'drafting';
callback({ success: true, room, game });
callback({ success: true, room, draftState: draft });
io.to(room.id).emit('room_update', room);
io.to(room.id).emit('game_update', game);
io.to(room.id).emit('draft_update', draft);
});
socket.on('start_game', ({ decks }) => {

View File

@@ -9,6 +9,8 @@ interface Card {
// ... other props
}
import { BotDeckBuilderService } from '../services/BotDeckBuilderService'; // Import service
interface Pack {
id: string;
cards: Card[];
@@ -29,8 +31,12 @@ interface DraftState {
isWaiting: boolean; // True if finished current pack round
pickedInCurrentStep: number; // HOW MANY CARDS PICKED FROM CURRENT ACTIVE PACK
pickExpiresAt: number; // Timestamp when auto-pick occurs
isBot: boolean;
deck?: Card[]; // Store constructed deck here
}>;
basicLands?: Card[]; // Store reference to available basic lands
status: 'drafting' | 'deck_building' | 'complete';
isPaused: boolean;
startTime?: number; // For timer
@@ -39,7 +45,9 @@ interface DraftState {
export class DraftManager extends EventEmitter {
private drafts: Map<string, DraftState> = new Map();
createDraft(roomId: string, players: string[], allPacks: Pack[]): DraftState {
private botBuilder = new BotDeckBuilderService();
createDraft(roomId: string, players: { id: string, isBot: boolean }[], allPacks: Pack[], basicLands: Card[] = []): DraftState {
// Distribute 3 packs to each player
// Assume allPacks contains (3 * numPlayers) packs
@@ -56,15 +64,17 @@ export class DraftManager extends EventEmitter {
const draftState: DraftState = {
roomId,
seats: players, // Assume order is randomized or fixed
seats: players.map(p => p.id), // Assume order is randomized or fixed
packNumber: 1,
players: {},
status: 'drafting',
isPaused: false,
startTime: Date.now()
startTime: Date.now(),
basicLands: basicLands
};
players.forEach((pid, index) => {
players.forEach((p, index) => {
const pid = p.id;
const playerPacks = shuffledPacks.slice(index * 3, (index + 1) * 3);
const firstPack = playerPacks.shift(); // Open Pack 1 immediately
@@ -76,7 +86,8 @@ export class DraftManager extends EventEmitter {
unopenedPacks: playerPacks,
isWaiting: false,
pickedInCurrentStep: 0,
pickExpiresAt: Date.now() + 60000 // 60 seconds for first pack
pickExpiresAt: Date.now() + 60000, // 60 seconds for first pack
isBot: p.isBot
};
});
@@ -178,10 +189,13 @@ export class DraftManager extends EventEmitter {
for (const playerId of Object.keys(draft.players)) {
const playerState = draft.players[playerId];
// Check if player is thinking (has active pack) and time expired
if (playerState.activePack && now > playerState.pickExpiresAt) {
const result = this.autoPick(roomId, playerId);
if (result) {
draftUpdated = true;
// OR if player is a BOT (Auto-Pick immediately)
if (playerState.activePack) {
if (playerState.isBot || now > playerState.pickExpiresAt) {
const result = this.autoPick(roomId, playerId);
if (result) {
draftUpdated = true;
}
}
}
}
@@ -251,6 +265,16 @@ export class DraftManager extends EventEmitter {
// Draft Complete
draft.status = 'deck_building';
draft.startTime = Date.now(); // Start deck building timer
// AUTO-BUILD BOT DECKS
Object.values(draft.players).forEach(p => {
if (p.isBot) {
// Build deck
const lands = draft.basicLands || [];
const deck = this.botBuilder.buildDeck(p.pool, lands);
p.deck = deck;
}
});
}
}
}

View File

@@ -7,6 +7,7 @@ interface Player {
deck?: any[];
socketId?: string; // Current or last known socket
isOffline?: boolean;
isBot?: boolean;
}
interface ChatMessage {
@@ -196,6 +197,45 @@ export class RoomManager {
return message;
}
addBot(roomId: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
// Check limits
if (room.players.length >= room.maxPlayers) return null;
const botNumber = room.players.filter(p => p.isBot).length + 1;
const botId = `bot-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
const botPlayer: Player = {
id: botId,
name: `Bot ${botNumber}`,
isHost: false,
role: 'player',
ready: true, // Bots are always ready? Or host readies them? Let's say ready for now.
isOffline: false,
isBot: true
};
room.players.push(botPlayer);
return room;
}
removeBot(roomId: string, botId: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
const botIndex = room.players.findIndex(p => p.id === botId && p.isBot);
if (botIndex !== -1) {
room.players.splice(botIndex, 1);
return room;
}
return null;
}
getPlayerBySocket(socketId: string): { player: Player, room: Room } | null {
for (const room of this.rooms.values()) {
const player = room.players.find(p => p.socketId === socketId);

View File

@@ -0,0 +1,136 @@
interface Card {
id: string;
name: string;
manaCost?: string;
typeLine?: string;
colors?: string[]; // e.g. ['W', 'U']
colorIdentity?: string[];
rarity?: string;
cmc?: number;
}
export class BotDeckBuilderService {
buildDeck(pool: Card[], basicLands: Card[]): Card[] {
// 1. Analyze Colors to find top 2 archetypes
const colorCounts = { W: 0, U: 0, B: 0, R: 0, G: 0 };
pool.forEach(card => {
// Simple heuristic: Count cards by color identity
// Weighted by Rarity: Mythic=4, Rare=3, Uncommon=2, Common=1
const weight = this.getRarityWeight(card.rarity);
if (card.colors && card.colors.length > 0) {
card.colors.forEach(c => {
if (colorCounts[c as keyof typeof colorCounts] !== undefined) {
colorCounts[c as keyof typeof colorCounts] += weight;
}
});
}
});
// Sort colors by count desc
const sortedColors = Object.entries(colorCounts)
.sort(([, a], [, b]) => b - a)
.map(([color]) => color);
const mainColors = sortedColors.slice(0, 2); // Top 2 colors
// 2. Filter Pool for On-Color + Artifacts
const candidates = pool.filter(card => {
if (!card.colors || card.colors.length === 0) return true; // Artifacts/Colorless
// Check if card fits within main colors
return card.colors.every(c => mainColors.includes(c));
});
// 3. Separate Lands and Spells
const lands = candidates.filter(c => c.typeLine?.includes('Land')); // Non-basic lands in pool
const spells = candidates.filter(c => !c.typeLine?.includes('Land'));
// 4. Select Spells (Curve + Power)
// Sort by Weight + slight curve preference (lower cmc preferred for consistency)
spells.sort((a, b) => {
const weightA = this.getRarityWeight(a.rarity);
const weightB = this.getRarityWeight(b.rarity);
return weightB - weightA;
});
const deckSpells = spells.slice(0, 23);
const deckNonBasicLands = lands.slice(0, 4); // Take up to 4 non-basics if available (simple cap)
// 5. Fill with Basic Lands
const cardsNeeded = 40 - (deckSpells.length + deckNonBasicLands.length);
const deckLands: Card[] = [];
if (cardsNeeded > 0 && basicLands.length > 0) {
// Calculate ratio of colors in spells
let whitePips = 0;
let bluePips = 0;
let blackPips = 0;
let redPips = 0;
let greenPips = 0;
deckSpells.forEach(c => {
if (c.colors?.includes('W')) whitePips++;
if (c.colors?.includes('U')) bluePips++;
if (c.colors?.includes('B')) blackPips++;
if (c.colors?.includes('R')) redPips++;
if (c.colors?.includes('G')) greenPips++;
});
const totalPips = whitePips + bluePips + blackPips + redPips + greenPips || 1;
// Allocate lands
const landAllocation = {
W: Math.round((whitePips / totalPips) * cardsNeeded),
U: Math.round((bluePips / totalPips) * cardsNeeded),
B: Math.round((blackPips / totalPips) * cardsNeeded),
R: Math.round((redPips / totalPips) * cardsNeeded),
G: Math.round((greenPips / totalPips) * cardsNeeded),
};
// Fix rounding errors
const allocatedTotal = Object.values(landAllocation).reduce((a, b) => a + b, 0);
if (allocatedTotal < cardsNeeded) {
// Add to main color
landAllocation[mainColors[0] as keyof typeof landAllocation] += (cardsNeeded - allocatedTotal);
}
// Add actual land objects
// We need a source of basic lands. Passed in argument.
Object.entries(landAllocation).forEach(([color, count]) => {
const landName = this.getBasicLandName(color);
const landCard = basicLands.find(l => l.name === landName) || basicLands[0]; // Fallback
if (landCard) {
for (let i = 0; i < count; i++) {
deckLands.push({ ...landCard, id: `land-${Date.now()}-${Math.random()}` }); // clone with new ID
}
}
});
}
return [...deckSpells, ...deckNonBasicLands, ...deckLands];
}
private getRarityWeight(rarity?: string): number {
switch (rarity) {
case 'mythic': return 5;
case 'rare': return 4;
case 'uncommon': return 2;
default: return 1;
}
}
private getBasicLandName(color: string): string {
switch (color) {
case 'W': return 'Plains';
case 'U': return 'Island';
case 'B': return 'Swamp';
case 'R': return 'Mountain';
case 'G': return 'Forest';
default: return 'Wastes';
}
}
}