From e13aa16766e050e036d518899122b1cbc6a895b4 Mon Sep 17 00:00:00 2001 From: dnviti Date: Wed, 17 Dec 2025 16:15:20 +0100 Subject: [PATCH] feat: Implement deck builder magnified card view, land advice, basic land integration, and unlimited time for deck construction. --- docs/development/CENTRAL.md | 4 + .../2025-12-17-153300_basic_lands_handling.md | 28 ++ ...7-155500_land_advice_and_unlimited_time.md | 31 ++ ...2-17-160500_deck_builder_magnified_view.md | 27 ++ ...500_gameplay_magnified_view_and_timeout.md | 24 ++ src/client/src/App.tsx | 21 +- src/client/src/modules/cube/CubeManager.tsx | 17 +- .../src/modules/draft/DeckBuilderView.tsx | 315 +++++++++++---- src/client/src/modules/game/CardComponent.tsx | 6 +- src/client/src/modules/game/GameView.tsx | 381 ++++++++++-------- src/client/src/modules/lobby/GameRoom.tsx | 5 +- src/client/src/modules/lobby/LobbyManager.tsx | 22 +- src/client/src/types/game.ts | 3 + src/server/index.ts | 38 +- src/server/managers/DraftManager.ts | 3 +- src/server/managers/GameManager.ts | 3 + src/server/managers/RoomManager.ts | 4 +- src/server/services/PackGeneratorService.ts | 4 + 18 files changed, 672 insertions(+), 264 deletions(-) create mode 100644 docs/development/devlog/2025-12-17-153300_basic_lands_handling.md create mode 100644 docs/development/devlog/2025-12-17-155500_land_advice_and_unlimited_time.md create mode 100644 docs/development/devlog/2025-12-17-160500_deck_builder_magnified_view.md create mode 100644 docs/development/devlog/2025-12-17-161500_gameplay_magnified_view_and_timeout.md diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index 605769d..dc71dc4 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -78,3 +78,7 @@ - [Stack View Consistency Fix](./devlog/2025-12-17-143000_stack_view_consistency.md): Completed. Removed transparent overrides for Stack View, ensuring it renders with the standard unified container graphic. - [Dynamic Pack Grid Layout](./devlog/2025-12-17-144000_dynamic_pack_grid.md): Completed. Implemented responsive CSS grid with `minmax(550px, 1fr)` for Stack/Grid views to auto-fit packs based on screen width without explicit column limits. - [Fix Socket Payload Limit](./devlog/2025-12-17-152700_fix_socket_payload_limit.md): Completed. Increased Socket.IO `maxHttpBufferSize` to 300MB to support massive drafting payloads. +- [Basic Lands Handling](./devlog/2025-12-17-153300_basic_lands_handling.md): Completed. Implemented flow to cache and provide set-specific basic lands for infinite use during deck building. +- [Land Advice & Unlimited Time](./devlog/2025-12-17-155500_land_advice_and_unlimited_time.md): Completed. Implemented land suggestion algorithm and disabled deck builder timer. +- [Deck Builder Magnified View](./devlog/2025-12-17-160500_deck_builder_magnified_view.md): Completed. Added magnified card preview sidebar to deck builder. +- [Gameplay Magnified View & Timeout](./devlog/2025-12-17-161500_gameplay_magnified_view_and_timeout.md): Completed. Added magnified view with full card details (Oracle text, type, mana) to gameplay and disabled timeout. diff --git a/docs/development/devlog/2025-12-17-153300_basic_lands_handling.md b/docs/development/devlog/2025-12-17-153300_basic_lands_handling.md new file mode 100644 index 0000000..94d3475 --- /dev/null +++ b/docs/development/devlog/2025-12-17-153300_basic_lands_handling.md @@ -0,0 +1,28 @@ +# Basic Lands Handling + +## Requirements +- Upon draft room creation, basic lands from the selected sets must be cached and loaded. +- During deck building, players must have access to an infinite number of these basic lands matching the selected sets. + +## Implementation Details + +### Server-Side +- **Pack Generation**: Updated `/api/packs/generate` to extract unique basic lands from the processed card pool and return them in the response: `{ packs, basicLands }`. +- **Room Management**: Updated `RoomManager` and `Room` interface to store `basicLands` array. +- **Socket Events**: Updated `create_room` event handler to accept `basicLands` and pass them to the room creation logic. + +### Client-Side +- **State Management**: Added `availableLands` state to `App.tsx` with local storage persistence to persist lands between generation and lobby creation. +- **Cube Manager**: Updated `handleGenerate` to parse the new API response and update specific state. +- **Lobby Manager**: + - Enhanced `handleCreateRoom` to include basic lands in the server-side image caching request. + - Updated `create_room` socket emission to send the basic lands to the server. +- **Deck Builder**: + - Added a "Land Station" UI component. + - If specific basic lands are available, it displays a horizontal scrollable gallery of the unique land arts. + - Clicking a land adds a unique copy (with a specific ID) to the deck, allowing for infinite copies. + - Preserved fallback to generic land counters if no specific lands are available. + +## Verification +- Verified flow from pack generation -> lobby -> room -> deck builder. +- Validated that lands are deduplicated by Scryfall ID to ensure unique arts are offered. diff --git a/docs/development/devlog/2025-12-17-155500_land_advice_and_unlimited_time.md b/docs/development/devlog/2025-12-17-155500_land_advice_and_unlimited_time.md new file mode 100644 index 0000000..5f5f292 --- /dev/null +++ b/docs/development/devlog/2025-12-17-155500_land_advice_and_unlimited_time.md @@ -0,0 +1,31 @@ +# Land Advice and Unlimited Deck Building + +## Requirements +1. **Unlimited Timer**: The deck building phase should have an unlimited duration for now. +2. **Land Advice Algorithm**: + - Suggest a mana base aiming for a total of 17 lands (including those already picked in the deck). + - Calculate the number of basic lands needed based on the color distribution (mana symbols) of the non-land cards in the deck. + - Provide a UI to view and apply these suggestions. + +## Implementation Details + +### `DeckBuilderView.tsx` + +- **Timer disabled**: `useState("Unlimited")` is used effectively to remove the countdown mechanism. +- **Land Suggestion Algorithm**: + - Target total lands: 17. + - `existingLands` calculated from cards in `deck` with type `Land`. + - `landsNeeded` = `max(0, 17 - existingLands)`. + - Scans `mana_cost` of non-land cards to build a frequency map of colored mana symbols (`{W}`, `{U}`, etc.). + - Distributes `landsNeeded` proportionally to the symbol counts. + - Handles remainders by allocating them to the colors with the highest symbol counts. + - Returns `null` if no colored symbols or `landsNeeded` is 0. +- **UI**: + - A panel "Land Advisor (Target: 17)" is added above the Land Station. + - Displays the calculated basic land distribution (e.g., "P: 3, I: 2"). + - "Auto-Fill" button applies these counts to the `lands` state directly. + +## Verification +- Verified manually that adding/removing cards updates the suggestion logic. +- "Unlimited" timer is displayed correctly. +- Lint errors resolved. diff --git a/docs/development/devlog/2025-12-17-160500_deck_builder_magnified_view.md b/docs/development/devlog/2025-12-17-160500_deck_builder_magnified_view.md new file mode 100644 index 0000000..f8da087 --- /dev/null +++ b/docs/development/devlog/2025-12-17-160500_deck_builder_magnified_view.md @@ -0,0 +1,27 @@ +# Deck Builder Magnified View + +## Requirements +- Add a magnified card view to the `DeckBuilderView`, similar to the one in `DraftView`. +- Show card details (name, type, oracle text) when hovering over any card in the pool, deck, or land station. +- Use a persistent sidebar layout for the magnified view. + +## Implementation Details + +### `DeckBuilderView.tsx` + +- **State**: Added `hoveredCard` state to track the card being inspected. +- **Layout**: + - Changed the layout to a 3-column flex design: + 1. **Zoom Sidebar** (`hidden xl:flex w-80`): Shows the magnified card image and text details. Defaults to a "Hover Card" placeholder. + 2. **Card Pool** (`flex-1`): Displays available cards. + 3. **Deck & Lands** (`flex-1`): Displays the current deck and land controls. +- **Interactions**: + - Added `onMouseEnter={() => setHoveredCard(card)}` and `onMouseLeave={() => setHoveredCard(null)}` handlers to: + - Cards in the Pool. + - Cards in the current Deck. + - Basic Lands in the Land Station. + +## Verification +- Verified that hovering over cards updates the sidebar image and text. +- Verified that moving mouse away clears the preview (consistent with Draft View). +- Layout adjusts responsively (sidebar hidden on smaller screens). diff --git a/docs/development/devlog/2025-12-17-161500_gameplay_magnified_view_and_timeout.md b/docs/development/devlog/2025-12-17-161500_gameplay_magnified_view_and_timeout.md new file mode 100644 index 0000000..7e007d6 --- /dev/null +++ b/docs/development/devlog/2025-12-17-161500_gameplay_magnified_view_and_timeout.md @@ -0,0 +1,24 @@ +# Gameplay Magnified View Details Update + +## Requirements +- Display detailed card information (Oracle Text, Type Line, Mana Cost) in the magnified view on the battlefield. + +## Implementation Details + +### Data Model Updates +- **`CardInstance` Interface**: Added optional fields `typeLine`, `oracleText`, and `manaCost` to both client (`src/client/src/types/game.ts`) and server (`src/server/managers/GameManager.ts`) definitions. +- **`DraftCard` Interface**: Added `oracleText` and `manaCost` to the server-side interface (`PackGeneratorService.ts`). + +### Logic Updates +- **`PackGeneratorService.ts`**: Updated `processCards` to map `oracle_text` and `mana_cost` from Scryfall data to `DraftCard`. +- **`src/server/index.ts`**: Updated all `addCardToGame` calls (timeout handling, player ready, solo test, start game) to pass the new fields from the draft card/deck source to the `CardInstance`. + +### UI Updates (`GameView.tsx`) +- Updated the **Zoom Sidebar** to conditionally render: + - **Mana Cost**: Displayed in a monospace font. + - **Type Line**: Displayed in emerald color with uppercase styling. + - **Oracle Text**: Displayed in a formatted block with proper whitespace handling. +- Replaced undefined `cardIsCreature` helper with an inline check. + +## Verification +- Hovering over a card in the game view now shows not just the image but also the text details, which is crucial for readability of complex cards. diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx index ea8d803..334f61f 100644 --- a/src/client/src/App.tsx +++ b/src/client/src/App.tsx @@ -23,6 +23,16 @@ export const App: React.FC = () => { } }); + const [availableLands, setAvailableLands] = useState(() => { + try { + const saved = localStorage.getItem('availableLands'); + return saved ? JSON.parse(saved) : []; + } catch (e) { + console.error("Failed to load lands from storage", e); + return []; + } + }); + React.useEffect(() => { localStorage.setItem('activeTab', activeTab); }, [activeTab]); @@ -35,6 +45,14 @@ export const App: React.FC = () => { } }, [generatedPacks]); + React.useEffect(() => { + try { + localStorage.setItem('availableLands', JSON.stringify(availableLands)); + } catch (e) { + console.error("Failed to save lands to storage", e); + } + }, [availableLands]); + return (
@@ -82,10 +100,11 @@ export const App: React.FC = () => { setActiveTab('lobby')} /> )} - {activeTab === 'lobby' && } + {activeTab === 'lobby' && } {activeTab === 'tester' && } {activeTab === 'bracket' && } diff --git a/src/client/src/modules/cube/CubeManager.tsx b/src/client/src/modules/cube/CubeManager.tsx index 5f71227..e38ec25 100644 --- a/src/client/src/modules/cube/CubeManager.tsx +++ b/src/client/src/modules/cube/CubeManager.tsx @@ -7,13 +7,15 @@ import { PackCard } from '../../components/PackCard'; interface CubeManagerProps { packs: Pack[]; setPacks: React.Dispatch>; + setAvailableLands: React.Dispatch>; onGoToLobby: () => void; } import { useToast } from '../../components/Toast'; -export const CubeManager: React.FC = ({ packs, setPacks, onGoToLobby }) => { +export const CubeManager: React.FC = ({ packs, setPacks, setAvailableLands, onGoToLobby }) => { const { showToast } = useToast(); + // --- Services --- // Memoize services to persist cache across renders const generatorService = React.useMemo(() => new PackGeneratorService(), []); @@ -251,12 +253,23 @@ export const CubeManager: React.FC = ({ packs, setPacks, onGoT throw new Error(err.error || "Generation failed"); } - const newPacks: Pack[] = await response.json(); + const data = await response.json(); + + let newPacks: Pack[] = []; + let newLands: any[] = []; + + if (Array.isArray(data)) { + newPacks = data; + } else { + newPacks = data.packs; + newLands = data.basicLands || []; + } if (newPacks.length === 0) { alert(`No packs generated. Check your card pool settings.`); } else { setPacks(newPacks); + setAvailableLands(newLands); } } catch (err: any) { console.error("Process failed", err); diff --git a/src/client/src/modules/draft/DeckBuilderView.tsx b/src/client/src/modules/draft/DeckBuilderView.tsx index 79d32e5..c4bc27e 100644 --- a/src/client/src/modules/draft/DeckBuilderView.tsx +++ b/src/client/src/modules/draft/DeckBuilderView.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { socketService } from '../../services/SocketService'; import { Save, Layers, Clock } from 'lucide-react'; @@ -7,35 +7,117 @@ interface DeckBuilderViewProps { roomId: string; currentPlayerId: string; initialPool: any[]; + availableBasicLands?: any[]; } -export const DeckBuilderView: React.FC = ({ initialPool }) => { - const [timer, setTimer] = useState(45 * 60); // 45 minutes +export const DeckBuilderView: React.FC = ({ initialPool, availableBasicLands = [] }) => { + // Unlimited Timer (Static for now) + const [timer] = useState("Unlimited"); const [pool, setPool] = useState(initialPool); const [deck, setDeck] = useState([]); const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }); + const [hoveredCard, setHoveredCard] = useState(null); + /* + // Disable timer countdown 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}`; + // --- Land Advice Logic --- + const landSuggestion = React.useMemo(() => { + const targetLands = 17; + // Count existing non-basic lands in deck + const existingLands = deck.filter(c => c.type_line && c.type_line.includes('Land')).length; + // We want to suggest basics to reach target + const landsNeeded = Math.max(0, targetLands - existingLands); + + if (landsNeeded === 0) return null; + + // Count pips in spell costs + const pips = { Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }; + let totalPips = 0; + + deck.forEach(card => { + if (card.type_line && card.type_line.includes('Land')) return; + if (!card.mana_cost) return; + + const cost = card.mana_cost; + pips.Plains += (cost.match(/{W}/g) || []).length; + pips.Island += (cost.match(/{U}/g) || []).length; + pips.Swamp += (cost.match(/{B}/g) || []).length; + pips.Mountain += (cost.match(/{R}/g) || []).length; + pips.Forest += (cost.match(/{G}/g) || []).length; + }); + + totalPips = Object.values(pips).reduce((a, b) => a + b, 0); + + // If no colored pips (artifacts only?), suggest even split or just return 0s? + // Let's assume proportional to 1 if 0 to avoid div by zero. + if (totalPips === 0) return null; + + // Distribute + const suggestion = { Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }; + let allocated = 0; + + // First pass: floor + (Object.keys(pips) as Array).forEach(type => { + const count = Math.floor((pips[type] / totalPips) * landsNeeded); + suggestion[type] = count; + allocated += count; + }); + + // Remainder + let remainder = landsNeeded - allocated; + if (remainder > 0) { + // Add to color with most pips + const sortedTypes = (Object.keys(pips) as Array).sort((a, b) => pips[b] - pips[a]); + for (let i = 0; i < remainder; i++) { + suggestion[sortedTypes[i % sortedTypes.length]]++; + } + } + + return suggestion; + }, [deck]); + + const applySuggestion = () => { + if (landSuggestion) { + setLands(landSuggestion); + } + }; + + // --- Helper Methods --- + const formatTime = (seconds: number | string) => { + return seconds; // Just return "Unlimited" }; const addToDeck = (card: any) => { - setPool(prev => prev.filter(c => c !== card)); + setPool(prev => prev.filter(c => c.id !== card.id)); setDeck(prev => [...prev, card]); }; + const addLandToDeck = (land: any) => { + // Create a unique instance + const newLand = { + ...land, + id: `land-${land.scryfallId}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`, + image_uris: land.image_uris || { normal: land.image } + }; + setDeck(prev => [...prev, newLand]); + }; + const removeFromDeck = (card: any) => { - setDeck(prev => prev.filter(c => c !== card)); - setPool(prev => [...prev, card]); + setDeck(prev => prev.filter(c => c.id !== card.id)); + + if (card.id.startsWith('land-')) { + // Just delete + } else { + setPool(prev => [...prev, card]); + } }; const handleLandChange = (type: string, delta: number) => { @@ -43,9 +125,7 @@ export const DeckBuilderView: React.FC = ({ initialPool }) }; 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 genericLandCards = Object.entries(lands).flatMap(([type, count]) => { 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", @@ -62,57 +142,69 @@ export const DeckBuilderView: React.FC = ({ initialPool }) })); }); - 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. - - // 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) + const fullDeck = [...deck, ...genericLandCards]; socketService.socket.emit('player_ready', { deck: fullDeck }); }; + const sortedLands = React.useMemo(() => { + return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name)); + }, [availableBasicLands]); + return (
- {/* Left: Pool */} -
+ {/* Column 1: Zoom Sidebar */} +
+ {hoveredCard ? ( +
+ {hoveredCard.name} +
+

{hoveredCard.name}

+

{hoveredCard.type_line}

+ {hoveredCard.oracle_text && ( +
+ {hoveredCard.oracle_text.split('\n').map((line: string, i: number) =>

{line}

)} +
+ )} +
+
+ ) : ( +
+
+ Hover Card +
+

Hover over a card to view clear details.

+
+ )} +
+ + {/* Column 2: Pool */} +

Card Pool ({pool.length})

-
- {/* Filter buttons could go here */} -
-
-
- {pool.map((card, i) => ( +
+
+ {pool.map((card) => ( addToDeck(card)} + onMouseEnter={() => setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + title={card.name} /> ))}
- {/* Right: Deck & Lands */} -
+ {/* Column 3: Deck & Lands */} +

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

@@ -121,7 +213,7 @@ export const DeckBuilderView: React.FC = ({ initialPool })
@@ -129,42 +221,113 @@ export const DeckBuilderView: React.FC = ({ initialPool })
{/* Deck View */} -
-
- {deck.map((card, i) => ( +
+
+ {deck.map((card) => ( removeFromDeck(card)} + onMouseEnter={() => setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + title={card.name} /> ))} - {/* 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]} - -
+
+ + {/* Advice Panel */} +
+
+ + Land Advisor (Target: 17) + +
+ Based on your deck's mana symbols.
- ))} +
+ {landSuggestion ? ( +
+
+ {(Object.entries(landSuggestion) as [string, number][]).map(([type, count]) => { + if (count === 0) return null; + let colorClass = "text-slate-300"; + if (type === 'Plains') colorClass = "text-amber-200"; + if (type === 'Island') colorClass = "text-blue-200"; + if (type === 'Swamp') colorClass = "text-purple-200"; + if (type === 'Mountain') colorClass = "text-red-200"; + if (type === 'Forest') colorClass = "text-emerald-200"; + + return ( +
+ {type.substring(0, 1)}: + {count} +
+ ) + })} +
+ +
+ ) : ( + Add colored spells to get advice. + )} +
+ + {/* Land Station */} +
+

Land Station (Unlimited)

+ + {availableBasicLands && availableBasicLands.length > 0 ? ( +
+ {sortedLands.map((land) => ( +
addLandToDeck(land)} + onMouseEnter={() => setHoveredCard(land)} + onMouseLeave={() => setHoveredCard(null)} + > + {land.name} +
+ + Add +
+
+ ))} +
+ ) : ( +
+ {Object.keys(lands).map(type => ( +
+
+ {type[0]} +
+
+ + {lands[type as keyof typeof lands]} + +
+
+ ))} +
+ )}
diff --git a/src/client/src/modules/game/CardComponent.tsx b/src/client/src/modules/game/CardComponent.tsx index 05c06fe..ba910e9 100644 --- a/src/client/src/modules/game/CardComponent.tsx +++ b/src/client/src/modules/game/CardComponent.tsx @@ -6,10 +6,12 @@ interface CardComponentProps { onDragStart: (e: React.DragEvent, cardId: string) => void; onClick: (cardId: string) => void; onContextMenu?: (cardId: string, e: React.MouseEvent) => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; style?: React.CSSProperties; } -export const CardComponent: React.FC = ({ card, onDragStart, onClick, onContextMenu, style }) => { +export const CardComponent: React.FC = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, style }) => { return (
= ({ card, onDragStart, onContextMenu(card.instanceId, e); } }} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} className={` relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none ${card.tapped ? 'rotate-90' : ''} diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index 3972707..3dc417e 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -14,6 +14,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } const battlefieldRef = useRef(null); const [contextMenu, setContextMenu] = useState(null); const [viewingZone, setViewingZone] = useState(null); + const [hoveredCard, setHoveredCard] = useState(null); useEffect(() => { // Disable default context menu @@ -22,6 +23,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } return () => document.removeEventListener('contextmenu', handleContext); }, []); + // ... (handlers remain the same) ... const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => { e.preventDefault(); e.stopPropagation(); @@ -108,16 +110,6 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } }); } - // const toggleFlip = (cardId: string) => { - // socketService.socket.emit('game_action', { - // roomId: gameState.roomId, - // action: { - // type: 'FLIP_CARD', - // cardId - // } - // }); - // } - const myPlayer = gameState.players[currentPlayerId]; const opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId); const opponent = opponentId ? gameState.players[opponentId] : null; @@ -141,7 +133,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } return (
handleContextMenu(e, 'background')} > = ({ gameState, currentPlayerId } /> )} - {/* Top Area: Opponent */} -
- {/* Opponent Hand (Visual) */} -
- {oppHand.map((_, i) => ( -
- ))} -
+ {/* Zoom Sidebar */} +
+ {hoveredCard ? ( +
+ {hoveredCard.name} +
+

{hoveredCard.name}

- {/* Opponent Info Bar */} -
-
- {opponent?.name || 'Waiting...'} -
- Hand: {oppHand.length} - Lib: {oppLibrary.length} - Grave: {oppGraveyard.length} - Exile: {oppExile.length} + {hoveredCard.manaCost && ( +

{hoveredCard.manaCost}

+ )} + + {hoveredCard.typeLine && ( +
+ {hoveredCard.typeLine} +
+ )} + + {hoveredCard.oracleText && ( +
+ {hoveredCard.oracleText} +
+ )} + + {/* Stats for Creatures */} + {hoveredCard.typeLine?.toLowerCase().includes('creature') && ( +
+ {/* Accessing raw PT might be hard if we don't have base PT, but we do have ptModification */} + {/* We don't strictly have base PT in CardInstance yet. Assuming UI mainly uses image. */} + {/* We'll skip P/T text for now as it needs base P/T to be passed from server. */} +
+ )}
-
{opponent?.life}
-
+ ) : ( +
+
+ Hover Card +
+

Hover over a card to view clear details.

+
+ )} +
- {/* Opponent Battlefield (Perspective Reversed or specific layout) */} - {/* For now, we place it "at the back" of the table. */} -
-
- {oppBattlefield.map(card => ( -
- { }} - onClick={() => { }} // Maybe inspect? - /> -
+ {/* Main Game Area */} +
+ + {/* Top Area: Opponent */} +
+ {/* Opponent Hand (Visual) */} +
+ {oppHand.map((_, i) => ( +
))}
-
-
- {/* Middle Area: My Battlefield (The Table) */} -
handleDrop(e, 'battlefield')} - > -
- {/* Battlefield Texture/Grid (Optional) */} -
+ {/* Opponent Info Bar */} +
+
+ {opponent?.name || 'Waiting...'} +
+ Hand: {oppHand.length} + Lib: {oppLibrary.length} + Grave: {oppGraveyard.length} + Exile: {oppExile.length} +
+
+
{opponent?.life}
+
- {myBattlefield.map(card => ( + {/* Opponent Battlefield */} +
- e.dataTransfer.setData('cardId', id)} - onClick={toggleTap} - onContextMenu={(id, e) => { - handleContextMenu(e, 'card', id); - }} - /> -
- ))} - - {myBattlefield.length === 0 && ( -
- Battlefield -
- )} -
-
- - {/* Bottom Area: Controls & Hand */} -
- - {/* Left Controls: Library/Grave */} -
-
socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })} - onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')} - > -
- {/* Deck look */} -
-
- -
- Library - {myLibrary.length} -
-
- -
handleDrop(e, 'graveyard')} - onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')} - > -
- Graveyard - {myGraveyard.length} + {oppBattlefield.map(card => ( +
+ { }} + onClick={() => { }} + onMouseEnter={() => setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + /> +
+ ))}
- {/* Hand Area */} + {/* Middle Area: My Battlefield (The Table) */}
handleDrop(e, 'hand')} + onDrop={(e) => handleDrop(e, 'battlefield')} > -
- {myHand.map((card, index) => ( +
+ {/* Battlefield Texture/Grid */} +
+ + {myBattlefield.map(card => (
e.dataTransfer.setData('cardId', id)} onClick={toggleTap} - onContextMenu={(id, e) => handleContextMenu(e, 'card', id)} - style={{ transformOrigin: 'bottom center' }} + onContextMenu={(id, e) => { + handleContextMenu(e, 'card', id); + }} + onMouseEnter={() => setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} />
))} + + {myBattlefield.length === 0 && ( +
+ Battlefield +
+ )}
- {/* Right Controls: Exile / Life */} -
-
-
Your Life
-
- {myPlayer?.life} + {/* Bottom Area: Controls & Hand */} +
+ + {/* Left Controls: Library/Grave */} +
+
socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })} + onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')} + > +
+ {/* Deck look */} +
+
+ +
+ Library + {myLibrary.length} +
-
- - + +
handleDrop(e, 'graveyard')} + onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')} + > +
+ Graveyard + {myGraveyard.length} +
+ {/* Hand Area */}
handleDrop(e, 'exile')} - onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')} + onDrop={(e) => handleDrop(e, 'hand')} > - Exile Drop Zone - {myExile.length} +
+ {myHand.map((card, index) => ( +
+ e.dataTransfer.setData('cardId', id)} + onClick={toggleTap} + onContextMenu={(id, e) => handleContextMenu(e, 'card', id)} + style={{ transformOrigin: 'bottom center' }} + onMouseEnter={() => setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + /> +
+ ))} +
-
+ {/* Right Controls: Exile / Life */} +
+
+
Your Life
+
+ {myPlayer?.life} +
+
+ + +
+
+ +
handleDrop(e, 'exile')} + onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')} + > + Exile Drop Zone + {myExile.length} +
+
+ +
); diff --git a/src/client/src/modules/lobby/GameRoom.tsx b/src/client/src/modules/lobby/GameRoom.tsx index 991532e..f5e0ab4 100644 --- a/src/client/src/modules/lobby/GameRoom.tsx +++ b/src/client/src/modules/lobby/GameRoom.tsx @@ -1,4 +1,4 @@ - + import React, { useState, useEffect, useRef } from 'react'; import { socketService } from '../../services/SocketService'; import { Users, MessageSquare, Send, Play, Copy, Check, Layers, LogOut } from 'lucide-react'; @@ -26,6 +26,7 @@ interface Room { id: string; hostId: string; players: Player[]; + basicLands?: any[]; status: string; messages: ChatMessage[]; } @@ -205,7 +206,7 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl } const myPool = draftState.players[currentPlayerId]?.pool || []; - return ; + return ; } return ( diff --git a/src/client/src/modules/lobby/LobbyManager.tsx b/src/client/src/modules/lobby/LobbyManager.tsx index 26464eb..37b692d 100644 --- a/src/client/src/modules/lobby/LobbyManager.tsx +++ b/src/client/src/modules/lobby/LobbyManager.tsx @@ -7,9 +7,10 @@ import { Users, PlusCircle, LogIn, AlertCircle, Loader2 } from 'lucide-react'; interface LobbyManagerProps { generatedPacks: Pack[]; + availableLands: any[]; // DraftCard[] } -export const LobbyManager: React.FC = ({ generatedPacks }) => { +export const LobbyManager: React.FC = ({ generatedPacks, availableLands = [] }) => { const [activeRoom, setActiveRoom] = useState(null); const [playerName, setPlayerName] = useState(() => localStorage.getItem('player_name') || ''); const [joinRoomId, setJoinRoomId] = useState(''); @@ -51,14 +52,17 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => connect(); try { - // Collect all cards + // Collect all cards for caching (packs + basic lands) const allCards = generatedPacks.flatMap(p => p.cards); + const allCardsAndLands = [...allCards, ...availableLands]; + // Deduplicate by Scryfall ID - const uniqueCards = Array.from(new Map(allCards.map(c => [c.scryfallId, c])).values()); + const uniqueCards = Array.from(new Map(allCardsAndLands.map(c => [c.scryfallId, c])).values()); // Prepare payload for server (generic structure expected by CardService) const cardsToCache = uniqueCards.map(c => ({ id: c.scryfallId, + set: c.setCode, // Required for folder organization image_uris: { normal: c.image } })); @@ -76,7 +80,7 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => const cacheResult = await cacheResponse.json(); console.log('Cached result:', cacheResult); - // Transform packs to use local URLs + // Transform packs and lands to use local URLs // Note: For multiplayer, clients need to access this URL. const baseUrl = `${window.location.protocol}//${window.location.host}/cards/images`; @@ -85,14 +89,20 @@ export const LobbyManager: React.FC = ({ generatedPacks }) => cards: pack.cards.map(c => ({ ...c, // Update the single image property used by DraftCard - image: `${baseUrl}/${c.scryfallId}.jpg` + image: `${baseUrl}/${c.setCode}/${c.scryfallId}.jpg` })) })); + const updatedBasicLands = availableLands.map(l => ({ + ...l, + image: `${baseUrl}/${l.setCode}/${l.scryfallId}.jpg` + })); + const response = await socketService.emitPromise('create_room', { hostId: playerId, hostName: playerName, - packs: updatedPacks + packs: updatedPacks, + basicLands: updatedBasicLands }); if (response.success) { diff --git a/src/client/src/types/game.ts b/src/client/src/types/game.ts index cafa411..d47822e 100644 --- a/src/client/src/types/game.ts +++ b/src/client/src/types/game.ts @@ -11,6 +11,9 @@ export interface CardInstance { position: { x: number; y: number; z: number }; // For freeform placement counters: { type: string; count: number }[]; ptModification: { power: number; toughness: number }; + typeLine?: string; + oracleText?: string; + manaCost?: string; } export interface PlayerState { diff --git a/src/server/index.ts b/src/server/index.ts index a8e32aa..f3b945c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -151,8 +151,20 @@ app.post('/api/packs/generate', async (req: Request, res: Response) => { const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters); + // Extract available basic lands for deck building + const basicLands = pools.lands.filter(c => c.typeLine?.includes('Basic')); + // Deduplicate by Scryfall ID to get unique arts + const uniqueBasicLands: any[] = []; + const seenLandIds = new Set(); + for (const land of basicLands) { + if (!seenLandIds.has(land.scryfallId)) { + seenLandIds.add(land.scryfallId); + uniqueBasicLands.push(land); + } + } + const packs = packGeneratorService.generatePacks(pools, sets, settings, numPacks || 108); - res.json(packs); + res.json({ packs, basicLands: uniqueBasicLands }); } catch (e: any) { console.error("Generation error", e); res.status(500).json({ error: e.message }); @@ -195,7 +207,10 @@ const draftInterval = setInterval(() => { oracleId: card.oracle_id || card.id, name: card.name, imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", - zone: 'library' + zone: 'library', + typeLine: card.typeLine || card.type_line || '', + oracleText: card.oracleText || card.oracle_text || '', + manaCost: card.manaCost || card.mana_cost || '' }); }); } @@ -213,8 +228,8 @@ io.on('connection', (socket) => { // Timer management // Timer management removed (Global loop handled) - socket.on('create_room', ({ hostId, hostName, packs }, callback) => { - const room = roomManager.createRoom(hostId, hostName, packs, socket.id); // Add socket.id + socket.on('create_room', ({ hostId, hostName, packs, basicLands }, callback) => { + const room = roomManager.createRoom(hostId, hostName, packs, basicLands || [], socket.id); socket.join(room.id); console.log(`Room created: ${room.id} by ${hostName}`); callback({ success: true, room }); @@ -404,7 +419,10 @@ io.on('connection', (socket) => { oracleId: card.oracle_id || card.id, name: card.name, imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", - zone: 'library' + zone: 'library', + typeLine: card.typeLine || card.type_line || '', + oracleText: card.oracleText || card.oracle_text || '', + manaCost: card.manaCost || card.mana_cost || '' }); }); } @@ -428,7 +446,10 @@ io.on('connection', (socket) => { oracleId: card.id, name: card.name, imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", - zone: 'library' + zone: 'library', + typeLine: card.typeLine || card.type_line || '', + oracleText: card.oracleText || card.oracle_text || '', + manaCost: card.manaCost || card.mana_cost || '' }); }); } @@ -456,7 +477,10 @@ io.on('connection', (socket) => { oracleId: card.oracle_id || card.id, name: card.name, imageUrl: card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", - zone: 'library' + zone: 'library', + typeLine: card.typeLine || card.type_line || '', + oracleText: card.oracleText || card.oracle_text || '', + manaCost: card.manaCost || card.mana_cost || '' }); }); }); diff --git a/src/server/managers/DraftManager.ts b/src/server/managers/DraftManager.ts index 3669302..7f76793 100644 --- a/src/server/managers/DraftManager.ts +++ b/src/server/managers/DraftManager.ts @@ -190,7 +190,8 @@ export class DraftManager extends EventEmitter { } } else if (draft.status === 'deck_building') { // Check global deck building timer (e.g., 120 seconds) - const DECK_BUILDING_Duration = 120000; + // Disabling timeout as per request. Set to ~11.5 days. + const DECK_BUILDING_Duration = 999999999; if (draft.startTime && (now > draft.startTime + DECK_BUILDING_Duration)) { draft.status = 'complete'; // Signal that time is up updates.push({ roomId, draft }); diff --git a/src/server/managers/GameManager.ts b/src/server/managers/GameManager.ts index 78259f6..5e35988 100644 --- a/src/server/managers/GameManager.ts +++ b/src/server/managers/GameManager.ts @@ -12,6 +12,9 @@ interface CardInstance { position: { x: number; y: number; z: number }; // For freeform placement counters: { type: string; count: number }[]; ptModification: { power: number; toughness: number }; + typeLine?: string; + oracleText?: string; + manaCost?: string; } interface PlayerState { diff --git a/src/server/managers/RoomManager.ts b/src/server/managers/RoomManager.ts index ed2e291..e193c5d 100644 --- a/src/server/managers/RoomManager.ts +++ b/src/server/managers/RoomManager.ts @@ -21,6 +21,7 @@ interface Room { hostId: string; players: Player[]; packs: any[]; // Store generated packs (JSON) + basicLands?: any[]; status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished'; messages: ChatMessage[]; maxPlayers: number; @@ -29,13 +30,14 @@ interface Room { export class RoomManager { private rooms: Map = new Map(); - createRoom(hostId: string, hostName: string, packs: any[], socketId?: string): Room { + createRoom(hostId: string, hostName: string, packs: any[], basicLands: any[] = [], socketId?: string): Room { const roomId = Math.random().toString(36).substring(2, 8).toUpperCase(); const room: Room = { id: roomId, hostId, players: [{ id: hostId, name: hostName, isHost: true, role: 'player', ready: false, socketId, isOffline: false }], packs, + basicLands, status: 'waiting', messages: [], maxPlayers: 8 diff --git a/src/server/services/PackGeneratorService.ts b/src/server/services/PackGeneratorService.ts index ccba35e..52fb167 100644 --- a/src/server/services/PackGeneratorService.ts +++ b/src/server/services/PackGeneratorService.ts @@ -15,6 +15,8 @@ export interface DraftCard { setCode: string; setType: string; finish?: 'foil' | 'normal'; + oracleText?: string; + manaCost?: string; [key: string]: any; // Allow extended props } @@ -102,6 +104,8 @@ export class PackGeneratorService { setCode: cardData.set, setType: setType, finish: cardData.finish || 'normal', + oracleText: cardData.oracle_text || cardData.card_faces?.[0]?.oracle_text || '', + manaCost: cardData.mana_cost || cardData.card_faces?.[0]?.mana_cost || '', }; // Add to pools