diff --git a/.gitignore b/.gitignore index 26f3440..91706d7 100644 --- a/.gitignore +++ b/.gitignore @@ -140,4 +140,5 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ -src/server/public/cards/* \ No newline at end of file +src/server/public/cards/* +src/server-data \ No newline at end of file diff --git a/docs/development/devlog/2024-12-18-163500_game_battlefield_sidebar.md b/docs/development/devlog/2024-12-18-163500_game_battlefield_sidebar.md new file mode 100644 index 0000000..aa7bc0a --- /dev/null +++ b/docs/development/devlog/2024-12-18-163500_game_battlefield_sidebar.md @@ -0,0 +1,26 @@ + +# 2024-12-18 16:35:00 - Refactor Game Battlefield Sidebar + +## Description +Refactored the `GameView` sidebar to be graphically and functionally consistent with `DeckBuilderView` and `DraftView`. + +## Key Changes +- **Component**: `GameView.tsx` +- **Functionality**: + - Implemented collapsible sidebar state with persistence (`game_sidebarCollapsed`). + - Implemented resizable sidebar width with persistence (`game_sidebarWidth`). + - Added transition animations for collapsing/expanding. +- **Visuals**: + - Adopted the "Card Preview" style with a 3D flip effect. + - Used `back.jpg` (path `/images/back.jpg`) for the empty/back state. + - Moved the resize handle *inside* the sidebar container with consistent styling (floating pill). + - Preserved Oracle Text display below the card image (as it is critical for gameplay), styled within the new container. + +## Consistent Elements +- **Icons**: Used `Eye` and `ChevronLeft` from Lucide. +- **Styling**: `slate-900` backgrounds, glassmorphism borders (`slate-800/50`), shadow effects. +- **Behavior**: Sidebar width allows dragging between 200px and 600px. + +## Status +- [ ] Verify `back.jpg` exists in the deployed `public/images` folder (currently assumed based on other files). +- [x] Code refactoring complete. diff --git a/docs/development/devlog/2024-12-18-164500_game_persistence.md b/docs/development/devlog/2024-12-18-164500_game_persistence.md new file mode 100644 index 0000000..7542ded --- /dev/null +++ b/docs/development/devlog/2024-12-18-164500_game_persistence.md @@ -0,0 +1,20 @@ + +# 2024-12-18 16:45:00 - Implement Game Persistence on Reload + +## Description +Updated `LobbyManager.tsx` to ensure that when a user reloads the page and automatically rejoins a room, the active game state (`initialGameState`) is correctly retrieved from the server and passed to the game view components. + +## Key Changes +- **Component**: `LobbyManager.tsx` +- **Functionality**: + - Added `initialGameState` state. + - Updated `join_room` and `rejoin_room` response handling to capture `gameState` if present. + - Passed `initialGameState` to the `GameRoom` component. + +## Impact +- **User Experience**: If a user is in the middle of a game (battlefield phase) and refreshes the browser, they will now immediately see the battlefield state instead of a loading or broken screen, ensuring continuity. +- **Data Flow**: `GameRoom` uses this prop to initialize its local `gameState` before the first socket update event arrives. + +## Status +- [x] Implementation logic complete. +- [ ] User testing required (refresh page during active game). diff --git a/docs/development/devlog/2024-12-18-165500_server_persistence.md b/docs/development/devlog/2024-12-18-165500_server_persistence.md new file mode 100644 index 0000000..d1e8af4 --- /dev/null +++ b/docs/development/devlog/2024-12-18-165500_server_persistence.md @@ -0,0 +1,26 @@ + +# 2024-12-18 16:55:00 - Implement Server Persistence and Room Cleanup + +## Description +Implemented server-side state persistence to ensure game rooms, drafts, and game states survive server restarts and network issues. Added logic to keep rooms alive for at least 8 hours after the last activity, satisfying the requirements for robustness and re-joinability. + +## Key Changes +1. **Persistence Manager**: + - Created `PersistenceManager.ts` to save and load `rooms`, `drafts`, and `games` to/from JSON files in `./server-data`. + - Integrated into `server/index.ts` with auto-save interval (every 5s) and save-on-shutdown. + +2. **Room Manager**: + - Added `lastActive` timestamp to `Room` interface. + - Updated `lastActive` on all significant interactions (join, leave, message, etc.). + - Implemented `disconnect` logic: if players disconnect, the room is NOT deleted immediately. + - Implemented `leaveRoom` logic: Explicit leaving (waiting phase) still removes players but preserves the room until cleanup if empty. + - Added `cleanupRooms()` method running every 5 minutes to delete rooms inactive for > 8 hours. + +## Impact +- **Reliability**: Server crashes or restarts will no longer wipe out active games or drafts. +- **User Experience**: Users can reconnect to their room even hours later (up to 8 hours), or after a server reboot, using their room code. +- **Maintenance**: `server-data` directory now contains the active state, useful for debugging. + +## Status +- [x] Code implementation complete. +- [ ] Verify `server-data` folder is created and populated on run. diff --git a/docs/development/devlog/2024-12-18-170500_distributed_storage_redis.md b/docs/development/devlog/2024-12-18-170500_distributed_storage_redis.md new file mode 100644 index 0000000..c33c6c2 --- /dev/null +++ b/docs/development/devlog/2024-12-18-170500_distributed_storage_redis.md @@ -0,0 +1,29 @@ + +# 2024-12-18 17:05:00 - Distributed Storage with Redis + +## Description +Implemented distributed storage using Redis (`ioredis`) to support horizontal scaling and persistence outside of local file systems, while retaining local storage for development. + +## Key Changes +1. **Dependencies**: Added `ioredis` and `@types/ioredis`. +2. **Redis Manager**: created `RedisClientManager.ts` to manage connections: + - `db0`: Session Persistence (Rooms, Drafts, Games). + - `db1`: File Storage (Card Images, Metadata). + - Enabled via environment variable `USE_REDIS=true`. +3. **Persistence Manager**: Updated `PersistenceManager.ts` to read/write state to Redis DB 0 if enabled. +4. **File Storage Manager**: Created `FileStorageManager.ts` to abstract file operations (`saveFile`, `readFile`, `exists`). + - Uses Redis DB 1 if enabled. + - Uses Local FS otherwise. +5. **Card Service**: Refactored `CardService.ts` to use `FileStorageManager` instead of `fs` direct calls. +6. **Server File Serving**: Updated `server/index.ts` to conditionally serve files: + - If Redis enabled: Dynamic route intercepting `/cards/*` to fetch from Redis DB 1. + - If Local: Standard `express.static` middleware. + +## Configuration +- `USE_REDIS`: Set to `true` to enable Redis. +- `REDIS_HOST`: Default `localhost`. +- `REDIS_PORT`: Default `6379`. + +## Status +- [x] Code implementation complete. +- [ ] Redis functionality verification (requires Redis instance). diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index 3dc417e..a2b407f 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -1,4 +1,5 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import { ChevronLeft, Eye } from 'lucide-react'; import { GameState, CardInstance } from '../../types/game'; import { socketService } from '../../services/SocketService'; import { CardComponent } from './CardComponent'; @@ -12,10 +13,79 @@ interface GameViewProps { export const GameView: React.FC = ({ gameState, currentPlayerId }) => { const battlefieldRef = useRef(null); + const sidebarRef = useRef(null); const [contextMenu, setContextMenu] = useState(null); const [viewingZone, setViewingZone] = useState(null); const [hoveredCard, setHoveredCard] = useState(null); + // --- Sidebar State --- + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { + return localStorage.getItem('game_sidebarCollapsed') === 'true'; + }); + const [sidebarWidth, setSidebarWidth] = useState(() => { + const saved = localStorage.getItem('game_sidebarWidth'); + return saved ? parseInt(saved, 10) : 320; + }); + + const resizingState = useRef<{ + startX: number, + startWidth: number, + active: boolean + }>({ startX: 0, startWidth: 0, active: false }); + + // --- Persistence --- + useEffect(() => { + localStorage.setItem('game_sidebarCollapsed', isSidebarCollapsed.toString()); + }, [isSidebarCollapsed]); + + useEffect(() => { + localStorage.setItem('game_sidebarWidth', sidebarWidth.toString()); + }, [sidebarWidth]); + + useEffect(() => { + if (sidebarRef.current) sidebarRef.current.style.width = `${sidebarWidth}px`; + }, []); + + // --- Resize Handlers --- + const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => { + if (e.cancelable) e.preventDefault(); + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + + resizingState.current = { + startX: clientX, + startWidth: sidebarRef.current?.getBoundingClientRect().width || 320, + active: true + }; + + document.addEventListener('mousemove', onResizeMove); + document.addEventListener('touchmove', onResizeMove, { passive: false }); + document.addEventListener('mouseup', onResizeEnd); + document.addEventListener('touchend', onResizeEnd); + document.body.style.cursor = 'col-resize'; + }; + + const onResizeMove = useCallback((e: MouseEvent | TouchEvent) => { + if (!resizingState.current.active || !sidebarRef.current) return; + if (e.cancelable) e.preventDefault(); + + const clientX = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX; + const delta = clientX - resizingState.current.startX; + const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta)); + sidebarRef.current.style.width = `${newWidth}px`; + }, []); + + const onResizeEnd = useCallback(() => { + if (resizingState.current.active && sidebarRef.current) { + setSidebarWidth(parseInt(sidebarRef.current.style.width)); + } + resizingState.current.active = false; + document.removeEventListener('mousemove', onResizeMove); + document.removeEventListener('touchmove', onResizeMove); + document.removeEventListener('mouseup', onResizeEnd); + document.removeEventListener('touchend', onResizeEnd); + document.body.style.cursor = 'default'; + }, []); + useEffect(() => { // Disable default context menu const handleContext = (e: MouseEvent) => e.preventDefault(); @@ -152,52 +222,111 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } )} {/* Zoom Sidebar */} -
- {hoveredCard ? ( -
- {hoveredCard.name} -
-

{hoveredCard.name}

+ {isSidebarCollapsed ? ( +
+ +
+ ) : ( +
+ {/* Collapse Button */} + - {hoveredCard.manaCost && ( -

{hoveredCard.manaCost}

- )} - - {hoveredCard.typeLine && ( -
- {hoveredCard.typeLine} +
+
+
+ {/* Front Face (Hovered Card) */} +
+ {hoveredCard && ( + {hoveredCard.name} + )}
- )} - {hoveredCard.oracleText && ( -
- {hoveredCard.oracleText} + {/* Back Face (Card Back) */} +
+ Card Back
- )} - - {/* Stats for Creatures */} - {hoveredCard.typeLine?.toLowerCase().includes('creature') && ( -
- {/* Accessing raw PT might be hard if we don't have base PT, but we do have ptModification */} - {/* We don't strictly have base PT in CardInstance yet. Assuming UI mainly uses image. */} - {/* We'll skip P/T text for now as it needs base P/T to be passed from server. */} -
- )} +
+ + {/* Oracle Text & Details - Only when card is hovered */} + {hoveredCard && ( +
+

{hoveredCard.name}

+ + {hoveredCard.manaCost && ( +

{hoveredCard.manaCost}

+ )} + + {hoveredCard.typeLine && ( +
+ {hoveredCard.typeLine} +
+ )} + + {hoveredCard.oracleText && ( +
+ {hoveredCard.oracleText} +
+ )} +
+ )}
- ) : ( -
-
- Hover Card -
-

Hover over a card to view clear details.

+ + {/* Resize Handle */} +
+
- )} -
+
+ )} {/* Main Game Area */}
diff --git a/src/client/src/modules/lobby/LobbyManager.tsx b/src/client/src/modules/lobby/LobbyManager.tsx index f72a067..aafa211 100644 --- a/src/client/src/modules/lobby/LobbyManager.tsx +++ b/src/client/src/modules/lobby/LobbyManager.tsx @@ -18,6 +18,7 @@ export const LobbyManager: React.FC = ({ generatedPacks, avai const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [initialDraftState, setInitialDraftState] = useState(null); + const [initialGameState, setInitialGameState] = useState(null); const [playerId] = useState(() => { const saved = localStorage.getItem('player_id'); @@ -195,6 +196,7 @@ export const LobbyManager: React.FC = ({ generatedPacks, avai if (response.success) { setInitialDraftState(response.draftState || null); + setInitialGameState(response.gameState || null); setActiveRoom(response.room); } else { setError(response.message || 'Failed to join room'); @@ -227,6 +229,9 @@ export const LobbyManager: React.FC = ({ generatedPacks, avai if (response.draftState) { setInitialDraftState(response.draftState); } + if (response.gameState) { + setInitialGameState(response.gameState); + } } else { console.warn("Rejoin failed by server: ", response.message); localStorage.removeItem('active_room_id'); @@ -261,11 +266,12 @@ export const LobbyManager: React.FC = ({ generatedPacks, avai } setActiveRoom(null); setInitialDraftState(null); + setInitialGameState(null); localStorage.removeItem('active_room_id'); }; if (activeRoom) { - return ; + return ; } return ( diff --git a/src/package-lock.json b/src/package-lock.json index 201d291..e7611af 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "express": "^4.21.2", + "ioredis": "^5.8.2", "lucide-react": "^0.475.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -22,6 +23,7 @@ }, "devDependencies": { "@types/express": "^4.17.21", + "@types/ioredis": "^4.28.10", "@types/node": "^22.10.1", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.1", @@ -1999,6 +2001,12 @@ "node": ">=18" } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -2714,6 +2722,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3387,6 +3405,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3671,6 +3698,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4694,6 +4730,30 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5289,6 +5349,18 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -6016,6 +6088,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6847,6 +6940,12 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/src/package.json b/src/package.json index db5ae7c..a913729 100644 --- a/src/package.json +++ b/src/package.json @@ -15,6 +15,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "express": "^4.21.2", + "ioredis": "^5.8.2", "lucide-react": "^0.475.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -25,6 +26,7 @@ }, "devDependencies": { "@types/express": "^4.17.21", + "@types/ioredis": "^4.28.10", "@types/node": "^22.10.1", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.1", diff --git a/src/server/index.ts b/src/server/index.ts index 7dbb89d..faed2c7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -10,6 +10,7 @@ import { CardService } from './services/CardService'; import { ScryfallService } from './services/ScryfallService'; import { PackGeneratorService } from './services/PackGeneratorService'; import { CardParserService } from './services/CardParserService'; +import { PersistenceManager } from './managers/PersistenceManager'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -27,6 +28,16 @@ const io = new Server(httpServer, { const roomManager = new RoomManager(); const gameManager = new GameManager(); const draftManager = new DraftManager(); +const persistenceManager = new PersistenceManager(roomManager, draftManager, gameManager); + +// Load previous state +persistenceManager.load(); + +// Auto-Save Loop (Every 5 seconds) +const persistenceInterval = setInterval(() => { + persistenceManager.save(); +}, 5000); + const cardService = new CardService(); const scryfallService = new ScryfallService(); const packGeneratorService = new PackGeneratorService(); @@ -36,7 +47,32 @@ const PORT = process.env.PORT || 3000; app.use(express.json({ limit: '1000mb' })); // Increase limit for large card lists // Serve static images (Nested) -app.use('/cards', express.static(path.join(__dirname, 'public/cards'))); +import { RedisClientManager } from './managers/RedisClientManager'; +import { fileStorageManager } from './managers/FileStorageManager'; + +const redisForFiles = RedisClientManager.getInstance().db1; + +if (redisForFiles) { + console.log('[Server] Using Redis for file serving'); + app.get('/cards/*', async (req: Request, res: Response) => { + const relativePath = req.path; + const filePath = path.join(__dirname, 'public', relativePath); + + const buffer = await fileStorageManager.readFile(filePath); + if (buffer) { + if (filePath.endsWith('.jpg')) res.type('image/jpeg'); + else if (filePath.endsWith('.png')) res.type('image/png'); + else if (filePath.endsWith('.json')) res.type('application/json'); + res.send(buffer); + } else { + res.status(404).send('Not Found'); + } + }); +} else { + console.log('[Server] Using Local FS for file serving'); + app.use('/cards', express.static(path.join(__dirname, 'public/cards'))); +} + app.use('/images', express.static(path.join(__dirname, 'public/images'))); // API Routes @@ -566,6 +602,8 @@ httpServer.listen(Number(PORT), '0.0.0.0', () => { const gracefulShutdown = () => { console.log('Received kill signal, shutting down gracefully'); clearInterval(draftInterval); + clearInterval(persistenceInterval); + persistenceManager.save(); // Save on exit io.close(() => { console.log('Socket.io closed'); diff --git a/src/server/managers/FileStorageManager.ts b/src/server/managers/FileStorageManager.ts new file mode 100644 index 0000000..379c5f3 --- /dev/null +++ b/src/server/managers/FileStorageManager.ts @@ -0,0 +1,55 @@ + +import fs from 'fs'; +import path from 'path'; +import { RedisClientManager } from './RedisClientManager'; + +export class FileStorageManager { + private redisManager: RedisClientManager; + + constructor() { + this.redisManager = RedisClientManager.getInstance(); + } + + async saveFile(filePath: string, data: Buffer | string): Promise { + if (this.redisManager.db1) { + // Use Redis DB1 + // Key: Normalize path to be relative to project root or something unique? + // Simple approach: Use absolute path (careful with different servers) or relative path key. + // Let's assume filePath passed in is absolute. We iterate up to remove common prefix if we want cleaner keys, + // but absolute is safest uniqueness. + await this.redisManager.db1.set(filePath, typeof data === 'string' ? data : data.toString('binary')); + } else { + // Local File System + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, data); + } + } + + async readFile(filePath: string): Promise { + if (this.redisManager.db1) { + // Redis DB1 + const data = await this.redisManager.db1.getBuffer(filePath); + return data; + } else { + // Local + if (fs.existsSync(filePath)) { + return fs.readFileSync(filePath); + } + return null; + } + } + + async exists(filePath: string): Promise { + if (this.redisManager.db1) { + const exists = await this.redisManager.db1.exists(filePath); + return exists > 0; + } else { + return fs.existsSync(filePath); + } + } +} + +export const fileStorageManager = new FileStorageManager(); diff --git a/src/server/managers/PersistenceManager.ts b/src/server/managers/PersistenceManager.ts new file mode 100644 index 0000000..d5e9715 --- /dev/null +++ b/src/server/managers/PersistenceManager.ts @@ -0,0 +1,114 @@ + +import fs from 'fs'; +import path from 'path'; +import { RoomManager } from './RoomManager'; +import { DraftManager } from './DraftManager'; +import { GameManager } from './GameManager'; +import { fileURLToPath } from 'url'; +import { RedisClientManager } from './RedisClientManager'; + +// Handling __dirname in ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Store data in src/server/data so it persists (assuming not inside a dist that gets wiped, but user root) +const DATA_DIR = path.resolve(process.cwd(), 'server-data'); + +export class PersistenceManager { + private roomManager: RoomManager; + private draftManager: DraftManager; + private gameManager: GameManager; + private redisManager: RedisClientManager; + + constructor(roomManager: RoomManager, draftManager: DraftManager, gameManager: GameManager) { + this.roomManager = roomManager; + this.draftManager = draftManager; + this.gameManager = gameManager; + this.redisManager = RedisClientManager.getInstance(); + + if (!this.redisManager.db0 && !fs.existsSync(DATA_DIR)) { + console.log(`Creating data directory at ${DATA_DIR}`); + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + } + + async save() { + try { + // Accessing private maps via any cast for simplicity without modifying all manager classes to add getters + const rooms = Array.from((this.roomManager as any).rooms.entries()); + const drafts = Array.from((this.draftManager as any).drafts.entries()); + const games = Array.from((this.gameManager as any).games.entries()); + + if (this.redisManager.db0) { + // Save to Redis + const pipeline = this.redisManager.db0.pipeline(); + pipeline.set('rooms', JSON.stringify(rooms)); + pipeline.set('drafts', JSON.stringify(drafts)); + pipeline.set('games', JSON.stringify(games)); + await pipeline.exec(); + // console.log('State saved to Redis'); + } else { + // Save to Local File + fs.writeFileSync(path.join(DATA_DIR, 'rooms.json'), JSON.stringify(rooms)); + fs.writeFileSync(path.join(DATA_DIR, 'drafts.json'), JSON.stringify(drafts)); + fs.writeFileSync(path.join(DATA_DIR, 'games.json'), JSON.stringify(games)); + } + + } catch (e) { + console.error('Failed to save state', e); + } + } + + async load() { + try { + if (this.redisManager.db0) { + // Load from Redis + const [roomsData, draftsData, gamesData] = await Promise.all([ + this.redisManager.db0.get('rooms'), + this.redisManager.db0.get('drafts'), + this.redisManager.db0.get('games') + ]); + + if (roomsData) { + (this.roomManager as any).rooms = new Map(JSON.parse(roomsData)); + console.log(`[Redis] Loaded ${(this.roomManager as any).rooms.size} rooms`); + } + if (draftsData) { + (this.draftManager as any).drafts = new Map(JSON.parse(draftsData)); + console.log(`[Redis] Loaded ${(this.draftManager as any).drafts.size} drafts`); + } + if (gamesData) { + (this.gameManager as any).games = new Map(JSON.parse(gamesData)); + console.log(`[Redis] Loaded ${(this.gameManager as any).games.size} games`); + } + + } else { + // Load from Local File + const roomFile = path.join(DATA_DIR, 'rooms.json'); + const draftFile = path.join(DATA_DIR, 'drafts.json'); + const gameFile = path.join(DATA_DIR, 'games.json'); + + if (fs.existsSync(roomFile)) { + const roomsData = JSON.parse(fs.readFileSync(roomFile, 'utf-8')); + (this.roomManager as any).rooms = new Map(roomsData); + console.log(`[Local] Loaded ${roomsData.length} rooms`); + } + + if (fs.existsSync(draftFile)) { + const draftsData = JSON.parse(fs.readFileSync(draftFile, 'utf-8')); + (this.draftManager as any).drafts = new Map(draftsData); + console.log(`[Local] Loaded ${draftsData.length} drafts`); + } + + if (fs.existsSync(gameFile)) { + const gamesData = JSON.parse(fs.readFileSync(gameFile, 'utf-8')); + (this.gameManager as any).games = new Map(gamesData); + console.log(`[Local] Loaded ${gamesData.length} games`); + } + } + + } catch (e) { + console.error('Failed to load state', e); + } + } +} diff --git a/src/server/managers/RedisClientManager.ts b/src/server/managers/RedisClientManager.ts new file mode 100644 index 0000000..d032b6a --- /dev/null +++ b/src/server/managers/RedisClientManager.ts @@ -0,0 +1,52 @@ + +import Redis from 'ioredis'; + +export class RedisClientManager { + private static instance: RedisClientManager; + public db0: Redis | null = null; // Session Persistence + public db1: Redis | null = null; // File Storage + + private constructor() { + const useRedis = process.env.USE_REDIS === 'true'; + const redisHost = process.env.REDIS_HOST || 'localhost'; + const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10); + + if (useRedis) { + console.log(`[RedisManager] Connecting to Redis at ${redisHost}:${redisPort}...`); + + this.db0 = new Redis({ + host: redisHost, + port: redisPort, + db: 0, + retryStrategy: (times) => Math.min(times * 50, 2000) + }); + + this.db1 = new Redis({ + host: redisHost, + port: redisPort, + db: 1, + retryStrategy: (times) => Math.min(times * 50, 2000) + }); + + this.db0.on('connect', () => console.log('[RedisManager] DB0 Connected')); + this.db0.on('error', (err) => console.error('[RedisManager] DB0 Error', err)); + + this.db1.on('connect', () => console.log('[RedisManager] DB1 Connected')); + this.db1.on('error', (err) => console.error('[RedisManager] DB1 Error', err)); + } else { + console.log('[RedisManager] Redis disabled. Using local storage.'); + } + } + + public static getInstance(): RedisClientManager { + if (!RedisClientManager.instance) { + RedisClientManager.instance = new RedisClientManager(); + } + return RedisClientManager.instance; + } + + public async quit() { + if (this.db0) await this.db0.quit(); + if (this.db1) await this.db1.quit(); + } +} diff --git a/src/server/managers/RoomManager.ts b/src/server/managers/RoomManager.ts index e193c5d..26b4b26 100644 --- a/src/server/managers/RoomManager.ts +++ b/src/server/managers/RoomManager.ts @@ -25,11 +25,17 @@ interface Room { status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished'; messages: ChatMessage[]; maxPlayers: number; + lastActive: number; // For persistence cleanup } export class RoomManager { private rooms: Map = new Map(); + constructor() { + // Cleanup job: Check every 5 minutes + setInterval(() => this.cleanupRooms(), 5 * 60 * 1000); + } + createRoom(hostId: string, hostName: string, packs: any[], basicLands: any[] = [], socketId?: string): Room { const roomId = Math.random().toString(36).substring(2, 8).toUpperCase(); const room: Room = { @@ -40,7 +46,8 @@ export class RoomManager { basicLands, status: 'waiting', messages: [], - maxPlayers: 8 + maxPlayers: hostId.startsWith('SOLO_') ? 1 : 8, // Little hack for solo testing, though 8 is fine + lastActive: Date.now() }; this.rooms.set(roomId, room); return room; @@ -50,6 +57,7 @@ export class RoomManager { const room = this.rooms.get(roomId); if (!room) return null; + room.lastActive = Date.now(); const player = room.players.find(p => p.id === playerId); if (player) { player.ready = true; @@ -62,6 +70,8 @@ export class RoomManager { const room = this.rooms.get(roomId); if (!room) return null; + room.lastActive = Date.now(); + // Rejoin if already exists const existingPlayer = room.players.find(p => p.id === playerId); if (existingPlayer) { @@ -83,6 +93,9 @@ export class RoomManager { updatePlayerSocket(roomId: string, playerId: string, socketId: string): Room | null { const room = this.rooms.get(roomId); if (!room) return null; + + room.lastActive = Date.now(); + const player = room.players.find(p => p.id === playerId); if (player) { player.socketId = socketId; @@ -97,6 +110,12 @@ export class RoomManager { const player = room.players.find(p => p.socketId === socketId); if (player) { player.isOffline = true; + // Do NOT update lastActive on disconnect, or maybe we should? + // No, lastActive is for "when was the room last used?". Disconnect is an event, but inactivity starts from here. + // So keeping lastActive as previous interaction time is safer? + // Actually, if everyone disconnects now, room should be kept for 8 hours from NOW. + // So update lastActive. + room.lastActive = Date.now(); return { room, playerId: player.id }; } } @@ -107,29 +126,30 @@ export class RoomManager { const room = this.rooms.get(roomId); if (!room) return null; + room.lastActive = Date.now(); + + // Logic change: Explicit leave only removes player from list if waiting. + // If playing, mark offline (abandon). + // NEVER DELETE ROOM HERE. Rely on cleanup. + if (room.status === 'waiting') { // Normal logic: Remove player completely 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) { + if (room.players.length > 0 && room.hostId === playerId) { const nextPlayer = room.players.find(p => p.role === 'player') || room.players[0]; if (nextPlayer) { room.hostId = nextPlayer.id; nextPlayer.isHost = true; } } + // If 0 players, room remains in Map until cleanup } else { // Game in progress (Drafting/Playing) - // DO NOT REMOVE PLAYER. Just mark offline. - // This allows them to rejoin and reclaim their seat (and deck). const player = room.players.find(p => p.id === playerId); if (player) { player.isOffline = true; - // Note: socketId is already handled by disconnect event usually, but if explicit leave, we should clear it? player.socketId = undefined; } console.log(`Player ${playerId} left active game in room ${roomId}. Marked as offline.`); @@ -141,26 +161,30 @@ export class RoomManager { const room = this.rooms.get(roomId); if (!room) return null; room.status = 'drafting'; + room.lastActive = Date.now(); return room; } getRoom(roomId: string): Room | undefined { + // Refresh activity if accessed? Not necessarily, only write actions. + // But rejoining calls getRoom implicitly in join logic or index logic? + // Let's assume write actions update lastActive. return this.rooms.get(roomId); } kickPlayer(roomId: string, playerId: string): Room | null { const room = this.rooms.get(roomId); if (!room) return null; + room.lastActive = Date.now(); room.players = room.players.filter(p => p.id !== playerId); - - // If game was running, we might need more cleanup, but for now just removal. return room; } addMessage(roomId: string, sender: string, text: string): ChatMessage | null { const room = this.rooms.get(roomId); if (!room) return null; + room.lastActive = Date.now(); const message: ChatMessage = { id: Math.random().toString(36).substring(7), @@ -173,7 +197,6 @@ export class RoomManager { } getPlayerBySocket(socketId: string): { player: Player, room: Room } | null { - // Inefficient linear search, but robust for now. Maps would be better for high scale. for (const room of this.rooms.values()) { const player = room.players.find(p => p.socketId === socketId); if (player) { @@ -182,4 +205,26 @@ export class RoomManager { } return null; } + + private cleanupRooms() { + const now = Date.now(); + const EXPIRATION_MS = 8 * 60 * 60 * 1000; // 8 Hours + + for (const [roomId, room] of this.rooms.entries()) { + // Logic: + // 1. If players are online, room is active. -> Don't delete. + // 2. If NO players are online (all offline or empty), check lastActive. + + const anyOnline = room.players.some(p => !p.isOffline); + if (anyOnline) { + continue; // Active + } + + // No one online. Check expiration. + if (now - room.lastActive > EXPIRATION_MS) { + console.log(`Cleaning up expired room ${roomId}. Inactive for > 8 hours.`); + this.rooms.delete(roomId); + } + } + } } diff --git a/src/server/services/CardService.ts b/src/server/services/CardService.ts index 2cc2b87..6e62e53 100644 --- a/src/server/services/CardService.ts +++ b/src/server/services/CardService.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { fileStorageManager } from '../managers/FileStorageManager'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -15,65 +16,10 @@ export class CardService { this.imagesDir = path.join(CARDS_DIR, 'images'); this.metadataDir = path.join(CARDS_DIR, 'metadata'); - this.ensureDirs(); - this.migrateExistingImages(); - } - - private ensureDirs() { - if (!fs.existsSync(this.imagesDir)) { - fs.mkdirSync(this.imagesDir, { recursive: true }); - } - if (!fs.existsSync(this.metadataDir)) { - fs.mkdirSync(this.metadataDir, { recursive: true }); - } - } - - private migrateExistingImages() { - console.log('[CardService] Checking for images to migrate...'); - const start = Date.now(); - let moved = 0; - - try { - if (fs.existsSync(this.metadataDir)) { - const items = fs.readdirSync(this.metadataDir); - for (const item of items) { - const itemPath = path.join(this.metadataDir, item); - if (fs.statSync(itemPath).isDirectory()) { - // This determines the set - const setCode = item; - const cardFiles = fs.readdirSync(itemPath); - - for (const file of cardFiles) { - if (!file.endsWith('.json')) continue; - const id = file.replace('.json', ''); - - // Check for legacy image - const legacyImgPath = path.join(this.imagesDir, `${id}.jpg`); - if (fs.existsSync(legacyImgPath)) { - const targetDir = path.join(this.imagesDir, setCode); - if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); - - const targetPath = path.join(targetDir, `${id}.jpg`); - try { - fs.renameSync(legacyImgPath, targetPath); - moved++; - } catch (e) { - console.error(`[CardService] Failed to move ${id}.jpg to ${setCode}`, e); - } - } - } - } - } - } - } catch (e) { - console.error('[CardService] Migration error', e); - } - - if (moved > 0) { - console.log(`[CardService] Migrated ${moved} images to set folders in ${Date.now() - start}ms.`); - } else { - console.log(`[CardService] No images needed migration.`); - } + // Directory creation is handled by FileStorageManager on write for Local, + // and not needed for Redis. + // Migration logic removed as it's FS specific and one-time. + // If we need migration to Redis, it should be a separate script. } async cacheImages(cards: any[]): Promise { @@ -102,36 +48,19 @@ export class CardService { if (!imageUrl) continue; - const setDir = path.join(this.imagesDir, setCode); - if (!fs.existsSync(setDir)) { - fs.mkdirSync(setDir, { recursive: true }); - } + const filePath = path.join(this.imagesDir, setCode, `${uuid}.jpg`); - const filePath = path.join(setDir, `${uuid}.jpg`); - - // Check if exists in set folder - if (fs.existsSync(filePath)) { + // Check if exists + if (await fileStorageManager.exists(filePath)) { continue; } - // Check legacy location and move if exists (double check) - const legacyPath = path.join(this.imagesDir, `${uuid}.jpg`); - if (fs.existsSync(legacyPath)) { - try { - fs.renameSync(legacyPath, filePath); - // console.log(`Migrated image ${uuid} to ${setCode}`); - continue; - } catch (e) { - console.error(`Failed to migrate image ${uuid}`, e); - } - } - try { // Download const response = await fetch(imageUrl); if (response.ok) { const buffer = await response.arrayBuffer(); - fs.writeFileSync(filePath, Buffer.from(buffer)); + await fileStorageManager.saveFile(filePath, Buffer.from(buffer)); downloadedCount++; console.log(`Cached image: ${setCode}/${uuid}.jpg`); } else { @@ -154,19 +83,10 @@ export class CardService { for (const card of cards) { if (!card.id || !card.set) continue; - const setDir = path.join(this.metadataDir, card.set); - if (!fs.existsSync(setDir)) { - fs.mkdirSync(setDir, { recursive: true }); - } - - const filePath = path.join(setDir, `${card.id}.json`); - if (!fs.existsSync(filePath)) { + const filePath = path.join(this.metadataDir, card.set, `${card.id}.json`); + if (!(await fileStorageManager.exists(filePath))) { try { - fs.writeFileSync(filePath, JSON.stringify(card, null, 2)); - // Check and delete legacy if exists - const legacy = path.join(this.metadataDir, `${card.id}.json`); - if (fs.existsSync(legacy)) fs.unlinkSync(legacy); - + await fileStorageManager.saveFile(filePath, JSON.stringify(card, null, 2)); cachedCount++; } catch (e) { console.error(`Failed to save metadata for ${card.id}`, e);