mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
ca4d3d283c
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.
604 lines
25 KiB
Lua
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()
|