mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
311 lines
10 KiB
Lua
311 lines
10 KiB
Lua
-- Executes map scripts: lists of { "command", args... } rows (see
|
|
-- src/script/Commands.lua and data/scripts/). Runs as a coroutine so
|
|
-- commands like show_text and wait can block on UI.
|
|
--
|
|
-- Map-specific behavior lives in data/scripts/<map>.lua modules, never in
|
|
-- engine code. Each hand-ported script references its asm source.
|
|
--
|
|
-- Script v2: { "label", "name" } rows are jump targets; jump/jump_if_*
|
|
-- may return a label name and exec resolves it through a pre-scanned
|
|
-- label map. "end" is reserved shorthand for halt. Numeric targets are
|
|
-- untouched, so hand-numbered vanilla ports keep working row for row.
|
|
|
|
local Commands = require("src.script.Commands")
|
|
local Logger = require("src.core.Logger")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local SaveSerializer = require("src.core.SaveSerializer")
|
|
local Strings = require("src.core.Strings")
|
|
|
|
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
|
|
|
local ScriptRunner = {}
|
|
ScriptRunner.__index = ScriptRunner
|
|
|
|
-- weak-keyed so a hot-reloaded script table drops its stale scan
|
|
local labelCache = setmetatable({}, { __mode = "k" })
|
|
|
|
function ScriptRunner.scanLabels(script)
|
|
local hit = labelCache[script]
|
|
if hit then return hit end
|
|
local labels = {}
|
|
for index, row in ipairs(script) do
|
|
if type(row) == "table" and row[1] == "label" and type(row[2]) == "string"
|
|
and labels[row[2]] == nil then
|
|
labels[row[2]] = index
|
|
end
|
|
end
|
|
labelCache[script] = labels
|
|
return labels
|
|
end
|
|
|
|
-- Load-time validation (09 §4.9): every finding names its row. lookup
|
|
-- is fn(verb) -> true when the verb resolves; nil uses the built-in set,
|
|
-- so the offline validator and the loader share this code path.
|
|
function ScriptRunner.validate(script, lookup)
|
|
local problems = {}
|
|
local function bad(fmt, ...)
|
|
problems[#problems + 1] = fmt:format(...)
|
|
end
|
|
if type(script) ~= "table" then
|
|
bad("script is not a row list")
|
|
return problems
|
|
end
|
|
lookup = lookup or function(verb) return Commands[verb] ~= nil end
|
|
local labels = {}
|
|
for index, row in ipairs(script) do
|
|
if type(row) ~= "table" or type(row[1]) ~= "string" then
|
|
bad("row %d is not a { \"command\", ... } row", index)
|
|
elseif row[1] == "label" then
|
|
local name = row[2]
|
|
if type(name) ~= "string" then
|
|
bad("row %d: label needs a string name", index)
|
|
elseif labels[name] then
|
|
bad("row %d: duplicate label '%s' (first at row %d)",
|
|
index, name, labels[name])
|
|
else
|
|
labels[name] = index
|
|
end
|
|
elseif not lookup(row[1]) then
|
|
bad("row %d: unknown command '%s'", index, row[1])
|
|
end
|
|
end
|
|
for index, row in ipairs(script) do
|
|
if type(row) == "table" and (row[1] == "jump" or row[1] == "jump_if_true"
|
|
or row[1] == "jump_if_false") then
|
|
local target = row[2]
|
|
if type(target) == "string" then
|
|
if target ~= "end" and not labels[target] then
|
|
bad("row %d: jump to missing label '%s'", index, target)
|
|
end
|
|
elseif type(target) == "number" then
|
|
if target ~= math.huge and (target < 1 or target > #script
|
|
or target % 1 ~= 0) then
|
|
bad("row %d: jump target %s out of range 1..%d",
|
|
index, tostring(target), #script)
|
|
end
|
|
else
|
|
bad("row %d: jump needs a label or row number", index)
|
|
end
|
|
end
|
|
end
|
|
return problems
|
|
end
|
|
|
|
function ScriptRunner.new(game, overworld)
|
|
local self = setmetatable({}, ScriptRunner)
|
|
self.game = game
|
|
self.overworld = overworld
|
|
self.co = nil
|
|
return self
|
|
end
|
|
|
|
function ScriptRunner:isRunning()
|
|
return self.co ~= nil and coroutine.status(self.co) ~= "dead"
|
|
end
|
|
|
|
-- ctx passed to commands: engine services plus per-run info (npc, map).
|
|
-- extra.source = { modId, mapId, hook } attributes errors and the mod:
|
|
-- field route to the contribution's owner.
|
|
function ScriptRunner:makeContext(extra)
|
|
local ctx = {
|
|
game = self.game,
|
|
overworld = self.overworld,
|
|
save = self.game.save,
|
|
runner = self,
|
|
}
|
|
for k, v in pairs(extra or {}) do ctx[k] = v end
|
|
return ctx
|
|
end
|
|
|
|
function ScriptRunner:run(script, extra, startPc)
|
|
assert(not self:isRunning(), "script already running")
|
|
local ctx = self:makeContext(extra)
|
|
self.ctx = ctx
|
|
if Runtime.wants("script.started") then
|
|
Runtime.emit("script.started", { ctx = ctx })
|
|
end
|
|
self.co = coroutine.create(function()
|
|
self:exec(script, ctx, startPc)
|
|
-- Commands can defer a game action until the script's own dialogue is
|
|
-- done. start_battle uses this for win-path evolutions, which must not
|
|
-- be covered by post-battle trainer text.
|
|
for _, callback in ipairs(ctx.afterScript or {}) do callback() end
|
|
if ctx.onDone then ctx.onDone() end
|
|
if Runtime.wants("script.ended") then
|
|
Runtime.emit("script.ended", { ctx = ctx, completed = true })
|
|
end
|
|
end)
|
|
self:resume()
|
|
end
|
|
|
|
-- Execute a command list. Supports labels via jump commands: a script is
|
|
-- an array of rows; control commands return a new program counter, as a
|
|
-- row number or a label name.
|
|
function ScriptRunner:exec(script, ctx, startPc)
|
|
local labels = ScriptRunner.scanLabels(script)
|
|
local data = self.game and self.game.data
|
|
local pc = startPc or 1
|
|
self.script = script
|
|
while pc <= #script do
|
|
self.pc = pc
|
|
local row = script[pc]
|
|
local name = row[1]
|
|
local fn, meta = Commands.resolve(data, name)
|
|
if not fn then
|
|
-- api 2 owned scripts fail loudly; everything else keeps the v1
|
|
-- skip so old content degrades instead of dying
|
|
if ctx.source and ctx.source.strict then
|
|
error(("unknown command '%s' at row %d"):format(tostring(name), pc), 0)
|
|
end
|
|
Logger.warn("script: unknown command '%s' (skipped)", tostring(name))
|
|
pc = pc + 1
|
|
else
|
|
if self.parallel and meta and meta.foreground then
|
|
error(("'%s' is a foreground command; illegal in a parallel script")
|
|
:format(name), 0)
|
|
end
|
|
local jump
|
|
if Runtime.wantsHook("script.command") then
|
|
local args = { select(2, unpack(row)) }
|
|
jump = Runtime.call("script.command", function(hctx, _, hargs)
|
|
return fn(hctx, unpack(hargs))
|
|
end, ctx, name, args)
|
|
else
|
|
jump = fn(ctx, select(2, unpack(row)))
|
|
end
|
|
if type(jump) == "string" then
|
|
if jump == "end" then
|
|
pc = math.huge
|
|
else
|
|
local target = labels[jump]
|
|
if not target then
|
|
error(("jump to missing label '%s' at row %d"):format(jump, pc), 0)
|
|
end
|
|
pc = target
|
|
end
|
|
elseif type(jump) == "number" then
|
|
pc = jump
|
|
else
|
|
pc = pc + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local CHECKPOINT_BATTLE_COMMANDS = {
|
|
start_battle = true,
|
|
static_battle = true,
|
|
rival_battle = true,
|
|
}
|
|
|
|
local function dataCopy(value)
|
|
local ok, encoded = pcall(SaveSerializer.encode, value)
|
|
if not ok then return nil end
|
|
return SaveSerializer.decode(encoded)
|
|
end
|
|
|
|
-- Return a detached semantic continuation only for built-in battle commands
|
|
-- whose post-yield behavior can be replayed from the current row. The live
|
|
-- coroutine and arbitrary completion callbacks never cross this boundary.
|
|
function ScriptRunner:battleCheckpointOrigin(battle)
|
|
local row = self.script and self.script[self.pc]
|
|
if not self:isRunning() or type(row) ~= "table"
|
|
or not CHECKPOINT_BATTLE_COMMANDS[row[1]] then
|
|
return nil
|
|
end
|
|
local ctx = self.ctx or {}
|
|
if ctx.onDone ~= nil and ctx.checkpointOnDone ~= "release_npc" then
|
|
return nil
|
|
end
|
|
local npcId = ctx.npc and ctx.npc.id or nil
|
|
if ctx.checkpointOnDone == "release_npc" and type(npcId) ~= "string" then
|
|
return nil
|
|
end
|
|
local map = self.overworld and self.overworld.map and self.overworld.map.id
|
|
if type(map) ~= "string" then return nil end
|
|
local origin = {
|
|
kind = "script_battle",
|
|
map = map,
|
|
script = self.script,
|
|
pc = self.pc,
|
|
command = row[1],
|
|
npcId = npcId,
|
|
source = ctx.source,
|
|
battleKind = battle and battle.kind,
|
|
trainerClass = battle and battle.oppClass or nil,
|
|
partyIndex = battle and battle.partyIndex or nil,
|
|
wildSpecies = battle and battle.enemy and battle.enemy.mon
|
|
and battle.enemy.mon.species or nil,
|
|
wildLevel = battle and battle.enemy and battle.enemy.mon
|
|
and battle.enemy.mon.level or nil,
|
|
}
|
|
return dataCopy(origin)
|
|
end
|
|
|
|
function ScriptRunner:isCheckpointBattle(battle)
|
|
return self.checkpointBattle == battle and self:isRunning()
|
|
end
|
|
|
|
-- Called by blocking commands from inside the coroutine.
|
|
function ScriptRunner:yield()
|
|
return coroutine.yield()
|
|
end
|
|
|
|
function ScriptRunner:resume(...)
|
|
local co = self.co
|
|
if not co then return end
|
|
-- A completion callback can fire synchronously from inside the running
|
|
-- coroutine (e.g. a battle that finishes during its own stack push when
|
|
-- the party is already fainted). Resuming a running coroutine is an
|
|
-- error that would kill the whole script, so land the pending yield
|
|
-- first and continue on the next update tick instead.
|
|
if coroutine.status(co) == "running" then
|
|
self.waitingFrames = 1
|
|
return
|
|
end
|
|
local ok, err = coroutine.resume(co, ...)
|
|
if not ok then
|
|
local source = self.ctx and self.ctx.source
|
|
local where = source
|
|
and Strings(" [%s %s.%s]", tostring(source.modId or "engine"),
|
|
tostring(source.mapId or "?"), tostring(source.hook or "?"))
|
|
or ""
|
|
Logger.error("script error%s: %s", where, tostring(err))
|
|
if source and source.modId then
|
|
Runtime.reportError(source.modId, tostring(err))
|
|
end
|
|
if Runtime.wants("script.ended") then
|
|
Runtime.emit("script.ended", { ctx = self.ctx, completed = false })
|
|
end
|
|
self.co = nil
|
|
self.waitingFrames = nil
|
|
self.waitingCheck = nil
|
|
-- status via the captured co: a nested resume during the call above may
|
|
-- already have torn self.co down, and status(nil) would throw
|
|
elseif coroutine.status(co) == "dead" then
|
|
if self.co == co then self.co = nil end
|
|
end
|
|
end
|
|
|
|
function ScriptRunner:update()
|
|
-- commands that wait on frames re-resume every step while running
|
|
if self:isRunning() and self.waitingFrames then
|
|
self.waitingFrames = self.waitingFrames - 1
|
|
if self.waitingFrames <= 0 then
|
|
self.waitingFrames = nil
|
|
self:resume()
|
|
end
|
|
end
|
|
-- per-frame condition polls (wait_flag, push_screen): the check returns
|
|
-- done plus the value the yield should hand back
|
|
if self:isRunning() and self.waitingCheck then
|
|
local done, result = self.waitingCheck()
|
|
if done then
|
|
self.waitingCheck = nil
|
|
self:resume(result)
|
|
end
|
|
end
|
|
end
|
|
|
|
return ScriptRunner
|