feat(mods): extend contextual field actions

This commit is contained in:
AverageConsumer
2026-08-15 01:59:53 +02:00
parent fb738fa1ce
commit 2b6473ae03
7 changed files with 258 additions and 70 deletions
+5 -3
View File
@@ -486,9 +486,11 @@ 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. **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 `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. byte for it) and returns `nil, reason` rather than approximating one.
`availableFieldActions` and `useFieldAction` expose the same contextual `availableFieldActions` and `useFieldAction` expose the same contextual field
bicycle and fishing records in both games. Each engine keeps ownership of its item and move records in both games. Gold extends the shared ids with its own
inventory, terrain, surfing, bike, and fishing rules. `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
actions. Each engine keeps ownership of its inventory, badges, terrain,
surfing, bike, fishing, and field-move rules.
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name **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 carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
+11 -7
View File
@@ -147,19 +147,23 @@ Companion UIs and alternate party screens can call
operation is accepted only during idle overworld play; menus, movement, operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched. scripts, battles, and transitions leave the party untouched.
## Contextual field items ## Contextual field actions
`mod.world:availableFieldActions()` returns the field items that can start at `mod.world:availableFieldActions()` returns the field items and moves that can
the player's current position. Red and Gold currently expose `bicycle` and start at the player's current position. Both games expose `bicycle`, `fish`,
`fish`; fishing rows include the owned rods that are valid choices. The list `cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
is empty while the world is busy, while riding states or terrain forbid an exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
action, or when the required item is not owned. 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.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through 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" }` 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 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 busy requests return `nil` plus a reason without changing game state. Mods do
not need generation-specific bike, collision, or fishing logic. 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.
## Rendering pipelines ## Rendering pipelines
+3 -50
View File
@@ -454,30 +454,7 @@ function PartyMenu:update(dt)
refuseBadge(self) refuseBadge(self)
return return
end end
local TextBox = require("src.render.TextBox") ow:useFlashFieldMove(function() self:close() end)
local Transition = require("src.render.Transition")
-- .flash prints on any map, but the light it records is this map's:
-- home/overworld.asm re-arms wMapPalOffset on the next dark map, so
-- a FLASH used in daylight must not carry into Rock Tunnel
local wasDark = ow and ow.dark
if wasDark then self.game.save.flashLit = true end
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
self:close()
-- setDark, not a bare field write: ADVANCED carries the darkness
-- in a baked atlas, so lighting the cave drops every resident map
-- and rebakes this one (#383). It runs HERE, before the blink,
-- because start_sub_menus.asm .flash clears wMapPalOffset before
-- PrintText and blinks last of all: the cave is already lit by the
-- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
-- blink's completion instead left that rebuild's whole cost --
-- seconds of per-pixel atlas baking on a phone -- on screen as a
-- solid white frame with nothing under it, which reads as a
-- lockup (#610).
if wasDark then ow:setDark(false) end
self.game.stack:push(Transition.whiteFlash(self.game))
end))
return return
elseif action == "surf" then elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove), -- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
@@ -505,12 +482,7 @@ function PartyMenu:update(dt)
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land (or across a connection -- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge) -- strip when the shore is the next map's edge)
self.game.stack:pop() ow:stopSurfing(function() self.game.stack:pop() end)
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:stepForwardOrCrossEdge(ow.player.facing)
end))
return return
end end
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
@@ -571,26 +543,7 @@ function PartyMenu:update(dt)
refuseBadge(self) refuseBadge(self)
return return
end end
local TextBox = require("src.render.TextBox") ow:useStrengthFieldMove(mon, function() self:close() end)
local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species]
local name = mon.nickname or def.name
ow.strengthActive = true
local t1 = (self.game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
local t2 = (self.game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
-- like surf (#320, #385): both texts print with the party menu
-- still on screen, and the blink IS the menu closing afterwards,
-- not a flashbang on the empty map
self.game.stack:push(TextBox.new(self.game, t1, function()
self.game.stack:push(TextBox.new(self.game, t2, function()
self:close()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(self.game.data, mon.species)
end } }))
return return
elseif action == "softboiled" then elseif action == "softboiled" then
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
+52
View File
@@ -784,6 +784,49 @@ function OverworldState:useBicycle()
return true return true
end end
-- Field-move entry points keep presentation and state transitions in the
-- overworld. The party menu and supported mod facade both call these, so a
-- shortcut cannot drift from the game's own move flow.
function OverworldState:useFlashFieldMove(onClose)
-- A FLASH used in daylight must not carry into the next dark map. Lighting
-- also happens before the blink: setDark may rebake an ADVANCED atlas, and
-- putting that work in WhiteFlash:onDone caused the frozen white frame in
-- #610.
local wasDark = self.dark
if wasDark then Game.save.flashLit = true end
Game.stack:push(TextBox.new(Game,
Game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
if onClose then onClose() end
if wasDark then self:setDark(false) end
Game.stack:push(Transition.whiteFlash(Game))
end))
return true
end
function OverworldState:useStrengthFieldMove(mon, onClose)
mon = mon or self:partyKnows("STRENGTH")
if not mon then return false end
local def = Game.data.pokemon[mon.species]
local name = mon.nickname or def.name
self.strengthActive = true
local first = (Game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH."))
:gsub("{RAM:wNameBuffer}", name)
local second = (Game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders."))
:gsub("{RAM:wNameBuffer}", name)
Game.stack:push(TextBox.new(Game, first, function()
Game.stack:push(TextBox.new(Game, second, function()
if onClose then onClose() end
Game.stack:push(Transition.whiteFlash(Game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(Game.data, mon.species)
end } }))
return true
end
-- The battle transition's dungeon wipe uses the explicit map lists in -- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions -- inclusive map-id ranges -- faithful to the original's omissions
@@ -2500,6 +2543,15 @@ function OverworldState:trySurf(fx, fy, onClose)
end)) end))
end end
function OverworldState:stopSurfing(onClose)
if onClose then onClose() end
self.player.surfing = false
require("src.core.Music").setSurfing(Game.data, false)
Game.stack:push(Transition.whiteFlash(Game, nil, function()
self:stepForwardOrCrossEdge(self.player.facing)
end))
end
function OverworldState:tryCut(fx, fy) function OverworldState:tryCut(fx, fy)
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before -- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile -- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
+48
View File
@@ -6,6 +6,8 @@
-- stays unsupported; anything a mod legitimately needs belongs here. -- stays unsupported; anything a mod legitimately needs belongs here.
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader") local MapLoader = require("src.world.MapLoader")
local MapOverview = require("src.world.MapOverview") local MapOverview = require("src.world.MapOverview")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
@@ -15,6 +17,8 @@ local WorldAPI = {}
WorldAPI.__index = WorldAPI WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld" local NO_OVERWORLD = "no overworld"
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
FACILITY = true, INTERIOR = true }
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" } local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local function acceptsMenuInput(game, ow) local function acceptsMenuInput(game, ow)
@@ -115,6 +119,30 @@ function WorldAPI:availableFieldActions()
out[#out + 1] = { id = "fish", label = "FISH", rods = rods } out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end end
end end
if ow:useCutFieldMove() == "ok" then
out[#out + 1] = { id = "cut", label = "CUT" }
end
local surf = ow:useSurfFieldMove()
if surf == "ok" or surf == "dismount" then
out[#out + 1] = { id = "surf",
label = surf == "dismount" and "LEAVE WATER" or "SURF" }
end
if not ow.strengthActive and ow:partyKnows("STRENGTH") then
out[#out + 1] = { id = "strength", label = "STRENGTH" }
end
if ow.dark and ow:partyKnows("FLASH") then
out[#out + 1] = { id = "flash", label = "FLASH" }
end
if DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM"
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
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end
return out return out
end end
@@ -130,6 +158,19 @@ function WorldAPI:useFieldAction(id, opts)
if id == "bicycle" then if id == "bicycle" then
if ow:useBicycle() then return true end if ow:useBicycle() then return true end
elseif id == "cut" then
local x, y = ow.player:facingCell()
if ow:tryCut(x, y) then return true end
elseif id == "surf" then
local mode = ow:useSurfFieldMove()
if mode == "dismount" then
ow:stopSurfing()
return true
elseif mode == "ok" then
local x, y = ow.player:facingCell()
ow:trySurf(x, y)
return true
end
elseif id == "fish" then elseif id == "fish" then
local rod = opts and opts.rod local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end if not rod and #found.rods == 1 then rod = found.rods[1].id end
@@ -137,6 +178,13 @@ function WorldAPI:useFieldAction(id, opts)
if choice.id == rod and ow:useFishingRod(rod) then return true end if choice.id == rod and ow:useFishingRod(rod) then return true end
end end
return nil, "fishing rod unavailable" return nil, "fishing rod unavailable"
elseif id == "strength" then
if ow:useStrengthFieldMove() then return true end
elseif id == "flash" then
if ow:useFlashFieldMove() then return true end
elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut()
return true
end end
return nil, "field action unavailable" return nil, "field action unavailable"
end end
+44
View File
@@ -36,6 +36,18 @@ WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld" local NO_OVERWORLD = "no overworld"
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" } local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local FIELD_ACTIONS = {
{ id = "cut", move = "CUT" },
{ id = "surf", move = "SURF" },
{ id = "strength", move = "STRENGTH" },
{ id = "flash", move = "FLASH" },
{ id = "headbutt", move = "HEADBUTT" },
{ id = "whirlpool", move = "WHIRLPOOL" },
{ id = "waterfall", move = "WATERFALL" },
{ id = "sweet_scent", move = "SWEET_SCENT" },
{ id = "dig", move = "DIG" },
{ id = "teleport", move = "TELEPORT" },
}
function WorldAPI.new(game, modId) function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI) return setmetatable({ game = game, modId = modId }, WorldAPI)
@@ -96,6 +108,26 @@ function WorldAPI:availableFieldActions()
out[#out + 1] = { id = "fish", label = "FISH", rods = rods } out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end end
end end
for _, row in ipairs(FIELD_ACTIONS) do
if not (row.move == "STRENGTH" and world.strengthActive) then
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
if mon then
context.mon = mon
local result = FieldMoves.fromMenu(row.move, context)
if result.ok then
out[#out + 1] = { id = row.id,
label = row.move:gsub("_", " ") }
end
end
end
end
if (inventory.SQUIRTBOTTLE or 0) > 0
and world:squirtbottleTreeScript() then
out[#out + 1] = { id = "squirtbottle",
label = itemLabel(game, "SQUIRTBOTTLE") }
end
return out return out
end end
@@ -123,6 +155,18 @@ function WorldAPI:useFieldAction(id, opts)
end end
end end
return nil, "fishing rod unavailable" return nil, "fishing rod unavailable"
elseif id == "squirtbottle" then
local outcome = world:useFieldItem("SQUIRTBOTTLE")
if outcome and outcome ~= "nowhere" then return true end
end
for _, row in ipairs(FIELD_ACTIONS) do
if row.id == id then
local context = world:fieldContext()
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
local result = mon and world:useFieldMove(row.move, mon)
if result and result.ok then return true end
return nil, "field action unavailable"
end
end end
return nil, "field action unavailable" return nil, "field action unavailable"
end end
+95 -10
View File
@@ -1,11 +1,13 @@
-- Contextual bicycle and fishing actions share one public contract in both -- Contextual field actions share one public contract in both generations
-- generations while each engine keeps ownership of its own field-item path. -- while each engine keeps ownership of its own field-item and move paths.
package.path = "./?.lua;./?/init.lua;" .. package.path package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness").suite("mod world field items") local T = require("tests.harness").suite("mod world field actions")
local facingWater = false local facingWater = false
local redCut, redSurf = false, "no_water"
local redMoves = {}
local redWorld = { local redWorld = {
isOverworld = true, isOverworld = true,
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } }, map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
@@ -14,11 +16,15 @@ local redWorld = {
scriptMoves = {}, scriptMoves = {},
bikeAllowed = function() return true end, bikeAllowed = function() return true end,
facingIsShoreOrWater = function() return facingWater end, facingIsShoreOrWater = function() return facingWater end,
useCutFieldMove = function() return redCut and "ok" or "nothing" end,
useSurfFieldMove = function() return redSurf end,
partyKnows = function(_, move) return redMoves[move] end,
useBicycle = function(self) self.bikeUsed = true return true end, useBicycle = function(self) self.bikeUsed = true return true end,
useFishingRod = function(self, rod) self.rodUsed = rod return true end, useFishingRod = function(self, rod) self.rodUsed = rod return true end,
} }
local redGame = { local redGame = {
data = { items = { OLD_ROD = { name = "OLD ROD" } } }, data = { field = { outsideTilesets = { "OVERWORLD" } },
items = { OLD_ROD = { name = "OLD ROD" } } },
save = { player = { name = "RED" }, party = {}, save = { player = { name = "RED" }, party = {},
inventory = { BICYCLE = 1, OLD_ROD = 1 } }, inventory = { BICYCLE = 1, OLD_ROD = 1 } },
stack = { states = { redWorld } }, stack = { states = { redWorld } },
@@ -30,8 +36,11 @@ local RedAPI = require("src.world.WorldAPI")
local red = RedAPI.new(redGame, "fixture") local red = RedAPI.new(redGame, "fixture")
local RedWorld = require("src.world.OverworldController") local RedWorld = require("src.world.OverworldController")
T.check(type(RedWorld.useBicycle) == "function" T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function", and type(RedWorld.useFishingRod) == "function"
"Red keeps field-item execution in its world") and type(RedWorld.useFlashFieldMove) == "function"
and type(RedWorld.useStrengthFieldMove) == "function"
and type(RedWorld.stopSurfing) == "function",
"Red keeps field-action execution in its world")
local actions = red:availableFieldActions() local actions = red:availableFieldActions()
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle") T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle") T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle")
@@ -55,26 +64,98 @@ ok, err = red:useFieldAction("bicycle")
T.check(not ok and err == "world is busy", T.check(not ok and err == "world is busy",
"Red refuses a stale action while busy") "Red refuses a stale action while busy")
redWorld.player.moving = false
facingWater, redCut, redSurf = false, true, "ok"
redWorld.dark = true
for _, move in ipairs({ "STRENGTH", "FLASH", "TELEPORT" }) do
redMoves[move] = { species = "MEW", moves = { { id = move } } }
end
redWorld.player.facingCell = function() return 4, 5 end
redWorld.tryCut = function(self) self.cutUsed = true return true end
redWorld.trySurf = function(self) self.surfUsed = true end
redWorld.useStrengthFieldMove = function(self) self.strengthUsed = true return true end
redWorld.useFlashFieldMove = function(self) self.flashUsed = true return true end
redWorld.beginTeleportOut = function(self) self.teleportUsed = true end
actions = red:availableFieldActions()
local byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
T.check(byId.cut and byId.surf and byId.strength and byId.flash
and byId.teleport, "Red lists field moves that can start now")
for _, id in ipairs({ "cut", "surf", "strength", "flash", "teleport" }) do
T.check(red:useFieldAction(id), "Red accepts listed " .. id)
end
T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
and redWorld.flashUsed and redWorld.teleportUsed,
"Red delegates every move to its overworld path")
redSurf = "dismount"
redWorld.player.surfing = true
redWorld.stopSurfing = function(self) self.dismounted = true end
T.check(red:useFieldAction("surf") and redWorld.dismounted,
"Red delegates the contextual SURF dismount")
redWorld.player.surfing, redSurf = false, "ok"
redCut = false
ok, err = red:useFieldAction("cut")
T.check(not ok and err == "field action unavailable",
"Red revalidates a stale field move")
redWorld.map.id = "ROCK_TUNNEL_1F"
redWorld.map.def.tileset = "CAVERN"
redMoves.DIG = { species = "MEW", moves = { { id = "DIG" } } }
redWorld.beginTeleportOut = function(self) self.digUsed = true end
byId = {}
for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
T.check(byId.dig and not byId.teleport,
"Red distinguishes dungeon DIG from outdoor TELEPORT")
T.check(red:useFieldAction("dig") and redWorld.digUsed,
"Red delegates DIG to its escape path")
local goldWorld = { local goldWorld = {
map = { id = "ROUTE_29", def = { environment = "ROUTE" } }, map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
player = {}, playerState = "normal", player = {}, playerState = "normal",
acceptsMenuInput = function() return true end, acceptsMenuInput = function() return true end,
playerCollision = function() return 0x00 end, playerCollision = function() return 0x00 end,
alwaysOnBike = function() return false end, alwaysOnBike = function() return false end,
fieldContext = function() return { facingColl = 0x20 } end,
useFieldItem = function(self, item) self.itemUsed = item return "used" end, useFieldItem = function(self, item) self.itemUsed = item return "used" end,
squirtbottleTreeScript = function() return { { op = "end" } } end,
useFieldMove = function(self, move)
self.moveUsed = move
return { ok = true }
end,
} }
local goldGame = { local goldGame = {
data = { items = { OLD_ROD = { name = "OLD ROD" } } }, data = { items = { OLD_ROD = { name = "OLD ROD" },
save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } }, SQUIRTBOTTLE = { name = "SQUIRTBOTTLE" } } },
save = { inventory = { BICYCLE = 1, OLD_ROD = 1, SQUIRTBOTTLE = 1 },
player = { badges = { FOG = true } },
party = { { moves = { { id = "SURF" }, { id = "SWEET_SCENT" },
{ id = "TELEPORT" } } } } },
world = goldWorld, world = goldWorld,
} }
goldWorld.fieldContext = function(_, mon) return {
save = goldGame.save, party = goldGame.save.party, mon = mon,
facing = "right", facingColl = 0x29, playerColl = 0,
environment = "ROUTE", playerState = "normal", alwaysOnBike = false,
dark = false, canEscapeRope = false,
} end
local GoldAPI = require("src.world.gen2.WorldAPI") local GoldAPI = require("src.world.gen2.WorldAPI")
local gold = GoldAPI.new(goldGame, "fixture") local gold = GoldAPI.new(goldGame, "fixture")
actions = gold:availableFieldActions() actions = gold:availableFieldActions()
byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id") 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.eq(actions[2].id, "fish", "Gold preserves the original action order")
T.check(byId.surf and byId.sweet_scent and byId.teleport,
"Gold lists field moves through its generic dispatcher")
T.eq(byId.fish.rods[1].id, "OLD_ROD", "Gold shares the rod shape")
T.check(byId.squirtbottle,
"Gold lists the SquirtBottle only at its matching tree")
T.check(gold:useFieldAction("sweet_scent"),
"Gold accepts a listed field move")
T.eq(goldWorld.moveUsed, "SWEET_SCENT",
"Gold delegates moves to its own field-move path")
T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }), T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }),
"Gold accepts the same fishing request") "Gold accepts the same fishing request")
T.eq(goldWorld.itemUsed, "OLD_ROD", T.eq(goldWorld.itemUsed, "OLD_ROD",
@@ -84,5 +165,9 @@ ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" })
T.check(not ok and err == "fishing rod unavailable", T.check(not ok and err == "fishing rod unavailable",
"Gold rejects an unowned rod") "Gold rejects an unowned rod")
T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing") T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing")
T.check(gold:useFieldAction("squirtbottle"),
"Gold accepts the contextual SquirtBottle")
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
"Gold delegates the SquirtBottle to its field-item path")
T.finish() T.finish()