Compare commits

...

3 Commits

15 changed files with 744 additions and 28 deletions

4
.gitignore vendored
View File

@@ -138,4 +138,6 @@ dist
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
.vite/
src/server/public/cards/*

View File

@@ -9,6 +9,8 @@ The project has successfully migrated from a .NET backend to a Node.js Modular M
- **[2025-12-14] Set Generation**: Implemented full set fetching and booster box generation (Completed). [Link](./devlog/2025-12-14-211000_set_based_generation.md)
- **[2025-12-14] Cleanup**: Removed Tournament Mode and simplified pack display as requested. [Link](./devlog/2025-12-14-211500_remove_tournament_mode.md)
- **[2025-12-14] UI Tweak**: Auto-configured generation mode based on source selection. [Link](./devlog/2025-12-14-212000_ui_simplification.md)
- **[2025-12-14] Multiplayer Game Plan**: Plan for Real Game & Online Multiplayer. [Link](./devlog/2025-12-14-212500_multiplayer_game_plan.md)
- **[2025-12-14] Bug Fix**: Fixed `crypto.randomUUID` error for non-secure contexts. [Link](./devlog/2025-12-14-214400_fix_uuid_error.md)
## Active Modules
1. **Cube Manager**: Fully functional (Parsing, Fetching, Pack Generation).

View File

@@ -0,0 +1,37 @@
# Work Plan: Real Game & Online Multiplayer
## User Epics
1. **Lobby System**: Create and join private rooms.
2. **Game Setup**: Use generated packs to start a game.
3. **Multiplayer Draft**: Real-time drafting with friends.
4. **Chat**: In-game communication.
## Tasks
### 1. Backend Implementation (Node.js + Socket.IO)
- [ ] Create `src/server/managers/RoomManager.ts` to handle room state.
- [ ] Implement `Room` and `Player` interfaces.
- [ ] Update `src/server/index.ts` to initialize `RoomManager` and handle socket events:
- `create_room`
- `join_room`
- `leave_room`
- `send_message`
- `start_game` (placeholder for next phase)
### 2. Frontend Implementation (React)
- [ ] Create `src/client/src/modules/lobby` directory.
- [ ] Create `LobbyManager.tsx` (The main view for finding/creating rooms).
- [ ] Create `GameRoom.tsx` (The specific room view with chat and player list).
- [ ] Create `socket.ts` service in `src/client/src/services` for client-side socket handling.
- [ ] Update `App.tsx` to include the "Lobby" tab.
- [ ] Update `CubeManager.tsx` to add "Create Online Room" button.
### 3. Integration
- [ ] Ensure created room receives the packs from `CubeManager`.
- [ ] Verify players can join via Room ID.
- [ ] Verify chat works.
## Technical Notes
- Use `socket.io-client` on frontend.
- Generate Room IDs (short random strings).
- Manage state synchronization for the room (players list updates).

View File

@@ -0,0 +1,15 @@
# Fix UUID Error in Insecure Contexts
## Problem
The user reported a `TypeError: crypto.randomUUID is not a function` when accessing the application from a public IP. This is because `crypto.randomUUID()` is part of the Web Crypto API, which is often restricted to secure contexts (HTTPS) or localhost. When accessing via `http://PUBLIC_IP:PORT`, the browser disables this API.
## Solution
We need to implement a fallback UUID generation method that works in non-secure contexts.
## Plan
1. Modify `src/client/src/services/PackGeneratorService.ts`.
2. Add a private method `generateUUID()` to the `PackGeneratorService` class (or a standalone helper function in the module) that:
* Checks if `crypto.randomUUID` is available.
* If yes, uses it.
* If no, uses a fallback algorithm (e.g., `Math.random()` based v4 UUID generation).
3. Replace the call `crypto.randomUUID()` with this new method.

View File

@@ -1,10 +1,13 @@
import React, { useState } from 'react';
import { Layers, Box, Trophy } from 'lucide-react';
import { Layers, Box, Trophy, Users } from 'lucide-react';
import { CubeManager } from './modules/cube/CubeManager';
import { TournamentManager } from './modules/tournament/TournamentManager';
import { LobbyManager } from './modules/lobby/LobbyManager';
import { Pack } from './services/PackGeneratorService';
export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<'draft' | 'bracket'>('draft');
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby'>('draft');
const [generatedPacks, setGeneratedPacks] = useState<Pack[]>([]);
return (
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">
@@ -21,22 +24,35 @@ export const App: React.FC = () => {
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button
onClick={() => setActiveTab('draft')}
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Box className="w-4 h-4" /> Draft Management
<Box className="w-4 h-4" /> <span className="hidden md:inline">Draft Management</span>
</button>
<button
onClick={() => setActiveTab('lobby')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'lobby' ? 'bg-emerald-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Users className="w-4 h-4" /> <span className="hidden md:inline">Online Lobby</span>
</button>
<button
onClick={() => setActiveTab('bracket')}
className={`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'}`}
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'}`}
>
<Trophy className="w-4 h-4" /> Tournament / Bracket
<Trophy className="w-4 h-4" /> <span className="hidden md:inline">Tournament</span>
</button>
</div>
</div>
</header>
<main>
{activeTab === 'draft' && <CubeManager />}
{activeTab === 'draft' && (
<CubeManager
packs={generatedPacks}
setPacks={setGeneratedPacks}
onGoToLobby={() => setActiveTab('lobby')}
/>
)}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />}
{activeTab === 'bracket' && <TournamentManager />}
</main>
</div>

View File

@@ -1,11 +1,17 @@
import React, { useState, useRef, useEffect } from 'react';
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings } from 'lucide-react';
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users } from 'lucide-react';
import { CardParserService } from '../../services/CardParserService';
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
import { PackCard } from '../../components/PackCard';
export const CubeManager: React.FC = () => {
interface CubeManagerProps {
packs: Pack[];
setPacks: React.Dispatch<React.SetStateAction<Pack[]>>;
onGoToLobby: () => void;
}
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoToLobby }) => {
// --- Services ---
// --- Services ---
// Memoize services to persist cache across renders
@@ -27,8 +33,6 @@ export const CubeManager: React.FC = () => {
ignoreTokens: true
});
const [packs, setPacks] = useState<Pack[]>([]);
// UI State
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
@@ -378,10 +382,22 @@ export const CubeManager: React.FC = () => {
</h2>
</div>
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
<button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><List className="w-4 h-4" /></button>
<button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><LayoutGrid className="w-4 h-4" /></button>
<button onClick={() => setViewMode('stack')} className={`p-2 rounded ${viewMode === 'stack' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><Layers className="w-4 h-4" /></button>
<div className="flex gap-2">
{/* Play Button */}
{packs.length > 0 && (
<button
onClick={onGoToLobby}
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
>
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
</button>
)}
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
<button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><List className="w-4 h-4" /></button>
<button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><LayoutGrid className="w-4 h-4" /></button>
<button onClick={() => setViewMode('stack')} className={`p-2 rounded ${viewMode === 'stack' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><Layers className="w-4 h-4" /></button>
</div>
</div>
</div>

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect, useRef } from 'react';
import { socketService } from '../../services/SocketService';
import { Users, MessageSquare, Send, Play, Copy, Check } from 'lucide-react';
interface Player {
id: string;
name: string;
isHost: boolean;
role: 'player' | 'spectator';
}
interface ChatMessage {
id: string;
sender: string;
text: string;
timestamp: string;
}
interface Room {
id: string;
hostId: string;
players: Player[];
status: string;
messages: ChatMessage[];
}
interface GameRoomProps {
room: Room;
currentPlayerId: string;
}
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId }) => {
const [room, setRoom] = useState<Room>(initialRoom);
const [message, setMessage] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setRoom(initialRoom);
setMessages(initialRoom.messages || []);
}, [initialRoom]);
useEffect(() => {
const socket = socketService.socket;
const handleRoomUpdate = (updatedRoom: Room) => {
console.log('Room updated:', updatedRoom);
setRoom(updatedRoom);
};
const handleNewMessage = (msg: ChatMessage) => {
setMessages(prev => [...prev, msg]);
};
socket.on('room_update', handleRoomUpdate);
socket.on('new_message', handleNewMessage);
return () => {
socket.off('room_update', handleRoomUpdate);
socket.off('new_message', handleNewMessage);
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
const me = room.players.find(p => p.id === currentPlayerId);
socketService.socket.emit('send_message', {
roomId: room.id,
sender: me?.name || 'Unknown',
text: message
});
setMessage('');
};
const copyRoomId = () => {
navigator.clipboard.writeText(room.id);
// Could show a toast here
};
return (
<div className="flex h-[calc(100vh-100px)] gap-4">
{/* Main Game Area (Placeholder for now) */}
<div className="flex-1 bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl flex flex-col items-center justify-center">
<h2 className="text-3xl font-bold text-white mb-4">Waiting for Players...</h2>
<div className="flex items-center gap-4 bg-slate-900 px-6 py-3 rounded-xl border border-slate-700">
<span className="text-slate-400 uppercase text-xs font-bold tracking-wider">Room Code</span>
<code className="text-2xl font-mono text-emerald-400 font-bold tracking-widest">{room.id}</code>
<button onClick={copyRoomId} className="p-2 text-slate-400 hover:text-white transition-colors" title="Copy Code">
<Copy className="w-5 h-5" />
</button>
</div>
<div className="mt-8 text-center text-slate-400">
<p>Share the code with your friends to join.</p>
<p className="text-sm mt-2">
<span className="text-emerald-400 font-bold">{room.players.filter(p => p.role === 'player').length}</span> / 8 Players Joined
</p>
<p className="text-xs mt-1 text-slate-500">
{room.players.length} total connected (including spectators)
</p>
</div>
{room.players.find(p => p.id === currentPlayerId)?.isHost && (
<button
onClick={() => socketService.socket.emit('start_game', { roomId: room.id })}
disabled={room.status !== 'waiting'}
className="mt-8 px-8 py-3 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg shadow-emerald-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="w-5 h-5" /> {room.status === 'waiting' ? 'Start Draft' : 'Draft in Progress'}
</button>
)}
</div>
{/* Sidebar: Players & Chat */}
<div className="w-80 flex flex-col gap-4">
{/* Players List */}
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<Users className="w-4 h-4" /> Lobby
</h3>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{room.players.map(p => (
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${p.role === 'spectator' ? 'bg-slate-700 text-slate-300' : 'bg-gradient-to-br from-purple-500 to-blue-500 text-white'}`}>
{p.name.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${p.id === currentPlayerId ? 'text-white' : 'text-slate-300'}`}>
{p.name}
</span>
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500">
{p.role} {p.isHost && <span className="text-amber-500 ml-1"> Host</span>}
</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Chat */}
<div className="h-1/2 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" /> Chat
</h3>
<div className="flex-1 overflow-y-auto space-y-2 mb-3 pr-1 custom-scrollbar">
{messages.map(msg => (
<div key={msg.id} className="text-sm">
<span className="font-bold text-purple-400 text-xs">{msg.sender}: </span>
<span className="text-slate-300">{msg.text}</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} className="flex gap-2">
<input
type="text"
value={message}
onChange={e => setMessage(e.target.value)}
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Type..."
/>
<button type="submit" className="p-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-white transition-colors">
<Send className="w-4 h-4" />
</button>
</form>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { socketService } from '../../services/SocketService';
import { GameRoom } from './GameRoom';
import { Pack } from '../../services/PackGeneratorService';
import { Users, PlusCircle, LogIn, AlertCircle } from 'lucide-react';
interface LobbyManagerProps {
generatedPacks: Pack[];
}
export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) => {
const [activeRoom, setActiveRoom] = useState<any>(null);
const [playerName, setPlayerName] = useState('');
const [joinRoomId, setJoinRoomId] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [playerId] = useState(() => Math.random().toString(36).substring(2) + Date.now().toString(36)); // Simple persistent ID
const connect = () => {
if (!socketService.socket.connected) {
socketService.connect();
}
};
const handleCreateRoom = async () => {
if (!playerName) {
setError('Please enter your name');
return;
}
if (generatedPacks.length === 0) {
setError('No packs generated! Please go to Draft Management and generate packs first.');
return;
}
setLoading(true);
setError('');
connect();
try {
const response = await socketService.emitPromise('create_room', {
hostId: playerId,
hostName: playerName,
packs: generatedPacks
});
if (response.success) {
setActiveRoom(response.room);
} else {
setError(response.message || 'Failed to create room');
}
} catch (err: any) {
setError(err.message || 'Connection error');
} finally {
setLoading(false);
}
};
const handleJoinRoom = async () => {
if (!playerName) {
setError('Please enter your name');
return;
}
if (!joinRoomId) {
setError('Please enter a Room ID');
return;
}
setLoading(true);
setError('');
connect();
try {
const response = await socketService.emitPromise('join_room', {
roomId: joinRoomId.toUpperCase(),
playerId,
playerName
});
if (response.success) {
setActiveRoom(response.room);
} else {
setError(response.message || 'Failed to join room');
}
} catch (err: any) {
setError(err.message || 'Connection error');
} finally {
setLoading(false);
}
};
if (activeRoom) {
return <GameRoom room={activeRoom} currentPlayerId={playerId} />;
}
return (
<div className="max-w-4xl mx-auto p-4 md:p-10">
<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">
<Users className="w-8 h-8 text-purple-500" /> Multiplayer Lobby
</h2>
<p className="text-slate-400 mb-8">Create a private room for your draft or join an existing one.</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">Your Name</label>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Enter your nickname..."
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-4 text-white focus:ring-2 focus:ring-purple-500 outline-none text-lg"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-4 border-t border-slate-700">
{/* Create Room */}
<div className={`space-y-4 ${generatedPacks.length === 0 ? 'opacity-50' : ''}`}>
<h3 className="text-xl font-bold text-white">Create Room</h3>
<p className="text-sm text-slate-400">Start a new draft with your {generatedPacks.length} generated packs.</p>
<button
onClick={handleCreateRoom}
disabled={loading || generatedPacks.length === 0}
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl shadow-lg transform transition hover:scale-[1.02] flex justify-center items-center gap-2 disabled:cursor-not-allowed disabled:grayscale"
>
<PlusCircle className="w-5 h-5" /> {loading ? 'Creating...' : 'Create Private Room'}
</button>
{generatedPacks.length === 0 && (
<p className="text-xs text-amber-500 text-center font-bold">Requires packs from Draft Management tab.</p>
)}
</div>
{/* Join Room */}
<div className="space-y-4">
<h3 className="text-xl font-bold text-white">Join Room</h3>
<p className="text-sm text-slate-400">Enter a code shared by your friend.</p>
<div className="flex gap-2">
<input
type="text"
value={joinRoomId}
onChange={(e) => setJoinRoomId(e.target.value)}
placeholder="ROOM CODE"
className="flex-1 bg-slate-900 border border-slate-700 rounded-xl p-4 text-white font-mono uppercase text-lg text-center tracking-widest focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<button
onClick={handleJoinRoom}
disabled={loading}
className="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl shadow-lg transform transition hover:scale-[1.02] flex justify-center items-center gap-2"
>
<LogIn className="w-5 h-5" /> {loading ? 'Joining...' : 'Join Room'}
</button>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -63,7 +63,7 @@ export class PackGeneratorService {
}
const cardObj: DraftCard = {
id: crypto.randomUUID(),
id: this.generateUUID(),
scryfallId: cardData.id,
name: cardData.name,
rarity: rarity,
@@ -298,4 +298,15 @@ export class PackGeneratorService {
}
return newArray;
}
private generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback for insecure contexts or older browsers
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
}

View File

@@ -0,0 +1,37 @@
import { io, Socket } from 'socket.io-client';
const URL = `http://${window.location.hostname}:3000`;
class SocketService {
public socket: Socket;
constructor() {
this.socket = io(URL, {
autoConnect: false
});
}
connect() {
this.socket.connect();
}
disconnect() {
this.socket.disconnect();
}
// Helper method to make requests with acknowledgements
emitPromise(event: string, data: any): Promise<any> {
return new Promise((resolve, reject) => {
this.socket.emit(event, data, (response: any) => {
if (response?.error) {
reject(response.error);
} else {
resolve(response);
}
});
});
}
}
export const socketService = new SocketService();

73
src/package-lock.json generated
View File

@@ -12,7 +12,8 @@
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"socket.io": "^4.8.1"
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
@@ -2019,6 +2020,36 @@
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@@ -3684,6 +3715,38 @@
}
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
@@ -4732,6 +4795,14 @@
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -12,23 +12,24 @@
},
"dependencies": {
"express": "^4.21.2",
"socket.io": "^4.8.1",
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.475.0"
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@types/express": "^4.17.21",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"tsx": "^4.19.2",
"concurrently": "^9.1.0"
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}

View File

@@ -1,6 +1,7 @@
import express, { Request, Response } from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { RoomManager } from './managers/RoomManager';
const app = express();
const httpServer = createServer(app);
@@ -11,6 +12,7 @@ const io = new Server(httpServer, {
}
});
const roomManager = new RoomManager();
const PORT = process.env.PORT || 3000;
app.use(express.json());
@@ -20,15 +22,68 @@ app.get('/api/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', message: 'Server is running' });
});
// Socket.IO connection
// Socket.IO logic
io.on('connection', (socket) => {
console.log('A user connected', socket.id);
socket.on('create_room', ({ hostId, hostName, packs }, callback) => {
const room = roomManager.createRoom(hostId, hostName, packs);
socket.join(room.id);
console.log(`Room created: ${room.id} by ${hostName}`);
callback({ success: true, room });
});
socket.on('join_room', ({ roomId, playerId, playerName }, callback) => {
const room = roomManager.joinRoom(roomId, playerId, playerName);
if (room) {
socket.join(room.id);
console.log(`Player ${playerName} joined room ${roomId}`);
io.to(room.id).emit('room_update', room); // Broadcast update
callback({ success: true, room });
} else {
callback({ success: false, message: 'Room not found or full' });
}
});
socket.on('rejoin_room', ({ roomId }) => {
// Just rejoin the socket channel if validation passes (not fully secure yet)
socket.join(roomId);
const room = roomManager.getRoom(roomId);
if (room) socket.emit('room_update', room);
});
socket.on('send_message', ({ roomId, sender, text }) => {
const message = roomManager.addMessage(roomId, sender, text);
if (message) {
io.to(roomId).emit('new_message', message);
}
});
socket.on('start_game', ({ roomId }) => {
const room = roomManager.startGame(roomId);
if (room) {
io.to(roomId).emit('room_update', room);
// Here we would also emit 'draft_state' with initial packs
}
});
socket.on('disconnect', () => {
console.log('User disconnected', socket.id);
// TODO: Handle player disconnect (mark as offline but don't kick immediately)
});
});
httpServer.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
import os from 'os';
httpServer.listen(Number(PORT), '0.0.0.0', () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]!) {
if (iface.family === 'IPv4' && !iface.internal) {
console.log(` - Network IP: http://${iface.address}:${PORT}`);
}
}
}
});

View File

@@ -0,0 +1,107 @@
interface Player {
id: string;
name: string;
isHost: boolean;
role: 'player' | 'spectator';
}
interface ChatMessage {
id: string;
sender: string;
text: string;
timestamp: string;
}
interface Room {
id: string;
hostId: string;
players: Player[];
packs: any[]; // Store generated packs (JSON)
status: 'waiting' | 'drafting' | 'finished';
messages: ChatMessage[];
maxPlayers: number;
}
export class RoomManager {
private rooms: Map<string, Room> = new Map();
createRoom(hostId: string, hostName: string, packs: any[]): Room {
const roomId = Math.random().toString(36).substring(2, 8).toUpperCase();
const room: Room = {
id: roomId,
hostId,
players: [{ id: hostId, name: hostName, isHost: true, role: 'player' }],
packs,
status: 'waiting',
messages: [],
maxPlayers: 8
};
this.rooms.set(roomId, room);
return room;
}
joinRoom(roomId: string, playerId: string, playerName: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
// Rejoin if already exists
const existingPlayer = room.players.find(p => p.id === playerId);
if (existingPlayer) {
return room;
}
// Determine role
let role: 'player' | 'spectator' = 'player';
if (room.players.filter(p => p.role === 'player').length >= room.maxPlayers || room.status !== 'waiting') {
role = 'spectator';
}
room.players.push({ id: playerId, name: playerName, isHost: false, role });
return room;
}
leaveRoom(roomId: string, playerId: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.players = room.players.filter(p => p.id !== playerId);
// If host leaves, assign new host from remaining players
if (room.players.length === 0) {
this.rooms.delete(roomId);
return null;
} else if (room.hostId === playerId) {
const nextPlayer = room.players.find(p => p.role === 'player') || room.players[0];
if (nextPlayer) {
room.hostId = nextPlayer.id;
nextPlayer.isHost = true;
}
}
return room;
}
startGame(roomId: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.status = 'drafting';
return room;
}
getRoom(roomId: string): Room | undefined {
return this.rooms.get(roomId);
}
addMessage(roomId: string, sender: string, text: string): ChatMessage | null {
const room = this.rooms.get(roomId);
if (!room) return null;
const message: ChatMessage = {
id: Math.random().toString(36).substring(7),
sender,
text,
timestamp: new Date().toISOString()
};
room.messages.push(message);
return message;
}
}

View File

@@ -1,6 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import * as path from 'path';
export default defineConfig({
plugins: [react()],
@@ -15,6 +15,7 @@ export default defineConfig({
}
},
server: {
host: '0.0.0.0', // Expose to network
proxy: {
'/api': 'http://localhost:3000', // Proxy API requests to backend
'/socket.io': {