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:
2025-12-17 16:29:12 +01:00
parent e13aa16766
commit 0fb330e10b
6 changed files with 145 additions and 32 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -100,6 +100,7 @@ export const App: React.FC = () => {
<CubeManager
packs={generatedPacks}
setPacks={setGeneratedPacks}
availableLands={availableLands}
setAvailableLands={setAvailableLands}
onGoToLobby={() => setActiveTab('lobby')}
/>

View File

@@ -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"

View File

@@ -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 && (

View File

@@ -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?