Files
gen1recomp/tests/engine/leech_seed_timing_bug784.lua
T
johnjohto ba8ac3d143 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.
2026-08-04 12:06:21 -04:00

154 lines
6.0 KiB
Lua

-- 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)")