Files
gen1recomp/tests/gen2_battle_tower_battle_test.lua

635 lines
26 KiB
Lua

-- The Battle Tower BATTLE: the roster tables, the opponent draw and the two
-- specials maps/BattleTowerBattleRoom.asm's loop is built out of.
--
-- luajit tests/gen2_battle_tower_battle_test.lua
--
-- ROM-free. The extractor half runs against a synthetic cartridge whose
-- tables are laid out byte for byte the way data/battle_tower/ assembles
-- them, and the rest against a fixture roster taken from
-- ../pokecrystal/data/battle_tower/parties.asm group 1.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battle tower battle")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local BattleTower = require("src.core.gen2.BattleTower")
local Mon = require("src.battle.gen2.Mon")
local RomExtractorGen2 = require("src.import.RomExtractorGen2")
local Save = require("src.core.gen2.Save")
local Specials = require("src.script.gen2.Specials")
-- ---------------------------------------------------------------- fixtures
-- ../pokecrystal/data/pokemon/base_stats/*.asm, the five species group 1 of
-- BattleTowerMons opens with.
local POKEMON = {
JOLTEON = { name = "JOLTEON", index = 135, types = { "ELECTRIC" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 65, attack = 65, defense = 60, speed = 130,
specialAttack = 110, specialDefense = 95 } },
ESPEON = { name = "ESPEON", index = 196, types = { "PSYCHIC_TYPE" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 65, attack = 65, defense = 60, speed = 110,
specialAttack = 130, specialDefense = 95 } },
UMBREON = { name = "UMBREON", index = 197, types = { "DARK" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 95, attack = 65, defense = 110, speed = 65,
specialAttack = 60, specialDefense = 130 } },
WOBBUFFET = { name = "WOBBUFFET", index = 202, types = { "PSYCHIC_TYPE" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 190, attack = 33, defense = 58, speed = 33,
specialAttack = 33, specialDefense = 58 } },
KANGASKHAN = { name = "KANGASKHAN", index = 115, types = { "NORMAL" },
growthRate = "MEDIUM_FAST", levelMoves = {},
baseStats = { hp = 105, attack = 95, defense = 80, speed = 90,
specialAttack = 40, specialDefense = 80 } },
}
local MOVES = {
THUNDERBOLT = { pp = 15 }, HYPER_BEAM = { pp = 5 }, SHADOW_BALL = { pp = 15 },
ROAR = { pp = 20 }, MUD_SLAP = { pp = 10 }, PSYCHIC_M = { pp = 10 },
PSYCH_UP = { pp = 10 }, TOXIC = { pp = 10 }, IRON_TAIL = { pp = 15 },
COUNTER = { pp = 20 }, MIRROR_COAT = { pp = 20 }, SAFEGUARD = { pp = 25 },
DESTINY_BOND = { pp = 5 }, REVERSAL = { pp = 15 }, EARTHQUAKE = { pp = 10 },
ATTRACT = { pp = 15 },
}
-- ../pokecrystal/data/battle_tower/parties.asm:4-140, transcribed field for
-- field: the DVs, the stat exp words, the PP bytes and the stats the cart
-- copies straight into wOTPartyMon.
local function row(species, item, moves, pp, dvs, statExp, stats)
return {
species = species, item = item, moves = moves, pp = pp,
level = 10, happiness = 100, experience = 1000, otId = 0,
dvs = { attack = dvs[1], defense = dvs[2], speed = dvs[3],
special = dvs[4] },
statExp = { hp = statExp[1], attack = statExp[2], defense = statExp[3],
speed = statExp[4], special = statExp[5] },
hp = stats[1], maxHp = stats[1],
stats = { hp = stats[1], attack = stats[2], defense = stats[3],
speed = stats[4], specialAttack = stats[5], specialDefense = stats[6] },
}
end
local GROUP1 = {
row("JOLTEON", "MIRACLEBERRY",
{ "THUNDERBOLT", "HYPER_BEAM", "SHADOW_BALL", "ROAR" }, { 15, 5, 15, 20 },
{ 13, 13, 11, 13 }, { 50000, 40000, 40000, 35000, 40000 },
{ 41, 25, 24, 37, 34, 31 }),
row("ESPEON", "LEFTOVERS",
{ "MUD_SLAP", "PSYCHIC_M", "PSYCH_UP", "TOXIC" }, { 10, 10, 10, 10 },
{ 14, 13, 15, 11 }, { 40000, 50000, 35000, 40000, 40000 },
{ 39, 26, 24, 35, 38, 31 }),
row("UMBREON", "GOLD_BERRY",
{ "SHADOW_BALL", "IRON_TAIL", "PSYCH_UP", "TOXIC" }, { 15, 15, 10, 10 },
{ 13, 11, 14, 15 }, { 40000, 40000, 45000, 50000, 40000 },
{ 46, 25, 34, 26, 25, 39 }),
row("WOBBUFFET", "FOCUS_BAND",
{ "COUNTER", "MIRROR_COAT", "SAFEGUARD", "DESTINY_BOND" },
{ 20, 20, 25, 5 }, { 7, 15, 13, 7 },
{ 50000, 50000, 50000, 50000, 50000 }, { 66, 18, 25, 19, 18, 23 }),
-- MIRACLEBERRY again, which is the item collision the draw refuses.
row("KANGASKHAN", "MIRACLEBERRY",
{ "REVERSAL", "HYPER_BEAM", "EARTHQUAKE", "ATTRACT" }, { 15, 5, 10, 15 },
{ 14, 15, 12, 15 }, { 40000, 30000, 40000, 30000, 30000 },
{ 47, 31, 29, 29, 20, 28 }),
}
-- ../pokecrystal/data/battle_tower/classes.asm:11-17
local ROSTER = {
partyLength = 3,
levelGroups = 2,
uniqueMon = #GROUP1,
uniqueTrainers = 4,
sampleTrainers = 4,
trainers = {
{ index = 0, name = "HANSON", class = 37, classId = "FISHER" },
{ index = 1, name = "SAWYER", class = 30, classId = "POKEMANIAC" },
{ index = 2, name = "MASUDA", class = 43, classId = "GUITARIST" },
{ index = 3, name = "NICKEL", class = 20, classId = "SCIENTIST" },
},
groups = { GROUP1, GROUP1 },
classSprites = {
FISHER = "SPRITE_FISHER", POKEMANIAC = "SPRITE_SUPER_NERD",
GUITARIST = "SPRITE_ROCKER", SCIENTIST = "SPRITE_SCIENTIST",
},
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
trainers = {
battleTower = ROSTER,
classes = {
FISHER = { index = 37, name = "FISHER", baseMoney = 40,
attributes = { 0, 0, 40, 1, 0, 0, 0 }, items = {} },
POKEMANIAC = { index = 30, name = "POKEMANIAC", baseMoney = 60,
attributes = { 0, 0, 60, 1, 0, 0, 0 }, items = {} },
GUITARIST = { index = 43, name = "GUITARIST", baseMoney = 36,
attributes = { 0, 0, 36, 1, 0, 0, 0 }, items = {} },
SCIENTIST = { index = 20, name = "SCIENTIST", baseMoney = 44,
attributes = { 0, 0, 44, 1, 0, 0, 0 }, items = {} },
},
},
}
local function crystalSave(levelGroup)
local save = Save.normalize({ version = "crystal", generation = 2 })
save.battleTower.levelGroup = levelGroup or 1
save.party = {}
return save
end
-- A roll that walks a canned list, so every draw below is pinned. Same
-- 1..n convention Specials.random uses.
local function rolls(list)
local at = 0
return function(n)
at = at + 1
local value = list[at] or 1
if value > n then value = ((value - 1) % n) + 1 end
return value
end
end
local function fakeVm(save, hooks)
local vm = {
scriptVar = 0,
mem = {},
stringBuffer = "",
specials = hooks or {},
}
vm.specials.save = vm.specials.save or function() return save end
vm.specials.data = vm.specials.data or function() return DATA end
vm.specials.party = vm.specials.party or function() return save.party end
function vm:setStringBuffer(value) self.stringBuffer = value or "" end
function vm:showRaw() end
return vm
end
-- =========================================== the extractor's roster decode
--
-- A synthetic cartridge: BattleTowerTrainers, BattleTowerMons and
-- BTTrainerClassSprites laid out exactly as data/battle_tower/classes.asm,
-- parties.asm and data/trainers/sprites.asm assemble, plus the two
-- `maskbits N / cp N / jr nc` pairs load_trainer.asm samples with.
do
local NAME_LENGTH, MON_NAME_LENGTH = 11, 11
local NICKNAMED = 48 + MON_NAME_LENGTH
local TRAINERS_AT, MONS_AT = 0x4100, 0x4100 + 3 * NAME_LENGTH
local SPRITES_AT, TR_SAMPLE_AT, MON_SAMPLE_AT = 0x5000, 0x5100, 0x5200
local bytes = {}
local function put(offset, list)
for index, value in ipairs(list) do bytes[offset + index] = value end
end
local function name(text, width)
local out = {}
for index = 1, width do
out[index] = (index <= #text) and text:byte(index) or 0x50
end
return out
end
-- charmap rows for the letters the three names use.
local charmap, letters = {}, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for index = 1, #letters do
charmap[tostring(0x80 + index - 1)] = letters:sub(index, index)
end
local function encode(text, width)
local out = {}
for index = 1, width do
if index <= #text then
out[index] = 0x80 + (text:byte(index) - 65)
else
out[index] = 0x50
end
end
return out
end
-- Three rows, the third exactly NAME_LENGTH - 1 long so the unterminated
-- name the `dname` macro leaves (classes.asm:16 ZABOROWSKI) is covered.
local rows = { { "ABLE", 1 }, { "BAKER", 2 }, { "CDEFGHIJKL", 3 } }
for index, entry in ipairs(rows) do
local at = TRAINERS_AT + (index - 1) * NAME_LENGTH
put(at, encode(entry[1], NAME_LENGTH - 1))
bytes[at + NAME_LENGTH] = entry[2]
end
-- One level group of two mons. Only the fields the reader names are set;
-- everything else stays 0, which is what ByteFill leaves.
local function monBytes(species, item, moves, pp, dv1, dv2, level, stats)
local raw = {}
for index = 1, NICKNAMED do raw[index] = 0 end
raw[1] = species
raw[2] = item
for slot = 1, 4 do raw[2 + slot] = moves[slot] or 0 end
raw[9], raw[10], raw[11] = 0, 0x03, 0xe8
raw[12], raw[13] = 0xc3, 0x50
raw[22], raw[23] = dv1, dv2
for slot = 1, 4 do raw[23 + slot] = pp[slot] or 0 end
raw[28] = 100
raw[32] = level
raw[35], raw[36] = 0, stats[1]
raw[37], raw[38] = 0, stats[1]
raw[39], raw[40] = 0, stats[2]
raw[41], raw[42] = 0, stats[3]
raw[43], raw[44] = 0, stats[4]
raw[45], raw[46] = 0, stats[5]
raw[47], raw[48] = 0, stats[6]
return raw
end
put(MONS_AT, monBytes(135, 109, { 85, 63, 247, 46 }, { 15, 5, 15, 20 },
0xdd, 0xbd, 10, { 41, 25, 24, 37, 34, 31 }))
put(MONS_AT + NICKNAMED, monBytes(196, 78, { 189, 94, 0, 0 }, { 10, 10, 0, 0 },
0xed, 0xfb, 10, { 39, 26, 24, 35, 38, 31 }))
put(SPRITES_AT, { 7, 9, 11 })
-- `and $03 / cp 3 / jr nc, .resample` and `and $01 / cp 2 / jr nc`.
put(TR_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47,
0xe6, 0x03, 0xfe, 0x03, 0x30, 0xf3 })
put(MON_SAMPLE_AT, { 0xcd, 0x8c, 0x2f, 0xf0, 0xe1, 0x80, 0x47,
0xe6, 0x01, 0xfe, 0x02, 0x30, 0xf3 })
local rom = {}
for index = 1, 0x8000 do rom[index] = string.char(bytes[index] or 0) end
local manifest = {
romSha1 = "",
charmap = charmap,
constants = {
trainerClassOrder = { "TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY",
"MYSTICALMAN" },
spriteOrder = { "SPRITE_A", "SPRITE_B", "SPRITE_C", "SPRITE_D",
"SPRITE_E", "SPRITE_F", "SPRITE_G", "SPRITE_H", "SPRITE_I",
"SPRITE_J", "SPRITE_K" },
moveOrder = {}, itemOrder = {}, speciesOrder = {},
},
symbols = {
BattleTowerTrainers = { 1, TRAINERS_AT },
BattleTowerMons = { 1, MONS_AT },
BTTrainerClassSprites = { 1, SPRITES_AT },
["LoadOpponentTrainerAndPokemon.resample"] = { 1, TR_SAMPLE_AT },
["LoadRandomBattleTowerMon.resample"] = { 1, MON_SAMPLE_AT },
},
}
manifest.constants.speciesOrder[135] = "JOLTEON"
manifest.constants.speciesOrder[196] = "ESPEON"
manifest.constants.itemOrder[109] = "MIRACLEBERRY"
manifest.constants.itemOrder[78] = "LEFTOVERS"
manifest.constants.moveOrder[85] = "THUNDERBOLT"
manifest.constants.moveOrder[63] = "HYPER_BEAM"
manifest.constants.moveOrder[247] = "SHADOW_BALL"
manifest.constants.moveOrder[46] = "ROAR"
manifest.constants.moveOrder[189] = "MUD_SLAP"
manifest.constants.moveOrder[94] = "PSYCHIC_M"
local ex = RomExtractorGen2.new(table.concat(rom), manifest, nil)
local read = ex:readBattleTowerRoster(manifest.constants, charmap)
check(read ~= nil, "the roster reads out of the cartridge")
eq(read.uniqueTrainers, 3, "the trainer count is the gap to BattleTowerMons")
eq(read.uniqueMon, 2, "NUM_UNIQUE_MON comes off the mon resample loop")
eq(read.sampleTrainers, 3,
"and the trainer ceiling off its own, which Crystal 1.0 gets wrong")
eq(read.trainers[1].name, "ABLE", "a terminated name stops at '@'")
eq(read.trainers[3].name, "CDEFGHIJKL",
"and a full-width one runs to NAME_LENGTH - 1 with no terminator")
eq(read.trainers[2].classId, "WHITNEY", "the class byte resolves to a name")
eq(read.classSprites.FALKNER, "SPRITE_G", "class 1 takes the first sprite")
eq(read.classSprites.BUGSY, "SPRITE_K", "class 3 the third")
eq(read.classSprites.MYSTICALMAN, nil,
"and MYSTICALMAN is off the end of BTTrainerClassSprites")
eq(#read.groups, 10, "ten level groups are always read")
local first = read.groups[1][1]
eq(first.species, "JOLTEON", "group 1 mon 1 species")
eq(first.item, "MIRACLEBERRY", "its held item")
eq(first.level, 10, "its level")
eq(first.happiness, 100, "its happiness")
eq(first.experience, 1000, "its three exp bytes, big-endian")
eq(first.statExp.hp, 50000, "its HP stat exp word")
eq(first.dvs.attack, 13, "attack DV out of the high nibble")
eq(first.dvs.defense, 13, "defense DV out of the low nibble")
eq(first.dvs.speed, 11, "speed DV")
eq(first.dvs.special, 13, "special DV")
eq(#first.moves, 4, "four moves")
eq(first.moves[3], "SHADOW_BALL", "move three")
eq(first.pp[2], 5, "and its PP byte")
eq(first.maxHp, 41, "the stored max HP")
eq(first.stats.speed, 37, "and the stored Speed")
local second = read.groups[1][2]
eq(#second.moves, 2, "a row with two NO_MOVE slots keeps two moves")
eq(#second.pp, 2, "and two PP bytes")
end
-- =========================== Mon.stats agrees with the cart's stored stats
--
-- ../pokecrystal/engine/pokemon/move_mon.asm CalcMonStats is what produced the
-- bytes in parties.asm, so the port's own formula has to land on them.
do
for _, mon in ipairs(GROUP1) do
local dvs = { attack = mon.dvs.attack, defense = mon.dvs.defense,
speed = mon.dvs.speed, special = mon.dvs.special }
local stats = Mon.stats(POKEMON[mon.species].baseStats, dvs, mon.level,
mon.statExp)
eq(stats.hp, mon.stats.hp, mon.species .. " HP matches the ROM")
eq(stats.attack, mon.stats.attack, mon.species .. " Attack matches")
eq(stats.defense, mon.stats.defense, mon.species .. " Defense matches")
eq(stats.speed, mon.stats.speed, mon.species .. " Speed matches")
eq(stats.specialAttack, mon.stats.specialAttack,
mon.species .. " Sp.Atk matches")
eq(stats.specialDefense, mon.stats.specialDefense,
mon.species .. " Sp.Def matches")
end
end
-- ================================================ roster lookup and gating
do
eq(BattleTower.roster(DATA), ROSTER, "the roster comes off trainers.lua")
eq(BattleTower.roster({ trainers = {} }), nil,
"a Gold cache has no battleTower block")
eq(BattleTower.roster({ trainers = { battleTower = { trainers = {} } } }), nil,
"and half a block is not one either")
eq(BattleTower.opponentGroup(0, ROSTER), 1,
"a level group of 0 cannot index before the table")
eq(BattleTower.opponentGroup(9, ROSTER), 2,
"nor past the last group the roster carries")
eq(BattleTower.opponentGroup(2, ROSTER), 2, "a real group is kept")
end
-- load_trainer.asm:104
local GROUP2 = {}
for i, base in ipairs(GROUP1) do
local copy = {}
for key, value in pairs(base) do copy[key] = value end
copy.level = 20
GROUP2[i] = copy
end
local ROSTER2 = {}
for key, value in pairs(ROSTER) do ROSTER2[key] = value end
ROSTER2.groups = { GROUP1, GROUP2 }
local DATA2 = { pokemon = POKEMON, moves = MOVES,
trainers = { battleTower = ROSTER2, classes = DATA.trainers.classes } }
do
local save = crystalSave(1)
local opponent = BattleTower.drawOpponent(DATA2, save, rolls({ 1, 1, 1, 1 }), 2)
eq(opponent and opponent.group, 2, "the draw takes the WRAM level group")
eq(opponent and opponent.rows[1].level, 20, "and the rows come from that group")
eq(save.battleTower.levelGroup, 1, "while the SRAM byte stays untouched")
local vm = fakeVm(crystalSave(1), {
data = function() return DATA2 end,
pushScreen = function(_id, opts) opts.onDone(2) return true end,
})
Specials.ALL.BattleTowerRoomMenu(vm)
eq(vm.btLevelGroup, 2, "the room menu writes wBTChoiceOfLvlGroup")
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm)
eq(vm.btOpponent and vm.btOpponent.group, 2,
"and the next draw reads it without SAVELEVELGROUP ever running")
eq(vm.btOpponent and vm.btOpponent.rows[1].level, 20, "L20 mons")
end
-- ============================================== the trainer draw and sBTTrainers
do
local save = crystalSave()
local trainer = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 }))
eq(trainer.name, "SAWYER", "the second row is drawn")
eq(save.battleTower.trainers[1], 1,
"and recorded in sBTTrainers[sNrOfBeatenBattleTowerTrainers]")
-- ../pokecrystal/engine/events/battle_tower/load_trainer.asm:44-51: the
-- streak list is what the reroll refuses.
save.battleTower.streak = 1
local again = BattleTower.chooseTrainer(save, ROSTER, rolls({ 2 }))
check(again.name ~= "SAWYER", "a trainer already in the streak is refused")
eq(save.battleTower.trainers[2], again.index,
"and the new one lands in the next slot")
-- :29-37, Crystal 1.0's ceiling: with sampleTrainers 2 only the first two
-- rows can ever come out, however the roll lands.
local capped = { partyLength = 3, levelGroups = 1, uniqueMon = #GROUP1,
uniqueTrainers = 4, sampleTrainers = 2, trainers = ROSTER.trainers,
groups = { GROUP1 }, classSprites = ROSTER.classSprites }
local seen = {}
for roll = 1, 8 do
local fresh = crystalSave()
seen[BattleTower.chooseTrainer(fresh, capped, rolls({ roll })).index] = true
end
eq(seen[2], nil, "row 2 is past the 1.0 ceiling and never drawn")
eq(seen[3], nil, "nor row 3")
check(seen[0] and seen[1], "the first two rows both are")
end
-- ================================================== the three-mon team draw
do
local save = crystalSave()
local team = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 }))
eq(#team, 3, "three mons come out")
eq(team[1].species, "JOLTEON", "the first roll takes the first row")
-- :131-136, the species and item collisions: JOLTEON is out, and so is
-- KANGASKHAN, which holds JOLTEON's MIRACLEBERRY.
eq(team[2].species, "ESPEON",
"the second roll skips the species already picked")
eq(team[3].species, "UMBREON", "and so does the third")
local held = {}
for _, mon in ipairs(team) do
eq(held[mon.item], nil, mon.species .. " brings an unheld item")
held[mon.item] = true
end
-- :149-166 and :195-206: the last two teams' species are refused too.
eq(save.battleTower.prevTeams.prev[1], "JOLTEON",
"the drawn team becomes sBTMonPrevTrainer")
eq(#save.battleTower.prevTeams.prevPrev, 0,
"with nothing displaced into sBTMonPrevPrevTrainer yet")
local second = BattleTower.chooseTeam(save, ROSTER, 1, rolls({ 1, 1, 1 }))
eq(second[1].species, "WOBBUFFET", "the next team skips the previous one")
eq(second[2].species, "KANGASKHAN", "and keeps skipping it")
eq(save.battleTower.prevTeams.prevPrev[1], "JOLTEON",
"and the old team shifts down to sBTMonPrevPrevTrainer")
-- Five rows cannot field three fresh species twice running, which is
-- exactly where the cart's `jr z, .FindARandomBattleTowerMon` would spin
-- forever: the port takes the unfiltered draw instead and still hands back
-- a full team. Twenty-one rows never reach it.
eq(#second, 3, "an exhausted pool still fields three rather than hanging")
eq(second[3].species, "JOLTEON", "the third falls back to a plain roll")
end
-- ============================================== the party the battle fights
do
local party = BattleTower.battleParty(DATA, { GROUP1[1], GROUP1[3] })
eq(#party, 2, "one battle mon per roster row")
local jolteon = party[1]
eq(jolteon.species, "JOLTEON", "species")
eq(jolteon.name, "JOLTEON", "and the species name, not the ROM nickname")
eq(jolteon.nickname, nil, "which is why no nickname is carried")
eq(jolteon.level, 10, "level")
eq(jolteon.item, "MIRACLEBERRY", "held item")
eq(jolteon.happiness, 100, "happiness")
eq(jolteon.maxHp, 41, "max HP off the stored bytes")
eq(jolteon.hp, 41, "at full")
eq(jolteon.stats.speed, 37, "stored Speed")
eq(jolteon.stats.specialDefense, 31, "stored Sp.Def")
eq(jolteon.dvs.attack, 13, "the row's own DVs, not a roll")
eq(#jolteon.moves, 4, "four moves")
eq(jolteon.moves[2].id, "HYPER_BEAM", "move two")
eq(jolteon.moves[2].pp, 5, "with the row's PP")
eq(jolteon.moves[2].maxPp, 5, "and the move's own maximum")
eq(party[2].species, "UMBREON", "and the second row follows")
-- Mon.new mutates the dvs table it is handed, so a second build has to see
-- the roster row untouched.
local again = BattleTower.battleParty(DATA, { GROUP1[1] })
eq(again[1].stats.attack, 25, "a second build reads the same row")
eq(GROUP1[1].dvs.hp, nil, "and leaves the roster row alone")
end
-- ============================== LoadOpponentTrainerAndPokemonWithOTSprite
do
local painted = {}
local save = crystalSave()
local vm = fakeVm(save, {
setObjectSprite = function(object, sprite)
painted[#painted + 1] = { object = object, sprite = sprite }
end,
})
vm.scriptVar = 2
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm)
eq(vm.scriptVar, 2, "wScriptVar still names the object, unwritten")
check(vm.btOpponent ~= nil, "the opponent is drawn onto the VM")
eq(vm.btOpponent.name, "HANSON", "the trainer's own name")
eq(vm.btOpponent.classId, "FISHER", "its class")
eq(#vm.btOpponent.rows, 3, "with three mons")
eq(#painted, 1, "the map object is repainted once")
eq(painted[1].object, 2, "the object wScriptVar named")
eq(painted[1].sprite, "SPRITE_FISHER",
"with BTTrainerClassSprites[class - 1]")
eq(save.battleTower.streak, 0, "the draw does not step the streak")
-- A save is all it needs; a world with no sprite hook still draws.
local bare = crystalSave()
local plain = fakeVm(bare, {})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(plain)
check(plain.btOpponent ~= nil, "and the draw survives a missing hook")
-- A cache with no roster leaves nothing behind rather than half an opponent.
local goldVm = fakeVm(crystalSave(), { data = function() return {} end })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(goldVm)
eq(goldVm.btOpponent, nil, "a cache with no roster draws nobody")
end
-- ============================================================ BattleTowerBattle
local function towerBattle(save, outcome, extra)
local log = { heals = 0, started = nil }
local hooks = {
healParty = function() log.heals = log.heals + 1 end,
startTowerBattle = function(spec, done)
log.started = spec
log.healsAtStart = log.heals
done(outcome)
return true
end,
}
for key, value in pairs(extra or {}) do hooks[key] = value end
local vm = fakeVm(save, hooks)
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.LoadOpponentTrainerAndPokemonWithOTSprite(vm)
Specials.ALL.BattleTowerBattle(vm)
return vm, log
end
do
local save = crystalSave()
local vm, log = towerBattle(save, "win")
-- ../pokecrystal/engine/events/battle_tower/battle_tower.asm:236-237
eq(vm.scriptVar, 0, "a win leaves wBattleResult 0 in wScriptVar")
eq(log.heals, 2, ":228 and :235, HealParty on both sides of the battle")
eq(log.healsAtStart, 1, "the first one before the send-out")
check(log.started ~= nil, "the battle was started")
eq(log.started.name, "FISHER HANSON", "named class-then-trainer")
eq(log.started.trainerName, "HANSON", "with the bare name kept")
eq(log.started.classId, "FISHER", "and the class key the pic reads")
eq(log.started.class, 37, "and its constant")
eq(log.started.baseMoney, 40, "the class's own payout multiplier")
eq(#log.started.party, 3, "three mons walk in")
eq(log.started.party[1].species, "JOLTEON", "the drawn team, built")
eq(log.started.party[1].maxHp, 41, "with the roster's stats")
-- :549-570 CopyBTTrainer, which is what steps the counter.
eq(save.battleTower.streak, 1, "the streak steps once")
eq(save.battleTower.challenge, BattleTower.CHALLENGE_IN_PROGRESS,
"and the challenge is now in progress")
-- :240-250, the byte maps/BattleTowerBattleRoom.asm:38 reads back.
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 1,
"wNrOfBeatenBattleTowerTrainers holds the new count")
eq(vm.stringBuffer, "2", "and wStringBuffer3 the NEXT opponent's number")
eq(vm.btBattleEnded, 1, "wBattleTowerBattleEnded ends the loop")
eq(vm.btOpponent, nil, "and the drawn opponent is spent")
end
do
local save = crystalSave()
save.battleTower.streak = 6
local vm = towerBattle(save, "win")
eq(save.battleTower.streak, 7, "the seventh win reaches BATTLETOWER_STREAK_LENGTH")
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 7,
"which is what the room script's `ifequal 7` reads")
end
do
local save = crystalSave()
local vm, log = towerBattle(save, "lose")
eq(vm.scriptVar, 1, "a loss leaves wBattleResult 1")
eq(log.heals, 2, "and still heals on both sides")
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], nil,
":238-239 skips the counter copy when the battle was lost")
eq(vm.stringBuffer, "", "and prints no next-opponent number")
eq(save.battleTower.streak, 1,
"the counter still stepped, because ReadBTTrainerParty ran")
end
do
-- No hook at all: the challenge has to END rather than loop on an opponent
-- that never appears.
local save = crystalSave()
local vm = fakeVm(save, {})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.BattleTowerBattle(vm)
eq(vm.scriptVar, 1, "an unwired world reports a loss")
eq(vm.btBattleEnded, 1, "and ends the tower loop")
local goldVm = fakeVm(crystalSave(), { data = function() return {} end })
Specials.ALL.BattleTowerBattle(goldVm)
eq(goldVm.scriptVar, 1, "so does a cache with no roster")
end
do
-- The special draws its own opponent when the room script never loaded one.
local save = crystalSave()
local log
local vm = fakeVm(save, {
healParty = function() end,
startTowerBattle = function(spec, done) log = spec; done("win"); return true end,
})
Specials.random = rolls({ 1, 1, 1, 1 })
Specials.ALL.BattleTowerBattle(vm)
check(log ~= nil and #log.party == 3,
"BattleTowerBattle alone still fields an opponent")
end
-- ============================================ InitBattleTowerChallengeRAM
do
local save = crystalSave()
local vm = fakeVm(save, { pushScreen = function() return false end })
vm.mem[BattleTower.WRAM_NR_BEATEN] = 4
Specials.ALL.BattleTowerRoomMenu(vm)
eq(vm.mem[BattleTower.WRAM_NR_BEATEN], 0,
":193 zeroes wNrOfBeatenBattleTowerTrainers with the rest")
eq(vm.btBattleEnded, 0, "and wBattleTowerBattleEnded with it")
end
Specials.random = math.random
S.finish()