Compare commits

...

2 Commits

Author SHA1 Message Date
dd9f19aff7 feat: Introduce zone viewing overlay and add server-side zone management actions.
Some checks failed
Build and Deploy / build (push) Failing after 1m15s
2025-12-16 12:55:01 +01:00
b13627363f feat: Add deck tester feature to import custom deck lists and immediately start solo games. 2025-12-15 00:31:58 +01:00
10 changed files with 563 additions and 65 deletions

View File

@@ -2,6 +2,7 @@
## Active Plans
- [Enhance 3D Game View](./devlog/2025-12-14-235500_enhance_3d_game_view.md): Active. Transforming the battlefield into a fully immersive 3D environment.
- [Deck Tester Feature](./devlog/2025-12-15-002500_deck_tester_feature.md): Completed. Implemented a dedicated view to parse custom decks and instantly launch the 3D game sandbox.
- [Game Context Menu & Immersion](./devlog/2025-12-14-235000_game_context_menu.md): Completed. Implemented custom right-click menus and game-feel enhancements.
## Recent Completions

View File

@@ -0,0 +1,32 @@
# Deck Tester Feature Implementation
## Objective
Create a way to add a cards list to generate a deck and directly enter the game ui to test the imported deck, using the same exact game and battlefield of the draft.
## Implementation Details
### Frontend
1. **DeckTester Component (`src/client/src/modules/tester/DeckTester.tsx`)**:
- Created a new component that allows users to input a deck list (text area or file upload).
- Reused `CardParserService` and `ScryfallService` to parse the list and fetch card data.
- Implemented image caching logic (sending to `/api/cards/cache`).
- Connects to socket and emits `start_solo_test`.
- Upon success, switches view to `GameRoom` with the received `room` and `game` state.
2. **App Integration (`src/client/src/App.tsx`)**:
- Added a new "Deck Tester" tab to the main navigation.
- Uses the `Play` icon from lucide-react.
3. **GameRoom Enhancement (`src/client/src/modules/lobby/GameRoom.tsx`)**:
- Added `initialGameState` prop to allow initializing the `GameView` immediately without waiting for a socket update (handling potential race conditions or state sync delays).
### Backend
1. **Socket Event (`src/server/index.ts`)**:
- Added `start_solo_test` event handler.
- Creates a room with status `playing`.
- Initializes a game instance.
- Adds cards from the provided deck list to the game (library zone).
- Emits `room_update` and `game_update` to the client.
## Outcome
The user can now navigate to "Deck Tester", paste a deck list, and immediately enter the 3D Game View to test interactions on the battlefield. This reuses the entire Draft Game infrastructure, ensuring consistency.

View File

@@ -1,12 +1,13 @@
import React, { useState } from 'react';
import { Layers, Box, Trophy, Users } from 'lucide-react';
import { Layers, Box, Trophy, Users, Play } from 'lucide-react';
import { CubeManager } from './modules/cube/CubeManager';
import { TournamentManager } from './modules/tournament/TournamentManager';
import { LobbyManager } from './modules/lobby/LobbyManager';
import { DeckTester } from './modules/tester/DeckTester';
import { Pack } from './services/PackGeneratorService';
export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby'>('draft');
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>('draft');
const [generatedPacks, setGeneratedPacks] = useState<Pack[]>([]);
return (
@@ -34,6 +35,12 @@ export const App: React.FC = () => {
>
<Users className="w-4 h-4" /> <span className="hidden md:inline">Online Lobby</span>
</button>
<button
onClick={() => setActiveTab('tester')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'tester' ? 'bg-teal-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Play className="w-4 h-4" /> <span className="hidden md:inline">Deck Tester</span>
</button>
<button
onClick={() => setActiveTab('bracket')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
@@ -53,6 +60,7 @@ export const App: React.FC = () => {
/>
)}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />}
{activeTab === 'tester' && <DeckTester />}
{activeTab === 'bracket' && <TournamentManager />}
</main>
</div>

View File

@@ -1,12 +1,13 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { CardInstance } from '../../types/game';
interface ContextMenuRequest {
export interface ContextMenuRequest {
x: number;
y: number;
type: 'background' | 'card';
targetId?: string;
type: 'background' | 'card' | 'zone';
targetId?: string; // cardId or zoneName
card?: CardInstance;
zone?: string; // 'library', 'graveyard', 'exile', 'hand'
}
interface GameContextMenuProps {
@@ -16,7 +17,6 @@ interface GameContextMenuProps {
}
export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClose, onAction }) => {
const [submenu, setSubmenu] = useState<string | null>(null);
useEffect(() => {
const handleClickOutside = () => onClose();
@@ -33,9 +33,9 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
const style: React.CSSProperties = {
position: 'fixed',
top: request.y,
left: request.x,
zIndex: 9999, // Ensure it's above everything
top: Math.min(request.y, window.innerHeight - 300), // Prevent going off bottom
left: Math.min(request.x, window.innerWidth - 224), // Prevent going off right (w-56 = 224px)
zIndex: 9999,
};
// Prevent closing when clicking inside the menu
@@ -43,6 +43,131 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
e.stopPropagation();
};
const renderCardMenu = (card: CardInstance) => {
const zone = card.zone;
return (
<>
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1 flex justify-between items-center">
<span className="truncate max-w-[120px]">{card.name}</span>
<span className="text-[10px] bg-slate-800 px-1 rounded text-slate-400 capitalize">{zone}</span>
</div>
{/* Hand Menu */}
{zone === 'hand' && (
<>
<MenuItem label="Play (Battlefield)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield', position: { x: 50, y: 50 } })} />
<MenuItem label="Discard" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} />
<MenuItem label="Exile" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} />
<div className="h-px bg-slate-800 my-1 mx-2"></div>
<MenuItem label="To Library (Top)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'top' })} />
<MenuItem label="To Library (Bottom)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'bottom' })} />
</>
)}
{/* Battlefield Menu */}
{zone === 'battlefield' && (
<>
<MenuItem label="Tap / Untap" onClick={() => handleAction('TAP_CARD', { cardId: card.instanceId })} />
<MenuItem label={card.faceDown ? "Flip Face Up" : "Flip Face Down"} onClick={() => handleAction('FLIP_CARD', { cardId: card.instanceId })} />
<div className="relative group">
<MenuItem label="Add Counter ▸" onClick={() => { }} />
<div className="absolute left-full top-0 ml-1 w-40 bg-slate-900 border border-slate-700 rounded shadow-lg hidden group-hover:block z-50">
<MenuItem label="+1/+1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: 1 })} />
<MenuItem label="-1/-1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '-1/-1', amount: 1 })} />
<MenuItem label="Loyalty Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: 'loyalty', amount: 1 })} />
<MenuItem label="Remove Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} />
</div>
</div>
<MenuItem label="Clone (Copy)" onClick={() => handleAction('CREATE_TOKEN', {
tokenData: {
name: `${card.name} (Copy)`,
imageUrl: card.imageUrl,
power: card.ptModification?.power,
toughness: card.ptModification?.toughness
},
position: { x: (card.position.x || 50) + 2, y: (card.position.y || 50) + 2 }
})} />
<div className="h-px bg-slate-800 my-1 mx-2"></div>
<MenuItem label="To Hand" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} />
<MenuItem label="Destroy (Grave)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} />
<MenuItem label="Exile" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} />
<MenuItem label="To Library (Top)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'top' })} />
</>
)}
{/* Graveyard Menu */}
{zone === 'graveyard' && (
<>
<MenuItem label="Exile" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} />
<MenuItem label="Return to Hand" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} />
<MenuItem label="Return to Battlefield" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield' })} />
<MenuItem label="To Library (Bottom)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'bottom' })} />
</>
)}
{/* Exile Menu */}
{zone === 'exile' && (
<>
<MenuItem label="Return to Graveyard" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} />
<MenuItem label="Return to Battlefield" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield' })} />
<MenuItem label="Return to Hand" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} />
</>
)}
{/* Library Menu (if we ever show context menu for cards IN library view?) */}
{zone === 'library' && (
<>
<MenuItem label="Draw" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} />
</>
)}
<div className="h-px bg-slate-800 my-1 mx-2"></div>
<MenuItem
label="Delete Object"
className="text-red-500 hover:bg-red-900/30 hover:text-red-400"
onClick={() => handleAction('DELETE_CARD', { cardId: card.instanceId })}
/>
</>
);
};
const renderZoneMenu = (zone: string) => {
return (
<>
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
{zone} Zone
</div>
<MenuItem label={`View ${zone.charAt(0).toUpperCase() + zone.slice(1)}`} onClick={() => handleAction('VIEW_ZONE', { zone })} />
{zone === 'library' && (
<>
<MenuItem label="Draw Card" onClick={() => handleAction('DRAW_CARD')} />
<MenuItem label="Shuffle Library" onClick={() => handleAction('SHUFFLE_LIBRARY')} />
<MenuItem label="Mill 1 Card" onClick={() => handleAction('MILL_CARD', { amount: 1 })} />
</>
)}
{zone === 'graveyard' && (
<>
<MenuItem label="Exile All" onClick={() => handleAction('EXILE_GRAVEYARD')} />
<MenuItem label="Shuffle Graveyard" onClick={() => handleAction('SHUFFLE_GRAVEYARD')} />
</>
)}
{zone === 'exile' && (
<>
<MenuItem label="Shuffle Exile" onClick={() => handleAction('SHUFFLE_EXILE')} />
</>
)}
</>
);
};
return (
<div
style={style}
@@ -50,44 +175,9 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
onClick={onMenuClick}
onContextMenu={(e) => e.preventDefault()}
>
{request.type === 'card' && request.card && (
<>
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
{request.card.name}
</div>
<MenuItem label="Tap / Untap" onClick={() => handleAction('TAP_CARD', { cardId: request.targetId })} />
<MenuItem label={request.card.faceDown ? "Flip Face Up" : "Flip Face Down"} onClick={() => handleAction('FLIP_CARD', { cardId: request.targetId })} />
{request.type === 'card' && request.card && renderCardMenu(request.card)}
<div className="relative group">
<MenuItem label="Add Counter ▸" onClick={() => { }} onMouseEnter={() => setSubmenu('counter')} />
{/* Submenu */}
<div className="absolute left-full top-0 ml-1 w-40 bg-slate-900 border border-slate-700 rounded shadow-lg hidden group-hover:block">
<MenuItem label="+1/+1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '+1/+1', amount: 1 })} />
<MenuItem label="-1/-1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '-1/-1', amount: 1 })} />
<MenuItem label="Loyalty Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: 'loyalty', amount: 1 })} />
<MenuItem label="Remove Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '+1/+1', amount: -1 })} />
</div>
</div>
<MenuItem label="Clone (Copy)" onClick={() => handleAction('CREATE_TOKEN', {
tokenData: {
name: `${request.card?.name} (Copy)`,
imageUrl: request.card?.imageUrl,
power: request.card?.ptModification?.power,
toughness: request.card?.ptModification?.toughness
},
position: { x: (request.card?.position.x || 50) + 2, y: (request.card?.position.y || 50) + 2 }
})} />
<div className="h-px bg-slate-800 my-1 mx-2"></div>
<MenuItem
label="Delete Object"
className="text-red-500 hover:bg-red-900/30 hover:text-red-400"
onClick={() => handleAction('DELETE_CARD', { cardId: request.targetId })}
/>
</>
)}
{request.type === 'zone' && request.zone && renderZoneMenu(request.zone)}
{request.type === 'background' && (
<>
@@ -98,25 +188,25 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
label="Create Token (1/1)"
onClick={() => handleAction('CREATE_TOKEN', {
tokenData: { name: 'Soldier', power: 1, toughness: 1 },
// Convert click position to approximate percent if possible or center
// For now, simpler to spawn at center or random.
position: { x: Math.random() * 40 + 30, y: Math.random() * 40 + 30 }
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
})}
/>
<MenuItem
label="Create Token (2/2)"
onClick={() => handleAction('CREATE_TOKEN', {
tokenData: { name: 'Zombie', power: 2, toughness: 2, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' },
position: { x: Math.random() * 40 + 30, y: Math.random() * 40 + 30 }
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
})}
/>
<MenuItem
label="Create Treasure"
onClick={() => handleAction('CREATE_TOKEN', {
tokenData: { name: 'Treasure', power: 0, toughness: 0, imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg' },
position: { x: Math.random() * 40 + 30, y: Math.random() * 40 + 30 }
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
})}
/>
<div className="h-px bg-slate-800 my-1 mx-2"></div>
<MenuItem label="Untap All My Permanents" onClick={() => handleAction('UNTAP_ALL')} />
</>
)}
</div>

View File

@@ -2,7 +2,8 @@ import React, { useRef, useState, useEffect } from 'react';
import { GameState, CardInstance } from '../../types/game';
import { socketService } from '../../services/SocketService';
import { CardComponent } from './CardComponent';
import { GameContextMenu } from './GameContextMenu';
import { GameContextMenu, ContextMenuRequest } from './GameContextMenu';
import { ZoneOverlay } from './ZoneOverlay';
interface GameViewProps {
gameState: GameState;
@@ -11,7 +12,8 @@ interface GameViewProps {
export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }) => {
const battlefieldRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'background' | 'card'; targetId?: string; card?: CardInstance } | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
const [viewingZone, setViewingZone] = useState<string | null>(null);
useEffect(() => {
// Disable default context menu
@@ -20,30 +22,46 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
return () => document.removeEventListener('contextmenu', handleContext);
}, []);
const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card', targetId?: string) => {
const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => {
e.preventDefault();
const card = targetId ? gameState.cards[targetId] : undefined;
e.stopPropagation();
const card = (type === 'card' && targetId) ? gameState.cards[targetId] : undefined;
setContextMenu({
x: e.clientX,
y: e.clientY,
type,
targetId,
card
card,
zone: zoneName
});
};
const handleMenuAction = (actionType: string, payload: any) => {
// If creating token, inject current player ID as owner if not present
if (actionType === 'CREATE_TOKEN' && !payload.ownerId) {
payload.ownerId = currentPlayerId;
if (actionType === 'VIEW_ZONE') {
setViewingZone(payload.zone);
return;
}
// Default payload to object if undefined
const safePayload = payload || {};
// Inject currentPlayerId if not present (acts as actor)
if (!safePayload.playerId) {
safePayload.playerId = currentPlayerId;
}
// Inject ownerId if not present (useful for token creation etc)
if (!safePayload.ownerId) {
safePayload.ownerId = currentPlayerId;
}
socketService.socket.emit('game_action', {
roomId: gameState.roomId,
action: {
type: actionType,
...payload
...safePayload
}
});
};
@@ -135,6 +153,15 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
onAction={handleMenuAction}
/>
{viewingZone && (
<ZoneOverlay
zoneName={viewingZone}
cards={getCards(currentPlayerId, viewingZone)}
onClose={() => setViewingZone(null)}
onCardContextMenu={(e, cardId) => handleContextMenu(e, 'card', cardId)}
/>
)}
{/* Top Area: Opponent */}
<div className="flex-[2] relative flex flex-col pointer-events-none">
{/* Opponent Hand (Visual) */}
@@ -224,7 +251,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
onClick={toggleTap}
onContextMenu={(id, e) => {
e.stopPropagation(); // Stop bubbling to background
handleContextMenu(e, 'card', id);
}}
/>
@@ -247,6 +273,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
<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', { roomId: gameState.roomId, action: { type: 'DRAW_CARD', playerId: currentPlayerId } })}
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 */}
@@ -263,6 +290,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
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>
@@ -291,7 +319,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
card={card}
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
onClick={toggleTap}
onContextMenu={(id) => toggleFlip(id)}
onContextMenu={(id, e) => handleContextMenu(e, 'card', id)}
style={{ transformOrigin: 'bottom center' }}
/>
</div>
@@ -326,6 +354,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
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>

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { CardInstance } from '../../types/game';
interface ZoneOverlayProps {
zoneName: string;
cards: CardInstance[];
onClose: () => void;
onCardContextMenu?: (e: React.MouseEvent, cardId: string) => void;
}
export const ZoneOverlay: React.FC<ZoneOverlayProps> = ({ zoneName, cards, onClose, onCardContextMenu }) => {
return (
<div className="fixed inset-0 z-[9990] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-slate-900 border border-slate-700 rounded-lg shadow-2xl w-3/4 h-3/4 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-950">
<h2 className="text-2xl font-bold text-slate-200 capitalize flex items-center gap-3">
<span>{zoneName}</span>
<span className="text-sm font-normal text-slate-500 bg-slate-900 px-2 py-1 rounded-full border border-slate-800">
{cards.length} Cards
</span>
</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-white transition-colors p-2 hover:bg-white/10 rounded-full"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 bg-[url('/bg-pattern.png')]">
{cards.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-500">
<p className="text-lg">This zone is empty.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{cards.map((card) => (
<div key={card.instanceId} className="relative group perspective-1000">
<div
className="relative aspect-[2.5/3.5] bg-slate-800 rounded-lg overflow-hidden shadow-lg border border-slate-700 transition-transform duration-200 hover:scale-105 hover:z-10 hover:shadow-xl hover:shadow-cyan-900/20 cursor-context-menu"
onContextMenu={(e) => {
if (onCardContextMenu) {
e.preventDefault();
e.stopPropagation();
onCardContextMenu(e, card.instanceId);
}
}}
>
<img
src={card.imageUrl || 'https://via.placeholder.com/250x350'}
alt={card.name}
className="w-full h-full object-cover"
/>
</div>
<div className="mt-2 text-center">
<p className="text-xs text-slate-400 truncate w-full">{card.name}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-800 bg-slate-950 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded text-sm font-medium transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
};

View File

@@ -31,14 +31,15 @@ interface Room {
interface GameRoomProps {
room: Room;
currentPlayerId: string;
initialGameState?: any;
}
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId }) => {
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState }) => {
const [room, setRoom] = useState<Room>(initialRoom);
const [message, setMessage] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [gameState, setGameState] = useState<any>(null);
const [gameState, setGameState] = useState<any>(initialGameState || null);
useEffect(() => {
setRoom(initialRoom);

View File

@@ -0,0 +1,180 @@
import React, { useState, useMemo } from 'react';
import { Play, Upload, Loader2, AlertCircle } from 'lucide-react';
import { CardParserService } from '../../services/CardParserService';
import { ScryfallService, ScryfallCard } from '../../services/ScryfallService';
import { socketService } from '../../services/SocketService';
import { GameRoom } from '../lobby/GameRoom';
export const DeckTester: React.FC = () => {
const parserService = useMemo(() => new CardParserService(), []);
const scryfallService = useMemo(() => new ScryfallService(), []);
const [inputText, setInputText] = useState('');
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState('');
const [error, setError] = useState('');
const [activeRoom, setActiveRoom] = useState<any>(null);
const [initialGame, setInitialGame] = useState<any>(null);
const [playerId] = useState(() => Math.random().toString(36).substring(2) + Date.now().toString(36));
const [playerName, setPlayerName] = useState('Tester');
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => setInputText(e.target?.result as string || '');
reader.readAsText(file);
event.target.value = '';
};
const handleTestDeck = async () => {
if (!inputText.trim()) {
setError('Please enter a deck list');
return;
}
setLoading(true);
setError('');
setProgress('Parsing deck list...');
try {
// 1. Parse
const identifiers = parserService.parse(inputText);
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
// 2. Fetch from Scryfall
const expandedCards: ScryfallCard[] = [];
await scryfallService.fetchCollection(fetchList, (current, total) => {
setProgress(`Fetching cards... (${current}/${total})`);
});
// 3. Expand Quantities
identifiers.forEach(id => {
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
if (card) {
for (let i = 0; i < id.quantity; i++) expandedCards.push(card);
} else {
console.warn("Card not found:", id.value);
}
});
if (expandedCards.length === 0) {
throw new Error("No valid cards found in list.");
}
// 4. Cache Images on Server
setProgress('Caching images...');
const uniqueCards = Array.from(new Map(expandedCards.map(c => [c.id, c])).values());
const cardsToCache = uniqueCards.map(c => ({
id: c.id,
image_uris: { normal: c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal || "" }
}));
const cacheResponse = await fetch('/api/cards/cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cards: cardsToCache })
});
if (!cacheResponse.ok) {
console.warn("Failed to cache images, proceeding anyway...");
}
// 5. Update cards with local image paths
const baseUrl = `${window.location.protocol}//${window.location.host}/cards`;
const deckToSend = expandedCards.map(c => ({
...c,
image: `${baseUrl}/${c.id}.jpg`
}));
// 6. Connect & Start Solo Game
setProgress('Starting game...');
if (!socketService.socket.connected) {
socketService.connect();
}
const response = await socketService.emitPromise('start_solo_test', {
playerId,
playerName,
deck: deckToSend
});
if (response.success) {
setInitialGame(response.game);
setActiveRoom(response.room);
} else {
throw new Error(response.message || "Failed to start game");
}
} catch (err: any) {
console.error(err);
setError(err.message || "An error occurred");
} finally {
setLoading(false);
setProgress('');
}
};
if (activeRoom) {
return <GameRoom room={activeRoom} currentPlayerId={playerId} initialGameState={initialGame} />;
}
return (
<div className="max-w-4xl mx-auto p-4 md:p-8">
<div className="bg-slate-800 rounded-2xl p-8 border border-slate-700 shadow-2xl">
<h2 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
<Play className="w-8 h-8 text-emerald-500" /> Deck Tester
</h2>
<p className="text-slate-400 mb-8">Paste your deck list below to instantly test it on the battlefield.</p>
{error && (
<div className="bg-red-900/50 border border-red-500 text-red-200 p-4 rounded-xl mb-6 flex items-center gap-3">
<AlertCircle className="w-5 h-5" />
{error}
</div>
)}
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-slate-300 mb-2">Player Name</label>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-3 text-white focus:ring-2 focus:ring-emerald-500 outline-none"
/>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-bold text-slate-300">Deck List</label>
<label className="cursor-pointer text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 hover:underline">
<Upload className="w-3 h-3" /> Upload .txt
<input type="file" className="hidden" accept=".txt,.csv" onChange={handleFileUpload} />
</label>
</div>
<textarea
className="w-full h-64 bg-slate-900 border border-slate-700 rounded-xl p-4 font-mono text-sm text-slate-300 focus:ring-2 focus:ring-emerald-500 outline-none resize-none placeholder:text-slate-600"
placeholder={"4 Lightning Bolt\n4 Mountain\n..."}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
disabled={loading}
/>
</div>
<button
onClick={handleTestDeck}
disabled={loading || !inputText}
className={`w-full py-4 rounded-xl font-bold text-lg shadow-lg flex justify-center items-center gap-2 transition-all ${loading
? 'bg-slate-700 cursor-not-allowed text-slate-500'
: 'bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white transform hover:scale-[1.01]'
}`}
>
{loading ? <Loader2 className="w-6 h-6 animate-spin" /> : <Play className="w-6 h-6 fill-current" />}
{loading ? progress : 'Start Test Game'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -167,6 +167,39 @@ io.on('connection', (socket) => {
}
});
socket.on('start_solo_test', ({ playerId, playerName, deck }, callback) => {
// Create new room in 'playing' state (empty packs as not drafting)
const room = roomManager.createRoom(playerId, playerName, []);
room.status = 'playing';
// Join socket
socket.join(room.id);
console.log(`Solo Game started for ${room.id} by ${playerName}`);
// Init Game
const game = gameManager.createGame(room.id, room.players);
// Load Deck (Expects expanded array of cards)
if (Array.isArray(deck)) {
deck.forEach((card: any) => {
gameManager.addCardToGame(room.id, {
ownerId: playerId,
controllerId: playerId,
oracleId: card.id,
name: card.name,
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
zone: 'library'
});
});
}
// Send Init Updates
callback({ success: true, room, game });
// Emit updates to ensure client is in sync
io.to(room.id).emit('room_update', room);
io.to(room.id).emit('game_update', game);
});
socket.on('start_game', ({ roomId, decks }) => {
const room = roomManager.startGame(roomId);
if (room) {

View File

@@ -104,6 +104,18 @@ export class GameManager {
case 'SHUFFLE_LIBRARY':
this.shuffleLibrary(game, action);
break;
case 'SHUFFLE_GRAVEYARD':
this.shuffleGraveyard(game, action);
break;
case 'SHUFFLE_EXILE':
this.shuffleExile(game, action);
break;
case 'MILL_CARD':
this.millCard(game, action);
break;
case 'EXILE_GRAVEYARD':
this.exileGraveyard(game, action);
break;
}
return game;
@@ -220,6 +232,37 @@ export class GameManager {
// No-op in current logic since we pick randomly
}
private shuffleGraveyard(_game: GameState, _action: { playerId: string }) {
// No-op
}
private shuffleExile(_game: GameState, _action: { playerId: string }) {
// No-op
}
private millCard(game: GameState, action: { playerId: string; amount: number }) {
// Similar to draw but to graveyard
const amount = action.amount || 1;
for (let i = 0; i < amount; i++) {
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
if (libraryCards.length > 0) {
const randomIndex = Math.floor(Math.random() * libraryCards.length);
const card = libraryCards[randomIndex];
card.zone = 'graveyard';
card.faceDown = false;
card.position.z = ++game.maxZ;
}
}
}
private exileGraveyard(game: GameState, action: { playerId: string }) {
const graveyardCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'graveyard');
graveyardCards.forEach(card => {
card.zone = 'exile';
card.position.z = ++game.maxZ;
});
}
// Helper to add cards (e.g. at game start)
addCardToGame(roomId: string, cardData: Partial<CardInstance>) {
const game = this.games.get(roomId);