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