mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
960 lines
36 KiB
Lua
960 lines
36 KiB
Lua
-- Scripting v2 (M5): label resolution, the commands registry consumed by
|
|
-- dispatch, map_scripts compose semantics, the queueScript FIFO, bounded
|
|
-- parallel runners, the tokens registry, mod-field routing, load-time
|
|
-- validation and the script events/hook.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
love = love or require("tests.love_stub")
|
|
|
|
local Commands = require("src.script.Commands")
|
|
local Data = require("src.core.Data")
|
|
local Events = require("src.mods.Events")
|
|
local Flags = require("src.script.Flags")
|
|
local Hooks = require("src.mods.Hooks")
|
|
local Loader = require("src.mods.Loader")
|
|
local Logger = require("src.core.Logger")
|
|
local MapScripts = require("src.script.MapScripts")
|
|
local OW = require("src.world.OverworldController")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local ScriptRunner = require("src.script.ScriptRunner")
|
|
local TextBox = require("src.render.TextBox")
|
|
local Tokens = require("src.script.Tokens")
|
|
|
|
local S = require("tests.harness").suite("scripting v2 (M5)")
|
|
local check = S.check
|
|
|
|
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
|
|
|
local function memfs(files)
|
|
return {
|
|
read = function(path) return files[path] end,
|
|
getInfo = function(path)
|
|
if files[path] then return { type = "file" } end
|
|
local prefix = path .. "/"
|
|
for key in pairs(files) do
|
|
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
|
end
|
|
return nil
|
|
end,
|
|
load = function(path)
|
|
if not files[path] then return nil, "no file: " .. path end
|
|
return load(files[path], path)
|
|
end,
|
|
getDirectoryItems = function(path)
|
|
local seen, items = {}, {}
|
|
local prefix = path .. "/"
|
|
for key in pairs(files) do
|
|
if key:sub(1, #prefix) == prefix then
|
|
local child = key:sub(#prefix + 1):match("^[^/]+")
|
|
if child and not seen[child] then
|
|
seen[child] = true
|
|
items[#items + 1] = child
|
|
end
|
|
end
|
|
end
|
|
table.sort(items)
|
|
return items
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function newGame()
|
|
return { data = Data, save = { flags = {}, inventory = {} } }
|
|
end
|
|
|
|
local function drive(runner, frames)
|
|
for _ = 1, frames or 200 do
|
|
if not runner:isRunning() then return true end
|
|
runner:update()
|
|
end
|
|
return not runner:isRunning()
|
|
end
|
|
|
|
-- ------- labels
|
|
|
|
do
|
|
local labeled = {
|
|
{ "check_flag", "MOD_SCRIPT_L" },
|
|
{ "jump_if_true", "yes" },
|
|
{ "set_field", "labelPath", "no" },
|
|
{ "jump", "end" },
|
|
{ "label", "yes" },
|
|
{ "set_field", "labelPath", "yes" },
|
|
}
|
|
local numbered = {
|
|
{ "check_flag", "MOD_SCRIPT_L" },
|
|
{ "jump_if_true", 5 },
|
|
{ "set_field", "labelPath", "no" },
|
|
{ "jump", math.huge },
|
|
{ "set_field", "labelPath", "yes" },
|
|
}
|
|
for _, flagged in ipairs({ false, true }) do
|
|
local results = {}
|
|
for kind, script in pairs({ labeled = labeled, numbered = numbered }) do
|
|
local game = newGame()
|
|
if flagged then Flags.set(game.save, "MOD_SCRIPT_L") end
|
|
local runner = ScriptRunner.new(game, nil)
|
|
runner:run(script, {})
|
|
check(not runner:isRunning(), kind .. " script completes")
|
|
results[kind] = game.save.labelPath
|
|
end
|
|
check(results.labeled == results.numbered,
|
|
"label jumps match the hand-numbered equivalent")
|
|
check(results.labeled == (flagged and "yes" or "no"),
|
|
"label branch picks the right path")
|
|
end
|
|
|
|
local labels = ScriptRunner.scanLabels(labeled)
|
|
check(labels.yes == 5, "scanLabels finds the label row")
|
|
|
|
-- a jump to a missing label kills the script instead of skipping
|
|
local game = newGame()
|
|
local runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "jump", "nowhere" }, { "set_field", "after", 1 } }, {})
|
|
check(not runner:isRunning() and game.save.after == nil,
|
|
"missing label kills the script")
|
|
end
|
|
|
|
-- ------- unknown verbs: v1 skip vs api-2 strict kill
|
|
|
|
do
|
|
local game = newGame()
|
|
local runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "totally_bogus_verb" }, { "set_field", "after", 1 } }, {})
|
|
check(game.save.after == 1, "unknown verb skips for compat scripts")
|
|
|
|
game = newGame()
|
|
runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "totally_bogus_verb" }, { "set_field", "after", 1 } },
|
|
{ source = { modId = "tmod", strict = true } })
|
|
check(not runner:isRunning() and game.save.after == nil,
|
|
"unknown verb kills a strict (api 2) script")
|
|
end
|
|
|
|
-- ------- load-time validation
|
|
|
|
do
|
|
local problems = ScriptRunner.validate({
|
|
{ "label", "a" },
|
|
{ "label", "a" },
|
|
{ "jump", "missing" },
|
|
{ "bogus_verb" },
|
|
{ "jump", 99 },
|
|
"not a row",
|
|
})
|
|
local text = table.concat(problems, "\n")
|
|
check(text:find("duplicate label 'a'", 1, true), "duplicate label reported")
|
|
check(text:find("row 2", 1, true), "duplicate names its row")
|
|
check(text:find("missing label 'missing'", 1, true), "missing label reported")
|
|
check(text:find("unknown command 'bogus_verb'", 1, true), "unknown verb reported")
|
|
check(text:find("out of range", 1, true), "numeric jump bounds checked")
|
|
check(text:find("row 6 is not", 1, true), "malformed row reported")
|
|
|
|
check(#ScriptRunner.validate({ { "set_flag", "X" }, { "jump", "end" } }) == 0,
|
|
"a clean script validates clean")
|
|
|
|
local findings = MapScripts.validateContribution({
|
|
talk = { TEXT_V = { { "typod_verb" } } },
|
|
scripts = { amb = { { "jump", "gone" } } },
|
|
})
|
|
local joined = table.concat(findings, "\n")
|
|
check(joined:find("talk.TEXT_V", 1, true), "contribution findings name the talk key")
|
|
check(joined:find("scripts.amb", 1, true), "contribution findings name the script key")
|
|
end
|
|
|
|
-- ------- commands registry: mod verbs, override wins, collision fails
|
|
|
|
do
|
|
local fs = memfs({
|
|
["mods/tmod/manifest.json"] =
|
|
'{"id":"tmod","name":"T","version":"1.0.0","entry":"main.lua","api":2}',
|
|
["mods/tmod/main.lua"] = [[
|
|
return function(mod)
|
|
mod.commands:register("tmod:mark", function(ctx, value)
|
|
ctx.save.marked = value
|
|
end)
|
|
mod.content.commands:override("give_money", function(ctx, amount)
|
|
ctx.save.money = 777
|
|
end)
|
|
mod.content.tokens:register("TMOD_X", function() return "42" end)
|
|
mod.content.map_scripts:register("MOD_LOADER_MAP", {
|
|
talk = { TEXT_T = { { "tmod:mark", 5 } } },
|
|
})
|
|
end
|
|
]],
|
|
})
|
|
local loader = Loader.new({ fs = fs })
|
|
local data = { pokemon = {}, moves = {}, items = {} }
|
|
check(loader:load(data) == true, "scripting fixture mod loads")
|
|
check(type(data.commands["tmod:mark"]) == "function", "mod verb merges")
|
|
|
|
local game = { data = data, save = { flags = {}, inventory = {}, money = 0 } }
|
|
local runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "tmod:mark", 5 }, { "give_money", 10 } }, {})
|
|
check(game.save.marked == 5, "a mod-registered verb dispatches")
|
|
check(game.save.money == 777, "a mod override of an engine verb wins dispatch")
|
|
|
|
-- the merged chain drives MapScripts through the same store the loader wrote
|
|
local savedChains = Data.map_scripts
|
|
Data.map_scripts = data.map_scripts
|
|
MapScripts.invalidate()
|
|
local rows = MapScripts.talkScript("MOD_LOADER_MAP", "TEXT_T")
|
|
check(type(rows) == "table" and rows[1][1] == "tmod:mark",
|
|
"a registered map_scripts talk entry resolves")
|
|
Data.map_scripts = savedChains
|
|
MapScripts.invalidate()
|
|
|
|
-- tokens registry drives substitution
|
|
local tokenGame = { data = data, save = { player = { name = "ASH" } } }
|
|
check(TextBox.substitute(tokenGame, "{TMOD_X}/{PLAYER}") == "42/ASH",
|
|
"a mod token expands beside the engine set")
|
|
|
|
-- register over an engine verb is a collision, not a silent replace
|
|
local clash = Loader.new({ fs = memfs({
|
|
["mods/clash/manifest.json"] =
|
|
'{"id":"clash","name":"C","version":"1.0.0","entry":"main.lua","api":2}',
|
|
["mods/clash/main.lua"] = [[
|
|
return function(mod)
|
|
mod.commands:register("show_text", function() end)
|
|
end
|
|
]],
|
|
}) })
|
|
check(clash:load({ pokemon = {} }) == false,
|
|
"registering over an engine verb fails the mod")
|
|
end
|
|
|
|
-- ------- foreground metadata and parallel rejection
|
|
|
|
do
|
|
check(Commands.meta.show_text.foreground and Commands.meta.show_text.blocking,
|
|
"show_text carries foreground+blocking metadata")
|
|
check(Commands.meta.wait_flag.blocking and not Commands.meta.wait_flag.foreground,
|
|
"wait_flag is background-legal")
|
|
|
|
local game = newGame()
|
|
local runner = ScriptRunner.new(game, nil)
|
|
runner.parallel = true
|
|
runner:run({ { "show_text", "HI" }, { "set_field", "after", 1 } }, {})
|
|
check(not runner:isRunning() and game.save.after == nil,
|
|
"a foreground verb kills a parallel script")
|
|
|
|
game = newGame()
|
|
runner = ScriptRunner.new(game, nil)
|
|
runner.parallel = true
|
|
runner:run({ { "set_flag", "MOD_BG_OK" } }, {})
|
|
check(Flags.get(game.save, "MOD_BG_OK"), "background-legal verbs run in parallel")
|
|
end
|
|
|
|
-- ------- tokens: engine parity and the unknown-token fallback
|
|
|
|
do
|
|
local game = { save = { player = { name = "ASH" } }, stringBuffer = "POTION" }
|
|
check(TextBox.substitute(game, "{PLAYER} got\n{RAM:wStringBuffer}!")
|
|
== "ASH got\nPOTION!", "PLAYER and RAM expand as before")
|
|
check(TextBox.substitute(game, "{RIVAL}") == "BLUE", "RIVAL keeps its default")
|
|
check(TextBox.substitute(game, "A{RAM:wOtherBuffer}B") == "AB",
|
|
"an unhandled RAM buffer still drops silently")
|
|
|
|
local before = #Logger.history
|
|
check(Tokens.expand(game, "A{MOD_NOPE_TOKEN}B{MOD_NOPE_TOKEN}C",
|
|
TextBox.TOKENS) == "ABC", "an unknown token is dropped, not rendered")
|
|
local warns = 0
|
|
for i = before + 1, #Logger.history do
|
|
if Logger.history[i]:find("MOD_NOPE_TOKEN", 1, true) then warns = warns + 1 end
|
|
end
|
|
check(warns == 1, "the unknown token warns once, not per occurrence")
|
|
end
|
|
|
|
-- ------- tokens: golden parity sweep over the vanilla text corpus
|
|
|
|
do
|
|
-- the pre-registry substitute, reproduced verbatim as the oracle: the
|
|
-- registry path must render every vanilla string byte-identically,
|
|
-- including the {NUM:...} extractor spans the old catch-all left alone
|
|
local function oracle(game, text)
|
|
local save = game.save
|
|
text = text:gsub("{PLAYER}", save.player.name or "RED")
|
|
text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
|
|
if game.stringBuffer then
|
|
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
|
|
end
|
|
text = text:gsub("{[%w_:]+}", "")
|
|
return text
|
|
end
|
|
local game = { save = { player = { name = "ASH", rival = "GARY" } },
|
|
stringBuffer = "POTION" }
|
|
check(type(Data.text) == "table" and next(Data.text) ~= nil,
|
|
"the text corpus is loaded")
|
|
local swept = 0
|
|
for id, text in pairs(Data.text) do
|
|
if type(text) == "string" then
|
|
check(TextBox.substitute(game, text) == oracle(game, text),
|
|
"token expansion diverges from the oracle on " .. tostring(id))
|
|
swept = swept + 1
|
|
end
|
|
end
|
|
check(swept > 2000, "the sweep covered the generated corpus")
|
|
end
|
|
|
|
-- ------- map_scripts compose semantics
|
|
|
|
do
|
|
local calls = {}
|
|
local baseTalk = { { "set_flag", "BASE_TALK" } }
|
|
MapScripts.attachBase("MOD_COMPOSE_MAP", {
|
|
talk = { TEXT_A = baseTalk, TEXT_B = { { "set_flag", "BASE_B" } } },
|
|
onEnter = function() calls[#calls + 1] = "base" end,
|
|
onStep = function() calls[#calls + 1] = "base_step" return false end,
|
|
})
|
|
local fastPath = MapScripts.get("MOD_COMPOSE_MAP")
|
|
check(fastPath.talk.TEXT_A == baseTalk,
|
|
"no chain returns the base table untouched")
|
|
|
|
-- the loader writes Registry:chain output: priority descending, then
|
|
-- registration order; the view re-ranks equal-priority ties so the
|
|
-- later registration wins, and slots base at priority 0 behind mods
|
|
local modTalkA = { { "set_flag", "MOD_TALK" } }
|
|
local chain = {
|
|
{ priority = 5, onEnter = function() calls[#calls + 1] = "D" end,
|
|
talk = { TEXT_B = false } },
|
|
{ onEnter = function() calls[#calls + 1] = "A" end,
|
|
talk = { TEXT_A = modTalkA },
|
|
scripts = { amb = { { "set_flag", "AMB" } } } },
|
|
{ onEnter = function() calls[#calls + 1] = "B" error("boom") end,
|
|
onStep = function() calls[#calls + 1] = "B_step" return true end },
|
|
{ priority = -1, onEnter = function() calls[#calls + 1] = "C" end },
|
|
}
|
|
local savedChains = Data.map_scripts
|
|
Data.map_scripts = { MOD_COMPOSE_MAP = chain }
|
|
MapScripts.invalidate()
|
|
|
|
local view = MapScripts.get("MOD_COMPOSE_MAP")
|
|
check(MapScripts.get("MOD_COMPOSE_MAP") == view, "merged views are cached")
|
|
|
|
check(view.talk.TEXT_A == modTalkA, "talk is single-winner: the mod outranks base")
|
|
check(view.talk.TEXT_B == nil, "talk false suppresses the base entry")
|
|
check(MapScripts.baseTalk("MOD_COMPOSE_MAP", "TEXT_A") == baseTalk,
|
|
"baseTalk still reaches the engine handler behind the override")
|
|
check(MapScripts.namedScript("MOD_COMPOSE_MAP", "amb") ~= nil,
|
|
"scripts entries resolve by MAP/name")
|
|
|
|
calls = {}
|
|
view.onEnter({}, {})
|
|
check(table.concat(calls, ",") == "D,B,A,base,C",
|
|
"onEnter all-run order: priority desc, later-first ties, base behind, "
|
|
.. "negative after base, throwing sibling isolated (got "
|
|
.. table.concat(calls, ",") .. ")")
|
|
|
|
calls = {}
|
|
local consumed = view.onStep({}, {}, 0, 0)
|
|
check(consumed == true and #calls == 1 and calls[1] == "B_step",
|
|
"onStep first truthy return consumes the step")
|
|
|
|
Data.map_scripts = savedChains
|
|
MapScripts.invalidate()
|
|
check(MapScripts.get("MOD_COMPOSE_MAP").talk.TEXT_A == baseTalk,
|
|
"dropping the chain restores the base fast path")
|
|
|
|
-- a mod talk script on a real map merges beside the vanilla NPCs
|
|
local init = require("data.scripts.init")
|
|
local clerk = init.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")
|
|
check(clerk ~= nil, "the vanilla clerk script is registered")
|
|
Data.map_scripts = { VIRIDIAN_MART = {
|
|
{ talk = { TEXT_VIRIDIANMART_MODNPC = { { "set_flag", "MOD_HELLO" } } } },
|
|
} }
|
|
MapScripts.invalidate()
|
|
check(init.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_MODNPC") ~= nil,
|
|
"the mod talk entry resolves on an existing map")
|
|
check(init.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK") == clerk,
|
|
"the vanilla clerk script is not displaced")
|
|
Data.map_scripts = savedChains
|
|
MapScripts.invalidate()
|
|
end
|
|
|
|
-- ------- map_scripts:override, the total-conversion escape hatch (09 4.4)
|
|
|
|
do
|
|
local baseRan = false
|
|
local baseTalk = { { "set_flag", "TC_BASE_TALK" } }
|
|
MapScripts.attachBase("MOD_TC_MAP", {
|
|
talk = { TEXT_TC_BASE = baseTalk, TEXT_TC_KEPT = { { "set_flag", "KEPT" } } },
|
|
onEnter = function() baseRan = true end,
|
|
onStep = function() return true end,
|
|
snorlaxWake = { script = { { "set_flag", "WAKE" } } },
|
|
})
|
|
|
|
local fs = memfs({
|
|
["mods/tc/manifest.json"] =
|
|
'{"id":"tc","name":"TC","version":"1.0.0","entry":"main.lua","api":2}',
|
|
["mods/tc/main.lua"] = [[
|
|
return function(mod)
|
|
-- a plain contribution first: override must clear this one too, not just
|
|
-- fold on top of it
|
|
mod.content.map_scripts:register("MOD_TC_MAP", {
|
|
talk = { TEXT_TC_EARLY = { { "set_flag", "EARLY" } } },
|
|
})
|
|
mod.content.map_scripts:override("MOD_TC_MAP", {
|
|
talk = { TEXT_TC_NEW = { { "set_flag", "TC_NEW" } } },
|
|
onEnter = function(game) game.tcRan = true end,
|
|
})
|
|
mod.content.map_scripts:register("MOD_TC_ADD_MAP", {
|
|
talk = { TEXT_TC_ADD = { { "set_flag", "ADD" } } },
|
|
})
|
|
end
|
|
]],
|
|
})
|
|
local loader = Loader.new({ fs = fs })
|
|
local data = { pokemon = {}, moves = {}, items = {} }
|
|
check(loader:load(data) == true, "total-conversion fixture mod loads")
|
|
|
|
local chain = data.map_scripts.MOD_TC_MAP
|
|
check(chain.replacesBase == true, "an override stamps replacesBase on the chain")
|
|
check(#chain == 1, "override collapses the chain to a single contribution")
|
|
|
|
local savedChains = Data.map_scripts
|
|
Data.map_scripts = data.map_scripts
|
|
MapScripts.invalidate()
|
|
|
|
local view = MapScripts.get("MOD_TC_MAP")
|
|
check(view.talk.TEXT_TC_NEW ~= nil, "the override's own talk entry resolves")
|
|
check(view.talk.TEXT_TC_BASE == nil,
|
|
"base talk entries are absent from an overridden map")
|
|
check(view.talk.TEXT_TC_KEPT == nil,
|
|
"a base TEXT constant the override never redefined does not bleed through")
|
|
check(view.talk.TEXT_TC_EARLY == nil,
|
|
"override clears lower-precedence mod contributions too")
|
|
check(view.snorlaxWake == nil, "legacy base keys are cleared by an override")
|
|
check(view.onStep == nil, "base hooks are absent from an overridden map")
|
|
|
|
baseRan = false
|
|
local probe = {}
|
|
view.onEnter(probe, {})
|
|
check(probe.tcRan == true, "the override's onEnter runs")
|
|
check(baseRan == false, "base onEnter does not run on an overridden map")
|
|
|
|
-- the control: a plain register still composes on top of base
|
|
check(MapScripts.get("MOD_TC_ADD_MAP") ~= nil, "a register-only map still merges")
|
|
check(data.map_scripts.MOD_TC_ADD_MAP.replacesBase == nil,
|
|
"a register-only chain is not flagged as replacing base")
|
|
|
|
Data.map_scripts = savedChains
|
|
MapScripts.invalidate()
|
|
check(MapScripts.get("MOD_TC_MAP").talk.TEXT_TC_BASE == baseTalk,
|
|
"dropping the chain restores the untouched base contribution")
|
|
end
|
|
|
|
-- ------- map_scripts:remove, the whole-map tombstone (09 4.4)
|
|
|
|
do
|
|
MapScripts.attachBase("MOD_RM_MAP", {
|
|
talk = { TEXT_RM_BASE = { { "set_flag", "RM_BASE" } } },
|
|
onEnter = function() error("base onEnter ran on a removed map") end,
|
|
snorlaxWake = { script = { { "set_flag", "RM_WAKE" } } },
|
|
})
|
|
local keptTalk = { { "set_flag", "RM_KEPT" } }
|
|
MapScripts.attachBase("MOD_RM_KEPT_MAP", { talk = { TEXT_RM_KEPT = keptTalk } })
|
|
MapScripts.attachBase("MOD_RM_BACK_MAP", {
|
|
talk = { TEXT_RM_BACK_BASE = { { "set_flag", "RM_BACK_BASE" } } },
|
|
})
|
|
|
|
local fs = memfs({
|
|
["mods/rmadd/manifest.json"] =
|
|
'{"id":"rmadd","name":"Add","version":"1.0.0","entry":"main.lua","api":2}',
|
|
["mods/rmadd/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.map_scripts:register("MOD_RM_MAP", {
|
|
talk = { TEXT_RM_ADDED = { { "set_flag", "RM_ADDED" } } },
|
|
onEnter = function(game) game.addedRan = true end,
|
|
})
|
|
end
|
|
]],
|
|
-- depends on rmadd so the remove is guaranteed to land after it
|
|
["mods/rmcut/manifest.json"] =
|
|
'{"id":"rmcut","name":"Cut","version":"1.0.0","entry":"main.lua","api":2,'
|
|
.. '"dependencies":["rmadd"]}',
|
|
["mods/rmcut/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.map_scripts:remove("MOD_RM_MAP")
|
|
mod.content.map_scripts:remove("MOD_RM_BACK_MAP")
|
|
mod.content.map_scripts:register("MOD_RM_BACK_MAP", {
|
|
talk = { TEXT_RM_BACK_NEW = { { "set_flag", "RM_BACK_NEW" } } },
|
|
})
|
|
end
|
|
]],
|
|
})
|
|
local loader = Loader.new({ fs = fs })
|
|
local data = { pokemon = {}, moves = {}, items = {} }
|
|
check(loader:load(data) == true, "map_scripts remove fixture loads")
|
|
|
|
local chain = data.map_scripts.MOD_RM_MAP
|
|
check(type(chain) == "table" and #chain == 0,
|
|
"a removed map merges as an empty chain, not as a missing key")
|
|
check(chain.replacesBase == true,
|
|
"the tombstone is stamped so the consumer drops its base contribution")
|
|
|
|
local savedChains = Data.map_scripts
|
|
Data.map_scripts = data.map_scripts
|
|
MapScripts.invalidate()
|
|
|
|
local init = require("data.scripts.init")
|
|
check(MapScripts.get("MOD_RM_MAP") == nil,
|
|
"a removed map has no view: no base talk, no base onEnter, no snorlaxWake")
|
|
check(init.get("MOD_RM_MAP") == nil, "the dispatcher sees the map as gone")
|
|
check(MapScripts.talkScript("MOD_RM_MAP", "TEXT_RM_BASE") == nil,
|
|
"base talk does not survive the removal")
|
|
check(MapScripts.baseTalk("MOD_RM_MAP", "TEXT_RM_BASE") ~= nil,
|
|
"the base contribution itself is untouched, only excluded")
|
|
check(MapScripts.talkScript("MOD_RM_MAP", "TEXT_RM_ADDED") == nil,
|
|
"remove clears another owner's contribution too")
|
|
|
|
-- registering after the tombstone rebuilds the map from nothing
|
|
local backView = MapScripts.get("MOD_RM_BACK_MAP")
|
|
check(backView and backView.talk.TEXT_RM_BACK_NEW ~= nil,
|
|
"a register after remove resurrects the map id")
|
|
check(backView.talk.TEXT_RM_BACK_BASE == nil,
|
|
"and base stays out of the resurrected chain")
|
|
|
|
check(MapScripts.get("MOD_RM_KEPT_MAP").talk.TEXT_RM_KEPT == keptTalk,
|
|
"an untouched map still takes the base fast path")
|
|
|
|
Data.map_scripts = savedChains
|
|
MapScripts.invalidate()
|
|
check(MapScripts.talkScript("MOD_RM_MAP", "TEXT_RM_BASE") ~= nil,
|
|
"dropping the chain restores the base contribution")
|
|
end
|
|
|
|
-- ------- owner attribution through the real dispatch path
|
|
|
|
do
|
|
local fs = memfs({
|
|
["mods/srcmod/manifest.json"] =
|
|
'{"id":"srcmod","name":"S","version":"1.0.0","entry":"main.lua","api":2}',
|
|
["mods/srcmod/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.map_scripts:register("MOD_SOURCE_MAP", {
|
|
talk = { TEXT_S = { { "set_field", "mod:asked_count", 0 } } },
|
|
scripts = { amb = { { "set_flag", "MOD_SRC_AMB" } } },
|
|
})
|
|
end
|
|
]],
|
|
})
|
|
local loader = Loader.new({ fs = fs })
|
|
local data = { pokemon = {}, moves = {}, items = {} }
|
|
check(loader:load(data) == true, "source fixture mod loads")
|
|
local chain = data.map_scripts.MOD_SOURCE_MAP
|
|
check(chain.owners and chain.owners[1]
|
|
and chain.owners[1].modId == "srcmod" and chain.owners[1].strict == true,
|
|
"the merged chain carries owner records")
|
|
|
|
local savedChains = Data.map_scripts
|
|
Data.map_scripts = data.map_scripts
|
|
MapScripts.invalidate()
|
|
|
|
local source = MapScripts.talkSource("MOD_SOURCE_MAP", "TEXT_S")
|
|
check(source and source.modId == "srcmod" and source.strict == true
|
|
and source.mapId == "MOD_SOURCE_MAP" and source.hook == "talk",
|
|
"talkSource names the owning contribution")
|
|
local named = MapScripts.namedSource("MOD_SOURCE_MAP", "amb")
|
|
check(named and named.modId == "srcmod" and named.hook == "scripts.amb",
|
|
"namedSource names the scripts entry owner")
|
|
|
|
-- the real showMapText dispatch: the source rides into the runner, so a
|
|
-- mod: field lands in the owner's save.modData bucket instead of killing
|
|
-- the script
|
|
local fakeGame = { data = Data, save = { flags = {}, inventory = {} } }
|
|
local gameIdx, scriptsIdx
|
|
local i = 1
|
|
while true do
|
|
local name = debug.getupvalue(OW.showMapText, i)
|
|
if not name then break end
|
|
if name == "Game" then
|
|
gameIdx = i
|
|
debug.setupvalue(OW.showMapText, i, fakeGame)
|
|
elseif name == "mapScripts" then
|
|
scriptsIdx = i
|
|
debug.setupvalue(OW.showMapText, i, require("data.scripts.init"))
|
|
end
|
|
i = i + 1
|
|
end
|
|
check(gameIdx and scriptsIdx, "showMapText binds Game and mapScripts")
|
|
local ow = setmetatable({
|
|
map = { id = "MOD_SOURCE_MAP", def = { label = "ModSourceMap" } },
|
|
}, { __index = OW })
|
|
ow.runner = ScriptRunner.new(fakeGame, ow)
|
|
ow:showMapText("TEXT_S", nil, nil)
|
|
check(not ow.runner:isRunning(), "the talk rows complete")
|
|
check(fakeGame.save.modData and fakeGame.save.modData.srcmod
|
|
and fakeGame.save.modData.srcmod.asked_count == 0,
|
|
"a mod: field write lands under the owner on the real dispatch path")
|
|
|
|
-- a startParallel named ref runs as the owning contribution
|
|
ow:startParallel("MOD_SOURCE_MAP/amb")
|
|
local queued = ow.parallelQueue and ow.parallelQueue[1]
|
|
check(queued and queued.extra and queued.extra.source
|
|
and queued.extra.source.modId == "srcmod",
|
|
"a named parallel ref carries its owner's source")
|
|
|
|
debug.setupvalue(OW.showMapText, gameIdx, require("src.core.Game"))
|
|
Data.map_scripts = savedChains
|
|
MapScripts.invalidate()
|
|
end
|
|
|
|
-- ------- hook chains attribute a throwing handler to its owner
|
|
|
|
do
|
|
local savedEvents, savedHooks, savedErrors =
|
|
Runtime.events, Runtime.hooks, Runtime.errors
|
|
local errs = {}
|
|
Runtime.install(Events.new(), Hooks.new(), errs)
|
|
local savedChains = Data.map_scripts
|
|
Data.map_scripts = { MOD_ATTR_MAP = {
|
|
{ onEnter = function() error("attr boom") end },
|
|
owners = { { modId = "attrmod", strict = true } },
|
|
} }
|
|
MapScripts.invalidate()
|
|
local view = MapScripts.get("MOD_ATTR_MAP")
|
|
local before = #Logger.history
|
|
view.onEnter({}, {}) -- must be swallowed, not propagate
|
|
local named = false
|
|
for i = before + 1, #Logger.history do
|
|
if Logger.history[i]:find("attrmod", 1, true) then named = true end
|
|
end
|
|
check(named, "a throwing mod hook logs its owner")
|
|
check(#errs == 1 and errs[1]:find("attrmod", 1, true) ~= nil
|
|
and errs[1]:find("attr boom", 1, true) ~= nil,
|
|
"the failure lands in the runtime error feed")
|
|
Data.map_scripts = savedChains
|
|
MapScripts.invalidate()
|
|
Runtime.install(savedEvents, savedHooks, savedErrors)
|
|
end
|
|
|
|
-- ------- §4.9 through the real loader: bad rows fail an api 2 mod
|
|
|
|
do
|
|
local badMain = [[
|
|
return function(mod)
|
|
mod.commands:register("badmod:mark", function() end)
|
|
mod.content.map_scripts:register("VIRIDIAN_CITY", {
|
|
talk = { TEXT_VIRIDIANCITY_GAMBLER1 = { { "totally_bogus_verb_xyz" } } },
|
|
})
|
|
end
|
|
]]
|
|
local badFiles = {
|
|
["mods/badmod/manifest.json"] =
|
|
'{"id":"badmod","name":"B","version":"1.0.0","entry":"main.lua","api":2}',
|
|
["mods/badmod/main.lua"] = badMain,
|
|
}
|
|
local loader = Loader.new({ fs = memfs(badFiles) })
|
|
local data = { pokemon = {}, moves = {}, items = {} }
|
|
check(loader:load(data) == false, "a typo'd verb fails an api 2 mod at load")
|
|
local seen
|
|
for _, entry in ipairs(loader:status().available) do
|
|
if entry.id == "badmod" then seen = entry end
|
|
end
|
|
check(seen and seen.state == "failed"
|
|
and seen.error:find("unknown command 'totally_bogus_verb_xyz'", 1, true) ~= nil
|
|
and seen.error:find("VIRIDIAN_CITY", 1, true) ~= nil,
|
|
"the manager sees a named load error")
|
|
check(data.map_scripts == nil, "the bad contribution never merges")
|
|
check(data.commands["badmod:mark"] == nil and data.commands.show_text ~= nil,
|
|
"the failed mod's other content rolls back")
|
|
|
|
-- the same rows in an api 1 mod keep the v1 runtime skip: warn and load
|
|
local softLoader = Loader.new({ fs = memfs({
|
|
["mods/softmod/manifest.json"] =
|
|
'{"id":"softmod","name":"S","version":"1.0.0","entry":"main.lua"}',
|
|
["mods/softmod/main.lua"] = badMain,
|
|
}) })
|
|
local softData = { pokemon = {}, moves = {}, items = {} }
|
|
local before = #Logger.history
|
|
check(softLoader:load(softData) == true, "api 1 findings do not fail the load")
|
|
check(softData.map_scripts and softData.map_scripts.VIRIDIAN_CITY ~= nil,
|
|
"the api 1 contribution still merges")
|
|
local warned = false
|
|
for i = before + 1, #Logger.history do
|
|
if Logger.history[i]:find("totally_bogus_verb_xyz", 1, true) then warned = true end
|
|
end
|
|
check(warned, "api 1 findings surface as warnings")
|
|
|
|
-- a dependent of the failed mod is unloaded and purged with it
|
|
local casFiles = {
|
|
["mods/leech/manifest.json"] = '{"id":"leech","name":"L","version":"1.0.0",'
|
|
.. '"entry":"main.lua","api":2,"dependencies":["badmod"]}',
|
|
["mods/leech/main.lua"] = [[
|
|
return function(mod)
|
|
mod.commands:register("leech:mark", function() end)
|
|
end
|
|
]],
|
|
}
|
|
for path, content in pairs(badFiles) do casFiles[path] = content end
|
|
local casLoader = Loader.new({ fs = memfs(casFiles) })
|
|
local casData = { pokemon = {}, moves = {}, items = {} }
|
|
check(casLoader:load(casData) == false, "the cascade load fails")
|
|
local states = {}
|
|
for _, entry in ipairs(casLoader:status().available) do
|
|
states[entry.id] = entry.state
|
|
end
|
|
check(states.badmod == "failed" and states.leech == "blocked_dependency",
|
|
"the dependent is taken down with the bad mod")
|
|
check(casData.commands["leech:mark"] == nil,
|
|
"the dependent's content rolls back too")
|
|
check(#casLoader:status().order == 0, "neither mod stays in the load order")
|
|
end
|
|
|
|
-- ------- queueScript FIFO
|
|
|
|
do
|
|
local ran = {}
|
|
local fakeRunner = { running = false }
|
|
function fakeRunner:isRunning() return self.running end
|
|
function fakeRunner:run(script) ran[#ran + 1] = script end
|
|
function fakeRunner:update() end
|
|
local ow = setmetatable({
|
|
scriptMoves = {}, transitioning = false, runner = fakeRunner,
|
|
map = { id = "MOD_FIFO_MAP" },
|
|
}, { __index = OW })
|
|
local s1, s2, s3 = { "s1" }, { "s2" }, { "s3" }
|
|
ow:queueScript(s1)
|
|
ow:queueScript(s2)
|
|
ow:queueScript(s3)
|
|
check(#ow.pendingScripts == 3, "three scripts queue without clobbering")
|
|
ow:drainPendingScripts()
|
|
check(#ran == 1 and ran[1] == s1, "one script starts per idle frame, in order")
|
|
fakeRunner.running = true
|
|
ow:drainPendingScripts()
|
|
check(#ran == 1, "a busy runner defers the queue")
|
|
fakeRunner.running = false
|
|
ow:drainPendingScripts()
|
|
ow:drainPendingScripts()
|
|
check(ran[2] == s2 and ran[3] == s3, "the FIFO drains head-first")
|
|
end
|
|
|
|
-- ------- parallel runners: slots, drain, kill, move locks
|
|
|
|
do
|
|
-- OverworldState's methods close over a module-local Game
|
|
local fakeGame = newGame()
|
|
local bound = false
|
|
local i = 1
|
|
while true do
|
|
local name = debug.getupvalue(OW.updateParallel, i)
|
|
if not name then break end
|
|
if name == "Game" then
|
|
debug.setupvalue(OW.updateParallel, i, fakeGame)
|
|
bound = true
|
|
break
|
|
end
|
|
i = i + 1
|
|
end
|
|
check(bound, "updateParallel binds Game")
|
|
|
|
local ow = setmetatable({
|
|
scriptMoves = {}, transitioning = false, npcs = {}, entities = {},
|
|
map = { id = "MOD_PARA_MAP" },
|
|
parallelRunners = {}, parallelQueue = {}, marchers = {},
|
|
pendingScripts = {}, npcMoveLocks = {},
|
|
}, { __index = OW })
|
|
ow.runner = ScriptRunner.new(fakeGame, ow)
|
|
|
|
for _ = 1, 5 do
|
|
ow:startParallel({ { "wait_flag", "MOD_PARA_GO" } })
|
|
end
|
|
ow:updateParallel()
|
|
check(#ow.parallelRunners == 4 and #ow.parallelQueue == 1,
|
|
"four bounded slots; overflow waits FIFO-style")
|
|
|
|
Flags.set(fakeGame.save, "MOD_PARA_GO")
|
|
ow:updateParallel()
|
|
ow:updateParallel()
|
|
ow:updateParallel()
|
|
check(#ow.parallelRunners == 0 and #ow.parallelQueue == 0,
|
|
"parallel runners drain once the flag lands")
|
|
|
|
-- a parallel NPC walk takes the move lock; a foreground move preempts
|
|
Flags.clear(fakeGame.save, "MOD_PARA_GO")
|
|
local npc = { def = { index = 2 }, cellX = 0, cellY = 0, moving = false }
|
|
ow.npcs = { npc }
|
|
ow:startParallel({ { "walk_npc", 2, { "down", "down" } } })
|
|
ow:updateParallel()
|
|
local holder = ow.npcMoveLocks[npc]
|
|
check(holder ~= nil and holder.parallel, "a parallel walk takes the NPC move lock")
|
|
|
|
ow.runner:run({ { "move_npc", 2, "up", 1 } }, {})
|
|
check(ow.npcMoveLocks[npc] == nil, "a foreground move releases the lock")
|
|
check(not holder:isRunning(), "the parallel runner was preempted")
|
|
-- finish the foreground move so the runner ends clean
|
|
ow:updateScriptMoves()
|
|
npc.moving = false
|
|
ow:updateScriptMoves()
|
|
check(not ow.runner:isRunning(), "the foreground move completes")
|
|
|
|
-- the player is never movable from a parallel runner
|
|
ow.player = { cellX = 0, cellY = 0, moving = false }
|
|
ow:startParallel({ { "walk_npc", "player", { "down" } } })
|
|
ow:updateParallel()
|
|
ow:updateParallel()
|
|
check(#ow.parallelRunners == 0, "a parallel player move dies on the spot")
|
|
|
|
-- march_in_place toggles ride ow.marchers, not scriptMoves
|
|
local marchRunner = ScriptRunner.new(fakeGame, ow)
|
|
marchRunner:run({ { "march_in_place", 2, true } }, {})
|
|
check(ow.marchers[npc] == true and #ow.scriptMoves == 0,
|
|
"march_in_place arms the marcher table without a scriptMove")
|
|
ow:updateScriptMoves()
|
|
check(npc.marching == true, "the marcher cycle re-arms")
|
|
npc.moving, npc.marching = false, false
|
|
marchRunner:run({ { "march_in_place", 2, false } }, {})
|
|
ow:updateScriptMoves()
|
|
check(ow.marchers[npc] == nil and npc.marching == false,
|
|
"march_in_place off stops the cycle")
|
|
|
|
-- restore the module-local Game for later chained suites
|
|
debug.setupvalue(OW.updateParallel, i, require("src.core.Game"))
|
|
end
|
|
|
|
-- ------- emote and choice
|
|
|
|
do
|
|
local fakeGame = newGame()
|
|
local ow = { player = { px = 0, py = 0 }, npcs = {} }
|
|
local runner = ScriptRunner.new(fakeGame, ow)
|
|
runner:run({ { "emote", "player", "question", 5 },
|
|
{ "set_field", "after", 1 } }, {})
|
|
check(ow.emote and ow.emote.npc == ow.player and ow.emote.bubble == 2
|
|
and ow.emote.frames == 5, "emote arms the bubble hold")
|
|
check(fakeGame.save.after == nil, "emote blocks until the hold ends")
|
|
ow.emote.onDone()
|
|
check(fakeGame.save.after == 1, "the hold resumes the script")
|
|
|
|
local stack = { states = {} }
|
|
function stack:push(state) self.states[#self.states + 1] = state end
|
|
function stack:pop() return table.remove(self.states) end
|
|
fakeGame.stack = stack
|
|
runner = ScriptRunner.new(fakeGame, ow)
|
|
runner:run({ { "choice", { "YES", "NO", "MAYBE" }, { cancel = 3 } },
|
|
{ "jump_if_true", "first" },
|
|
{ "set_field", "picked", "other" },
|
|
{ "jump", "end" },
|
|
{ "label", "first" },
|
|
{ "set_field", "picked", "first" } }, {})
|
|
local menu = stack.states[#stack.states]
|
|
check(menu and #menu.items == 3, "choice pushes a three-way menu")
|
|
menu.items[2].onSelect()
|
|
check(fakeGame.save.picked == "other", "a non-first choice clears lastCheck")
|
|
|
|
runner = ScriptRunner.new(fakeGame, ow)
|
|
runner:run({ { "choice", { "YES", "NO" } },
|
|
{ "jump_if_true", "first" },
|
|
{ "jump", "end" },
|
|
{ "label", "first" },
|
|
{ "set_field", "picked", "first" } }, {})
|
|
stack.states[#stack.states].items[1].onSelect()
|
|
check(fakeGame.save.picked == "first", "the first choice sets lastCheck")
|
|
end
|
|
|
|
-- ------- mod-field routing and wait_flag
|
|
|
|
do
|
|
local game = newGame()
|
|
local runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "set_field", "mod:stage", 3 },
|
|
{ "check_flag", "mod:stage" },
|
|
{ "jump_if_true", "yes" },
|
|
{ "set_field", "sawStage", false },
|
|
{ "jump", "end" },
|
|
{ "label", "yes" },
|
|
{ "set_field", "sawStage", true } },
|
|
{ source = { modId = "tmod" } })
|
|
check(game.save.modData and game.save.modData.tmod
|
|
and game.save.modData.tmod.stage == 3,
|
|
"mod: fields land in save.modData under the owner")
|
|
check(game.save.sawStage == true, "check_flag reads mod: fields symmetrically")
|
|
|
|
game = newGame()
|
|
runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "set_field", "mod:x", 1 }, { "set_field", "after", 1 } }, {})
|
|
check(game.save.after == nil and game.save.modData == nil,
|
|
"mod: fields are a script error in engine-owned scripts")
|
|
|
|
-- wait_flag: timeout path then flag path
|
|
game = newGame()
|
|
runner = ScriptRunner.new(game, nil)
|
|
local script = { { "wait_flag", "MOD_WF", 3 },
|
|
{ "jump_if_true", "hit" },
|
|
{ "set_field", "wf", "timeout" },
|
|
{ "jump", "end" },
|
|
{ "label", "hit" },
|
|
{ "set_field", "wf", "flag" } }
|
|
runner:run(script, {})
|
|
check(runner:isRunning(), "wait_flag blocks")
|
|
drive(runner, 10)
|
|
check(game.save.wf == "timeout", "wait_flag times out with lastCheck false")
|
|
|
|
game = newGame()
|
|
runner = ScriptRunner.new(game, nil)
|
|
runner:run(script, {})
|
|
Flags.set(game.save, "MOD_WF")
|
|
drive(runner, 10)
|
|
check(game.save.wf == "flag", "wait_flag resumes true when the flag lands")
|
|
end
|
|
|
|
-- ------- script events and the script.command hook
|
|
|
|
do
|
|
local savedEvents, savedHooks, savedErrors =
|
|
Runtime.events, Runtime.hooks, Runtime.errors
|
|
local events, hooks = Events.new(), Hooks.new()
|
|
Runtime.install(events, hooks, {})
|
|
|
|
local flagSeen = {}
|
|
events:on("flag.changed", function(ev)
|
|
flagSeen[#flagSeen + 1] = ev.name .. "=" .. tostring(ev.value)
|
|
end, 0, "t")
|
|
local save = { flags = {} }
|
|
Flags.set(save, "MOD_EV")
|
|
Flags.set(save, "MOD_EV")
|
|
Flags.clear(save, "MOD_EV")
|
|
Flags.clear(save, "MOD_EV")
|
|
check(table.concat(flagSeen, ",") == "MOD_EV=true,MOD_EV=false",
|
|
"flag.changed fires only on actual transitions")
|
|
|
|
local lifecycle = {}
|
|
events:on("script.started", function() lifecycle[#lifecycle + 1] = "start" end,
|
|
0, "t")
|
|
events:on("script.ended", function(ev)
|
|
lifecycle[#lifecycle + 1] = "end:" .. tostring(ev.completed)
|
|
end, 0, "t")
|
|
local game = newGame()
|
|
local runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "set_flag", "X" } }, {})
|
|
check(table.concat(lifecycle, ",") == "start,end:true",
|
|
"script.started/ended bracket a clean run")
|
|
lifecycle = {}
|
|
runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "jump", "nowhere" } }, {})
|
|
check(lifecycle[2] == "end:false", "an error-kill emits completed = false")
|
|
|
|
local commandsSeen = {}
|
|
hooks:wrap("script.command", function(nextFn, ctx, name, args)
|
|
commandsSeen[#commandsSeen + 1] = name
|
|
if name == "set_field" and args[1] == "skipme" then
|
|
return 4 -- force the jump past the marker row
|
|
end
|
|
return nextFn()
|
|
end, 0, "t")
|
|
game = newGame()
|
|
runner = ScriptRunner.new(game, nil)
|
|
runner:run({ { "set_flag", "A" },
|
|
{ "set_field", "skipme", 1 },
|
|
{ "set_field", "skipped", 1 },
|
|
{ "set_flag", "B" } }, {})
|
|
check(table.concat(commandsSeen, ",") == "set_flag,set_field,set_flag",
|
|
"the script.command hook wraps every dispatch")
|
|
check(game.save.skipme == nil and game.save.skipped == nil
|
|
and Flags.get(game.save, "B"),
|
|
"a hook-returned pc rewrites the jump")
|
|
|
|
Runtime.install(savedEvents, savedHooks, savedErrors)
|
|
end
|
|
|
|
S.finish()
|