chroma_solstice/libs/narrator/narrator/story.lua
Your Name 03532d29e4 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>
2026-08-11 11:37:09 -04:00

1253 lines
No EOL
33 KiB
Lua

--
-- Dependencies
local classic = require('narrator.libs.classic')
local lume = require('narrator.libs.lume')
local enums = require('narrator.enums')
local list_mt = require('narrator.list.mt')
--
-- Story
---@class Narrator.Story
---@field global_tags string[]
---@field constants table<string, any>
---@field variables table<string, any>
---@field migrate fun(state: Narrator.State, old_version: number, new_version: number):Narrator.State
---@field private tree any
---@field private lists any
---@field private params any
---@field private list_mt any
---@field private version any
---@field private functions any
---@field private observers any
---@field private temp any
---@field private seeds any
---@field private choices any
---@field private paragraphs any
---@field private output any
---@field private visits any
---@field private current_path any
---@field private is_over any
---@field private tunnels any
---@field private stack any
---@field private debug_seed any
---@field private return_value any
local story = classic:extend()
--
-- Initialization
---@private
---@param book Narrator.Book
function story:new(book)
self.tree = book.tree
self.constants = book.constants
self.variables = lume.clone(book.variables)
self.lists = book.lists
self.params = book.params
self.list_mt = list_mt
self.list_mt.lists = self.lists
self.version = book.constants.version or 0
---@param state Narrator.State
---@param old_version number
---@param new_version number
---@return Narrator.State
self.migrate = function(state, old_version, new_version) return state end
self.functions = self:ink_functions()
self.observers = { }
self.global_tags = self:get_tags()
self.temp = { }
self.seeds = { }
self.choices = { }
self.paragraphs = { }
self.output = { }
self.visits = { }
self.current_path = nil
self.is_over = false
self.tunnels = { }
self.stack = { }
end
--
-- Public
---Start a story
---Generate the first chunk of paragraphs and choices
function story:begin()
if #self.paragraphs > 0 or #self.choices > 0 then
return
end
self:jump_path('_')
end
---Does the story have paragraphs to output or not
---@return boolean can_continue
function story:can_continue()
return #self.paragraphs > 0
end
---Pull the current paragraphs from the queue.
---@param steps number|nil Count of paragraphs to pull
---@return Narrator.Paragraph[]
function story:continue(steps)
local lines = { }
if not self:can_continue() then
return lines
end
local steps = steps or 0
local single_mode = steps == 1
steps = steps > 0 and steps or #self.paragraphs
steps = steps > #self.paragraphs and #self.paragraphs or steps
for index = 1, steps do
local paragraph = self.paragraphs[index]
paragraph.text = paragraph.text:gsub('^%s*(.-)%s*$', '%1')
table.insert(lines, paragraph)
table.insert(self.output, paragraph)
end
for _ = 1, steps do
table.remove(self.paragraphs, 1)
end
return single_mode and lines[1] or lines
end
---Does the story have choices to output or not.
---Also returns false if there are available paragraphs to continue.
---@return boolean can_choose
function story:can_choose()
return self.choices ~= nil and #self.choices > 0 and not self:can_continue()
end
---Returns an array of available choice titles.
---Also returns an empty array if there are available paragraphs to continue.
---@return Narrator.Choice[]
function story:get_choices()
local choices = { }
if self:can_continue() then
return choices
end
for _, choice in ipairs(self.choices) do
local model = {
text = choice.title,
tags = choice.tags
}
table.insert(choices, model)
end
return choices
end
---Make a choice to continue the story.
---@param index number an index of the choice
function story:choose(index)
if self:can_continue() then
return
end
if #self.tunnels > 0 then
self.tunnels[#self.tunnels].restore = true
-- we are moving to another context, so the last one should be restored on exit from tunnel
end
local choice_is_available = index > 0 and index <= #self.choices
assert(choice_is_available, 'Choice index ' .. index .. ' out of bounds 1-' .. #self.choices)
local choice = self.choices[index]
assert(choice, 'Choice index ' .. index .. ' out of bounds 1-' .. #self.choices)
self.paragraphs = { }
self.choices = { }
if choice.text and #choice.text > 0 then
local paragraph = {
text = choice.text,
tags = choice.tags
}
table.insert(self.paragraphs, paragraph)
end
self:visit(choice.path)
if choice.divert ~= nil then
if choice.divert.tunnel then
local context = { path = choice.path, restore = true, previous = self.current_path }
table.insert(self.tunnels, context)
end
self:jump_path(choice.divert.path)
else
self:read_path(choice.path)
end
end
---Jump to the path
---@param path_string string a path string like 'knot.stitch.label'
function story:jump_to(path_string)
self:jump_path(path_string)
end
---Get the number of visits for the path.
---@param path_string string a path string like 'knot.stitch.label'
---@return integer
function story:get_visits(path_string)
return self:get_visits_with_context(path_string)
end
---Get tags for the path
---@param path_string string|nil a path string with knot or stitch
---@return string[]
function story:get_tags(path_string)
local path = self:path_from_string(path_string)
local items = self:items_for(path.knot, path.stitch)
local tags = { }
for _, item in ipairs(items) do
if type(item) == 'table' and lume.count(item) > 1 or item.tags == nil then
break
end
local item_tags = type(item.tags) == 'string' and { item.tags } or item.tags
tags = lume.concat(tags, item_tags)
end
return tags
end
---Creates a table with the story state that can be saved and loaded later.
---Use it to save the game.
---@return Narrator.State
function story:save_state()
local state = {
version = self.version,
temp = self.temp,
seeds = self.seeds,
variables = self.variables,
params = self.params,
visits = self.visits,
path = self.current_path,
paragraphs = self.paragraphs,
choices = self.choices,
output = self.output,
tunnels = self.tunnels
}
return state
end
---Restore the story state from the saved state.
---Use it to load the game.
---@param state Narrator.State
function story:load_state(state)
if self.version ~= state.version then
state = self.migrate(state, state.version, self.version)
end
self.temp = state.temp
self.seeds = state.seeds
self.variables = state.variables
self.params = state.params or { }
self.visits = state.visits
self.current_path = state.path
self.paragraphs = state.paragraphs
self.choices = state.choices
self.output = state.output
self.tunnels = state.tunnels or { }
end
---Assign an observer function to the variable's changes.
---@param variable string
---@param observer fun(variable)
function story:observe(variable, observer)
self.observers[variable] = observer
end
---Bind a function to external calling from the Ink.
---The function can returns the value or not.
---@param func_name string
---@param handler fun(...):any
function story:bind(func_name, handler)
self.functions[func_name] = handler
end
--
-- Private
---@private
function story:path_chain_for_label(path)
local label = path.label
local items = self:items_for(path.knot, path.stitch)
-- TODO: Find a more smart solution to divert to labels
-- TODO: This works but... isn't good.
local function find_label_chain_in_items(items)
if type(items) ~= 'table' then
return nil
end
for index, item in ipairs(items) do
if item.label == label then
return { index }
elseif item.node ~= nil then
local result = find_label_chain_in_items(item.node)
if result ~= nil then
table.insert(result, 1, index)
return result
end
elseif item.success ~= nil then
if type(item.success) == 'table' then
local is_switch = item.success[1] ~= nil and item.success[1][1] ~= nil
local cases = is_switch and item.success or { item.success }
for case_index, case in ipairs(cases) do
local result = find_label_chain_in_items(case)
if result ~= nil then
table.insert(result, 1, 't' .. case_index)
table.insert(result, 1, index)
return result
end
end
end
if type(item.failure) == 'table' then
local result = find_label_chain_in_items(item.failure)
if result ~= nil then
table.insert(result, 1, 'f')
table.insert(result, 1, index)
return result
end
end
end
end
return nil
end
local chain = find_label_chain_in_items(items)
assert(chain, 'Label \'' ..path.label .. '\' not found')
return chain
end
---@private
function story:jump_path(path_string, params)
assert(path_string, 'The path_string can\'t be nil')
self.choices = { }
if path_string == 'END' or path_string == 'DONE' then
self.is_over = true
return
end
local path = self:path_from_string(path_string, self.current_path)
if path.label ~= nil then
path.chain = self:path_chain_for_label(path)
end
return self:read_path(path, params)
end
---@private
function story:read_path(path, params)
assert(path, 'The reading path can\'t be nil')
if self.is_over then
return
end
-- Visit only the paths without labels.
-- Items with labels will increment visits counter by themself in read_items().
if not path.label then
self:visit(path)
end
if params then
for name, value in pairs(params) do
self:assign_value_to(name, value, true)
end
end
local items = self:items_for(path.knot, path.stitch)
return self:read_items(items, path)
end
---@private
function story:items_for(knot, stitch)
local root_node = self.tree
local knot_node = knot == nil and root_node._ or root_node[knot]
assert(knot_node or lume.isarray(root_node), 'The knot \'' .. (knot or '_') .. '\' not found')
local stitch_node = stitch == nil and knot_node._ or knot_node[stitch]
assert(stitch_node or lume.isarray(knot_node), 'The stitch \'' .. (knot or '_') .. '.' .. (stitch or '_') .. '\' not found')
return stitch_node or knot_node or root_node
end
---@private
function story:read_items(items, path, depth, mode, current_index)
assert(items, 'Items can\'t be nil')
assert(path, 'Path can\'t be nil')
local chain = path.chain or { }
local depth = depth or 0
local deep_index = chain[depth + 1]
local mode = mode or enums.read_mode.text
-- Deep path factory
local make_deep_path = function(values, label_prefix)
local deep_chain = lume.slice(chain, 1, depth)
for values_index, value in ipairs(values) do
deep_chain[depth + values_index] = value
end
local deep_path = lume.clone(path)
deep_path.chain = deep_chain
if label_prefix then
deep_path.label = label_prefix .. table.concat(deep_chain, '.')
end
return deep_path
end
-- Iterate items
for index = current_index or (deep_index or 1), #items do
local context = {
items = items,
path = path,
depth = depth,
mode = mode,
index = index + 1,
previous = self.current_path
}
local item = items[index]
local skip = false
if item.return_value then
self.return_value = tostring(item.return_value)
return enums.read_mode.quit
end
local item_type = enums.item.text
if type(item) == 'table' then
if item.choice ~= nil then
item_type = enums.item.choice
elseif item.success ~= nil then
item_type = enums.item.condition
elseif item.var ~= nil then
item_type = enums.item.variable
elseif item.alts ~= nil then
item_type = enums.item.alts
end
end
-- Go deep
if index == deep_index then
if item_type == enums.item.choice and item.node ~= nil then
-- Go deep to the choice node
mode = enums.read_mode.gathers
mode = self:read_items(item.node, path, depth + 1) or mode
elseif item_type == enums.item.condition then
-- Go deep to the condition node
local chain_value = chain[depth + 2]
local is_success = chain_value:sub(1, 1) == 't'
local node
if is_success then
local success_index = tonumber(chain_value:sub(2, 2)) or 0
node = success_index > 0 and item.success[success_index] or item.success
else
node = item.failure
end
mode = self:read_items(node, path, depth + 2, mode) or mode
end
if item_type == enums.item.condition or item_type == enums.item.choice then
mode = mode ~= enums.read_mode.quit and enums.read_mode.gathers or mode
skip = true
end
end
-- Check the situation
if mode == enums.read_mode.choices and item_type ~= enums.item.choice then
mode = enums.read_mode.quit
skip = true
elseif mode == enums.read_mode.gathers and item_type == enums.item.choice then
skip = true
end
-- Read the item
if skip then
-- skip
elseif item_type == enums.item.text then
mode = enums.read_mode.text
local safe_item = type(item) == 'string' and { text = item } or item
mode = self:read_text(safe_item, context) or mode
elseif item_type == enums.item.alts then
mode = enums.read_mode.text
local deep_path = make_deep_path({ index }, '~')
mode = self:read_alts(item, deep_path, depth + 1, mode) or mode
elseif item_type == enums.item.choice and self:check_condition(item.condition) then
mode = enums.read_mode.choices
local deep_path = make_deep_path({ index }, '>')
deep_path.label = item.label or deep_path.label
mode = self:read_choice(item, deep_path) or mode
if index == #items and type(chain[#chain]) == 'number' then
mode = enums.read_mode.quit
end
elseif item_type == enums.item.condition then
local result, chain_value
if type(item.condition) == 'string' then
local success = self:check_condition(item.condition)
result = success and item.success or (item.failure or { })
chain_value = success and 't' or 'f'
elseif type(item.condition) == 'table' then
local success = self:check_switch(item.condition)
result = success > 0 and item.success[success] or (item.failure or { })
chain_value = success > 0 and ('t' .. success) or 'f'
end
if type(result) == 'string' then
mode = enums.read_mode.text
mode = self:read_text({ text = result }, context) or mode
elseif type(result) == 'table' then
local deep_path = make_deep_path({ index, chain_value })
mode = self:read_items(result, deep_path, depth + 2, mode) or mode
end
elseif item_type == enums.item.variable then
self:assign_value_to(item.var, item.value, item.temp)
end
-- Read the label
if item.label ~= nil and item_type ~= enums.item.choice and not skip then
local label_path = lume.clone(path)
label_path.label = item.label
self:visit(label_path)
end
if mode == enums.read_mode.quit then
break
end
end
if depth == 0 then
for index = #self.paragraphs, 1, -1 do
local paragraph = self.paragraphs[index]
if (not paragraph.text or #paragraph.text == 0) and (not paragraph.tags or #paragraph.tags == 0) then
-- Remove safe prefixes and suffixes of failured inline conditions
table.remove(self.paragraphs, index)
else
-- Remove <> tail from unexpectedly broken paragraphs
paragraph.text = paragraph.text:match('(.-)%s*<>$') or paragraph.text
end
end
end
return mode
end
---@private
function story:read_text(item, context)
local text = item.text
local tags = type(item.tags) == 'string' and { item.tags } or item.tags
local paragraphs = #self.stack == 0 and self.paragraphs or self.stack[#self.stack]
if text ~= nil or tags ~= nil then
local paragraph = { text = text or '<>', tags = tags }
local stack
paragraph.text, stack = self:replace_expressions(paragraph.text)
paragraph.text = paragraph.text:gsub('%s+', ' ')
table.insert(stack, paragraph)
for _, paragraph in ipairs(stack) do
local glued_by_prev = #paragraphs > 0 and paragraphs[#paragraphs].text:sub(-2) == '<>'
local glued_by_this = text ~= nil and text:sub(1, 2) == '<>'
if glued_by_prev then
local prev_paragraph = paragraphs[#paragraphs]
prev_paragraph.text = prev_paragraph.text:sub(1, #prev_paragraph.text - 2)
paragraphs[#paragraphs] = prev_paragraph
end
if glued_by_this then
paragraph.text = paragraph.text:sub(3)
end
if glued_by_prev or (glued_by_this and #paragraphs > 0) then
local prev_paragraph = paragraphs[#paragraphs]
prev_paragraph.text = (prev_paragraph.text .. paragraph.text):gsub('%s+', ' ')
prev_paragraph.tags = lume.concat(prev_paragraph.tags, paragraph.tags)
prev_paragraph.tags = #prev_paragraph.tags > 0 and prev_paragraph.tags or nil
paragraphs[#paragraphs] = prev_paragraph
else
table.insert(paragraphs, #paragraphs + 1, paragraph)
end
end
end
if item.divert ~= nil then
if item.divert.tunnel then
table.insert(self.tunnels, context)
end
local mode = self:jump_path(item.divert.path)
if item.divert.tunnel then
return (mode == enums.read_mode.quit and #self.choices == 0) and enums.read_mode.text or mode
end
return enums.read_mode.quit
end
if item.exit then
local context = assert(table.remove(self.tunnels), 'Tunnel stack is empty')
self.current_path = context.previous
if context.restore then
if context.items == nil then
self:read_path(context.path)
return enums.read_mode.quit
end
self:read_items(context.items, context.path, context.depth, context.mode, context.index)
return enums.read_mode.quit
end
return enums.read_mode.text
end
end
---@private
function story:read_alts(item, path, depth, mode)
assert(item.alts, 'Alternatives can\'t be nil')
local alts = lume.clone(item.alts)
local sequence = item.sequence or enums.sequence.stopping
if type(sequence) == 'string' then
sequence = enums.sequence[item.sequence]
end
self:visit(path)
local visits = self:get_visits_for_path(path)
local index = 0
if item.shuffle then
local seed_key = (path.knot or '_') .. '.' .. (path.stitch or '_') .. ':' .. path.label
local seed = visits % #alts == 1 and (self.debug_seed or os.time() * 1000) or self.seeds[seed_key]
self.seeds[seed_key] = seed
for index, alt in ipairs(alts) do
math.randomseed(seed + index)
local pair_index = index < #alts and math.random(index, #alts) or index
alts[index] = alts[pair_index]
alts[pair_index] = alt
end
end
if sequence == enums.sequence.cycle then
index = visits % #alts
index = index > 0 and index or #alts
elseif sequence == enums.sequence.stopping then
index = visits < #alts and visits or #alts
elseif sequence == enums.sequence.once then
index = visits
end
local alt = index <= #alts and alts[index] or { }
local items = type(alt) == 'string' and { alt } or alt
return self:read_items(items, path, depth, mode)
end
---@private
function random_seed()
end
---@private
function story:read_choice(item, path)
local is_fallback = item.choice == 0
if is_fallback then
-- Works correctly only when a fallback is the last choice
if #self.choices == 0 then
if item.divert ~= nil then
self:jump_path(item.divert.path)
else
self:read_path(path)
end
end
return enums.read_mode.quit
end
local title = self:replace_expressions(item.choice)
title = title:match('(.-)%s*<>$') or title
local choice = {
title = title,
text = item.text ~= nil and self:replace_expressions(item.text) or title,
divert = item.divert,
tags = item.tags,
path = path
}
if item.sticky or self:get_visits_for_path(path) == 0 then
table.insert(self.choices, #self.choices + 1, choice)
end
end
-- Expressions
---@private
function story:replace_expressions(text)
local stack = { }
local replaced = text:gsub('%b##', function(match)
if #match == 2 then
return '#'
else
local result
result, stack = self:do_expression(match:sub(2, #match - 1))
if type(result) == 'table' then
result = self.list_mt.__tostring(result)
elseif type(result) == 'boolean' then
result = result and 1 or 0
elseif type(result) == 'number' then
result = tostring(result)
if result:sub(-2) == '.0' then
result = result:sub(1, -3)
end
elseif result == nil then
result = ''
end
return result
end
end)
return replaced, stack
end
---@private
function story:check_switch(conditions)
for index, condition in ipairs(conditions) do
if self:check_condition(condition) then
return index
end
end
return 0
end
---@private
function story:check_condition(condition)
if condition == nil then
return true
end
local result, stack = self:do_expression(condition)
for _, paragraph in ipairs(stack) do
table.insert(self.paragraphs, paragraph)
end
if type(result) == 'table' and not next(result) then
result = nil
end
return result ~= nil and result ~= false
end
---@private
function story:do_expression(expression)
assert(type(expression) == 'string', 'Expression must be a string')
local code = ''
local lists = { }
local stack = { }
-- Replace operators
expression = expression:gsub('!=', '~=')
expression = expression:gsub('%s*||%s*', ' or ')
expression = expression:gsub('%s*%&%&%s*', ' and ')
expression = expression:gsub('%s+has%s+', ' ? ')
expression = expression:gsub('%s+hasnt%s+', ' !? ')
expression = expression:gsub('!%s*%w', ' not ')
-- Replace functions results
expression = expression:gsub('[%a_][%w_]*%b()', function(match)
local func_name = match:match('([%a_][%w_]*)%(')
local params_string = match:match('[%a_][%w_]*%((.+)%)')
local params = params_string ~= nil and lume.map(lume.split(params_string, ','), lume.trim) or nil
for index, param in ipairs(params or { }) do
params[index] = self:do_expression(param)
end
local func = self.functions[func_name]
if func ~= nil then
local value = func((table.unpack or unpack)(params or { }))
if type(value) == 'table' then
lists[#lists + 1] = value
return '__list' .. #lists
else
return lume.serialize(value)
end
elseif self.lists[func_name] ~= nil then
local index = params and params[1] or 0
local item = self.lists[func_name][index]
local list = item and { [func_name] = { [item] = true } } or { }
lists[#lists + 1] = list
return '__list' .. #lists
else
self.return_value = nil
local func_params = { }
local path = self.current_path
if params then
for i, value in ipairs(params) do
func_params[self.params[func_name][i]] = tostring(value)
end
end
table.insert(self.stack, { })
self:jump_path(func_name, func_params)
self.current_path = path
for _, paragraph in ipairs(table.remove(self.stack)) do
table.insert(stack, paragraph)
end
return self.return_value
end
end)
-- Replace lists
expression = expression:gsub('%(([%s%w%.,_]*)%)', function(match)
local list = self:make_list_for(match)
if list ~= nil then
lists[#lists + 1] = list
return '__list' .. #lists
else
return 'nil'
end
end)
-- Store strings to the bag before to replace variables
-- otherwise it can replace strings inside quotes to nils.
-- Info: Ink doesn't interpret single quotes '' as string expression value
local strings_bag = { }
expression = expression:gsub('%b\"\"', function(match)
table.insert(strings_bag, match)
return '#' .. #strings_bag .. '#'
end)
-- Replace variables
expression = expression:gsub('[%a_][%w_%.]*', function(match)
local exceptions = { 'and', 'or', 'true', 'false', 'nil', 'not'}
if lume.find(exceptions, match) or match:match('__list%d*') then
return match
else
local value = self:get_value_for(match)
if type(value) == 'table' then
lists[#lists + 1] = value
return '__list' .. #lists
else
return lume.serialize(value)
end
end
end)
-- Replace with math results
expression = expression:gsub('[%a_#][%w_%.#]*[%s]*[%?!]+[%s]*[%a_#][%w_%.#]*', function(match)
local lhs, operator, rhs = match:match('([%a_#][%w_%.#]*)[%s]*([%!?]+)[%s]*([%a_#][%w_%.#]*)')
if lhs:match('__list%d*') then
return lhs .. ' % ' .. rhs .. (operator == '?' and ' == true' or ' == false')
else
return 'string.match(' .. lhs .. ', ' .. rhs .. ')' .. (operator == '?' and ' ~= nil' or ' == nil')
end
end)
-- Restore strings after variables replacement
expression = expression:gsub('%b##', function(match)
local index = tonumber(match:sub(2, -2))
return strings_bag[index or 0]
end)
-- Attach the metatable to list tables
if #lists > 0 then
code = code .. 'local mt = require(\'narrator.list.mt\')\n'
code = code .. 'mt.lists = ' .. lume.serialize(self.lists) .. '\n\n'
for index, list in pairs(lists) do
local name = '__list' .. index
code = code .. 'local ' .. name .. ' = ' .. lume.serialize(list) .. '\n'
code = code .. 'setmetatable(' .. name .. ', mt)\n\n'
end
end
code = code .. 'return ' .. expression
return lume.dostring(code), stack
end
-- Variables
---@private
function story:assign_value_to(variable, expression, temp)
if self.constants[variable] ~= nil then
return
end
local value = self:do_expression(expression)
if #variable == 0 then
return
end
local storage = (temp or self.temp[variable] ~= nil) and self.temp or self.variables
if storage[variable] == value then
return
end
storage[variable] = value
local observer = self.observers[variable]
if observer ~= nil then
observer(value)
end
end
---@private
function story:get_value_for(variable)
local result = self.temp[variable]
if result == nil then
result = self.variables[variable]
end
if result == nil then
result = self.constants[variable]
end
if result == nil then
result = self:make_list_for(variable)
end
if result == nil then
local visits = self:get_visits_with_context(variable, self.current_path)
result = visits > 0 and visits or nil
end
return result
end
-- Lists
---@private
function story:make_list_for(expression)
local result = { }
if not expression:find('%S') then
return result
end
local items = lume.array(expression:gmatch('[%w_%.]+'))
for _, item in ipairs(items) do
local list_name, item_name = self:get_list_name_for(item)
if list_name ~= nil and item_name ~= nil then
result[list_name] = result[list_name] or { }
result[list_name][item_name] = true
end
end
return next(result) ~= nil and result or nil
end
---@private
function story:get_list_name_for(name)
local list_name, item_name = name:match('([%w_]+)%.([%w_]+)')
item_name = item_name or name
if list_name == nil then
for key, list in pairs(self.lists) do
for _, string in ipairs(list) do
if string == item_name then
list_name = key
break
end
end
end
end
local not_found = list_name == nil or self.lists[list_name] == nil
if not_found then
return nil
end
return list_name, item_name
end
-- Visits
---@private
function story:visit(path)
local path_is_changed = self.current_path == nil or path.knot ~= self.current_path.knot or path.stitch ~= self.current_path.stitch
if path_is_changed then
if self.current_path == nil or path.knot ~= self.current_path.knot then
local knot = path.knot or '_'
local visits = self.visits[knot] or { _root = 0 }
visits._root = visits._root + 1
self.visits[knot] = visits
end
local knot, stitch = path.knot or '_', path.stitch or '_'
local visits = self.visits[knot][stitch] or { _root = 0 }
visits._root = visits._root + 1
self.visits[knot][stitch] = visits
end
if path.label ~= nil then
local knot, stitch, label = path.knot or '_', path.stitch or '_', path.label
self.visits[knot] = self.visits[knot] or { _root = 1, _ = { _root = 1 } }
self.visits[knot][stitch] = self.visits[knot][stitch] or { _root = 1 }
local visits = self.visits[knot][stitch][label] or 0
visits = visits + 1
self.visits[knot][stitch][path.label] = visits
end
self.current_path = lume.clone(path)
self.current_path.label = nil
self.temp = path_is_changed and { } or self.temp
end
---@private
function story:get_visits_for_path(path)
if path == nil then
return 0
end
local knot, stitch, label = path.knot or '_', path.stitch, path.label
if stitch == nil and label ~= nil then
stitch = '_'
end
local knot_visits = self.visits[knot]
if knot_visits == nil then
return 0
elseif stitch == nil then
return knot_visits._root or 0
end
local stitch_visits = knot_visits[stitch]
if stitch_visits == nil then
return 0
elseif label == nil then
return stitch_visits._root or 0
end
local label_visits = stitch_visits[label]
return label_visits or 0
end
---@private
function story:get_visits_with_context(path_string, context)
local path = self:path_from_string(path_string, context)
local visits_count = self:get_visits_for_path(path)
return visits_count
end
---@private
function story:path_from_string(path_string, context)
local path_string = path_string or ''
local context_knot = context and context.knot
local context_stitch = context and context.stitch
context_knot = context_knot or '_'
context_stitch = context_stitch or '_'
-- Try to parse 'part1.part2.part3'
local part1, part2, part3 = path_string:match('([%w_]+)%.([%w_]+)%.([%w_]+)')
if not part1 then
-- Try to parse 'part1.part2'
part1, part2 = path_string:match('([%w_]+)%.([%w_]+)')
end
if not part1 then
-- Try to parse 'part1'
part1 = #path_string > 0 and path_string or nil
end
local path = { }
if not part1 then
-- Path is empty
return path
end
if part3 then
-- Path is 'part1.part2.part3'
path.knot = part1
path.stitch = part2
path.label = part3
return path
end
if part2 then
-- Path is 'part1.part2'
if self.tree[part1] and self.tree[part1][part2] then
-- Knot 'part1' and stitch 'part2' exist so return part1.part2
path.knot = part1
path.stitch = part2
return path
end
if self.tree[context_knot][part1] then
-- Stitch 'part1' exists so return context_knot.part1.part2
path.knot = context_knot
path.stitch = part1
path.label = part2
return path
end
if self.tree[part1] then
-- Knot 'part1' exists so seems it's a label with a root stitch
path.knot = part1
path.stitch = '_'
path.label = part2
return path
end
if self.tree._[part1] then
-- Root stitch 'part1' exists so return _.part1.part2
path.knot = '_'
path.stitch = part1
path.label = part2
return path
end
end
if part1 then
-- Path is 'part1'
if self.tree[context_knot][part1] then
-- Stitch 'part1' exists so return context_knot.part1
path.knot = context_knot
path.stitch = part1
return path
elseif self.tree[part1] then
-- Knot 'part1' exists so return part1
path.knot = part1
return path
else
-- Seems it's a label
path.knot = context_knot
path.stitch = context_stitch
path.label = part1
end
end
return path
end
-- Ink functions
---@private
function story:ink_functions()
return {
CHOICE_COUNT = function() return #self.choices end,
SEED_RANDOM = function(seed) self.debug_seed = seed end,
POW = function(x, y) return math.pow and math.pow(x, y) or x ^ y end,
RANDOM = function(x, y)
math.randomseed(self.debug_seed or os.time() * 1000)
return math.random(x, y)
end,
INT = function(x) return math.floor(x) end,
FLOOR = function(x) return math.floor(x) end,
FLOAT = function(x) return x end,
-- TURNS = function() return nil end -- TODO
-- TURNS_SINCE = function(path) return nil end -- TODO
LIST_VALUE = function(list) return self.list_mt.first_raw_value_of(list) end,
LIST_COUNT = function(list) return self.list_mt.__len(list) end,
LIST_MIN = function(list) return self.list_mt.min_value_of(list) end,
LIST_MAX = function(list) return self.list_mt.max_value_of(list) end,
LIST_RANDOM = function(list)
math.randomseed(self.debug_seed or os.time() * 1000)
return self.list_mt.random_value_of(list)
end,
LIST_ALL = function(list) return self.list_mt.posible_values_of(list) end,
LIST_RANGE = function(list, min, max) return self.list_mt.range_of(list, min, max) end,
LIST_INVERT = function(list) return self.list_mt.invert(list) end
}
end
return story