Compare commits

...

6 Commits

21 changed files with 1048 additions and 352 deletions

View File

@@ -77,3 +77,9 @@
- [Responsive Pack Grid Layout](./devlog/2025-12-17-142500_responsive_pack_grid.md): Completed. Implemented responsive multi-column grid for generated packs when card size is reduced (<25% slider). - [Responsive Pack Grid Layout](./devlog/2025-12-17-142500_responsive_pack_grid.md): Completed. Implemented responsive multi-column grid for generated packs when card size is reduced (<25% slider).
- [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. - [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. - [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.
- [Test Deck Feature](./devlog/2025-12-17-162500_test_deck_feature.md): Completed. Implemented "Test Solo" button in Cube Manager to instantly start a solo game with a randomized deck from generated packs.

View File

@@ -0,0 +1,12 @@
# Fix Socket.IO Payload Limit
## Issue
The user reported a `413 (Payload Too Large)` error when creating a draft room with a full box of 36 packs. This was caused by the default `maxHttpBufferSize` limit (1MB) in Socket.IO, which is insufficient for the large JSON payload generated by 540 cards (36 packs * 15 cards) with full metadata.
## Resolution
- Modified `src/server/index.ts` to increase `maxHttpBufferSize` to 300MB (3e8 bytes).
- This explicitly sets the limit in the `Server` initialization options.
## Verification
- `tsx watch` should automatically restart the server.
- The new limit allows for massive payloads, covering the requirement for 36+ packs (requested >250MB).

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

@@ -0,0 +1,32 @@
# Test Deck Feature Implementation
## Requirements
- Allow users to "Test Deck" directly from the Cube Manager (Pack Generator).
- Create a randomized deck from the generated pool (approx. 23 spells + 17 lands).
- Start a solo game immediately.
- Enable return to lobby.
## Implementation Details
### Client-Side Updates
- **`App.tsx`**: Passed `availableLands` to `CubeManager` to allow for proper basic land inclusion in randomized decks.
- **`CubeManager.tsx`**:
- Added `handleStartSoloTest` function.
- Logic: Flattens generated packs, separates lands/spells, shuffles and picks 23 spells, adds 17 basic lands (using `availableLands` if available).
- Emits `start_solo_test` socket event with the constructed deck.
- On success, saves room ID to `localStorage` and navigates to the Lobby tab using `onGoToLobby`.
- Added "Test Solo" button to the UI next to "Play Online".
- **`LobbyManager.tsx`**: Existing `rejoin_room` logic (triggered on mount via `localStorage`) handles picking up the active session.
### Server-Side Updates
- **`src/server/index.ts`**:
- Updated `rejoin_room` handler to emit `game_update` if the room status is `playing`. This ensures that when the client navigates to the lobby and "rejoins" the solo session, the game board is correctly rendered.
## User Flow
1. User generates packs in Cube Manager.
2. User clicks "Test Solo".
3. System builds a random deck and creates a solo room on the server.
4. UI switches to "Online Lobby" tab.
5. Lobby Manager detects the active session and loads the Game Room.
6. User plays the game.
7. User can click "Leave Room" icon in the sidebar to return to the Lobby creation screen.

View File

@@ -23,18 +23,49 @@ 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(() => { React.useEffect(() => {
localStorage.setItem('activeTab', activeTab); localStorage.setItem('activeTab', activeTab);
}, [activeTab]); }, [activeTab]);
React.useEffect(() => { React.useEffect(() => {
try { try {
localStorage.setItem('generatedPacks', JSON.stringify(generatedPacks)); // Optimiziation: Strip 'definition' (ScryfallCard) from cards to save huge amount of space
// We only need the properties mapped to DraftCard for the UI and Game
const optimizedPacks = generatedPacks.map(p => ({
...p,
cards: p.cards.map(c => {
const { definition, ...rest } = c;
return rest;
})
}));
localStorage.setItem('generatedPacks', JSON.stringify(optimizedPacks));
} catch (e) { } catch (e) {
console.error("Failed to save packs to storage", e); console.error("Failed to save packs to storage (Quota likely exceeded)", e);
} }
}, [generatedPacks]); }, [generatedPacks]);
React.useEffect(() => {
try {
const optimizedLands = availableLands.map(l => {
const { definition, ...rest } = l;
return rest;
});
localStorage.setItem('availableLands', JSON.stringify(optimizedLands));
} catch (e) {
console.error("Failed to save lands to storage", e);
}
}, [availableLands]);
return ( return (
<ToastProvider> <ToastProvider>
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden"> <div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
@@ -82,10 +113,12 @@ export const App: React.FC = () => {
<CubeManager <CubeManager
packs={generatedPacks} packs={generatedPacks}
setPacks={setGeneratedPacks} setPacks={setGeneratedPacks}
availableLands={availableLands}
setAvailableLands={setAvailableLands}
onGoToLobby={() => setActiveTab('lobby')} onGoToLobby={() => setActiveTab('lobby')}
/> />
)} )}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />} {activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} availableLands={availableLands} />}
{activeTab === 'tester' && <DeckTester />} {activeTab === 'tester' && <DeckTester />}
{activeTab === 'bracket' && <TournamentManager />} {activeTab === 'bracket' && <TournamentManager />}
</main> </main>

View File

@@ -5,11 +5,13 @@ interface ModalProps {
isOpen: boolean; isOpen: boolean;
onClose?: () => void; onClose?: () => void;
title: string; title: string;
message: string; message?: string;
children?: React.ReactNode;
type?: 'info' | 'success' | 'warning' | 'error'; type?: 'info' | 'success' | 'warning' | 'error';
confirmLabel?: string; confirmLabel?: string;
onConfirm?: () => void; onConfirm?: () => void;
cancelLabel?: string; cancelLabel?: string;
maxWidth?: string;
} }
export const Modal: React.FC<ModalProps> = ({ export const Modal: React.FC<ModalProps> = ({
@@ -17,10 +19,12 @@ export const Modal: React.FC<ModalProps> = ({
onClose, onClose,
title, title,
message, message,
children,
type = 'info', type = 'info',
confirmLabel = 'OK', confirmLabel = 'OK',
onConfirm, onConfirm,
cancelLabel cancelLabel,
maxWidth = 'max-w-md'
}) => { }) => {
if (!isOpen) return null; if (!isOpen) return null;
@@ -45,10 +49,10 @@ export const Modal: React.FC<ModalProps> = ({
return ( return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div <div
className={`bg-slate-900 border ${getBorderColor()} rounded-xl shadow-2xl max-w-md w-full p-6 animate-in zoom-in-95 duration-200`} className={`bg-slate-900 border ${getBorderColor()} rounded-xl shadow-2xl ${maxWidth} w-full p-6 animate-in zoom-in-95 duration-200 flex flex-col max-h-[90vh]`}
role="dialog" role="dialog"
> >
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{getIcon()} {getIcon()}
<h3 className="text-xl font-bold text-white">{title}</h3> <h3 className="text-xl font-bold text-white">{title}</h3>
@@ -60,33 +64,42 @@ export const Modal: React.FC<ModalProps> = ({
)} )}
</div> </div>
<p className="text-slate-300 mb-8 leading-relaxed"> <div className="flex-1 overflow-y-auto custom-scrollbar">
{message} {message && (
</p> <p className="text-slate-300 mb-4 leading-relaxed">
{message}
<div className="flex justify-end gap-3"> </p>
{cancelLabel && onClose && (
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium transition-colors border border-slate-700"
>
{cancelLabel}
</button>
)} )}
<button {children}
onClick={() => {
if (onConfirm) onConfirm();
if (onClose) onClose();
}}
className={`px-6 py-2 rounded-lg font-bold text-white shadow-lg transition-transform hover:scale-105 ${type === 'error' ? 'bg-red-600 hover:bg-red-500' :
type === 'warning' ? 'bg-amber-600 hover:bg-amber-500' :
type === 'success' ? 'bg-emerald-600 hover:bg-emerald-500' :
'bg-blue-600 hover:bg-blue-500'
}`}
>
{confirmLabel}
</button>
</div> </div>
{(onConfirm || cancelLabel) && (
<div className="flex justify-end gap-3 mt-6 shrink-0">
{cancelLabel && onClose && (
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium transition-colors border border-slate-700"
>
{cancelLabel}
</button>
)}
{onConfirm && (
<button
onClick={() => {
onConfirm();
if (onClose) onClose();
}}
className={`px-6 py-2 rounded-lg font-bold text-white shadow-lg transition-transform hover:scale-105 ${type === 'error' ? 'bg-red-600 hover:bg-red-500' :
type === 'warning' ? 'bg-amber-600 hover:bg-amber-500' :
type === 'success' ? 'bg-emerald-600 hover:bg-emerald-500' :
'bg-blue-600 hover:bg-blue-500'
}`}
>
{confirmLabel}
</button>
)}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,19 +1,22 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X } from 'lucide-react'; import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X, PlayCircle, Plus, Minus } from 'lucide-react';
import { ScryfallCard, ScryfallSet } from '../../services/ScryfallService'; import { ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService'; import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
import { PackCard } from '../../components/PackCard'; import { PackCard } from '../../components/PackCard';
import { socketService } from '../../services/SocketService';
import { useToast } from '../../components/Toast';
interface CubeManagerProps { interface CubeManagerProps {
packs: Pack[]; packs: Pack[];
setPacks: React.Dispatch<React.SetStateAction<Pack[]>>; setPacks: React.Dispatch<React.SetStateAction<Pack[]>>;
availableLands: any[];
setAvailableLands: React.Dispatch<React.SetStateAction<any[]>>;
onGoToLobby: () => void; onGoToLobby: () => void;
} }
import { useToast } from '../../components/Toast'; export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, availableLands, setAvailableLands, onGoToLobby }) => {
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoToLobby }) => {
const { showToast } = useToast(); const { showToast } = useToast();
// --- Services --- // --- Services ---
// Memoize services to persist cache across renders // Memoize services to persist cache across renders
const generatorService = React.useMemo(() => new PackGeneratorService(), []); const generatorService = React.useMemo(() => new PackGeneratorService(), []);
@@ -69,7 +72,9 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
}); });
// UI State // UI State
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list'); const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>(() => {
return (localStorage.getItem('cube_viewMode') as 'list' | 'grid' | 'stack') || 'list';
});
// Generation Settings // Generation Settings
const [genSettings, setGenSettings] = useState<PackGenerationSettings>(() => { const [genSettings, setGenSettings] = useState<PackGenerationSettings>(() => {
@@ -97,10 +102,12 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
}); });
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [gameTypeFilter, setGameTypeFilter] = useState<'all' | 'paper' | 'digital'>('all'); // Filter state const [gameTypeFilter, setGameTypeFilter] = useState<'all' | 'paper' | 'digital'>(() => {
return (localStorage.getItem('cube_gameTypeFilter') as 'all' | 'paper' | 'digital') || 'all';
});
const [numBoxes, setNumBoxes] = useState<number>(() => { const [numBoxes, setNumBoxes] = useState<number>(() => {
const saved = localStorage.getItem('cube_numBoxes'); const saved = localStorage.getItem('cube_numBoxes');
return saved ? parseInt(saved) : 3; return saved ? parseInt(saved) : 1;
}); });
const [cardWidth, setCardWidth] = useState(() => { const [cardWidth, setCardWidth] = useState(() => {
@@ -116,6 +123,8 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
useEffect(() => localStorage.setItem('cube_selectedSets', JSON.stringify(selectedSets)), [selectedSets]); useEffect(() => localStorage.setItem('cube_selectedSets', JSON.stringify(selectedSets)), [selectedSets]);
useEffect(() => localStorage.setItem('cube_numBoxes', numBoxes.toString()), [numBoxes]); useEffect(() => localStorage.setItem('cube_numBoxes', numBoxes.toString()), [numBoxes]);
useEffect(() => localStorage.setItem('cube_cardWidth', cardWidth.toString()), [cardWidth]); useEffect(() => localStorage.setItem('cube_cardWidth', cardWidth.toString()), [cardWidth]);
useEffect(() => localStorage.setItem('cube_viewMode', viewMode), [viewMode]);
useEffect(() => localStorage.setItem('cube_gameTypeFilter', gameTypeFilter), [gameTypeFilter]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -180,6 +189,11 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
if (sourceMode === 'set' && selectedSets.length === 0) return; if (sourceMode === 'set' && selectedSets.length === 0) return;
if (sourceMode === 'upload' && !inputText) return; if (sourceMode === 'upload' && !inputText) return;
if (sourceMode === 'set' && numBoxes > 10) {
showToast("Maximum limit is 10 Boxes (360 Packs) to avoid instability.", "error");
return;
}
setLoading(true); setLoading(true);
setPacks([]); // Clear old packs to avoid confusion setPacks([]); // Clear old packs to avoid confusion
@@ -251,12 +265,23 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
throw new Error(err.error || "Generation failed"); 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) { if (newPacks.length === 0) {
alert(`No packs generated. Check your card pool settings.`); alert(`No packs generated. Check your card pool settings.`);
} else { } else {
setPacks(newPacks); setPacks(newPacks);
setAvailableLands(newLands);
} }
} catch (err: any) { } catch (err: any) {
console.error("Process failed", err); console.error("Process failed", err);
@@ -267,6 +292,84 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
} }
}; };
const handleStartSoloTest = async () => {
if (packs.length === 0) return;
// Validate Lands
if (!availableLands || availableLands.length === 0) {
if (!confirm("No basic lands detected in the current pool. The generated deck will have 0 lands. Continue?")) {
return;
}
}
setLoading(true);
try {
// Collect all cards
const allCards = packs.flatMap(p => p.cards);
// Random Deck Construction Logic
// 1. Separate lands and non-lands (Exclude existing Basic Lands from spells to be safe)
const spells = allCards.filter(c => !c.typeLine?.includes('Basic Land') && !c.typeLine?.includes('Land'));
// 2. Select 23 Spells randomly
const deckSpells: any[] = [];
const spellPool = [...spells];
// Fisher-Yates Shuffle
for (let i = spellPool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[spellPool[i], spellPool[j]] = [spellPool[j], spellPool[i]];
}
// Take up to 23 spells, or all if fewer
deckSpells.push(...spellPool.slice(0, Math.min(23, spellPool.length)));
// 3. Select 17 Lands (or fill to 40)
const deckLands: any[] = [];
const landCount = 40 - deckSpells.length; // Aim for 40 cards total
if (availableLands.length > 0) {
for (let i = 0; i < landCount; i++) {
const land = availableLands[Math.floor(Math.random() * availableLands.length)];
deckLands.push(land);
}
}
const fullDeck = [...deckSpells, ...deckLands];
// Emit socket event
const playerId = localStorage.getItem('player_id') || 'tester-' + Date.now();
const playerName = localStorage.getItem('player_name') || 'Tester';
if (!socketService.socket.connected) socketService.connect();
const response = await socketService.emitPromise('start_solo_test', {
playerId,
playerName,
deck: fullDeck
});
if (response.success) {
localStorage.setItem('active_room_id', response.room.id);
localStorage.setItem('player_id', playerId);
// Brief delay to allow socket events to propagate
setTimeout(() => {
onGoToLobby();
}, 100);
} else {
alert("Failed to start test game: " + response.message);
}
} catch (e: any) {
console.error(e);
alert("Error: " + e.message);
} finally {
setLoading(false);
}
};
const handleExportCsv = () => { const handleExportCsv = () => {
if (packs.length === 0) return; if (packs.length === 0) return;
const csvContent = generatorService.generateCsv(packs); const csvContent = generatorService.generateCsv(packs);
@@ -335,11 +438,15 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
setInputText(''); setInputText('');
setRawScryfallData(null); setRawScryfallData(null);
setProcessedData(null); setProcessedData(null);
setProcessedData(null); setAvailableLands([]);
setSelectedSets([]); setSelectedSets([]);
localStorage.removeItem('cube_inputText'); localStorage.removeItem('cube_inputText');
localStorage.removeItem('cube_rawScryfallData'); localStorage.removeItem('cube_rawScryfallData');
localStorage.removeItem('cube_selectedSets'); localStorage.removeItem('cube_selectedSets');
localStorage.removeItem('cube_viewMode');
localStorage.removeItem('cube_gameTypeFilter');
setViewMode('list');
setGameTypeFilter('all');
// We keep filters and settings as they are user preferences // We keep filters and settings as they are user preferences
} }
}; };
@@ -590,17 +697,27 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
{sourceMode === 'set' && ( {sourceMode === 'set' && (
<div className="mb-4"> <div className="mb-4">
<label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Quantity</label> <label className="text-xs font-bold text-slate-400 uppercase mb-1 block">Quantity</label>
<div className="flex items-center gap-2 bg-slate-800 p-2 rounded border border-slate-700"> <div className="flex items-center gap-3 bg-slate-800 p-2 rounded border border-slate-700">
<input <div className="flex items-center gap-1">
type="number" <button
min={1} onClick={() => setNumBoxes(prev => Math.max(1, prev - 1))}
max={20} disabled={numBoxes <= 1 || loading}
value={numBoxes} className="p-1.5 rounded bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-white"
onChange={(e) => setNumBoxes(parseInt(e.target.value))} >
className="w-16 bg-slate-700 border-none rounded p-1 text-center text-white font-mono" <Minus className="w-4 h-4" />
disabled={loading} </button>
/> <span className="w-8 text-center font-mono font-bold text-white text-lg">{numBoxes}</span>
<span className="text-slate-300 text-xs">Boxes ({numBoxes * 36} Packs)</span> <button
onClick={() => setNumBoxes(prev => Math.min(10, prev + 1))}
disabled={numBoxes >= 10 || loading}
className="p-1.5 rounded bg-slate-700 hover:bg-slate-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-white"
>
<Plus className="w-4 h-4" />
</button>
</div>
<span className="text-slate-400 text-xs font-medium border-l border-slate-700 pl-3">
<span className="text-white font-bold">{numBoxes * 36}</span> Packs
</span>
</div> </div>
</div> </div>
)} )}
@@ -663,6 +780,14 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
> >
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span> <Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
</button> </button>
<button
onClick={handleStartSoloTest}
disabled={loading}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
title="Test a randomized deck from these packs right now"
>
<PlayCircle className="w-4 h-4 text-emerald-400" /> <span className="hidden sm:inline">Test Solo</span>
</button>
<button <button
onClick={handleExportCsv} onClick={handleExportCsv}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in" className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { Save, Layers, Clock } from 'lucide-react'; import { Save, Layers, Clock } from 'lucide-react';
@@ -7,35 +7,117 @@ interface DeckBuilderViewProps {
roomId: string; roomId: string;
currentPlayerId: string; currentPlayerId: string;
initialPool: any[]; initialPool: any[];
availableBasicLands?: any[];
} }
export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool }) => { export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, availableBasicLands = [] }) => {
const [timer, setTimer] = useState(45 * 60); // 45 minutes // Unlimited Timer (Static for now)
const [timer] = useState<string>("Unlimited");
const [pool, setPool] = useState<any[]>(initialPool); const [pool, setPool] = useState<any[]>(initialPool);
const [deck, setDeck] = useState<any[]>([]); const [deck, setDeck] = useState<any[]>([]);
const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 }); const [lands, setLands] = useState({ Plains: 0, Island: 0, Swamp: 0, Mountain: 0, Forest: 0 });
const [hoveredCard, setHoveredCard] = useState<any>(null);
/*
// Disable timer countdown
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setTimer(t => t > 0 ? t - 1 : 0); setTimer(t => t > 0 ? t - 1 : 0);
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
*/
const formatTime = (seconds: number) => { // --- Land Advice Logic ---
const m = Math.floor(seconds / 60); const landSuggestion = React.useMemo(() => {
const s = seconds % 60; const targetLands = 17;
return `${m}:${s < 10 ? '0' : ''}${s}`; // 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) => { const addToDeck = (card: any) => {
setPool(prev => prev.filter(c => c !== card)); setPool(prev => prev.filter(c => c.id !== card.id));
setDeck(prev => [...prev, card]); 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) => { const removeFromDeck = (card: any) => {
setDeck(prev => prev.filter(c => c !== card)); setDeck(prev => prev.filter(c => c.id !== card.id));
setPool(prev => [...prev, card]);
if (card.id.startsWith('land-')) {
// Just delete
} else {
setPool(prev => [...prev, card]);
}
}; };
const handleLandChange = (type: string, delta: number) => { const handleLandChange = (type: string, delta: number) => {
@@ -43,9 +125,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool })
}; };
const submitDeck = () => { const submitDeck = () => {
// Construct final deck list including lands const genericLandCards = Object.entries(lands).flatMap(([type, count]) => {
const landCards = Object.entries(lands).flatMap(([type, count]) => {
// Placeholder images for basic lands for now or just generic objects
const landUrlMap: any = { const landUrlMap: any = {
Plains: "https://cards.scryfall.io/normal/front/d/1/d1ea1858-ad25-4d13-9860-25c898b02c42.jpg", 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", 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]; const fullDeck = [...deck, ...genericLandCards];
// 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)
socketService.socket.emit('player_ready', { deck: fullDeck }); socketService.socket.emit('player_ready', { deck: fullDeck });
}; };
const sortedLands = React.useMemo(() => {
return [...(availableBasicLands || [])].sort((a, b) => a.name.localeCompare(b.name));
}, [availableBasicLands]);
return ( return (
<div className="flex-1 w-full flex h-full bg-slate-900 text-white"> <div className="flex-1 w-full flex h-full bg-slate-900 text-white">
{/* Left: Pool */} {/* Column 1: Zoom Sidebar */}
<div className="w-1/2 p-4 flex flex-col border-r border-slate-700"> <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"> <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> <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>
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg"> <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"> <div className="flex flex-wrap gap-2 justify-center content-start">
{pool.map((card, i) => ( {pool.map((card) => (
<img <img
key={card.id + i} key={card.id}
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal} 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)} onClick={() => addToDeck(card)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
title={card.name}
/> />
))} ))}
</div> </div>
</div> </div>
</div> </div>
{/* Right: Deck & Lands */} {/* Column 3: Deck & Lands */}
<div className="w-1/2 p-4 flex flex-col"> <div className="flex-1 p-4 flex flex-col min-w-0">
<div className="flex justify-between items-center mb-4"> <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> <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"> <div className="flex items-center gap-4">
@@ -121,7 +213,7 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool })
</div> </div>
<button <button
onClick={submitDeck} 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 <Save className="w-4 h-4" /> Submit Deck
</button> </button>
@@ -129,42 +221,113 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool })
</div> </div>
{/* Deck View */} {/* Deck View */}
<div className="flex-1 overflow-y-auto p-2 bg-slate-950/50 rounded-lg mb-4"> <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"> <div className="flex flex-wrap gap-2 justify-center content-start">
{deck.map((card, i) => ( {deck.map((card) => (
<img <img
key={card.id + i} key={card.id}
src={card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal} 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)} onClick={() => removeFromDeck(card)}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
title={card.name}
/> />
))} ))}
{/* Visual representation of lands? Maybe just count for now */}
</div> </div>
</div> </div>
{/* Land Station */} <div className="flex flex-col gap-2">
<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> {/* Advice Panel */}
<div className="flex justify-around items-center"> <div className="bg-slate-800 rounded-lg p-3 border border-slate-700 flex justify-between items-center">
{Object.keys(lands).map(type => ( <div className="flex flex-col">
<div key={type} className="flex flex-col items-center gap-1"> <span className="text-xs text-slate-400 font-bold uppercase flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs border-2 <Layers className="w-3 h-3 text-emerald-400" /> Land Advisor (Target: 17)
${type === 'Plains' ? 'bg-amber-100 border-amber-300 text-amber-900' : ''} </span>
${type === 'Island' ? 'bg-blue-100 border-blue-300 text-blue-900' : ''} <div className="text-xs text-slate-500 mt-1">
${type === 'Swamp' ? 'bg-purple-100 border-purple-300 text-purple-900' : ''} Based on your deck's mana symbols.
${type === 'Mountain' ? 'bg-red-100 border-red-300 text-red-900' : ''}
${type === 'Forest' ? 'bg-green-100 border-green-300 text-green-900' : ''}
`}>
{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>
</div> </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-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-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' : ''}
${type === 'Mountain' ? 'bg-red-100 border-red-300 text-red-900' : ''}
${type === 'Forest' ? 'bg-green-100 border-green-300 text-green-900' : ''}
`}>
{type[0]}
</div>
<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>
</div> </div>

View File

@@ -6,10 +6,12 @@ interface CardComponentProps {
onDragStart: (e: React.DragEvent, cardId: string) => void; onDragStart: (e: React.DragEvent, cardId: string) => void;
onClick: (cardId: string) => void; onClick: (cardId: string) => void;
onContextMenu?: (cardId: string, e: React.MouseEvent) => void; onContextMenu?: (cardId: string, e: React.MouseEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
style?: React.CSSProperties; 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 ( return (
<div <div
draggable draggable
@@ -21,6 +23,8 @@ export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart,
onContextMenu(card.instanceId, e); onContextMenu(card.instanceId, e);
} }
}} }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={` className={`
relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none
${card.tapped ? 'rotate-90' : ''} ${card.tapped ? 'rotate-90' : ''}

View File

@@ -14,6 +14,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
const battlefieldRef = useRef<HTMLDivElement>(null); const battlefieldRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
const [viewingZone, setViewingZone] = useState<string | null>(null); const [viewingZone, setViewingZone] = useState<string | null>(null);
const [hoveredCard, setHoveredCard] = useState<CardInstance | null>(null);
useEffect(() => { useEffect(() => {
// Disable default context menu // Disable default context menu
@@ -22,6 +23,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
return () => document.removeEventListener('contextmenu', handleContext); return () => document.removeEventListener('contextmenu', handleContext);
}, []); }, []);
// ... (handlers remain the same) ...
const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => { const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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 myPlayer = gameState.players[currentPlayerId];
const opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId); const opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId);
const opponent = opponentId ? gameState.players[opponentId] : null; const opponent = opponentId ? gameState.players[opponentId] : null;
@@ -141,7 +133,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
return ( return (
<div <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')} onContextMenu={(e) => handleContextMenu(e, 'background')}
> >
<GameContextMenu <GameContextMenu
@@ -159,205 +151,260 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
/> />
)} )}
{/* Top Area: Opponent */} {/* Zoom Sidebar */}
<div className="flex-[2] relative flex flex-col pointer-events-none"> <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">
{/* Opponent Hand (Visual) */} {hoveredCard ? (
<div className="absolute top-[-40px] left-0 right-0 flex justify-center -space-x-4 opacity-70"> <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']">
{oppHand.map((_, i) => ( <img
<div key={i} className="w-16 h-24 bg-slate-800 border border-slate-600 rounded shadow-lg transform rotate-180"></div> src={hoveredCard.imageUrl}
))} alt={hoveredCard.name}
</div> 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>
{/* Opponent Info Bar */} {hoveredCard.manaCost && (
<div className="absolute top-4 left-4 z-10 flex items-center space-x-4 pointer-events-auto bg-black/50 p-2 rounded-lg backdrop-blur-sm border border-slate-700"> <p className="text-sm text-slate-400 mt-1 font-mono tracking-widest">{hoveredCard.manaCost}</p>
<div className="flex flex-col"> )}
<span className="font-bold text-lg text-red-400">{opponent?.name || 'Waiting...'}</span>
<div className="flex gap-2 text-xs text-slate-400"> {hoveredCard.typeLine && (
<span>Hand: {oppHand.length}</span> <div className="text-xs text-emerald-400 uppercase tracking-wider font-bold mt-2 border-b border-white/10 pb-2 mb-3">
<span>Lib: {oppLibrary.length}</span> {hoveredCard.typeLine}
<span>Grave: {oppGraveyard.length}</span> </div>
<span>Exile: {oppExile.length}</span> )}
{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> </div>
<div className="text-3xl font-bold text-white">{opponent?.life}</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>
{/* Opponent Battlefield (Perspective Reversed or specific layout) */} {/* Main Game Area */}
{/* For now, we place it "at the back" of the table. */} <div className="flex-1 flex flex-col h-full relative">
<div className="flex-1 w-full relative perspective-1000">
<div {/* Top Area: Opponent */}
className="w-full h-full relative" <div className="flex-[2] relative flex flex-col pointer-events-none">
style={{ {/* Opponent Hand (Visual) */}
transform: 'rotateX(-20deg) scale(0.9)', <div className="absolute top-[-40px] left-0 right-0 flex justify-center -space-x-4 opacity-70">
transformOrigin: 'center bottom', {oppHand.map((_, i) => (
}} <div key={i} className="w-16 h-24 bg-slate-800 border border-slate-600 rounded shadow-lg transform rotate-180"></div>
>
{oppBattlefield.map(card => (
<div
key={card.instanceId}
className="absolute transition-all duration-300 ease-out"
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
}}
>
<CardComponent
card={card}
// Opponent cards shouldn't necessarily be draggable by me, but depends on game rules.
// Usually not.
onDragStart={() => { }}
onClick={() => { }} // Maybe inspect?
/>
</div>
))} ))}
</div> </div>
</div>
</div>
{/* Middle Area: My Battlefield (The Table) */} {/* Opponent Info Bar */}
<div <div className="absolute top-4 left-4 z-10 flex items-center space-x-4 pointer-events-auto bg-black/50 p-2 rounded-lg backdrop-blur-sm border border-slate-700">
className="flex-[4] relative perspective-1000 z-10" <div className="flex flex-col">
ref={battlefieldRef} <span className="font-bold text-lg text-red-400">{opponent?.name || 'Waiting...'}</span>
onDragOver={handleDragOver} <div className="flex gap-2 text-xs text-slate-400">
onDrop={(e) => handleDrop(e, 'battlefield')} <span>Hand: {oppHand.length}</span>
> <span>Lib: {oppLibrary.length}</span>
<div <span>Grave: {oppGraveyard.length}</span>
className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner" <span>Exile: {oppExile.length}</span>
style={{ </div>
transform: 'rotateX(25deg)', </div>
transformOrigin: 'center 40%', /* Pivot point */ <div className="text-3xl font-bold text-white">{opponent?.life}</div>
boxShadow: 'inset 0 0 100px rgba(0,0,0,0.8)' </div>
}}
>
{/* Battlefield Texture/Grid (Optional) */}
<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 => ( {/* Opponent Battlefield */}
<div className="flex-1 w-full relative perspective-1000">
<div <div
key={card.instanceId} className="w-full h-full relative"
className="absolute transition-all duration-200"
style={{ style={{
left: `${card.position?.x || Math.random() * 80}%`, transform: 'rotateX(-20deg) scale(0.9)',
top: `${card.position?.y || Math.random() * 80}%`, transformOrigin: 'center bottom',
zIndex: card.position?.z ?? (Math.floor((card.position?.y || 0)) + 10),
}} }}
> >
<CardComponent {oppBattlefield.map(card => (
card={card} <div
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)} key={card.instanceId}
onClick={toggleTap} className="absolute transition-all duration-300 ease-out"
onContextMenu={(id, e) => { style={{
handleContextMenu(e, 'card', id); left: `${card.position?.x || 50}%`,
}} top: `${card.position?.y || 50}%`,
/> zIndex: Math.floor((card.position?.y || 0)),
</div> }}
))} >
<CardComponent
{myBattlefield.length === 0 && ( card={card}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> onDragStart={() => { }}
<span className="text-white/10 text-4xl font-bold uppercase tracking-widest">Battlefield</span> onClick={() => { }}
</div> onMouseEnter={() => setHoveredCard(card)}
)} onMouseLeave={() => setHoveredCard(null)}
</div> />
</div> </div>
))}
{/* Bottom Area: Controls & Hand */}
<div className="h-48 relative z-20 flex bg-gradient-to-t from-black to-slate-900/80 backdrop-blur-md shadow-[0_-5px_20px_rgba(0,0,0,0.5)]">
{/* Left Controls: Library/Grave */}
<div className="w-40 p-2 flex flex-col gap-2 items-center justify-center border-r border-white/10">
<div
className="group relative w-16 h-24 bg-slate-800 rounded border border-slate-600 cursor-pointer shadow-lg transition-transform hover:-translate-y-1 hover:shadow-cyan-500/20"
onClick={() => socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })}
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
>
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
{/* Deck look */}
<div className="absolute top-[-2px] left-[-2px] right-[-2px] bottom-[2px] bg-slate-700 rounded z-[-1]"></div>
<div className="absolute top-[-4px] left-[-4px] right-[-4px] bottom-[4px] bg-slate-800 rounded z-[-2]"></div>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-xs font-bold text-slate-300 shadow-black drop-shadow-md">Library</span>
<span className="text-lg font-bold text-white shadow-black drop-shadow-md">{myLibrary.length}</span>
</div>
</div>
<div
className="w-16 h-24 border-2 border-dashed border-slate-600 rounded flex items-center justify-center mt-2 transition-colors hover:border-slate-400 hover:bg-white/5"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'graveyard')}
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')}
>
<div className="text-center">
<span className="block text-slate-500 text-[10px] uppercase">Graveyard</span>
<span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Hand Area */} {/* Middle Area: My Battlefield (The Table) */}
<div <div
className="flex-1 relative flex items-end justify-center px-4 pb-2" className="flex-[4] relative perspective-1000 z-10"
ref={battlefieldRef}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'hand')} onDrop={(e) => handleDrop(e, 'battlefield')}
> >
<div className="flex justify-center -space-x-12 w-full h-full items-end pb-4 perspective-500"> <div
{myHand.map((card, index) => ( className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner"
style={{
transform: 'rotateX(25deg)',
transformOrigin: 'center 40%',
boxShadow: 'inset 0 0 100px rgba(0,0,0,0.8)'
}}
>
{/* 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 => (
<div <div
key={card.instanceId} key={card.instanceId}
className="transition-all duration-300 hover:-translate-y-12 hover:scale-110 hover:z-50 hover:rotate-0 origin-bottom" className="absolute transition-all duration-200"
style={{ style={{
transform: `rotate(${(index - (myHand.length - 1) / 2) * 5}deg) translateY(${Math.abs(index - (myHand.length - 1) / 2) * 5}px)`, left: `${card.position?.x || Math.random() * 80}%`,
zIndex: index top: `${card.position?.y || Math.random() * 80}%`,
zIndex: card.position?.z ?? (Math.floor((card.position?.y || 0)) + 10),
}} }}
> >
<CardComponent <CardComponent
card={card} card={card}
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)} onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
onClick={toggleTap} onClick={toggleTap}
onContextMenu={(id, e) => handleContextMenu(e, 'card', id)} onContextMenu={(id, e) => {
style={{ transformOrigin: 'bottom center' }} handleContextMenu(e, 'card', id);
}}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
/> />
</div> </div>
))} ))}
{myBattlefield.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-white/10 text-4xl font-bold uppercase tracking-widest">Battlefield</span>
</div>
)}
</div> </div>
</div> </div>
{/* Right Controls: Exile / Life */} {/* Bottom Area: Controls & Hand */}
<div className="w-40 p-2 flex flex-col gap-4 items-center justify-between border-l border-white/10 py-4"> <div className="h-48 relative z-20 flex bg-gradient-to-t from-black to-slate-900/80 backdrop-blur-md shadow-[0_-5px_20px_rgba(0,0,0,0.5)]">
<div className="text-center">
<div className="text-[10px] text-slate-400 uppercase tracking-wider mb-1">Your Life</div> {/* Left Controls: Library/Grave */}
<div className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-b from-emerald-400 to-emerald-700 drop-shadow-[0_2px_10px_rgba(16,185,129,0.3)]"> <div className="w-40 p-2 flex flex-col gap-2 items-center justify-center border-r border-white/10">
{myPlayer?.life} <div
className="group relative w-16 h-24 bg-slate-800 rounded border border-slate-600 cursor-pointer shadow-lg transition-transform hover:-translate-y-1 hover:shadow-cyan-500/20"
onClick={() => socketService.socket.emit('game_action', { action: { type: 'DRAW_CARD' } })}
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
>
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
{/* Deck look */}
<div className="absolute top-[-2px] left-[-2px] right-[-2px] bottom-[2px] bg-slate-700 rounded z-[-1]"></div>
<div className="absolute top-[-4px] left-[-4px] right-[-4px] bottom-[4px] bg-slate-800 rounded z-[-2]"></div>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-xs font-bold text-slate-300 shadow-black drop-shadow-md">Library</span>
<span className="text-lg font-bold text-white shadow-black drop-shadow-md">{myLibrary.length}</span>
</div>
</div> </div>
<div className="flex gap-1 mt-2 justify-center">
<button <div
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-red-500/20 text-red-500 border border-slate-700 hover:border-red-500 transition-colors flex items-center justify-center font-bold" className="w-16 h-24 border-2 border-dashed border-slate-600 rounded flex items-center justify-center mt-2 transition-colors hover:border-slate-400 hover:bg-white/5"
onClick={() => socketService.socket.emit('game_action', { action: { type: 'UPDATE_LIFE', amount: -1 } })} onDragOver={handleDragOver}
> onDrop={(e) => handleDrop(e, 'graveyard')}
- onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')}
</button> >
<button <div className="text-center">
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-emerald-500/20 text-emerald-500 border border-slate-700 hover:border-emerald-500 transition-colors flex items-center justify-center font-bold" <span className="block text-slate-500 text-[10px] uppercase">Graveyard</span>
onClick={() => socketService.socket.emit('game_action', { action: { type: 'UPDATE_LIFE', amount: 1 } })} <span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span>
> </div>
+
</button>
</div> </div>
</div> </div>
{/* Hand Area */}
<div <div
className="w-full text-center border-t border-white/5 pt-2 cursor-pointer hover:bg-white/5 rounded p-1" className="flex-1 relative flex items-end justify-center px-4 pb-2"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'exile')} onDrop={(e) => handleDrop(e, 'hand')}
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')}
> >
<span className="text-xs text-slate-500 block">Exile Drop Zone</span> <div className="flex justify-center -space-x-12 w-full h-full items-end pb-4 perspective-500">
<span className="text-lg font-bold text-slate-400">{myExile.length}</span> {myHand.map((card, index) => (
<div
key={card.instanceId}
className="transition-all duration-300 hover:-translate-y-12 hover:scale-110 hover:z-50 hover:rotate-0 origin-bottom"
style={{
transform: `rotate(${(index - (myHand.length - 1) / 2) * 5}deg) translateY(${Math.abs(index - (myHand.length - 1) / 2) * 5}px)`,
zIndex: index
}}
>
<CardComponent
card={card}
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
onClick={toggleTap}
onContextMenu={(id, e) => handleContextMenu(e, 'card', id)}
style={{ transformOrigin: 'bottom center' }}
onMouseEnter={() => setHoveredCard(card)}
onMouseLeave={() => setHoveredCard(null)}
/>
</div>
))}
</div>
</div> </div>
</div>
{/* Right Controls: Exile / Life */}
<div className="w-40 p-2 flex flex-col gap-4 items-center justify-between border-l border-white/10 py-4">
<div className="text-center">
<div className="text-[10px] text-slate-400 uppercase tracking-wider mb-1">Your Life</div>
<div className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-b from-emerald-400 to-emerald-700 drop-shadow-[0_2px_10px_rgba(16,185,129,0.3)]">
{myPlayer?.life}
</div>
<div className="flex gap-1 mt-2 justify-center">
<button
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-red-500/20 text-red-500 border border-slate-700 hover:border-red-500 transition-colors flex items-center justify-center font-bold"
onClick={() => socketService.socket.emit('game_action', { action: { type: 'UPDATE_LIFE', amount: -1 } })}
>
-
</button>
<button
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-emerald-500/20 text-emerald-500 border border-slate-700 hover:border-emerald-500 transition-colors flex items-center justify-center font-bold"
onClick={() => socketService.socket.emit('game_action', { action: { type: 'UPDATE_LIFE', amount: 1 } })}
>
+
</button>
</div>
</div>
<div
className="w-full text-center border-t border-white/5 pt-2 cursor-pointer hover:bg-white/5 rounded p-1"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'exile')}
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')}
>
<span className="text-xs text-slate-500 block">Exile Drop Zone</span>
<span className="text-lg font-bold text-slate-400">{myExile.length}</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { Users, MessageSquare, Send, Play, Copy, Check, Layers, LogOut } from 'lucide-react'; import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut } from 'lucide-react';
import { Modal } from '../../components/Modal'; import { Modal } from '../../components/Modal';
import { GameView } from '../game/GameView'; import { GameView } from '../game/GameView';
import { DraftView } from '../draft/DraftView'; import { DraftView } from '../draft/DraftView';
@@ -26,6 +26,7 @@ interface Room {
id: string; id: string;
hostId: string; hostId: string;
players: Player[]; players: Player[];
basicLands?: any[];
status: string; status: string;
messages: ChatMessage[]; messages: ChatMessage[];
} }
@@ -148,20 +149,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
} }
}; };
const handleStartGame = () => {
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"
: "https://cards.scryfall.io/normal/front/f/2/f29ba16f-c8fb-42fe-aabf-87089cb211a7.jpg"
}
}));
const decks = room.players.reduce((acc, p) => ({ ...acc, [p.id]: testDeck }), {});
socketService.socket.emit('start_game', { roomId: room.id, decks });
};
const handleStartDraft = () => { const handleStartDraft = () => {
socketService.socket.emit('start_draft', { roomId: room.id }); socketService.socket.emit('start_draft', { roomId: room.id });
@@ -205,7 +193,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
} }
const myPool = draftState.players[currentPlayerId]?.pool || []; 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 ( return (
@@ -236,16 +224,9 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
disabled={room.status !== 'waiting'} disabled={room.status !== 'waiting'}
className="px-8 py-3 bg-purple-600 hover:bg-purple-500 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg shadow-purple-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="px-8 py-3 bg-purple-600 hover:bg-purple-500 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg shadow-purple-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Layers className="w-5 h-5" /> Start Real Draft <Layers className="w-5 h-5" /> Start Draft
</button>
<span className="text-xs text-slate-500 text-center">- OR -</span>
<button
onClick={handleStartGame}
disabled={room.status !== 'waiting'}
className="px-8 py-3 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed text-xs uppercase tracking-wider"
>
<Play className="w-4 h-4" /> Quick Play (Test Decks)
</button> </button>
</div> </div>
)} )}
</div> </div>
@@ -267,6 +248,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
{room.players.map(p => { {room.players.map(p => {
const isReady = (p as any).ready; const isReady = (p as any).ready;
const isMe = p.id === currentPlayerId; const isMe = p.id === currentPlayerId;
const isSolo = room.players.length === 1 && room.status === 'playing';
return ( return (
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50 group"> <div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50 group">
@@ -286,14 +268,18 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
</div> </div>
</div> </div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className={`flex gap-2 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
{isMe && ( {isMe && (
<button <button
onClick={onExit} onClick={onExit}
className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-red-400" className={`p-1 rounded flex items-center gap-2 transition-colors ${isSolo
title="Leave Room" ? 'bg-red-900/40 text-red-200 hover:bg-red-900/60 px-3 py-1.5'
: 'hover:bg-slate-700 text-slate-400 hover:text-red-400'
}`}
title={isSolo ? "End Solo Session" : "Leave Room"}
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
{isSolo && <span className="text-xs font-bold">End Test</span>}
</button> </button>
)} )}
{isMeHost && !isMe && ( {isMeHost && !isMe && (

View File

@@ -3,13 +3,15 @@ import React, { useState } from 'react';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { GameRoom } from './GameRoom'; import { GameRoom } from './GameRoom';
import { Pack } from '../../services/PackGeneratorService'; import { Pack } from '../../services/PackGeneratorService';
import { Users, PlusCircle, LogIn, AlertCircle, Loader2 } from 'lucide-react'; import { Users, PlusCircle, LogIn, AlertCircle, Loader2, Package, Check } from 'lucide-react';
import { Modal } from '../../components/Modal';
interface LobbyManagerProps { interface LobbyManagerProps {
generatedPacks: Pack[]; 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 [activeRoom, setActiveRoom] = useState<any>(null);
const [playerName, setPlayerName] = useState(() => localStorage.getItem('player_name') || ''); const [playerName, setPlayerName] = useState(() => localStorage.getItem('player_name') || '');
const [joinRoomId, setJoinRoomId] = useState(''); const [joinRoomId, setJoinRoomId] = useState('');
@@ -30,35 +32,32 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
localStorage.setItem('player_name', playerName); localStorage.setItem('player_name', playerName);
}, [playerName]); }, [playerName]);
const [showBoxSelection, setShowBoxSelection] = useState(false);
const [availableBoxes, setAvailableBoxes] = useState<{ id: string, title: string, packs: Pack[], setCode: string, packCount: number }[]>([]);
const connect = () => { const connect = () => {
if (!socketService.socket.connected) { if (!socketService.socket.connected) {
socketService.connect(); socketService.connect();
} }
}; };
const handleCreateRoom = async () => { const executeCreateRoom = async (packsToUse: Pack[]) => {
if (!playerName) {
setError('Please enter your name');
return;
}
if (generatedPacks.length === 0) {
setError('No packs generated! Please go to Draft Management and generate packs first.');
return;
}
setLoading(true); setLoading(true);
setError(''); setError('');
connect(); connect();
try { try {
// Collect all cards // Collect all cards for caching (packs + basic lands)
const allCards = generatedPacks.flatMap(p => p.cards); const allCards = packsToUse.flatMap(p => p.cards);
const allCardsAndLands = [...allCards, ...availableLands];
// Deduplicate by Scryfall ID // 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) // Prepare payload for server (generic structure expected by CardService)
const cardsToCache = uniqueCards.map(c => ({ const cardsToCache = uniqueCards.map(c => ({
id: c.scryfallId, id: c.scryfallId,
set: c.setCode, // Required for folder organization
image_uris: { normal: c.image } image_uris: { normal: c.image }
})); }));
@@ -76,23 +75,29 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
const cacheResult = await cacheResponse.json(); const cacheResult = await cacheResponse.json();
console.log('Cached result:', cacheResult); 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. // Note: For multiplayer, clients need to access this URL.
const baseUrl = `${window.location.protocol}//${window.location.host}/cards/images`; const baseUrl = `${window.location.protocol}//${window.location.host}/cards/images`;
const updatedPacks = generatedPacks.map(pack => ({ const updatedPacks = packsToUse.map(pack => ({
...pack, ...pack,
cards: pack.cards.map(c => ({ cards: pack.cards.map(c => ({
...c, ...c,
// Update the single image property used by DraftCard // 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', { const response = await socketService.emitPromise('create_room', {
hostId: playerId, hostId: playerId,
hostName: playerName, hostName: playerName,
packs: updatedPacks packs: updatedPacks,
basicLands: updatedBasicLands
}); });
if (response.success) { if (response.success) {
@@ -105,9 +110,68 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
setError(err.message || 'Connection error'); setError(err.message || 'Connection error');
} finally { } finally {
setLoading(false); setLoading(false);
setShowBoxSelection(false);
} }
}; };
const handleCreateRoom = async () => {
if (!playerName) {
setError('Please enter your name');
return;
}
if (generatedPacks.length === 0) {
setError('No packs generated! Please go to Draft Management and generate packs first.');
return;
}
// Logic to detect Multiple Boxes
// 1. Group by Set Name
const packsBySet: Record<string, Pack[]> = {};
generatedPacks.forEach(p => {
const key = p.setName;
if (!packsBySet[key]) packsBySet[key] = [];
packsBySet[key].push(p);
});
const boxes: { id: string, title: string, packs: Pack[], setCode: string, packCount: number }[] = [];
// Sort sets alphabetically
Object.keys(packsBySet).sort().forEach(setName => {
const setPacks = packsBySet[setName];
const BOX_SIZE = 36;
// Split into chunks of 36
for (let i = 0; i < setPacks.length; i += BOX_SIZE) {
const chunk = setPacks.slice(i, i + BOX_SIZE);
const boxNum = Math.floor(i / BOX_SIZE) + 1;
const setCode = (chunk[0].cards[0]?.setCode || 'unk').toLowerCase();
boxes.push({
id: `${setCode}-${boxNum}-${Date.now()}`, // Unique ID
title: `${setName} - Box ${boxNum}`,
packs: chunk,
setCode: setCode,
packCount: chunk.length
});
}
});
// Strategy: If we have multiple boxes, or if we have > 36 packs but maybe not multiple "boxes" (e.g. 50 packs of mixed),
// we should interpret them.
// The prompt says: "more than 1 box has been generated".
// If I generate 2 boxes (72 packs), `boxes` array will have length 2.
// If I generate 1 box (36 packs), `boxes` array will have length 1.
if (boxes.length > 1) {
setAvailableBoxes(boxes);
setShowBoxSelection(true);
return;
}
// If only 1 box (or partial), just use all packs
executeCreateRoom(generatedPacks);
};
const handleJoinRoom = async () => { const handleJoinRoom = async () => {
if (!playerName) { if (!playerName) {
setError('Please enter your name'); setError('Please enter your name');
@@ -306,6 +370,62 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) =>
</div> </div>
</div> </div>
</div> </div>
{/* Box Selection Modal */}
<Modal
isOpen={showBoxSelection}
onClose={() => setShowBoxSelection(false)}
title="Select Sealed Box"
message="Multiple boxes available. Please select a sealed box to open for this draft."
type="info"
maxWidth="max-w-3xl"
>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto custom-scrollbar p-1">
{availableBoxes.map(box => (
<button
key={box.id}
onClick={() => executeCreateRoom(box.packs)}
className="group relative flex flex-col items-center p-6 bg-slate-900 border border-slate-700 rounded-xl hover:border-purple-500 hover:bg-slate-800 transition-all shadow-xl hover:shadow-purple-900/20"
>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="bg-purple-600 rounded-full p-1 shadow-lg shadow-purple-500/50">
<Check className="w-4 h-4 text-white" />
</div>
</div>
{/* Box Graphic simulation */}
<div className="w-24 h-32 mb-4 relative perspective-1000 group-hover:scale-105 transition-transform duration-300">
<div className="absolute inset-0 bg-slate-800 rounded border border-slate-600 transform rotate-y-12 translate-z-4 shadow-2xl flex items-center justify-center overflow-hidden">
{/* Set Icon as Box art */}
<img
src={`https://svgs.scryfall.io/sets/${box.setCode}.svg?1734307200`}
alt={box.setCode}
className="w-16 h-16 opacity-20 group-hover:opacity-50 transition-opacity invert"
/>
<Package className="absolute bottom-2 right-2 w-6 h-6 text-slate-500" />
</div>
<div className="absolute inset-0 bg-gradient-to-br from-transparent to-black/50 pointer-events-none rounded"></div>
</div>
<h3 className="font-bold text-white text-center text-lg leading-tight mb-1 group-hover:text-purple-400 transition-colors">
{box.title}
</h3>
<div className="flex items-center gap-2 text-xs text-slate-500 font-mono uppercase tracking-wider">
<span className="bg-slate-800 px-2 py-0.5 rounded border border-slate-700">{box.setCode.toUpperCase()}</span>
<span></span>
<span>{box.packCount} Packs</span>
</div>
</button>
))}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setShowBoxSelection(false)}
className="px-4 py-2 text-slate-400 hover:text-white transition-colors text-sm font-bold"
>
Cancel
</button>
</div>
</Modal>
</div> </div>
); );
}; };

View File

@@ -11,6 +11,9 @@ export interface CardInstance {
position: { x: number; y: number; z: number }; // For freeform placement position: { x: number; y: number; z: number }; // For freeform placement
counters: { type: string; count: number }[]; counters: { type: string; count: number }[];
ptModification: { power: number; toughness: number }; ptModification: { power: number; toughness: number };
typeLine?: string;
oracleText?: string;
manaCost?: string;
} }
export interface PlayerState { export interface PlayerState {

View File

@@ -17,6 +17,7 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const httpServer = createServer(app); const httpServer = createServer(app);
const io = new Server(httpServer, { const io = new Server(httpServer, {
maxHttpBufferSize: 300 * 1024 * 1024, // 300MB
cors: { cors: {
origin: "*", // Adjust for production, origin: "*", // Adjust for production,
methods: ["GET", "POST"] methods: ["GET", "POST"]
@@ -150,8 +151,20 @@ app.post('/api/packs/generate', async (req: Request, res: Response) => {
const { pools, sets } = packGeneratorService.processCards(poolCards, activeFilters); 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); const packs = packGeneratorService.generatePacks(pools, sets, settings, numPacks || 108);
res.json(packs); res.json({ packs, basicLands: uniqueBasicLands });
} catch (e: any) { } catch (e: any) {
console.error("Generation error", e); console.error("Generation error", e);
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });
@@ -194,7 +207,10 @@ const draftInterval = setInterval(() => {
oracleId: card.oracle_id || card.id, oracleId: card.oracle_id || card.id,
name: card.name, name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", 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 || ''
}); });
}); });
} }
@@ -212,8 +228,8 @@ io.on('connection', (socket) => {
// Timer management // Timer management
// Timer management removed (Global loop handled) // Timer management removed (Global loop handled)
socket.on('create_room', ({ hostId, hostName, packs }, callback) => { socket.on('create_room', ({ hostId, hostName, packs, basicLands }, callback) => {
const room = roomManager.createRoom(hostId, hostName, packs, socket.id); // Add socket.id const room = roomManager.createRoom(hostId, hostName, packs, basicLands || [], socket.id);
socket.join(room.id); socket.join(room.id);
console.log(`Room created: ${room.id} by ${hostName}`); console.log(`Room created: ${room.id} by ${hostName}`);
callback({ success: true, room }); callback({ success: true, room });
@@ -278,9 +294,16 @@ io.on('connection', (socket) => {
if (currentDraft) socket.emit('draft_update', currentDraft); if (currentDraft) socket.emit('draft_update', currentDraft);
} }
// Prepare Game State if exists
let currentGame = null;
if (room.status === 'playing') {
currentGame = gameManager.getGame(roomId);
if (currentGame) socket.emit('game_update', currentGame);
}
// ACK Callback // ACK Callback
if (typeof callback === 'function') { if (typeof callback === 'function') {
callback({ success: true, room, draftState: currentDraft }); callback({ success: true, room, draftState: currentDraft, gameState: currentGame });
} }
} else { } else {
// Room found but player not in it? Or room not found? // Room found but player not in it? Or room not found?
@@ -403,7 +426,10 @@ io.on('connection', (socket) => {
oracleId: card.oracle_id || card.id, oracleId: card.oracle_id || card.id,
name: card.name, name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", 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 || ''
}); });
}); });
} }
@@ -427,7 +453,10 @@ io.on('connection', (socket) => {
oracleId: card.id, oracleId: card.id,
name: card.name, name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", 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 || ''
}); });
}); });
} }
@@ -455,7 +484,10 @@ io.on('connection', (socket) => {
oracleId: card.oracle_id || card.id, oracleId: card.oracle_id || card.id,
name: card.name, name: card.name,
imageUrl: card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "", 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') { } else if (draft.status === 'deck_building') {
// Check global deck building timer (e.g., 120 seconds) // 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)) { if (draft.startTime && (now > draft.startTime + DECK_BUILDING_Duration)) {
draft.status = 'complete'; // Signal that time is up draft.status = 'complete'; // Signal that time is up
updates.push({ roomId, draft }); updates.push({ roomId, draft });

View File

@@ -12,6 +12,9 @@ interface CardInstance {
position: { x: number; y: number; z: number }; // For freeform placement position: { x: number; y: number; z: number }; // For freeform placement
counters: { type: string; count: number }[]; counters: { type: string; count: number }[];
ptModification: { power: number; toughness: number }; ptModification: { power: number; toughness: number };
typeLine?: string;
oracleText?: string;
manaCost?: string;
} }
interface PlayerState { interface PlayerState {

View File

@@ -21,6 +21,7 @@ interface Room {
hostId: string; hostId: string;
players: Player[]; players: Player[];
packs: any[]; // Store generated packs (JSON) packs: any[]; // Store generated packs (JSON)
basicLands?: any[];
status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished'; status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished';
messages: ChatMessage[]; messages: ChatMessage[];
maxPlayers: number; maxPlayers: number;
@@ -29,13 +30,14 @@ interface Room {
export class RoomManager { export class RoomManager {
private rooms: Map<string, Room> = new Map(); 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 roomId = Math.random().toString(36).substring(2, 8).toUpperCase();
const room: Room = { const room: Room = {
id: roomId, id: roomId,
hostId, hostId,
players: [{ id: hostId, name: hostName, isHost: true, role: 'player', ready: false, socketId, isOffline: false }], players: [{ id: hostId, name: hostName, isHost: true, role: 'player', ready: false, socketId, isOffline: false }],
packs, packs,
basicLands,
status: 'waiting', status: 'waiting',
messages: [], messages: [],
maxPlayers: 8 maxPlayers: 8

View File

@@ -15,6 +15,8 @@ export interface DraftCard {
setCode: string; setCode: string;
setType: string; setType: string;
finish?: 'foil' | 'normal'; finish?: 'foil' | 'normal';
oracleText?: string;
manaCost?: string;
[key: string]: any; // Allow extended props [key: string]: any; // Allow extended props
} }
@@ -102,6 +104,8 @@ export class PackGeneratorService {
setCode: cardData.set, setCode: cardData.set,
setType: setType, setType: setType,
finish: cardData.finish || 'normal', 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 // Add to pools