implemented game server sync
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -141,3 +141,4 @@ vite.config.ts.timestamp-*
|
|||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
src/server/public/cards/*
|
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 { GameState, CardInstance } from '../../types/game';
|
||||||
import { socketService } from '../../services/SocketService';
|
import { socketService } from '../../services/SocketService';
|
||||||
import { CardComponent } from './CardComponent';
|
import { CardComponent } from './CardComponent';
|
||||||
@@ -12,10 +13,79 @@ interface GameViewProps {
|
|||||||
|
|
||||||
export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }) => {
|
export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }) => {
|
||||||
const battlefieldRef = useRef<HTMLDivElement>(null);
|
const battlefieldRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
|
||||||
const [viewingZone, setViewingZone] = useState<string | null>(null);
|
const [viewingZone, setViewingZone] = useState<string | null>(null);
|
||||||
const [hoveredCard, setHoveredCard] = useState<CardInstance | 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(() => {
|
useEffect(() => {
|
||||||
// Disable default context menu
|
// Disable default context menu
|
||||||
const handleContext = (e: MouseEvent) => e.preventDefault();
|
const handleContext = (e: MouseEvent) => e.preventDefault();
|
||||||
@@ -152,15 +222,80 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Zoom Sidebar */}
|
{/* 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">
|
{isSidebarCollapsed ? (
|
||||||
{hoveredCard ? (
|
<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">
|
||||||
<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']">
|
<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
|
<img
|
||||||
src={hoveredCard.imageUrl}
|
src={hoveredCard.imageUrl}
|
||||||
alt={hoveredCard.name}
|
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>
|
<h3 className="text-lg font-bold text-slate-200 leading-tight">{hoveredCard.name}</h3>
|
||||||
|
|
||||||
{hoveredCard.manaCost && (
|
{hoveredCard.manaCost && (
|
||||||
@@ -174,30 +309,24 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hoveredCard.oracleText && (
|
{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}
|
{hoveredCard.oracleText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats for Creatures */}
|
{/* Resize Handle */}
|
||||||
{hoveredCard.typeLine?.toLowerCase().includes('creature') && (
|
<div
|
||||||
<div className="mt-3 bg-slate-800/80 rounded px-2 py-1 inline-block border border-slate-600 font-bold text-lg">
|
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"
|
||||||
{/* Accessing raw PT might be hard if we don't have base PT, but we do have ptModification */}
|
onMouseDown={handleResizeStart}
|
||||||
{/* We don't strictly have base PT in CardInstance yet. Assuming UI mainly uses image. */}
|
onTouchStart={handleResizeStart}
|
||||||
{/* We'll skip P/T text for now as it needs base P/T to be passed from server. */}
|
>
|
||||||
|
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
|
||||||
|
</div>
|
||||||
</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 */}
|
{/* Main Game Area */}
|
||||||
<div className="flex-1 flex flex-col h-full relative">
|
<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 [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [initialDraftState, setInitialDraftState] = useState<any>(null);
|
const [initialDraftState, setInitialDraftState] = useState<any>(null);
|
||||||
|
const [initialGameState, setInitialGameState] = useState<any>(null);
|
||||||
|
|
||||||
const [playerId] = useState(() => {
|
const [playerId] = useState(() => {
|
||||||
const saved = localStorage.getItem('player_id');
|
const saved = localStorage.getItem('player_id');
|
||||||
@@ -195,6 +196,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setInitialDraftState(response.draftState || null);
|
setInitialDraftState(response.draftState || null);
|
||||||
|
setInitialGameState(response.gameState || null);
|
||||||
setActiveRoom(response.room);
|
setActiveRoom(response.room);
|
||||||
} else {
|
} else {
|
||||||
setError(response.message || 'Failed to join room');
|
setError(response.message || 'Failed to join room');
|
||||||
@@ -227,6 +229,9 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
|
|||||||
if (response.draftState) {
|
if (response.draftState) {
|
||||||
setInitialDraftState(response.draftState);
|
setInitialDraftState(response.draftState);
|
||||||
}
|
}
|
||||||
|
if (response.gameState) {
|
||||||
|
setInitialGameState(response.gameState);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("Rejoin failed by server: ", response.message);
|
console.warn("Rejoin failed by server: ", response.message);
|
||||||
localStorage.removeItem('active_room_id');
|
localStorage.removeItem('active_room_id');
|
||||||
@@ -261,11 +266,12 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
|
|||||||
}
|
}
|
||||||
setActiveRoom(null);
|
setActiveRoom(null);
|
||||||
setInitialDraftState(null);
|
setInitialDraftState(null);
|
||||||
|
setInitialGameState(null);
|
||||||
localStorage.removeItem('active_room_id');
|
localStorage.removeItem('active_room_id');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activeRoom) {
|
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 (
|
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/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"ioredis": "^5.8.2",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/ioredis": "^4.28.10",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/react": "^19.0.1",
|
"@types/react": "^19.0.1",
|
||||||
"@types/react-dom": "^19.0.1",
|
"@types/react-dom": "^19.0.1",
|
||||||
@@ -1999,6 +2001,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@isaacs/balanced-match": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||||
@@ -2714,6 +2722,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
@@ -3387,6 +3405,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3671,6 +3698,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -4694,6 +4730,30 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -5289,6 +5349,18 @@
|
|||||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.sortby": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||||
@@ -6016,6 +6088,27 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -6847,6 +6940,12 @@
|
|||||||
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"ioredis": "^5.8.2",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/ioredis": "^4.28.10",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/react": "^19.0.1",
|
"@types/react": "^19.0.1",
|
||||||
"@types/react-dom": "^19.0.1",
|
"@types/react-dom": "^19.0.1",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { CardService } from './services/CardService';
|
|||||||
import { ScryfallService } from './services/ScryfallService';
|
import { ScryfallService } from './services/ScryfallService';
|
||||||
import { PackGeneratorService } from './services/PackGeneratorService';
|
import { PackGeneratorService } from './services/PackGeneratorService';
|
||||||
import { CardParserService } from './services/CardParserService';
|
import { CardParserService } from './services/CardParserService';
|
||||||
|
import { PersistenceManager } from './managers/PersistenceManager';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -27,6 +28,16 @@ const io = new Server(httpServer, {
|
|||||||
const roomManager = new RoomManager();
|
const roomManager = new RoomManager();
|
||||||
const gameManager = new GameManager();
|
const gameManager = new GameManager();
|
||||||
const draftManager = new DraftManager();
|
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 cardService = new CardService();
|
||||||
const scryfallService = new ScryfallService();
|
const scryfallService = new ScryfallService();
|
||||||
const packGeneratorService = new PackGeneratorService();
|
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
|
app.use(express.json({ limit: '1000mb' })); // Increase limit for large card lists
|
||||||
|
|
||||||
// Serve static images (Nested)
|
// Serve static images (Nested)
|
||||||
|
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('/cards', express.static(path.join(__dirname, 'public/cards')));
|
||||||
|
}
|
||||||
|
|
||||||
app.use('/images', express.static(path.join(__dirname, 'public/images')));
|
app.use('/images', express.static(path.join(__dirname, 'public/images')));
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
@@ -566,6 +602,8 @@ httpServer.listen(Number(PORT), '0.0.0.0', () => {
|
|||||||
const gracefulShutdown = () => {
|
const gracefulShutdown = () => {
|
||||||
console.log('Received kill signal, shutting down gracefully');
|
console.log('Received kill signal, shutting down gracefully');
|
||||||
clearInterval(draftInterval);
|
clearInterval(draftInterval);
|
||||||
|
clearInterval(persistenceInterval);
|
||||||
|
persistenceManager.save(); // Save on exit
|
||||||
|
|
||||||
io.close(() => {
|
io.close(() => {
|
||||||
console.log('Socket.io closed');
|
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';
|
status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished';
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
|
lastActive: number; // For persistence cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RoomManager {
|
export class RoomManager {
|
||||||
private rooms: Map<string, Room> = new Map();
|
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 {
|
createRoom(hostId: string, hostName: string, packs: any[], basicLands: any[] = [], socketId?: string): Room {
|
||||||
const roomId = Math.random().toString(36).substring(2, 8).toUpperCase();
|
const roomId = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
@@ -40,7 +46,8 @@ export class RoomManager {
|
|||||||
basicLands,
|
basicLands,
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
messages: [],
|
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);
|
this.rooms.set(roomId, room);
|
||||||
return room;
|
return room;
|
||||||
@@ -50,6 +57,7 @@ export class RoomManager {
|
|||||||
const room = this.rooms.get(roomId);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
room.lastActive = Date.now();
|
||||||
const player = room.players.find(p => p.id === playerId);
|
const player = room.players.find(p => p.id === playerId);
|
||||||
if (player) {
|
if (player) {
|
||||||
player.ready = true;
|
player.ready = true;
|
||||||
@@ -62,6 +70,8 @@ export class RoomManager {
|
|||||||
const room = this.rooms.get(roomId);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
room.lastActive = Date.now();
|
||||||
|
|
||||||
// Rejoin if already exists
|
// Rejoin if already exists
|
||||||
const existingPlayer = room.players.find(p => p.id === playerId);
|
const existingPlayer = room.players.find(p => p.id === playerId);
|
||||||
if (existingPlayer) {
|
if (existingPlayer) {
|
||||||
@@ -83,6 +93,9 @@ export class RoomManager {
|
|||||||
updatePlayerSocket(roomId: string, playerId: string, socketId: string): Room | null {
|
updatePlayerSocket(roomId: string, playerId: string, socketId: string): Room | null {
|
||||||
const room = this.rooms.get(roomId);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
room.lastActive = Date.now();
|
||||||
|
|
||||||
const player = room.players.find(p => p.id === playerId);
|
const player = room.players.find(p => p.id === playerId);
|
||||||
if (player) {
|
if (player) {
|
||||||
player.socketId = socketId;
|
player.socketId = socketId;
|
||||||
@@ -97,6 +110,12 @@ export class RoomManager {
|
|||||||
const player = room.players.find(p => p.socketId === socketId);
|
const player = room.players.find(p => p.socketId === socketId);
|
||||||
if (player) {
|
if (player) {
|
||||||
player.isOffline = true;
|
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 };
|
return { room, playerId: player.id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,29 +126,30 @@ export class RoomManager {
|
|||||||
const room = this.rooms.get(roomId);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return null;
|
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') {
|
if (room.status === 'waiting') {
|
||||||
// Normal logic: Remove player completely
|
// Normal logic: Remove player completely
|
||||||
room.players = room.players.filter(p => p.id !== playerId);
|
room.players = room.players.filter(p => p.id !== playerId);
|
||||||
|
|
||||||
// If host leaves, assign new host from remaining players
|
// If host leaves, assign new host from remaining players
|
||||||
if (room.players.length === 0) {
|
if (room.players.length > 0 && room.hostId === playerId) {
|
||||||
this.rooms.delete(roomId);
|
|
||||||
return null;
|
|
||||||
} else if (room.hostId === playerId) {
|
|
||||||
const nextPlayer = room.players.find(p => p.role === 'player') || room.players[0];
|
const nextPlayer = room.players.find(p => p.role === 'player') || room.players[0];
|
||||||
if (nextPlayer) {
|
if (nextPlayer) {
|
||||||
room.hostId = nextPlayer.id;
|
room.hostId = nextPlayer.id;
|
||||||
nextPlayer.isHost = true;
|
nextPlayer.isHost = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If 0 players, room remains in Map until cleanup
|
||||||
} else {
|
} else {
|
||||||
// Game in progress (Drafting/Playing)
|
// 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);
|
const player = room.players.find(p => p.id === playerId);
|
||||||
if (player) {
|
if (player) {
|
||||||
player.isOffline = true;
|
player.isOffline = true;
|
||||||
// Note: socketId is already handled by disconnect event usually, but if explicit leave, we should clear it?
|
|
||||||
player.socketId = undefined;
|
player.socketId = undefined;
|
||||||
}
|
}
|
||||||
console.log(`Player ${playerId} left active game in room ${roomId}. Marked as offline.`);
|
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);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
room.status = 'drafting';
|
room.status = 'drafting';
|
||||||
|
room.lastActive = Date.now();
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRoom(roomId: string): Room | undefined {
|
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);
|
return this.rooms.get(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
kickPlayer(roomId: string, playerId: string): Room | null {
|
kickPlayer(roomId: string, playerId: string): Room | null {
|
||||||
const room = this.rooms.get(roomId);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
room.lastActive = Date.now();
|
||||||
|
|
||||||
room.players = room.players.filter(p => p.id !== playerId);
|
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;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage(roomId: string, sender: string, text: string): ChatMessage | null {
|
addMessage(roomId: string, sender: string, text: string): ChatMessage | null {
|
||||||
const room = this.rooms.get(roomId);
|
const room = this.rooms.get(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
room.lastActive = Date.now();
|
||||||
|
|
||||||
const message: ChatMessage = {
|
const message: ChatMessage = {
|
||||||
id: Math.random().toString(36).substring(7),
|
id: Math.random().toString(36).substring(7),
|
||||||
@@ -173,7 +197,6 @@ export class RoomManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPlayerBySocket(socketId: string): { player: Player, room: Room } | null {
|
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()) {
|
for (const room of this.rooms.values()) {
|
||||||
const player = room.players.find(p => p.socketId === socketId);
|
const player = room.players.find(p => p.socketId === socketId);
|
||||||
if (player) {
|
if (player) {
|
||||||
@@ -182,4 +205,26 @@ export class RoomManager {
|
|||||||
}
|
}
|
||||||
return null;
|
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 fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { fileStorageManager } from '../managers/FileStorageManager';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -15,65 +16,10 @@ export class CardService {
|
|||||||
this.imagesDir = path.join(CARDS_DIR, 'images');
|
this.imagesDir = path.join(CARDS_DIR, 'images');
|
||||||
this.metadataDir = path.join(CARDS_DIR, 'metadata');
|
this.metadataDir = path.join(CARDS_DIR, 'metadata');
|
||||||
|
|
||||||
this.ensureDirs();
|
// Directory creation is handled by FileStorageManager on write for Local,
|
||||||
this.migrateExistingImages();
|
// 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.
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async cacheImages(cards: any[]): Promise<number> {
|
async cacheImages(cards: any[]): Promise<number> {
|
||||||
@@ -102,36 +48,19 @@ export class CardService {
|
|||||||
|
|
||||||
if (!imageUrl) continue;
|
if (!imageUrl) continue;
|
||||||
|
|
||||||
const setDir = path.join(this.imagesDir, setCode);
|
const filePath = path.join(this.imagesDir, setCode, `${uuid}.jpg`);
|
||||||
if (!fs.existsSync(setDir)) {
|
|
||||||
fs.mkdirSync(setDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(setDir, `${uuid}.jpg`);
|
// Check if exists
|
||||||
|
if (await fileStorageManager.exists(filePath)) {
|
||||||
// Check if exists in set folder
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
continue;
|
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 {
|
try {
|
||||||
// Download
|
// Download
|
||||||
const response = await fetch(imageUrl);
|
const response = await fetch(imageUrl);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
fs.writeFileSync(filePath, Buffer.from(buffer));
|
await fileStorageManager.saveFile(filePath, Buffer.from(buffer));
|
||||||
downloadedCount++;
|
downloadedCount++;
|
||||||
console.log(`Cached image: ${setCode}/${uuid}.jpg`);
|
console.log(`Cached image: ${setCode}/${uuid}.jpg`);
|
||||||
} else {
|
} else {
|
||||||
@@ -154,19 +83,10 @@ export class CardService {
|
|||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
if (!card.id || !card.set) continue;
|
if (!card.id || !card.set) continue;
|
||||||
|
|
||||||
const setDir = path.join(this.metadataDir, card.set);
|
const filePath = path.join(this.metadataDir, card.set, `${card.id}.json`);
|
||||||
if (!fs.existsSync(setDir)) {
|
if (!(await fileStorageManager.exists(filePath))) {
|
||||||
fs.mkdirSync(setDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(setDir, `${card.id}.json`);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(filePath, JSON.stringify(card, null, 2));
|
await fileStorageManager.saveFile(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);
|
|
||||||
|
|
||||||
cachedCount++;
|
cachedCount++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to save metadata for ${card.id}`, e);
|
console.error(`Failed to save metadata for ${card.id}`, e);
|
||||||
|
|||||||
Reference in New Issue
Block a user