Vendor libs/hump, sfxrlua, json4lua, narrator as real files

These were committed as empty submodule gitlinks with no .gitmodules,
so the folders were never populated and the game could not load
(require of libs/hump/timer, libs/sfxrlua/sfxr, narrator.narrator).
Checked out each at its pinned commit and vendored the files, matching
the existing bundled-libs convention. Removed the junk libs/libs/ tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-08-11 11:37:09 -04:00
parent e73f63b5d4
commit 03532d29e4
219 changed files with 20254 additions and 252 deletions

@ -1 +0,0 @@
Subproject commit 45a8b03553f2385716c24ec762757e418b2b73c1

7
libs/narrator/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# System
.DS_Store
Thumbs.db
# Defold
/.internal
/build

24
libs/narrator/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,24 @@
{
"configurations": [
{
"name": "Debug",
"type": "lua-local",
"request": "launch",
"program": {
"lua": "lua",
"file": "${workspaceFolder}/debug.lua"
}
},
{
"name": "Busted",
"type": "lua-local",
"request": "launch",
"program": {
"command": "busted"
},
"args": [
"${workspaceFolder}/test/run.lua",
],
}
]
}

81
libs/narrator/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,81 @@
{
"Lua.diagnostics.globals": [
"unpack",
"loadstring",
"describe",
"it",
"msg",
"sound",
"hash",
"vmath",
"gui",
"socket",
"sys",
"render",
"go",
"factory",
"resource",
"pprint",
"timer",
"particlefx",
"spine",
"sprite",
"json",
"window",
"physics"
],
"Lua.diagnostics.disable": [
"trailing-space",
"redefined-local",
"deprecated",
"lowercase-global"
],
"Lua.completion.callSnippet": "Replace",
"Lua.completion.keywordSnippet": "Replace",
"Lua.completion.showWord": "Fallback",
"Lua.completion.autoRequire": false,
"[lua]": {
"editor.defaultFormatter": "sumneko.lua"
},
"glsllint.additionalStageAssociations": {
".fp": "frag",
".vp": "vert"
},
"files.associations": {
"*.project": "ini",
"*.script": "lua",
"*.gui_script": "lua",
"*.render_script": "lua",
"*.editor_script": "lua",
"*.fp": "glsl",
"*.vp": "glsl",
"*.go": "textproto",
"*.animationset": "textproto",
"*.atlas": "textproto",
"*.buffer": "json",
"*.camera": "textproto",
"*.collection": "textproto",
"*.collectionfactory": "textproto",
"*.collectionproxy": "textproto",
"*.collisionobject": "textproto",
"*.display_profiles": "textproto",
"*.factory": "textproto",
"*.gamepads": "textproto",
"*.gui": "textproto",
"*.input_binding": "textproto",
"*.label": "textproto",
"*.material": "textproto",
"*.mesh": "textproto",
"*.model": "textproto",
"*.particlefx": "textproto",
"*.render": "textproto",
"*.sound": "textproto",
"*.spinemodel": "textproto",
"*.spinescene": "textproto",
"*.sprite": "textproto",
"*.texture_profiles": "textproto",
"*.tilemap": "textproto",
"*.tilesource": "textproto",
"*.manifest": "textproto"
}
}

18
libs/narrator/.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Busted",
"command": "busted",
"args": [
"${workspaceFolder}/test/run.lua"
],
"type": "shell",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

21
libs/narrator/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Roman Silin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

469
libs/narrator/README.md Normal file
View file

@ -0,0 +1,469 @@
![logo](https://user-images.githubusercontent.com/4752473/85455900-141f8f80-b5a7-11ea-8cd7-b441d662b361.png)
# Narrator
[![Release](https://img.shields.io/github/v/release/astrochili/narrator.svg?include_prereleases=&sort=semver&color=blue)](https://github.com/astrochili/narrator/releases)
[![License](https://img.shields.io/badge/License-MIT-blue)](https://github.com/astrochili/narrator/blob/master/LICENSE)
[![Website](https://img.shields.io/badge/website-gray.svg?&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxOCIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDE4IDE2Ij48Y2lyY2xlIGN4PSIzLjY2IiBjeT0iMTQuNzUiIHI9IjEuMjUiIGZpbGw9InVybCgjYSkiLz48Y2lyY2xlIGN4PSI4LjY2IiBjeT0iMTQuNzUiIHI9IjEuMjUiIGZpbGw9InVybCgjYikiLz48Y2lyY2xlIGN4PSIxMy42NSIgY3k9IjE0Ljc1IiByPSIxLjI1IiBmaWxsPSJ1cmwoI2MpIi8+PHBhdGggZmlsbD0idXJsKCNkKSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNNy42MyAxLjQ4Yy41LS43IDEuNTUtLjcgMi4wNSAwbDYuMjIgOC44MWMuNTguODMtLjAxIDEuOTctMS4wMyAxLjk3SDIuNDRhMS4yNSAxLjI1IDAgMCAxLTEuMDItMS45N2w2LjIxLTguODFaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyLjQxIiB4Mj0iMi40MSIgeTE9IjEzLjUiIHkyPSIxNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRDhENDIiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGOTU0MUYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjcuNDEiIHgyPSI3LjQxIiB5MT0iMTMuNSIgeTI9IjE2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHN0b3Agc3RvcC1jb2xvcj0iI0ZEOEQ0MiIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iI0Y5NTQxRiIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjIiB4MT0iMTIuNCIgeDI9IjEyLjQiIHkxPSIxMy41IiB5Mj0iMTYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjRkQ4RDQyIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRjk1NDFGIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImQiIHgxPSIuMDMiIHgyPSIuMDMiIHkxPSIuMDMiIHkyPSIxMi4yNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNGRkU2NUUiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGRkM4MzAiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=)](https://astronachos.com/)
[![Mastodon](https://img.shields.io/badge/mastodon-gray?&logo=mastodon)](https://mastodon.gamedev.place/@astronachos)
[![Twitter](https://img.shields.io/badge/twitter-gray?&logo=twitter)](https://twitter.com/astronachos)
[![Telegram](https://img.shields.io/badge/telegram-gray?&logo=telegram)](https://t.me/astronachos)
[![Buy me a coffee](https://img.shields.io/badge/buy_me_a_coffee-gray?&logo=buy%20me%20a%20coffee)](https://buymeacoffee.com/astrochili)
## Overview
The [Ink](https://www.inklestudios.com/ink/) language parser and runtime implementation in Lua.
Ink is a powerful narrative scripting language. You can find more information about how to write Ink scripts [here](https://github.com/inkle/ink/blob/master/Documentation/WritingWithInk.md). There is also [Inky](https://github.com/inkle/inky) editor with useful features to test and debug Ink scripts.
Narrator allows to convert raw Ink scripts to the book (a lua table) and play it as story.
- 📖 A book is a passive model on the shelf like a game level.
- ✨ A story is a runtime state of the book reading like a game process.
## Quick example
```lua
local narrator = require('narrator.narrator')
-- Parse a book from the Ink file.
local book = narrator.parse_file('stories.game')
-- Init a story from the book
local story = narrator.init_story(book)
-- Begin the story
story:begin()
while story:can_continue() do
-- Get current paragraphs to output
local paragraphs = story:continue()
for _, paragraph in ipairs(paragraphs) do
local text = paragraph.text
-- You can handle tags as you like, but we attach them to text here.
if paragraph.tags then
text = text .. ' #' .. table.concat(paragraph.tags, ' #')
end
-- Output text to the player
print(text)
end
-- If there is no choice it seems like the game is over
if not story:can_choose() then break end
-- Get available choices and output them to the player
local choices = story:get_choices()
for i, choice in ipairs(choices) do
print(i .. ') ' .. choice.text)
end
-- Read the choice from the player input
local answer = tonumber(io.read())
-- Send answer to the story to generate new paragraphs
story:choose(answer)
end
```
## Alternatives
- [defold-ink](https://github.com/abadonna/defold-ink) — The Ink language runtime implementation in Lua based on parsing compiled JSON files.
## Showcase
- [Cat's Day](https://astronachos.com/catsday/) — A short card game about one furry.
- [Rare Pets](https://jetpackcollective.games/rarepets/) — A merge game for mobile about pets that become what they eat.
- [Sensual Hunting](https://store.steampowered.com/app/1967470/Sensual_Haunting/) (NSFW) — An adult only game where all the navigation and dialogs made with this library.
- [The Secret Laboratory](https://astrochili.itch.io/the-secret-laboratory) — A short card game about the labaratory director.
## Features
### Supported
- [x] Comments: singleline, multiline, todo's
- [x] Tags: global tags, knot tags, stitch tags, paragraph tags
- [x] Paths and sections: inclusions, knots, stitches, labels
- [x] Choices: suppressing and mixing, labels, conditions, sticky and fallback choices, tags
- [x] Branching: diversions, glues, gathers, nesting
- [x] Tunnels
- [x] Alternatives: sequences, cycles, once-only, shuffles, empty steps, nesting
- [x] Multiline alternatives: all the same + shuffle options
- [x] Conditions: logical operations, string queries, if and else statements, nesting
- [x] Multiline conditions: all the same + elseif statements, switches, nesting
- [x] Variables: assignments, constants, global variables, temporary variables, visits, lists
- [x] Lists: logical operations, multivalued lists, multi-list lists, all the queries, work with numbers
- [x] Game queries: all the queries without `TURNS()` and `TURNS_SINCE()`
- [x] State: saving and loading
- [x] Integration: external functions, variables observing, jumping
- [x] Migration: the ability to implement the migration of player's saves after the book update
- [x] Internal functions
### Unsupported
- [ ] [Threads](https://github.com/astrochili/narrator/issues/22)
- [ ] [Divert target as variable type](https://github.com/astrochili/narrator/issues/23)
- [ ] [Assigning string evaluations to variables](https://github.com/astrochili/narrator/issues/24)
- [ ] [Multiple parallel flows](https://github.com/astrochili/narrator/issues/25)
Also there is a list of [known limitations](https://github.com/astrochili/narrator/labels/known%20limitation) on the issues page.
## Installation
### Common case (Löve, pure Lua, etc.)
Download the latest [release archive](https://github.com/astrochili/narrator/releases) and require the `narrator` module.
```lua
local narrator = require('narrator.narrator')
```
Narrator requires [lpeg](http://www.inf.puc-rio.br/~roberto/lpeg/) as dependency to parse Ink content. You can install it with [luarocks](https://luarocks.org/).
```shell
$ luarocks install lpeg
```
In fact, you don't need `lpeg` in the release, but you need it locally to parse Ink content and generate lua versions of books to play in your game. Use parsing in development only, prefer already parsed and stored books in production.
### Defold
Add links to the zip-archives of the latest versions of [narrator](https://github.com/astrochili/narrator/releases) and [defold-lpeg](https://github.com/astrochili/defold-lpeg/releases) to your Defold project as [dependencies](http://www.defold.com/manuals/libraries/).
```
https://github.com/astrochili/narrator/archive/master.zip
https://github.com/astrochili/defold-lpeg/archive/master.zip
```
Then you can require the `narrator` module.
```lua
local narrator = require('narrator.narrator')
```
## Documentation
### narrator.parse_file(path, params)
Parses the Ink file at path with all the inclusions and returns a book instance. Path notations `'stories/game.ink'`, `'stories/game'` and `'stories.game'` are valid.
You can save a parsed book to the lua file with the same path by passing `{ save = true }` as `params` table. By default, the `params` table is `{ save = false }`.
```lua
-- Parse a Ink file at path 'stories/game.ink'
local book = narrator.parse_file('stories.game')
-- Parse a Ink file at path 'stories/game.ink'
-- and save the book at path 'stories/game.lua'
local book = narrator.parse_file('stories.game', { save = true })
```
Reading and saving files required `io` so if you can't work with files by this way use `narrator.parse_content()`.
### narrator.parse_content(content, inclusions)
Parses the string with Ink content and returns a book instance. The `inclusions` param is optional and can be used to pass an array of strings with Ink content of inclusions.
```lua
local content = 'Content of a root Ink file'
local inclusions = {
'Content of an included Ink file',
'Content of another included Ink file'
}
-- Parse a string with Ink content
local book = narrator.parse_content(content)
-- Parse a string with Ink content and inclusions
local book = narrator.parse_content(content, inclusions)
```
Content parsing is useful when you should manage files by your engine environment and don't want to use `io` module. For example, in Defold, you may want to load ink files as custom resources with [sys.load_resource()](https://defold.com/ref/sys/#sys.load_resource:filename).
### narrator.init_story(book)
Inits a story instance from the book. This is aclual to use in production. For example, just load a book with `require()` and pass it to this function.
```lua
-- Require a parsed and saved before book
local book = require('stories.game')
-- Init a story instance
local story = narrator.init_story(book)
```
### story:begin()
Begins the story. Generates the first chunk of paragraphs and choices.
### story:can_continue()
Returns a boolean, does the story have paragraphs to output or not.
```lua
while story:can_continue() do
-- Get paragraphs?
end
```
### story:continue(steps)
Get the next paragraphs. You can specify the number of paragraphs that you want to pull by the `steps` param.
- Pass nothing if you want to get all the currently available paragraphs. `0` also works.
- Pass `1` if you want to get one next paragraph without wrapping to array.
A paragraph is a table like `{ text = 'Hello.', tags = { 'tag1', 'tag2' } }`. Most of the paragraphs do not have tags so `tags` can be `nil`.
```lua
-- Get all the currently available paragraphs
local paragraphs = story:continue()
-- Get one next paragraph
local paragraph = story:continue(1)
```
### story:can_choose()
Returns a boolean, does the story have choices to output or not. Also returns `false` if there are available paragraphs to continue.
```lua
if story:can_choose() do
-- Get choices?
end
```
### story:get_choices()
Returns an array of available choices. Returns an empty array if there are available paragraphs to continue.
A choice is a table like `{ text = 'Bye.', tags = { 'tag1', 'tag2' } }`. Most of the choices do not have tags so `tags` can be `nil`.
Choice tags are not an official feature of Ink, but it's a Narrator feature. These tags also will appear in the answer paragraph as it works in Ink by default. But if you have a completely eaten choice like `'[Answer] #tag'` you will receive tags only in the choice.
```lua
-- Get available choices and output them to the player
local choices = story:get_choices()
for i, choice in ipairs(choices) do
print(i .. ') ' .. choice.text)
end
```
### story:choose(index)
Make a choice to continue the story. Pass the `index` of the choice that you was received with `get_choices()` before. Will do nothing if `can_continue()` returns `false`.
```lua
-- Get the answer from the player in the terminal
answer = tonumber(io.read())
-- Send the answer to the story to generate new paragraphs
story:choose(answer)
-- Get the new paragraphs
local new_paragraphs = story:continue()
```
### story:jump_to(path_string)
Jumps to the path. The `path_string` param is a string like `'knot.stitch.label'`.
```lua
-- Jump to the maze stitch in the adventure knot
story:jump_to('adventure.maze')
-- Get the maze paragraphs
local maze_paragraphs = story:continue()
```
### story:get_visits(path_string)
Returns the number of visits to the path. The `path_string` param is a string like `'knot.stitch.label'`.
```lua
-- Get the number of visits to the maze's red room
local red_room_visits = story:get_visits('adventure.maze.red_room')
-- Get the number of adventures visited.
local adventure_visits = story:get_visits('adventure')
```
### story:get_tags(path_string)
Returns tags for the path. The `path_string` param is a string like `'knot.stitch'`. This function is useful when you want to get tags before continue the story and pull paragraphs. Read more about it [here](https://github.com/inkle/ink/blob/master/Documentation/RunningYourInk.md#knot-tags).
```lua
-- Get tags for the path 'adventure.maze'
local mazeTags = story:get_tags('adventure.maze')
```
### story:save_state()
Raturns a table with the story state that can be saved and restored later. Use it to save the game.
```lua
-- Get the story's state
local state = story:save_state()
-- Save the state to your local storage
manager.save(state)
```
### story:load_state(state)
Restores a story's state from the saved before state. Use it to load the game.
```lua
-- Load the state from your local storage
local state = manager.load()
-- Restore the story's state
story:load_state(state)
```
### story:observe(variable, observer)
Assigns an observer function to the variable's changes.
```lua
local function x_did_change(x)
print('The x did change! Now it\'s ' .. x)
end
-- Start observing the variable 'x'
story:observe('x', x_did_change)
```
### story:bind(func_name, handler)
Binds a function to external calling from the Ink. The function can returns the value or not.
```lua
local function beep()
print('Beep! 😃')
end
local function sum(x, y)
return x + y
end
-- Bind the function without params and returned value
story:bind('beep', beep)
-- Bind the function with params and returned value
story:bind('sum', sum)
```
### story.global_tags
An array with book's global tags. Tags are strings of course.
```lua
-- Get the global tags
local global_tags = story.global_tags
-- A hacky way to get the same global tags
local global_tags = story:get_tags()
```
### story.constants
A table with book's constants. Just read them, constants changing is not a good idea.
```lua
-- Get the theme value from the Ink constants
local theme = story.constants['theme']
```
### story.variables
A table with story's variables. You can read or change them by this way.
```lua
-- Get the mood variable value
local mood = story.variables['mood']
-- Set the mood variable value
story.variables['mood'] = 'sunny'
```
### story.migrate
A function that you can specify for migration from old to new versions of your books. This is useful, for example, when you don't want to corrupt player's save after the game update.
This is the place where you can rename or change variables, visits, update the current path, etc. The default implementation returns the same state without any migration.
```lua
-- Default implementation
function(state, old_version, new_version) return state end
```
The `old_version` is the version of the saved state, the `new_version` is the version of the book. You can specify the verson of the book with the constant `'version'` in the Ink content, otherwise it's equal to `0`.
```lua
-- A migration function example
local function migrate(state, old_version, new_version)
-- Check the need for migration
if new_version == old_version then
return state
end
-- Migration for the second version of the book
if new_version == 2 then
-- Get the old value
local old_mood = state.variables['mood']
-- If it exists then migrate ...
if old_mood then
-- ... migrate the old number value to the new string value
state.variables['mood'] = old_mood < 50 and 'sadly' or 'sunny'
end
end
return state
end
-- Assign the migration function before loading a saved game
story.migrate = migrate
-- Load the game
story:load_state(saved_state)
```
## Contribution
### Development
There are some useful extensions and configs for [VSCode](https://code.visualstudio.com/) that I use in development of Narrator.
- [Local Lua Debugger](https://github.com/tomblind/local-lua-debugger-vscode) by [tomblind](https://github.com/tomblind/).
- [Lua Language Server](https://github.com/sumneko/lua-language-server) by [sunmeko](https://github.com/sumneko).
- A task named `Busted` runs tests with `tests/run.lua`.
- A lunch configuration named `Busted` runs the debugger with `tests/run.lua`.
- A lunch configuration named `Debug` runs the debugger with `debug.lua`.
### Testing
To run tests you need to install [busted](https://github.com/Olivine-Labs/busted).
```shell
$ luarocks install busted
```
Don't forget also to install `lpeg` as described in [Common case](#common-case-löve-pure-lua-etc) installation section.
After that you can run tests from the terminal:
```shell
$ busted test/run.lua
```
## Third Party Libraries
- [LPeg](http://www.inf.puc-rio.br/~roberto/lpeg/) by [Roberto Ierusalimschy](http://www.inf.puc-rio.br/~roberto/) (MIT Licence).
- [classic](https://github.com/rxi/classic) by [rxi](https://github.com/rxi) (MIT Licence).
- [lume](https://github.com/rxi/lume) by [rxi](https://github.com/rxi) (MIT Licence).

63
libs/narrator/bot.lua Normal file
View file

@ -0,0 +1,63 @@
--
-- Bot for story playing
local bot = { }
--- Play a story by bot
-- @param story Story: a story instance
-- @param instructor function: function that will be return the answer index
-- @param params.print boolean: print a game log to console or not, false by default
-- @return string: a log of the game
function bot.play(story, instructor, params)
local params = params or { print = false }
local log = { }
local step = 1
local function output(text)
if params.print then print(text) end
table.insert(log, text)
end
story:begin()
while story:can_continue() or story:can_choose() do
local paragraphs = story:continue()
for _, paragraph in ipairs(paragraphs or { }) do
local text = paragraph.text or ''
if paragraph.tags then
local hashtag = #text > 0 and ' #' or '#'
text = text .. hashtag .. table.concat(paragraph.tags, ' #')
end
output(text)
end
if not story:can_choose() then break end
local choices = story:get_choices()
local answer = instructor(choices, step)
step = step + 1
-- Check for a signal to emergency exit
if answer == -1 then
return nil
end
output('')
for i, choice in ipairs(choices) do
local prefix = (i == answer and '>' or i) .. ') '
local text = prefix .. choice.text
if choice.tags then
text = text .. ' #' .. table.concat(choice.tags, ' #')
end
output(text)
end
output('')
story:choose(answer)
end
return table.concat(log, '\n')
end
return bot

30
libs/narrator/debug.lua Normal file
View file

@ -0,0 +1,30 @@
--
-- Script for debugging in VSCode with
-- Local Lua Debugger by Tom Blind (https://github.com/tomblind/local-lua-debugger-vscode)
-- Dependencies
local narrator = require('narrator.narrator')
local bot = require('bot')
-- Loading
-- local book = require('stories.debug')
-- local book = narrator.parseBook('Hello world!', { '=== one === \n text 1', '=== two === \n text 2' })
local book = narrator.parse_file('stories.debug', { save = false })
local story = narrator.init_story(book)
local answers = { }
-- Choice instructor for a bot
local function instructor(choices, step)
local answer = answers[step]
if answer == nil then
math.randomseed(os.time() * 10000000)
answer = math.random(1, #choices)
end
return answer
end
-- Game
print('--- Game started ---\n')
bot.play(story, instructor, { print = true })
print('\n--- Game over ---')

View file

@ -0,0 +1,19 @@
- I looked at Monsieur Fogg
* ... and I could contain myself no longer.
'What is the purpose of our journey, Monsieur?'
'A wager,' he replied.
* * 'A wager!'[] I returned.
He nodded.
* * * 'But surely that is foolishness!'
* * * 'A most serious matter then!'
- - - He nodded again.
* * * 'But can we win?'
'That is what we will endeavour to find out,' he answered.
* * * 'A modest wager, I trust?'
'Twenty thousand pounds,' he replied, quite flatly.
* * * I asked nothing further of him then[.], and after a final, polite cough, he offered nothing more to me. <>
* * 'Ah[.'],' I replied, uncertain what I thought.
- - After that, <>
* ... but I said nothing[] and <>
- we passed the day in silence.
- -> END

View file

@ -0,0 +1,39 @@
name: "main"
scale_along_z: 0
embedded_instances {
id: "example"
data: "components {\n"
" id: \"example\"\n"
" component: \"/example-defold/example.gui\"\n"
" position {\n"
" x: 0.0\n"
" y: 0.0\n"
" z: 0.0\n"
" }\n"
" rotation {\n"
" x: 0.0\n"
" y: 0.0\n"
" z: 0.0\n"
" w: 1.0\n"
" }\n"
" property_decls {\n"
" }\n"
"}\n"
""
position {
x: 0.0
y: 0.0
z: 0.0
}
rotation {
x: 0.0
y: 0.0
z: 0.0
w: 1.0
}
scale3 {
x: 1.0
y: 1.0
z: 1.0
}
}

View file

@ -0,0 +1,17 @@
font: "/builtins/fonts/vera_mo_bd.ttf"
material: "/builtins/fonts/font.material"
size: 32
antialias: 1
alpha: 1.0
outline_alpha: 0.0
outline_width: 0.0
shadow_alpha: 0.0
shadow_blur: 0
shadow_x: 0.0
shadow_y: 0.0
extra_characters: ""
output_format: TYPE_BITMAP
all_chars: false
cache_width: 0
cache_height: 0
render_mode: MODE_SINGLE_LAYER

View file

@ -0,0 +1,146 @@
script: "/example-defold/example.gui_script"
fonts {
name: "example"
font: "/example-defold/example.font"
}
background_color {
x: 0.0
y: 0.0
z: 0.0
w: 0.0
}
nodes {
position {
x: 320.0
y: 340.0
z: 0.0
w: 1.0
}
rotation {
x: 0.0
y: 0.0
z: 0.0
w: 1.0
}
scale {
x: 0.5
y: 0.5
z: 0.5
w: 1.0
}
size {
x: 800.0
y: 0.0
z: 0.0
w: 1.0
}
color {
x: 0.102
y: 0.102
z: 0.102
w: 1.0
}
type: TYPE_TEXT
blend_mode: BLEND_MODE_ALPHA
text: "paragraph"
font: "example"
id: "paragraph"
xanchor: XANCHOR_NONE
yanchor: YANCHOR_NONE
pivot: PIVOT_N
outline {
x: 1.0
y: 1.0
z: 1.0
w: 1.0
}
shadow {
x: 1.0
y: 1.0
z: 1.0
w: 1.0
}
adjust_mode: ADJUST_MODE_FIT
line_break: true
layer: ""
inherit_alpha: true
alpha: 1.0
outline_alpha: 1.0
shadow_alpha: 1.0
template_node_child: false
text_leading: 1.0
text_tracking: 0.0
custom_type: 0
enabled: false
visible: true
material: ""
}
nodes {
position {
x: 320.0
y: 300.0
z: 0.0
w: 1.0
}
rotation {
x: 0.0
y: 0.0
z: 0.0
w: 1.0
}
scale {
x: 0.5
y: 0.5
z: 0.5
w: 1.0
}
size {
x: 800.0
y: 0.0
z: 0.0
w: 1.0
}
color {
x: 0.6
y: 0.2
z: 0.0
w: 1.0
}
type: TYPE_TEXT
blend_mode: BLEND_MODE_ALPHA
text: "choice"
font: "example"
id: "choice"
xanchor: XANCHOR_NONE
yanchor: YANCHOR_NONE
pivot: PIVOT_N
outline {
x: 1.0
y: 1.0
z: 1.0
w: 1.0
}
shadow {
x: 1.0
y: 1.0
z: 1.0
w: 1.0
}
adjust_mode: ADJUST_MODE_FIT
line_break: true
layer: ""
inherit_alpha: true
alpha: 1.0
outline_alpha: 1.0
shadow_alpha: 1.0
template_node_child: false
text_leading: 1.0
text_tracking: 0.0
custom_type: 0
enabled: false
visible: true
material: ""
}
material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT
max_nodes: 512

View file

@ -0,0 +1,167 @@
local narrator = require('narrator.narrator')
---@class Self
---@field paragraph_template node
---@field paragraph_initial_position vector3
---@field paragraph_nodes node[]
---@field choice_template node
---@field choice_initial_position vector3
---@field choice_nodes node[]
---@field book Narrator.Book
---@field story Narrator.Story
---@param node node
---@param text string
---@return integer height
local function estimated_height(node, text)
local font_name = gui.get_font(node)
local font = gui.get_font_resource(font_name)
local metrics = resource.get_text_metrics(font, text, {
width = gui.get_size(node).x,
line_break = true
})
return metrics.height
end
---@param self Self
local function clear_output(self)
for index = #self.paragraph_nodes, 1, -1 do
local node = self.paragraph_nodes[index]
table.remove(self.paragraph_nodes, index)
gui.animate(node, 'color.w', 0, gui.EASING_LINEAR, 0.5, 0, function(_, node)
gui.delete_node(node)
end)
end
for index = #self.choice_nodes, 1, -1 do
local node = self.choice_nodes[index]
table.remove(self.choice_nodes, index)
gui.animate(node, 'color.w', 0, gui.EASING_LINEAR, 0.5, 0, function(_, node)
gui.delete_node(node)
end)
end
end
---@param self Self
local function display_output(self)
clear_output(self)
-- Pull all the paragraphs
local paragraphs = self.story:continue()
-- Get the available choices
local choices = self.story:get_choices()
local paragraph_position = vmath.vector3(self.paragraph_initial_position)
local choice_position = vmath.vector3(self.choice_initial_position)
for index = #paragraphs, 1, -1 do
local paragraph = paragraphs[index]
local node = gui.clone(self.paragraph_template)
gui.set_text(node, paragraph.text)
local height = estimated_height(node, paragraph.text)
if index < #paragraphs then
paragraph_position.y = paragraph_position.y + height / 2 + 16
end
gui.set_position(node, paragraph_position)
gui.set_enabled(node, true)
gui.set_alpha(node, 0)
gui.animate(node, 'color.w', 1, go.EASING_LINEAR, 0.5, index / 2)
table.insert(self.paragraph_nodes, node)
end
for index = 1, #choices do
local choice = choices[index]
local node = gui.clone(self.choice_template)
gui.set_text(node, choice.text)
local height = estimated_height(node, choice.text)
if index > 1 then
choice_position.y = choice_position.y - height / 2 - 16
end
gui.set_enabled(node, true)
gui.set_position(node, choice_position)
local size = gui.get_size(node)
size.y = height
gui.set_size(node, size)
gui.set_alpha(node, 0)
gui.animate(node, 'color.w', 1, go.EASING_LINEAR, 0.5, (#paragraphs + 1) / 2)
table.insert(self.choice_nodes, node)
end
end
---@param self Self
function init(self)
msg.post('@render:', 'clear_color', { color = vmath.vector4(1, 1, 1, 1)})
msg.post('.', 'acquire_input_focus')
self.paragraph_template = gui.get_node('paragraph')
self.paragraph_initial_position = gui.get_position(self.paragraph_template)
self.paragraph_nodes = {}
self.choice_template = gui.get_node('choice')
self.choice_initial_position = gui.get_position(self.choice_template)
self.choice_nodes = {}
-- Parse and save a book
self.book = narrator.parse_file('example-defold.book', { save = true })
-- Or load and parse the Ink file from the custom resources
-- local content = sys.load_resource('/example-defold/book.ink')
-- self.book = narrator.parseBook(content)
-- Or load a book from the saved lua module
-- self.book = require('example-defold.book')
-- Or parse a book from the string with Ink content
-- self.book = narrator.parseBook('Hello world!')
-- Init a story
self.story = narrator.init_story(self.book)
-- Begin the story
self.story:begin()
display_output(self)
end
---@param self Self
---@param action_id hash|string
---@param action table
function on_input(self, action_id, action)
if action_id ~= hash 'touch' or not action.pressed then
return
end
if not self.story:can_choose() then
-- Begin a new story
self.story = narrator.init_story(self.book)
self.story:begin()
display_output(self)
return true
end
for index, node in ipairs(self.choice_nodes) do
if gui.pick_node(node, action.x, action.y) then
-- Make a choice
self.story:choose(index)
display_output(self)
return true
end
end
end

60
libs/narrator/game.lua Normal file
View file

@ -0,0 +1,60 @@
--
-- A simple command-line game example
-- Dependencies
local narrator = require('narrator.narrator')
-- Parse a book from the Ink file and save as module 'stories.game.lua'
local book = narrator.parse_file('stories.game', { save = true })
-- Init a story from the book
local story = narrator.init_story(book)
-- Start observing the Ink variable 'x'
story:observe('x', function(x) print('The x did change! Now it\'s ' .. x) end)
-- Bind local functions to call from ink as external functions
story:bind('beep', function() print('Beep! 😃') end)
story:bind('sum', function(x, y) return x + y end)
-- Begin the story
story:begin()
print('--- Game started ---\n')
while story:can_continue() do
-- Get current paragraphs to output
local paragraphs = story:continue()
for _, paragraph in ipairs(paragraphs) do
local text = paragraph.text
-- You can handle tags as you like, but we attach them to text here.
if paragraph.tags then
text = text .. ' #' .. table.concat(paragraph.tags, ' #')
end
-- Output text to the player
print(text)
end
-- If there is no choice, it seems the game is over
if not story:can_choose() then break end
print('')
-- Get available choices and output them to the player
local choices = story:get_choices()
for i, choice in ipairs(choices) do
print(i .. ') ' .. choice.text)
end
-- Read the choice from the player input
local answer = tonumber(io.read()) or 0
print('')
-- Send an answer to the story to generate new paragraphs
story:choose(answer)
end
print('\n--- Game over ---')

View file

@ -0,0 +1,25 @@
[bootstrap]
main_collection = /example-defold/examlpe.collectionc
render = /builtins/render/default.renderc
[script]
shared_state = 1
[display]
width = 640
height = 640
[android]
input_method = HiddenInputField
[project]
title = Narrator
custom_resources = example-defold/book.ink
dependencies#0 = https://github.com/astrochili/defold-lpeg/archive/1.0.4.zip
[library]
include_dirs = narrator
[input]
game_binding = /builtins/input/all.input_bindingc

View file

@ -0,0 +1,37 @@
---@class Narrator.Book.Version
---@field engine number
---@field tree number
---@class Narrator.Book
---@field version Narrator.Book.Version
---@field inclusions string[]
---@field lists table
---@field constants table
---@field variables table
---@field params table
---@field tree table
---@class Narrator.ParsingParams
---@field save boolean Save a parsed book to the lua file
---@class Narrator.Paragraph
---@field text string
---@field tags string[]|nil
---@class Narrator.Choice
---@field text string
---@field tags string[]|nil
---@class Narrator.State
---@field version number
---@field temp table
---@field seeds table
---@field variables table
---@field params table|nil
---@field visits table
---@field current_path table
---@field paragraphs table
---@field choices table
---@field output table
---@field tunnels table|nil
---@field path table

View file

@ -0,0 +1,32 @@
local enums = {
---Bump it when the state structure is changed
engine_version = 2,
---@enum Narrator.ItemType
item = {
text = 1,
alts = 2,
choice = 3,
condition = 4,
variable = 5
},
---@enum Narrator.Sequence
sequence = {
cycle = 1,
stopping = 2,
once = 3
},
---@enum Narrator.ReadMode
read_mode = {
text = 1,
choices = 2,
gathers = 3,
quit = 4
}
}
return enums

View file

@ -0,0 +1,68 @@
--
-- classic
--
-- Copyright (c) 2014, rxi
--
-- This module is free software; you can redistribute it and/or modify it under
-- the terms of the MIT license. See LICENSE for details.
--
local Object = {}
Object.__index = Object
function Object:new()
end
function Object:extend()
local cls = {}
for k, v in pairs(self) do
if k:find("__") == 1 then
cls[k] = v
end
end
cls.__index = cls
cls.super = self
setmetatable(cls, self)
return cls
end
function Object:implement(...)
for _, cls in pairs({...}) do
for k, v in pairs(cls) do
if self[k] == nil and type(v) == "function" then
self[k] = v
end
end
end
end
function Object:is(T)
local mt = getmetatable(self)
while mt do
if mt == T then
return true
end
mt = getmetatable(mt)
end
return false
end
function Object:__tostring()
return "Object"
end
function Object:__call(...)
local obj = setmetatable({}, self)
obj:new(...)
return obj
end
return Object

View file

@ -0,0 +1,780 @@
--
-- lume
--
-- Copyright (c) 2020 rxi
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy of
-- this software and associated documentation files (the "Software"), to deal in
-- the Software without restriction, including without limitation the rights to
-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-- of the Software, and to permit persons to whom the Software is furnished to do
-- so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in all
-- copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
--
local lume = { _version = "2.3.0" }
local pairs, ipairs = pairs, ipairs
local type, assert, unpack = type, assert, unpack or table.unpack
local tostring, tonumber = tostring, tonumber
local math_floor = math.floor
local math_ceil = math.ceil
local math_atan2 = math.atan2 or math.atan
local math_sqrt = math.sqrt
local math_abs = math.abs
local noop = function()
end
local identity = function(x)
return x
end
local patternescape = function(str)
return str:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")
end
local absindex = function(len, i)
return i < 0 and (len + i + 1) or i
end
local iscallable = function(x)
if type(x) == "function" then return true end
local mt = getmetatable(x)
return mt and mt.__call ~= nil
end
local getiter = function(x)
if lume.isarray(x) then
return ipairs
elseif type(x) == "table" then
return pairs
end
error("expected table", 3)
end
local iteratee = function(x)
if x == nil then return identity end
if iscallable(x) then return x end
if type(x) == "table" then
return function(z)
for k, v in pairs(x) do
if z[k] ~= v then return false end
end
return true
end
end
return function(z) return z[x] end
end
function lume.clamp(x, min, max)
return x < min and min or (x > max and max or x)
end
function lume.round(x, increment)
if increment then return lume.round(x / increment) * increment end
return x >= 0 and math_floor(x + .5) or math_ceil(x - .5)
end
function lume.sign(x)
return x < 0 and -1 or 1
end
function lume.lerp(a, b, amount)
return a + (b - a) * lume.clamp(amount, 0, 1)
end
function lume.smooth(a, b, amount)
local t = lume.clamp(amount, 0, 1)
local m = t * t * (3 - 2 * t)
return a + (b - a) * m
end
function lume.pingpong(x)
return 1 - math_abs(1 - x % 2)
end
function lume.distance(x1, y1, x2, y2, squared)
local dx = x1 - x2
local dy = y1 - y2
local s = dx * dx + dy * dy
return squared and s or math_sqrt(s)
end
function lume.angle(x1, y1, x2, y2)
return math_atan2(y2 - y1, x2 - x1)
end
function lume.vector(angle, magnitude)
return math.cos(angle) * magnitude, math.sin(angle) * magnitude
end
function lume.random(a, b)
if not a then a, b = 0, 1 end
if not b then b = 0 end
return a + math.random() * (b - a)
end
function lume.randomchoice(t)
return t[math.random(#t)]
end
function lume.weightedchoice(t)
local sum = 0
for _, v in pairs(t) do
assert(v >= 0, "weight value less than zero")
sum = sum + v
end
assert(sum ~= 0, "all weights are zero")
local rnd = lume.random(sum)
for k, v in pairs(t) do
if rnd < v then return k end
rnd = rnd - v
end
end
function lume.isarray(x)
return type(x) == "table" and x[1] ~= nil
end
function lume.push(t, ...)
local n = select("#", ...)
for i = 1, n do
t[#t + 1] = select(i, ...)
end
return ...
end
function lume.remove(t, x)
local iter = getiter(t)
for i, v in iter(t) do
if v == x then
if lume.isarray(t) then
table.remove(t, i)
break
else
t[i] = nil
break
end
end
end
return x
end
function lume.clear(t)
local iter = getiter(t)
for k in iter(t) do
t[k] = nil
end
return t
end
function lume.extend(t, ...)
for i = 1, select("#", ...) do
local x = select(i, ...)
if x then
for k, v in pairs(x) do
t[k] = v
end
end
end
return t
end
function lume.shuffle(t)
local rtn = {}
for i = 1, #t do
local r = math.random(i)
if r ~= i then
rtn[i] = rtn[r]
end
rtn[r] = t[i]
end
return rtn
end
function lume.sort(t, comp)
local rtn = lume.clone(t)
if comp then
if type(comp) == "string" then
table.sort(rtn, function(a, b) return a[comp] < b[comp] end)
else
table.sort(rtn, comp)
end
else
table.sort(rtn)
end
return rtn
end
function lume.array(...)
local t = {}
for x in ... do t[#t + 1] = x end
return t
end
function lume.each(t, fn, ...)
local iter = getiter(t)
if type(fn) == "string" then
for _, v in iter(t) do v[fn](v, ...) end
else
for _, v in iter(t) do fn(v, ...) end
end
return t
end
function lume.map(t, fn)
fn = iteratee(fn)
local iter = getiter(t)
local rtn = {}
for k, v in iter(t) do rtn[k] = fn(v) end
return rtn
end
function lume.all(t, fn)
fn = iteratee(fn)
local iter = getiter(t)
for _, v in iter(t) do
if not fn(v) then return false end
end
return true
end
function lume.any(t, fn)
fn = iteratee(fn)
local iter = getiter(t)
for _, v in iter(t) do
if fn(v) then return true end
end
return false
end
function lume.reduce(t, fn, first)
local started = first ~= nil
local acc = first
local iter = getiter(t)
for _, v in iter(t) do
if started then
acc = fn(acc, v)
else
acc = v
started = true
end
end
assert(started, "reduce of an empty table with no first value")
return acc
end
function lume.unique(t)
local rtn = {}
for k in pairs(lume.invert(t)) do
rtn[#rtn + 1] = k
end
return rtn
end
function lume.filter(t, fn, retainkeys)
fn = iteratee(fn)
local iter = getiter(t)
local rtn = {}
if retainkeys then
for k, v in iter(t) do
if fn(v) then rtn[k] = v end
end
else
for _, v in iter(t) do
if fn(v) then rtn[#rtn + 1] = v end
end
end
return rtn
end
function lume.reject(t, fn, retainkeys)
fn = iteratee(fn)
local iter = getiter(t)
local rtn = {}
if retainkeys then
for k, v in iter(t) do
if not fn(v) then rtn[k] = v end
end
else
for _, v in iter(t) do
if not fn(v) then rtn[#rtn + 1] = v end
end
end
return rtn
end
function lume.merge(...)
local rtn = {}
for i = 1, select("#", ...) do
local t = select(i, ...)
local iter = getiter(t)
for k, v in iter(t) do
rtn[k] = v
end
end
return rtn
end
function lume.concat(...)
local rtn = {}
for i = 1, select("#", ...) do
local t = select(i, ...)
if t ~= nil then
local iter = getiter(t)
for _, v in iter(t) do
rtn[#rtn + 1] = v
end
end
end
return rtn
end
function lume.find(t, value)
local iter = getiter(t)
for k, v in iter(t) do
if v == value then return k end
end
return nil
end
function lume.match(t, fn)
fn = iteratee(fn)
local iter = getiter(t)
for k, v in iter(t) do
if fn(v) then return v, k end
end
return nil
end
function lume.count(t, fn)
local count = 0
local iter = getiter(t)
if fn then
fn = iteratee(fn)
for _, v in iter(t) do
if fn(v) then count = count + 1 end
end
else
if lume.isarray(t) then
return #t
end
for _ in iter(t) do count = count + 1 end
end
return count
end
function lume.slice(t, i, j)
i = i and absindex(#t, i) or 1
j = j and absindex(#t, j) or #t
local rtn = {}
for x = i < 1 and 1 or i, j > #t and #t or j do
rtn[#rtn + 1] = t[x]
end
return rtn
end
function lume.first(t, n)
if not n then return t[1] end
return lume.slice(t, 1, n)
end
function lume.last(t, n)
if not n then return t[#t] end
return lume.slice(t, -n, -1)
end
function lume.invert(t)
local rtn = {}
for k, v in pairs(t) do rtn[v] = k end
return rtn
end
function lume.pick(t, ...)
local rtn = {}
for i = 1, select("#", ...) do
local k = select(i, ...)
rtn[k] = t[k]
end
return rtn
end
function lume.keys(t)
local rtn = {}
local iter = getiter(t)
for k in iter(t) do rtn[#rtn + 1] = k end
return rtn
end
function lume.clone(t)
local rtn = {}
for k, v in pairs(t) do rtn[k] = v end
return rtn
end
function lume.fn(fn, ...)
assert(iscallable(fn), "expected a function as the first argument")
local args = { ... }
return function(...)
local a = lume.concat(args, { ... })
return fn(unpack(a))
end
end
function lume.once(fn, ...)
local f = lume.fn(fn, ...)
local done = false
return function(...)
if done then return end
done = true
return f(...)
end
end
local memoize_fnkey = {}
local memoize_nil = {}
function lume.memoize(fn)
local cache = {}
return function(...)
local c = cache
for i = 1, select("#", ...) do
local a = select(i, ...) or memoize_nil
c[a] = c[a] or {}
c = c[a]
end
c[memoize_fnkey] = c[memoize_fnkey] or {fn(...)}
return unpack(c[memoize_fnkey])
end
end
function lume.combine(...)
local n = select('#', ...)
if n == 0 then return noop end
if n == 1 then
local fn = select(1, ...)
if not fn then return noop end
assert(iscallable(fn), "expected a function or nil")
return fn
end
local funcs = {}
for i = 1, n do
local fn = select(i, ...)
if fn ~= nil then
assert(iscallable(fn), "expected a function or nil")
funcs[#funcs + 1] = fn
end
end
return function(...)
for _, f in ipairs(funcs) do f(...) end
end
end
function lume.call(fn, ...)
if fn then
return fn(...)
end
end
function lume.time(fn, ...)
local start = os.clock()
local rtn = {fn(...)}
return (os.clock() - start), unpack(rtn)
end
local lambda_cache = {}
function lume.lambda(str)
if not lambda_cache[str] then
local args, body = str:match([[^([%w,_ ]-)%->(.-)$]])
assert(args and body, "bad string lambda")
local s = "return function(" .. args .. ")\nreturn " .. body .. "\nend"
lambda_cache[str] = lume.dostring(s)
end
return lambda_cache[str]
end
local serialize
local serialize_map = {
[ "boolean" ] = tostring,
[ "nil" ] = tostring,
[ "string" ] = function(v) return string.format("%q", v) end,
[ "number" ] = function(v)
if v ~= v then return "0/0" -- nan
elseif v == 1 / 0 then return "1/0" -- inf
elseif v == -1 / 0 then return "-1/0" end -- -inf
return tostring(v)
end,
[ "table" ] = function(t, stk)
stk = stk or {}
if stk[t] then error("circular reference") end
local rtn = {}
stk[t] = true
for k, v in pairs(t) do
rtn[#rtn + 1] = "[" .. serialize(k, stk) .. "]=" .. serialize(v, stk)
end
stk[t] = nil
return "{" .. table.concat(rtn, ",") .. "}"
end
}
setmetatable(serialize_map, {
__index = function(_, k) error("unsupported serialize type: " .. k) end
})
serialize = function(x, stk)
return serialize_map[type(x)](x, stk)
end
function lume.serialize(x)
return serialize(x)
end
function lume.deserialize(str)
return lume.dostring("return " .. str)
end
function lume.split(str, sep)
if not sep then
return lume.array(str:gmatch("([%S]+)"))
else
assert(sep ~= "", "empty separator")
local psep = patternescape(sep)
return lume.array((str..sep):gmatch("(.-)("..psep..")"))
end
end
function lume.trim(str, chars)
if not chars then return str:match("^[%s]*(.-)[%s]*$") end
chars = patternescape(chars)
return str:match("^[" .. chars .. "]*(.-)[" .. chars .. "]*$")
end
function lume.wordwrap(str, limit)
limit = limit or 72
local check
if type(limit) == "number" then
check = function(s) return #s >= limit end
else
check = limit
end
local rtn = {}
local line = ""
for word, spaces in str:gmatch("(%S+)(%s*)") do
local s = line .. word
if check(s) then
table.insert(rtn, line .. "\n")
line = word
else
line = s
end
for c in spaces:gmatch(".") do
if c == "\n" then
table.insert(rtn, line .. "\n")
line = ""
else
line = line .. c
end
end
end
table.insert(rtn, line)
return table.concat(rtn)
end
function lume.format(str, vars)
if not vars then return str end
local f = function(x)
return tostring(vars[x] or vars[tonumber(x)] or "{" .. x .. "}")
end
return (str:gsub("{(.-)}", f))
end
function lume.trace(...)
local info = debug.getinfo(2, "Sl")
local t = { info.short_src .. ":" .. info.currentline .. ":" }
for i = 1, select("#", ...) do
local x = select(i, ...)
if type(x) == "number" then
x = string.format("%g", lume.round(x, .01))
end
t[#t + 1] = tostring(x)
end
print(table.concat(t, " "))
end
function lume.dostring(str)
return assert((loadstring or load)(str))()
end
function lume.uuid()
local fn = function(x)
local r = math.random(16) - 1
r = (x == "x") and (r + 1) or (r % 4) + 9
return ("0123456789abcdef"):sub(r, r)
end
return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn))
end
function lume.hotswap(modname)
local oldglobal = lume.clone(_G)
local updated = {}
local function update(old, new)
if updated[old] then return end
updated[old] = true
local oldmt, newmt = getmetatable(old), getmetatable(new)
if oldmt and newmt then update(oldmt, newmt) end
for k, v in pairs(new) do
if type(v) == "table" then update(old[k], v) else old[k] = v end
end
end
local err = nil
local function onerror(e)
for k in pairs(_G) do _G[k] = oldglobal[k] end
err = lume.trim(e)
end
local ok, oldmod = pcall(require, modname)
oldmod = ok and oldmod or nil
xpcall(function()
package.loaded[modname] = nil
local newmod = require(modname)
if type(oldmod) == "table" then update(oldmod, newmod) end
for k, v in pairs(oldglobal) do
if v ~= _G[k] and type(v) == "table" then
update(v, _G[k])
_G[k] = v
end
end
end, onerror)
package.loaded[modname] = oldmod
if err then return nil, err end
return oldmod
end
local ripairs_iter = function(t, i)
i = i - 1
local v = t[i]
if v ~= nil then
return i, v
end
end
function lume.ripairs(t)
return ripairs_iter, t, (#t + 1)
end
function lume.color(str, mul)
mul = mul or 1
local r, g, b, a
r, g, b = str:match("#(%x%x)(%x%x)(%x%x)")
if r then
r = tonumber(r, 16) / 0xff
g = tonumber(g, 16) / 0xff
b = tonumber(b, 16) / 0xff
a = 1
elseif str:match("rgba?%s*%([%d%s%.,]+%)") then
local f = str:gmatch("[%d.]+")
r = (f() or 0) / 0xff
g = (f() or 0) / 0xff
b = (f() or 0) / 0xff
a = f() or 1
else
error(("bad color string '%s'"):format(str))
end
return r * mul, g * mul, b * mul, a * mul
end
local chain_mt = {}
chain_mt.__index = lume.map(lume.filter(lume, iscallable, true),
function(fn)
return function(self, ...)
self._value = fn(self._value, ...)
return self
end
end)
chain_mt.__index.result = function(x) return x._value end
function lume.chain(value)
return setmetatable({ _value = value }, chain_mt)
end
setmetatable(lume, {
__call = function(_, ...)
return lume.chain(...)
end
})
return lume

View file

@ -0,0 +1,401 @@
--
-- Dependencies
local lume = require('narrator.libs.lume')
--
-- Metatable
local mt = { lists = { } }
function mt.__tostring(self)
local pool = { }
local list_keys = { }
for key, _ in pairs(self) do
table.insert(list_keys, key)
end
table.sort(list_keys)
for i = 1, #list_keys do
local list_name = list_keys[i]
local list_items = self[list_name]
for index = 1, #mt.lists[list_name] do
pool[index] = pool[index] or { }
local item_name = mt.lists[list_name][index]
if list_items[item_name] == true then
table.insert(pool[index], 1, item_name)
end
end
end
local items = { }
for _, titles in ipairs(pool) do
for _, title in ipairs(titles) do
table.insert(items, title)
end
end
return table.concat(items, ', ')
end
--
-- Operators
function mt.__add(lhs, rhs) -- +
if type(rhs) == 'table' then
return mt.__add_list(lhs, rhs)
elseif type(rhs) == 'number' then
return mt.__shift_by_number(lhs, rhs)
else
error('Attempt to sum the list with ' .. type(rhs))
end
end
function mt.__sub(lhs, rhs) -- -
if type(rhs) == 'table' then
return mt.__subList(lhs, rhs)
elseif type(rhs) == 'number' then
return mt.__shift_by_number(lhs, -rhs)
else
error('Attempt to sub the list with ' .. type(rhs))
end
end
function mt.__mod(lhs, rhs) -- % (contain)
if type(rhs) ~= 'table' then
error('Attempt to check content of the list for ' .. type(rhs))
end
for list_name, list_items in pairs(rhs) do
if lhs[list_name] == nil then return false end
for item_name, item_value in pairs(list_items) do
if (lhs[list_name][item_name] or false) ~= item_value then return false end
end
end
return true
end
function mt.__pow(lhs, rhs) -- ^ (intersection)
if type(rhs) ~= 'table' then
error('Attempt to interselect the list with ' .. type(rhs))
end
local intersection = { }
for list_name, list_items in pairs(lhs) do
for item_name, item_value in pairs(list_items) do
local left = lhs[list_name][item_name]
local right = (rhs[list_name] or { })[item_name]
if left == true and right == true then
intersection[list_name] = intersection[list_name] or { }
intersection[list_name][item_name] = true
end
end
end
setmetatable(intersection, mt)
return intersection
end
function mt.__len(self) -- #
local len = 0
for list_name, list_items in pairs(self) do
for item_name, item_value in pairs(list_items) do
if item_value == true then len = len + 1 end
end
end
return len
end
function mt.__eq(lhs, rhs) -- ==
if type(rhs) ~= 'table' then
error('Attempt to compare the list with ' .. type(rhs))
end
local function keys_count(object)
local count = 0
for _, _ in pairs(object) do
count = count + 1
end
return count
end
local left_lists_count = keys_count(lhs)
local right_lists_count = keys_count(rhs)
if left_lists_count ~= right_lists_count then
return false
end
for list_name, left_items in pairs(lhs) do
local right_items = rhs[list_name]
if right_items == nil then
return false
end
local left_items_count = keys_count(left_items)
local right_items_count = keys_count(right_items)
if left_items_count ~= right_items_count then
return false
end
end
return mt.__mod(lhs, rhs)
end
function mt.__lt(lhs, rhs) -- <
if type(rhs) ~= 'table' then
error('Attempt to compare the list with ' .. type(rhs))
end
-- LEFT < RIGHT means "the smallest value in RIGHT is bigger than the largest values in LEFT"
local minLeft = mt.min_value_of(lhs, true)
local maxRight = mt.max_value_of(rhs, true)
return minLeft < maxRight
end
function mt.__le(lhs, rhs) -- <=
if type(rhs) ~= 'table' then
error('Attempt to compare the list with ' .. type(rhs))
end
-- LEFT => RIGHT means "the smallest value in RIGHT is at least the smallest value in LEFT,
-- and the largest value in RIGHT is at least the largest value in LEFT".
local minRight = mt.min_value_of(rhs, true)
local minLeft = mt.min_value_of(lhs, true)
local maxRight = mt.max_value_of(rhs, true)
local maxLeft = mt.max_value_of(lhs, true)
return minRight >= minLeft and maxRight >= maxLeft
end
--
-- Custom operators
function mt.__add_list(lhs, rhs)
local result = lume.clone(lhs)
for list_name, list_items in pairs(rhs) do
result[list_name] = result[list_name] or { }
for item_name, item_value in pairs(list_items) do
result[list_name][item_name] = item_value
end
end
return result
end
function mt.__subList(lhs, rhs)
local result = lume.clone(lhs)
for list_name, list_items in pairs(rhs) do
if lhs[list_name] ~= nil then
for item_name, _ in pairs(list_items) do
lhs[list_name][item_name] = nil
end
end
end
return mt.remove_empties_in_list(result)
end
function mt.__shift_by_number(list, number)
local result = { }
for list_name, list_items in pairs(list) do
result[list_name] = { }
for index, item_name in ipairs(mt.lists[list_name]) do
if list_items[item_name] == true then
local nextItem = mt.lists[list_name][index + number]
if nextItem ~= nil then
result[list_name][nextItem] = true
end
end
end
end
return mt.remove_empties_in_list(result)
end
--
-- Helpers
function mt.remove_empties_in_list(list)
local result = lume.clone(list)
for list_name, list_items in pairs(list) do
if next(list_items) == nil then
result[list_name] = nil
end
end
return result
end
function mt.min_value_of(list, raw)
local min_index = 0
local min_value = { }
local list_keys = { }
for key, _ in pairs(list) do
table.insert(list_keys, key)
end
table.sort(list_keys)
for i = 1, #list_keys do
local list_name = list_keys[i]
local list_items = list[list_name]
for item_name, item_value in pairs(list_items) do
if item_value == true then
local index = lume.find(mt.lists[list_name], item_name)
if index and index < min_index or min_index == 0 then
min_index = index
min_value = { [list_name] = { [item_name] = true } }
end
end
end
end
return raw and min_index or min_value
end
function mt.max_value_of(list, raw)
local max_index = 0
local max_value = { }
local list_keys = { }
for key, _ in pairs(list) do
table.insert(list_keys, key)
end
table.sort(list_keys)
for i = 1, #list_keys do
local list_name = list_keys[i]
local list_items = list[list_name]
for item_name, item_value in pairs(list_items) do
if item_value == true then
local index = lume.find(mt.lists[list_name], item_name)
if index and index > max_index or max_index == 0 then
max_index = index
max_value = { [list_name] = { [item_name] = true } }
end
end
end
end
return raw and max_index or max_value
end
function mt.random_value_of(list)
local items = { }
local list_keys = { }
for key, _ in pairs(list) do
table.insert(list_keys, key)
end
table.sort(list_keys)
for i = 1, #list_keys do
local list_name = list_keys[i]
local list_items = list[list_name]
local items_keys = { }
for key, _ in pairs(list_items) do
table.insert(items_keys, key)
end
table.sort(items_keys)
for i = 1, #items_keys do
local item_name = items_keys[i]
local item_value = list_items[item_name]
if item_value == true then
local result = { [list_name] = { [item_name] = true } }
table.insert(items, result)
end
end
end
local random_index = math.random(1, #items)
return items[random_index]
end
function mt.first_raw_value_of(list)
local result = 0
for list_name, list_items in pairs(list) do
for item_name, item_value in pairs(list_items) do
if item_value == true then
local index = lume.find(mt.lists[list_name], item_name)
if index then
result = index
break
end
end
end
end
return result
end
function mt.posible_values_of(list)
local result = { }
for list_name, list_items in pairs(list) do
local subList = { }
for _, item_name in ipairs(mt.lists[list_name]) do
subList[item_name] = true
end
result[list_name] = subList
end
return result
end
function mt.range_of(list, min, max)
if type(min) ~= 'table' and type(min) ~= 'number' then
error('Attempt to get a range with incorrect min value of type ' .. type(min))
end
if type(max) ~= 'table' and type(max) ~= 'number' then
error('Attempt to get a range with incorrect max value of type ' .. type(max))
end
local result = { }
local allList = mt.posible_values_of(list)
local min_index = type(min) == 'number' and min or mt.first_raw_value_of(min)
local max_index = type(max) == 'number' and max or mt.first_raw_value_of(max)
for list_name, list_items in pairs(allList) do
for item_name, item_value in pairs(list_items) do
local index = lume.find(mt.lists[list_name], item_name)
if index and index >= min_index and index <= max_index and list[list_name][item_name] == true then
result[list_name] = result[list_name] or { }
result[list_name][item_name] = true
end
end
end
return result
end
function mt.invert(list)
local result = mt.posible_values_of(list)
for list_name, list_items in pairs(list) do
for item_name, item_value in pairs(list_items) do
if item_value == true then
result[list_name][item_name] = nil
end
end
end
return result
end
return mt

View file

@ -0,0 +1,150 @@
local lume = require('narrator.libs.lume')
local enums = require('narrator.enums')
local parser = require('narrator.parser')
local Story = require('narrator.story')
--
-- Local
local folder_separator = package.config:sub(1, 1)
---Clear path from '.lua' and '.ink' extensions and replace '.' to '/' or '\'
---@param path string
---@return string normalized_path
local function normalize_path(path)
local path = path:gsub('.lua$', '')
local path = path:gsub('.ink$', '')
if path:match('%.') and not path:match(folder_separator) then
path = path:gsub('%.', folder_separator)
end
return path
end
---Parse an .ink file to the content string.
---@param path string
---@return string content
local function read_ink_file(path)
local path = normalize_path(path) .. '.ink'
local file = io.open(path, 'r')
assert(file, 'File doesn\'t exist: ' .. path)
local content = file:read('*all')
file:close()
return content
end
---Save a book to the lua module
---@param book Narrator.Book
---@param path string
---@return boolean success
local function save_book(book, path)
local path = normalize_path(path) .. '.lua'
local data = lume.serialize(book)
data = data:gsub('%[%d+%]=', '')
data = data:gsub('[\'[%w_]+\']', function(match) return
match:sub(3, #match - 2)
end)
local file = io.open(path, 'w')
if file == nil then
return false
end
file:write('return ' .. data)
file:close()
return true
end
---Merge a chapter to the book
---@param book Narrator.Book
---@param chapter Narrator.Book
---@return Narrator.Book
local function merge_chapter_to_book(book, chapter)
-- Check a engine version compatibility
if chapter.version.engine and chapter.version.engine ~= enums.engine_version then
assert('Version ' .. chapter.version.engine .. ' of book isn\'t equal to the version ' .. enums.engine_version .. ' of Narrator.')
end
--Merge the root knot and it's stitch
book.tree._._ = lume.concat(chapter.tree._._, book.tree._._)
chapter.tree._._ = nil
book.tree._ = lume.merge(chapter.tree._, book.tree._)
chapter.tree._ = nil
--Merge a chapter to the book
book.tree = lume.merge(book.tree or { }, chapter.tree or { })
book.constants = lume.merge(book.constants or { }, chapter.constants or { })
book.lists = lume.merge(book.lists or { }, chapter.lists or { })
book.variables = lume.merge(book.variables or { }, chapter.variables or { })
book.params = lume.merge(book.params or { }, chapter.params or { })
return book
end
--
-- Public
local narrator = { }
---Parse a book from an Ink file
---Use it during development, but prefer already parsed and stored books in production
---Requires `lpeg` and `io`.
---@param path string
---@param params Narrator.ParsingParams|nil
---@return Narrator.Book
function narrator.parse_file(path, params)
local params = params or { save = false }
assert(parser, 'Can\'t parse anything without lpeg, sorry.')
local content = read_ink_file(path)
local book = parser.parse(content)
for _, inclusion in ipairs(book.inclusions) do
local folder_path = normalize_path(path):match('(.*' .. folder_separator .. ')')
local inclusion_path = folder_path .. normalize_path(inclusion) .. '.ink'
local chapter = narrator.parse_file(inclusion_path)
merge_chapter_to_book(book, chapter)
end
if params.save then
save_book(book, path)
end
return book
end
---Parse a book from the ink content string
---Use it during development, but prefer already parsed and stored books in production
---Requires `lpeg`
---@param content string
---@param inclusions string[]
---@return Narrator.Book
function narrator.parse_content(content, inclusions)
local inclusions = inclusions or { }
assert(parser, 'Can\'t parse anything without a parser.')
local book = parser.parse(content)
for _, inclusion in ipairs(inclusions) do
local chapter = parser.parse(inclusion)
merge_chapter_to_book(book, chapter)
end
return book
end
---Init a story based on the book
---@param book Narrator.Book
---@return Narrator.Story
function narrator.init_story(book)
return Story(book)
end
return narrator

View file

@ -0,0 +1,789 @@
local lume = require('narrator.libs.lume')
local enums = require('narrator.enums')
--
-- LPeg
-- To allow to build in Defold
local lpeg_name = 'lpeg'
if not pcall(require, lpeg_name) then
return false
end
local lpeg = require(lpeg_name)
local S, C, P, V = lpeg.S, lpeg.C, lpeg.P, lpeg.V
local Cb, Ct, Cc, Cg = lpeg.Cb, lpeg.Ct, lpeg.Cc, lpeg.Cg
local Cmt = lpeg.Cmt
lpeg.locale(lpeg)
--
-- Parser
local parser = { }
local constructor = { }
---Parse ink content string
---@param content string
---@return Narrator.Book
function parser.parse(content)
--
-- Basic patterns
local function get_length(array) return
#array
end
local eof = -1
local sp = S(' \t') ^ 0
local ws = S(' \t\r\n') ^ 0
local nl = S('\r\n') ^ 1
local none = Cc(nil)
local divert_sign = P'->'
local gather_mark = sp * C('-' - divert_sign)
local gather_level = Cg(Ct(gather_mark ^ 1) / get_length + none, 'level')
local sticky_marks = Cg(Ct((sp * C('+')) ^ 1) / get_length, 'level') * Cg(Cc(true), 'sticky')
local choice_marks = Cg(Ct((sp * C('*')) ^ 1) / get_length, 'level') * Cg(Cc(false), 'sticky')
local choice_level = sticky_marks + choice_marks
local id = (lpeg.alpha + '_') * (lpeg.alnum + '_') ^ 0
local label = Cg('(' * sp * C(id) * sp * ')', 'label')
local address = id * ('.' * id) ^ -2
---Something for tunnels
local function check_tunnel(s, i, a)
local r = lpeg.match (sp * divert_sign, s, i)
return i, r ~= nil
end
-- TODO: Clean divert expression to divert and tunnel
local divert = divert_sign * sp * Cg(address, 'path') -- base search for divert symbol and path to follow
local check_tunnel = Cg(Cmt(Cb('path'), check_tunnel), 'tunnel') -- a weird way to to check tunnel
local opt_tunnel_sign = (sp * divert_sign * sp * (#nl + #S'#') ) ^ -1 -- tunnel sign in end of string, keep newline not consumed
divert = Cg(Ct(divert * sp * check_tunnel * opt_tunnel_sign), 'divert')
local divert_to_nothing = divert_sign * none
local exit_tunnel = Cg(divert_sign * divert_sign, 'exit')
local tag = '#' * sp * V'text'
local tags = Cg(Ct(tag * (sp * tag) ^ 0), 'tags')
local todo = sp * 'TODO:' * (1 - nl) ^ 0
local comment_line = sp * '//' * sp * (1 - nl) ^ 0
local comment_multi = sp * '/*' * ((P(1) - '*/') ^ 0) * '*/'
local comment = comment_line + comment_multi
local multiline_end = ws * '}'
--
-- Dynamic patterns and evaluation helpers
local function item_type(type)
return Cg(Cc(type), 'type')
end
local function balanced_multiline_item(is_restricted)
local is_restricted = is_restricted ~= nil and is_restricted or false
local paragraph = is_restricted and V'restricted_paragraph' or V'paragraph'
return sp * paragraph ^ -1 * sp * V'multiline_item' * sp * paragraph ^ -1 * ws
end
local function sentence_before(excluded, tailed)
local tailed = tailed or false
local character = P(1 - S(' \t')) - excluded
local pattern = (sp * character ^ 1) ^ 1
local with_tail = C(pattern * sp)
local without_tail = C(pattern) * sp
local without_tail_always = C(pattern) * sp * #(tags + nl)
return without_tail_always + (tailed and with_tail or without_tail)
end
local function unwrap_assignment(assignment)
local unwrapped = assignment
unwrapped = unwrapped:gsub('([%w_]*)%s*([%+%-])[%+%-]', '%1 = %1 %2 1')
unwrapped = unwrapped:gsub('([%w_]*)%s*([%+%-])=%s*(.*)', '%1 = %1 %2 %3')
local name, value = unwrapped:match('([%w_]*)%s*=%s*(.*)')
return name or '', value or assignment
end
local function check_special_escape(s, i, a)
if string.sub(s, i - 2, i - 2) == '\\' then
return
end
return i
end
--
-- Grammar rules
local ink_grammar = P({ 'root',
-- Root
root = ws * V'items' + eof,
items = Ct(V'item' ^ 0),
item = balanced_multiline_item() + V'singleline_item',
singleline_item = sp * (V'global' + V'statement' + V'paragraph' + V'gatherPoint') * ws,
multiline_item = ('{' * sp * (V'sequence' + V'switch') * sp * multiline_end) - V'inline_condition',
-- Gather points
gatherPoint = Ct(gather_level * sp * nl * item_type('gather')),
-- Global declarations
global =
Ct(V'inclusion' * item_type('inclusion')) +
Ct(V'list' * item_type('list')) +
Ct(V'constant' * item_type('constant')) +
Ct(V'variable' * item_type('variable'))
,
inclusion = 'INCLUDE ' * sp * Cg(sentence_before(nl + comment), 'filename'),
list = 'LIST ' * sp * V'assignment_pair',
constant = 'CONST ' * sp * V'assignment_pair',
variable = 'VAR ' * sp * V'assignment_pair',
-- Statements
statement =
Ct(V'return_from_func' * item_type('return')) +
Ct(V'assignment' * item_type('assignment')) +
Ct(V'func' * item_type('func')) +
Ct(V'knot' * item_type('knot')) +
Ct(V'stitch' * item_type('stitch')) +
Ct(V'choice' * item_type('choice')) +
comment + todo
,
section_name = C(id) * sp * P'=' ^ 0,
knot = P'==' * (P'=' ^ 0) * sp * Cg(V'section_name', 'knot'),
stitch = '=' * sp * Cg(V'section_name', 'stitch'),
func_param = sp * C(id) * sp * S','^0,
func_params = P'(' * Cg(Ct(V'func_param'^0), 'params') * P')',
function_name = P'function' * sp * Cg(id, 'name') * sp * V'func_params' * sp * P'=' ^ 0,
func = P'==' * (P'=' ^ 0) * sp * Cg(Ct(V'function_name'), 'func'),
return_from_func = sp * '~' * sp * P('return') * sp * Cg((P(1) - nl)^0, 'value') * nl ^ 0,
assignment = gather_level * sp * '~' * sp * V'assignment_temp' * sp * V'assignment_pair',
assignment_temp = Cg('temp' * Cc(true) + Cc(false), 'temp'),
assignment_pair = Cg(sentence_before(nl + comment) / unwrap_assignment, 'name') * Cg(Cb('name') / 2, 'value'),
choice_condition = Cg(V'expression' + none, 'condition'),
choice_fallback = choice_level * sp * V'label_optional' * sp * V'choice_condition' * sp * (divert + divert_to_nothing) * sp * V'tags_optional',
choice_normal = choice_level * sp * V'label_optional' * sp * V'choice_condition' * sp * Cg(V'text', 'text') * divert ^ -1 * sp * V'tags_optional',
choice = V'choice_fallback' + V'choice_normal',
-- Paragraph
paragraph = Ct(gather_level * sp * (V'paragraph_label' + V'paragraph_text' + V'paragraph_tags') * item_type('paragraph')),
paragraph_label = label * sp * Cg(V'text_optional', 'parts') * sp * V'tags_optional',
paragraph_text = V'label_optional' * sp * Cg(V'text_complex', 'parts') * sp * V'tags_optional',
paragraph_tags = V'label_optional' * sp * Cg(V'text_optional', 'parts') * sp * tags,
label_optional = label + none,
text_optional = V'text_complex' + none,
tags_optional = tags + none,
text_complex = Ct((Ct(
Cg(V'inline_condition', 'condition') +
Cg(V'inline_sequence', 'sequence') +
Cg(V'expression', 'expression') +
Cg(V'text' + ' ', 'text') * (exit_tunnel ^ -1) * (divert ^ -1) + exit_tunnel + divert
) - V'multiline_item') ^ 1),
special_check_escape = Cmt(S("{|}"), check_special_escape),
text = sentence_before(nl + exit_tunnel + divert + comment + tag + V'special_check_escape', true) - V'statement',
-- Inline expressions, conditions, sequences
expression = '{' * sp * sentence_before('}' + nl) * sp * '}',
inline_condition = '{' * sp * Ct(V'inline_if_else' + V'inline_if') * sp * '}',
inline_if = Cg(sentence_before(S':}' + nl), 'condition') * sp * ':' * sp * Cg(V'text_complex', 'success'),
inline_if_else = (V'inline_if') * sp * '|' * sp * Cg(V'text_complex', 'failure'),
inline_alt_empty = Ct(Ct(Cg(sp * Cc'', 'text') * sp * divert ^ -1)),
inline_alt = V'text_complex' + V'inline_alt_empty',
inline_alts = Ct(((sp * V'inline_alt' * sp * '|') ^ 1) * sp * V'inline_alt'),
inline_sequence = '{' * sp * (
'!' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('once'), 'sequence')) +
'&' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('cycle'), 'sequence')) +
'~' * sp * Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('stopping'), 'sequence') * Cg(Cc(true), 'shuffle')) +
Ct(Cg(V'inline_alts', 'alts') * Cg(Cc('stopping'), 'sequence'))
) * sp * '}',
-- Multiline conditions and switches
switch = Ct((V'switch_comparative' + V'switch_conditional') * item_type('switch')),
switch_comparative = Cg(V'switch_condition', 'expression') * ws * Cg(Ct((sp * V'switch_case') ^ 1), 'cases'),
switch_conditional = Cg(Ct(V'switch_cases_headed' + V'switch_cases_only'), 'cases'),
switch_cases_headed = V'switch_if' * ((sp * V'switch_case') ^ 0),
switch_cases_only = ws * ((sp * V'switch_case') ^ 1),
switch_if = Ct(Cg(V'switch_condition', 'condition') * ws * Cg(Ct(V'switch_items'), 'node')),
switch_case = ('-' - divert_sign) * sp * V'switch_if',
switch_condition = sentence_before(':' + nl) * sp * ':' * sp * comment ^ -1,
switch_items = (V'restricted_item' - V'switch_case') ^ 1,
-- Multiline sequences
sequence = Ct((V'sequence_params' * sp * nl * sp * V'sequence_alts') * item_type('sequence')),
sequence_params = (
V'sequence_shuffle_optional' * sp * V'sequence_type' +
V'sequence_shuffle' * sp * V'sequence_type' +
V'sequence_shuffle' * sp * V'sequence_type_optional'
) * sp * ':' * sp * comment ^ -1,
sequence_shuffle_optional = V'sequence_shuffle' + Cg(Cc(false), 'shuffle'),
sequence_shuffle = Cg(P'shuffle' / function() return true end, 'shuffle'),
sequence_type_optional = V'sequence_type' + Cg(Cc'cycle', 'sequence'),
sequence_type = Cg(P'cycle' + 'stopping' + 'once', 'sequence'),
sequence_alts = Cg(Ct((sp * V'sequence_alt') ^ 1), 'alts'),
sequence_alt = ('-' - divert_sign) * ws * Ct(V'sequence_items'),
sequence_items = (V'restricted_item' - V'sequence_alt') ^ 1,
-- Restricted items inside multiline items
restricted_item = balanced_multiline_item(true) + V'restricted_singleline_item',
restricted_singleline_item = sp * (V'global' + V'restricted_statement' + V'restricted_paragraph' - multiline_end) * ws,
restricted_statement = Ct(
V'choice' * item_type('choice') +
V'assignment' * item_type('assignment')
) + comment + todo,
restricted_paragraph = Ct((
Cg(V'text_complex', 'parts') * sp * V'tags_optional' +
Cg(V'text_optional', 'parts') * sp * tags
) * item_type('paragraph'))
})
--
-- Result
local parsed_items = ink_grammar:match(content)
local book = constructor.construct_book(parsed_items)
return book
end
--
-- A book construction
function constructor.unescape(text)
local result = text
result = result:gsub('\\|', '|')
result = result:gsub('\\{', '{')
result = result:gsub('\\}', '}')
return result
end
function constructor.construct_book(items)
local construction = {
current_knot = '_',
current_stitch = '_',
variables_to_compute = { }
}
construction.book = {
inclusions = { },
lists = { },
constants = { },
variables = { },
params = { },
tree = { _ = { _ = { } } }
}
construction.book.version = {
engine = enums.engine_version,
tree = 1
}
construction.nodes_chain = {
construction.book.tree[construction.current_knot][construction.current_stitch]
}
constructor.add_node(construction, items)
constructor.clear(construction.book.tree)
constructor.compute_variables(construction)
return construction.book
end
function constructor:add_node(items, is_restricted)
local is_restricted = is_restricted ~= nil and is_restricted or false
for _, item in ipairs(items) do
if is_restricted then
-- Are not allowed inside multiline blocks by Ink rules:
-- a) nesting levels
-- b) choices without diverts
item.level = nil
if item.type == 'choice' and item.divert == nil then
item.type = nil
end
end
if item.type == 'inclusion' then
-- filename
constructor.add_inclusion(self, item.filename)
elseif item.type == 'list' then
-- name, value
constructor.add_list(self, item.name, item.value)
elseif item.type == 'constant' then
-- name, value
constructor.add_constant(self, item.name, item.value)
elseif item.type == 'variable' then
-- name, value
constructor.add_variable(self, item.name, item.value)
elseif item.type == 'func' then
-- function
constructor.add_function(self, item.func.name, item.func.params)
elseif item.type == 'knot' then
-- knot
constructor.add_knot(self, item.knot)
elseif item.type == 'stitch' then
-- stitch
constructor.add_stitch(self, item.stitch)
elseif item.type == 'switch' then
-- expression, cases
constructor.add_switch(self, item.expression, item.cases)
elseif item.type == 'sequence' then
-- sequence, shuffle, alts
constructor.add_sequence(self, item.sequence, item.shuffle, item.alts)
elseif item.type == 'assignment' then
-- level, name, value, temp
constructor.add_assignment(self, item.level, item.name, item.value, item.temp)
elseif item.type == 'return' then
constructor.add_return(self, item.value)
elseif item.type == 'paragraph' then
-- level, label, parts, tags
constructor.add_paragraph(self, item.level, item.label, item.parts, item.tags)
elseif item.type == 'gather' then
constructor.add_paragraph(self, item.level, "", nil, item.tags)
elseif item.type == 'choice' then
-- level, sticky, label, condition, text, divert, tags
constructor.add_choice(self, item.level, item.sticky, item.label, item.condition, item.text, item.divert, item.tags)
end
end
end
function constructor:add_inclusion(filename)
table.insert(self.book.inclusions, filename)
end
function constructor:add_list(name, value)
local items = lume.array(value:gmatch('[%w_%.]+'))
self.book.lists[name] = items
local switched = lume.array(value:gmatch('%b()'))
switched = lume.map(switched, function(item) return item:sub(2, #item - 1) end)
self.book.variables[name] = { [name] = { } }
lume.each(switched, function(item) self.book.variables[name][name][item] = true end)
end
function constructor:add_constant(constant, value)
local value = lume.deserialize(value)
self.book.constants[constant] = value
end
function constructor:add_variable(variable, value)
self.variables_to_compute[variable] = value
end
function constructor:add_function(fname, params)
local node = { }
self.book.tree[fname] = { ['_'] = node }
self.book.params[fname] = params
self.nodes_chain = { node }
end
function constructor:add_knot(knot)
self.current_knot = knot
self.current_stitch = '_'
local node = { }
self.book.tree[self.current_knot] = { [self.current_stitch] = node }
self.nodes_chain = { node }
end
function constructor:add_stitch(stitch)
-- If a root stitch is empty we need to add a divert to the first stitch in the ink file.
if self.current_stitch == '_' then
local root_stitch_node = self.book.tree[self.current_knot]._
if #root_stitch_node == 0 then
local divertItem = { divert = { path = stitch } }
table.insert(root_stitch_node, divertItem)
end
end
self.current_stitch = stitch
local node = { }
self.book.tree[self.current_knot][self.current_stitch] = node
self.nodes_chain = { node }
end
function constructor:add_switch(expression, cases)
if expression then
-- Convert switch cases to comparing conditions with expression
for _, case in ipairs(cases) do
if case.condition ~= 'else' then
case.condition = expression .. '==' .. case.condition
end
end
end
local item = {
condition = { },
success = { }
}
for _, case in ipairs(cases) do
if case.condition == 'else' then
local failure_node = { }
table.insert(self.nodes_chain, failure_node)
constructor.add_node(self, case.node, true)
table.remove(self.nodes_chain)
item.failure = failure_node
else
local success_node = { }
table.insert(self.nodes_chain, success_node)
constructor.add_node(self, case.node, true)
table.remove(self.nodes_chain)
table.insert(item.success, success_node)
table.insert(item.condition, case.condition)
end
end
constructor.add_item(self, nil, item)
end
function constructor:add_sequence(sequence, shuffle, alts)
local item = {
sequence = sequence,
shuffle = shuffle and true or nil,
alts = { }
}
for _, alt in ipairs(alts) do
local alt_node = { }
table.insert(self.nodes_chain, alt_node)
constructor.add_node(self, alt, true)
table.remove(self.nodes_chain)
table.insert(item.alts, alt_node)
end
constructor.add_item(self, nil, item)
end
function constructor:add_return(value)
local item = {
return_value = value
}
constructor.add_item(self, nil, item)
end
function constructor:add_assignment(level, name, value, temp)
local item = {
temp = temp or nil,
var = name,
value = value
}
constructor.add_item(self, level, item)
end
function constructor:add_paragraph(level, label, parts, tags)
local items = constructor.convert_paragraph_parts_to_items(parts, true)
items = items or { }
-- If the paragraph has a label or tags we need to place them as the first text item.
if label ~= nil or tags ~= nil then
local first_item
if #items > 0 and items[1].condition == nil then
first_item = items[1]
else
first_item = { }
table.insert(items, first_item)
end
first_item.label = label
first_item.tags = tags
end
for _, item in ipairs(items) do
constructor.add_item(self, level, item)
end
end
function constructor.convert_paragraph_parts_to_items(parts, is_root)
if parts == nil then return nil end
local is_root = is_root ~= nil and is_root or false
local items = { }
local item
for index, part in ipairs(parts) do
if part.condition then -- Inline condition part
item = {
condition = part.condition.condition,
success = constructor.convert_paragraph_parts_to_items(part.condition.success),
failure = constructor.convert_paragraph_parts_to_items(part.condition.failure)
}
table.insert(items, item)
item = nil
elseif part.sequence then -- Inline sequence part
item = {
sequence = part.sequence.sequence,
shuffle = part.sequence.shuffle and true or nil,
alts = { }
}
for _, alt in ipairs(part.sequence.alts) do
table.insert(item.alts, constructor.convert_paragraph_parts_to_items(alt))
end
table.insert(items, item)
item = nil
else -- Text, expression and divert may be
local is_divert_only = part.divert ~= nil and part.text == nil
if item == nil then
item = { text = (is_root or is_divert_only) and '' or '<>' }
end
if part.text then
item.text = item.text .. part.text:gsub('%s+', ' ')
item.text = constructor.unescape(item.text)
elseif part.expression then
item.text = item.text .. '#' .. part.expression .. '#'
end
if part.divert or part.exit then
item.exit = part.exit and true or nil
item.divert = part.divert
item.text = #item.text > 0 and (item.text .. '<>') or nil
table.insert(items, item)
item = nil
else
local next = parts[index + 1]
local next_is_block = next and not (next.text or next.expression)
if not next or next_is_block then
if not is_root or next_is_block then
item.text = item.text .. '<>'
end
table.insert(items, item)
item = nil
end
end
end
end
if is_root then
-- Add a safe prefix and suffix for correct conditions gluing
local first_item = items[1]
if first_item.text == nil and first_item.divert == nil and first_item.exit == nil then
table.insert(items, 1, { text = '' } )
end
local last_item = items[#items]
if last_item.text == nil and last_item.divert == nil and last_item.exit == nil then
table.insert(items, { text = '' } )
elseif last_item.text ~= nil and last_item.divert == nil then
last_item.text = last_item.text:gsub('(.-)%s*$', '%1')
end
end
return items
end
function constructor:add_choice(level, sticky, label, condition, sentence, divert, tags)
local item = {
sticky = sticky or nil,
condition = condition,
label = label,
divert = divert,
tags = tags
}
if sentence == nil then
item.choice = 0
else
local prefix, divider, suffix = sentence:match('(.*)%[(.*)%](.*)')
prefix = prefix or sentence
divider = divider or ''
suffix = suffix or ''
local text = (prefix .. suffix):gsub('%s+', ' ')
local choice = (prefix .. divider):gsub('%s+', ' '):gsub('^%s*(.-)%s*$', '%1')
if divert and #text > 0 and text:match('%S+') then
text = text .. '<>'
else
text = text:gsub('^%s*(.-)%s*$', '%1')
end
item.text = constructor.unescape(text)
item.choice = constructor.unescape(choice)
end
constructor.add_item(self, level, item)
if divert == nil then
item.node = { }
table.insert(self.nodes_chain, item.node)
end
end
function constructor:add_item(level, item)
local level = (level ~= nil and level > 0) and level or #self.nodes_chain
while #self.nodes_chain > level do
table.remove(self.nodes_chain)
end
local node = self.nodes_chain[#self.nodes_chain]
table.insert(node, item)
end
function constructor:compute_variable(variable, value)
local constant = self.book.constants[value]
if constant then
self.book.variables[variable] = constant
return
end
local list_expression = value:match('%(([%s%w%.,_]*)%)')
local item_expressions = list_expression and lume.array(list_expression:gmatch('[%w_%.]+')) or { value }
local list_variable = list_expression and { } or nil
for _, item_expression in ipairs(item_expressions) do
local list_part, item_part = item_expression:match('([%w_]+)%.([%w_]+)')
item_part = item_part or item_expression
for list_name, list_items in pairs(self.book.lists) do
local list_is_valid = list_part == nil or list_part == list_name
local item_is_found = lume.find(list_items, item_part)
if list_is_valid and item_is_found then
list_variable = list_variable or { }
list_variable[list_name] = list_variable[list_name] or { }
list_variable[list_name][item_part] = true
end
end
end
if list_variable then
self.book.variables[variable] = list_variable
else
self.book.variables[variable] = lume.deserialize(value)
end
end
function constructor:compute_variables()
for variable, value in pairs(self.variables_to_compute) do
constructor.compute_variable(self, variable, value)
end
end
function constructor.clear(tree)
for knot, node in pairs(tree) do
for stitch, node in pairs(node) do
constructor.clear_node(node)
end
end
end
function constructor.clear_node(node)
for index, item in ipairs(node) do
-- Simplify text only items
if item.text ~= nil and lume.count(item) == 1 then
node[index] = item.text
end
if item.node ~= nil then
-- Clear choice nodes
if #item.node == 0 then
item.node = nil
else
constructor.clear_node(item.node)
end
end
if item.success ~= nil then
-- Simplify single condition
if type(item.condition) == 'table' and #item.condition == 1 then
item.condition = item.condition[1]
end
-- Clear success nodes
if item.success[1] ~= nil and item.success[1][1] ~= nil then
for index, success_node in ipairs(item.success) do
constructor.clear_node(success_node)
if #success_node == 1 and type(success_node[1]) == 'string' then
item.success[index] = success_node[1]
end
end
if #item.success == 1 then
item.success = item.success[1]
end
else
constructor.clear_node(item.success)
if #item.success == 1 and type(item.success[1]) == 'string' then
item.success = item.success[1]
end
end
-- Clear failure nodes
if item.failure ~= nil then
constructor.clear_node(item.failure)
if #item.failure == 1 and type(item.failure[1]) == 'string' then
item.failure = item.failure[1]
end
end
end
if item.alts ~= nil then
for index, alt_node in ipairs(item.alts) do
constructor.clear_node(alt_node)
if #alt_node == 1 and type(alt_node[1]) == 'string' then
item.alts[index] = alt_node[1]
end
end
end
end
end
return parser

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,19 @@
- I looked at Monsieur Fogg
* ... and I could contain myself no longer.
'What is the purpose of our journey, Monsieur?'
'A wager,' he replied.
* * 'A wager!'[] I returned.
He nodded.
* * * 'But surely that is foolishness!'
* * * 'A most serious matter then!'
- - - He nodded again.
* * * 'But can we win?'
'That is what we will endeavour to find out,' he answered.
* * * 'A modest wager, I trust?'
'Twenty thousand pounds,' he replied, quite flatly.
* * * I asked nothing further of him then[.], and after a final, polite cough, he offered nothing more to me. <>
* * 'Ah[.'],' I replied, uncertain what I thought.
- - After that, <>
* ... but I said nothing[] and <>
- we passed the day in silence.
- -> END

View file

@ -0,0 +1 @@
return {inclusions={},constants={},version={tree=1,engine=1},tree={_={_={"I looked at Monsieur Fogg",{text="... and I could contain myself no longer.",choice="... and I could contain myself no longer.",node={"'What is the purpose of our journey, Monsieur?'","'A wager,' he replied.",{text="'A wager!' I returned.",choice="'A wager!'",node={"He nodded.",{text="'But surely that is foolishness!'",choice="'But surely that is foolishness!'"},{text="'A most serious matter then!'",choice="'A most serious matter then!'"},"He nodded again.",{text="'But can we win?'",choice="'But can we win?'",node={"'That is what we will endeavour to find out,' he answered."}},{text="'A modest wager, I trust?'",choice="'A modest wager, I trust?'",node={"'Twenty thousand pounds,' he replied, quite flatly."}},{text="I asked nothing further of him then, and after a final, polite cough, he offered nothing more to me. <>",choice="I asked nothing further of him then."}}},{text="'Ah,' I replied, uncertain what I thought.",choice="'Ah.'"},"After that, <>"}},{text="... but I said nothing and <>",choice="... but I said nothing"},"we passed the day in silence.",{divert="END"}}}},lists={},variables={}}

View file

@ -0,0 +1,78 @@
--
-- Test cases
local runtime = {
'continue',
'observing',
'binding',
'set-get',
'visits',
'tags',
'jumping',
'save-load'
}
local units = {
'inclusions',
'comments',
'knots',
'stitches',
'text-line',
'text-lines',
'text-tags',
'text-glue',
'choices-basic',
'choices-tags',
'choices-conditional',
'choices-sticky',
'choices-fallback',
'choices-tunnel',
'labels-choices',
'labels-nested',
'branching',
'nesting',
'gather',
'loop',
'vars',
'constants',
'expressions',
'queries',
'conditions-inline',
'alts-inline',
'conditions-switch',
'alts-blocks',
'lists-basic',
'lists-operators',
'lists-queries',
'tunnels',
'escape',
'functions'
}
local stories = {
-- No complex stories at the moment
}
local cases = {
runtime = runtime,
units = units,
stories = stories
}
local folder_separator = package.config:sub(1, 1)
for folder_name, folder_cases in pairs(cases) do
local items_with_foldes = { }
for _, case in ipairs(folder_cases) do
table.insert(items_with_foldes, folder_name .. folder_separator .. case)
end
cases[folder_name] = items_with_foldes
end
return cases

252
libs/narrator/test/run.lua Normal file
View file

@ -0,0 +1,252 @@
--
-- Testing with Busted library
--
-- Dependencies
local import = require
import('busted.runner')()
if os.getenv('LOCAL_LUA_DEBUGGER_VSCODE') == '1' then
import('lldebugger').start()
end
local bot = require('bot')
local narrator = require('narrator.narrator')
local lume = require('narrator.libs.lume')
--
-- Constants
local folder_separator = package.config:sub(1, 1)
local tests_folder = 'test' .. folder_separator
--- Make path for a .lua file
-- @param case string: an runtime test case
-- @return string: a .lua path
local function lua_path(case)
return tests_folder .. case .. '.lua'
end
--- Make path for an .ink file
-- @param case string: an Ink test case
-- @return string: an .ink path
local function ink_path(case)
return tests_folder .. case .. '.ink'
end
--- Make path for a .txt file
-- @param case string: an Ink test case
-- @param answers table: a sequence of answers (numbers)
-- @return string: a .txt path
local function txt_path(case, answers)
local path = tests_folder .. case
if answers and #answers > 0 then
path = path .. folder_separator .. table.concat(answers, '-')
end
path = path .. '.txt'
return path
end
--- Get all possible sequences and logs of the case
-- @param case string: an Ink test case
-- @return table: an array of possible games { sequence, log }
local function get_possible_results(case)
local path = ink_path(case)
local book = narrator.parse_file(path)
local results = { }
local sequences = { { } }
local seq_index
local function instructor(choices, step)
local cur_seq = sequences[seq_index]
local answer = cur_seq[step]
if not answer then
-- Transform a current sequence to branches for each available choice
table.remove(sequences, seq_index)
for index, _ in ipairs(choices) do
local new_seq = lume.concat(cur_seq, { index })
table.insert(sequences, new_seq)
end
-- Set a stop signal for the bot
answer = -1
end
return answer
end
while #sequences > 0 do
-- Iterate sequences and play them
for index = 1, #sequences do
local sequence = sequences[index]
seq_index = index
-- Play the sequence
local story = narrator.init_story(book)
local log = bot.play(story, instructor)
-- If the sequence was finished then save the result and mark it as finished
if log then
local result = { sequence = sequence, log = log }
table.insert(results, result)
sequences[index] = { is_finished = true }
end
end
-- Remove finished sequences
for index = #sequences, 1, -1 do
local sequence = sequences[index]
if sequence.is_finished then
table.remove(sequences, index)
end
end
end
return results
end
--- Create possible results for an Ink test case and save them to txt files
-- @param case string: an Ink test case
-- @param override boolean: override a txt file if it already exists.
local function create_txt_for_ink_case(case, override)
local override = override ~= nil and override or false
local results = get_possible_results(case)
for _, result in ipairs(results) do
local txt_path = txt_path(case, #results > 1 and result.sequence or nil)
local file = io.open(txt_path, 'r')
local is_file_exists = file ~= nil
if is_file_exists then io.close(file) end
if not is_file_exists or override then
local folder_path = txt_path:match('(.*' .. folder_separator .. ')')
local folder = io.open(folder_path, 'r')
local is_folder_exists = folder ~= nil
if is_folder_exists then
io.close(folder)
else
os.execute('mkdir ' .. folder_path)
end
file = io.open(txt_path, 'w')
assert(file, 'Has no access to the file at path \'' .. txt_path .. '\'.')
file:write(result.log)
file:close()
end
end
end
--- Create possible results for Ink test cases and save them to txt files
-- @param cases table: an array of Ink test cases
-- @param override bool: override the txt file if it already exists
local function create_txt_for_ink_cases(cases, override)
for _, case in ipairs(cases) do
create_txt_for_ink_case(case, override)
end
end
--- Test an Ink case
-- @param case string: an Ink test case
local function test_ink_case(case)
describe('Test an Ink case \'' .. case .. '\'.', function()
local path = ink_path(case)
local book = narrator.parse_file(path)
local results = get_possible_results(case)
for _, result in ipairs(results) do
describe('Sequence is [' .. table.concat(result.sequence, '-') .. '].', function()
local txt_path = txt_path(case, #results > 1 and result.sequence or nil)
local file = io.open(txt_path, 'r')
it('Checking results.', function()
assert.is_not_nil(file)
local expected = file:read('*all')
file:close()
assert.are.same(expected, result.log)
end)
end)
end
end)
end
--- Test Ink cases
-- @param cases table: an array of test cases
local function test_ink_cases(cases)
for _, case in ipairs(cases) do
test_ink_case(case)
end
end
--- Test a runtime case
-- @param case string: a runtime test case
local function test_lua_case(case)
describe('Test a runtime case \'' .. case .. '\'.', function()
local lua_path = lua_path(case)
loadfile(lua_path)(narrator, describe, it, assert)
end)
end
--- Test runtime cases
-- @param cases table: an array of runtime test cases
local function test_lua_cases(cases)
for _, case in ipairs(cases) do
test_lua_case(case)
end
end
--- Override math.random functions to prevent different results beetween machines and pass test-cases.
local function override_random()
local test_seed
local original_random = math.random
math.randomseed = function(x)
test_seed = x
end
math.random = function(x, y)
if test_seed then
local result = math.max(x, math.min(test_seed, y))
test_seed = nil
return result
else
return original_random(x, y)
end
end
end
--
-- Main
local case = nil
local cases = require('test.cases')
local override_case_results = false
override_random()
if override_case_results then
if case then
create_txt_for_ink_case(case, true)
else
create_txt_for_ink_cases(cases.units, true)
create_txt_for_ink_cases(cases.stories, true)
end
end
if case then
if case:find('runtime/', 1, 8) then
test_lua_case(case)
else
test_ink_case(case)
end
else
test_lua_cases(cases.runtime)
test_ink_cases(cases.units)
test_ink_cases(cases.stories)
end

View file

@ -0,0 +1,42 @@
local narrator, describe, it, assert = ...
local content = [[
~ beep()
{ sum(1, 2) }
{ did_solve_puzzle("labirint") }
]]
local book = narrator.parse_content(content)
local story = narrator.init_story(book)
local is_beeped = false
local puzzles = { }
story:bind('beep', function()
is_beeped = true
end)
story:bind('sum', function(x, y)
return x + y
end)
story:bind('did_solve_puzzle', function(puzzle)
puzzles[puzzle] = true
end)
story:begin()
it('Was a beep?', function()
assert.is_true(is_beeped)
end)
it('Sum is equal to 3.', function()
local paragraphs = story:continue()
assert.equal(#paragraphs, 1)
assert.equal('3', paragraphs[1].text)
end)
it('Labirint is sovled.', function()
local puzzle_is_solved = puzzles['labirint']
assert.is_true(puzzle_is_solved)
end)

View file

@ -0,0 +1,30 @@
local narrator, describe, it, assert = ...
local content = [[
Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
]]
local book = narrator.parse_content(content)
local story = narrator.init_story(book)
story:begin()
it('Get one paragraph.', function()
local paragraph = story:continue(1)
assert.equal(paragraph.text, 'Line 1')
end)
it('Get two paragraphs.', function()
local paragraphs = story:continue(2)
assert.equal(#paragraphs, 2)
end)
it('Get remain paragraphs.', function()
local paragraphs = story:continue()
assert.equal(#paragraphs, 3)
end)

View file

@ -0,0 +1,39 @@
local narrator, describe, it, assert = ...
local content = [[
A root line
=== knot
A knot line
= stitch
A stitch line
=== somewhere
- (label) A label line
]]
local book = narrator.parse_content(content)
local story = narrator.init_story(book)
story:begin()
local paragraphs = story:continue()
it('Jump to label.', function()
story:jump_to('somewhere.label')
local paragraphs = story:continue()
assert.equal(#paragraphs, 1)
assert.equal(paragraphs[1].text, 'A label line')
end)
it('Jump to stitch.', function()
story:jump_to('knot.stitch')
local paragraphs = story:continue()
assert.equal(#paragraphs, 1)
assert.equal(paragraphs[1].text, 'A stitch line')
end)
it('Jump to knot.', function()
story:jump_to('knot')
local paragraphs = story:continue()
assert.equal(#paragraphs, 1)
assert.equal(paragraphs[1].text, 'A knot line')
end)

View file

@ -0,0 +1,21 @@
local narrator, describe, it, assert = ...
local content = [[
VAR mood = "sadly"
~ mood = "sunny"
]]
local book = narrator.parse_content(content)
local story = narrator.init_story(book)
local mood
story:observe('mood', function(value)
mood = value
end)
story:begin()
it('Sunny mood.', function()
assert.equal(mood, 'sunny')
end)

View file

@ -0,0 +1,59 @@
local narrator, describe, it, assert = ...
local content = [[
VAR x = 1
* (choice) [Hello]
- (hello) Hello world!
-> knot
=== knot
= stitch
~ x = 2
~ temp y = 3
A line 1
A line 2
A line 3
* Just a road to hell -> END
* The best road to hell -> END
]]
local book = narrator.parse_content(content)
local saved_state
it('Saving', function()
local story = narrator.init_story(book)
story:begin()
story:continue()
story:choose(1)
story:continue(2)
saved_state = story:save_state()
local expected_path = { knot = 'knot', stitch = 'stitch' }
assert.are.same(saved_state.path, expected_path)
assert.equal(saved_state.variables['x'], 2)
assert.equal(saved_state.temp['y'], 3)
assert.equal(saved_state.visits._._.hello, 1)
assert.equal(#saved_state.output, 2)
assert.equal(#saved_state.paragraphs, 2)
assert.equal(#saved_state.choices, 2)
end)
it('Loading.', function()
local story = narrator.init_story(book)
story:begin()
story:load_state(saved_state)
local expected_path = { knot = 'knot', stitch = 'stitch' }
assert.are.same(story.current_path, expected_path)
assert.equal(story.variables['x'], 2)
assert.equal(story.temp['y'], 3)
assert.equal(story:get_visits('hello'), 1)
assert.equal(#story.output, 2)
local paragraphs = story:continue()
local choices = story:get_choices()
assert.equal(#paragraphs, 2)
assert.equal(#choices, 2)
end)

View file

@ -0,0 +1,30 @@
local narrator, describe, it, assert = ...
local content = [[
VAR x = 1
* [Change x to 1]
* [Change x to 2]
* [Change x to 3]
x = { x }
]]
local book = narrator.parse_content(content)
local story = narrator.init_story(book)
story:begin()
it('The x is equal to 1.', function()
local x = story.variables['x']
assert.equal(x, 1)
end)
it('The x is changed to 3.', function()
local answer = 3
story.variables['x'] = answer
story:choose(answer)
local paragraphs = story:continue()
assert.equal(#paragraphs, 1)
assert.equal(paragraphs[1].text, 'x = 3')
end)

View file

@ -0,0 +1,51 @@
local narrator, describe, it, assert = ...
local content = [[
# global tag 1
# global tag 2 # global tag 3
Root line -> knot
=== knot ===
# knot tag
A knot line 1 # line 1 tag
A knot line 2 # line 2 tag
= stitch
# stitch tag
A stitch line # line 3 tag
]]
local book = narrator.parse_content(content)
local story = narrator.init_story(book)
story:begin()
local paragraphs = story:continue()
it('Global tags.', function()
local expected = { 'global tag 1', 'global tag 2', 'global tag 3' }
assert.are.same(expected, story.global_tags)
local global_tags = story:get_tags()
assert.are.same(expected, global_tags)
end)
it('Knot tags.', function()
local expected = { 'knot tag' }
local knot_tags = story:get_tags('knot')
assert.are.same(expected, knot_tags)
end)
it('Stitch tags.', function()
local expected = { 'stitch tag' }
local stitch_tags = story:get_tags('knot.stitch')
assert.are.same(expected, stitch_tags)
end)
it('Paragraph tags.', function()
assert.equal(#paragraphs, 2)
local expected = { 'global tag 1', 'global tag 2', 'global tag 3', 'knot tag', 'line 1 tag' }
assert.are.same(expected, paragraphs[1].tags)
local expected = { 'line 2 tag' }
assert.are.same(expected, paragraphs[2].tags)
end)

View file

@ -0,0 +1,65 @@
local narrator, describe, it, assert = ...
local content = [[
A root text
+ Go to knot -> knot
=== knot
+ Go to stitch -> stitch
= stitch
+ Go to label -> label
- (label) Some text
+ (choice) Go to root -> _._
]]
local book = narrator.parse_content(content)
local story = narrator.init_story(book)
story:begin()
local function visits()
local visits = {
root = story:get_visits(''),
knot = story:get_visits('knot'),
stitch = story:get_visits('knot.stitch'),
label = story:get_visits('knot.stitch.label'),
choice = story:get_visits('knot.stitch.choice')
}
return visits
end
local places = { 'root', 'knot', 'stitch', 'label' }
for cycle = 1, 3 do
describe('Visits with cycle ' .. cycle .. '.', function()
story:continue()
for place_index = 1, #places do
local place = places[place_index]
it('Visit the ' .. place .. '.', function()
story:continue()
local place = place
local divert = story.choices[1].divert
local expected_root = cycle
local expected_knot = place_index > 1 and cycle or cycle - 1
local expected_stitch = place_index > 2 and cycle or cycle - 1
local expected_label = place_index > 3 and cycle or cycle - 1
local expected_choice = place_index > 4 and cycle or cycle - 1
local visits = visits()
assert.equal(visits.root, expected_root)
assert.equal(visits.knot, expected_knot)
assert.equal(visits.stitch, expected_stitch)
assert.equal(visits.label, expected_label)
assert.equal(visits.choice, expected_choice)
story:choose(1)
end)
end
end)
end

View file

@ -0,0 +1,60 @@
VAR counter = 1
-> casino
= casino
=== { counter }
At the table, I drew a card. <>
{ stopping:
-
I entered the casino.
- Okay. <>
I entered the casino again.
- { counter }. <>
Once more, I went inside.
}
~ counter++
{ counter < 5 : -> casino | -> cycle }
= cycle
=== { counter }
{ cycle:
- I held my breath.
- I waited impatiently.
- I paused.
}
~ counter++
{ counter < 9: -> cycle | -> once }
= once
=== { counter }
{ once:
- Would my luck hold?
- Could I win the hand?
}
~ counter++
{ counter < 13 : -> once | -> shuffle }
= shuffle
=== { counter }
{ SEED_RANDOM(counter - 13) }
At the table, I drew a card. <>
{ shuffle:
- Ace of Hearts.
- King of Spades.
- 2 of Diamonds.
'You lose this time!' crowed the croupier.
}
~ counter++
{ counter < 16 : -> shuffle | -> nested }
= nested
=== { counter }
{ SEED_RANDOM(counter - 16) }
{ true:
{ shuffle:
- Okay. Ace of Hearts again.
- Okay. Ace of Hearts?!
}
}
-> END

View file

@ -0,0 +1,31 @@
=== 1
At the table, I drew a card. I entered the casino.
=== 2
At the table, I drew a card. Okay. I entered the casino again.
=== 3
At the table, I drew a card. 3. Once more, I went inside.
=== 4
At the table, I drew a card. 4. Once more, I went inside.
=== 5
I held my breath.
=== 6
I waited impatiently.
=== 7
I paused.
=== 8
I held my breath.
=== 9
Would my luck hold?
=== 10
Could I win the hand?
=== 11
=== 12
=== 13
At the table, I drew a card. Ace of Hearts.
=== 14
At the table, I drew a card. King of Spades.
=== 15
At the table, I drew a card. 2 of Diamonds.
'You lose this time!' crowed the croupier.
=== 16
Okay. Ace of Hearts again.

View file

@ -0,0 +1,37 @@
VAR counter = 0
-> coffee
= coffee
{I bought a coffee with my five-pound note.|I bought a second coffee for my friend.|I didn't have enough money to buy any more coffee.}
~ counter++
{ counter < 4 : -> coffee | -> today }
= today
It was {&Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday} today.
~ counter++
{ counter < 15 : -> today | -> joke}
= joke
He told me a joke. {!I laughed politely.|I smiled.|I grimaced.|I promised myself to not react again.}
~ counter++
{ counter < 20 : -> joke | -> coin}
= coin
{ SEED_RANDOM(20 - counter) }
I tossed the coin. {~Heads|Tails}.
~ counter++
{ counter < 22 : -> coin | -> lights}
= lights
I took a step forward. {!||Then the lights went out. -> dark }
~ counter++
{ counter < 27 : -> lights | -> ratbear}
= dark
So dark...
-> ratbear
= ratbear
The Ratbear {&{wastes no time and |}swipes|scratches } {&at you|into your {&leg|arm|cheek}}.
~ counter++
{ counter < 32 : -> ratbear | -> END }

View file

@ -0,0 +1,33 @@
I bought a coffee with my five-pound note.
I bought a second coffee for my friend.
I didn't have enough money to buy any more coffee.
I didn't have enough money to buy any more coffee.
It was Monday today.
It was Tuesday today.
It was Wednesday today.
It was Thursday today.
It was Friday today.
It was Saturday today.
It was Sunday today.
It was Monday today.
It was Tuesday today.
It was Wednesday today.
It was Thursday today.
He told me a joke. I laughed politely.
He told me a joke. I smiled.
He told me a joke. I grimaced.
He told me a joke. I promised myself to not react again.
He told me a joke.
I tossed the coin. Heads.
I tossed the coin. Tails.
I took a step forward.
I took a step forward.
I took a step forward. Then the lights went out. So dark...
The Ratbear wastes no time and swipes at you.
The Ratbear scratches into your leg.
The Ratbear swipes at you.
The Ratbear scratches into your arm.
The Ratbear swipes at you.
The Ratbear scratches into your cheek.
The Ratbear swipes at you.
The Ratbear scratches into your leg.

View file

@ -0,0 +1,21 @@
-> back_in_london
=== back_in_london ===
We arrived into London at 9.45pm exactly.
* 'There is not a moment to lose!'[] I declared.
-> hurry_outside
* 'Monsieur, let us savour this moment!'[] I declared.
My master clouted me firmly around the head and dragged me out of the door.
-> dragged_outside
* [We hurried home] -> hurry_outside
=== hurry_outside ===
We hurried home to Savile Row -> as_fast_as_we_could
=== dragged_outside ===
He insisted that we hurried home to Savile Row
-> as_fast_as_we_could
=== as_fast_as_we_could ===
<> as fast as we could.

View file

@ -0,0 +1,8 @@
We arrived into London at 9.45pm exactly.
>) 'There is not a moment to lose!'
2) 'Monsieur, let us savour this moment!'
3) We hurried home
'There is not a moment to lose!' I declared.
We hurried home to Savile Row as fast as we could.

View file

@ -0,0 +1,9 @@
We arrived into London at 9.45pm exactly.
1) 'There is not a moment to lose!'
>) 'Monsieur, let us savour this moment!'
3) We hurried home
'Monsieur, let us savour this moment!' I declared.
My master clouted me firmly around the head and dragged me out of the door.
He insisted that we hurried home to Savile Row as fast as we could.

View file

@ -0,0 +1,7 @@
We arrived into London at 9.45pm exactly.
1) 'There is not a moment to lose!'
2) 'Monsieur, let us savour this moment!'
>) We hurried home
We hurried home to Savile Row as fast as we could.

View file

@ -0,0 +1,9 @@
"What's that?" my master asked again.
* I don't know.
Okay...
* [Eat answer!]
Nice to hear from you!
* "I am somewhat tired[."]," I repeated.
"Really," he responded. "How deleterious."
* "Nothing, Monsieur!"[] I replied.
"Very good, then."

View file

@ -0,0 +1,9 @@
"What's that?" my master asked again.
>) I don't know.
2) Eat answer!
3) "I am somewhat tired."
4) "Nothing, Monsieur!"
I don't know.
Okay...

View file

@ -0,0 +1,8 @@
"What's that?" my master asked again.
1) I don't know.
>) Eat answer!
3) "I am somewhat tired."
4) "Nothing, Monsieur!"
Nice to hear from you!

View file

@ -0,0 +1,9 @@
"What's that?" my master asked again.
1) I don't know.
2) Eat answer!
>) "I am somewhat tired."
4) "Nothing, Monsieur!"
"I am somewhat tired," I repeated.
"Really," he responded. "How deleterious."

View file

@ -0,0 +1,9 @@
"What's that?" my master asked again.
1) I don't know.
2) Eat answer!
3) "I am somewhat tired."
>) "Nothing, Monsieur!"
"Nothing, Monsieur!" I replied.
"Very good, then."

View file

@ -0,0 +1,17 @@
-> door
=== door ===
* { not france.paris } [Go to Paris] -> france.paris
* { not england.london } [Go to London] -> england.london
* { france || england } [Go home]
Sweet home... -> END
=== france ===
= paris
Hello Paris!
-> door
=== england ===
= london
Hello London!
-> door

View file

@ -0,0 +1,14 @@
>) Go to Paris
2) Go to London
Hello Paris!
>) Go to London
2) Go home
Hello London!
>) Go home
Sweet home...

View file

@ -0,0 +1,10 @@
>) Go to Paris
2) Go to London
Hello Paris!
1) Go to London
>) Go home
Sweet home...

View file

@ -0,0 +1,14 @@
1) Go to Paris
>) Go to London
Hello London!
>) Go to Paris
2) Go home
Hello Paris!
>) Go home
Sweet home...

View file

@ -0,0 +1,10 @@
1) Go to Paris
>) Go to London
Hello London!
1) Go to Paris
>) Go home
Sweet home...

View file

@ -0,0 +1,10 @@
-> find_help
=== find_help ===
You search desperately for a friendly face in the crowd.
* The woman in the hat[?] pushes you roughly aside. -> find_help
* The man with the briefcase[?] looks disgusted as you stumble past him. -> find_help
* ->
But it is too late: you collapse onto the station platform. This is the end.
-> END

View file

@ -0,0 +1,11 @@
You search desperately for a friendly face in the crowd.
>) The woman in the hat?
2) The man with the briefcase?
The woman in the hat pushes you roughly aside. You search desperately for a friendly face in the crowd.
>) The man with the briefcase?
The man with the briefcase looks disgusted as you stumble past him. You search desperately for a friendly face in the crowd.
But it is too late: you collapse onto the station platform. This is the end.

View file

@ -0,0 +1,11 @@
You search desperately for a friendly face in the crowd.
1) The woman in the hat?
>) The man with the briefcase?
The man with the briefcase looks disgusted as you stumble past him. You search desperately for a friendly face in the crowd.
>) The woman in the hat?
The woman in the hat pushes you roughly aside. You search desperately for a friendly face in the crowd.
But it is too late: you collapse onto the station platform. This is the end.

View file

@ -0,0 +1,13 @@
-> homers_couch
=== homers_couch ===
+ [Eat another donut]
You eat another donut. -> fine
* [Get off the couch]
You struggle up off the couch to go and compose epic poetry.
-> END
=== fine ===
* Ok, is it fine? -> homers_couch
* Ok, it's fine! -> homers_couch
* -> END

View file

@ -0,0 +1,24 @@
>) Eat another donut
2) Get off the couch
You eat another donut.
>) Ok, is it fine?
2) Ok, it's fine!
Ok, is it fine?
>) Eat another donut
2) Get off the couch
You eat another donut.
>) Ok, it's fine!
Ok, it's fine!
>) Eat another donut
2) Get off the couch
You eat another donut.

View file

@ -0,0 +1,24 @@
>) Eat another donut
2) Get off the couch
You eat another donut.
>) Ok, is it fine?
2) Ok, it's fine!
Ok, is it fine?
>) Eat another donut
2) Get off the couch
You eat another donut.
>) Ok, it's fine!
Ok, it's fine!
1) Eat another donut
>) Get off the couch
You struggle up off the couch to go and compose epic poetry.

View file

@ -0,0 +1,15 @@
>) Eat another donut
2) Get off the couch
You eat another donut.
>) Ok, is it fine?
2) Ok, it's fine!
Ok, is it fine?
1) Eat another donut
>) Get off the couch
You struggle up off the couch to go and compose epic poetry.

View file

@ -0,0 +1,24 @@
>) Eat another donut
2) Get off the couch
You eat another donut.
1) Ok, is it fine?
>) Ok, it's fine!
Ok, it's fine!
>) Eat another donut
2) Get off the couch
You eat another donut.
>) Ok, is it fine?
Ok, is it fine?
>) Eat another donut
2) Get off the couch
You eat another donut.

View file

@ -0,0 +1,24 @@
>) Eat another donut
2) Get off the couch
You eat another donut.
1) Ok, is it fine?
>) Ok, it's fine!
Ok, it's fine!
>) Eat another donut
2) Get off the couch
You eat another donut.
>) Ok, is it fine?
Ok, is it fine?
1) Eat another donut
>) Get off the couch
You struggle up off the couch to go and compose epic poetry.

View file

@ -0,0 +1,15 @@
>) Eat another donut
2) Get off the couch
You eat another donut.
1) Ok, is it fine?
>) Ok, it's fine!
Ok, it's fine!
1) Eat another donut
>) Get off the couch
You struggle up off the couch to go and compose epic poetry.

View file

@ -0,0 +1,5 @@
1) Eat another donut
>) Get off the couch
You struggle up off the couch to go and compose epic poetry.

View file

@ -0,0 +1,6 @@
Let's try an experemental feature:
The Choice Tags!
* Are you seriously? #tag1
Yeap, absolutely!
* [Eat answer!] #tag2
Are you hungry?

View file

@ -0,0 +1,8 @@
Let's try an experemental feature:
The Choice Tags!
>) Are you seriously? #tag1
2) Eat answer! #tag2
Are you seriously? #tag1
Yeap, absolutely!

View file

@ -0,0 +1,7 @@
Let's try an experemental feature:
The Choice Tags!
1) Are you seriously? #tag1
>) Eat answer! #tag2
Are you hungry?

View file

@ -0,0 +1,7 @@
* [Master] ->master-> # test
Okay...
=== master
"What's that?" my master asked again.
* I don't know.
->->

View file

@ -0,0 +1,9 @@
>) Master #test
"What's that?" my master asked again.
>) I don't know.
I don't know.
Okay...

View file

@ -0,0 +1,15 @@
"What do you make of this?" she asked.
// A simple comment
"I couldn't possibly comment," I replied. // A simple comment after text
/*
... or an unlimited block of text
*/
Before comment ... /* A comment */ ... and after. Must be in one line, but right now it's known limitation.
TODO: This TODO will not be printed.
Text before TODO. TODO: This TODO will be printed.

View file

@ -0,0 +1,5 @@
"What do you make of this?" she asked.
"I couldn't possibly comment," I replied.
Before comment ...
... and after. Must be in one line, but right now it's known limitation.
Text before TODO. TODO: This TODO will be printed.

View file

@ -0,0 +1,15 @@
VAR mood = 10
VAR midnight = true
-> simple
=== simple
Simple condition: { mood > 20 : Hmm... { mood > 40 : Wow! } I was feeling positive enough. } Okay.
~ mood += 20
{ mood > 50 : -> complex | -> simple }
=== complex
Complex condition with midnight. { midnight : Wow! { midnight : Nice! | Bad! } | { not midnight : Very nice! | Very bad! } This is the end. }
{ midnight != true : -> END }
~ midnight = false
-> complex

View file

@ -0,0 +1,5 @@
Simple condition: Okay.
Simple condition: Hmm... I was feeling positive enough. Okay.
Simple condition: Hmm... Wow! I was feeling positive enough. Okay.
Complex condition with midnight. Wow! Nice!
Complex condition with midnight. Very nice! This is the end.

View file

@ -0,0 +1,78 @@
VAR foo = false
VAR x = 0
-> simple
== simple
{not foo:
Hello!
}
{ foo :
True! -> choice
- else:
False! }
~ foo = not foo
-> simple
=== choice
{ true :
* [Answer] -> nested_inline
}
== nested_inline
{
- foo : { foo: True! | False! }
Text here.
- else: Badaboom!
...again. -> nested_block
}
~ foo = not foo
-> nested_inline
== nested_block
I love you. { true:
And what about you?
{
- false:
False!
- true:
* Choice -> tags
}
...
} Something.
== tags
{
- not foo:
text { true : x = { x } } #success
- else:
-> not_a_label
}
~ foo = not foo
-> tags
== not_a_label
{
- foo: suc...
success #success
- x < 0: mid...
middle
text -> END
- else: els...
(notLabel) else
-> switch
}
~ foo = not foo
-> not_a_label
== switch
{ x:
- 0: zero
- 1: one
- 2: two
- else: lots -> END
}
~ x++
-> switch

View file

@ -0,0 +1,23 @@
Hello!
False!
True!
>) Answer
True!
Text here.
Badaboom!
...again. I love you.
And what about you?
>) Choice
Choice text x = 0 #success
suc...
success #success
els...
(notLabel) else
zero
one
two
lots

View file

@ -0,0 +1,26 @@
CONST STRING_EXAMPLE = "This is the string constant"
CONST BOOLEAN_EXAMLPE_TRUE = true
CONST BOOLEAN_EXAMLPE_FALSE = false
CONST LOBBY = 1
CONST STAIRCASE = 2
CONST HALLWAY = 3
CONST HELD_BY_AGENT = -1
VAR secret_agent_location = LOBBY
VAR suitcase_location = HALLWAY
{ STRING_EXAMPLE }. One is { BOOLEAN_EXAMLPE_TRUE }. Zero is { BOOLEAN_EXAMLPE_FALSE }.
-> report_progress
=== report_progress ===
{ secret_agent_location == suitcase_location:
The secret agent grabs the suitcase!
~ suitcase_location = HELD_BY_AGENT
- secret_agent_location < suitcase_location:
The secret agent moves forward.
~ secret_agent_location++
-> report_progress
}

View file

@ -0,0 +1,4 @@
This is the string constant. One is 1. Zero is 0.
The secret agent moves forward.
The secret agent moves forward.
The secret agent grabs the suitcase!

View file

@ -0,0 +1 @@
The \| dark \{grass\} is soft under your feet.

View file

@ -0,0 +1 @@
The | dark {grass} is soft under your feet.

View file

@ -0,0 +1,7 @@
VAR friendly_name_of_player = "Katy"
VAR age = "32"
VAR dalmatians_count = 101
My friends call me {friendly_name_of_player}. I'm { age } years old.
Wow, 2 x 2 = { 2 * 2 }!
I have { dalmatians_count / 101 } dalmatian.

View file

@ -0,0 +1,3 @@
My friends call me Katy. I'm 32 years old.
Wow, 2 x 2 = 4!
I have 1 dalmatian.

View file

@ -0,0 +1,23 @@
{foo(): ->continue}
something wrong 1
-(continue)
{boo():
something wrong 2
}
sum: {test(2, 3)}
-> END
=== function test(a, b) ===
first param is {a}
second param is {b}
~return a + b
=== function foo() ===
~return true
=== function boo() ===
~return

View file

@ -0,0 +1,3 @@
first param is 2
second param is 3
sum: 5

View file

@ -0,0 +1,7 @@
I looked at Monsieur Fogg
* ... and I could contain myself no longer.
'What is the purpose of our journey, Monsieur?'
'A wager,' he replied.
-
we passed the day in silence.
-> END

View file

@ -0,0 +1,8 @@
I looked at Monsieur Fogg
>) ... and I could contain myself no longer.
... and I could contain myself no longer.
'What is the purpose of our journey, Monsieur?'
'A wager,' he replied.
we passed the day in silence.

View file

@ -0,0 +1,3 @@
INCLUDE text-line.ink
A line after a line

View file

@ -0,0 +1,2 @@
Hello, world! I am a string.
A line after a line

View file

@ -0,0 +1,13 @@
-> back_in_london
== back_in_london ===
We arrived into London at 9.45pm exactly.
-> hurry_home
=== hurry_home =
We hurried home to Savile Row -> as_fast_as_we_could
====== as_fast_as_we_could
as fast as we could. -> END

View file

@ -0,0 +1,2 @@
We arrived into London at 9.45pm exactly.
We hurried home to Savile Row as fast as we could.

View file

@ -0,0 +1,32 @@
-> meet_guard
=== meet_guard ===
The guard frowns at you.
* (greet) [Greet him]
'Greetings.'
* (get_out) 'Get out of my way[.'],' you tell the guard.
- 'Hmm,' replies the guard.
* {greet} 'Having a nice day?' // only if you greeted him
* 'Hmm?'[] you reply.
* {get_out} [Shove him aside] // only if you threatened him
You shove him sharply. He stares in reply, and draws his sword!
-> fight_guard // this route diverts out of the weave
- 'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?'
=== fight_guard ===
-> throw_something
= throw_something
* (rock) [Throw rock at guard] -> throw
* (sand) [Throw sand at guard] -> throw
= throw
You hurl {throw_something.rock:a rock|a handful of sand} at the guard.

View file

@ -0,0 +1,13 @@
The guard frowns at you.
>) Greet him
2) 'Get out of my way.'
'Greetings.'
'Hmm,' replies the guard.
>) 'Having a nice day?'
2) 'Hmm?'
'Having a nice day?'
'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?'

View file

@ -0,0 +1,13 @@
The guard frowns at you.
>) Greet him
2) 'Get out of my way.'
'Greetings.'
'Hmm,' replies the guard.
1) 'Having a nice day?'
>) 'Hmm?'
'Hmm?' you reply.
'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?'

View file

@ -0,0 +1,13 @@
The guard frowns at you.
1) Greet him
>) 'Get out of my way.'
'Get out of my way,' you tell the guard.
'Hmm,' replies the guard.
>) 'Hmm?'
2) Shove him aside
'Hmm?' you reply.
'Mff,' the guard replies, and then offers you a paper bag. 'Toffee?'

View file

@ -0,0 +1,17 @@
The guard frowns at you.
1) Greet him
>) 'Get out of my way.'
'Get out of my way,' you tell the guard.
'Hmm,' replies the guard.
1) 'Hmm?'
>) Shove him aside
You shove him sharply. He stares in reply, and draws his sword!
>) Throw rock at guard
2) Throw sand at guard
You hurl a rock at the guard.

View file

@ -0,0 +1,17 @@
The guard frowns at you.
1) Greet him
>) 'Get out of my way.'
'Get out of my way,' you tell the guard.
'Hmm,' replies the guard.
1) 'Hmm?'
>) Shove him aside
You shove him sharply. He stares in reply, and draws his sword!
1) Throw rock at guard
>) Throw sand at guard
You hurl a handful of sand at the guard.

View file

@ -0,0 +1,22 @@
-> dialog_start
==dialog_start==
* [Tell me about the mister]
- - (dima)
- - His name is Dima.
* * {not vika} [Tell me about Vika]
-> vika
* * [Finish conversation]
-> stop_dialog
* [Tell me about the missis]
- - (vika)
Her name is Vika.
* * {not dima} [Tell me about Dima]
-> dima
* * [Finish conversation]
-> stop_dialog
==stop_dialog==
That's all.
-> END

View file

@ -0,0 +1,14 @@
>) Tell me about the mister
2) Tell me about the missis
His name is Dima.
>) Tell me about Vika
2) Finish conversation
Her name is Vika.
>) Finish conversation
That's all.

View file

@ -0,0 +1,10 @@
>) Tell me about the mister
2) Tell me about the missis
His name is Dima.
1) Tell me about Vika
>) Finish conversation
That's all.

Some files were not shown because too many files have changed in this diff Show more