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.
This commit is contained in:
johnjohto
2026-08-04 12:06:21 -04:00
parent 1820f411ae
commit ba8ac3d143
5 changed files with 237 additions and 3 deletions
+75 -1
View File
@@ -1889,6 +1889,9 @@ function BattleState:update(dt)
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) 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) self:act(function() self:endOfTurn() end)
elseif choice == "fight" then elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move -- 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) self:act(function() self:endOfTurn() end)
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() function BattleState:endOfTurn()
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed -- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual -- 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 -- that sets the flag also zeroes the counter, so a stale value is
-- unobservable (a switch or cure downgrades Toxic to plain poison). -- unobservable (a switch or cure downgrades Toxic to plain poison).
self.sideToxic = self.sideToxic or {} 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 -- a battler whose opponent was already knocked out by a move this turn
-- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the -- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
-- move faints the target); snapshot before residual so one side's -- 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 }, for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
{ self.enemy, self.player, "enemy", playerAlive } }) do { self.enemy, self.player, "enemy", playerAlive } }) do
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4] 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) local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
@@ -2397,6 +2445,9 @@ function BattleState:endOfTurn()
self:onFaint(b) self:onFaint(b)
end end
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 -- CheckNumAttacksLeft (core.asm:683-697): a trapping counter that
-- hit 0 this turn releases its bit only now, at the end of the turn -- hit 0 this turn releases its bit only now, at the end of the turn
if b.trappingTurns and b.trappingTurns <= 0 then if b.trappingTurns and b.trappingTurns <= 0 then
@@ -3181,6 +3232,12 @@ function BattleState:executeAction(user, target, action)
run() run()
-- after announce/anim/effect text (pokered DrawHUDsAndHPBars) -- after announce/anim/effect text (pokered DrawHUDsAndHPBars)
self:actNext(function() self:syncShownStatus() end) 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 end
-- Sleep / confusion onomatopoeia from Check*StatusConditions -- Sleep / confusion onomatopoeia from Check*StatusConditions
@@ -4245,6 +4302,9 @@ function BattleState:tryRun()
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) 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) self:act(function() self:endOfTurn() end)
end end
end end
@@ -4267,6 +4327,11 @@ function BattleState:itemUsed(messages)
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) 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) self:act(function() self:endOfTurn() end)
end end
@@ -4448,6 +4513,9 @@ function BattleState:throwBall(ball)
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) 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) self:act(function() self:endOfTurn() end)
return return
end end
@@ -4466,6 +4534,9 @@ function BattleState:throwBall(ball)
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) 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) self:act(function() self:endOfTurn() end)
return return
end end
@@ -4492,6 +4563,9 @@ function BattleState:throwBall(ball)
self:act(function() self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction()) self:executeAction(self.enemy, self.player, self:enemyAction())
end) 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) self:act(function() self:endOfTurn() end)
end end
end) end)
+4
View File
@@ -19,4 +19,8 @@ return {
-- Gen 1 Hyper Beam: no recharge when the target faints (or its -- Gen 1 Hyper Beam: no recharge when the target faints (or its
-- substitute breaks). Set false to always recharge like Gen 2+. -- substitute breaks). Set false to always recharge like Gen 2+.
hyperBeamSkipRechargeOnKO = true, 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,
} }
+3
View File
@@ -13,4 +13,7 @@ return {
enemyUnlimitedPP = false, enemyUnlimitedPP = false,
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO. -- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
hyperBeamSkipRechargeOnKO = false, hyperBeamSkipRechargeOnKO = false,
-- Gen 3+ style: poison/burn/leech seed tick in an end-of-round sweep
-- after both sides have moved.
residualAfterMove = false,
} }
+153
View File
@@ -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)")
+2 -2
View File
@@ -1050,7 +1050,7 @@ do
kb.player.mon.status = "PSN" kb.player.mon.status = "PSN"
kb.enemy.mon.hp = 0 -- the opponent was already knocked out this turn kb.enemy.mon.hp = 0 -- the opponent was already knocked out this turn
local hpBefore = kb.player.mon.hp local hpBefore = kb.player.mon.hp
kb:endOfTurn() kb:residualFor(kb.player, kb.enemy)
eq(kb.player.mon.hp, hpBefore, eq(kb.player.mon.hp, hpBefore,
"no residual poison on the turn the poisoned mon lands the KO") "no residual poison on the turn the poisoned mon lands the KO")
@@ -1058,7 +1058,7 @@ do
local lb = BattleState.newWild(Game, "RATTATA", 5) local lb = BattleState.newWild(Game, "RATTATA", 5)
lb.player.mon.status = "PSN" lb.player.mon.status = "PSN"
local live = lb.player.mon.hp 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") check(lb.player.mon.hp < live, "poison still ticks while the opponent lives")
end end