feat(mods): expose safe overworld party reordering

This commit is contained in:
AverageConsumer
2026-08-10 23:25:54 +02:00
parent 79ed37699e
commit e3fc8380cf
3 changed files with 115 additions and 0 deletions
+8
View File
@@ -45,6 +45,14 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
`warp`, visible `item`, and untaken `hidden` locations. All fields are
read-only snapshots; mods choose which layers to render.
## Party ordering
Companion UIs and alternate party screens can call
`mod.world:canReorderParty()` before offering a reorder action, then
`mod.world:reorderParty(fromSlot, toSlot)` with one-based party slots. The
operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
+41
View File
@@ -69,6 +69,22 @@ local function mapTileRows(map)
return rows, detailRows
end
local function acceptsMenuInput(game, ow)
local stack = game and game.stack
local runner = ow and ow.runner
return ow and stack and stack.top and stack:top() == ow
and not ow.transitioning and not ow.flyAnim and not ow.teleportOut
and not ow.engaging and not ow.emote and not ow.pikaHop and not ow.healAnim
and not (ow.player and (ow.player.moving or ow.player.inputLocked))
and not (runner and runner.isRunning and runner:isRunning())
and #(ow.scriptMoves or {}) == 0
end
local function validPartySlot(party, slot)
return type(slot) == "number" and slot == math.floor(slot)
and party[slot] ~= nil
end
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
end
@@ -98,6 +114,31 @@ function WorldAPI:current()
facing = p and p.facing }
end
-- Companion UIs may offer party ordering while the player is in free roam.
-- The same guard that makes opening a menu safe keeps scripts, transitions,
-- movement and screens above the overworld from observing a mid-action swap.
function WorldAPI:canReorderParty()
local game, ow = self.game, self:overworld()
local party = game and game.save and game.save.party or {}
return #party > 1 and not not acceptsMenuInput(game, ow)
end
function WorldAPI:reorderParty(fromSlot, toSlot)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
local party = game.save and game.save.party or {}
if not validPartySlot(party, fromSlot)
or not validPartySlot(party, toSlot) then
return nil, "invalid party slot"
end
if fromSlot ~= toSlot then
party[fromSlot], party[toSlot] = party[toSlot], party[fromSlot]
require("src.core.Sound").play(game.data, "Swap")
end
return true
end
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
@@ -0,0 +1,66 @@
-- Public party-ordering contract over an idle overworld fixture. No ROM data
-- is needed, so companion UIs exercise this seam in the normal mod-SDK tier.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod world party reorder")
local StateStack = require("src.core.StateStack")
local WorldAPI = require("src.world.WorldAPI")
local first = { species = "BULBASAUR" }
local second = { species = "CHARMANDER" }
local runner = { running = false }
function runner:isRunning() return self.running end
local ow = {
isOverworld = true,
map = { id = "PALLET_TOWN" },
player = { moving = false, inputLocked = false },
runner = runner,
scriptMoves = {},
}
local stack = setmetatable({ states = { ow } }, { __index = StateStack })
local game = {
data = {},
save = { party = { first, second } },
stack = stack,
overworld = ow,
}
local api = WorldAPI.new(game, "fixture")
T.check(api:canReorderParty(), "idle free roam allows party reordering")
local Sound = require("src.core.Sound")
local realPlay, played = Sound.play
Sound.play = function(_, name) played = name end
T.check(api:reorderParty(1, 2) == true, "valid slots reorder")
Sound.play = realPlay
T.check(game.save.party[1] == second and game.save.party[2] == first,
"the live party is swapped")
T.eq(played, "Swap", "the normal party swap sound is used")
local value, err = api:reorderParty(1.5, 2)
T.check(value == nil and err == "invalid party slot",
"non-integer slots are rejected")
value, err = api:reorderParty("1", 2)
T.check(value == nil and err == "invalid party slot",
"string slots are rejected")
stack:push({ screenId = "SomeMenu" })
T.check(not api:canReorderParty(), "a screen above the world blocks reordering")
value, err = api:reorderParty(1, 2)
T.check(value == nil and err == "world is busy",
"reordering refuses while another screen owns input")
stack:pop()
ow.player.moving = true
T.check(not api:canReorderParty(), "movement blocks reordering")
ow.player.moving = false
runner.running = true
T.check(not api:canReorderParty(), "scripts block reordering")
runner.running = false
ow.transitioning = true
T.check(not api:canReorderParty(), "map transitions block reordering")
T.finish()