From 799885405785762105a60f420bd1bd14a5c325b0 Mon Sep 17 00:00:00 2001 From: gudzpoz Date: Wed, 5 Jun 2024 16:35:47 +0800 Subject: [PATCH] feat(vm): export read stats --- docs/roadmap.md | 7 ++-- vm/spec/vm_spec.lua | 78 ++++++++++++++++++++++++++++++------- vm/src/mdvm/env_api.lua | 39 ++++++++++++------- vm/src/mdvm/history.lua | 47 +++++++++++++++------- vm/src/mdvm/savedata.lua | 2 + vm/src/mdvm/stacked_env.lua | 23 +++++++++-- vm/src/mdvm/vm.lua | 48 +++++++++++++++++------ 7 files changed, 185 insertions(+), 59 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 389b0e9..c80c43e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -71,10 +71,9 @@ Markdown compiler implementation: - [ ] Save & load. - [X] API. - [ ] Examples? Or detailed Lua documentation. -- [ ] Fast-forward (skipping only texts that users have read). - - [ ] Instead, we should probably export read counts along with the lines - to let the user / the outer game engine decide whether to skip lines - (or even display some relevant text / highlighting). +- [X] Export read counts along with the lines to let the user + / the outer game engine decide whether to skip lines + (or even display some relevant text / highlighting). - [ ] Complete porting The Intercept, which is a little bit lengthy. ### `v0.4.0` diff --git a/vm/spec/vm_spec.lua b/vm/spec/vm_spec.lua index fee8709..fdf8eef 100644 --- a/vm/spec/vm_spec.lua +++ b/vm/spec/vm_spec.lua @@ -3,6 +3,8 @@ local utils = require("spec.test_utils") local StackedEnv = require("mdvm.stacked_env") local TablePath = require("mdvm.table_path") +assert:set_parameter("TableFormatLevel", -1) + --- @param root table local function wrap(root) local vm = brocatel.VM._new({ @@ -127,8 +129,8 @@ describe("VM", function() local get = assert(vm.env:get("GET")) assert.is_nil(get(args, "k")) assert.equal("v", get(args:resolve(nil, nil, 2, "args"), "k")) - local visited = assert(vm.env:get("VISITS")) - assert.equal(1, visited(vm.env:get("a"))) + local visits = assert(vm.env:get("VISITS")) + assert.equal(1, visits(vm.env:get("a"))) assert.error(function() vm.env:set("GET", 0) end) assert.no_error(function() vm.env:set("_GET", 0) end) end @@ -138,8 +140,8 @@ describe("VM", function() assert.same({ "Hello", "end" }, utils.gather_til_end(vm)) assert.same({ main = { - { I = 1, R = { 0xe } }, - { args = { { I = 1, k = "v", R = { 2 } }, { { I = 1, R = { 2 } } } } }, + { I = 1, R = { 0xe } }, + { args = { { I = 1, k = "v" }, { { I = 1, R = { 2 } } } } }, } }, vm.savedata.stats) end) @@ -253,10 +255,11 @@ describe("VM", function() local output = assert(vm:next()) assert.same({ tags = true, + visited = false, select = { - { key = 2, option = { tags = true, text = "Selection #1" } }, - { key = 5, option = { tags = true, text = "Selection #3" } }, - { key = 6, option = { tags = true, text = "Selection #4" } }, + { key = 2, option = { tags = true, text = "Selection #1", visited = false } }, + { key = 5, option = { tags = true, text = "Selection #3", visited = false } }, + { key = 6, option = { tags = true, text = "Selection #4", visited = false } }, }, }, output) assert.equal("Result #3", vm:next(5).text) @@ -264,18 +267,20 @@ describe("VM", function() assert.same({ tags = true, + visited = true, select = { - { key = 2, option = { tags = true, text = "Selection #1" } }, - { key = 6, option = { tags = true, text = "Selection #4" } }, + { key = 2, option = { tags = true, text = "Selection #1", visited = false } }, + { key = 6, option = { tags = true, text = "Selection #4", visited = false } }, }, }, vm:next()) assert.equal("Result #4", vm:next(6).text) assert.equal("Hello", vm:next().text) assert.same({ tags = true, + visited = true, select = { - { key = 2, option = { tags = true, text = "Selection #1" } }, - { key = 6, option = { tags = true, text = "Selection #4" } }, + { key = 2, option = { tags = true, text = "Selection #1", visited = false } }, + { key = 6, option = { tags = true, text = "Selection #4", visited = true } }, }, }, vm:next()) assert.equal("Result #4", vm:next(6).text) @@ -283,8 +288,9 @@ describe("VM", function() assert.same({ tags = true, + visited = true, select = { - { key = 2, option = { tags = true, text = "Selection #1" } }, + { key = 2, option = { tags = true, text = "Selection #1", visited = false } }, }, }, vm:next()) assert.equal("Result #1", vm:next(2).text) @@ -312,7 +318,53 @@ describe("VM", function() assert.is_nil(vm:next()) end) - it("too many jumps", function () + it("text read count", function() + local vm + vm = wrap({ + { + labels = { + first = { 2 }, + } + }, + "Text1", + "Text2", + { + {}, + "Text3", + "Text4", + { + ---@param args TablePath + func = function(args) vm.env:get("FUNC").S_ONCE(args) end, + args = { + {}, + { {}, "Selection #1", "Result #1" }, + { {}, { function() return vm.env:get("RECUR")(1) end, + { {}, "Selection #2" } }, "Result #2" }, + }, + }, + }, + { + link = { "first" }, root_node = "main", + }, + }) + for i = 1, 4 do + assert.same({ tags = true, text = "Text" .. tostring(i), visited = false }, vm:next()) + end + assert.same({ tags = true, visited = false, select = { + { key = 2, option = { tags = true, text = "Selection #1", visited = false } }, + { key = 3, option = { tags = true, text = "Selection #2", visited = false } }, + }}, vm:next()) + assert.same({ tags = true, text = "Result #2", visited = false }, vm:next(3)) + for i = 1, 4 do + assert.same({ tags = true, text = "Text" .. tostring(i), visited = true }, vm:next()) + end + assert.same({ tags = true, visited = true, select = { + { key = 2, option = { tags = true, text = "Selection #1", visited = false } }, + { key = 3, option = { tags = true, text = "Selection #2", visited = true } }, + }}, vm:next()) + end) + + it("too many jumps", function() local vm = wrap({ { labels = { diff --git a/vm/src/mdvm/env_api.lua b/vm/src/mdvm/env_api.lua index 9ebd720..ee2ea16 100644 --- a/vm/src/mdvm/env_api.lua +++ b/vm/src/mdvm/env_api.lua @@ -6,11 +6,12 @@ local utils = require("mdvm.utils") ---@param self brocatel.VM return function (self) local env = self.env - local ip = assert(self:_get_coroutine()).ip local lua = env:get_lua_env() - lua.IP = ip lua.VM = self - + local function get_ip() + return assert(self:_get_coroutine()).ip + end + env:set_api("IP", get_ip, true) env:set_api("GET", function(path, key) path = self.env.is_label(path) and assert(self:lookup_label(path)) or path return history.get(self.savedata.stats, path, key) @@ -27,7 +28,7 @@ return function (self) end) env:set_api("END", function(path) - local IP = assert(env:get("IP")) + local ip = get_ip() -- For calls like `END()` or `END(true)` if not path or type(path) == "boolean" then -- Tries to return to the calling subroutine. @@ -35,16 +36,16 @@ return function (self) return end -- Otherwise (or when `END(true)` is called), terminates the story execution. - IP:set(TablePath.from({ IP[1] })) + ip:set(TablePath.from({ ip[1] })) return end -- For calls like `END({ "label", "path_name" })`, it breaks that array (which is probably a loop or something). path = self.env.is_label(path) and assert(self:lookup_label(path)) or path - assert(path:is_parent_to(IP)) + assert(path:is_parent_to(ip)) local root = assert(self:_ensure_root(path)) path = path:copy() path:step(root) - IP:set(path) + ip:set(path) end) env:set_api("EVAL", function(path, extra_env) @@ -56,7 +57,7 @@ return function (self) --- @param path table|TablePath local function visits(path) path = self.env.is_label(path) and assert(self:lookup_label(path)) or path - return history.get(self.savedata.stats, path, "I") or 0 + return history.get_recorded_count(self.savedata.stats, path) end env:set_api("VISITS", visits) env:set_api("VISITED", function(path) return visits(path) > 0 end) @@ -67,8 +68,9 @@ return function (self) local current = self.savedata.current local counts = history.get(self.savedata.stats, args, "S") or {} local root = assert(self:_ensure_root(args)) + local ip = get_ip() if current.input then - env:get("IP"):set(args:copy():resolve(current.input, 3)) + ip:set(args:copy():resolve(current.input, 3)) local count = counts[current.input] or 0 count = count + 1 counts[current.input] = count @@ -77,7 +79,7 @@ return function (self) return end - env:get("IP"):set(args:copy():resolve(nil)) + ip:set(args:copy():resolve(nil)) recur = recur or 0 assert(recur == true or recur >= 0) local selectables = {} --- @type Selectable[] @@ -106,10 +108,16 @@ return function (self) end }) end - local line, tags = self:eval_with_env(local_env, args:copy():resolve(i), utils.get_keys(inner)) + local path = args:copy():resolve(i) + local line, tags = self:eval_with_env( + local_env, + path, + utils.get_keys(inner) + ) if line and should_recur and tags then + local visited = counts[i] and counts[i] > 0 or false selectables[#selectables + 1] = { - option = { text = line, tags = tags }, + option = { text = line, tags = tags, visited = visited }, key = i, } end @@ -118,7 +126,12 @@ return function (self) ip:step(root) return nil, true end - current.output = { select = selectables, tags = true } + self.flags.redirected = true -- Stop _fetch_and_next from incrementing ip + current.output = { + select = selectables, + tags = true, + visited = history.get_recorded_count(self.savedata.stats, args:copy():resolve(nil)) > 0, + } end env:set_api("FUNC", { SELECT = user_select, diff --git a/vm/src/mdvm/history.lua b/vm/src/mdvm/history.lua index 36de06d..f469246 100644 --- a/vm/src/mdvm/history.lua +++ b/vm/src/mdvm/history.lua @@ -44,6 +44,7 @@ end --- Fetches a bit in a bit set. --- @param bitset table --- @param index number +--- @return boolean bit the bit function history.get_bit(bitset, index) local offset = (index - 1) % bits_per_number local i = (index - 1 - offset) / bits_per_number + 1 @@ -62,7 +63,7 @@ end --- @param save table --- @param path TablePath --- @param key string ---- @returns string|number|boolean|nil +--- @return string|number|boolean|table|nil function history.get(save, path, key) for _, segment in ipairs(path) do save = save[segment] @@ -73,7 +74,7 @@ function history.get(save, path, key) local meta = save[1] if meta then assert(type(meta) == "table") - return meta[key] + return meta[assert(key)] end return nil end @@ -91,7 +92,8 @@ function history.set(save, root, path, key, value) local node = path:get(root, 1) assert(node and node.func) else - assert(path:is_array(root)) + local is_array = path:is_array(root) + assert(is_array, tostring(path)) end for _, segment in ipairs(path) do save = makedir(save, segment) @@ -107,12 +109,37 @@ end --- Records the path change. --- --- - For text nodes, it simply marks the line as read. +--- @param save table +--- @param root table +--- @param path TablePath +function history.record_simple(save, root, path) + local old = path:copy() + local i = old[#old] + if type(i) ~= "number" then + return + end + if not old:get(root) or not old:is_array(root, 1) then + return + end + + old[#old] = nil + local reads = history.get(save, old, "R") + if type(reads) ~= "table" then + reads = {} + history.set(save, root, old, "R", reads) + end + old[#old + 1] = i + history.set_bit(reads, i) +end + +--- Records the path change. +--- --- - For arrays (probably with labeled ones), it increments their visited counter. --- @param save table --- @param root table --- @param old TablePath|nil --- @param new TablePath -function history.record_simple(save, root, old, new) +function history.record_move(save, root, old, new) if old and old:equals(new) then return end @@ -137,15 +164,6 @@ function history.record_simple(save, root, old, new) if not old or meta.I == 0 then meta.I = meta.I + 1 end - - if i < #new then - local read = meta.R - if not read then - read = {} - meta.R = read - end - history.set_bit(read, new[i + 1]) - end end i = i + 1 end @@ -154,6 +172,7 @@ end --- Returns the visited count. --- @param save table --- @param path TablePath +--- @return number count the visited count function history.get_recorded_count(save, path) local is_array, node = path:is_array(save) if is_array then @@ -163,7 +182,7 @@ function history.get_recorded_count(save, path) is_array, parent = path:is_array(save, 1) if is_array then local bitset = assert(parent)[1].R - return bitset and history.get_bit(bitset, path[#path]) or 0 + return bitset and (history.get_bit(bitset, path[#path]) and 1) or 0 end return 0 end diff --git a/vm/src/mdvm/savedata.lua b/vm/src/mdvm/savedata.lua index b058959..4f64e20 100644 --- a/vm/src/mdvm/savedata.lua +++ b/vm/src/mdvm/savedata.lua @@ -23,6 +23,7 @@ local TablePath = require("mdvm.table_path") --- --- @field tags table|boolean|nil tags --- @field text string|nil the translated and interpolated text +--- @field visited boolean whether the text has been visited --- @class Selectable --- @@ -38,6 +39,7 @@ local TablePath = require("mdvm.table_path") --- --- @field tags table|boolean|nil tags --- @field text string|nil the translated and interpolated text +--- @field visited boolean whether the text or the select has been visited --- @field select Selectable[]|nil the selectable options --- @class IOCache diff --git a/vm/src/mdvm/stacked_env.lua b/vm/src/mdvm/stacked_env.lua index 30ed158..e941d41 100644 --- a/vm/src/mdvm/stacked_env.lua +++ b/vm/src/mdvm/stacked_env.lua @@ -20,10 +20,11 @@ --- @class StackedEnv --- @field lua table the Lua environment --- @field global table the global scope ---- @field label fun(table):table label lookup function +--- @field label fun(table):(table|nil) label lookup function --- @field stack table some normal scopes that pose no requirements --- @field env table the environment to be used --- @field api table keys that forbid overriding +--- @field getter table keys in Lua environment to be treated as getters --- @field init boolean whether in initialing state local StackedEnv = {} StackedEnv.__index = StackedEnv @@ -32,12 +33,14 @@ StackedEnv.__index = StackedEnv --- --- @return StackedEnv function StackedEnv.new() + --- @type StackedEnv local stacked = { lua = {}, global = {}, - label = nil, + label = function() end, stack = {}, env = {}, + getter = {}, api = { ROOT = true }, init = true, } @@ -194,12 +197,24 @@ function StackedEnv:get(key) return value end -- Lua. - return self.lua[key] + value = self.lua[key] + if self.getter[key] then + return value() + end + return value end -function StackedEnv:set_api(key, value) +--- Sets a value in the Lua scope. +--- +--- @param key string +--- @param value any +--- @param getter boolean|nil +function StackedEnv:set_api(key, value, getter) self.lua[key] = value self.api[key] = true + if getter then + self.getter[key] = true + end end function StackedEnv:set(key, value) diff --git a/vm/src/mdvm/vm.lua b/vm/src/mdvm/vm.lua index f290162..ab02a49 100644 --- a/vm/src/mdvm/vm.lua +++ b/vm/src/mdvm/vm.lua @@ -60,8 +60,9 @@ function VM:_set_up_listener(co) co.ip:set_listener(function(old, new) assert(self:_ensure_root(new), "invalid ip assigned") local current_co = assert(self:_get_coroutine()) - history.record_simple(self.savedata.stats, self.code, current_co.prev_ip, old) + history.record_move(self.savedata.stats, self.code, current_co.prev_ip, old) current_co.prev_ip = old:copy() + self.flags.redirected = true end) end @@ -151,10 +152,10 @@ function VM:eval_with_env(env, ip, env_keys) env_keys = env_keys or utils.get_keys(env) self.env:push(env_keys, env) while true do - local line, tags = self:_fetch_and_next(ip) + local line, tags, visited = self:_fetch_and_next(ip) if line or not tags or self.flags["if-else"] == false or self.flags["empty"] then self.env:pop() - return line, tags + return line, tags, visited end end end @@ -339,12 +340,12 @@ function VM:current() end while true do - local line, tags = self:_fetch_and_next() + local line, tags, visited = self:_fetch_and_next() if not tags then return nil end if line then - output = { text = line, tags = tags } + output = { text = line, tags = tags, visited = visited or false } current.output = output end output = current.output @@ -385,6 +386,17 @@ end --- @field jumps number|nil consecutive jump counts without any content yielded --- @field empty boolean|nil the result from the last if-else branch node --- @field if-else boolean|nil the result from the last if-else branch node +--- @field redirected boolean|nil whether the story flow was programmatically redirected + +--- Update history info on a node. +--- +--- @param should_track boolean +--- @param ip TablePath|false|nil +function VM:_track(should_track, ip) + if should_track and ip then + history.record_simple(self.savedata.stats, self.code, ip) + end +end --- Returns the current node and goes to the next. --- @@ -399,7 +411,9 @@ end --- @param ip TablePath|nil the pointer --- @return string|nil result --- @return table|boolean|nil tags `nil` if reaches the end +--- @return boolean|nil visited `true` if the text has been read function VM:_fetch_and_next(ip) + local should_track = not ip ip = ip or assert(self:_get_coroutine()).ip local root = assert(self:_ensure_root(ip)) if ip:is_done() then @@ -417,10 +431,12 @@ function VM:_fetch_and_next(ip) local node_type = VM._node_type(node) if node_type == "text" and type(node) == "string" then + local visited_count = history.get_recorded_count(self.savedata.stats, ip) + local text = self:translate(node) + self:_track(should_track, ip) ip:step(root) - return self:translate(node), true + return text, true, visited_count > 0 elseif node_type == "tagged_text" then - ip:step(root) local formatted = self:interpolate(node.text, node.values, true, node.plural) local tags = {} --- @type table for k, v in pairs(type(node.tags) == "table" and node.tags or {}) do @@ -430,6 +446,8 @@ function VM:_fetch_and_next(ip) tags[k] = tostring(v) end end + self:_track(should_track, ip) + ip:step(root) return formatted, tags or true elseif node_type == "link" then local new_root_name = node.root @@ -438,6 +456,7 @@ function VM:_fetch_and_next(ip) end local found = lookup.find_by_labels(root, new_root_name or ip, node.link) assert(#found == 1, "not found / found too many: " .. tostring(#found)) + self:_track(should_track, ip) if node.params then local is_array, target = found[1]:is_array(root) if is_array and target and target[1].routine then @@ -459,15 +478,22 @@ function VM:_fetch_and_next(ip) return nil, true elseif node_type == "if-else" then local result = node[1]() - local _ - _, self.flags["empty"] = ip:resolve(result and 2 or 3):step(root, true) + local tracked = should_track and ip:copy() self.flags["if-else"] = result and true or false + self:_track(should_track, tracked) + if not self.flags.redirected then + local _ + _, self.flags["empty"] = ip:resolve(result and 2 or 3):step(root, true) + end return nil, true elseif node_type == "func" then local args = ip:copy():resolve("args") - ip:step(root) + local tracked = should_track and ip:copy() node.func(args) - if not ip:is_done() then + self:_track(should_track, tracked) + if not self.flags.redirected then + ip:step(root) + elseif not ip:is_done() then ip:step(assert(self:_ensure_root(ip)), true) end return nil, true