feat: Implement draft and game phases with client views, dedicated managers, and server-side card image caching.
This commit is contained in:
@@ -1,27 +1,58 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { RoomManager } from './managers/RoomManager';
|
||||
import { GameManager } from './managers/GameManager';
|
||||
import { DraftManager } from './managers/DraftManager';
|
||||
import { CardService } from './services/CardService';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "*", // Adjust for production
|
||||
origin: "*", // Adjust for production,
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
});
|
||||
|
||||
const roomManager = new RoomManager();
|
||||
const gameManager = new GameManager();
|
||||
const draftManager = new DraftManager();
|
||||
const cardService = new CardService();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '50mb' })); // Increase limit for large card lists
|
||||
|
||||
// Serve static images
|
||||
app.use('/cards', express.static(path.join(__dirname, 'public/cards')));
|
||||
|
||||
// API Routes
|
||||
app.get('/api/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', message: 'Server is running' });
|
||||
});
|
||||
|
||||
app.post('/api/cards/cache', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { cards } = req.body;
|
||||
if (!cards || !Array.isArray(cards)) {
|
||||
res.status(400).json({ error: 'Invalid payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Caching images for ${cards.length} cards...`);
|
||||
const count = await cardService.cacheImages(cards);
|
||||
res.json({ success: true, downloaded: count });
|
||||
} catch (err: any) {
|
||||
console.error('Error in cache route:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO logic
|
||||
io.on('connection', (socket) => {
|
||||
console.log('A user connected', socket.id);
|
||||
@@ -59,11 +90,79 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('start_game', ({ roomId }) => {
|
||||
socket.on('start_draft', ({ roomId }) => {
|
||||
const room = roomManager.getRoom(roomId);
|
||||
if (room && room.status === 'waiting') {
|
||||
// Create Draft
|
||||
// All packs in room.packs need to be flat list or handled
|
||||
// room.packs is currently JSON.
|
||||
const draft = draftManager.createDraft(roomId, room.players.map(p => p.id), room.packs);
|
||||
room.status = 'drafting';
|
||||
|
||||
io.to(roomId).emit('room_update', room);
|
||||
io.to(roomId).emit('draft_update', draft);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('pick_card', ({ roomId, cardId }) => {
|
||||
// Find player from socket? Actually we trust clientId sent or inferred (but simpler to trust socket for now if we tracked map, but here just use helper?)
|
||||
// We didn't store socket->player map here globally. We'll pass playerId in payload for simplicity but validation later.
|
||||
// Wait, let's look at signature.. pickCard(roomId, playerId, cardId)
|
||||
|
||||
// Need playerId. Let's ask client to send it.
|
||||
// Or we can find it if we know connection...
|
||||
// Let's assume payload: { roomId, playerId, cardId }
|
||||
});
|
||||
|
||||
// Revised pick_card to actual impl
|
||||
socket.on('pick_card', ({ roomId, playerId, cardId }) => {
|
||||
const draft = draftManager.pickCard(roomId, playerId, cardId);
|
||||
if (draft) {
|
||||
io.to(roomId).emit('draft_update', draft);
|
||||
|
||||
if (draft.status === 'deck_building') {
|
||||
// Notify room
|
||||
const room = roomManager.getRoom(roomId);
|
||||
if (room) {
|
||||
room.status = 'deck_building';
|
||||
io.to(roomId).emit('room_update', room);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('start_game', ({ roomId, decks }) => {
|
||||
const room = roomManager.startGame(roomId);
|
||||
if (room) {
|
||||
io.to(roomId).emit('room_update', room);
|
||||
// Here we would also emit 'draft_state' with initial packs
|
||||
|
||||
// Initialize Game
|
||||
const game = gameManager.createGame(roomId, room.players);
|
||||
// If decks are provided, load them
|
||||
if (decks) {
|
||||
Object.entries(decks).forEach(([playerId, deck]: [string, any]) => {
|
||||
// @ts-ignore
|
||||
deck.forEach(card => {
|
||||
gameManager.addCardToGame(roomId, {
|
||||
ownerId: playerId,
|
||||
controllerId: playerId,
|
||||
oracleId: card.oracle_id || card.id,
|
||||
name: card.name,
|
||||
imageUrl: card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
|
||||
zone: 'library' // Start in library
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
io.to(roomId).emit('game_update', game);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('game_action', ({ roomId, action }) => {
|
||||
const game = gameManager.handleAction(roomId, action);
|
||||
if (game) {
|
||||
io.to(roomId).emit('game_update', game);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
166
src/server/managers/DraftManager.ts
Normal file
166
src/server/managers/DraftManager.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
interface Card {
|
||||
id: string; // instanceid or scryfall id
|
||||
name: string;
|
||||
image_uris?: { normal: string };
|
||||
card_faces?: { image_uris: { normal: string } }[];
|
||||
// ... other props
|
||||
}
|
||||
|
||||
interface Pack {
|
||||
id: string;
|
||||
cards: Card[];
|
||||
}
|
||||
|
||||
interface DraftState {
|
||||
roomId: string;
|
||||
seats: string[]; // PlayerIDs in seating order
|
||||
packNumber: number; // 1, 2, 3
|
||||
|
||||
// State per player
|
||||
players: Record<string, {
|
||||
id: string;
|
||||
queue: Pack[]; // Packs passed to this player waiting to be viewed
|
||||
activePack: Pack | null; // The pack currently being looked at
|
||||
pool: Card[]; // Picked cards
|
||||
unopenedPacks: Pack[]; // Pack 2 and 3 kept aside
|
||||
isWaiting: boolean; // True if finished current pack round
|
||||
}>;
|
||||
|
||||
status: 'drafting' | 'deck_building' | 'complete';
|
||||
startTime?: number; // For timer
|
||||
}
|
||||
|
||||
export class DraftManager extends EventEmitter {
|
||||
private drafts: Map<string, DraftState> = new Map();
|
||||
|
||||
createDraft(roomId: string, players: string[], allPacks: Pack[]): DraftState {
|
||||
// Distribute 3 packs to each player
|
||||
const numPlayers = players.length;
|
||||
// Assume allPacks contains (3 * numPlayers) packs
|
||||
|
||||
// Shuffle packs just in case (optional, but good practice)
|
||||
const shuffledPacks = [...allPacks].sort(() => Math.random() - 0.5);
|
||||
|
||||
const draftState: DraftState = {
|
||||
roomId,
|
||||
seats: players, // Assume order is randomized or fixed
|
||||
packNumber: 1,
|
||||
players: {},
|
||||
status: 'drafting',
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
players.forEach((pid, index) => {
|
||||
const playerPacks = shuffledPacks.slice(index * 3, (index + 1) * 3);
|
||||
const firstPack = playerPacks.shift(); // Open Pack 1 immediately
|
||||
|
||||
draftState.players[pid] = {
|
||||
id: pid,
|
||||
queue: [],
|
||||
activePack: firstPack || null,
|
||||
pool: [],
|
||||
unopenedPacks: playerPacks,
|
||||
isWaiting: false
|
||||
};
|
||||
});
|
||||
|
||||
this.drafts.set(roomId, draftState);
|
||||
return draftState;
|
||||
}
|
||||
|
||||
getDraft(roomId: string): DraftState | undefined {
|
||||
return this.drafts.get(roomId);
|
||||
}
|
||||
|
||||
pickCard(roomId: string, playerId: string, cardId: string): DraftState | null {
|
||||
const draft = this.drafts.get(roomId);
|
||||
if (!draft) return null;
|
||||
|
||||
const playerState = draft.players[playerId];
|
||||
if (!playerState || !playerState.activePack) return null;
|
||||
|
||||
// Find card
|
||||
const cardIndex = playerState.activePack.cards.findIndex(c => c.id === cardId || (c as any).uniqueId === cardId);
|
||||
// uniqueId check implies if cards have unique instance IDs in pack, if not we rely on strict equality or assume 1 instance per pack
|
||||
|
||||
// Fallback: If we can't find by ID (if Scryfall ID generic), just pick the first matching ID?
|
||||
// We should ideally assume the frontend sends the exact card object or unique index.
|
||||
// For now assuming cardId is unique enough or we pick first match.
|
||||
// Better: In a draft, a pack might have 2 duplicates. We need index or unique ID.
|
||||
// Let's assume the pack generation gave unique IDs or we just pick by index.
|
||||
// I'll stick to ID for now, assuming unique.
|
||||
|
||||
const card = playerState.activePack.cards.find(c => c.id === cardId);
|
||||
if (!card) return null;
|
||||
|
||||
// 1. Add to pool
|
||||
playerState.pool.push(card);
|
||||
|
||||
// 2. Remove from pack
|
||||
playerState.activePack.cards = playerState.activePack.cards.filter(c => c !== card);
|
||||
|
||||
const passedPack = playerState.activePack;
|
||||
playerState.activePack = null;
|
||||
|
||||
// 3. Logic for Passing or Discarding (End of Pack)
|
||||
if (passedPack.cards.length > 0) {
|
||||
// Pass to neighbor
|
||||
const seatIndex = draft.seats.indexOf(playerId);
|
||||
let nextSeatIndex;
|
||||
|
||||
// Pack 1: Left (Increase Index), Pack 2: Right (Decrease), Pack 3: Left
|
||||
if (draft.packNumber === 2) {
|
||||
nextSeatIndex = (seatIndex - 1 + draft.seats.length) % draft.seats.length;
|
||||
} else {
|
||||
nextSeatIndex = (seatIndex + 1) % draft.seats.length;
|
||||
}
|
||||
|
||||
const neighborId = draft.seats[nextSeatIndex];
|
||||
draft.players[neighborId].queue.push(passedPack);
|
||||
|
||||
// Try to assign active pack for neighbor if they are empty
|
||||
this.processQueue(draft, neighborId);
|
||||
} else {
|
||||
// Pack is empty/exhausted
|
||||
playerState.isWaiting = true;
|
||||
this.checkRoundCompletion(draft);
|
||||
}
|
||||
|
||||
// 4. Try to assign new active pack for self from queue
|
||||
this.processQueue(draft, playerId);
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
private processQueue(draft: DraftState, playerId: string) {
|
||||
const p = draft.players[playerId];
|
||||
if (!p.activePack && p.queue.length > 0) {
|
||||
p.activePack = p.queue.shift()!;
|
||||
}
|
||||
}
|
||||
|
||||
private checkRoundCompletion(draft: DraftState) {
|
||||
const allWaiting = Object.values(draft.players).every(p => p.isWaiting);
|
||||
if (allWaiting) {
|
||||
// Start Next Round
|
||||
if (draft.packNumber < 3) {
|
||||
draft.packNumber++;
|
||||
// Open next pack for everyone
|
||||
Object.values(draft.players).forEach(p => {
|
||||
p.isWaiting = false;
|
||||
const nextPack = p.unopenedPacks.shift();
|
||||
if (nextPack) {
|
||||
p.activePack = nextPack;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Draft Complete
|
||||
draft.status = 'deck_building';
|
||||
draft.startTime = Date.now(); // Start deck building timer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/server/managers/GameManager.ts
Normal file
165
src/server/managers/GameManager.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
|
||||
interface CardInstance {
|
||||
instanceId: string;
|
||||
oracleId: string; // Scryfall ID
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
controllerId: string;
|
||||
ownerId: string;
|
||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command';
|
||||
tapped: boolean;
|
||||
faceDown: boolean;
|
||||
position: { x: number; y: number; z: number }; // For freeform placement
|
||||
counters: { type: string; count: number }[];
|
||||
ptModification: { power: number; toughness: number };
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
id: string;
|
||||
name: string;
|
||||
life: number;
|
||||
poison: number;
|
||||
energy: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
roomId: string;
|
||||
players: Record<string, PlayerState>;
|
||||
cards: Record<string, CardInstance>; // Keyed by instanceId
|
||||
order: string[]; // Turn order (player IDs)
|
||||
turn: number;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export class GameManager {
|
||||
private games: Map<string, GameState> = new Map();
|
||||
|
||||
createGame(roomId: string, players: { id: string; name: string }[]): GameState {
|
||||
const gameState: GameState = {
|
||||
roomId,
|
||||
players: {},
|
||||
cards: {},
|
||||
order: players.map(p => p.id),
|
||||
turn: 1,
|
||||
phase: 'beginning',
|
||||
};
|
||||
|
||||
players.forEach(p => {
|
||||
gameState.players[p.id] = {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
life: 20,
|
||||
poison: 0,
|
||||
energy: 0,
|
||||
isActive: false
|
||||
};
|
||||
});
|
||||
|
||||
// Set first player active
|
||||
if (gameState.order.length > 0) {
|
||||
gameState.players[gameState.order[0]].isActive = true;
|
||||
}
|
||||
|
||||
// TODO: Load decks here. For now, we start with empty board/library.
|
||||
|
||||
this.games.set(roomId, gameState);
|
||||
return gameState;
|
||||
}
|
||||
|
||||
getGame(roomId: string): GameState | undefined {
|
||||
return this.games.get(roomId);
|
||||
}
|
||||
|
||||
// Generic action handler for sandbox mode
|
||||
handleAction(roomId: string, action: any): GameState | null {
|
||||
const game = this.games.get(roomId);
|
||||
if (!game) return null;
|
||||
|
||||
switch (action.type) {
|
||||
case 'MOVE_CARD':
|
||||
this.moveCard(game, action);
|
||||
break;
|
||||
case 'TAP_CARD':
|
||||
this.tapCard(game, action);
|
||||
break;
|
||||
case 'UPDATE_LIFE':
|
||||
this.updateLife(game, action);
|
||||
break;
|
||||
case 'DRAW_CARD':
|
||||
this.drawCard(game, action);
|
||||
break;
|
||||
case 'SHUFFLE_LIBRARY':
|
||||
this.shuffleLibrary(game, action); // Placeholder logic
|
||||
break;
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card) {
|
||||
card.zone = action.toZone;
|
||||
if (action.position) {
|
||||
card.position = { ...card.position, ...action.position };
|
||||
}
|
||||
// Reset tapped state if moving to hand/library/graveyard?
|
||||
if (['hand', 'library', 'graveyard', 'exile'].includes(action.toZone)) {
|
||||
card.tapped = false;
|
||||
card.faceDown = action.toZone === 'library';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tapCard(game: GameState, action: { cardId: string }) {
|
||||
const card = game.cards[action.cardId];
|
||||
if (card) {
|
||||
card.tapped = !card.tapped;
|
||||
}
|
||||
}
|
||||
|
||||
private updateLife(game: GameState, action: { playerId: string; amount: number }) {
|
||||
const player = game.players[action.playerId];
|
||||
if (player) {
|
||||
player.life += action.amount;
|
||||
}
|
||||
}
|
||||
|
||||
private drawCard(game: GameState, action: { playerId: string }) {
|
||||
// Find top card of library for this player
|
||||
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
|
||||
if (libraryCards.length > 0) {
|
||||
// In a real implementation this should be ordered.
|
||||
// For now, just pick one (random or first).
|
||||
const card = libraryCards[0];
|
||||
card.zone = 'hand';
|
||||
card.faceDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
private shuffleLibrary(game: GameState, action: { playerId: string }) {
|
||||
// In a real implementation we would shuffle the order array.
|
||||
// Since we retrieve by filtering currently, we don't have order.
|
||||
// We need to implement order index if we want shuffling.
|
||||
}
|
||||
|
||||
// Helper to add cards (e.g. at game start)
|
||||
addCardToGame(roomId: string, cardData: Partial<CardInstance>) {
|
||||
const game = this.games.get(roomId);
|
||||
if (!game) return;
|
||||
|
||||
// @ts-ignore
|
||||
const card: CardInstance = {
|
||||
instanceId: cardData.instanceId || Math.random().toString(36).substring(7),
|
||||
zone: 'library',
|
||||
tapped: false,
|
||||
faceDown: true,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
counters: [],
|
||||
ptModification: { power: 0, toughness: 0 },
|
||||
...cardData
|
||||
};
|
||||
game.cards[card.instanceId] = card;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ interface Room {
|
||||
hostId: string;
|
||||
players: Player[];
|
||||
packs: any[]; // Store generated packs (JSON)
|
||||
status: 'waiting' | 'drafting' | 'finished';
|
||||
status: 'waiting' | 'drafting' | 'deck_building' | 'finished';
|
||||
messages: ChatMessage[];
|
||||
maxPlayers: number;
|
||||
}
|
||||
|
||||
70
src/server/services/CardService.ts
Normal file
70
src/server/services/CardService.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const CARDS_DIR = path.join(__dirname, '../public/cards');
|
||||
|
||||
export class CardService {
|
||||
constructor() {
|
||||
if (!fs.existsSync(CARDS_DIR)) {
|
||||
fs.mkdirSync(CARDS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async cacheImages(cards: any[]): Promise<number> {
|
||||
let downloadedCount = 0;
|
||||
|
||||
// Use a concurrency limit to avoid creating too many connections
|
||||
const CONCURRENCY_LIMIT = 5;
|
||||
const queue = [...cards];
|
||||
|
||||
const downloadWorker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const card = queue.shift();
|
||||
if (!card) break;
|
||||
|
||||
// Determine UUID and URL
|
||||
const uuid = card.id || card.oracle_id; // Prefer ID
|
||||
if (!uuid) continue;
|
||||
|
||||
// Check for normal image
|
||||
let imageUrl = card.image_uris?.normal;
|
||||
if (!imageUrl && card.card_faces && card.card_faces.length > 0) {
|
||||
imageUrl = card.card_faces[0].image_uris?.normal;
|
||||
}
|
||||
|
||||
if (!imageUrl) continue;
|
||||
|
||||
const filePath = path.join(CARDS_DIR, `${uuid}.jpg`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
// Already cached
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Download
|
||||
const response = await fetch(imageUrl);
|
||||
if (response.ok) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
fs.writeFileSync(filePath, Buffer.from(buffer));
|
||||
downloadedCount++;
|
||||
console.log(`Cached image: ${uuid}.jpg`);
|
||||
} else {
|
||||
console.error(`Failed to download ${imageUrl}: ${response.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error downloading image for ${uuid}:`, err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array(CONCURRENCY_LIMIT).fill(null).map(() => downloadWorker());
|
||||
await Promise.all(workers);
|
||||
|
||||
return downloadedCount;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user