mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
185 lines
7.4 KiB
Lua
185 lines
7.4 KiB
Lua
-- Driver: a multi-hit move must step the enemy HP bar down once per strike
|
|
-- (#394), not empty it on hit 1 and freeze for the rest. pokered
|
|
-- ApplyDamageToEnemyPokemon (engine/battle/core.asm:4684-4727) subtracts
|
|
-- wDamage then runs UpdateHPBar2 inside the wNumAttacksLeft loop. Machine
|
|
-- half: tests/engine/multihit_hp_drain.lua. Never under POKEPORT_SPEED:
|
|
-- fast-forward desynchronizes the per-strike damage sound from the bar.
|
|
-- POKEPORT_DRIVER=tests/drivers/multihit_bug394_test.lua POKEPORT_IDENTITY=bug394 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
|
return function(game)
|
|
local U = dofile("tests/drivers/util.lua")
|
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local BattleState = require("src.battle.BattleState")
|
|
|
|
local MOVE = "DOUBLESLAP"
|
|
local pass, fail = 0, 0
|
|
local function check(label, ok)
|
|
if ok then pass = pass + 1 else fail = fail + 1 end
|
|
U.log(ok and "PASS" or "FAIL", label)
|
|
return ok
|
|
end
|
|
|
|
local moveDef = game.data.moves[MOVE]
|
|
check(MOVE .. " resolves in the move table", moveDef ~= nil)
|
|
check("...as a 2-to-5 strike move",
|
|
moveDef ~= nil and moveDef.effect == "TWO_TO_FIVE_ATTACKS_EFFECT")
|
|
check("SNORLAX resolves (a big HP pool, so each strike is visible)",
|
|
game.data.pokemon.SNORLAX ~= nil)
|
|
check("the damage sound is in the generated audio",
|
|
game.data.audio and game.data.audio.sfx
|
|
and game.data.audio.sfx.Damage ~= nil)
|
|
|
|
local vol = game.save.options and game.save.options.sfxVol
|
|
if vol == 0 then
|
|
U.log("sfxVol is 0: the per-strike damage hits will be SILENT,",
|
|
"raise it in OPTION first")
|
|
else
|
|
U.log("sfxVol", tostring(vol), "-- expect one damage hit per strike")
|
|
end
|
|
|
|
-- :L50 so a SNORLAX counterattack cannot end the run before the handoff
|
|
local lead = Pokemon.new(game.data, "BULBASAUR", 50)
|
|
lead.moves = { { id = MOVE, pp = 10, maxPP = 10 } } -- one slot, no mis-pick
|
|
game.save.party = { lead }
|
|
|
|
-- offscreen scratch battle: the queue a multi-hit turn builds, with no
|
|
-- animation timing in the way. Each drain row has to name its target and
|
|
-- carry the HP left after its own strike; unpinned rows were the bug.
|
|
do
|
|
local seqIndex = 0
|
|
local scratch = BattleState.newWild(game, "SNORLAX", 30)
|
|
scratch.onFinish = function() end
|
|
scratch.rng = function(_, hi) -- 5 hits, hit, no crit, max damage roll
|
|
seqIndex = seqIndex + 1
|
|
local scripted = ({ 7, 0, 255, 255 })[seqIndex]
|
|
return scripted ~= nil and scripted or hi
|
|
end
|
|
local startHP = scratch.enemy.mon.hp
|
|
scratch:performMove(scratch.player, scratch.enemy, { id = MOVE, pp = 10 })
|
|
local stops, anims = {}, 0
|
|
for _, row in ipairs(scratch.queue) do
|
|
if row.drain then stops[#stops + 1] = row
|
|
elseif row.anim == MOVE then anims = anims + 1 end
|
|
end
|
|
check(("%d strikes queued %d animations"):format(#stops, anims),
|
|
#stops > 1 and anims == #stops)
|
|
local stepped, named = true, true
|
|
local prev = startHP
|
|
local shownStops = {}
|
|
for _, row in ipairs(stops) do
|
|
named = named and row.battler == scratch.enemy
|
|
stepped = stepped and type(row.stopAt) == "number" and row.stopAt < prev
|
|
prev = row.stopAt or prev
|
|
shownStops[#shownStops + 1] = tostring(row.stopAt)
|
|
end
|
|
check("every drain row names the enemy", named)
|
|
check("and stops on its own strike's HP, one step at a time", stepped)
|
|
check("the last stop is the post-turn HP", prev == scratch.enemy.mon.hp)
|
|
U.log(("enemy HP %d -> stops: %s"):format(startHP,
|
|
table.concat(shownStops, ", ")))
|
|
end
|
|
|
|
-- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the cell is read
|
|
-- off the loaded map so a map edit degrades to another walkable cell
|
|
-- instead of a wall.
|
|
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
|
U.wait(10)
|
|
local map = game.overworld.map
|
|
if not map:isWalkableCell(5, 5) then
|
|
local fx, fy
|
|
for cy = 0, map.heightCells - 1 do
|
|
for cx = 0, map.widthCells - 1 do
|
|
if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
|
|
end
|
|
if fx then break end
|
|
end
|
|
if fx then
|
|
U.teleport(game, "ROUTE_1", fx, fy, "down")
|
|
U.wait(10)
|
|
end
|
|
end
|
|
local ow = game.overworld
|
|
check("player stands on a walkable ROUTE_1 cell",
|
|
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
|
|
|
local function mashUntil(cond, max)
|
|
for _ = 1, max or 120 do
|
|
if cond() then return true end
|
|
U.tap(game, "a")
|
|
U.wait(4)
|
|
end
|
|
return cond()
|
|
end
|
|
|
|
local function newFight()
|
|
local battle = BattleState.newWild(game, "SNORLAX", 30)
|
|
battle.onFinish = function() end
|
|
game.overworld:pushBattle(battle)
|
|
U.wait(220) -- the send-out intro plays before the menu is reachable
|
|
mashUntil(function() return battle.phase == "menu" end)
|
|
return battle
|
|
end
|
|
|
|
-- ---- scripted run: watch the bar across the strikes ---------------------
|
|
local battle = newFight()
|
|
check("the wild battle reached its FIGHT menu", battle.phase == "menu")
|
|
U.shot(game, DIR .. "/bug394_menu.png")
|
|
|
|
U.tap(game, "a") -- FIGHT
|
|
U.wait(16)
|
|
U.tap(game, "a") -- the only move slot
|
|
U.wait(8)
|
|
|
|
-- sample on the falling edge of battle.draining, a frame at a time: a
|
|
-- coarser poll can miss a strike's drain entirely and read the next one's
|
|
-- rest twice. Rows that leave the enemy bar where it was (the foe's own
|
|
-- turn drains the player bar) and the full-bar reset a finished battle
|
|
-- does are skipped. A is tapped only every eighth frame, to walk the
|
|
-- queue's text rows without eating a whole drain.
|
|
local seen, wasDraining = {}, false
|
|
local lastShown = battle.enemy.shownHP or battle.enemy.mon.hp
|
|
for frame = 1, 2400 do
|
|
local draining = battle.draining ~= nil
|
|
if wasDraining and not draining then
|
|
local shown = battle.enemy.shownHP or battle.enemy.mon.hp
|
|
if shown < lastShown - 0.5 then
|
|
lastShown = shown
|
|
seen[#seen + 1] = shown
|
|
U.shot(game, DIR .. ("/bug394_hit%d.png"):format(#seen))
|
|
U.log(("after strike %d the enemy bar rests on %.0f HP, model HP %d")
|
|
:format(#seen, shown, battle.enemy.mon.hp))
|
|
end
|
|
end
|
|
wasDraining = draining
|
|
if not draining and battle.phase == "menu" and #battle.queue == 0 then break end
|
|
if not draining and frame % 8 == 0 then U.tap(game, "a") end
|
|
U.wait(1)
|
|
end
|
|
local rests = {}
|
|
for i, hp in ipairs(seen) do rests[i] = ("%.0f"):format(hp) end
|
|
U.log(("enemy bar rests: %s"):format(table.concat(rests, ", ")))
|
|
check(("more than one strike moved the enemy bar (%d)"):format(#seen), #seen > 1)
|
|
check("the first strike did not drain the whole turn's damage (#394)",
|
|
#seen > 1 and seen[1] > seen[#seen])
|
|
|
|
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
|
|
|
-- ---- re-arm and hand the pad over --------------------------------------
|
|
lead.hp = lead.stats.hp
|
|
lead.status = nil
|
|
lead.moves[1].pp = lead.moves[1].maxPP
|
|
local handoff = newFight()
|
|
U.log("A fresh SNORLAX is waiting on FIGHT with DOUBLESLAP in the only slot.")
|
|
U.log("Press A twice and watch the enemy bar: it should tick down a bit on")
|
|
U.log("every strike, in step with the replayed slap and its damage hit, with")
|
|
U.log("the HP number dropping each time. Before #394 the whole bar emptied on")
|
|
U.log("slap one and then sat frozen for the rest.")
|
|
if handoff.phase ~= "menu" then
|
|
U.log("(the menu did not come back on its own: mash A to reach FIGHT)")
|
|
end
|
|
|
|
while true do
|
|
coroutine.yield()
|
|
end
|
|
end
|