feat: init

This commit is contained in:
2025-09-01 02:28:55 +07:00
commit eb357ef703
21 changed files with 6628 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { QuestLang } from '..';
describe('questLang Integration', () => {
it('should parse and validate complete quest', () => {
const questSource = `
квест ИнтеграционныйТест;
цель "Полная проверка функциональности";
граф {
узлы {
старт: {
тип: начальный;
описание: "Начало квеста";
переходы: [действие1];
}
действие1: {
тип: действие;
описание: "Первое решение";
варианты: [
("Вариант А", действие2),
("Вариант Б", концовка1)
];
}
действие2: {
тип: действие;
описание: "Второе решение";
варианты: [
("Продолжить", концовка2)
];
}
концовка1: {
тип: концовка;
название: "Быстрый финал";
описание: "Вы завершили квест быстро";
}
концовка2: {
тип: концовка;
название: "Полный финал";
описание: "Вы прошли весь квест";
}
}
начало: старт;
}
конец;
`;
// Test parsing
const ast = QuestLang.parse(questSource);
expect(ast.name).toBe('ИнтеграционныйТест');
expect(ast.goal).toBe('Полная проверка функциональности');
// Test interpretation
const interpreter = QuestLang.interpret(questSource);
expect(interpreter.getQuestInfo().name).toBe('ИнтеграционныйТест');
// Test validation
const validation = QuestLang.validate(questSource);
expect(validation.isValid).toBe(true);
// Test gameplay flow
interpreter.moveToNode(ействие1');
const choices = interpreter.getAvailableChoices();
expect(choices).toHaveLength(2);
const result = interpreter.executeChoice(0);
expect(result.success).toBe(true);
expect(result.newState.currentNode).toBe(ействие2');
});
it('should handle parsing errors gracefully', () => {
const invalidSource = 'квест без точки с запятой';
const validation = QuestLang.validate(invalidSource);
expect(validation.isValid).toBe(false);
expect(validation.errors[0]).toContain('Expected');
});
it('should work with the original quest example', () => {
const originalQuest = `
квест Шашлык;
цель "Сегодня день труда и отдыха и надо купить шашлык. На пути нас встречают разнообразные трудности.";
граф {
узлы {
старт: {
тип: начальный;
описание: "Начало приключения";
переходы: [выбор_пути];
}
выбор_пути: {
тип: действие;
описание: "Ближайшая дешёвая шашлычная находится в 100 метрах от вас. Хорошая - в 1 км. Куда идти?";
варианты: [
("Пойти в дешёвую шашлычную", дешевая_очередь),
("Пойти в хорошую шашлычную", дорога_к_хорошей),
("Пойти домой", поход_в_магазин)
];
}
дешевая_очередь: {
тип: концовка;
название: "В очереди";
описание: "Вы стоите в очереди за шашлыком";
}
дорога_к_хорошей: {
тип: концовка;
название: "Дорога к хорошей шашлычной";
описание: "Вы идёте к хорошей шашлычной";
}
поход_в_магазин: {
тип: концовка;
название: "В магазине";
описание: "Вы пошли в магазин за продуктами";
}
}
начало: старт;
}
конец;
`;
const validation = QuestLang.validate(originalQuest);
expect(validation.isValid).toBe(true);
const interpreter = QuestLang.interpret(originalQuest);
expect(interpreter.getQuestInfo().name).toBe('Шашлык');
// Test navigation
interpreter.moveToNode('выбор_пути');
const choices = interpreter.getAvailableChoices();
expect(choices).toHaveLength(3);
expect(choices[0].text).toBe('Пойти в дешёвую шашлычную');
});
});

View File

@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { QuestLang } from '..';
describe('questInterpreter', () => {
const questSource = `
квест ТестКвест;
цель "Найти выход из лабиринта";
граф {
узлы {
старт: {
тип: начальный;
описание: "Вы стоите перед входом в лабиринт";
переходы: [выбор];
}
выбор: {
тип: действие;
описание: "Перед вами два коридора. Куда пойти?";
варианты: [
("Налево", налево),
("Направо", направо)
];
}
налево: {
тип: действие;
описание: "Вы идете налево и находите сокровище!";
варианты: [
("Взять сокровище", победа)
];
}
направо: {
тип: концовка;
название: "Ловушка!";
описание: "Вы попали в ловушку и проиграли";
}
победа: {
тип: концовка;
название: "Победа!";
описание: "Вы нашли сокровище и победили";
}
}
начало: старт;
}
конец;
`;
let interpreter: any;
beforeEach(() => {
interpreter = QuestLang.interpret(questSource);
});
it('should initialize with correct quest info', () => {
const questInfo = interpreter.getQuestInfo();
expect(questInfo.name).toBe('ТестКвест');
expect(questInfo.goal).toBe('Найти выход из лабиринта');
expect(questInfo.isComplete).toBe(false);
});
it('should start with correct initial state', () => {
const state = interpreter.getState();
expect(state.currentNode).toBe('старт');
expect(state.history).toHaveLength(0);
expect(state.isComplete).toBe(false);
});
it('should get current node information', () => {
const currentNode = interpreter.getCurrentNode();
expect(currentNode).toBeDefined();
expect(currentNode?.id).toBe('старт');
expect(currentNode?.nodeType).toBe('начальный');
});
it('should move to next node', () => {
const result = interpreter.moveToNode('выбор');
expect(result.success).toBe(true);
expect(result.newState.currentNode).toBe('выбор');
expect(result.newState.history).toContain('старт');
});
it('should get available choices for action node', () => {
interpreter.moveToNode('выбор');
const choices = interpreter.getAvailableChoices();
expect(choices).toHaveLength(2);
expect(choices[0].text).toBe('Налево');
expect(choices[0].target).toBe('налево');
expect(choices[1].text).toBe('Направо');
expect(choices[1].target).toBe('направо');
});
it('should execute choice correctly', () => {
interpreter.moveToNode('выбор');
const result = interpreter.executeChoice(0); // Choose "Налево"
expect(result.success).toBe(true);
expect(result.newState.currentNode).toBe('налево');
});
it('should handle invalid choice index', () => {
interpreter.moveToNode('выбор');
const result = interpreter.executeChoice(5); // Invalid index
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid choice index');
});
it('should complete quest when reaching ending', () => {
interpreter.moveToNode('направо'); // Go to ending
const state = interpreter.getState();
expect(state.isComplete).toBe(true);
expect(state.endingTitle).toBe('Ловушка!');
});
it('should reset to initial state', () => {
interpreter.moveToNode('выбор');
interpreter.reset();
const state = interpreter.getState();
expect(state.currentNode).toBe('старт');
expect(state.history).toHaveLength(0);
expect(state.isComplete).toBe(false);
});
it('should validate quest structure', () => {
const validation = interpreter.validate();
expect(validation.isValid).toBe(true);
expect(validation.errors).toHaveLength(0);
});
it('should detect invalid quest structure', () => {
const invalidSource = `
квест Неправильный;
цель "Тест";
граф {
узлы {
старт: {
тип: действие;
описание: "Тест";
варианты: [
("Выбор", несуществующийУзел)
];
}
}
начало: старт;
}
конец;
`;
const invalidInterpreter = QuestLang.interpret(invalidSource);
const validation = invalidInterpreter.validate();
expect(validation.isValid).toBe(false);
expect(validation.errors).toContain('Node \'старт\' references non-existent target \'несуществующийУзел\'');
});
it('should find all possible paths', () => {
const paths = interpreter.getAllPaths();
expect(paths.length).toBeGreaterThan(0);
// Should have at least two paths (налево->победа and направо)
expect(paths.some(path => path.includes('победа'))).toBe(true);
expect(paths.some(path => path.includes('направо'))).toBe(true);
});
});

106
src/__tests__/lexer.test.ts Normal file
View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { Lexer } from '../lexer';
import { TokenType } from '..';
describe('lexer', () => {
it('should tokenize quest keywords', () => {
const source = 'квест Тест; цель "Описание"; конец;';
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.QUEST);
expect(tokens[0].value).toBe('квест');
expect(tokens[1].type).toBe(TokenType.IDENTIFIER);
expect(tokens[1].value).toBe('Тест');
expect(tokens[2].type).toBe(TokenType.SEMICOLON);
expect(tokens[3].type).toBe(TokenType.GOAL);
expect(tokens[3].value).toBe('цель');
expect(tokens[4].type).toBe(TokenType.STRING);
expect(tokens[4].value).toBe('Описание');
});
it('should tokenize strings correctly', () => {
const source = '"Тестовая строка с пробелами"';
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.STRING);
expect(tokens[0].value).toBe('Тестовая строка с пробелами');
});
it('should tokenize numbers', () => {
const source = '42 3.14';
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.NUMBER);
expect(tokens[0].value).toBe('42');
expect(tokens[1].type).toBe(TokenType.NUMBER);
expect(tokens[1].value).toBe('3.14');
});
it('should handle comments', () => {
const source = '// это комментарий\nквест';
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].type).toBe(TokenType.COMMENT);
expect(tokens[0].value).toBe('// это комментарий');
expect(tokens[1].type).toBe(TokenType.QUEST);
});
it('should track line and column numbers', () => {
const source = 'квест\nТест';
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
expect(tokens[0].line).toBe(1);
expect(tokens[0].column).toBe(1);
expect(tokens[1].line).toBe(2);
expect(tokens[1].column).toBe(1);
});
it('should handle all symbols', () => {
const source = '{ } [ ] ( ) : ; , .';
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
const expectedTypes = [
TokenType.LEFT_BRACE,
TokenType.RIGHT_BRACE,
TokenType.LEFT_BRACKET,
TokenType.RIGHT_BRACKET,
TokenType.LEFT_PAREN,
TokenType.RIGHT_PAREN,
TokenType.COLON,
TokenType.SEMICOLON,
TokenType.COMMA,
TokenType.DOT,
];
expectedTypes.forEach((expectedType, index) => {
expect(tokens[index].type).toBe(expectedType);
});
});
it('should throw error on unexpected character', () => {
const source = 'квест @';
const lexer = new Lexer(source);
expect(() => lexer.tokenize()).toThrow('Unexpected character');
});
it('should throw error on unterminated string', () => {
const source = 'квест "незакрытая строка';
const lexer = new Lexer(source);
expect(() => lexer.tokenize()).toThrow('Unterminated string');
});
});

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest';
import { Lexer } from '../lexer';
import { Parser } from '../parser';
describe('parser', () => {
const parseSource = (source: string) => {
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
const parser = new Parser(tokens);
return parser.parse();
};
it('should parse simple quest', () => {
const source = `
квест ТестКвест;
цель "Тестовое описание";
граф {
узлы {
старт: {
тип: начальный;
описание: "Начало";
переходы: [действие1];
}
действие1: {
тип: действие;
описание: "Первое действие";
варианты: [
("Выбор 1", конец1),
("Выбор 2", конец2)
];
}
конец1: {
тип: концовка;
название: "Первый финал";
описание: "Описание финала";
}
конец2: {
тип: концовка;
название: "Второй финал";
описание: "Описание второго финала";
}
}
начало: старт;
}
конец;
`;
const ast = parseSource(source);
expect(ast.type).toBe('QuestProgram');
expect(ast.name).toBe('ТестКвест');
expect(ast.goal).toBe('Тестовое описание');
expect(ast.graph.start).toBe('старт');
const nodes = ast.graph.nodes;
expect(Object.keys(nodes)).toHaveLength(4);
expect(nodes.старт.nodeType).toBe('начальный');
expect(nodes.действие1.nodeType).toBe('действие');
expect(nodes.конец1.nodeType).toBe('концовка');
expect(nodes.конец2.nodeType).toBe('концовка');
});
it('should parse action node with options', () => {
const source = `
квест Тест;
цель "Тест";
граф {
узлы {
действие1: {
тип: действие;
описание: "Выберите действие";
варианты: [
("Первый вариант", цель1),
("Второй вариант", цель2)
];
}
}
начало: действие1;
}
конец;
`;
const ast = parseSource(source);
const actionNode = ast.graph.nodes.действие1 as any;
expect(actionNode.nodeType).toBe('действие');
expect(actionNode.options).toHaveLength(2);
expect(actionNode.options[0].text).toBe('Первый вариант');
expect(actionNode.options[0].target).toBe(ель1');
expect(actionNode.options[1].text).toBe('Второй вариант');
expect(actionNode.options[1].target).toBe(ель2');
});
it('should parse ending node with title', () => {
const source = `
квест Тест;
цель "Тест";
граф {
узлы {
финал: {
тип: концовка;
название: "Название финала";
описание: "Описание финала";
}
}
начало: финал;
}
конец;
`;
const ast = parseSource(source);
const endingNode = ast.graph.nodes.финал as any;
expect(endingNode.nodeType).toBe('концовка');
expect(endingNode.title).toBe('Название финала');
expect(endingNode.description).toBe('Описание финала');
});
it('should throw error on missing semicolon', () => {
const source = 'квест Тест цель "Описание"';
expect(() => parseSource(source)).toThrow('Expected \';\' after quest name');
});
it('should throw error on invalid node type', () => {
const source = `
квест Тест;
цель "Тест";
граф {
узлы {
узел1: {
тип: неправильныйТип;
описание: "Тест";
}
}
начало: узел1;
}
конец;
`;
expect(() => parseSource(source)).toThrow('Invalid node type');
});
});

152
src/ast.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Token types for QuestLang
*/
export enum TokenType {
// Literals
IDENTIFIER = 'IDENTIFIER',
STRING = 'STRING',
NUMBER = 'NUMBER',
// Keywords
QUEST = 'квест',
GOAL = 'цель',
GRAPH = 'граф',
NODES = 'узлы',
START = 'начало',
END = 'конец',
TYPE = 'тип',
DESCRIPTION = 'описание',
TRANSITIONS = 'переходы',
OPTIONS = 'варианты',
TITLE = 'название',
// Node types
INITIAL = 'начальный',
ACTION = 'действие',
ENDING = 'концовка',
// Symbols
SEMICOLON = ';',
COLON = ':',
COMMA = ',',
DOT = '.',
LEFT_BRACE = '{',
RIGHT_BRACE = '}',
LEFT_BRACKET = '[',
RIGHT_BRACKET = ']',
LEFT_PAREN = '(',
RIGHT_PAREN = ')',
// Special
EOF = 'EOF',
NEWLINE = 'NEWLINE',
COMMENT = 'COMMENT',
WHITESPACE = 'WHITESPACE'
}
/**
* Represents a token in the source code
*/
export interface Token {
type: TokenType;
value: string;
line: number;
column: number;
start: number;
end: number;
}
/**
* Position in source code
*/
export interface Position {
line: number;
column: number;
offset: number;
}
/**
* Abstract Syntax Tree node types for QuestLang
*/
export interface ASTNode {
type: string;
line: number;
column: number;
}
/**
* Root node of the program
*/
export interface QuestProgram extends ASTNode {
type: 'QuestProgram';
name: string;
goal: string;
graph: GraphNode;
}
/**
* Graph definition
*/
export interface GraphNode extends ASTNode {
type: 'Graph';
nodes: Record<string, NodeDefinition>;
start: string;
}
/**
* Base node definition
*/
export interface NodeDefinition extends ASTNode {
nodeType: 'начальный' | 'действие' | 'концовка';
id: string;
description: string;
}
/**
* Initial node
*/
export interface InitialNode extends NodeDefinition {
nodeType: 'начальный';
transitions: string[];
}
/**
* Action node
*/
export interface ActionNode extends NodeDefinition {
nodeType: 'действие';
options: OptionChoice[];
}
/**
* Ending node
*/
export interface EndingNode extends NodeDefinition {
nodeType: 'концовка';
title: string;
}
/**
* Option choice in action nodes
*/
export interface OptionChoice extends ASTNode {
type: 'OptionChoice';
text: string;
target: string;
}
/**
* String literal
*/
export interface StringLiteral extends ASTNode {
type: 'StringLiteral';
value: string;
}
/**
* Identifier
*/
export interface Identifier extends ASTNode {
type: 'Identifier';
name: string;
}

327
src/cli.ts Normal file
View File

@@ -0,0 +1,327 @@
#!/usr/bin/env node
import fs from 'node:fs';
import * as p from '@clack/prompts';
import { QuestLang, type QuestInterpreter } from '.';
import { type NodeDefinition, type InitialNode } from './ast';
/**
* Beautiful command-line interface for QuestLang using clack
*/
class ClackCLI {
/**
* Run the CLI
*/
public async run(): Promise<void> {
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 <file.ql>');
process.exit(1);
}
await this.playQuest(args[1]);
break;
case 'validate':
if (args.length < 2) {
p.outro('❌ Usage: questlang-clack validate <file.ql>');
process.exit(1);
}
await this.validateQuest(args[1]);
break;
case 'analyze':
if (args.length < 2) {
p.outro('❌ Usage: questlang-clack analyze <file.ql>');
process.exit(1);
}
await this.analyzeQuest(args[1]);
break;
default:
p.outro(`❌ Unknown command: ${command}`);
process.exit(1);
}
}
private async showInteractiveMenu(): Promise<void> {
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<void> {
const spinner = p.spinner();
try {
spinner.start('Loading quest...');
const source = this.readFile(filename);
const interpreter = QuestLang.interpret(source);
// 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<void> {
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 result = interpreter.moveToNode(initialNode.transitions[0]);
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<void> {
const spinner = p.spinner();
try {
spinner.start('Validating quest...');
const source = this.readFile(filename);
const validation = QuestLang.validate(source);
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<void> {
const spinner = p.spinner();
try {
spinner.start('Analyzing quest...');
const source = this.readFile(filename);
const interpreter = QuestLang.interpret(source);
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);
});

50
src/index.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Lexer } from './lexer';
import { Parser } from './parser';
import { QuestInterpreter } from './interpreter';
import type { QuestProgram } from './ast';
/**
* Main QuestLang processor
*/
export class QuestLang {
/**
* Parse QuestLang source code and return AST
*/
public static parse(source: string): QuestProgram {
const lexer = new Lexer(source);
const tokens = lexer.tokenize();
const parser = new Parser(tokens);
return parser.parse();
}
/**
* Create interpreter from source code
*/
public static interpret(source: string): QuestInterpreter {
const ast = this.parse(source);
return new QuestInterpreter(ast);
}
/**
* Validate QuestLang source code
*/
public static validate(source: string): { isValid: boolean; errors: string[] } {
try {
const interpreter = this.interpret(source);
return interpreter.validate();
} catch (error) {
return {
isValid: false,
errors: [error instanceof Error ? error.message : 'Unknown parsing error']
};
}
}
}
// Re-export main classes
export { Lexer } from './lexer';
export { Parser } from './parser';
export { QuestInterpreter } from './interpreter';
export * from './ast';
export * from './types';

283
src/interpreter.ts Normal file
View File

@@ -0,0 +1,283 @@
import type {
QuestProgram,
GraphNode,
NodeDefinition,
InitialNode,
ActionNode,
EndingNode,
OptionChoice
} from './ast';
/**
* Runtime state of the quest
*/
export interface QuestState {
currentNode: string;
history: string[];
isComplete: boolean;
endingTitle?: string;
}
/**
* Result of executing a choice
*/
export interface ExecutionResult {
success: boolean;
newState: QuestState;
error?: string;
}
/**
* Visitor interface for quest nodes
*/
export interface QuestVisitor {
visitInitialNode(node: InitialNode, state: QuestState): void;
visitActionNode(node: ActionNode, state: QuestState): void;
visitEndingNode(node: EndingNode, state: QuestState): void;
}
/**
* Interpreter for QuestLang programs
*/
export class QuestInterpreter {
private program: QuestProgram;
private currentState: QuestState;
constructor(program: QuestProgram) {
this.program = program;
this.currentState = {
currentNode: program.graph.start,
history: [],
isComplete: false
};
}
/**
* Get the current quest state
*/
public getState(): QuestState {
return { ...this.currentState };
}
/**
* Get quest information
*/
public getQuestInfo(): { name: string; goal: string; isComplete: boolean } {
return {
name: this.program.name,
goal: this.program.goal,
isComplete: this.currentState.isComplete
};
}
/**
* Get the quest program
*/
public getProgram(): QuestProgram {
return this.program;
}
/**
* Get current node information
*/
public getCurrentNode(): NodeDefinition | null {
const nodeId = this.currentState.currentNode;
return this.program.graph.nodes[nodeId] || null;
}
/**
* Get available choices for current action node
*/
public getAvailableChoices(): OptionChoice[] {
const currentNode = this.getCurrentNode();
if (!currentNode || currentNode.nodeType !== 'действие') {
return [];
}
return (currentNode as ActionNode).options;
}
/**
* Execute a choice by index
*/
public executeChoice(choiceIndex: number): ExecutionResult {
const currentNode = this.getCurrentNode();
if (!currentNode) {
return {
success: false,
newState: this.currentState,
error: `Current node '${this.currentState.currentNode}' not found`
};
}
if (currentNode.nodeType !== 'действие') {
return {
success: false,
newState: this.currentState,
error: `Cannot execute choice on node type '${currentNode.nodeType}'`
};
}
const actionNode = currentNode as ActionNode;
const choices = actionNode.options;
if (choiceIndex < 0 || choiceIndex >= choices.length) {
return {
success: false,
newState: this.currentState,
error: `Invalid choice index: ${choiceIndex}. Available choices: 0-${choices.length - 1}`
};
}
const choice = choices[choiceIndex];
return this.moveToNode(choice.target);
}
/**
* Move to a specific node
*/
public moveToNode(nodeId: string): ExecutionResult {
const targetNode = this.program.graph.nodes[nodeId];
if (!targetNode) {
return {
success: false,
newState: this.currentState,
error: `Target node '${nodeId}' not found`
};
}
// Update state
const newState: QuestState = {
currentNode: nodeId,
history: [...this.currentState.history, this.currentState.currentNode],
isComplete: targetNode.nodeType === 'концовка',
endingTitle: targetNode.nodeType === 'концовка' ? (targetNode as EndingNode).title : undefined
};
this.currentState = newState;
return {
success: true,
newState: { ...newState }
};
}
/**
* Reset quest to initial state
*/
public reset(): void {
this.currentState = {
currentNode: this.program.graph.start,
history: [],
isComplete: false
};
}
/**
* Get all possible paths from current state (for debugging/analysis)
*/
public getAllPaths(): string[][] {
const paths: string[][] = [];
const visited = new Set<string>();
this.findPaths(this.currentState.currentNode, [this.currentState.currentNode], paths, visited);
return paths;
}
private findPaths(nodeId: string, currentPath: string[], allPaths: string[][], visited: Set<string>): void {
const node = this.program.graph.nodes[nodeId];
if (!node || visited.has(nodeId)) {
return;
}
if (node.nodeType === 'концовка') {
allPaths.push([...currentPath]);
return;
}
visited.add(nodeId);
if (node.nodeType === 'действие') {
const actionNode = node as ActionNode;
for (const option of actionNode.options) {
this.findPaths(option.target, [...currentPath, option.target], allPaths, new Set(visited));
}
} else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) {
this.findPaths(transition, [...currentPath, transition], allPaths, new Set(visited));
}
}
}
/**
* Validate the quest graph for consistency
*/
public validate(): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
const nodeIds = Object.keys(this.program.graph.nodes);
// Check if start node exists
if (!this.program.graph.nodes[this.program.graph.start]) {
errors.push(`Start node '${this.program.graph.start}' does not exist`);
}
// Check all node references
for (const [nodeId, node] of Object.entries(this.program.graph.nodes)) {
if (node.nodeType === 'действие') {
const actionNode = node as ActionNode;
for (const option of actionNode.options) {
if (!this.program.graph.nodes[option.target]) {
errors.push(`Node '${nodeId}' references non-existent target '${option.target}'`);
}
}
} else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) {
if (!this.program.graph.nodes[transition]) {
errors.push(`Initial node '${nodeId}' references non-existent transition '${transition}'`);
}
}
}
}
// Check for unreachable nodes
const reachable = new Set<string>();
this.findReachableNodes(this.program.graph.start, reachable);
for (const nodeId of nodeIds) {
if (!reachable.has(nodeId)) {
errors.push(`Node '${nodeId}' is unreachable`);
}
}
return {
isValid: errors.length === 0,
errors
};
}
private findReachableNodes(nodeId: string, reachable: Set<string>): void {
if (reachable.has(nodeId)) return;
const node = this.program.graph.nodes[nodeId];
if (!node) return;
reachable.add(nodeId);
if (node.nodeType === 'действие') {
const actionNode = node as ActionNode;
for (const option of actionNode.options) {
this.findReachableNodes(option.target, reachable);
}
} else if (node.nodeType === 'начальный') {
const initialNode = node as InitialNode;
for (const transition of initialNode.transitions) {
this.findReachableNodes(transition, reachable);
}
}
}
}

234
src/lexer.ts Normal file
View File

@@ -0,0 +1,234 @@
import { TokenType } from './ast';
import type { Token, Position } from './ast';
/**
* Lexical analyzer for QuestLang
*/
export class Lexer {
private source: string;
private position: number = 0;
private line: number = 1;
private column: number = 1;
private tokens: Token[] = [];
// Keywords mapping
private readonly keywords = new Map<string, TokenType>([
['квест', TokenType.QUEST],
['цель', TokenType.GOAL],
['граф', TokenType.GRAPH],
['узлы', TokenType.NODES],
['начало', TokenType.START],
['конец', TokenType.END],
['тип', TokenType.TYPE],
['описание', TokenType.DESCRIPTION],
['переходы', TokenType.TRANSITIONS],
['варианты', TokenType.OPTIONS],
['название', TokenType.TITLE],
['начальный', TokenType.INITIAL],
['действие', TokenType.ACTION],
['концовка', TokenType.ENDING]
]);
constructor(source: string) {
this.source = source;
}
/**
* Tokenize the entire source code
*/
public tokenize(): Token[] {
this.tokens = [];
this.position = 0;
this.line = 1;
this.column = 1;
while (!this.isAtEnd()) {
this.scanToken();
}
this.addToken(TokenType.EOF, '');
return this.tokens;
}
private scanToken(): void {
const start = this.position;
const startLine = this.line;
const startColumn = this.column;
const c = this.advance();
switch (c) {
case ' ':
case '\r':
case '\t':
// Skip whitespace
break;
case '\n':
this.line++;
this.column = 1;
break;
case ';':
this.addToken(TokenType.SEMICOLON, c, start, startLine, startColumn);
break;
case ':':
this.addToken(TokenType.COLON, c, start, startLine, startColumn);
break;
case ',':
this.addToken(TokenType.COMMA, c, start, startLine, startColumn);
break;
case '.':
this.addToken(TokenType.DOT, c, start, startLine, startColumn);
break;
case '{':
this.addToken(TokenType.LEFT_BRACE, c, start, startLine, startColumn);
break;
case '}':
this.addToken(TokenType.RIGHT_BRACE, c, start, startLine, startColumn);
break;
case '[':
this.addToken(TokenType.LEFT_BRACKET, c, start, startLine, startColumn);
break;
case ']':
this.addToken(TokenType.RIGHT_BRACKET, c, start, startLine, startColumn);
break;
case '(':
this.addToken(TokenType.LEFT_PAREN, c, start, startLine, startColumn);
break;
case ')':
this.addToken(TokenType.RIGHT_PAREN, c, start, startLine, startColumn);
break;
case '/':
if (this.match('/')) {
this.scanComment(start, startLine, startColumn);
} else {
throw new Error(`Unexpected character: ${c} at ${startLine}:${startColumn}`);
}
break;
case '"':
this.scanString(start, startLine, startColumn);
break;
default:
if (this.isDigit(c)) {
this.scanNumber(start, startLine, startColumn);
} else if (this.isAlpha(c)) {
this.scanIdentifier(start, startLine, startColumn);
} else {
throw new Error(`Unexpected character: ${c} at ${startLine}:${startColumn}`);
}
break;
}
}
private scanComment(start: number, startLine: number, startColumn: number): void {
while (this.peek() !== '\n' && !this.isAtEnd()) {
this.advance();
}
const value = this.source.substring(start, this.position);
this.addToken(TokenType.COMMENT, value, start, startLine, startColumn);
}
private scanString(start: number, startLine: number, startColumn: number): void {
while (this.peek() !== '"' && !this.isAtEnd()) {
if (this.peek() === '\n') {
this.line++;
this.column = 1;
}
this.advance();
}
if (this.isAtEnd()) {
throw new Error(`Unterminated string at ${startLine}:${startColumn}`);
}
// Consume closing "
this.advance();
// Get string content without quotes
const value = this.source.substring(start + 1, this.position - 1);
this.addToken(TokenType.STRING, value, start, startLine, startColumn);
}
private scanNumber(start: number, startLine: number, startColumn: number): void {
while (this.isDigit(this.peek())) {
this.advance();
}
// Look for decimal part
if (this.peek() === '.' && this.isDigit(this.peekNext())) {
this.advance(); // consume '.'
while (this.isDigit(this.peek())) {
this.advance();
}
}
const value = this.source.substring(start, this.position);
this.addToken(TokenType.NUMBER, value, start, startLine, startColumn);
}
private scanIdentifier(start: number, startLine: number, startColumn: number): void {
while (this.isAlphaNumeric(this.peek()) || this.peek() === '_') {
this.advance();
}
const value = this.source.substring(start, this.position);
const type = this.keywords.get(value) || TokenType.IDENTIFIER;
this.addToken(type, value, start, startLine, startColumn);
}
private addToken(type: TokenType, value: string, start?: number, line?: number, column?: number): void {
this.tokens.push({
type,
value,
line: line || this.line,
column: column || this.column,
start: start || this.position,
end: this.position
});
}
private advance(): string {
const char = this.source.charAt(this.position);
this.position++;
this.column++;
return char;
}
private match(expected: string): boolean {
if (this.isAtEnd()) return false;
if (this.source.charAt(this.position) !== expected) return false;
this.position++;
this.column++;
return true;
}
private peek(): string {
if (this.isAtEnd()) return '\0';
return this.source.charAt(this.position);
}
private peekNext(): string {
if (this.position + 1 >= this.source.length) return '\0';
return this.source.charAt(this.position + 1);
}
private isAtEnd(): boolean {
return this.position >= this.source.length;
}
private isDigit(c: string): boolean {
return c >= '0' && c <= '9';
}
private isAlpha(c: string): boolean {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= 'а' && c <= 'я') ||
(c >= 'А' && c <= 'Я') ||
c === 'ё' || c === 'Ё' ||
c === '_';
}
private isAlphaNumeric(c: string): boolean {
return this.isAlpha(c) || this.isDigit(c);
}
}

263
src/parser.ts Normal file
View File

@@ -0,0 +1,263 @@
import { TokenType } from './ast';
import type { Token } from './ast';
import type {
ASTNode,
QuestProgram,
GraphNode,
NodeDefinition,
InitialNode,
ActionNode,
EndingNode,
OptionChoice,
StringLiteral,
Identifier
} from './ast';
/**
* Parser for QuestLang
*/
export class Parser {
private tokens: Token[];
private current: number = 0;
constructor(tokens: Token[]) {
// Filter out comments and whitespace
this.tokens = tokens.filter(token =>
token.type !== TokenType.COMMENT &&
token.type !== TokenType.WHITESPACE
);
}
/**
* Parse the entire program
*/
public parse(): QuestProgram {
return this.parseQuest();
}
private parseQuest(): QuestProgram {
const questToken = this.consume(TokenType.QUEST, "Expected 'квест'");
const name = this.consume(TokenType.IDENTIFIER, "Expected quest name").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after quest name");
const goal = this.parseGoal();
const graph = this.parseGraph();
this.consume(TokenType.END, "Expected 'конец'");
this.consume(TokenType.SEMICOLON, "Expected ';' after 'конец'");
return {
type: 'QuestProgram',
name,
goal,
graph,
line: questToken.line,
column: questToken.column
};
}
private parseGoal(): string {
this.consume(TokenType.GOAL, "Expected 'цель'");
const goalValue = this.consume(TokenType.STRING, "Expected goal description").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after goal");
return goalValue;
}
private parseGraph(): GraphNode {
const graphToken = this.consume(TokenType.GRAPH, "Expected 'граф'");
this.consume(TokenType.LEFT_BRACE, "Expected '{' after 'граф'");
const nodes: Record<string, NodeDefinition> = {};
let start = '';
while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
if (this.match(TokenType.NODES)) {
this.parseNodes(nodes);
} else if (this.match(TokenType.START)) {
this.consume(TokenType.COLON, "Expected ':' after 'начало'");
start = this.consume(TokenType.IDENTIFIER, "Expected start node identifier").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after start node");
} else {
throw new Error(`Unexpected token in graph: ${this.peek().type} at ${this.peek().line}:${this.peek().column}`);
}
}
this.consume(TokenType.RIGHT_BRACE, "Expected '}' after graph body");
return {
type: 'Graph',
nodes,
start,
line: graphToken.line,
column: graphToken.column
};
}
private parseNodes(nodes: Record<string, NodeDefinition>): void {
this.consume(TokenType.LEFT_BRACE, "Expected '{' after 'узлы'");
while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
const nodeId = this.consume(TokenType.IDENTIFIER, "Expected node identifier").value;
this.consume(TokenType.COLON, "Expected ':' after node identifier");
this.consume(TokenType.LEFT_BRACE, "Expected '{' after node identifier");
const node = this.parseNodeDefinition(nodeId);
nodes[nodeId] = node;
this.consume(TokenType.RIGHT_BRACE, "Expected '}' after node definition");
}
this.consume(TokenType.RIGHT_BRACE, "Expected '}' after nodes");
}
private parseNodeDefinition(id: string): NodeDefinition {
const startToken = this.peek();
let nodeType: 'начальный' | 'действие' | 'концовка' = 'действие';
let description = '';
const transitions: string[] = [];
const options: OptionChoice[] = [];
let title = '';
while (!this.check(TokenType.RIGHT_BRACE) && !this.isAtEnd()) {
if (this.match(TokenType.TYPE)) {
this.consume(TokenType.COLON, "Expected ':' after 'тип'");
const typeToken = this.advance();
if (typeToken.type === TokenType.INITIAL) {
nodeType = 'начальный';
} else if (typeToken.type === TokenType.ACTION) {
nodeType = 'действие';
} else if (typeToken.type === TokenType.ENDING) {
nodeType = 'концовка';
} else {
throw new Error(`Invalid node type: ${typeToken.value} at ${typeToken.line}:${typeToken.column}`);
}
this.consume(TokenType.SEMICOLON, "Expected ';' after node type");
} else if (this.match(TokenType.DESCRIPTION)) {
this.consume(TokenType.COLON, "Expected ':' after 'описание'");
description = this.consume(TokenType.STRING, "Expected description string").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after description");
} else if (this.match(TokenType.TRANSITIONS)) {
this.consume(TokenType.COLON, "Expected ':' after 'переходы'");
this.parseTransitions(transitions);
} else if (this.match(TokenType.OPTIONS)) {
this.consume(TokenType.COLON, "Expected ':' after 'варианты'");
this.parseOptions(options);
} else if (this.match(TokenType.TITLE)) {
this.consume(TokenType.COLON, "Expected ':' after 'название'");
title = this.consume(TokenType.STRING, "Expected title string").value;
this.consume(TokenType.SEMICOLON, "Expected ';' after title");
} else {
throw new Error(`Unexpected token in node definition: ${this.peek().type} at ${this.peek().line}:${this.peek().column}`);
}
}
// Create appropriate node type
const baseNode = {
id,
nodeType,
description,
line: startToken.line,
column: startToken.column
};
switch (nodeType) {
case 'начальный':
return {
...baseNode,
type: 'InitialNode',
transitions
} as InitialNode;
case 'действие':
return {
...baseNode,
type: 'ActionNode',
options
} as ActionNode;
case 'концовка':
return {
...baseNode,
type: 'EndingNode',
title
} as EndingNode;
}
}
private parseTransitions(transitions: string[]): void {
this.consume(TokenType.LEFT_BRACKET, "Expected '[' for transitions");
if (!this.check(TokenType.RIGHT_BRACKET)) {
do {
const transition = this.consume(TokenType.IDENTIFIER, "Expected transition identifier").value;
transitions.push(transition);
} while (this.match(TokenType.COMMA));
}
this.consume(TokenType.RIGHT_BRACKET, "Expected ']' after transitions");
this.consume(TokenType.SEMICOLON, "Expected ';' after transitions");
}
private parseOptions(options: OptionChoice[]): void {
this.consume(TokenType.LEFT_BRACKET, "Expected '[' for options");
if (!this.check(TokenType.RIGHT_BRACKET)) {
do {
const optionToken = this.peek();
this.consume(TokenType.LEFT_PAREN, "Expected '(' for option");
const text = this.consume(TokenType.STRING, "Expected option text").value;
this.consume(TokenType.COMMA, "Expected ',' in option");
const target = this.consume(TokenType.IDENTIFIER, "Expected target identifier").value;
this.consume(TokenType.RIGHT_PAREN, "Expected ')' after option");
options.push({
type: 'OptionChoice',
text,
target,
line: optionToken.line,
column: optionToken.column
});
} while (this.match(TokenType.COMMA));
}
this.consume(TokenType.RIGHT_BRACKET, "Expected ']' after options");
this.consume(TokenType.SEMICOLON, "Expected ';' after options");
}
private match(...types: TokenType[]): boolean {
for (const type of types) {
if (this.check(type)) {
this.advance();
return true;
}
}
return false;
}
private check(type: TokenType): boolean {
if (this.isAtEnd()) return false;
return this.peek().type === type;
}
private advance(): Token {
if (!this.isAtEnd()) this.current++;
return this.previous();
}
private isAtEnd(): boolean {
return this.peek().type === TokenType.EOF;
}
private peek(): Token {
return this.tokens[this.current];
}
private previous(): Token {
return this.tokens[this.current - 1];
}
private consume(type: TokenType, message: string): Token {
if (this.check(type)) return this.advance();
const current = this.peek();
throw new Error(`${message}. Got ${current.type} at ${current.line}:${current.column}`);
}
}