Files
gen1recomp/tests/gen2_battle_tower_battle_test.lua
T
bryanthaboi ca4d3d283c Add Pokemon Crystal as a sixth supported version
Crystal boots from a user-supplied ROM, imports a full cache and is playable:
copyright, the Crystal intro movie, the animated title, gender select, Oak,
and out into Johto. 122 of the cart's 169 script specials are implemented.

Import and data
- tools/make_crystal_manifest.py derives the manifest by importing
  make_gold_manifest as a library, with three additive keyword seams. Gold and
  Silver still regenerate byte-identical, which is the standing requirement for
  touching that generator.
- crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta:
  Crystal renames the credits mons, splits the trainer card, Pokegear and
  pack-pal blocks by gender, and replaces the intro and title outright.
- Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the
  badge block sits one higher) and unownCharmap (the main charmap parser stops
  at the first newcharmap so the two cannot contaminate each other).

Extractor
- RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK
  0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole,
  seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim
  steps with per-row degrade, and the column-major trainer card portraits.
- New: animated front sprites (frames, bitmasks, play and idle scripts), the
  Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal
  intro and title via src/import/CrystalMovie.lua.

Engine
- GameVersion gains engine(id) and fixes(id). Gold and Silver keep their
  original bugs where the bug is not hardware dependent; Crystal gets the fixes
  Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the
  Reflect and Light Screen defence overflow.
- Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins
  of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower
  including the wInBattleTowerBattle badge-boost guard.
- Kris and the gender flag, animated fronts in battle and the summary screen,
  and mon caught data.

Verification
- Every extracted asset is pixel-compared against pret's own source PNGs.
- Gold caches are byte-identical before and after, file for file.
- New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
2026-08-23 12:10:28 -04:00

604 lines
25 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")
local save = crystalSave(0)
eq(BattleTower.opponentGroup(save, ROSTER), 1,
"a level group of 0 cannot index before the table")
save.battleTower.levelGroup = 9
eq(BattleTower.opponentGroup(save, ROSTER), 2,
"nor past the last group the roster carries")
save.battleTower.levelGroup = 2
eq(BattleTower.opponentGroup(save, ROSTER), 2, "a real group is kept")
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()