diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index d3e39b5..8ebab89 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -9,6 +9,7 @@ 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) ## Active Modules 1. **Cube Manager**: Fully functional (Parsing, Fetching, Pack Generation). diff --git a/docs/development/devlog/2025-12-14-212500_multiplayer_game_plan.md b/docs/development/devlog/2025-12-14-212500_multiplayer_game_plan.md new file mode 100644 index 0000000..7946aad --- /dev/null +++ b/docs/development/devlog/2025-12-14-212500_multiplayer_game_plan.md @@ -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). diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx index b58b0dd..96d4cf9 100644 --- a/src/client/src/App.tsx +++ b/src/client/src/App.tsx @@ -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([]); return (
@@ -21,22 +24,35 @@ export const App: React.FC = () => {
+
- {activeTab === 'draft' && } + {activeTab === 'draft' && ( + setActiveTab('lobby')} + /> + )} + {activeTab === 'lobby' && } {activeTab === 'bracket' && }
diff --git a/src/client/src/modules/cube/CubeManager.tsx b/src/client/src/modules/cube/CubeManager.tsx index fb1f752..b1f87f3 100644 --- a/src/client/src/modules/cube/CubeManager.tsx +++ b/src/client/src/modules/cube/CubeManager.tsx @@ -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>; + onGoToLobby: () => void; +} + +export const CubeManager: React.FC = ({ 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([]); - // UI State const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list'); @@ -378,10 +382,22 @@ export const CubeManager: React.FC = () => { -
- - - +
+ {/* Play Button */} + {packs.length > 0 && ( + + )} + +
+ + + +
diff --git a/src/client/src/modules/lobby/GameRoom.tsx b/src/client/src/modules/lobby/GameRoom.tsx new file mode 100644 index 0000000..4c81bfd --- /dev/null +++ b/src/client/src/modules/lobby/GameRoom.tsx @@ -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 = ({ room: initialRoom, currentPlayerId }) => { + const [room, setRoom] = useState(initialRoom); + const [message, setMessage] = useState(''); + const [messages, setMessages] = useState(initialRoom.messages || []); + const messagesEndRef = useRef(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 ( +
+ {/* Main Game Area (Placeholder for now) */} +
+

Waiting for Players...

+
+ Room Code + {room.id} + +
+ +
+

Share the code with your friends to join.

+

+ {room.players.filter(p => p.role === 'player').length} / 8 Players Joined +

+

+ {room.players.length} total connected (including spectators) +

+
+ + {room.players.find(p => p.id === currentPlayerId)?.isHost && ( + + )} +
+ + {/* Sidebar: Players & Chat */} +
+ {/* Players List */} +
+

+ Lobby +

+
+ {room.players.map(p => ( +
+
+
+ {p.name.substring(0, 2).toUpperCase()} +
+
+ + {p.name} + + + {p.role} {p.isHost && • Host} + +
+
+
+ ))} +
+
+ + {/* Chat */} +
+

+ Chat +

+
+ {messages.map(msg => ( +
+ {msg.sender}: + {msg.text} +
+ ))} +
+
+
+ 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..." + /> + +
+
+
+
+ ); +}; diff --git a/src/client/src/modules/lobby/LobbyManager.tsx b/src/client/src/modules/lobby/LobbyManager.tsx new file mode 100644 index 0000000..0698306 --- /dev/null +++ b/src/client/src/modules/lobby/LobbyManager.tsx @@ -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 = ({ generatedPacks }) => { + const [activeRoom, setActiveRoom] = useState(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 ; + } + + return ( +
+
+

+ Multiplayer Lobby +

+

Create a private room for your draft or join an existing one.

+ + {error && ( +
+ + {error} +
+ )} + +
+
+ + 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" + /> +
+ +
+ {/* Create Room */} +
+

Create Room

+

Start a new draft with your {generatedPacks.length} generated packs.

+ + {generatedPacks.length === 0 && ( +

Requires packs from Draft Management tab.

+ )} +
+ + {/* Join Room */} +
+

Join Room

+

Enter a code shared by your friend.

+
+ 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" + /> +
+ +
+
+
+
+
+ ); +}; diff --git a/src/client/src/services/SocketService.ts b/src/client/src/services/SocketService.ts new file mode 100644 index 0000000..49c7ffd --- /dev/null +++ b/src/client/src/services/SocketService.ts @@ -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 { + 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(); diff --git a/src/package-lock.json b/src/package-lock.json index 53d5ca7..a6afb07 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -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", diff --git a/src/package.json b/src/package.json index d3152d8..53ff179 100644 --- a/src/package.json +++ b/src/package.json @@ -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" } } diff --git a/src/server/index.ts b/src/server/index.ts index 538ba8d..a6b726a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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}`); + } + } + } }); diff --git a/src/server/managers/RoomManager.ts b/src/server/managers/RoomManager.ts new file mode 100644 index 0000000..d2c1e68 --- /dev/null +++ b/src/server/managers/RoomManager.ts @@ -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 = 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; + } +} diff --git a/src/vite.config.ts b/src/vite.config.ts index 7015233..263d3f8 100644 --- a/src/vite.config.ts +++ b/src/vite.config.ts @@ -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': {