From dcc388a94218b0724264ec7d51f95346e2a30eeb Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:39:18 +0200 Subject: [PATCH] feat(mods): expose contextual field items --- docs/mod-api-gen2-compat.md | 5 +- docs/modding.md | 14 ++++ docs/preparing-your-mod-for-gen2.md | 4 +- src/mods/Gen2Compat.lua | 3 +- src/world/OverworldController.lua | 27 ++++++++ src/world/WorldAPI.lua | 55 +++++++++++++++ src/world/gen2/WorldAPI.lua | 75 ++++++++++++++++++++ tests/modkit/cases/world_field_items.lua | 88 ++++++++++++++++++++++++ 8 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 tests/modkit/cases/world_field_items.lua diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index cb04fbf6..47c1a5b3 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -240,7 +240,7 @@ resolves to the weaker claim: | `warned` | present, answers nil or degrades, and names itself once with the mod attributed | | `absent` | deliberately not served; a nil read is the honest failure | -Today that is 288 backed, 32 warned and 161 absent across the fifteen modules. +Today that is 291 backed, 32 warned and 161 absent across the fifteen modules. `notes` keys are documentation topics rather than a member list -- dotted paths (`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is @@ -486,6 +486,9 @@ has its own entry points for (`start_battle "wild" species level`, `warp`, **by name, before the first row runs**, so a mod never gets a half-run queue. `marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no byte for it) and returns `nil, reason` rather than approximating one. +`availableFieldActions` and `useFieldAction` expose the same contextual +bicycle and fishing records in both games. Each engine keeps ownership of its +inventory, terrain, surfing, bike, and fishing rules. **Hooks and events that fire on Gold.** Every name below is the Gen 1 name carrying the Gen 1 payload keys, because Gold's call sites reuse them rather diff --git a/docs/modding.md b/docs/modding.md index 4aeb5616..0b42ac01 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -147,6 +147,20 @@ Companion UIs and alternate party screens can call operation is accepted only during idle overworld play; menus, movement, scripts, battles, and transitions leave the party untouched. +## Contextual field items + +`mod.world:availableFieldActions()` returns the field items that can start at +the player's current position. Red and Gold currently expose `bicycle` and +`fish`; fishing rows include the owned rods that are valid choices. The list +is empty while the world is busy, while riding states or terrain forbid an +action, or when the required item is not owned. + +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 bike, collision, or fishing logic. + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index eb6500cf..cf4eec9d 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -325,7 +325,7 @@ This is not a dev-mode feature; it installs on any Gold boot that has mods. | `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 | | `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 | | `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 | -| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 12 / 2 / 0 | +| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 | | `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 | | `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 | | `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 | @@ -774,7 +774,7 @@ profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload. - **Coverage is partial and will stay partial.** 15 Gen 1 modules are served out of a much larger engine, and within those 15 the coverage table records - 288 backed members against 32 warned and 161 absent. The absent ones are not + 291 backed members against 32 warned and 161 absent. The absent ones are not a backlog; most are absent because there is no honest Gen 2 answer, and each one carries its reason. The counts move as the adapter learns something: a member that turns out to answer nil is demoted from backed to warned or diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index aa319967..8f87f870 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -737,7 +737,8 @@ COVERAGE["src.pokemon.Boxes"] = { COVERAGE["src.world.WorldAPI"] = { kind = "alias", target = "src.world.gen2.WorldAPI", backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock " - .. "spawnNpc removeNpc npc queueScript invalidateMap", + .. "spawnNpc removeNpc npc queueScript invalidateMap " + .. "availableFieldActions useFieldAction", warned = "setFlag getFlag", absent = "", notes = { diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index cf70c3e2..0b845d84 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -763,6 +763,27 @@ function OverworldState:bikeAllowed(mapId) return false end +-- Field-item entry points keep presentation and state transitions in the +-- owning world instead of asking a supported facade to reproduce either one. +function OverworldState:useBicycle() + local name = Game.save.player.name + if Game.save.onBike then + if Game.save.forcedBike then return false end + Game.save.onBike = false + require("src.core.Music").playMap(Game.data, self.map.id, false) + Game.stack:push(TextBox.new(Game, + Strings("%s got off\nthe BICYCLE.", name))) + elseif self:bikeAllowed(self.map.id) and not self.player.surfing then + Game.save.onBike = true + require("src.core.Music").playMap(Game.data, self.map.id, true) + Game.stack:push(TextBox.new(Game, + Strings("%s got on\nthe BICYCLE!", name))) + else + return false + end + 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 @@ -1726,6 +1747,12 @@ function OverworldState:goFishing(rod) end)) end +function OverworldState:useFishingRod(rod) + if self.player.surfing or not self:facingIsShoreOrWater() then return false end + self:goFishing(rod) + return true +end + -- Fly to a visited town (called from the party menu). function OverworldState:flyTo(mapId) local spot = Game.data.field.flyWarps[mapId] diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 64283bea..a907e8f1 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -15,6 +15,7 @@ local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" +local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" } local function acceptsMenuInput(game, ow) local stack = game and game.stack @@ -86,6 +87,60 @@ function WorldAPI:reorderParty(fromSlot, toSlot) return true end +-- Contextual field-item shortcuts. Only actions that can start immediately +-- are listed; callers receive copied labels and never inspect world internals. +function WorldAPI:availableFieldActions() + local game, ow, out = self.game, self:overworld(), {} + if not (game and game.save and ow and ow.map and ow.player) + or not acceptsMenuInput(game, ow) then return out end + local save, inventory = game.save, game.save.inventory or {} + local items = game.data and game.data.items or {} + + if (inventory.BICYCLE or 0) > 0 and not ow.player.surfing + and not (save.onBike and save.forcedBike) + and (save.onBike or ow:bikeAllowed(ow.map.id)) then + out[#out + 1] = { id = "bicycle", + label = save.onBike and "BIKE OFF" or "BICYCLE" } + end + + if not ow.player.surfing and ow:facingIsShoreOrWater() then + local rods = {} + for _, id in ipairs(RODS) do + if (inventory[id] or 0) > 0 then + local def = items[id] + rods[#rods + 1] = { id = id, label = def and def.name or id } + end + end + if #rods > 0 then + out[#out + 1] = { id = "fish", label = "FISH", rods = rods } + end + end + return out +end + +function WorldAPI:useFieldAction(id, opts) + 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 found + for _, action in ipairs(self:availableFieldActions()) do + if action.id == id then found = action break end + end + if not found then return nil, "field action unavailable" end + + if id == "bicycle" then + if ow:useBicycle() then return true end + elseif id == "fish" then + local rod = opts and opts.rod + if not rod and #found.rods == 1 then rod = found.rods[1].id end + for _, choice in ipairs(found.rods) do + if choice.id == rod and ow:useFishingRod(rod) then return true end + end + return nil, "fishing rod unavailable" + end + return nil, "field action unavailable" +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/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index 09830721..c391678a 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -27,11 +27,15 @@ local Movement = require("src.script.gen2.Movement") local Runtime = require("src.mods.Runtime") local HiddenItems = require("src.world.gen2.HiddenItems") local MapOverview = require("src.world.MapOverview") +local Bike = require("src.world.gen2.Bike") +local FieldMoves = require("src.world.gen2.FieldMoves") +local Permissions = require("src.world.gen2.Permissions") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" +local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" } function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) @@ -52,6 +56,77 @@ function WorldAPI:current() facing = p and p.facing } end +local function itemLabel(game, id) + local def = game and game.data and game.data.items + and game.data.items[id] + return (def and def.name) or id +end + +-- The same field-item contract as Gen 1, resolved through Gold's own bike, +-- collision and fishing rules. +function WorldAPI:availableFieldActions() + local world, game, out = self:overworld(), self.game, {} + if not (world and game and game.save and world.map and world.player) + or not world:acceptsMenuInput() then return out end + local inventory = game.save.inventory or {} + + if (inventory.BICYCLE or 0) > 0 then + local bike = Bike.tryBike({ + state = world.playerState, + environment = world.map.def and world.map.def.environment, + collision = world:playerCollision(), + alwaysOnBike = world:alwaysOnBike(), + }) + if bike == "mount" or bike == "dismount" then + out[#out + 1] = { id = "bicycle", + label = bike == "dismount" and "BIKE OFF" or "BICYCLE" } + end + end + + local context = world:fieldContext() + if not FieldMoves.isSurfing(world.playerState) + and Permissions.isWater(context.facingColl) then + local rods = {} + for _, id in ipairs(RODS) do + if (inventory[id] or 0) > 0 then + rods[#rods + 1] = { id = id, label = itemLabel(game, id) } + end + end + if #rods > 0 then + out[#out + 1] = { id = "fish", label = "FISH", rods = rods } + end + end + return out +end + +function WorldAPI:useFieldAction(id, opts) + local world = self:overworld() + if not world then return nil, NO_OVERWORLD end + if not world:acceptsMenuInput() then return nil, "world is busy" end + local found + for _, action in ipairs(self:availableFieldActions()) do + if action.id == id then found = action break end + end + if not found then return nil, "field action unavailable" end + + if id == "bicycle" then + local outcome = world:useFieldItem("BICYCLE") + if outcome and outcome ~= "nowhere" then return true end + elseif id == "fish" then + local rod = opts and opts.rod + if not rod and #found.rods == 1 then rod = found.rods[1].id end + for _, choice in ipairs(found.rods) do + if choice.id == rod then + local outcome = world:useFieldItem(rod) + if outcome and outcome ~= "nowhere" then return true end + break + end + end + return nil, "fishing rod unavailable" + end + return nil, "field action unavailable" +end + -- The same read-only minimap contract as Gen 1, with Gold's object/event -- visibility rules supplying the semantic markers. function WorldAPI:mapOverview() diff --git a/tests/modkit/cases/world_field_items.lua b/tests/modkit/cases/world_field_items.lua new file mode 100644 index 00000000..a5a4a869 --- /dev/null +++ b/tests/modkit/cases/world_field_items.lua @@ -0,0 +1,88 @@ +-- Contextual bicycle and fishing actions share one public contract in both +-- generations while each engine keeps ownership of its own field-item path. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness").suite("mod world field items") + +local facingWater = false +local redWorld = { + isOverworld = true, + map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } }, + player = { moving = false, inputLocked = false, surfing = false }, + runner = { isRunning = function() return false end }, + scriptMoves = {}, + bikeAllowed = function() return true end, + facingIsShoreOrWater = function() return facingWater end, + useBicycle = function(self) self.bikeUsed = true return true end, + useFishingRod = function(self, rod) self.rodUsed = rod return true end, +} +local redGame = { + data = { items = { OLD_ROD = { name = "OLD ROD" } } }, + save = { player = { name = "RED" }, party = {}, + inventory = { BICYCLE = 1, OLD_ROD = 1 } }, + stack = { states = { redWorld } }, + overworld = redWorld, +} +function redGame.stack:top() return self.states[#self.states] end + +local RedAPI = require("src.world.WorldAPI") +local red = RedAPI.new(redGame, "fixture") +local RedWorld = require("src.world.OverworldController") +T.check(type(RedWorld.useBicycle) == "function" + and type(RedWorld.useFishingRod) == "function", + "Red keeps field-item execution in its world") +local actions = red:availableFieldActions() +T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle") +T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle") +T.check(redWorld.bikeUsed, "Red delegates to its world-owned bicycle path") + +facingWater = true +actions = red:availableFieldActions() +T.eq(actions[2].rods[1].id, "OLD_ROD", "Red lists owned rods at water") +T.check(red:useFieldAction("fish", { rod = "OLD_ROD" }), + "Red accepts a listed rod") +T.eq(redWorld.rodUsed, "OLD_ROD", "Red delegates to its fishing path") +local used = redWorld.rodUsed +local ok, err = red:useFieldAction("fish", { rod = "SUPER_ROD" }) +T.check(not ok and err == "fishing rod unavailable", + "Red rejects an unowned rod") +T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing") + +redWorld.player.moving = true +T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving") +ok, err = red:useFieldAction("bicycle") +T.check(not ok and err == "world is busy", + "Red refuses a stale action while busy") + +local goldWorld = { + map = { id = "ROUTE_29", def = { environment = "ROUTE" } }, + player = {}, playerState = "normal", + acceptsMenuInput = function() return true end, + playerCollision = function() return 0x00 end, + alwaysOnBike = function() return false end, + fieldContext = function() return { facingColl = 0x20 } end, + useFieldItem = function(self, item) self.itemUsed = item return "used" end, +} +local goldGame = { + data = { items = { OLD_ROD = { name = "OLD ROD" } } }, + save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } }, + world = goldWorld, +} + +local GoldAPI = require("src.world.gen2.WorldAPI") +local gold = GoldAPI.new(goldGame, "fixture") +actions = gold:availableFieldActions() +T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id") +T.eq(actions[2].rods[1].id, "OLD_ROD", "Gold shares the rod shape") +T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }), + "Gold accepts the same fishing request") +T.eq(goldWorld.itemUsed, "OLD_ROD", + "Gold delegates to its own field-item path") +used = goldWorld.itemUsed +ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" }) +T.check(not ok and err == "fishing rod unavailable", + "Gold rejects an unowned rod") +T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing") + +T.finish()