feat: Add 'Test Solo' feature to Cube Manager for randomized deck play, with server support for solo game state on rejoin.
This commit is contained in:
@@ -82,3 +82,4 @@
|
||||
- [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,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.
|
||||
@@ -100,6 +100,7 @@ export const App: React.FC = () => {
|
||||
<CubeManager
|
||||
packs={generatedPacks}
|
||||
setPacks={setGeneratedPacks}
|
||||
availableLands={availableLands}
|
||||
setAvailableLands={setAvailableLands}
|
||||
onGoToLobby={() => setActiveTab('lobby')}
|
||||
/>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
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 } from 'lucide-react';
|
||||
import { ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||
import { PackCard } from '../../components/PackCard';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { useToast } from '../../components/Toast';
|
||||
|
||||
interface CubeManagerProps {
|
||||
packs: Pack[];
|
||||
setPacks: React.Dispatch<React.SetStateAction<Pack[]>>;
|
||||
availableLands: any[];
|
||||
setAvailableLands: React.Dispatch<React.SetStateAction<any[]>>;
|
||||
onGoToLobby: () => void;
|
||||
}
|
||||
|
||||
import { useToast } from '../../components/Toast';
|
||||
|
||||
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, setAvailableLands, onGoToLobby }) => {
|
||||
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, availableLands, setAvailableLands, onGoToLobby }) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// --- Services ---
|
||||
@@ -280,6 +281,84 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, setAv
|
||||
}
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
if (packs.length === 0) return;
|
||||
const csvContent = generatorService.generateCsv(packs);
|
||||
@@ -676,6 +755,14 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, setAv
|
||||
>
|
||||
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
|
||||
</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
|
||||
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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
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 { GameView } from '../game/GameView';
|
||||
import { DraftView } from '../draft/DraftView';
|
||||
@@ -149,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 = () => {
|
||||
socketService.socket.emit('start_draft', { roomId: room.id });
|
||||
@@ -237,16 +224,9 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
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"
|
||||
>
|
||||
<Layers className="w-5 h-5" /> Start Real 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)
|
||||
<Layers className="w-5 h-5" /> Start Draft
|
||||
</button>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -268,6 +248,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
{room.players.map(p => {
|
||||
const isReady = (p as any).ready;
|
||||
const isMe = p.id === currentPlayerId;
|
||||
const isSolo = room.players.length === 1 && room.status === 'playing';
|
||||
|
||||
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">
|
||||
@@ -287,14 +268,18 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
||||
</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 && (
|
||||
<button
|
||||
onClick={onExit}
|
||||
className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-red-400"
|
||||
title="Leave Room"
|
||||
className={`p-1 rounded flex items-center gap-2 transition-colors ${isSolo
|
||||
? '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" />
|
||||
{isSolo && <span className="text-xs font-bold">End Test</span>}
|
||||
</button>
|
||||
)}
|
||||
{isMeHost && !isMe && (
|
||||
|
||||
@@ -294,9 +294,16 @@ io.on('connection', (socket) => {
|
||||
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
|
||||
if (typeof callback === 'function') {
|
||||
callback({ success: true, room, draftState: currentDraft });
|
||||
callback({ success: true, room, draftState: currentDraft, gameState: currentGame });
|
||||
}
|
||||
} else {
|
||||
// Room found but player not in it? Or room not found?
|
||||
|
||||
Reference in New Issue
Block a user