implemented game server sync
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -141,3 +141,4 @@ vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
src/server/public/cards/*
|
||||
src/server-data
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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">
|
||||
|
||||
@@ -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
99
src/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
55
src/server/managers/FileStorageManager.ts
Normal file
55
src/server/managers/FileStorageManager.ts
Normal 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();
|
||||
114
src/server/managers/PersistenceManager.ts
Normal file
114
src/server/managers/PersistenceManager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/server/managers/RedisClientManager.ts
Normal file
52
src/server/managers/RedisClientManager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user