mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 05:32:07 -04:00
635 lines
26 KiB
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()
|