#!/usr/bin/env node import type { QuestInterpreter } from '.'; import type { InitialNode, NodeDefinition } from './ast.js'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import * as p from '@clack/prompts'; import { QuestLang } from '.'; /** * Beautiful command-line interface for QuestLang using clack */ class ClackCLI { /** * Run the CLI */ public async run(): Promise { const args = process.argv.slice(2); console.clear(); p.intro('🎮 QuestLang Interpreter'); if (args.length === 0) { await this.showInteractiveMenu(); return; } const command = args[0]; switch (command) { case 'play': if (args.length < 2) { p.outro('❌ Usage: questlang-clack play '); process.exit(1); } await this.playQuest(args[1]!); break; case 'validate': if (args.length < 2) { p.outro('❌ Usage: questlang-clack validate '); process.exit(1); } await this.validateQuest(args[1]!); break; case 'analyze': if (args.length < 2) { p.outro('❌ Usage: questlang-clack analyze '); process.exit(1); } await this.analyzeQuest(args[1]!); break; default: p.outro(`❌ Unknown command: ${command}`); process.exit(1); } } private async showInteractiveMenu(): Promise { const questFiles = this.findQuestFiles(); if (questFiles.length === 0) { p.outro('❌ No .ql quest files found in current directory'); process.exit(1); } const action = await p.select({ message: 'What would you like to do?', options: [ { value: 'play', label: '🎮 Play a quest', hint: 'Start an interactive quest game' }, { value: 'validate', label: '✅ Validate a quest', hint: 'Check quest syntax and structure' }, { value: 'analyze', label: '📊 Analyze a quest', hint: 'Show detailed quest statistics' }, ], }); if (p.isCancel(action)) { p.cancel('Operation cancelled'); process.exit(0); } const file = await p.select({ message: 'Choose a quest file:', options: questFiles.map(file => ({ value: file, label: file })), }); if (p.isCancel(file)) { p.cancel('Operation cancelled'); process.exit(0); } switch (action) { case 'play': await this.playQuest(file as string); break; case 'validate': await this.validateQuest(file as string); break; case 'analyze': await this.analyzeQuest(file as string); break; } } private findQuestFiles(): string[] { try { return fs.readdirSync('.') .filter(file => file.endsWith('.ql')) .sort(); } catch { return []; } } private async playQuest(filename: string): Promise { const spinner = p.spinner(); try { spinner.start('Loading quest...'); const source = this.readFile(filename); const host = { readFile: (file: string) => fs.readFileSync(file, 'utf8'), resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec), } as const; const interpreter = QuestLang.interpret(source, filename, host); // Validate first const validation = interpreter.validate(); if (!validation.isValid) { spinner.stop('❌ Quest validation failed'); p.log.error('Validation errors:'); validation.errors.forEach(error => p.log.error(` • ${error}`)); p.outro('Fix the errors and try again'); process.exit(1); } const questInfo = interpreter.getQuestInfo(); spinner.stop('✅ Quest loaded successfully'); p.note(`📖 ${questInfo.goal}`, `🎮 ${questInfo.name}`); await this.gameLoop(interpreter); } catch (error) { spinner.stop('❌ Error loading quest'); p.log.error(error instanceof Error ? error.message : String(error)); p.outro('Failed to start quest'); process.exit(1); } } private async gameLoop(interpreter: QuestInterpreter): Promise { while (!interpreter.getState().isComplete) { const currentNode = interpreter.getCurrentNode(); if (!currentNode) { p.log.error('Current node not found'); break; } // Show current node description p.log.step(currentNode.description); if (currentNode.nodeType === 'действие') { const choices = interpreter.getAvailableChoices(); const choice = await p.select({ message: 'What do you want to do?', options: choices.map((choice, index) => ({ value: index, label: choice.text, })), }); if (p.isCancel(choice)) { p.cancel('Quest cancelled'); return; } const result = interpreter.executeChoice(choice as number); if (!result.success) { p.log.error(`Error: ${result.error}`); break; } } else if (currentNode.nodeType === 'начальный') { // Auto-advance from initial nodes to first transition const initialNode = currentNode as InitialNode; if (initialNode.transitions && initialNode.transitions.length > 0) { const firstTransition = initialNode.transitions[0]; if (!firstTransition) { p.log.error('First transition is undefined'); break; } const result = interpreter.moveToNode(firstTransition); if (!result.success) { p.log.error(`Error: ${result.error}`); break; } } else { p.log.error('Initial node has no transitions'); break; } } } // Show ending const state = interpreter.getState(); if (state.isComplete && state.endingTitle) { const finalNode = interpreter.getCurrentNode(); p.note( finalNode?.description || 'Quest completed', `🏆 ${state.endingTitle}`, ); const playAgain = await p.confirm({ message: 'Would you like to play again?', }); if (!p.isCancel(playAgain) && playAgain) { interpreter.reset(); await this.gameLoop(interpreter); } else { p.outro('Thanks for playing! 🎉'); } } } private async validateQuest(filename: string): Promise { const spinner = p.spinner(); try { spinner.start('Validating quest...'); const source = this.readFile(filename); const host = { readFile: (file: string) => fs.readFileSync(file, 'utf8'), resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec), } as const; const validation = QuestLang.validate(source, filename, host); if (validation.isValid) { spinner.stop('✅ Validation completed'); p.log.success('Quest is valid!'); p.outro('🎉 No issues found'); } else { spinner.stop('❌ Validation failed'); p.log.error('Validation errors:'); validation.errors.forEach(error => p.log.error(` • ${error}`)); p.outro('Fix the errors and try again'); process.exit(1); } } catch (error) { spinner.stop('❌ Error during validation'); p.log.error(error instanceof Error ? error.message : String(error)); p.outro('Validation failed'); process.exit(1); } } private async analyzeQuest(filename: string): Promise { const spinner = p.spinner(); try { spinner.start('Analyzing quest...'); const source = this.readFile(filename); const host = { readFile: (file: string) => fs.readFileSync(file, 'utf8'), resolve: (fromFile: string, spec: string) => path.resolve(path.dirname(fromFile), spec), } as const; const interpreter = QuestLang.interpret(source, filename, host); const questInfo = interpreter.getQuestInfo(); spinner.stop('✅ Analysis completed'); const nodes = Object.values(interpreter.getProgram().graph.nodes) as NodeDefinition[]; const initialNodes = nodes.filter(n => n.nodeType === 'начальный'); const actionNodes = nodes.filter(n => n.nodeType === 'действие'); const endingNodes = nodes.filter(n => n.nodeType === 'концовка'); p.note( `📊 Quest Analysis Results\n\n` + `Total nodes: ${nodes.length}\n` + ` • Initial nodes: ${initialNodes.length}\n` + ` • Action nodes: ${actionNodes.length}\n` + ` • Ending nodes: ${endingNodes.length}`, `📖 ${questInfo.name}`, ); // Analyze paths const paths = interpreter.getAllPaths(); if (paths.length > 0) { const avgPathLength = paths.reduce((sum, path) => sum + path.length, 0) / paths.length; const shortestPath = Math.min(...paths.map(path => path.length)); const longestPath = Math.max(...paths.map(path => path.length)); p.log.info('📈 Path Analysis:'); p.log.info(` • Possible paths: ${paths.length}`); p.log.info(` • Average path length: ${avgPathLength.toFixed(1)} steps`); p.log.info(` • Shortest path: ${shortestPath} steps`); p.log.info(` • Longest path: ${longestPath} steps`); } // Validation const validation = interpreter.validate(); if (validation.isValid) { p.log.success('✅ Quest structure is valid'); p.outro('🎉 Analysis completed successfully'); } else { p.log.warn('⚠️ Quest has validation issues:'); validation.errors.forEach(error => p.log.warn(` • ${error}`)); p.outro('Consider fixing these issues'); } } catch (error) { spinner.stop('❌ Error during analysis'); p.log.error(error instanceof Error ? error.message : String(error)); p.outro('Analysis failed'); process.exit(1); } } private readFile(filename: string): string { if (!fs.existsSync(filename)) { throw new Error(`File not found: ${filename}`); } return fs.readFileSync(filename, 'utf-8'); } } const cli = new ClackCLI(); cli.run().catch((error) => { p.log.error(`Unexpected error: ${error}`); p.outro('❌ CLI crashed'); process.exit(1); });