diff --git a/docs/modding.md b/docs/modding.md index b13fb048..90294167 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 566afc9d..7aadea3c 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -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). diff --git a/tests/modkit/cases/world_party_reorder.lua b/tests/modkit/cases/world_party_reorder.lua new file mode 100644 index 00000000..f2ee8a7a --- /dev/null +++ b/tests/modkit/cases/world_party_reorder.lua @@ -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()