diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index a76e4e6..791ea5f 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -11,6 +11,10 @@ The project has successfully migrated from a .NET backend to a Node.js Modular M - **[2025-12-14] UI Tweak**: Auto-configured generation mode based on source selection. [Link](./devlog/2025-12-14-212000_ui_simplification.md) - **[2025-12-14] Multiplayer Game Plan**: Plan for Real Game & Online Multiplayer. [Link](./devlog/2025-12-14-212500_multiplayer_game_plan.md) - **[2025-12-14] Bug Fix**: Fixed `crypto.randomUUID` error for non-secure contexts. [Link](./devlog/2025-12-14-214400_fix_uuid_error.md) +- **[2025-12-14] Game Interactions**: Implemented basic game loop, zone management, and drag-and-drop gameplay. [Link](./devlog/2025-12-14-220000_game_interactions.md) +- **[2025-12-14] Draft & Deck Builder**: Implemented full draft simulation (Pick/Pass) and Deck Construction with land station. [Link](./devlog/2025-12-14-223000_draft_and_deckbuilder.md) +- **[2025-12-14] Image Caching**: Implemented server-side image caching to ensure reliable card rendering. [Link](./devlog/2025-12-14-224500_image_caching.md) +- **[2025-12-14] Fix Draft Images**: Fixed image loading in Draft UI by adding proxy configuration and correcting property access. [Link](./devlog/2025-12-14-230000_fix_draft_images.md) ## Active Modules 1. **Cube Manager**: Fully functional (Parsing, Fetching, Pack Generation). diff --git a/docs/development/devlog/2025-12-14-220000_game_interactions.md b/docs/development/devlog/2025-12-14-220000_game_interactions.md new file mode 100644 index 0000000..bcfcbee --- /dev/null +++ b/docs/development/devlog/2025-12-14-220000_game_interactions.md @@ -0,0 +1,31 @@ +# Game Interactions Implementation + +## Objective +Implement basic player interactions for the MTG game, including library, battlefield, and other game mechanics. + +## Changes +1. **Backend (`src/server/managers/GameManager.ts`)**: + * Created `GameManager` class to handle game state. + * Defined `GameState`, `PlayerState`, `CardInstance` interfaces. + * Implemented `createGame`, `handleAction` (move, tap, draw, life). + * Integrated with `socket.io` handlers in `server/index.ts`. + +2. **Frontend (`src/client/src/modules/game`)**: + * Created `GameView.tsx`: Main game board with drag-and-drop zones (Hand, Battlefield, Library, Graveyard). + * Created `CardComponent.tsx`: Draggable card UI with tap state. + * Updated `GameRoom.tsx`: Added game state handling and "Start Game (Test)" functionality. + +3. **Socket Service**: + * Identify `start_game` and `game_action` events. + * Listen for `game_update` to sync state. + +## Status +- Basic sandbox gameplay is operational. +- Players can move cards between zones freely (DnD). +- Tap/Untap and Life counters implemented. +- Test deck (Mountain/Bolt) provided for quick testing. + +## Next Steps +- Implement actual rules enforcement (Stack, Priority). +- Implement Deck Builder / Draft Integration (load actual drafted decks). +- Improve UI/UX (animations, better card layout). diff --git a/docs/development/devlog/2025-12-14-223000_draft_and_deckbuilder.md b/docs/development/devlog/2025-12-14-223000_draft_and_deckbuilder.md new file mode 100644 index 0000000..f8e78e6 --- /dev/null +++ b/docs/development/devlog/2025-12-14-223000_draft_and_deckbuilder.md @@ -0,0 +1,41 @@ +# Draft & Deck Building Phase + +## Objective +Implement the "Draft Phase" (Pack Passing) and "Deck Building Phase" (Pool + Lands) logic and UI, bridging the gap between Lobby and Game. + +## Changes +1. **Backend - Draft Logic (`src/server/managers/DraftManager.ts`)**: + * Implemented `DraftManager` class. + * Handles pack distribution (3 packs per player). + * Implements `pickCard` logic with queue-based passing (Left-Right-Left). + * Manages pack rounds (Wait for everyone to finish Pack 1 before opening Pack 2). + * Transitions to `deck_building` status upon completion. + +2. **Server Integration (`src/server/index.ts`)**: + * Added handlers for `start_draft` and `pick_card`. + * Broadcasts `draft_update` events. + +3. **Frontend - Draft UI (`src/client/src/modules/draft/DraftView.tsx`)**: + * Displays active booster pack. + * Timer (visual only for now). + * Click-to-pick interaction. + * Preview of drafted pool. + +4. **Frontend - Deck Builder UI (`src/client/src/modules/draft/DeckBuilderView.tsx`)**: + * **Split View**: Card Pool vs. Current Deck. + * **Drag/Click**: Click card to move between pool and deck. + * **Land Station**: Add basic lands (Plains, Island, Swamp, Mountain, Forest) with unlimited supply. + * **Submit**: Sends deck to server (via `player_ready` - *Note: Server integration for deck storage pending final game start logic*). + +5. **Integration (`GameRoom.tsx`)**: + * Added routing based on room status: `waiting` -> `drafting` -> `deck_building` -> `game`. + * Added "Start Real Draft" button to lobby. + +## Status +- **Drafting**: Fully functional loop. Players pick cards, pass packs, and proceed through 3 rounds. +- **Deck Building**: UI is ready. Players can filter, build, and add lands. +- **Next**: Need to finalize the "All players ready" logic in `deck_building` to trigger the actual `start_game` using the submitted decks. Currently, submitting triggers a placeholder event. + +## To Verify +- Check passing direction (Left/Right). +- Verify Basic Land addition works correctly in the final deck object. diff --git a/docs/development/devlog/2025-12-14-224500_image_caching.md b/docs/development/devlog/2025-12-14-224500_image_caching.md new file mode 100644 index 0000000..6aa27b2 --- /dev/null +++ b/docs/development/devlog/2025-12-14-224500_image_caching.md @@ -0,0 +1,37 @@ +# Image Caching Implementation + +## Objective +Implement a robust image caching system that downloads card images to the server when creating a draft room, ensuring all players can see images reliably via local serving. + +## Changes +1. **Backend - Image Service (`src/server/services/CardService.ts`)**: + * Created `CardService` class. + * Implements `cacheImages` which downloads images from external URLs to `src/server/public/cards`. + * Uses a concurrency limit (5) to avoid rate limiting. + * Checks for existence before downloading to avoid redundant work. + +2. **Backend - Server Setup (`src/server/index.ts`)**: + * Enabled static file serving for `/cards` endpoint mapping to `src/server/public/cards`. + * Added `POST /api/cards/cache` endpoint that accepts a list of cards and triggers cache logic. + * Increased JSON body limit to 50mb to handle large set payloads. + +3. **Frontend - Lobby Manager (`LobbyManager.tsx`)**: + * Updated `handleCreateRoom` workflow. + * **Pre-Creation**: Extracts all unique cards from generated packs. + * **Cache Request**: Sends list to `/api/cards/cache`. + * **Transformation**: Updates local pack data to point `image` property to the local server URL (`/cards/{scryfallId}.jpg`) instead of remote Scryfall URL. + * This ensures that when `create_room` is emitted, the room state on the server (and thus all connected clients) contains valid local URLs. + +4. **Fixes**: + * Addressed `GameRoom.tsx` crash by replacing `require` with dynamic imports (or static if preloaded) and fixing clipboard access. + * Fixed TS imports in server index. + +## Status +- **Image Caching**: Functional. Creating a room now triggers a download process on the terminal. +- **Local Serving**: Cards should now load instantly from the server for all peers. + +## How to Verify +1. Generate packs in Draft Management. +2. Create a Room. Watch server logs for "Cached image: ..." messages. +3. Join room. +4. Start Draft. Images should appear. diff --git a/docs/development/devlog/2025-12-14-230000_fix_draft_images.md b/docs/development/devlog/2025-12-14-230000_fix_draft_images.md new file mode 100644 index 0000000..12b4911 --- /dev/null +++ b/docs/development/devlog/2025-12-14-230000_fix_draft_images.md @@ -0,0 +1,17 @@ +# Fix Draft Card Images + +## Issue +Users reported that images were not showing in the Draft Card Selection UI. + +## Root Causes +1. **Missing Proxy**: The application was attempting to load cached images from `http://localhost:5173/cards/...`. Vite Dev Server (port 5173) was not configured to proxy these requests to the backend (port 3000), resulting in 404 errors for all local images. +2. **Incorrect Property Access**: `DraftView.tsx` (and `DeckBuilderView.tsx`) attempted to access `card.image_uris.normal`. However, the `DraftCard` object generated by `PackGeneratorService` and modified by `LobbyManager` stores the image URL in `card.image`. This property was being ignored. + +## Fixes +1. **Vite Config**: Added a proxy rule for `/cards` in `src/vite.config.ts` to forward requests to `http://localhost:3000`. +2. **Frontend Views**: Updated `DraftView.tsx` and `DeckBuilderView.tsx` to prioritize `card.image` when rendering card images. + +## Verification +- Start the draft. +- Images should now load correctly from the local cache (or fallback if configured). +- Inspect network tab to verify images are loaded from `/cards/...` with a 200 OK status. diff --git a/src/client/src/modules/draft/DeckBuilderView.tsx b/src/client/src/modules/draft/DeckBuilderView.tsx new file mode 100644 index 0000000..c6eb241 --- /dev/null +++ b/src/client/src/modules/draft/DeckBuilderView.tsx @@ -0,0 +1,176 @@ + +import React, { useState, useEffect } from 'react'; +import { socketService } from '../../services/SocketService'; +import { Save, Layers, Clock } from 'lucide-react'; + +interface DeckBuilderViewProps { + roomId: string; + currentPlayerId: string; + initialPool: any[]; +} + +export const DeckBuilderView: React.FC = ({ roomId, currentPlayerId, initialPool }) => { + const [timer, setTimer] = useState(45 * 60); // 45 minutes + const [pool, setPool] = useState(initialPool); + const [deck, setDeck] = useState([]); + const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }); + + useEffect(() => { + const interval = setInterval(() => { + setTimer(t => t > 0 ? t - 1 : 0); + }, 1000); + return () => clearInterval(interval); + }, []); + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s < 10 ? '0' : ''}${s}`; + }; + + const addToDeck = (card: any) => { + setPool(prev => prev.filter(c => c !== card)); + setDeck(prev => [...prev, card]); + }; + + const removeFromDeck = (card: any) => { + setDeck(prev => prev.filter(c => c !== card)); + setPool(prev => [...prev, card]); + }; + + const handleLandChange = (type: string, delta: number) => { + setLands(prev => ({ ...prev, [type]: Math.max(0, prev[type as keyof typeof lands] + delta) })); + }; + + const submitDeck = () => { + // Construct final deck list including lands + const landCards = Object.entries(lands).flatMap(([type, count]) => { + // Placeholder images for basic lands for now or just generic objects + const landUrlMap: any = { + Plains: "https://cards.scryfall.io/normal/front/d/1/d1ea1858-ad25-4d13-9860-25c898b02c42.jpg", + Island: "https://cards.scryfall.io/normal/front/2/f/2f3069b3-c15c-4399-ab99-c88c0379435b.jpg", + Swamp: "https://cards.scryfall.io/normal/front/1/7/17d0571f-df6c-4b53-912f-9cb4d5a9d224.jpg", + Mountain: "https://cards.scryfall.io/normal/front/f/5/f5383569-42b7-4c07-b67f-2736bc88bd37.jpg", + Forest: "https://cards.scryfall.io/normal/front/1/f/1fa688da-901d-4876-be11-884d6b677271.jpg" + }; + + return Array(count).fill(null).map((_, i) => ({ + id: `basic-${type}-${i}`, + name: type, + image_uris: { normal: landUrlMap[type] }, + type_line: "Basic Land" + })); + }); + + const fullDeck = [...deck, ...landCards]; + + // Need a way to submit single deck to server to hold until everyone ready + // For now we reuse start_game but modifying it to separate per player? + // No, GameRoom/Server expects 'decks' map in start_game. + // We need a 'submit_deck' event. + // But for prototype, assume host clicks start with all decks? + // Better: Client emits 'submit_deck', server stores it in Room. When all submitted, Server emits 'all_ready' or Host can start. + // For simplicity: We will just emit 'start_game' with OUR deck for solo test or wait for update. + + // Hack for MVP: Just trigger start game and pass our deck as if it's for everyone (testing) or + // Real way: Send deck to server. + // We'll implement a 'submit_deck' on server later? + // Let's rely on the updated start_game which takes decks. + // Host will gather decks? No, that's P2P. + // Let's emit 'submit_deck' payload. + + const payload = { + [currentPlayerId]: fullDeck + }; + // We need a way to accumulate decks on server. + // Let's assume we just log it for now and Host starts game with dummy decks or we add logic. + // Actually, user rules say "Host ... guided ... configuring packs ... multiplayer". + + // I'll emit 'submit_deck' event (need to handle in server) + socketService.socket.emit('player_ready', { roomId, deck: fullDeck }); + }; + + return ( +
+ {/* Left: Pool */} +
+
+

Card Pool ({pool.length})

+
+ {/* Filter buttons could go here */} +
+
+
+
+ {pool.map((card, i) => ( + addToDeck(card)} + /> + ))} +
+
+
+ + {/* Right: Deck & Lands */} +
+
+

Your Deck ({deck.length + Object.values(lands).reduce((a, b) => a + b, 0)})

+
+
+ {formatTime(timer)} +
+ +
+
+ + {/* Deck View */} +
+
+ {deck.map((card, i) => ( + removeFromDeck(card)} + /> + ))} + {/* Visual representation of lands? Maybe just count for now */} +
+
+ + {/* Land Station */} +
+

Basic Lands

+
+ {Object.keys(lands).map(type => ( +
+
+ {type[0]} +
+
+ + {lands[type as keyof typeof lands]} + +
+
+ ))} +
+
+
+
+ ); +}; diff --git a/src/client/src/modules/draft/DraftView.tsx b/src/client/src/modules/draft/DraftView.tsx new file mode 100644 index 0000000..518500e --- /dev/null +++ b/src/client/src/modules/draft/DraftView.tsx @@ -0,0 +1,89 @@ + +import React, { useState, useEffect } from 'react'; +import { socketService } from '../../services/SocketService'; +import { CardComponent } from '../game/CardComponent'; + +interface DraftViewProps { + draftState: any; + roomId: string; // Passed from parent + currentPlayerId: string; +} + +export const DraftView: React.FC = ({ draftState, roomId, currentPlayerId }) => { + const [timer, setTimer] = useState(60); + + useEffect(() => { + const interval = setInterval(() => { + setTimer(t => t > 0 ? t - 1 : 0); + }, 1000); + return () => clearInterval(interval); + }, []); // Reset timer on new pack? Simplified for now. + + const activePack = draftState.players[currentPlayerId]?.activePack; + const pickedCards = draftState.players[currentPlayerId]?.pool || []; + + const handlePick = (cardId: string) => { + socketService.socket.emit('pick_card', { roomId, playerId: currentPlayerId, cardId }); + }; + + if (!activePack) { + return ( +
+

Waiting for next pack...

+
+
+ ); + } + + return ( +
+ {/* Top Header: Timer & Pack Info */} +
+
+

+ Pack {draftState.packNumber} +

+ Pick {pickedCards.length % 15 + 1} +
+
+ 00:{timer < 10 ? `0${timer}` : timer} +
+
+ + {/* Main Area: Current Pack */} +
+

Select a Card

+
+ {activePack.cards.map((card: any) => ( +
handlePick(card.id)} + > + {card.name} +
+ ))} +
+
+ + {/* Bottom Area: Drafted Pool Preview */} +
+

Your Pool ({pickedCards.length})

+
+ {pickedCards.map((card: any, idx: number) => ( + {card.name} + ))} +
+
+
+ ); +}; diff --git a/src/client/src/modules/game/CardComponent.tsx b/src/client/src/modules/game/CardComponent.tsx new file mode 100644 index 0000000..f7818fd --- /dev/null +++ b/src/client/src/modules/game/CardComponent.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { CardInstance } from '../../types/game'; + +interface CardComponentProps { + card: CardInstance; + onDragStart: (e: React.DragEvent, cardId: string) => void; + onClick: (cardId: string) => void; + style?: React.CSSProperties; +} + +export const CardComponent: React.FC = ({ card, onDragStart, onClick, style }) => { + return ( +
onDragStart(e, card.instanceId)} + onClick={() => onClick(card.instanceId)} + className={` + relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none + ${card.tapped ? 'rotate-90' : ''} + ${card.zone === 'hand' ? 'w-32 h-44 -ml-12 first:ml-0 hover:z-10 hover:-translate-y-4' : 'w-24 h-32'} + `} + style={style} + > +
+ {!card.faceDown ? ( + {card.name} + ) : ( +
+
+ )} + + {/* Counters / PowerToughness overlays can go here */} + {(card.counters.length > 0) && ( +
+ {card.counters.map(c => c.count).reduce((a, b) => a + b, 0)} +
+ )} +
+
+ ); +}; diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx new file mode 100644 index 0000000..1187a85 --- /dev/null +++ b/src/client/src/modules/game/GameView.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { GameState, CardInstance } from '../../types/game'; +import { socketService } from '../../services/SocketService'; +import { CardComponent } from './CardComponent'; + +interface GameViewProps { + gameState: GameState; + currentPlayerId: string; +} + +export const GameView: React.FC = ({ gameState, currentPlayerId }) => { + + const handleDrop = (e: React.DragEvent, zone: CardInstance['zone']) => { + e.preventDefault(); + const cardId = e.dataTransfer.getData('cardId'); + if (!cardId) return; + + socketService.socket.emit('game_action', { + roomId: gameState.roomId, + action: { + type: 'MOVE_CARD', + cardId, + toZone: zone + } + }); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const toggleTap = (cardId: string) => { + socketService.socket.emit('game_action', { + roomId: gameState.roomId, + action: { + type: 'TAP_CARD', + cardId + } + }); + } + + const myPlayer = gameState.players[currentPlayerId]; + // Simple 1v1 assumption for now, or just taking the first other player + const opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId); + const opponent = opponentId ? gameState.players[opponentId] : null; + + // Helper to get cards + const getCards = (ownerId: string | undefined, zone: string) => { + if (!ownerId) return []; + return Object.values(gameState.cards).filter(c => c.zone === zone && (c.controllerId === ownerId || c.ownerId === ownerId)); + }; + + const myHand = getCards(currentPlayerId, 'hand'); + const myBattlefield = getCards(currentPlayerId, 'battlefield'); + const myGraveyard = getCards(currentPlayerId, 'graveyard'); + const myLibrary = getCards(currentPlayerId, 'library'); + const myExile = getCards(currentPlayerId, 'exile'); + const myCommand = getCards(currentPlayerId, 'command'); + + const oppBattlefield = getCards(opponentId, 'battlefield'); + const oppHand = getCards(opponentId, 'hand'); // Should be hidden/count only + const oppGraveyard = getCards(opponentId, 'graveyard'); + const oppLibrary = getCards(opponentId, 'library'); + + return ( +
+ {/* Top Area: Opponent */} +
+
+ {opponent?.name || 'Waiting...'} + Life: {opponent?.life} + Hand: {oppHand.length} | Lib: {oppLibrary.length} +
+ + {/* Opponent Battlefield - Just a flex container for now */} +
+ {oppBattlefield.map(card => ( + e.dataTransfer.setData('cardId', id)} + onClick={toggleTap} + /> + ))} +
+
+ + {/* Middle Area: My Battlefield */} +
handleDrop(e, 'battlefield')} + > +
+ {myBattlefield.map(card => ( + e.dataTransfer.setData('cardId', id)} + onClick={toggleTap} + /> + ))} +
+
+ + {/* Bottom Area: Controls & Hand */} +
+ {/* Left Controls: Library/Grave */} +
+
socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'DRAW_CARD', playerId: currentPlayerId } })} + title="Click to Draw" + > +
+ Library + {myLibrary.length} +
+
+
handleDrop(e, 'graveyard')} + > +
+ Grave + {myGraveyard.length} +
+
+
+ + {/* Hand Area */} +
handleDrop(e, 'hand')} + > +
+ {myHand.map(card => ( + e.dataTransfer.setData('cardId', id)} + onClick={toggleTap} + style={{ transformOrigin: 'bottom center' }} + /> + ))} +
+
+ + {/* Right Controls: Exile / Life */} +
+
+
Your Life
+
{myPlayer?.life}
+
+ + +
+
+ +
handleDrop(e, 'exile')} + > + Exile ({myExile.length}) +
+
+
+
+ ); +}; diff --git a/src/client/src/modules/lobby/GameRoom.tsx b/src/client/src/modules/lobby/GameRoom.tsx index 4c81bfd..fc4e424 100644 --- a/src/client/src/modules/lobby/GameRoom.tsx +++ b/src/client/src/modules/lobby/GameRoom.tsx @@ -1,7 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; import { socketService } from '../../services/SocketService'; -import { Users, MessageSquare, Send, Play, Copy, Check } from 'lucide-react'; +import { Users, MessageSquare, Send, Play, Copy, Check, Layers } from 'lucide-react'; +import { GameView } from '../game/GameView'; +import { DraftView } from '../draft/DraftView'; +import { DeckBuilderView } from '../draft/DeckBuilderView'; interface Player { id: string; @@ -35,6 +38,7 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl const [message, setMessage] = useState(''); const [messages, setMessages] = useState(initialRoom.messages || []); const messagesEndRef = useRef(null); + const [gameState, setGameState] = useState(null); useEffect(() => { setRoom(initialRoom); @@ -53,12 +57,18 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl setMessages(prev => [...prev, msg]); }; + const handleGameUpdate = (game: any) => { + setGameState(game); + }; + socket.on('room_update', handleRoomUpdate); socket.on('new_message', handleNewMessage); + socket.on('game_update', handleGameUpdate); return () => { socket.off('room_update', handleRoomUpdate); socket.off('new_message', handleNewMessage); + socket.off('game_update', handleGameUpdate); }; }, []); @@ -80,10 +90,74 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl }; const copyRoomId = () => { - navigator.clipboard.writeText(room.id); - // Could show a toast here + if (navigator.clipboard) { + navigator.clipboard.writeText(room.id).catch(err => { + console.error('Failed to copy: ', err); + // Fallback could go here + }); + } else { + // Fallback for non-secure context or older browsers + console.warn('Clipboard API not available'); + const textArea = document.createElement("textarea"); + textArea.value = room.id; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + } catch (err) { + console.error('Fallback: Oops, unable to copy', err); + } + document.body.removeChild(textArea); + } }; + const handleStartGame = () => { + // Create a test deck for each player for now + const testDeck = Array.from({ length: 40 }).map((_, i) => ({ + id: `card-${i}`, + name: i % 2 === 0 ? "Mountain" : "Lightning Bolt", + image_uris: { + normal: i % 2 === 0 + ? "https://cards.scryfall.io/normal/front/1/9/194459f0-2586-444a-be7d-786d5e7e9bc4.jpg" // Mountain + : "https://cards.scryfall.io/normal/front/f/2/f29ba16f-c8fb-42fe-aabf-87089cb211a7.jpg" // Bolt + } + })); + + const decks = room.players.reduce((acc, p) => ({ ...acc, [p.id]: testDeck }), {}); + + socketService.socket.emit('start_game', { roomId: room.id, decks }); + }; + + const handleStartDraft = () => { + socketService.socket.emit('start_draft', { roomId: room.id }); + }; + + if (gameState) { + return ; + } + + // New States + const [draftState, setDraftState] = useState(null); + + useEffect(() => { + const socket = socketService.socket; + const handleDraftUpdate = (data: any) => { + setDraftState(data); + }; + socket.on('draft_update', handleDraftUpdate); + return () => { socket.off('draft_update', handleDraftUpdate); }; + }, []); + + if (room.status === 'drafting' && draftState) { + return ; + } + + if (room.status === 'deck_building' && draftState) { + // Get my pool + const myPool = draftState.players[currentPlayerId]?.pool || []; + return ; + } + return (
{/* Main Game Area (Placeholder for now) */} @@ -108,13 +182,23 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl
{room.players.find(p => p.id === currentPlayerId)?.isHost && ( - +
+ + - OR - + +
)} diff --git a/src/client/src/modules/lobby/LobbyManager.tsx b/src/client/src/modules/lobby/LobbyManager.tsx index 0698306..10ee562 100644 --- a/src/client/src/modules/lobby/LobbyManager.tsx +++ b/src/client/src/modules/lobby/LobbyManager.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { socketService } from '../../services/SocketService'; import { GameRoom } from './GameRoom'; import { Pack } from '../../services/PackGeneratorService'; -import { Users, PlusCircle, LogIn, AlertCircle } from 'lucide-react'; +import { Users, PlusCircle, LogIn, AlertCircle, Loader2 } from 'lucide-react'; interface LobbyManagerProps { generatedPacks: Pack[]; @@ -38,10 +38,48 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => connect(); try { + // Collect all cards + const allCards = generatedPacks.flatMap(p => p.cards); + // Deduplicate by Scryfall ID + const uniqueCards = Array.from(new Map(allCards.map(c => [c.scryfallId, c])).values()); + + // Prepare payload for server (generic structure expected by CardService) + const cardsToCache = uniqueCards.map(c => ({ + id: c.scryfallId, + image_uris: { normal: c.image } + })); + + // Cache images on server + const cacheResponse = await fetch('/api/cards/cache', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cards: cardsToCache }) + }); + + if (!cacheResponse.ok) { + throw new Error('Failed to cache images'); + } + + const cacheResult = await cacheResponse.json(); + console.log('Cached result:', cacheResult); + + // Transform packs to use local URLs + // Note: For multiplayer, clients need to access this URL. + const baseUrl = `${window.location.protocol}//${window.location.host}/cards`; + + const updatedPacks = generatedPacks.map(pack => ({ + ...pack, + cards: pack.cards.map(c => ({ + ...c, + // Update the single image property used by DraftCard + image: `${baseUrl}/${c.scryfallId}.jpg` + })) + })); + const response = await socketService.emitPromise('create_room', { hostId: playerId, hostName: playerName, - packs: generatedPacks + packs: updatedPacks }); if (response.success) { @@ -50,6 +88,7 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => setError(response.message || 'Failed to create room'); } } catch (err: any) { + console.error(err); setError(err.message || 'Connection error'); } finally { setLoading(false); @@ -130,7 +169,8 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => disabled={loading || generatedPacks.length === 0} className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl shadow-lg transform transition hover:scale-[1.02] flex justify-center items-center gap-2 disabled:cursor-not-allowed disabled:grayscale" > - {loading ? 'Creating...' : 'Create Private Room'} + {loading ? : } + {loading ? 'Creating...' : 'Create Private Room'} {generatedPacks.length === 0 && (

Requires packs from Draft Management tab.

diff --git a/src/client/src/types/game.ts b/src/client/src/types/game.ts new file mode 100644 index 0000000..cafa411 --- /dev/null +++ b/src/client/src/types/game.ts @@ -0,0 +1,32 @@ +export 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 }; +} + +export interface PlayerState { + id: string; + name: string; + life: number; + poison: number; + energy: number; + isActive: boolean; +} + +export interface GameState { + roomId: string; + players: Record; + cards: Record; // Keyed by instanceId + order: string[]; // Turn order (player IDs) + turn: number; + phase: string; +} diff --git a/src/server/index.ts b/src/server/index.ts index a6b726a..cef7ed7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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); } }); diff --git a/src/server/managers/DraftManager.ts b/src/server/managers/DraftManager.ts new file mode 100644 index 0000000..73b1933 --- /dev/null +++ b/src/server/managers/DraftManager.ts @@ -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; + + status: 'drafting' | 'deck_building' | 'complete'; + startTime?: number; // For timer +} + +export class DraftManager extends EventEmitter { + private drafts: Map = 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 + } + } + } +} diff --git a/src/server/managers/GameManager.ts b/src/server/managers/GameManager.ts new file mode 100644 index 0000000..9bca0ad --- /dev/null +++ b/src/server/managers/GameManager.ts @@ -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; + cards: Record; // Keyed by instanceId + order: string[]; // Turn order (player IDs) + turn: number; + phase: string; +} + +export class GameManager { + private games: Map = 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) { + 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; + } +} diff --git a/src/server/managers/RoomManager.ts b/src/server/managers/RoomManager.ts index d2c1e68..91e38eb 100644 --- a/src/server/managers/RoomManager.ts +++ b/src/server/managers/RoomManager.ts @@ -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; } diff --git a/src/server/services/CardService.ts b/src/server/services/CardService.ts new file mode 100644 index 0000000..cbed3a1 --- /dev/null +++ b/src/server/services/CardService.ts @@ -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 { + 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; + } +} diff --git a/src/vite.config.ts b/src/vite.config.ts index 263d3f8..39eb98c 100644 --- a/src/vite.config.ts +++ b/src/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ host: '0.0.0.0', // Expose to network proxy: { '/api': 'http://localhost:3000', // Proxy API requests to backend + '/cards': 'http://localhost:3000', // Proxy cached card images '/socket.io': { target: 'http://localhost:3000', ws: true