From ba8ac3d143a15c4d8b335da1489cddbefcda6c56 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Tue, 4 Aug 2026 12:06:21 -0400 Subject: [PATCH] Tick residuals after each move in Gen 1 mode In pokered, MainInBattleLoop calls HandlePoisonBurnLeechSeed right after every Execute*Move (core.asm:426-464), so a seeded, poisoned or burned mon takes its residual before the slower side acts. The port ran the whole sweep in endOfTurn, which made leech seed behave like Gen 3+ and never showed the drain animation (#784). The residual sweep now runs per action under the gen1_faithful ruleset, gated by a new residualAfterMove flag; modern_clean keeps the end of round sweep. The leech seed drain plays the ABSORB animation from the healing side, the way the original flips hWhoseTurn before PlayMoveAnimation. Item, ball, failed run and ghost-fear turns still tick the player's residual, matching ExecutePlayerMoveDone. --- src/battle/BattleState.lua | 76 ++++++++++- src/battle/rulesets/gen1_faithful.lua | 4 + src/battle/rulesets/modern_clean.lua | 3 + tests/engine/leech_seed_timing_bug784.lua | 153 ++++++++++++++++++++++ tests/run_tests.lua | 4 +- 5 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 tests/engine/leech_seed_timing_bug784.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 5a2a9bc2..7ae75b41 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1889,6 +1889,9 @@ function BattleState:update(dt) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) + -- the scared turn still ticks the player's residual (PrintGhostText + -- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279) + self:queueResidual(self.player, self.enemy) self:act(function() self:endOfTurn() end) elseif choice == "fight" then -- After the menu: own trapping/Bide or foe Wrap skips the move @@ -2358,6 +2361,48 @@ function BattleState:resolveSwitch(newMon) self:act(function() self:endOfTurn() end) end +-- Gen 1 calls HandlePoisonBurnLeechSeed right after the acting side's +-- move (core.asm:426-464), so the drain lands before the slower mon acts; +-- the modern ruleset sweeps residuals at end of round instead (Gen 3+). +local function residualAfterMove(battle) + local ruleset = battle.ruleset + return not ruleset or ruleset.residualAfterMove ~= false +end + +-- HandlePoisonBurnLeechSeed for one side, run right after its action. +-- Skipped when the action settled the battle (a Teleport escape rets +-- before the call), when an AI switch swapped the side out mid-action, or +-- when the move already knocked the opponent out (core.asm:423-425, +-- 452-454) -- the same bypass the end-of-round sweep applies. +function BattleState:residualFor(b, opp) + if self.result then return end + if self.player ~= b and self.enemy ~= b then return end + if b.mon.hp <= 0 or opp.mon.hp <= 0 then return end + local msgs = Status.residual(b, opp, self) + for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end + if b.leechSeeded and b.mon.hp > 0 then + -- the drain plays the ABSORB animation from the healing side + -- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation) + self:animNext("ABSORB", opp.isPlayer) + end + if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved + self.sideToxic = self.sideToxic or {} + if b.toxicCounter then + self.sideToxic[b.isPlayer and "player" or "enemy"] = b.toxicCounter + end + if b.mon.hp <= 0 then + self:onFaint(b) + end +end + +-- append one side's residual to the queue under Gen 1 timing; a no-op +-- under the modern ruleset, whose sweep runs in endOfTurn instead +function BattleState:queueResidual(b, opp) + if residualAfterMove(self) then + self:act(function() self:residualFor(b, opp) end) + end +end + function BattleState:endOfTurn() -- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed -- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual @@ -2377,6 +2422,9 @@ function BattleState:endOfTurn() -- that sets the flag also zeroes the counter, so a stale value is -- unobservable (a switch or cure downgrades Toxic to plain poison). self.sideToxic = self.sideToxic or {} + -- Gen 1 timing already ran each side's residual right after its move + -- (see executeAction); the end-of-round sweep is the modern ruleset's + local sweep = not residualAfterMove(self) -- a battler whose opponent was already knocked out by a move this turn -- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the -- move faints the target); snapshot before residual so one side's @@ -2386,7 +2434,7 @@ function BattleState:endOfTurn() for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive }, { self.enemy, self.player, "enemy", playerAlive } }) do local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4] - if b.mon.hp > 0 and oppAlive then + if sweep and b.mon.hp > 0 and oppAlive then local msgs = Status.residual(b, opp, self) for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved @@ -2397,6 +2445,9 @@ function BattleState:endOfTurn() self:onFaint(b) end end + -- the Haze move-forfeit only covers the turn Haze was used; if the + -- cured mon had already moved, drop the flag before next turn + b.skipMove = nil -- CheckNumAttacksLeft (core.asm:683-697): a trapping counter that -- hit 0 this turn releases its bit only now, at the end of the turn if b.trappingTurns and b.trappingTurns <= 0 then @@ -3181,6 +3232,12 @@ function BattleState:executeAction(user, target, action) run() -- after announce/anim/effect text (pokered DrawHUDsAndHPBars) self:actNext(function() self:syncShownStatus() end) + -- MainInBattleLoop calls HandlePoisonBurnLeechSeed right after each + -- Execute*Move (core.asm:426-464): the acting side's poison/burn/leech + -- seed ticks before the slower mon acts, not at end of round + if residualAfterMove(self) then + self:actNext(function() self:residualFor(user, target) end) + end end -- Sleep / confusion onomatopoeia from Check*StatusConditions @@ -4245,6 +4302,9 @@ function BattleState:tryRun() self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) + -- a failed escape loses the turn (core.asm:1572): the player's + -- residual still ticks, same as an item turn + self:queueResidual(self.player, self.enemy) self:act(function() self:endOfTurn() end) end end @@ -4267,6 +4327,11 @@ function BattleState:itemUsed(messages) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) + -- the item spends the player's move, but its residual still ticks: + -- ExecutePlayerMove rets early on wActionResultOrTookBattleTurn and + -- MainInBattleLoop calls HandlePoisonBurnLeechSeed anyway + -- (core.asm:3086-3088, 3275-3279) + self:queueResidual(self.player, self.enemy) self:act(function() self:endOfTurn() end) end @@ -4448,6 +4513,9 @@ function BattleState:throwBall(ball) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) + -- a thrown ball spends the turn like an item: the player's residual + -- still ticks (core.asm:3275-3279) + self:queueResidual(self.player, self.enemy) self:act(function() self:endOfTurn() end) return end @@ -4466,6 +4534,9 @@ function BattleState:throwBall(ball) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) + -- a thrown ball spends the turn like an item: the player's residual + -- still ticks (core.asm:3275-3279) + self:queueResidual(self.player, self.enemy) self:act(function() self:endOfTurn() end) return end @@ -4492,6 +4563,9 @@ function BattleState:throwBall(ball) self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) + -- a thrown ball spends the turn like an item: the player's residual + -- still ticks (core.asm:3275-3279) + self:queueResidual(self.player, self.enemy) self:act(function() self:endOfTurn() end) end end) diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua index 7b338511..68f171ab 100644 --- a/src/battle/rulesets/gen1_faithful.lua +++ b/src/battle/rulesets/gen1_faithful.lua @@ -19,4 +19,8 @@ return { -- Gen 1 Hyper Beam: no recharge when the target faints (or its -- substitute breaks). Set false to always recharge like Gen 2+. hyperBeamSkipRechargeOnKO = true, + -- Gen 1 runs HandlePoisonBurnLeechSeed right after each side's move + -- (core.asm:426-464): poison/burn/leech seed tick before the slower + -- mon acts, not in an end-of-round sweep like Gen 3+. + residualAfterMove = true, } diff --git a/src/battle/rulesets/modern_clean.lua b/src/battle/rulesets/modern_clean.lua index d251d301..42e8678a 100644 --- a/src/battle/rulesets/modern_clean.lua +++ b/src/battle/rulesets/modern_clean.lua @@ -13,4 +13,7 @@ return { enemyUnlimitedPP = false, -- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO. hyperBeamSkipRechargeOnKO = false, + -- Gen 3+ style: poison/burn/leech seed tick in an end-of-round sweep + -- after both sides have moved. + residualAfterMove = false, } diff --git a/tests/engine/leech_seed_timing_bug784.lua b/tests/engine/leech_seed_timing_bug784.lua new file mode 100644 index 00000000..478683d8 --- /dev/null +++ b/tests/engine/leech_seed_timing_bug784.lua @@ -0,0 +1,153 @@ +-- Gen 1 Leech Seed drains right after the SEEDED mon's move, not in an +-- end-of-round sweep (#784): MainInBattleLoop calls +-- HandlePoisonBurnLeechSeed after every Execute*Move (core.asm:426-464), +-- and the drain plays the ABSORB animation from the healing side +-- (core.asm:506-517). The port ran the whole residual sweep in +-- endOfTurn, after both sides had acted, and showed no drain animation. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function newBattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 30) + battle.rng = function() return 0 end -- every roll lands: moves always hit + battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end + -- the seeded foe moves first, so its drain is observable mid-turn + battle.enemy.curStats.speed = 200 + battle.player.curStats.speed = 1 + return battle +end + +-- consume the queue the way updateQueue does, minus the presentation; +-- keeps the text and anim rows in order +local function drain(battle) + local rows = {} + for _ = 1, 400 do + local item = table.remove(battle.queue, 1) + if not item then return rows end + if item.text then rows[#rows + 1] = { text = item.text } end + if item.anim then + rows[#rows + 1] = { anim = item.anim, + attackerIsPlayer = item.attackerIsPlayer } + end + if item.fn then + battle.nextInsert = 0 + item.fn() + end + end + error("the turn queue never drained") +end + +local function indexOf(rows, pred) + for i, row in ipairs(rows) do + if pred(row) then return i end + end + return nil +end + +local function saidWith(rows, needle) + return indexOf(rows, function(r) + return r.text and r.text:find(needle, 1, true) ~= nil + end) +end + +-- --------------------------------------------------------------------- +-- the seeded foe is faster: its drain lands between the two moves +-- --------------------------------------------------------------------- +do + local battle = newBattle() + battle.enemy.leechSeeded = true + battle:resolveTurn({ id = "FIX_TACKLE", pp = 35 }) + local rows = drain(battle) + + local scratchIdx = saidWith(rows, "FIX SCRATCH") + local seedIdx = saidWith(rows, "LEECH SEED") + local tackleIdx = saidWith(rows, "FIX TACKLE") + T.check(scratchIdx ~= nil, "the foe's move announces") + T.check(seedIdx ~= nil, "the drain announces") + T.check(tackleIdx ~= nil, "the player's move announces") + T.check(scratchIdx < seedIdx, + "the drain comes right after the seeded mon's move") + T.check(seedIdx < tackleIdx, + "and before the slower mon acts, not at end of round") + + local animIdx = indexOf(rows, function(r) return r.anim == "ABSORB" end) + T.check(animIdx ~= nil, "the drain plays the ABSORB animation") + T.check(animIdx ~= nil and rows[animIdx].attackerIsPlayer == true, + "played from the healing side (hWhoseTurn flipped)") + T.check(animIdx ~= nil and animIdx > seedIdx and animIdx < tackleIdx, + "and it rides with the drain, between the two moves") +end + +-- --------------------------------------------------------------------- +-- a seeded foe at 1 HP faints to its own drain right after moving: +-- the slower player never gets a move that turn (core.asm:426-429) +-- --------------------------------------------------------------------- +do + local battle = newBattle() + battle.enemy.leechSeeded = true + battle.enemy.mon.hp = 1 + battle:resolveTurn({ id = "FIX_TACKLE", pp = 35 }) + local rows = drain(battle) + + T.check(saidWith(rows, "FIX SCRATCH") ~= nil, "the foe still moves first") + T.check(saidWith(rows, "LEECH SEED") ~= nil, "the drain still runs") + T.eq(battle.enemy.mon.hp, 0, "the drain faints the seeded foe") + T.check(saidWith(rows, "FIX TACKLE") == nil, + "the slower mon never moves after the residual faint") + T.eq(battle.result, "win", "and the battle is decided there and then") +end + +-- --------------------------------------------------------------------- +-- the modern ruleset keeps the Gen 3+ end-of-round sweep, with no +-- mid-turn drain animation +-- --------------------------------------------------------------------- +do + local battle = newBattle() + battle.ruleset = require("src.battle.rulesets.modern_clean") + battle.enemy.leechSeeded = true + battle:resolveTurn({ id = "FIX_TACKLE", pp = 35 }) + local rows = drain(battle) + + local scratchIdx = saidWith(rows, "FIX SCRATCH") + local seedIdx = saidWith(rows, "LEECH SEED") + local tackleIdx = saidWith(rows, "FIX TACKLE") + T.check(scratchIdx ~= nil and tackleIdx ~= nil and seedIdx ~= nil, + "all three beats still happen under the modern ruleset") + T.check(scratchIdx < tackleIdx and tackleIdx < seedIdx, + "but the drain waits for the end of the round") + T.check(indexOf(rows, function(r) return r.anim == "ABSORB" end) == nil, + "and no mid-turn drain animation is queued") +end + +-- --------------------------------------------------------------------- +-- an item turn spends the player's move but its residual still ticks +-- (ExecutePlayerMove rets early on wActionResultOrTookBattleTurn and +-- MainInBattleLoop calls HandlePoisonBurnLeechSeed anyway) +-- --------------------------------------------------------------------- +do + local battle = newBattle() + battle.player.mon.status = "PSN" + battle:itemUsed({}) + local rows = drain(battle) + + local scratchIdx = saidWith(rows, "FIX SCRATCH") + local poisonIdx = saidWith(rows, "hurt by poison") + T.check(scratchIdx ~= nil and poisonIdx ~= nil, + "the foe moves and the poison ticks on an item turn") + T.check(scratchIdx < poisonIdx, "the tick lands right after the foe's move") +end + +T.finish("leech seed drain timing (#784)") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 8dbd9d63..b09a9182 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1050,7 +1050,7 @@ do kb.player.mon.status = "PSN" kb.enemy.mon.hp = 0 -- the opponent was already knocked out this turn local hpBefore = kb.player.mon.hp - kb:endOfTurn() + kb:residualFor(kb.player, kb.enemy) eq(kb.player.mon.hp, hpBefore, "no residual poison on the turn the poisoned mon lands the KO") @@ -1058,7 +1058,7 @@ do local lb = BattleState.newWild(Game, "RATTATA", 5) lb.player.mon.status = "PSN" local live = lb.player.mon.hp - lb:endOfTurn() + lb:residualFor(lb.player, lb.enemy) check(lb.player.mon.hp < live, "poison still ticks while the opponent lives") end