-- The map_scripts compose store (09 §4.4). The engine's hand-ported -- data/scripts/* modules are the base contribution -- attachBase folds -- them with the v1 merge rules (talk per TEXT constant, other keys -- replaced by later files), so a mod-free boot dispatches the exact -- table it always did. Mod contributions arrive through the merged -- registry (Data.map_scripts, one ordered chain per map id) and compose: -- -- talk / scripts / legacy keys single winner, false suppresses -- onEnter / onVictory / onBoulderMoved all-run, pcall-guarded -- onStep / onInteract first truthy return consumes -- -- Precedence is priority descending, later registration first at equal -- priority; base sits at priority 0 behind every default-priority mod. local Data = require("src.core.Data") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Strings = require("src.core.Strings") local MapScripts = {} local base = {} -- mapId -> merged engine contribution local views = {} -- mapId -> { chain = chainRef, value = merged view, sources } local HOOK_RULES = { onEnter = "all", onVictory = "all", onBoulderMoved = "all", onStep = "first", onInteract = "first", } -- the v1 merge, verbatim: later base files override earlier ones function MapScripts.attachBase(mapId, contribution) local existing = base[mapId] if not existing then base[mapId] = contribution else for k, v in pairs(contribution) do if k == "talk" and existing.talk then for textConst, script in pairs(v) do existing.talk[textConst] = script end else existing[k] = v end end end views[mapId] = nil end function MapScripts.invalidate(mapId) if mapId then views[mapId] = nil else views = {} end end -- Registry:chain hands the mod contributions priority-descending with -- earlier registrations first; re-rank each equal-priority run so a -- later registration outranks an earlier one, then slot base in at -- priority 0 behind the mods that tie it. chain.owners -- stamped by the -- loader merge, index-aligned with the values -- rides along so every -- contribution keeps its attribution. local function contributions(chain, baseEntry) local owners = chain.owners or {} local ordered, run, runPriority = {}, {}, nil local function flush() for i = #run, 1, -1 do ordered[#ordered + 1] = run[i] end run = {} end for i, entry in ipairs(chain) do local p = type(entry) == "table" and entry.priority or 0 if p ~= runPriority then flush() runPriority = p end run[#run + 1] = { value = entry, owner = owners[i] } end flush() if baseEntry then local at = #ordered + 1 for i, entry in ipairs(ordered) do local p = type(entry.value) == "table" and entry.value.priority or 0 if (p or 0) < 0 then at = i break end end table.insert(ordered, at, { value = baseEntry }) end return ordered end -- the runner's ctx.source for one contribution's rows (09 §4.4): errors, -- mod: field routing and the script events name the owning mod. Base -- contributions stay unattributed, so a mod-free dispatch hands the runner -- the exact extra it always did. local function sourceFor(owner, mapId, hook) if not (owner and owner.modId) then return nil end return { modId = owner.modId, strict = owner.strict, mapId = mapId, hook = hook } end local function blame(mapId, hookName, owner, err) local modId = owner and owner.modId Logger.error("map script %s.%s [%s]: %s", mapId, hookName, tostring(modId or "engine"), tostring(err)) if modId then Runtime.reportError(modId, tostring(err)) end end local function chainAll(mapId, hookName, handlers) return function(...) for _, handler in ipairs(handlers) do local ok, err = pcall(handler.fn, ...) if not ok then blame(mapId, hookName, handler.owner, err) end end end end local function chainFirst(mapId, hookName, handlers) return function(...) local result for _, handler in ipairs(handlers) do local ok, value = pcall(handler.fn, ...) if not ok then blame(mapId, hookName, handler.owner, value) elseif value then return value else result = value end end return result end end local function buildView(mapId, ordered) local view = { talk = {} } local sources = { talk = {}, scripts = {} } local talkDefined, scriptsDefined, otherDefined = {}, {}, {} local hooks = {} for _, entry in ipairs(ordered) do local contribution, owner = entry.value, entry.owner for key, value in pairs(contribution) do if key == "talk" then for textConst, script in pairs(value) do if not talkDefined[textConst] then talkDefined[textConst] = true -- false is explicit suppression: talkTo falls through to its -- item-ball/trainer/mart branches if script ~= false then view.talk[textConst] = script sources.talk[textConst] = sourceFor(owner, mapId, "talk") end end end elseif key == "scripts" then view.scripts = view.scripts or {} for name, rows in pairs(value) do if not scriptsDefined[name] then scriptsDefined[name] = true if rows ~= false then view.scripts[name] = rows sources.scripts[name] = sourceFor(owner, mapId, "scripts." .. name) end end end elseif HOOK_RULES[key] then local list = hooks[key] if not list then list = {} hooks[key] = list end list[#list + 1] = { fn = value, owner = owner } elseif key ~= "priority" then -- legacy ad-hoc keys (snorlaxWake, escort, ...): talk's rule if not otherDefined[key] then otherDefined[key] = true if value ~= false then view[key] = value end end end end end for hookName, handlers in pairs(hooks) do if #handlers == 1 and not (handlers[1].owner and handlers[1].owner.modId) then -- a lone base handler dispatches bare, exactly as the v1 merge did view[hookName] = handlers[1].fn elseif HOOK_RULES[hookName] == "all" then view[hookName] = chainAll(mapId, hookName, handlers) else view[hookName] = chainFirst(mapId, hookName, handlers) end end return view, sources end function MapScripts.get(mapId) local chains = Data.map_scripts local chain = chains and chains[mapId] local baseEntry = base[mapId] -- map_scripts:override or :remove cleared the chain for this map -- (09 4.4), so the engine's own contribution is excluded too -- otherwise -- a total conversion still gets the vanilla onEnter and every TEXT -- constant it did not redefine, and a removed map is not actually gone. -- A tombstone arrives as an empty chain and falls through to nil below if chain and chain.replacesBase then baseEntry = nil end if not chain or #chain == 0 then return baseEntry end local hit = views[mapId] if hit and hit.chain == chain then return hit.value end local view, sources = buildView(mapId, contributions(chain, baseEntry)) views[mapId] = { chain = chain, value = view, sources = sources } return view end -- the cached sources beside a map's merged view; nil when the map has no -- chain (base fast path) and for base-owned winners local function viewSources(mapId) local chains = Data.map_scripts local chain = chains and chains[mapId] if not chain or #chain == 0 then return nil end MapScripts.get(mapId) local hit = views[mapId] return hit and hit.sources end -- ctx.source for a talk dispatch, handed to ScriptRunner:run by -- showMapText so the winning contribution's rows run as their owner function MapScripts.talkSource(mapId, textConst) local sources = viewSources(mapId) return sources and sources.talk[textConst] or nil end -- ctx.source for a named `scripts` entry (run_parallel / queueScript refs) function MapScripts.namedSource(mapId, name) local sources = viewSources(mapId) return sources and sources.scripts[name] or nil end -- script to run when the player talks to an object with this TEXT_ constant function MapScripts.talkScript(mapId, textConst) local view = MapScripts.get(mapId) return view and view.talk and view.talk[textConst] or nil end -- the base (engine) talk handler behind any mod override -- the supported -- replacement for the old re-wrap idiom function MapScripts.baseTalk(mapId, textConst) local entry = base[mapId] return entry and entry.talk and entry.talk[textConst] or nil end -- "MAP_ID/name" refs used by run_parallel and queueScript function MapScripts.namedScript(mapId, name) local view = MapScripts.get(mapId) return view and view.scripts and view.scripts[name] or nil end -- ------- load-time validation (09 §4.9) -- every row list reachable from one contribution; findings name the key -- they were found under. lookup is the verb resolver handed to -- ScriptRunner.validate; the loader and modkit share this pass. function MapScripts.validateContribution(contribution, lookup) local ScriptRunner = require("src.script.ScriptRunner") local problems = {} local function collect(where, rows) if type(rows) ~= "table" then return end for _, finding in ipairs(ScriptRunner.validate(rows, lookup)) do problems[#problems + 1] = where .. ": " .. finding end end if type(contribution) ~= "table" then return { Strings("contribution is not a table") } end for textConst, script in pairs(contribution.talk or {}) do if type(script) == "table" then collect("talk." .. textConst, script) end end for name, rows in pairs(contribution.scripts or {}) do if type(rows) == "table" then collect("scripts." .. name, rows) end end if type(contribution.snorlaxWake) == "table" then collect("snorlaxWake", contribution.snorlaxWake.script) end return problems end return MapScripts