/** * AI Interactive Fiction - Web Server * Serves the web UI and handles WebSocket communication */ import path from 'path'; import express from 'express'; import http from 'http'; import { Server as SocketIOServer } from 'socket.io'; import * as dotenv from 'dotenv'; import { GameRunner } from './cli/game-runner'; import { existsSync, mkdirSync, copyFileSync } from 'fs'; // Load environment variables dotenv.config(); // Create Express application const app = express(); const server = http.createServer(app); const io = new SocketIOServer(server); // Get port from environment variables or use default const DEFAULT_PORT = 3000; const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT; const PORT_RANGE = 10; // Try up to 10 ports starting from the default // Serve static files from the public directory app.use(express.static(path.join(__dirname, '../public'))); // Set up game sessions const gameSessions = new Map(); // Handle socket connections io.on('connection', (socket) => { console.log(`New client connected: ${socket.id}`); // Start a new game socket.on('startGame', async () => { try { // Initialize game runner const gameRunner = new GameRunner(); const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml'; // Initialize the game await gameRunner.initialize(worldFile); // Store game session gameSessions.set(socket.id, gameRunner); // Send introduction to client const gameState = gameRunner.getGameState(); socket.emit('gameIntroduction', { introduction: gameState.world.introduction, initialRoomDescription: gameRunner.getCurrentRoomDescription(), currentRoomId: gameState.currentRoomId }); } catch (error) { console.error('Error starting game:', error); socket.emit('error', { message: 'Failed to start game. Please try again.' }); } }); // Process player command socket.on('playerCommand', async (data) => { try { const gameRunner = gameSessions.get(socket.id); if (!gameRunner) { socket.emit('error', { message: 'Game session not found. Please start a new game.' }); return; } // Process command and get response const response = await gameRunner.processCommand(data.command); // Send narrative response to client socket.emit('narrativeResponse', { text: response, gameState: { currentRoomId: gameRunner.getGameState().currentRoomId }, suggestions: gameRunner.getSuggestions() }); } catch (error) { console.error('Error processing command:', error); socket.emit('error', { message: 'Failed to process command. Please try again.' }); } }); // Save game state socket.on('saveGame', () => { try { const gameRunner = gameSessions.get(socket.id); if (!gameRunner) { socket.emit('error', { message: 'Game session not found. Please start a new game.' }); return; } // Store save data in session socket.data.savedGame = gameRunner.getGameState(); socket.emit('gameSaved'); } catch (error) { console.error('Error saving game:', error); socket.emit('error', { message: 'Failed to save game. Please try again.' }); } }); // Load game state socket.on('loadGame', () => { try { const gameRunner = gameSessions.get(socket.id); if (!gameRunner) { socket.emit('error', { message: 'Game session not found. Please start a new game.' }); return; } // Check if there's a saved game if (!socket.data.savedGame) { socket.emit('error', { message: 'No saved game found.' }); return; } // Load saved game gameRunner.loadGameState(socket.data.savedGame); // Send current state to client socket.emit('gameLoaded', { currentRoomDescription: gameRunner.getCurrentRoomDescription(), currentRoomId: gameRunner.getGameState().currentRoomId }); } catch (error) { console.error('Error loading game:', error); socket.emit('error', { message: 'Failed to load game. Please try again.' }); } }); // Handle disconnection socket.on('disconnect', () => { console.log(`Client disconnected: ${socket.id}`); // Clean up game session if (gameSessions.has(socket.id)) { gameSessions.delete(socket.id); } }); }); // Ensure required asset folders exist function ensureDirectories() { const dirs = [ path.join(__dirname, '../public'), path.join(__dirname, '../public/js'), path.join(__dirname, '../public/css'), path.join(__dirname, '../public/images'), path.join(__dirname, '../public/fonts') ]; for (const dir of dirs) { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } } // Copy kokoro-js library from node_modules if not already present function ensureKokoroJs() { const source = path.join(__dirname, '../node_modules/kokoro-js/dist/index.js'); const destination = path.join(__dirname, '../public/js/kokoro-js.js'); if (existsSync(source) && !existsSync(destination)) { copyFileSync(source, destination); console.log(`Copied kokoro-js from ${source} to ${destination}`); } } // Start the server with port fallback export async function startServer(initialPort: number, range: number): Promise { let currentPort = initialPort; const maxPort = initialPort + range; // Try ports in the specified range while (currentPort < maxPort) { try { // Ensure directories exist ensureDirectories(); // Ensure kokoro-js is copied try { ensureKokoroJs(); } catch (error) { console.error('Error copying kokoro-js:', error); } // Try to start the server on the current port await new Promise((resolve, reject) => { server.listen(currentPort, () => { console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`); resolve(); }); server.on('error', (error: NodeJS.ErrnoException) => { // If port is in use, try next port if (error.code === 'EADDRINUSE') { console.log(`Port ${currentPort} is in use, trying next port...`); server.close(); currentPort++; reject(); } else { // For other errors, log and reject console.error('Server error:', error); reject(error); } }); }); // If we reach here, server started successfully return; } catch (error) { // If we reach the max port and still fail, throw an error if (currentPort >= maxPort - 1) { throw new Error(`Failed to start server on ports ${initialPort} to ${maxPort - 1}`); } // Otherwise try the next port // The loop continues as the rejection above increments currentPort } } } // Start the server when this module is run directly if (require.main === module) { startServer(PORT, PORT_RANGE).catch(error => { console.error('Failed to start server:', error); process.exit(1); }); } export { app, server, io };