mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +02:00
Add lifecycle hooks for tool mods
This commit is contained in:
@@ -150,5 +150,19 @@ The console understands these verbs (anything else is evaluated as Lua):
|
|||||||
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
||||||
- `help` — list the verbs.
|
- `help` — list the verbs.
|
||||||
|
|
||||||
|
## Tool input and title-menu hooks
|
||||||
|
|
||||||
|
Tool mods that need to act once per game logic tick can wrap `input.step`.
|
||||||
|
It runs immediately before queued button edges are promoted, so input added by
|
||||||
|
the wrapper is visible during that same fixed step. The callback receives
|
||||||
|
`(next, game, dt)` and must call `next(game, dt)`.
|
||||||
|
|
||||||
|
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||||
|
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||||
|
for a tool to offer a fresh-session action before gameplay begins.
|
||||||
|
|
||||||
|
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
|
||||||
|
progress write before world state is captured or any bytes reach disk.
|
||||||
|
|
||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|||||||
+11
-1
@@ -169,6 +169,11 @@ function Game:returnToTitle()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Game:step(dt)
|
function Game:step(dt)
|
||||||
|
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||||
|
-- the same fixed-step boundary as a physical controller. Run them before
|
||||||
|
-- Input:step promotes queued edges so a button chosen here is visible to
|
||||||
|
-- this logic tick, not one tick later. With no wrapper this is a no-op.
|
||||||
|
ModRuntime.call("input.step", function() end, self, dt)
|
||||||
self.input:step()
|
self.input:step()
|
||||||
-- serviced unconditionally: a link battle's ENet transport must not
|
-- serviced unconditionally: a link battle's ENet transport must not
|
||||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||||
@@ -476,6 +481,11 @@ end
|
|||||||
-- Capture the live world state into the save table and persist it.
|
-- Capture the live world state into the save table and persist it.
|
||||||
-- Options are flushed to options.lua as part of SaveData.save.
|
-- Options are flushed to options.lua as part of SaveData.save.
|
||||||
function Game:writeSave()
|
function Game:writeSave()
|
||||||
|
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
|
||||||
|
-- before captureSave mutates the snapshot or any progress bytes reach disk.
|
||||||
|
if ModRuntime.call("save.write", function() return true end, self) == false then
|
||||||
|
return false
|
||||||
|
end
|
||||||
if self.overworld and self.overworld.captureSave then
|
if self.overworld and self.overworld.captureSave then
|
||||||
self.overworld:captureSave(self.save)
|
self.overworld:captureSave(self.save)
|
||||||
end
|
end
|
||||||
@@ -486,7 +496,7 @@ function Game:writeSave()
|
|||||||
if ModRuntime.wants("save.writing") then
|
if ModRuntime.wants("save.writing") then
|
||||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||||
end
|
end
|
||||||
SaveData.save(self.save)
|
return SaveData.save(self.save)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
||||||
|
|||||||
@@ -676,11 +676,15 @@ function Commands.record_hall_of_fame(ctx)
|
|||||||
if game.overworld then
|
if game.overworld then
|
||||||
game.overworld.lastOutdoor = ctx.save.lastOutdoor
|
game.overworld.lastOutdoor = ctx.save.lastOutdoor
|
||||||
end
|
end
|
||||||
if game.writeSave then game:writeSave() end
|
local saveAllowed = true
|
||||||
|
if game.writeSave then saveAllowed = game:writeSave() ~= false end
|
||||||
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
|
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
|
||||||
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
||||||
SaveData.applyPostGameHome(ctx.save, boot)
|
SaveData.applyPostGameHome(ctx.save, boot)
|
||||||
SaveData.save(ctx.save)
|
-- A tool session may veto Game:writeSave to keep its in-memory run
|
||||||
|
-- isolated from the player's real save. The relocation write is part
|
||||||
|
-- of that same save operation and must honor the same decision.
|
||||||
|
if saveAllowed then SaveData.save(ctx.save) end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
runner:yield()
|
runner:yield()
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ local Font = require("src.render.Font")
|
|||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Logger = require("src.core.Logger")
|
||||||
|
|
||||||
local TitleState = {}
|
local TitleState = {}
|
||||||
TitleState.__index = TitleState
|
TitleState.__index = TitleState
|
||||||
@@ -136,6 +138,8 @@ local function hasSave()
|
|||||||
return ok and info ~= nil
|
return ok and info ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function sameItems(_, items) return items end
|
||||||
|
|
||||||
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
|
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
|
||||||
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
|
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
|
||||||
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
|
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
|
||||||
@@ -211,6 +215,13 @@ function TitleState:openMenu()
|
|||||||
love.event.quit()
|
love.event.quit()
|
||||||
end
|
end
|
||||||
end })
|
end })
|
||||||
|
local hooked = Runtime.call("ui.title_menu.items", sameItems, game, items)
|
||||||
|
if type(hooked) == "table" then
|
||||||
|
items = hooked
|
||||||
|
else
|
||||||
|
Logger.error("ui.title_menu.items returned %s; keeping the vanilla items",
|
||||||
|
type(hooked))
|
||||||
|
end
|
||||||
local th = #items * 2 + 2
|
local th = #items * 2 + 2
|
||||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
-- Public seams a tool mod needs: a fixed-step input hook and a
|
||||||
|
-- title-menu entry point. Both are no-ops without a mod (gate_hooks.lua
|
||||||
|
-- covers that parity); this case proves a real public wrapper can act at the
|
||||||
|
-- correct point and decorate the real title menu.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Hooks = require("src.mods.Hooks")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local savedEvents, savedHooks, savedErrors =
|
||||||
|
Runtime.events, Runtime.hooks, Runtime.errors
|
||||||
|
local hooks = Hooks.new()
|
||||||
|
Runtime.hooks = hooks
|
||||||
|
|
||||||
|
-- If input.step disappears, or moves after Input:step, a bot's buttons land
|
||||||
|
-- one logic tick late. Exercise Game:step itself and pin the observable
|
||||||
|
-- ordering rather than merely checking that a hook was registered.
|
||||||
|
do
|
||||||
|
local order = {}
|
||||||
|
local fake = {
|
||||||
|
input = { step = function() order[#order + 1] = "input" end },
|
||||||
|
stack = { update = function(_, dt)
|
||||||
|
order[#order + 1] = "world"
|
||||||
|
T.eq(dt, 1 / 60, "the world receives the fixed-step dt")
|
||||||
|
end },
|
||||||
|
save = {},
|
||||||
|
}
|
||||||
|
hooks:wrap("input.step", function(nextFn, game, dt)
|
||||||
|
T.check(game == fake, "input.step receives the live Game object")
|
||||||
|
T.eq(dt, 1 / 60, "input.step receives the fixed-step dt")
|
||||||
|
order[#order + 1] = "hook"
|
||||||
|
return nextFn(game, dt)
|
||||||
|
end, 0, "tool_fixture")
|
||||||
|
|
||||||
|
require("src.core.Game").step(fake, 1 / 60)
|
||||||
|
T.eq(table.concat(order, ","), "hook,input,world",
|
||||||
|
"input.step runs before input edges are promoted and before gameplay")
|
||||||
|
hooks:removeOwner("tool_fixture")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If ui.title_menu.items disappears, a tool mod can load but has no
|
||||||
|
-- safe user-facing way to begin a fresh, non-destructive run.
|
||||||
|
do
|
||||||
|
local TitleState = require("src.ui.TitleState")
|
||||||
|
local stack = { states = {} }
|
||||||
|
function stack:push(state) self.states[#self.states + 1] = state end
|
||||||
|
function stack:top() return self.states[#self.states] end
|
||||||
|
local game = {
|
||||||
|
data = { field = { title = { cycleSpecies = { "MEW" } } },
|
||||||
|
pokemon = { MEW = {} } },
|
||||||
|
stack = stack,
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks:wrap("ui.title_menu.items", function(nextFn, liveGame, items)
|
||||||
|
T.check(liveGame == game, "ui.title_menu.items receives the live Game")
|
||||||
|
table.insert(items, #items, { label = "AUTOPLAY" })
|
||||||
|
return nextFn(liveGame, items)
|
||||||
|
end, 0, "tool_fixture")
|
||||||
|
|
||||||
|
TitleState.new(game, {}):openMenu()
|
||||||
|
local menu = stack:top()
|
||||||
|
T.eq(menu.items[#menu.items - 1].label, "AUTOPLAY",
|
||||||
|
"ui.title_menu.items can insert AUTOPLAY before EXIT GAME")
|
||||||
|
hooks:removeOwner("tool_fixture")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A viewer/AI session must be able to veto every normal progress-save path,
|
||||||
|
-- including the in-game SAVE menu and autosaves, before captureSave mutates
|
||||||
|
-- the live snapshot in preparation for disk IO.
|
||||||
|
do
|
||||||
|
local captured = false
|
||||||
|
local fake = {
|
||||||
|
save = {},
|
||||||
|
overworld = { captureSave = function() captured = true end },
|
||||||
|
}
|
||||||
|
hooks:wrap("save.write", function(nextFn, game)
|
||||||
|
T.check(game == fake, "save.write receives the live Game object")
|
||||||
|
return false
|
||||||
|
end, 0, "tool_fixture")
|
||||||
|
|
||||||
|
local saved = require("src.core.Game").writeSave(fake)
|
||||||
|
T.eq(saved, false, "save.write can veto progress persistence")
|
||||||
|
T.eq(captured, false, "a veto happens before save-state capture")
|
||||||
|
hooks:removeOwner("tool_fixture")
|
||||||
|
end
|
||||||
|
|
||||||
|
Runtime.events, Runtime.hooks, Runtime.errors =
|
||||||
|
savedEvents, savedHooks, savedErrors
|
||||||
|
|
||||||
|
T.finish("tool_mod_hooks")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- End-game persistence must honor a Game:writeSave veto so an ephemeral tool
|
||||||
|
-- session cannot overwrite the player's real save while THE END is on screen.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Screens = require("src.ui.Screens")
|
||||||
|
|
||||||
|
local originalPush, originalSave = Screens.push, SaveData.save
|
||||||
|
local screens = {}
|
||||||
|
local directWrites = 0
|
||||||
|
|
||||||
|
Screens.push = function(_, id, onDone, onTheEnd)
|
||||||
|
screens[id] = { onDone = onDone, onTheEnd = onTheEnd }
|
||||||
|
return screens[id]
|
||||||
|
end
|
||||||
|
SaveData.save = function()
|
||||||
|
directWrites = directWrites + 1
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = {}
|
||||||
|
local game = {
|
||||||
|
data = { field = { boot = {} } },
|
||||||
|
save = save,
|
||||||
|
writeSave = function() return false end,
|
||||||
|
}
|
||||||
|
local runner = { yield = function() coroutine.yield() end }
|
||||||
|
local ctx = { game = game, save = save, runner = runner }
|
||||||
|
local co = coroutine.create(function() Commands.record_hall_of_fame(ctx) end)
|
||||||
|
local ok, err = coroutine.resume(co)
|
||||||
|
T.check(ok, "Hall of Fame command reaches its UI yield: " .. tostring(err))
|
||||||
|
T.check(screens.HallOfFame ~= nil, "Hall of Fame induction was requested")
|
||||||
|
|
||||||
|
screens.HallOfFame.onDone()
|
||||||
|
T.check(screens.Credits ~= nil, "credits were requested after induction")
|
||||||
|
screens.Credits.onTheEnd()
|
||||||
|
T.eq(directWrites, 0,
|
||||||
|
"a vetoed Hall of Fame autosave performs no fallback disk write")
|
||||||
|
|
||||||
|
Screens.push, SaveData.save = originalPush, originalSave
|
||||||
|
T.finish("tool_save_safety")
|
||||||
Reference in New Issue
Block a user