more buggies

This commit is contained in:
bryanthaboi
2026-07-24 09:00:19 -04:00
parent 5312e34997
commit b40df05d2a
25 changed files with 1405 additions and 196 deletions
+3
View File
@@ -52,6 +52,9 @@ stub.graphics = {
push = noop, pop = noop, translate = noop, scale = noop,
rotate = noop, origin = noop, setShader = noop, setScissor = noop,
getDimensions = function() return 640, 576 end,
-- dpi=1 desktop default; issue #87 tests override these for Android density
getPixelDimensions = function() return 640, 576 end,
getDPIScale = function() return 1 end,
}
stub.math = {
+2
View File
@@ -128,6 +128,8 @@ love.graphics = {
translate = noop, scale = noop, rotate = noop, origin = noop,
setScissor = noop, getColor = function() return 1, 1, 1, 1 end,
getDimensions = function() return 640, 576 end,
getPixelDimensions = function() return 640, 576 end,
getDPIScale = function() return 1 end,
}
-- Fresh copies of the modules that cache a compiled shader or a page set
+49 -1
View File
@@ -21,12 +21,17 @@ check(hof and type(hof.onEnter) == "function", "HALL_OF_FAME.onEnter is a functi
local champ = init.get("CHAMPIONS_ROOM")
check(champ ~= nil, "CHAMPIONS_ROOM map script registered")
check(champ and type(champ.onEnter) == "function",
"CHAMPIONS_ROOM.onEnter is a function (forced rival entrance)")
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists")
-- (2) the rival cutscene rows contain the pokered beats in order
rows = rows or {}
local preds = {
{ "play_music Music_Cities1 (OakArrives / Music_Cities1AlternateTempo)",
function(r) return r[1] == "play_music" and r[2] == "Music_Cities1"
and type(r[3]) == "table" and r[3].keep == true end },
{ "show_object CHAMPIONSROOM_OAK",
function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end },
{ "move_npc(2,'up',5) OakEntranceAfterVictoryMovement",
@@ -82,7 +87,13 @@ check(champToHof, "CHAMPIONS_ROOM has a warp up into HALL_OF_FAME")
-- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and
-- queues (does not directly run) the room cutscene.
local queued
local fakeOw = { queueScript = function(self, script, extra) queued = script; self.pendingScript = { script = script } end }
local fakeOw = {
map = { def = { signs = {} }, widthCells = 10, signAt = {} },
queueScript = function(self, script, extra)
queued = script
self.pendingScript = { script = script }
end,
}
local fakeGame = { save = { pendingHallOfFame = true } }
hof.onEnter(fakeGame, fakeOw)
check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set")
@@ -102,4 +113,41 @@ fakeGame.save.pendingHallOfFame = false
hof.onEnter(fakeGame, fakeOw)
check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed")
-- (6) Champions Room forced entrance (ChampionsRoomPlayerEntersScript):
-- from Lance (y=7), queue RivalEntrance walk then the rival battle script.
local champQueued = {}
local champOw = {
player = { cellX = 3, cellY = 7 },
npcs = { { def = { name = "CHAMPIONSROOM_RIVAL" } } },
queueScript = function(_, script, extra)
champQueued[#champQueued + 1] = { script = script, extra = extra }
end,
}
local champGame = { save = { flags = {} } }
champ.onEnter(champGame, champOw)
eq(#champQueued, 2, "CHAMPIONS_ROOM.onEnter queues entrance walk + rival script")
local walk = champQueued[1] and champQueued[1].script
check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 1
and walk[2][1] == "move_player" and walk[2][2] == "right" and walk[2][3] == 1
and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 3,
"entrance walk is RivalEntrance_RLEMovement (up 1, right 1, up 3)")
check(champQueued[2] and champQueued[2].script == rows,
"second queue is TEXT_CHAMPIONSROOM_RIVAL script rows")
check(champQueued[2] and champQueued[2].extra
and champQueued[2].extra.npc == champOw.npcs[1],
"rival script receives the CHAMPIONSROOM_RIVAL npc")
-- already beaten this run: no forced entrance
champQueued = {}
champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = true
champ.onEnter(champGame, champOw)
eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter idle after champion beaten this run")
-- Hall of Fame return (y=0) must not re-trigger even if the run flag is clear
champQueued = {}
champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil
champOw.player.cellY = 0
champ.onEnter(champGame, champOw)
eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter ignores Hall of Fame return landing")
S.finish()
+26 -1
View File
@@ -221,10 +221,35 @@ do
check(fe.chooser == nil, "enemy Mimic never opens a chooser")
eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK",
"enemy Mimic copies a random player move immediately")
eq(tbe.enemy.curMoves[1].pp, 9, "enemy Mimic also keeps the slot's PP")
-- Gen 1 never decrements enemy PP, so the copied move inherits Mimic's
-- full remaining PP (still 10). Player Mimic would leave 9.
eq(tbe.enemy.curMoves[1].pp, 10, "enemy Mimic keeps full slot PP (no enemy drain)")
check(fe.anim and hasText(fe, "learned"),
"enemy Mimic still plays the animation and learned text")
-- === #94: gen1_faithful enemies never deplete PP; player still does ===
do
local tb = freshBattle()
check(tb.ruleset.enemyUnlimitedPP,
"default ruleset grants enemy unlimited PP")
local enemyMove = { id = "TACKLE", pp = 5 }
local playerMove = { id = "TACKLE", pp = 5 }
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.enemy, tb.player, enemyMove)
eq(enemyMove.pp, 5, "gen1_faithful: enemy move PP is not decremented")
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.player, tb.enemy, playerMove)
eq(playerMove.pp, 4, "gen1_faithful: player move PP still decrements")
-- modern_clean tracks enemy PP (Gen 2+ style)
local modern = require("src.battle.rulesets.modern_clean")
tb.ruleset = modern
local enemyModern = { id = "TACKLE", pp = 5 }
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.enemy, tb.player, enemyModern)
eq(enemyModern.pp, 4, "modern_clean: enemy move PP decrements")
end
-- link battle: the player's Mimic rolls random too (no chooser)
local tbl = freshBattle()
tbl.kind = "link"
+31 -3
View File
@@ -115,11 +115,39 @@ do
end
-- === STRUGGLE fallback when nothing is usable ===
-- modern_clean / no unlimited flag: empty PP forces Struggle.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} } })
check(pick and pick.struggle and pick.id == "STRUGGLE", "STRUGGLE fallback when no PP")
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} },
ruleset = { enemyUnlimitedPP = false },
})
check(pick and pick.struggle and pick.id == "STRUGGLE",
"STRUGGLE fallback when no PP (enemy PP tracked)")
end
-- gen1_faithful: AI ignores PP; a 0-PP move is still selectable.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} },
ruleset = { enemyUnlimitedPP = true },
})
eq(pick and pick.id, "TACKLE",
"gen1_faithful: enemy still picks 0-PP moves (no Struggle)")
end
-- Disable with unlimited PP: sole disabled move still yields Struggle.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 10 } }, disabledSlot = 1 }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = {}, data = Data,
ruleset = { enemyUnlimitedPP = true },
})
check(pick and pick.struggle and pick.id == "STRUGGLE",
"gen1_faithful: Struggle only when every move is disabled")
end
-- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) ===
+68
View File
@@ -0,0 +1,68 @@
-- Parity test (#96): blackouts must not play the Dig/Teleport EnterMapAnim.
--
-- pret HandleBlackOut fades to black and SpecialEnterMap's without setting
-- BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap skips EnterMapAnim. Dig,
-- Teleport and Escape Rope go through HandleFlyWarpOrDungeonWarp and do
-- spin. warpToHealPoint used to always set arriveWarp="teleport", so a
-- loss (notably Elite Four -> Indigo lobby) rematerialized with the
-- teleport spin instead of a plain whiteout warp.
-- Self-contained; run via `luajit tests/parity_blackout_warp.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 S = require("tests.harness").suite("parity blackout warp")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 5) }
Game.save.party[1].hp = 0
Game.save.lastHeal = { map = "INDIGO_PLATEAU_LOBBY", x = 7, y = 6,
outdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 } }
Game.stack:push(OW, "LORELEIS_ROOM", 4, 5, "up")
local ow = Game.stack:top()
Game.overworld = ow
local captured
local realStart = ow.startWarpTo
ow.startWarpTo = function(self, mapId, x, y, facing, onDone, opts)
captured = self.arriveWarp
-- skip Transition; only the arrive flag matters here
self.arriveWarp = nil
self.transitioning = false
end
-- Battle blackout (afterBattle -> warpToHealPoint): no EnterMapAnim.
captured = "sentinel"
ow:afterBattle("lose", { oppClass = "OPP_LORELEI" })
eq(captured, nil,
"Elite Four blackout does not set arriveWarp=teleport (#96)")
-- Poison / field blackout path uses the same helper with no opts.
captured = "sentinel"
ow.arriveWarp = nil
ow:warpToHealPoint()
eq(captured, nil, "plain warpToHealPoint has no teleport arrive FX")
-- Dig / Teleport / Escape Rope keep EnterMapAnim.
captured = "sentinel"
ow:warpToHealPoint(nil, { arrive = "teleport" })
eq(captured, "teleport",
"escape warps still request EnterMapAnim on arrival")
ow.startWarpTo = realStart
S.finish()
+157
View File
@@ -0,0 +1,157 @@
-- Parity: Dig / Fly semi-invulnerable pic hide (#100).
-- Dig charge SLIDE_DOWN hides the user; a cancelled Dig release (miss /
-- type immunity) must restore the pic; a successful Dig release keeps the
-- user hidden through the dirt subanim then emerges via SE_SLIDE_MON_UP
-- (not a cyclic bounce). Self-contained: `luajit tests/parity_dig_pic.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 Font = require("src.render.Font")
Font.load(Data)
local S = require("tests.harness").suite("parity dig pic")
local check, eq = S.check, S.eq
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 makeGame(species, level, moves)
local save = SaveData.newGame()
local mon = Pokemon.new(Data, species, level)
mon.moves = moves
save.party = { mon }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function() return true end } }
end
local function pumpToMenu(battle)
local kinds, anims = {}, {}
local guard = 0
while guard < 20000 do
guard = guard + 1
battle.frame = (battle.frame or 0) + 1
battle:updateFx()
local pf = battle.picFx and battle.picFx[battle.player]
if pf and pf.kind and not kinds[pf.kind] then
kinds[pf.kind] = true
end
if battle.animPlaying and battle.animName
and anims[#anims] ~= battle.animName then
anims[#anims + 1] = battle.animName
end
if not battle:updateQueue() then
if battle.phase == "messages" and battle.afterQueue == "menu"
and not battle.animPlaying and not battle.current
and #battle.queue == 0 then
battle.phase = "menu"
break
end
if battle.phase == "menu" then break end
if not battle.animPlaying and not battle.current
and #battle.queue == 0 then
if battle.afterQueue == "menu" then battle.phase = "menu" end
break
end
end
end
for _ = 1, 200 do
battle.frame = battle.frame + 1
battle:updateFx()
end
return kinds, anims
end
local function picHidden(battle)
local pf = battle.picFx and battle.picFx[battle.player]
return pf and pf.hidden or false
end
-- Dig charge hides; Dig miss on release restores the pic (#100 vanish).
do
local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } })
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.player.curMoves = game.save.party[1].moves
local dig = battle.player.curMoves[1]
battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end
battle.rng = function(a) return a or 0 end
battle:resolveTurn(dig)
pumpToMenu(battle)
check(battle.player.invulnerable == true, "Dig charge sets invulnerable")
check(picHidden(battle), "Dig charge leaves the user pic hidden")
battle.rng = function(a, b)
if a == 0 and b == 255 then return 255 end -- force Dig accuracy miss
return a or 0
end
battle:resolveTurn(dig)
local kinds, anims = pumpToMenu(battle)
check(not picHidden(battle),
"Dig miss on release restores the user pic (#100)")
check(not kinds.bounce, "Dig release miss never starts a bounce pic fx")
local sawDig = false
for _, name in ipairs(anims) do
if name == "DIG" then sawDig = true end
end
check(not sawDig, "Dig miss cancels the DIG release anim")
end
-- Dig hit: stay hidden through DIG start, emerge via slideUp (not bounce).
do
local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } })
local battle = BattleState.newWild(game, "SNORLAX", 40)
battle.player.curMoves = game.save.party[1].moves
local dig = battle.player.curMoves[1]
battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end
battle.rng = function(a) return a or 0 end
battle:resolveTurn(dig)
pumpToMenu(battle)
battle:resolveTurn(dig)
local hiddenAtDigStart = nil
local kinds = {}
local guard = 0
while guard < 20000 do
guard = guard + 1
battle.frame = (battle.frame or 0) + 1
battle:updateFx()
if battle.animPlaying and battle.animName == "DIG"
and hiddenAtDigStart == nil then
-- right after DIG row starts (resetPicFx already ran)
hiddenAtDigStart = picHidden(battle)
end
local pf = battle.picFx and battle.picFx[battle.player]
if pf and pf.kind then kinds[pf.kind] = true end
if not battle:updateQueue() then
if battle.phase == "messages" and battle.afterQueue == "menu"
and not battle.animPlaying and not battle.current
and #battle.queue == 0 then
battle.phase = "menu"
break
end
if battle.phase == "menu" then break end
if not battle.animPlaying and not battle.current
and #battle.queue == 0 then
if battle.afterQueue == "menu" then battle.phase = "menu" end
break
end
end
end
for _ = 1, 200 do
battle.frame = battle.frame + 1
battle:updateFx()
end
check(hiddenAtDigStart == true,
"DIG release keeps the digger hidden until SE_SLIDE_MON_UP")
check(kinds.slideUp == true, "Dig release uses slideUp emerge")
check(not kinds.bounce, "Dig release must not bounce (#100)")
check(not picHidden(battle), "Dig hit leaves the user pic shown")
end
S.finish()
+16
View File
@@ -113,6 +113,22 @@ eq(#game2.save.hallOfFame, 1, "winning team recorded (SaveHallOfFameTeams)")
local HallOfFame = require("src.ui.HallOfFame")
check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed")
-- Gen1 layout (issue #102): pic rests at hlcoord (12,5); mon phase starts
-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone)
local hofUi = stack2:top()
eq(hofUi.phase, "mons", "induction opens on the mon showcase phase")
eq(hofUi.scrollX < 12 * 8, true, "front pic starts off-screen left of (12,5)")
-- drive past the scroll so the info box is armed
local scrollGuard = 0
while hofUi.scrollX < 12 * 8 and scrollGuard < 200 do
scrollGuard = scrollGuard + 1
hofUi:update(1 / 60)
end
eq(hofUi.scrollX, 12 * 8, "front pic settles at hlcoord (12,5)")
eq(hofUi.showHofBanner, false, "bottom HALL OF FAME banner waits for the 80-frame hold")
check(hofUi.timer == 80 or hofUi.timer < 80,
"info hold uses the pokered 80 DelayFrames window")
-- drive induction + full credits with A held (pages are unskippable; A
-- only advances the induction and the final THE END wait)
pressed.a = true
+141
View File
@@ -0,0 +1,141 @@
-- Parity test: Lance room walk-in stays on the floor, and defeat dialogue
-- includes the rival-became-champion after-battle text.
--
-- Sources: scripts/LancesRoom.asm (WalkToLance / LancesRoomLanceEndBattleScript),
-- text/LancesRoom.asm (_LancesRoomLanceAfterBattleText).
-- Self-contained: run via `luajit tests/parity_lance.lua`; also dofile'd
-- by tests/run_tests.lua's aggregator.
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.LANCES_ROOM) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local S = require("tests.harness").suite("parity lance")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local hooks = mapScripts.get("LANCES_ROOM")
check(hooks and hooks.walkInRoute, "LANCES_ROOM exposes walkInRoute")
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- === (1) walk-in RLE lands on the door-lock trigger (6,11) ===
do
local x, y = 24, 16
local steps = 0
for _, seg in ipairs(hooks.walkInRoute) do
local d = D[seg[1]]
check(d ~= nil, "walk segment direction " .. tostring(seg[1]))
for _ = 1, seg[2] do
x, y = x + d[1], y + d[2]
steps = steps + 1
end
end
eq(x, 6, "walk-in ends at x=6")
eq(y, 11, "walk-in ends at y=11 (door-lock / arena entry)")
check(steps > 0, "walk-in has steps")
end
-- === (2) every cell of the route is walkable with the entrance open ===
do
local Map = require("src.world.Map")
local def = Data.maps.LANCES_ROOM
local tileset = Data.tilesets[def.tileset]
-- mutate a copy of the block list so we don't poison the registry
local blocks = {}
for i, b in ipairs(def.blocks) do blocks[i] = b end
local copy = {}
for k, v in pairs(def) do copy[k] = v end
copy.blocks = blocks
-- LanceShowOrHideEntranceBlocks with the door unlocked
blocks[6 * def.width + 2 + 1] = 0x31
blocks[6 * def.width + 3 + 1] = 0x32
local map = Map.new(copy, tileset)
local x, y = 24, 16
check(map:isWalkableCell(x, y), "start (24,16) walkable")
for _, seg in ipairs(hooks.walkInRoute) do
local d = D[seg[1]]
for _ = 1, seg[2] do
x, y = x + d[1], y + d[2]
check(map:isWalkableCell(x, y),
("walk-in cell (%d,%d) is floor, not void/wall"):format(x, y))
end
end
end
-- === (3) after-battle text names the rival as the real champion ===
do
local after = Data.text._LancesRoomLanceAfterBattleText
check(after ~= nil, "_LancesRoomLanceAfterBattleText extracted")
check(after:find("ELITE", 1, true) or after:find("{RIVAL}", 1, true),
"after text mentions rival / Elite Four")
check(after:find("champion", 1, true) or after:find("CHAMPION", 1, true),
"after text has the champion reveal")
check(after:find("before you", 1, true) or after:find("before you!", 1, true)
or after:find("FOUR before", 1, true),
"after text says rival beat the Elite Four first")
local header = Data:trainerHeader("LancesRoom", 1)
check(header and header.after == "_LancesRoomLanceAfterBattleText",
"trainer header wires the after-battle label")
check(header and header.won == "_LancesRoomLanceEndBattleText",
"trainer header wires the won label")
end
-- === (4) onStep win callback pushes the after text (not only won text) ===
do
local pushed = {}
local game = {
data = Data,
save = {
flags = {},
defeatedTrainers = {},
player = { name = "RED", rival = "BLUE" },
},
stack = {
push = function(_, state)
pushed[#pushed + 1] = state
end,
},
}
local lance = {
def = { name = "LANCESROOM_LANCE", index = 1,
trainerClass = "OPP_LANCE", trainerParty = 1, text = 1 },
id = "LANCES_ROOM:1",
facePlayer = function() end,
}
local engaged = false
local ow = {
npcs = { lance },
player = { cellX = 6, cellY = 2 },
trainerDefeated = function(_, npc)
return game.save.defeatedTrainers[npc.id] == true
end,
engageTrainer = function(_, npc, onDone)
engaged = npc == lance
-- simulate engageTrainer's win path: flag the trainer, then onDone
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_LANCES_ROOM_TRAINER_0 = true
if onDone then onDone() end
end,
}
local handled = hooks.onStep(game, ow, 6, 2)
check(handled == true, "Lance coord trigger engages")
check(engaged, "engageTrainer called for Lance")
check(#pushed == 1 and pushed[1].pages ~= nil, "after-battle TextBox pushed")
-- TextBox.substitute already expanded {RIVAL}/{PLAYER}; page glyphs
-- are opaque, so re-check the source label via the header + that a
-- box was queued on win only.
check(#pushed[1].pages > 0, "after-battle TextBox has pages")
-- loss path must not show after text
pushed = {}
game.save.defeatedTrainers = {}
ow.engageTrainer = function(_, npc, onDone)
-- lose: do not mark defeated
if onDone then onDone() end
end
hooks.onStep(game, ow, 6, 2)
eq(#pushed, 0, "loss does not push after-battle text")
end
print("parity_lance: ok")
+77
View File
@@ -0,0 +1,77 @@
-- Regression: map-connection seam steps must show walk frames (issue #93).
--
-- A hitch inside setMap (neighbor rebuild / map-song start) made the next
-- real-time dt huge; FixedStep catch-up then advanced many walk frames
-- before the next draw, which looked like a slide with no leg animation.
-- crossConnection now discards that catch-up, starts a fresh animClock,
-- and defers PlayMapMusic until the seam step lands.
--
-- Self-contained; run via `luajit tests/parity_seam_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 FixedStep = require("src.core.FixedStep")
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Music = require("src.core.Music")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity seam walk anim")
local check, eq = S.check, S.eq
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up")
local ow = Game.stack:top()
local north = ow.map:connection("north")
check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1")
-- simulate a post-hitch catch-up budget waiting in the accumulator
FixedStep.accum = 0.24
local played = {}
local realPlayMap = Music.playMap
Music.playMap = function(data, mapId, onBike, surfing)
played[#played + 1] = mapId
return realPlayMap(data, mapId, onBike, surfing)
end
check(ow:crossConnection("up", north) == true, "Pallet -> Route 1 crosses")
eq(ow.map.id, "ROUTE_1", "landed on ROUTE_1")
eq(FixedStep.accum, 0, "seam cross discards FixedStep catch-up")
eq(ow.pendingSeamMusic, "ROUTE_1", "map music deferred across the seam step")
eq(#played, 0, "PlayMapMusic not called inside setMap for the seam")
eq(ow.player.animClock, 0, "seam step starts a fresh walk-cycle clock")
check(ow.player.moving, "seam step is in progress")
local sawWalk = false
local phases = {}
for _ = 1, 20 do
ow:update(1 / 60)
phases[#phases + 1] = ow.player:walkPhase()
if ow.player:walkPhase() == 1 then sawWalk = true end
if not ow.player.moving and not ow.pendingSeamMusic then break end
end
check(sawWalk, "seam step shows at least one walk frame")
-- mid-cycle: frames 4..11 of a fresh animClock are walk
local midWalk = false
for i = 4, 11 do
if phases[i] == 1 then midWalk = true break end
end
check(midWalk, "fresh animClock puts walk frames in the middle of the seam step")
eq(ow.pendingSeamMusic, nil, "deferred music flushed after the seam step")
eq(played[1], "ROUTE_1", "PlayMapMusic runs once the seam step lands")
Music.playMap = realPlayMap
S.finish()
+125
View File
@@ -0,0 +1,125 @@
-- Parity: sleep/confusion onomatopoeia on status-check text
-- (core.asm CheckPlayerStatusConditions / CheckEnemyStatusConditions).
-- Self-contained: `luajit tests/parity_status_onomatopoeia.lua`; also
-- dofile'd by tests/run_tests.lua's parity_* aggregator.
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 S = require("tests.harness").suite("parity status onomatopoeia")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
Game.data = Data
Game.save = require("src.core.SaveData").newGame()
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local function freshBattle()
Game.save.party = { Pokemon.new(Data, "NIDOKING", 40) }
return BattleState.newWild(Game, "DEWGONG", 30)
end
-- Capture queue order of anim/text rows inserted via *Next helpers.
local function capture(battle)
local seq = {}
battle.nextInsert = 0
battle.queue = {}
battle.animNext = function(_, name, isPlayer)
seq[#seq + 1] = { kind = "anim", name = name, isPlayer = isPlayer }
end
battle.sayNext = function(_, text)
seq[#seq + 1] = { kind = "text", text = text }
end
return seq
end
-- --- sleep: player anim-before-text, enemy text-before-anim -------------
do
local b = freshBattle()
b.rng = function() return 255 end
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 3
check(b:statusInterrupt(b.player, b.enemy) == true, "sleep interrupts the turn")
eq(#seq, 2, "sleep queues anim + text")
eq(seq[1].kind, "anim", "player sleep: SLP_PLAYER_ANIM before text")
eq(seq[1].name, "SLP_PLAYER_ANIM", "player sleep uses SLP_PLAYER_ANIM")
eq(seq[1].isPlayer, true, "player sleep anim faces the player")
check(seq[2].text:find("is fast asleep!", 1, true),
"player sleep text follows the anim")
end
do
local b = freshBattle()
b.rng = function() return 255 end
local seq = capture(b)
b.enemy.mon.status = "SLP"
b.enemy.sleepTurns = 3
check(b:statusInterrupt(b.enemy, b.player) == true, "enemy sleep interrupts")
eq(seq[1].kind, "text", "enemy sleep: FastAsleepText before anim")
check(seq[1].text:find("Enemy ", 1, true),
"enemy sleep text carries the Enemy prefix")
eq(seq[2].name, "SLP_ANIM", "enemy sleep uses SLP_ANIM (enemy Z coords)")
eq(seq[2].isPlayer, false, "enemy sleep anim faces the enemy")
end
do
local b = freshBattle()
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 3
check(b:preRechargeChecks(b.player, b.enemy) == true,
"pre-recharge sleep still loses the turn")
eq(seq[1].name, "SLP_PLAYER_ANIM",
"pre-recharge sleep plays the same onomatopoeia")
end
-- --- confusion: text then CONF_*_ANIM (both sides) ---------------------
-- Status.beforeMove: rng(0,255) < 128 -> hurt itself; else can still move.
do
local b = freshBattle()
b.rng = function() return 200 end -- no self-hit
local seq = capture(b)
b.player.confusedTurns = 3
b.player.mon.status = nil
local stopped = b:statusInterrupt(b.player, b.enemy)
check(stopped == false, "confusion can still allow a move")
eq(seq[1].kind, "text", "player confusion: IsConfusedText before anim")
check(seq[1].text:find("is confused!", 1, true), "player confusion text")
eq(seq[2].name, "CONF_PLAYER_ANIM", "player confusion uses CONF_PLAYER_ANIM")
eq(seq[2].isPlayer, true, "player confusion anim faces the player")
end
do
local b = freshBattle()
b.rng = function() return 0 end -- self-hit
local seq = capture(b)
b.computeDamage = function() return 1 end
b.applyDamage = function() end
b.onFaint = function() end
b.enemy.confusedTurns = 3
b.enemy.mon.status = nil
local stopped = b:statusInterrupt(b.enemy, b.player)
check(stopped == true, "confusion self-hit interrupts")
eq(seq[1].kind, "text", "enemy confusion text first")
check(seq[1].text:find("is confused!", 1, true), "enemy confusion text")
eq(seq[2].name, "CONF_ANIM", "enemy confusion uses CONF_ANIM")
eq(seq[2].isPlayer, false, "enemy confusion anim faces the enemy")
check(seq[3] and seq[3].text:find("hurt itself", 1, true),
"hurt-itself text follows the confusion anim")
end
-- wake stays text-only (no onomatopoeia)
do
local b = freshBattle()
b.rng = function() return 0 end
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 1
b:statusInterrupt(b.player, b.enemy)
eq(#seq, 1, "waking up is text-only")
check(seq[1].text:find("woke up!", 1, true), "wake text")
end
S.finish()
+63
View File
@@ -0,0 +1,63 @@
-- Parity test: Victory Road 3F hole at (23,15) dungeon-warps the player
-- to VICTORY_ROAD_2F (22,16).
--
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript feeds
-- .SwitchOrHoleCoords into IsPlayerOnDungeonWarp with destination
-- VICTORY_ROAD_2F; data/maps/special_warps.asm DungeonWarpList entry
-- (VICTORY_ROAD_2F, 2) lands at DungeonWarpData (22, 16). The same cell
-- also drops a boulder (onBoulderMoved), but the player fall was missing
-- -- CAVERN $22 is walkable, so without onStep Red stood on the hole
-- (GitHub #86).
--
-- Self-contained; run via `luajit tests/parity_victory_road_hole.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 victory road hole")
local check, eq = S.check, S.eq
local M = dofile("data/scripts/story.lua")
local vr3 = M.VICTORY_ROAD_3F
check(vr3 ~= nil and vr3.onStep ~= nil, "VICTORY_ROAD_3F has an onStep hole trigger")
local function owRecording()
local warps = {}
return {
player = { facing = "right" },
startWarpTo = function(_, mapId, x, y, facing)
warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing }
end,
_warps = warps,
}, warps
end
-- Stepping onto the hole falls through to 2F at the dungeon-warp landing.
do
local ow, warps = owRecording()
local handled = vr3.onStep({}, ow, 23, 15)
check(handled, "stepping on (23,15) is consumed")
eq(#warps, 1, "exactly one dungeon warp fires")
eq(warps[1].mapId, "VICTORY_ROAD_2F", "destination is VICTORY_ROAD_2F")
eq(warps[1].x, 22, "lands at x=22")
eq(warps[1].y, 16, "lands at y=16")
eq(warps[1].facing, "right", "facing is preserved across the fall")
end
-- Any other cell is ignored (switch at 3,5 is boulder-only).
do
local ow, warps = owRecording()
eq(vr3.onStep({}, ow, 3, 5), false, "the switch cell does not dungeon-warp")
eq(vr3.onStep({}, ow, 22, 15), false, "a neighboring floor cell is ignored")
eq(#warps, 0, "no warp fires off the hole")
end
-- The hole collision tile stays walkable (fall, do not block).
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.VICTORY_ROAD_3F) then Data:load() end
local MapLoader = require("src.world.MapLoader")
local map = MapLoader.load(Data, "VICTORY_ROAD_3F")
check(map:isWalkableCell(23, 15), "CAVERN hole tile at (23,15) is walkable")
eq(map:warpPadOrHoleAt(23, 15), "hole", "collision tile is the CAVERN hole ($22)")
check(map:warpAtCell(23, 15) == nil,
"the hole is a dungeon warp, not a map warp event")
S.finish()
+86
View File
@@ -1659,6 +1659,47 @@ do
eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y")
end
-- ---------------------------------------------------------------- dpi fit scale (#87)
-- Android density is often non-integer; fitScale must use framebuffer
-- pixels so each GB pixel maps to a whole number of physical pixels.
do
local Renderer = require("src.render.Renderer")
local Zoom = require("src.render.Zoom")
local g = love.graphics
local oldDim, oldPix, oldDpi = g.getDimensions, g.getPixelDimensions, g.getDPIScale
-- desktop / dpi=1: identical to the pre-fix unit-based floor scale
g.getDimensions = function() return 1920, 1080 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1 end
eq(Renderer:fitScale(), 7, "dpi=1 1080p fitScale is floor(1080/144)=7")
-- density 1.5 on a 1920x1080 panel → LOVE units 1280x720
g.getDimensions = function() return 1280, 720 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1.5 end
eq(Renderer:fitScale(), 7,
"non-integer density still picks integer framebuffer pixels (7, not 5)")
-- old unit-only math would have returned floor(min(1280/160,720/144))=5
-- and 5*1.5=7.5 physical px/GB px (shimmer). 7 physical is crisp.
Zoom.reset()
local vw, vh = Renderer:worldViewSize()
check(vw % 2 == 0 and vh % 2 == 0, "world view sizes are even (integer camera)")
-- ceil(pw/Sp)=ceil(1920/7)=275 → even 276; ceil(1080/7)=155 → even 156
eq(vw, 276, "world fill width covers the unit window at pixel scale 7")
eq(vh, 156, "world fill height covers the unit window at pixel scale 7")
-- missing pixel API falls back to getDimensions (headless / old stub)
g.getPixelDimensions = nil
g.getDPIScale = nil
g.getDimensions = function() return 640, 576 end
eq(Renderer:fitScale(), 4, "no pixel API: fitScale uses unit dimensions")
g.getDimensions, g.getPixelDimensions, g.getDPIScale = oldDim, oldPix, oldDpi
Zoom.reset()
end
-- ---------------------------------------------------------------- spawn filter
do
local OW = require("src.world.OverworldController")
@@ -2486,6 +2527,51 @@ do
love.graphics.draw = origDraw
eq(xs[1], 8, "player/old-man back pic also rests at x=8")
end
-- Front pics: LoadUncompressedSpriteData centers in a 7x7 buffer at
-- hlcoord 12,0. A 5x5 (40x40) Squirtle rests at (104,16), not
-- right/bottom-aligned to (112,8).
do
local front = {
getWidth = function() return 40 end,
getHeight = function() return 40 end,
}
local battle = setmetatable({
showEnemyTrainer = false,
enemyHidden = false,
enemySendingOut = false,
phase = "command",
enemy = { sprite = front, isPlayer = false },
}, BattleState)
function battle:picImage(i) return i end
function battle:growInScale() return nil end
function battle:fxHidden() return false end
function battle:drawBattlerPic(b, x, y, scale)
battle._ex, battle._ey, battle._scale = x, y, scale
end
battle:drawPicsLayer(0, 0, 0)
eq(battle._ex, 104, "enemy 5x5 front rests at hlcoord 12,0 + hPad 1 (x=104)")
eq(battle._ey, 16, "enemy 5x5 front bottom-aligned in 7x7 (y=16)")
eq(battle._scale, 1, "enemy front draws at 1x")
end
-- Battle message lines skip a tile row (14 then 16), matching the menu.
do
local Font = require("src.render.Font")
local ys, origCode, origBox = {}, Font.drawCode, Font.drawBox
Font.drawBox = function() end
Font.drawCode = function(_, _, y) ys[#ys + 1] = y end
local battle = setmetatable({
phase = "messages",
current = true,
charIndex = 999,
lines = { { 0x80 }, { 0x81 } },
}, BattleState)
battle:drawTextArea()
Font.drawCode, Font.drawBox = origCode, origBox
eq(ys[1], 112, "battle text line 1 at row 14 (y=112)")
eq(ys[2], 128, "battle text line 2 at row 16 (y=128)")
end
end
-- ================= BUGS.md batch: ledge-shadow =================