From a3a20a07e14fd29d59470de4c855d3ea2165dc02 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:57:39 +0200 Subject: [PATCH] feat(mods): expose Fly and Softboiled field actions --- docs/modding.md | 21 ++++-- src/ui/PartyMenu.lua | 18 +---- src/world/OverworldController.lua | 17 +++++ src/world/WorldAPI.lua | 93 +++++++++++++++++++++++- tests/modkit/cases/world_field_items.lua | 41 ++++++++++- 5 files changed, 165 insertions(+), 25 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 264ad9d5..339cf921 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched. start at the player's current position. Both games expose `bicycle`, `fish`, `cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the -contextual `squirtbottle` key item. Fishing rows include the owned rods that -are valid choices. The list is empty while the world is busy, and omits an -action whenever its item, move, badge, terrain, or engine state forbids it. +contextual `squirtbottle` key item. Red additionally exposes `softboiled` with +eligible `sources`; each source contains its eligible `targets`. Fishing rows +include the owned rods that are valid choices. The list is empty while the +world is busy, and omits an action whenever its item, move, badge, terrain, or +engine state forbids it. The optional second return is `"world is busy"` during transient input locks or `"no overworld"` before a playable world exists. Call `mod.world:useFieldAction(id, opts)` to perform a listed action through the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }` -and chooses automatically when only one rod is available. Invalid, stale, and -busy requests return `nil` plus a reason without changing game state. Mods do -not need generation-specific badge, terrain, bike, fishing, or field-move +and chooses automatically when only one rod is available. Red's `softboiled` +accepts one-based `{ sourceSlot, targetSlot }` values copied from its action +record. Invalid, stale, and busy requests return `nil` plus a reason without +changing game state. Mods do not need generation-specific badge, terrain, +bike, fishing, or field-move logic. Action lists are extensible; callers should render the records they understand and ignore unknown ids rather than assuming a fixed list length. +Red exposes FLY separately because it requires a destination picker: +`mod.world:canFly()` reports whether FLY is eligible at the current location, +and `mod.world:flyTo(mapId)` accepts only a visited destination from the native +Fly town list. Gold does not expose these two methods yet. + ## Read-only battle snapshots `mod.battle:snapshot()` returns `nil` outside a battle and a copied battle diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 32db27ae..04c73930 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -628,22 +628,8 @@ function PartyMenu:update(dt) end if self.softboiledFrom then local user = party[self.softboiledFrom] - local heal = math.floor(user.stats.hp / 5) - if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp - or user.hp <= heal then - self.softboiledFrom = nil - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) - else - user.hp = user.hp - heal - mon.hp = math.min(mon.stats.hp, mon.hp + heal) - self.softboiledFrom = nil - require("src.core.Sound").play(self.game.data, "Heal_HP") - local def = self.game.data.pokemon[mon.species] - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - Strings("%s's HP\nwas restored!", mon.nickname or def.name))) - end + self.softboiledFrom = nil + self.game.overworld:useSoftboiledFieldMove(user, mon) elseif self.swapFrom then if self.swapFrom ~= self.index then party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6037233a..a2cfda3b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -827,6 +827,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose) return true end +function OverworldState:useSoftboiledFieldMove(user, target) + local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0 + if not user or not user.stats or not target or not target.stats + or target == user or target.hp <= 0 + or target.hp >= target.stats.hp or user.hp <= heal then + Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect."))) + return false + end + user.hp = user.hp - heal + target.hp = math.min(target.stats.hp, target.hp + heal) + require("src.core.Sound").play(Game.data, "Heal_HP") + local def = Game.data.pokemon[target.species] + Game.stack:push(TextBox.new(Game, + Strings("%s's HP\nwas restored!", target.nickname or def.name))) + return true +end + -- The battle transition's dungeon wipe uses the explicit map lists in -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- inclusive map-id ranges -- faithful to the original's omissions diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 286e5dd6..69b9eea2 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -37,6 +37,57 @@ local function validPartySlot(party, slot) and party[slot] ~= nil end +local function outside(game, ow) + return Map.isOutside(ow.map.def, + FieldDefaults.field(game.data, "outsideTilesets")) +end + +local function knows(mon, moveId) + for _, move in ipairs(mon.moves or {}) do + if move.id == moveId then return true end + end + return false +end + +local function monInfo(game, mon, slot) + local def = game.data.pokemon[mon.species] or {} + return { slot = slot, species = mon.species, + name = mon.nickname or def.name or mon.species, level = mon.level, + hp = mon.hp, maxHp = mon.stats and mon.stats.hp or mon.hp } +end + +local function softboiledSources(game) + local party, sources = game.save.party or {}, {} + for sourceSlot, source in ipairs(party) do + local heal = source.stats and math.floor(source.stats.hp / 5) or 0 + if knows(source, "SOFTBOILED") and source.hp > heal then + local info = monInfo(game, source, sourceSlot) + info.targets = {} + for targetSlot, target in ipairs(party) do + if target ~= source and target.hp > 0 and target.stats + and target.hp < target.stats.hp then + info.targets[#info.targets + 1] = monInfo(game, target, targetSlot) + end + end + if #info.targets > 0 then sources[#sources + 1] = info end + end + end + return sources +end + +local function flyDestinationAvailable(game, mapId) + local field, save = game.data.field or {}, game.save + for _, id in ipairs(field.flyOrder or {}) do + if id == mapId then + local def = game.data.maps and game.data.maps[id] + return not not (save.visited and save.visited[id] + and field.flyWarps and field.flyWarps[id] + and def and Map.isFlyTown(def)) + end + end + return false +end + function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) end @@ -141,10 +192,14 @@ function WorldAPI:availableFieldActions() and ow:partyKnows("DIG") then out[#out + 1] = { id = "dig", label = "DIG" } end - if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def, - FieldDefaults.field(game.data, "outsideTilesets")) then + if ow:partyKnows("TELEPORT") and outside(game, ow) then out[#out + 1] = { id = "teleport", label = "TELEPORT" } end + local sources = softboiledSources(game) + if #sources > 0 then + out[#out + 1] = { id = "softboiled", label = "SOFTBOILED", + sources = sources } + end return out end @@ -187,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts) elseif id == "dig" or id == "teleport" then ow:beginTeleportOut() return true + elseif id == "softboiled" then + local sourceSlot = opts and tonumber(opts.sourceSlot) + local targetSlot = opts and tonumber(opts.targetSlot) + local allowed + for _, source in ipairs(found.sources or {}) do + if source.slot == sourceSlot then + for _, target in ipairs(source.targets or {}) do + if target.slot == targetSlot then allowed = true break end + end + end + end + if not allowed then return nil, "softboiled target unavailable" end + if ow:useSoftboiledFieldMove(game.save.party[sourceSlot], + game.save.party[targetSlot]) then return true end end return nil, "field action unavailable" end +-- FLY needs a destination choice, so it is exposed separately from the +-- immediate actions above. The request is still checked against the same +-- visited-town list as the native Town Map picker before the world may warp. +function WorldAPI:canFly() + local game, ow = self.game, self:overworld() + return not not (ow and ow.map and outside(game, ow) and ow:partyKnows("FLY")) +end + +function WorldAPI:flyTo(mapId) + local game, ow = self.game, self:overworld() + if not ow then return nil, NO_OVERWORLD end + if not self:canFly() then return nil, "fly unavailable" end + if not acceptsMenuInput(game, ow) then return nil, "world is busy" end + if not flyDestinationAvailable(game, mapId) then + return nil, "destination unavailable" + end + ow:flyTo(mapId) + 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_field_items.lua b/tests/modkit/cases/world_field_items.lua index 0e90146e..e6e237e7 100644 --- a/tests/modkit/cases/world_field_items.lua +++ b/tests/modkit/cases/world_field_items.lua @@ -24,7 +24,11 @@ local redWorld = { } local redGame = { data = { field = { outsideTilesets = { "OVERWORLD" } }, - items = { OLD_ROD = { name = "OLD ROD" } } }, + items = { OLD_ROD = { name = "OLD ROD" } }, + maps = { PALLET_TOWN = { index = 0, tileset = "OVERWORLD" }, + ROUTE_4 = { index = 11, tileset = "OVERWORLD" } }, + pokemon = { CHANSEY = { name = "CHANSEY" }, + PIKACHU = { name = "PIKACHU" } } }, save = { player = { name = "RED" }, party = {}, inventory = { BICYCLE = 1, OLD_ROD = 1 } }, stack = { states = { redWorld } }, @@ -42,6 +46,7 @@ T.check(type(RedWorld.useBicycle) == "function" and type(RedWorld.useFishingRod) == "function" and type(RedWorld.useFlashFieldMove) == "function" and type(RedWorld.useStrengthFieldMove) == "function" + and type(RedWorld.useSoftboiledFieldMove) == "function" and type(RedWorld.stopSurfing) == "function", "Red keeps field-action execution in its world") local actions = red:availableFieldActions() @@ -93,6 +98,40 @@ T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed and redWorld.flashUsed and redWorld.teleportUsed, "Red delegates every move to its overworld path") +local source = { species = "CHANSEY", level = 30, hp = 80, + stats = { hp = 100 }, moves = { { id = "SOFTBOILED" } } } +local target = { species = "PIKACHU", level = 20, hp = 10, + stats = { hp = 50 }, moves = {} } +redGame.save.party = { source, target } +redWorld.useSoftboiledFieldMove = function(self, user, recipient) + self.softboiled = { user, recipient } + return true +end +byId = {} +for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end +T.check(byId.softboiled and byId.softboiled.sources[1].targets[1].slot == 2, + "Red lists only valid SOFTBOILED targets") +T.check(red:useFieldAction("softboiled", { sourceSlot = 1, targetSlot = 2 }), + "Red accepts a listed SOFTBOILED transfer") +T.check(redWorld.softboiled[1] == source and redWorld.softboiled[2] == target, + "Red delegates SOFTBOILED to its overworld path") +ok, err = red:useFieldAction("softboiled", { sourceSlot = 2, targetSlot = 1 }) +T.check(not ok and err == "softboiled target unavailable", + "Red rejects an invalid SOFTBOILED source") + +redGame.save.inventory.THUNDERBADGE = 1 +redGame.save.visited = { PALLET_TOWN = true, ROUTE_4 = true } +redGame.data.field.flyOrder = { "PALLET_TOWN", "ROUTE_4" } +redGame.data.field.flyWarps = { PALLET_TOWN = true, ROUTE_4 = true } +redMoves.FLY = source +redWorld.flyTo = function(self, mapId) self.flewTo = mapId end +T.check(red:canFly(), "Red exposes FLY only in a valid outdoor context") +T.check(red:flyTo("PALLET_TOWN") and redWorld.flewTo == "PALLET_TOWN", + "Red validates and delegates a visited FLY destination") +ok, err = red:flyTo("ROUTE_4") +T.check(not ok and err == "destination unavailable", + "Red rejects a fly warp that is not a native town destination") + redSurf = "dismount" redWorld.player.surfing = true redWorld.stopSurfing = function(self) self.dismounted = true end