Compare commits
6 Commits
4ff2eb0ef0
...
a0c3b7c59a
| Author | SHA1 | Date | |
|---|---|---|---|
| a0c3b7c59a | |||
| 0b374c7630 | |||
| 60c012cbb5 | |||
| 0fb330e10b | |||
| e13aa16766 | |||
| e5750d9729 |
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' : ''}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user