mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 12:15:31 +02:00
more and more bugs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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 =================
|
||||
|
||||
Reference in New Issue
Block a user