diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 4b41c10f..2df79993 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -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 diff --git a/src/core/game3/field_moves.lua b/src/core/game3/field_moves.lua index 400cbc01..b82fce8b 100644 --- a/src/core/game3/field_moves.lua +++ b/src/core/game3/field_moves.lua @@ -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 diff --git a/tests/engine/game3_sweet_scent_fallback_test.lua b/tests/engine/game3_sweet_scent_fallback_test.lua new file mode 100644 index 00000000..335f86c8 --- /dev/null +++ b/tests/engine/game3_sweet_scent_fallback_test.lua @@ -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") diff --git a/tests/quick.list b/tests/quick.list index 84a43e1c..123ae372 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -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