fix(game3): add fallback guards and weather suppression for sweet scent

- Fix fatal script cache assertion crash when Sweet Scent fails outside grass
- Resolve failure text polymorphically in Field.finishSweetScent, supporting TextIR tables, ROM symbol keys, plain strings, and static fallback text
- Anchor Field.locked release inside Message.show's done callback, with a defensive pcall catch to prevent softlocks or walk-away desyncs
- Route active weather suppression (Rain, Thunderstorm, Downpour, Snow, Fog) through the failure dialogue path per canonical GBA rules
- Add PP validation in FieldMoves.sweetScentFromMenu and protect FieldMoves.TEXT against unprimed script cache lookups with TEXT_FALLBACKS
- Add game3_sweet_scent_fallback_test.lua covering zero PP, non-grass terrain, weather suppression, facility rejection, and lock lifecycle
This commit is contained in:
1jamie
2026-10-13 17:30:59 -05:00
parent 1f6ad5a104
commit 6ef00a7bb5
4 changed files with 275 additions and 11 deletions
+56 -8
View File
@@ -1497,7 +1497,6 @@ function Field.executeFieldMove(payload)
showMon(function()
if payload.se then Audio.playSe(payload.se) end
FieldEffects.startSweetScent(function()
Field.locked = false
Field.finishSweetScent(payload)
end)
end)
@@ -1514,18 +1513,68 @@ function Field.finishSweetScent(payload)
local Map = package.loaded["src.core.game3.map"]
mapId = Map and Map.current
end
local function showFailMessage()
local RomText = lazyReq("src.core.game3.rom_text")
local Message = lazyReq("src.ui.game3.message")
local failText = payload and payload.failText
local failKey = payload and payload.failKey
local textToShow
if type(failText) == "table" then
textToShow = failText
elseif failKey and RomText and RomText.has and RomText.has(failKey) then
textToShow = RomText.box(failKey)
elseif type(failText) == "string" and RomText and RomText.has and RomText.has(failText) then
textToShow = RomText.box(failText)
elseif type(failText) == "string" and #failText > 0 and not failText:match("^Text_") and not failText:match("^gText_") then
textToShow = failText
else
local FieldMoves = lazyReq("src.core.game3.field_moves")
local k = FieldMoves and FieldMoves.textKey and FieldMoves.textKey("NO_SWEET_SCENT_MONS")
if k and RomText and RomText.has and RomText.has(k) then
textToShow = RomText.box(k)
else
textToShow = "Looks like there's nothing here…"
end
end
local ok, err = pcall(function()
Message.show(textToShow, {
session = session,
done = function()
Field.locked = false
Message.close()
end,
})
end)
if not ok then
Field.locked = false
print("[game3/field] sweet scent fail message error: " .. tostring(err))
end
end
local rules = Encounters.rules()
if rules.sweetScentFacility then
local handled = rules.sweetScentFacility(mapId)
if handled == true then return end
if handled == false then
if not payload.failText then return end
local Message = lazyReq("src.ui.game3.message")
Message.show(RomText.box(payload.failText), { session = session, done = function() Message.close() end })
showFailMessage()
return
end
end
local terrain = Encounters.terrainAt(Player.cellX, Player.cellY)
-- Weather suppression check: heavy precipitation (rain, downpour, snow) & fog suppress sweet scent
local Weather = lazyReq("src.core.game3.weather")
local curWeather = Weather and Weather.get and Weather.get()
local weatherSuppressed = curWeather == Weather.RAIN
or curWeather == Weather.SNOW
or curWeather == Weather.RAIN_THUNDERSTORM
or curWeather == Weather.DOWNPOUR
or curWeather == Weather.FOG_HORIZONTAL
or curWeather == Weather.FOG_DIAGONAL
local px, py = (Player and Player.cellX) or 0, (Player and Player.cellY) or 0
local terrain = not weatherSuppressed and Encounters.terrainAt(px, py) or nil
local enc = (terrain == "land" or terrain == "water") and Encounters.rollSweetScent(mapId, terrain) or nil
if enc then
local BattleBridge = lazyReq("src.core.game3.battle_bridge")
@@ -1533,9 +1582,8 @@ function Field.finishSweetScent(payload)
if ok then return end
print("[game3/field] sweet scent startWild failed: " .. tostring(err))
end
if not payload.failText then return end
local Message = lazyReq("src.ui.game3.message")
Message.show(RomText.box(payload.failText), { session = session, done = function() Message.close() end })
showFailMessage()
end
-- pokefirered/src/wild_encounter.c:446
+47 -3
View File
@@ -276,11 +276,28 @@ local function textKey(key)
end
FieldMoves.textKey = textKey
local TEXT_FALLBACKS = {
NO_SWEET_SCENT_MONS = "Looks like there's nothing here…",
CANT_USE_HERE = "Can't use that here.",
NOT_ENOUGH_HP = "Not enough HP.",
TREE_CAN_BE_CUT = "This tree looks like it can be CUT down!",
MON_MAY_SMASH_ROCK = "This rock looks like it can be smashed with a POKéMON move!",
MON_MAY_PUSH_BOULDER = "It's a big boulder, but a POKéMON may be able to push it aside.",
CUT_NOTHING = "There's nothing to CUT here.",
FLASH_IN_USE = "FLASH is already in use.",
ALREADY_SURFING = "You're already surfing.",
CANT_SURF_HERE = "You can't surf here.",
}
FieldMoves.TEXT = setmetatable({}, {
__index = function(_, key)
local k = textKey(key)
if k then return lazyReq("src.core.game3.rom_text").ascii(k) end
return nil
local okRt, RomText = pcall(lazyReq, "src.core.game3.rom_text")
if okRt and RomText and k and RomText.has and RomText.has(k) then
local okAscii, res = pcall(RomText.ascii, k)
if okAscii and res then return res end
end
return TEXT_FALLBACKS[key] or nil
end,
})
@@ -672,9 +689,35 @@ function FieldMoves.teleportFromMenu(ctx)
}
end
local function hasMovePp(mon, moveIdentifier)
if not mon then return false end
local targetId = FieldMoves.normalizeMoveId(moveIdentifier)
local moves = mon.moves or {}
for idx, m in ipairs(moves) do
local mId = FieldMoves.normalizeMoveId(m)
if mId == targetId then
if type(m) == "table" and m.pp ~= nil then
return (tonumber(m.pp) or 0) > 0
end
if type(mon.pp) == "table" and mon.pp[idx] ~= nil then
return (tonumber(mon.pp[idx]) or 0) > 0
end
return true
end
end
return false
end
FieldMoves.hasMovePp = hasMovePp
--- Sweet Scent from Party Menu
function FieldMoves.sweetScentFromMenu(ctx)
local mon = ctx.mon or FieldMoves.partyMoveUser(ctx.party, "SWEET_SCENT")
if not mon then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
if not hasMovePp(mon, "SWEET_SCENT") then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return {
ok = true,
@@ -682,7 +725,8 @@ function FieldMoves.sweetScentFromMenu(ctx)
mon = mon,
se = FieldMoves.SE.SWEET_SCENT,
hasEncounter = ctx.hasWildEncounters == true,
failText = FieldMoves.TEXT.NO_SWEET_SCENT_MONS,
failText = FieldMoves.TEXT.NO_SWEET_SCENT_MONS or "Looks like there's nothing here…",
failKey = FieldMoves.textKey("NO_SWEET_SCENT_MONS"),
}
end
@@ -0,0 +1,171 @@
-- luajit tests/engine/game3_sweet_scent_fallback_test.lua
-- Unit tests for Gen 3 Sweet Scent outside grass, weather suppression,
-- lock management, PP validation, and script cache assertion safeguards.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Field = require("src.core.game3.field")
local FieldMoves = require("src.core.game3.field_moves")
local Encounters = require("src.core.game3.encounters")
local Message = require("src.ui.game3.message")
local Weather = require("src.core.game3.weather")
-- =========================================================================
-- 1. FieldMoves.sweetScentFromMenu: PP validation & safe cache lookups
-- =========================================================================
local monWithPp = {
species = 1,
moves = { "SWEET_SCENT" },
pp = { 20 },
}
local monNoPp = {
species = 1,
moves = { "SWEET_SCENT" },
pp = { 0 },
}
local monTableMovesNoPp = {
species = 1,
moves = { { id = "SWEET_SCENT", pp = 0 } },
}
local okRes = FieldMoves.sweetScentFromMenu({ mon = monWithPp })
check(okRes.ok == true, "mon with PP: sweetScentFromMenu succeeds")
eq(okRes.action, "sweet_scent", "action is sweet_scent")
check(type(okRes.failText) == "string" and #okRes.failText > 0, "failText is a non-empty string fallback")
local noPpRes = FieldMoves.sweetScentFromMenu({ mon = monNoPp })
check(noPpRes.ok == false, "mon with 0 PP (array): sweetScentFromMenu returns ok = false")
local noPpTableRes = FieldMoves.sweetScentFromMenu({ mon = monTableMovesNoPp })
check(noPpTableRes.ok == false, "mon with 0 PP (move table): sweetScentFromMenu returns ok = false")
local noMonRes = FieldMoves.sweetScentFromMenu({ party = {} })
check(noMonRes.ok == false, "no mon knowing sweet scent returns ok = false")
-- =========================================================================
-- 2. Field.finishSweetScent outside grass (terrain == nil)
-- =========================================================================
Player = { cellX = 5, cellY = 5 }
Field._session = { map = "MAP_PALLET_TOWN" }
Field.locked = true
Weather.current = Weather.NONE
-- Mock encounters so terrainAt returns nil (not grass/water)
local origTerrainAt = Encounters.terrainAt
Encounters.terrainAt = function() return nil end
local function dismissMessage()
if Message.open then
Message.advance() -- skip typewriter reveal
Message.advance() -- dismiss page
end
end
Field.finishSweetScent({
action = "sweet_scent",
failText = "Looks like there's nothing here…",
})
check(Message.open == true, "finishSweetScent outside grass opens dialogue")
check(Field.locked == true, "Field.locked remains true while failure dialogue is open")
-- Simulate player dismissing dialogue with A/B button
dismissMessage()
check(Message.open == false, "dialogue closes on advance")
check(Field.locked == false, "Field.locked is released on dialogue closure")
-- =========================================================================
-- 3. String failText with no symbols: ensure RomText.box is NOT called with English string
-- =========================================================================
Field.locked = true
Field.finishSweetScent({
action = "sweet_scent",
failText = "Custom failure string that is definitely not in ROM cache",
})
check(Message.open == true, "un-cached custom string does not crash and opens Message")
check(Field.locked == true, "Field.locked remains held")
dismissMessage()
check(Field.locked == false, "Field.locked released on dismiss")
-- =========================================================================
-- 4. Weather suppression: Sweet Scent fails in rain / thunderstorm / downpour / fog
-- =========================================================================
-- Restore terrain to pretend player IS in tall grass
Encounters.terrainAt = function() return "land" end
local origRoll = Encounters.rollSweetScent
local rollCalled = false
Encounters.rollSweetScent = function()
rollCalled = true
return { species = 16, level = 3 }
end
Weather.current = Weather.RAIN
Field.locked = true
rollCalled = false
Field.finishSweetScent({
action = "sweet_scent",
failText = "Looks like there's nothing here…",
})
check(rollCalled == false, "Weather.RAIN suppresses rollSweetScent")
check(Message.open == true, "Rain triggers failure dialogue")
check(Field.locked == true, "Field.locked is true while dialogue is open")
dismissMessage()
check(Field.locked == false, "Field.locked released on dismiss")
-- Fog suppression
Weather.current = Weather.FOG_HORIZONTAL
Field.locked = true
rollCalled = false
Field.finishSweetScent({
action = "sweet_scent",
failText = "Looks like there's nothing here…",
})
check(rollCalled == false, "Fog suppresses rollSweetScent")
check(Message.open == true, "Fog triggers failure dialogue")
dismissMessage()
check(Field.locked == false, "Field.locked released on dismiss")
-- Weather clear: battle roll is called
Weather.current = Weather.NONE
rollCalled = false
Field.finishSweetScent({
action = "sweet_scent",
failText = "Looks like there's nothing here…",
})
check(rollCalled == true, "Clear weather allows rollSweetScent on land")
-- =========================================================================
-- 5. Facility rejection (handled == false)
-- =========================================================================
Encounters.rules = function()
return {
sweetScentFacility = function() return false end,
}
end
Field.locked = true
Field.finishSweetScent({
action = "sweet_scent",
failText = "Looks like there's nothing here…",
})
check(Message.open == true, "facility rejection triggers failure message")
check(Field.locked == true, "Field.locked held during facility rejection text")
dismissMessage()
check(Field.locked == false, "Field.locked released on facility rejection dismiss")
-- Cleanup
Encounters.terrainAt = origTerrainAt
Encounters.rollSweetScent = origRoll
Weather.current = Weather.NONE
Field.locked = false
T.finish("game3_sweet_scent_fallback_test")
+1
View File
@@ -935,3 +935,4 @@ tests/engine/love_run_shell_matrix.lua
tests/engine/pc_item_box_order_bug2840.lua
tests/engine/town_map_og_obp0_bug2841.lua
tests/engine/stick_rebind_isolation_test.lua
tests/engine/game3_sweet_scent_fallback_test.lua