mirror of
https://github.com/robonen/questlang.git
synced 2026-03-20 10:54:45 +00:00
feat: init
This commit is contained in:
143
src/__tests__/integration.test.ts
Normal file
143
src/__tests__/integration.test.ts
Normal 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('Пойти в дешёвую шашлычную');
|
||||
});
|
||||
});
|
||||
176
src/__tests__/interpreter.test.ts
Normal file
176
src/__tests__/interpreter.test.ts
Normal 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
106
src/__tests__/lexer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
148
src/__tests__/parser.test.ts
Normal file
148
src/__tests__/parser.test.ts
Normal 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
152
src/ast.ts
Normal 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
327
src/cli.ts
Normal 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
50
src/index.ts
Normal 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
283
src/interpreter.ts
Normal 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
234
src/lexer.ts
Normal 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
263
src/parser.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user