feat: Implement deck builder magnified card view, land advice, basic land integration, and unlimited time for deck construction.

This commit is contained in:
2025-12-17 16:15:20 +01:00
parent e5750d9729
commit e13aa16766
18 changed files with 672 additions and 264 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<string>("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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -23,6 +23,16 @@ export const App: React.FC = () => {
}
});
const [availableLands, setAvailableLands] = useState<any[]>(() => {
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 (
<ToastProvider>
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
@@ -82,10 +100,11 @@ export const App: React.FC = () => {
<CubeManager
packs={generatedPacks}
setPacks={setGeneratedPacks}
setAvailableLands={setAvailableLands}
onGoToLobby={() => setActiveTab('lobby')}
/>
)}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} availableLands={availableLands} />}
{activeTab === 'tester' && <DeckTester />}
{activeTab === 'bracket' && <TournamentManager />}
</main>

View File

@@ -7,13 +7,15 @@ import { PackCard } from '../../components/PackCard';
interface CubeManagerProps {
packs: Pack[];
setPacks: React.Dispatch<React.SetStateAction<Pack[]>>;
setAvailableLands: React.Dispatch<React.SetStateAction<any[]>>;
onGoToLobby: () => void;
}
import { useToast } from '../../components/Toast';
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoToLobby }) => {
export const CubeManager: React.FC<CubeManagerProps> = ({ 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<CubeManagerProps> = ({ 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);

View File

@@ -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<DeckBuilderViewProps> = ({ initialPool }) => {
const [timer, setTimer] = useState(45 * 60); // 45 minutes
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
// Unlimited Timer (Static for now)
const [timer] = useState<string>("Unlimited");
const [pool, setPool] = useState<any[]>(initialPool);
const [deck, setDeck] = useState<any[]>([]);
const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 });
const [hoveredCard, setHoveredCard] = useState<any>(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<keyof typeof pips>).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<keyof typeof pips>).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));
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<DeckBuilderViewProps> = ({ 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<DeckBuilderViewProps> = ({ 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 (
<div className="flex-1 w-full flex h-full bg-slate-900 text-white">
{/* Left: Pool */}
<div className="w-1/2 p-4 flex flex-col border-r border-slate-700">
{/* Column 1: Zoom Sidebar */}
<div className="hidden xl:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800 bg-slate-950/50 z-10 p-4">
{hoveredCard ? (
<div className="animate-in fade-in slide-in-from-left-4 duration-200 sticky top-4 w-full">
<img
src={hoveredCard.image || hoveredCard.image_uris?.normal || hoveredCard.card_faces?.[0]?.image_uris?.normal}
alt={hoveredCard.name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
/>
<div className="mt-4 text-center">
<h3 className="text-lg font-bold text-slate-200">{hoveredCard.name}</h3>
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">{hoveredCard.type_line}</p>
{hoveredCard.oracle_text && (
<div className="mt-4 text-sm text-slate-400 text-left bg-slate-900/50 p-3 rounded-lg border border-slate-800">
{hoveredCard.oracle_text.split('\n').map((line: string, i: number) => <p key={i} className="mb-1">{line}</p>)}
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 text-center opacity-50">
<div className="w-48 h-64 border-2 border-dashed border-slate-700 rounded-xl mb-4 flex items-center justify-center">
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span>
</div>
<p className="text-sm">Hover over a card to view clear details.</p>
</div>
)}
</div>
{/* Column 2: Pool */}
<div className="flex-1 p-4 flex flex-col border-r border-slate-700 min-w-0">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold flex items-center gap-2"><Layers /> Card Pool ({pool.length})</h2>
<div className="flex gap-2">
{/* Filter buttons could go here */}
</div>
</div>
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg">
<div className="flex flex-wrap gap-2 justify-center">
{pool.map((card, i) => (
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg custom-scrollbar">
<div className="flex flex-wrap gap-2 justify-center content-start">
{pool.map((card) => (
<img
key={card.id + i}
key={card.id}
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
className="w-32 hover:scale-105 transition-transform cursor-pointer rounded"
className="w-28 hover:scale-110 transition-transform cursor-pointer rounded shadow-md"
onClick={() => addToDeck(card)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
title={card.name}
/>
))}
</div>
</div>
</div>
{/* Right: Deck & Lands */}
<div className="w-1/2 p-4 flex flex-col">
{/* Column 3: Deck & Lands */}
<div className="flex-1 p-4 flex flex-col min-w-0">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Your Deck ({deck.length + Object.values(lands).reduce((a, b) => a + b, 0)})</h2>
<div className="flex items-center gap-4">
@@ -121,7 +213,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool })
</div>
<button
onClick={submitDeck}
className="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-2 rounded-lg font-bold shadow-lg flex items-center gap-2"
className="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-2 rounded-lg font-bold shadow-lg flex items-center gap-2 transition-transform hover:scale-105"
>
<Save className="w-4 h-4" /> Submit Deck
</button>
@@ -129,27 +221,96 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool })
</div>
{/* Deck View */}
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg mb-4">
<div className="flex flex-wrap gap-2 justify-center">
{deck.map((card, i) => (
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg mb-4 custom-scrollbar">
<div className="flex flex-wrap gap-2 justify-center content-start">
{deck.map((card) => (
<img
key={card.id + i}
key={card.id}
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal}
className="w-32 hover:scale-105 transition-transform cursor-pointer rounded"
className="w-28 hover:scale-110 transition-transform cursor-pointer rounded shadow-md"
onClick={() => removeFromDeck(card)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
title={card.name}
/>
))}
{/* Visual representation of lands? Maybe just count for now */}
</div>
</div>
<div className="flex flex-col gap-2">
{/* Advice Panel */}
<div className="bg-slate-800 rounded-lg p-3 border border-slate-700 flex justify-between items-center">
<div className="flex flex-col">
<span className="text-xs text-slate-400 font-bold uppercase flex items-center gap-2">
<Layers className="w-3 h-3 text-emerald-400" /> Land Advisor (Target: 17)
</span>
<div className="text-xs text-slate-500 mt-1">
Based on your deck's mana symbols.
</div>
</div>
{landSuggestion ? (
<div className="flex items-center gap-4">
<div className="flex gap-2">
{(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 (
<div key={type} className={`font-bold ${colorClass} text-sm flex items-center gap-1`}>
<span>{type.substring(0, 1)}:</span>
<span>{count}</span>
</div>
)
})}
</div>
<button
onClick={applySuggestion}
className="bg-emerald-700 hover:bg-emerald-600 text-white text-xs px-3 py-1 rounded shadow transition-colors font-bold"
>
Auto-Fill
</button>
</div>
) : (
<span className="text-xs text-slate-500 italic">Add colored spells to get advice.</span>
)}
</div>
{/* Land Station */}
<div className="h-32 bg-slate-800 rounded-lg p-4 border border-slate-700">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-2">Basic Lands</h3>
<div className="flex justify-around items-center">
<div className="h-48 bg-slate-800 rounded-lg p-4 border border-slate-700 flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-2">Land Station (Unlimited)</h3>
{availableBasicLands && availableBasicLands.length > 0 ? (
<div className="flex-1 overflow-x-auto flex items-center gap-3 custom-scrollbar p-2 bg-slate-900/50 rounded-lg">
{sortedLands.map((land) => (
<div
key={land.scryfallId}
className="flex-shrink-0 relative group cursor-pointer"
onClick={() => addLandToDeck(land)}
onMouseEnter={() => setHoveredCard(land)}
onMouseLeave={() => setHoveredCard(null)}
>
<img
src={land.image || land.image_uris?.normal}
className="w-24 rounded shadow-lg group-hover:scale-110 transition-transform"
alt={land.name}
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 bg-black/40 rounded transition-opacity">
<span className="text-white font-bold text-xs bg-black/50 px-2 py-1 rounded">+ Add</span>
</div>
</div>
))}
</div>
) : (
<div className="flex justify-around items-center h-full">
{Object.keys(lands).map(type => (
<div key={type} className="flex flex-col items-center gap-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs border-2
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-xs border-2
${type === 'Plains' ? 'bg-amber-100 border-amber-300 text-amber-900' : ''}
${type === 'Island' ? 'bg-blue-100 border-blue-300 text-blue-900' : ''}
${type === 'Swamp' ? 'bg-purple-100 border-purple-300 text-purple-900' : ''}
@@ -158,14 +319,16 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool })
`}>
{type[0]}
</div>
<div className="flex items-center gap-1">
<button onClick={() => handleLandChange(type, -1)} className="w-6 h-6 bg-slate-700 rounded hover:bg-slate-600">-</button>
<span className="w-6 text-center text-sm font-bold">{lands[type as keyof typeof lands]}</span>
<button onClick={() => handleLandChange(type, 1)} className="w-6 h-6 bg-slate-700 rounded hover:bg-slate-600">+</button>
<div className="flex items-center gap-2">
<button onClick={() => handleLandChange(type, -1)} className="w-8 h-8 bg-slate-700 rounded hover:bg-slate-600 flex items-center justify-center text-lg font-bold text-slate-300">-</button>
<span className="w-8 text-center font-bold text-lg">{lands[type as keyof typeof lands]}</span>
<button onClick={() => handleLandChange(type, 1)} className="w-8 h-8 bg-slate-700 rounded hover:bg-slate-600 flex items-center justify-center text-lg font-bold text-slate-300">+</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -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<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, style }) => {
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, style }) => {
return (
<div
draggable
@@ -21,6 +23,8 @@ export const CardComponent: React.FC<CardComponentProps> = ({ 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' : ''}

View File

@@ -14,6 +14,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
const battlefieldRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
const [viewingZone, setViewingZone] = useState<string | null>(null);
const [hoveredCard, setHoveredCard] = useState<CardInstance | null>(null);
useEffect(() => {
// Disable default context menu
@@ -22,6 +23,7 @@ export const GameView: React.FC<GameViewProps> = ({ 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<GameViewProps> = ({ 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<GameViewProps> = ({ gameState, currentPlayerId }
return (
<div
className="flex flex-col h-full w-full bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 to-black text-white overflow-hidden select-none font-sans"
className="flex h-full w-full bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 to-black text-white overflow-hidden select-none font-sans"
onContextMenu={(e) => handleContextMenu(e, 'background')}
>
<GameContextMenu
@@ -159,6 +151,57 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
/>
)}
{/* Zoom Sidebar */}
<div className="hidden xl:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800 bg-slate-950/50 z-30 p-4 relative shadow-2xl">
{hoveredCard ? (
<div className="animate-in fade-in slide-in-from-left-4 duration-200 sticky top-4 w-full h-[calc(100vh-2rem)] overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<img
src={hoveredCard.imageUrl}
alt={hoveredCard.name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
/>
<div className="mt-4 text-center pb-4">
<h3 className="text-lg font-bold text-slate-200 leading-tight">{hoveredCard.name}</h3>
{hoveredCard.manaCost && (
<p className="text-sm text-slate-400 mt-1 font-mono tracking-widest">{hoveredCard.manaCost}</p>
)}
{hoveredCard.typeLine && (
<div className="text-xs text-emerald-400 uppercase tracking-wider font-bold mt-2 border-b border-white/10 pb-2 mb-3">
{hoveredCard.typeLine}
</div>
)}
{hoveredCard.oracleText && (
<div className="text-sm text-slate-300 text-left bg-slate-900/50 p-3 rounded-lg border border-slate-800 whitespace-pre-wrap leading-relaxed">
{hoveredCard.oracleText}
</div>
)}
{/* Stats for Creatures */}
{hoveredCard.typeLine?.toLowerCase().includes('creature') && (
<div className="mt-3 bg-slate-800/80 rounded px-2 py-1 inline-block border border-slate-600 font-bold text-lg">
{/* 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. */}
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 text-center opacity-50">
<div className="w-48 h-64 border-2 border-dashed border-slate-700 rounded-xl mb-4 flex items-center justify-center">
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span>
</div>
<p className="text-sm">Hover over a card to view clear details.</p>
</div>
)}
</div>
{/* Main Game Area */}
<div className="flex-1 flex flex-col h-full relative">
{/* Top Area: Opponent */}
<div className="flex-[2] relative flex flex-col pointer-events-none">
{/* Opponent Hand (Visual) */}
@@ -182,8 +225,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
<div className="text-3xl font-bold text-white">{opponent?.life}</div>
</div>
{/* Opponent Battlefield (Perspective Reversed or specific layout) */}
{/* For now, we place it "at the back" of the table. */}
{/* Opponent Battlefield */}
<div className="flex-1 w-full relative perspective-1000">
<div
className="w-full h-full relative"
@@ -199,15 +241,15 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
style={{
left: `${card.position?.x || 50}%`,
top: `${card.position?.y || 50}%`,
zIndex: Math.floor((card.position?.y || 0)), // Simple z-index based on vertical pos
zIndex: Math.floor((card.position?.y || 0)),
}}
>
<CardComponent
card={card}
// Opponent cards shouldn't necessarily be draggable by me, but depends on game rules.
// Usually not.
onDragStart={() => { }}
onClick={() => { }} // Maybe inspect?
onClick={() => { }}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
/>
</div>
))}
@@ -226,11 +268,11 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner"
style={{
transform: 'rotateX(25deg)',
transformOrigin: 'center 40%', /* Pivot point */
transformOrigin: 'center 40%',
boxShadow: 'inset 0 0 100px rgba(0,0,0,0.8)'
}}
>
{/* Battlefield Texture/Grid (Optional) */}
{/* Battlefield Texture/Grid */}
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
{myBattlefield.map(card => (
@@ -250,6 +292,8 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
onContextMenu={(id, e) => {
handleContextMenu(e, 'card', id);
}}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
/>
</div>
))}
@@ -318,6 +362,8 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
onClick={toggleTap}
onContextMenu={(id, e) => handleContextMenu(e, 'card', id)}
style={{ transformOrigin: 'bottom center' }}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
/>
</div>
))}
@@ -360,5 +406,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
</div>
</div>
</div>
);
};

View File

@@ -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<GameRoomProps> = ({ room: initialRoom, currentPl
}
const myPool = draftState.players[currentPlayerId]?.pool || [];
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} />;
return <DeckBuilderView roomId={room.id} currentPlayerId={currentPlayerId} initialPool={myPool} availableBasicLands={room.basicLands} />;
}
return (

View File

@@ -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<LobbyManagerProps> = ({ generatedPacks }) => {
export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, availableLands = [] }) => {
const [activeRoom, setActiveRoom] = useState<any>(null);
const [playerName, setPlayerName] = useState(() => localStorage.getItem('player_name') || '');
const [joinRoomId, setJoinRoomId] = useState('');
@@ -51,14 +52,17 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ 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<LobbyManagerProps> = ({ 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<LobbyManagerProps> = ({ 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) {

View File

@@ -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 {

View File

@@ -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 || ''
});
});
});

View File

@@ -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 });

View File

@@ -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 {

View File

@@ -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<string, Room> = 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

View File

@@ -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