From ddcce5b95a1d08f71c1ed1494c9552cfc654e772 Mon Sep 17 00:00:00 2001 From: sirj0k3r Date: Fri, 31 Jul 2026 21:14:23 +0100 Subject: [PATCH 1/2] + Implemented Exp hook --- src/battle/BattleState.lua | 62 +++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 4306cd56..84a5fab9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3216,7 +3216,10 @@ function BattleState:onFaint(battler) end end -function BattleState:enemyMonFainted() +-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted) +-- and, when a mod's battle.catch_exp hook says so, the catch path +-- (storeCaughtMon). +function BattleState:awardExp() -- exp is split among the mons that fought this enemy -- (engine/battle/experience.asm); traded mons earn x1.5; each -- participant gets the full stat exp @@ -3294,27 +3297,44 @@ function BattleState:enemyMonFainted() end end end - -- with EXP.ALL, participants split half the exp and the other half - -- is divided among the whole party (engine/battle/experience.asm) - local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 - for _, mon in ipairs(alive) do - applyShare(mon, participants * (expAll and 2 or 1), true) - end - if expAll then - -- the second GainExperience pass sets the gain flags for the WHOLE - -- party, so DivideExpDataByNumMonsGainingExp divides the already - -- halved-and-participant-divided exp again by the party count, and - -- .partyMonLoop still skips fainted mons (core.asm:818-858 + - -- experience.asm:9-13); each mon gets its own GainedText with the - -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no - -- summary line - for _, mon in ipairs(self.game.save.party) do - if mon.hp > 0 then - applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll") + -- battle.exp_award: the participant/EXP.ALL split, factored out so a + -- mod can replace it wholesale (e.g. a flat undivided share to every + -- non-fainted party mon) without re-deriving participants/alive. + -- ctx.applyShare(mon, split, announce) is the same helper vanilla uses. + local function vanillaExpAward(ctx) + -- with EXP.ALL, participants split half the exp and the other half + -- is divided among the whole party (engine/battle/experience.asm) + local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 + for _, mon in ipairs(ctx.alive) do + ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true) + end + if expAll then + -- the second GainExperience pass sets the gain flags for the WHOLE + -- party, so DivideExpDataByNumMonsGainingExp divides the already + -- halved-and-participant-divided exp again by the party count, and + -- .partyMonLoop still skips fainted mons (core.asm:818-858 + + -- experience.asm:9-13); each mon gets its own GainedText with the + -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no + -- summary line + for _, mon in ipairs(self.game.save.party) do + if mon.hp > 0 then + ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll") + end end end end + local awardCtx = { battle = self, participants = participants, alive = alive, + applyShare = applyShare } + if Runtime.wantsHook("battle.exp_award") then + Runtime.call("battle.exp_award", vanillaExpAward, awardCtx) + else + vanillaExpAward(awardCtx) + end self.participants = {} +end + +function BattleState:enemyMonFainted() + self:awardExp() if self.kind == "trainer" then -- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan @@ -3875,6 +3895,12 @@ function BattleState:storeCaughtMon() -- (item_effects.asm:472-501), regenerating its move list from the -- base data -- a Mimic'd slot never leaves the battle with it self:restoreMimicked(self.enemy) + -- battle.catch_exp: vanilla catches never grant exp; a mod can flip + -- this to true to pay out the same award a faint would have. + if Runtime.wantsHook("battle.catch_exp") + and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then + self:awardExp() + end local game = self.game local dex = game.save.pokedex local species = self.enemy.mon.species From 0fad22256997aa632fb87bf1600371700b67521f Mon Sep 17 00:00:00 2001 From: sirj0k3r Date: Fri, 31 Jul 2026 22:10:26 +0100 Subject: [PATCH 2/2] + Implemented tests for `battle.catch_exp` and `battle.exp_award` hooks --- tests/mod_battle_tests.lua | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index 93c35319..4c44bc89 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -716,6 +716,32 @@ do check(actBattle:enemyAction().hooked == true, "battle.enemy_action hook rewrites the choice") unsub() + + -- battle.catch_exp: vanilla catches never grant exp; a mod can flip that + unsub = hooks:wrap("battle.catch_exp", function() return true end) + local catchExpParty = { Pokemon.new(Data, "BULBASAUR", 10) } + local catchExpGame = makeGame(catchExpParty) + local catchExpBattle = BattleState.newWild(catchExpGame, "RATTATA", 3) + catchExpBattle.enemy.mon = Pokemon.new(Data, "RATTATA", 3) + local expBeforeCatch = catchExpParty[1].exp + catchExpBattle:storeCaughtMon() + check(catchExpParty[1].exp > expBeforeCatch, + "battle.catch_exp hook pays out exp on a catch") + unsub() + + -- battle.exp_award: a mod can replace the participant/EXP.ALL split + -- wholesale via ctx.applyShare + unsub = hooks:wrap("battle.exp_award", function(nextFn, ctx) + ctx.applyShare(ctx.alive[1], 999, "flatShare") + end) + local awardParty = { Pokemon.new(Data, "BULBASAUR", 10) } + local awardGame = makeGame(awardParty) + local awardBattle = BattleState.newWild(awardGame, "RATTATA", 3) + local expBeforeAward = awardParty[1].exp + awardBattle:awardExp() + check(awardParty[1].exp > expBeforeAward, + "battle.exp_award hook replaces the award split") + unsub() end -- ------- battle events: the scripted sequence