mirror of
https://github.com/robonen/questlang.git
synced 2026-03-19 18:34:46 +00:00
feat: init
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
281
README.md
Normal file
281
README.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# QuestLang Interpreter
|
||||
|
||||
A modern TypeScript interpreter for the QuestLang programming language - a domain-specific language for creating interactive text-based quests and adventures.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **Modern TypeScript**: Built with latest TypeScript features and ES modules
|
||||
- 📦 **Modular Architecture**: Clean separation between lexer, parser, AST, and interpreter
|
||||
- 🧪 **Comprehensive Testing**: Full test coverage with Vitest
|
||||
- 🔍 **Validation & Analysis**: Built-in quest validation and structural analysis
|
||||
- 🎮 **Interactive CLI**: Command-line interface for playing and analyzing quests
|
||||
- 📊 **Graph-based**: Declarative graph representation of quest flow
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install questlang-interpreter
|
||||
```
|
||||
|
||||
Or for development:
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd questlang-interpreter
|
||||
npm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
QuestLang comes with two CLI variants:
|
||||
|
||||
#### Standard CLI
|
||||
```bash
|
||||
# Play a quest
|
||||
questlang play quest.ql
|
||||
|
||||
# Validate quest syntax and structure
|
||||
questlang validate quest.ql
|
||||
|
||||
# Analyze quest structure and show statistics
|
||||
questlang analyze quest.ql
|
||||
```
|
||||
|
||||
#### Enhanced Clack CLI (Beautiful Interactive Interface)
|
||||
```bash
|
||||
# Interactive mode with file picker and beautiful prompts
|
||||
questlang-clack
|
||||
|
||||
# Direct commands with enhanced visual output
|
||||
questlang-clack play quest.ql
|
||||
questlang-clack validate quest.ql
|
||||
questlang-clack analyze quest.ql
|
||||
```
|
||||
|
||||
The clack CLI features:
|
||||
- 🎨 Beautiful colored prompts with icons
|
||||
- 📊 Enhanced visual output
|
||||
- ⏳ Loading spinners
|
||||
- 🎯 Interactive file selection
|
||||
- 🔄 "Play again" functionality
|
||||
|
||||
See [CLI_GUIDE.md](./CLI_GUIDE.md) for detailed comparison.
|
||||
|
||||
### Programmatic API
|
||||
|
||||
```typescript
|
||||
import { QuestLang } from 'questlang-interpreter';
|
||||
|
||||
// Parse quest source code
|
||||
const ast = QuestLang.parse(sourceCode);
|
||||
|
||||
// Create interpreter
|
||||
const interpreter = QuestLang.interpret(sourceCode);
|
||||
|
||||
// Get quest information
|
||||
const questInfo = interpreter.getQuestInfo();
|
||||
console.log(`Playing: \${questInfo.name}`);
|
||||
|
||||
// Navigate through the quest
|
||||
const currentNode = interpreter.getCurrentNode();
|
||||
const choices = interpreter.getAvailableChoices();
|
||||
|
||||
// Execute player choice
|
||||
const result = interpreter.executeChoice(0);
|
||||
|
||||
// Validate quest
|
||||
const validation = QuestLang.validate(sourceCode);
|
||||
if (!validation.isValid) {
|
||||
console.error('Quest has errors:', validation.errors);
|
||||
}
|
||||
```
|
||||
|
||||
## QuestLang Syntax
|
||||
|
||||
QuestLang uses a declarative graph-based syntax for defining interactive quests:
|
||||
|
||||
```questlang
|
||||
квест MyQuest;
|
||||
цель "Find the treasure in the ancient castle";
|
||||
|
||||
граф {
|
||||
узлы {
|
||||
старт: {
|
||||
тип: начальный;
|
||||
описание: "You stand before an ancient castle";
|
||||
переходы: [entrance];
|
||||
}
|
||||
|
||||
entrance: {
|
||||
тип: действие;
|
||||
описание: "There are two doors. Which do you choose?";
|
||||
варианты: [
|
||||
("Enter the left door", left_room),
|
||||
("Enter the right door", right_room)
|
||||
];
|
||||
}
|
||||
|
||||
left_room: {
|
||||
тип: концовка;
|
||||
название: "Victory!";
|
||||
описание: "You found the treasure!";
|
||||
}
|
||||
|
||||
right_room: {
|
||||
тип: концовка;
|
||||
название: "Defeat";
|
||||
описание: "You fell into a trap";
|
||||
}
|
||||
}
|
||||
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
```
|
||||
|
||||
### Language Elements
|
||||
|
||||
- **квест** - Quest declaration with name
|
||||
- **цель** - Quest goal/description
|
||||
- **граф** - Graph definition containing all nodes
|
||||
- **узлы** - Node definitions section
|
||||
- **начало** - Starting node reference
|
||||
|
||||
### Node Types
|
||||
|
||||
- **начальный** - Initial node (entry point)
|
||||
- **действие** - Action node with player choices
|
||||
- **концовка** - Ending node (quest completion)
|
||||
|
||||
### Node Properties
|
||||
|
||||
- **тип** - Node type
|
||||
- **описание** - Node description shown to player
|
||||
- **варианты** - Available choices (action nodes only)
|
||||
- **переходы** - Direct transitions (initial nodes only)
|
||||
- **название** - Ending title (ending nodes only)
|
||||
|
||||
## Architecture
|
||||
|
||||
The interpreter follows best practices for language implementation:
|
||||
|
||||
### 1. Lexical Analysis (Lexer)
|
||||
- Tokenizes source code into meaningful tokens
|
||||
- Handles Russian keywords and identifiers
|
||||
- Provides detailed position information for error reporting
|
||||
|
||||
### 2. Syntax Analysis (Parser)
|
||||
- Builds Abstract Syntax Tree (AST) from tokens
|
||||
- Implements recursive descent parsing
|
||||
- Comprehensive error handling with meaningful messages
|
||||
|
||||
### 3. Semantic Analysis & Interpretation
|
||||
- Validates quest graph structure
|
||||
- Detects unreachable nodes and broken references
|
||||
- Runtime quest execution and state management
|
||||
|
||||
### 4. CLI & Tools
|
||||
- Interactive quest player
|
||||
- Validation and analysis tools
|
||||
- Development utilities
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run tests with coverage
|
||||
npm run coverage
|
||||
|
||||
# Type check
|
||||
npm run type-check
|
||||
|
||||
# Lint code
|
||||
npm run lint
|
||||
|
||||
# Format code
|
||||
npm run format
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Development mode
|
||||
npm run dev quest.ql
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The project uses Vitest for testing with comprehensive coverage:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test
|
||||
|
||||
# Generate coverage report
|
||||
npm run coverage
|
||||
```
|
||||
|
||||
Test categories:
|
||||
- **Unit Tests**: Lexer, Parser, Interpreter components
|
||||
- **Integration Tests**: Full quest parsing and execution
|
||||
- **Example Tests**: Real quest scenarios
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── types.ts # Token and type definitions
|
||||
├── lexer.ts # Lexical analyzer
|
||||
├── ast.ts # Abstract Syntax Tree definitions
|
||||
├── parser.ts # Syntax parser
|
||||
├── interpreter.ts # Quest interpreter and runtime
|
||||
├── index.ts # Main API exports
|
||||
├── cli.ts # Command-line interface
|
||||
└── __tests__/ # Test suites
|
||||
├── lexer.test.ts
|
||||
├── parser.test.ts
|
||||
├── interpreter.test.ts
|
||||
└── integration.test.ts
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: \`git checkout -b feature/amazing-feature\`
|
||||
3. Make your changes and add tests
|
||||
4. Run the test suite: \`npm test\`
|
||||
5. Commit your changes: \`git commit -m 'Add amazing feature'\`
|
||||
6. Push to the branch: \`git push origin feature/amazing-feature\`
|
||||
7. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
|
||||
## Language Design Goals
|
||||
|
||||
QuestLang was designed with the following principles:
|
||||
|
||||
- **Declarative**: Describe what the quest is, not how to execute it
|
||||
- **Graph-based**: Natural representation for branching narratives
|
||||
- **Readable**: Clear syntax that non-programmers can understand
|
||||
- **Validatable**: Built-in validation to catch structural issues
|
||||
- **Extensible**: Architecture allows for easy feature additions
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
- [ ] Visual quest graph editor
|
||||
- [ ] Quest debugging tools
|
||||
- [ ] Export to other formats (Ink, Twine)
|
||||
- [ ] Advanced scripting features
|
||||
- [ ] Multiplayer quest support
|
||||
- [ ] Web-based quest player
|
||||
19
eslint.config.mjs
Normal file
19
eslint.config.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import antfu from '@antfu/eslint-config';
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
stylistic: {
|
||||
indent: 2,
|
||||
semi: true,
|
||||
quotes: 'single',
|
||||
overrides: {
|
||||
'style/comma-dangle': ['error', 'always-multiline'],
|
||||
},
|
||||
},
|
||||
typescript: true,
|
||||
rules: {
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'unused-imports/no-unused-vars': 'error',
|
||||
}
|
||||
},
|
||||
);
|
||||
110
examples/demo.ql
Normal file
110
examples/demo.ql
Normal file
@@ -0,0 +1,110 @@
|
||||
квест ПримерКвеста;
|
||||
цель "Демонстрация возможностей QuestLang интерпретатора";
|
||||
|
||||
граф {
|
||||
узлы {
|
||||
старт: {
|
||||
тип: начальный;
|
||||
описание: "Добро пожаловать в пример квеста на QuestLang!";
|
||||
переходы: [главное_меню];
|
||||
}
|
||||
|
||||
главное_меню: {
|
||||
тип: действие;
|
||||
описание: "Вы находитесь в главном меню. Что хотите сделать?";
|
||||
варианты: [
|
||||
("Начать приключение", начало_приключения),
|
||||
("Прочитать правила", правила),
|
||||
("Выйти из игры", выход)
|
||||
];
|
||||
}
|
||||
|
||||
правила: {
|
||||
тип: действие;
|
||||
описание: "Правила просты: читайте текст и выбирайте действия. Ваши решения влияют на исход истории.";
|
||||
варианты: [
|
||||
("Вернуться в меню", главное_меню),
|
||||
("Начать играть", начало_приключения)
|
||||
];
|
||||
}
|
||||
|
||||
начало_приключения: {
|
||||
тип: действие;
|
||||
описание: "Вы стоите на развилке дорог. Слева ведет тропинка в тёмный лес, справа - дорога к светлому замку.";
|
||||
варианты: [
|
||||
("Пойти в тёмный лес", тёмный_лес),
|
||||
("Пойти к светлому замку", светлый_замок),
|
||||
("Остаться на развилке", размышления)
|
||||
];
|
||||
}
|
||||
|
||||
тёмный_лес: {
|
||||
тип: действие;
|
||||
описание: "В лесу темно и страшно. Вы слышите странные звуки. Внезапно перед вами появляется волк!";
|
||||
варианты: [
|
||||
("Попытаться подружиться с волком", друг_волка),
|
||||
("Убежать обратно", начало_приключения),
|
||||
("Встать в оборонительную позу", волчья_схватка)
|
||||
];
|
||||
}
|
||||
|
||||
светлый_замок: {
|
||||
тип: действие;
|
||||
описание: "Замок выглядит дружелюбно. У ворот вас встречает стражник в блестящих доспехах.";
|
||||
варианты: [
|
||||
("Поговорить со стражником", разговор_со_стражником),
|
||||
("Попытаться пройти мимо", незаметное_проникновение),
|
||||
("Вернуться назад", начало_приключения)
|
||||
];
|
||||
}
|
||||
|
||||
размышления: {
|
||||
тип: действие;
|
||||
описание: "Вы размышляете о выборе. Время идёт, и скоро стемнеет.";
|
||||
варианты: [
|
||||
("Всё же пойти в лес", тёмный_лес),
|
||||
("Пойти к замку", светлый_замок),
|
||||
("Вернуться домой", возвращение_домой)
|
||||
];
|
||||
}
|
||||
|
||||
друг_волка: {
|
||||
тип: концовка;
|
||||
название: "Необычная дружба";
|
||||
описание: "Волк оказался дружелюбным! Теперь у вас есть верный спутник, и вы вместе отправляетесь в новые приключения.";
|
||||
}
|
||||
|
||||
волчья_схватка: {
|
||||
тип: концовка;
|
||||
название: "Героическая победа";
|
||||
описание: "Вы смело сражались с волком и победили! Ваша храбрость будет воспета в легендах.";
|
||||
}
|
||||
|
||||
разговор_со_стражником: {
|
||||
тип: концовка;
|
||||
название: "Мудрый совет";
|
||||
описание: "Стражник рассказал вам древнюю мудрость, которая изменила вашу жизнь к лучшему.";
|
||||
}
|
||||
|
||||
незаметное_проникновение: {
|
||||
тип: концовка;
|
||||
название: "Тайный проход";
|
||||
описание: "Вы обнаружили секретный проход в замок и нашли там древние сокровища!";
|
||||
}
|
||||
|
||||
возвращение_домой: {
|
||||
тип: концовка;
|
||||
название: "Домашний уют";
|
||||
описание: "Иногда лучшее приключение - это возвращение домой к тёплому очагу.";
|
||||
}
|
||||
|
||||
выход: {
|
||||
тип: концовка;
|
||||
название: "До свидания!";
|
||||
описание: "Спасибо за игру! Возвращайтесь ещё.";
|
||||
}
|
||||
}
|
||||
|
||||
начало: старт;
|
||||
}
|
||||
конец;
|
||||
145
examples/kvest_Shashlyk.ql
Normal file
145
examples/kvest_Shashlyk.ql
Normal file
@@ -0,0 +1,145 @@
|
||||
квест Шашлык;
|
||||
цель "Сегодня день труда и отдыха и надо купить шашлык. На пути нас встречают разнообразные трудности.";
|
||||
|
||||
граф {
|
||||
// Узлы графа
|
||||
узлы {
|
||||
старт: {
|
||||
тип: начальный;
|
||||
описание: "Начало приключения";
|
||||
переходы: [выбор_пути];
|
||||
}
|
||||
|
||||
выбор_пути: {
|
||||
тип: действие;
|
||||
описание: "Ближайшая дешёвая шашлычная находится в 100 метрах от вас. Хорошая - в 1 км. Куда идти?";
|
||||
варианты: [
|
||||
("Пойти в дешёвую шашлычную", дешевая_очередь),
|
||||
("Пойти в хорошую шашлычную", дорога_к_хорошей),
|
||||
("Пойти домой", поход_в_магазин)
|
||||
];
|
||||
}
|
||||
|
||||
дешевая_очередь: {
|
||||
тип: действие;
|
||||
описание: "У шашлычной ты увидел очередь к ней. Как же её обойти?";
|
||||
варианты: [
|
||||
("Пойти напролом", бабка_остановила),
|
||||
("Попасть в шашлычную хитро", фальшивый_бейдж),
|
||||
("Подождать очередь", шашлыки_кончились)
|
||||
];
|
||||
}
|
||||
|
||||
бабка_остановила: {
|
||||
тип: действие;
|
||||
описание: "И вот вы разрушили всю очередь, но бабка с демонстрации остановила вас. Вы слушаете рассказ про то как было раньше и возвращаетесь в конец очереди.";
|
||||
варианты: [
|
||||
("Снова попробовать пройти напролом", бабка_остановила),
|
||||
("Попасть в шашлычную хитро", фальшивый_бейдж),
|
||||
("Подождать очередь", шашлыки_кончились)
|
||||
];
|
||||
}
|
||||
|
||||
шашлыки_кончились: {
|
||||
тип: действие;
|
||||
описание: "Простояв час в очереди, в шашлычной закончились шашлыки. Но ты всё ещё можешь поесть шашлыки в хорошей шашлычной или дома.";
|
||||
варианты: [
|
||||
("Пойти в хорошую шашлычную", дорога_к_хорошей),
|
||||
("Пойти домой", поход_в_магазин)
|
||||
];
|
||||
}
|
||||
|
||||
дорога_к_хорошей: {
|
||||
тип: действие;
|
||||
описание: "По пути к шашлычной, ты увидел толпу злых школьников. Что же нужно сделать?";
|
||||
варианты: [
|
||||
("Пойти напролом", школьники_безобидные),
|
||||
("Обойти толпу", попал_в_демонстрацию),
|
||||
("Подождать, пока они уйдут", школьники_пьют)
|
||||
];
|
||||
}
|
||||
|
||||
поход_в_магазин: {
|
||||
тип: действие;
|
||||
описание: "По пути домой, ты вспоминаешь, что у тебя не хватает нескольких продуктов дома и тебе нужно зайти в магазин. Когда ты находишь всё и идёшь на кассу, оказывается у тебя не так уж и много денег. Что же делать?";
|
||||
варианты: [
|
||||
("Своровать продукты", поймали_вора),
|
||||
("Купить половину продуктов", овощное_рагу),
|
||||
("Пойти в ближайшую шашлычную", дешевая_очередь)
|
||||
];
|
||||
}
|
||||
|
||||
фальшивый_бейдж: {
|
||||
тип: действие;
|
||||
описание: "За несколько минут вы покупаете в соседнем магазине ручку и бейджик и пишете на нём, что вы являетесь сотрудником этой шашлычной. Вы незаметно проникаете в шашлычную и видите, какая тут есть продукция. Она вас не очень впечатляет, но вы очень хотите есть. И что же теперь делать.";
|
||||
варианты: [
|
||||
("Купить шашлык", плохой_шашлык),
|
||||
("Пойти в хорошую шашлычную", дорога_к_хорошей),
|
||||
("Поесть шашлык дома", поход_в_магазин)
|
||||
];
|
||||
}
|
||||
|
||||
школьники_безобидные: {
|
||||
тип: действие;
|
||||
описание: "Вы начинаете проходить рядом, и… ничего не происходит. Это обыкновенные школьники. Они просто пьют пиво, и больше ничего. И чего мне бояться школьников в свои 35 лет. По дороге в шашлючную вы понимаете, что денег не так много и нужно что-то сделать.";
|
||||
варианты: [
|
||||
("Заработать деньги", заработал_деньги),
|
||||
("Договориться с продавщицей", торг_провалился),
|
||||
("Пойти в дешёвую шашлычную", дешевая_очередь)
|
||||
];
|
||||
}
|
||||
|
||||
школьники_пьют: {
|
||||
тип: действие;
|
||||
описание: "Ребят становилось всё больше и больше, они пили всё больше и больше. Так продолжалось несколько часов, а когда приехали полицейские, было уже поздно. Шашлычная закрылась.";
|
||||
варианты: [
|
||||
("Пойти домой и приготовить шашлык", поход_в_магазин)
|
||||
];
|
||||
}
|
||||
|
||||
торг_провалился: {
|
||||
тип: действие;
|
||||
описание: "Распахнулась дверь, вы вошли в шашлючную. С дерзким голосом вы стали торговаться с продавщицей. Всё закончилось тем, что шашлык вам не дали.";
|
||||
варианты: [
|
||||
("Заработать деньги", заработал_деньги),
|
||||
("Пойти в дешёвую шашлычную", дешевая_очередь)
|
||||
];
|
||||
}
|
||||
|
||||
// Концовки
|
||||
попал_в_демонстрацию: {
|
||||
тип: концовка;
|
||||
название: "Нужно быть смелее ради шашлыка";
|
||||
описание: "Вы начинаете обходить толпу злых школьников, но вдруг вы попадаете в многотысячную толпу демонстрантов, которая уносит вас очень далеко от вашего шашлыка. Вы не можете сопротивляться толпе и находитесь в ней несколько часов. Когда вы добираетесь до шашлычной, она уже закрыта, и все шашлыки распродали.";
|
||||
}
|
||||
|
||||
поймали_вора: {
|
||||
тип: концовка;
|
||||
название: "Воровать-плохо!";
|
||||
описание: "Вы запихиваете продукты куда только можно, во все карманы и щели. И чтобы не привлекать внимания вы покупаете одну помидорку. Вы уже собираетесь уходить, как вдруг у вас из кармана вываливается лук. Через секунду вы уже лежите на полу на всех своих украденных продуктах. Точнее то, что от них осталось. Теперь вы точно будете есть хорошо 15 суток, с хорошей компанией. Правда с грязной одеждой.";
|
||||
}
|
||||
|
||||
овощное_рагу: {
|
||||
тип: концовка;
|
||||
название: "Ни рыба, ни мясо";
|
||||
описание: "Купив только овощи, вы идёте домой, достаёте из холодильника последние яйца и делаете рагу. Довольно вкусно, но мясо то хочется.";
|
||||
}
|
||||
|
||||
плохой_шашлык: {
|
||||
тип: концовка;
|
||||
название: "Я за шашлык собаку съем";
|
||||
описание: "Ух, какой вкусный шашлык. Просто объедение. Это самая лучшая шашлычная, в которой я ел. Если не считать того, что я испытал незабываемую ночь в туалете… Зато это опыт… Наверное…";
|
||||
}
|
||||
|
||||
заработал_деньги: {
|
||||
тип: концовка;
|
||||
название: "Да здравствует шашлык";
|
||||
описание: "Чтобы заработать, вы пособирали мусор (шары, ленты, кепки) возле площади и стали его продавать. Через час вы набрали довольно хорошую сумму и смогли купить шашлык. Желудок доволен, и вы сыты.";
|
||||
}
|
||||
}
|
||||
|
||||
// Начальный узел
|
||||
начало: старт;
|
||||
}
|
||||
|
||||
конец;
|
||||
20
examples/minimal.ql
Normal file
20
examples/minimal.ql
Normal file
@@ -0,0 +1,20 @@
|
||||
квест ТестКвеста;
|
||||
цель "Простой тест";
|
||||
|
||||
граф {
|
||||
узлы {
|
||||
старт: {
|
||||
тип: начальный;
|
||||
описание: "Начало теста";
|
||||
переходы: [конец];
|
||||
}
|
||||
|
||||
конец: {
|
||||
тип: концовка;
|
||||
описание: "Конец теста";
|
||||
название: "Успех";
|
||||
}
|
||||
}
|
||||
|
||||
начало: старт;
|
||||
}
|
||||
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "questlang-interpreter",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"description": "TypeScript interpreter for QuestLang programming language",
|
||||
"keywords": [
|
||||
"questlang",
|
||||
"interpreter",
|
||||
"typescript",
|
||||
"language"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"questlang": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "vitest",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest --coverage",
|
||||
"lint": "eslint ./src",
|
||||
"lint:fix": "eslint ./src --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^5.2.1",
|
||||
"@robonen/tsconfig": "^0.0.2",
|
||||
"tsdown": "^0.14.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.11.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
4092
pnpm-lock.yaml
generated
Normal file
4092
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
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}`);
|
||||
}
|
||||
}
|
||||
5
tsconfig.json
Normal file
5
tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"include": ["src/**/*", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
12
tsdown.config.ts
Normal file
12
tsdown.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: 'src/index.ts',
|
||||
dts: true,
|
||||
},
|
||||
{
|
||||
entry: 'src/cli.ts',
|
||||
noExternal: ['@clack/prompts'],
|
||||
},
|
||||
]);
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
exclude: ['src/cli.ts', 'dist/', 'node_modules/']
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user