From b4809beedbedb3203cf3cfb3af70f5c4c9a33489 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sun, 4 Oct 2026 11:00:11 -0500 Subject: [PATCH] fix(game3): battle UI, Pyramid battles, Daycare, Faraway Mew, status timing, Solar Beam, and Seagallop cutscene - Fixes #2667: Align FireRed battle UI text and cursor positions with vanilla layout. - Fixes #2663: Fix Battle Pyramid double trainer sight crash and party resolution. - Fixes #2651: Fix crash when picking up Pokemon at the daycare (native step-counting and exp/level sync). - Fixes #2668: Fix Faraway Island Mew encounter and grass rustle collision/despawn state. - Fixes #2647: Fix status display and Leech Seed absorption/healing sequence timing. - Fixes #2673: Set animTurn = 1 for instant Solar Beam execution in sunlight. - Fix Cinnabar Island Seagallop boat cutscene off-map culling and sprite fallback. --- src/core/game3/battle/adapter.lua | 11 ++ src/core/game3/battle/anim.lua | 2 + src/core/game3/battle/anim_seq.lua | 20 +++ src/core/game3/battle/effects/healing.lua | 5 +- src/core/game3/battle/engine.lua | 2 + src/core/game3/battle/healthbox.lua | 31 ++-- src/core/game3/battle/ui.lua | 8 +- src/core/game3/battle_bridge.lua | 6 +- src/core/game3/daycare.lua | 46 ++++-- src/core/game3/faraway_island.lua | 15 +- src/core/game3/field.lua | 8 + src/core/game3/field_view.lua | 4 +- src/core/game3/objects.lua | 2 +- src/core/game3/scripting/gfx_ids.lua | 2 + src/core/game3/scripting/natives_daycare.lua | 11 +- src/core/game3/trainer_sight.lua | 158 ++++++++++++++++--- tests/game3_battle_special_moves_test.lua | 27 ++++ tests/game3_cinnabar_seagallop_test.lua | 111 +++++++++++++ 18 files changed, 396 insertions(+), 73 deletions(-) create mode 100755 tests/game3_cinnabar_seagallop_test.lua diff --git a/src/core/game3/battle/adapter.lua b/src/core/game3/battle/adapter.lua index be87be23..e8e09d71 100644 --- a/src/core/game3/battle/adapter.lua +++ b/src/core/game3/battle/adapter.lua @@ -248,6 +248,12 @@ function Adapter.new(battleState, sayFn) local Engine = package.loaded["src.core.game3.battle.engine"] if Engine and Engine.cancelMultiTurnMoves then Engine.cancelMultiTurnMoves(battler) end end + self:pushEvent({ + kind = "status_apply", + battler = id_of(battler), + side = side_of(battler), + status = status, + }) -- pokefirered/src/battle_script_commands.c:2110 if ModRuntime.wants("battle.status_inflicted") then ModRuntime.emit("battle.status_inflicted", { @@ -264,6 +270,11 @@ function Adapter.new(battleState, sayFn) battler.toxicCounter = nil battler.sleepTurns = nil if battler.mon then battler.mon.status = nil end + self:pushEvent({ + kind = "status_clear", + battler = id_of(battler), + side = side_of(battler), + }) end function a:stages(battler) return battler and battler.stages end function a:changeStages(battler, changes) diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index e0d14a55..8ead9246 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -138,6 +138,7 @@ local function default_present(id) displayMaxHp = nil, displayExp = nil, displayLevel = nil, + displayStatus = nil, flash = 0, } end @@ -504,6 +505,7 @@ function Anim.syncDisplayFromState(st) p.displayHp = tonumber(b.mon.hp) or 0 p.displayMaxHp = tonumber(b.mon.maxHp) or 1 p.displayLevel = tonumber(b.mon.level) or 1 + p.displayStatus = b.status or (b.mon and (b.mon.status or b.mon.status1)) local prog = Experience.progress(b.mon) p.displayExp = prog.progressPercent or 0 end diff --git a/src/core/game3/battle/anim_seq.lua b/src/core/game3/battle/anim_seq.lua index e34f1351..c729a2ae 100644 --- a/src/core/game3/battle/anim_seq.lua +++ b/src/core/game3/battle/anim_seq.lua @@ -315,6 +315,10 @@ function AnimSeq.buildSteps(events, meta) else add("switch_in", d) end + elseif k == "status_apply" then + add("status_apply", { side = ev.side, battler = ev_id(ev, "battler", "side"), status = ev.status }) + elseif k == "status_clear" then + add("status_clear", { side = ev.side, battler = ev_id(ev, "battler", "side") }) elseif k == "end" then add("end", { result = ev.result, reason = ev.reason }) end @@ -660,6 +664,7 @@ local function run_switch_in(d) p.displayHp = tonumber(d.hp) or tonumber(b.mon.hp) or 0 p.displayMaxHp = tonumber(b.mon.maxHp) or 1 p.displayLevel = tonumber(b.mon.level) or 1 + p.displayStatus = b.status or (b.mon and (b.mon.status or b.mon.status1)) end end local SwitchSeq = require("src.core.game3.battle.switch_seq") @@ -733,12 +738,27 @@ local function run_step(step) launch_done() return end + if kind == "status_apply" then + local id = ev_id(d, "battler", "side") + local p = Anim.present(id) + if p then p.displayStatus = d.status end + advance() + return + end + if kind == "status_clear" then + local id = ev_id(d, "battler", "side") + local p = Anim.present(id) + if p then p.displayStatus = false end + advance() + return + end if kind == "faint" then local id = ev_id(d, "battler", "side") or 1 local p = Anim.present(id) if p then Anim.setSubstitute(id, false) p.blinkHidden = false + p.displayStatus = false end wait_anim() Anim.faintMon(id, { diff --git a/src/core/game3/battle/effects/healing.lua b/src/core/game3/battle/effects/healing.lua index 4809efae..bffd8db3 100644 --- a/src/core/game3/battle/effects/healing.lua +++ b/src/core/game3/battle/effects/healing.lua @@ -78,10 +78,7 @@ function Healing.rest(ctx) if hp >= maxHp then return hp_full(ctx, user) end local hadStatus = ad:status(user) ~= nil ad:clearStatus(user) - user.status = "SLP" - if user.mon then user.mon.status = "SLP" end - -- pokefirered/src/battle_script_commands.c:6480 - user.sleepTurns = 3 + ad:applyStatus(user, "SLP", user, { force = true, turns = 3 }) if hadStatus then ad:sayText("STRINGID_PKMNSLEPTHEALTHY", { atk = user }) else diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 2d8aea3f..82e07e92 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -935,6 +935,7 @@ local function charge_turn(M) if eff == E.SOLAR_BEAM and Rules.weather.effective(M.st, ad) == "SUN" then M:ppReduce() M.noPP = true + M.animTurn = 1 return false end if ModRuntime.wantsHook("battle.charge_required") then @@ -946,6 +947,7 @@ local function charge_turn(M) if required == false then M:ppReduce() M.noPP = true + M.animTurn = 1 return false end end diff --git a/src/core/game3/battle/healthbox.lua b/src/core/game3/battle/healthbox.lua index ff2bf5a1..7b3ef739 100644 --- a/src/core/game3/battle/healthbox.lua +++ b/src/core/game3/battle/healthbox.lua @@ -371,7 +371,9 @@ local function draw_doubles(id, battler, st, opts) local SummaryChrome = require("src.ui.game3.summary_chrome") local SummaryData = require("src.core.game3.summary_data") - local stObj = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1)) + local p = Anim.present and Anim.present(id) + local stObj = (p and p.displayStatus ~= nil) and p.displayStatus or (battler.status or (battler.mon and (battler.mon.status or battler.mon.status1))) + if stObj == false or stObj == 0 then stObj = nil end local ailment = SummaryData.statusAilment({ status = stObj, hp = battler.mon and battler.mon.hp }) local statused = ailment >= 1 and ailment <= 6 local hpText = isPlayer and Healthbox.hpTextShown(st, id) @@ -388,9 +390,7 @@ local function draw_doubles(id, battler, st, opts) end local name = State.displayName(battler) - local lv = battler.mon and battler.mon.level or 1 - local p = Anim.present and Anim.present(id) - if p and p.displayLevel then lv = p.displayLevel end + local lv = (p and p.displayLevel) or (battler.mon and battler.mon.level) or 1 local ty = tlY + TEXT_Y local gender = healthbox_gender(battler.mon) -- pokefirered/src/battle_interface.c:1531 @@ -450,12 +450,21 @@ function Healthbox.draw(side, battler, opts) return end + local p = nil + do + local ok, AnimP = pcall(require, "src.core.game3.battle.anim") + if ok and AnimP and AnimP.present then + p = AnimP.present(side) + end + end + local barCx, barCy = hp_bar_center(side, c.x + ox, c.y) local bx, by = hp_bar_top_left(barCx, barCy) local statusBorder = false if not isPlayer then local SummaryData = require("src.core.game3.summary_data") - local st1 = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1)) + local st1 = (p and p.displayStatus ~= nil) and p.displayStatus or (battler.status or (battler.mon and (battler.mon.status or battler.mon.status1))) + if st1 == false or st1 == 0 then st1 = nil end local a = SummaryData.statusAilment({ status = st1, hp = battler.mon and battler.mon.hp }) -- pokefirered/src/battle_interface.c:1668 statusBorder = a >= 1 and a <= 6 @@ -464,14 +473,7 @@ function Healthbox.draw(side, battler, opts) BattleChrome.drawHpBar(bx, by, hpNow, hpMax, statusBorder) local name = State.displayName(battler) - local lv = battler.mon and battler.mon.level or 1 - do - local ok, AnimP = pcall(require, "src.core.game3.battle.anim") - if ok and AnimP and AnimP.present then - local p = AnimP.present(side) - if p and p.displayLevel then lv = p.displayLevel end - end - end + local lv = (p and p.displayLevel) or (battler.mon and battler.mon.level) or 1 local ty = tlY + TEXT_Y local lvlX = isPlayer and PLAYER_LVL_X or ENEMY_LVL_X @@ -493,7 +495,8 @@ function Healthbox.draw(side, battler, opts) local SummaryChrome = require("src.ui.game3.summary_chrome") local SummaryData = require("src.core.game3.summary_data") - local stObj = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1)) + local stObj = (p and p.displayStatus ~= nil) and p.displayStatus or (battler.status or (battler.mon and (battler.mon.status or battler.mon.status1))) + if stObj == false or stObj == 0 then stObj = nil end local ailment = SummaryData.statusAilment({ status = stObj, hp = battler.mon and battler.mon.hp }) if isPlayer then diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 4ab8b6f5..c59866b2 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -2291,13 +2291,13 @@ local function draw_action_menu(st) local labels = menu_labels((st and st.safari) and "gText_SafariZoneMenu" or "gText_BattleMenu") draw_prompt_text(action_prompt(st, ab), 10, 122) local positions = { - { 136, 122 }, { 184, 122 }, - { 136, 138 }, { 184, 138 }, + { 136, 122 }, { 192, 122 }, + { 136, 138 }, { 192, 138 }, } local c = Ui._menuIndex - 1 local cursorPos = { - { 128, 122 }, { 176, 122 }, - { 128, 138 }, { 176, 138 }, + { 128, 122 }, { 184, 122 }, + { 128, 138 }, { 184, 138 }, } local cp = cursorPos[c + 1] or cursorPos[1] Window.cursorPx(cp[1], cp[2], { colors = FrlgFont.COLOR.NORMAL }) diff --git a/src/core/game3/battle_bridge.lua b/src/core/game3/battle_bridge.lua index 52d47aec..8e560912 100644 --- a/src/core/game3/battle_bridge.lua +++ b/src/core/game3/battle_bridge.lua @@ -383,10 +383,10 @@ function BattleBridge.start(mod, game, foe, opts) end if #battleParty == 0 then return nil, "empty party" end local foeHalf - if opts.twoOpponents and opts.trainerIdB and not opts.wild then - foe, foeHalf = BattleBridge.twoOpponentFoe(foe, opts.trainerIdB) - elseif opts.twoOpponents and opts.frontierFoeHalf and not opts.wild then + if opts.twoOpponents and opts.frontierFoeHalf and not opts.wild then foeHalf = opts.frontierFoeHalf + elseif opts.twoOpponents and opts.trainerIdB and not opts.wild then + foe, foeHalf = BattleBridge.twoOpponentFoe(foe, opts.trainerIdB) end local isDouble = (not opts.wild) and (opts.double or foeHalf or (foe and foe.doubleBattle)) and true or false if isDouble and Party.monsStateToDoubles(linkParty or session.party) ~= Party.PLAYER_HAS_TWO_USABLE_MONS then diff --git a/src/core/game3/daycare.lua b/src/core/game3/daycare.lua index 63675fa4..0c8e400c 100644 --- a/src/core/game3/daycare.lua +++ b/src/core/game3/daycare.lua @@ -166,25 +166,43 @@ function Daycare.teachMove(mon, moveId) mon.moves = mon.moves or {} mon.pp = mon.pp or {} mon.maxPp = mon.maxPp or {} - for i = 1, #mon.moves do - if mon.moves[i] == moveId then return false end + + local moves = {} + local pps = {} + local maxPps = {} + for i = 1, MAX_MON_MOVES do + local m = tonumber(mon.moves[i]) or 0 + if m > 0 then + if m == moveId then return false end + moves[#moves + 1] = m + pps[#pps + 1] = tonumber(mon.pp[i]) or 0 + maxPps[#maxPps + 1] = tonumber(mon.maxPp[i]) or 0 + end end + local Pokemon = pokemonMod() local maxPp = 0 if Pokemon.movePp then maxPp = tonumber(Pokemon.movePp(moveId)) or 0 end - if #mon.moves < MAX_MON_MOVES then - mon.moves[#mon.moves + 1] = moveId - mon.pp[#mon.moves] = maxPp - mon.maxPp[#mon.moves] = maxPp - return true + + if #moves < MAX_MON_MOVES then + moves[#moves + 1] = moveId + pps[#pps + 1] = maxPp + maxPps[#maxPps + 1] = maxPp + else + -- pokefirered/src/daycare.c:495 DeleteFirstMoveAndGiveMoveToMon + table.remove(moves, 1) + table.remove(pps, 1) + table.remove(maxPps, 1) + moves[MAX_MON_MOVES] = moveId + pps[MAX_MON_MOVES] = maxPp + maxPps[MAX_MON_MOVES] = maxPp + end + + for i = 1, MAX_MON_MOVES do + mon.moves[i] = moves[i] or 0 + mon.pp[i] = pps[i] or 0 + mon.maxPp[i] = maxPps[i] or 0 end - -- pokefirered/src/daycare.c:495 DeleteFirstMoveAndGiveMoveToMon - table.remove(mon.moves, 1) - table.remove(mon.pp, 1) - table.remove(mon.maxPp, 1) - mon.moves[MAX_MON_MOVES] = moveId - mon.pp[MAX_MON_MOVES] = maxPp - mon.maxPp[MAX_MON_MOVES] = maxPp return true end diff --git a/src/core/game3/faraway_island.lua b/src/core/game3/faraway_island.lua index 5c0bd6e9..00b8a2e5 100644 --- a/src/core/game3/faraway_island.lua +++ b/src/core/game3/faraway_island.lua @@ -34,13 +34,22 @@ local function counter() end local function mewGfx() - local Constants = lazyReq("src.core.game3.constants") - return Constants.of(Constants.versionOf(session())):require("event_objects", "OBJ_EVENT_GFX_MEW") + local ok, res = pcall(function() + local Constants = lazyReq("src.core.game3.constants") + return Constants.of(Constants.versionOf(session())):require("event_objects", "OBJ_EVENT_GFX_MEW") + end) + return ok and res or nil end -- pokeemerald/src/faraway_island.c:335 function FarawayIsland.isMew(eo) - return onMap() and eo ~= nil and tonumber(eo.graphicsId) == mewGfx() + if not (onMap() and eo) then return false end + local mgfx = mewGfx() + local gid = tonumber(eo.graphicsId) + local dgid = eo.def and tonumber(eo.def.graphicsId or eo.def.gfx) + if mgfx and (gid == mgfx or dgid == mgfx) then return true end + if eo.graphicsId == "OBJ_EVENT_GFX_MEW" or (eo.def and eo.def.graphicsId == "OBJ_EVENT_GFX_MEW") then return true end + return false end -- pokeemerald/src/faraway_island.c:347 diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index a9c4de81..b92b0b50 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -901,6 +901,14 @@ function Field.interact(game) -- 1) EventObject (nurse behind counter uses doubled cell; Cut tree / Rock / Boulder) local ox, oy = facing_object_cell(fx, fy, P.facing) local eo = Objects.at(ox, oy) + if not eo then + local under = Objects.at(P.cellX, P.cellY) + local Faraway = package.loaded["src.core.game3.faraway_island"] + if under and (under.copy or (Faraway and Faraway.isMew and Faraway.isMew(under))) then + eo = under + ox, oy = P.cellX, P.cellY + end + end if eo and eo.def then local gfx = eo.def.graphicsId or eo.def.gfx local FP = lazyReq("src.core.game3.profile").forSession(Field._session) diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index 6715f739..910fab02 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -418,8 +418,8 @@ local function collectNeighborActors(actors, baseIndex, hostMapId, hostDef, camX local p = nb.perm[lid] local ox = p and p.x or tonumber(obj.x) or 0 local oy = p and p.y or tonumber(obj.y) or 0 - local out = bounds and (ox < 0 or oy < 0 - or ox >= bounds.w or oy >= bounds.h) + local out = bounds and (ox < -16 or oy < -16 + or ox >= bounds.w + 16 or oy >= bounds.h + 16) -- src/event_object_movement.c:8014 if tonumber(obj.movementType) == 0x4C then out = true end local gid = obj.graphicsId or obj.graphics diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index 61b6ac87..03692ee6 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -844,7 +844,7 @@ function Objects.forDraw() local eo = Objects._byId[lid] -- src/event_object_movement.c:8014 if eo and eo.visible and not eo.hidden and not eo.invisible - and (eo.foreignMap ~= nil or not offMap(Objects._bounds, eo)) then + and (eo.foreignMap ~= nil or eo.moving or eo.scriptBusy or (Objects._tracks and Objects._tracks[eo.localId] ~= nil) or not offMap(Objects._bounds, eo)) then n = n + 1 list[n] = eo end diff --git a/src/core/game3/scripting/gfx_ids.lua b/src/core/game3/scripting/gfx_ids.lua index 556b9db6..68f0f465 100644 --- a/src/core/game3/scripting/gfx_ids.lua +++ b/src/core/game3/scripting/gfx_ids.lua @@ -44,6 +44,8 @@ GfxIds.TO_SPRITE = { [88] = "SPRITE_MOM", -- player's PC / mom-adjacent [89] = "SPRITE_SUPER_NERD", -- CELIO [92] = "SPRITE_POKE_BALL", + [108] = "SPRITE_SAILOR", -- OBJ_EVENT_GFX_SEAGALLOP fallback + [109] = "SPRITE_SAILOR", -- OBJ_EVENT_GFX_SS_ANNE fallback } local S, N, W, E = "down", "up", "left", "right" diff --git a/src/core/game3/scripting/natives_daycare.lua b/src/core/game3/scripting/natives_daycare.lua index a141142c..2710d7cd 100644 --- a/src/core/game3/scripting/natives_daycare.lua +++ b/src/core/game3/scripting/natives_daycare.lua @@ -1,4 +1,3 @@ -local Strings = require("src.core.Strings") local Std = require("src.core.game3.scripting.stdscripts") local Model = require("src.core.game3.daycare") local Breeding = require("src.core.game3.breeding") @@ -202,7 +201,10 @@ Daycare.BY_NAME = { return false, SPECIES_NONE end setStringVar(ctx, adapters, 1, nicknameOf(mon)) - return false, Model.take(session, index) + local species = Model.take(session, index) + local speciesId = tonumber(species) or tonumber(mon.species or mon.speciesId) or 0 + setResult(ctx, speciesId) + return false, speciesId end, -- pokefirered/src/daycare.c:1588 TakePokemonFromRoute5Daycare TakePokemonFromRoute5Daycare = function(ctx, adapters) @@ -219,7 +221,10 @@ Daycare.BY_NAME = { return false, SPECIES_NONE end setStringVar(ctx, adapters, 1, nicknameOf(mon)) - return false, Model.takeRoute5(session) + local species = Model.takeRoute5(session) + local speciesId = tonumber(species) or tonumber(mon.species or mon.speciesId) or 0 + setResult(ctx, speciesId) + return false, speciesId end, -- pokefirered/src/daycare.c:594 GetDaycareCost GetDaycareCost = function(ctx, adapters) diff --git a/src/core/game3/trainer_sight.lua b/src/core/game3/trainer_sight.lua index aff85c89..6d219019 100644 --- a/src/core/game3/trainer_sight.lua +++ b/src/core/game3/trainer_sight.lua @@ -560,14 +560,61 @@ function TrainerSight.engagePair(game, a, b) local Sp = Space() F.locked = true a.eo.frozen, b.eo.frozen = true, true - local tidA, tidB = TrainerSight.getTrainerId(a.eo), TrainerSight.getTrainerId(b.eo) + + local okPyr, Pyramid = pcall(require, "src.core.game3.rse.frontier.pyramid") + local inPyramid = okPyr and Pyramid and Pyramid.inPyramid and Pyramid.inPyramid() + + local tidA, tidB + local sess = nil + local D = nil + local partyA, partyB = {}, {} + local combinedParty = {} + local half = 0 + local foeAInfo, foeBInfo = {}, {} + local nameA, nameB = "", "" + local defeatTextA, defeatTextB = nil, nil + + if inPyramid then + local Rse = require("src.core.game3.rse.init") + sess = Rse.session() + D = require("src.core.game3.rse.frontier.data") + local F_FACILITY = D.FACILITY + tidA = Pyramid.localIdToTrainerId(sess, a.eo.localId) + tidB = Pyramid.localIdToTrainerId(sess, b.eo.localId) + sess.frontierOpponentA = tidA + sess.frontierOpponentB = tidB + D.fillTrainerParty(sess, tidA, 0, 1, partyA, { facility = F_FACILITY.PYRAMID }) + D.fillTrainerParty(sess, tidB, 0, 1, partyB, { facility = F_FACILITY.PYRAMID }) + for _, m in ipairs(partyA) do combinedParty[#combinedParty + 1] = m end + half = #combinedParty + for _, m in ipairs(partyB) do combinedParty[#combinedParty + 1] = m end + foeAInfo = D.trainerClass(sess, tidA, F_FACILITY.PYRAMID) or {} + foeBInfo = D.trainerClass(sess, tidB, F_FACILITY.PYRAMID) or {} + nameA = D.trainerName(sess, tidA, F_FACILITY.PYRAMID) or "" + nameB = D.trainerName(sess, tidB, F_FACILITY.PYRAMID) or "" + defeatTextA = Pyramid.speech(sess, tidA, 1) + defeatTextB = Pyramid.speech(sess, tidB, 1) + else + tidA = TrainerSight.getTrainerId(a.eo) + tidB = TrainerSight.getTrainerId(b.eo) + end + local rowA, rowB = battleRow(a.eo), battleRow(b.eo) TrainerSight._pair = { a = tidA, b = tidB } + local function finishBattle(result) local store = Sp and Sp.store local ctx = Sp and Sp.vm and Sp.vm.ctx TrainerSight._pair = nil F.locked = false + a.eo.frozen, b.eo.frozen = false, false + if inPyramid then + Pyramid.markBattled(sess, tidA, a.eo.localId) + Pyramid.markBattled(sess, tidB, b.eo.localId) + local N = require("src.core.game3.scripting.natives") + sess.battleOutcome = N.outcome_to_code(result or "win") + return + end if result == "lose" or result == "whiteout" or result == "blackout" then return end -- pokeemerald/src/battle_setup.c:1245 for _, tid in ipairs({ tidB, tidA }) do @@ -593,41 +640,102 @@ function TrainerSight.engagePair(game, a, b) end end end + local function startBattle() - local okT, Trainers = pcall(require, "src.core.game3.scripting.trainers") - local foe = okT and Trainers.foeFromId(tidA) - local dlgA, dlgB = Trainers.dialogs(tidA) or {}, Trainers.dialogs(tidB) or {} - local function text(row, key, dlg) - if row and row[key] and Sp and Sp.vm and Sp.vm.getText then - local t = Sp.vm:getText(row[key]) - if t then return t end - end - return dlg - end local Runtime = package.loaded["src.core.game3.runtime"] or require("src.core.game3.runtime") + local foe + local battleOpts + if inPyramid then + foe = { + party = combinedParty, + trainerId = tidA, + trainerClass = foeAInfo.class, + trainerClassName = foeAInfo.className, + trainerName = nameA, + trainerPicId = foeAInfo.pic, + } + battleOpts = { + wild = false, + trainerId = tidA, + trainerIdB = tidB, + twoOpponents = true, + double = true, + frontierFoeHalf = half, + pyramid = true, + frontier = true, + frontierTrainer = { class = foeAInfo.class, className = foeAInfo.className, name = nameA, pic = foeAInfo.pic }, + frontierTrainerB = { class = foeBInfo.class, className = foeBInfo.className, name = nameB, pic = foeBInfo.pic }, + defeatText = defeatTextA, + defeatTextB = defeatTextB, + transitionId = D and D.specialTransition(sess, "B_PYRAMID", combinedParty) or nil, + done = finishBattle, + } + else + local okT, Trainers = pcall(require, "src.core.game3.scripting.trainers") + foe = okT and Trainers.foeFromId(tidA) + local dlgA, dlgB = Trainers.dialogs(tidA) or {}, Trainers.dialogs(tidB) or {} + local function text(row, key, dlg) + if row and row[key] and Sp and Sp.vm and Sp.vm.getText then + local t = Sp.vm:getText(row[key]) + if t then return t end + end + return dlg + end + battleOpts = { + wild = false, + trainerId = tidA, + trainerIdB = tidB, + twoOpponents = true, + double = true, + defeatText = text(rowA, "defeatText", dlgA.defeat), + defeatTextB = text(rowB, "defeatText", dlgB.defeat), + done = finishBattle, + } + end -- pokeemerald/src/battle_setup.c:1272 - local ok, err = require("src.core.game3.battle_bridge").start(Runtime._mod, game or Runtime._game, foe, { - wild = false, - trainerId = tidA, - trainerIdB = tidB, - twoOpponents = true, - double = true, - defeatText = text(rowA, "defeatText", dlgA.defeat), - defeatTextB = text(rowB, "defeatText", dlgB.defeat), - done = finishBattle, - }) + local ok, err = require("src.core.game3.battle_bridge").start(Runtime._mod, game or Runtime._game, foe, battleOpts) if not ok then print("[game3/trainer_sight] two-trainer battle did not start: " .. tostring(err)) finishBattle("lose") end end - playEncounterMusic(tidA, rowA) + + local function showIntroA(onDone) + if inPyramid then + local introA = Pyramid.speech(sess, tidA, 0) + require("src.ui.game3.hud").openMessage(nil, introA, { done = onDone }) + else + introSpeech(a.eo, tidA, rowA, onDone) + end + end + + local function showIntroB(onDone) + if inPyramid then + local introB = Pyramid.speech(sess, tidB, 0) + require("src.ui.game3.hud").openMessage(nil, introB, { done = onDone }) + else + introSpeech(b.eo, tidB, rowB, onDone) + end + end + + if inPyramid then + local Audio = require("src.core.game3.audio") + Audio.playSong(Pyramid.encounterMusic(sess, tidA)) + else + playEncounterMusic(tidA, rowA) + end + approach(a.eo, a.dist, a.facing, function() - introSpeech(a.eo, tidA, rowA, function() + showIntroA(function() -- pokeemerald/src/trainer_see.c:666 - playEncounterMusic(tidB, rowB) + if inPyramid then + local Audio = require("src.core.game3.audio") + Audio.playSong(Pyramid.encounterMusic(sess, tidB)) + else + playEncounterMusic(tidB, rowB) + end approach(b.eo, b.dist, b.facing, function() - introSpeech(b.eo, tidB, rowB, startBattle) + showIntroB(startBattle) end) end) end) diff --git a/tests/game3_battle_special_moves_test.lua b/tests/game3_battle_special_moves_test.lua index 85cf8ad0..03f80f90 100644 --- a/tests/game3_battle_special_moves_test.lua +++ b/tests/game3_battle_special_moves_test.lua @@ -776,6 +776,33 @@ do check(st.player.twoTurnMove == nil, "twoTurnMove is cleared after unleash") end +do + -- Solarbeam in Sun (e.g. Groudon Drought) fires in 1 turn and uses unleash animation (turn=1) + local sbId = Moves.numForName("SOLARBEAM") or Moves.numForName("SOLAR_BEAM") + local st = State.new({ + wild = true, + weather = "SUN", + playerParty = { { species = 383, level = 50, hp = 200, maxHp = 200, moves = { sbId }, pp = { 10 }, + attack = 150, defense = 140, spAtk = 100, spDef = 90, speed = 90 } }, + foeMon = { species = 19, level = 20, hp = 100, maxHp = 100, moves = { 33 }, pp = { 35 }, + attack = 20, defense = 50, spAtk = 20, spDef = 50, speed = 20 }, + }) + local ad = setup_test_battle(st) + local out = {} + Engine.resolveMove(st.player, st.enemy, sbId, 1, ad, st, out) + check(ad:hp(st.enemy) < 100, "Foe takes damage immediately in Sun without a charge turn") + check(st.player.twoTurnMove == nil, "No twoTurnMove locked in Sun") + local moveEvents = {} + for _, ev in ipairs(ad:events()) do + if ev.kind == "move" and ev.moveId == sbId then + moveEvents[#moveEvents + 1] = ev + end + end + check(#moveEvents == 1, "Exactly one move event generated for instant Solar Beam") + check(moveEvents[1] and moveEvents[1].turn == 1, + "Solar Beam animation event uses turn=1 (unleash beam), got turn=" .. tostring(moveEvents[1] and moveEvents[1].turn)) +end + do -- Skull Bash raises defense on charge turn 1 local sbashId = Moves.numForName("SKULL_BASH") diff --git a/tests/game3_cinnabar_seagallop_test.lua b/tests/game3_cinnabar_seagallop_test.lua new file mode 100755 index 00000000..d7d8e11c --- /dev/null +++ b/tests/game3_cinnabar_seagallop_test.lua @@ -0,0 +1,111 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local Objects = require("src.core.game3.objects") +local GfxIds = require("src.core.game3.scripting.gfx_ids") +local Flags = require("src.core.game3.scripting.flags") +local Space = require("src.core.game3.scripting.space") + +print("[test] 1. GfxIds maps OBJ_EVENT_GFX_SEAGALLOP (108)") +local sprName = GfxIds.spriteFor(108) +check(sprName == "SPRITE_SAILOR", "108 maps to SPRITE_SAILOR fallback (got " .. tostring(sprName) .. ")") + +print("[test] 2. offMap bounds checking") +local bounds = { w = 24, h = 20 } +check(not Objects.offMap(bounds, { cellX = 10, cellY = 12 }), "x=10 is on-map") +check(not Objects.offMap(bounds, { cellX = 0, cellY = 0 }), "x=0, y=0 is on-map") +check(Objects.offMap(bounds, { cellX = 24, cellY = 12 }), "x=24 is off-map on 24x20 map") +check(Objects.offMap(bounds, { cellX = -1, cellY = 12 }), "x=-1 is off-map") + +print("[test] 3. Cinnabar Island Seagallop spawn & arrival movement") +local cinnabarMapDef = { + midLayout = { width = 24, height = 20 }, + objects = { + { localId = 1, graphicsId = 23, x = 14, y = 6, movementType = 5, flag = 0 }, + { localId = 2, graphicsId = 32, x = 11, y = 11, movementType = 1, flag = 0 }, + { localId = 3, graphicsId = 73, x = 20, y = 7, movementType = 10, flag = 0x27 }, -- Bill + { localId = 4, graphicsId = 108, x = 23, y = 7, elevation = 1, movementType = 9, flag = 0x28 }, -- Seagallop + } +} + +local store = Flags.newStore() +Flags.setFlag(store, nil, 0x28, true) -- FLAG_HIDE_CINNABAR_SEAGALLOP = true +Space.store = store +Space.mapId = "CinnabarIsland" + +Objects.clear() +Objects.loadMap(nil, "CinnabarIsland", cinnabarMapDef) + +-- Initially Seagallop is hidden by flag 0x28 +local drawList = Objects.forDraw() +local foundSeagallop = false +for _, eo in ipairs(drawList) do + if eo.localId == 4 then foundSeagallop = true end +end +check(not foundSeagallop, "Seagallop is initially hidden before addobject") + +-- Script runs: setobjectxyperm 4, 30, 12 then addobject 4 +Objects.setObjectXY(4, 30, 12) +Objects.addObject(4) +local boatEo = Objects.find(4) +check(boatEo and boatEo.cellX == 30 and boatEo.cellY == 12, "Seagallop coordinates set to (30, 12)") + +-- Script runs: applymovement 4, CinnabarIsland_Movement_BoatArrive (5 steps left: 30 -> 25) +local arriveBytes = { + 0x1B, -- delay_16 + 0x1B, -- delay_16 + 0x1F, -- walk_fast_left + 0x1F, -- walk_fast_left + 0x12, -- walk_left + 0x0A, -- walk_slower_left + 0x0A, -- walk_slower_left + 0xFE -- step_end +} + +local movementFinished = false +Objects.applyMovement(4, arriveBytes, function() + movementFinished = true +end) + +drawList = Objects.forDraw() +foundSeagallop = false +for _, eo in ipairs(drawList) do + if eo.localId == 4 then foundSeagallop = true end +end +check(foundSeagallop, "Seagallop moving from off-map (30, 12) is included in forDraw() while tracked") + +-- Step through frames until movement completes +local maxFrames = 300 +local frames = 0 +while not movementFinished and frames < maxFrames do + Objects.update(nil) + frames = frames + 1 +end + +check(movementFinished, "Boat arrival movement completed in " .. frames .. " frames") +check(boatEo and boatEo.cellX == 25 and boatEo.cellY == 12, + "Boat arrived at shoreline (25, 12), got (" .. tostring(boatEo and boatEo.cellX) .. ", " .. tostring(boatEo and boatEo.cellY) .. ")") + +drawList = Objects.forDraw() +foundSeagallop = false +for _, eo in ipairs(drawList) do + if eo.localId == 4 then foundSeagallop = true end +end +check(foundSeagallop, "Seagallop at (25, 12) is visible and drawn at shoreline") + +if failed > 0 then + print("\nFAIL: " .. failed .. " assertions failed") + os.exit(1) +else + print("\nPASS: all Cinnabar Seagallop tests passed") +end