From 0fb330e10b49ff0325ac9f9751302315b3f2bc41 Mon Sep 17 00:00:00 2001 From: dnviti Date: Wed, 17 Dec 2025 16:29:12 +0100 Subject: [PATCH] feat: Add 'Test Solo' feature to Cube Manager for randomized deck play, with server support for solo game state on rejoin. --- docs/development/CENTRAL.md | 1 + .../2025-12-17-162500_test_deck_feature.md | 32 +++++++ src/client/src/App.tsx | 1 + src/client/src/modules/cube/CubeManager.tsx | 95 ++++++++++++++++++- src/client/src/modules/lobby/GameRoom.tsx | 39 +++----- src/server/index.ts | 9 +- 6 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 docs/development/devlog/2025-12-17-162500_test_deck_feature.md diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index dc71dc4..94d409e 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -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. diff --git a/docs/development/devlog/2025-12-17-162500_test_deck_feature.md b/docs/development/devlog/2025-12-17-162500_test_deck_feature.md new file mode 100644 index 0000000..b34e2a9 --- /dev/null +++ b/docs/development/devlog/2025-12-17-162500_test_deck_feature.md @@ -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. diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx index 334f61f..c660e65 100644 --- a/src/client/src/App.tsx +++ b/src/client/src/App.tsx @@ -100,6 +100,7 @@ export const App: React.FC = () => { setActiveTab('lobby')} /> diff --git a/src/client/src/modules/cube/CubeManager.tsx b/src/client/src/modules/cube/CubeManager.tsx index e38ec25..47fc118 100644 --- a/src/client/src/modules/cube/CubeManager.tsx +++ b/src/client/src/modules/cube/CubeManager.tsx @@ -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>; + availableLands: any[]; setAvailableLands: React.Dispatch>; onGoToLobby: () => void; } -import { useToast } from '../../components/Toast'; - -export const CubeManager: React.FC = ({ packs, setPacks, setAvailableLands, onGoToLobby }) => { +export const CubeManager: React.FC = ({ packs, setPacks, availableLands, setAvailableLands, onGoToLobby }) => { const { showToast } = useToast(); // --- Services --- @@ -280,6 +281,84 @@ export const CubeManager: React.FC = ({ 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 = ({ packs, setPacks, setAv > Play Online + - - OR - - + )} @@ -268,6 +248,7 @@ export const GameRoom: React.FC = ({ 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 (
@@ -287,14 +268,18 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl
-
+
{isMe && ( )} {isMeHost && !isMe && ( diff --git a/src/server/index.ts b/src/server/index.ts index f3b945c..1a3abf2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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?