Files
gen1recomp/tests/game3_pokedex_and_catch_test.lua
bryanthaboi d920f7acdb clean up
2026-09-22 17:05:12 -04:00

457 lines
19 KiB
Lua

-- Comprehensive test suite for Game 3 Pokédex tracking, catch mechanics, and UI.
require("tests.game3_cache").requireData("game3_pokedex_and_catch_test")
local GameVersion = require("src.core.GameVersion")
GameVersion.set("firered")
local Dex = require("src.core.game3.dex")
local Pokemon = require("src.core.game3.pokemon")
local Catching = require("src.core.game3.battle.catching")
local BattleItems = require("src.core.game3.battle.items")
local Bag = require("src.core.game3.bag")
local Battle = require("src.core.game3.battle")
local Pokedex = require("src.ui.game3.pokedex")
Pokemon.install(nil)
print("[test] 1. Pokédex data structure & seen/caught tracking")
local dex = Dex.new()
assert(Dex.isSeen(dex, 1) == false, "Bulbasaur not seen initially")
assert(Dex.isCaught(dex, 1) == false, "Bulbasaur not caught initially")
assert(Dex.countSeen(dex, "kanto") == 0, "0 seen in Kanto")
assert(Dex.countCaught(dex, "kanto") == 0, "0 caught in Kanto")
-- Register encounter
local wasSeen = Dex.registerEncounter(dex, 1)
assert(wasSeen == false, "First encounter was not previously seen")
assert(Dex.isSeen(dex, 1) == true, "Bulbasaur is now seen")
assert(Dex.isCaught(dex, 1) == false, "Bulbasaur is not yet caught")
assert(Dex.countSeen(dex, "kanto") == 1, "1 seen in Kanto")
assert(Dex.countCaught(dex, "kanto") == 0, "0 caught in Kanto")
-- Second encounter returns wasSeen == true
assert(Dex.registerEncounter(dex, 1) == true, "Second encounter knows it was seen")
-- Register capture
local wasCaught = Dex.registerCapture(dex, 1)
assert(wasCaught == false, "First capture was not previously caught")
assert(Dex.isCaught(dex, 1) == true, "Bulbasaur is now caught")
assert(Dex.countSeen(dex, "kanto") == 1, "1 seen in Kanto")
assert(Dex.countCaught(dex, "kanto") == 1, "1 caught in Kanto")
-- String resolution
Dex.setSeen(dex, "CHARMANDER")
Dex.setCaught(dex, "CHARMANDER")
assert(Dex.isSeen(dex, 4) == true, "Charmander numeric is seen")
assert(Dex.isCaught(dex, 4) == true, "Charmander numeric is caught")
assert(Dex.isSeen(dex, "CHARMANDER") == true, "Charmander string is seen")
assert(Dex.isCaught(dex, "CHARMANDER") == true, "Charmander string is caught")
assert(Dex.countSeen(dex, "kanto") == 2, "2 seen in Kanto")
assert(Dex.countCaught(dex, "kanto") == 2, "2 caught in Kanto")
-- National dex tracking (e.g. Torchic = 255)
Dex.setSeen(dex, 255)
Dex.setCaught(dex, 255)
assert(Dex.countSeen(dex, "kanto") == 2, "Kanto seen count ignores Torchic (255)")
assert(Dex.countCaught(dex, "kanto") == 2, "Kanto caught count ignores Torchic (255)")
assert(Dex.countSeen(dex, "national") == 3, "National seen count includes Torchic")
assert(Dex.countCaught(dex, "national") == 3, "National caught count includes Torchic")
print("[ok] Pokédex seen/caught tracking and Kanto/National counts passed")
print("[test] 2. Catch rate calculations & ball multipliers")
local foePikachu = {
species = 25,
type1 = 13, -- ELECTRIC
mon = { species = 25, level = 5, hp = 10, maxHp = 20, status = nil }
}
-- Master Ball (item 1) -> guaranteed 255 odds
local masterOdds = Catching.catchOdds(1, foePikachu, {}, { dex = dex })
assert(masterOdds == 255, "Master ball catch odds is 255")
-- Ultra Ball (item 2) vs Poké Ball (item 4)
local pokeOdds = Catching.catchOdds(4, foePikachu, {}, { dex = dex })
local ultraOdds = Catching.catchOdds(2, foePikachu, {}, { dex = dex })
assert(ultraOdds > pokeOdds, "Ultra ball has higher odds than Poké ball")
assert(math.abs(ultraOdds - pokeOdds * 2) <= 1, "Ultra ball bonus is ~2x Poké ball")
-- Status bonuses: Sleep doubles base odds
foePikachu.status = "SLP"
local sleepOdds = Catching.catchOdds(4, foePikachu, {}, { dex = dex })
assert(sleepOdds == pokeOdds * 2 or sleepOdds == 255, "Sleep status doubles catch odds")
-- Net Ball (item 6) on Water type
local foeSquirtle = {
species = 7,
type1 = 11, -- WATER
mon = { species = 7, level = 5, hp = 10, maxHp = 20 }
}
local netMultWater = Catching.ballMultiplier(6, foeSquirtle, {}, { dex = dex })
assert(netMultWater == 30, "Net ball gets 3x multiplier on Water type")
-- Repeat Ball (item 9) on previously caught vs uncaught species
local repeatMultCaught = Catching.ballMultiplier(9, { species = 1, mon = { level = 5 } }, {}, { dex = dex })
assert(repeatMultCaught == 30, "Repeat ball gets 3x multiplier on caught species (Bulbasaur)")
local repeatMultUncaught = Catching.ballMultiplier(9, { species = 150, mon = { level = 70 } }, {}, { dex = dex })
assert(repeatMultUncaught == 10, "Repeat ball gets 1x on uncaught species (Mewtwo)")
-- Timer Ball (item 10) scaling with turns
local timerMultT1 = Catching.ballMultiplier(10, foePikachu, { turn = 1 }, { dex = dex })
local timerMultT15 = Catching.ballMultiplier(10, foePikachu, { turn = 15 }, { dex = dex })
local timerMultT40 = Catching.ballMultiplier(10, foePikachu, { turn = 40 }, { dex = dex })
assert(timerMultT1 == 11, "Timer ball at turn 1 is 1.1x")
assert(timerMultT15 == 25, "Timer ball at turn 15 is 2.5x")
assert(timerMultT40 == 40, "Timer ball caps at 4.0x")
print("[ok] Catch rate calculations and ball multipliers passed")
print("[test] 3. Deterministic Catch simulation & Storage (Party vs PC)")
-- Deterministic RNG mock
local mockRngPass = function(lo, hi) return lo end -- always passes threshold
local mockRngFail = function(lo, hi) return hi end -- always fails threshold
local foeCaterpie = {
species = 10,
mon = { species = 10, level = 3, hp = 2, maxHp = 15, moves = { 33 } }
}
local session = {
name = "RED",
id = 54321,
party = { { species = 1, level = 10, hp = 30, maxHp = 30 } },
dex = Dex.new(),
}
-- Catch Caterpie with Master Ball
local caught, shakes = Catching.tryCatch(1, foeCaterpie, {}, session, mockRngPass)
assert(caught == true, "Master ball catches Caterpie")
assert(shakes == 4, "Master ball gives 4 shakes")
-- Store Caterpie into party (< 6)
local res = Catching.storeCaught(session, foeCaterpie, 1)
assert(res.success == true, "storeCaught succeeded")
assert(res.location == "party", "Caterpie stored in party")
assert(res.firstTimeCaught == true, "First time Caterpie was caught")
assert(#session.party == 2, "Party count is now 2")
assert(session.party[2].species == 10, "Party slot 2 is Caterpie")
assert(session.party[2].pokeball == 1, "Stored with Master Ball ID")
assert(session.party[2].ot == "RED", "OT set to RED")
assert(Dex.isCaught(session.dex, 10) == true, "Caterpie marked caught in Dex")
-- Fill party to 6 Pokémon
while #session.party < 6 do
session.party[#session.party + 1] = { species = 1, level = 5, hp = 20, maxHp = 20 }
end
assert(#session.party == 6, "Party is now full (6)")
-- Catch Pidgey (species 16) -> should go to PC
local foePidgey = {
species = 16,
mon = { species = 16, level = 4, hp = 5, maxHp = 18 }
}
local resPc = Catching.storeCaught(session, foePidgey, 4)
assert(resPc.location == "pc", "Pidgey transferred to PC when party is full")
assert(resPc.firstTimeCaught == true, "First time Pidgey was caught")
assert(#session.party == 6, "Party still has 6 Pokémon")
assert(session.pc == nil, "no parallel session.pc table")
assert(require("src.core.game3.storage").countTotalMons(session.storage) == 1, "PC box has 1 Pokémon")
assert(session.storage.boxes[resPc.box].mons[resPc.slot].species == 16, "PC mon is Pidgey")
assert(Dex.isCaught(session.dex, 16) == true, "Pidgey marked caught in Dex")
-- Second capture of Caterpie -> firstTimeCaught is false
local res2 = Catching.storeCaught(session, foeCaterpie, 4)
assert(res2.firstTimeCaught == false, "Second Caterpie is not firstTimeCaught")
print("[ok] Deterministic catch simulation and party/PC storage passed")
print("[test] 4. In-battle encounter & ball use")
local battleSession = {
name = "RED",
party = { { species = 1, level = 10, hp = 30, maxHp = 30 } },
dex = Dex.new(),
}
local testBag = Bag.new()
Bag.add(testBag, "POKE_BALL", 2)
-- Wild battle start should mark wild opponent as SEEN
local foeRattata = { species = 19, level = 3, hp = 10, maxHp = 15 }
assert(Dex.isSeen(battleSession.dex, 19) == false, "Rattata not seen before battle")
Battle.start({
headless = true,
wild = true,
playerParty = battleSession.party,
foe = foeRattata,
session = battleSession,
})
assert(Dex.isSeen(battleSession.dex, 19) == true, "Rattata registered as SEEN on battle start")
assert(Dex.isCaught(battleSession.dex, 19) == false, "Rattata not yet caught")
assert(BattleItems.needsPartySelect(4) == false, "Poké Ball does not need party select")
assert(BattleItems.needsPartySelect("POKE_BALL") == false, "POKE_BALL does not need party select")
assert(BattleItems.needsPartySelect(13) == true, "Potion needs party select")
assert(BattleItems.needsPartySelect("POTION") == true, "POTION needs party select")
-- BattleItems.use ball in battle
local st = Battle._st
local adapter = Battle._adapter
local useRes, msgs, endsTurn, endsBattle = BattleItems.use(st, adapter, testBag, battleSession, 4)
assert(Bag.get(testBag, "POKE_BALL") == 1, "1 Poké Ball consumed from bag")
assert(endsTurn == true, "Ball throw ends player turn")
print("[ok] In-battle encounter marking and ball usage passed")
print("[test] 5. Pokédex UI Screen lifecycle and navigation")
local mockInput = {
_pressed = {},
wasPressed = function(self, key) return self._pressed[key] == true end,
press = function(self, key) self._pressed = { [key] = true } end,
clear = function(self) self._pressed = {} end,
}
local uiDex = Dex.new()
Dex.setSeen(uiDex, 1) -- Bulbasaur (seen)
Dex.setCaught(uiDex, 1) -- Bulbasaur (caught)
Dex.setSeen(uiDex, 4) -- Charmander (seen)
Dex.setSeen(uiDex, 255) -- Torchic (seen national)
Pokedex.show(uiDex, { session = { dex = uiDex }, mode = "kanto" })
assert(Pokedex.isOpen() == true, "Pokédex is open")
assert(Pokedex.mode == "kanto", "Pokédex is in Kanto mode")
assert(Pokedex.page == "list", "Pokédex starts on list page")
assert(Pokedex.maxSpecies() == 151, "Kanto mode max species is 151")
-- Cursor down
mockInput:press("down")
Pokedex.handleInput(mockInput)
assert(Pokedex.cursor == 2, "Cursor moved to No.002 Ivysaur")
-- Press A on unseen Ivysaur -> stays on list
mockInput:press("a")
Pokedex.handleInput(mockInput)
assert(Pokedex.page == "list", "Cannot open entry for unseen Pokémon")
-- Move to No.001 Bulbasaur and press A -> opens entry
mockInput:press("up")
Pokedex.handleInput(mockInput)
assert(Pokedex.cursor == 1, "Cursor on No.001 Bulbasaur")
mockInput:press("a")
Pokedex.handleInput(mockInput)
assert(Pokedex.page == "entry", "Opens detailed entry page for Bulbasaur")
-- Press Down in entry mode -> jumps to next seen Pokémon (No.004 Charmander)
mockInput:press("down")
Pokedex.handleInput(mockInput)
assert(Pokedex.cursor == 4, "Entry view jumps directly to next seen (Charmander)")
-- Press B in entry mode -> returns to list
mockInput:press("b")
Pokedex.handleInput(mockInput)
assert(Pokedex.page == "list", "Returns to list page on B")
-- Switch to National Mode
mockInput:press("right")
Pokedex.handleInput(mockInput)
assert(Pokedex.mode == "national", "Switched to National Mode")
assert(Pokedex.maxSpecies() == 386, "National mode max species is 386")
-- Press B in list mode -> returns to mode_select
mockInput:press("b")
Pokedex.handleInput(mockInput)
assert(Pokedex.screen == "mode_select", "Returns to mode_select on B")
-- Close Pokédex from mode_select
mockInput:press("b")
Pokedex.handleInput(mockInput)
assert(Pokedex.isOpen() == false, "Pokédex is closed")
-- Test PokedexChrome data loader
local PokedexChrome = require("src.ui.game3.pokedex_chrome")
PokedexChrome.install()
local bulbaEntry = PokedexChrome.getEntry(1)
assert(bulbaEntry ~= nil, "Bulbasaur entry exists")
assert(bulbaEntry.category == "SEED", "Bulbasaur is SEED category")
assert(bulbaEntry.categoryName == "SEED POKéMON", "Bulbasaur category name formatted")
assert(bulbaEntry.heightFormatted:find("2'04\"") ~= nil, "Bulbasaur height formatted")
assert(bulbaEntry.weightFormatted:find("15.2 lbs.") ~= nil, "Bulbasaur weight formatted")
assert(#bulbaEntry.description > 10, "Bulbasaur has description flavor text")
-- Test First-Time Caught Registration Screen
local regClosed = false
Pokedex.showRegistration(19, {
session = { dex = uiDex },
onDone = function() regClosed = true end,
})
assert(Pokedex.isOpen() == true, "Registration screen is open")
assert(Pokedex.mode == "registration", "Mode is registration")
assert(Pokedex._regSpecies == 19, "Registered species is Rattata (19)")
-- Press A to dismiss registration screen
mockInput:press("a")
Pokedex.handleInput(mockInput)
assert(Pokedex.isOpen() == false, "Registration screen closed on A press")
assert(regClosed == true, "onDone callback invoked")
print("[ok] Pokédex UI navigation, chrome entries, and registration view passed")
print("[test] 6. Battle Bag pocket navigation (ITEMS -> KEY_ITEMS -> POKE_BALLS) and action back-out")
local BagMenu = require("src.ui.game3.bag_menu")
local battleBag = Bag.new()
Bag.add(battleBag, "POTION", 3)
Bag.add(battleBag, "POKE_BALL", 5)
BagMenu.show(battleBag, {
session = { party = session.party, bag = battleBag },
battle = true,
})
assert(BagMenu.isOpen() == true, "BagMenu is open in battle")
assert(BagMenu.mode == "list", "BagMenu starts in list mode (not action mode)")
assert(BagMenu.currentPocket() == "ITEMS", "Starts on ITEMS pocket")
BagMenu.settle()
-- Swap to POKé_BALLS pocket with right arrow
mockInput:press("right")
BagMenu.handleInput(mockInput)
assert(BagMenu.currentPocket() == "KEY_ITEMS", "Swapped to KEY_ITEMS pocket")
mockInput:press("right")
BagMenu.handleInput(mockInput)
BagMenu.settle()
assert(BagMenu.currentPocket() == "POKE_BALLS", "Swapped to POKE_BALLS pocket")
assert(#BagMenu.list() == 1, "1 ball item in pocket")
assert(BagMenu.list()[1].id == 4 or BagMenu.list()[1].name == "POKé BALL", "Poke ball in list")
-- Press A on Poké Ball -> opens action mode
mockInput:press("a")
BagMenu.handleInput(mockInput)
assert(BagMenu.mode == "action", "Action mode opened for Poke Ball")
-- Press B in action mode -> backs out cleanly to list mode without closing bag
mockInput:press("b")
BagMenu.handleInput(mockInput)
assert(BagMenu.mode == "list", "Backed out to list mode on B")
assert(BagMenu.isOpen() == true, "BagMenu remains open")
assert(BagMenu.currentPocket() == "POKE_BALLS", "Still in POKE_BALLS pocket")
-- Swap back to ITEMS pocket with left arrow
mockInput:press("left")
BagMenu.handleInput(mockInput)
mockInput:press("left")
BagMenu.handleInput(mockInput)
BagMenu.settle()
assert(BagMenu.currentPocket() == "ITEMS", "Swapped back to ITEMS pocket")
-- Close bag with B in list mode
mockInput:press("b")
BagMenu.handleInput(mockInput)
BagMenu.settle()
assert(BagMenu.isOpen() == false, "BagMenu closed on B in list mode")
print("[ok] Battle bag pocket navigation and action back-out passed")
print("[test] 7. CatchSeq animation state machine, shakes & breakout vs capture")
local CatchSeq = require("src.core.game3.battle.catch_seq")
local Anim = require("src.core.game3.battle.anim")
local Task = require("src.core.game3.task")
-- Test CatchSeq with capture success (4 shakes)
Anim.reset({ headless = false })
local mockBattleSt = {
enemy = { species = 19, mon = { species = 19, name = "RATTATA", hp = 5, maxHp = 15 } },
}
local pushedMsgs = {}
CatchSeq.begin(mockBattleSt, 4, true, 4, {
pushMsg = function(t) pushedMsgs[#pushedMsgs + 1] = t end,
headless = false,
session = session,
})
assert(CatchSeq.busy() == true, "CatchSeq is busy after begin")
assert(#CatchSeq._steps == 3, "3 steps in catch sequence (msg, throw anim, success)")
assert(CatchSeq._steps[1].kind == "msg", "Step 1 is msg")
assert(CatchSeq._steps[2].kind == "throw" and CatchSeq._steps[2].data.caseId == 4, "Step 2 is the throw anim with BALL_3_SHAKES_SUCCESS")
assert(CatchSeq._steps[3].kind == "capture_success", "Step 3 is capture_success")
-- Step through tasks until completion
local safety = 0
while CatchSeq.busy() and safety < 2500 do
safety = safety + 1
Task.update(1 / 60)
Anim.update(1 / 60)
CatchSeq.update()
if CatchSeq._waitingMsg then
CatchSeq._waitingMsg = false
end
end
assert(CatchSeq.busy() == false, "CatchSeq finished all animation steps")
assert(CatchSeq.result() == "catch", "Result is catch")
-- Test CatchSeq with breakout (1 shake)
Anim.reset({ headless = false })
local breakoutMsgs = {}
CatchSeq.begin(mockBattleSt, 4, false, 1, {
pushMsg = function(t) breakoutMsgs[#breakoutMsgs + 1] = t end,
headless = false,
session = session,
})
assert(CatchSeq.busy() == true, "Breakout CatchSeq is busy")
assert(#CatchSeq._steps == 3, "3 steps in 1-shake breakout (msg, throw anim, breakout)")
assert(CatchSeq._steps[2].data.caseId == 1, "Step 2 throws with BALL_1_SHAKE")
assert(CatchSeq._steps[3].kind == "breakout", "Step 3 is breakout")
safety = 0
while CatchSeq.busy() and safety < 2500 do
safety = safety + 1
Task.update(1 / 60)
Anim.update(1 / 60)
CatchSeq.update()
if CatchSeq._waitingMsg then
CatchSeq._waitingMsg = false
end
end
assert(CatchSeq.busy() == false, "Breakout CatchSeq finished")
assert(CatchSeq.result() == "fail_catch", "Result is fail_catch")
print("[ok] CatchSeq animation state machine, shakes & breakout vs capture passed")
print("[test] 8. LCRNG generator & catch math verification")
local Rng = require("src.core.game3.rng")
Rng.SeedRng(12345)
local r1 = Rng.Random()
local r2 = Rng.Random()
assert(r1 ~= r2, "Random advances state")
-- Verify deterministic catch rate with seeded RNG
Rng.SeedRng(42)
local odds = Catching.catchOdds(4, mockBattleSt.enemy, mockBattleSt, session)
assert(odds > 0, "Valid catch odds computed")
local caughtSeeded, shakesSeeded = Catching.tryCatch(4, mockBattleSt.enemy, mockBattleSt, session, Rng.compat)
assert(type(caughtSeeded) == "boolean", "Returns boolean caught flag")
assert(type(shakesSeeded) == "number" and shakesSeeded >= 0 and shakesSeeded <= 4, "Shakes is 0..4")
print("[ok] LCRNG generator & catch math verification passed")
print("[test] 9. Battle.update Pokédex registration input dispatch")
Battle.start({
wild = true,
playerParty = { { species = 25, hp = 20, maxHp = 20, level = 5, moves = { { id = 33, pp = 35 } } } },
foe = { species = 19, level = 3, hp = 10, maxHp = 10 },
headless = false,
session = session,
})
assert(Battle.isActive() == true, "Battle is active")
-- Manually trigger pokedex_reg phase
local onDoneCalled = false
Pokedex.showRegistration(19, {
session = session,
onDone = function()
onDoneCalled = true
Battle._phase = "ending"
end,
})
Battle._phase = "pokedex_reg"
assert(Pokedex.isOpen() == true, "Pokedex registration is open during battle")
-- Send A press via Battle.update
mockInput:press("a")
Battle.update(1 / 60, { input = mockInput })
assert(Pokedex.isOpen() == false, "Pokedex registration closed via Battle.update input dispatch")
assert(onDoneCalled == true, "onDone callback invoked successfully")
assert(Battle._phase == "ending", "Battle transitioned to ending phase")
print("[ok] Battle.update Pokédex registration input dispatch passed")
print("[test] all Pokédex and catch mechanics tests passed successfully!")