CLOSES #2364, CLOSES #2380, CLOSES #2366, CLOSES #2362

This commit is contained in:
bryanthaboi
2026-09-21 14:43:32 -04:00
parent a8d54b5684
commit bf7f2bee90
27 changed files with 1540 additions and 317 deletions
+11 -9
View File
@@ -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
+15 -7
View File
@@ -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
+1 -1
View File
@@ -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
+50
View File
@@ -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
+1
View File
@@ -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)
+2 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+132
View File
@@ -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
@@ -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
@@ -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
+125
View File
@@ -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
+57
View File
@@ -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")
+20 -3
View File
@@ -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")
@@ -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")
+18 -4
View File
@@ -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 = {
+76
View File
@@ -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")
@@ -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')
+12 -12
View File
@@ -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")
+21 -4
View File
@@ -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")
+189
View File
@@ -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
+11 -111
View File
@@ -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 {}
+25 -16
View File
@@ -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)
+137 -137
View File
@@ -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
+5 -4
View File
@@ -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
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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