implemented game server sync

This commit is contained in:
2025-12-18 17:24:07 +01:00
parent e31323859f
commit a2a45a995c
15 changed files with 708 additions and 146 deletions

1
.gitignore vendored
View File

@@ -141,3 +141,4 @@ vite.config.ts.timestamp-*
.vite/
src/server/public/cards/*
src/server-data

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<GameViewProps> = ({ gameState, currentPlayerId }) => {
const battlefieldRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
const [viewingZone, setViewingZone] = useState<string | null>(null);
const [hoveredCard, setHoveredCard] = useState<CardInstance | null>(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,15 +222,80 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
)}
{/* Zoom Sidebar */}
<div className="hidden xl:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800 bg-slate-950/50 z-30 p-4 relative shadow-2xl">
{hoveredCard ? (
<div className="animate-in fade-in slide-in-from-left-4 duration-200 sticky top-4 w-full h-[calc(100vh-2rem)] overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
{isSidebarCollapsed ? (
<div key="collapsed" className="hidden xl:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-30 gap-4 transition-all duration-300">
<button
onClick={() => setIsSidebarCollapsed(false)}
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
title="Expand Preview"
>
<Eye className="w-6 h-6" />
<span className="absolute left-full ml-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10 z-50">
Card Preview
</span>
</button>
</div>
) : (
<div
key="expanded"
ref={sidebarRef}
className="hidden xl:flex shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-30 p-4 relative group/sidebar shadow-2xl"
style={{ width: sidebarWidth }}
>
{/* Collapse Button */}
<button
onClick={() => setIsSidebarCollapsed(true)}
className="absolute top-2 right-2 p-1.5 bg-slate-800/80 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors z-20 opacity-0 group-hover/sidebar:opacity-100"
title="Collapse Preview"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="w-full relative sticky top-4 flex flex-col h-full overflow-hidden">
<div className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out shrink-0">
<div
className="relative w-full h-full"
style={{
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)',
transition: 'transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)'
}}
>
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
style={{ backfaceVisibility: 'hidden' }}
>
{hoveredCard && (
<img
src={hoveredCard.imageUrl}
alt={hoveredCard.name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
className="w-full h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
/>
<div className="mt-4 text-center pb-4">
)}
</div>
{/* Back Face (Card Back) */}
<div
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
draggable={false}
/>
</div>
</div>
</div>
{/* Oracle Text & Details - Only when card is hovered */}
{hoveredCard && (
<div className="mt-4 flex-1 overflow-y-auto px-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<h3 className="text-lg font-bold text-slate-200 leading-tight">{hoveredCard.name}</h3>
{hoveredCard.manaCost && (
@@ -174,30 +309,24 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
)}
{hoveredCard.oracleText && (
<div className="text-sm text-slate-300 text-left bg-slate-900/50 p-3 rounded-lg border border-slate-800 whitespace-pre-wrap leading-relaxed">
<div className="text-sm text-slate-300 text-left bg-slate-900/50 p-3 rounded-lg border border-slate-800 whitespace-pre-wrap leading-relaxed shadow-inner">
{hoveredCard.oracleText}
</div>
)}
</div>
)}
</div>
{/* Stats for Creatures */}
{hoveredCard.typeLine?.toLowerCase().includes('creature') && (
<div className="mt-3 bg-slate-800/80 rounded px-2 py-1 inline-block border border-slate-600 font-bold text-lg">
{/* 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. */}
{/* Resize Handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-emerald-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors touch-none"
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
>
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
</div>
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 text-center opacity-50">
<div className="w-48 h-64 border-2 border-dashed border-slate-700 rounded-xl mb-4 flex items-center justify-center">
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span>
</div>
<p className="text-sm">Hover over a card to view clear details.</p>
</div>
)}
</div>
{/* Main Game Area */}
<div className="flex-1 flex flex-col h-full relative">

View File

@@ -18,6 +18,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [initialDraftState, setInitialDraftState] = useState<any>(null);
const [initialGameState, setInitialGameState] = useState<any>(null);
const [playerId] = useState(() => {
const saved = localStorage.getItem('player_id');
@@ -195,6 +196,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ 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<LobbyManagerProps> = ({ 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<LobbyManagerProps> = ({ generatedPacks, avai
}
setActiveRoom(null);
setInitialDraftState(null);
setInitialGameState(null);
localStorage.removeItem('active_room_id');
};
if (activeRoom) {
return <GameRoom room={activeRoom} currentPlayerId={playerId} onExit={handleExitRoom} initialDraftState={initialDraftState} />;
return <GameRoom room={activeRoom} currentPlayerId={playerId} onExit={handleExitRoom} initialDraftState={initialDraftState} initialGameState={initialGameState} />;
}
return (

99
src/package-lock.json generated
View File

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

View File

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

View File

@@ -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');

View File

@@ -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<void> {
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<Buffer | null> {
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<boolean> {
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();

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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<string, Room> = 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);
}
}
}
}

View File

@@ -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<number> {
@@ -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);