diff --git a/src/core/game3/doors.lua b/src/core/game3/doors.lua index 8e800c4d..375451fe 100644 --- a/src/core/game3/doors.lua +++ b/src/core/game3/doors.lua @@ -579,7 +579,7 @@ local function loadSheet(tileName) end --- Draw active door animation overlay -function Doors.draw(camX, camY) +function Doors.draw(camX, camY, canvasW, canvasH) local anim = Doors._activeAnim if not anim then return end if not (love and love.graphics and love.graphics.rectangle) then return end @@ -588,20 +588,23 @@ function Doors.draw(camX, camY) local sx = anim.x * CELL - (camX or 0) local sy = anim.y * CELL - (camY or 0) - -- Viewport bounds check - if sx < -CELL or sy < -32 or sx > 256 or sy > 176 then - return - end - -- src/field_door.c:457 local tileName = anim.tile if not tileName then return end local sheet = loadSheet(tileName) + local hasSheet = sheet and sheet.image and sheet.quads + local width = hasSheet and sheet.frame_width or CELL + local height = hasSheet and sheet.frame_height or CELL + local yOffset = (height > CELL) and CELL or 0 + local top = sy - yOffset + if sx + width <= 0 or top + height <= 0 + or sx >= (canvasW or 240) or top >= (canvasH or 160) then + return + end - if sheet and sheet.image and sheet.quads then + if hasSheet then local frame = math.min(anim.frame, sheet.frames - 1) - local yOffset = (sheet.frame_height > 16) and 16 or 0 -- Authentic black interior background behind the door graphic love.graphics.setColor(0.05, 0.07, 0.1, 1) @@ -665,4 +668,3 @@ end return Doors - diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 5467ec60..933ee1ed 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -234,11 +234,21 @@ local function bg_event_at(game, fx, fy, elevation, facingDir) return nil end +local function hidden_item_store(session) + local Runtime = package.loaded["src.core.game3.runtime"] + local Space = package.loaded["src.core.game3.scripting.space"] + if session and Runtime and Runtime.getSession and Runtime.getSession() == session + and Space and Space.store then + return Space.store, Space + end + return session and (session.store or session) +end + local function hidden_item_at(game, x, y, elevation) local session = Field._session local events = get_map_bg_events(game) local Flags = require("src.core.game3.scripting.flags") - local store = session and (session.store or session) + local store = hidden_item_store(session) for _, ev in ipairs(events) do if (ev.type == "hidden_item" or ev.kind == 7) and ev.x == x and ev.y == y then local flag = ev.flag or (ev.hiddenItemId and (0x3E8 + ev.hiddenItemId)) @@ -263,7 +273,7 @@ function Field.pickUpHiddenItem(game, hidden) local ItemsData = require("src.core.game3.items_data") local Message = require("src.ui.game3.message") local Audio = require("src.core.game3.audio") - local store = session and (session.store or session) + local store, Space = hidden_item_store(session) local flag = hidden.flag or (hidden.hiddenItemId and (0x3E8 + hidden.hiddenItemId)) if flag and Flags.getFlag(store, nil, flag) then @@ -274,7 +284,7 @@ function Field.pickUpHiddenItem(game, hidden) local qty = hidden.quantity or 1 local bag = session and session.bag - if bag and not Bag.canAdd(bag, itemId, qty) then + if not bag or not Bag.add(bag, itemId, qty) then Message.show("Too bad!\nThe BAG is full…", { done = function() Message.close() @@ -283,11 +293,9 @@ function Field.pickUpHiddenItem(game, hidden) return true end - if bag then - Bag.add(bag, itemId, qty) - end if flag and store then Flags.setFlag(store, nil, flag, true) + if Space then Space.persistSession(nil, game or Field._game) end end Audio.playFanfare(257) @@ -323,7 +331,7 @@ function Field.useItemfinder(session, showOWMessage) local Flags = require("src.core.game3.scripting.flags") local Audio = require("src.core.game3.audio") local Message = require("src.ui.game3.message") - local store = session and (session.store or session) + local store = hidden_item_store(session) local found = nil local underfoot = false diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index abf751a1..4d6a12be 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -1071,7 +1071,7 @@ function FieldView.draw(game, canvasW, canvasH, opts) do local okDoors, Doors = pcall(require, "src.core.game3.doors") if okDoors and Doors and Doors.draw then - Doors.draw(camX, camY) + Doors.draw(camX, camY, canvasW, canvasH) end end end diff --git a/src/core/game3/save_mon.lua b/src/core/game3/save_mon.lua new file mode 100644 index 00000000..f7fc3a8b --- /dev/null +++ b/src/core/game3/save_mon.lua @@ -0,0 +1,50 @@ +local Pokemon = require("src.core.game3.pokemon") + +local M = {} + +function M.normalize(mon) + if type(mon) ~= "table" then return mon end + local species = Pokemon.speciesOf(mon) + if not species and tonumber(mon.speciesId) then + species = Pokemon.speciesOf({ species = mon.speciesId, speciesNumbering = mon.speciesNumbering }) + end + if not species or not Pokemon.isInternalSpecies(species) then return mon end + mon.species, mon.speciesId = species, species + mon.speciesNumbering = Pokemon.NUMBERING_INTERNAL + local hp = tonumber(mon.hp) + Pokemon.applyStats(mon) + if hp then mon.hp = math.max(0, math.min(hp, mon.maxHp)) end + mon.stats = mon.stats or {} + for key, value in pairs({ hp = mon.maxHp, attack = mon.attack, defense = mon.defense, + speed = mon.speed, spAtk = mon.spAtk, spDef = mon.spDef, + specialAttack = mon.spAtk, specialDefense = mon.spDef }) do + mon.stats[key] = value + end + for slot = 1, 4 do + local move = mon.moves and mon.moves[slot] + if type(move) == "table" then + local id = tonumber(move.moveId or move.id or move.move) + if not id and type(move.id) == "string" then + for n = 1, 354 do + if Pokemon.moveName(n) == move.id then id = n; break end + end + end + if id then + move.id, move.moveId = id, id + mon.pp, mon.maxPp = mon.pp or {}, mon.maxPp or {} + mon.pp[slot] = move.pp or mon.pp[slot] or Pokemon.movePp(id) + mon.maxPp[slot] = move.maxPp or mon.maxPp[slot] or Pokemon.movePp(id) + end + end + end + return mon +end + +function M.each(save, fn) + for _, mon in pairs(save.party or {}) do fn(mon) end + for _, box in pairs(save.storage and save.storage.boxes or {}) do + for _, mon in pairs(type(box) == "table" and box.mons or {}) do fn(mon) end + end +end + +return M diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index e9983a3c..798f9df9 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -274,6 +274,7 @@ function Schema.fromSaveTable(save) modData = type(save.modData) == "table" and save.modData or {}, meta = save.meta, } + require("src.core.game3.save_mon").each(session, require("src.core.game3.save_mon").normalize) reset_state_on_continue(session) Schema.ensureMonBalls(session) Schema.repairOwnMons(session) diff --git a/src/core/game3/scripting/natives.lua b/src/core/game3/scripting/natives.lua index 65937352..13773fa7 100644 --- a/src/core/game3/scripting/natives.lua +++ b/src/core/game3/scripting/natives.lua @@ -427,7 +427,8 @@ Natives.ALLOW = { adapters.startWildBattle(foe, function(result) local code = outcome_to_code(result) if ctx then ctx.lastBattleOutcome = code end - setResult(ctx, code) + -- pokefirered/src/battle_setup.c:458 + setResult(ctx, code == B_OUTCOME_WON and 0 or 1) if done then done() end end, { wildScripted = true }) end) diff --git a/src/import/gba/extract_map_events.lua b/src/import/gba/extract_map_events.lua index 1e75193d..a7be1109 100644 --- a/src/import/gba/extract_map_events.lua +++ b/src/import/gba/extract_map_events.lua @@ -94,8 +94,8 @@ local function parse_bg_events(rom, ptr, count) if kind == BG_EVENT_HIDDEN_ITEM then local item = rom:u16(base + 8) local info = rom:u16(base + 10) - local hiddenItemId = info % 512 - local quantity = math.floor(info / 512) % 64 + local hiddenItemId = info % 256 + local quantity = math.floor(info / 256) % 128 if quantity == 0 then quantity = 1 end local underfoot = info >= 32768 local flag = FLAG_HIDDEN_ITEMS_START + hiddenItemId diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index b9b07a4f..e5d9464f 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -27,7 +27,7 @@ Versions.ROM_SIZE = 16777216 -- v99: gEggMoves → pokemon/egg_moves.lua (hidden-mon egg moves were inert). -- v100: location preview screens (sMapPreviewScreenData artwork) + ROM-derived -- mapsec names and sDungeonInfo dungeon descriptions. -Versions.CACHE_VERSION = 110 +Versions.CACHE_VERSION = 111 Versions.NATIVE_VERSION = 6 Versions.OW_VERSION = 1 Versions.ANIM_VERSION = 1 diff --git a/tests/drivers/game3_doors_viewport.lua b/tests/drivers/game3_doors_viewport.lua new file mode 100644 index 00000000..b1d91125 --- /dev/null +++ b/tests/drivers/game3_doors_viewport.lua @@ -0,0 +1,132 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_doors_viewport" +local TARGETS = { + { label = "gym", x = 36, y = 10, tile = "SlidingDouble" }, + { label = "wooden", x = 25, y = 11, tile = "Viridian" }, +} +local VIEWS = { + { label = "portrait", w = 390, h = 844, zoom = 0 }, + { label = "landscape_survey", w = 844, h = 390, zoom = -1 }, +} + +return function(game) + local failures = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Doors = require("src.core.game3.doors") + local Dataset = require("src.core.game3.dataset") + local Extract = require("src.import.gba.extract_island1") + local Renderer = require("src.render.Renderer") + local FaithfulRes = require("src.core.FaithfulRes") + local Zoom = require("src.render.Zoom") + FaithfulRes.apply(0) + require("src.render.Tilt").reset() + require("src.render.ShaderFX").applyOptions({}) + require("src.core.ScreenPosition").setMode("center") + Zoom.allowSurvey = true + + local realDoorDraw, realDraw = Doors.draw, love.graphics.draw + local insideDoor, observed = false, nil + love.graphics.draw = function(...) + local image, quad, x, y = ... + if insideDoor then + observed = { image = image, quad = quad, x = x, y = y, canvas = love.graphics.getCanvas() } + end + return realDraw(...) + end + Doors.draw = function(...) + insideDoor = true + realDoorDraw(...) + insideDoor = false + end + + for _, view in ipairs(VIEWS) do + Doors.reset() + local _, _, flags = love.window.getMode() + flags.fullscreen, flags.resizable = false, true + flags.minwidth, flags.minheight = 1, 1 + assert(love.window.setMode(view.w, view.h, flags)) + Zoom.offset = view.zoom + U.wait(15) + local vw, vh = Renderer:worldViewSize() + local ww, wh = love.graphics.getDimensions() + print(string.format("[driver] %s window=%dx%d worldViewSize=%dx%d zoom=%d faithful=%s", + view.label, ww, wh, vw, vh, Zoom.offset, tostring(FaithfulRes.scaleCap()))) + result(ww == view.w and wh == view.h, view.label .. "_window") + result(view.label == "portrait" and vh > 400 or view.label == "landscape_survey" and vw > 600, + view.label .. "_expanded_viewport") + + for _, target in ipairs(TARGETS) do + Doors.reset() + Map.load(nil, game, "FR_VIRIDIAN_CITY", { x = target.x, y = target.y + 2, facing = "up" }) + Player.reset(target.x, target.y + 2, "up") + if game.session then + game.session.x, game.session.y, game.session.facing = target.x, target.y + 2, "up" + end + U.wait(60) + local anim = Doors.open("FR_VIRIDIAN_CITY", target.x, target.y, { playSound = false }) + local label = view.label .. "_" .. target.label + result(anim.tile == target.tile, label .. "_sheet") + for _ = 1, 30 do + if anim.frame == 1 then break end + U.wait(1) + end + result(anim.frame == 1, label .. "_half_open") + anim.mode = "hold" + observed = nil + local path = DIR .. "/" .. label .. "_half_open.png" + local captured = U.shot(game, path) + local sheet = Doors._sheets[target.tile] + local drawn = observed and sheet and observed.image == sheet.image + and observed.quad == sheet.quads[1] and observed.canvas == Renderer.worldCanvas + result(drawn, label .. "_image_drawn") + if drawn then + result(observed.x > 256 or observed.y > 176, label .. "_past_old_cutoff") + local info = Doors._manifest.doors[target.tile] + local cache = Dataset.cache() + local bytes = assert(cache:read(Extract.CACHE_ROOT .. "/doors/" .. info.file) + or cache:read("doors/" .. info.file)) + local source = love.image.newImageData(info.width, info.height, "rgba8", bytes) + local pixels = observed.canvas:newImageData() + local matched, opaque, changed = 0, 0, 0 + for y = 0, info.frame_height - 1 do + for x = 0, info.frame_width - 1 do + local r, g, b, a = source:getPixel(x, y + info.frame_height) + if a > 0.99 then + opaque = opaque + 1 + local pr, pg, pb = pixels:getPixel(observed.x + x, observed.y + y) + if math.abs(pr - r) < 0.01 and math.abs(pg - g) < 0.01 and math.abs(pb - b) < 0.01 then + matched = matched + 1 + local cr, cg, cb, ca = source:getPixel(x, y) + if ca < 0.99 or math.abs(cr-r) + math.abs(cg-g) + math.abs(cb-b) > 0.05 then + changed = changed + 1 + end + end + end + end + end + print(string.format("[driver] %s opaque=%d matched=%d changed_from_closed=%d", label, opaque, matched, changed)) + result(opaque > 0 and matched == opaque and changed > 0, label .. "_half_open_pixels") + source:release() + pixels:release() + end + result(captured, label .. "_screenshot") + end + end + Doors.draw, love.graphics.draw = realDoorDraw, realDraw + Doors.reset() + result(failures == 0, "doors_viewport_driver") + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_editor_exp_roundtrip.lua b/tests/drivers/game3_editor_exp_roundtrip.lua new file mode 100644 index 00000000..3246897a --- /dev/null +++ b/tests/drivers/game3_editor_exp_roundtrip.lua @@ -0,0 +1,133 @@ +local U = require('tests.drivers.util') +local DIR = os.getenv('POKEPORT_SHOT_DIR') or '/tmp/game3_editor_exp_roundtrip' + +return function(game) + local ok, err = xpcall(function() + assert((os.getenv('POKEPORT_IDENTITY') or ''):match('^firered%-bsa'), 'isolated firered-bsa identity required') + package.path = package.path .. ';./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua' + local SaveData = require('src.core.SaveData') + assert(not SaveData.isPortable(), 'driver requires isolated nonportable saves') + for _ = 1, 900 do + if game.phase == 'boot' and game.boot then break end + U.wait(1) + end + local slot = assert(SaveData.createSlot('firered')) + SaveData.setActiveSlot('firered', slot) + game:_handleBootAction({ action = 'new_game', name = 'RED' }) + U.wait(240) + local Runtime = require('src.core.game3.runtime') + local Party = require('src.core.game3.party') + local P = require('src.core.game3.pokemon') + local Growth = require('src.core.game3.summary_data') + local Bag = require('src.core.game3.bag') + local Storage = require('src.core.game3.storage') + local App = require('tools.save-editor.App') + local Ops = require('Ops') + local Bridge = require('src.core.game3.battle_bridge') + local Battle = require('src.core.game3.battle') + local Anim = require('src.core.game3.battle.anim') + local Ui = require('src.core.game3.battle.ui') + local Summary = require('src.ui.game3.summary_menu') + local BagMenu = require('src.ui.game3.bag_menu') + local Boxes = require('src.ui.game3.box_storage_ui') + local Pc = require('src.ui.game3.pc_menu') + local function shot(name) + U.wait(50) + assert(U.shot(game, DIR .. '/' .. name .. '.png'), 'screenshot ' .. name) + end + local function continue() + game:_handleBootAction({ action = 'continue' }) + U.wait(60) + for _ = 1, 400 do + if game.phase ~= 'quest_log' then break end + U.tap(game, 'a'); U.wait(6) + end + U.wait(120) + assert(game.phase == 'field', 'Continue did not reach field: ' .. tostring(game.phase)) + return assert(Runtime.getSession()) + end + local session = assert(Runtime.getSession()) + session.party = {} + Party.giveMon(session, 25, 20); Party.giveMon(session, 4, 20); Party.giveMon(session, 9, 20) + session.storage = Storage.new() + session.storage.boxes[1].mons[7] = table.remove(session.party, 3) + session.storage.boxes[1].name, session.storage.boxes[1].wallpaper = 'KEPT', 8 + session.storage.items = { { id = 13, qty = 999 }, { id = 68, qty = 120 } } + session.bag = Bag.new() + for _, pair in ipairs({ { 13, 5 }, { 68, 2 }, { 4, 10 }, { 360, 1 }, { 289, 1 }, { 139, 3 } }) do + assert(Bag.add(session.bag, pair[1], pair[2])) + end + session.party[1].custom = { sentinel = 'kept' } + assert(game:saveGame()) + local path = assert(SaveData.slotDiskPath('firered', slot)) + App.load(path, { version = 'firered', slotId = slot, embedded = true }) + local S = App.getState() + for _, mon in ipairs(S.save.party) do + assert(Ops.setLevel(S, mon, 21)) + mon.exp = Growth.expForLevel(P.growthRate(mon.speciesId), 22) - 1 + assert(Ops.setMove(S, mon, 1, 10)) + end + assert(Ops.addToBag(S, 'RARE_CANDY')); assert(Ops.bagAdjust(S, '68', 1)) + assert(Ops.pcAdjust(S, 'RARE_CANDY', -1)) + S.selectedParty, S.selectedBox, S.selectedBoxSlot = 2, 1, 2 + assert(Ops.deposit(S)); assert(S.save.storage.boxes[1].mons[7]) + assert(App.save()); App.unload() + session = continue() + assert(session.storage.boxes[1].mons[2].species == 4 and session.storage.boxes[1].mons[7].species == 9) + print('PASS editor_sparse_deposit_continue') + Boxes.show({ session = session }); Boxes.cursorSlot = 7 + shot('u2_editor_sparse_box_slots_2_7'); Boxes.close(); U.wait(10) + App.load(path, { version = 'firered', slotId = slot, embedded = true }) + S = App.getState(); S.selectedBox, S.selectedBoxSlot = 1, 2 + assert(Ops.withdraw(S)); assert(App.save()); App.unload() + session = continue() + assert(session.storage.boxes[1].mons[2] == nil and session.storage.boxes[1].mons[7].species == 9) + assert(#session.party == 2 and session.party[2].species == 4) + print('PASS editor_sparse_withdraw_continue') + for _, pair in ipairs({ { 13, 5 }, { 68, 4 }, { 4, 10 }, { 360, 1 }, { 289, 1 }, { 139, 3 } }) do + assert(Bag.get(session.bag, pair[1]) == pair[2], 'preserved item ' .. pair[1]) + end + assert(session.storage.items[1].qty == 999 and session.storage.items[2].qty == 119) + assert(session.party[1].custom.sentinel == 'kept') + print('PASS editor_native_bag_pc_continue') + for _, pocket in ipairs({ 'ITEMS', 'KEY_ITEMS', 'POKE_BALLS', 'TM_CASE', 'BERRY_POUCH' }) do + BagMenu.show(session.bag, { session = session, pocket = pocket }) + shot('u3_editor_preserved_' .. pocket:lower()); BagMenu.close(); U.wait(10) + end + Pc.show({ session = session, startMode = 'player_pc' }) + U.tap(game, 'a'); U.wait(20); U.tap(game, 'a'); U.wait(20) + shot('u3_editor_pc_999_119'); Pc.close(); U.wait(10) + for _, species in ipairs({ 25, 4 }) do + if session.party[1].species ~= species then session.party[1], session.party[2] = session.party[2], session.party[1] end + local before = session.party[1].exp + assert(Bridge.startWild(Runtime._mod, game, { species = 129, level = 30 }, { fade = false })) + local captured = false + for frame = 1, 9000 do + if not Battle.isActive() then break end + if Battle._phase == 'command' and Ui._mode == 'menu' then + if not captured then shot('u2_editor_battle_' .. species); captured = true end + local state = Battle.getState() + state.enemy.mon.hp = 1; state.enemy.mon.moves = { 150 }; state.enemy.mon.pp = { 40 } + local present = Anim.present('enemy'); if present then present.displayHp = 1 end + Ui._pendingCommand = { kind = 'move', move = 10, slot = 1, user = 'player' } + Ui._mode = 'none'; U.wait(2) + elseif Anim.vm() and Anim.vm():busy() then U.wait(1) + elseif frame % 12 == 0 then U.tap(game, 'a') + else U.wait(1) end + end + assert(not Battle.isActive(), 'battle timeout') + U.wait(120) + assert(session.party[1].exp > before and session.party[1].level >= 22, 'normal battle award for ' .. species) + print('PASS editor_continue_battle_exp_' .. species) + Summary.openMenu(session.party, 1, { session = session, page = 1 }) + shot('u2_editor_levelup_stats_' .. species); Summary.close(); U.wait(10) + end + assert(game:saveGame()); session = continue() + assert(session.party[1].level >= 22 and session.party[2].level >= 22) + assert(Bag.get(session.bag, 68) == 4 and session.storage.items[2].qty == 119) + print('PASS editor_second_save_continue_preservation') + print('PASS game3_editor_exp_roundtrip') + end, debug.traceback) + if not ok then print('FAIL game3_editor_exp_roundtrip ' .. tostring(err)) end + love.event.quit(ok and 0 or 1) +end diff --git a/tests/drivers/game3_hidden_item_persistence.lua b/tests/drivers/game3_hidden_item_persistence.lua new file mode 100644 index 00000000..0c98ef90 --- /dev/null +++ b/tests/drivers/game3_hidden_item_persistence.lua @@ -0,0 +1,80 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_hidden_item_persistence" +return function(game) + local failures = 0 + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + local function finish() love.event.quit(failures == 0 and 0 or 1) end + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Field = require("src.core.game3.field") + local Bag = require("src.core.game3.bag") + local Message = require("src.ui.game3.message") + local Start = require("src.ui.game3.start_menu") + local BagMenu = require("src.ui.game3.bag_menu") + local session = Runtime.getSession() + if not check(session ~= nil, "hidden_new_game") then return finish() end + session.bag = Bag.new() + Flags.setVar(Space.store, nil, 0x4070, 1) + Space.persistSession(nil, game) + local function forest() + Map.load(nil, game, "FR_VIRIDIAN_FOREST", { x = 28, y = 58, facing = "up" }) + Player.cellX, Player.cellY, Player.targetX, Player.targetY = 28, 58, 28, 58 + Player.px, Player.py, Player.facing = 448, 928, "up" + session.x, session.y, session.facing = 28, 58, "up" + U.wait(150) + end + forest() + if not check(Field.hiddenItemAt(game, 28, 57, 0) ~= nil, "hidden_forest_antidote_present") then return finish() end + U.tap(game, "a") + U.wait(120) + if not check(Message.isOpen() and Bag.get(session.bag, 14) == 1, "hidden_first_pickup") then return finish() end + check(U.shot(game, DIR .. "/2364_antidote_found.png"), "hidden_pickup_screenshot_written") + for _ = 1, 30 do + if not Message.isOpen() then break end + U.tap(game, "a") + U.wait(15) + end + U.tap(game, "start") + U.wait(40) + if not check(Start.isOpen(), "hidden_start_open") then return finish() end + check(U.shot(game, DIR .. "/2364_start_after_pickup.png"), "hidden_start_screenshot_written") + U.tap(game, "b") + U.wait(30) + U.tap(game, "a") + U.wait(30) + check(not Message.isOpen() and Bag.get(session.bag, 14) == 1, "hidden_no_duplicate_after_start") + check(Flags.getFlag(Space.store, nil, 0x3E9) and Flags.getFlag(session, nil, 0x3E9), "hidden_live_and_saved_flags") + check(not Field.useItemfinder(session, false), "hidden_itemfinder_ignores_collected") + Map.load(nil, game, "FR_PALLET_TOWN", { x = 5, y = 7, facing = "down" }) + U.wait(60) + forest() + U.tap(game, "a") + U.wait(30) + check(not Message.isOpen() and Bag.get(session.bag, 14) == 1, "hidden_no_duplicate_after_map_return") + U.tap(game, "start") + U.wait(30) + for _ = 1, #Start.ENTRIES do + if Start.ENTRIES[Start.cursor].id == "bag" then break end + U.tap(game, "down") + U.wait(5) + end + U.tap(game, "a") + U.wait(60) + if check(BagMenu.isOpen() and BagMenu.currentPocket() == "ITEMS", "hidden_bag_quantity_visible") then + check(U.shot(game, DIR .. "/2364_one_antidote_after_menu_and_map.png"), "hidden_quantity_screenshot_written") + end + finish() +end diff --git a/tests/drivers/game3_marowak_2380.lua b/tests/drivers/game3_marowak_2380.lua new file mode 100644 index 00000000..dc56986c --- /dev/null +++ b/tests/drivers/game3_marowak_2380.lua @@ -0,0 +1,125 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_marowak_2380" +return function(game) + local failures = 0 + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + local function finish() love.event.quit(failures == 0 and 0 or 1) end + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Party = require("src.core.game3.party") + local Bag = require("src.core.game3.bag") + local Battle = require("src.core.game3.battle") + local Collision = require("src.core.game3.collision") + local Message = require("src.ui.game3.message") + local session = Runtime.getSession() + if not check(session ~= nil, "marowak_new_game") then return finish() end + session.party = {} + Party.giveMon(session, 150, 100) + session.party[1].moves, session.party[1].pp = { 94 }, { 10 } + session.party[1].maxPp = { 10 } + Bag.add(session.bag, 359, 1) + local function place(x, y, facing) + Player.moving, Player.progress = false, 0 + Player.cellX, Player.cellY, Player.targetX, Player.targetY = x, y, x, y + Player.px, Player.py, Player.facing = x * 16, y * 16, facing + session.x, session.y, session.facing = x, y, facing + end + local function walk(dir) + for _ = 1, 30 do + U.hold(game, dir, 1) + if Player.moving then break end + end + for _ = 1, 120 do + if not Player.moving then break end + U.wait(1) + end + U.wait(4) + end + Map.load(nil, game, "FR_POKEMON_TOWER_6F", { x = 11, y = 14, facing = "down" }) + place(11, 14, "down") + U.wait(150) + check(Flags.getVar(Space.store, nil, 0x4059) == 0, "marowak_scene_starts_zero") + walk("down") + for _ = 1, 300 do + if Battle.isActive() then break end + U.tap(game, "a") + U.wait(8) + end + if not check(Battle.isActive(), "marowak_coord_script_started_battle") then return finish() end + local farewell = false + for _ = 1, 1800 do + if not Battle.isActive() and Message.isOpen() + and Message.currentPage():lower():find("mother", 1, true) then + farewell = true + break + end + U.tap(game, "a") + U.wait(8) + end + if not check(farewell and session.battleOutcome == 1, "marowak_win_reaches_farewell") then return finish() end + U.wait(150) + check(U.shot(game, DIR .. "/2380_marowak_mothers_spirit_farewell.png"), "marowak_farewell_screenshot_written") + for _ = 1, 300 do + if not Message.isOpen() and not Space.vm:isRunning() then break end + U.tap(game, "a") + U.wait(8) + end + if not check(Flags.getVar(Space.store, nil, 0x4059) == 1, "marowak_scene_completed") then return finish() end + walk("up") + walk("down") + check(Player.cellX == 11 and Player.cellY == 15 and not Space.vm:isRunning() + and not Battle.isActive(), "marowak_first_trigger_stays_cleared") + place(13, 16, "left") + walk("left") + check(Player.cellX == 12 and Player.cellY == 16 and not Space.vm:isRunning() + and not Battle.isActive(), "marowak_second_trigger_stays_cleared") + walk("left") + if not check(Player.cellX == 11 and Player.cellY == 16 + and Collision.isStairWarp(game, 11, 16, "left") ~= nil, + "marowak_standing_on_7f_stairs") then return finish() end + walk("left") + U.wait(180) + if not check(Runtime.getSession().map == "FR_POKEMON_TOWER_7F", "marowak_reached_7f") then return finish() end + check(U.shot(game, DIR .. "/2380_tower_7f_after_marowak.png"), "marowak_7f_screenshot_written") + if not check(game:saveGame() == true, "marowak_save_written") then return finish() end + game:_handleBootAction({ action = "continue" }) + U.wait(60) + for _ = 1, 400 do + if game.phase ~= "quest_log" then break end + U.tap(game, "a") + U.wait(6) + end + U.wait(180) + session = Runtime.getSession() + check(session and session.map == "FR_POKEMON_TOWER_7F" + and Flags.getVar(Space.store, nil, 0x4059) == 1, "marowak_continue_keeps_scene_and_7f") + Map.load(nil, game, "FR_POKEMON_TOWER_6F", { x = 11, y = 14, facing = "down" }) + place(11, 14, "down") + U.wait(90) + walk("down") + check(not Space.vm:isRunning() and not Battle.isActive() + and Flags.getVar(Space.store, nil, 0x4059) == 1, "marowak_no_respawn_after_reload") + walk("down") + if not check(Player.cellX == 11 and Player.cellY == 16 + and Collision.isStairWarp(game, 11, 16, "left") ~= nil, + "marowak_standing_on_7f_stairs_after_reload") then return finish() end + walk("left") + U.wait(180) + if check(session.map == "FR_POKEMON_TOWER_7F", "marowak_7f_access_after_reload") then + check(U.shot(game, DIR .. "/2380_tower_7f_after_save_reload.png"), "marowak_reload_screenshot_written") + end + finish() +end diff --git a/tests/game3_doors_viewport_test.lua b/tests/game3_doors_viewport_test.lua new file mode 100644 index 00000000..1aef0668 --- /dev/null +++ b/tests/game3_doors_viewport_test.lua @@ -0,0 +1,57 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Doors = require("src.core.game3.doors") +local draws, rectangles = {}, {} +love = { graphics = { + draw = function(...) draws[#draws + 1] = {...} end, + rectangle = function(...) rectangles[#rectangles + 1] = {...} end, + setColor = function() end, + line = function() end, +} } + +local image, quad = {}, {} +local cases = { + { "native_defaults", 120, 56, nil, nil, 16, true }, + { "portrait_visible", 187, 398, 390, 844, 16, true }, + { "retina_portrait_visible", 139, 293, 294, 634, 16, true }, + { "landscape_visible", 400, 120, 844, 390, 16, true }, + { "survey_visible", 800, 420, 1688, 780, 32, true }, + { "native_right_edge", 239, 60, nil, nil, 16, true }, + { "native_right_offscreen", 240, 60, nil, nil, 16, false }, + { "native_bottom_offscreen", 100, 160, nil, nil, 16, false }, + { "left_partial", -15, 60, 390, 844, 16, true }, + { "left_offscreen", -16, 60, 390, 844, 16, false }, + { "right_offscreen", 390, 60, 390, 844, 16, false }, + { "tall_top_partial", 100, -15, 390, 844, 32, true }, + { "tall_top_offscreen", 100, -16, 390, 844, 32, false }, + { "tall_bottom_partial", 100, 859, 390, 844, 32, true }, + { "tall_bottom_offscreen", 100, 860, 390, 844, 32, false }, +} + +local failures = 0 +for _, t in ipairs(cases) do + draws, rectangles = {}, {} + Doors._sheets.Test = { + image = image, quads = { [1] = quad }, frames = 3, + frame_width = 16, frame_height = t[6], + } + Doors._activeAnim = { x = 73, y = 91, tile = "Test", frame = 1 } + Doors.draw(73 * 16 - t[2], 91 * 16 - t[3], t[4], t[5]) + local ok = #draws == (t[7] and 1 or 0) and #rectangles == (t[7] and 1 or 0) + if t[7] and draws[1] then + local d = draws[1] + ok = ok and d[1] == image and d[2] == quad and d[3] == t[2] + and d[4] == t[3] - (t[6] > 16 and 16 or 0) + end + print((ok and "PASS " or "FAIL ") .. t[1]) + if not ok then failures = failures + 1 end +end + +draws, rectangles = {}, {} +Doors._sheets.Test = false +Doors._activeAnim = { x = 25, y = 30, tile = "Test", frame = 1 } +Doors.draw(0, 0, 844, 780) +assert(#draws == 0 and #rectangles > 0, "fallback on enlarged viewport") +print("PASS fallback_visible") +assert(failures == 0, tostring(failures) .. " viewport cases failed") +print("PASS doors_viewport") diff --git a/tests/game3_growth_evolution_test.lua b/tests/game3_growth_evolution_test.lua index f681a965..905888b3 100644 --- a/tests/game3_growth_evolution_test.lua +++ b/tests/game3_growth_evolution_test.lua @@ -182,13 +182,30 @@ eq(Pokemon.speciesOf(boxed), ARON, "and it is still ARON") local again = Schema.fromSaveTable(Schema.toSaveTable(loaded)) eq(again.party[1].speciesNumbering, Pokemon.NUMBERING_INTERNAL, "the tag survives a save round trip") +local hostExp = require("src.core.game3.summary_data").expForLevel(Pokemon.growthRate(WURMPLE), 8) - 1 local hostSave = { schemaVersion = 1, - party = { { species = "WURMPLE", level = 7, hp = 10, maxHp = 10 } }, + party = { { species = "WURMPLE", level = 7, hp = 10, maxHp = 10, exp = hostExp, personality = 0 } }, } local hostLoaded = Schema.fromSaveTable(hostSave) -eq(hostLoaded.party[1].speciesNumbering, nil, "a host mon keyed by name is left untagged") -eq(Pokemon.speciesOf(hostLoaded.party[1]), WURMPLE, "and still resolves by name") +eq(hostLoaded.party[1].speciesNumbering, Pokemon.NUMBERING_INTERNAL, "a named host mon is normalized to internal numbering") +eq(hostLoaded.party[1].species, WURMPLE, "the persisted species is numeric internal WURMPLE") +eq(hostLoaded.party[1].speciesId, WURMPLE, "the species alias agrees with WURMPLE") +eq(hostLoaded.party[1].exp, hostExp, "normalization preserves earned EXP") +eq(hostLoaded.party[1].hp, 10, "normalization preserves damaged HP") +eq(Evolution.levelTarget(hostLoaded.party[1], national), SILCOON, "normalization preserves the WURMPLE evolution branch") +local SaveData = require("src.core.SaveData") +local hostRoundtrip = Schema.fromSaveTable(SaveData.decode(SaveData.encode(Schema.toSaveTable(hostLoaded)))) +eq(Pokemon.speciesOf(hostRoundtrip.party[1]), WURMPLE, "serialized roundtrip preserves WURMPLE rather than national NINCADA") +eq(hostRoundtrip.party[1].exp, hostExp, "serialized roundtrip preserves earned EXP") +local rewards = require("src.core.game3.battle.experience").awardFoe({ + playerParty = hostRoundtrip.party, player = { mon = hostRoundtrip.party[1], partyIndex = 1 }, + wild = true, session = hostRoundtrip, +}, { species = 113, level = 10, mon = { species = 113, level = 10 } }, + { partyIndices = { 1 }, getOpts = function() return {} end }) +eq(#rewards, 1, "normalized named mon remains an actual battle EXP recipient") +check(hostRoundtrip.party[1].exp > hostExp and hostRoundtrip.party[1].level >= 8, + "battle EXP crosses the normalized WURMPLE growth threshold") print("[test] 7. a mon handed over by a script is stamped when it is built") local Party = require("src.core.game3.party") diff --git a/tests/game3_hidden_item_persistence_test.lua b/tests/game3_hidden_item_persistence_test.lua new file mode 100644 index 00000000..7402db8b --- /dev/null +++ b/tests/game3_hidden_item_persistence_test.lua @@ -0,0 +1,70 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +require("src.core.GameVersion").set("firered") +local Field = require("src.core.game3.field") +local Flags = require("src.core.game3.scripting.flags") +local Space = require("src.core.game3.scripting.space") +local Runtime = require("src.core.game3.runtime") +local Bag = require("src.core.game3.bag") +local Items = require("src.core.game3.items") +local Message = require("src.ui.game3.message") +local Hud = require("src.ui.game3.hud") +local Start = require("src.ui.game3.start_menu") +local Schema = require("src.core.game3.save_schema_firered") +local SaveData = require("src.core.SaveData") +local hidden = { type = "hidden_item", x = 28, y = 57, flag = 0x3E9, item = 14, quantity = 1 } +local s = Schema.newGame({ name = "RED" }) +s.map, s.x, s.y, s.playerX, s.playerY = "FR_VIRIDIAN_FOREST", 28, 58, 28, 58 +local game = { session = s, data = { maps = { [s.map] = { bgEvents = { hidden } } } } } +Field._game, Field._session, Runtime._game, Runtime.session = game, s, game, s +Space.store = Flags.newStore() +Flags.setVar(Space.store, nil, 0x4070, 1) +Space.persistSession(nil, game) +s.bag = Bag.new() +assert(Field.pickUpHiddenItem(game, hidden)) +Message.close() +assert(Flags.getFlag(Space.store, nil, hidden.flag), "pickup must mark the live store") +assert(Flags.getFlag(s, nil, hidden.flag), "pickup must persist the session snapshot") +for _ = 1, 3 do + Hud.openStartMenu(game, s) + assert(Start.isOpen()) + Start.close(true) + assert(not Field.hiddenItemAt(game, 28, 57, 0)) + assert(not Field.pickUpHiddenItem(game, hidden)) + assert(Bag.get(s.bag, 14) == 1) + assert(not Field.useItemfinder(s, false)) +end +print("PASS hidden_pickup_survives_start_menu") +s.flags = {} +assert(not Field.hiddenItemAt(game, 28, 57, 0)) +assert(not Field.useItemfinder(s, false), "Itemfinder must prefer live flags over stale snapshots") +Space.persistSession(nil, game) +local restored = Schema.fromSaveTable(SaveData.decode(SaveData.encode(Schema.toSaveTable(s)))) +assert(Flags.getFlag(restored, nil, hidden.flag)) +assert(Bag.get(restored.bag, 14) == 1) +print("PASS hidden_itemfinder_and_save_roundtrip") +Flags.setFlag(Space.store, nil, hidden.flag, false) +Space.persistSession(nil, game) +s.bag = Bag.new() +assert(Bag.add(s.bag, 14, Items.GAME3_MAX_QTY)) +assert(Field.pickUpHiddenItem(game, hidden)) +Message.close() +assert(not Flags.getFlag(Space.store, nil, hidden.flag)) +assert(not Flags.getFlag(s, nil, hidden.flag)) +assert(Field.hiddenItemAt(game, 28, 57, 0) == hidden) +assert(Field.useItemfinder(s, false)) +assert(Bag.remove(s.bag, 14, Items.GAME3_MAX_QTY)) +assert(Field.pickUpHiddenItem(game, hidden)) +Message.close() +assert(Bag.get(s.bag, 14) == 1) +assert(Flags.getFlag(Space.store, nil, hidden.flag)) +print("PASS hidden_full_bag_does_not_consume") +local isolated = { map = s.map, flags = {}, bag = Bag.new(), x = 28, y = 58 } +Field._session = isolated +assert(Field.hiddenItemAt(game, 28, 57, 0) == hidden) +assert(Field.useItemfinder(isolated, false)) +assert(Field.pickUpHiddenItem(game, hidden)) +Message.close() +assert(Flags.getFlag(isolated, nil, hidden.flag)) +assert(Bag.get(isolated.bag, 14) == 1) +assert(Bag.get(s.bag, 14) == 1) +print("PASS hidden_isolated_session_fallback") diff --git a/tests/game3_hidden_items_test.lua b/tests/game3_hidden_items_test.lua index ac9250c1..0db63596 100644 --- a/tests/game3_hidden_items_test.lua +++ b/tests/game3_hidden_items_test.lua @@ -60,8 +60,7 @@ mock_bytes[15] = 20; mock_bytes[16] = 0 -- y = 20 mock_bytes[17] = 0 -- elevation = 0 mock_bytes[18] = 7 -- kind = 7 (BG_EVENT_HIDDEN_ITEM) mock_bytes[21] = 14; mock_bytes[22] = 0 -- item = 14 (ANTIDOTE) --- info: hiddenItemId = 1, quantity = 1 (1 << 9 = 0x0200) -> 0x0201 -mock_bytes[23] = 0x01; mock_bytes[24] = 0x02 +mock_bytes[23] = 0x01; mock_bytes[24] = 0x01 -- Event 2: Hidden Potion at offset 24 (underfoot bit 15 set) mock_bytes[25] = 3; mock_bytes[26] = 0 -- x = 3 @@ -69,8 +68,7 @@ mock_bytes[27] = 4; mock_bytes[28] = 0 -- y = 4 mock_bytes[29] = 0 -- elevation = 0 mock_bytes[30] = 7 -- kind = 7 (BG_EVENT_HIDDEN_ITEM) mock_bytes[33] = 13; mock_bytes[34] = 0 -- item = 13 (POTION) --- info: hiddenItemId = 0, quantity = 1 (1 << 9 = 0x0200), underfoot = 1 (1 << 15 = 0x8000) -> 0x8200 -mock_bytes[35] = 0x00; mock_bytes[36] = 0x82 +mock_bytes[35] = 0x00; mock_bytes[36] = 0x81 local rom = create_mock_rom(mock_bytes) local mapEventsOff = 0x08000000 @@ -130,6 +128,22 @@ assert(potion.flag == 0x3E8 + 0, "flag is 0x3E8 (1000)") print("[OK] BgEvent parsing correctly extracted hidden items and unpacked union fields.") +for _, hiddenId in ipairs({ 0, 1, 190, 255 }) do + for _, quantity in ipairs({ 1, 2, 3, 63, 64, 127 }) do + for _, underfoot in ipairs({ false, true }) do + full_bytes[32 + 23] = hiddenId + full_bytes[32 + 24] = quantity + (underfoot and 128 or 0) + local event = ExtractMapEvents.parseMapEvents(romFull, 0x08000000).bgEvents[2] + assert(event.hiddenItemId == hiddenId, "quantity must not leak into hidden item flag") + assert(event.flag == 1000 + hiddenId and event.flag < 1256, "hidden flags stay in their eight-bit range") + assert(event.quantity == quantity, "all seven quantity bits must survive extraction") + assert(event.underfoot == underfoot, "underfoot must not change quantity or flag") + end + end +end +full_bytes[32 + 23], full_bytes[32 + 24] = 1, 1 +print("PASS hidden_item_flag_quantity_bit_boundaries") + print("=== [Test 2: Hidden Item Field Pickup & Bag Integration] ===") local game = { diff --git a/tests/game3_marowak_progression_test.lua b/tests/game3_marowak_progression_test.lua new file mode 100644 index 00000000..afe26852 --- /dev/null +++ b/tests/game3_marowak_progression_test.lua @@ -0,0 +1,76 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +require("src.core.GameVersion").set("firered") +local Ctx = require("src.core.game3.scripting.ctx") +local Flags = require("src.core.game3.scripting.flags") +local Std = require("src.core.game3.scripting.stdscripts") +local Natives = require("src.core.game3.scripting.natives") +local Enc = require("src.core.game3.encounters") +local Runtime = require("src.core.game3.runtime") +local Bag = require("src.core.game3.bag") +local session = { bag = Bag.new(), party = {} } +Bag.add(session.bag, 359, 1) +Runtime.session = session +for _, case in ipairs({ { "win", 1, 0 }, { "ran", 4, 1 }, { "lose", 2, 1 }, + { 1, 1, 0 }, { 4, 4, 1 }, { 7, 7, 1 } }) do + local ctx, callback = Ctx.new(), nil + Enc._pendingWild = { species = 105, level = 30 } + Natives.special(ctx, Std.SPECIAL.StartMarowakBattle, { + startWildBattle = function(foe, done) + assert(foe.ghost and foe.ghostUnveiled) + callback = done + end, + }) + assert(callback and not ctx.nativePoll()) + callback(case[1]) + assert(ctx.nativePoll()) + assert(Flags.getVar(nil, ctx, 0x800D) == case[3], "Marowak boolean result for " .. tostring(case[1])) + assert(ctx.lastBattleOutcome == case[2]) + Natives.special(ctx, Std.SPECIAL.GetBattleOutcome, {}) + assert(Flags.getVar(nil, ctx, 0x800D) == case[2]) + Enc._pendingWild = { species = 249, level = 70 } + Natives.special(ctx, Std.SPECIAL.StartLegendaryBattle, { + startWildBattle = function(_, done) callback = done end, + }) + callback(case[1]) + assert(ctx.nativePoll()) + assert(Flags.getVar(nil, ctx, 0x800D) == case[2]) +end +print("PASS marowak_boolean_and_raw_outcomes") +local Cache = require("tests.game3_cache") +local bundle = assert(Cache.bundle("scripts/events.lua", { native = true }), Cache.reason) +local Vm = require("src.core.game3.scripting.vm") +local Adapters = require("src.core.game3.scripting.adapters") +local events = assert(bundle.events.FR_POKEMON_TOWER_6F.coordEvents) +local key = assert(events[1].scriptKey) +for _, case in ipairs({ { "win", 1 }, { "ran", 0 } }) do + local store, messages, callback = Flags.newStore(), {}, nil + local adapters = Adapters.stub({ onMessage = function(text) messages[#messages + 1] = text end, + lookupMovement = function(id) return bundle.movements[id] end }) + adapters.startWildBattle = function(foe, done) + assert(foe.species == 105 and foe.level == 30) + callback = done + end + local vm = Vm.new({ store = store, scripts = bundle.scripts, text = bundle.text, + movements = bundle.movements, adapters = adapters }) + assert(vm:start(key)) + for _ = 1, 100 do + if callback then break end + vm:tick() + end + assert(callback, "imported script must reach Marowak native") + assert(Flags.getVar(store, nil, 0x4059) == 0) + callback(case[1]) + for _ = 1, 2000 do + if not vm:isRunning() then break end + vm:tick() + end + assert(not vm:isRunning(), "imported Tower script must finish") + assert(Flags.getVar(store, nil, 0x4059) == case[2], "imported Tower scene for " .. case[1]) + local farewell = table.concat(messages, "\n"):lower():find("mother", 1, true) ~= nil + assert(farewell == (case[1] == "win"), "farewell follows the winning branch only") + for _, event in ipairs(events) do + assert(event.var == 0x4059 and event.value == 0) + assert((Flags.getVar(store, nil, event.var) ~= event.value) == (case[1] == "win")) + end +end +print("PASS marowak_imported_script_progression") diff --git a/tests/save_editor_gen3_persistence_tests.lua b/tests/save_editor_gen3_persistence_tests.lua new file mode 100644 index 00000000..3f5917c4 --- /dev/null +++ b/tests/save_editor_gen3_persistence_tests.lua @@ -0,0 +1,341 @@ +package.path = './?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua;' .. package.path +_G.love = require('tests.love_stub') +assert(require('tests.game3_cache').mount('pokemon/meta.lua')) +local P = require('src.core.game3.pokemon') +P.install(nil) +local E = require('src.core.game3.battle.experience') +local Schema = require('src.core.game3.save_schema_firered') +local Bag = require('src.core.game3.bag') +local Storage = require('src.core.game3.storage') +local SD = require('src.core.SaveData') +local IO = require('SaveIO') +local App = require('tools.save-editor.App') +local Ops = require('Ops') +local Gen = require('Gen') +local Copy = require('src.mods.Merge').deepCopy +local function eq(a, b, label) + if type(a) == 'table' and type(b) == 'table' then + for k, v in pairs(a) do eq(v, b[k], label .. '.' .. tostring(k)) end + for k in pairs(b) do assert(a[k] ~= nil, label .. ': extra ' .. tostring(k)) end + else assert(a == b, label .. ': ' .. tostring(a) .. ' ~= ' .. tostring(b)) end +end +local function mon(id) + local m = { species = id, speciesId = id, level = 20, personality = 123, + ivs = { hp = 20, atk = 14, def = 12, spe = 18, spa = 24, spd = 6 }, + evs = { hp = 10, atk = 8, def = 6, spe = 4, spa = 2, spd = 0 }, + moves = { 33, 45 }, pp = { 30, 35 }, maxPp = { 35, 40 }, ppBonusesPacked = 5, + heldItem = 13, otId = 123, otSecretId = 321, otName = 'OTHER', nickname = 'KEPT', + metLocation = 88, metLevel = 5, pokeball = 2, friendship = 133, pokerus = 17, + custom = { sentinel = 'kept' } } + E.syncExpToLevel(m); P.applyStats(m); m.hp = m.maxHp - 3 + return m +end +local files = {} +local function load(native) + local path = os.tmpname() + files[#files + 1] = path + local f = assert(io.open(path, 'wb')); f:write(SD.encode(native)); f:close() + App.load(path, { version = 'firered', embedded = true }) + return App.getState(), path +end +local function saved(path) + assert(App.save()) + return assert(IO.load(path)) +end +local function award(session) + return E.awardFoe({ playerParty = session.party, player = { mon = session.party[1], partyIndex = 1 }, + wild = true, session = session }, { species = 113, level = 40, mon = { species = 113, level = 40 } }, + { partyIndices = { 1 }, getOpts = function() return {} end }) +end +local function native(id) + local session = Schema.newGame({ name = 'RED' }) + session.party = { mon(id or 25), mon(6) } + session.storage.boxes[1].mons[7] = mon(9) + session.storage.boxes[1].name, session.storage.boxes[1].wallpaper = 'KEEP', 8 + session.storage.currentBox = 3 + for _, pair in ipairs({ { 13, 5 }, { 68, 2 }, { 4, 10 }, { 360, 1 }, { 289, 1 }, { 139, 3 } }) do + assert(Bag.add(session.bag, pair[1], pair[2])) + end + session.storage.items = { { id = 13, qty = 999 }, { id = 68, qty = 120 }, { id = 4, qty = 10 } } + return SD.decode(SD.encode(Schema.toSaveTable(session))) +end + +local metadata = { 'personality', 'ivs', 'evs', 'moves', 'pp', 'maxPp', 'ppBonusesPacked', 'heldItem', + 'otId', 'otSecretId', 'otName', 'nickname', 'metLocation', 'metLevel', 'pokeball', 'friendship', 'pokerus', 'custom' } +if os.getenv('POKEPORT_SAVE_EDITOR_ITEMS_ONLY') ~= '1' then +local species = { 25, 4 } +local groups = {} +for id = 1, 412 do + if P.isInternalSpecies(id) then + local gr = P.growthRate(id) + if gr ~= nil and not groups[gr] then groups[gr] = id; species[#species + 1] = id end + end +end +local groupCount = 0 +for _ in pairs(groups) do groupCount = groupCount + 1 end +assert(groupCount == 6, 'real pack includes all six growth groups') +for _, id in ipairs(species) do + for _, level in ipairs({ 0, 15, 21 }) do + local source = native(id) + local before = Copy(source.party[1]) + local S, path = load(source) + eq(S.save.party[1].maxHp, before.maxHp, 'hydrate stats') + eq(S.save.party[1].hp, before.hp, 'hydrate preserves current HP') + if level > 0 then assert(Ops.setLevel(S, S.save.party[1], level)) end + S.save.party[1].exp = require('src.core.game3.summary_data').expForLevel(P.growthRate(id), S.save.party[1].level + 1) - 1 + local output = saved(path) + eq(output.party[1].species, id, 'numeric species persistence') + for _, key in ipairs(metadata) do eq(output.party[1][key], before[key], 'mon metadata ' .. key) end + local reloaded = Schema.fromSaveTable(output) + local m = reloaded.party[1] + local stats = P.calcStats(id, m.level, m.ivs, m.evs, m.personality) + eq(m.maxHp, stats.maxHp, 'runtime stats') + local oldExp, oldLevel = m.exp, m.level + local rewards = award(reloaded) + eq(#rewards, 1, 'actual award count') + assert(m.exp > oldExp and m.level > oldLevel, 'actual battle EXP and level crossing') + App.unload() + end +end +print('PASS native_editor_award_all_growth_groups') + +do + local source = native() + source.party[1].species, source.party[1].maxHp, source.party[1].hp = 'PIKACHU', 999, 7 + source.storage.boxes[1].mons[7].species = 'BLASTOISE' + local loaded = Schema.fromSaveTable(source) + eq(loaded.party[1].species, 25, 'legacy named party repair') + eq(loaded.party[1].hp, 7, 'legacy repair no healing') + eq(loaded.storage.boxes[1].mons[7].species, 9, 'legacy named box repair') + assert(#award(loaded) == 1, 'legacy repaired mon receives award') + print('PASS legacy_named_species_repair') +end + +do + local S, path = load(native()) + local m = S.save.party[1] + assert(Ops.setMove(S, m, 1, 57)) + eq(P.moveIdAt(m, 1), 57, 'numeric move replacement') + eq(m.pp[1], P.movePp(57), 'new move PP') + eq(m.ppBonusesPacked, 4, 'reset replaced slot bonus only') + eq(m.moves[2], 45, 'unaffected move slot') + eq(m.pp[2], 35, 'unaffected PP slot') + assert(Ops.clearMove(S, m, 1)); eq(m.pp[1], nil, 'clear parallel PP') + assert(Ops.setMove(S, m, 1, 57)) + eq(P.moveIdAt(Schema.fromSaveTable(saved(path)).party[1], 1), 57, 'saved native move') + assert(S.save.boxes[1][7], 'sparse slot exposed') + S.selectedParty, S.selectedBox, S.selectedBoxSlot = 2, 1, 2 + assert(Ops.deposit(S)) + eq(S.save.storage.boxes[1].mons[2].species, 6, 'deposit exact empty slot') + eq(S.save.storage.boxes[1].mons[7].species, 9, 'untouched sparse slot') + S.selectedBoxSlot = 7; assert(Ops.withdraw(S)) + eq(S.save.storage.boxes[1].mons[7], nil, 'withdraw exact sparse slot') + local output = saved(path) + eq(output.storage.boxes[1].name, 'KEEP', 'box name') + eq(output.storage.boxes[1].wallpaper, 8, 'box wallpaper') + eq(output.storage.currentBox, 3, 'current box preserved') + eq(Schema.fromSaveTable(output).storage.boxes[1].mons[2].species, 6, 'runtime sparse deposit') + App.unload() + print('PASS native_moves_sparse_box_roundtrip') +end +end + +do + local source = native() + source.extra = { keep = true } + source.bag.pockets.ITEMS[1].custom = 'slot metadata' + source.storage.items[2].custom = 'PC metadata' + source.storage.custom = 'storage metadata' + source.bag.pockets.ITEMS[#source.bag.pockets.ITEMS + 1] = { id = 2001, qty = 17, custom = 'foreign' } + source.storage.items[#source.storage.items + 1] = { id = 2002, qty = 77, custom = 'foreign PC' } + local S, path = load(source) + local repeated = Copy(S.save) + Gen.hydrateSave(S.data, S.save); eq(S.save, repeated, 'idempotent hydration') + local boxRef = S.save.storage.boxes[1].mons[7] + assert(Ops.addToBag(S, 'RARE_CANDY')); assert(Ops.bagAdjust(S, '68', 1)) + eq(S.save.inventory.pockets, nil, 'clean flat projection') + assert(Ops.pcAdjust(S, 'RARE_CANDY', -1)); assert(Ops.addToPc(S, 68)) + eq(S.save.storage.boxes[1].mons[7], boxRef, 'item edit keeps selected mon identity') + eq(S.save.pcItems['68'], 120, 'PC 120 decrement and add') + eq(S.save.inventory['68'], 4, 'same ID aliases combined') + local output = saved(path) + local expected = Copy(source.bag.pockets); expected.ITEMS[2].qty = 4 + eq(output.bag.pockets, expected, 'full untouched pocket and metadata preservation') + eq(output.storage.items, source.storage.items, 'full PC order quantities metadata') + eq(output.extra, source.extra, 'unknown save fields') + eq(output.storage.custom, source.storage.custom, 'storage metadata') + local loaded = Schema.fromSaveTable(Copy(output)) + eq(Bag.get(loaded.bag, 68), 4, 'runtime candy') + eq(loaded.storage.items[2].qty, 120, 'runtime PC above 99') + App.unload(); App.load(path, { version = 'firered', embedded = true }); S = App.getState() + assert(Ops.bagMax(S, 'RARE_CANDY')); assert(Ops.pcMax(S, '68')) + local before = Copy(S.save); S.dirty = false + assert(not Ops.addToBag(S, 68)); eq(S.save, before, 'bag 999 rejection full rollback'); eq(S.dirty, false, 'failed bag not dirty') + assert(not Ops.addToPc(S, 'RARE_CANDY')); eq(S.save, before, 'PC 999 rejection full rollback') + assert(Ops.bagAdjust(S, 68, -1)); assert(Ops.pcAdjust(S, 68, -1)) + eq(S.save.inventory['68'], 998, 'bag 999 decrement'); eq(S.save.pcItems['68'], 998, 'PC 999 decrement') + assert(Ops.bagMaxAll(S)); assert(Ops.pcMaxAll(S)) + for _, slot in ipairs(S.save.bag.pockets.KEY_ITEMS) do eq(slot.qty, 1, 'containers not inflated') end + assert(Ops.bagDrop(S, 68)); assert(Ops.pcDrop(S, 68)) + local final = Schema.fromSaveTable(saved(path)) + eq(Bag.get(final.bag, 68), 0, 'drop persisted') + final.storage.items[1].qty = 998 + assert(Storage.depositItem(final, 'ITEMS', 1, 1)); assert(Storage.withdrawItem(final, 1, 1)) + App.unload() + print('PASS native_bag_pc_atomic_preservation') +end + +do + local source = native() + source.bag = Bag.new(); source.storage.items = {} + source.inventory = { stacks = { POTION = 999 } }; source.pcItems = { POTION = 999 } + local S, path = load(source) + eq(S.save.inventory, {}, 'native empty bag authoritative'); eq(S.save.pcItems, {}, 'native empty PC authoritative') + saved(path); App.unload() + source = native(); source.bag.pockets.ITEMS = {} + for i = 1, 42 do source.bag.pockets.ITEMS[i] = { id = 2000 + i, qty = 1 } end + S, path = load(source) + local before = Copy(S.save) + assert(not Ops.addToBag(S, 68)); eq(S.save, before, 'full pocket rollback'); eq(S.dirty, false, 'full rejection dirty') + App.unload() + source = native(); source.storage = nil + source.pc = { items = { { id = 68, qty = 120 } }, mons = { mon(9) }, custom = true } + S, path = load(source); assert(Ops.pcAdjust(S, 68, -1)) + local output = saved(path) + eq(output.storage.items[1].qty, 119, 'legacy PC migration quantity') + eq(output.storage.boxes[1].mons[1].species, 9, 'legacy PC mons retained') + eq(output.pc.custom, true, 'legacy unknown field') + App.unload() + print('PASS native_empty_legacy_full_pocket') +end +do + local A = require('Game3Adapter') + local source = native() + local old = mon(6); old.nickname = 'OLDDEPOSIT' + source.boxes = { { [7] = old, [8] = Copy(source.storage.boxes[1].mons[7]) } } + local S, path = load(source) + local function count(save, nickname) + local n = 0 + for _, box in pairs(save.storage.boxes) do + for _, m in pairs(box.mons) do if m.nickname == nickname then n = n + 1 end end + end + return n + end + eq(S.save.storage.boxes[1].mons[7].species, 9, 'collision native preserved') + eq(count(S.save, 'OLDDEPOSIT'), 1, 'collision deposit relocated') + eq(count(S.save, 'KEPT'), 1, 'serialized mirror deduplicated') + local before = Copy(S.save) + Gen.ensureBoxes(S.save); Gen.hydrateSave(S.data, S.save) + eq(S.save, before, 'legacy coexistence repeated hydration') + local output = saved(path) + eq(count(output, 'OLDDEPOSIT'), 1, 'old editor no-op save retains deposit') + eq(count(Schema.fromSaveTable(output), 'OLDDEPOSIT'), 1, 'runtime retains old deposit') + App.unload(); App.load(path, { version = 'firered', embedded = true }) + eq(count(saved(path), 'OLDDEPOSIT'), 1, 'second no-op save no duplication') + App.unload() + source = native() + for b = 1, 14 do + source.storage.boxes[b] = source.storage.boxes[b] or { mons = {} } + for s = 1, 30 do source.storage.boxes[b].mons[s] = mon(9) end + end + source.boxes = { { old } } + before = Copy(source) + local ok, err = pcall(A.ensureStorage, source) + assert(not ok and tostring(err):find('native storage is full', 1, true)) + eq(source, before, 'full collision leaves all recoverable data intact') + S, path = load(source) + assert(S.loadError and not S.allowSave and not App.save(), 'full collision disables App save') + eq(assert(IO.load(path)), source, 'failed App load leaves file intact') + App.unload() + source.storage.boxes[14].mons[30] = nil + S, path = load(source) + eq(count(saved(path), 'OLDDEPOSIT'), 1, 'last free slot collision migration') + App.unload() + print('PASS legacy_native_boxes_collision_noop_full_storage') +end + +do + local A = require('Game3Adapter') + for _, mode in ipairs({ 'minus', 'drop', 'max' }) do + local source = native() + local slots = { { id = 13, qty = 700, custom = 'before' }, { id = 68, qty = 120, custom = 'target' }, + { id = 2001, qty = 77, custom = 'middle' }, { id = 'RARE_CANDY', qty = 1 }, + { id = '68', qty = 2 }, { id = 2002, qty = 88, custom = 'after' } } + source.bag.pockets.ITEMS, source.storage.items = Copy(slots), Copy(slots) + local S, path = load(source) + eq(S.save.inventory['68'], 123, 'duplicate bag total') + eq(S.save.pcItems['68'], 123, 'duplicate PC total') + local untouched = Copy(S.save) + assert(not A.change(S.data, S.save, false, { { id = 68, qty = 122 }, { id = 13, qty = 1000 } })) + eq(S.save, untouched, 'duplicate bag batch rejection rollback') + assert(not A.change(S.data, S.save, true, { { id = 68, qty = 122 }, { id = 13, qty = 1000 } })) + eq(S.save, untouched, 'duplicate PC batch rejection rollback') + local expected = mode == 'minus' and 122 or mode == 'max' and 999 or 0 + for _, prefix in ipairs({ 'bag', 'pc' }) do + if mode == 'minus' then assert(Ops[prefix .. 'Adjust'](S, 'RARE_CANDY', -1)) + elseif mode == 'drop' then assert(Ops[prefix .. 'Drop'](S, '68')) + else assert(Ops[prefix .. 'Max'](S, 68)) end + end + local expectedSlots = { Copy(slots[1]), Copy(slots[3]), Copy(slots[6]) } + if expected > 0 then + local target = Copy(slots[2]); target.qty = expected + table.insert(expectedSlots, 2, target) + end + eq(S.save.bag.pockets.ITEMS, expectedSlots, 'bag duplicate ' .. mode) + eq(S.save.storage.items, expectedSlots, 'PC duplicate ' .. mode) + local output = saved(path) + eq(output.bag.pockets.ITEMS, expectedSlots, 'persist bag duplicate ' .. mode) + eq(output.storage.items, expectedSlots, 'persist PC duplicate ' .. mode) + App.unload(); App.load(path, { version = 'firered', embedded = true }); S = App.getState() + eq(S.save.inventory['68'] or 0, expected, 'reload bag aggregate ' .. mode) + eq(S.save.pcItems['68'] or 0, expected, 'reload PC aggregate ' .. mode) + if mode == 'max' then + untouched = Copy(S.save) + assert(not Ops.addToBag(S, 68) and not Ops.addToPc(S, 'RARE_CANDY')) + eq(S.save, untouched, 'duplicate capped rejection unchanged') + end + App.unload() + end + print('PASS duplicate_bag_pc_alias_minus_drop_max_reload_rollback') +end +do + local source = native() + local S, path = load(source) + S.editingMon, S.dirty, S._quitArmed, S._openArmed = S.save.party[1], true, true, true + assert(App.reload()) + eq(S.editingMon, nil, 'reload clears mon editor') + eq(S.dirty, false, 'reload clears dirty') + eq(S._quitArmed, false, 'reload disarms quit') + eq(S._openArmed, false, 'reload disarms open') + eq(S.save.inventory['68'], 2, 'actual reload bag projection') + eq(S.save.pcItems['68'], 120, 'actual reload PC projection') + assert(S.save.boxes[1] == S.save.storage.boxes[1].mons, 'reload native box alias') + local output = saved(path) + eq(output.bag.pockets, source.bag.pockets, 'reload no-op bag') + eq(output.storage.items, source.storage.items, 'reload no-op PC') + eq(output.storage.boxes[1].mons[7].species, 9, 'reload no-op native sparse mon') + assert(App.reload()) + assert(Ops.addToBag(S, 'RARE_CANDY')) + assert(Ops.pcAdjust(S, '68', -1)) + S.selectedParty, S.selectedBox, S.selectedBoxSlot = 2, 1, 2 + assert(Ops.deposit(S)) + output = saved(path) + local runtime = Schema.fromSaveTable(Copy(output)) + eq(Bag.get(runtime.bag, 68), 3, 'reload subsequent bag edit') + eq(runtime.storage.items[2].qty, 119, 'reload subsequent PC edit') + eq(runtime.storage.boxes[1].mons[2].species, 6, 'reload subsequent deposit') + eq(runtime.storage.boxes[1].mons[7].species, 9, 'reload untouched native mon') + for b = 1, 14 do + source.storage.boxes[b] = source.storage.boxes[b] or { mons = {} } + for s = 1, 30 do source.storage.boxes[b].mons[s] = mon(9) end + end + source.boxes = { { mon(6) } } + local f = assert(io.open(path, 'wb')); f:write(SD.encode(source)); f:close() + assert(not App.reload() and S.loadError and not S.allowSave, 'reload collision disables save') + assert(not App.save()) + eq(assert(IO.load(path)), source, 'reload collision leaves disk intact') + App.unload() + print('PASS actual_app_reload_native_noop_edits_collision') +end +for _, path in ipairs(files) do os.remove(path) end +print('PASS save_editor_gen3_native_persistence') diff --git a/tests/save_editor_gen3_tests.lua b/tests/save_editor_gen3_tests.lua index 148ffab3..7c4489b8 100644 --- a/tests/save_editor_gen3_tests.lua +++ b/tests/save_editor_gen3_tests.lua @@ -149,8 +149,8 @@ do -- Set Move MonOps.setMove(mockData, mon, 1, 57) -- Surf - checkEq(mon.moves[1].moveId, 57, "move 1 set to Surf (57)") - check(mon.moves[1].pp > 0, "move 1 PP initialized") + checkEq(require("src.core.game3.pokemon").moveIdAt(mon, 1), 57, "move 1 set to Surf (57)") + check(mon.pp[1] > 0, "move 1 PP initialized") -- Set DVs / IVs MonOps.setDv(mockData, mon, "attack", 15, 3) @@ -339,7 +339,7 @@ do ivs = { hp = 15, atk = 14, def = 13, spe = 12, spa = 11, spd = 10 }, } Gen.hydrateMon(mockData, rawMon) - checkEq(rawMon.species, "BULBASAUR", "hydrateMon normalizes numeric species to BULBASAUR") + checkEq(rawMon.species, 1, "hydrateMon preserves native numeric species") checkEq(rawMon.speciesId, 1, "hydrateMon preserves speciesId = 1") check(type(rawMon.dvs) == "table", "hydrateMon creates dvs table") checkEq(rawMon.dvs.attack, 7, "hydrateMon computes attack DV from IV") @@ -368,7 +368,7 @@ do -- Test MonOps.setSpecies Ops.setSpecies(S, mon, "CHARMANDER") - checkEq(mon.species, "CHARMANDER", "Ops.setSpecies updates to CHARMANDER") + checkEq(mon.species, 4, "Ops.setSpecies updates to numeric CHARMANDER") checkEq(mon.speciesId, 4, "Ops.setSpecies updates speciesId to 4") end @@ -440,31 +440,31 @@ do -- Add to bag Ops.addToBag(S, "POTION") - checkEq(save.inventory["POTION"], 1, "POTION added to bag x1") + checkEq(save.inventory["13"], 1, "POTION added to bag x1") check(save.bag ~= nil and save.bag.stacks ~= nil, "save.bag updated") -- Adjust bag quantity Ops.bagAdjust(S, "POTION", 4) - checkEq(save.inventory["POTION"], 5, "POTION adjusted to x5") + checkEq(save.inventory["13"], 5, "POTION adjusted to x5") -- Max bag stack Ops.bagMax(S, "POTION") - checkEq(save.inventory["POTION"], Ops.STACK_MAX, "POTION maxed to 99") + checkEq(save.inventory["13"], 999, "POTION maxed to 999") -- Drop bag item Ops.bagDrop(S, "POTION") - checkEq(save.inventory["POTION"], nil, "POTION dropped from bag") + checkEq(save.inventory["13"], nil, "POTION dropped from bag") -- PC operations Ops.addToPc(S, "POKE_BALL") - checkEq(save.pcItems["POKE_BALL"], 1, "POKE_BALL added to PC x1") + checkEq(save.pcItems["4"], 1, "POKE_BALL added to PC x1") check(save.storage ~= nil and #save.storage.items > 0, "save.storage.items synced") Ops.pcAdjust(S, "POKE_BALL", 9) - checkEq(save.pcItems["POKE_BALL"], 10, "POKE_BALL adjusted to x10 in PC") + checkEq(save.pcItems["4"], 10, "POKE_BALL adjusted to x10 in PC") Ops.pcMax(S, "POKE_BALL") - checkEq(save.pcItems["POKE_BALL"], Ops.STACK_MAX, "POKE_BALL maxed in PC") + checkEq(save.pcItems["4"], 999, "POKE_BALL maxed in PC") -- Test numeric item IDs in Bag.order, Bag.slots, and isBadge save.inventory[13] = 5 -- Potion numeric ID @@ -551,7 +551,7 @@ do -- Test table-typed entries in save.inventory and save.pcItems S.save.inventory[1] = { id = 13, qty = 5 } - S.save.inventory["POTION"] = { qty = 10 } + S.save.inventory["13"] = { qty = 10 } S.save.pcItems[1] = { id = 4, qty = 20 } check(pcall(Ops.bagCanMax, S), "Ops.bagCanMax handles table entries in inventory without error") check(pcall(Ops.pcCanMax, S), "Ops.pcCanMax handles table entries in pcItems without error") diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index db3f7fb0..bde1d1f4 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -121,8 +121,15 @@ local function applyLoaded(path, statusVerb) S._openArmed = false S.editingMon = nil Ops.disarm(S) - Gen.ensureBoxes(S.save) - Gen.hydrateSave(Data, S.save) + local prepared, prepareError = pcall(function() + Gen.ensureBoxes(S.save) + Gen.hydrateSave(Data, S.save) + end) + if not prepared then + S.loadError, S.allowSave = true, false + S.status = "Save disabled: " .. tostring(prepareError) + return + end local probe = require("src.mods.Merge").deepCopy(S.save) S.validation = Gen.validate(probe, Data) if not Gen.emptyReport(S.save, S.validation) then @@ -290,7 +297,13 @@ function App.save() if not S.allowSave then return Ops.say(S, "Save disabled, corrupt save loaded; fix the file and Reload first") end - local ok, err = SaveIO.save(S.path, S.save) + local output = S.save + if Gen.ofState(S) == 3 then + local prepared, result = pcall(require("Game3Adapter").export, S.save) + if not prepared then S.status = "Save failed: " .. tostring(result); return false end + output = result + end + local ok, err = SaveIO.save(S.path, output) if ok then S.dirty = false S._quitArmed = false @@ -305,6 +318,10 @@ end function App.reload() local save, err = SaveIO.load(S.path) if save then + if Gen.of(save, S.version) == 3 then + applyLoaded(S.path, "Reloaded") + return not S.loadError + end S.save = save S.dirty = false S.loadError = false @@ -594,7 +611,7 @@ local function tabCount(id) return ("%d/%d"):format(#S.save.party, require("src.pokemon.Party").MAX) elseif id == "boxes" then local n = 0 - for _, box in ipairs(Ops.boxes(S)) do n = n + #box end + for _, box in ipairs(Ops.boxes(S)) do n = n + Ops.boxSize(S, box) end return tostring(n) elseif id == "items" then local Bag = require("src.inventory.Bag") diff --git a/tools/save-editor/Game3Adapter.lua b/tools/save-editor/Game3Adapter.lua new file mode 100644 index 00000000..46df6ba4 --- /dev/null +++ b/tools/save-editor/Game3Adapter.lua @@ -0,0 +1,189 @@ +local Copy = require("src.mods.Merge").deepCopy +local Items = require("src.core.game3.items_data") +local Bag = require("src.core.game3.bag") +local Storage = require("src.core.game3.storage") +local Mons = require("src.core.game3.save_mon") + +local M = {} +local pockets = { "ITEMS", "KEY_ITEMS", "POKE_BALLS", "TM_CASE", "BERRY_POUCH" } + +function M.itemId(data, id) + local def = data and data.items and data.items[id] + return tonumber(def and def.itemId) or Items.toNumericId(id) or id +end + +function M.key(data, id) + return tostring(M.itemId(data, id)) +end + +local function equal(a, b) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + for k, v in pairs(a) do if not equal(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end + +function M.ensureStorage(save) + local legacy = type(save.storage) ~= "table" + local source = legacy and Storage.restore(nil, save.pc, save.pcItems or save.pc_items) or save.storage + local storage = {} + for k, v in pairs(source) do storage[k] = v end + storage.boxes, storage.items = {}, source.items or {} + local boxes = {} + local available = {} + for b = 1, 14 do + local original = source.boxes and source.boxes[b] or { name = "BOX " .. b, wallpaper = ((b - 1) % 16) + 1 } + local box = {} + for k, v in pairs(original) do box[k] = v end + box.mons = {} + for s, mon in pairs(original.mons or {}) do + box.mons[s] = mon + available[#available + 1] = { mon = mon } + end + storage.boxes[b], boxes[b] = box, box.mons + end + for b, box in pairs(save.boxes or {}) do + for s, mon in pairs(type(box) == "table" and box or {}) do + if type(s) == "number" and type(mon) == "table" then + local mirrored = false + for _, entry in ipairs(available) do + if not entry.used and equal(entry.mon, mon) then entry.used = true; mirrored = true; break end + end + if not mirrored then + local targetB, targetS + if boxes[b] and s >= 1 and s <= 30 and s == math.floor(s) and not boxes[b][s] then + targetB, targetS = b, s + else + targetB, targetS = Storage.findOpenSlot(storage) + end + if not targetB then error("Cannot preserve legacy box Pokemon: native storage is full") end + boxes[targetB][targetS] = mon + end + end + end + end + save.storage = storage + if legacy and type(save.pc) == "table" then save.pc.mons, save.pc.items = nil, nil end + save.boxes = boxes + save.currentBox = math.max(1, math.min(14, tonumber(storage.currentBox) or 1)) + return boxes +end + +local function project(data, slots, out, order) + for _, slot in ipairs(slots or {}) do + if slot.id ~= nil and (tonumber(slot.qty) or 0) > 0 then + local key = M.key(data, slot.id) + if not out[key] and order then order[#order + 1] = key end + out[key] = (out[key] or 0) + slot.qty + end + end +end + +function M.project(data, save) + local inv, order, pc = {}, {}, {} + for _, pocket in ipairs(pockets) do project(data, save.bag.pockets[pocket], inv, order) end + project(data, save.storage.items, pc) + save.inventory, save.bagOrder, save.pcItems = inv, order, pc + save.bag.stacks = Copy(inv) +end + +function M.hydrate(data, save) + M.ensureStorage(save) + if type(save.bag) ~= "table" then save.bag = Copy(save.inventory or {}) end + if type(save.bag.pockets) ~= "table" then + local source = save.bag + if not source.stacks and not source.items then source = { stacks = source } end + save.bag = Bag.migrate(Copy(source)) + end + M.project(data, save) +end + +function M.export(save) + local out = Copy(save) + M.ensureStorage(out) + Mons.each(out, Mons.normalize) + out.inventory = Copy(out.bag) + out.boxes, out.pcItems, out.bagOrder = nil, nil, nil + return out +end + +local function locate(data, slots, id) + for i, slot in ipairs(slots) do + if M.key(data, slot.id) == M.key(data, id) then return i, slot end + end +end + +function M.quantity(data, save, pc, id) + local key = M.key(data, id) + return tonumber((pc and save.pcItems or save.inventory or {})[key]) or 0 +end + +local function set(data, save, pc, id, qty) + if type(qty) ~= "number" or qty ~= math.floor(qty) or qty < 0 or qty > 999 then return false end + id = M.itemId(data, id) + local slots, cap + if pc then + slots, cap = save.storage.items, Storage.PC_ITEMS_COUNT + else + for _, p in ipairs(pockets) do + local list = save.bag.pockets[p] + if list and locate(data, list, id) then slots = list; break end + end + local pocket = Items.pocketOf(id) + slots = slots or save.bag.pockets[pocket] + if not slots then slots = {}; save.bag.pockets[pocket] = slots end + cap = Items.CAPACITY[pocket] or 42 + end + local lists = pc and { slots } or {} + if not pc then for _, p in ipairs(pockets) do lists[#lists + 1] = save.bag.pockets[p] or {} end end + local found = false + for _, list in ipairs(lists) do + local i = 1 + while i <= #list do + local slot = list[i] + if M.key(data, slot.id) == M.key(data, id) then + if not found and qty > 0 then + slot.qty = qty + found = true + i = i + 1 + else + found = true + table.remove(list, i) + end + else i = i + 1 end + end + end + if found then + return true + elseif qty > 0 then + if #slots >= cap then return false end + if not pc then + local pocket = Items.pocketOf(id) + local container = pocket == "TM_CASE" and Items.ITEM_TM_CASE or pocket == "BERRY_POUCH" and Items.ITEM_BERRY_POUCH + if container and not locate(data, save.bag.pockets.KEY_ITEMS or {}, container) then + if not set(data, save, false, container, 1) then return false end + end + end + slots[#slots + 1] = { id = id, qty = qty } + end + return true +end + +function M.change(data, save, pc, changes) + local staged = { bag = pc and save.bag or Copy(save.bag), storage = save.storage } + if pc then + staged.storage = {} + for k, value in pairs(save.storage) do staged.storage[k] = value end + staged.storage.items = Copy(save.storage.items) + end + for _, change in ipairs(changes) do + if not set(data, staged, pc, change.id, change.qty) then return false end + end + save.bag, save.storage = staged.bag, staged.storage + M.ensureStorage(save) + M.project(data, save) + return true +end + +return M diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua index cbdc8719..62ee63d9 100644 --- a/tools/save-editor/Gen.lua +++ b/tools/save-editor/Gen.lua @@ -216,6 +216,7 @@ function Gen.bindGame3Data(data) local iDef = { id = normName, name = info.name, pocket = info.pocket, itemId = num } data.items[normName] = iDef data.items[num] = iDef + data.items[tostring(num)] = iDef end end end @@ -267,13 +268,15 @@ function Gen.hydrateMon(data, mon) if isG3 then local okP, Pokemon = pcall(require, "src.core.game3.pokemon") if okP and Pokemon then - local spId = tonumber(mon.speciesId or mon.species) - or (Pokemon.speciesFromName and Pokemon.speciesFromName(tostring(mon.species))) + require("src.core.game3.save_mon").normalize(mon) + local spId = Pokemon.speciesOf(mon) if spId then mon.speciesId = spId local name = Pokemon.name(spId) if name and name ~= "" and name ~= "??????????" then - mon.species = name + mon.species = spId + mon.name = mon.name or name + mon.speciesNumbering = Pokemon.NUMBERING_INTERNAL end end mon.ivs = mon.ivs or { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 } @@ -285,17 +288,7 @@ function Gen.hydrateMon(data, mon) special = math.floor(((mon.ivs.spa or 0) + (mon.ivs.spd or 0)) / 4), hp = math.floor((mon.ivs.hp or 0) / 2), } - Pokemon.applyStats(mon) - mon.stats = { - hp = mon.maxHp or mon.hp or 10, - attack = mon.attack or 10, - defense = mon.defense or 10, - speed = mon.speed or 10, - spAtk = mon.spAtk or mon.spa or 10, - spDef = mon.spDef or mon.spd or 10, - specialAttack = mon.spAtk or mon.spa or 10, - specialDefense = mon.spDef or mon.spd or 10, - } + require("src.core.game3.save_mon").normalize(mon) end return mon end @@ -315,97 +308,9 @@ function Gen.hydrateSave(data, save) if type(save) ~= "table" then return save end local g = Gen.of(save) if g == 3 then - save.inventory = save.inventory or {} - save.bagOrder = save.bagOrder or {} - save.pcItems = save.pcItems or {} - save.bag = save.bag or require("src.core.game3.bag").new() - - if save.inventory then - local arraySlots = {} - for k, v in pairs(save.inventory) do - if type(k) == "number" and type(v) == "table" then - arraySlots[#arraySlots + 1] = k - local sId = v.id or v.itemId or v.name or v[1] - local sQty = tonumber(v.qty or v.quantity or v.count or v[2]) or 1 - if sId and sQty > 0 then - local okD, ItemsData = pcall(require, "src.core.game3.items_data") - local okI, Items = pcall(require, "src.core.game3.items") - local num = okD and ItemsData and ItemsData.toNumericId(sId) - local name = (num and okI and Items and Items.FRLG_TO_HOST[num]) or (okD and ItemsData and ItemsData.bagKey(sId)) or tostring(sId) - save.inventory[name] = sQty - end - end - end - for _, k in ipairs(arraySlots) do - save.inventory[k] = nil - end - end - - if save.bag and save.bag.pockets then - local okD, ItemsData = pcall(require, "src.core.game3.items_data") - local okI, Items = pcall(require, "src.core.game3.items") - if okD and ItemsData and okI and Items then - for _, pocketList in pairs(save.bag.pockets) do - for _, slot in ipairs(pocketList or {}) do - if slot.id and (tonumber(slot.qty) or 0) > 0 then - local num = ItemsData.toNumericId(slot.id) - local name = (num and Items.FRLG_TO_HOST[num]) or ItemsData.bagKey(slot.id) or tostring(slot.id) - save.inventory[name] = tonumber(slot.qty) or 1 - end - end - end - end - end - - if save.pcItems then - local arraySlots = {} - for k, v in pairs(save.pcItems) do - if type(k) == "number" and type(v) == "table" then - arraySlots[#arraySlots + 1] = k - local sId = v.id or v.itemId or v.name or v[1] - local sQty = tonumber(v.qty or v.quantity or v.count or v[2]) or 1 - if sId and sQty > 0 then - local okD, ItemsData = pcall(require, "src.core.game3.items_data") - local okI, Items = pcall(require, "src.core.game3.items") - local num = okD and ItemsData and ItemsData.toNumericId(sId) - local name = (num and okI and Items and Items.FRLG_TO_HOST[num]) or (okD and ItemsData and ItemsData.bagKey(sId)) or tostring(sId) - save.pcItems[name] = sQty - end - end - end - for _, k in ipairs(arraySlots) do - save.pcItems[k] = nil - end - end - - local pcLists = {} - if type(save.storage) == "table" and type(save.storage.items) == "table" then - pcLists[1] = save.storage.items - elseif type(save.pc) == "table" and type(save.pc.items) == "table" then - pcLists[1] = save.pc.items - end - for _, pcList in ipairs(pcLists) do - local okD, ItemsData = pcall(require, "src.core.game3.items_data") - local okI, Items = pcall(require, "src.core.game3.items") - if okD and ItemsData and okI and Items then - for _, slot in ipairs(pcList) do - local id = slot.id or slot.itemId - local qty = slot.qty or slot.quantity or 1 - if id and (tonumber(qty) or 0) > 0 then - local num = ItemsData.toNumericId(id) - local name = (num and Items.FRLG_TO_HOST[num]) or ItemsData.bagKey(id) or tostring(id) - save.pcItems[name] = tonumber(qty) or 1 - end - end - end - end - - for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end - for _, box in ipairs(save.boxes or {}) do - if type(box) == "table" then - for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end - end - end + require("Game3Adapter").hydrate(data, save) + local Mons = require("src.core.game3.save_mon") + Mons.each(save, function(mon) Mons.normalize(mon); Gen.hydrateMon(data, mon) end) return save elseif g == 2 then local Mon = require("src.battle.gen2.Mon") @@ -427,12 +332,7 @@ end function Gen.ensureBoxes(save) local g = Gen.of(save) if g == 3 then - save.boxes = save.boxes or {} - for i = 1, 14 do - save.boxes[i] = save.boxes[i] or {} - end - save.currentBox = math.max(1, math.min(14, save.currentBox or 1)) - return save.boxes + return require("Game3Adapter").ensureStorage(save) elseif g == 2 then local Boxes2 = require("src.core.gen2.Boxes") save.boxes = save.boxes or {} diff --git a/tools/save-editor/MonOps.lua b/tools/save-editor/MonOps.lua index 8a04fdc7..238819a5 100644 --- a/tools/save-editor/MonOps.lua +++ b/tools/save-editor/MonOps.lua @@ -39,7 +39,7 @@ function MonOps.create(data, species, level, gen) if mvId and mvId > 0 then local mvName = PokemonG3.moveName(mvId) monMoves[slot] = { - id = mvName, + id = mvId, moveId = mvId, pp = pp[slot] or 10, maxPp = maxPp[slot] or 10, @@ -48,7 +48,7 @@ function MonOps.create(data, species, level, gen) end local mon = { - species = name, + species = spId, speciesId = spId, name = name, nickname = "", @@ -74,7 +74,7 @@ function MonOps.create(data, species, level, gen) pokeball = 4, -- pokefirered/src/pokemon.c:1820 } - PokemonG3.applyStats(mon) + require("src.core.game3.save_mon").normalize(mon) mon.stats = { hp = mon.maxHp, attack = mon.attack, @@ -102,17 +102,7 @@ function MonOps.recalc(data, mon, gen) if isG3 then local okP, PokemonG3 = pcall(require, "src.core.game3.pokemon") if okP and PokemonG3 then - PokemonG3.applyStats(mon) - mon.stats = { - hp = mon.maxHp or mon.hp or 10, - attack = mon.attack or 10, - defense = mon.defense or 10, - speed = mon.speed or 10, - spAtk = mon.spAtk or mon.spa or 10, - spDef = mon.spDef or mon.spd or 10, - specialAttack = mon.spAtk or mon.spa or 10, - specialDefense = mon.spDef or mon.spd or 10, - } + require("src.core.game3.save_mon").normalize(mon) mon.hp = math.max(0, math.min(mon.hp or mon.maxHp, mon.maxHp)) end return @@ -135,7 +125,7 @@ function MonOps.setLevel(data, mon, level, gen) if isG3 then local SummaryData = require("src.core.game3.summary_data") - local gr = mon.growthRate or (data and data.pokemon and data.pokemon[mon.species] and data.pokemon[mon.species].growthRate) or 0 + local gr = require("src.core.game3.pokemon").growthRate(require("src.core.game3.pokemon").speciesOf(mon)) or mon.growthRate or 0 mon.exp = SummaryData.expForLevel(gr, level) mon.experience = mon.exp MonOps.recalc(data, mon, gen) @@ -154,6 +144,16 @@ function MonOps.setLevel(data, mon, level, gen) MonOps.recalc(data, mon, gen) end +function MonOps.clearMove(mon, slot) + for _, key in ipairs({ "moves", "pp", "maxPp", "moveIds", "ppBonuses", "ppBonus", "ppUp" }) do + if type(mon[key]) == "table" then mon[key][slot] = nil end + end + if type(mon.ppBonusesPacked) == "number" then + local bit = require("bit") + mon.ppBonusesPacked = bit.band(mon.ppBonusesPacked, bit.bnot(bit.lshift(3, (slot - 1) * 2))) + end +end + function MonOps.setMove(data, mon, slot, moveId) assert(slot >= 1 and slot <= 4) local mdef = data and data.moves and data.moves[moveId] @@ -175,6 +175,14 @@ function MonOps.setMove(data, mon, slot, moveId) assert(type(mdef) == "table", "unknown move: " .. tostring(moveId)) mon.moves = mon.moves or {} local basePp = mdef.pp or 10 + if mon.speciesId ~= nil or mon.personality ~= nil then + local nativeId = assert(tonumber(mdef.moveId or numMove), "unknown native move") + MonOps.clearMove(mon, slot) + mon.moves[slot] = nativeId + mon.pp, mon.maxPp = mon.pp or {}, mon.maxPp or {} + mon.pp[slot], mon.maxPp[slot] = basePp, basePp + return + end local currentUps = (mon.moves[slot] and mon.moves[slot].ppUps) or 0 mon.moves[slot] = { id = mdef.id or mdef.name or tostring(moveId), @@ -234,7 +242,8 @@ function MonOps.setSpecies(data, mon, species, gen) pcall(PokemonG3.install, nil) local spId = tonumber(species) or (PokemonG3.speciesFromName and PokemonG3.speciesFromName(tostring(species))) or 1 local name = (PokemonG3.name and PokemonG3.name(spId)) or tostring(species) - mon.species = name + mon.species = spId + mon.speciesNumbering = PokemonG3.NUMBERING_INTERNAL mon.speciesId = spId mon.name = name local meta = PokemonG3.speciesMeta and PokemonG3.speciesMeta(spId) diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 0b3324d9..513228f5 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -20,9 +20,19 @@ local Charmap = require("src.save_convert.data.charmap") local Gen = require("Gen") local Ops = {} +local G3 = require("Game3Adapter") +local boxInsert, boxRemove Ops.MONEY_MAX = 999999 Ops.STACK_MAX = 99 +function Ops.stackMax(S) + return Gen.ofState(S) == 3 and 999 or Ops.STACK_MAX +end + +local function moveId(mon, slot) + local move = mon.moves and mon.moves[slot] + return type(move) == "table" and (move.moveId or move.id) or move +end Ops.ARM_SECONDS = 2.5 -- The in-game naming screen caps a nickname at 10 glyphs -- (BattleState:askNicknameUI / src/ui/NamingScreen.lua maxLen = 10); the @@ -427,9 +437,9 @@ end -- the target is the box, not a mon. function Ops.openBoxAddPicker(S, Kit) local box = Ops.boxes(S)[S.selectedBox] - if #box >= Ops.boxCapacity(S) then + if Ops.boxSize(S, box) >= Ops.boxCapacity(S) then return Ops.say(S, ("Box %d is full (%d/%d)") - :format(S.selectedBox, #box, Ops.boxCapacity(S))) + :format(S.selectedBox, Ops.boxSize(S, box), Ops.boxCapacity(S))) end S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" } if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529) @@ -441,20 +451,19 @@ end -- box mon and a party mon born in the editor are indistinguishable. function Ops.boxAddSpecies(S, id) local box = Ops.boxes(S)[S.selectedBox] - if #box >= Ops.boxCapacity(S) then + if Ops.boxSize(S, box) >= Ops.boxCapacity(S) then return Ops.say(S, ("Box %d is full (%d/%d)") - :format(S.selectedBox, #box, Ops.boxCapacity(S))) + :format(S.selectedBox, Ops.boxSize(S, box), Ops.boxCapacity(S))) end if not Ops.speciesUsable(S, id) then return Ops.say(S, ("%s has no usable base stats, cannot add it") :format(tostring(id))) end local mon = createMon(S, id, 5) - table.insert(box, mon) - S.selectedBoxSlot = #box + S.selectedBoxSlot = boxInsert(S, box, mon) S.editingMon = mon return Ops.mark(S, ("Added %s Lv5 to box %d slot %d") - :format(id, S.selectedBox, #box)) + :format(id, S.selectedBox, S.selectedBoxSlot)) end function Ops.setDv(S, mon, key, value) @@ -472,7 +481,7 @@ end function Ops.cycleMove(S, mon, slot) if not mon or not (S.cat and S.cat.moves and #S.cat.moves > 0) then return false end local moves = S.cat.moves - local current = mon.moves and mon.moves[slot] and mon.moves[slot].id + local current = moveId(mon, slot) local idx = 0 if current then for i, id in ipairs(moves) do @@ -554,7 +563,7 @@ function Ops.setMove(S, mon, slot, id) if not mon then return false end slot = math.floor(tonumber(slot) or 0) if slot < 1 or slot > 4 then return false end - local current = mon.moves and mon.moves[slot] and mon.moves[slot].id + local current = moveId(mon, slot) if id == current then return Ops.say(S, ("Move %d is already %s"):format(slot, tostring(id))) end @@ -596,8 +605,8 @@ function Ops.clearMove(S, mon, slot) if not (mon and mon.moves and mon.moves[slot]) then return Ops.say(S, ("Move slot %d is already empty"):format(slot)) end - local id = mon.moves[slot].id - mon.moves[slot] = nil + local id = moveId(mon, slot) + if Gen.ofState(S) == 3 then MonOps.clearMove(mon, slot) else mon.moves[slot] = nil end return Ops.mark(S, ("Cleared move slot %d (%s)"):format(slot, id)) end @@ -606,7 +615,10 @@ function Ops.resetMoves(S, mon) local def = S.data.pokemon[mon.species] local gen = Gen.ofState(S) local learned - if gen == 2 then + if gen == 3 then + learned = require("src.core.game3.pokemon").movesAtLevel(mon, mon.level) + for slot = 1, 4 do MonOps.clearMove(mon, slot) end + elseif gen == 2 then local Mon = require("src.battle.gen2.Mon") learned = {} for _, mv in ipairs(Mon.movesAtLevel(def, mon.level, S.data.moves)) do @@ -631,9 +643,16 @@ function Ops.healMon(S, mon) mon.hp = mon.stats.hp if mon.maxHp then mon.maxHp = mon.stats.hp end mon.status = nil - for _, mv in ipairs(mon.moves or {}) do - local def = S.data.moves[mv.id] - if def then mv.pp = def.pp + ((mv.ppUps or 0) * math.floor(def.pp / 5)) end + for slot, mv in pairs(mon.moves or {}) do + if Gen.ofState(S) == 3 then + local pp = mon.maxPp and mon.maxPp[slot] or require("src.core.game3.pokemon").movePp(moveId(mon, slot)) + mon.pp = mon.pp or {} + mon.pp[slot] = pp + if type(mv) == "table" then mv.pp = pp end + else + local def = S.data.moves[mv.id] + if def then mv.pp = def.pp + ((mv.ppUps or 0) * math.floor(def.pp / 5)) end + end end return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp)) end @@ -787,6 +806,24 @@ function Ops.clearNickname(S, mon) end -- ------------------------------------------------------------------ boxes +function Ops.boxSize(S, box) + if Gen.ofState(S) ~= 3 then return #box end + local count = 0 + for slot = 1, 30 do if box[slot] then count = count + 1 end end + return count +end + +boxInsert = function(S, box, mon) + if Gen.ofState(S) ~= 3 then table.insert(box, mon); return #box end + local wanted = S.selectedBoxSlot + if wanted and wanted >= 1 and wanted <= 30 and not box[wanted] then box[wanted] = mon; return wanted end + for slot = 1, 30 do if not box[slot] then box[slot] = mon; return slot end end +end + +boxRemove = function(S, box, slot) + if Gen.ofState(S) == 3 then box[slot] = nil else table.remove(box, slot) end +end + function Ops.boxCount(S) return Gen.boxCount(S.save) end @@ -803,8 +840,9 @@ function Ops.selectBox(S, index) S.selectedBox = clamp(index, 1, Ops.boxCount(S)) S.selectedBoxSlot = 1 S.save.currentBox = S.selectedBox + if Gen.ofState(S) == 3 then S.save.storage.currentBox = S.selectedBox end local box = Ops.boxes(S)[S.selectedBox] - S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, Ops.boxCapacity(S)) + S.status = ("Box %d (%d/%d)"):format(S.selectedBox, Ops.boxSize(S, box), Ops.boxCapacity(S)) return true end @@ -830,17 +868,16 @@ end -- chooses what lands in the box instead of always getting catalog entry #1. function Ops.boxAdd(S) local box = Ops.boxes(S)[S.selectedBox] - if #box >= Ops.boxCapacity(S) then + if Ops.boxSize(S, box) >= Ops.boxCapacity(S) then return Ops.say(S, ("Box %d is full (%d/%d)") - :format(S.selectedBox, #box, Ops.boxCapacity(S))) + :format(S.selectedBox, Ops.boxSize(S, box), Ops.boxCapacity(S))) end local species = S.cat.species[1] local mon = createMon(S, species, 5) - table.insert(box, mon) - S.selectedBoxSlot = #box + S.selectedBoxSlot = boxInsert(S, box, mon) S.editingMon = mon return Ops.mark(S, ("Added %s Lv5 to box %d slot %d") - :format(species, S.selectedBox, #box)) + :format(species, S.selectedBox, S.selectedBoxSlot)) end function Ops.withdraw(S) @@ -857,7 +894,7 @@ function Ops.withdraw(S) if not ok then return Ops.say(S, reason) end Boxes2.withdraw(S.save, S.selectedBox, S.selectedBoxSlot) else - table.remove(box, S.selectedBoxSlot) + boxRemove(S, box, S.selectedBoxSlot) table.insert(S.save.party, mon) end S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#Ops.boxes(S)[S.selectedBox], 1)) @@ -873,7 +910,7 @@ function Ops.release(S) ("Release %s permanently? Click again to confirm"):format(mon.species)) then return false end - table.remove(box, S.selectedBoxSlot) + boxRemove(S, box, S.selectedBoxSlot) if S.editingMon == mon then S.editingMon = nil end S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1)) return Ops.mark(S, ("Released %s"):format(mon.species)) @@ -896,6 +933,22 @@ function Ops.deposit(S) if S.editingMon == mon then S.editingMon = nil end return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxIndex)) end + if Gen.ofState(S) == 3 then + local boxes = Ops.boxes(S) + local first = S.selectedBox or S.save.currentBox or 1 + for offset = 0, 13 do + local b = ((first - 1 + offset) % 14) + 1 + if Ops.boxSize(S, boxes[b]) < 30 then + local slot = boxInsert(S, boxes[b], mon) + table.remove(S.save.party, i) + S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1)) + S.selectedBox, S.selectedBoxSlot = b, slot + if S.editingMon == mon then S.editingMon = nil end + return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, b)) + end + end + return Ops.say(S, "Every box is full") + end local boxNum = BoxesMod.deposit(S.save, mon) if not boxNum then return Ops.say(S, "Every box is full, release something first") @@ -942,19 +995,6 @@ function Ops.maxCoins(S) return Ops.addCoins(S, Ops.COIN_MAX) end -local function syncG3Bag(S) - if Gen.ofState(S) ~= 3 or not S.save then return end - local okB, BagG3 = pcall(require, "src.core.game3.bag") - if okB and BagG3 then - S.save.bag = BagG3.new() - for id, qty in pairs(S.save.inventory or {}) do - if qty and qty > 0 then - BagG3.add(S.save.bag, id, qty) - end - end - end -end - local function itemQty(inv, id) if not inv or id == nil then return 0 end local val = inv[id] @@ -967,42 +1007,30 @@ local function itemQty(inv, id) end Ops.itemQty = itemQty -local function syncG3Pc(S) - if Gen.ofState(S) ~= 3 or not S.save then return end - if type(S.save.storage) ~= "table" then - S.save.storage = { currentBox = 1, boxes = {}, items = {} } +local function changeG3(S, pc, id, quantity) + if not id then return Ops.say(S, "Pick an item first") end + if not G3.change(S.data, S.save, pc, { { id = id, qty = quantity } }) then + return Ops.say(S, "Item change refused: quantity or storage capacity") end - if type(S.save.pc) == "table" then - S.save.pc.items = nil - if next(S.save.pc) == nil then S.save.pc = nil end - end - local okD, ItemsData = pcall(require, "src.core.game3.items_data") - local items = {} - for id, val in pairs(S.save.pcItems or {}) do - local qty = itemQty(S.save.pcItems, id) - if qty and qty > 0 then - local num = okD and ItemsData and ItemsData.toNumericId(id) or id - items[#items + 1] = { id = num, qty = qty } + return Ops.mark(S, ("%s x%d%s"):format(tostring(id), quantity, pc and " in PC storage" or "")) +end + +local function maxG3(S, pc) + local changes = {} + for id, qty in pairs(pc and S.save.pcItems or S.save.inventory) do + if type(qty) == "number" and qty > 0 and qty < 999 and Ops.itemStacks(S, id) then + changes[#changes + 1] = { id = id, qty = 999 } end end - S.save.storage.items = items + if #changes == 0 then return Ops.say(S, "Every stack is already maxed") end + if not G3.change(S.data, S.save, pc, changes) then return Ops.say(S, "Item changes refused") end + return Ops.mark(S, ("Maxed %d stacks to x999"):format(#changes)) end function Ops.addToBag(S, id) if not id then return Ops.say(S, "Pick an item first") end S.save.inventory = S.save.inventory or {} - if Gen.ofState(S) == 3 then - local BagG3 = require("src.core.game3.bag") - S.save.bag = S.save.bag or BagG3.new() - local ok = BagG3.add(S.save.bag, id, 1) - if ok then - S.save.inventory[id] = itemQty(S.save.inventory, id) + 1 - Bag.order(S.save, S.data) - return Ops.mark(S, ("Added %s to the bag"):format(tostring(id))) - else - return Ops.say(S, ("Could not add %s to bag (pocket full)"):format(tostring(id))) - end - end + if Gen.ofState(S) == 3 then return changeG3(S, false, id, G3.quantity(S.data, S.save, false, id) + 1) end local pocket = Bag.pocketOf(id, S.data) local capacity = Bag.capacity(S.data, pocket) if Bag.add(S.save, id, 1, S.data) then @@ -1017,31 +1045,10 @@ function Ops.bagAdjust(S, id, delta) if not id then return Ops.say(S, "No bag row selected") end S.save.inventory = S.save.inventory or {} local have = itemQty(S.save.inventory, id) - if Gen.ofState(S) == 3 then - if delta > 0 then - if have >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) - end - local nextQty = have + delta - S.save.inventory[id] = nextQty - syncG3Bag(S) - else - local nextQty = have + delta - if nextQty <= 0 then - S.save.inventory[id] = nil - Bag.order(S.save, S.data) - syncG3Bag(S) - return Ops.mark(S, ("Removed the last %s from the bag"):format(tostring(id))) - else - S.save.inventory[id] = nextQty - syncG3Bag(S) - end - end - return Ops.mark(S, ("%s x%d"):format(tostring(id), itemQty(S.save.inventory, id))) - end + if Gen.ofState(S) == 3 then return changeG3(S, false, id, math.max(0, G3.quantity(S.data, S.save, false, id) + delta)) end if delta > 0 then - if have >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) + if have >= Ops.stackMax(S) then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) end Bag.add(S.save, id, delta, S.data) else @@ -1057,12 +1064,7 @@ function Ops.bagDrop(S, id) if not id then return Ops.say(S, "No bag row selected") end S.save.inventory = S.save.inventory or {} local qty = itemQty(S.save.inventory, id) - if Gen.ofState(S) == 3 then - S.save.inventory[id] = nil - Bag.order(S.save, S.data) - syncG3Bag(S) - return Ops.mark(S, ("Dropped all %d %s"):format(qty, tostring(id))) - end + if Gen.ofState(S) == 3 then return changeG3(S, false, id, 0) end Bag.remove(S.save, id, qty) return Ops.mark(S, ("Dropped all %d %s"):format(qty, tostring(id))) end @@ -1073,8 +1075,9 @@ end function Ops.itemStacks(S, id) if not id then return false end if Gen.ofState(S) == 3 then - local def = S.data and S.data.items and S.data.items[id] - return (def and def.pocket) ~= "KEY_ITEMS" and not tostring(id):find("^HM_") + local id3 = G3.itemId(S.data, id) + local info = require("src.core.game3.items_data") + return info.pocketOf(id3) ~= "KEY_ITEMS" and not info.isHm(id3) end if Gen.ofState(S) == 2 then return Bag.pocketOf(id, S.data) ~= "KEY_ITEM" @@ -1086,6 +1089,10 @@ end -- engine/items/inventory.asm:74 caps a slot at 99 function Ops.bagMax(S, id) + if Gen.ofState(S) == 3 then + if not id or not Ops.itemStacks(S, id) or G3.quantity(S.data, S.save, false, id) <= 0 then return Ops.say(S, "No stack to max") end + return changeG3(S, false, id, 999) + end if not id then return Ops.say(S, "No bag row selected") end S.save.inventory = S.save.inventory or {} local have = itemQty(S.save.inventory, id) @@ -1093,22 +1100,17 @@ function Ops.bagMax(S, id) if not Ops.itemStacks(S, id) then return Ops.say(S, ("%s has no quantity to max"):format(tostring(id))) end - if have >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) + if have >= Ops.stackMax(S) then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) end - if Gen.ofState(S) == 3 then - S.save.inventory[id] = Ops.STACK_MAX - syncG3Bag(S) - else - Bag.add(S.save, id, Ops.STACK_MAX - have, S.data) - end - return Ops.mark(S, ("%s x%d"):format(tostring(id), Ops.STACK_MAX)) + Bag.add(S.save, id, Ops.stackMax(S) - have, S.data) + return Ops.mark(S, ("%s x%d"):format(tostring(id), Ops.stackMax(S))) end function Ops.bagCanMax(S, id) if id ~= nil then - local have = itemQty(S.save.inventory, id) - return have > 0 and have < Ops.STACK_MAX and Ops.itemStacks(S, id) + local have = Gen.ofState(S) == 3 and G3.quantity(S.data, S.save, false, id) or itemQty(S.save.inventory, id) + return have > 0 and have < Ops.stackMax(S) and Ops.itemStacks(S, id) end for _, rowId in ipairs(Bag.order(S.save, S.data)) do if Ops.bagCanMax(S, rowId) then return true end @@ -1117,27 +1119,23 @@ function Ops.bagCanMax(S, id) end function Ops.bagMaxAll(S) + if Gen.ofState(S) == 3 then return maxG3(S, false) end local order = Bag.order(S.save, S.data) local ids = {} for i = 1, #order do ids[i] = order[i] end local n = 0 for _, id in ipairs(ids) do local have = itemQty(S.save.inventory, id) - if have > 0 and have < Ops.STACK_MAX and Ops.itemStacks(S, id) then - if Gen.ofState(S) == 3 then - S.save.inventory[id] = Ops.STACK_MAX - else - Bag.add(S.save, id, Ops.STACK_MAX - have, S.data) - end + if have > 0 and have < Ops.stackMax(S) and Ops.itemStacks(S, id) then + Bag.add(S.save, id, Ops.stackMax(S) - have, S.data) n = n + 1 end end if n == 0 then - return Ops.say(S, ("Every bag stack is already at x%d"):format(Ops.STACK_MAX)) + return Ops.say(S, ("Every bag stack is already at x%d"):format(Ops.stackMax(S))) end - if Gen.ofState(S) == 3 then syncG3Bag(S) end return Ops.mark(S, ("Maxed %d bag stack%s to x%d") - :format(n, n == 1 and "" or "s", Ops.STACK_MAX)) + :format(n, n == 1 and "" or "s", Ops.stackMax(S))) end -- constants/item_data_constants.asm:41 @@ -1220,6 +1218,7 @@ function Ops.pcSort(S, mode) end function Ops.addToPc(S, id) + if Gen.ofState(S) == 3 then return changeG3(S, true, id, G3.quantity(S.data, S.save, true, id) + 1) end if not id then return Ops.say(S, "Pick an item first") end local pc = Ops.pcItems(S) local n = 0 @@ -1228,40 +1227,42 @@ function Ops.addToPc(S, id) return Ops.say(S, "PC item storage is full (50 stacks)") end local cur = itemQty(pc, id) - pc[id] = math.min(Ops.STACK_MAX, cur + 1) - syncG3Pc(S) + pc[id] = math.min(Ops.stackMax(S), cur + 1) return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) end function Ops.pcAdjust(S, id, delta) + if Gen.ofState(S) == 3 then return changeG3(S, true, id, math.max(0, G3.quantity(S.data, S.save, true, id) + delta)) end if not id then return Ops.say(S, "No PC row selected") end local pc = Ops.pcItems(S) local cur = itemQty(pc, id) if not pc[id] and cur <= 0 then return Ops.say(S, ("%s is not in PC storage"):format(tostring(id))) end - if delta > 0 and cur >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) + if delta > 0 and cur >= Ops.stackMax(S) then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) end - local nextQty = clamp(cur + delta, 0, Ops.STACK_MAX) + local nextQty = clamp(cur + delta, 0, Ops.stackMax(S)) if nextQty <= 0 then pc[id] = nil - syncG3Pc(S) return Ops.mark(S, ("Removed %s from PC storage"):format(tostring(id))) end pc[id] = nextQty - syncG3Pc(S) return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) end function Ops.pcDrop(S, id) + if Gen.ofState(S) == 3 then return changeG3(S, true, id, 0) end if not id then return Ops.say(S, "No PC row selected") end local pc = Ops.pcItems(S) local qty = itemQty(pc, id) pc[id] = nil - syncG3Pc(S) return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, tostring(id))) end function Ops.pcMax(S, id) + if Gen.ofState(S) == 3 then + if not id or not Ops.itemStacks(S, id) or G3.quantity(S.data, S.save, true, id) <= 0 then return Ops.say(S, "No stack to max") end + return changeG3(S, true, id, 999) + end if not id then return Ops.say(S, "No PC row selected") end local pc = Ops.pcItems(S) local cur = itemQty(pc, id) @@ -1269,23 +1270,22 @@ function Ops.pcMax(S, id) if not Ops.itemStacks(S, id) then return Ops.say(S, ("%s has no quantity to max"):format(tostring(id))) end - if cur >= Ops.STACK_MAX then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.STACK_MAX)) + if cur >= Ops.stackMax(S) then + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) end - pc[id] = Ops.STACK_MAX - syncG3Pc(S) - return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), Ops.STACK_MAX)) + pc[id] = Ops.stackMax(S) + return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), Ops.stackMax(S))) end function Ops.pcCanMax(S, id) local pc = Ops.pcItems(S) if id ~= nil then - local have = itemQty(pc, id) - return have > 0 and have < Ops.STACK_MAX and Ops.itemStacks(S, id) + local have = Gen.ofState(S) == 3 and G3.quantity(S.data, S.save, true, id) or itemQty(pc, id) + return have > 0 and have < Ops.stackMax(S) and Ops.itemStacks(S, id) end for rowId, val in pairs(pc) do local qty = itemQty(pc, rowId) - if qty > 0 and qty < Ops.STACK_MAX and Ops.itemStacks(S, rowId) then + if qty > 0 and qty < Ops.stackMax(S) and Ops.itemStacks(S, rowId) then return true end end @@ -1293,21 +1293,21 @@ function Ops.pcCanMax(S, id) end function Ops.pcMaxAll(S) + if Gen.ofState(S) == 3 then return maxG3(S, true) end local pc = Ops.pcItems(S) local n = 0 for id, val in pairs(pc) do local qty = itemQty(pc, id) - if qty > 0 and qty < Ops.STACK_MAX and Ops.itemStacks(S, id) then - pc[id] = Ops.STACK_MAX + if qty > 0 and qty < Ops.stackMax(S) and Ops.itemStacks(S, id) then + pc[id] = Ops.stackMax(S) n = n + 1 end end if n == 0 then - return Ops.say(S, ("Every PC stack is already at x%d"):format(Ops.STACK_MAX)) + return Ops.say(S, ("Every PC stack is already at x%d"):format(Ops.stackMax(S))) end - syncG3Pc(S) return Ops.mark(S, ("Maxed %d PC stack%s to x%d") - :format(n, n == 1 and "" or "s", Ops.STACK_MAX)) + :format(n, n == 1 and "" or "s", Ops.stackMax(S))) end -- Badges are truthy inventory flags, not stackable items, which is why the diff --git a/tools/save-editor/panels/Boxes.lua b/tools/save-editor/panels/Boxes.lua index 2ec71574..57f0f687 100644 --- a/tools/save-editor/panels/Boxes.lua +++ b/tools/save-editor/panels/Boxes.lua @@ -22,6 +22,7 @@ local PartyMod = require("src.pokemon.Party") local Theme = require("Theme") local Ops = require("Ops") +local Gen = require("Gen") local PAL = Theme.PAL local M = {} @@ -43,7 +44,7 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h) if Kit.row(x + pad, ry, stripInner, bRowH, i == S.selectedBox, PAL.blue, 9 * s) then Ops.selectBox(S, i) end - local fill = #boxes[i] + local fill = Ops.boxSize(S, boxes[i]) Kit.text("mono", ("Box %d"):format(i), x + pad + 10 * s, ry + (bRowH - Kit.textHeight("mono")) / 2, PAL.text) local countW = Kit.textWidth("tiny", tostring(fill)) @@ -65,7 +66,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h) Kit.text("tab", ("Box %d"):format(S.selectedBox), gx, y + gpad + (headH - Kit.textHeight("tab")) / 2, PAL.heading) local titleW = Kit.textWidth("tab", ("Box %d"):format(S.selectedBox)) - Kit.text("mono", ("%d/%d"):format(#box, Ops.boxCapacity(S)), + Kit.text("mono", ("%d/%d"):format(Ops.boxSize(S, box), Ops.boxCapacity(S)), gx + titleW + 14 * s, y + gpad + (headH - Kit.textHeight("mono")) / 2, PAL.caption) local navW = 34 * s if Kit.stepper(gx + ginner - 2 * navW - 8 * s, y + gpad, navW, headH, "<", @@ -101,7 +102,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h) end if Kit.button(gx + wdW + 10 * s, actY, addW, actH, addLabel, { font = "small", radius = 9 * s, - enabled = #box < Ops.boxCapacity(S) }) then + enabled = Ops.boxSize(S, box) < Ops.boxCapacity(S) }) then Ops.openBoxAddPicker(S, Kit) end if Kit.button(gx + ginner - relW, actY, relW, actH, relLabel, @@ -158,7 +159,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h) Kit.textCenter("micro", "+", bx, by + cellH / 2 - Kit.textHeight("micro") / 2, cellW, PAL.faint) if Kit.press(bx, by, cellW, cellH) then - S.selectedBoxSlot = math.min(i, #box + 1) + S.selectedBoxSlot = Gen.ofState(S) == 3 and i or math.min(i, Ops.boxSize(S, box) + 1) Ops.openBoxAddPicker(S, Kit) end end diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index 2afb143e..315fdbd9 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -41,7 +41,7 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu onPlus() end if Kit.stepper(bx + 2 * (btn + 6 * s), y + (h - btn) / 2, btn, btn, - tostring(Ops.STACK_MAX), { font = "tiny", enabled = canMax }) then + tostring(Ops.stackMax(S)), { font = "tiny", enabled = canMax }) then onMax() end if Kit.button(bx + 3 * (btn + 6 * s), y + (h - btn) / 2, btn, btn, "x", @@ -298,7 +298,7 @@ end local function drawBag(S, Kit, x, y, w, h) S.save.inventory = S.save.inventory or {} local order = Bag.order(S.save, S.data) or {} - local capacity = Bag.capacity(S.data) or 20 + local capacity = Gen.ofState(S) == 3 and 186 or Bag.capacity(S.data) or 20 local slots = Bag.slots(S.save, S.data) or 0 S.bagOffset = drawQuantityCard(S, Kit, x, y, w, h, { title = "BAG", @@ -330,7 +330,7 @@ local function drawPc(S, Kit, x, y, w, h) counter = ("%d kinds"):format(#pcOrder), order = pcOrder, offset = S.pcOffset or 0, - empty = "PC storage is empty. Items sent here have no slot cap.", + empty = "PC storage is empty. ", qty = function(id) return (S.save.pcItems and S.save.pcItems[id]) or 0 end, selected = function() return S.selectedPcId end, select = function(id) diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 855c11a0..ea887f86 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -233,11 +233,11 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap) local mvId = nil local mvPp = 0 if type(mv) == "table" then - mvId = mv.id or mv.name or (mv.moveId and (S.data and S.data.moves and S.data.moves[mv.moveId] and S.data.moves[mv.moveId].name)) + mvId = (S.data and S.data.moves and S.data.moves[mv.id] and S.data.moves[mv.id].name) or mv.id or mv.name mvPp = mv.pp or 0 elseif type(mv) == "number" then mvId = S.data and S.data.moves and S.data.moves[mv] and S.data.moves[mv].name - mvPp = S.data and S.data.moves and S.data.moves[mv] and S.data.moves[mv].pp or 10 + mvPp = mon.pp and mon.pp[slot] or 0 elseif type(mv) == "string" then mvId = mv end