From 5775f5aa10156911c3cf388e394481c09256f7fd Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 08:49:57 +0100 Subject: [PATCH] Retire native battle insertion cursors and completed HP holds --- src/battle/BattleState.lua | 12 ++++- tests/engine/battle_queue_lifecycle.lua | 59 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/engine/battle_queue_lifecycle.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9688aa08f..ada4d84c9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2462,6 +2462,10 @@ function BattleState:stepHPDrain() b.draining = nil b.drainHold = Timing.hpDrainClosingFrames(b.isPlayer == true) - 1 busy = true + elseif b.drainHold == 0 then + -- The last closing frame was consumed above. Retire its marker only + -- on the same idle frame that already returned false before this fix. + b.drainHold = nil end end end @@ -2708,10 +2712,16 @@ function BattleState:updateQueue() end if not self.current then local item = table.remove(self.queue, 1) - if not item then return false end + if not item then + -- A UI completion can insert rows outside an executing fn item too. + -- No insertion cursor survives the queue it belongs to. + self.nextInsert = nil + return false + end if item.fn then self.nextInsert = 0 -- sayNext inserts right after this item item.fn() + self.nextInsert = nil self.current = nil return true end diff --git a/tests/engine/battle_queue_lifecycle.lua b/tests/engine/battle_queue_lifecycle.lua new file mode 100644 index 000000000..359b78516 --- /dev/null +++ b/tests/engine/battle_queue_lifecycle.lua @@ -0,0 +1,59 @@ +-- Native insertion cursors and HP holds belong to one queue/drain, not to +-- later command menus. Exercise the real writers and retain their frame cost. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local original = Version.get() +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local Save = require("src.core.SaveData") +local Timing = require("src.core.Timing") +for _, version in ipairs({ "red", "gold", "silver", "crystal", "firered", "emerald" }) do + Version.set(version) + local data = T.fixtures.fresh() + require("src.render.Font").load(data) + local save = Save.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local game = { data = data, save = save, stack = { top = function() end } } + local battle = Battle.newWild(game, "FIXMON_C", 40) + battle.queue, battle.current = {}, nil + local seen = {} + battle:act(function() + T.eq(battle.nextInsert, 0, version .. " callback owns insertion cursor") + battle:actNext(function() + seen[#seen + 1] = "nested" + battle:actNext(function() seen[#seen + 1] = "inner" end) + end) + battle:actNext(function() seen[#seen + 1] = "sibling" end) + end) + battle:act(function() seen[#seen + 1] = "tail" end) + for _ = 1, 5 do + T.eq(battle:updateQueue(), true, version .. " one native callback per row") + T.eq(battle.nextInsert, nil, version .. " callback releases insertion cursor") + end + T.same(seen, { "nested", "inner", "sibling", "tail" }, version .. " nested insertion order") + T.eq(battle:updateQueue(), false, version .. " drained queue is idle") + -- UI completions may call *Next outside a fn. This cursor must also retire + -- when their final row completes, without changing insertion order. + battle.nextInsert = 0 + battle:waitNext(1) + for _ = 1, 5 do battle:updateQueue() end + T.eq(battle.nextInsert, nil, version .. " UI-inserted queue releases cursor") + for _, actor in ipairs({ battle.player, battle.enemy }) do + -- One HP per bar pixel avoids the enemy's existing zero-cost trailing + -- point batching; this regression concerns retirement, not that rounding. + actor.mon.stats.hp, actor.mon.hp, actor.shownHP = 48, 40, 40 + local from = actor.mon.hp + actor.mon.hp = from - 2 + local frames = 0 + while battle:stepHPDrain() and frames < 1000 do frames = frames + 1 end + T.eq(frames, Timing.hpDrainFrames(from, actor.mon.hp, actor.mon.stats.hp, + actor.isPlayer == true), version .. " exact native HP timing") + T.eq(actor.shownHP, actor.mon.hp, version .. " actual HP caught up") + T.eq(actor.draining, nil, version .. " drain retired") + T.eq(actor.drainHold, nil, version .. " completed closing hold retired") + T.eq(battle:stepHPDrain(), false, version .. " following frame remains idle") + end +end +Version.set(original) +T.finish("native battle transient lifecycle")