implemented game server sync

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

View File

@@ -10,6 +10,7 @@ import { CardService } from './services/CardService';
import { ScryfallService } from './services/ScryfallService';
import { PackGeneratorService } from './services/PackGeneratorService';
import { CardParserService } from './services/CardParserService';
import { PersistenceManager } from './managers/PersistenceManager';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -27,6 +28,16 @@ const io = new Server(httpServer, {
const roomManager = new RoomManager();
const gameManager = new GameManager();
const draftManager = new DraftManager();
const persistenceManager = new PersistenceManager(roomManager, draftManager, gameManager);
// Load previous state
persistenceManager.load();
// Auto-Save Loop (Every 5 seconds)
const persistenceInterval = setInterval(() => {
persistenceManager.save();
}, 5000);
const cardService = new CardService();
const scryfallService = new ScryfallService();
const packGeneratorService = new PackGeneratorService();
@@ -36,7 +47,32 @@ const PORT = process.env.PORT || 3000;
app.use(express.json({ limit: '1000mb' })); // Increase limit for large card lists
// Serve static images (Nested)
app.use('/cards', express.static(path.join(__dirname, 'public/cards')));
import { RedisClientManager } from './managers/RedisClientManager';
import { fileStorageManager } from './managers/FileStorageManager';
const redisForFiles = RedisClientManager.getInstance().db1;
if (redisForFiles) {
console.log('[Server] Using Redis for file serving');
app.get('/cards/*', async (req: Request, res: Response) => {
const relativePath = req.path;
const filePath = path.join(__dirname, 'public', relativePath);
const buffer = await fileStorageManager.readFile(filePath);
if (buffer) {
if (filePath.endsWith('.jpg')) res.type('image/jpeg');
else if (filePath.endsWith('.png')) res.type('image/png');
else if (filePath.endsWith('.json')) res.type('application/json');
res.send(buffer);
} else {
res.status(404).send('Not Found');
}
});
} else {
console.log('[Server] Using Local FS for file serving');
app.use('/cards', express.static(path.join(__dirname, 'public/cards')));
}
app.use('/images', express.static(path.join(__dirname, 'public/images')));
// API Routes
@@ -566,6 +602,8 @@ httpServer.listen(Number(PORT), '0.0.0.0', () => {
const gracefulShutdown = () => {
console.log('Received kill signal, shutting down gracefully');
clearInterval(draftInterval);
clearInterval(persistenceInterval);
persistenceManager.save(); // Save on exit
io.close(() => {
console.log('Socket.io closed');

View File

@@ -0,0 +1,55 @@
import fs from 'fs';
import path from 'path';
import { RedisClientManager } from './RedisClientManager';
export class FileStorageManager {
private redisManager: RedisClientManager;
constructor() {
this.redisManager = RedisClientManager.getInstance();
}
async saveFile(filePath: string, data: Buffer | string): Promise<void> {
if (this.redisManager.db1) {
// Use Redis DB1
// Key: Normalize path to be relative to project root or something unique?
// Simple approach: Use absolute path (careful with different servers) or relative path key.
// Let's assume filePath passed in is absolute. We iterate up to remove common prefix if we want cleaner keys,
// but absolute is safest uniqueness.
await this.redisManager.db1.set(filePath, typeof data === 'string' ? data : data.toString('binary'));
} else {
// Local File System
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, data);
}
}
async readFile(filePath: string): Promise<Buffer | null> {
if (this.redisManager.db1) {
// Redis DB1
const data = await this.redisManager.db1.getBuffer(filePath);
return data;
} else {
// Local
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath);
}
return null;
}
}
async exists(filePath: string): Promise<boolean> {
if (this.redisManager.db1) {
const exists = await this.redisManager.db1.exists(filePath);
return exists > 0;
} else {
return fs.existsSync(filePath);
}
}
}
export const fileStorageManager = new FileStorageManager();

View File

@@ -0,0 +1,114 @@
import fs from 'fs';
import path from 'path';
import { RoomManager } from './RoomManager';
import { DraftManager } from './DraftManager';
import { GameManager } from './GameManager';
import { fileURLToPath } from 'url';
import { RedisClientManager } from './RedisClientManager';
// Handling __dirname in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Store data in src/server/data so it persists (assuming not inside a dist that gets wiped, but user root)
const DATA_DIR = path.resolve(process.cwd(), 'server-data');
export class PersistenceManager {
private roomManager: RoomManager;
private draftManager: DraftManager;
private gameManager: GameManager;
private redisManager: RedisClientManager;
constructor(roomManager: RoomManager, draftManager: DraftManager, gameManager: GameManager) {
this.roomManager = roomManager;
this.draftManager = draftManager;
this.gameManager = gameManager;
this.redisManager = RedisClientManager.getInstance();
if (!this.redisManager.db0 && !fs.existsSync(DATA_DIR)) {
console.log(`Creating data directory at ${DATA_DIR}`);
fs.mkdirSync(DATA_DIR, { recursive: true });
}
}
async save() {
try {
// Accessing private maps via any cast for simplicity without modifying all manager classes to add getters
const rooms = Array.from((this.roomManager as any).rooms.entries());
const drafts = Array.from((this.draftManager as any).drafts.entries());
const games = Array.from((this.gameManager as any).games.entries());
if (this.redisManager.db0) {
// Save to Redis
const pipeline = this.redisManager.db0.pipeline();
pipeline.set('rooms', JSON.stringify(rooms));
pipeline.set('drafts', JSON.stringify(drafts));
pipeline.set('games', JSON.stringify(games));
await pipeline.exec();
// console.log('State saved to Redis');
} else {
// Save to Local File
fs.writeFileSync(path.join(DATA_DIR, 'rooms.json'), JSON.stringify(rooms));
fs.writeFileSync(path.join(DATA_DIR, 'drafts.json'), JSON.stringify(drafts));
fs.writeFileSync(path.join(DATA_DIR, 'games.json'), JSON.stringify(games));
}
} catch (e) {
console.error('Failed to save state', e);
}
}
async load() {
try {
if (this.redisManager.db0) {
// Load from Redis
const [roomsData, draftsData, gamesData] = await Promise.all([
this.redisManager.db0.get('rooms'),
this.redisManager.db0.get('drafts'),
this.redisManager.db0.get('games')
]);
if (roomsData) {
(this.roomManager as any).rooms = new Map(JSON.parse(roomsData));
console.log(`[Redis] Loaded ${(this.roomManager as any).rooms.size} rooms`);
}
if (draftsData) {
(this.draftManager as any).drafts = new Map(JSON.parse(draftsData));
console.log(`[Redis] Loaded ${(this.draftManager as any).drafts.size} drafts`);
}
if (gamesData) {
(this.gameManager as any).games = new Map(JSON.parse(gamesData));
console.log(`[Redis] Loaded ${(this.gameManager as any).games.size} games`);
}
} else {
// Load from Local File
const roomFile = path.join(DATA_DIR, 'rooms.json');
const draftFile = path.join(DATA_DIR, 'drafts.json');
const gameFile = path.join(DATA_DIR, 'games.json');
if (fs.existsSync(roomFile)) {
const roomsData = JSON.parse(fs.readFileSync(roomFile, 'utf-8'));
(this.roomManager as any).rooms = new Map(roomsData);
console.log(`[Local] Loaded ${roomsData.length} rooms`);
}
if (fs.existsSync(draftFile)) {
const draftsData = JSON.parse(fs.readFileSync(draftFile, 'utf-8'));
(this.draftManager as any).drafts = new Map(draftsData);
console.log(`[Local] Loaded ${draftsData.length} drafts`);
}
if (fs.existsSync(gameFile)) {
const gamesData = JSON.parse(fs.readFileSync(gameFile, 'utf-8'));
(this.gameManager as any).games = new Map(gamesData);
console.log(`[Local] Loaded ${gamesData.length} games`);
}
}
} catch (e) {
console.error('Failed to load state', e);
}
}
}

View File

@@ -0,0 +1,52 @@
import Redis from 'ioredis';
export class RedisClientManager {
private static instance: RedisClientManager;
public db0: Redis | null = null; // Session Persistence
public db1: Redis | null = null; // File Storage
private constructor() {
const useRedis = process.env.USE_REDIS === 'true';
const redisHost = process.env.REDIS_HOST || 'localhost';
const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10);
if (useRedis) {
console.log(`[RedisManager] Connecting to Redis at ${redisHost}:${redisPort}...`);
this.db0 = new Redis({
host: redisHost,
port: redisPort,
db: 0,
retryStrategy: (times) => Math.min(times * 50, 2000)
});
this.db1 = new Redis({
host: redisHost,
port: redisPort,
db: 1,
retryStrategy: (times) => Math.min(times * 50, 2000)
});
this.db0.on('connect', () => console.log('[RedisManager] DB0 Connected'));
this.db0.on('error', (err) => console.error('[RedisManager] DB0 Error', err));
this.db1.on('connect', () => console.log('[RedisManager] DB1 Connected'));
this.db1.on('error', (err) => console.error('[RedisManager] DB1 Error', err));
} else {
console.log('[RedisManager] Redis disabled. Using local storage.');
}
}
public static getInstance(): RedisClientManager {
if (!RedisClientManager.instance) {
RedisClientManager.instance = new RedisClientManager();
}
return RedisClientManager.instance;
}
public async quit() {
if (this.db0) await this.db0.quit();
if (this.db1) await this.db1.quit();
}
}

View File

@@ -25,11 +25,17 @@ interface Room {
status: 'waiting' | 'drafting' | 'deck_building' | 'playing' | 'finished';
messages: ChatMessage[];
maxPlayers: number;
lastActive: number; // For persistence cleanup
}
export class RoomManager {
private rooms: Map<string, Room> = new Map();
constructor() {
// Cleanup job: Check every 5 minutes
setInterval(() => this.cleanupRooms(), 5 * 60 * 1000);
}
createRoom(hostId: string, hostName: string, packs: any[], basicLands: any[] = [], socketId?: string): Room {
const roomId = Math.random().toString(36).substring(2, 8).toUpperCase();
const room: Room = {
@@ -40,7 +46,8 @@ export class RoomManager {
basicLands,
status: 'waiting',
messages: [],
maxPlayers: 8
maxPlayers: hostId.startsWith('SOLO_') ? 1 : 8, // Little hack for solo testing, though 8 is fine
lastActive: Date.now()
};
this.rooms.set(roomId, room);
return room;
@@ -50,6 +57,7 @@ export class RoomManager {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
const player = room.players.find(p => p.id === playerId);
if (player) {
player.ready = true;
@@ -62,6 +70,8 @@ export class RoomManager {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
// Rejoin if already exists
const existingPlayer = room.players.find(p => p.id === playerId);
if (existingPlayer) {
@@ -83,6 +93,9 @@ export class RoomManager {
updatePlayerSocket(roomId: string, playerId: string, socketId: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
const player = room.players.find(p => p.id === playerId);
if (player) {
player.socketId = socketId;
@@ -97,6 +110,12 @@ export class RoomManager {
const player = room.players.find(p => p.socketId === socketId);
if (player) {
player.isOffline = true;
// Do NOT update lastActive on disconnect, or maybe we should?
// No, lastActive is for "when was the room last used?". Disconnect is an event, but inactivity starts from here.
// So keeping lastActive as previous interaction time is safer?
// Actually, if everyone disconnects now, room should be kept for 8 hours from NOW.
// So update lastActive.
room.lastActive = Date.now();
return { room, playerId: player.id };
}
}
@@ -107,29 +126,30 @@ export class RoomManager {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
// Logic change: Explicit leave only removes player from list if waiting.
// If playing, mark offline (abandon).
// NEVER DELETE ROOM HERE. Rely on cleanup.
if (room.status === 'waiting') {
// Normal logic: Remove player completely
room.players = room.players.filter(p => p.id !== playerId);
// If host leaves, assign new host from remaining players
if (room.players.length === 0) {
this.rooms.delete(roomId);
return null;
} else if (room.hostId === playerId) {
if (room.players.length > 0 && room.hostId === playerId) {
const nextPlayer = room.players.find(p => p.role === 'player') || room.players[0];
if (nextPlayer) {
room.hostId = nextPlayer.id;
nextPlayer.isHost = true;
}
}
// If 0 players, room remains in Map until cleanup
} else {
// Game in progress (Drafting/Playing)
// DO NOT REMOVE PLAYER. Just mark offline.
// This allows them to rejoin and reclaim their seat (and deck).
const player = room.players.find(p => p.id === playerId);
if (player) {
player.isOffline = true;
// Note: socketId is already handled by disconnect event usually, but if explicit leave, we should clear it?
player.socketId = undefined;
}
console.log(`Player ${playerId} left active game in room ${roomId}. Marked as offline.`);
@@ -141,26 +161,30 @@ export class RoomManager {
const room = this.rooms.get(roomId);
if (!room) return null;
room.status = 'drafting';
room.lastActive = Date.now();
return room;
}
getRoom(roomId: string): Room | undefined {
// Refresh activity if accessed? Not necessarily, only write actions.
// But rejoining calls getRoom implicitly in join logic or index logic?
// Let's assume write actions update lastActive.
return this.rooms.get(roomId);
}
kickPlayer(roomId: string, playerId: string): Room | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
room.players = room.players.filter(p => p.id !== playerId);
// If game was running, we might need more cleanup, but for now just removal.
return room;
}
addMessage(roomId: string, sender: string, text: string): ChatMessage | null {
const room = this.rooms.get(roomId);
if (!room) return null;
room.lastActive = Date.now();
const message: ChatMessage = {
id: Math.random().toString(36).substring(7),
@@ -173,7 +197,6 @@ export class RoomManager {
}
getPlayerBySocket(socketId: string): { player: Player, room: Room } | null {
// Inefficient linear search, but robust for now. Maps would be better for high scale.
for (const room of this.rooms.values()) {
const player = room.players.find(p => p.socketId === socketId);
if (player) {
@@ -182,4 +205,26 @@ export class RoomManager {
}
return null;
}
private cleanupRooms() {
const now = Date.now();
const EXPIRATION_MS = 8 * 60 * 60 * 1000; // 8 Hours
for (const [roomId, room] of this.rooms.entries()) {
// Logic:
// 1. If players are online, room is active. -> Don't delete.
// 2. If NO players are online (all offline or empty), check lastActive.
const anyOnline = room.players.some(p => !p.isOffline);
if (anyOnline) {
continue; // Active
}
// No one online. Check expiration.
if (now - room.lastActive > EXPIRATION_MS) {
console.log(`Cleaning up expired room ${roomId}. Inactive for > 8 hours.`);
this.rooms.delete(roomId);
}
}
}
}

View File

@@ -2,6 +2,7 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { fileStorageManager } from '../managers/FileStorageManager';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -15,65 +16,10 @@ export class CardService {
this.imagesDir = path.join(CARDS_DIR, 'images');
this.metadataDir = path.join(CARDS_DIR, 'metadata');
this.ensureDirs();
this.migrateExistingImages();
}
private ensureDirs() {
if (!fs.existsSync(this.imagesDir)) {
fs.mkdirSync(this.imagesDir, { recursive: true });
}
if (!fs.existsSync(this.metadataDir)) {
fs.mkdirSync(this.metadataDir, { recursive: true });
}
}
private migrateExistingImages() {
console.log('[CardService] Checking for images to migrate...');
const start = Date.now();
let moved = 0;
try {
if (fs.existsSync(this.metadataDir)) {
const items = fs.readdirSync(this.metadataDir);
for (const item of items) {
const itemPath = path.join(this.metadataDir, item);
if (fs.statSync(itemPath).isDirectory()) {
// This determines the set
const setCode = item;
const cardFiles = fs.readdirSync(itemPath);
for (const file of cardFiles) {
if (!file.endsWith('.json')) continue;
const id = file.replace('.json', '');
// Check for legacy image
const legacyImgPath = path.join(this.imagesDir, `${id}.jpg`);
if (fs.existsSync(legacyImgPath)) {
const targetDir = path.join(this.imagesDir, setCode);
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
const targetPath = path.join(targetDir, `${id}.jpg`);
try {
fs.renameSync(legacyImgPath, targetPath);
moved++;
} catch (e) {
console.error(`[CardService] Failed to move ${id}.jpg to ${setCode}`, e);
}
}
}
}
}
}
} catch (e) {
console.error('[CardService] Migration error', e);
}
if (moved > 0) {
console.log(`[CardService] Migrated ${moved} images to set folders in ${Date.now() - start}ms.`);
} else {
console.log(`[CardService] No images needed migration.`);
}
// Directory creation is handled by FileStorageManager on write for Local,
// and not needed for Redis.
// Migration logic removed as it's FS specific and one-time.
// If we need migration to Redis, it should be a separate script.
}
async cacheImages(cards: any[]): Promise<number> {
@@ -102,36 +48,19 @@ export class CardService {
if (!imageUrl) continue;
const setDir = path.join(this.imagesDir, setCode);
if (!fs.existsSync(setDir)) {
fs.mkdirSync(setDir, { recursive: true });
}
const filePath = path.join(this.imagesDir, setCode, `${uuid}.jpg`);
const filePath = path.join(setDir, `${uuid}.jpg`);
// Check if exists in set folder
if (fs.existsSync(filePath)) {
// Check if exists
if (await fileStorageManager.exists(filePath)) {
continue;
}
// Check legacy location and move if exists (double check)
const legacyPath = path.join(this.imagesDir, `${uuid}.jpg`);
if (fs.existsSync(legacyPath)) {
try {
fs.renameSync(legacyPath, filePath);
// console.log(`Migrated image ${uuid} to ${setCode}`);
continue;
} catch (e) {
console.error(`Failed to migrate image ${uuid}`, e);
}
}
try {
// Download
const response = await fetch(imageUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
fs.writeFileSync(filePath, Buffer.from(buffer));
await fileStorageManager.saveFile(filePath, Buffer.from(buffer));
downloadedCount++;
console.log(`Cached image: ${setCode}/${uuid}.jpg`);
} else {
@@ -154,19 +83,10 @@ export class CardService {
for (const card of cards) {
if (!card.id || !card.set) continue;
const setDir = path.join(this.metadataDir, card.set);
if (!fs.existsSync(setDir)) {
fs.mkdirSync(setDir, { recursive: true });
}
const filePath = path.join(setDir, `${card.id}.json`);
if (!fs.existsSync(filePath)) {
const filePath = path.join(this.metadataDir, card.set, `${card.id}.json`);
if (!(await fileStorageManager.exists(filePath))) {
try {
fs.writeFileSync(filePath, JSON.stringify(card, null, 2));
// Check and delete legacy if exists
const legacy = path.join(this.metadataDir, `${card.id}.json`);
if (fs.existsSync(legacy)) fs.unlinkSync(legacy);
await fileStorageManager.saveFile(filePath, JSON.stringify(card, null, 2));
cachedCount++;
} catch (e) {
console.error(`Failed to save metadata for ${card.id}`, e);