@hhoangphuoc/escape-room-cli
Version:
A CLI for playing AI-generated escape room games. Install globally with: npm install -g @hhoangphuoc/escape-room-cli
833 lines (832 loc) ⢠32.5 kB
JavaScript
import { getApiUrl } from './apiConfig.js';
import { validateOpenAIApiKey, validateAnthropicApiKey } from './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');
// Help Command Handler
export const handleHelpCommand = (userContext, gameContext) => {
const baseCommands = [
{ command: '/help', description: 'Show available commands and their usage' },
{
command: '/instructions',
description: 'Show detailed game instructions',
},
{ command: '/history', description: 'Show command history' },
{ command: '/cost', description: 'Show current session cost summary' },
{ command: '/usage', description: 'Show detailed AI usage statistics' },
];
const authCommands = userContext.sessionToken
? [
{
command: '/newgame',
description: 'Start a new AI-generated game',
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: 'Guess the puzzle answer for an object',
usage: '/guess safe 1234',
},
{
command: '/password [password]',
description: 'Submit the password to unlock the door',
usage: '/password escape123',
},
{
command: '/leaderboard',
description: 'View the top 10 players on the leaderboard',
},
{ command: '/login', description: 'Login to the game' },
{ command: '/logout', description: 'End your current session' },
]
: [
{ command: '/login', description: 'Login to the game' },
{ command: '/register', description: 'Start the registration process' },
];
const aiCommands = userContext.hasAICapability
? [{ command: '/model', description: 'Change AI model for chat assistance' }]
: [];
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',
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';
if (viewMode === 'cost' || viewMode === 'efficiency') {
endpoint = '/api/game/leaderboard/cost';
}
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,
totalCost: entry.totalCost,
costEfficiency: entry.costEfficiency,
aiRequestsUsed: entry.aiRequestsUsed,
}));
// Build leaderboard text based on view mode
let leaderboardText = '';
if (viewMode === 'cost') {
leaderboardText = 'š° COST-BASED LEADERBOARD š°\n\n';
leaderboardText += 'Rank | Player | Time | Cost | Efficiency\n';
leaderboardText += 'āāāāāā¼āāāāāāāāāāā¼āāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāā\n';
entries.forEach((entry) => {
const rank = entry.rank.toString().padStart(4, ' ');
const player = entry.userName.substring(0, 9).padEnd(9, ' ');
const time = `${entry.timeElapsed}s`.padEnd(6, ' ');
const cost = entry.totalCost ? `$${entry.totalCost.toFixed(3)}`.padEnd(8, ' ') : 'N/A'.padEnd(8, ' ');
const efficiency = entry.costEfficiency ? `${entry.costEfficiency.toFixed(0)}%` : 'N/A';
leaderboardText += `${rank} ā ${player} ā ${time} ā ${cost} ā ${efficiency}\n`;
});
}
else if (viewMode === 'efficiency') {
leaderboardText = 'ā” EFFICIENCY LEADERBOARD ā”\n\n';
leaderboardText += 'Rank | Player | Efficiency | Cost | Time\n';
leaderboardText += 'āāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāā\n';
// Sort by efficiency for this view
const sortedByEfficiency = [...entries].sort((a, b) => (b.costEfficiency || 0) - (a.costEfficiency || 0));
sortedByEfficiency.forEach((entry, index) => {
const rank = (index + 1).toString().padStart(4, ' ');
const player = entry.userName.substring(0, 9).padEnd(9, ' ');
const efficiency = entry.costEfficiency ? `${entry.costEfficiency.toFixed(0)}%`.padEnd(10, ' ') : 'N/A'.padEnd(10, ' ');
const cost = entry.totalCost ? `$${entry.totalCost.toFixed(3)}`.padEnd(8, ' ') : 'N/A'.padEnd(8, ' ');
const time = `${entry.timeElapsed}s`;
leaderboardText += `${rank} ā ${player} ā ${efficiency} ā ${cost} ā ${time}\n`;
});
}
else {
// Default time-based view
leaderboardText = 'š TIME-BASED LEADERBOARD š\n\n';
leaderboardText += 'Rank | Player | Time | Hints | Mode\n';
leaderboardText += 'āāāāāā¼āāāāāāāāā¼āāāāāāā¼āāāāāāāā¼āāāāāāāāā\n';
entries.forEach((entry) => {
const rank = entry.rank.toString().padStart(4, ' ');
const player = entry.userName.substring(0, 8).padEnd(8, ' ');
const time = `${entry.timeElapsed}s`.padEnd(6, ' ');
const hints = entry.hintsUsed.toString().padStart(5, ' ');
const mode = entry.gameMode.substring(0, 9).padEnd(9, ' ');
leaderboardText += `${rank} ā ${player} ā ${time} ā${hints} ā ${mode}\n`;
});
}
leaderboardText += '\nš” Switch views: /leaderboard [time|cost|efficiency]';
return {
success: true,
message: leaderboardText,
leaderboardData: {
entries,
count: entries.length,
mode: viewMode,
},
};
}
else {
return {
success: true,
message: '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 },
};
}
};
// 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',
};
}
};
// 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.",
};
}
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 Handler
export const handleUsageCommand = async (userContext, gameContext) => {
if (!userContext.sessionToken) {
return {
success: false,
message: 'You must be logged in to view usage statistics.',
};
}
try {
const apiUrl = getApiUrl();
const gameIdParam = gameContext.currentGameId ? `?gameId=${gameContext.currentGameId}` : '';
// Get monitoring data
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 data = await response.json();
if (!data.success) {
return {
success: false,
message: data.error || 'Failed to retrieve usage data.',
};
}
const usage = data.data;
// Transform API response to CostDashboard expected format
const usageData = {
currentSessionCost: usage.currentSessionCost || 0,
currentSessionTokens: usage.currentSessionTokens || 0,
userTotalCost: usage.userTotalCost || 0,
userTotalTokens: usage.userTotalTokens || 0,
lastRequestCost: usage.lastRequestCost || 0,
lastRequestTokens: usage.lastRequestTokens || 0,
averageCostPerRequest: usage.averageCostPerRequest || 0,
sessionStartTime: usage.sessionStartTime,
modelUsageBreakdown: 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.',
};
}
};
// Enhanced Cost Command Handler with detailed analytics
export const handleCostCommand = async (userContext, gameContext) => {
if (!userContext.sessionToken) {
return {
success: false,
message: 'You must be logged in to view cost information.',
};
}
try {
const apiUrl = getApiUrl();
const gameIdParam = gameContext.currentGameId ? `?gameId=${gameContext.currentGameId}` : '';
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 data = await response.json();
if (!data.success) {
return {
success: false,
message: 'ā Failed to retrieve cost data.',
};
}
const cost = data.data;
// Transform API response to CostMonitor expected format
const costData = {
currentSessionCost: cost.currentSessionCost || 0,
currentSessionTokens: cost.currentSessionTokens || 0,
userTotalCost: cost.userTotalCost || 0,
userTotalTokens: cost.userTotalTokens || 0,
lastRequestCost: cost.lastRequestCost || 0,
lastRequestTokens: cost.lastRequestTokens || 0,
averageCostPerRequest: cost.averageCostPerRequest || 0,
modelUsageBreakdown: cost.modelUsageBreakdown || {}
};
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.',
};
}
};
// Removed: handlePricingCommand - simplified cost monitoring
// Removed: handleCompareCommand - simplified cost monitoring
// Removed: handleEstimateCommand - simplified cost monitoring