Files
gen1recomp/tests/save_editor_gen2_tests.lua
T
1jamie df0be1cba6 Add Gold save-editor support, rearrange the launcher, and ship a patch-notes modal.
The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
2026-08-14 18:48:06 -05:00

353 lines
11 KiB
Lua

-- Headless Gold save-editor rules. Run from repo root:
-- luajit tests/save_editor_gen2_tests.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
local love_stub = require("tests.love_stub")
love = love_stub
local passed, failed = 0, 0
local function check(cond, msg)
if cond then
passed = passed + 1
else
failed = failed + 1
print("FAIL: " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
end
print("== save editor gen2 tests ==")
local Gen = require("Gen")
local Catalog = require("Catalog")
local MonOps = require("MonOps")
local Ops = require("Ops")
local State = require("State")
local Save2 = require("src.core.gen2.Save")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local data = {
pokemon = {
CYNDAQUIL = {
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155,
types = { "FIRE" },
baseStats = {
hp = 39, attack = 52, defense = 43, speed = 65,
specialAttack = 60, specialDefense = 50,
},
catchRate = 45, baseExp = 65,
growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "TACKLE" } },
genderRatio = 31,
},
TOTODILE = {
id = "TOTODILE", name = "TOTODILE", dex = 158,
types = { "WATER" },
baseStats = {
hp = 50, attack = 65, defense = 64, speed = 43,
specialAttack = 44, specialDefense = 48,
},
catchRate = 45, baseExp = 66,
growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "SCRATCH" } },
genderRatio = 31,
},
},
moves = {
TACKLE = { pp = 35 },
SCRATCH = { pp = 35 },
},
items = {
POTION = { pocket = "ITEM" },
MASTER_BALL = { pocket = "BALL" },
FLOWER_MAIL = { pocket = "ITEM" },
},
maps = {},
}
local function newState()
local S = State.new()
S.data = data
S.cat = Catalog.build(data)
S.save = Save2.newGame()
S.version = "gold"
Gen.ensureBoxes(S.save)
return S
end
do
GameVersion.set("gold")
eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field")
eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold")
eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame")
eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame")
end
do
local S = newState()
check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable")
local S1 = State.new()
S1.data = {
pokemon = {
PIDGEY = { baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, special = 35 } },
BROKEN = { baseStats = { hp = 1 } },
},
}
check(Ops.speciesUsable(S1, "PIDGEY"), "gen1 special species is usable")
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
end
do
local S = newState()
Ops.partyAdd(S)
eq(#S.save.party, 1, "partyAdd on gold")
local mon = S.save.party[1]
eq(mon.species, "CYNDAQUIL", "first catalog species")
check(mon.experience ~= nil, "gold mon has experience")
check(mon.exp == nil or mon.experience ~= nil, "does not rely on gen1 exp")
check(mon.stats.specialAttack and mon.stats.specialDefense,
"gold stats have spa/spd")
check(mon.happiness ~= nil, "gold mon has happiness")
eq(mon.ot, S.save.player.name, "stampOT copies player name")
Ops.setLevel(S, mon, 20)
eq(mon.level, 20, "setLevel 20")
check(mon.experience > 0, "experience resynced")
Ops.setHappiness(S, mon, 200)
eq(mon.happiness, 200, "happiness 200")
Ops.setPokerus(S, mon, 15)
eq(mon.pokerus, 15, "pokerus byte")
Ops.setHeldItem(S, mon, "POTION")
eq(mon.item, "POTION", "held item")
eq(mon.name, "CYNDAQUIL", "new mon copies species display name")
Ops.setSpecies(S, mon, "TOTODILE")
eq(mon.species, "TOTODILE", "setSpecies id")
eq(mon.name, "TOTODILE", "setSpecies rewrites the Gold display name")
check(mon.nickname == nil, "setSpecies does not invent a nickname")
eq(mon.types[1], "WATER", "setSpecies rewrites copied types")
end
do
local S = newState()
Ops.partyAdd(S)
Ops.partyAdd(S)
local Mail = require("src.core.gen2.Mail")
Mail.set(S.save, 1, Mail.entry("FLOWER_MAIL", "hi", "GOLD", 1, "CYNDAQUIL"))
Mail.set(S.save, 2, Mail.entry("SURF_MAIL", "bye", "GOLD", 1, "CYNDAQUIL"))
S.selectedParty = 1
Ops.partyMove(S, 1)
eq(Mail.state(S.save).party[1].message, "bye", "partyMove carries mail with the mon")
eq(Mail.state(S.save).party[2].message, "hi", "partyMove swaps the other letter")
S.selectedParty = 1
check(Ops.partyRemove(S) == false, "partyRemove arms")
check(Ops.partyRemove(S) == true, "partyRemove commits")
eq(Mail.state(S.save).party[1].message, "hi", "partyRemove shifts leftover mail up")
check(Mail.state(S.save).party[2] == nil, "partyRemove clears the vacated slot")
end
do
local S = newState()
Ops.partyAdd(S)
local mon = S.save.party[1]
local Mail = require("src.core.gen2.Mail")
Ops.setHeldItem(S, mon, "FLOWER_MAIL")
eq(mon.item, "FLOWER_MAIL", "held mail item")
local letter = Mail.state(S.save).party[1]
check(letter ~= nil, "giving mail writes sPartyMail")
eq(letter.species, "CYNDAQUIL", "new letter stamps current species")
Ops.setSpecies(S, mon, "TOTODILE")
eq(Mail.state(S.save).party[1].species, "TOTODILE",
"setSpecies updates the letter's species copy")
Ops.setHeldItem(S, mon, "POTION")
check(Mail.state(S.save).party[1] == nil, "non-mail held item drops the letter")
end
do
local Mon = require("src.battle.gen2.Mon")
local S = newState()
local stale = Mon.new(data, "CYNDAQUIL", 5)
stale.species = "TOTODILE"
stale.name = "CYNDAQUIL"
stale.types = { "FIRE" }
S.save.dayCare = { man = { mon = stale }, lady = {} }
Mon.syncSaveIdentity(S.save, data)
eq(stale.name, "TOTODILE", "syncSaveIdentity rewrites Day-Care display name")
eq(stale.types[1], "WATER", "syncSaveIdentity rewrites Day-Care types")
eq(Mon.displayName({ nickname = nil, name = "ABRA", species = "RAYQUAZA" }),
"ABRA", "displayName prefers the species copy over the id")
eq(Mon.displayName({ nickname = "BOB", name = "ABRA", species = "RAYQUAZA" }),
"BOB", "displayName prefers nickname")
end
do
local S = newState()
eq(Ops.boxCount(S), 14, "14 gold boxes")
eq(Ops.boxCapacity(S), 20, "20 per box")
Ops.boxAdd(S)
eq(#Ops.boxes(S)[1], 1, "boxAdd into box 1")
end
do
local S = newState()
Ops.partyAdd(S)
S.save.party[1].hp = 0
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(ok, "deposit fainted mon while a healthy remains")
end
do
local S = newState()
Ops.partyAdd(S)
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(ok, "deposit one of two healthy mons")
end
do
local S = newState()
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(not ok, "refuse depositing last healthy mon")
check(S.status:lower():find("last", 1, true) or S.status:find("POKéMON")
or S.status:find("POKEMON") or S.status:find("last"),
"deposit refusal names the last-healthy rule: " .. tostring(S.status))
eq(#S.save.party, 1, "party still has the mon")
end
do
local S = newState()
eq(Gen.money(S.save), 3000, "gold start money on player")
Ops.addMoney(S, 1000)
eq(S.save.player.money, 4000, "money writes player.money")
check(S.save.money == nil or S.save.money ~= 4000, "does not write save.money")
Ops.maxMoney(S)
eq(S.save.player.money, 999999, "money cap")
Ops.addCoins(S, 250)
eq(S.save.player.coins, 250, "coins write player.coins")
end
do
local S = newState()
check(not Gen.hasBadge(S.save, "ZEPHYR"), "no zephyr yet")
Ops.toggleBadge(S, "ZEPHYR")
check(Gen.hasBadge(S.save, "ZEPHYR"), "zephyr earned")
check(S.save.player.badges.ZEPHYR, "stored on player.badges")
Ops.toggleBadge(S, "BOULDER")
check(S.save.player.kantoBadges.BOULDER, "kanto badge store")
end
do
local S = newState()
Ops.dexOwned(S, "CYNDAQUIL", true)
check(S.save.pokedex.caught.CYNDAQUIL, "dex writes caught")
check(S.save.pokedex.owned == nil or S.save.pokedex.owned.CYNDAQUIL == nil,
"does not write owned on gold")
check(S.save.pokedex.seen.CYNDAQUIL, "owned implies seen")
local _, owned = Ops.dexCounts(S)
eq(owned, 1, "dexCounts reads caught")
end
do
local S = newState()
local name = "EVENT_BEAT_FALKNER"
Ops.setFlag(S, name, true)
check(Gen.getFlag(S.save, name), "gold EVENT_ sets bitfield")
check(S.save.flags[name] == nil, "numeric flags are not string keys")
Ops.setFlag(S, "MOD_EDITMON_GIFT", true)
check(S.save.flags.MOD_EDITMON_GIFT, "mod flags stay named on gold")
end
do
local S = newState()
S.mapId = "NEW_BARK_TOWN"
S.mapClickCell = { cx = 4, cy = 5 }
Ops.setPlayerHere(S)
eq(S.save.position.map, "NEW_BARK_TOWN", "position.map")
eq(S.save.position.x, 4, "position.x")
eq(S.save.position.y, 5, "position.y")
check(S.save.player.map == nil or S.save.player.map ~= "NEW_BARK_TOWN",
"does not write player.map on gold")
end
do
local encoded = SaveData.encode(Save2.newGame())
local back = SaveData.decode(encoded)
eq(back.generation, 2, "round-trip keeps generation 2")
end
do
local names = Catalog.goldEventList()
local hasFalkner = false
for _, n in ipairs(names) do
if n == "EVENT_BEAT_FALKNER" then hasFalkner = true break end
end
check(hasFalkner, "gold event list includes EVENT_BEAT_FALKNER")
end
do
local maps = Gen.maps({ gen2Maps = { AZALEA_GYM = true }, maps = { PALLET_TOWN = true } })
check(maps.AZALEA_GYM, "Gen.maps includes gen2Maps")
check(maps.PALLET_TOWN, "Gen.maps keeps Data:load maps beside gen2Maps")
check(Gen.maps({ maps = { PALLET_TOWN = true } }).PALLET_TOWN,
"Gen.maps falls back to maps")
local mansion = Gen.maps({
maps = {
CELADON_MANSION_2F = { id = "CELADON_MANSION_2F", width = 4, height = 5 },
},
gen2Maps = {
CELADON_MANSION_2F = { objects = { { name = "NPC" } } },
BERRY_FARM = { id = "BERRY_FARM", width = 19, height = 12 },
},
})
eq(mansion.CELADON_MANSION_2F.width, 4,
"Gen.maps keeps extractor width under a gen2Maps objects patch")
eq(mansion.CELADON_MANSION_2F.objects[1].name, "NPC",
"Gen.maps still applies the gen2Maps patch fields")
eq(mansion.BERRY_FARM.width, 19, "Gen.maps keeps mod maps only on gen2Maps")
local bound = Gen.bindGoldData({ maps = { A = true }, tilesets = { T = true } })
check(bound.gen2Maps == bound.maps, "bindGoldData aliases gen2Maps")
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
"Gen.tilesets prefers gen2Tilesets")
end
do
local Map2 = require("src.world.gen2.Map")
local MapPreview = require("src.world.gen2.MapPreview")
local def = {
id = "AZALEA_GYM", tileset = "TILESET_GYM",
width = 1, height = 1, blocks = { 1 }, borderBlock = 1,
warps = {}, environment = "INDOOR",
}
local tileset = {
id = "TILESET_GYM",
image = "assets/generated/tilesets/gym.png",
tilesPerRow = 16,
blocks = { { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } },
}
local map = Map2.new(def, tileset)
check(map.renderer == nil, "Map2 does not ship a renderer")
local baker = MapPreview.baker({ tilesets = { TILESET_GYM = tileset } })
local renderer = MapPreview.renderer(baker, map)
check(renderer ~= nil and renderer.draw ~= nil,
"MapPreview attaches a draw for a Gold map")
end
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end