UNPKG

@hhoangphuoc/escape-room-cli

Version:

A CLI for playing AI-generated escape room games. Install globally with: npm install -g @hhoangphuoc/escape-room-cli

908 lines (907 loc) 35.2 kB
import { getApiUrl } from '../utils/apiConfig.js'; import { validateOpenAIApiKey, validateAnthropicApiKey } from '../utils/validation.js'; import fs from 'fs'; import path from 'path'; import os from 'os'; const USER_CONFIG_FILE = path.join(os.homedir(), '.escape-room-config.json'); //============================================================================================================== // BASE GAME COMMAND HANDLERS //============================================================================================================== // Help Command Handler export const handleHelpCommand = (userContext, gameContext) => { const baseCommands = [ { command: '/help', description: 'Show available commands and their usage' }, { command: '/instructions', description: 'Detailed game instructions and game play', }, { command: '/history', description: 'Show the conversation history with AI models' }, { command: '/cost', description: 'Show cost and token overview' }, { command: '/usage', description: 'Detailed breakdown in current session' }, ]; const authCommands = userContext.sessionToken ? [ { command: '/newgame', description: 'Start a new AI-generated escape room', usage: '/newgame', }, { command: '/hint', description: 'Get a hint for the password of this room' }, { command: '/look', description: 'Look around the current room' }, { command: '/inspect [object]', description: 'Inspect an object in the room', usage: '/inspect table', }, { command: '/guess [object] [answer]', description: 'Decode a puzzle or riddle of an object', usage: '/guess safe 1234', }, { command: '/password [password]', description: 'Submit the password to unlock the door', usage: '/password escape123', }, { command: '/leaderboard', description: 'Show top players (with best performance)', }, { command: '/end-session', description: 'End the current game, if you are stuck, otherwise /logout to end session.' }, { command: '/login', description: 'Login to your account' }, { command: '/logout', description: 'Logout to current account' }, ] : [ { command: '/login', description: 'Login to your account' }, { command: '/register', description: 'Register a new account' }, ]; const aiCommands = userContext.hasAICapability ? [{ command: '/model', description: 'Change AI model that you want to use' }] : []; const commands = [...baseCommands, ...authCommands, ...aiCommands]; return { success: true, message: 'Available commands:', commands, currentContext: { hasAI: userContext.hasAICapability, currentModel: userContext.selectedModel?.label, currentRoom: gameContext.currentRoomName !== 'Loading...' ? gameContext.currentRoomName : undefined, gameMode: gameContext.currentGameMode !== 'unknown' ? gameContext.currentGameMode : undefined, isAuthenticated: !!userContext.sessionToken, }, }; }; // Look Command Handler export const handleLookCommand = async (userContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You are not in a game session. Please login first', error: 'Not authenticated. Use /login to enter the session', roomData: { name: '', objects: [] }, }; } try { const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/api/game/look`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); if (response.ok) { const data = (await response.json()); return { success: true, message: data.message || `You are in ${data.roomName}`, roomData: { name: data.roomName || 'Unknown Room', background: data.background, objects: data.objects || [], }, }; } else { const errorData = (await response.json()); return { success: false, message: 'Failed to look around the room', error: errorData.error || 'Unknown error', roomData: { name: '', objects: [] }, }; } } catch (error) { return { success: false, message: 'Network error occurred while looking around', error: 'Network error', roomData: { name: '', objects: [] }, }; } }; // Inspect Command Handler export const handleInspectCommand = async (objectName, userContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You are not in a game session. Please login first', error: 'Not authenticated', }; } if (!objectName) { return { success: false, message: 'Object name is required', error: 'Missing object name', }; } try { const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/api/game/inspect?object=${encodeURIComponent(objectName)}`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); const data = (await response.json()); if (response.ok && data.object) { return { success: true, message: data.message || `Inspecting ${objectName}...`, objectData: { name: data.object.name, description: data.object.description, puzzle: data.object.puzzle, answer: data.object.answer, unlocked: data.object.unlocked || false, details: data.object.details, }, }; } else { return { success: false, message: data.error || `Could not inspect ${objectName}.`, error: data.error || 'Object not found', }; } } catch (error) { return { success: false, message: 'Network error occurred while inspecting object', error: 'Network error', }; } }; // Guess Command Handler export const handleGuessCommand = async (objectName, answer, userContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You are not in a game session. Please login first', error: 'Not authenticated', objectData: { name: objectName, unlocked: false, correctAnswer: false }, }; } if (!objectName || !answer) { return { success: false, message: 'Both object name and answer are required', error: 'Missing parameters', objectData: { name: objectName, unlocked: false, correctAnswer: false }, }; } try { const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/api/game/guess?object=${encodeURIComponent(objectName)}&answer=${encodeURIComponent(answer)}`, { method: 'POST', headers: { Authorization: `Bearer ${userContext.sessionToken}`, 'Content-Type': 'application/json', }, }); const data = (await response.json()); if (response.ok && data.object) { return { success: true, message: data.message, objectData: { name: data.object.name, unlocked: data.object.unlocked || false, correctAnswer: data.object.unlocked || false, }, }; } else { return { success: false, message: data.error || 'Failed to process guess.', error: data.error || 'Guess failed', objectData: { name: objectName, unlocked: false, correctAnswer: false }, }; } } catch (error) { return { success: false, message: 'Network error occurred while processing guess', error: 'Network error', objectData: { name: objectName, unlocked: false, correctAnswer: false }, }; } }; // Password Command Handler export const handlePasswordCommand = async (password, userContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You are not in a game session. Please login first', error: 'Not authenticated', gameResult: { escaped: false, gameCompleted: false }, }; } if (!password) { return { success: false, message: 'Password is required', error: 'Missing password', gameResult: { escaped: false, gameCompleted: false }, }; } try { const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/api/game/password?password=${encodeURIComponent(password)}`, { method: 'POST', headers: { Authorization: `Bearer ${userContext.sessionToken}`, 'Content-Type': 'application/json', }, }); const data = (await response.json()); return { success: data.escaped || false, message: data.message + (data.timeElapsed ? `\nTime: ${data.timeElapsed} seconds` : ''), gameResult: { escaped: data.escaped || false, gameCompleted: data.gameCompleted || false, timeElapsed: data.timeElapsed, hintsUsed: data.hintsUsed, }, }; } catch (error) { return { success: false, message: 'Network error occurred while checking password', error: 'Network error', gameResult: { escaped: false, gameCompleted: false }, }; } }; // Hint Command Handler export const handleHintCommand = async (userContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You are not in a game session. Please login first', error: 'Not authenticated', hintData: { hint: '', hintsUsed: 0 }, }; } try { const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/api/game/hint`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); const data = (await response.json()); let hintText = ''; if (typeof data.hint === 'string') { hintText = data.hint; } else if (data.hint) { hintText = JSON.stringify(data.hint, null, 2); } else if (data.message) { hintText = typeof data.message === 'string' ? data.message : JSON.stringify(data.message, null, 2); } else { hintText = 'No hints available.'; } return { success: true, message: hintText, hintData: { hint: hintText, hintsUsed: data.hintsUsed || 0, hintType: 'general', }, }; } catch (error) { return { success: false, message: 'Network error occurred while getting hint', error: 'Network error', hintData: { hint: '', hintsUsed: 0 }, }; } }; // New Game Command Handler export const handleNewGameCommand = async (mode = 'single-room', userContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You are not in a game session. Please login first', error: 'Not authenticated', gameData: { id: '', name: '', background: '', mode: '', currentRoom: 1, totalRooms: 1, objectCount: 0, }, }; } const requestedMode = mode === 'multi-room' ? 'multi-room' : 'single-room'; try { const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/api/game/newgame`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userContext.sessionToken}`, }, body: JSON.stringify({ mode: requestedMode }), }); const data = (await response.json()); if (data.success && data.game) { const gameInfo = data.game; const gameMessage = ` New ${gameInfo.mode || 'custom'} game created successfully! Room ${gameInfo.currentRoom || 1}${gameInfo.totalRooms && gameInfo.totalRooms > 1 ? ` of ${gameInfo.totalRooms}` : ''}: ${gameInfo.name} ${gameInfo.background || ''} This room contains ${gameInfo.objectCount !== undefined ? gameInfo.objectCount : '?'} objects. Use /look to see them. ${data.game.roomData?.hint ? `\nHint: ${data.game.roomData.hint}` : ''} Password needed to escape. Use /password [your_guess] when ready!`; return { success: true, message: gameMessage.trim(), gameData: { id: gameInfo.id || '', name: gameInfo.name || 'Untitled Room', background: gameInfo.background || 'No description provided.', mode: gameInfo.mode || requestedMode, currentRoom: gameInfo.currentRoom || 1, totalRooms: gameInfo.totalRooms || 1, objectCount: gameInfo.objectCount || 0, startTime: gameInfo.startTime, }, }; } else { return { success: false, message: `Failed to create new game: ${data.error || 'Unknown error'}`, error: data.error || 'Unknown error', gameData: { id: '', name: '', background: '', mode: '', currentRoom: 1, totalRooms: 1, objectCount: 0, }, }; } } catch (error) { return { success: false, message: 'Network error occurred while creating new game', error: 'Network error', gameData: { id: '', name: '', background: '', mode: '', currentRoom: 1, totalRooms: 1, objectCount: 0, }, }; } }; // Enhanced Leaderboard Command Handler with cost support export const handleLeaderboardCommand = async (userContext, viewMode = 'time') => { if (!userContext.sessionToken) { return { success: false, message: 'You are not in a game session. Please login first', error: 'Not authenticated', leaderboardData: { entries: [], count: 0, mode: 'all' }, }; } try { const apiUrl = getApiUrl(); // Determine which endpoint to use based on view mode let endpoint = '/api/game/leaderboard/games'; const response = await fetch(`${apiUrl}${endpoint}`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); if (response.ok) { const data = (await response.json()); if (data.leaderboard && data.leaderboard.length > 0) { const entries = data.leaderboard.map((entry, index) => ({ rank: index + 1, userName: entry.userName, timeElapsed: entry.timeElapsed, hintsUsed: entry.hintsUsed, gameMode: entry.gameMode, completedAt: entry.completedAt || entry.submittedAt, totalCost: entry.totalCost, totalTokens: entry.totalTokens, costEfficiency: entry.costEfficiency || entry.costEfficiencyScore, aiRequestsUsed: entry.aiRequestsUsed, })); return { success: true, message: "Leaderboard fetched successfully", leaderboardData: { entries, count: entries.length, mode: viewMode, }, }; } else { return { success: true, message: "No completed games found.", error: 'No completed games found on the leaderboard yet. Be the first to complete a game!', leaderboardData: { entries: [], count: 0, mode: viewMode }, }; } } else { const errorData = (await response.json()); return { success: false, message: 'Failed to fetch leaderboard', error: errorData.error || 'Unknown error', leaderboardData: { entries: [], count: 0, mode: viewMode }, }; } } catch (error) { return { success: false, message: 'Network error occurred while fetching leaderboard', error: 'Network error', leaderboardData: { entries: [], count: 0, mode: viewMode }, }; } }; //============================================================================================================== // AUTHENTICATION COMMAND HANDLERS //============================================================================================================== // Logout Command Handler export const handleLogoutCommand = () => { return { success: true, message: 'Logged out successfully. Type /login to login again', }; }; // Login Command Handler export const handleLoginCommand = async () => { let configUserId; let configApiKey; let configName; let configProvider = 'openai'; if (fs.existsSync(USER_CONFIG_FILE)) { try { const config = JSON.parse(fs.readFileSync(USER_CONFIG_FILE, 'utf-8')); configUserId = config.userId; configName = config.name; // Validate OpenAI API key if (config.apiKeys?.openai) { configApiKey = config.apiKeys.openai; configProvider = 'openai'; // Validate the stored OpenAI API key if (configApiKey) { try { const validation = await validateOpenAIApiKey(configApiKey); if (!validation.isValid) { return { success: false, message: `Stored OpenAI API key is invalid: ${validation.error}`, error: 'Invalid API key', }; } } catch (validationError) { // If validation fails, warn but continue (maybe backend is unavailable) console.warn('API key validation failed during login:', validationError); } } // Validate Anthropic API key (NOT IMPLEMENTED) } else if (config.apiKeys?.anthropic) { configApiKey = config.apiKeys.anthropic; configProvider = 'anthropic'; if (configApiKey) { const validation = validateAnthropicApiKey(configApiKey); if (!validation.isValid) { return { success: false, message: `Stored Anthropic API key is invalid: ${validation.error}`, error: 'Invalid API key', }; } } } } catch (error) { return { success: false, message: 'Error reading config file', error: 'Config file corrupted', }; } } if (configUserId) { try { // Import dynamically to avoid circular dependencies const { handleLogin } = await import('../components/UserRegistration.js'); const loginResponse = await handleLogin(configUserId, configApiKey, configProvider); const loginData = (await loginResponse.json()); if (loginResponse.ok && loginData.token) { return { success: true, message: 'Logged in successfully.', userData: { userId: configUserId, userName: configName || 'Unknown User', sessionToken: loginData.token, apiKey: configApiKey, apiKeyProvider: configProvider, }, }; } else { return { success: false, message: `Login failed: ${loginData.error || 'Unknown error'}`, error: loginData.error || 'Unknown error', }; } } catch (error) { return { success: false, message: 'Network error during login', error: 'Network error', }; } } else { return { success: false, message: 'No user configuration found. Please register first.', error: 'No config found', }; } }; // End/Finalize Command Handler // NOTE: /end-session finalizes current session to get totals cost of AI requests when game is not completed export const handleEndSessionCommand = async (userContext, gameContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You must be logged in and to end session.' }; } if (!gameContext.currentGameId) { return { success: false, message: 'No active game to end. Use /newgame to start a new game, otherwise /logout to end session.' }; } try { const apiUrl = getApiUrl(); const resp = await fetch(`${apiUrl}/api/usage/finalize`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userContext.sessionToken}` }, body: JSON.stringify({ gameId: gameContext.currentGameId }) }); const json = await resp.json(); if (resp.ok && json.success) { return { success: true, message: `Session ended. Current session cost: $${((json.data?.totalCost) || 0).toFixed(3)}` }; } return { success: false, message: json.error || 'Failed to finalize session.' }; } catch (error) { return { success: false, message: 'Network error while finalizing session.' }; } }; //============================================================================================================== // AI COMMAND HANDLERS //============================================================================================================== // Natural Language Command Handler export const handleNaturalLanguageCommand = async (text, userContext, model) => { if (!userContext.sessionToken) { return { success: false, message: 'Please login or register to use AI chat.', }; } const apiKey = userContext.cliApiKey || process.env['OPENAI_API_KEY'] || process.env['ANTHROPIC_API_KEY']; if (!apiKey) { return { success: false, message: 'Error: No API key available for AI chat.', }; } // Simplified API key validation - just check basic format try { let isValidFormat = false; // Check for OpenAI key format (sk-proj- or sk-) if (apiKey.startsWith('sk-proj-') || (apiKey.startsWith('sk-') && apiKey.length >= 20)) { isValidFormat = true; } // Check for Anthropic key format else if (apiKey.startsWith('sk-ant-') || apiKey.startsWith('anthropic')) { isValidFormat = true; } if (!isValidFormat) { return { success: false, message: 'Error: Invalid API key format. Please check your API key.', }; } } catch (validationError) { // If validation fails, warn but continue (maybe backend is unavailable) console.warn('API key validation failed during natural language command:', validationError); } try { const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/api/game/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${userContext.sessionToken}`, }, body: JSON.stringify({ message: text, model: model.value, }), }); if (!response.ok) { const errorData = (await response.json()); return { success: false, message: `AI Chat Error: ${errorData.error || 'Unknown error'}`, }; } const data = (await response.json()); return { success: true, message: data.response || "AI couldn't understand that.", response: data.response, usage: data.usage, // reasoning: data.reasoning, model: data.usage?.model || model.value, }; } catch (error) { return { success: false, message: 'Error with AI chat request.' }; } }; // Instructions Command Handler export const handleInstructionsCommand = (_userContext, _gameContext) => { return { success: true, message: 'Showing game instructions...', showInstructions: true, }; }; //============================================================================================================== // USAGE COMMAND HANDLERS //============================================================================================================== // Usage Command Handler // NOTE: /usage is command showing full cost usages of the game export const handleUsageCommand = async (userContext, gameContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You must be logged in to view usage statistics. Use /login to login.', }; } try { const apiUrl = getApiUrl(); const gameIdParam = gameContext.currentGameId ? `?gameId=${gameContext.currentGameId}` : ''; // Get monitoring data (real-time, in-memory) const response = await fetch(`${apiUrl}/api/usage/monitoring${gameIdParam}`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); if (!response.ok) { return { success: false, message: 'Failed to fetch usage data. Please try again later.', }; } const monitoringPayload = await response.json(); if (!monitoringPayload.success) { return { success: false, message: monitoringPayload.error || 'Failed to retrieve usage data.', }; } const usage = monitoringPayload.data; // Fetch stable totals from backend for accurate lifetime/session totals if available let totalsUserCost = usage.userTotalCost || 0; let totalsUserTokens = usage.userTotalTokens || 0; let aggregatedModelBreakdown; try { const totalsResp = await fetch(`${apiUrl}/api/usage/cost${gameIdParam}`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); if (totalsResp.ok) { const totalsJson = await totalsResp.json(); if (totalsJson.success) { totalsUserCost = totalsJson.data?.user?.totalCost ?? totalsUserCost; totalsUserTokens = totalsJson.data?.user?.totalTokens ?? totalsUserTokens; } } } catch { } // Fetch aggregated per-model breakdown (persisted) try { const breakdownResp = await fetch(`${apiUrl}/api/usage/model-breakdown`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); if (breakdownResp.ok) { const breakdownJson = await breakdownResp.json(); if (breakdownJson.success && breakdownJson.data) { aggregatedModelBreakdown = breakdownJson.data; } } } catch { } // Transform API response to UsageDashboard expected format const usageData = { currentSessionCost: usage.currentSessionCost || 0, currentSessionTokens: usage.currentSessionTokens || 0, userTotalCost: totalsUserCost, userTotalTokens: totalsUserTokens, lastRequestCost: usage.lastRequestCost || 0, lastRequestTokens: usage.lastRequestTokens || 0, averageCostPerRequest: usage.averageCostPerRequest || 0, sessionStartTime: usage.sessionStartTime, // usage.modelUsageBreakdown: the live session breakdown (realtime), // aggregatedModelBreakdown: the persisted breakdown (fetch) modelUsageBreakdown: usage.modelUsageBreakdown || aggregatedModelBreakdown || {}, // modelUsageBreakdown: mergeModelBreakdowns(aggregatedModelBreakdown, usage.modelUsageBreakdown), budgetSettings: usage.budgetSettings, alerts: usage.alerts || [] }; return { success: true, message: 'Opening usage dashboard...', showUsageDashboard: true, usageData: usageData, }; } catch (error) { return { success: false, message: '❌ Error fetching usage statistics. Please check your connection and try again.', }; } }; // Cost Command Handler // NOTE: /cost is usage command without modelUsageBreakdown and budgetSettings export const handleCostCommand = async (userContext, gameContext) => { if (!userContext.sessionToken) { return { success: false, message: 'You must be logged in to view cost information. Use /login to login.', }; } try { const apiUrl = getApiUrl(); const gameIdParam = gameContext.currentGameId ? `?gameId=${gameContext.currentGameId}` : ''; // Real-time in-memory monitoring const response = await fetch(`${apiUrl}/api/usage/monitoring${gameIdParam}`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); if (!response.ok) { return { success: false, message: '❌ Unable to fetch cost data.', }; } const monitoringPayload = await response.json(); if (!monitoringPayload.success) { return { success: false, message: '❌ Failed to retrieve cost data.', }; } const cost = monitoringPayload.data; // Fetch stable totals snapshot let totalsUserCost = cost.userTotalCost || 0; let totalsUserTokens = cost.userTotalTokens || 0; let aggregatedModelBreakdown; try { const totalsResp = await fetch(`${apiUrl}/api/usage/cost${gameIdParam}`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); if (totalsResp.ok) { const totalsJson = await totalsResp.json(); if (totalsJson.success) { totalsUserCost = totalsJson.data?.user?.totalCost ?? totalsUserCost; totalsUserTokens = totalsJson.data?.user?.totalTokens ?? totalsUserTokens; } } } catch { } // Fetch aggregated per-model breakdown (persisted) try { const breakdownResp = await fetch(`${apiUrl}/api/usage/model-breakdown`, { headers: { Authorization: `Bearer ${userContext.sessionToken}` }, }); if (breakdownResp.ok) { const breakdownJson = await breakdownResp.json(); if (breakdownJson.success && breakdownJson.data) { aggregatedModelBreakdown = breakdownJson.data; } } } catch { } // Transform API response to CostMonitor expected format const costData = { currentSessionCost: cost.currentSessionCost || 0, currentSessionTokens: cost.currentSessionTokens || 0, userTotalCost: totalsUserCost, userTotalTokens: totalsUserTokens, lastRequestCost: cost.lastRequestCost || 0, lastRequestTokens: cost.lastRequestTokens || 0, averageCostPerRequest: cost.averageCostPerRequest || 0, // modelUsageBreakdown: mergeModelBreakdowns(aggregatedModelBreakdown, cost.modelUsageBreakdown) // cost.modelUsageBreakdown: the live session breakdown (realtime), // aggregatedModelBreakdown: the persisted breakdown (fetch) modelUsageBreakdown: cost.modelUsageBreakdown || aggregatedModelBreakdown || {}, }; return { success: true, message: 'Opening cost monitor...', showCostMonitor: true, costData: costData, }; } catch (error) { return { success: false, message: '❌ Error fetching cost data. Please try again or check your connection.', }; } };