mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
8dfbd1daae
- Auto-recover save slots: Scan saves/<version>/ on boot to re-index orphaned slot files if options.lua is reset or loses its slot registry. - Fix Gen 2 field moves: Make PartyMenu field actions (Strength, Surf, Cut, Flash) generation-aware so they execute safely in Gold. - Fortify platform stability: Guard native controller/hidapi init against Android 14 receiver exceptions and ensure safe Windows file replacement. - Add unit test coverage for slot auto-recovery in save_editor_gen2_tests.lua.
378 lines
12 KiB
Lua
378 lines
12 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
|
|
|
|
do
|
|
local memfs = {
|
|
files = {
|
|
["saves/gold/slot1.lua"] = 'return { version = "gold", generation = 2, player = { name = "GOLD" } }',
|
|
["options.lua"] = 'return { textSpeed = 3 }',
|
|
},
|
|
getInfo = function(self, path)
|
|
return self.files[path] and { type = "file" } or nil
|
|
end,
|
|
read = function(self, path)
|
|
return self.files[path]
|
|
end,
|
|
write = function(self, path, data)
|
|
self.files[path] = data
|
|
return true
|
|
end,
|
|
remove = function(self, path)
|
|
self.files[path] = nil
|
|
return true
|
|
end,
|
|
}
|
|
local main, _, _ = SaveData.saveFilename("gold")
|
|
check(main ~= nil, "saveFilename resolves for gold")
|
|
end
|
|
|
|
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
|
|
if failed > 0 then os.exit(1) end
|