mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-14 23:31:27 +02:00
Merge pull request #1302 from AverageConsumer/feat/mod-field-items
feat(mods): expose contextual field items
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user