mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
625 lines
23 KiB
Lua
625 lines
23 KiB
Lua
-- Comprehensive unit tests for Battle Pokémon Switch and Faint systems in Game 3.
|
|
require("tests.game3_cache").mountOrSkip("game3_battle_switch_and_faint_test")
|
|
|
|
local State = require("src.core.game3.battle.state")
|
|
local Engine = require("src.core.game3.battle.engine")
|
|
local SwitchSeq = require("src.core.game3.battle.switch_seq")
|
|
local Battle = require("src.core.game3.battle.init")
|
|
local Damage = require("src.core.game3.battle.damage")
|
|
local Commands = require("src.core.game3.battle.commands")
|
|
local PartyMenu = require("src.ui.game3.party_menu")
|
|
|
|
local function check(cond, msg)
|
|
if not cond then
|
|
error("[FAIL] " .. tostring(msg), 2)
|
|
end
|
|
print("[PASS] " .. tostring(msg))
|
|
end
|
|
|
|
local function eq(a, b, msg)
|
|
if a ~= b then
|
|
error(string.format("[FAIL] %s: expected %s, got %s", tostring(msg), tostring(b), tostring(a)), 2)
|
|
end
|
|
print("[PASS] " .. tostring(msg))
|
|
end
|
|
|
|
print("--- Testing State.wipeVolatilesAndStages & State.syncBattlerToParty ---")
|
|
do
|
|
local party = {
|
|
{ species = 1, level = 5, hp = 20, maxHp = 20, status = 0, moves = { 33 }, pp = { 35 } },
|
|
{ species = 4, level = 5, hp = 19, maxHp = 19, status = 0, moves = { 33 }, pp = { 35 } },
|
|
}
|
|
local battler = State.makeBattler(party[1], "player", { partyIndex = 1 })
|
|
battler.stages.attack = 2
|
|
battler.stages.defense = -1
|
|
battler.confusion = 3
|
|
battler.seeded = true
|
|
battler.mon.hp = 12
|
|
battler.status = "PSN"
|
|
|
|
State.syncBattlerToParty(battler, party)
|
|
eq(party[1].hp, 12, "party entry synced HP")
|
|
eq(party[1].status, "PSN", "party entry synced status")
|
|
|
|
State.wipeVolatilesAndStages(battler, { batonPass = false })
|
|
eq(battler.stages.attack, 0, "attack stage reset to 0")
|
|
eq(battler.stages.defense, 0, "defense stage reset to 0")
|
|
eq(battler.confusion, nil, "confusion cleared")
|
|
eq(battler.seeded, nil, "seeded cleared")
|
|
|
|
-- Baton pass preserves stages
|
|
battler.stages.speed = 3
|
|
battler.substitute = 10
|
|
State.wipeVolatilesAndStages(battler, { batonPass = true })
|
|
eq(battler.stages.speed, 3, "baton pass preserves stat stages")
|
|
eq(battler.substitute, 10, "baton pass preserves substitute")
|
|
end
|
|
|
|
print("\n--- Testing Engine.hasLivingMons & Engine.nextLivingMonIndex ---")
|
|
do
|
|
local party = {
|
|
{ species = 1, hp = 0, maxHp = 20 },
|
|
{ species = 4, hp = 15, maxHp = 19 },
|
|
{ species = 7, hp = 0, maxHp = 22 },
|
|
}
|
|
eq(Engine.hasLivingMons(party), true, "hasLivingMons detects conscious mon")
|
|
eq(Engine.nextLivingMonIndex(party, 1), 2, "nextLivingMonIndex finds slot 2")
|
|
eq(Engine.nextLivingMonIndex(party, 2), nil, "nextLivingMonIndex finds nil if only current slot is alive")
|
|
|
|
local deadParty = {
|
|
{ species = 1, hp = 0, maxHp = 20 },
|
|
{ species = 4, hp = 0, maxHp = 19 },
|
|
}
|
|
eq(Engine.hasLivingMons(deadParty), false, "hasLivingMons false when all dead")
|
|
end
|
|
|
|
print("\n--- Testing SwitchSeq Dynamic Withdraw Strings ---")
|
|
do
|
|
local st = State.new({
|
|
playerParty = {
|
|
{ species = 1, level = 10, hp = 25, maxHp = 30 }, -- >50% HP
|
|
{ species = 4, level = 10, hp = 10, maxHp = 30 }, -- <=50% & >20% HP
|
|
{ species = 7, level = 10, hp = 5, maxHp = 30 }, -- <=20% HP
|
|
},
|
|
foeMon = { species = 16, level = 10, hp = 30, maxHp = 30 },
|
|
})
|
|
|
|
local logged = {}
|
|
local pushMsg = function(t) logged[#logged + 1] = t end
|
|
|
|
-- pokefirered/src/battle_script_commands.c:6025
|
|
SwitchSeq.beginPlayerSwitch(st, 2, { headless = true, pushMsg = pushMsg })
|
|
check(logged[1]:find("that's enough!"), "foe unhurt uses 'that\'s enough!'")
|
|
eq(st.player.partyIndex, 2, "active player party index switched to 2")
|
|
eq(st.enemy.participants[1], true, "outgoing mon index recorded as participant")
|
|
eq(st.enemy.participants[2], true, "incoming mon index recorded as participant")
|
|
|
|
logged = {}
|
|
st.enemy.mon.hp = 24
|
|
SwitchSeq.beginPlayerSwitch(st, 3, { headless = true, pushMsg = pushMsg })
|
|
check(logged[1]:find(", come back!"), "foe lost 20% uses 'come back!'")
|
|
eq(st.player.partyIndex, 3, "active player party index switched to 3")
|
|
|
|
logged = {}
|
|
st.enemy.mon.hp = 12
|
|
SwitchSeq.beginPlayerSwitch(st, 1, { headless = true, pushMsg = pushMsg })
|
|
check(logged[1]:find("OK!\nCome back!"), "foe lost 50% uses 'OK! Come back!'")
|
|
eq(st.player.partyIndex, 1, "active player party index switched to 1")
|
|
|
|
logged = {}
|
|
st.enemy.mon.hp = 2
|
|
SwitchSeq.beginPlayerSwitch(st, 2, { headless = true, pushMsg = pushMsg })
|
|
check(logged[1]:find("good!\nCome back!"), "foe lost over 70% uses 'good! Come back!'")
|
|
end
|
|
|
|
print("\n--- Testing Manual Switch Turn Execution vs Opponent Attack ---")
|
|
do
|
|
local pMon1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
local pMon2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
local foeMon = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
|
|
local ok, err = Battle.start({ playerName = "RED",
|
|
headless = true,
|
|
autoFight = false,
|
|
playerParty = { pMon1, pMon2 },
|
|
foe = foeMon,
|
|
wild = true,
|
|
})
|
|
check(ok, "Battle started successfully")
|
|
Battle.update(0, nil) -- Advance intro to command
|
|
|
|
local st = Battle.getState()
|
|
eq(st.player.partyIndex, 1, "starts with slot 1 active")
|
|
|
|
-- Queue a player switch to slot 2 while opponent uses Tackle
|
|
Battle._actions = {
|
|
{ kind = "move", user = "enemy", target = "player", move = 33, slot = 1 },
|
|
}
|
|
Battle._actionI = 1
|
|
Battle._metaAct = { kind = "switch", user = "player", slot = 2 }
|
|
Battle._phase = "actions"
|
|
|
|
Battle.update(0, nil)
|
|
|
|
print("AFTER TURN st.player.mon.hp=", st.player.mon.hp, "pMon2.hp=", pMon2.hp, "pMon2.maxHp=", pMon2.maxHp)
|
|
eq(st.player.partyIndex, 2, "switched to slot 2 before enemy attack")
|
|
check(st.player.mon.hp < pMon2.maxHp, "enemy attack hit newly switched-in Pokémon (slot 2)")
|
|
eq(pMon1.hp, pMon1.maxHp, "withdrawn Pokémon (slot 1) took no damage")
|
|
|
|
Battle.abort()
|
|
end
|
|
|
|
print("\n--- Testing Pursuit Intercept on Switch ---")
|
|
do
|
|
-- Move 228 is PURSUIT
|
|
local pMon1 = Damage.ensureStats({ species = 1, level = 5, hp = 10, maxHp = 20, moves = { 33 }, pp = { 35 } })
|
|
local pMon2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
local foeMon = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, moves = { 228 }, pp = { 20 } })
|
|
|
|
Battle.start({ playerName = "RED",
|
|
headless = true,
|
|
autoFight = false,
|
|
playerParty = { pMon1, pMon2 },
|
|
foe = foeMon,
|
|
wild = true,
|
|
})
|
|
Battle.update(0, nil) -- Advance intro
|
|
|
|
local st = Battle.getState()
|
|
-- Enemy queues Pursuit, Player queues switch to slot 2
|
|
Battle._actions = {
|
|
{ kind = "move", user = "enemy", target = "player", move = 228, slot = 1 },
|
|
}
|
|
Battle._actionI = 1
|
|
Battle._metaAct = { kind = "switch", user = "player", slot = 2 }
|
|
Battle._phase = "actions"
|
|
|
|
Battle.update(0, nil)
|
|
|
|
check(State.isFainted(st.player) or st.player.partyIndex == 2, "Pursuit intercepted switch")
|
|
|
|
Battle.abort()
|
|
end
|
|
|
|
print("\n--- Testing Multi-Mon Enemy Trainer Battle & Shift Species Leak ---")
|
|
do
|
|
local pMon1 = Damage.ensureStats({ species = 1, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } })
|
|
local pMon2 = Damage.ensureStats({ species = 4, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } })
|
|
local eMon1 = Damage.ensureStats({ species = 16, level = 5, hp = 1, maxHp = 15, moves = { 33 }, pp = { 35 } })
|
|
local eMon2 = Damage.ensureStats({ species = 19, level = 5, hp = 15, maxHp = 15, moves = { 33 }, pp = { 35 } })
|
|
|
|
local ok, err = Battle.start({ playerName = "RED",
|
|
headless = true,
|
|
autoFight = false,
|
|
playerParty = { pMon1, pMon2 },
|
|
foeParty = { eMon1, eMon2 },
|
|
foe = { trainerId = 326 },
|
|
wild = false,
|
|
})
|
|
check(ok, "Trainer battle started with 2 enemy Pokémon")
|
|
Battle.update(0, nil) -- Advance intro
|
|
|
|
local st = Battle.getState()
|
|
eq(#st.foeParty, 2, "st.foeParty has 2 Pokémon")
|
|
eq(st.enemy.partyIndex, 1, "starts with enemy slot 1")
|
|
|
|
-- Defeat first enemy Pokémon (1 HP)
|
|
Battle._actions = {
|
|
{ kind = "move", user = "player", target = "enemy", move = 33, slot = 1 },
|
|
}
|
|
Battle._actionI = 1
|
|
Battle._phase = "actions"
|
|
Battle.update(0, nil)
|
|
|
|
eq(st.over, false, "battle is NOT over after first enemy Pokémon faints")
|
|
eq(st.enemy.partyIndex, 2, "enemy trainer sent out second Pokémon (slot 2)")
|
|
eq(st.enemy.species, 19, "second enemy Pokémon is species 19 (RATTATA)")
|
|
|
|
-- Defeat second enemy Pokémon
|
|
Battle._actions = {
|
|
{ kind = "move", user = "player", target = "enemy", move = 33, slot = 1 },
|
|
}
|
|
Battle._actionI = 1
|
|
Battle._phase = "actions"
|
|
Battle.update(0, nil)
|
|
|
|
eq(st.over, true, "battle ends when all enemy trainer Pokémon faint")
|
|
eq(st.result, "win", "battle result is win")
|
|
|
|
Battle.abort()
|
|
end
|
|
|
|
print("\n--- Testing Party Blackout Defeat ---")
|
|
do
|
|
local pMon1 = Damage.ensureStats({ species = 1, level = 5, hp = 1, maxHp = 20, moves = { 33 }, pp = { 35 } })
|
|
local foeMon = Damage.ensureStats({ species = 16, level = 20, hp = 50, maxHp = 50, moves = { 33 }, pp = { 35 } })
|
|
|
|
Battle.start({ playerName = "RED",
|
|
headless = true,
|
|
autoFight = false,
|
|
playerParty = { pMon1 },
|
|
foe = foeMon,
|
|
wild = true,
|
|
})
|
|
Battle.update(0, nil) -- Advance intro
|
|
|
|
local st = Battle.getState()
|
|
-- Enemy defeats only player Pokémon
|
|
Battle._actions = {
|
|
{ kind = "move", user = "enemy", target = "player", move = 33, slot = 1 },
|
|
}
|
|
Battle._actionI = 1
|
|
Battle._phase = "actions"
|
|
Battle.update(0, nil)
|
|
|
|
eq(st.over, true, "battle ended on player party blackout")
|
|
eq(st.result, "lose", "result is lose")
|
|
|
|
Battle.abort()
|
|
end
|
|
|
|
print("\n--- Testing PartyMenu Battle Switch & Faint Validations ---")
|
|
do
|
|
local party = {
|
|
{ species = 1, level = 10, hp = 25, maxHp = 25 }, -- Slot 1: Active
|
|
{ species = 4, level = 10, hp = 0, maxHp = 25 }, -- Slot 2: Fainted
|
|
{ species = 7, level = 10, hp = 20, maxHp = 25 }, -- Slot 3: Conscious bench
|
|
}
|
|
|
|
local selectedSlot = nil
|
|
local function validate(slot)
|
|
local mon = party[slot]
|
|
if mon and mon.hp <= 0 then return "BULBASAUR has no energy\nleft to battle!" end
|
|
return nil
|
|
end
|
|
PartyMenu.show(party, nil, {
|
|
mode = "battle_switch",
|
|
activeSlot = 1,
|
|
validate = validate,
|
|
onSelect = function(slot) selectedSlot = slot end,
|
|
})
|
|
|
|
local fakeInput = {
|
|
wasPressed = function(self, key) return key == "a" end,
|
|
}
|
|
PartyMenu.cursor = 2
|
|
PartyMenu.handleInput(fakeInput)
|
|
eq(PartyMenu.mode, "action", "A on a fainted mon opens the action menu")
|
|
PartyMenu.actionCursor = 1
|
|
PartyMenu.handleInput(fakeInput)
|
|
eq(PartyMenu.mode, "message", "SHIFT on a fainted mon shows the warning")
|
|
check(PartyMenu._messageText:find("no energy"), "message is pret's no-energy line")
|
|
PartyMenu.dismissMessage()
|
|
|
|
-- Move to slot 3 and pick
|
|
PartyMenu.cursor = 3
|
|
PartyMenu.handleInput(fakeInput)
|
|
eq(PartyMenu.mode, "action", "picking conscious bench mon opens action menu")
|
|
eq(PartyMenu.ACTIONS[1], "SHIFT", "action menu has SHIFT option")
|
|
|
|
-- Confirm SHIFT
|
|
PartyMenu.actionCursor = 1
|
|
PartyMenu.handleInput(fakeInput)
|
|
eq(selectedSlot, 3, "SHIFT confirms slot 3 selection")
|
|
eq(PartyMenu.isOpen(), false, "PartyMenu closed on confirmation")
|
|
|
|
-- Test battle_faint mode (must pick conscious, cannot cancel)
|
|
selectedSlot = nil
|
|
PartyMenu.show(party, nil, {
|
|
mode = "battle_faint",
|
|
activeSlot = 1,
|
|
onSelect = function(slot) selectedSlot = slot end,
|
|
})
|
|
eq(PartyMenu.mode, "battle_faint", "opened in battle_faint mode")
|
|
|
|
-- Try to cancel with B
|
|
local cancelInput = {
|
|
wasPressed = function(self, key) return key == "b" end,
|
|
}
|
|
PartyMenu.handleInput(cancelInput)
|
|
eq(PartyMenu.mode, "battle_faint", "pressing B cannot leave battle_faint mode")
|
|
|
|
-- Pick valid conscious slot 3 -> opens action menu
|
|
PartyMenu.cursor = 3
|
|
PartyMenu.handleInput(fakeInput)
|
|
eq(PartyMenu.mode, "action", "battle_faint opens action menu for conscious slot 3")
|
|
eq(PartyMenu.ACTIONS[1], "SEND OUT", "action menu has SEND OUT")
|
|
eq(PartyMenu.ACTIONS[2], "SUMMARY", "action menu has SUMMARY")
|
|
eq(PartyMenu.ACTIONS[3], "CANCEL", "action menu has CANCEL")
|
|
|
|
-- Confirm SHIFT
|
|
PartyMenu.actionCursor = 1
|
|
PartyMenu.handleInput(fakeInput)
|
|
eq(selectedSlot, 3, "SEND OUT confirms slot 3 selection in battle_faint")
|
|
eq(PartyMenu.isOpen(), false, "PartyMenu closed after faint replacement")
|
|
end
|
|
|
|
print("\n--- Testing Multi-Mon EXP Split & Bench Participation ---")
|
|
do
|
|
local pMon1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000, moves = { 33 }, pp = { 35 } })
|
|
local pMon2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000, moves = { 33 }, pp = { 35 } })
|
|
local foeMon = Damage.ensureStats({ species = 16, level = 10, hp = 1, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
|
|
Battle.start({ playerName = "RED",
|
|
headless = true,
|
|
autoFight = false,
|
|
playerParty = { pMon1, pMon2 },
|
|
foe = foeMon,
|
|
wild = true,
|
|
})
|
|
Battle.update(0, nil)
|
|
|
|
local st = Battle.getState()
|
|
-- Switch from slot 1 to slot 2
|
|
Battle._actions = {}
|
|
Battle._actionI = 1
|
|
Battle._metaAct = { kind = "switch", user = "player", slot = 2 }
|
|
Battle._phase = "actions"
|
|
Battle.update(0, nil)
|
|
eq(st.player.partyIndex, 2, "switched to slot 2")
|
|
|
|
-- Defeat enemy with slot 2
|
|
local p1ExpBefore = pMon1.exp
|
|
local p2ExpBefore = pMon2.exp
|
|
Battle._actions = {
|
|
{ kind = "move", user = "player", target = "enemy", move = 33, slot = 1 },
|
|
}
|
|
Battle._actionI = 1
|
|
Battle._phase = "actions"
|
|
Battle.update(0, nil)
|
|
|
|
check(pMon1.exp > p1ExpBefore, "outgoing bench mon (slot 1) received participation EXP")
|
|
check(pMon2.exp > p2ExpBefore, "finishing active mon (slot 2) received participation EXP")
|
|
eq(pMon1.exp - p1ExpBefore, pMon2.exp - p2ExpBefore, "EXP split equally between both participants")
|
|
|
|
Battle.abort()
|
|
end
|
|
|
|
print("\n--- Testing SwitchSeq.beginShiftSwitch Interleaving & Timing ---")
|
|
do
|
|
local st = State.new({
|
|
playerParty = {
|
|
{ species = 1, level = 10, hp = 25, maxHp = 30, speed = 20 },
|
|
{ species = 4, level = 10, hp = 30, maxHp = 30, speed = 25 },
|
|
},
|
|
foeParty = {
|
|
{ species = 16, level = 10, hp = 0, maxHp = 30, speed = 15 },
|
|
{ species = 19, level = 10, hp = 30, maxHp = 30, speed = 30 },
|
|
},
|
|
trainerName = "CAMPER",
|
|
})
|
|
-- include/constants/trainers.h:244
|
|
st.trainerClass = 61
|
|
st.trainerName = "RICKY"
|
|
local logged = {}
|
|
local pushMsg = function(t) logged[#logged + 1] = t end
|
|
|
|
-- Headless test
|
|
SwitchSeq.beginShiftSwitch(st, 2, 2, { headless = true, pushMsg = pushMsg })
|
|
eq(st.player.partyIndex, 2, "player active slot is 2")
|
|
eq(st.enemy.partyIndex, 2, "enemy active slot is 2")
|
|
check(logged[1]:find("that's enough!"), "step 1: player recall message")
|
|
check(logged[2]:find("Go!"), "step 2: player sendout message (BEFORE enemy sendout)")
|
|
check(logged[3]:find("sent\nout"), "step 3: enemy sendout message (AFTER player sendout)")
|
|
|
|
-- Non-headless step ordering test
|
|
SwitchSeq.beginShiftSwitch(st, 2, 2, { headless = false, pushMsg = pushMsg })
|
|
local kinds = {}
|
|
for _, s in ipairs(SwitchSeq._steps or {}) do
|
|
kinds[#kinds + 1] = s.kind
|
|
end
|
|
|
|
local function find_step(k, startI)
|
|
for i = (startI or 1), #kinds do
|
|
if kinds[i] == k then return i end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local iWithdraw = find_step("withdraw")
|
|
local iEnemySend = find_step("sendout_enemy")
|
|
local iPlayerSend = find_step("sendout_player")
|
|
local iEntryTriggers = find_step("entry_triggers", iEnemySend)
|
|
|
|
check(iWithdraw ~= nil, "has withdraw step")
|
|
check(iEnemySend ~= nil, "has sendout_enemy step")
|
|
check(iPlayerSend ~= nil, "has sendout_player step")
|
|
check(iEntryTriggers ~= nil, "has entry_triggers step")
|
|
|
|
check(iWithdraw < iPlayerSend, "player withdraws BEFORE player sends out")
|
|
check(iPlayerSend < iEnemySend, "player sends out BEFORE enemy sends out (retail FRLG)")
|
|
check(iEnemySend < iEntryTriggers, "entry triggers fire AFTER the enemy send-out")
|
|
end
|
|
|
|
print("\n--- Testing SwitchSeq.beginTrainerSlideIn ---")
|
|
do
|
|
local st = State.new({
|
|
trainerId = 326,
|
|
trainerName = "LASS",
|
|
foeParty = { { species = 16, hp = 0, maxHp = 20 } },
|
|
})
|
|
SwitchSeq.beginTrainerSlideIn(st, { headless = false })
|
|
check(SwitchSeq._steps ~= nil and #SwitchSeq._steps == 1, "trainer slide-in step created")
|
|
eq(SwitchSeq._steps[1].kind, "trainer_slide_in", "step is trainer_slide_in")
|
|
end
|
|
|
|
print("\n--- Testing Double KO / Mutual Faint Resolution ---")
|
|
do
|
|
-- Player and Enemy both have 1 HP and player uses move with recoil
|
|
local pMon1 = Damage.ensureStats({ species = 1, level = 10, hp = 1, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
local pMon2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
local eMon1 = Damage.ensureStats({ species = 16, level = 10, hp = 1, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
local eMon2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, moves = { 33 }, pp = { 35 } })
|
|
|
|
local ok = Battle.start({ playerName = "RED",
|
|
headless = true,
|
|
autoFight = false,
|
|
playerParty = { pMon1, pMon2 },
|
|
foeParty = { eMon1, eMon2 },
|
|
foe = { trainerId = 326 },
|
|
wild = false,
|
|
})
|
|
check(ok, "Battle started for mutual faint test")
|
|
Battle.update(0, nil)
|
|
|
|
local st = Battle.getState()
|
|
-- Set both active mons to 0 HP simultaneously
|
|
st.player.mon.hp = 0
|
|
st.enemy.mon.hp = 0
|
|
|
|
Battle._actions = {}
|
|
Battle._actionI = 1
|
|
Battle._phase = "actions"
|
|
Battle.update(0, nil)
|
|
|
|
eq(st.over, false, "mutual faint does not end battle when both have reserves")
|
|
eq(st.enemy.partyIndex, 2, "enemy sent out next mon without shift prompt")
|
|
eq(st.player.partyIndex, 2, "player sent out next mon without shift prompt")
|
|
|
|
Battle.abort()
|
|
end
|
|
|
|
print("\n--- Testing Dialogue Formatting & 2-Line Pagination Bounds ---")
|
|
do
|
|
local TextIR = require("src.core.game3.scripting.text_ir")
|
|
local Message = require("src.ui.game3.message")
|
|
|
|
-- Test 1: Shift prompt with \p formats into exactly 2 pages with <= 2 lines each
|
|
local rawPrompt = "BUG CATCHER DOUG is\nabout to use WEEDLE.\\pWill RED change\nPOKéMON?"
|
|
local ir = TextIR.fromAscii(rawPrompt)
|
|
local boxText = TextIR.toTextBox(ir)
|
|
Message.show(boxText, { frame = "battle" })
|
|
|
|
eq(#Message._pages, 2, "Shift prompt splits into exactly 2 pages")
|
|
for pi, p in ipairs(Message._pages) do
|
|
local lineCount = 1
|
|
for _ in p:gmatch("\n") do lineCount = lineCount + 1 end
|
|
check(lineCount <= 2, string.format("Page %d has at most 2 lines (got %d: %q)", pi, lineCount, p))
|
|
end
|
|
Message.close()
|
|
|
|
-- Test 2: Unformatted 3-line string with literal newlines automatically paginates without overflowing
|
|
local raw3Line = "Line 1\nLine 2\nLine 3"
|
|
local ir2 = TextIR.fromAscii(raw3Line)
|
|
local boxText2 = TextIR.toTextBox(ir2)
|
|
Message.show(boxText2, { frame = "battle" })
|
|
eq(#Message._pages, 2, "3-line string paginates across 2 pages")
|
|
check(not Message._pages[1]:find("Line 3"), "Page 1 does not contain Line 3")
|
|
check(Message._pages[2]:find("Line 3") ~= nil, "Page 2 contains Line 3")
|
|
Message.close()
|
|
|
|
-- Test 3: Long single sentence without newlines wraps and paginates at <= 2 lines per page
|
|
local longSentence = "In the world which you are about to enter, you will embark on a grand adventure with you as the hero."
|
|
local ir3 = TextIR.fromAscii(longSentence)
|
|
local boxText3 = TextIR.toTextBox(ir3, { maxWidth = 208 })
|
|
Message.show(boxText3, { frame = "dialogue" })
|
|
check(#Message._pages >= 2, "Long sentence paginates across multiple pages")
|
|
for pi, p in ipairs(Message._pages) do
|
|
local lineCount = 1
|
|
for _ in p:gmatch("\n") do lineCount = lineCount + 1 end
|
|
check(lineCount <= 2, string.format("Dialogue page %d has <= 2 lines (got %d: %q)", pi, lineCount, p))
|
|
end
|
|
Message.close()
|
|
end
|
|
|
|
print("\n--- Testing In-Battle Level Up Stat Growth Window ---")
|
|
do
|
|
local Experience = require("src.core.game3.battle.experience")
|
|
local ExpSeq = require("src.core.game3.battle.exp_seq")
|
|
local StatGrowth = require("src.ui.game3.stat_growth")
|
|
local Ui = require("src.core.game3.battle.ui")
|
|
|
|
local mon = {
|
|
species = 1,
|
|
level = 5,
|
|
hp = 20,
|
|
maxHp = 20,
|
|
attack = 10,
|
|
defense = 10,
|
|
spAtk = 12,
|
|
spDef = 12,
|
|
speed = 9,
|
|
exp = Experience.expForLevel(3, 5),
|
|
growthRate = 3,
|
|
}
|
|
|
|
local res = Experience.apply(mon, 100)
|
|
check(#res.levels > 0, "experience award leveled up mon")
|
|
check(res.steps[1].oldStats ~= nil, "recorded oldStats before level up")
|
|
check(res.steps[1].newStats ~= nil, "recorded newStats after level up")
|
|
|
|
local messages = {}
|
|
local awards = {
|
|
{ mon = mon, result = res, partyIndex = 1 },
|
|
}
|
|
|
|
Ui.reset({ headless = false })
|
|
local started = ExpSeq.begin(awards, function(t) table.insert(messages, t) end, nil, {
|
|
headless = false,
|
|
})
|
|
check(started, "ExpSeq started with level up steps")
|
|
|
|
-- Pump exp gain message
|
|
ExpSeq.update()
|
|
Ui._showing = false
|
|
Ui._queue = {}
|
|
ExpSeq.update()
|
|
|
|
-- Pump exp bar anim
|
|
local Anim = require("src.core.game3.battle.anim")
|
|
Anim.reset({ headless = false })
|
|
ExpSeq.update()
|
|
|
|
-- Clear level up message -> opens StatGrowth
|
|
Ui._showing = false
|
|
Ui._queue = {}
|
|
ExpSeq.update()
|
|
check(StatGrowth.isOpen(), "StatGrowth window opened on level up")
|
|
eq(StatGrowth._page, 1, "StatGrowth starts on Page 1 (diffs)")
|
|
|
|
-- #2324: the sequence must WAIT on the window, not run past it.
|
|
local stepBefore = ExpSeq._i
|
|
local msgsBefore = #messages
|
|
for _ = 1, 20 do
|
|
eq(ExpSeq.update(), false, "ExpSeq.update() reports busy while the stat window is open")
|
|
end
|
|
eq(ExpSeq._i, stepBefore, "ExpSeq did not advance past the open stat window")
|
|
eq(#messages, msgsBefore, "no battle text pushed while the stat window is open")
|
|
check(StatGrowth.isOpen(), "StatGrowth still open after idle pumps")
|
|
eq(StatGrowth._page, 1, "StatGrowth still on Page 1 after idle pumps")
|
|
|
|
-- Advance to Page 2
|
|
local fakeInput = {
|
|
wasPressed = function(self, key) return key == "a" end,
|
|
}
|
|
StatGrowth.handleInput(fakeInput)
|
|
check(StatGrowth.isOpen(), "StatGrowth still open on Page 2")
|
|
eq(StatGrowth._page, 2, "StatGrowth on Page 2 (new values)")
|
|
|
|
-- #2324: Page 2 is still a wait
|
|
for _ = 1, 20 do
|
|
eq(ExpSeq.update(), false, "ExpSeq.update() reports busy on stat window Page 2")
|
|
end
|
|
eq(ExpSeq._i, stepBefore, "ExpSeq still did not advance on Page 2")
|
|
check(StatGrowth.isOpen(), "StatGrowth still open after idle pumps on Page 2")
|
|
eq(#messages, msgsBefore, "still no battle text while Page 2 is up")
|
|
|
|
-- Confirm Page 2 -> closes window and advances sequence
|
|
StatGrowth.handleInput(fakeInput)
|
|
check(not StatGrowth.isOpen(), "StatGrowth closed after confirmation")
|
|
eq(ExpSeq._i, stepBefore + 1, "ExpSeq advanced only once the window closed")
|
|
|
|
-- #2324: a window whose phase can no longer dismiss it must be torn down.
|
|
local savedActive, savedPhase, savedHeadless = Battle._active, Battle._phase, Battle._headless
|
|
Battle._active, Battle._phase, Battle._headless = true, nil, true
|
|
StatGrowth.open(mon, res.steps[1].oldStats, res.steps[1].newStats, function()
|
|
error("[FAIL] a torn-down stat window must not fire its onDone callback")
|
|
end)
|
|
check(StatGrowth.isOpen(), "stale stat window open before Battle.update")
|
|
Battle.update(1 / 60, { input = fakeInput })
|
|
check(not StatGrowth.isOpen(), "Battle.update closed a stat window outside its phases")
|
|
Battle._active, Battle._phase, Battle._headless = savedActive, savedPhase, savedHeadless
|
|
end
|
|
|
|
print("\nALL BATTLE SWITCH & FAINT TESTS PASSED! (100%)")
|