feat: Add deck tester feature to import custom deck lists and immediately start solo games.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
180
src/client/src/modules/tester/DeckTester.tsx
Normal file
180
src/client/src/modules/tester/DeckTester.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user