feat: Implement solo draft mode with bot players and automated deck building.
This commit is contained in:
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.c9el36ma12"
|
"revision": "0.5drsp6r8gnc"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@@ -305,9 +305,9 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
const handleStartSoloTest = async () => {
|
const handleStartSoloTest = async () => {
|
||||||
if (packs.length === 0) return;
|
if (packs.length === 0) return;
|
||||||
|
|
||||||
// Validate Lands
|
// Validate Lands - Warn but allow proceed (server will handle it or deck builder will be landless)
|
||||||
if (!availableLands || availableLands.length === 0) {
|
if (!availableLands || availableLands.length === 0) {
|
||||||
if (!confirm("No basic lands detected in the current pool. The generated deck will have 0 lands. Continue?")) {
|
if (!confirm("No basic lands detected in the current pool. Decks might be invalid. Continue?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,49 +315,18 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Collect all cards
|
|
||||||
const allCards = packs.flatMap(p => p.cards);
|
|
||||||
|
|
||||||
// Random Deck Construction Logic
|
|
||||||
// 1. Separate lands and non-lands (Exclude existing Basic Lands from spells to be safe)
|
|
||||||
const spells = allCards.filter(c => !c.typeLine?.includes('Basic Land') && !c.typeLine?.includes('Land'));
|
|
||||||
|
|
||||||
// 2. Select 23 Spells randomly
|
|
||||||
const deckSpells: any[] = [];
|
|
||||||
const spellPool = [...spells];
|
|
||||||
|
|
||||||
// Fisher-Yates Shuffle
|
|
||||||
for (let i = spellPool.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[spellPool[i], spellPool[j]] = [spellPool[j], spellPool[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take up to 23 spells, or all if fewer
|
|
||||||
deckSpells.push(...spellPool.slice(0, Math.min(23, spellPool.length)));
|
|
||||||
|
|
||||||
// 3. Select 17 Lands (or fill to 40)
|
|
||||||
const deckLands: any[] = [];
|
|
||||||
const landCount = 40 - deckSpells.length; // Aim for 40 cards total
|
|
||||||
|
|
||||||
if (availableLands.length > 0) {
|
|
||||||
for (let i = 0; i < landCount; i++) {
|
|
||||||
const land = availableLands[Math.floor(Math.random() * availableLands.length)];
|
|
||||||
deckLands.push(land);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullDeck = [...deckSpells, ...deckLands];
|
|
||||||
|
|
||||||
// Emit socket event
|
|
||||||
const playerId = localStorage.getItem('player_id') || 'tester-' + Date.now();
|
const playerId = localStorage.getItem('player_id') || 'tester-' + Date.now();
|
||||||
const playerName = localStorage.getItem('player_name') || 'Tester';
|
const playerName = localStorage.getItem('player_name') || 'Tester';
|
||||||
|
|
||||||
if (!socketService.socket.connected) socketService.connect();
|
if (!socketService.socket.connected) socketService.connect();
|
||||||
|
|
||||||
|
// Emit new start_solo_test event
|
||||||
|
// Now sends PACKS and LANDS instead of a constructed DECK
|
||||||
const response = await socketService.emitPromise('start_solo_test', {
|
const response = await socketService.emitPromise('start_solo_test', {
|
||||||
playerId,
|
playerId,
|
||||||
playerName,
|
playerName,
|
||||||
deck: fullDeck
|
packs,
|
||||||
|
basicLands: availableLands
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -369,7 +338,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
|||||||
onGoToLobby();
|
onGoToLobby();
|
||||||
}, 100);
|
}, 100);
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to start test game: " + response.message);
|
alert("Failed to start solo draft: " + response.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { socketService } from '../../services/SocketService';
|
import { socketService } from '../../services/SocketService';
|
||||||
import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut, Bell, BellOff, X } from 'lucide-react';
|
import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut, Bell, BellOff, X, Bot } from 'lucide-react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useToast } from '../../components/Toast';
|
import { useToast } from '../../components/Toast';
|
||||||
import { GameView } from '../game/GameView';
|
import { GameView } from '../game/GameView';
|
||||||
@@ -14,6 +14,7 @@ interface Player {
|
|||||||
isHost: boolean;
|
isHost: boolean;
|
||||||
role: 'player' | 'spectator';
|
role: 'player' | 'spectator';
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
|
isBot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
@@ -283,7 +284,13 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
>
|
>
|
||||||
<Layers className="w-5 h-5" /> Start Draft
|
<Layers className="w-5 h-5" /> Start Draft
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => socketService.socket.emit('add_bot', { roomId: room.id })}
|
||||||
|
disabled={room.status !== 'waiting' || room.players.length >= 8}
|
||||||
|
className="px-8 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg shadow-indigo-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Bot className="w-5 h-5" /> Add Bot
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -426,8 +433,8 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
return (
|
return (
|
||||||
<div key={p.id} className="flex items-center justify-between bg-slate-900/80 p-3 rounded-xl border border-slate-700/50 hover:border-slate-600 transition-colors group">
|
<div key={p.id} className="flex items-center justify-between bg-slate-900/80 p-3 rounded-xl border border-slate-700/50 hover:border-slate-600 transition-colors group">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm shadow-inner ${p.role === 'spectator' ? 'bg-slate-800 text-slate-500' : 'bg-gradient-to-br from-purple-600 to-blue-600 text-white shadow-purple-900/30'}`}>
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm shadow-inner ${p.isBot ? 'bg-indigo-900 text-indigo-200 border border-indigo-500' : p.role === 'spectator' ? 'bg-slate-800 text-slate-500' : 'bg-gradient-to-br from-purple-600 to-blue-600 text-white shadow-purple-900/30'}`}>
|
||||||
{p.name.substring(0, 2).toUpperCase()}
|
{p.isBot ? <Bot className="w-5 h-5" /> : p.name.substring(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className={`text-sm font-bold ${isMe ? 'text-white' : 'text-slate-200'}`}>
|
<span className={`text-sm font-bold ${isMe ? 'text-white' : 'text-slate-200'}`}>
|
||||||
@@ -436,6 +443,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500 flex items-center gap-1">
|
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500 flex items-center gap-1">
|
||||||
{p.role}
|
{p.role}
|
||||||
{p.isHost && <span className="text-amber-500 flex items-center">• Host</span>}
|
{p.isHost && <span className="text-amber-500 flex items-center">• Host</span>}
|
||||||
|
{p.isBot && <span className="text-indigo-400 flex items-center">• Bot</span>}
|
||||||
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 flex items-center">• Ready</span>}
|
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 flex items-center">• Ready</span>}
|
||||||
{p.isOffline && <span className="text-red-500 flex items-center">• Offline</span>}
|
{p.isOffline && <span className="text-red-500 flex items-center">• Offline</span>}
|
||||||
</span>
|
</span>
|
||||||
@@ -456,6 +464,17 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
<LogOut className="w-4 h-4 rotate-180" />
|
<LogOut className="w-4 h-4 rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isMeHost && p.isBot && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
socketService.socket.emit('remove_bot', { roomId: room.id, botId: p.id });
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-500 hover:text-red-500 transition-colors"
|
||||||
|
title="Remove Bot"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isMe && (
|
{isMe && (
|
||||||
<button onClick={onExit} className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-400 hover:text-red-400 transition-colors" title="Accions">
|
<button onClick={onExit} className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-400 hover:text-red-400 transition-colors" title="Accions">
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
|
|||||||
@@ -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
|
// Secure helper to get player context
|
||||||
const getContext = () => roomManager.getPlayerBySocket(socket.id);
|
const getContext = () => roomManager.getPlayerBySocket(socket.id);
|
||||||
|
|
||||||
@@ -441,7 +465,7 @@ io.on('connection', (socket) => {
|
|||||||
// return;
|
// 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';
|
room.status = 'drafting';
|
||||||
|
|
||||||
io.to(room.id).emit('room_update', room);
|
io.to(room.id).emit('room_update', room);
|
||||||
@@ -461,6 +485,24 @@ io.on('connection', (socket) => {
|
|||||||
if (draft.status === 'deck_building') {
|
if (draft.status === 'deck_building') {
|
||||||
room.status = 'deck_building';
|
room.status = 'deck_building';
|
||||||
io.to(room.id).emit('room_update', room);
|
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) => {
|
socket.on('start_solo_test', ({ playerId, playerName, packs, basicLands }, callback) => { // Updated signature
|
||||||
// Solo test is a separate creation flow, doesn't require existing context
|
// Solo test -> 1 Human + 7 Bots + Start Draft
|
||||||
const room = roomManager.createRoom(playerId, playerName, []);
|
console.log(`Starting Solo Draft for ${playerName}`);
|
||||||
room.status = 'playing';
|
|
||||||
|
const room = roomManager.createRoom(playerId, playerName, packs, basicLands || [], socket.id);
|
||||||
socket.join(room.id);
|
socket.join(room.id);
|
||||||
const game = gameManager.createGame(room.id, room.players);
|
|
||||||
if (Array.isArray(deck)) {
|
// Add 7 Bots
|
||||||
deck.forEach((card: any) => {
|
for (let i = 0; i < 7; i++) {
|
||||||
gameManager.addCardToGame(room.id, {
|
roomManager.addBot(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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Game State (Draw Hands)
|
// Start Draft
|
||||||
const engine = new RulesEngine(game);
|
const draft = draftManager.createDraft(room.id, room.players.map(p => ({ id: p.id, isBot: !!p.isBot })), room.packs, room.basicLands);
|
||||||
engine.startGame();
|
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('room_update', room);
|
||||||
io.to(room.id).emit('game_update', game);
|
io.to(room.id).emit('draft_update', draft);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('start_game', ({ decks }) => {
|
socket.on('start_game', ({ decks }) => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface Card {
|
|||||||
// ... other props
|
// ... other props
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { BotDeckBuilderService } from '../services/BotDeckBuilderService'; // Import service
|
||||||
|
|
||||||
interface Pack {
|
interface Pack {
|
||||||
id: string;
|
id: string;
|
||||||
cards: Card[];
|
cards: Card[];
|
||||||
@@ -29,8 +31,12 @@ interface DraftState {
|
|||||||
isWaiting: boolean; // True if finished current pack round
|
isWaiting: boolean; // True if finished current pack round
|
||||||
pickedInCurrentStep: number; // HOW MANY CARDS PICKED FROM CURRENT ACTIVE PACK
|
pickedInCurrentStep: number; // HOW MANY CARDS PICKED FROM CURRENT ACTIVE PACK
|
||||||
pickExpiresAt: number; // Timestamp when auto-pick occurs
|
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';
|
status: 'drafting' | 'deck_building' | 'complete';
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
startTime?: number; // For timer
|
startTime?: number; // For timer
|
||||||
@@ -39,7 +45,9 @@ interface DraftState {
|
|||||||
export class DraftManager extends EventEmitter {
|
export class DraftManager extends EventEmitter {
|
||||||
private drafts: Map<string, DraftState> = new Map();
|
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
|
// Distribute 3 packs to each player
|
||||||
// Assume allPacks contains (3 * numPlayers) packs
|
// Assume allPacks contains (3 * numPlayers) packs
|
||||||
|
|
||||||
@@ -56,15 +64,17 @@ export class DraftManager extends EventEmitter {
|
|||||||
|
|
||||||
const draftState: DraftState = {
|
const draftState: DraftState = {
|
||||||
roomId,
|
roomId,
|
||||||
seats: players, // Assume order is randomized or fixed
|
seats: players.map(p => p.id), // Assume order is randomized or fixed
|
||||||
packNumber: 1,
|
packNumber: 1,
|
||||||
players: {},
|
players: {},
|
||||||
status: 'drafting',
|
status: 'drafting',
|
||||||
isPaused: false,
|
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 playerPacks = shuffledPacks.slice(index * 3, (index + 1) * 3);
|
||||||
const firstPack = playerPacks.shift(); // Open Pack 1 immediately
|
const firstPack = playerPacks.shift(); // Open Pack 1 immediately
|
||||||
|
|
||||||
@@ -76,7 +86,8 @@ export class DraftManager extends EventEmitter {
|
|||||||
unopenedPacks: playerPacks,
|
unopenedPacks: playerPacks,
|
||||||
isWaiting: false,
|
isWaiting: false,
|
||||||
pickedInCurrentStep: 0,
|
pickedInCurrentStep: 0,
|
||||||
pickExpiresAt: Date.now() + 60000 // 60 seconds for first pack
|
pickExpiresAt: Date.now() + 60000, // 60 seconds for first pack
|
||||||
|
isBot: p.isBot
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,13 +189,16 @@ export class DraftManager extends EventEmitter {
|
|||||||
for (const playerId of Object.keys(draft.players)) {
|
for (const playerId of Object.keys(draft.players)) {
|
||||||
const playerState = draft.players[playerId];
|
const playerState = draft.players[playerId];
|
||||||
// Check if player is thinking (has active pack) and time expired
|
// Check if player is thinking (has active pack) and time expired
|
||||||
if (playerState.activePack && now > playerState.pickExpiresAt) {
|
// OR if player is a BOT (Auto-Pick immediately)
|
||||||
|
if (playerState.activePack) {
|
||||||
|
if (playerState.isBot || now > playerState.pickExpiresAt) {
|
||||||
const result = this.autoPick(roomId, playerId);
|
const result = this.autoPick(roomId, playerId);
|
||||||
if (result) {
|
if (result) {
|
||||||
draftUpdated = true;
|
draftUpdated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (draftUpdated) {
|
if (draftUpdated) {
|
||||||
updates.push({ roomId, draft });
|
updates.push({ roomId, draft });
|
||||||
}
|
}
|
||||||
@@ -251,6 +265,16 @@ export class DraftManager extends EventEmitter {
|
|||||||
// Draft Complete
|
// Draft Complete
|
||||||
draft.status = 'deck_building';
|
draft.status = 'deck_building';
|
||||||
draft.startTime = Date.now(); // Start deck building timer
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Player {
|
|||||||
deck?: any[];
|
deck?: any[];
|
||||||
socketId?: string; // Current or last known socket
|
socketId?: string; // Current or last known socket
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
|
isBot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
@@ -196,6 +197,45 @@ export class RoomManager {
|
|||||||
return message;
|
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 {
|
getPlayerBySocket(socketId: string): { player: Player, room: Room } | null {
|
||||||
for (const room of this.rooms.values()) {
|
for (const room of this.rooms.values()) {
|
||||||
const player = room.players.find(p => p.socketId === socketId);
|
const player = room.players.find(p => p.socketId === socketId);
|
||||||
|
|||||||
136
src/server/services/BotDeckBuilderService.ts
Normal file
136
src/server/services/BotDeckBuilderService.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user