mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-27 07:28:37 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user