more and more bugs

This commit is contained in:
bryanthaboi
2026-07-24 09:24:55 -04:00
parent e62ad3f2a4
commit 3a55c0b1a5
22 changed files with 836 additions and 61 deletions
+16
View File
@@ -620,6 +620,22 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
"RED++ still has ROUTE (aliased from VIRIDIAN)")
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC")
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
do
local aliases = PaletteFX.TILE_ALIASES
local roof = aliases and aliases.CELADON_MART_ROOF
local diner = aliases and aliases.CELADON_DINER
check(roof ~= nil and diner ~= nil,
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
check(diner == roof,
"diner reuses the same lobby table-top alias as the mart roof")
local al = diner and diner[1]
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
and al.group == 5 and al.cells[5] and al.cells[6]
and al.cells[9] and al.cells[10],
"lobby table-top alias remaps block 29 cells 5/6/9/10")
end
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
do
+2 -1
View File
@@ -295,7 +295,8 @@ check(bm.screenId == "BindingsMenu",
check(#bm.items == 8, "one row per logical button")
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
and bm.items[5].label == "A" and bm.items[5].right == "Z"
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE",
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
"with no rebind the rows mirror the fixed map")
check(cbGame.save.options.bindings == nil,
"opening the screen alone writes nothing")
+44
View File
@@ -432,6 +432,50 @@ check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength,
"fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu")
popToOW()
-- ===========================================================================
-- #83: FLY/TELEPORT use CheckIfInOutsideMap (OVERWORLD + PLATEAU), so the
-- outdoor strip between Victory Road and the Elite Four / Indigo Plateau
-- building allows Fly like any other outdoor overworld map.
-- ===========================================================================
Game.save.party = { mkMon("AERODACTYL", "FLY", "TELEPORT") }
Game.save.inventory = { THUNDERBADGE = true }
ow = pushOW("INDIGO_PLATEAU", 10, 5, "down")
eq(ow.map.def.tileset, "PLATEAU", "Indigo Plateau outdoor uses PLATEAU tileset")
local pmIndigo = PartyMenu.new(Game)
Game.stack:push(pmIndigo)
frame({ "a" })
local actsIndigo = submenuActions(pmIndigo)
check(actsIndigo.fly, "FLY listed on Indigo Plateau outdoor (PLATEAU)")
check(actsIndigo.escape, "TELEPORT listed on Indigo Plateau outdoor (PLATEAU)")
popToOW()
ow = pushOW("ROUTE_23", 10, 6, "down")
eq(ow.map.def.tileset, "PLATEAU", "Route 23 uses PLATEAU tileset")
local pmR23 = PartyMenu.new(Game)
Game.stack:push(pmR23)
frame({ "a" })
check(submenuActions(pmR23).fly, "FLY listed on Route 23 (PLATEAU)")
popToOW()
-- Indoors at the lobby still blocks FLY/TELEPORT (MART tileset)
ow = pushOW("INDIGO_PLATEAU_LOBBY", 7, 8, "down")
check(ow.map.def.tileset ~= "OVERWORLD" and ow.map.def.tileset ~= "PLATEAU",
"Indigo lobby is not an outside tileset")
local pmLobby = PartyMenu.new(Game)
Game.stack:push(pmLobby)
frame({ "a" })
local actsLobby = submenuActions(pmLobby)
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
popToOW()
-- restore fainted field-move mon for the STRENGTH/SURF cases below
Game.save.party = { fainted }
Game.save.inventory = {
THUNDERBADGE = true, CASCADEBADGE = true,
RAINBOWBADGE = true, SOULBADGE = true,
}
-- STRENGTH activation from a fainted user (name text + strengthActive)
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
+97
View File
@@ -0,0 +1,97 @@
-- Regression: bike / desynced walk steps must not flash stand on land (issue #82).
--
-- walkPhase used to return 0 whenever moving was false. A step clears
-- moving on its final FixedStep tick, so the draw after landing snapped
-- to stand even when animClock was mid walk-cycle. Bike steps are 8
-- frames, so they land at animClock % 16 == 8 (walk) every tile — always
-- stuttery. Walking after a bike ride inherits a desynced animClock and
-- hit the same stand flash "sometimes."
--
-- Self-contained; run via `luajit tests/parity_bike_walk_anim.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local Player = require("src.world.Player")
local S = require("tests.harness").suite("parity bike walk anim")
local check, eq = S.check, S.eq
local function makePlayer(onBike)
local p = Player.new(Data, 5, 5, "down")
p.onBike = onBike
if onBike then
p.stepFramesCur = p.bikeStepFrames or 8
else
p.stepFramesCur = p.stepFrames or 16
end
p.animClock = 0
return p
end
local function startStep(p)
p.moving = true
p.progress = 0
p.targetX, p.targetY = p.cellX, p.cellY + 1
end
-- --- bike: every land frame mid-cycle must stay walk ---
local bike = makePlayer(true)
local landPhases = {}
for tile = 1, 4 do
startStep(bike)
local stepLen = bike.stepFramesCur
for _ = 1, stepLen do
local done = bike:update()
if done then
landPhases[#landPhases + 1] = bike:walkPhase()
eq(bike.moving, false, "bike step clears moving on land")
check(bike.stepLanded, "bike land latches stepLanded for draw")
end
end
end
-- lands at animClock 8, 16, 24, 32 → %16 = 8, 0, 8, 0
-- walk band is 4..11, so lands at 8 must be phase 1; at 0 must be phase 0
eq(landPhases[1], 1, "bike land at animClock%16==8 keeps walk pose")
eq(landPhases[2], 0, "bike land at animClock%16==0 is stand (in-band)")
eq(landPhases[3], 1, "bike land at animClock%16==8 keeps walk pose again")
eq(landPhases[4], 0, "bike land at animClock%16==0 is stand again")
-- continuous bike phases across two tiles: no forced stand in the walk band
bike = makePlayer(true)
local phases = {}
for _ = 1, 2 do
startStep(bike)
for _ = 1, bike.stepFramesCur do
bike:update()
phases[#phases + 1] = bike:walkPhase()
end
end
-- frames 4..11 of animClock are walk; across 16 ticks that is indices 4..11
for i = 4, 11 do
eq(phases[i], 1, "bike continuous walk band has no stand flash at " .. i)
end
-- idle after land: next update drops the latch → stand
bike = makePlayer(true)
startStep(bike)
for _ = 1, bike.stepFramesCur do bike:update() end
eq(bike:walkPhase(), 1, "latched land frame still walk")
bike:update()
eq(bike.stepLanded, false, "idle update clears stepLanded")
eq(bike:walkPhase(), 0, "truly idle is stand")
-- --- walk after desynced animClock (post-bike): land must not force stand ---
local walk = makePlayer(false)
walk.animClock = 8 -- leftover from a bike half-cycle
startStep(walk)
local lastPhase
for _ = 1, walk.stepFramesCur do
walk:update()
lastPhase = walk:walkPhase()
end
eq(walk.animClock % 16, 8, "desynced walk lands mid walk-cycle")
eq(lastPhase, 1, "desynced walk land keeps walk pose (no post-bike stutter)")
S.finish()
+41
View File
@@ -80,5 +80,46 @@ check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0,
GBCFX.setLevel(0)
-- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK)
check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX")
local prevSystem = love.system
love.system = { getOS = function() return "Android" end }
check(not GBCFX.isSupported(), "Android reports GBC FX unsupported")
GBCFX.setLevel(3)
eq(GBCFX.level, 0, "setLevel forces OFF on Android")
eq(GBCFX.cycle(), 0, "cycle stays OFF on Android")
local opts = { gbcfx = 4 }
check(GBCFX.applyOptions(opts) == true,
"applyOptions reports a cleared persisted level on Android")
eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android")
eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android")
check(not GBCFX.active(), "active() is false on Android")
eq(GBCFX.shader(), nil, "shader() is nil on Android")
love.system = { getOS = function() return "iOS" end }
check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported")
love.system = prevSystem
check(GBCFX.isSupported(), "support restores when OS stub is removed")
-- desktop path still applies a level after leaving the mobile gate
GBCFX.applyOptions({ gbcfx = 2 })
eq(GBCFX.level, 2, "applyOptions still sets levels on desktop")
GBCFX.setLevel(0)
-- Options menu hides the GBC FX row on Android
local OptionsMenu = require("src.ui.OptionsMenu")
love.system = { getOS = function() return "Android" end }
local om = OptionsMenu.new({
data = { rulesets = {}, constants = {} },
save = { options = {} },
stack = { pop = function() end },
input = { wasPressed = function() return false end },
modStatus = { available = {} },
})
local hasGbc = false
for _, row in ipairs(om.rows) do
if row.id == "gbcfx" then hasGbc = true end
end
check(not hasGbc, "Options menu omits GBC FX on Android")
love.system = prevSystem
-- === summary ===
S.finish()
+129
View File
@@ -0,0 +1,129 @@
-- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves.
--
-- Select marks a slot, move the cursor, Select (or A) swaps. Defaults:
-- Tab / either Shift / gamepad Back. Self-contained; also picked up by
-- tests/run_tests.lua's parity_* glob.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Input = require("src.core.Input")
local S = require("tests.harness").suite("parity move swap")
local check, eq = S.check, S.eq
local function freshGame()
local mon = Pokemon.new(Data, "NIDORAN_M", 8)
mon.moves = {
{ id = "TACKLE", pp = 35 },
{ id = "LEER", pp = 30 },
{ id = "HORN_ATTACK", pp = 25 },
{ id = "POISON_STING", pp = 35 },
}
return {
data = Data,
input = Input,
save = {
party = { mon },
player = { name = "RED" },
inventory = {},
options = {},
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
local function tapKey(battle, key)
Input:keypressed(key)
Input:step()
battle:update(0)
Input:keyreleased(key)
end
local function tapPad(battle, button)
Input:gamepadpressed(nil, button)
Input:step()
battle:update(0)
Input:gamepadreleased(nil, button)
end
-- Default Select sources all edge the logical select button.
do
Input:init()
for _, key in ipairs({ "tab", "rshift", "lshift" }) do
Input:reset()
Input:keypressed(key)
Input:step()
check(Input:wasPressed("select"), key .. " maps to select")
end
Input:reset()
Input:gamepadpressed(nil, "back")
Input:step()
check(Input:wasPressed("select"), "gamepad back maps to select")
end
-- Fight menu: Select, move, Select swaps slots 1 and 2.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
eq(battle.moveSwapIndex, 1, "first Select marks the current slot")
tapKey(battle, "down")
eq(battle.moveIndex, 2, "cursor moved to slot 2")
tapKey(battle, "tab")
check(battle.moveSwapIndex == nil, "second Select clears the mark")
eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move")
eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move")
eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync")
end
-- Same reorder via gamepad Back (SDL "back" = controller Select/View).
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapPad(battle, "back")
tapPad(battle, "dpdown")
tapPad(battle, "back")
eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1")
eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2")
end
-- A confirms a pending swap (bag-style), without starting the turn.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
tapKey(battle, "down")
tapKey(battle, "z") -- A
eq(battle.phase, "moveSelect", "A completes a pending swap without attacking")
eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1")
eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2")
end
S.finish()
+98
View File
@@ -0,0 +1,98 @@
-- Parity test: Pokemon Tower 5F purified-zone heal pad (#81).
--
-- pokered scripts/PokemonTower5F.asm PokemonTower5FDefaultScript:
-- coords (10,8)/(11,8)/(10,9)/(11,9); CheckAndSetEvent
-- EVENT_IN_PURIFIED_ZONE so the heal fires once until the player
-- leaves; HealParty -> GBFadeOutToWhite -> Delay3 x2 ->
-- GBFadeInFromWhite -> _PokemonTower5FPurifiedZoneText (no heal jingle).
--
-- Self-contained; run via `luajit tests/parity_tower_heal_pad.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity tower heal pad")
local check, eq = S.check, S.eq
local M = dofile("data/scripts/story3.lua")
local tower = M.POKEMON_TOWER_5F
check(tower ~= nil and type(tower.onStep) == "function",
"POKEMON_TOWER_5F has an onStep heal-pad trigger")
local function cmds(rows)
local out = {}
for _, row in ipairs(rows) do out[#out + 1] = row[1] end
return out
end
local function gameWith(flags)
return { save = { flags = flags or {} } }
end
local function owWith()
local ran = {}
return {
runner = {
isRunning = function() return false end,
run = function(_, rows) ran[#ran + 1] = rows end,
},
}, ran
end
-- ---- 1. pad coords + leave clears latch --------------------------------
do
local game = gameWith()
local ow, ran = owWith()
check(not tower.onStep(game, ow, 9, 8), "off-pad X does not heal")
check(not tower.onStep(game, ow, 10, 7), "off-pad Y does not heal")
eq(#ran, 0, "no script off the pad")
end
do
local game = gameWith()
local ow, ran = owWith()
check(tower.onStep(game, ow, 10, 8), "first step on (10,8) heals")
check(game.save.flags.EVENT_IN_PURIFIED_ZONE, "latches EVENT_IN_PURIFIED_ZONE")
eq(#ran, 1, "heal script runs once")
check(tower.onStep(game, ow, 11, 9),
"staying on the pad still consumes the step (BIT_NO_BATTLES)")
eq(#ran, 1, "heal does not re-fire while still on the pad")
check(not tower.onStep(game, ow, 12, 9), "stepping off clears and returns false")
check(not game.save.flags.EVENT_IN_PURIFIED_ZONE,
"EVENT_IN_PURIFIED_ZONE resets off the pad")
check(tower.onStep(game, ow, 11, 8), "re-entering the pad heals again")
eq(#ran, 2, "a fresh visit runs the heal script again")
end
-- ---- 2. all four pad tiles trigger -------------------------------------
for _, cell in ipairs({ { 10, 8 }, { 11, 8 }, { 10, 9 }, { 11, 9 } }) do
local game = gameWith()
local ow, ran = owWith()
check(tower.onStep(game, ow, cell[1], cell[2]),
("pad tile (%d,%d) heals"):format(cell[1], cell[2]))
eq(#ran, 1, ("pad tile (%d,%d) queued a script"):format(cell[1], cell[2]))
end
-- ---- 3. heal sequence matches pokered order ----------------------------
do
local game = gameWith()
local ow, ran = owWith()
tower.onStep(game, ow, 10, 9)
local rows = ran[1]
check(rows ~= nil, "heal rows captured")
local sequence = table.concat(cmds(rows), ",")
eq(sequence, "heal_party,fade,wait,wait,fade,show_text",
"Tower pad sequence is heal → fade out → Delay3×2 → fade in → text")
eq(rows[2][3], "white", "fades out to white")
eq(rows[3][2], 3, "first Delay3 is 3 frames")
eq(rows[4][2], 3, "second Delay3 is 3 frames")
eq(rows[5][3], "white", "fades in from white")
eq(rows[6][2], "_PokemonTower5FPurifiedZoneText",
"shows the purified-zone text")
for _, row in ipairs(rows) do
check(row[1] ~= "play_once", "no Music_PkmnHealed on the Tower pad")
end
end
S.finish()
+49
View File
@@ -169,4 +169,53 @@ do
"tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)")
end
-- === (4) Route 9 Bug Catcher: 4-tiles-north screen Y $fc quirk ===
-- Object 7 = SPRITE_YOUNGSTER at (22,2), STAY DOWN, OPP_BUG_CATCHER
-- (data/maps/objects/Route9.asm); header range 4 (Route9TrainerHeader6).
-- A ledge sits on (22,5); the walkable tile below it is (22,6) at cell
-- distance 4. Cell math would engage, but pokered's unsigned screen-Y
-- distance with SPRITESTATEDATA1_YPIXELS=$fc is $c0 > range<<4 ($40), so
-- the trainer must not challenge through that ledge (GitHub #76).
local ROUTE9_BUG_CATCHER = 7
local function freshRoute9(px, py, facing)
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Input:init()
OW.engaging = false
OW.emote = nil
Game.stack:push(OW, "ROUTE_9", px, py, facing)
local ow = Game.stack:top()
local trainer
for _, npc in ipairs(ow.npcs) do
if npc.def.index == ROUTE9_BUG_CATCHER then trainer = npc end
end
return ow, trainer
end
do
local ow, trainer = freshRoute9(22, 6, "up")
check(trainer ~= nil and trainer.cellX == 22 and trainer.cellY == 2,
"Route 9 Bug Catcher stands at (22,2)")
eq(trainer.facing, "down", "Bug Catcher faces down (STAY DOWN)")
local header = Data:trainerHeader("Route9", ROUTE9_BUG_CATCHER)
eq(header and header.range, 4, "Bug Catcher sight range is 4")
for _ = 1, 10 do frame(ow) end
check(not ow.engaging,
"distance 4 south of DOWN trainer: no engage (screen Y $fc quirk)")
-- same plateau, still in pixel range: distance 2 and 3 must engage
local ow2, t2 = freshRoute9(22, 4, "up")
local guard = 0
while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end
check(ow2.engaging and t2.cellY == 2,
"distance 2 on the upper plateau still engages")
local ow3 = freshRoute9(22, 3, "up")
guard = 0
while not ow3.engaging and guard < 10 do guard = guard + 1; frame(ow3) end
check(ow3.engaging, "distance 3 on the upper plateau still engages")
end
S.finish()
+112
View File
@@ -1072,6 +1072,81 @@ do
mh:performMove(mh.player, mh.enemy, { id = "DOUBLESLAP", pp = 10 })
check(hasText(mh, "Hit the enemy\n5 times!"),
"player multi-hit uses _MultiHitText")
-- #85: multi-hit replays PlayMoveAnimation per strike (pokered
-- GetPlayerAnimationType loop), interleaved with each HP drain
do
local anims, seq = 0, {}
for _, r in ipairs(mh.queue) do
if r.anim == "DOUBLESLAP" then
anims = anims + 1
seq[#seq + 1] = "anim"
elseif r.drain then
seq[#seq + 1] = "drain"
end
end
eq(anims, 5, "multi-hit queues the move animation once per hit")
eq(table.concat(seq, ","),
"anim,drain,anim,drain,anim,drain,anim,drain,anim,drain",
"multi-hit interleaves anim + drain per strike")
for _, r in ipairs(mh.queue) do
if r.anim == "DOUBLESLAP" then
check(r.hit ~= nil, "each multi-hit anim carries hit blink/sfx")
end
end
end
-- #85: crit / effectiveness reprint each strike (.moveDidNotMiss
-- before the GetPlayerAnimationType loop-back). DOUBLE_KICK is a
-- fixed 2-hit Fighting move -- SE vs Normal SNORLAX.
do
local function countText(b, s)
local n = 0
for _, it in ipairs(b.queue) do
if it.text and it.text:find(s, 1, true) then n = n + 1 end
end
return n
end
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local mhk = BattleState.newWild(Game, "SNORLAX", 40)
mhk.rng = mkseq({ 0, 255, 255 }) -- hit, no crit, max roll
mhk:performMove(mhk.player, mhk.enemy, { id = "DOUBLE_KICK", pp = 10 })
eq(countText(mhk, "It's super\neffective!"), 2,
"multi-hit prints effectiveness once per strike")
local seq = {}
for _, r in ipairs(mhk.queue) do
if r.anim == "DOUBLE_KICK" then seq[#seq + 1] = "anim"
elseif r.drain then seq[#seq + 1] = "drain"
elseif r.text == "It's super\neffective!" then seq[#seq + 1] = "se"
elseif r.text and r.text:find("times!", 1, true) then seq[#seq + 1] = "count"
end
end
eq(table.concat(seq, ","), "anim,drain,se,anim,drain,se,count",
"multi-hit queues SE text between strikes, count after")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local Events = require("src.mods.Events")
local savedE, savedH = Runtime.events, Runtime.hooks
local hooks = Hooks.new()
Runtime.install(Events.new(), hooks)
local unsub = hooks:wrap("battle.crit", function() return true end)
local mhc = BattleState.newWild(Game, "SNORLAX", 40)
mhc.rng = mkseq({ 0, 255 }) -- acc, damage; crit from the hook
mhc:performMove(mhc.player, mhc.enemy, { id = "DOUBLE_KICK", pp = 10 })
eq(countText(mhc, "Critical hit!"), 2,
"multi-hit prints Critical hit! once per strike")
local cseq = {}
for _, r in ipairs(mhc.queue) do
if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim"
elseif r.drain then cseq[#cseq + 1] = "drain"
elseif r.text == "Critical hit!" then cseq[#cseq + 1] = "crit"
elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se"
end
end
eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,crit,se",
"crit then effectiveness follow each multi-hit drain")
unsub()
Runtime.install(savedE, savedH)
end
Game.save.party = { Pokemon.new(Data, "SNORLAX", 30) }
local mh2 = BattleState.newWild(Game, "RATTATA", 5)
mh2.rng = mkseq({ 7, 0, 255, 255 })
@@ -2611,6 +2686,43 @@ do
tostring(c[3]), tostring(c[4])))
end
end
-- == issue #4: ledge hop countdown is fixed-step, not draw-rate ==
-- hopFrames used to decrement inside Player:draw, so >59fps ended the
-- arc early and <59fps stretched it. Countdown belongs in update().
do
local Player = require("src.world.Player")
local p = Player.new(Data, 5, 6, "down")
p.hopFrames, p.hopTotal = 32, 32
p:draw(0, 0)
p:draw(0, 0)
p:draw(0, 0)
eq(p.hopFrames, 32, "draw does not consume hopFrames")
for _ = 1, 10 do p:update() end
eq(p.hopFrames, 22, "ten fixed updates consume ten hopFrames")
for _ = 1, 22 do p:update() end
eq(p.hopFrames, 0, "hop expires after hopTotal fixed updates")
p:update()
eq(p.hopFrames, 0, "hopFrames stays at 0 once expired")
end
-- == issue #4: tile anim tick accumulates wall-clock into 60Hz steps ==
do
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.setSpinning(true)
local before = TileRenderer.spinBlurActive()
for _ = 1, 8 do TileRenderer.tick(1 / 60) end
check(before ~= TileRenderer.spinBlurActive(),
"tick(1/60) advances water/spinner clock at fixed 60Hz")
local mid = TileRenderer.spinBlurActive()
TileRenderer.tick(1 / 120)
eq(TileRenderer.spinBlurActive(), mid,
"sub-frame dt does not advance the tile anim clock")
TileRenderer.tick(1 / 120)
-- second half-frame completes one step; phase may or may not flip
-- (8-tick blur period), but the clock must have accepted the step
TileRenderer.setSpinning(false)
end
end
-- ================= BUGS.md batch: border-tree =================