feat: Add deck tester feature to import custom deck lists and immediately start solo games.

This commit is contained in:
2025-12-15 00:31:58 +01:00
parent 2eea9b860e
commit b13627363f
7 changed files with 261 additions and 7 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,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { CardInstance } from '../../types/game';
interface ContextMenuRequest {
@@ -16,7 +16,6 @@ interface GameContextMenuProps {
}
export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClose, onAction }) => {
const [submenu, setSubmenu] = useState<string | null>(null);
useEffect(() => {
const handleClickOutside = () => onClose();
@@ -59,7 +58,7 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
<MenuItem label={request.card.faceDown ? "Flip Face Up" : "Flip Face Down"} onClick={() => handleAction('FLIP_CARD', { cardId: request.targetId })} />
<div className="relative group">
<MenuItem label="Add Counter ▸" onClick={() => { }} onMouseEnter={() => setSubmenu('counter')} />
<MenuItem label="Add Counter ▸" onClick={() => { }} />
{/* 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 })} />

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