diff --git a/assets/box/showcase/scenery-v1.json b/assets/box/showcase/scenery-v1.json new file mode 100644 index 00000000..e1551ca9 --- /dev/null +++ b/assets/box/showcase/scenery-v1.json @@ -0,0 +1,59 @@ +{ + "image": "scenery-v1.png", + "rects": { + "Platform": [ + 31, + 147, + 392, + 218 + ], + "Block": [ + 473, + 94, + 338, + 274 + ], + "Steps": [ + 868, + 103, + 357, + 263 + ], + "Pillar": [ + 101, + 451, + 215, + 353 + ], + "Arch": [ + 442, + 440, + 378, + 364 + ], + "Tree": [ + 877, + 447, + 326, + 357 + ], + "Flowers": [ + 49, + 919, + 344, + 254 + ], + "Rock": [ + 450, + 914, + 364, + 248 + ], + "Fence": [ + 873, + 936, + 344, + 216 + ] + } +} diff --git a/assets/box/showcase/scenery-v1.png b/assets/box/showcase/scenery-v1.png new file mode 100644 index 00000000..cb8a96f5 Binary files /dev/null and b/assets/box/showcase/scenery-v1.png differ diff --git a/assets/labels/gbalabels.psd b/assets/labels/gbalabels.psd index 4d73eb0e..3198dee7 100644 Binary files a/assets/labels/gbalabels.psd and b/assets/labels/gbalabels.psd differ diff --git a/main.lua b/main.lua index 77d3200f..d5343f2f 100644 --- a/main.lua +++ b/main.lua @@ -330,6 +330,12 @@ local function openEditor(version, slotId) Importer.saveNotice[version] = { ok = false, text = text } end local SaveData = require("src.core.SaveData") + local recovered, recoveryError, recoveryNotice = require("src.box.Transaction").recover(SaveData.persistenceFs()) + if not recovered then refuse(recoveryError); return end + if recoveryNotice then + require("src.core.Logger").warn("%s", recoveryNotice) + if Importer and Importer._boxState then Importer._boxState.notice = recoveryNotice end + end local path = SaveData.slotDiskPath(version, slotId) if not path then refuse("Could not resolve that save slot on disk.") @@ -558,6 +564,15 @@ function bootGame(version, cartId, opts) if require("src.core.RequireGuard").repair() then print("boot: restored love.filesystem searcher (see #2001)") end + local recovered, recoveryError, recoveryNotice = require("src.box.Transaction").recover( + require("src.core.SaveData").persistenceFs()) + if recoveryNotice then require("src.core.Logger").warn("%s", recoveryNotice) end + if not recovered then + Importer = makeLauncher({ initialTab = "box" }) + Importer._boxState = nil + require("src.import.BoxPanel").refresh(Importer).notice = recoveryError + return + end pcall(function() require("src.online.Trade").hostIsLive = function() return true end end) @@ -1316,6 +1331,8 @@ end love.handlers = love.handlers or {} function love.handlers.audiosuspend() + local BoxCry = package.loaded["src.box.Cry"] + if BoxCry then pcall(BoxCry.setSuspended, true) end local ChipAudio = package.loaded["src.core.ChipAudio"] if ChipAudio then pcall(ChipAudio.setSuspended, true) end local Sound = package.loaded["src.core.Sound"] @@ -1325,6 +1342,8 @@ function love.handlers.audiosuspend() end function love.handlers.audioreset() + local BoxCry = package.loaded["src.box.Cry"] + if BoxCry then pcall(BoxCry.setSuspended, false) end local ChipAudio = package.loaded["src.core.ChipAudio"] if ChipAudio then pcall(ChipAudio.setSuspended, false) @@ -1473,9 +1492,7 @@ function love.mousepressed(x, y, button, istouch) -- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from -- love.touch / mouse.isDown, so dropping the synthesized istouch press is -- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed. - if istouch and (love.system.getOS() == "Android" - or love.system.getOS() == "iOS") then return end - return Importer:mousepressed(x, y, button) + return Importer:mousepressed(x, y, button, istouch) end if editorMode and EditorApp.mousepressed then -- The editor owns the real touch lifecycle on Android and iOS. @@ -1505,7 +1522,7 @@ function love.mousereleased(x, y, button, istouch) if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end return Studio.mousereleased(x, y, button) end - if Importer then return end + if Importer then return Importer:mousereleased(x, y, button, istouch) end if editorMode and EditorApp.mousereleased then return EditorApp.mousereleased(x, y, button) end @@ -1527,7 +1544,8 @@ function love.mousemoved(x, y, dx, dy, istouch) if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end return Studio.mousemoved(x, y) end - if editorMode or Importer then return end + if Importer then return Importer:mousemoved(x, y, dx, dy, istouch) end + if editorMode then return end if mouseTouch then if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end return diff --git a/src/box/Animation.lua b/src/box/Animation.lua new file mode 100644 index 00000000..743dc171 --- /dev/null +++ b/src/box/Animation.lua @@ -0,0 +1,111 @@ +local Catalog = require("src.box.Catalog") +local Sprites = require("src.online.OnlineSprites") +local MonAnim = require("src.core.game3.mon_anim") +local Animation = {} +local data +local live = setmetatable({}, { __mode = "k" }) + +local function animationData() + if data ~= nil then return data or nil end + data = false + local body = Sprites.readBytes("emerald", "data/generated/gba/pokemon/front_anims.lua") + if not body then return nil end + local chunk = (loadstring or load)(body, "@box-emerald-animation") + if not chunk then return nil end + if setfenv then setfenv(chunk, {}) end + local ok, front = pcall(chunk) + if not ok or type(front) ~= "table" or not (front.species and front.lists + and front.functions and front.animIds and front.animDelays) then return nil end + local pack = { version = "emerald" } + function pack.functionName(id) return front.functions[id] end + function pack.frontAnimId(species) return front.animIds[species] end + function pack.delay(species) return front.animDelays[species] or 0 end + function pack.anims(species) + local row = front.species[species] + if not row then return nil end + local result = {} + for i, id in ipairs(row) do + local list = front.lists[id] + result[i - 1] = list and list.cmds or nil + end + return result + end + data = pack + return pack +end + +function Animation.release(animation) + if not animation or animation.released then return end + for _, image in pairs(animation.frames) do + if image.release then pcall(image.release, image) end + end + animation.frames, animation.released = {}, true + live[animation] = nil +end + +function Animation.reset() + for animation in pairs(live) do Animation.release(animation) end + data = nil +end + +function Animation.new(entry) + if not entry or entry.mon.isEgg or entry.display.egg then return nil end + local art, version, mon = Catalog.art(entry) + if version ~= "emerald" or not art.front or not mon then return nil end + local pack = animationData() + local species = tonumber(mon.species) + if not pack or not species or pack.frontAnimId(species) == nil then return nil end + local ok, sprite = pcall(function() + local s = MonAnim.newSprite(species, { animationData = pack, affineMode = "off", data = { [0] = species, [1] = 1 } }) + MonAnim.summary(s, species, false) + return s + end) + if not ok then return nil end + local animation = { entry = entry, art = art, sprite = sprite, elapsed = 0, frames = {} } + live[animation] = true + if MonAnim.hasTwoFramesAnimation(species, "emerald") and love and love.image and love.graphics then + local Pokemon = require("src.core.game3.pokemon") + local pic = Pokemon.picSpecies(species, mon.personality) + local path = (entry.display.shiny and MonAnim.Data.SHEET_SHINY or MonAnim.Data.SHEET):format(pic) + local rgba = Sprites.readBytes("emerald", path) + local size = 64 * 64 * 4 + if rgba then + for frame = 1, math.min(15, math.floor(#rgba / size) - 1) do + local made, image = pcall(function() + local pixels = love.image.newImageData(64, 64, "rgba8", rgba:sub(size * frame + 1, size * (frame + 1))) + local img = love.graphics.newImage(pixels) + if pixels.release then pixels:release() end + img:setFilter("nearest", "nearest") + return img + end) + if made then animation.frames[frame] = image end + end + end + end + return animation +end + +function Animation.update(animation, dt) + if not animation or animation.released or MonAnim.done(animation.sprite) then return end + animation.elapsed = animation.elapsed + math.max(0, math.min(dt or 0, 0.25)) + while animation.elapsed >= 1 / 60 and not MonAnim.done(animation.sprite) do + MonAnim.step(animation.sprite, true) + animation.elapsed = animation.elapsed - 1 / 60 + end +end + +function Animation.draw(animation, x, y, size) + if not animation or animation.released then return false end + local image = animation.frames[animation.sprite.frame] or animation.art.front + if not image then return false end + local scale = size / 64 + if scale >= 1 then scale = math.floor(scale) end + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(1, 1, 1, 1) + MonAnim.draw(animation.sprite, image, math.floor(x + size / 2), math.floor(y + size / 2), { scale = scale }) + love.graphics.setColor(r, g, b, a) + return true +end + +require("src.render.Assets").register({ invalidate = Animation.reset, release = Animation.reset }) +return Animation diff --git a/src/box/Catalog.lua b/src/box/Catalog.lua new file mode 100644 index 00000000..6d36ca8d --- /dev/null +++ b/src/box/Catalog.lua @@ -0,0 +1,315 @@ +local CacheFs = require("src.import.CacheFs") +local GameVersion = require("src.core.GameVersion") +local Store = require("src.box.Store") + +local Catalog = {} +local cached = {} +local artCache = {} + +local function read(version, path) + local body = CacheFs.readAt(GameVersion.cachePrefix(version) .. path) + if not body then return nil end + local chunk = (loadstring or load)(body, "@" .. version .. "/" .. path) + if not chunk then return nil end + if setfenv then setfenv(chunk, {}) end + local ok, value = pcall(chunk) + return ok and type(value) == "table" and value or nil +end + +function Catalog.reset() + local cry = package.loaded["src.box.Cry"] + if cry then cry.reset() end + local animation = package.loaded["src.box.Animation"] + if animation then animation.reset() end + local themes = package.loaded["src.box.Themes"] + if type(themes) == "table" and themes.reset then themes.reset() end + cached, artCache = {}, {} + require("src.online.OnlineSprites").reset() +end + +function Catalog.get(version) + if cached[version] then return cached[version] end + local generation = GameVersion.generation(version) + local data = { generation = generation } + if generation == 3 then + local root = "data/generated/gba/pokemon/" + data.names = read(version, root .. "names.lua") + data.national = read(version, root .. "national.lua") or {} + data.moves = read(version, root .. "move_names.lua") or {} + data.items = (read(version, "data/generated/gba/items/pack.lua") or {}).items or {} + data.types = read(version, root .. "types.lua") or {} + data.typeNames = read(version, root .. "type_names.lua") or {} + data.stats = read(version, root .. "stats.lua") or {} + data.meta = read(version, root .. "meta.lua") or {} + data.abilities = read(version, root .. "abilities.lua") or {} + data.abilityNames = read(version, root .. "ability_names.lua") or {} + data.battleMoves = (read(version, root .. "battle_moves.lua") or {}).moves or {} + local sections = read(version, "data/generated/gba/region_map/map_sections.lua") or {} + data.locations = sections.sections or sections + data.ready = data.names ~= nil + else + data.pokemon = read(version, "data/generated/pokemon.lua") + data.moves = read(version, "data/generated/moves.lua") or {} + data.items = read(version, "data/generated/items.lua") or {} + data.byNational = {} + for key, def in pairs(data.pokemon or {}) do + if type(def) == "table" and type(def.dex) == "number" then + data.byNational[def.dex] = key + end + end + data.ready = data.pokemon ~= nil + end + cached[version] = data + return data +end + +local function label(records, key) + local value = records[key] + if type(value) == "table" then return value.name or value.id or tostring(key) end + return value or tostring(key or "") +end + +function Catalog.describe(version, mon) + local data = Catalog.get(version) + local def = data.pokemon and data.pokemon[mon.species] + local species = data.names and data.names[tonumber(mon.species)] + or (def and def.name) or tostring(mon.species or "Unknown") + local national = def and def.dex or (data.national and data.national.toNational or {})[tonumber(mon.species)] + local moves = {} + for _, move in ipairs(mon.moves or {}) do + local key = type(move) == "table" and (move.moveId or move.id or move.move or move.name) or move + if key and key ~= 0 then moves[#moves + 1] = tostring(label(data.moves, key)) end + end + local item = mon.heldItem or mon.item + local shiny = mon.isShiny == true or mon.shiny == true + if data.generation == 3 then shiny = require("src.core.game3.pokemon").isShiny(mon) end + if data.generation == 2 and mon.dvs then + shiny = require("src.battle.gen2.Mon").vanillaShiny(mon.dvs) + end + local types = def and def.types or data.types and data.types[tonumber(mon.species)] or mon.types or {} + if data.generation == 3 and type(types) == "table" then + local names = {} + for _, id in ipairs(types) do names[#names + 1] = tostring(data.typeNames[id] or id) end + types = names + end + if type(types) == "table" then + local unique, seen = {}, {} + for _, value in ipairs(types) do + if not seen[value] then unique[#unique + 1], seen[value] = value, true end + end + types = unique + end + local display = { name = mon.nickname or species, species = species, national = national, + level = mon.level or 0, types = type(types) == "table" and table.concat(types, " / ") or tostring(types), + moves = table.concat(moves, ", "), item = item and item ~= 0 and tostring(label(data.items, item)) or "", + shiny = shiny, egg = mon.isEgg == true } + require("src.box.Metadata").describe(data, mon, display, def) + return display +end + +local function moveKey(move) + return type(move) == "table" and (move.moveId or move.id or move.move or move.name) or move +end + +local function whole(value, lo, hi) + return type(value) == "number" and value == math.floor(value) and value >= lo and value <= hi +end + +local function clamp(value, lo, hi) + if type(value) ~= "number" or value ~= value then return lo end + return math.max(lo, math.min(hi, math.floor(value))) +end + +local function clampAll(values, lo, hi) + for key, value in pairs(values) do values[key] = clamp(value, lo, hi) end +end + +local function placeholder(name) + return type(name) ~= "string" or name == "" or name:match("^%?+$") ~= nil or name == "TERU-SAMA" +end + +function Catalog.knownItem(data, item) + local row = data.items and data.items[item] + if type(row) == "table" then return not placeholder(row.name) end + return data.generation == 3 and type(row) == "string" and not placeholder(row) +end + +function Catalog.check(version, mon) + local data = Catalog.get(version) + if type(mon) ~= "table" then return "This Pokémon's data is missing.", {} end + local known + if data.generation == 3 then + local name = data.names and data.names[tonumber(mon.species)] + local national = (data.national.toNational or {})[tonumber(mon.species)] + known = not placeholder(name) and whole(national, 1, 386) + else + local def = data.pokemon and data.pokemon[mon.species] + known = type(def) == "table" and whole(def.dex, 1, data.generation == 1 and 151 or 251) + end + if not known then + return "This Pokémon is not in the original games' Pokédex.", {} + end + local fixes = {} + local function add(label, apply) fixes[#fixes + 1] = { label = label, apply = apply } end + local moves = mon.moves + if moves ~= nil and type(moves) ~= "table" then return "This Pokémon's moves are unreadable.", {} end + local legalMoves = 0 + for _, move in ipairs(moves or {}) do + local key = moveKey(move) + if key and key ~= 0 and data.moves[key] then legalMoves = legalMoves + 1 end + end + local badMoves = 0 + for _, move in ipairs(moves or {}) do + local key = moveKey(move) + if key and key ~= 0 and not data.moves[key] then badMoves = badMoves + 1 end + end + if badMoves > 0 or legalMoves > 4 then + if legalMoves == 0 then return "None of this Pokémon's moves exist in the original games.", {} end + add(badMoves > 0 and ("remove %d unknown move%s"):format(badMoves, badMoves == 1 and "" or "s") + or "keep only 4 moves", function(m) + local keptMoves, keptPP, pp = {}, {}, type(m.pp) == "table" and #m.pp == #m.moves and m.pp or nil + for i, move in ipairs(m.moves) do + local key = moveKey(move) + if key and key ~= 0 and data.moves[key] and #keptMoves < 4 then + keptMoves[#keptMoves + 1] = move + if pp then keptPP[#keptPP + 1] = pp[i] end + end + end + m.moves = keptMoves + if pp then m.pp = keptPP end + if type(m.cartExtra) == "table" then m.cartExtra.moveSlots = nil end + end) + end + local item = mon.heldItem or mon.item + if item and item ~= 0 and not Catalog.knownItem(data, item) then + add("remove unknown held item", function(m) + if type(m.heldItem) == "number" then m.heldItem = 0 else m.heldItem = nil end + m.item = nil + end) + end + if mon.level ~= nil and not whole(mon.level, 1, 100) then + add("set level within 1-100", function(m) m.level = clamp(m.level, 1, 100) end) + end + local function group(values, lo, hi) + if values == nil then return true, 0 end + if type(values) ~= "table" then return false, 0 end + local ok, total = true, 0 + for _, value in pairs(values) do + if whole(value, lo, hi) then total = total + value else ok = false end + end + return ok, total + end + if data.generation == 3 then + if not group(mon.ivs, 0, 31) then + add("cap IVs at 31", function(m) if type(m.ivs) == "table" then clampAll(m.ivs, 0, 31) else m.ivs = nil end end) + end + local evsOk, evTotal = group(mon.evs, 0, 255) + if not evsOk or evTotal > 510 then + add("cap EVs at 255 each, 510 total", function(m) + if type(m.evs) ~= "table" then m.evs = nil return end + clampAll(m.evs, 0, 255) + local keys, total = {}, 0 + for key, value in pairs(m.evs) do keys[#keys + 1], total = key, total + value end + table.sort(keys, function(a, b) return m.evs[a] > m.evs[b] end) + local i = 1 + while total > 510 do + local key = keys[(i - 1) % #keys + 1] + local cut = math.min(m.evs[key], total - 510) + m.evs[key], total, i = m.evs[key] - cut, total - cut, i + 1 + end + end) + end + if mon.abilityNum ~= nil and not whole(mon.abilityNum, 0, 1) then + add("reset ability slot", function(m) m.abilityNum = 0 end) + end + if mon.friendship ~= nil and not whole(mon.friendship, 0, 255) then + add("cap friendship at 255", function(m) + m.friendship = clamp(m.friendship, 0, 255) + if m.happiness ~= nil then m.happiness = m.friendship end + end) + end + if mon.pokeball ~= nil and not whole(mon.pokeball, 0, 12) then + add("change ball to Poké Ball", function(m) m.pokeball = 4 end) + end + if mon.metLevel ~= nil and not whole(mon.metLevel, 0, 100) then + add("set met level within 0-100", function(m) m.metLevel = clamp(m.metLevel, 0, 100) end) + end + else + if not group(mon.dvs, 0, 15) then + add("cap DVs at 15", function(m) if type(m.dvs) == "table" then clampAll(m.dvs, 0, 15) else m.dvs = nil end end) + end + if not group(mon.statExp, 0, 65535) then + add("cap stat experience", function(m) + if type(m.statExp) == "table" then clampAll(m.statExp, 0, 65535) else m.statExp = nil end + end) + end + end + return nil, fixes +end + +function Catalog.repair(version, mon) + local fatal, fixes = Catalog.check(version, mon) + if fatal then return nil, fatal end + local fixed = Store.copy(mon) + for _, fix in ipairs(fixes) do fix.apply(fixed) end + return fixed, fixes +end + +function Catalog.compatible(entry, version) + if entry.generation ~= GameVersion.generation(version) then + return nil, "This Pokémon must return to a game in its original generation." + end + local data = Catalog.get(version) + if not data.ready then return nil, "Import that game's ROM before withdrawing." end + local fatal, fixes = Catalog.check(version, entry.mon) + if fatal then return nil, fatal end + if #fixes > 0 then return nil, "This Pokémon is illegal: " .. fixes[1].label .. ".", fixes end + return true +end + +function Catalog.art(entry) + local display, mon = entry.display, entry.mon + local letter = display.national == 201 and entry.generation == 2 + and (mon.unownLetter or require("src.core.gen2.Unown").letterFromDVs(mon.dvs)) or "" + local key = table.concat({ entry.version, tostring(mon.species), tostring(display.national), + tostring(mon.personality), tostring(letter), tostring(display.shiny), tostring(display.egg) }, "|") + local hit = artCache[key] + if hit then return hit.art or nil, hit.version, hit.mon end + local order = {} + for i, version in ipairs(GameVersion.ORDER) do + order[#order + 1] = { version = version, generation = GameVersion.generation(version), index = i } + end + table.sort(order, function(a, b) + if (a.version == "emerald") ~= (b.version == "emerald") then return a.version == "emerald" end + return a.generation > b.generation or (a.generation == b.generation and a.index > b.index) + end) + local sprites = require("src.online.OnlineSprites") + for _, row in ipairs(order) do + local data = Catalog.get(row.version) + local species + if data.generation == 3 then + species = (data.national.toSpecies or {})[display.national] + else species = data.byNational and data.byNational[display.national] end + if row.version == entry.version and species == nil then species = mon.species end + if data.ready and species and (not display.egg or data.generation >= 2) then + local artMon = Store.copy(mon) + artMon.species, artMon.isShiny, artMon.shiny = species, display.shiny, display.shiny + if data.generation == 3 and entry.generation == 2 and display.national == 201 then + local Unown = require("src.core.gen2.Unown") + local letter = Unown.index(mon.unownLetter or Unown.letterFromDVs(mon.dvs)) or 1 + local value = letter - 1 + artMon.personality = value % 4 + (math.floor(value / 4) % 4) * 256 + + (math.floor(value / 16) % 4) * 65536 + end + if display.egg and data.generation == 2 then artMon.species = "BOX_EGG" end + local art = sprites.ensure(row.version, artMon) + if art and (art.front or art.icon) then + artCache[key] = { art = art, version = row.version, mon = artMon } + return art, row.version, artMon + end + end + end + artCache[key] = {} +end + +return Catalog diff --git a/src/box/Collection.lua b/src/box/Collection.lua new file mode 100644 index 00000000..9e7e81f2 --- /dev/null +++ b/src/box/Collection.lua @@ -0,0 +1,55 @@ +local Store = require("src.box.Store") +local Collection = {} + +function Collection.dashboard(state, save) + local result = { total = 0, species = 0, eggs = 0, shiny = 0, forms = {}, duplicates = {}, caught = 0, + held = {}, byGame = {}, missing = {} } + for b = 1, Store.BOXES do + for _, entry in pairs(state.boxes[b].mons) do + local d = entry.display + result.total = result.total + 1 + if d.egg then result.eggs = result.eggs + 1 + else + local dex = d.national + if dex then result.held[dex] = (result.held[dex] or 0) + 1 end + if d.shiny then result.shiny = result.shiny + 1 end + if dex == 201 then + local letter + if entry.generation == 2 then + local Unown = require("src.core.gen2.Unown") + local index = Unown.index(entry.mon.unownLetter) + or type(entry.mon.dvs) == "table" and Unown.letterFromDVs(entry.mon.dvs) + letter = index and index - 1 + elseif type(entry.mon.personality) == "number" then + letter = require("src.core.game3.pokemon").unownLetter(entry.mon.personality) + end + if letter then result.forms[tostring(letter)] = true end + end + end + result.byGame[entry.version] = (result.byGame[entry.version] or 0) + 1 + end + end + for dex, count in pairs(result.held) do + result.species = result.species + 1 + if count > 1 then result.duplicates[#result.duplicates + 1] = { dex = dex, count = count } end + end + table.sort(result.duplicates, function(a, b) return a.dex < b.dex end) + local pokedex = save and (save.dex or save.pokedex) or {} + local caught = {} + local data = save and save.version and require("src.box.Catalog").get(save.version) + local function national(key) + if not data then return tostring(key) end + local def = data.pokemon and data.pokemon[key] + return tostring(def and def.dex or data.national and (data.national.toNational or {})[tonumber(key)] or key) + end + for _, field in ipairs({ "caught", "owned" }) do + for key, value in pairs(pokedex[field] or {}) do if value then caught[national(key)] = true end end + end + local imported = save and save.modData and save.modData.cartImport + for _, dex in ipairs(imported and imported.dexOwned or {}) do caught[tostring(dex)] = true end + for _ in pairs(caught) do result.caught = result.caught + 1 end + for dex = 1, 386 do if not result.held[dex] then result.missing[#result.missing + 1] = dex end end + return result +end + +return Collection diff --git a/src/box/Cry.lua b/src/box/Cry.lua new file mode 100644 index 00000000..130fe53e --- /dev/null +++ b/src/box/Cry.lua @@ -0,0 +1,163 @@ +local CacheFs = require("src.import.CacheFs") +local Catalog = require("src.box.Catalog") +local GameVersion = require("src.core.GameVersion") +local Cry = {} +local packs, sources, sourceCount, playing = {}, {}, 0, nil +local suspended = false +local MAX_SOURCES = 24 +local MAX_PACKS = 2 +local packOrder = {} + +local function read(version, path) + return CacheFs.readAt(GameVersion.cachePrefix(version) .. path) +end + +local function readTable(version, path) + local body = read(version, path) + if not body then return nil end + local chunk = (loadstring or load)(body, "@box-cry/" .. version .. "/" .. path) + if not chunk then return nil end + if setfenv then setfenv(chunk, {}) end + local ok, value = pcall(chunk) + return ok and type(value) == "table" and value or nil +end + +function Cry.stop() + if playing then pcall(playing.stop, playing) end + playing = nil +end + +local function clearSources() + Cry.stop() + for _, source in pairs(sources) do + pcall(source.stop, source) + if source.release then pcall(source.release, source) end + end + sources, sourceCount = {}, 0 +end + +function Cry.reset() + clearSources() + packs, packOrder = {}, {} +end + +function Cry.setSuspended(value) + suspended = value == true + Cry.reset() +end + +local function pack(version) + if packs[version] ~= nil then return packs[version] or nil end + local result + if GameVersion.generation(version) == 3 then + local root = "data/generated/gba/audio/" + local index = readTable(version, root .. "index.lua") + local bytes = index and read(version, root .. "samples.bin") + if index and bytes then + result = { index = index, bytes = bytes, + samples = index.samples or readTable(version, root .. "samples.lua") or {} } + end + else + local audio = readTable(version, "data/generated/audio.lua") + local bytes = audio and audio.programFile and read(version, audio.programFile) + if audio and bytes then + audio.programBytes = bytes + result = { audio = audio } + end + end + packs[version] = result or false + packOrder[#packOrder + 1] = version + while #packOrder > MAX_PACKS do packs[table.remove(packOrder, 1)] = nil end + return result +end + +local function voicedPikachu(version, data, species) + if species ~= "PIKACHU" or not data.audio.pikaCries then return nil end + local bytes = read(version, "assets/generated/audio/pika_cries/cry_01.wav") + if not bytes then return nil end + local file = love.filesystem.newFileData(bytes, "box-pikachu.wav") + local sound = love.sound.newSoundData(file) + file:release() + if sound:getChannelCount() == 1 then + local stereo = love.sound.newSoundData(sound:getSampleCount(), sound:getSampleRate(), 16, 2) + for i = 0, sound:getSampleCount() - 1 do + local value = sound:getSample(i) + stereo:setSample(i, 1, value); stereo:setSample(i, 2, value) + end + sound:release(); sound = stereo + end + local source = love.audio.newSource(sound, "static") + sound:release() + return source +end + +local function render(version, species) + local data = pack(version) + if not data then return nil end + if GameVersion.generation(version) < 3 then + local voiced, source = pcall(voicedPikachu, version, data, species) + if voiced and source then return source end + return require("src.core.ChipAudio").newCry(data, species) + end + local index = data.index + local id = (index.cryIds or {})[species] or (index.cryIds or {})[tostring(species)] + local row = id ~= nil and ((index.cries or {})[id] or (index.cries or {})[tostring(id)]) + local sample = row and (data.samples[row.sampleId] or data.samples[tostring(row.sampleId)]) + if not sample then return nil end + local Sample = require("src.core.game3.m4a_sample") + local pcm = Sample.loadPcm(data.bytes, sample) + if not pcm then return nil end + local config = require("src.core.game3.profile").of(version).audio or {} + local params = Sample.cryParams(0, config.cryDefaultVolume, config.cryModeOverrides) + local sound = Sample.renderCry(pcm, Sample.waveRate(sample.freq), params, + { pan = 0, outRate = require("src.core.game3.m4a_mix").SAMPLE_RATE }) + if not sound then return nil end + local source = love.audio.newSource(sound, "static") + sound:release() + return source +end + +function Cry.play(entry) + Cry.stop() + if suspended or not entry or not entry.mon or entry.mon.isEgg or entry.display and entry.display.egg + or not (love and love.audio and love.audio.newSource and love.sound and love.sound.newSoundData) then return nil end + local chip = package.loaded["src.core.ChipAudio"] + if chip and chip.isSuspended() then return nil end + local options = require("src.core.SaveData").loadOptions() + local volume = math.max(0, math.min(7, tonumber(options.boxCryVol) or 7)) / 7 + if volume == 0 then return nil end + local order = { entry.version } + for i = #GameVersion.ORDER, 1, -1 do + local version = GameVersion.ORDER[i] + if version ~= entry.version then order[#order + 1] = version end + end + for _, version in ipairs(order) do + local data = Catalog.get(version) + local national = entry.display and entry.display.national + local species = version == entry.version and entry.mon.species + or data.generation == 3 and (data.national.toSpecies or {})[national] + or data.byNational and data.byNational[national] + if data.ready and species then + local key = version .. ":" .. tostring(species) + local source = sources[key] + if not source then + local ok, result = pcall(render, version, species) + if ok and result then + if sourceCount >= MAX_SOURCES then clearSources() end + source, sources[key], sourceCount = result, result, sourceCount + 1 + end + end + if source then + local gain = volume + if version == "yellow" and species == "PIKACHU" then + gain = gain * math.max(0, math.min(7, tonumber(options.pikaVol) or 7)) / 7 + end + local ok = pcall(function() source:setVolume(gain); source:play() end) + if ok then playing = source; return source, version end + end + end + end +end + +require("src.render.Assets").register({ invalidate = Cry.reset, release = Cry.reset }) +return Cry diff --git a/src/box/Events.lua b/src/box/Events.lua new file mode 100644 index 00000000..4f4c16dc --- /dev/null +++ b/src/box/Events.lua @@ -0,0 +1,113 @@ +local Store = require("src.box.Store") +local Context = require("src.box.GameContext") +local Events = {} +local WESTERN = { [2]=true,[3]=true,[4]=true,[5]=true,[7]=true } +local ALL = { [1]=true,[2]=true,[3]=true,[4]=true,[5]=true,[7]=true } +Events.ROWS = { + { id="eon",name="Eon Ticket",item="ITEM_EON_TICKET",versions={ruby=ALL,sapphire=ALL,emerald=ALL}, + card="rse_eon_ticket",route="Southern Island",method="Record mixing" }, + { id="aurora",name="AuroraTicket",item="ITEM_AURORA_TICKET",versions={firered=ALL,leafgreen=ALL,emerald=WESTERN}, + card="aurora_ticket",rseCard="rse_aurora_ticket",route="Birth Island",method="Wonder Card" }, + { id="mystic",name="MysticTicket",item="ITEM_MYSTIC_TICKET", + versions={firered={[1]=true,[2]=true},leafgreen={[1]=true,[2]=true},emerald={[1]=true,[2]=true}}, + card="mystic_ticket",rseCard="rse_mystic_ticket",route="Navel Rock",method="Wonder Card" }, + { id="oldSeaMap",name="Old Sea Map",item="ITEM_OLD_SEA_MAP",versions={emerald={[1]=true}}, + card="rse_old_sea_map",route="Faraway Island",method="Wonder Card" }, +} +function Events.allowed(id,version,language) + for _,row in ipairs(Events.ROWS) do + if row.id==id then return row.versions[version] and row.versions[version][language] and row or nil end + end +end +function Events.list(version,language) + local out={} + for _,row in ipairs(Events.ROWS) do if Events.allowed(row.id,version,language) then out[#out+1]=row end end + return out +end +local function session(save) + local store={version=save.version,flags={},vars={}} + for id,on in pairs(save.flags or {}) do store.flags[tonumber(id) or id]=on end + for id,value in pairs(save.vars or {}) do store.vars[tonumber(id) or id]=value end + return {version=save.version,store=store,bag=save.bag,storage=save.storage,modData=save.modData or {}} +end +local function apply(s,save) + local snap=require("src.core.game3.scripting.flags").serialize(s.store) + save.flags,save.vars,save.bag,save.storage,save.modData=snap.flags,snap.vars,s.bag,s.storage,s.modData +end +function Events.status(save,row) + return Context.withItems(save.version,function() + local Gift=require("src.core.game3.mystery_gift") + local s=session(Store.copy(save)) + local item=require("src.core.game3.constants").of(save.version):require("items",row.item) + if require("src.core.game3.bag").has(s.bag or {pockets={}},item,1) then return "Collected" end + local received=row.id=="eon" and (save.version=="ruby" or save.version=="sapphire") and "FLAG_SYS_HAS_EON_TICKET" + or row.id=="eon" and "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND" + or row.id=="aurora" and "FLAG_RECEIVED_AURORA_TICKET" + or row.id=="mystic" and "FLAG_RECEIVED_MYSTIC_TICKET" or "FLAG_RECEIVED_OLD_SEA_MAP" + if Gift.getFlag(s,require("src.core.game3.constants").of(save.version):require("flags",received)) then return "Collected" end + if row.id=="eon" then return "Available" end + local family=save.version=="emerald" and "rse" or "frlg" + local key=family=="rse" and (row.rseCard or row.card) or row.card + for _,candidate in ipairs(Gift.builtins(family)) do + if candidate.key==key then + for _,flag in ipairs(candidate.card.gift and candidate.card.gift.haveFlags or {}) do + if Gift.getFlag(s,flag) then return "Event already completed" end + end + end + end + local card=Gift.getSavedCard(s) + if card and card.gift and card.gift.item==item then return "Ticket pending" end + return "Available" + end) +end +function Events.receive(save,id,language) + local row=Events.allowed(id,save.version,language) + if not row then return nil,"This event was not historically distributed for this game and language." end + local status,why=Events.status(save,row) + if not status then return nil,why end + if status~="Available" then return nil,"This event has already been received." end + return Context.withItems(save.version,function() + local out=Store.copy(save) + local s=session(out) + local C=require("src.core.game3.constants").of(save.version) + local item=C:require("items",row.item) + if id=="eon" then + local leader={name="Box Events",giftItem=item} + local result + if save.version=="ruby" or save.version=="sapphire" then + result=require("src.core.game3.link.rs_record_mix").receiveGift(s,leader,2) + else result=require("src.core.game3.rse.record_mixing_gift").mixImport({leader,{}},s,2) end + if not result or result.item~=item then return nil,"Make room in the Key Items pocket before receiving the Eon Ticket." end + else + local Gift=require("src.core.game3.mystery_gift") + local existing=Gift.getSavedCard(s) + if existing then + local flag=Gift.receivedGiftFlag(existing.flagId,s) + if flag and not Gift.getFlag(s,flag) then return nil,"Collect your existing Wonder Card gift before receiving another card." end + end + local family=save.version=="emerald" and "rse" or "frlg" + local key=family=="rse" and (row.rseCard or row.card) or row.card + local card + for _,candidate in ipairs(Gift.builtins(family)) do if candidate.key==key then card=candidate.card end end + if not card or not Gift.receiveCard(s,card) then return nil,"The game's ticket delivery card could not be prepared." end + end + apply(s,out);return out + end) +end +function Events.collect(save,id,language) + local row=Events.allowed(id,save.version,language) + if not row or id=="eon" then return nil,"Choose a pending ticket card." end + return Context.withItems(save.version,function() + local Gift=require("src.core.game3.mystery_gift") + local out=Store.copy(save);local s=session(out) + local card=Gift.getSavedCard(s) + local item=require("src.core.game3.constants").of(save.version):require("items",row.item) + if not card or not card.gift or card.gift.item~=item then return nil,"Receive this ticket's card first." end + local result=Gift.deliverGift(s,card) + if result~=Gift.DELIVER_GIVEN then + return nil,result==Gift.DELIVER_NO_ROOM and "The Key Items pocket is full." or "This ticket has already been collected." + end + Gift.claimCard(s,card);apply(s,out);return out + end) +end +return Events diff --git a/src/box/GCI.lua b/src/box/GCI.lua new file mode 100644 index 00000000..bd26ef9b --- /dev/null +++ b/src/box/GCI.lua @@ -0,0 +1,185 @@ +local Store = require("src.box.Store") +local Catalog = require("src.box.Catalog") +local Serializer = require("src.core.SaveSerializer") +local Pack = require("src.save_convert.gen3_port.imagepack") +local Gen3 = require("src.save_convert.Gen3Save") +local GCI = { SIZE = 0x76000, BLOCK = 0x2000, BODY = 0x1FF0 } +local function be32(s, off) + local a,b,c,d = s:byte(off+1,off+4) + return a*16777216+b*65536+c*256+d +end +local function word(value) + return string.char(math.floor(value/16777216)%256,math.floor(value/65536)%256,math.floor(value/256)%256,value%256) +end +local function le32(s, off) + local a,b,c,d = s:byte(off+1,off+4) + return a+b*256+c*65536+d*16777216 +end +local function little(value) + return word(value):reverse() +end +function GCI.checksum(block) + local sum = 0 + for i = 5, 0x1FFC, 2 do sum = (sum + block:byte(i)*256 + block:byte(i+1)) % 65536 end + return sum*65536 + (0xF004-sum)%65536 +end + +function GCI.decode(bytes) + if type(bytes) ~= "string" then return nil, "Choose a Pokémon Box GCI file." end + local header, raw = "", bytes + if #bytes == GCI.SIZE + 64 then + local code = bytes:sub(1,4) + if code ~= "GPXE" and code ~= "GPXP" and code ~= "GPXJ" then return nil, "This GCI belongs to another game." end + if bytes:sub(5,6) ~= "01" or bytes:byte(57)*256+bytes:byte(58) ~= 59 then + return nil, "The GCI header does not describe a 59-block Nintendo Pokémon Box save." + end + header, raw = bytes:sub(1,64), bytes:sub(65) + elseif #bytes ~= GCI.SIZE then return nil, "A Pokémon Box save has 0x76000 data bytes plus an optional 64-byte GCI header." end + local banks, extra = {}, {} + for bank = 0, 1 do + local rows, count, valid = {}, nil, true + for i = 0, 22 do + local at = (1+bank*23+i)*GCI.BLOCK + local block = raw:sub(at+1,at+GCI.BLOCK) + local id, counter = be32(block,4), be32(block,8) + if id >= 23 or rows[id] or GCI.checksum(block) ~= be32(block,0) + or count ~= nil and counter ~= count then valid = false end + rows[id], count = { offset = at, block = block }, counter + end + if valid then banks[#banks+1] = { rows = rows, count = count, bank = bank } end + end + if #banks == 0 then return nil, "Both Pokémon Box save banks are damaged." end + table.sort(banks, function(a,b) + local distance=(a.count-b.count)%4294967296 + return distance>0 and distance<2147483648 + end) + local active = banks[1] + for i = 0, 11 do + local at = (47+i)*GCI.BLOCK + local block = raw:sub(at+1,at+GCI.BLOCK) + local id = be32(block,4) + if id >= 12 or extra[id] or GCI.checksum(block) ~= be32(block,0) then + return nil, "Pokémon Box's showcase/photo blocks are damaged." + end + extra[id] = { offset = at, block = block } + end + local pieces = {} + for i = 0, 22 do pieces[#pieces+1] = active.rows[i].block:sub(13,12+GCI.BODY) end + pieces[#pieces+1] = string.rep("\0",23*(GCI.BLOCK-GCI.BODY)) + for i = 0, 11 do pieces[#pieces+1] = extra[i].block:sub(13,12+GCI.BODY) end + pieces[#pieces+1] = string.rep("\0",12*(GCI.BLOCK-GCI.BODY)) + return { header = header, raw = raw, logical = table.concat(pieces), active = active, extra = extra, + recovered = #banks == 1, japanese = header:sub(1,4) == "GPXJ" or raw:byte(1) == 0x83 } +end + +function GCI.import(state, bytes) + if Store.count(state) ~= 0 then return nil, "Import a native Box save into an empty warehouse to preserve every existing Pokémon." end + local data, why = GCI.decode(bytes) + if not data then return nil, why end + if data.japanese then return nil, "Japanese Box saves use a different name character set; import an English or European Box save." end + local nextState, codec = Store.copy(state), Gen3.forVersion("ruby") + local versions = { [1] = "sapphire", [2] = "ruby", [3] = "emerald", [4] = "firered", [5] = "leafgreen" } + for b = 1, Store.BOXES do + local box = nextState.boxes[b] + local rawName = data.logical:sub(0x1EC38+(b-1)*9+1,0x1EC38+b*9) + local first = rawName:byte(1) + box.name = (first == 0 or first == 0xFF) and "BOX "..b or codec.decodeString(rawName,0,9) + box.gciNameRaw, box.gciNameOriginal = rawName, box.name + box.gciWallpaper = data.logical:byte(0x1ED19+b) + for slot = 1, Store.SLOTS do + local at = 8+((b-1)*60+slot-1)*84 + local raw = data.logical:sub(at+1,at+80) + local mon = math.floor(raw:byte(20)/2)%2 == 1 and codec.decodeBoxMon(raw) or nil + if mon then + if not mon.checksumOk or mon.isBadEgg then return nil, "A native Pokémon record is damaged at Box "..b..", slot "..slot.."." end + local version = versions[mon.metGame] or "ruby" + local port = codec.toPortMon(mon,false) + box.mons[slot] = { id = nextState.nextId, version = version, generation = 3, mon = port, + depositorId = le32(data.logical,at+80), display = Catalog.describe(version,port), + gciRaw = raw, gciOriginal = Serializer.encode(port) } + nextState.nextId = nextState.nextId+1 + end + end + end + nextState.gciTemplate = Pack.pack(bytes) + nextState.revision = state.revision+1 + return nextState, data.recovered and "Imported using the complete surviving save bank." or nil +end + +function GCI.export(state) + local template = Pack.unpack(state.gciTemplate) + if not template then return nil, "Import an original Pokémon Box save once to supply its native file template." end + local data, why = GCI.decode(template) + if not data then return nil, why end + local codec = Gen3.forVersion("ruby") + local edits = {} + local function put(off, bytes) edits[#edits+1] = { off, bytes } end + for b = 1, Store.BOXES do + local box = state.boxes[b] + local name + if box.name == box.gciNameOriginal then name = box.gciNameRaw + else + name = codec.encodeString(box.name,8)..string.char(0xFF) + if codec.decodeString(name,0,9) ~= box.name then return nil, "A Box name uses characters the native file cannot represent." end + end + put(0x1EC38+(b-1)*9,name) + put(0x1ED19+b-1,string.char(box.gciWallpaper or 0)) + for slot = 1, Store.SLOTS do + local entry = box.mons[slot] + local raw, depositor = string.rep("\0",80), 0 + if entry then + if entry.generation ~= 3 then return nil, "Native Pokémon Box files accept Gen 3 records. Convert or withdraw earlier-generation Pokémon first." end + if entry.gciRaw and entry.gciOriginal == Serializer.encode(entry.mon) then raw = entry.gciRaw + else + local compatible, err = Catalog.compatible(entry, entry.version) + if not compatible then return nil, err end + local native = codec.fromPortMon(entry.mon, {}, false) + if native.species <= 0 or native.species > 412 then return nil, "A species cannot be represented by native Pokémon Box." end + if native.heldItem > 346 then return nil, "This held item is outside original Pokémon Box's Ruby/Sapphire item range." end + for _, move in ipairs(native.moves) do + if move < 0 or move > 354 then return nil, "A move is outside the native Gen 3 move range." end + end + if entry.mon.nickname and native.nickname ~= entry.mon.nickname + or (entry.mon.otName or entry.mon.ot) and native.otName ~= (entry.mon.otName or entry.mon.ot) then + return nil, "A Pokémon name would be truncated by native export." + end + local rawNick = native.nicknameRaw and codec.decodeString(native.nicknameRaw,0,10,native.language) == native.nickname + local rawOt = native.otNameRaw and codec.decodeString(native.otNameRaw,0,7,native.language) == native.otName + if not native.nicknameBytes and not rawNick + and codec.decodeString(codec.encodeString(native.nickname,10,0xFF,native.language),0,10,native.language) ~= native.nickname + or not rawOt and codec.decodeString(codec.encodeString(native.otName,7,0xFF,native.language),0,7,native.language) ~= native.otName then + return nil, "A Pokémon name uses characters the native format cannot represent." + end + raw = codec.encodeBoxMon(native) + end + depositor = entry.depositorId or 0 + end + put(8+((b-1)*60+slot-1)*84,raw..little(depositor)) + end + end + table.sort(edits, function(a,b) return a[1] < b[1] end) + local pieces, at = {}, 0 + local source = data.logical + for _, edit in ipairs(edits) do + pieces[#pieces+1] = source:sub(at+1, edit[1]) + pieces[#pieces+1] = edit[2] + at = edit[1] + #edit[2] + end + pieces[#pieces+1] = source:sub(at+1) + local logical = table.concat(pieces) + local raw, bank = {}, 1-data.active.bank + local count = (data.active.count+1)%4294967296 + local first = (1+bank*23)*GCI.BLOCK + raw[1] = data.raw:sub(1,first) + for id = 0, 22 do + local at = first+id*GCI.BLOCK + local old = data.raw:sub(at+1,at+GCI.BLOCK) + local block = string.rep("\0",4)..word(id)..word(count) + ..logical:sub(id*GCI.BODY+1,(id+1)*GCI.BODY)..old:sub(0x1FFD) + raw[#raw+1] = word(GCI.checksum(block))..block:sub(5) + end + raw[#raw+1] = data.raw:sub(first+23*GCI.BLOCK+1) + return data.header..table.concat(raw) +end + +return GCI diff --git a/src/box/GameContext.lua b/src/box/GameContext.lua new file mode 100644 index 00000000..97b3dde6 --- /dev/null +++ b/src/box/GameContext.lua @@ -0,0 +1,49 @@ +local Catalog = require("src.box.Catalog") +local Context = {} + +local function capture(t, nested) + local rows = {} + for k, v in pairs(t) do + rows[k] = nested and type(v) == "table" and { table = v, rows = capture(v) } or { value = v } + end + return rows +end + +local function restore(t, rows) + for k in pairs(t) do t[k] = nil end + for k, row in pairs(rows) do + if row.rows then restore(row.table, row.rows); t[k] = row.table else t[k] = row.value end + end +end + +local function upvalue(fn, wanted) + for i = 1, math.huge do + local name, value = debug.getupvalue(fn, i) + if name == nil then return nil end + if name == wanted then return value end + end +end + +function Context.withItems(version, operation) + local Items = require("src.core.game3.items_data") + local tables = {} + for _, key in ipairs({ "POCKET", "POCKET_RESULT", "POCKET_ORDER", "BAG_POCKET_ORDER", + "CAPACITY", "PACK_POCKET", "CONTAINERS", "BAG_MODEL" }) do + if type(Items[key]) == "table" then tables[#tables + 1] = { Items[key], capture(Items[key], key == "BAG_MODEL") } end + end + local labels = upvalue(Items.applyProfile, "LABEL_KEYS") + if type(labels) == "table" then tables[#tables + 1] = { labels, capture(labels) } end + local fields = capture(Items) + local ok, a, b = pcall(function() + local data = Catalog.get(version) + if not data.ready then return nil, "Import this game's ROM before receiving an event." end + Items.applyProfile(version) + Items.installPack({ items = data.items }) + return operation() + end) + for _, row in ipairs(tables) do restore(row[1], row[2]) end + restore(Items, fields) + if not ok then return nil, tostring(a) end + return a, b +end +return Context diff --git a/src/box/Gifts.lua b/src/box/Gifts.lua new file mode 100644 index 00000000..9163fd95 --- /dev/null +++ b/src/box/Gifts.lua @@ -0,0 +1,94 @@ +local Catalog = require("src.box.Catalog") +local Store = require("src.box.Store") +local Rng = require("src.core.game3.rng") +local GameVersion = require("src.core.GameVersion") +local Gifts = {} +Gifts.AWARDS = { + { name = "Swablu", national = 333, threshold = 0, moves = { 64, 45, 206 } }, + { name = "Zigzagoon", national = 263, threshold = 100, moves = { 33, 45, 39, 245 } }, + { name = "Skitty", national = 300, threshold = 500, moves = { 45, 33, 39, 6 } }, + { name = "Pichu", national = 172, threshold = 1499, moves = { 84, 204, 57 } }, +} +local MET_GAME = { sapphire = 1, ruby = 2, emerald = 3, firered = 4, leafgreen = 5 } + +function Gifts.trainerId(save) + local tid = tonumber(save.trainerId or save.player and save.player.trainerId) + local sid = tonumber(save.secretId or save.player and save.player.secretId) + if tid == nil or sid == nil then return nil end + return tid % 65536 + (sid % 65536) * 65536 +end + +function Gifts.identity(source, save) + return table.concat({ source.version, source.path, tostring(Gifts.trainerId(save)), + tostring(save.meta and save.meta.playthroughId or save.name or save.playerName or "") }, "|") +end + +function Gifts.flags(save) + return require("src.core.game3.save_mon").boxFlags(save) or 0 +end + +function Gifts.setFlags(save, value) + require("src.core.game3.save_mon").setBoxFlags(save, value) +end + +function Gifts.progress(state, source, save) + if GameVersion.generation(source.version) ~= 3 then return nil, "Original Box gift eggs require a Gen 3 save." end + local tid = Gifts.trainerId(save) + if not tid then return nil, "This save needs a recorded trainer ID and secret ID for Box gifts." end + local count = 0 + for _, box in ipairs(state.boxes) do + for _, entry in pairs(box.mons) do + if entry.generation == 3 and entry.depositorId == tid then count = count + 1 end + end + end + local key = Gifts.identity(source, save) + local peak = math.max(count, state.progress and state.progress[key] or 0) + local flags = Gifts.flags(save) + local nextAward = flags % 2 == 0 and 1 or math.floor(flags / 2) % 4 + 2 + local award = Gifts.AWARDS[nextAward] + return { current = count, peak = peak, flags = flags, key = key, + nextAward = nextAward, award = award, eligible = award ~= nil and peak >= award.threshold } +end + +function Gifts.recordProgress(state, source, save) + local progress = Gifts.progress(state, source, save) + if progress then state.progress = state.progress or {}; state.progress[progress.key] = progress.peak end +end + +function Gifts.create(version, index, seed) + local award, data = Gifts.AWARDS[index], Catalog.get(version) + if not award or not data.ready then return nil, "Import this game's complete species and move data first." end + local species = (data.national.toSpecies or {})[award.national] + local meta = species and data.meta[species] + if not meta or meta.eggCycles == nil or meta.growthRate == nil then return nil, "The gift species data is incomplete." end + if seed == nil then seed = math.floor((love and love.timer and love.timer.getTime() or os.clock()) * 1000000) end + seed = math.floor(seed) % 4294967296 + local function random() + seed = (Rng.mulU32(seed, 0x41C64E6D) + 0x6073) % 4294967296 + return math.floor(seed / 65536) + end + local high, low = random(), random() + local first, second = random(), random() + local ivs = { hp = first % 32, atk = math.floor(first / 32) % 32, + def = math.floor(first / 1024) % 32, spe = second % 32, + spa = math.floor(second / 32) % 32, spd = math.floor(second / 1024) % 32 } + local mon = { species = species, speciesId = species, speciesNumbering = "internal", + personality = high * 65536 + low, ivs = ivs, evs = {}, contest = {}, + exp = require("src.core.game3.summary_data").expForLevel(meta.growthRate, 5), level = 5, + nickname = "EGG", otName = "AZUSA", ot = "AZUSA", otId = 0, otSecretId = 0, + language = 1, otGender = 1, isEgg = true, friendship = meta.eggCycles, + eggCycles = meta.eggCycles, pokeball = 4, metLocation = 255, metLevel = 0, + metGame = MET_GAME[version], moves = Store.copy(award.moves), pp = {}, + ribbons = 0, markings = 0, pokerus = 0, heldItem = 0, ppBonusesPacked = 0, + cartExtra = { nicknameBytes = { 0x60, 0x6F, 0x8B, 0xFF }, nicknameLanguage = 1 } } + local pair = data.abilities[species] or {} + mon.abilityNum = pair[2] and pair[2] ~= 0 and mon.personality % 2 or 0 + for i, move in ipairs(mon.moves) do + local battle = data.battleMoves[move] + if not battle or battle.pp == nil then return nil, "The gift move data is incomplete." end + mon.pp[i] = battle.pp + end + return mon, seed +end + +return Gifts diff --git a/src/box/Metadata.lua b/src/box/Metadata.lua new file mode 100644 index 00000000..bb4c8bfc --- /dev/null +++ b/src/box/Metadata.lua @@ -0,0 +1,154 @@ +local Metadata = {} +local NATURES = { "Hardy", "Lonely", "Brave", "Adamant", "Naughty", "Bold", "Docile", + "Relaxed", "Impish", "Lax", "Timid", "Hasty", "Serious", "Jolly", "Naive", "Modest", + "Mild", "Quiet", "Bashful", "Rash", "Calm", "Gentle", "Sassy", "Careful", "Quirky" } +local function number(value) return tonumber(value) end +local function name(records, key) + local value = records and records[key] + if type(value) == "table" then return value.name or tostring(key) end + return value or (key ~= nil and tostring(key) or nil) +end + +function Metadata.describe(data, mon, d, def) + local gen, pid = data.generation, number(mon.personality) or 0 + local meta = gen == 3 and data.meta[tonumber(mon.species)] or def + d.trainer = mon.otName or mon.ot + d.trainerId, d.secretId = mon.otId or mon.trainerId, mon.otSecretId + d.friendship = mon.friendship or mon.happiness + d.experience = mon.exp or mon.experience + if gen == 3 then + local Summary = require("src.core.game3.summary_data") + local nature = pid % 25 + d.nature, d.natureId = NATURES[nature + 1], nature + local ratio = meta and meta.genderRatio + if ratio ~= nil then + d.gender = ratio == 255 and "Genderless" or ratio == 0 and "Male" + or ratio == 254 and "Female" or (pid % 256 < ratio and "Female" or "Male") + end + local pair = data.abilities[tonumber(mon.species)] or {} + local slot = tonumber(mon.abilityNum) + if slot == nil then slot = pid % 2 end + local ability = pair[slot + 1] + if not ability or ability == 0 then ability = pair[1] end + d.ability = ability and name(data.abilityNames, ability) or nil + local growth = meta and meta.growthRate + if mon.level == nil and growth ~= nil and d.experience ~= nil then + d.level = 1 + while d.level < 100 and Summary.expForLevel(growth, d.level + 1) <= d.experience do + d.level = d.level + 1 + end + end + local base = data.stats[tonumber(mon.species)] + if base and d.level > 0 then + local iv, ev, stats = mon.ivs or {}, mon.evs or {}, {} + for _, row in ipairs({ { "hp", "hp" }, { "atk", "attack" }, { "def", "defense" }, + { "spe", "speed" }, { "spa", "spAtk" }, { "spd", "spDef" } }) do + local key, out = row[1], row[2] + local value = math.floor((2 * (base[key] or 0) + (iv[key] or 0) + + math.floor((ev[key] or 0) / 4)) * d.level / 100) + if key == "hp" then value = d.national == 292 and 1 or value + d.level + 10 + else + local natureKey = ({ spe = "spd", spa = "spAtk", spd = "spDef" })[key] or key + local percent = math.floor(Summary.natureStatModifier(nature, natureKey) * 100 + 0.5) + value = math.floor((value + 5) * percent / 100) + end + stats[out] = value + end + d.stats = stats + end + local location = data.locations[tonumber(mon.metLocation)] + d.location = mon.metLocationName or (type(location) == "table" and location.name) + or (mon.metLocation ~= nil and "Location " .. tostring(mon.metLocation) or nil) + d.ball = name(data.items, mon.pokeball or mon.ball) + d.metLevel, d.metGame = mon.metLevel, mon.metGame + d.markings = tonumber(mon.markings) or 0 + elseif gen == 2 then + if def and def.genderRatio ~= nil then + local g = require("src.battle.gen2.Mon").vanillaGender(def, mon.dvs) + d.gender = g == "male" and "Male" or g == "female" and "Female" or "Genderless" + end + if def and def.baseStats then + d.stats = require("src.battle.gen2.Mon").stats(def.baseStats, mon.dvs, d.level, mon.statExp) + end + if mon.caughtLocation ~= nil then d.location = "Landmark " .. tostring(mon.caughtLocation) end + d.metLevel = mon.caughtLevel + elseif def and def.baseStats then + d.stats = require("src.pokemon.Stats").calc(def, d.level, mon.dvs or {}, mon.statExp) + end + d.stats = d.stats or mon.stats or {} + d.hp = d.stats.hp or mon.maxHp + d.attack = d.stats.attack or mon.attack + d.defense = d.stats.defense or mon.defense + d.speed = d.stats.speed or mon.speed + d.special = d.stats.special + d.spAtk = d.stats.spAtk or d.stats.specialAttack or mon.spAtk + d.spDef = d.stats.spDef or d.stats.specialDefense or mon.spDef + d.moveDetails = {} + local originalSlots = mon.cartExtra and mon.cartExtra.moveSlots + local slotCursor = 1 + for i, move in ipairs(mon.moves or {}) do + local key = type(move) == "table" and (move.moveId or move.id or move.move or move.name) or move + if key and key ~= 0 then + local nativeSlot = i + if gen == 3 and originalSlots and originalSlots.moves then + while slotCursor <= 4 and (originalSlots.moves[slotCursor] or 0) == 0 do slotCursor = slotCursor + 1 end + if originalSlots.moves[slotCursor] == key then nativeSlot = slotCursor; slotCursor = slotCursor + 1 end + end + local battle = gen == 3 and data.battleMoves[key] or data.moves[key] + local basePP = type(battle) == "table" and battle.pp + local bonus = type(move) == "table" and (move.ppUps or move.ppUp or move.ppBonuses) + or type(mon.ppBonuses) == "table" and mon.ppBonuses[i] + bonus = tonumber(bonus) or math.floor((tonumber(mon.ppBonusesPacked) or 0) / 4^(nativeSlot - 1)) % 4 + local maxPP = type(move) == "table" and move.maxPp + or basePP and basePP + math.floor(basePP * bonus / 5) + d.moveDetails[#d.moveDetails + 1] = { name = name(data.moves, key), + pp = type(move) == "table" and move.pp or (mon.pp or {})[i], maxPP = maxPP, ppUps = bonus } + end + end + d.contest, d.ribbons, d.pokerus = mon.contest, mon.ribbons, mon.pokerus + d.championRibbon, d.fateful = mon.championRibbon, mon.modernFatefulEncounter + return d +end + +function Metadata.summary(entry) + local d, mon, gen = entry.display, entry.mon, entry.generation + local rows = {} + local function add(label, value) + rows[#rows + 1] = { label, value == nil and "Not recorded" or tostring(value) } + end + add("Original Trainer", d.trainer) + add("Trainer ID", d.trainerId) + if gen == 3 then add("Secret ID", d.secretId) end + add("Experience", d.experience) + if gen >= 2 then add("Gender", d.gender); add("Friendship / egg cycles", d.friendship) end + if gen == 3 then + add("Nature", d.nature); add("Ability", d.ability); add("Poké Ball", d.ball) + add("Met game", d.metGame); add("Markings", d.markings) + end + if gen >= 2 then add("Met location", d.location); add("Met level", d.metLevel); add("Pokérus", d.pokerus) end + for _, move in ipairs(d.moveDetails or {}) do + add(move.name, tostring(move.pp or "?") .. " / " .. tostring(move.maxPP or "?") .. " PP · " .. move.ppUps .. " PP Ups") + end + for _, field in ipairs({ { "Stats", d.stats }, { "IVs", mon.ivs }, { "DVs", mon.dvs }, + { gen == 3 and "EVs" or "Stat experience", gen == 3 and mon.evs or mon.statExp }, { "Contest", d.contest } }) do + if type(field[2]) == "table" then + local values = {} + for key, value in pairs(field[2]) do values[#values + 1] = key .. " " .. tostring(value) end + table.sort(values); add(field[1], #values > 0 and table.concat(values, " · ") + or (field[1] == "EVs" or field[1] == "Stat experience") and "All zero" or "Not recorded") + end + end + if gen == 3 then + local Ribbons = require("src.core.game3.rse.ribbons") + local names = {} + for _, key in ipairs(Ribbons.COUNTED) do + local count = Ribbons.get(mon, key) + if count > 0 then names[#names + 1] = key .. (count > 1 and " ×" .. count or "") end + end + add("Ribbons", #names > 0 and table.concat(names, ", ") or "None") + add("Fateful encounter", d.fateful) + end + return rows +end + +return Metadata diff --git a/src/box/Migration.lua b/src/box/Migration.lua new file mode 100644 index 00000000..63e17af2 --- /dev/null +++ b/src/box/Migration.lua @@ -0,0 +1,307 @@ +local Store = require("src.box.Store") +local Catalog = require("src.box.Catalog") +local GameVersion = require("src.core.GameVersion") +local Serializer = require("src.core.SaveSerializer") +local Mon = require("src.battle.gen2.Mon") +local Pokemon = require("src.core.game3.pokemon") +local Unown = require("src.core.gen2.Unown") +local bit = require("bit") +local Migration = {} +local KEYS = { {"hp","hp"},{"atk","attack"},{"def","defense"},{"spe","speed"},{"spa","special"},{"spd","special"} } +local MET_GAME = { sapphire=1,ruby=2,emerald=3,firered=4,leafgreen=5 } +local function clamp(n,lo,hi) return math.max(lo,math.min(hi,math.floor(tonumber(n) or lo))) end +local function normalized(name) return tostring(name):upper():gsub("[%s_%p]","") end +local function label(value,key) return type(value)=="table" and (value.name or key) or value or key end +local function mapped(records,name) + local want,found=normalized(name),nil + for key,value in pairs(records) do + if normalized(label(value,key))==want then + if found~=nil and found~=key then return nil end + found=key + end + end + return found +end +local function nameFits(text,generation,limit) + if type(text)~="string" or text:find("%c") then return false end + if generation==3 then + local codec=require("src.save_convert.Gen3Save").forVersion("emerald") + return codec.decodeString(codec.encodeString(text,limit,0xFF),0,limit)==text + end + local chars=generation==1 and require("src.save_convert.data.charmap").byToken + or require("src.save_convert.Gen2Layout").charmap + local tokens={} + for key,value in pairs(chars) do + local glyph=generation==1 and key or value + local code=generation==1 and value or key + if type(glyph)=="string" and not glyph:find("[<>@{}]") and code~=0x50 then tokens[#tokens+1]=glyph end + end + table.sort(tokens,function(a,b) return #a>#b end) + local at,count=1,0 + while at<=#text do + local found + for _,token in ipairs(tokens) do + if #token>0 and text:sub(at,at+#token-1)==token then found=token;break end + end + if not found then return false end + at,count=at+#found,count+1 + if count>limit then return false end + end + return true +end +local function gender3(ratio,pid) + return ratio==255 and "Genderless" or ratio==0 and "Male" or ratio==254 and "Female" + or pid%25626 then return nil,"Gen 2 cannot represent Unown ! or ?." end + local best,score + for n=0,65535 do + local d={attack=math.floor(n/4096),defense=math.floor(n/256)%16,speed=math.floor(n/16)%16,special=n%16} + if Mon.vanillaShiny(d)==shiny and (generation==1 or gender2(def,d)==gender) + and (not letter or Unown.letterFromDVs(d)==letter) then + local distance=0 + for key,value in pairs(target) do distance=distance+math.abs(d[key]-value) end + if score==nil or distance65535 then return nil,"The original trainer ID is missing or invalid." end + if d.level<1 or d.level>100 or d.level%1~=0 then return nil,"The source level is invalid." end + if g1==3 and (not from.meta[mon.species] or from.meta[mon.species].growthRate==nil) then return nil,"Import complete source species data first." end + local national=d.national + local species=g2==3 and (to.national.toSpecies or {})[national] or to.byNational[national] + if not species then return nil,"The destination cannot represent this species." end + local def=g2==3 and to.meta[species] or to.pokemon[species] + if not def or def.growthRate==nil or g2==3 and (not to.stats[species] or def.genderRatio==nil) + or g2<3 and not def.baseStats then return nil,"Import complete destination species data first." end + local nickname=mon.nickname or d.species + if g2<3 and normalized(nickname)==normalized(d.species) then nickname=nil end + local ot=mon.otName or mon.ot or "" + if nickname~=nil and not nameFits(nickname,g2,10) or not nameFits(ot,g2,7) then + return nil,"The destination cannot represent this nickname or trainer name without changing it." + end + local item=mon.heldItem or mon.item + local targetItem + if item and item~=0 then + if g2==1 then return nil,"Gen 1 cannot represent held items. Remove this item in its game first." end + if not from.items[item] then return nil,"The held item is unknown in the source game." end + targetItem=mapped(to.items,label(from.items[item],item)) + if not targetItem then return nil,"The destination cannot represent this held item." end + end + local moves,pps,ups={},{},{} + for _,move in ipairs(mon.moves or {}) do + local key=type(move)=="table" and (move.moveId or move.id or move.move or move.name) or move + if key and key~=0 then + if not from.moves[key] then return nil,"A move is unknown in the source game." end + local id=mapped(to.moves,label(from.moves[key],key)) + local row=id and (g2==3 and to.battleMoves[id] or to.moves[id]) + if not row or not row.pp then return nil,"The destination cannot represent move "..label(from.moves[key],key).."." end + local detail=d.moveDetails[#pps+1] + local bonus=detail and detail.ppUps or 0 + local max=row.pp+bonus*(g2==3 and math.floor(row.pp/5) or math.min(7,math.floor(row.pp/5))) + local pp=clamp(detail and detail.pp or max,0,max) + moves[#moves+1]=g2==3 and id or {id=id,pp=pp,ppUps=bonus,maxPp=max} + pps[#pps+1],ups[#ups+1]=pp,bonus + end + end + if #moves>4 then return nil,"The destination can represent at most four moves." end + local out={species=species,nickname=nickname,ot=ot,otName=ot,otId=clamp(mon.otId,0,65535), + level=clamp(d.level,1,100),moves=moves,isEgg=mon.isEgg or nil,item=targetItem,traded=mon.traded, + pokerus=g2>=2 and clamp(mon.pokerus,0,255) or nil} + local lines={"Original native record will be archived. Restore replaces this converted record; it does not create a duplicate."} + local function note(value) lines[#lines+1]=value end + if g1<3 then + if type(mon.dvs)~="table" then return nil,"The source DVs are missing." end + for _,key in ipairs({"attack","defense","speed","special"}) do + if type(mon.dvs[key])~="number" or mon.dvs[key]%1~=0 or mon.dvs[key]<0 or mon.dvs[key]>15 then return nil,"The source DVs are invalid." end + end + elseif type(mon.personality)~="number" or type(mon.ivs)~="table" then return nil,"The native personality or IVs are missing." end + if g2==3 then + local pid=pidFor(mon,def,national,mon.dvs,Mon.vanillaShiny(mon.dvs)) + if not pid then return nil,"No personality can preserve this Pokémon's gender, shininess and form." end + out.personality,out.otSecretId,out.nature=pid,0,0 + out.ivs,out.evs={},{} + for _,pair in ipairs(KEYS) do out.ivs[pair[1]]=clamp(mon.dvs[pair[2]],0,15)*2+1 end + out.ivs.hp=Mon.hpDV(mon.dvs)*2+1 + local raw,total={},0 + for _,pair in ipairs(KEYS) do + local value=math.min(252,math.floor(math.sqrt(clamp((mon.statExp or {})[pair[2]],0,65535))/4)*4) + raw[pair[1]],total=value,total+value + end + local used=0 + for _,pair in ipairs(KEYS) do + local value=total>510 and math.floor(raw[pair[1]]*510/total/4)*4 or raw[pair[1]] + out.evs[pair[1]],used=value,used+value + end + out.friendship=mon.isEgg and clamp(mon.happiness or mon.eggCycles,0,255) or clamp(mon.happiness or 70,0,255) + out.eggCycles=mon.isEgg and out.friendship or nil + out.heldItem,out.pp=targetItem or 0,pps + out.ppBonusesPacked=0;for i,value in ipairs(ups) do out.ppBonusesPacked=out.ppBonusesPacked+value*4^(i-1) end + out.markings=entry.markings or 0 + out.abilityNum,out.language,out.otGender=0,2,mon.caughtGender or 0 + out.metLocation,out.metLevel,out.metGame,out.pokeball=255,out.level,MET_GAME[version],4 + out.modernFatefulEncounter=false + note("DVs → odd IVs (2 × DV + 1); HP uses the derived HP DV.") + note("Stat Exp → EVs with the same effort contribution, proportionally capped: "..used.." / 510 EVs.") + note("New personality: Hardy nature, first ability, Secret ID 0. DV shininess, gender and Unown letter are preserved.") + note("Origin: destination game, Poké Ball, met level "..out.level..", location 255. Earlier met data remains in the archive.") + elseif g1==3 then + local input=Store.copy(mon);input._boxVersion=entry.version + local dvs,why=dvsFor(input,def,national,g2) + if not dvs then return nil,why end + out.dvs,out.statExp=dvs,{} + for _,pair in ipairs(KEYS) do + local effort=math.floor(clamp((mon.evs or {})[pair[1]],0,255)/4)*4 + out.statExp[pair[2]]=math.max(out.statExp[pair[2]] or 0,effort^2) + end + out.happiness=g2==2 and clamp(mon.friendship or mon.happiness or 70,0,255) or nil + out.caughtLevel,out.caughtLocation,out.caughtGender=g2==2 and out.level or nil,g2==2 and 0 or nil,g2==2 and (mon.otGender or 0) or nil + note("IVs → nearest DVs preserving shininess"..(g2==2 and ", gender and Unown letter." or " as a latent DV pattern.")) + note(("DVs: Attack %d · Defense %d · Speed %d · Special %d · HP %d"):format(dvs.attack,dvs.defense,dvs.speed,dvs.special,dvs.hp)) + note("EVs → Stat Exp; Special uses the greater Special Attack/Defense effort. HP DV is derived.") + note("Nature, ability, Secret ID, ball, ribbons, contests and Gen 3 origin data remain only in the archive.") + else + local prepared=Store.copy(mon);prepared.species=mon.species + local Convert=require("src.online.Convert") + local native,why=(g2==2 and Convert.toGen2 or Convert.toGen1)(prepared,from,to) + if not native then return nil,"Time Capsule conversion refused: "..tostring(why) end + out.dvs,out.statExp=Store.copy(native.dvs),Store.copy(native.statExp) + out.happiness=g2==2 and 70 or nil + out.caughtLevel=g2==2 and out.level or nil + note("DVs and Stat Exp are preserved; stats are recalculated using the destination's base stats.") + if g2==2 then note("Friendship starts at 70; caught level is the current level.") end + end + local oldExp=tonumber(mon.exp or mon.experience) + local oldBase=expAt(from,mon.species,out.level) + local newBase=expAt(to,species,out.level) + local experience=newBase + if oldExp and out.level<100 then + local oldNext,newNext=expAt(from,mon.species,out.level+1),expAt(to,species,out.level+1) + local fraction=math.max(0,math.min(1,(oldExp-oldBase)/math.max(1,oldNext-oldBase))) + experience=newBase+math.floor(fraction*(newNext-newBase)) + end + if g2==2 then out.experience=experience else out.exp=experience end + note("Level "..out.level.." retained; experience "..tostring(oldExp or "unrecorded").." → "..experience.." (same progress within the level).") + local statuses={SLP="sleep",PSN="poison",BRN="burn",PAR="paralyze",FRZ="freeze"} + local reverse={sleep="SLP",poison="PSN",toxic="PSN",burn="BRN",paralyze="PAR",freeze="FRZ"} + out.status=g2==2 and (statuses[mon.status] or mon.status) or (reverse[mon.status] or mon.status) + out.sleep=mon.sleep or mon.sleepTurns + if g2==2 then + out.gender=Mon.vanillaGender(def,out.dvs);out.shiny=Mon.vanillaShiny(out.dvs) + if national==201 then out.unownLetter=Unown.letterFromDVs(out.dvs) end + elseif g2==1 then out.catchRate=def.catchRate end + local display=Catalog.describe(version,out) + out.stats=g2<3 and Store.copy(display.stats) or nil + out.maxHp,out.hp=display.hp,display.hp + if g2==3 then for _,key in ipairs({"attack","defense","speed","spAtk","spDef"}) do out[key]=display[key] end end + note("Stats recalculated; boxed HP restored to full. Status: "..tostring(mon.status or "healthy").." → "..tostring(out.status or "healthy")..".") + note("Moves and held item mapped by exact name; current PP is capped to destination maximum, PP Ups retained.") + if g2==1 then note("Friendship, Pokérus, gender and met data remain only in the archive.") end + return out,lines +end +function Migration.preview(state,refs,version) + local preview={revision=state.revision,destination=version,rows={},allowed=true} + local seen={} + if type(refs)~="table" or #refs==0 then return nil,"Select warehouse Pokémon to preview." end + for _,ref in ipairs(refs) do + local entry=Store.at(state,ref.box,ref.slot) + if not entry or seen[entry.id] then return nil,"A selected Pokémon is missing or repeated." end + seen[entry.id]=true + local converted,lines=Migration.convert(entry,version) + preview.rows[#preview.rows+1]={id=entry.id,name=entry.display.name,original=Serializer.encode(entry), + converted=converted,lines=converted and lines or {lines}} + if not converted then preview.allowed=false end + end + return preview +end +function Migration.apply(state,refs,version,preview) + if type(preview)~="table" or preview.revision~=state.revision or preview.destination~=version then + return nil,"Storage changed. Preview the migration again." + end + local fresh,why=Migration.preview(state,refs,version) + if not fresh then return nil,why end + if not fresh.allowed then return nil,"The destination cannot represent every selected Pokémon." end + if Serializer.encode(preview)~=Serializer.encode(fresh) then return nil,"The preview changed. Review it again." end + local nextState=Store.copy(state) + for i,ref in ipairs(refs) do + local entry=Store.at(nextState,ref.box,ref.slot) + local snapshot=Store.copy(entry);snapshot.archives=nil + entry.archives=entry.archives or {};entry.archives[#entry.archives+1]=snapshot + entry.version,entry.generation,entry.mon=version,GameVersion.generation(version),fresh.rows[i].converted + entry.display=Catalog.describe(version,entry.mon) + entry.gciRaw,entry.gciOriginal=nil,nil + end + nextState.revision=state.revision+1 + return nextState +end +function Migration.restore(state,ref,index) + local entry=Store.at(state,ref.box,ref.slot) + local original=entry and entry.archives and entry.archives[index] + if not original then return nil,"Choose an archived original that is still in Box." end + local nextState=Store.copy(state) + local archives=Store.copy(entry.archives);table.remove(archives,index) + local current=Store.copy(entry);current.archives=nil;archives[#archives+1]=current + local restored=Store.copy(original);restored.archives=archives + restored.display=Catalog.describe(restored.version,restored.mon) + nextState.boxes[ref.box].mons[ref.slot]=restored + nextState.revision=state.revision+1 + return nextState +end +return Migration diff --git a/src/box/Organizer.lua b/src/box/Organizer.lua new file mode 100644 index 00000000..3998acd0 --- /dev/null +++ b/src/box/Organizer.lua @@ -0,0 +1,333 @@ +local Store = require("src.box.Store") +local Records = require("src.box.Records") +local Catalog = require("src.box.Catalog") +local GameVersion = require("src.core.GameVersion") +local Organizer = {} +Organizer.SORTS = { "species", "name", "level", "type", "nature", "ability", "trainer", "shiny", "egg", "friendship" } +Organizer.LABELS = { species = "Pokédex number", name = "Nickname", level = "Level", type = "Type", + nature = "Nature", ability = "Ability", trainer = "Trainer", shiny = "Shiny", egg = "Egg", friendship = "Friendship" } +Organizer.FIELDS = { "shiny", "egg", "species", "type", "levelMin", "levelMax", "nature", "ability", + "gender", "trainer", "game", "mark", "tag", "name", "move", "item" } +Organizer.FIELD_LABELS = { shiny = "Shiny", egg = "Egg", species = "Species", type = "Type", + levelMin = "Level at least", levelMax = "Level at most", nature = "Nature", ability = "Ability", + gender = "Gender", trainer = "Trainer", game = "Game", mark = "Marked", tag = "Tag contains", + name = "Nickname", move = "Move", item = "Held item" } +function Organizer.available(field, version) + if not version then return true end + local generation = GameVersion.generation(version) + if field == "tag" then return false end + if generation < 3 and (field == "mark" or field == "nature" or field == "ability") then return false end + if generation == 1 and (field == "gender" or field == "egg" or field == "item") then return false end + return true +end +local function integer(n, lo, hi) + return type(n) == "number" and n == math.floor(n) and n >= lo and n <= hi +end +local function contains(list, value) + for _, item in ipairs(list) do if item == value then return true end end + return false +end +local function array(list, max) + if type(list) ~= "table" or #list > max then return false end + for key in pairs(list) do if not integer(key, 1, #list) then return false end end + return true +end +function Organizer.validate(config, count) + count = count or Store.BOXES + if type(config) ~= "table" or (config.mode ~= "sort" and config.mode ~= "rules") + or not contains(Organizer.SORTS, config.sort) + or type(config.descending) ~= "boolean" + or config.box ~= nil and not integer(config.box, 1, count) then + return nil, "Choose a valid sort and box." + end + if config.mode == "sort" then + if config.rules ~= nil then + local ruleConfig = Store.copy(config) + ruleConfig.mode, ruleConfig.box = "rules", nil + return Organizer.validate(ruleConfig) + end + return true + end + if config.box ~= nil or not array(config.rules, 24) or #config.rules == 0 + or (config.fallback ~= "keep" and config.fallback ~= "sort") then + return nil, "Add a rule and choose what happens to the rest." + end + for index, rule in ipairs(config.rules) do + if type(rule) ~= "table" or not integer(rule.firstBox, 1, count) + or not integer(rule.lastBox, rule.firstBox, count) then + return nil, "Rule " .. index .. " needs a destination available in this collection." + end + if not array(rule.conditions, 8) or #rule.conditions == 0 + or (rule.match ~= "all" and rule.match ~= "any") then + return nil, "Rule " .. index .. " needs a condition." + end + for _, condition in ipairs(rule.conditions) do + if type(condition) ~= "table" or not contains(Organizer.FIELDS, condition.field) then + return nil, "A rule condition is invalid." + end + local field, value = condition.field, condition.value + if field == "shiny" or field == "egg" then + if type(value) ~= "boolean" then return nil, "Choose yes or no for this condition." end + elseif field == "levelMin" or field == "levelMax" then + if not integer(value, 0, 255) then return nil, "Choose a level from 0 to 255." end + elseif type(value) ~= "string" or #value == 0 or #value > 128 or value:find("%c") then + return nil, "Choose a value for this condition." + elseif field == "game" and not GameVersion.VERSIONS[value] + or field == "mark" and not contains({ "circle", "square", "triangle", "heart" }, value) then + return nil, "Choose a valid game or marking." + end + end + end + return true +end + +local function normalized(value) return require("src.box.Search").normalize(value) end +local function matches(entry, condition) + local d, field, wanted = entry.display, condition.field, condition.value + if field == "shiny" or field == "egg" then return (d[field] == true) == wanted end + if field == "levelMin" then return tonumber(d.level) ~= nil and tonumber(d.level) >= wanted end + if field == "levelMax" then return tonumber(d.level) ~= nil and tonumber(d.level) <= wanted end + if field == "mark" then + local mask = ({ circle = 1, square = 2, triangle = 4, heart = 8 })[wanted] + local value = tonumber(entry.generation == 3 and entry.mon.markings or entry.markings) or 0 + return math.floor(value / mask) % 2 == 1 + end + if field == "tag" then return normalized(entry.tags):find(normalized(wanted), 1, true) ~= nil end + if field == "type" then + for item in tostring(d.types or ""):gmatch("[^/]+") do + if normalized(item:match("^%s*(.-)%s*$")) == normalized(wanted) then return true end + end + return false + end + if field == "move" then + for item in tostring(d.moves or ""):gmatch("[^,]+") do + if normalized(item:match("^%s*(.-)%s*$")) == normalized(wanted) then return true end + end + return false + end + local value = field == "game" and entry.version or d[field] + return value ~= nil and normalized(value) == normalized(wanted) +end +local function ruleMatches(entry, rule) + for _, condition in ipairs(rule.conditions) do + local hit = matches(entry, condition) + if rule.match == "any" and hit then return true end + if rule.match == "all" and not hit then return false end + end + return rule.match == "all" +end +local function sortValue(entry, field) + local d = entry.display + if field == "species" then + local national = tonumber(d.national) + return national and national > 0 and national or nil + end + if field == "type" then return d.types and d.types ~= "" and normalized(d.types) or nil end + if field == "shiny" or field == "egg" then return d[field] and 1 or 0 end + if field == "level" or field == "friendship" then return tonumber(d[field]) end + return d[field] and d[field] ~= "" and normalized(d[field]) or nil +end +local function compare(a, b, config) + local av, bv = sortValue(a.entry, config.sort), sortValue(b.entry, config.sort) + if av == nil or bv == nil then + if av ~= bv then return av ~= nil end + elseif av ~= bv then + if config.descending then return av > bv end + return av < bv + end + for _, key in ipairs({ "species", "name", "level" }) do + av, bv = sortValue(a.entry, key), sortValue(b.entry, key) + if av == nil or bv == nil then + if av ~= bv then return av ~= nil end + elseif av ~= bv then return av < bv end + end + return a.box < b.box or a.box == b.box and a.slot < b.slot +end + +function Organizer.warehouse(state) + local layout = { count = Store.BOXES, capacity = Store.SLOTS, rows = {}, names = {} } + for b, box in ipairs(state.boxes) do + layout.names[b] = box.name + for slot = 1, Store.SLOTS do + if box.mons[slot] then layout.rows[#layout.rows + 1] = { box = b, slot = slot, entry = box.mons[slot] } end + end + end + return layout +end +function Organizer.game(save, version) + if not GameVersion.VERSIONS[version] then return nil, "Choose a supported game." end + local generation = GameVersion.generation(version) + if not Catalog.get(version).ready then return nil, "Import this game's ROM before organizing its PC." end + local layout = { count = generation == 1 and 12 or 14, capacity = generation == 3 and 30 or 20, + rows = {}, names = {}, compact = generation < 3 } + local boxes = Records.boxes(save, generation) + if type(boxes) ~= "table" then return nil, "This save's PC could not be read." end + for b, box in pairs(boxes) do + if not integer(b, 1, layout.count) or type(box) ~= "table" then + return nil, "This save has a PC box this tool cannot represent." + end + end + for b = 1, layout.count do + local list = Records.list(save, generation, b) + if type(list) ~= "table" then return nil, "A PC box could not be read." end + local seen = {} + for key, mon in pairs(list) do + local slot = tonumber(key) + if not integer(slot, 1, layout.capacity) or type(mon) ~= "table" or seen[slot] then + return nil, "A PC record is invalid or appears twice in one slot." + end + seen[slot] = true + end + local box = boxes[b] + layout.names[b] = generation == 3 and box and box.name or save.boxNames and save.boxNames[b] or "BOX " .. b + for slot = 1, layout.capacity do + local mon = list[slot] or list[tostring(slot)] + if mon then layout.rows[#layout.rows + 1] = { box = b, slot = slot, + entry = { version = version, generation = generation, mon = mon, display = Catalog.describe(version, mon) } } end + end + end + return layout +end + +function Organizer.analyze(layout, config) + local valid, why = Organizer.validate(config, layout.count) + if not valid then return nil, why end + local counts, reserved, rest = {}, {}, 0 + for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do + counts[i] = 0 + for b = rule.firstBox, rule.lastBox do reserved[b] = true end + end + for _, row in ipairs(layout.rows) do + local found + for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do + if ruleMatches(row.entry, rule) then counts[i], found = counts[i] + 1, true; break end + end + if not found then rest = rest + 1 end + end + local free = 0 + for b = 1, layout.count do if not reserved[b] then free = free + 1 end end + return { ruleCounts = counts, remaining = rest, reserved = reserved, freeBoxes = free, total = #layout.rows } +end + +function Organizer.plan(layout, config) + local valid, why = Organizer.validate(config, layout.count) + if not valid then return nil, why end + local boxes, groups, rest, reserved = {}, {}, {}, {} + local report = { total = #layout.rows, moved = 0, ruleCounts = {}, boxes = {}, assignments = {} } + for b = 1, layout.count do + boxes[b] = {} + report.boxes[b] = { name = layout.names[b], before = 0, after = 0, capacity = layout.capacity, moved = 0 } + end + for i, rule in ipairs(config.mode == "rules" and config.rules or {}) do + groups[i], report.ruleCounts[i] = {}, 0 + for b = rule.firstBox, rule.lastBox do reserved[b] = true end + end + for _, row in ipairs(layout.rows) do + report.boxes[row.box].before = report.boxes[row.box].before + 1 + local chosen + if config.mode == "rules" then + for i, rule in ipairs(config.rules) do + if ruleMatches(row.entry, rule) then chosen = i; break end + end + end + if chosen then + groups[chosen][#groups[chosen] + 1] = row + report.ruleCounts[chosen] = report.ruleCounts[chosen] + 1 + elseif config.mode == "rules" and config.fallback == "keep" + or config.mode == "sort" and config.box and row.box ~= config.box then + boxes[row.box][row.slot] = row + else rest[#rest + 1] = row end + end + local function place(rows, firstBox, lastBox, skipReserved) + table.sort(rows, function(a, b) return compare(a, b, config) end) + local index = 1 + for b = firstBox, lastBox do + if not skipReserved or not reserved[b] then + for slot = 1, layout.capacity do + if not boxes[b][slot] and rows[index] then boxes[b][slot], index = rows[index], index + 1 end + end + end + end + return index > #rows + end + if config.mode == "rules" then + for i, rule in ipairs(config.rules) do + if not place(groups[i], rule.firstBox, rule.lastBox) then + return nil, "Rule " .. i .. " needs more room in its destination. Widen its box range." + end + end + end + if not place(rest, config.box or 1, config.box or layout.count, config.mode == "rules") then + return nil, "The remaining Pokémon need more room outside the rule boxes. Keep their boxes or free a destination." + end + for b = 1, layout.count do + if layout.compact and (config.mode == "rules" or not config.box or config.box == b) then + local dense = {} + for slot = 1, layout.capacity do if boxes[b][slot] then dense[#dense + 1] = boxes[b][slot] end end + boxes[b] = dense + end + for slot = 1, layout.capacity do + local row = boxes[b][slot] + if row then + report.boxes[b].after = report.boxes[b].after + 1 + local moved = row.box ~= b or row.slot ~= slot + if moved then report.moved, report.boxes[b].moved = report.moved + 1, report.boxes[b].moved + 1 end + report.assignments[#report.assignments + 1] = { fromBox = row.box, fromSlot = row.slot, + box = b, slot = slot, entry = row.entry, moved = moved } + end + end + end + return report, boxes +end + +function Organizer.applyWarehouse(state, config) + local report, boxes = Organizer.plan(Organizer.warehouse(state), config) + if not report then return nil, boxes end + local nextState = Store.copy(state) + for b = 1, Store.BOXES do + nextState.boxes[b].mons = {} + for slot, row in pairs(boxes[b]) do nextState.boxes[b].mons[slot] = Store.copy(row.entry) end + end + nextState.revision = state.revision + 1 + return nextState, report +end +function Organizer.applyGame(save, version, config) + local layout, why = Organizer.game(save, version) + if not layout then return nil, why end + local valid, err = Organizer.validate(config, layout.count) + if not valid then return nil, err end + if config.mode == "rules" then + for _, rule in ipairs(config.rules) do + for _, condition in ipairs(rule.conditions) do + if not Organizer.available(condition.field, version) then + return nil, GameVersion.info(version).label .. "'s Game PC doesn't record " + .. Organizer.FIELD_LABELS[condition.field] .. ". Choose another condition." + end + end + end + end + local report, boxes = Organizer.plan(layout, config) + if not report then return nil, boxes end + local nextSave, generation = Store.copy(save), GameVersion.generation(version) + if generation == 3 then + nextSave.storage = nextSave.storage or { currentBox = 1, boxes = {} } + nextSave.storage.boxes = nextSave.storage.boxes or {} + for b = 1, layout.count do + if config.mode == "rules" or not config.box or config.box == b then + local box = nextSave.storage.boxes[b] or { name = layout.names[b], wallpaper = 0 } + box.mons = {}; nextSave.storage.boxes[b] = box + for slot, row in pairs(boxes[b]) do box.mons[slot] = Store.copy(row.entry.mon) end + end + end + else + nextSave.boxes, nextSave.box = Records.boxes(nextSave, generation), nil + for b = 1, layout.count do + if config.mode == "rules" or not config.box or config.box == b then + nextSave.boxes[b] = {} + for slot, row in pairs(boxes[b]) do nextSave.boxes[b][slot] = Store.copy(row.entry.mon) end + end + end + end + return nextSave, report +end +return Organizer diff --git a/src/box/Records.lua b/src/box/Records.lua new file mode 100644 index 00000000..448508c3 --- /dev/null +++ b/src/box/Records.lua @@ -0,0 +1,194 @@ +local Store = require("src.box.Store") +local Records = {} + +function Records.boxes(save, generation) + if generation == 3 then + return save.storage and save.storage.boxes or {} + end + return save.boxes or (save.box and { save.box }) or {} +end + +function Records.list(save, generation, box) + local row = Records.boxes(save, generation)[box] + if type(row) ~= "table" then return {} end + return generation == 3 and (row.mons or {}) or row +end + +function Records.at(save, generation, ref) + if type(ref) ~= "table" then return nil end + local list = ref.where == "party" and (save.party or {}) + or Records.list(save, generation, ref.box) + return list[ref.index] or list[tostring(ref.index)] +end + +function Records.candidates(save, generation) + local out = {} + for i, mon in ipairs(save.party or {}) do + out[#out + 1] = { where = "party", index = i, mon = mon, source = "Party" } + end + local count, size = generation == 1 and 12 or 14, generation == 3 and 30 or 20 + for b = 1, count do + local row = Records.boxes(save, generation)[b] + local name = generation == 3 and row and row.name + or save.boxNames and save.boxNames[b] or "BOX " .. b + for i = 1, size do + local mon = Records.list(save, generation, b)[i] + or Records.list(save, generation, b)[tostring(i)] + if type(mon) == "table" then + out[#out + 1] = { where = "box", box = b, index = i, mon = mon, source = name } + end + end + end + return out +end + +function Records.key(ref) + return table.concat({ ref.where or "box", ref.box or 0, ref.index or ref.slot or 0 }, ":") +end + +function Records.identity(generation, mon) + if generation == 3 then + if type(mon.personality) ~= "number" then return nil end + return "3|" .. mon.personality .. "|" .. tostring(mon.otId or 0) .. "|" .. tostring(mon.otSecretId or 0) + end + if type(mon.dvs) ~= "table" or mon.otId == nil then return nil end + local dvs = mon.dvs + local ot = mon.otName or mon.ot + if ot == nil then ot = require("src.core.TrainerIdentity").monName(mon) end + return table.concat({ generation, mon.otId, ot or "", + dvs.attack or 0, dvs.defense or 0, dvs.speed or 0, dvs.special or 0 }, "|") +end + +local function catalog(version) + return require("src.box.Catalog").get(version) +end + +function Records.enterBox(version, generation, mon) + if type(mon) ~= "table" then return mon end + if generation == 2 then + require("src.core.gen2.Boxes").enterBox(mon, catalog(version)) + elseif generation == 3 then + pcall(require("src.core.game3.storage").fullHealMon, mon, { version = version }) + end + return mon +end + +function Records.leaveBox(version, generation, mon) + if type(mon) ~= "table" then return mon end + if generation == 1 then + local data = catalog(version) + require("src.pokemon.Stats").ensure(data.pokemon and data.pokemon[mon.species], mon) + elseif generation == 2 then + mon.status, mon.statusTurns = nil, nil + if mon.isEgg then mon.hp = 0 else mon.hp = mon.maxHp or mon.hp end + end + return mon +end + +function Records.registerReceived(save, generation, mon) + if mon.isEgg then return end + if generation == 3 then + save.dex = save.dex or {} + require("src.core.game3.dex").handleSetPokedexFlag(save.dex, mon.species, true, mon.personality) + else + save.pokedex = save.pokedex or {} + local dex = save.pokedex + dex.seen = dex.seen or {} + local key = generation == 2 and "caught" or "owned" + dex[key] = dex[key] or {} + dex.seen[mon.species], dex[key][mon.species] = true, true + if generation == 2 and mon.species == "UNOWN" then + local Unown = require("src.core.gen2.Unown") + local letter = Unown.monLetter(mon) + save.unownDex = save.unownDex or {} + local found = false + for _, value in ipairs(save.unownDex) do if value == letter then found = true end end + if letter and not found then save.unownDex[#save.unownDex + 1] = letter end + end + end +end + +function Records.remove(save, generation, refs) + local nextSave = Store.copy(save) + local seen, party = {}, false + for _, ref in ipairs(refs) do + local key = Records.key(ref) + if seen[key] or not Records.at(nextSave, generation, ref) then return nil, "A selected Pokémon is missing." end + seen[key] = true + local mon = Records.at(nextSave, generation, ref) + local mail = tonumber(mon.mail) + if generation == 3 and mail and mail ~= 255 then return nil, "Remove held mail before depositing." end + if generation == 2 and (require("src.core.gen2.Mail").monHoldsMail(mon) + or ref.where == "party" and nextSave.mail and nextSave.mail.party + and nextSave.mail.party[ref.index]) then return nil, "Remove held mail before depositing." end + local list + if ref.where == "party" then list, party = nextSave.party, true + else + if generation ~= 3 and not nextSave.boxes then + nextSave.boxes = { nextSave.box or {} }; nextSave.box = nil + end + list = Records.list(nextSave, generation, ref.box) + end + list[ref.index], list[tostring(ref.index)] = nil, nil + end + local function compact(list) + local keys, values = {}, {} + for key, value in pairs(list) do + if type(value) == "table" then keys[#keys + 1] = tonumber(key) end + end + table.sort(keys) + for _, key in ipairs(keys) do values[#values + 1] = list[key] or list[tostring(key)] end + for key in pairs(list) do list[key] = nil end + for i, value in ipairs(values) do list[i] = value end + return keys + end + if party then + local oldSlots = compact(nextSave.party) + if generation == 2 and nextSave.mail and type(nextSave.mail.party) == "table" then + local oldMail, nextMail = nextSave.mail.party, {} + for index, oldIndex in ipairs(oldSlots) do nextMail[index] = oldMail[oldIndex] end + nextSave.mail.party = nextMail + end + local healthy = 0 + for _, mon in ipairs(nextSave.party) do + if not mon.isEgg and (tonumber(mon.hp) or 0) > 0 then healthy = healthy + 1 end + end + if healthy == 0 then return nil, "Keep at least one Pokémon able to battle in the party." end + end + if generation < 3 then + for _, list in pairs(nextSave.boxes or {}) do compact(list) end + end + return nextSave +end + +function Records.insert(save, generation, box, entries) + local count, capacity = generation == 1 and 12 or 14, generation == 3 and 30 or 20 + if type(box) ~= "number" or box < 1 or box > count or box ~= math.floor(box) then + return nil, "Choose a valid destination PC box." + end + local nextSave = Store.copy(save) + local boxes + if generation == 3 then + nextSave.storage = nextSave.storage or { currentBox = 1, boxes = {} } + nextSave.storage.boxes = nextSave.storage.boxes or {} + boxes = nextSave.storage.boxes + boxes[box] = boxes[box] or { name = "BOX " .. box, mons = {} } + boxes[box].mons = boxes[box].mons or {} + else + nextSave.boxes = nextSave.boxes or { nextSave.box or {} } + nextSave.box = nil + boxes = nextSave.boxes + boxes[box] = boxes[box] or {} + end + local list = generation == 3 and boxes[box].mons or boxes[box] + for _, entry in ipairs(entries) do + local free + for i = 1, capacity do if not (list[i] or list[tostring(i)]) then free = i; break end end + if not free then return nil, "That PC box does not have enough room." end + list[free] = Store.copy(entry.mon) + Records.registerReceived(nextSave, generation, list[free]) + end + return nextSave +end + +return Records diff --git a/src/box/Search.lua b/src/box/Search.lua new file mode 100644 index 00000000..a54bd681 --- /dev/null +++ b/src/box/Search.lua @@ -0,0 +1,66 @@ +local Search = {} +local FOLD = { ["É"]="e",["é"]="e",["È"]="e",["è"]="e",["Ê"]="e",["ê"]="e", + ["À"]="a",["à"]="a",["Á"]="a",["á"]="a",["Ä"]="a",["ä"]="a", + ["Í"]="i",["í"]="i",["Ï"]="i",["ï"]="i",["Ó"]="o",["ó"]="o",["Ö"]="o",["ö"]="o", + ["Ú"]="u",["ú"]="u",["Ü"]="u",["ü"]="u",["Ñ"]="n",["ñ"]="n",["Ç"]="c",["ç"]="c" } +function Search.normalize(value) + return (tostring(value or ""):gsub("[%z\1-\127\194-\244][\128-\191]*",function(glyph) + return FOLD[glyph] or glyph:lower() + end)) +end + +function Search.compile(query) + local terms = {} + local token, quoted = "", false + for ch in Search.normalize(query):gmatch(".") do + if ch == '"' then quoted = not quoted + elseif ch:match("%s") and not quoted then + if token ~= "" then terms[#terms + 1], token = token, "" end + else token = token .. ch end + end + if token ~= "" then terms[#terms + 1] = token end + return function(entry, boxName) + if #terms == 0 then return true end + local d = entry.display + local fields = { name = d.name, species = d.species, dex = d.national, + type = d.types, move = d.moves, item = d.item, game = entry.version, box = boxName, + trainer = d.trainer, gender = d.gender, nature = d.nature, ability = d.ability, + location = d.location, tag = entry.tags } + local all = Search.normalize(table.concat({ tostring(d.name or ""), tostring(d.species or ""), + tostring(d.national or ""), tostring(d.types or ""), tostring(d.moves or ""), + tostring(d.item or ""), tostring(d.level or ""), entry.version, tostring(boxName or ""), + tostring(d.trainer or ""), tostring(d.nature or ""), tostring(d.ability or ""), + tostring(d.gender or ""), tostring(entry.tags or "") }, " ")) + for _, raw in ipairs(terms) do + local term, negate = raw, false + if term:sub(1, 1) == "-" then term, negate = term:sub(2), true end + local matched + local property, operator, number = term:match("^(%a+)([<>=]+)(%d+)$") + local numeric = { level = d.level, lv = d.level, hp = d.hp, attack = d.attack, + defense = d.defense, speed = d.speed, special = d.special, spatk = d.spAtk, + spdef = d.spDef, friendship = d.friendship } + if property and numeric[property] ~= nil then + local value, wanted = tonumber(numeric[property]) or 0, tonumber(number) + matched = operator == "<" and value < wanted or operator == ">" and value > wanted + or operator == "<=" and value <= wanted or operator == ">=" and value >= wanted + or operator == "=" and value == wanted + elseif term == "shiny" then matched = d.shiny == true + elseif term == "egg" then matched = d.egg == true + elseif term:match("^mark:") then + local mark = ({ circle = 1, square = 2, triangle = 4, heart = 8 })[term:sub(6)] + local value = tonumber(entry.generation == 3 and entry.mon.markings or entry.markings) or 0 + matched = mark ~= nil and math.floor(value / mark) % 2 == 1 + else + local key, value = term:match("^(%a+):(.+)$") + local text = key and fields[key] + if key == "gender" and text ~= nil then matched = Search.normalize(text) == value + elseif text ~= nil then matched = Search.normalize(text):find(value, 1, true) ~= nil + else matched = all:find(term, 1, true) ~= nil end + end + if (matched and negate) or (not matched and not negate) then return false end + end + return true + end +end + +return Search diff --git a/src/box/Service.lua b/src/box/Service.lua new file mode 100644 index 00000000..8da4cba7 --- /dev/null +++ b/src/box/Service.lua @@ -0,0 +1,481 @@ +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local Serializer = require("src.core.SaveSerializer") +local Store = require("src.box.Store") +local Transaction = require("src.box.Transaction") +local Records = require("src.box.Records") +local Catalog = require("src.box.Catalog") + +local Service = {} +Service.__index = Service + +local function sourceCart(source) + return source.cartId or type(source.path) == "string" and source.path:match("^saves/cart_([%w%._%-]+)/slot%d+%.lua$") or nil +end + +function Service.open(fs) + fs = fs or SaveData.persistenceFs() + if not fs then return nil, "Save storage is unavailable." end + local ok, why, notice = Transaction.recover(fs) + if not ok then return nil, why end + local state, err, body = Store.load(fs) + if not state then return nil, err end + local repaired, changed = pcall(require("src.save_convert.Gen3Save").repairJapaneseNames, state) + if body and repaired and changed then + local after = Serializer.encode(state) + if Transaction.commit(fs, { { path = Store.PATH, before = body, after = after } }) then body = after end + end + for _, box in ipairs(state.boxes) do + for _, entry in pairs(box.mons) do entry.display = Catalog.describe(entry.version, entry.mon) end + end + return setmetatable({ fs = fs, state = state, body = body, recoveryNotice = notice }, Service) +end + +function Service.sources() + local out = {} + for _, version in ipairs(GameVersion.ORDER) do + for _, slot in ipairs(SaveData.listSlots(version)) do + if slot.exists then + out[#out + 1] = { version = version, slotId = slot.id, + path = "saves/" .. version .. "/" .. slot.id .. ".lua", + label = GameVersion.info(version).label .. " · " .. tostring(slot.name or slot.id) } + end + end + end + for _, cartId in ipairs(SaveData.cartsWithSlots()) do + for _, slot in ipairs(SaveData.listCartSlots(cartId)) do + if slot.exists and not SaveData.slotSealBroken(cartId, slot.id) then + local body = SaveData.readCartSlotSource(cartId, slot.id) + local save = body and Serializer.decode(body) + if type(save) == "table" and GameVersion.VERSIONS[save.version] then + out[#out + 1] = { version = save.version, slotId = slot.id, cartId = cartId, + path = "saves/cart_" .. cartId .. "/" .. slot.id .. ".lua", + label = cartId .. " · " .. tostring(slot.name or slot.id) } + end + end + end + end + return out +end + +function Service:read(source) + if type(source) ~= "table" or not GameVersion.VERSIONS[source.version] then return nil, "Choose a save." end + if source.cartId and SaveData.slotSealBroken(source.cartId, source.slotId) then + return nil, "This save's seal is broken." + end + local checked, pending = pcall(require("src.online.Trade").pendingSentAt, source.path) + if not checked then return nil, "Online trade status could not be read." end + if #pending > 0 then return nil, "Finish this save's pending online trade before using Box." end + local body = self.fs.read(source.path) + local save, why + if body then save, why = Serializer.decode(body) end + if type(save) ~= "table" then return nil, why or "That save could not be read." end + if save.version and save.version ~= source.version then return nil, "The save's game does not match." end + if save.generation and save.generation ~= GameVersion.generation(source.version) then + return nil, "The save's generation does not match." + end + if GameVersion.generation(source.version) == 3 then pcall(require("src.save_convert.Gen3Save").repairJapaneseNames, save) end + return save, nil, body +end + +function Service:commit(nextState, source, before, nextSave) + if source then + nextSave.meta = type(nextSave.meta) == "table" and nextSave.meta or {} + local options = SaveData.loadOptions(self.fs) + local cart = sourceCart(source) + local scope = cart and "cart_" .. cart or source.version + local mapped = options.playthroughIds and options.playthroughIds[scope] + local id = nextSave.meta.playthroughId or mapped and mapped[source.slotId] or SaveData.newPlaythroughId() + nextSave.meta.playthroughId = id + if cart then nextSave.meta.cartId = cart end + nextSave.meta.savedAt = os.time() + nextState.syncMembers = nextState.syncMembers or {} + nextState.syncMembers[source.version .. "/" .. id] = { version = source.version, + playthroughId = id, path = source.path, cartId = cart, slotId = source.slotId } + end + nextState.savedAt = os.time() + local valid, why = Store.validate(nextState) + if not valid then return nil, why end + local after = Serializer.encode(nextState) + local jobs = { { path = Store.PATH, before = self.body, after = after } } + if source then + jobs[#jobs + 1] = { path = source.path, before = before, after = Serializer.encode(nextSave) } + end + local ok, err = Transaction.commit(self.fs, jobs) + if not ok then return nil, err end + self.state, self.body = nextState, after + return true +end + +function Service:audit(source, refs, kind) + if type(refs) ~= "table" or type(source) ~= "table" then return {} end + local save = kind == "pc" and self:read(source) or nil + if kind == "pc" and not save then return {} end + local generation = GameVersion.generation(source.version) + local out = {} + for _, ref in ipairs(refs) do + local mon, version + if kind == "pc" then + mon, version = Records.at(save, generation, ref), source.version + else + local entry = Store.at(self.state, ref.box, ref.slot) + if entry and entry.generation == generation then mon, version = entry.mon, source.version end + end + if mon then + local fatal, fixes = Catalog.check(version, mon) + if fatal then return {} end + if #fixes > 0 then out[#out + 1] = { name = Catalog.describe(version, mon).name, fixes = fixes } end + end + end + return out +end + +function Service:deposit(source, refs, box, expectedBody, repair) + if type(refs) ~= "table" or #refs == 0 then return nil, "Select Pokémon to deposit." end + local save, why, body = self:read(source) + if not save then return nil, why end + if expectedBody and body ~= expectedBody then return nil, "That save changed. Reload before transferring." end + local generation = GameVersion.generation(source.version) + local nextSave, err = Records.remove(save, generation, refs) + if not nextSave then return nil, err end + local nextState = Store.copy(self.state) + for _, ref in ipairs(refs) do + local slot = Store.free(nextState, box) + if not slot then return nil, "That Box does not have enough room." end + local mon = Records.at(save, generation, ref) + if not mon then return nil, "A selected Pokémon is missing." end + if repair then + local fixed, fixWhy = Catalog.repair(source.version, mon) + if not fixed then return nil, fixWhy end + mon = fixed + end + if ref.where == "party" then mon = Records.enterBox(source.version, generation, Store.copy(mon)) end + local legal, why = Catalog.compatible({ generation = generation, mon = mon }, source.version) + if not legal then return nil, why end + local identity, returned = Records.identity(generation, mon), nil + if identity then + local matches = {} + for id, departure in pairs(nextState.departures or {}) do + if departure.identity == identity and departure.entry.generation == generation then matches[#matches + 1] = id end + end + if #matches == 1 then returned = matches[1] end + end + local entry = returned and Store.copy(nextState.departures[returned].entry) or { id = nextState.nextId } + entry.version, entry.generation, entry.slotId, entry.cartId = source.version, generation, source.slotId, sourceCart(source) + entry.mon, entry.display = Store.copy(mon), Catalog.describe(source.version, mon) + if returned then nextState.departures[returned] = nil else nextState.nextId = nextState.nextId + 1 end + if generation == 3 then entry.depositorId = require("src.box.Gifts").trainerId(save) end + nextState.boxes[box].mons[slot] = entry + end + nextState.revision = nextState.revision + 1 + require("src.box.Gifts").recordProgress(nextState, source, nextSave) + return self:commit(nextState, source, body, nextSave) +end + +function Service:withdraw(source, refs, pcBox, repair) + if type(refs) ~= "table" or #refs == 0 then return nil, "Select Pokémon to withdraw." end + local save, why, body = self:read(source) + if not save then return nil, why end + local nextState, entries, ids = Store.copy(self.state), {}, {} + for _, ref in ipairs(refs) do + local entry = Store.at(nextState, ref.box, ref.slot) + if not entry or ids[entry.id] then return nil, "A selected Box Pokémon is missing." end + if entry.generation ~= GameVersion.generation(source.version) then + local converted, convertWhy = require("src.box.Migration").convert(entry, source.version) + if not converted then return nil, tostring(entry.display and entry.display.name or "This Pokémon") .. ": " .. tostring(convertWhy) end + local snapshot = Store.copy(entry); snapshot.archives = nil + entry.archives = entry.archives or {}; entry.archives[#entry.archives + 1] = snapshot + entry.version, entry.generation, entry.mon = source.version, GameVersion.generation(source.version), converted + entry.display, entry.gciRaw, entry.gciOriginal = Catalog.describe(source.version, converted), nil, nil + end + if repair and entry.generation == GameVersion.generation(source.version) then + local fixed, fixWhy = Catalog.repair(source.version, entry.mon) + if not fixed then return nil, fixWhy end + entry.mon, entry.display = fixed, Catalog.describe(entry.version, fixed) + end + local ok, err = Catalog.compatible(entry, source.version) + if not ok then return nil, err end + ids[entry.id] = true + entries[#entries + 1] = entry + nextState.departures = nextState.departures or {} + nextState.departures[entry.id] = { entry = Store.copy(entry), identity = Records.identity(entry.generation, entry.mon), path = source.path } + nextState.boxes[ref.box].mons[ref.slot] = nil + end + local nextSave, err = Records.insert(save, GameVersion.generation(source.version), pcBox, entries) + if not nextSave then return nil, err end + nextState.revision = nextState.revision + 1 + return self:commit(nextState, source, body, nextSave) +end + +function Service:movePC(source, refs, target, expectedBody) + if type(refs) ~= "table" or #refs == 0 then return nil, "Select Pokémon to move." end + local save, why, body = self:read(source) + if not save then return nil, why end + if expectedBody and body ~= expectedBody then return nil, "That save changed. Reload before moving." end + local generation = GameVersion.generation(source.version) + local count, capacity = generation == 1 and 12 or 14, generation == 3 and 30 or 20 + local function whole(n, hi) return type(n) == "number" and n == math.floor(n) and n >= 1 and n <= hi end + if type(target) ~= "table" or target.where ~= "party" + and not (whole(target.box, count) and whole(target.index, capacity)) then + return nil, "Choose a valid destination PC box." + end + local mons = {} + for i, ref in ipairs(refs) do + local mon = Records.at(save, generation, ref) + if not mon then return nil, "A selected Pokémon is missing." end + mons[i] = Store.copy(mon) + if target.where == "party" and ref.where ~= "party" then + Records.leaveBox(source.version, generation, mons[i]) + elseif target.where ~= "party" and ref.where == "party" then + Records.enterBox(source.version, generation, mons[i]) + end + end + local nextSave, err = Records.remove(save, generation, refs) + if not nextSave then return nil, err end + if target.where == "party" then + nextSave.party = nextSave.party or {} + if #nextSave.party + #mons > 6 then return nil, "The party is full." end + for _, mon in ipairs(mons) do + if generation == 3 and (mon.level == nil or mon.hp == nil or mon.maxHp == nil or mon.attack == nil) then + local d = Catalog.describe(source.version, mon) + if not d.hp then return nil, "Import complete species data before moving this Pokémon to the party." end + mon.level, mon.maxHp, mon.hp = d.level, d.hp, d.hp + for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do mon[key] = d[key] end + end + nextSave.party[#nextSave.party + 1] = mon + if generation == 2 and type(nextSave.mail) == "table" and type(nextSave.mail.party) == "table" then + nextSave.mail.party[#nextSave.party] = nil + end + end + elseif generation == 3 then + nextSave.storage = nextSave.storage or { currentBox = 1, boxes = {} } + nextSave.storage.boxes = nextSave.storage.boxes or {} + local box = nextSave.storage.boxes[target.box] or { name = "BOX " .. target.box, mons = {} } + nextSave.storage.boxes[target.box] = box + box.mons = box.mons or {} + for _, mon in ipairs(mons) do + local free + for step = 0, 29 do + local i = (target.index - 1 + step) % 30 + 1 + if not (box.mons[i] or box.mons[tostring(i)]) then free = i; break end + end + if not free then return nil, "That PC box does not have enough room." end + box.mons[free] = mon + end + else + if not nextSave.boxes then nextSave.boxes = { nextSave.box or {} }; nextSave.box = nil end + local list = nextSave.boxes[target.box] or {} + nextSave.boxes[target.box] = list + if #list + #mons > 20 then return nil, "That PC box does not have enough room." end + local at = math.min(target.index, #list + 1) + for i, mon in ipairs(mons) do table.insert(list, at + i - 1, mon) end + end + return self:commit(Store.copy(self.state), source, body, nextSave) +end + +function Service:move(fromBox, fromSlot, toBox, toSlot) + local state, why = Store.move(self.state, fromBox, fromSlot, toBox, toSlot) + if not state then return nil, why end + return self:commit(state) +end + +function Service:rename(box, name) + local state, why = Store.rename(self.state, box, name) + if not state then return nil, why end + return self:commit(state) +end + +function Service:previewOrganize(target, source, config) + if self.fs.read(Store.PATH) ~= self.body then return nil, "Box changed. Reload before organizing." end + local Organizer = require("src.box.Organizer") + local result, report, body + if target == "box" then + result, report = Organizer.applyWarehouse(self.state, config) + elseif target == "pc" then + local save, why + save, why, body = self:read(source) + if not save then return nil, why end + result, report = Organizer.applyGame(save, source.version, config) + else return nil, "Choose Box storage or Game PC." end + if not result then return nil, report end + return { target = target, source = target == "pc" and Store.copy(source) or nil, + config = Store.copy(config), boxBefore = self.body, gameBefore = body, report = report } +end + +function Service:applyOrganize(preview) + if type(preview) ~= "table" or type(preview.config) ~= "table" then return nil, "Preview the arrangement first." end + if preview.boxBefore ~= self.body or self.fs.read(Store.PATH) ~= self.body then + return nil, "Box changed since the preview. Preview it again." + end + local Organizer = require("src.box.Organizer") + local nextState, nextSave, report + if preview.target == "box" then + nextState, report = Organizer.applyWarehouse(self.state, preview.config) + if not nextState then return nil, report end + elseif preview.target == "pc" then + local save, why, body = self:read(preview.source) + if not save then return nil, why end + if body ~= preview.gameBefore then return nil, "The game save changed since the preview. Preview it again." end + nextSave, report = Organizer.applyGame(save, preview.source.version, preview.config) + if not nextSave then return nil, report end + nextState = Store.copy(self.state); nextState.revision = nextState.revision + 1 + else return nil, "Choose Box storage or Game PC." end + if report.moved == 0 then return nil, "Everything is already in place." end + return self:commit(nextState, preview.target == "pc" and preview.source or nil, preview.gameBefore, nextSave) +end + +function Service:saveOrganizer(name, target, config, index) + if type(name) ~= "string" or #name == 0 or #name > 64 or name:find("%c") then + return nil, "Name this preset with 1 to 64 bytes." + end + local valid, why = require("src.box.Organizer").validate(config) + if not valid then return nil, why end + if target ~= "box" and target ~= "pc" then return nil, "Choose Box storage or Game PC." end + local nextState = Store.copy(self.state) + nextState.organizerProfiles = nextState.organizerProfiles or {} + if index == nil then index = #nextState.organizerProfiles + 1 end + if type(index) ~= "number" or index ~= math.floor(index) or index < 1 + or index > 20 or index > #nextState.organizerProfiles + 1 then return nil, "All 20 preset slots are full." end + nextState.organizerProfiles[index] = { name = name, target = target, config = Store.copy(config) } + nextState.revision = nextState.revision + 1 + return self:commit(nextState) +end + +function Service:deleteOrganizer(index) + local profiles = self.state.organizerProfiles or {} + if type(index) ~= "number" or index ~= math.floor(index) or not profiles[index] then return nil, "Choose a saved preset." end + local nextState = Store.copy(self.state) + table.remove(nextState.organizerProfiles, index) + nextState.revision = nextState.revision + 1 + return self:commit(nextState) +end + +function Service:giftProgress(source) + local save, why = self:read(source) + if not save then return nil, why end + return require("src.box.Gifts").progress(self.state, source, save) +end + +function Service:claimEgg(source, seed) + local save, why, body = self:read(source) + if not save then return nil, why end + local Gifts = require("src.box.Gifts") + local progress, err = Gifts.progress(self.state, source, save) + if not progress then return nil, err end + if not progress.eligible then return nil, "The next gift egg has not been unlocked." end + local box, slot + for b = 1, Store.BOXES do + local free = Store.free(self.state, b) + if free then box, slot = b, free; break end + end + if not box then return nil, "Make room in Box storage before claiming this egg." end + local mon, createErr = Gifts.create(source.version, progress.nextAward, seed) + if not mon then return nil, createErr end + local nextState, nextSave = Store.copy(self.state), Store.copy(save) + nextState.boxes[box].mons[slot] = { id = nextState.nextId, version = source.version, generation = 3, + slotId = source.slotId, cartId = source.cartId, depositorId = Gifts.trainerId(save), + mon = mon, display = Catalog.describe(source.version, mon), gift = progress.nextAward } + nextState.nextId, nextState.revision = nextState.nextId + 1, nextState.revision + 1 + Gifts.setFlags(nextSave, progress.flags - progress.flags % 8 + 1 + (progress.nextAward - 1) * 2) + Gifts.recordProgress(nextState, source, nextSave) + return self:commit(nextState, source, body, nextSave) +end + +function Service:importGCI(bytes) + local state, why = require("src.box.GCI").import(self.state, bytes) + if not state then return nil, why end + local ok, err = self:commit(state) + return ok, err or why +end + +function Service:exportGCI() + local bytes, why = require("src.box.GCI").export(self.state) + if not bytes then return nil, why end + local path = "box/exports/pokemon-box-" .. self.state.revision .. (#bytes == 0x76040 and ".gci" or ".sav") + if not self.fs.createDirectory("box/exports") then return nil, "Could not create the export folder." end + local ok, err = self.fs.write(path, bytes) + if not ok or self.fs.read(path) ~= bytes then return nil, err or "The exported file could not be verified." end + return true, path +end + +function Service:saveStage(index, stage) + local state, why = require("src.box.Showcase").save(self.state, index, stage) + if not state then return nil, why end + return self:commit(state) +end + +function Service:exportStage(index, box) + local path, why = require("src.box.Showcase").export(self.state, index, self.fs) + if not path then return nil, why end + if box then return self:theme(box, "Showcase", path, self.state.stages[index].music) end + return true, path +end + +function Service:event(source, id, collect) + local save, why, body = self:read(source) + if not save then return nil, why end + local Events = require("src.box.Events") + local nextSave, err = (collect and Events.collect or Events.receive)(save, id, 2) + if not nextSave then return nil, err end + local nextState = Store.copy(self.state) + nextState.revision = nextState.revision + 1 + return self:commit(nextState, source, body, nextSave) +end + +function Service:migrate(refs, version, preview) + local state, why = require("src.box.Migration").apply(self.state, refs, version, preview) + if not state then return nil, why end + return self:commit(state) +end + +function Service:restoreOriginal(ref, index) + local state, why = require("src.box.Migration").restore(self.state, ref, index) + if not state then return nil, why end + return self:commit(state) +end + +for _, operation in ipairs({ "moveGroup", "arrange", "theme", "mark", "preset" }) do + Service[operation] = function(self, ...) + local state, why = Store[operation](self.state, ...) + if not state then return nil, why end + return self:commit(state) + end +end + +function Service:deployPreset(source, index) + local preset = self.state.presets and self.state.presets[index] + if not preset then return nil, "Choose a saved team." end + local save, why, body = self:read(source) + if not save then return nil, why end + if #(save.party or {}) + #preset.ids > 6 then return nil, "The party needs room for the entire team." end + local nextSave, nextState = Store.copy(save), Store.copy(self.state) + nextSave.party = nextSave.party or {} + for _, id in ipairs(preset.ids) do + local row = Store.find(nextState, id) + if not row then return nil, "A preset member is currently outside Box storage." end + local ok, err = Catalog.compatible(row.entry, source.version) + if not ok then return nil, err end + if row.entry.mon.isEgg then return nil, "Hatch eggs before deploying a team." end + local mon = Store.copy(row.entry.mon) + local d = Catalog.describe(source.version, mon) + if row.entry.generation == 3 then + if not d.hp then return nil, "Import complete species data before deploying this team." end + mon.level, mon.maxHp, mon.hp = d.level, d.hp, d.hp + for _, key in ipairs({ "attack", "defense", "speed", "spAtk", "spDef" }) do mon[key] = d[key] end + elseif next(d.stats) then + mon.stats, mon.maxHp = Store.copy(d.stats), d.hp + mon.hp = math.min(tonumber(mon.hp) or d.hp, d.hp) + if row.entry.generation == 2 then Records.leaveBox(source.version, 2, mon) end + else return nil, "Import complete species data before deploying this team." end + nextSave.party[#nextSave.party + 1] = mon + Records.registerReceived(nextSave, row.entry.generation, mon) + nextState.departures = nextState.departures or {} + nextState.departures[row.entry.id] = { entry = Store.copy(row.entry), + identity = Records.identity(row.entry.generation, mon), path = source.path } + nextState.boxes[row.box].mons[row.slot] = nil + end + nextState.revision = nextState.revision + 1 + return self:commit(nextState, source, body, nextSave) +end + +return Service diff --git a/src/box/Showcase.lua b/src/box/Showcase.lua new file mode 100644 index 00000000..34f66a56 --- /dev/null +++ b/src/box/Showcase.lua @@ -0,0 +1,314 @@ +local Store = require("src.box.Store") +local Theme = require("src.ui.kit.Theme") +local Catalog = require("src.box.Catalog") +local Sprites = require("src.online.OnlineSprites") +local Showcase = {} +Showcase.BACKGROUNDS = { "Forest", "Sky", "Brick", "Sunset", "Ocean", "Midnight", "Meadow", "Lavender", "Sand", "Snow" } +Showcase.PATTERNS = { "Plain", "Checks", "Dots", "Stripes", "Crosses", "Diamonds", "Bricks", "Waves", "Stars", "Grid" } +Showcase.PIECES = { "Platform", "Block", "Steps", "Pillar", "Arch", "Tree", "Flowers", "Rock", "Fence" } +Showcase.MUSIC = { "Silent", "Gentle", "Bright", "Waltz", "March", "Dream", "Waves", "Night", "Playful" } +local COLORS = { Forest={46,92,61},Sky={77,128,173},Brick={112,77,87},Sunset={153,82,64}, + Ocean={36,107,128},Midnight={31,36,66},Meadow={92,128,61},Lavender={110,87,148}, + Sand={166,143,97},Snow={153,171,184} } +local function contains(list,value) for _,v in ipairs(list) do if v==value then return true end end end +local function finite(value,lo,hi) + return type(value)=="number" and value==value and value>=lo and value<=hi +end +function Showcase.new() + return {name="Stage",background="Forest",pattern="Plain",music="Silent",pieces={}} +end +function Showcase.validate(stage, nextId) + if type(stage)~="table" or type(stage.name)~="string" or #stage.name>64 + or not contains(Showcase.BACKGROUNDS,stage.background) or not contains(Showcase.PATTERNS,stage.pattern) + or not contains(Showcase.MUSIC,stage.music) or type(stage.pieces)~="table" or #stage.pieces>1500 then + return nil,"A showcase stage is invalid." + end + for _,piece in ipairs(stage.pieces) do + if type(piece)~="table" or not finite(piece.x,0,1) or not finite(piece.y,0,1) + or not finite(piece.scale,0.25,4) or not finite(piece.rotation,-math.pi,math.pi) + or piece.flip~=nil and type(piece.flip)~="boolean" + or piece.flipY~=nil and type(piece.flipY)~="boolean" then return nil,"A stage placement is invalid." end + if piece.entryId then + if not finite(piece.entryId,1,nextId-1) or piece.entryId%1~=0 then return nil,"A stage Pokémon reference is invalid." end + elseif not contains(Showcase.PIECES,piece.kind) then return nil,"A showcase piece is invalid." end + end + return stage +end +function Showcase.reorder(stage, index, destination) + if not stage.pieces[index] or destination < 1 or destination > #stage.pieces then return nil end + local piece = table.remove(stage.pieces, index) + table.insert(stage.pieces, destination, piece) + return destination +end +local sceneryImage, sceneryRects, sceneryQuads +function Showcase.drawProp(kind, x, y, size) + local g = love and love.graphics + if not g or not g.newImage or not g.newQuad then return false end + if sceneryImage == false then return false end + if not sceneryImage then + local ok, img = pcall(g.newImage, "assets/box/showcase/scenery-v1.png") + if not ok then sceneryImage = false; return false end + sceneryImage = img + sceneryImage:setFilter("nearest", "nearest") + local bytes = love.filesystem.read("assets/box/showcase/scenery-v1.json") + local ok2, data = pcall(function() return bytes and require("src.link.Json").decode(bytes) end) + local rects = ok2 and type(data) == "table" and data.rects + sceneryRects, sceneryQuads = type(rects) == "table" and rects or {}, {} + end + local rect = sceneryRects[kind] + if type(rect) ~= "table" then return false end + for i = 1, 4 do if type(rect[i]) ~= "number" then return false end end + if not sceneryQuads[kind] then sceneryQuads[kind] = g.newQuad(rect[1],rect[2],rect[3],rect[4],sceneryImage:getDimensions()) end + local scale = size / math.max(rect[3],rect[4]) + Theme.col({255,255,255},1) + g.draw(sceneryImage, sceneryQuads[kind], x + (size - rect[3] * scale) / 2, + y + (size - rect[4] * scale) / 2, 0, scale, scale) + return true +end +function Showcase.hit(stage, x, y, w, h, mx, my) + for index = #stage.pieces, 1, -1 do + local p = stage.pieces[index] + local size = math.min(w/8,h/2)*p.scale + local dx,dy = mx-x-p.x*w,my-y-p.y*h + local c,s = math.cos(p.rotation),math.sin(p.rotation) + if math.abs(dx*c+dy*s)<=size/2 and math.abs(-dx*s+dy*c)<=size/2 then return index end + end +end +function Showcase.save(state,index,stage) + if type(index)~="number" or index%1~=0 or index<1 or index>5 then return nil,"Choose one of five stages." end + local valid,why=Showcase.validate(stage,state.nextId) + if not valid then return nil,why end + local nextState=Store.copy(state) + nextState.stages=nextState.stages or {};nextState.stages[index]=Store.copy(stage) + nextState.revision=state.revision+1 + return nextState +end +function Showcase.add(stage,refs,state) + local draft=Store.copy(stage) + for _,ref in ipairs(refs) do + local entry=Store.at(state,ref.box,ref.slot) + if not entry then return nil,"A selected Pokémon is missing." end + if #draft.pieces>=1500 then return nil,"The stage has no more room." end + local index=#draft.pieces + draft.pieces[#draft.pieces+1]={entryId=entry.id,x=0.12+(index%8)*0.105,y=0.28+(math.floor(index/8)%3)*0.25, + scale=1,rotation=0,flip=false} + end + return draft +end +function Showcase.resolve(state,stage) + local out,entries={},{} + for _,box in ipairs(state.boxes) do for _,entry in pairs(box.mons) do entries[entry.id]=entry end end + for _,piece in ipairs(stage.pieces) do + local entry=piece.entryId and entries[piece.entryId] + out[#out+1]={piece=piece,entry=entry,missing=piece.entryId~=nil and entry==nil} + end + return out +end +local musicSource, musicTrack, musicOwner, musicVolume, musicFailed, musicHeld +local function audioSuspended() + local chip=package.loaded["src.core.ChipAudio"] + return chip and chip.isSuspended and chip.isSuspended() or false +end +local function startPending() + if musicSource or not musicTrack or musicTrack=="Silent" then return true end + if musicFailed then return nil,musicFailed end + if audioSuspended() then return "pending" end + local sound,why=require("src.box.ShowcaseMusic").request(musicTrack) + if not sound then + if why=="pending" then return "pending" end + musicFailed="Choose a stage music track." + return nil,musicFailed + end + local ok,source=pcall(love.audio.newSource,sound,"static") + if not ok or not source then + musicFailed="Audio is unavailable." + return nil,musicFailed + end + musicSource,musicHeld=source,false + if musicVolume==nil then musicVolume=require("src.core.SaveData").loadOptions().boxMusicVol end + Showcase.setVolume(musicVolume) + if not pcall(function() musicSource:setLooping(true);musicSource:play() end) then + Showcase.stopMusic() + return nil,"Audio is unavailable." + end + return true +end +function Showcase.stopMusic() + if musicSource then + pcall(musicSource.stop,musicSource) + if musicSource.release then pcall(musicSource.release,musicSource) end + musicSource=nil + elseif musicTrack then + local music=package.loaded["src.box.ShowcaseMusic"] + if music then music.cancel(musicTrack) end + end + musicTrack,musicOwner,musicFailed,musicHeld=nil,nil,nil,nil +end +function Showcase.update() + if musicSource then + local held=audioSuspended() + if held~=musicHeld then + musicHeld=held + if held then pcall(musicSource.pause,musicSource) else pcall(musicSource.play,musicSource) end + end + elseif musicTrack and not musicFailed then startPending() end +end +function Showcase.currentMusic() + Showcase.update() + return musicTrack, musicOwner, musicSource +end +function Showcase.ensureMusic(track, owner) + if musicTrack==track and musicOwner~=owner and not musicSource then + musicOwner=owner + elseif musicTrack~=track or musicOwner~=owner then + Showcase.stopMusic() + if track~="Silent" and not (love and love.sound and love.sound.newSoundData and love.audio) then return nil,"Audio is unavailable." end + musicTrack,musicOwner=track,owner + end + return startPending() +end +function Showcase.setVolume(level) + musicVolume=math.max(0,math.min(7,tonumber(level) or 7)) + if musicSource then musicSource:setVolume(musicVolume/7) end +end +function Showcase.playMusic(stage) return Showcase.ensureMusic(stage.music,"editor") end +function Showcase.boxMusic(state, box) + if not box or box.theme~="Showcase" then return "Silent" end + if box.showcaseMusic then return box.showcaseMusic end + local exported=tonumber(tostring(box.wallpaper):match("^box/showcase/%d*(%d)%.png$")) + local stage=exported and state.stages and state.stages[exported] + return stage and stage.music or "Silent" +end +function Showcase.draw(state,stage,x,y,w,h) + if not (w>0 and h>0) then return end + local color=COLORS[stage.background] or COLORS.Forest + Theme.fillRounded(x,y,w,h,color,1,0) + local g=love.graphics + local sx,sy,sw,sh=g.getScissor() + if sx then + local xx,yy=math.max(x,sx),math.max(y,sy) + g.setScissor(xx,yy,math.max(0,math.min(x+w,sx+sw)-xx),math.max(0,math.min(y+h,sy+sh)-yy)) + else g.setScissor(x,y,w,h) end + local cell=w/20 + Theme.col({255,255,255},0.1) + for row=0,math.ceil(h/cell)-1 do + for col=0,19 do + local xx,yy=x+col*cell,y+row*cell + local pw,ph=math.min(cell,w-col*cell),math.min(cell,h-row*cell) + if stage.pattern=="Checks" and (row+col)%2==0 then g.rectangle("fill",xx,yy,pw,ph) + elseif stage.pattern=="Dots" then g.circle("fill",xx+pw/2,yy+ph/2,cell/8) + elseif stage.pattern=="Stripes" and col%2==0 then g.rectangle("fill",xx,yy,pw/2,ph) + elseif stage.pattern=="Crosses" then + g.line(xx+cell/2,yy+cell*.3,xx+cell/2,yy+cell*.7) + g.line(xx+cell*.3,yy+cell/2,xx+cell*.7,yy+cell/2) + elseif stage.pattern=="Diamonds" then g.polygon("line",xx+pw/2,yy,xx+pw,yy+ph/2,xx+pw/2,yy+ph,xx,yy+ph/2) + elseif stage.pattern=="Bricks" then + g.line(xx,yy,xx+pw,yy) + if (col+row)%2==0 then g.line(xx,yy,xx,yy+ph) end + elseif stage.pattern=="Waves" then g.line(xx,yy+ph/2,xx+pw/2,yy+ph/3,xx+pw,yy+ph/2) + elseif stage.pattern=="Stars" then g.circle("fill",xx+pw/2,yy+ph/2,cell/16);g.line(xx+pw/2,yy+ph/4,xx+pw/2,yy+3*ph/4) + elseif stage.pattern=="Grid" then + g.line(xx,yy,xx+pw,yy);g.line(xx,yy,xx,yy+ph) + end + end + end + for _,row in ipairs(Showcase.resolve(state,stage)) do + local piece=row.piece + local size=math.min(w/8,h/2)*piece.scale + local cx,cy=x+piece.x*w,y+piece.y*h + g.push();g.translate(cx,cy);g.rotate(piece.rotation);g.scale(piece.flip and -1 or 1,piece.flipY and -1 or 1) + if row.entry then + local art=Catalog.art(row.entry) + Theme.col({0,0,0},0.25);g.ellipse("fill",0,size*0.37,size*0.33,size*0.06) + if art then + if not Sprites.drawFront(art,-size/2,-size/2,size) then Sprites.drawIcon(art,-size/2,-size/2,size) end + else + Theme.fillRounded(-size/5,-size/4,size*0.4,size*0.55,{255,255,255},0.5,8) + end + elseif row.missing then + Theme.col({255,255,255},0.65);g.rectangle("line",-size/3,-size/3,size*2/3,size*2/3) + g.line(-size/3,-size/3,size/3,size/3);g.line(size/3,-size/3,-size/3,size/3) + else + Theme.col({217,219,230},1) + if Showcase.drawProp(piece.kind,-size/2,-size/2,size) then + elseif piece.kind=="Platform" then g.rectangle("fill",-size/2,-size/10,size,size/5) + elseif piece.kind=="Block" then g.rectangle("fill",-size/3,-size/3,size*2/3,size*2/3) + elseif piece.kind=="Pillar" then g.rectangle("fill",-size/8,-size/2,size/4,size) + elseif piece.kind=="Arch" then + g.rectangle("fill",-size/2,-size/2,size,size/6) + g.rectangle("fill",-size/2,-size/2,size/6,size);g.rectangle("fill",size/3,-size/2,size/6,size) + else + for step=1,4 do g.rectangle("fill",-size/2+(step-1)*size/4,size/2-step*size/4,size/4,step*size/4) end + end + end + g.pop() + end + if sx then g.setScissor(sx,sy,sw,sh) else g.setScissor() end +end +local function digest(bytes) + local hex + if love and love.data and love.data.hash then + local ok,raw=pcall(love.data.hash,"sha256",bytes) + if ok and type(raw)=="string" then hex=raw:gsub(".",function(c) return string.format("%02x",c:byte()) end) end + end + if not hex then + local bit=require("bit") + local parts={} + for seed=1,3 do + local h=bit.bxor(2166136261,seed*16777619) + for i=1,#bytes do + h=bit.bxor(h,bytes:byte(i)) + h=bit.tobit(h*403+bit.lshift(h,24)) + end + parts[seed]=bit.tohex(h) + end + hex=table.concat(parts) + end + local out={} + for i=0,2 do out[#out+1]=string.format("%08d",tonumber(hex:sub(i*6+1,i*6+6),16)) end + return table.concat(out) +end +function Showcase.export(state,index,fs) + local stage=state.stages and state.stages[index] + if not stage then return nil,"Save the stage before exporting." end + if not love.graphics.newCanvas then return nil,"PNG export requires the running launcher." end + local canvas=love.graphics.newCanvas(1136,432) + love.graphics.push("all") + local ok,err=pcall(function() + love.graphics.setCanvas(canvas);love.graphics.origin();love.graphics.setScissor();love.graphics.clear(0,0,0,0) + Showcase.draw(state,stage,0,0,1136,432) + end) + love.graphics.pop() + if not ok then canvas:release();return nil,tostring(err) end + local data=canvas:newImageData();canvas:release() + local bytes=data:encode("png"):getString();data:release() + local path="box/showcase/"..digest(bytes)..index..".png" + if not fs.createDirectory("box/showcase") then return nil,"Could not create the showcase folder." end + local written,why=fs.write(path,bytes) + if not written or fs.read(path)~=bytes then return nil,why or "Could not verify the exported PNG." end + Showcase.prune(state,fs,path) + return path +end +function Showcase.prune(state,fs,keep) + if not fs.getDirectoryItems or not fs.remove then return 0 end + if fs.getInfo and fs.getInfo(require("src.box.Transaction").PATH) then return 0 end + local used={[keep or ""]=true} + local body=fs.read and fs.read(Store.PATH) + local persisted=body and require("src.core.SaveSerializer").decode(body) + for _,source in ipairs({state,type(persisted)=="table" and persisted or {}}) do + for _,box in ipairs(type(source.boxes)=="table" and source.boxes or {}) do + if type(box)=="table" and box.wallpaper then used[box.wallpaper]=true end + end + end + local ok,items=pcall(fs.getDirectoryItems,"box/showcase") + local removed=0 + for _,name in ipairs(ok and type(items)=="table" and items or {}) do + local path="box/showcase/"..name + if name:match("^%d+%.png$") and not used[path] and fs.remove(path) then + require("src.box.Themes").forget(path) + removed=removed+1 + end + end + return removed +end +return Showcase diff --git a/src/box/ShowcaseMusic.lua b/src/box/ShowcaseMusic.lua new file mode 100644 index 00000000..65264854 --- /dev/null +++ b/src/box/ShowcaseMusic.lua @@ -0,0 +1,478 @@ +local Music = {} + +local RATE = 22050 +local TAU = math.pi * 2 +local sin, exp, floor, abs, max, min, random = math.sin, math.exp, math.floor, math.abs, math.max, math.min, math.random + +local INST = { + flute = { wave = "sine", h2 = 0.25, a = 0.07, d = 0.3, s = 0.75, r = 0.18, vib = 0.14, vibRate = 5 }, + chip = { wave = "pulse", duty = 0.25, a = 0.004, d = 0.1, s = 0.7, r = 0.04, vib = 0.1, vibRate = 6.5, gate = 0.85 }, + chiparp = { wave = "pulse", duty = 0.125, a = 0.002, d = 0.06, s = 0.4, r = 0.02, gate = 0.7 }, + tri = { wave = "tri", a = 0.003, d = 0.08, s = 0.85, r = 0.03, gate = 0.85 }, + accordion = { wave = "pulse", duty = 0.4, detune = 14, a = 0.03, d = 0.12, s = 0.85, r = 0.06, lp = 2800, vib = 0.04, vibRate = 5.5 }, + brass = { wave = "saw", detune = 7, a = 0.03, d = 0.2, s = 0.75, r = 0.08, lp = 2200, vib = 0.07, vibRate = 5.2, gate = 0.9 }, + tuba = { wave = "saw", a = 0.02, d = 0.15, s = 0.7, r = 0.06, lp = 500, gate = 0.7 }, + bell = { wave = "fm", ratio = 3.5, index = 2.2, idxDecay = 0.5, a = 0.002, decay = 1.1, r = 0.8 }, + epiano = { wave = "fm", ratio = 1, index = 1.4, idxDecay = 0.3, a = 0.004, decay = 1.0, r = 0.12 }, + pad = { wave = "tri", detune = 10, a = 0.8, d = 0.6, s = 0.8, r = 0.9, lp = 1500 }, + harp = { wave = "harp", a = 0.002, decay = 0.9, bright = 5, r = 0.3, gate = 2.5 }, + nylon = { wave = "harp", a = 0.003, decay = 1.3, bright = 3, r = 0.25, gate = 2 }, + ocarina = { wave = "tri", a = 0.05, d = 0.2, s = 0.8, r = 0.12, lp = 2400, vib = 0.16, vibRate = 5 }, + muted = { wave = "saw", a = 0.05, d = 0.25, s = 0.65, r = 0.1, lp = 850, vib = 0.12, vibRate = 4.6, gate = 0.9 }, + upright = { wave = "tri", a = 0.004, decay = 0.45, r = 0.05, lp = 700, gate = 0.95 }, + sub = { wave = "sine", a = 0.02, d = 0.3, s = 0.8, r = 0.2 }, + square = { wave = "pulse", duty = 0.5, a = 0.002, d = 0.08, s = 0.5, r = 0.03, lp = 3500, gate = 0.55 }, +} + +local SONGS = { + Gentle = { + bpm = 76, spb = 2, + chords = "F:8 Dm:8 Bb:8 C:8 F:8 Am:8 Bb:4 C:4 F:8", + melody = "a4:2 c5:2 f5:3 e5:1 d5:2 c5:2 a4:4 bb4:2 d5:2 f5:2 d5:2 c5:6 -:2 " + .. "a4:2 c5:2 f5:3 g5:1 a5:2 g5:1 f5:1 e5:4 d5:2 f5:2 e5:2 g5:2 f5:6 -:2", + lead = { inst = "flute", vol = 0.32, pan = 0.1, send = 0.35 }, + parts = { + { role = "chord", inst = "harp", pattern = "12343212", oct = 1, vol = 0.12, pan = -0.35, send = 0.3 }, + { role = "chord", inst = "pad", pattern = "c---c---", vol = 0.05, pan = 0.25 }, + { role = "bass", inst = "sub", pattern = "r---f---", vol = 0.3 }, + }, + echo = { steps = 3, fb = 0.4, mix = 0.35 }, + }, + Bright = { + bpm = 144, spb = 4, + chords = "D:16 A:16 Bm:16 G:16 D:16 A:16 G:8 A:8 D:16", + melody = "f#5:2 a5:2 d6:4 c#6:2 b5:2 a5:4 e5:2 a5:2 c#6:4 b5:2 a5:2 e5:4 " + .. "f#5:2 b5:2 d6:3 c#6:1 b5:2 a5:2 f#5:4 g5:4 f#5:2 e5:2 d5:6 -:2 " + .. "f#5:2 a5:2 d6:4 e6:2 f#6:2 e6:2 d6:2 c#6:4 a5:2 b5:2 c#6:2 e6:2 c#6:2 a5:2 " + .. "b5:2 d6:2 g5:4 a5:2 c#6:2 e6:4 d6:8 -:4 d5:2 e5:2", + lead = { inst = "chip", oct = -1, vol = 0.2, pan = 0.1, send = 0.3 }, + parts = { + { role = "chord", inst = "chiparp", pattern = "1324132413241324", vol = 0.09, pan = -0.3, send = 0.2 }, + { role = "bass", inst = "tri", pattern = "r-.ro.r.r-.ro.r.", vol = 0.35 }, + { role = "drum", pattern = "K...s..kk.k.S...", vol = 0.45 }, + { role = "drum", pattern = "h.h.h.hoh.h.h.h.", vol = 0.22, pan = 0.35 }, + }, + echo = { steps = 3, fb = 0.3, mix = 0.2 }, + }, + Waltz = { + bpm = 132, spb = 2, + chords = "G:6 G:6 C:6 C:6 D7:6 D7:6 G:6 G:6 Em:6 Em:6 Am:6 Am:6 D:6 D7:6 G:12", + melody = "d5:2 g5:2 b5:2 d6:4 b5:2 c6:2 e6:2 c6:2 g5:6 a5:2 c6:2 f#5:2 a5:4 d5:2 " + .. "g5:2 b5:2 d6:2 g6:4 -:2 g6:2 f#6:1 e6:1 b5:2 e6:4 d6:2 c6:2 e6:1 c6:1 a5:2 e5:4 c6:2 " + .. "b5:2 a5:2 f#5:2 a5:2 c6:2 f#5:2 g5:6 -:6", + lead = { inst = "accordion", oct = -1, vol = 0.2, pan = 0.15, send = 0.15 }, + parts = { + { role = "bass", inst = "tuba", pattern = "r-....f-....", vol = 0.35 }, + { role = "chord", inst = "square", pattern = "..c.c.", oct = 1, vol = 0.045, pan = -0.3, send = 0.1 }, + { role = "drum", pattern = "..u.u.", vol = 0.12, pan = 0.4 }, + }, + echo = { steps = 2, fb = 0.25, mix = 0.18 }, + }, + March = { + bpm = 112, spb = 4, + chords = "Bb:16 Eb:16 Bb:16 F:16 Bb:16 Eb:16 F7:16 Bb:16", + melody = "f5:3 f5:1 bb5:4 d6:3 c6:1 bb5:4 g5:3 bb5:1 eb6:4 d6:2 c6:2 bb5:4 " + .. "d6:3 c6:1 bb5:4 f5:3 g5:1 a5:2 bb5:2 c6:8 a5:2 bb5:2 c6:4 " + .. "f5:3 f5:1 bb5:4 d6:3 c6:1 bb5:4 eb6:3 d6:1 c6:2 bb5:2 g5:4 bb5:2 c6:2 " + .. "d6:4 c6:2 a5:2 f5:2 a5:2 c6:2 eb6:2 bb5:8 -:4 f5:3 f5:1", + lead = { inst = "brass", oct = -1, vol = 0.22, pan = 0.1, send = 0.15 }, + parts = { + { role = "bass", inst = "tuba", pattern = "r-......f-......", vol = 0.35 }, + { role = "chord", inst = "brass", pattern = "....c-......c-..", vol = 0.04, pan = -0.3 }, + { role = "drum", pattern = "S..ss.s.S..ss.ss", vol = 0.28, pan = 0.2 }, + { role = "drum", pattern = "k.......k.......", vol = 0.5 }, + }, + echo = { steps = 4, fb = 0.2, mix = 0.15 }, + }, + Dream = { + bpm = 72, spb = 2, + chords = "Ebmaj7:8 F:8 Ebmaj7:8 F:8 Cm7:8 Abmaj7:8 Bbsus4:8 Bb:8", + melody = "g5:2 bb5:2 d6:4 c6:2 a5:2 f5:4 g5:2 bb5:2 d6:2 f6:2 c6:6 -:2 " + .. "eb6:2 d6:2 bb5:4 c6:2 eb6:2 g6:4 f6:4 eb6:4 d6:6 -:2", + lead = { inst = "bell", vol = 0.25, send = 0.5 }, + parts = { + { role = "chord", inst = "pad", pattern = "c-------", vol = 0.07, send = 0.3 }, + { role = "chord", inst = "bell", pattern = "..3...4.", oct = 1, vol = 0.06, pan = -0.5, send = 0.6 }, + { role = "bass", inst = "sub", pattern = "r-------", vol = 0.25 }, + }, + echo = { steps = 3, fb = 0.5, mix = 0.45 }, + }, + Waves = { + bpm = 60, spb = 3, + chords = "A:6 D:6 A:6 E:6 F#m:6 D:6 Bm:6 E:6 A:6 D:6 E:6 A:6", + melody = "e5:3 c#5:2 e5:1 f#5:4 a5:2 e5:3 c#5:2 a4:1 b4:6 c#5:2 e5:1 a5:3 f#5:3 e5:2 d5:1 " + .. "d5:2 c#5:1 b4:3 g#4:3 b4:3 a4:2 c#5:1 e5:3 f#5:2 a5:1 d6:3 c#6:2 b5:1 g#5:3 a5:6", + lead = { inst = "ocarina", vol = 0.28, pan = 0.15, send = 0.35 }, + parts = { + { role = "chord", inst = "nylon", pattern = "123432", vol = 0.13, pan = -0.3, send = 0.2 }, + { role = "bass", inst = "sub", pattern = "r--f--", vol = 0.25 }, + { role = "drum", pattern = "w-----------", vol = 0.35 }, + { role = "drum", pattern = "..r..r", vol = 0.12, pan = 0.5 }, + }, + echo = { steps = 2, fb = 0.35, mix = 0.25 }, + }, + Night = { + bpm = 92, spb = 2, swing = 0.33, + chords = "Am7:8 Dm7:8 G7:8 Cmaj7:8 Fmaj7:8 Bm7b5:8 E7:8 Am7:8", + melody = "e5:2 g5:1 a5:1 c6:2 b5:1 a5:1 f5:4 -:2 a5:1 c6:1 b5:2 a5:1 g5:1 f5:2 d5:2 e5:6 -:2 " + .. "a5:2 c6:1 e6:1 d6:2 c6:2 b5:2 d6:1 f6:1 e6:2 d6:1 c6:1 b5:2 g#5:2 d6:2 c6:1 b5:1 a5:6 -:2", + lead = { inst = "muted", oct = -1, vol = 0.3, pan = 0.15, send = 0.3 }, + parts = { + { role = "bass", inst = "upright", pattern = "r-t-f-w-", vol = 0.4 }, + { role = "chord", inst = "epiano", pattern = "c--...c-", vol = 0.07, pan = -0.25, send = 0.2 }, + { role = "drum", pattern = "u.uuu.uu", vol = 0.2, pan = 0.3 }, + { role = "drum", pattern = "..x...x.", vol = 0.2, pan = -0.2 }, + }, + echo = { steps = 3, fb = 0.3, mix = 0.2 }, + }, + Playful = { + bpm = 126, spb = 4, + chords = "C:16 A7:16 D7:16 G7:16 C:16 C7:16 F:8 G7:8 C:16", + melody = "e5:2 g5:2 c6:2 g5:2 e5:1 f5:1 g5:2 -:4 c#6:2 e6:2 a5:2 e6:2 d6:1 c#6:1 a5:2 -:4 " + .. "d6:2 f#5:2 a5:2 c6:2 b5:1 a5:1 f#5:2 d5:2 -:2 g5:1 f#5:1 g5:2 b5:2 d6:2 f6:4 d6:2 b5:2 " + .. "e5:2 g5:2 c6:2 g5:2 e5:1 f5:1 g5:2 c6:4 bb5:2 g5:2 e5:2 g5:2 bb5:2 c6:2 bb5:2 g5:2 " + .. "a5:2 c6:2 f6:2 c6:2 g5:2 b5:2 d6:2 f6:2 e6:4 c6:2 g5:2 c6:4 -:4", + lead = { inst = "square", vol = 0.14, pan = 0.1, send = 0.15 }, + parts = { + { role = "bass", inst = "tri", pattern = "r.......f.......", vol = 0.35 }, + { role = "chord", inst = "square", pattern = "....c.......c...", oct = 1, vol = 0.04, pan = -0.3 }, + { role = "drum", pattern = "b..b..b.....b.b.", vol = 0.2, pan = 0.4 }, + }, + echo = { steps = 3, fb = 0.25, mix = 0.15 }, + }, +} + +local LETTER = { c = 0, d = 2, e = 4, f = 5, g = 7, a = 9, b = 11 } +local QUALITY = { + [""] = { 0, 4, 7 }, m = { 0, 3, 7 }, ["7"] = { 0, 4, 7, 10 }, m7 = { 0, 3, 7, 10 }, maj7 = { 0, 4, 7, 11 }, + dim = { 0, 3, 6 }, dim7 = { 0, 3, 6, 9 }, m7b5 = { 0, 3, 6, 10 }, sus4 = { 0, 5, 7 }, +} + +local function split(name) + local letter, accidental, rest = name:match("^(%a)([#b]?)(.*)$") + return (LETTER[letter:lower()] + (accidental == "#" and 1 or accidental == "b" and -1 or 0)) % 12, rest +end + +local function midi(name) + local pc, octave = split(name) + return pc + 12 * (tonumber(octave) + 1) +end + +local function seq(text) + local out = {} + for name, len in text:gmatch("(%S+):(%d+)") do out[#out + 1] = { name, tonumber(len) } end + return out +end + +local function held(inst, t) + if t < inst.a then return t / inst.a end + if inst.decay then return exp(-(t - inst.a) / inst.decay) end + if t < inst.a + inst.d then return 1 - (1 - inst.s) * (t - inst.a) / inst.d end + return inst.s +end + +local function gains(pan) + local angle = ((pan or 0) + 1) * math.pi / 4 + return math.cos(angle) * 1.414, math.sin(angle) * 1.414 +end + +local function osc(inst, p) + p = p % 1 + if inst.wave == "tri" then return 4 * abs(p - 0.5) - 1 end + if inst.wave == "saw" then return 2 * p - 1 end + return p < (inst.duty or 0.5) and 0.7 or -0.7 +end + +local function voice(mix, inst, note, t0, gate, vol, pan, send) + local L, R, SL, SR, n, pause = mix.L, mix.R, mix.SL, mix.SR, mix.n, mix.pause + local work = mix.work + local freq = 440 * 2 ^ ((note - 69) / 12) + local gl, gr = gains(pan) + send = send or 0 + local start = floor(t0 * RATE) + local released = held(inst, gate) + local k = inst.lp and (1 - exp(-TAU * inst.lp / RATE)) + local spread = inst.detune and 2 ^ (inst.detune / 1200) + local wave = inst.wave + local ph, ph2, y = 0, 0, 0 + for j = 0, floor((gate + inst.r) * RATE) - 1 do + local t = j / RATE + local env = t < gate and held(inst, t) or released * max(0, 1 - (t - gate) / inst.r) + local f = freq + if inst.vib then f = f * 2 ^ (inst.vib / 12 * sin(TAU * inst.vibRate * t) * min(1, t / 0.3)) end + local inc = f / RATE + ph = ph + inc + local x + if wave == "sine" then + x = sin(TAU * ph) + (inst.h2 or 0) * sin(TAU * 2 * ph) + elseif wave == "fm" then + x = sin(TAU * ph + inst.index * exp(-t / inst.idxDecay) * sin(TAU * inst.ratio * ph)) + elseif wave == "harp" then + local e, a = exp(-t * inst.bright), 1 + x = 0 + for h = 1, 5 do + x = x + sin(TAU * h * ph) * a / h + a = a * e + end + else + x = osc(inst, ph) + if spread then + ph2 = ph2 + inc * spread + x = (x + osc(inst, ph2)) * 0.5 + end + end + if k then + y = y + k * (x - y) + x = y + end + x = x * env * vol + local i = (start + j) % n + 1 + L[i] = L[i] + x * gl + R[i] = R[i] + x * gr + if send > 0 then + SL[i] = SL[i] + x * gl * send + SR[i] = SR[i] + x * gr * send + end + work = work + 1 + if work >= 1024 then + work = 0 + pause() + end + end + mix.work = work +end + +local DRUM_LENGTH = { k = 0.35, s = 0.25, h = 0.08, o = 0.4, x = 0.05, u = 0.3, b = 0.12, r = 0.15 } + +local function hit(mix, kind, t0, dur, vol, pan) + local length = kind == "w" and dur * 1.3 or DRUM_LENGTH[kind] + if not length then return end + local L, R, n, pause = mix.L, mix.R, mix.n, mix.pause + local work = mix.work + local gl, gr = gains(pan) + local start = floor(t0 * RATE) + local lp, lp2, prev, ph = 0, 0, 0, 0 + for j = 0, floor(length * RATE) - 1 do + local t = j / RATE + local noise = random() * 2 - 1 + local x, xr + if kind == "k" then + ph = ph + (48 + 120 * exp(-t / 0.025)) / RATE + x = sin(TAU * ph) * exp(-t / 0.11) + elseif kind == "s" then + lp = lp + 0.35 * (noise - lp) + x = (noise - lp) * exp(-t / 0.07) * 0.8 + sin(TAU * 190 * t) * exp(-t / 0.04) * 0.5 + elseif kind == "h" or kind == "o" or kind == "x" then + x = (noise - prev) * 0.3 * exp(-t / (kind == "o" and 0.12 or kind == "h" and 0.02 or 0.01)) + prev = noise + elseif kind == "u" then + lp = lp + 0.5 * (noise - lp) + x = lp * 0.5 * min(1, t / 0.008) * exp(-t / 0.08) + elseif kind == "b" then + x = (sin(TAU * 1050 * t) + 0.5 * sin(TAU * 1650 * t)) * exp(-t / 0.025) * 0.5 + elseif kind == "r" then + x = (noise - prev) * 0.25 * (t < 0.012 and t / 0.012 or exp(-(t - 0.012) / 0.035)) + prev = noise + else + local env = sin(math.pi * t / length) ^ 2 * 3 + lp = lp + 0.05 * (noise - lp) + lp2 = lp2 + 0.05 * ((random() * 2 - 1) - lp2) + x, xr = lp * env, lp2 * env + end + local i = (start + j) % n + 1 + L[i] = L[i] + x * vol * gl + R[i] = R[i] + (xr or x) * vol * gr + work = work + 1 + if work >= 1024 then + work = 0 + pause() + end + end + mix.work = work +end + +local function render(song, pause) + local sd = 60 / song.bpm / song.spb + local chords, total = {}, 0 + for _, token in ipairs(seq(song.chords)) do + local pc, quality = split(token[1]) + chords[#chords + 1] = { start = total, pc = pc, iv = QUALITY[quality] } + total = total + token[2] + end + local function at(step) + return (step + ((song.swing and step % 2 == 1) and song.swing or 0)) * sd + end + local function chordAt(step) + for i = #chords, 1, -1 do + if chords[i].start <= step then return chords[i], chords[i % #chords + 1] end + end + end + local n = floor(total * sd * RATE) + local mix = { L = {}, R = {}, SL = {}, SR = {}, n = n, pause = pause, work = 0 } + for i = 1, n do + mix.L[i], mix.R[i], mix.SL[i], mix.SR[i] = 0, 0, 0, 0 + if i % 8192 == 0 then pause() end + end + + local lead, step = song.lead, 0 + local leadInst = INST[lead.inst] + for _, token in ipairs(seq(song.melody)) do + if token[1] ~= "-" then + local t0 = at(step) + voice(mix, leadInst, midi(token[1]) + 12 * (lead.oct or 0), t0, + (at(step + token[2]) - t0) * (leadInst.gate or 0.92), lead.vol, lead.pan, lead.send) + end + step = step + token[2] + end + + for _, part in ipairs(song.parts) do + local pattern, size = part.pattern, #part.pattern + local inst = INST[part.inst] + for s = 0, total - 1 do + local ch = pattern:sub(s % size + 1, s % size + 1) + if ch ~= "." and ch ~= "-" then + local len = 1 + while len < size and pattern:sub((s + len) % size + 1, (s + len) % size + 1) == "-" do len = len + 1 end + local t0 = at(s) + local dur = at(s + len) - t0 + if part.role == "drum" then + hit(mix, ch:lower(), t0, dur, part.vol * (ch:match("%u") and 1.4 or 1), part.pan) + else + local chord, nextChord = chordAt(s) + local notes = {} + if part.role == "bass" then + local root = 36 + chord.pc + if ch == "w" then + local approach = 36 + (nextChord.pc + 11) % 12 + if abs(approach + 12 - (root + 7)) < abs(approach - (root + 7)) then approach = approach + 12 end + notes[1] = approach + else + notes[1] = ch == "f" and root + 7 or ch == "o" and root + 12 or ch == "t" and root + chord.iv[2] or root + end + elseif ch == "c" then + for i, iv in ipairs(chord.iv) do notes[i] = 48 + chord.pc + iv end + else + local i = tonumber(ch) - 1 + notes[1] = 48 + chord.pc + chord.iv[i % #chord.iv + 1] + 12 * floor(i / #chord.iv) + end + for _, note in ipairs(notes) do + voice(mix, inst, note + 12 * (part.oct or 0), t0, dur * (inst.gate or 0.92), part.vol, part.pan, part.send) + end + end + end + end + end + + local echo = song.echo + if echo then + local d = floor(echo.steps * sd * RATE) + local L, R, SL, SR = mix.L, mix.R, mix.SL, mix.SR + for i = 1, n do + local g, wl, wr = echo.mix, 0, 0 + for tap = 1, 3 do + local j = (i - 1 - tap * d) % n + 1 + if tap % 2 == 1 then + wl, wr = wl + g * SR[j], wr + g * SL[j] + else + wl, wr = wl + g * SL[j], wr + g * SR[j] + end + g = g * echo.fb + end + L[i], R[i] = L[i] + wl, R[i] + wr + if i % 4096 == 0 then pause() end + end + end + mix.SL, mix.SR = nil, nil + + local peak = 1e-6 + for i = 1, n do + peak = max(peak, abs(mix.L[i]), abs(mix.R[i])) + if i % 16384 == 0 then pause() end + end + return mix, 0.55 / peak, total +end + +local function build(song, pause, job) + local mix, scale = render(song, pause) + local sound = love.sound.newSoundData(mix.n, RATE, 16, 2) + job.sound = sound + local L, R = mix.L, mix.R + for i = 0, mix.n - 1 do + sound:setSample(i, 1, L[i + 1] * scale) + sound:setSample(i, 2, R[i + 1] * scale) + if i % 8192 == 8191 then pause() end + end + return sound +end + +local function now() + return love and love.timer and love.timer.getTime and love.timer.getTime() or os.clock() +end + +Music.BUDGET = 0.004 +Music.MAX_CACHED = 2 +local cache, order, jobs, failed = {}, {}, {}, {} + +local function free(sound) + if sound and sound.release then pcall(sound.release, sound) end +end + +local function drop(name) + local job = jobs[name] + jobs[name] = nil + if job then free(job.sound) end +end + +local function remember(name, sound) + cache[name] = sound + order[#order + 1] = name + while #order > Music.MAX_CACHED do + local old = table.remove(order, 1) + free(cache[old]) + cache[old] = nil + end +end + +function Music.request(name, budget) + if cache[name] then return cache[name] end + if failed[name] then return nil, failed[name] end + local song = SONGS[name] + if not song then return nil, "unknown" end + local job = jobs[name] + if not job then + job = { deadline = 0 } + local function pause() + if now() >= job.deadline then coroutine.yield() end + end + job.co = coroutine.create(function() return build(song, pause, job) end) + jobs[name] = job + end + job.deadline = now() + (budget or Music.BUDGET) + local ok, result = coroutine.resume(job.co) + if not ok then + drop(name) + failed[name] = tostring(result) + return nil, failed[name] + end + if coroutine.status(job.co) ~= "dead" then return nil, "pending" end + jobs[name] = nil + remember(name, result) + return result +end + +function Music.cancel(name) + if name then + drop(name) + else + for key in pairs(jobs) do drop(key) end + end +end + +function Music.reset() + Music.cancel() + for _, sound in pairs(cache) do free(sound) end + cache, order, failed = {}, {}, {} +end + +require("src.render.Assets").register({ invalidate = Music.reset, release = Music.reset }) +return Music diff --git a/src/box/Store.lua b/src/box/Store.lua new file mode 100644 index 00000000..b642794a --- /dev/null +++ b/src/box/Store.lua @@ -0,0 +1,333 @@ +local GameVersion = require("src.core.GameVersion") +local Serializer = require("src.core.SaveSerializer") + +local Store = { PATH = "box/storage.lua", FORMAT = 1, BOXES = 25, SLOTS = 60 } +Store.THEMES = { "Launcher", "Forest", "Sky", "Brick", "Sunset", "Ocean", "Showcase" } + +function Store.copy(value, seen) + if type(value) ~= "table" then return value end + seen = seen or {} + if seen[value] then return seen[value] end + local out = {}; seen[value] = out + for k, v in pairs(value) do out[Store.copy(k, seen)] = Store.copy(v, seen) end + return out +end + +local function integer(n, lo, hi) + return type(n) == "number" and n == math.floor(n) and n >= lo and n <= hi +end + +local function archivesValid(entry) + if entry.archives == nil then return true end + if type(entry.archives) ~= "table" then return false end + for index, original in pairs(entry.archives) do + if not integer(index, 1, #entry.archives) or type(original) ~= "table" + or original.id ~= entry.id or not GameVersion.VERSIONS[original.version] + or original.generation ~= GameVersion.generation(original.version) + or type(original.mon) ~= "table" or type(original.display) ~= "table" + or original.archives ~= nil then return false end + end + return true +end + +function Store.new() + local state = { format = Store.FORMAT, revision = 0, nextId = 1, boxes = {} } + for b = 1, Store.BOXES do state.boxes[b] = { name = "BOX " .. b, mons = {} } end + return state +end + +function Store.validate(state) + if type(state) ~= "table" or state.format ~= Store.FORMAT + or not integer(state.revision, 0, 2^53 - 1) + or not integer(state.nextId, 1, 2^53 - 1) or type(state.boxes) ~= "table" then + return nil, "The Box storage format is invalid." + end + local ids = {} + for b, box in pairs(state.boxes) do + if not integer(b, 1, Store.BOXES) or type(box) ~= "table" + or type(box.name) ~= "string" or #box.name > 128 or type(box.mons) ~= "table" then + return nil, "A Box header is invalid." + end + if box.theme ~= nil then + local found = false + for _, theme in ipairs(Store.THEMES) do if box.theme == theme then found = true end end + if not found then return nil, "A Box theme is invalid." end + end + if box.wallpaper ~= nil and (type(box.wallpaper) ~= "string" + or not box.wallpaper:match("^box/showcase/%d+%.png$")) then + return nil, "A Box wallpaper is invalid." + end + if box.showcaseMusic ~= nil then + local found = false + for _, track in ipairs(require("src.box.Showcase").MUSIC) do if track == box.showcaseMusic then found = true end end + if not found or box.theme ~= "Showcase" then return nil, "A Box music track is invalid." end + end + for slot, entry in pairs(box.mons) do + if not integer(slot, 1, Store.SLOTS) or type(entry) ~= "table" + or not integer(entry.id, 1, state.nextId - 1) or ids[entry.id] + or not GameVersion.VERSIONS[entry.version] or type(entry.mon) ~= "table" + or entry.generation ~= GameVersion.generation(entry.version) + or type(entry.display) ~= "table" or not archivesValid(entry) then + return nil, "A stored Pokémon record is invalid." + end + ids[entry.id] = true + if entry.markings ~= nil and not integer(entry.markings, 0, 15) + or entry.depositorId ~= nil and not integer(entry.depositorId, 0, 4294967295) + or entry.tags ~= nil and (type(entry.tags) ~= "string" or #entry.tags > 128) then + return nil, "A Pokémon's local labels are invalid." + end + end + end + for b = 1, Store.BOXES do + if not state.boxes[b] then return nil, "A Box is missing." end + end + if state.progress ~= nil and type(state.progress) ~= "table" then return nil, "Gift progress is invalid." end + for key, value in pairs(state.progress or {}) do + if type(key) ~= "string" or #key > 1024 or not integer(value, 0, Store.BOXES * Store.SLOTS) then + return nil, "Gift progress is invalid." + end + end + if state.presets ~= nil and type(state.presets) ~= "table" then return nil, "Team presets are invalid." end + if state.organizerProfiles ~= nil then + if type(state.organizerProfiles) ~= "table" or #state.organizerProfiles > 20 then return nil, "Auto Box presets are invalid." end + for index, profile in pairs(state.organizerProfiles) do + if not integer(index, 1, #state.organizerProfiles) or type(profile) ~= "table" + or type(profile.name) ~= "string" or #profile.name == 0 or #profile.name > 64 or profile.name:find("%c") + or (profile.target ~= "box" and profile.target ~= "pc") + or not require("src.box.Organizer").validate(profile.config) then return nil, "An Auto Box preset is invalid." end + end + end + if state.stages ~= nil and type(state.stages) ~= "table" then return nil, "Showcase stages are invalid." end + if state.departures ~= nil and type(state.departures) ~= "table" then return nil, "Departed entries are invalid." end + if state.syncMembers ~= nil and type(state.syncMembers) ~= "table" then return nil, "Box sync links are invalid." end + for key, member in pairs(state.syncMembers or {}) do + if type(key) ~= "string" or type(member) ~= "table" or not GameVersion.VERSIONS[member.version] + or type(member.playthroughId) ~= "string" or not member.playthroughId:match("^[%w%._:%-]+$") + or #member.playthroughId > 64 or key ~= member.version .. "/" .. member.playthroughId + or type(member.path) ~= "string" or not member.path:match("^saves/[%w%._%-]+/slot%d+%.lua$") then + return nil, "A Box sync link is invalid." + end + local scope, slot = member.path:match("^saves/([%w%._%-]+)/(slot%d+)%.lua$") + if member.cartId ~= nil and (type(member.cartId) ~= "string" or #member.cartId > 64 + or not member.cartId:match("^[%w%._%-]+$")) + or scope ~= (member.cartId and "cart_" .. member.cartId or member.version) + or member.slotId ~= nil and member.slotId ~= slot + or member.deleted ~= nil and type(member.deleted) ~= "boolean" then + return nil, "A Box sync link points to a different save." + end + end + for id, departure in pairs(state.departures or {}) do + local entry = type(departure) == "table" and departure.entry + if not integer(id, 1, state.nextId - 1) or ids[id] or type(entry) ~= "table" or entry.id ~= id + or not GameVersion.VERSIONS[entry.version] or entry.generation ~= GameVersion.generation(entry.version) + or type(entry.mon) ~= "table" or type(entry.display) ~= "table" + or type(departure.path) ~= "string" or not archivesValid(entry) + or departure.identity ~= nil and type(departure.identity) ~= "string" then return nil, "A departed Pokémon reference is invalid." end + end + for index, stage in pairs(state.stages or {}) do + if not integer(index, 1, 5) then return nil, "A showcase stage slot is invalid." end + local valid, why = require("src.box.Showcase").validate(stage, state.nextId) + if not valid then return nil, why end + end + for key, preset in pairs(state.presets or {}) do + if not integer(key, 1, 1000000) or type(preset) ~= "table" + or type(preset.name) ~= "string" or #preset.name > 64 + or type(preset.ids) ~= "table" or #preset.ids < 1 or #preset.ids > 6 then + return nil, "A team preset is invalid." + end + local used = {} + for _, id in ipairs(preset.ids) do + if not integer(id, 1, state.nextId - 1) or used[id] then return nil, "A team reference is invalid." end + used[id] = true + end + end + return state +end + +function Store.load(fs) + local body = fs.read(Store.PATH) + if not body then + if fs.getInfo(Store.PATH) then return nil, "Box storage could not be read." end + local backup = fs.read(Store.PATH .. ".bak") + if backup then + local state = Serializer.decode(backup) + if not Store.validate(state) then return nil, "Box storage is missing and its backup could not be read." end + local ok = fs.write(Store.PATH, backup) + if not ok or fs.read(Store.PATH) ~= backup then return nil, "Box storage could not be restored from its backup." end + return state, nil, backup + end + if fs.getInfo(Store.PATH .. ".bak") or fs.getInfo(Store.PATH .. ".box-bak") then + return nil, "Box storage is missing. Its backup was kept in the box folder." + end + return Store.new(), nil, nil + end + local state, err = Serializer.decode(body) + if not state then return nil, "Box storage could not be decoded: " .. tostring(err) end + local valid, why = Store.validate(state) + if not valid then return nil, why end + return valid, nil, body +end + +function Store.at(state, box, slot) + if not integer(box, 1, Store.BOXES) or not integer(slot, 1, Store.SLOTS) then return nil end + return state.boxes[box].mons[slot] +end + +function Store.free(state, box) + if not integer(box, 1, Store.BOXES) then return nil end + for slot = 1, Store.SLOTS do if not state.boxes[box].mons[slot] then return slot end end +end + +function Store.count(state) + local n = 0 + for b = 1, Store.BOXES do for _ in pairs(state.boxes[b].mons) do n = n + 1 end end + return n +end + +function Store.move(state, fromBox, fromSlot, toBox, toSlot) + local entry = Store.at(state, fromBox, fromSlot) + if not entry or not integer(toBox, 1, Store.BOXES) + or not integer(toSlot, 1, Store.SLOTS) then return nil, "Choose valid Box slots." end + local nextState = Store.copy(state) + nextState.boxes[fromBox].mons[fromSlot], nextState.boxes[toBox].mons[toSlot] = + nextState.boxes[toBox].mons[toSlot], nextState.boxes[fromBox].mons[fromSlot] + nextState.revision = state.revision + 1 + return nextState +end + +function Store.rename(state, box, name) + if not integer(box, 1, Store.BOXES) or type(name) ~= "string" + or #name == 0 or #name > 128 or name:find("%c") then + return nil, "Enter a Box name of 1 to 128 bytes." + end + local nextState = Store.copy(state) + nextState.boxes[box].name = name + nextState.revision = state.revision + 1 + return nextState +end + +function Store.find(state, id) + for b = 1, Store.BOXES do + for slot, entry in pairs(state.boxes[b].mons) do + if entry.id == id then return { box = b, slot = slot, entry = entry } end + end + end +end + +function Store.moveGroup(state, refs, box, slot) + if type(refs) ~= "table" or #refs == 0 or not integer(box, 1, Store.BOXES) + or not integer(slot, 1, Store.SLOTS) or slot + #refs - 1 > Store.SLOTS then + return nil, "Choose a destination with room for the entire group." + end + local nextState, group, origins, vacated, displaced = Store.copy(state), {}, {}, {}, {} + for _, ref in ipairs(refs) do + local entry = Store.at(state, ref.box, ref.slot) + local key = tostring(ref.box) .. ":" .. tostring(ref.slot) + if not entry or origins[key] then return nil, "A selected Box Pokémon is missing or repeated." end + origins[key], group[#group + 1] = true, Store.copy(entry) + nextState.boxes[ref.box].mons[ref.slot] = nil + end + local targets = {} + for i = 1, #refs do + local target = slot + i - 1 + targets[box .. ":" .. target] = true + local entry = nextState.boxes[box].mons[target] + if entry then displaced[#displaced + 1] = entry end + nextState.boxes[box].mons[target] = group[i] + end + for _, ref in ipairs(refs) do + if not targets[ref.box .. ":" .. ref.slot] then vacated[#vacated + 1] = ref end + end + for i, entry in ipairs(displaced) do + local ref = vacated[i] + nextState.boxes[ref.box].mons[ref.slot] = entry + end + nextState.revision = state.revision + 1 + return nextState +end + +function Store.arrange(state, box, sort) + if not integer(box, 1, Store.BOXES) then return nil, "Choose a Box." end + local nextState, rows = Store.copy(state), Store.search(state, "", sort) + nextState.boxes[box].mons = {} + local index = 0 + for _, row in ipairs(rows) do + if row.box == box then index = index + 1; nextState.boxes[box].mons[index] = Store.copy(row.entry) end + end + nextState.revision = state.revision + 1 + return nextState +end + +function Store.theme(state, box, theme, wallpaper, music) + if not integer(box, 1, Store.BOXES) then return nil, "Choose a Box." end + local nextState = Store.copy(state) + nextState.boxes[box].theme, nextState.boxes[box].wallpaper = theme, wallpaper + nextState.boxes[box].showcaseMusic = theme == "Showcase" and music or nil + nextState.revision = state.revision + 1 + return Store.validate(nextState) +end + +function Store.mark(state, refs, markings, tags) + if not integer(markings, 0, 15) or type(refs) ~= "table" or #refs == 0 then + return nil, "Choose Pokémon and valid markings." + end + if tags ~= nil and (type(tags) ~= "string" or #tags > 128 or tags:find("%c")) then + return nil, "Enter tags of at most 128 bytes." + end + local nextState, seen = Store.copy(state), {} + for _, ref in ipairs(refs) do + local entry = Store.at(nextState, ref.box, ref.slot) + if not entry or seen[entry.id] then return nil, "A selected Pokémon is missing or repeated." end + seen[entry.id] = true + if entry.generation == 3 then + entry.mon.markings, entry.display.markings = markings, markings + else entry.markings = markings end + if tags ~= nil then entry.tags = tags end + end + nextState.revision = state.revision + 1 + return nextState +end + +function Store.preset(state, name, refs) + if type(name) ~= "string" or #name == 0 or #name > 64 or name:find("%c") + or type(refs) ~= "table" or #refs == 0 or #refs > 6 then + return nil, "Name a team of one to six Pokémon." + end + local nextState, ids, seen = Store.copy(state), {}, {} + for _, ref in ipairs(refs) do + local entry = Store.at(state, ref.box, ref.slot) + if not entry or seen[entry.id] then return nil, "A team member is missing or repeated." end + seen[entry.id], ids[#ids + 1] = true, entry.id + end + nextState.presets = nextState.presets or {} + if #nextState.presets >= 100 then return nil, "All 100 team preset slots are full." end + nextState.presets[#nextState.presets + 1] = { name = name, ids = ids } + nextState.revision = state.revision + 1 + return nextState +end + +function Store.search(state, query, sort) + local out = {} + local matches = require("src.box.Search").compile(query) + for b = 1, Store.BOXES do + for slot, entry in pairs(state.boxes[b].mons) do + if matches(entry, state.boxes[b].name) then + out[#out + 1] = { box = b, slot = slot, entry = entry } + end + end + end + table.sort(out, function(a, b) + local x, y = a.entry.display, b.entry.display + local av, bv + if sort == "level" then av, bv = tonumber(x.level) or 0, tonumber(y.level) or 0 + elseif sort == "species" then av, bv = tonumber(x.national) or 0, tonumber(y.national) or 0 + elseif sort == "name" or sort == "nature" or sort == "ability" or sort == "trainer" or sort == "gender" then + av, bv = tostring(x[sort] or ""):lower(), tostring(y[sort] or ""):lower() + elseif sort ~= "slot" then av, bv = tonumber(x[sort]) or -1, tonumber(y[sort]) or -1 end + if av and av ~= bv then return av < bv end + return a.box < b.box or (a.box == b.box and a.slot < b.slot) + end) + return out +end + +return Store diff --git a/src/box/Themes.lua b/src/box/Themes.lua new file mode 100644 index 00000000..efb2c125 --- /dev/null +++ b/src/box/Themes.lua @@ -0,0 +1,94 @@ +local Theme = require("src.ui.kit.Theme") +local Themes = {} +local COLORS = { Forest = { 38, 107, 56 }, Sky = { 51, 92, 148 }, + Brick = { 107, 66, 79 }, Sunset = { 130, 66, 43 }, Ocean = { 26, 99, 117 } } +local images, count, retired = {}, 0, {} +Themes.MAX_IMAGES = 4 + +local function stamp() + local kit = package.loaded["src.ui.kit.Kit"] + return kit and kit.time +end + +local function retire(image) + if image and image.release then retired[#retired + 1] = { image = image, at = stamp() } end +end + +local function flush(all) + local now, kept = stamp(), {} + for _, row in ipairs(retired) do + if all or row.at ~= now then pcall(row.image.release, row.image) else kept[#kept + 1] = row end + end + retired = kept +end + +function Themes.forget(path) + local image = images[path] + if image == nil then return end + images[path] = nil + if image then + count = count - 1 + retire(image) + end +end + +function Themes.reset() + for _, image in pairs(images) do retire(image or nil) end + images, count = {}, 0 +end + +function Themes.release() + Themes.reset() + flush(true) +end + +function Themes.draw(box, x, y, w, h, scale) + if #retired > 0 then flush(false) end + if box.theme == "Showcase" and box.wallpaper and love.graphics.newImage then + local image = images[box.wallpaper] + if image == nil then + if count >= Themes.MAX_IMAGES then Themes.reset() end + local ok, value = pcall(function() + local fs = require("src.core.SaveData").persistenceFs() + local bytes = fs and fs.read and fs.read(box.wallpaper) + if type(bytes) ~= "string" then return false end + local file = love.filesystem.newFileData(bytes, box.wallpaper) + local loaded = love.graphics.newImage(file) + if file.release then file:release() end + return loaded + end) + image = ok and value or false + images[box.wallpaper] = image + if image then count = count + 1 end + end + if image then + local old = love.graphics.getScissor and { love.graphics.getScissor() } + local left, top, right, bottom = x, y, x + w, y + h + if old and #old == 4 then + left, top = math.max(left, old[1]), math.max(top, old[2]) + right, bottom = math.min(right, old[1] + old[3]), math.min(bottom, old[2] + old[4]) + end + if love.graphics.setScissor then love.graphics.setScissor(left, top, math.max(0, right-left), math.max(0, bottom-top)) end + Theme.col({ 255, 255, 255 }, 0.3) + love.graphics.draw(image, x, y, 0, w / image:getWidth(), h / image:getHeight()) + if love.graphics.setScissor then + if old and #old == 4 then love.graphics.setScissor(unpack(old)) else love.graphics.setScissor() end + end + return + end + end + local color = COLORS[box.theme] + if not color then return end + Theme.fillRounded(x, y, w, h, color, 0.35, 8) + local cell = 32 * (scale or 1) + for row = 0, math.ceil(h / cell) - 1 do + for col = 0, math.ceil(w / cell) - 1 do + if (row + col) % 2 == 0 then + Theme.fillRounded(x + col * cell, y + row * cell, math.min(cell - 2, w-col*cell), + math.min(cell - 2, h-row*cell), color, 0.16, box.theme == "Brick" and 2 or 8) + end + end + end +end +require("src.render.Assets").register({ invalidate = Themes.reset, release = Themes.release }) +return Themes diff --git a/src/box/Transaction.lua b/src/box/Transaction.lua new file mode 100644 index 00000000..6c1b2d34 --- /dev/null +++ b/src/box/Transaction.lua @@ -0,0 +1,234 @@ +local Serializer = require("src.core.SaveSerializer") +local Store = require("src.box.Store") +local GameVersion = require("src.core.GameVersion") + +local Transaction = { PATH = "box/pending.lua" } + +local function allowed(path) + if path == Store.PATH then return true end + if type(path) ~= "string" then return false end + local scope = path:match("^saves/([%w%._%-]+)/slot%d+%.lua$") + return scope ~= nil and (GameVersion.VERSIONS[scope] ~= nil + or (#scope <= 69 and scope:match("^cart_%w[%w%._%-]*$") ~= nil)) +end + +function Transaction.assetValid(path, body) + if type(path) ~= "string" or not path:match("^box/showcase/%d+%.png$") + or type(body) ~= "string" or #body < 24 or #body > 2 * 1024 * 1024 + or body:sub(1, 8) ~= "\137PNG\r\n\26\n" then return false end + local function be(i) + local a, b, c, d = body:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + return be(17) == 1136 and be(21) == 432 +end + +local function write(fs, path, body) + local dir = path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then + local ok, err = fs.createDirectory(dir) + if not ok then return nil, err or "Could not create the save directory." end + end + local ok, err = fs.write(path, body) + if not ok then return nil, err or "Could not write the save." end + if fs.read(path) ~= body then return nil, "The save could not be verified after writing." end + return true +end + +local function validJournal(journal) + if type(journal) ~= "table" or (journal.format ~= 1 and journal.format ~= 2 and journal.format ~= 3) + or type(journal.jobs) ~= "table" or #journal.jobs < 1 + or #journal.jobs > (journal.format ~= 1 and 512 or 2) then return nil, "Invalid Box transfer journal." end + local seen = {} + for _, job in ipairs(journal.jobs) do + if type(job) ~= "table" then return nil, "Invalid Box transfer target." end + local asset = journal.format == 2 and job.kind == "asset" + local options = journal.format == 2 and job.path == "options.lua" + local deleting = journal.format == 2 and job.deleted == true and job.path ~= Store.PATH and not asset and not options + if type(job) ~= "table" or not (allowed(job.path) or asset or options) or seen[job.path] + or (not deleting and type(job.after) ~= "string") or deleting and job.after ~= nil + or (job.before ~= nil and type(job.before) ~= "string") then + return nil, "Invalid Box transfer target." + end + seen[job.path] = true + if asset then + if not Transaction.assetValid(job.path, job.after) then return nil, "Invalid Box wallpaper." end + elseif not deleting then + local data = Serializer.decode(job.after) + if type(data) ~= "table" then return nil, "Invalid transfer save data." end + if job.path == Store.PATH and not Store.validate(data) then return nil, "Invalid Box data." end + end + end + if journal.format ~= 3 and not seen[Store.PATH] then return nil, "The transfer does not include Box storage." end + return journal +end + +local function quarantine(fs, body) + local stamp = os.time() + for n = 1, 1000 do + local path = ("box/conflicts/pending-%d-%d.lua"):format(stamp, n) + if not fs.getInfo(path) then + local ok, err = write(fs, path, body) + if not ok then return nil, err end + ok, err = fs.remove(Transaction.PATH) + if not ok or fs.getInfo(Transaction.PATH) then + return nil, err or "The unfinished Box transfer record could not be set aside." + end + return path + end + end + return nil, "Too many unfinished Box transfer records are waiting in box/conflicts." +end + +local function abort(fs, journal, body) + local wrote = false + for _, job in ipairs(journal.jobs) do + local current = fs.read(job.path) + if not job.conflict and current ~= job.before then + local ok, why + if job.before == nil then + ok, why = fs.remove(job.path) + if ok and fs.getInfo(job.path) then ok = nil end + else ok, why = write(fs, job.path, job.before) end + if not ok then return nil, why or "Could not undo the unfinished Box transfer." end + wrote = true + end + if not job.conflict and job.after ~= nil and fs.read(job.path .. ".bak") == job.after then + local ok, why + if job.before == nil then ok, why = fs.remove(job.path .. ".bak") + else ok, why = write(fs, job.path .. ".bak", job.before) end + if not ok then return nil, why or "Could not undo the unfinished Box transfer." end + end + end + if wrote and journal.format ~= 2 then + local sync = package.loaded["src.sync.SyncEngine"] + local engine = type(sync) == "table" and sync._shared + if type(engine) == "table" then engine:noteSaveWritten() end + end + local kept, err = quarantine(fs, body) + if not kept then return nil, err end + return true, false, "A save changed while a Box transfer was pending. The transfer was undone and its record kept at " .. kept .. "." +end + +local function touched(fs, journal) + if type(journal) ~= "table" or type(journal.jobs) ~= "table" then return false end + for _, job in pairs(journal.jobs) do + if type(job) == "table" and type(job.path) == "string" then + local ok, current = pcall(fs.read, job.path) + if not ok then return true end + if current ~= job.before then + if job.deleted == true and current == nil then return true end + if type(job.after) == "string" and type(current) == "string" + and job.after:sub(1, #current) == current then return true end + end + end + end + return false +end + +function Transaction.recover(fs) + if not fs or not fs.read or not fs.getInfo then return nil, "Save storage is unavailable." end + local body = fs.read(Transaction.PATH) + if not body then + if fs.getInfo(Transaction.PATH) then return nil, "The pending Box transfer could not be read." end + return true, false + end + local journal = Serializer.decode(body) + if journal == nil then + local kept, err = quarantine(fs, body) + if not kept then return nil, err end + return true, false, "An unfinished Box transfer record was unreadable and was set aside at " .. kept .. "." + end + local valid, why = validJournal(journal) + if not valid then + if touched(fs, journal) then return nil, why end + local kept, err = quarantine(fs, body) + if not kept then return nil, err end + return true, false, why .. " The unfinished Box transfer record was set aside at " .. kept .. "." + end + local conflict = false + for _, job in ipairs(journal.jobs) do + local current = fs.read(job.path) + if current == nil and fs.getInfo(job.path) then return nil, "A transfer save could not be read." end + local interrupted = journal.format ~= 1 and type(current) == "string" and type(job.after) == "string" + and job.after:sub(1, #current) == current + if current ~= job.before and current ~= job.after and not interrupted + and (journal.format ~= 1 or current == nil or Serializer.decode(current) ~= nil) then + job.conflict, conflict = true, true + end + end + if conflict then return abort(fs, journal, body) end + for _, job in ipairs(journal.jobs) do + if fs.read(job.path) ~= job.after then + if job.before then + local ok, err = write(fs, job.path .. ".box-bak", job.before) + if not ok then return nil, err end + end + if job.deleted then + local ok, err = fs.remove(job.path) + if not ok or fs.getInfo(job.path) then return nil, err or "Could not remove the synced save." end + else + local ok, err = write(fs, job.path, job.after) + if not ok then return nil, err end + end + end + if job.deleted then + for _, suffix in ipairs({ ".bak", ".tmp" }) do + if fs.getInfo(job.path .. suffix) then + local ok, err = fs.remove(job.path .. suffix) + if not ok or fs.getInfo(job.path .. suffix) then return nil, err or "Could not remove a deleted save's recovery file." end + end + end + elseif fs.read(job.path .. ".bak") ~= job.after then + local ok, err = write(fs, job.path .. ".bak", job.after) + if not ok then return nil, err end + end + if not job.deleted and fs.getInfo(job.path .. ".tmp") then + local ok, err = fs.remove(job.path .. ".tmp") + if not ok or fs.getInfo(job.path .. ".tmp") then return nil, err or "Could not clear a stale save recovery file." end + end + end + local ok, err = fs.remove(Transaction.PATH) + if not ok or fs.getInfo(Transaction.PATH) then + return nil, err or "The completed Box transfer journal could not be cleared." + end + local stored = fs.read(Store.PATH) + if stored and fs.read(Store.PATH .. ".bak") == stored + and fs.getInfo(Store.PATH .. ".box-bak") then + fs.remove(Store.PATH .. ".box-bak") + end + if journal.format == 1 or journal.format == 3 then + local sync = package.loaded["src.sync.SyncEngine"] + local engine = type(sync) == "table" and sync._shared + if type(engine) == "table" then engine:noteSaveWritten() end + end + return true, true +end + +local function commit(fs, jobs, format) + local ok, why = Transaction.recover(fs) + if not ok then return nil, why end + local journal = { format = format, jobs = jobs } + local valid, err = validJournal(journal) + if not valid then return nil, err end + for _, job in ipairs(jobs) do + if fs.read(job.path) ~= job.before then return nil, "That save changed. Reload before transferring." end + end + local body = Serializer.encode(journal) + ok, err = write(fs, Transaction.PATH, body) + if not ok and fs.read(Transaction.PATH) ~= body then + fs.remove(Transaction.PATH) + return nil, err + end + local applied + ok, applied, err = Transaction.recover(fs) + if not ok then return nil, "Box transfer is pending: " .. tostring(applied) end + if not applied then return nil, err or "The Box transfer was not applied." end + return true +end + +function Transaction.commit(fs, jobs) return commit(fs, jobs, 1) end +function Transaction.commitSync(fs, jobs) return commit(fs, jobs, 2) end +function Transaction.commitIdentity(fs, jobs) return commit(fs, jobs, 3) end + +return Transaction diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 69b6c798..49f00c73 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -179,11 +179,13 @@ local cachedBanks local function loadBanks(data) local audio = data.audio local WorkerFs = require("src.core.WorkerFs") - local key = (WorkerFs.normalize(audio.programPrefix) or "") .. tostring(audio.programFile) + local key = audio.programBytes and audio + or (WorkerFs.normalize(audio.programPrefix) or "") .. tostring(audio.programFile) if cachedProgramFile == key and cachedBanks then return cachedBanks end - local raw, readError = WorkerFs.read(audio.programPrefix, audio.programFile) + local raw, readError = audio.programBytes + if not raw then raw, readError = WorkerFs.read(audio.programPrefix, audio.programFile) end if not raw then error("could not read sound programs: " .. tostring(readError)) end local banks = {} for index, bank in ipairs(audio.bankOrder) do diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 004c5bfa..2d52c3f5 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -2672,6 +2672,9 @@ function SaveData.load(version) SaveData.runMigrations(data) data.options = SaveData.loadOptions() local loadInfo = type(data.version) == "string" and GameVersion.info(data.version) + if loadInfo and loadInfo.generation == 3 then + pcall(function() require("src.save_convert.Gen3Save").repairJapaneseNames(data) end) + end if loadInfo and loadInfo.generation == 2 then data.options = require("src.core.gen2.Save").loadOptions() end diff --git a/src/core/TrainerIdSync.lua b/src/core/TrainerIdSync.lua index 196624f9..babac7cb 100644 --- a/src/core/TrainerIdSync.lua +++ b/src/core/TrainerIdSync.lua @@ -1,102 +1,55 @@ local bit = require("bit") - +local Identity = require("src.core.TrainerIdentity") +local Store = require("src.box.Store") +local Transaction = require("src.box.Transaction") +local GameVersion = require("src.core.GameVersion") local TrainerIdSync = {} -local function u16(v) - local n = tonumber(v) - if not n or n ~= n or n < 0 or n == math.huge then return nil end - return math.floor(n) % 65536 -end - function TrainerIdSync.generationOf(save, version) if type(save) ~= "table" then return nil end if save.engine == "game3" or save.generation == 3 then return 3 end if save.generation == 2 then return 2 end - local GameVersion = require("src.core.GameVersion") local info = GameVersion.info(type(save.version) == "string" and save.version or version) - if not info then info = GameVersion.info(version) end + or GameVersion.info(version) return info and info.generation or 1 end function TrainerIdSync.playerIdOf(save, gen) if type(save) ~= "table" then return nil end - if gen == 3 then return u16(save.trainerId) end - return u16(type(save.player) == "table" and save.player.id or nil) + return Identity.u16(gen == 3 and save.trainerId or save.player and save.player.id) end -local function reseat(oldId, sid, newId) - if type(sid) ~= "number" then return sid end - return bit.band(bit.bxor(sid, oldId, newId), 0xFFFF) -end - -local function rawOtId(raw) - if type(raw) ~= "string" or #raw < 28 then return nil end - return tonumber(raw:sub(25, 28), 16) -end - -local function setRawOtId(raw, id) - return raw:sub(1, 24) .. ("%04X"):format(id) .. raw:sub(29) -end - -local function syncMon(mon, gen, newId, ids) - if type(mon) ~= "table" then return false end - if gen == 1 and mon.otId == nil and type(mon.cartRaw) == "string" then - local id = rawOtId(mon.cartRaw) - if id and ids[id] and id ~= newId then - mon.cartRaw = setRawOtId(mon.cartRaw, newId) - return true - end - return false - end - local id = u16(mon.otId) - if not id or not ids[id] then return false end - local changed = false - if id ~= newId then - if gen == 3 then mon.otSecretId = reseat(id, mon.otSecretId, newId) end - mon.otId = newId - changed = true - end - if gen ~= 3 and mon.traded then - mon.traded = nil - changed = true - end - return changed +function TrainerIdSync.addOwner(owners, profile, gen) + for g = 1, 2 do owners[Identity.key(g, profile.id, profile.name)] = profile end + owners[Identity.key(3, profile.id, profile.name, gen == 3 and profile.sid or 0)] = profile end local function each(list, fn) - if type(list) ~= "table" then return end - for _, v in pairs(list) do fn(v) end + for _, value in pairs(type(list) == "table" and list or {}) do fn(value) end end -local function monsOf(save, gen) +function TrainerIdSync.monsOf(save, gen) local out, seen = {}, {} local function add(mon) - if type(mon) == "table" and not seen[mon] then - seen[mon] = true - out[#out + 1] = mon - end + if type(mon) == "table" and not seen[mon] then seen[mon], out[#out + 1] = true, mon end end each(save.party, add) if gen == 3 then - local boxes = type(save.storage) == "table" and save.storage.boxes or nil - each(boxes, function(box) each(type(box) == "table" and box.mons, add) end) + each(type(save.storage) == "table" and save.storage.boxes, + function(box) each(type(box) == "table" and box.mons, add) end) each(save.savedPlayerParty, add) - local tower = type(save.frontier) == "table" and save.frontier.towerPlayer or nil + local tower = type(save.frontier) == "table" and save.frontier.towerPlayer each(type(tower) == "table" and tower.party, add) for key, mod in pairs(type(save.modData) == "table" and save.modData or {}) do if type(key) == "string" and key:match("_daycare$") and type(mod) == "table" then local dc = mod.daycare - if type(dc) == "table" then - add(dc[1]) - add(dc[2]) - each(dc.mons, add) - end - local r5 = mod.route5Daycare - if type(r5) == "table" then add(r5.mon) end + if type(dc) == "table" then add(dc[1]); add(dc[2]); each(dc.mons, add) end + if type(mod.route5Daycare) == "table" then add(mod.route5Daycare.mon) end end end else - each(save.boxes, function(box) each(box, add) end) + each(save.boxes or (save.box and { save.box }), + function(box) each(box, add) end) if type(save.daycare) == "table" then add(save.daycare.mon) end if type(save.orphaned) == "table" then each(save.orphaned.mons, add) end if gen == 2 and type(save.dayCare) == "table" then @@ -109,183 +62,353 @@ local function monsOf(save, gen) return out end -local function syncExtras(save, gen, oldId, oldSid, newId, newSid, ids) +local function syncMon(mon, gen, target, owners, localOwner, report) + if not Identity.owner(mon, gen, owners, localOwner) then + if (mon.otId ~= nil or mon.cartRaw ~= nil) and Identity.monName(mon) == nil then + report.unknown = report.unknown + 1 + end + return false + end + local changed = false + if gen == 3 then + local pid, why = Identity.personality(mon, target) + if not pid then return nil, why end + if pid ~= mon.personality then + mon.personality, changed = pid, true + report.personalities = report.personalities + 1 + if tonumber(mon.species) == 308 then report.spinda = report.spinda + 1 end + end + if mon.otSecretId ~= target.sid then mon.otSecretId, changed = target.sid, true end + end + local id = Identity.u16(mon.otId) or Identity.rawId(mon, gen) + if id ~= target.id or mon.otId ~= nil and mon.otId ~= target.id then + if gen == 1 and type(mon.cartRaw) == "string" then + mon.cartRaw = mon.cartRaw:sub(1, 24) .. ("%04X"):format(target.id) .. mon.cartRaw:sub(29) + elseif gen == 2 and type(mon.cartRaw) == "string" then + require("src.save_convert.Gen2Save").rewriteCarrierId(mon, target.id) + end + if mon.otId ~= nil or gen ~= 1 or not mon.cartRaw then mon.otId = target.id end + changed = true + end + local renamed, why = Identity.rename(mon, target.name, gen) + if renamed == nil then return nil, why end + changed = changed or renamed + if mon.traded then mon.traded, changed = nil, true end + if changed then report.mons = report.mons + 1 end + return changed +end + +local function syncRawDaycare(save, gen, target, owners, localOwner, report) + local dc = gen == 1 and type(save.daycare) == "table" and save.daycare or nil + local raw = dc and dc.mon == nil and type(dc.cartRaw) == "string" and dc.cartRaw or nil + if not raw or #raw < 112 or raw:find("[^%x]") then return false end + local view = { cartOt = raw:sub(25, 46), cartRaw = raw:sub(47) } + local changed, why = syncMon(view, gen, target, owners, localOwner, report) + if changed == nil then return nil, why end + if changed then dc.cartRaw = raw:sub(1, 24) .. view.cartOt .. view.cartRaw end + return changed +end + +local function syncMail(mail, gen, target, owners, localOwner) + if type(mail) ~= "table" then return false end + local name = gen == 2 and mail.author or mail.playerName + local id = gen == 2 and mail.authorId or mail.trainerId + local full = Identity.packed(id) + if not full or type(name) ~= "string" then return false end + local sid = math.floor(full / 65536) + if gen == 3 and full < 65536 and localOwner and localOwner.id == full + and localOwner.name == name then sid = localOwner.sid end + if not owners[Identity.key(gen, full % 65536, name, sid)] then return false end + local nextId = gen == 2 and target.id or target.id + target.sid * 65536 + if id == nextId and name == target.name then return false end + if gen == 2 then mail.authorId, mail.author = nextId, target.name + else mail.trainerId, mail.playerName = nextId, target.name end + return true +end + +local function syncExtras(save, gen, target, owners, old) local changed = false if gen == 2 then - local hof = type(save.hallOfFame) == "table" and save.hallOfFame.teams or nil - each(hof, function(team) - each(type(team) == "table" and team.mons, function(mon) - local id = type(mon) == "table" and u16(mon.otId) - if id and ids[id] and id ~= newId then mon.otId = newId; changed = true end - end) - end) - local mail = type(save.mail) == "table" and save.mail or nil - for _, list in ipairs({ mail and mail.party, mail and mail.box }) do - each(list, function(m) - local id = type(m) == "table" and u16(m.authorId) - if id and ids[id] and id ~= newId then m.authorId = newId; changed = true end - end) + local mail = type(save.mail) == "table" and save.mail or {} + for _, key in ipairs({ "party", "box" }) do + each(mail[key], function(m) changed = syncMail(m, gen, target, owners, old) or changed end) end elseif gen == 3 then - each(save.hallOfFameTeams, function(team) - each(team, function(mon) - local id = type(mon) == "table" and u16(mon.trainerId) - if id and ids[id] and id ~= newId then - mon.otSecretId = reseat(id, mon.otSecretId, newId) - mon.trainerId = newId - changed = true + each(save.mail, function(m) changed = syncMail(m, gen, target, owners, old) or changed end) + for key, mod in pairs(save.modData or {}) do + if type(key) == "string" and key:match("_daycare$") and type(mod) == "table" then + local dc = type(mod.daycare) == "table" and mod.daycare or {} + each(dc.mail, function(m) + if type(m) == "table" then changed = syncMail(m.message, gen, target, owners, old) or changed end + end) + local r5 = type(mod.route5Daycare) == "table" and mod.route5Daycare + if r5 and type(r5.mail) == "table" then + changed = syncMail(r5.mail.message, gen, target, owners, old) or changed end - end) - end) - local base = type(save.secretBases) == "table" and save.secretBases[1] or nil - local tid = type(base) == "table" and base.trainerId or nil - if type(tid) == "table" and oldId ~= newId - and tid[1] == oldId % 256 and tid[2] == math.floor(oldId / 256) then - tid[1], tid[2] = newId % 256, math.floor(newId / 256) - if type(newSid) == "number" and tid[3] == (oldSid or 0) % 256 - and tid[4] == math.floor((oldSid or 0) / 256) then - tid[3], tid[4] = newSid % 256, math.floor(newSid / 256) end - changed = true + end + local base = type(save.secretBases) == "table" and save.secretBases[1] + local tid = type(base) == "table" and base.trainerId + if type(tid) == "table" and tid[1] == old.id % 256 and tid[2] == math.floor(old.id / 256) + and tid[3] == old.sid % 256 and tid[4] == math.floor(old.sid / 256) + and (base.trainerName == nil or base.trainerName == old.name) then + if old.id ~= target.id or old.sid ~= target.sid or old.name ~= target.name then + base.trainerId = { target.id % 256, math.floor(target.id / 256), + target.sid % 256, math.floor(target.sid / 256) } + if base.trainerName ~= nil then base.trainerName = target.name end + changed = true + end end end return changed end -function TrainerIdSync.rewriteSave(save, gen, newId, ids) - if type(save) ~= "table" then return false end - local changed = false - local oldId = TrainerIdSync.playerIdOf(save, gen) - local oldSid, newSid +local function reportNew() return { mons = 0, personalities = 0, spinda = 0, unknown = 0 } end + +function TrainerIdSync.rewriteSave(save, gen, target, owners, report) + local old = Identity.profile(save, gen) + if not old or type(target) ~= "table" then return nil, "The save has no complete trainer identity." end + report = report or reportNew() + local changed = old.id ~= target.id or old.name ~= target.name or gen == 3 and old.sid ~= target.sid + for _, mon in ipairs(TrainerIdSync.monsOf(save, gen)) do + local updated, why = syncMon(mon, gen, target, owners, old, report) + if updated == nil then return nil, why end + changed = updated or changed + end + local daycare, why = syncRawDaycare(save, gen, target, owners, old, report) + if daycare == nil then return nil, why end + changed = daycare or changed + changed = syncExtras(save, gen, target, owners, old) or changed if gen == 3 then - oldSid = save.secretId - if oldId and oldId ~= newId then - newSid = reseat(oldId, oldSid, newId) - save.secretId = newSid - save.trainerId = newId - changed = true - end - elseif oldId ~= newId then - save.player = type(save.player) == "table" and save.player or {} - save.player.id = newId - changed = true - end - for _, mon in ipairs(monsOf(save, gen)) do - if syncMon(mon, gen, newId, ids) then changed = true end - end - if oldId and syncExtras(save, gen, oldId, oldSid, newId, newSid or oldSid, ids) then - changed = true - end + save.trainerId, save.secretId, save.name = target.id, target.sid, target.name + if save.playerName ~= nil then save.playerName = target.name end + else save.player.id, save.player.name = target.id, target.name end return changed end local function scopes(SaveData) - local GameVersion = require("src.core.GameVersion") local out = {} - for _, version in ipairs(GameVersion.ORDER) do - out[#out + 1] = { key = version, version = version } - end - local ok, ids = pcall(SaveData.cartsWithSlots) - if ok and type(ids) == "table" then - local okOpts, options = pcall(SaveData.loadOptions) - local reg = okOpts and type(options) == "table" and type(options.carts) == "table" - and options.carts or {} - for _, id in ipairs(ids) do - local row = type(reg[id]) == "table" and reg[id] or nil - out[#out + 1] = { key = "cart_" .. id, cart = id, version = row and row.base or nil } - end + for _, version in ipairs(GameVersion.ORDER) do out[#out + 1] = { key = version, version = version } end + local options = SaveData.loadOptions() + for _, id in ipairs(SaveData.cartsWithSlots()) do + local row = type(options.carts) == "table" and options.carts[id] + out[#out + 1] = { key = "cart_" .. id, cart = id, version = type(row) == "table" and row.base } end return out end function TrainerIdSync.newJob(source, SaveData) SaveData = SaveData or require("src.core.SaveData") - local entries = {} + local fs = SaveData.persistenceFs() + local job = setmetatable({ SaveData = SaveData, fs = fs, source = source, entries = {}, + phase = "read", at = 0, owners = {}, written = 0, failed = {}, report = reportNew(), jobs = {} }, + { __index = TrainerIdSync }) + local ok, why = Transaction.recover(fs) + if not ok then job.error, job.phase = why, "done"; return job end + job.warehouse, why, job.boxBody = Store.load(fs) + if not job.warehouse then job.error, job.phase = why, "done"; return job end for _, scope in ipairs(scopes(SaveData)) do - local okList, slots = pcall(function() - if scope.cart then return SaveData.listCartSlots(scope.cart) end - return SaveData.listSlots(scope.version) - end) - for _, slot in ipairs(okList and type(slots) == "table" and slots or {}) do - if slot.exists then - entries[#entries + 1] = { scope = scope.key, cart = scope.cart, - version = scope.version, slot = slot.id } + local slots = scope.cart and SaveData.listCartSlots(scope.cart) or SaveData.listSlots(scope.version) + for _, slot in ipairs(slots) do + local path = "saves/" .. scope.key .. "/" .. slot.id .. ".lua" + if slot.exists or fs.getInfo(path) or fs.getInfo(path .. ".bak") or fs.getInfo(path .. ".tmp") then + job.entries[#job.entries + 1] = { scope = scope.key, cart = scope.cart, + version = scope.version, slot = slot.id, path = path } end end end - return setmetatable({ - SaveData = SaveData, source = source, entries = entries, - phase = "read", at = 0, ids = {}, written = 0, failed = {}, - }, { __index = TrainerIdSync }) + job.optionsBody = fs.read("options.lua") + return job end function TrainerIdSync:progress() - local n = #self.entries - if n == 0 then return 1 end - local done = self.phase == "read" and self.at or n + self.at if self.phase == "done" then return 1 end - return math.min(1, done / (2 * n)) + return math.min(0.99, ((self.phase == "read" and 0 or #self.entries) + self.at) / math.max(1, 2 * #self.entries + 1)) +end + +function TrainerIdSync:_check(e) + if e.cart and self.SaveData.slotSealBroken(e.cart, e.slot) then return nil, "A save's seal is broken." end + local ok, pending = pcall(require("src.online.Trade").pendingSentAt, e.path) + if not ok or type(pending) ~= "table" then return nil, "Online trade status could not be read." end + if #pending > 0 then return nil, "Finish the pending online trade before using ID Sync." end + return true end function TrainerIdSync:_read(e) - local SaveData = self.SaveData - local ok, source = pcall(function() - if e.cart then return SaveData.readCartSlotSource(e.cart, e.slot) end - return SaveData.readSlotSource(e.version, e.slot) - end) - local save = ok and source and SaveData.decode(source) or nil - if type(save) ~= "table" then return end - e.save = save - e.gen = TrainerIdSync.generationOf(save, e.version) - local id = TrainerIdSync.playerIdOf(save, e.gen) - if id then self.ids[id] = true end - if e.scope == self.source.scope and e.slot == self.source.slot then - self.newId = id + local ok, why = self:_check(e) + if not ok then return nil, why end + e.before = self.fs.read(e.path) + e.save = e.before and self.SaveData.decode(e.before) + if type(e.save) ~= "table" then return nil, "A save could not be read: " .. e.path end + e.version = e.save.version or e.version + e.gen = TrainerIdSync.generationOf(e.save, e.version) + e.profile = Identity.profile(e.save, e.gen) + if not GameVersion.VERSIONS[e.version] or not e.profile + or e.gen ~= GameVersion.generation(e.version) then + return nil, "A save has an incomplete or mismatched trainer profile: " .. e.path end + TrainerIdSync.addOwner(self.owners, e.profile, e.gen) + if e.scope == self.source.scope and e.slot == self.source.slot then self.target = Store.copy(e.profile) end + return true end -function TrainerIdSync:_write(e) - if type(e.save) ~= "table" then return end - if not TrainerIdSync.rewriteSave(e.save, e.gen, self.newId, self.ids) then return end - local meta = type(e.save.meta) == "table" and e.save.meta or {} - e.save.meta = meta - meta.savedAt = os.time() - local SaveData = self.SaveData - local ok, wrote, err = pcall(function() - if e.cart then return SaveData.writeCartSlot(e.cart, e.slot, e.save) end - return SaveData.writeSlot(e.version, e.slot, e.save) - end) - if ok and wrote then - self.written = self.written + 1 - else - self.failed[#self.failed + 1] = tostring(ok and err or wrote) +function TrainerIdSync:_target() + if not self.target then return nil, "The selected save has no complete trainer identity." end + if self.target.sid == nil then + for _, e in ipairs(self.entries) do + if e.gen == 3 then self.target.sid = bit.bxor(e.profile.sid, e.profile.id, self.target.id); break end + end + self.target.sid = self.target.sid or 0 end + local checked = {} + for _, e in ipairs(self.entries) do checked[e.gen] = true end + local function checkEntry(entry) + checked[entry.generation] = true + for _, original in ipairs(entry.archives or {}) do checked[original.generation] = true end + end + if self.boxBody then + for _, box in ipairs(self.warehouse.boxes) do each(box.mons, checkEntry) end + each(self.warehouse.departures, function(d) checkEntry(d.entry) end) + end + for gen in pairs(checked) do + local codes, why + if gen < 3 then codes, why = Identity.nameCodes(self.target.name, gen) + else + local Codec = require("src.save_convert.Gen3Save") + local encoded = Codec.encodeString(self.target.name, 8) + codes = encoded:find("\255", 1, true) ~= nil + and Codec.decodeString(encoded, 0, 8) == self.target.name + if not codes then why = "The trainer name cannot be represented in a Gen 3 save." end + end + if not codes then return nil, why end + end + self.newId = self.target.id + return true +end + +function TrainerIdSync:_plan(e) + local changed, why = TrainerIdSync.rewriteSave(e.save, e.gen, self.target, self.owners, self.report) + if changed == nil then return nil, e.path .. ": " .. why end + e.changed = changed + return true +end + +function TrainerIdSync:_box() + if not self.boxBody then return true end + local Catalog, Records, Gifts = require("src.box.Catalog"), require("src.box.Records"), require("src.box.Gifts") + local function entrySync(entry) + local changed, why = syncMon(entry.mon, entry.generation, self.target, self.owners, nil, self.report) + if changed == nil then return nil, why end + if changed then entry.display = Catalog.describe(entry.version, entry.mon) end + for _, original in ipairs(entry.archives or {}) do + local ok, err = entrySync(original) + if not ok then return nil, err end + end + if entry.generation == 3 and entry.depositorId ~= nil then + for _, e in ipairs(self.entries) do + if e.gen == 3 and entry.depositorId == e.profile.id + e.profile.sid * 65536 + and (entry.slotId == nil or entry.slotId == e.slot and entry.version == e.version and entry.cartId == e.cart) then + entry.depositorId = self.target.id + self.target.sid * 65536 + break + end + end + end + return true + end + for _, box in ipairs(self.warehouse.boxes) do + for _, entry in pairs(box.mons) do + local ok, why = entrySync(entry) + if not ok then return nil, why end + end + end + for _, departure in pairs(self.warehouse.departures or {}) do + local ok, why = entrySync(departure.entry) + if not ok then return nil, why end + departure.identity = Records.identity(departure.entry.generation, departure.entry.mon) + end + for _, e in ipairs(self.entries) do + local source = { version = e.version, path = e.path } + local original = self.SaveData.decode(e.before) + local oldKey, newKey = Gifts.identity(source, original), Gifts.identity(source, e.save) + local progress = self.warehouse.progress + if progress and progress[oldKey] ~= nil and oldKey ~= newKey then + progress[newKey] = math.max(progress[newKey] or 0, progress[oldKey]) + progress[oldKey] = nil + end + end + return true +end + +function TrainerIdSync:_commit() + local ok, why + local changed = false + for _, e in ipairs(self.entries) do + if self.boxBody or e.changed then + local meta = type(e.save.meta) == "table" and e.save.meta or {} + e.save.meta = meta + if self.boxBody then + local opts = self.SaveData.loadOptions() + local mapped = opts.playthroughIds and opts.playthroughIds[e.scope] + local id = meta.playthroughId or mapped and mapped[e.slot] or self.SaveData.newPlaythroughId() + if meta.playthroughId ~= id or e.cart and meta.cartId ~= e.cart then e.changed = true end + meta.playthroughId = id + if e.cart then meta.cartId = e.cart end + self.warehouse.syncMembers = self.warehouse.syncMembers or {} + self.warehouse.syncMembers[e.version .. "/" .. meta.playthroughId] = { + version = e.version, playthroughId = meta.playthroughId, path = e.path, cartId = e.cart, slotId = e.slot } + end + if e.changed then + meta.savedAt = os.time() + self.jobs[#self.jobs + 1] = { path = e.path, before = e.before, after = self.SaveData.encode(e.save) } + changed = true + end + end + end + ok, why = self:_box() + if not ok then return nil, why end + if self.boxBody and self.SaveData.encode(self.warehouse) ~= self.boxBody then + local members = 0 + for _ in pairs(self.warehouse.syncMembers or {}) do members = members + 1 end + if members > 240 then return nil, "ID Sync exceeds the 240-save cloud Box limit." end + self.warehouse.revision, self.warehouse.savedAt = self.warehouse.revision + 1, os.time() + self.jobs[#self.jobs + 1] = { path = Store.PATH, before = self.boxBody, after = self.SaveData.encode(self.warehouse) } + self.boxUpdated, changed = true, true + end + if self.fs.read("options.lua") ~= self.optionsBody or self.fs.read(Store.PATH) ~= self.boxBody then + return nil, "Save settings or Box changed. Run ID Sync again." + end + for _, e in ipairs(self.entries) do + ok, why = self:_check(e) + if not ok then return nil, why end + if self.fs.read(e.path) ~= e.before then return nil, "A save changed. Run ID Sync again." end + end + if not changed then return true end + ok, why = Transaction.commitIdentity(self.fs, self.jobs) + if not ok then return nil, why end + for _, e in ipairs(self.entries) do if e.changed then self.written = self.written + 1 end end + return true end function TrainerIdSync:step() if self.phase == "done" then return true end - self.at = self.at + 1 - local e = self.entries[self.at] - if self.phase == "read" then - if e then self:_read(e) end - if self.at >= #self.entries then - if not self.newId then - self.error = "the selected save has no trainer ID" - self.phase = "done" - return true - end - self.phase, self.at = "write", 0 + local ok, why + if self.phase == "read" or self.phase == "plan" then + self.at = self.at + 1 + local e = self.entries[self.at] + if e then + if self.phase == "read" then ok, why = self:_read(e) else ok, why = self:_plan(e) end + else ok = true end + if ok and self.at >= #self.entries then + if self.phase == "read" then + ok, why = self:_target() + if ok then self.phase, self.at = "plan", 0 end + else self.phase = "commit" end end - return false - end - if e then - self:_write(e) - e.save = nil - end - if self.at >= #self.entries then - self.phase = "done" - return true + else + ok, why = self:_commit() + if ok then self.phase = "done"; return true end end + if not ok then self.error, self.phase = why, "done"; return true end return false end diff --git a/src/core/TrainerIdentity.lua b/src/core/TrainerIdentity.lua new file mode 100644 index 00000000..90ee7d47 --- /dev/null +++ b/src/core/TrainerIdentity.lua @@ -0,0 +1,168 @@ +local bit = require("bit") +local Identity = {} +local U32 = 4294967296 + +function Identity.u16(value) + local n = tonumber(value) + if not n or n ~= n or n < 0 or n == math.huge or n ~= math.floor(n) then return nil end + return n % 65536 +end +function Identity.packed(id, sid) + local n = tonumber(id) + if not n or n ~= n or n < 0 or n >= U32 or n ~= math.floor(n) then return nil end + return n % 65536 + (Identity.u16(sid) or math.floor(n / 65536)) * 65536 +end +function Identity.nameOf(save, gen) + if type(save) ~= "table" then return nil end + local player = type(save.player) == "table" and save.player or {} + local name = gen == 3 and (save.name or save.playerName or player.name) or player.name + return type(name) == "string" and name ~= "" and name or nil +end +function Identity.monName(mon) + if type(mon) ~= "table" then return nil end + local name = mon.otName or mon.ot or mon.originalTrainer + if type(name) == "string" then return name end + if type(mon.cartOt) == "string" and not mon.cartOt:find("[^%x]") then + local map, out = require("src.save_convert.data.charmap").byByte, {} + for token in mon.cartOt:gmatch("%x%x") do + local code = tonumber(token, 16) + if code == 0x50 then break end + local glyph = map[code] + if not glyph or glyph:sub(1, 1) == "<" then return nil end + out[#out + 1] = glyph + end + return table.concat(out) + end +end +function Identity.rawId(mon, gen) + local raw = type(mon) == "table" and mon.cartRaw + local at = gen == 1 and 25 or gen == 2 and 13 + if not at or type(raw) ~= "string" or #raw < at + 3 then return nil end + return tonumber(raw:sub(at, at + 3), 16) +end +function Identity.profile(save, gen) + if type(save) ~= "table" then return nil end + local name = Identity.nameOf(save, gen) + local id = gen == 3 and save.trainerId or save.player and save.player.id + local packed = Identity.packed(id, gen == 3 and save.secretId or 0) + if not packed or not name then return nil end + local sid = math.floor(packed / 65536) + if gen == 3 and save.secretId == nil and tonumber(id) < 65536 then + local function scan(list) + for _, mon in pairs(type(list) == "table" and list or {}) do + if type(mon) == "table" and Identity.u16(mon.otId) == packed + and Identity.monName(mon) == name and Identity.u16(mon.otSecretId) ~= nil then + return Identity.u16(mon.otSecretId) + end + end + end + sid = scan(save.party) + if sid == nil then + local boxes = type(save.storage) == "table" and save.storage.boxes + for _, box in pairs(type(boxes) == "table" and boxes or {}) do + if type(box) == "table" then sid = sid or scan(box.mons) end + end + end + sid = sid or 0 + end + return { id = packed % 65536, sid = gen == 3 and sid or nil, name = name } +end +function Identity.key(gen, id, name, sid) + return table.concat({ gen, id, #name, name, gen == 3 and (sid or 0) or 0 }, "|") +end +function Identity.owner(mon, gen, owners, localOwner) + local id = Identity.u16(mon.otId) or Identity.rawId(mon, gen) + if id == nil then return nil end + local name = Identity.monName(mon) + local packed = gen == 3 and Identity.packed(mon.otId, mon.otSecretId) + local sid = packed and math.floor(packed / 65536) or 0 + if name == nil and localOwner and not mon.traded and id == localOwner.id + and (gen ~= 3 or sid == (localOwner.sid or 0)) then name = localOwner.name end + if name == nil then return nil end + if gen == 3 and mon.otSecretId == nil and localOwner and not mon.traded and id == localOwner.id + and name == localOwner.name and tonumber(mon.otId) == id then sid = localOwner.sid or 0 end + return owners[Identity.key(gen, id, name, sid)] +end + +local function unown(pid) + return (bit.band(pid, 3) + bit.band(bit.rshift(pid, 6), 12) + + bit.band(bit.rshift(pid, 12), 48) + bit.band(bit.rshift(pid, 18), 192)) % 28 +end +local function shiny(pid, id, sid) + return bit.bxor(id, sid, pid % 65536, math.floor(pid / 65536)) < 8 +end +-- pokefirered/src/pokemon.c:6062 IsShinyOtIdPersonality +function Identity.personality(mon, target) + local pid = tonumber(mon.personality) + if not pid or pid ~= math.floor(pid) or pid < 0 or pid >= U32 then + return nil, "A Gen 3 Pokémon has no valid personality value." + end + local originalId = Identity.packed(mon.otId, mon.otSecretId) or 0 + local old = shiny(pid, originalId % 65536, math.floor(originalId / 65536)) + if mon.isShiny ~= nil then old = not not mon.isShiny end + if shiny(pid, target.id, target.sid) == old then return pid end + local low, high = pid % 65536, math.floor(pid / 65536) + if not old then + local nextHigh = high + 25600 <= 65535 and high + 25600 or high - 25600 + return nextHigh * 65536 + low + end + local isUnown = tonumber(mon.species) == 201 + local isWurmple = tonumber(mon.species) == 290 + local limit = isUnown and 65536 or 256 + for offset = 0, limit - 1 do + local nextLow = isUnown and (low + offset) % 65536 + or (low % 256 + ((math.floor(low / 256) + offset) % 256) * 256) + for key = 0, 7 do + local nextHigh = bit.bxor(target.id, target.sid, nextLow, key) + local nextPid = nextHigh * 65536 + nextLow + if nextPid % 25 == pid % 25 and nextPid % 2 == pid % 2 + and (not isUnown or unown(nextPid) == unown(pid)) + and (not isWurmple or (nextHigh % 10 < 5) == (high % 10 < 5)) then return nextPid end + end + end + return nil, "Shininess and this Pokémon's personality traits cannot be preserved together." +end + +function Identity.nameCodes(name, gen) + local map = gen == 2 and require("src.save_convert.Gen2Layout").charmap + or require("src.save_convert.data.charmap").byByte + local reverse = {} + for code, token in pairs(map) do + if type(token) == "string" and token ~= "" and code ~= 0x50 then reverse[token] = code end + end + local codes, pos = {}, 1 + while pos <= #name do + local best, code + for token, byte in pairs(reverse) do + if name:sub(pos, pos + #token - 1) == token and (not best or #token > #best) then + best, code = token, byte + end + end + if not best then return nil, "The trainer name cannot be represented in a Gen " .. gen .. " save." end + codes[#codes + 1], pos = code, pos + #best + end + if #codes > 7 then return nil, "The trainer name is longer than the game's seven-character limit." end + return codes +end + +function Identity.rename(mon, name, gen) + local changed, found = false, false + for _, key in ipairs({ "ot", "otName", "originalTrainer" }) do + if mon[key] ~= nil then + found = true + if mon[key] ~= name then mon[key], changed = name, true end + end + end + if not found then mon[gen == 3 and "otName" or "ot"], changed = name, true end + if gen == 1 and type(mon.cartOt) == "string" then + local codes, err = Identity.nameCodes(name, gen) + if not codes then return nil, err end + local tokens = {} + for _, code in ipairs(codes) do tokens[#tokens + 1] = ("%02X"):format(code) end + local prefix = table.concat(tokens) .. "50" + local raw = prefix .. mon.cartOt:sub(#prefix + 1) + if raw ~= mon.cartOt then mon.cartOt, changed = raw, true end + end + return changed +end +return Identity diff --git a/src/core/game3/battle/catching.lua b/src/core/game3/battle/catching.lua index a3cea4ac..7239f2f6 100644 --- a/src/core/game3/battle/catching.lua +++ b/src/core/game3/battle/catching.lua @@ -247,30 +247,7 @@ end -- pokefirered/src/new_game.c:56 function Catching.playerSecretId(session) - if type(session) ~= "table" then return 0 end - local sec = tonumber(session.secretId or session.otSecretId) - local tid = tonumber(session.trainerId or session.id or session.playerId) - if not sec and tid then - local function scan(list) - for _, m in pairs(list or {}) do - local ms = (type(m) == "table") and tonumber(m.otSecretId) or nil - if ms and tonumber(m.otId) == tid then return ms end - end - return nil - end - sec = scan(session.party) - local storage = session.storage - for _, box in pairs((not sec) and storage and storage.boxes or {}) do - sec = sec or scan(type(box) == "table" and box.mons or nil) - end - end - if not sec then - local okR, Rng = pcall(require, "src.core.game3.rng") - sec = (okR and Rng and Rng.Random and Rng.Random()) or math.random(0, 0xFFFF) - end - sec = math.floor(sec) % 0x10000 - session.secretId = sec - return sec + return Pokemon.playerSecretId(session) end -- pokefirered/src/battle_script_commands.c:9617 diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index df30c733..27f77da6 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -1280,25 +1280,22 @@ function Engine.moveLimitations(b, ad) end local function player_identity(st) - local id, nm = st.playerTrainerId, st.playerOtName or st.playerName - if id == nil then + local sess = st.session + if not sess then local Runtime = package.loaded["src.core.game3.runtime"] - local ok, sess = pcall(function() return Runtime and Runtime.getSession and Runtime.getSession() end) - if ok and sess then - id = sess.trainerId or sess.id or sess.playerId or 12345 - nm = sess.name or sess.playerName or nm or "RED" - end + local ok, current = pcall(function() return Runtime and Runtime.getSession and Runtime.getSession() end) + if ok then sess = current end end - return id, nm + sess = sess or {} + return { trainerId = st.playerTrainerId or sess.trainerId or sess.id or sess.playerId, + secretId = st.playerSecretId or sess.secretId, + name = st.playerOtName or st.playerName or sess.name or sess.playerName } end -- pokefirered/src/pokemon.c:5965 function Engine.isTradedMon(st, mon) if not mon or mon.otId == nil then return false end - local pid, pname = player_identity(st or {}) - if pid == nil then return false end - return tonumber(mon.otId) ~= tonumber(pid) - or (mon.otName ~= nil and pname ~= nil and tostring(mon.otName) ~= tostring(pname)) + return require("src.core.game3.pokemon").isTradedMon(mon, player_identity(st or {})) end -- src/battle_message.c:1180 diff --git a/src/core/game3/link/rs_record_mix.lua b/src/core/game3/link/rs_record_mix.lua index 94743e29..a278bfdb 100644 --- a/src/core/game3/link/rs_record_mix.lua +++ b/src/core/game3/link/rs_record_mix.lua @@ -152,4 +152,5 @@ function M.receive(s, packets, mine) giftItem = true, gift = receiveGift(s, players[1], mine)} end M.receiveTower = receiveTower +M.receiveGift = receiveGift return M diff --git a/src/core/game3/mon_anim.lua b/src/core/game3/mon_anim.lua index 8006dbcb..1429375c 100644 --- a/src/core/game3/mon_anim.lua +++ b/src/core/game3/mon_anim.lua @@ -3052,8 +3052,8 @@ function F.Anim_ShakeGlowBlue_Fast(sprite) shakeGlow(sprite, 10, 2, 2) end function F.Anim_ShakeGlowBlue(sprite) shakeGlow(sprite, 20, 1, 2) end function F.Anim_ShakeGlowBlue_Slow(sprite) shakeGlow(sprite, 80, 1, 2) end -function MonAnim.animFunction(id) - local name = Data.functionName(id) +function MonAnim.animFunction(id, animationData) + local name = (animationData or Data).functionName(id) local fn = name and F[name] if not fn then error("mon_anim: no port for anim function " .. tostring(id) .. " (" .. tostring(name) .. ")") end return fn, name @@ -3150,6 +3150,7 @@ end function MonAnim.newSprite(species, opts) opts = opts or {} + local animationData = opts.animationData or Data local sprite = { species = tonumber(species) or 0, data = { [0] = 0, 0, 0, 0, 0, 0, 0, 0 }, @@ -3163,7 +3164,8 @@ function MonAnim.newSprite(species, opts) centerToCornerVecX = -32, paletteNum = opts.paletteNum or 0, blendCoeff = 0, blendColor = 0, - anims = Data.anims(species) or { [0] = { { frame = 0, duration = 0 }, { op = "end" } } }, + anims = animationData.anims(species) or { [0] = { { frame = 0, duration = 0 }, { op = "end" } } }, + animationData = opts.animationData, animNum = 0, animCmdIndex = 0, animDelayCounter = 0, animLoopCounter = 0, animBeginning = true, animEnded = false, animPaused = false, frame = 0, @@ -3217,7 +3219,7 @@ local function Task_HandleMonAnimation(sprite, task) sprite.data[1] = 1 sprite.data[0] = 0 for i = 2, 7 do sprite.data[i] = 0 end - sprite.callback = MonAnim.animFunction(task.animId) + sprite.callback = MonAnim.animFunction(task.animId, sprite.animationData) sIsSummaryAnim = false task.state = task.state + 1 end @@ -3242,7 +3244,7 @@ function MonAnim.startSummary(sprite, frontAnimId) sIsSummaryAnim = true sprite.summary = true sprite.animId = frontAnimId - sprite.callback = MonAnim.animFunction(frontAnimId) + sprite.callback = MonAnim.animFunction(frontAnimId, sprite.animationData) end -- pokeemerald/src/pokemon_animation.c:956 @@ -3253,18 +3255,18 @@ function MonAnim.launchBack(sprite, backAnimSet, nature) sprite.animId = t.animId end -local function speciesConst(name) +local function speciesConst(name, version) local GameVersion = require("src.core.GameVersion") - return require("src.core.game3.constants").of(GameVersion.get()):id("species", name) + return require("src.core.game3.constants").of(version or GameVersion.get()):id("species", name) end -- pokeemerald/src/pokemon.c:6988 -function MonAnim.hasTwoFramesAnimation(species) +function MonAnim.hasTwoFramesAnimation(species, version) species = tonumber(species) - return species ~= speciesConst("SPECIES_CASTFORM") - and species ~= speciesConst("SPECIES_DEOXYS") - and species ~= speciesConst("SPECIES_SPINDA") - and species ~= speciesConst("SPECIES_UNOWN") + return species ~= speciesConst("SPECIES_CASTFORM", version) + and species ~= speciesConst("SPECIES_DEOXYS", version) + and species ~= speciesConst("SPECIES_SPINDA", version) + and species ~= speciesConst("SPECIES_UNOWN", version) end local function playCry(opts, species, pan) @@ -3339,9 +3341,10 @@ end -- pokeemerald/src/pokemon.c:6858 function MonAnim.summary(sprite, species, oneFrame) - if not oneFrame and MonAnim.hasTwoFramesAnimation(species) then MonAnim.startSpriteAnim(sprite, 1) end - local delay = Data.delay(species) - local animId = Data.frontAnimId(species) + local animationData = sprite.animationData or Data + if not oneFrame and MonAnim.hasTwoFramesAnimation(species, animationData.version) then MonAnim.startSpriteAnim(sprite, 1) end + local delay = animationData.delay(species) + local animId = animationData.frontAnimId(species) sprite.summary = true if delay ~= 0 then local t = createTask(sprite, Task_PokemonSummaryAnimateAfterDelay, 0) @@ -3472,9 +3475,10 @@ function MonAnim.draw(sprite, image, cx, cy, opts) opts = opts or {} local t = MonAnim.transform(sprite) if t.invisible then return end - local x, y = cx + t.x2, cy + t.y2 + local scale = opts.scale or 1 + local x, y = cx + t.x2 * scale, cy + t.y2 * scale local r, g, b, a = love.graphics.getColor() - love.graphics.draw(image, x, y, t.rotation, t.sx, t.sy, 32, 32) + love.graphics.draw(image, x, y, t.rotation, t.sx * scale, t.sy * scale, 32, 32) if (sprite.blendCoeff or 0) > 0 then if silhouette == nil then local okS, sh = pcall(love.graphics.newShader, [[ @@ -3488,7 +3492,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { local br, bg, bb, k = MonAnim.blendRgb(sprite) love.graphics.setShader(silhouette) love.graphics.setColor(br, bg, bb, math.min(1, k) * a) - love.graphics.draw(image, x, y, t.rotation, t.sx, t.sy, 32, 32) + love.graphics.draw(image, x, y, t.rotation, t.sx * scale, t.sy * scale, 32, 32) love.graphics.setShader() end end diff --git a/src/core/game3/party.lua b/src/core/game3/party.lua index 99d3ac7d..89703186 100644 --- a/src/core/game3/party.lua +++ b/src/core/game3/party.lua @@ -256,7 +256,7 @@ function Party.giveMon(session, species, level, nickname, opts) otName = session.name or session.playerName or "RED", otId = session.trainerId or session.id or session.playerId or 12345, -- pokefirered/src/pokemon.c:1796 CreateBoxMon OT_ID_PLAYER_ID - otSecretId = tonumber(session.secretId) or nil, + otSecretId = Pokemon.playerSecretId(session), -- pokefirered/src/pokemon.c:1822 otGender = Party.otGender(session), pokeball = 4, -- Poké Ball diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua index d9da4323..c1dab9ab 100644 --- a/src/core/game3/pokemon.lua +++ b/src/core/game3/pokemon.lua @@ -793,6 +793,7 @@ end local function player_identity(player) player = player or {} local id = player.trainerId or player.id or player.playerId + local sid = player.secretId local name = player.name or player.playerName or player.otName if id == nil or name == nil then local Runtime = package.loaded["src.core.game3.runtime"] @@ -801,17 +802,18 @@ local function player_identity(player) end) if ok and sess then id = id or sess.trainerId or sess.id or sess.playerId + sid = sid or sess.secretId name = name or sess.name or sess.playerName end end - return tonumber(id), name + return require("src.core.TrainerIdentity").packed(id, sid), name end -- pokefirered/src/pokemon.c:5974 IsOtherTrainer function Pokemon.isOtherTrainer(otId, otName, player) local playerId, playerName = player_identity(player) if playerId == nil then return false end - if tonumber(otId) ~= playerId then return true end + if require("src.core.TrainerIdentity").packed(otId) ~= playerId then return true end local mine = tostring(playerName or "") local theirs = tostring(otName or "") for i = 1, #theirs do @@ -820,10 +822,43 @@ function Pokemon.isOtherTrainer(otId, otName, player) return false end +-- pokefirered/src/new_game.c:56 +function Pokemon.playerSecretId(session) + if type(session) ~= "table" then return 0 end + local Identity = require("src.core.TrainerIdentity") + local sec = Identity.u16(session.secretId) or Identity.u16(session.otSecretId) + local tid = Identity.u16(session.trainerId or session.id or session.playerId) + local name = session.name or session.playerName + local function scan(list) + for _, m in pairs(type(list) == "table" and list or {}) do + local otName = type(m) == "table" and (m.otName or m.ot) + if type(m) == "table" and Identity.u16(m.otId) == tid + and not (type(name) == "string" and type(otName) == "string" and otName ~= name) then + local packed = Identity.packed(m.otId, m.otSecretId) + if packed and (Identity.u16(m.otSecretId) or packed >= 65536) then + return math.floor(packed / 65536) + end + end + end + end + if sec == nil and tid then + sec = scan(session.party) + local storage = session.storage + for _, box in pairs((sec == nil) and type(storage) == "table" and type(storage.boxes) == "table" + and storage.boxes or {}) do + sec = sec or scan(type(box) == "table" and box.mons or nil) + end + end + sec = sec or 0 + session.secretId = sec + return sec +end + -- pokefirered/src/pokemon.c:5965 IsTradedMon function Pokemon.isTradedMon(mon, player) if type(mon) ~= "table" or mon.otId == nil then return false end - return Pokemon.isOtherTrainer(mon.otId, mon.otName or mon.ot, player) + local id = require("src.core.TrainerIdentity").packed(mon.otId, mon.otSecretId) + return Pokemon.isOtherTrainer(id, mon.otName or mon.ot, player) end local function friendship_bonuses(mon, friendship, ctx) diff --git a/src/core/game3/rse/record_mixing_gift.lua b/src/core/game3/rse/record_mixing_gift.lua index a3756962..e7596573 100644 --- a/src/core/game3/rse/record_mixing_gift.lua +++ b/src/core/game3/rse/record_mixing_gift.lua @@ -84,6 +84,19 @@ local function pcHas(sess, item) return false end +local function writeGiftState(sess, kind, name, value) + if sess.store then + local id = constant(sess, kind, name) + if id then + local Flags = require("src.core.game3.scripting.flags") + local setter = kind == "flags" and Flags.setFlag or Flags.setVar + setter(sess.store, nil, id, value) + end + else + pcall(kind == "flags" and Rse.setFlag or Rse.setVar, name, value, sess) + end +end + -- pokeemerald/src/record_mixing.c:969 function Gift.mixImport(players, sess, myIndex) sess = sessionOf(sess) @@ -97,13 +110,13 @@ function Gift.mixImport(players, sess, myIndex) sess.bag = sess.bag or Bag.new() local got = not Bag.has(sess.bag, item, 1) and not pcHas(sess, item) and Bag.add(sess.bag, item, 1) if got then - pcall(Rse.setVar, "VAR_TEMP_RECORD_MIX_GIFT_ITEM", item, sess) + writeGiftState(sess, "vars", "VAR_TEMP_RECORD_MIX_GIFT_ITEM", item) if item == constant(sess, "items", "ITEM_EON_TICKET") then - pcall(Rse.setFlag, "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", true, sess) + writeGiftState(sess, "flags", "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", true) end return { item = item, from = p0.name or "" } end - pcall(Rse.setVar, "VAR_TEMP_RECORD_MIX_GIFT_ITEM", 0, sess) + writeGiftState(sess, "vars", "VAR_TEMP_RECORD_MIX_GIFT_ITEM", 0) return { item = 0 } end diff --git a/src/core/game3/save_mon.lua b/src/core/game3/save_mon.lua index 2c5df7a7..dff3e280 100644 --- a/src/core/game3/save_mon.lua +++ b/src/core/game3/save_mon.lua @@ -88,6 +88,24 @@ local function healIdFor(w) return false end +-- pokeemerald/include/global.h:956 +function M.boxFlags(save) + local md = type(save) == "table" and type(save.modData) == "table" and save.modData or {} + local ci, gift = md.cartImport, md.boxGift + if type(ci) == "table" and tonumber(ci.boxFlags) then return tonumber(ci.boxFlags) % 256 end + if type(gift) == "table" and tonumber(gift.flags) then return tonumber(gift.flags) % 256 end + return nil +end + +function M.setBoxFlags(save, value) + value = (tonumber(value) or 0) % 256 + save.modData = type(save.modData) == "table" and save.modData or {} + local ci = save.modData.cartImport + if type(ci) == "table" and ci.boxFlags ~= nil then ci.boxFlags = value end + if type(save.modData.boxGift) ~= "table" then save.modData.boxGift = {} end + save.modData.boxGift.flags = value +end + function M.finishCartImport(save) local ci = type(save.modData) == "table" and save.modData.cartImport if type(ci) ~= "table" or not Pokemon.isInternalSpecies(1) then return end @@ -120,6 +138,11 @@ function M.finishCartImport(save) else ci.lastHealLocation = nil end + if tonumber(ci.boxFlags) then + local flags = M.boxFlags(save) + ci.boxFlags = nil + M.setBoxFlags(save, flags) + end if ci.lastHealLocation == nil then save.modData.cartImport = nil end end diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 469b22b8..eb5c5a7e 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -70,6 +70,9 @@ local function is_own_mon(session, mon) if type(otName) == "string" and type(name) == "string" and otName ~= name then return false end local otId, tid = tonumber(mon.otId), tonumber(session.trainerId) if otId and tid and (otId % 0x10000) ~= (tid % 0x10000) then return false end + local Identity = require("src.core.TrainerIdentity") + local sid = Identity.u16(session.secretId) + if mon.otSecretId ~= nil and sid and Identity.u16(mon.otSecretId) ~= sid then return false end return true end @@ -83,21 +86,80 @@ local function repair_own_mon(session, mon) mon.metLocation = met end local secret = tonumber(session.secretId) - if secret and tonumber(mon.otSecretId) ~= secret then - mon.otSecretId = secret + if secret and mon.otSecretId == nil then + local otId = tonumber(mon.otId) + mon.otSecretId = otId and otId >= 0x10000 and math.floor(otId / 0x10000) % 0x10000 or secret end end -function Schema.repairOwnMons(session) - for _, mon in ipairs(session.party or {}) do - repair_own_mon(session, mon) +local function each_held_mon(session, fn) + local seen = {} + local function visit(mon) + if type(mon) ~= "table" or seen[mon] then return end + seen[mon] = true + fn(mon) end + local function visit_list(list) + if type(list) ~= "table" then return end + for _, mon in pairs(list) do visit(mon) end + end + visit_list(session.party) local storage = session.storage for _, box in pairs(storage and storage.boxes or {}) do - for _, mon in pairs(type(box) == "table" and box.mons or {}) do - repair_own_mon(session, mon) + visit_list(type(box) == "table" and box.mons or nil) + end + local function visit_held(mon) + if type(mon) == "table" and (tonumber(mon.species or mon.speciesId) or 0) ~= 0 then visit(mon) end + end + local function visit_held_list(list) + if type(list) ~= "table" then return end + for _, mon in pairs(list) do visit_held(mon) end + end + local md = type(session.modData) == "table" and session.modData or {} + local okD, Daycare = pcall(require, "src.core.game3.daycare") + local root = okD and md[Daycare.saveKey(session)] or nil + -- pokefirered/include/global.h:549 + local dcs = { type(root) == "table" and root.daycare or false, rawget(session, "daycare") or false } + for _, dc in ipairs(dcs) do + if type(dc) == "table" then + for i = 1, Daycare.DAYCARE_MON_COUNT do visit_held(dc[i]) end + visit_held_list(dc.mons) end end + -- pokefirered/src/daycare.c:1563 + local r5s = { type(root) == "table" and root.route5Daycare or false, rawget(session, "route5Daycare") or false } + for _, r5 in ipairs(r5s) do + if type(r5) == "table" then visit_held(r5.mon) end + end + -- pokefirered/src/load_save.c:160 + visit_held_list(session.savedPlayerParty) + visit_held_list(md.savedPlayerParty) +end + +function Schema.repairOwnMons(session) + require("src.core.game3.pokemon").playerSecretId(session) + each_held_mon(session, function(mon) repair_own_mon(session, mon) end) +end + +function Schema.reseatLegacySecretIds(session) + local Identity = require("src.core.TrainerIdentity") + local sid = Identity.u16(session.secretId) + local tid = Identity.u16(session.trainerId) + local name = session.name or session.playerName + if not sid or not tid or type(name) ~= "string" then return 0 end + local moved = 0 + local function reseat(mon) + if type(mon) ~= "table" or mon.otSecretId == nil then return end + local otName = mon.otName or mon.ot or mon.originalTrainer + if otName ~= name or Identity.u16(mon.otId) ~= tid then return end + if Identity.u16(mon.otSecretId) == sid then return end + local pid = Identity.personality(mon, { id = tid, sid = sid }) + if not pid then return end + mon.personality, mon.otSecretId, mon.otId = pid, sid, tid + moved = moved + 1 + end + each_held_mon(session, reseat) + return moved end -- pokefirered/src/union_room_chat.c:1430 @@ -308,7 +370,6 @@ function Schema.toSaveTable(session) move_overlay = session.move_overlay or {}, trainerId = session.trainerId, secretId = session.secretId, - secretId = session.secretId, rng = session.rng, vsSeeker = session.vsSeeker, roamer = session.roamer, @@ -456,7 +517,9 @@ function Schema.fromSaveTable(save) rules.resetStateOnContinue(session) rules.useContinueGameWarp(session) Schema.ensureMonBalls(session) + local legacySecret = save.secretId == nil and session.secretId == nil Schema.repairOwnMons(session) + if legacySecret then Schema.reseatLegacySecretIds(session) end if rules.repairSaveState then rules.repairSaveState(session) end Schema.repairRoamer(session) if type(save.options) == "table" then diff --git a/src/import/BoxDetails.lua b/src/import/BoxDetails.lua new file mode 100644 index 00000000..bba543a2 --- /dev/null +++ b/src/import/BoxDetails.lua @@ -0,0 +1,253 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Icons = require("src.ui.kit.Icons") +local UI = require("src.import.BoxUI") +local Details = {} +local PAL = Theme.PAL +Details.SECTIONS = { "Overview", "Moves", "Stats", "Origin" } +Details.HELP = { + Overview = "the basics for this pokémon. marks are just labels you can add. held items stay with it when you move it.", + Moves = "PP is how many uses the move has left. PP Ups increase its maximum. the bar shows what’s left right now.", + Stats = "current stats, built-in potential and training effort sit in separate columns. — means it wasn’t recorded.", + Origin = "who caught it and where it came from. trainer IDs distinguish trainers with the same name. missing details say Not recorded.", +} +function Details.help(entry, section) + if section ~= "Stats" then return Details.HELP[section] end + return entry.generation == 3 + and "IVs are the pokémon’s built-in potential. EVs are effort from training. — means it wasn’t recorded." + or "DVs are the pokémon’s built-in potential. Stat Exp is effort from training. Gen 2 shares its Special DV and effort between both special stats." +end +local OVERVIEW = { Experience = true, Gender = true, ["Friendship / egg cycles"] = true, + Nature = true, Ability = true, Markings = true, ["Pokérus"] = true } +local STAT_FIELDS = { Stats = true, IVs = true, DVs = true, EVs = true, ["Stat experience"] = true, Contest = true } +local STAT_ORDER = { "hp", "attack", "defense", "spAtk", "spDef", "speed", "special" } +local ALIASES = { atk = "attack", def = "defense", spe = "speed", spa = "spAtk", spd = "spDef", + specialAttack = "spAtk", specialDefense = "spDef" } +local LABELS = { hp = "HP", attack = "Attack", defense = "Defense", speed = "Speed", + spAtk = "Sp. Attack", spDef = "Sp. Defense", special = "Special" } +local FIELD_ICONS = { Experience = "award", Gender = "user-round", Nature = "paintbrush", Ability = "shield-check", + Friendship = "heart", ["Egg cycles"] = "heart", ["Held item"] = "backpack", Markings = "check", + ["Pokérus"] = "heart", ["Original Trainer"] = "user-round", ["Trainer ID"] = "users", ["Secret ID"] = "lock", + ["Poké Ball"] = "package", ["Met game"] = "flag", ["Met location"] = "map-pin", ["Met level"] = "award", + ["Fateful encounter"] = "flag", Tags = "file-pen-line" } +local function human(key) + return LABELS[key] or tostring(key):gsub("(%l)(%u)", "%1 %2"):gsub("_", " "):gsub("^%l", string.upper) +end +local function normalized(values) + local out = {} + for key, value in pairs(values or {}) do if not ALIASES[key] then out[key] = value end end + for key, value in pairs(values or {}) do + if ALIASES[key] then + if out[ALIASES[key]] == nil then out[ALIASES[key]] = value + elseif out[ALIASES[key]] ~= value then out[key] = value end + end + end + return out +end +local function number(value) + local text = tostring(value) + local sign, whole, tail = text:match("^(-?)(%d+)(.*)$") + if not whole then return text end + return sign .. whole:reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "") .. tail +end +local function shown(value) + if value == nil then return "Not recorded" end + if value == true then return "Yes" elseif value == false then return "No" end + return tostring(value) +end +local function statRows(entry) + local mon, gen = entry.mon, entry.generation + local stats = normalized(entry.display.stats) + local genes = normalized(gen == 3 and mon.ivs or mon.dvs) + local effort = normalized(gen == 3 and mon.evs or mon.statExp) + local derived + if gen <= 2 and genes.hp == nil and genes.attack ~= nil and genes.defense ~= nil + and genes.speed ~= nil and genes.special ~= nil then + genes.hp = require("src.battle.gen2.Mon").hpDV(genes); derived = true + end + if gen == 2 then + for _, key in ipairs({ "spAtk", "spDef" }) do + genes[key] = genes.special ~= nil and genes.special or genes[key] + effort[key] = effort.special ~= nil and effort.special or effort[key] + end + end + local keys, seen = {}, {} + local function add(key) + if not seen[key] then keys[#keys + 1], seen[key] = key, true end + end + for _, key in ipairs(STAT_ORDER) do + if stats[key] ~= nil or genes[key] ~= nil or effort[key] ~= nil then + if gen ~= 2 or key ~= "special" or stats.special ~= nil then add(key) end + end + end + local extras = {} + for _, values in ipairs({ stats, genes, effort }) do + for key in pairs(values) do + if not seen[key] and not (gen == 2 and key == "special") then extras[key] = true end + end + end + local sorted = {}; for key in pairs(extras) do sorted[#sorted + 1] = key end + table.sort(sorted, function(a, b) return tostring(a) < tostring(b) end) + for _, key in ipairs(sorted) do add(key) end + local rows = {} + local effortRecorded = type(gen == 3 and mon.evs or mon.statExp) == "table" + for _, key in ipairs(keys) do rows[#rows + 1] = { label = human(key), value = stats[key], gene = genes[key], + effort = effort[key] ~= nil and effort[key] or effortRecorded and 0 or nil } end + return rows, derived +end + +function Details.model(entry) + local d, mon = entry.display, entry.mon + local model = { overview = {}, origin = {}, moves = d.moveDetails or {}, ribbons = {}, contest = {} } + local moveNames = {}; for _, move in ipairs(model.moves) do moveNames[move.name] = true end + for _, field in ipairs(require("src.box.Metadata").summary(entry)) do + local label, value = field[1], field[2] + if not STAT_FIELDS[label] and not moveNames[label] and label ~= "Ribbons" then + local group = OVERVIEW[label] and model.overview or model.origin + if label == "Experience" and d.experience ~= nil then value = number(d.experience) + elseif label == "Friendship / egg cycles" then label = d.egg and "Egg cycles" or "Friendship" + elseif label == "Markings" and tonumber(value) then + local marks = {}; for i, mark in ipairs({ "Circle", "Square", "Triangle", "Heart" }) do + if math.floor(tonumber(value) / 2 ^ (i - 1)) % 2 == 1 then marks[#marks + 1] = mark end + end + value = #marks > 0 and table.concat(marks, " · ") or "None" + elseif label == "Pokérus" and tonumber(value) then + local status = tonumber(value) + value = status == 0 and "None" or "Strain " .. math.floor(status / 16) .. "\n" + .. (status % 16 == 0 and "Recovered" or status % 16 .. " days left") + elseif label == "Fateful encounter" then value = shown(d.fateful) + elseif (label == "Trainer ID" or label == "Secret ID") and tonumber(value) then + value = string.format("%05d", tonumber(value)) + elseif label == "Met game" and tonumber(value) then + for _, layout in ipairs({ "rs", "emerald", "frlg" }) do + local version = require("src.save_convert.gen3_layouts." .. layout).GAME_CODES[tonumber(value)] + if version then value = require("src.core.GameVersion").info(version).label; break end + end + end + group[#group + 1] = { label = label, value = value, icon = FIELD_ICONS[label] } + end + end + model.overview[#model.overview + 1] = { label = "Held item", value = d.item == nil and "Not recorded" or d.item ~= "" and d.item or "None", icon = "backpack" } + if entry.tags and entry.tags ~= "" then model.overview[#model.overview + 1] = { label = "Tags", value = entry.tags, icon = "file-pen-line" } end + model.stats, model.derivedHP = statRows(entry) + local contestKeys = {}; for key in pairs(mon.contest or {}) do contestKeys[#contestKeys + 1] = key end + table.sort(contestKeys, function(a, b) return tostring(a) < tostring(b) end) + for _, key in ipairs(contestKeys) do model.contest[#model.contest + 1] = { label = human(key), value = tostring(mon.contest[key]), icon = "award" } end + if entry.generation == 3 then + local Ribbons = require("src.core.game3.rse.ribbons") + for _, key in ipairs(Ribbons.COUNTED) do + local count = Ribbons.get(mon, key) + if count > 0 then model.ribbons[#model.ribbons + 1] = { label = human(key), value = count > 1 and "×" .. count or "Earned", icon = "award" } end + end + model.hasRibbons = true + end + return model +end + +local function fields(rows, x, y, w, m) + local gap, pad = 10 * m.s, 12 * m.s + local cols = w >= 580 * m.s and 3 or w >= 270 * m.s and 2 or 1 + local cw = (w - (cols - 1) * gap) / cols + for first = 1, #rows, cols do + local height = 0 + for i = first, math.min(#rows, first + cols - 1) do + local row = rows[i] + local labelH = math.max(18 * m.s, Kit.wrapHeight("micro", row.label, cw - 2 * pad - 24 * m.s)) + height = math.max(height, 2 * pad + labelH + 8 * m.s + Kit.wrapHeight("button", row.value, cw - 2 * pad)) + end + for i = first, math.min(#rows, first + cols - 1) do + local row, cx = rows[i], x + (i - first) * (cw + gap) + Theme.card(cx, y, cw, height, { shadow = true }) + Icons.draw(row.icon or "book-open", cx + pad, y + pad, 18 * m.s, PAL.blue, 1) + local labelH = math.max(18 * m.s, Kit.textWrapped("micro", row.label, cx + pad + 24 * m.s, + y + pad, cw - 2 * pad - 24 * m.s, PAL.muted)) + Kit.textWrapped("button", row.value, cx + pad, y + pad + labelH + 8 * m.s, cw - 2 * pad, + row.value == "Not recorded" and PAL.muted or PAL.heading) + end + y = y + height + gap + end + return y +end +local function heading(imp, label, help, x, y, w, m) + local h = math.max(Kit.tapMin(), Kit.textHeight("button")) + Kit.textBold("button", label, x, y + (h - Kit.textHeight("button")) / 2, PAL.heading) + if help then UI.help(imp, label, x + w - h, y, h, help) end + return y + h + 8 * m.s +end +local function statTable(entry, model, x, y, w, m) + local gen = entry.generation + local compact = w < 580 * m.s + local pad, rowH = 12 * m.s, (compact and 30 or 38) * m.s + local headerH = (compact and 28 or 34) * m.s + Theme.card(x, y, w, 2 * pad + headerH + #model.stats * rowH, { shadow = true }) + local inner, labelW = w - 2 * pad, (w - 2 * pad) * .37 + local numberW = (inner - labelW) / 3 + local headers = { "Value", gen == 3 and "IV" or "DV", gen == 3 and "EV" or "Stat Exp" } + Kit.text("micro", "Stat", x + pad, y + pad + 6 * m.s, PAL.muted) + for i, label in ipairs(headers) do Kit.textCenter("micro", label, x + pad + labelW + (i - 1) * numberW, + y + pad + 6 * m.s, numberW, PAL.muted) end + local cy = y + pad + headerH + for i, row in ipairs(model.stats) do + if i % 2 == 1 then Theme.fillRounded(x + 4 * m.s, cy, w - 8 * m.s, rowH, PAL.raised, .8, 5 * m.s) end + Kit.text("small", row.label, x + pad, cy + (rowH - Kit.textHeight("small")) / 2, PAL.text) + for column, key in ipairs({ "value", "gene", "effort" }) do + local value = row[key] == nil and "—" or tostring(row[key]) + Kit.textCenterBold(column == 1 and "button" or "small", value, x + pad + labelW + (column - 1) * numberW, + cy + (rowH - Kit.textHeight(column == 1 and "button" or "small")) / 2, numberW, + column == 1 and PAL.heading or row[key] == nil and PAL.muted or PAL.blue) + end + cy = cy + rowH + end + y = cy + pad + 10 * m.s + Kit.text("micro", "— not recorded" .. (model.derivedHP and " · HP DV is derived" or ""), x, y, PAL.muted) + return y + Kit.textHeight("micro") + 10 * m.s +end +local function moves(rows, x, y, w, m) + local pad, gap = 14 * m.s, 10 * m.s + for _, move in ipairs(rows) do + local h = 2 * pad + Kit.textHeight("button") + Kit.textHeight("micro") + 20 * m.s + local ppW = math.min(116 * m.s, w * .34) + local titleW = w - 2 * pad - 30 * m.s - ppW - gap + local titleH = Kit.wrapHeight("button", tostring(move.name), titleW) + if titleH > Kit.textHeight("button") then h = h + titleH - Kit.textHeight("button") end + Theme.card(x, y, w, h, { shadow = true }) + Icons.draw("book-open", x + pad, y + pad, 22 * m.s, PAL.blue, 1) + Kit.textWrapped("button", tostring(move.name), x + pad + 30 * m.s, y + pad, titleW, PAL.heading) + Kit.textRight("button", (move.pp == nil and "—" or move.pp) .. " / " .. (move.maxPP == nil and "—" or move.maxPP), + x + w - pad, y + pad, PAL.heading) + Kit.text("micro", tostring(move.ppUps or 0) .. " PP Ups", x + pad + 30 * m.s, y + h - pad - Kit.textHeight("micro"), PAL.muted) + Kit.textRight("micro", "PP", x + w - pad, y + h - pad - Kit.textHeight("micro"), PAL.muted) + local trackW, trackH = w - 2 * pad, 3 * m.s + Theme.fillRounded(x + pad, y + h - 7 * m.s, trackW, trackH, PAL.line, .4, trackH) + if type(move.pp) == "number" and type(move.maxPP) == "number" and move.maxPP > 0 then + Theme.fillRounded(x + pad, y + h - 7 * m.s, trackW * math.max(0, math.min(1, move.pp / move.maxPP)), trackH, + move.pp == 0 and PAL.red or PAL.blue, 1, trackH) + end + y = y + h + gap + end + if #rows == 0 then y = fields({ { label = "Moves", value = "None recorded", icon = "book-open" } }, x, y, w, m) end + return y +end +local modelCache = setmetatable({}, { __mode = "k" }) +function Details.draw(imp, entry, section, x, y, w, m) + local hit = modelCache[entry] + if not hit or hit.display ~= entry.display or hit.mon ~= entry.mon or hit.tags ~= entry.tags then + hit = { display = entry.display, mon = entry.mon, tags = entry.tags, model = Details.model(entry) } + modelCache[entry] = hit + end + local top, model = y, hit.model + if section == "Overview" then y = fields(model.overview, x, y, w, m) + elseif section == "Moves" then y = moves(model.moves, x, y, w, m) + elseif section == "Stats" then + y = statTable(entry, model, x, y, w, m) + if #model.contest > 0 then y = heading(imp, "Contest", nil, x, y, w, m); y = fields(model.contest, x, y, w, m) end + else + y = fields(model.origin, x, y, w, m) + if model.hasRibbons then + y = heading(imp, "Ribbons", nil, x, y, w, m) + y = fields(#model.ribbons > 0 and model.ribbons or { { label = "Ribbons", value = "None", icon = "award" } }, x, y, w, m) + end + end + return y - top +end +return Details diff --git a/src/import/BoxOrganizer.lua b/src/import/BoxOrganizer.lua new file mode 100644 index 00000000..44dc879f --- /dev/null +++ b/src/import/BoxOrganizer.lua @@ -0,0 +1,399 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local UI = require("src.import.BoxUI") +local Store = require("src.box.Store") +local Organizer = require("src.box.Organizer") +local Catalog = require("src.box.Catalog") +local GameVersion = require("src.core.GameVersion") +local PAL = Theme.PAL +local Panel = {} + +local function newRule() + return { match = "all", firstBox = 1, lastBox = 1, conditions = { { field = "shiny", value = true } } } +end +local function conditionLabel(condition) + local value = condition.value + if type(value) == "boolean" then value = value and "Yes" or "No" end + return (Organizer.FIELD_LABELS[condition.field] or condition.field) .. ": " .. tostring(value or "Choose") +end +local function ruleLabel(rule, index) + return index .. ". " .. conditionLabel(rule.conditions[1]) .. (#rule.conditions > 1 and " + more" or "") + .. " → Box " .. rule.firstBox .. (rule.lastBox ~= rule.firstBox and "–" .. rule.lastBox or "") +end +local valueCache = setmetatable({}, { __mode = "k" }) +local function computeValues(field, layout, target, source) + if field == "shiny" or field == "egg" then return { true, false }, { [true] = "Yes", [false] = "No" } end + if field == "levelMin" or field == "levelMax" then + local out = {}; for level = 0, 255 do out[#out + 1] = level end; return out + end + if field == "game" then + local labels = {}; for _, version in ipairs(GameVersion.ORDER) do labels[version] = GameVersion.info(version).label end + return GameVersion.ORDER, labels + end + if field == "mark" then return { "circle", "square", "triangle", "heart" } end + if field == "gender" then return { "Male", "Female", "Genderless" } end + local out, seen = {}, {} + local function add(value) + if type(value) == "string" and value ~= "" and not seen[value:lower()] then + seen[value:lower()], out[#out + 1] = true, value + end + end + for _, row in ipairs(layout.rows) do + local d = row.entry.display + if field == "type" or field == "move" then + for value in tostring(field == "type" and d.types or d.moves or ""):gmatch(field == "type" and "[^/]+" or "[^,]+") do + add(value:match("^%s*(.-)%s*$")) + end + elseif field == "tag" then add(row.entry.tags) + else add(d[field]) end + end + if field == "species" then + for _, version in ipairs(target == "pc" and source and { source.version } or GameVersion.ORDER) do + local data = Catalog.get(version) + for _, value in pairs(data.names or {}) do add(value) end + for key, def in pairs(data.pokemon or {}) do add(type(def) == "table" and (def.name or key) or key) end + end + end + table.sort(out, function(a, b) return a:lower() < b:lower() end) + return out +end +local function values(field, layout, target, source) + local byLayout = valueCache[layout] + if not byLayout then byLayout = {}; valueCache[layout] = byLayout end + local key = tostring(field) .. "|" .. tostring(target) .. "|" .. tostring(source and source.version) + local hit = byLayout[key] + if not hit then + local out, labels = computeValues(field, layout, target, source) + hit = { out, labels }; byLayout[key] = hit + end + return hit[1], hit[2] +end +local function clampBoxes(config, count) + local out = Store.copy(config) + if out.box and (out.box < 1 or out.box > count) then out.box = nil end + for _, rule in ipairs(out.rules or {}) do + local first = math.max(1, math.min(count, rule.firstBox or 1)) + rule.firstBox, rule.lastBox = first, math.max(first, math.min(count, rule.lastBox or first)) + end + return out +end +local function findProfile(profiles, mode, key) + if not key then return nil end + local hinted = profiles[key.index] + if hinted and hinted.config and hinted.config.mode == mode and hinted.name == key.name and hinted.target == key.target then + return key.index + end + for i, p in ipairs(profiles) do + if p.config and p.config.mode == mode and p.name == key.name and p.target == key.target then return i end + end +end + +local function card(x, y, w, h, title, number, m, accent) + Theme.card(x,y,w,h,{stroke=accent or PAL.line,strokeA=accent and .8 or .35}) + local pad = 14*m.s + if number then + Theme.strokeRounded(x+pad,y+pad,26*m.s,26*m.s,PAL.line,.65,1,13*m.s) + Kit.textCenterBold("micro",tostring(number),x+pad,y+pad+5*m.s,26*m.s,PAL.heading) + end + Kit.textBold("small",title,x+pad+(number and 38*m.s or 0),y+pad+3*m.s,PAL.heading) +end +local function changed(o) o.preview,o.previewPage,o.signature=nil,1,nil end +local function previewColumns(w,m,compact,n) + return compact and n or math.max(2,math.min(6,math.floor(w/(46*m.s)))) +end +local function previewActions(imp,s,o,layout,report,why,x,y,w,m,api,compact) + local gap,h=8*m.s,math.max(Kit.tapMin(),m.btnH) + Kit.textBold("micro","AFTER APPLYING",x,y,PAL.muted);y=y+Kit.textHeight("micro")+gap + local boxes=report and report.boxes + local n=layout.count + local cols=previewColumns(w,m,compact,n) + local gridGap=compact and 2*m.s or gap + local cw=(w-(cols-1)*gridGap)/cols + local bh=compact and 30*m.s or 54*m.s + for b=1,n do + local cx=x+(b-1)%cols*(cw+gridGap) + local cy=y+math.floor((b-1)/cols)*(bh+gap) + local box=boxes and boxes[b] + local count=box and box.after or 0 + local color=PAL.muted + if o.analysis and o.analysis.reserved[b] then color=PAL.blue end + Theme.card(cx,cy,cw,bh,{fill=PAL.rowBg,strokeA=.35}) + if not compact then + Kit.textBold("micro",tostring(b),cx+5*m.s,cy+5*m.s,PAL.heading) + Kit.text("micro",count.."/"..layout.capacity,cx+5*m.s,cy+25*m.s,PAL.muted) + end + local barY=compact and cy or cy+bh-8*m.s + Theme.fillRounded(cx+2,barY,math.max(1,cw-4),compact and bh or 3*m.s,PAL.raised,1,2) + if count>0 then Theme.fillRounded(cx+2,barY,math.max(1,(cw-4)*count/layout.capacity), + compact and bh or 3*m.s,color,1,2) end + end + y=y+math.ceil(n/cols)*(bh+gap) + local status=why or (report and (report.total.." Pokémon · "..report.moved.." moves") or "Choose your rules.") + y=y+Kit.textWrapped("micro",status,x,y,w,why and PAL.yellow or PAL.muted,3)+gap + api.button(imp,x,y,(w-gap)/2,h,"organize-preview","Preview",function() + local source=s.sources[s.sourceIndex] + local p,err=s.service:previewOrganize(o.target,source,o.effective or o.config) + o.preview,s.notice,o.previewBox=p,err,1 + if p then + local moves={} + for _,row in ipairs(p.report.assignments) do + if row.moved then moves[#moves+1]={label=tostring(row.entry.display.name).." · Box "..row.fromBox.."/"..row.fromSlot.. + " → "..row.box.."/"..row.slot,entry=row.entry} end + end + UI.open(imp,"Preview · "..p.report.moved.." moves",moves) + end + end,false,why~=nil,"eye") + api.button(imp,x+(w+gap)/2,y,(w-gap)/2,h,"organize-apply", + compact and "Apply" or o.target=="pc" and "Apply to game" or "Apply to Box",function() + if api.perform(imp,function() return s.service:applyOrganize(o.preview) end, + o.target=="pc" and "Game PC arranged. Saved and backed up." or "Box arranged. Previous layout backed up.") then changed(o) end + end,false,not o.preview or o.preview.report.moved==0 or why~=nil,"save") + return y+h +end + +function Panel.drawSticky(imp,m) + local f=imp._boxOrganizerFooter + if not f or imp.tab~="box" or not imp._boxState or imp._boxState.toolsPage~="Arrange" then return end + local overlay=Kit._overlay;Kit._overlay=true + Theme.fillRounded(f.x-8*m.s,f.y-10*m.s,f.w+16*m.s,f.h+20*m.s,PAL.surface,1,10) + Theme.fill(f.x-8*m.s,f.y-10*m.s,f.w+16*m.s,1,PAL.line,.55) + previewActions(imp,f.s,f.o,f.layout,f.report,f.why,f.x,f.y,f.w,m,f.api,true) + Kit._overlay=overlay +end + +function Panel.draw(imp,s,x,y,w,m,api,mode) + local top,gap,h,pad=y,10*m.s,math.max(Kit.tapMin(),m.btnH),14*m.s + s.organizers=s.organizers or {} + local pick=s.organizers[mode] or {target="box"} + s.organizers[mode]=pick + local function stateFor(target) + local key=mode..":"..target + s.organizers[key]=s.organizers[key] or {target=target,ruleIndex=1,conditionIndex=1, + config={mode=mode,sort="species",descending=false,fallback="keep",rules={newRule()}}} + return s.organizers[key] + end + local o=stateFor(pick.target=="pc" and "pc" or "box") + local source=s.sources[s.sourceIndex] + local context=o.target..":"..tostring(source and source.path) + if o.context~=context then o.context=context;changed(o) end + if o.preview and o.preview.boxBefore~=s.service.body then changed(o) end + local config=o.config + local input=o.target=="pc" and s.pcSave or s.service.state + if o.layoutInput~=input or o.layoutContext~=context then + o.layoutInput,o.layoutContext=input,context + if o.target=="pc" then + if source and s.pcSave then o.layout,o.layoutError=Organizer.game(s.pcSave,source.version) + else o.layout,o.layoutError=nil,"Choose a readable game save above." end + else o.layout,o.layoutError=Organizer.warehouse(s.service.state),nil end + end + local layout,why=o.layout,o.layoutError + local function btn(id,label,fn,bx,bw,active,disabled,icon) + UI.button(imp,bx or x,y,bw or w,h,"organize-"..id,label,fn, + {face=active and "tab" or "selection",active=active,enabled=not disabled,icon=icon}) + end + local function targetCard(info,color) + local half=(w-2*pad-gap)/2 + local infoH=Kit.wrapHeight("micro",info,w-2*pad,3) + local cardH=52*m.s+h+6*m.s+infoH+pad + card(x,y,w,cardH,"Which boxes?",1,m) + y=y+52*m.s + btn("target-pc","Game PC",function() pick.target="pc" end,x+pad,half,o.target=="pc",not source,"monitor") + btn("target-box","Box storage",function() pick.target="box" end,x+pad+half+gap,half,o.target=="box",false,"package") + y=y+h+6*m.s + Kit.textWrapped("micro",info,x+pad,y,w-2*pad,color or PAL.muted,3) + return cardH + end + if not layout then return targetCard(tostring(why or ""),PAL.yellow)+gap end + if not o.signature or o.layoutSource~=(o.target=="pc" and s.pcSave or s.service.state) then + o.preview=nil;o.signature=true;o.layoutSource=o.target=="pc" and s.pcSave or s.service.state + o.effective=clampBoxes(config,layout.count) + o.analysis,o.analysisError=Organizer.analyze(layout,o.effective) + local planned, result=Organizer.plan(layout,o.effective) + o.plannedBoxes=planned + o.planError=not planned and result or nil + end + local report=o.preview and o.preview.report or o.plannedBoxes + local wide=w>=740*m.s + local fullX,fullW=x,w + local rightX,rightW + if wide then w=(w-2*gap)*.6;rightX=x+w+2*gap;rightW=fullW-w-2*gap end + local view=imp._tabRegionRect + local footerH + if not wide and view and view.h>260*m.s then + local status=o.planError or (report and (report.total.." Pokémon · "..report.moved.." moves") or "") + footerH=Kit.textHeight("micro")+3*gap+30*m.s+Kit.wrapHeight("micro",status,fullW,3)+h+10*m.s + local fy=math.min(view.y+view.h,m.H-40*m.s)-footerH + imp._boxOrganizerFooter={s=s,o=o,layout=layout,report=report,why=o.planError, + x=fullX,y=fy,w=fullW,h=footerH,api=api} + Kit.occlude(fullX,fy-10*m.s,fullW,footerH+20*m.s) + end + local function dropdown(id,label,choices,current,apply,bx,bw,icon) + UI.dropdown(imp,bx or x,y,bw or w,h,"organize-"..id,label,choices,current,function(v) apply(v);changed(o) end,icon) + end + local profiles=s.service.state.organizerProfiles or {} + local options={} + for i,p in ipairs(profiles) do + if p.config.mode==mode then + options[#options+1]={label=p.name,icon="save",action=function() + local t=stateFor(p.target=="pc" and "pc" or "box") + pick.target=t.target + t.config,t.profile=Store.copy(p.config),{index=i,name=p.name,target=p.target};t.ruleIndex=1;changed(t) + end} + end + end + local profileIndex=findProfile(profiles,mode,o.profile) + local function stalePreset() + s.notice,o.profile="That preset changed. Choose it again.",nil + end + local function savePreset(update) + require("src.import.BoxPrompt").open(imp,"Preset name",update and profiles[profileIndex] and profiles[profileIndex].name or "",64,function(name) + local at=update and findProfile(s.service.state.organizerProfiles or {},mode,o.profile) or nil + if update and not at then return stalePreset() end + local target,config=o.target,o.config + if api.perform(imp,function() return s.service:saveOrganizer(name,target,config,at) end,"Preset saved.") then + o.profile={index=at or #s.service.state.organizerProfiles,name=name,target=target};changed(o) + end + end) + end + options[#options+1]={label="Save as new preset",icon="plus",disabled=#profiles>=20,action=function() savePreset(false) end} + if profileIndex then + options[#options+1]={label="Update preset",icon="save",action=function() savePreset(true) end} + options[#options+1]={label="Delete preset",icon="trash",action=function() + local at=findProfile(s.service.state.organizerProfiles or {},mode,o.profile) + if not at then return stalePreset() end + if api.perform(imp,function() return s.service:deleteOrganizer(at) end,"Preset removed.") then o.profile=nil end + end} + end + dropdown("presets",profileIndex and profiles[profileIndex].name or "Presets",options,nil,function() end,x,w,"save") + y=y+h+gap + local cardH=targetCard(layout.count.." boxes · "..layout.capacity.." slots each · "..#layout.rows.." Pokémon") + y=top+h+gap+cardH+gap + local innerX,innerW=x+pad,w-2*pad + local boxes,labels={},{} + for b=1,layout.count do boxes[b],labels[b]=b,"Box "..b end + if mode=="sort" then + card(x,y,w,120*m.s,"Choose boxes",2,m) + y=y+50*m.s + local scopes={{id=0,label="All boxes",icon="grid-2x2"}} + for b=1,layout.count do scopes[#scopes+1]={id=b,label=labels[b],icon="package"} end + dropdown("scope",config.box and labels[config.box] or "All boxes",scopes,config.box or 0, + function(v) config.box=v~=0 and v or nil end,innerX,innerW,"package") + y=y+h+gap+pad + else + config.rules=config.rules or {newRule()} + local titleY=y + card(x,y,w,54*m.s,"Rules",2,m);y=y+62*m.s + Kit.text("micro","Checked from the top. First match wins.",innerX,y,PAL.muted) + y=y+Kit.textHeight("micro")+gap + for index,rule in ipairs(config.rules) do + local expanded=o.ruleIndex==index + local counts=o.analysis and o.analysis.ruleCounts[index] or 0 + local start=y + local lines=#rule.conditions + local rh=expanded and (h*(lines+2)+gap*(lines+3)+88*m.s) or h+22*m.s + local needed=math.max(1,math.ceil(counts/layout.capacity)) + local oversized=rule.lastBox-rule.firstBox+1>needed + if expanded and oversized then rh=rh+h+gap end + Theme.card(innerX,y,innerW,rh,{fill=PAL.rowBg,strokeA=expanded and .65 or .35}) + UI.button(imp,innerX+8*m.s,y+6*m.s,innerW-16*m.s,h,"organize-rule-"..index, + ruleLabel(rule,index).." · "..counts.." match",function() o.ruleIndex=expanded and 0 or index end, + {face="bare",align="left",trailingIcon=expanded and "chevron-up" or "chevron-down"}) + y=y+h+gap+8*m.s + if expanded then + local usableX,usableW=innerX+12*m.s,innerW-24*m.s + local cw=(usableW-gap)/2 + for ci,c in ipairs(rule.conditions) do + local suffix=index==1 and ci==1 and "" or "-"..index.."-"..ci + local fields={} + for _,field in ipairs(Organizer.FIELDS) do + if Organizer.available(field,o.target=="pc" and source.version or nil) then fields[#fields+1]=field end + end + dropdown("field"..suffix,Organizer.FIELD_LABELS[c.field],UI.values(fields,Organizer.FIELD_LABELS,"list-filter"),c.field,function(v) + c.field=v;local choices=values(v,layout,o.target,source);c.value=choices[1]~=nil and choices[1] or "" + end,usableX,cw,"list-filter") + local choices,valueLabels=values(c.field,layout,o.target,source) + local opts=UI.values(choices,valueLabels,"check") + if type(c.value)=="string" then + opts[#opts+1]={label="Type a value…",icon="pencil",action=function() + require("src.import.BoxPrompt").open(imp,Organizer.FIELD_LABELS[c.field],c.value,128,function(v) c.value=v;changed(o) end) + end} + end + dropdown("value"..suffix,valueLabels and valueLabels[c.value] or tostring(c.value),opts,c.value, + function(v) c.value=v end,usableX+cw+gap,cw,"check") + y=y+h+gap + end + local actions={ + {label="Add condition",icon="plus",disabled=lines>=8,action=function() + rule.conditions[#rule.conditions+1]={field="shiny",value=true};changed(o) + end}, + {label="Remove last condition",icon="minus",disabled=lines==1,action=function() table.remove(rule.conditions);changed(o) end}, + {label="Match all conditions",icon="check",action=function() rule.match="all";changed(o) end}, + {label="Match any condition",icon="check",action=function() rule.match="any";changed(o) end}, + {label="Move earlier",icon="arrow-up",disabled=index==1,action=function() + config.rules[index],config.rules[index-1]=config.rules[index-1],rule;o.ruleIndex=index-1;changed(o) + end}, + {label="Move later",icon="arrow-down",disabled=index==#config.rules,action=function() + config.rules[index],config.rules[index+1]=config.rules[index+1],rule;o.ruleIndex=index+1;changed(o) + end}, + {label="Remove rule",icon="trash",disabled=#config.rules==1,action=function() + table.remove(config.rules,index);o.ruleIndex=math.max(1,index-1);changed(o) + end}} + dropdown("rule-options"..(index==1 and "" or "-"..index),lines>1 and ("Conditions: "..rule.match) or "Add condition / rule options", + actions,nil,function() end,usableX,usableW,"plus");y=y+h+gap + Kit.text("micro","SEND TO",usableX,y,PAL.muted);y=y+Kit.textHeight("micro")+5*m.s + dropdown("first-box"..(index==1 and "" or "-"..index),labels[rule.firstBox] or "Box "..rule.firstBox,UI.values(boxes,labels,"package"),rule.firstBox, + function(v) rule.firstBox,rule.lastBox=v,math.max(v,rule.lastBox) end,usableX,cw,"package") + dropdown("last-box"..(index==1 and "" or "-"..index),"through "..(labels[rule.lastBox] or "Box "..rule.lastBox),UI.values(boxes,labels,"package"),rule.lastBox, + function(v) rule.lastBox,rule.firstBox=v,math.min(v,rule.firstBox) end,usableX+cw+gap,cw,"package") + y=y+h+gap + if oversized then + UI.button(imp,usableX,y,usableW,h,"organize-shrink-"..index, + counts.." match · Use only Box "..rule.firstBox.."–"..(rule.firstBox+needed-1),function() + rule.lastBox=rule.firstBox+needed-1;changed(o) + end,{icon="undo-2",face="invert"}) + y=y+h+gap + end + end + y=math.max(y,start+rh)+gap + end + btn("add-rule","Add another rule",function() + config.rules[#config.rules+1]=newRule();o.ruleIndex=#config.rules;changed(o) + end,innerX,innerW,false,#config.rules>=24,"plus");y=y+h+gap + if y-titleY<54*m.s then y=titleY+54*m.s end + local remaining=o.analysis and o.analysis.remaining or 0 + local noRoom=config.fallback=="sort" and o.analysis and remaining>o.analysis.freeBoxes*layout.capacity + card(x,y,w,50*m.s+2*Kit.textHeight("micro")+2*gap+h+pad,"Everyone else",3,m,noRoom and PAL.yellow or nil);y=y+50*m.s + Kit.text("micro",remaining.." Pokémon don't match any rule.",innerX,y,PAL.muted) + y=y+Kit.textHeight("micro")+gap + local cw=(innerW-gap)/2 + btn("fallback-keep","Leave them where they are",function() config.fallback="keep";changed(o) end, + innerX,cw,config.fallback=="keep",false,"map-pin") + btn("fallback-sort","Fill other boxes",function() config.fallback="sort";changed(o) end, + innerX+cw+gap,cw,config.fallback=="sort",false,"package") + y=y+h+gap + Kit.text("micro",noRoom and "No room left. Shrink a rule's range or keep their boxes." + or config.fallback=="keep" and "Same box, same slot." or (o.analysis and o.analysis.freeBoxes or 0).." boxes no rule uses.", + innerX,y,noRoom and PAL.yellow or PAL.muted);y=y+Kit.textHeight("micro")+pad+gap + end + card(x,y,w,116*m.s,"Order inside each box",mode=="sort" and 3 or 4,m) + y=y+50*m.s + local cw=(innerW-gap)/2 + dropdown("sort",Organizer.LABELS[config.sort],UI.values(Organizer.SORTS,Organizer.LABELS,"arrow-up-down"),config.sort, + function(v) config.sort=v end,innerX,cw,"arrow-up-down") + dropdown("direction",config.descending and "High → Low" or "Low → High", + UI.values({false,true},{[false]="Low → High",[true]="High → Low"}),config.descending, + function(v) config.descending=v end,innerX+cw+gap,cw,"arrow-up-down") + y=y+h+pad+gap + if wide then + local cols=previewColumns(rightW-2*pad,m,false,layout.count) + card(rightX,top,rightW,math.ceil(layout.count/cols)*62*m.s+100*m.s,"",nil,m) + local bottom=previewActions(imp,s,o,layout,report,o.planError,rightX+pad,top+pad,rightW-2*pad,m,api,false) + y=math.max(y,bottom+gap);imp._boxOrganizerFooter=nil + else + if footerH then + y=y+footerH+gap + else y=previewActions(imp,s,o,layout,report,o.planError,x,y,w,m,api,true)+gap end + end + return y-top +end +return Panel diff --git a/src/import/BoxPanel.lua b/src/import/BoxPanel.lua new file mode 100644 index 00000000..c3e12e0c --- /dev/null +++ b/src/import/BoxPanel.lua @@ -0,0 +1,876 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local GameVersion = require("src.core.GameVersion") +local Service = require("src.box.Service") +local Store = require("src.box.Store") +local Records = require("src.box.Records") +local Catalog = require("src.box.Catalog") +local Sprites = require("src.online.OnlineSprites") +local Icons = require("src.ui.kit.Icons") +local Animation = require("src.box.Animation") +local UI = require("src.import.BoxUI") + +local PAL = Theme.PAL +local BoxPanel = {} +local function LV() return require("src.import.LauncherView") end + +local function readPC(s) + s.pcRows, s.pcBody, s.pcSave = {}, nil, nil + local source = s.sources[s.sourceIndex] + if not source or not s.service then return end + local save, why, body = s.service:read(source) + if not save then s.notice = why; return end + s.pcSave, s.pcBody = save, body + local generation = GameVersion.generation(source.version) + for _, row in ipairs(Records.candidates(save, generation)) do + row.entry = { version = source.version, generation = generation, mon = row.mon, + display = Catalog.describe(source.version, row.mon) } + row.key = Records.key(row) + s.pcRows[#s.pcRows + 1] = row + end +end + +function BoxPanel.refresh(imp) + UI.close(imp) + require("src.ui.kit.Transition").clear("box") + Catalog.reset() + local previous = imp._boxState or {} + local service, why = Service.open() + local s = { service = service, notice = why or (service and service.recoveryNotice), sources = Service.sources(), + sourceIndex = 1, box = previous.box or 1, + pcBox = previous.pcBox or 1, query = previous.query or "", sort = previous.sort or "slot", + selectedBox = {}, selectedPC = {}, pageBox = 1, pagePC = 1, sourcePage = 1, filterQuery = previous.filterQuery, + filters = previous.filters, view = previous.view or "box", multi = previous.multi ~= false, + toolsPage = previous.toolsPage ~= "Themes" and previous.toolsPage or nil, toolsOpen = previous.toolsOpen, + organizers = previous.organizers, stageDraft = previous.stageDraft, stageDrafts = previous.stageDrafts, + stageUndo = previous.stageUndo, stageIndex = previous.stageIndex, stagePiece = previous.stagePiece, + stageSection = previous.stageSection } + local was = previous.sources and previous.sources[previous.sourceIndex] + if was then + for i, source in ipairs(s.sources) do + if source.path == was.path and source.slotId == was.slotId then s.sourceIndex = i; break end + end + end + local current = s.sources[s.sourceIndex] + local total = current and GameVersion.generation(current.version) == 1 and 12 or 14 + if type(s.pcBox) ~= "number" or s.pcBox < 0 or s.pcBox > total then s.pcBox = 1 end + imp._boxState = s + readPC(s) + if service and previous.service and previous.service.body == service.body and previous.selectedBox then + s.selectedBox = previous.selectedBox + end + if s.pcBody and previous.pcBody == s.pcBody and previous.selectedPC and was and current + and was.path == current.path and was.slotId == current.slotId then + s.selectedPC = previous.selectedPC + end + return s +end + +local function state(imp) + return imp._boxState or BoxPanel.refresh(imp) +end + +local function animateInspection(s) + Animation.release(s.animation) + s.animationEntry, s.animation = s.inspect, Animation.new(s.inspect) +end + +local function checkDisk(s, dt) + s._diskCheck = (s._diskCheck or 1) + math.max(0, dt or 0) + local fs = s.service and s.service.fs + if s._diskCheck < 1 or not fs or not fs.getInfo or not fs.read then return end + s._diskCheck = 0 + local ok, info = pcall(fs.getInfo, Store.PATH) + local sig = ok and type(info) == "table" and (tostring(info.size) .. ":" .. tostring(info.modtime)) or "none" + if sig == s._diskSig and s._diskBody == s.service.body then return end + s._diskSig, s._diskBody = sig, s.service.body + local stale = fs.read(Store.PATH) ~= s.service.body + if stale and not s.diskStale then + s.notice, s._latestBackup = "Box changed outside this screen. Reload before making changes.", nil + end + s.diskStale = stale or nil +end + +function BoxPanel.update(imp, dt) + local s = imp._boxState + if not s then return end + checkDisk(s, dt) + s.artTime = (s.artTime or 0) + math.max(0, dt or 0) + if s.animationEntry ~= s.inspect or s.animation and s.animation.released then animateInspection(s) end + Animation.update(s.animation, dt) + local Showcase = require("src.box.Showcase") + if not s.toolsPage or s.toolsPage == "Pokémon" then + local warehouse = s.service and s.service.state + Showcase.ensureMusic(warehouse and Showcase.boxMusic(warehouse, warehouse.boxes[s.box]) or "Silent", "box") + else + local _, owner = Showcase.currentMusic() + if owner == "box" then Showcase.stopMusic() end + end +end + +local function selected(t) + local out = {} + for _, ref in pairs(t) do out[#out + 1] = ref end + table.sort(out, function(a, b) + if a.where ~= b.where then return (a.where or "box") < (b.where or "box") end + if (a.box or 0) ~= (b.box or 0) then return (a.box or 0) < (b.box or 0) end + return (a.index or a.slot or 0) < (b.index or b.slot or 0) + end) + return out +end + +local function perform(imp, operation, text) + local s = state(imp) + local called, ok, why = pcall(operation) + if not called then why, ok = ok, nil end + s._latestBackup = nil + if not ok then + s.notice = tostring(why or "Box operation failed.") + local beforeBox, beforePC = s.service and s.service.body, s.pcBody + if s.service then + local recovered, err = Service.open(s.service.fs) + s.service = recovered + if not recovered then s.notice = s.notice .. " " .. tostring(err) end + end + readPC(s) + if not s.service or s.service.body ~= beforeBox or s.pcBody ~= beforePC then + Animation.release(s.animation) + s.animation, s.animationEntry = nil, nil + s.selectedBox, s.selectedPC, s.moving, s.movingPC = {}, {}, nil, nil + s.inspect, s.inspectKind, s.inspectRef, s.summary = nil, nil, nil, nil + end + return false + end + s.notice = text + require("src.box.Cry").stop() + Animation.release(s.animation) + s.animation, s.animationEntry = nil, nil + s.selectedBox, s.selectedPC, s.moving, s.movingPC, s.inspect = {}, {}, nil, nil, nil + s._filteredBox, s._filteredPC, s.motionFrom, s._giftProgress = nil, nil, nil, nil + require("src.ui.kit.Transition").clear("box") + readPC(s) + if imp.savesChanged then + local source = s.sources[s.sourceIndex] + if source then imp:savesChanged(source.cartId and "cart_" .. tostring(source.cartId) or source.version) end + end + return true +end + +function BoxPanel.importGCI(imp, bytes, why) + local s = state(imp) + if not bytes then s.notice = why or "The file could not be read."; return false end + if not s.service then return false end + return perform(imp, function() return s.service:importGCI(bytes) end, "Native Pokémon Box save imported.") +end + +local function button(imp, x, y, w, h, id, text, action, active, disabled, icon) + icon = icon or ({ summary = "eye", ["summary-back"] = "arrow-left", reload = "rotate-ccw", + ["view-box"] = "package", ["view-pc"] = "monitor", multi = "check", + deposit = "download", withdraw = "upload", ["select-visible"] = "check", ["clear-selection"] = "x", + move = "arrow-left-right", rename = "pencil" })[id] + return LV().btn(imp, x, y, w, h, "box-" .. id, Strings(text), { + kind = active and "primary" or "ghost", font = "small", + face = id == "multi" and "selection" or nil, active = active, + enabled = not disabled, action = action, icon = icon, + }) +end + +local function choose(imp, x, y, w, h, id, text, previous, nextOne, pick) + local bw, gap = Kit.tapMin(), 4 * Kit.scale + button(imp, x, y, bw, h, id .. "-prev", "", previous, false, false, "chevron-left") + button(imp, x + w - bw, y, bw, h, id .. "-next", "", nextOne, false, false, "chevron-right") + if pick then + UI.button(imp, x + bw + gap, y, w - 2 * (bw + gap), h, id .. "-picker", text, pick, + { face = "bare", trailingIcon = "chevron-down" }) + else + Kit.textCenterBold("small", Kit.ellipsize("small", text, w - 2 * (bw + gap)), + x + bw + gap, y + (h - Kit.textHeight("small")) / 2, w - 2 * (bw + gap), PAL.heading) + end +end + +local function rowsForPC(s) + local cached = s._filteredPC + if cached and cached.rows == s.pcRows and cached.box == s.pcBox and cached.query == s.query then + return cached.result + end + local out, bySlot = {}, {} + local matches = require("src.box.Search").compile(s.query) + for _, row in ipairs(s.pcRows) do + if (s.pcBox == 0 and row.where == "party") or (row.where == "box" and row.box == s.pcBox) then + if s.query ~= "" then + if matches(row.entry, row.source) then out[#out + 1] = row end + else bySlot[row.index] = row end + end + end + if s.query == "" then + local source = s.sources[s.sourceIndex] + local capacity = s.pcBox == 0 and 6 or source and GameVersion.generation(source.version) == 3 and 30 or 20 + for i = 1, capacity do + local ref = { where = s.pcBox == 0 and "party" or "box", box = s.pcBox, index = i } + ref.key = Records.key(ref) + out[i] = bySlot[i] or ref + end + end + s._filteredPC = { rows = s.pcRows, box = s.pcBox, query = s.query, result = out } + return out +end + +local function rowsForBox(s) + local cached = s._filteredBox + if cached and cached.state == s.service.state and cached.box == s.box + and cached.query == s.query and cached.sort == s.sort then return cached.result end + local out = {} + if s.query ~= "" then + out = Store.search(s.service.state, s.query, s.sort) + elseif s.sort ~= "slot" then + for _, row in ipairs(Store.search(s.service.state, "", s.sort)) do + if row.box == s.box then out[#out + 1] = row end + end + else + for slot = 1, Store.SLOTS do + out[#out + 1] = { box = s.box, slot = slot, entry = Store.at(s.service.state, s.box, slot) } + end + end + s._filteredBox = { state = s.service.state, box = s.box, query = s.query, sort = s.sort, result = out } + return out +end + +local function count(selection) + local n = 0 + for _ in pairs(selection) do n = n + 1 end + return n +end + +local function panel(x, y, w, h, accent) + Theme.fillRounded(x + 4, y + 7, w, h, PAL.inverse, 0.5, Theme.cardRadius()) + Theme.card(x, y, w, h, { shadow = true, stroke = accent or PAL.line, strokeA = 0.65, strokeW = 2 }) +end + +local function art(entry, x, y, size, small, s) + local image, version = Catalog.art(entry) + if not image then + Kit.textCenterBold("small", "?", x, y + (size - Kit.textHeight("small")) / 2, size, PAL.muted) + return + end + local sw, sh = size * 0.58, math.max(3, size * 0.085) + if love.graphics.ellipse then + Theme.col(PAL.inverse, 0.35) + love.graphics.ellipse("fill", x + size / 2, y + size * 0.86, sw / 2, sh / 2) + else Theme.fillRounded(x + (size - sw) / 2, y + size * 0.82, sw, sh, PAL.inverse, 0.35, sh / 2) end + if small then + local frame = version == "emerald" and math.floor((s.artTime or 0) * 4) or 0 + if Sprites.drawIcon(image, x, y, size, frame) then return end + end + if not small and s.animationEntry == entry and Animation.draw(s.animation, x, y, size) then return end + if not Sprites.drawFront(image, x, y, size) then Sprites.drawIcon(image, x, y, size) end +end + +local function checkBadge(x, y, size, checked, accent) + if checked then + Theme.fillRounded(x, y, size, size, accent or PAL.green, 1, 4) + Icons.draw("check", x + 2, y + 2, size - 4, PAL.inverse, 1) + else + Theme.fillRounded(x, y, size, size, PAL.bg, 0.95, 4) + Theme.strokeRounded(x, y, size, size, PAL.line, 0.6, 1, 4) + end +end + +local function inspectEntry(s, kind, row) + s.inspect, s.inspectKind, s.inspectRef = row.entry, kind, row + animateInspection(s) + require("src.box.Cry").play(row.entry) +end + +local function openSummary(s, imp) + if imp then UI.change(imp, s, "summary", true) else s.summary = true end + Kit.blur() + animateInspection(s) + require("src.box.Cry").play(s.inspect) +end + +local function selectEntry(s, kind, row) + local key = kind == "pc" and row.key or (row.box .. ":" .. row.slot) + local selection = kind == "pc" and s.selectedPC or s.selectedBox + if s.multi == false then + local was = selection[key] ~= nil + s.selectedPC, s.selectedBox = {}, {} + if was then return end + selection = kind == "pc" and s.selectedPC or s.selectedBox + end + if selection[key] then selection[key] = nil + elseif kind == "pc" then selection[key] = { where = row.where, box = row.box, index = row.index } + else selection[key] = { box = row.box, slot = row.slot } end + inspectEntry(s, kind, row) +end + +local function gridMetrics(w, m) + local gap = math.max(3, math.floor(4 * m.s)) + local cols = w >= 620 * m.s and 12 or w >= 390 * m.s and 6 or 4 + local cellW = (w - (cols - 1) * gap) / cols + local cellH = math.max(Kit.tapMin() + 6, math.min(cellW, (cols == 12 and 50 or 68) * m.s)) + return cols, gap, cellW, cellH, cols == 12 and Store.SLOTS or cols * 6 +end + +local function visibleRows(s, kind, w, m) + local rows = kind == "pc" and rowsForPC(s) or rowsForBox(s) + local _, _, _, _, perPage = gridMetrics(w, m) + local first, last, page = Kit.pageBounds(kind == "pc" and s.pagePC or s.pageBox, #rows, perPage) + return rows, first, last, page, perPage +end + +local function grid(imp, s, kind, x, y, w, m) + local rows, first, last, page, perPage = visibleRows(s, kind, w, m) + local cols, gap, cellW, cellH = gridMetrics(w, m) + local pageKey = kind == "pc" and "pagePC" or "pageBox" + s[pageKey] = page + local selection = kind == "pc" and s.selectedPC or s.selectedBox + for i = first, last do + local row, index = rows[i], i - first + local entry = row.entry + local cx = x + (index % cols) * (cellW + gap) + local cy = y + math.floor(index / cols) * (cellH + gap) + local key = kind == "pc" and row.key or (row.box .. ":" .. row.slot) + local chosen = selection[key] ~= nil + local focused = s.inspect == entry and entry ~= nil + local emptyTarget = not entry and (kind == "box" and s.moving or kind == "pc" and s.movingPC) + local id = "box-" .. kind .. "-" .. key + local hot = entry and Kit.hover(cx, cy, cellW, cellH) + local ring = Kit._ringShown and Kit.focusId == id + if chosen or focused or hot or ring then + local color = chosen and PAL.green or (focused or ring) and PAL.blue or PAL.line + Theme.col(color, chosen and 0.3 or 0.15) + if love.graphics.ellipse then + love.graphics.ellipse("fill", cx + cellW / 2, cy + cellH * 0.7, cellW * 0.43, cellH * 0.28) + else + Theme.fillRounded(cx + 4, cy + cellH * 0.4, cellW - 8, cellH * 0.56, color, 0.2, 12) + end + end + LV().btn(imp, cx, cy, cellW, cellH, id, "", { + face = "bare", ring = false, + enabled = entry ~= nil or not not emptyTarget, action = function() + if kind == "box" and s.moving then + local from = s.moving + return perform(imp, function() return s.service:moveGroup(from, row.box, row.slot) end, + "Pokémon moved.") + end + if kind == "pc" and s.movingPC then + local from, source = s.movingPC, s.sources[s.sourceIndex] + s.movingPC = nil + return perform(imp, function() + return s.service:movePC(source, from, { where = row.where, box = row.box, index = row.index }, s.pcBody) + end, "Pokémon moved.") + end + selectEntry(s, kind, row) + end, + }) + if entry then + local size = math.min(cellW - 2, cellH - 2) + art(entry, cx + (cellW - size) / 2, cy + (cellH - size) / 2, size, true, s) + local badge = math.max(12, math.min(18 * m.s, cellW * 0.27)) + checkBadge(cx + cellW - badge - 4, cy + 4, badge, chosen) + if entry.display.shiny then Icons.draw("award", cx + 3, cy + 2, badge, PAL.yellow, 1) end + else + Kit.textCenter("micro", tostring(row.slot or row.index), cx, + cy + (cellH - Kit.textHeight("micro")) / 2, cellW, PAL.faint) + end + end + local nrows = math.max(1, math.ceil((last - first + 1) / cols)) + local height = nrows * (cellH + gap) + if #rows == 0 then + Kit.textWrapped("small", Strings("No Pokémon match this view."), x + 8, y + 10, w - 16, PAL.muted, 2) + end + if #rows > perPage then + local h = Kit.tapMin() + choose(imp, x, y + height, w, h, kind .. "-page", + ("%d–%d / %d"):format(first, last, #rows), + function() s[pageKey] = math.max(1, page - 1) end, + function() s[pageKey] = math.min(math.ceil(#rows / perPage), page + 1) end) + height = height + h + gap + end + return height +end + +local TYPE_COLORS = { + FIRE = PAL.railFireRed, WATER = PAL.buttonBlue, GRASS = PAL.railLeafGreen, + ELECTRIC = PAL.yellow, POISON = PAL.buttonPurple, PSYCHIC = PAL.buttonPurple, + ICE = PAL.blue, BUG = PAL.green, DRAGON = PAL.buttonPurple, +} + +local function typeBadges(types, x, y, w, m, measure) + local seen, names = {}, {} + for name in tostring(types):gmatch("[^/]+") do + name = name:match("^%s*(.-)%s*$"):upper() + if name ~= "" and not seen[name] then names[#names + 1], seen[name] = name, true end + end + local h, gap, cx, cy = Kit.textHeight("micro") + 8 * m.s, 4 * m.s, x, y + for _, name in ipairs(names) do + local bw = math.min(w, Kit.textWidth("micro", name) + 16 * m.s) + if cx > x and cx + bw > x + w then cx, cy = x, cy + h + gap end + local color = TYPE_COLORS[name] or PAL.raised + if not measure then + Kit.tag(cx, cy, bw, h, name, color, { + fill = true, bold = true, ink = (color == PAL.yellow or color == PAL.green) and PAL.inverse or PAL.heading, + }) + end + cx = cx + bw + gap + end + return #names == 0 and 0 or cy + h - y +end + +local function detail(imp, s, x, y, w, m, compact, inspectOnly) + local entry, pad = s.inspect, 12 * m.s + local gap = 6 * m.s + if not entry then + local h = compact and 58 * m.s or 304 * m.s + panel(x, y, w, h) + if not compact then + Icons.draw("package", x + (w - 64 * m.s) / 2, y + 54 * m.s, 64 * m.s, PAL.muted, 0.65) + end + Kit.textWrapped("small", Strings("Select a Pokémon to see it here."), x + pad, + y + (compact and 12 or 148) * m.s, w - 2 * pad, PAL.muted, 2) + return h + end + local name = tostring(entry.display.species) + local nickname = entry.display.name ~= entry.display.species and tostring(entry.display.name) or nil + local size = compact and math.max(128, 108 * m.s) or math.min(w - 2 * pad, 140 * m.s) + local tw = compact and w - 2 * pad - size - gap or w - 2 * pad + local item = entry.display.item ~= "" and entry.display.item or Strings("No held item") + local fieldH = Kit.textHeight("button") + typeBadges(entry.display.types, 0, 0, tw, m, true) + + Kit.wrapHeight("micro", item, tw, 2) + 3 * gap + if nickname then fieldH = fieldH + Kit.wrapHeight("micro", nickname, tw, 2) + gap end + local headerH = 48 * m.s + local top = headerH + 8 * m.s + local h = math.max((compact and 166 or 315) * m.s, + top + (compact and math.max(size, fieldH + gap + Kit.tapMin()) + or size + gap + fieldH + Kit.textHeight("micro") + gap + Kit.tapMin()) + pad) + if inspectOnly then + h = compact and top + math.max(size, 3 * m.s + fieldH) + pad or h - Kit.tapMin() - gap + end + panel(x, y, w, h, PAL.blue) + Theme.fillRounded(x + 2, y + 2, w - 4, headerH, PAL.raised, 1, 7) + local dex = entry.display.national and ("#" .. ("%03d"):format(entry.display.national)) or "" + local nameH, dexH = Kit.textHeight("button"), Kit.textHeight("micro") + local headerY = y + 2 + (headerH - nameH - dexH - 2 * m.s) / 2 + Kit.textCenterBold("button", Kit.ellipsize("button", name, w - 2 * pad), x + pad, headerY, w - 2 * pad, PAL.heading) + Kit.textCenter("micro", dex, x + pad, headerY + nameH + 2 * m.s, w - 2 * pad, PAL.muted) + local ax = compact and x + pad or x + (w - size) / 2 + local ay = y + top + Theme.fillRounded(ax, ay, size, size, PAL.bg, 1, 7) + art(entry, ax, ay, size, false, s) + local tx = compact and ax + size + gap or x + pad + local ty = compact and ay + 3 * m.s or ay + size + gap + Kit.textBold("button", "Lv. " .. tostring(entry.display.level), tx, ty, PAL.heading) + ty = ty + Kit.textHeight("button") + gap + ty = ty + typeBadges(entry.display.types, tx, ty, tw, m) + gap + ty = ty + Kit.textWrapped("micro", item, tx, ty, tw, PAL.text, 2) + gap + if nickname then ty = ty + Kit.textWrapped("micro", nickname, tx, ty, tw, PAL.heading, 2) + gap end + if not compact then + local origin = GameVersion.info(entry.version).label + Kit.text("micro", origin .. (entry.display.egg and " · Egg" or entry.display.shiny and " · Shiny" or ""), tx, ty, PAL.muted) + end + local by = y + h - Kit.tapMin() - pad + if not inspectOnly then button(imp, compact and tx or x + pad, by, compact and tw or w - 2 * pad, Kit.tapMin(), + "summary", "Summary", function() openSummary(s, imp) end) end + return h +end + +local function summary(imp, s, x, y, w, m) + local entry = s.inspect + if not entry then return 0 end + local top, gap = y, 8 * m.s + local Details = require("src.import.BoxDetails") + s.summarySection = s.summarySection or "Overview" + local sectionH = math.max(m.btnH, Kit.tapMin()) + UI.dropdown(imp, x, y, w - sectionH - gap, sectionH, "summary-section", s.summarySection, + UI.values(Details.SECTIONS, nil, "book-open"), s.summarySection, + function(value) UI.change(imp, s, "summarySection", value) end, "book-open") + UI.button(imp, x + w - sectionH, y, sectionH, sectionH, "summary-section-next", "", function() + local sections, at = Details.SECTIONS, 1 + for i, name in ipairs(sections) do if name == s.summarySection then at = i end end + UI.change(imp, s, "summarySection", sections[at % #sections + 1]) + end, { face = "invert", icon = "arrow-right" }) + y = y + sectionH + gap + if w >= 740 * m.s then + local cardW = math.min(w * .25, 280 * m.s) + local cardH = detail(imp, s, x, y, cardW, m, false, true) + local fx, fw = x + cardW + 2 * gap, w - cardW - 2 * gap + local fieldsH = Details.draw(imp, entry, s.summarySection, fx, y, fw, m) + y = y + math.max(cardH, fieldsH) + gap + else + y = y + detail(imp, s, x, y, w, m, true, true) + gap + y = y + Details.draw(imp, entry, s.summarySection, x, y, w, m) + gap + end + local context = s.inspectKind == "pc" and rowsForPC(s) or rowsForBox(s) + local neighbors, position = {}, 1 + for _, row in ipairs(context) do + if row.entry then + neighbors[#neighbors + 1] = row + if row.entry == entry then position = #neighbors end + end + end + local navW = (w - 2 * gap) / 3 + button(imp, x + navW + gap, y, navW, m.btnH, "summary-prev", "Previous", function() + local row = neighbors[position - 1] + if row then inspectEntry(s, s.inspectKind or "box", row) end + end, false, position <= 1, "chevron-left") + button(imp, x, y, navW, m.btnH, "summary-back", "Box", function() UI.change(imp, s, "summary", nil, -1) end) + button(imp, x + 2 * (navW + gap), y, navW, m.btnH, "summary-next", "Next", function() + local row = neighbors[position + 1] + if row then inspectEntry(s, s.inspectKind or "box", row) end + end, false, position >= #neighbors, "chevron-right") + return y + m.btnH - top +end + +local function boxName(s) + if s.view ~= "pc" then return s.service.state.boxes[s.box].name end + if s.pcBox == 0 then return Strings("Party") end + local source = s.sources[s.sourceIndex] + local name = s.pcSave and (GameVersion.generation(source.version) == 3 + and s.pcSave.storage and s.pcSave.storage.boxes and s.pcSave.storage.boxes[s.pcBox] + and s.pcSave.storage.boxes[s.pcBox].name + or s.pcSave.boxNames and s.pcSave.boxNames[s.pcBox]) + return name or "PC BOX " .. s.pcBox +end + +local function changeBox(imp, s, delta) + if s.view == "pc" then + local source = s.sources[s.sourceIndex] + local total = source and GameVersion.generation(source.version) == 1 and 12 or 14 + UI.change(imp, s, "pcBox", (s.pcBox + delta) % (total + 1), delta); s.pagePC = 1 + else + UI.change(imp, s, "box", (s.box - 1 + delta) % Store.BOXES + 1, delta); s.pageBox, s.railFirst = 1, nil + end +end + +local function thumbnail(imp, s, x, y, w, h, m, kind, box) + local active = s.view == kind and (kind == "pc" or s.box == box) + local accent = kind == "pc" and PAL.blue or PAL.green + panel(x, y, w, h, active and accent or nil) + LV().btn(imp, x, y, w, h, "box-thumb-" .. kind .. "-" .. (box or 0), "", { + face = "invert", fill = active and PAL.raised or PAL.surface, ring = false, + action = function() + if s.view ~= kind then UI.change(imp, s, "view", kind, kind == "pc" and 1 or -1) + elseif box then UI.change(imp, s, "box", box, box >= s.box and 1 or -1) end + s.summary = nil; if box then s.box, s.pageBox = box, 1 end + end, + }) + if active then Theme.strokeRounded(x + 1, y + 1, w - 2, h - 2, accent, 1, 3, 8) end + local source = s.sources[s.sourceIndex] + local label = kind == "pc" and (source and GameVersion.info(source.version).label .. " PC" or "Game PC") or s.service.state.boxes[box].name + Kit.textCenterBold("micro", Kit.ellipsize("micro", label, w - 10), x + 5, y + 5 * m.s, w - 10, active and accent or PAL.heading) + local entries, occupied, capacity = {}, 0, Store.SLOTS + if kind == "pc" then + capacity = s.pcBox == 0 and 6 or s.sources[s.sourceIndex] + and GameVersion.generation(s.sources[s.sourceIndex].version) == 3 and 30 or 20 + for _, row in ipairs(s.pcRows) do + if (s.pcBox == 0 and row.where == "party") or row.where == "box" and row.box == s.pcBox then + entries[row.index] = row.entry; occupied = occupied + 1 + end + end + else + entries = s.service.state.boxes[box].mons + for _ in pairs(entries) do occupied = occupied + 1 end + end + local cols = kind == "pc" and 6 or 10 + local pad, gy = 8 * m.s, y + 27 * m.s + local cw = (w - 2 * pad) / cols + local gh = h - 50 * m.s + local ch = gh / math.ceil(capacity / cols) + for i = 1, capacity do + local gx = x + pad + ((i - 1) % cols) * cw + local yy = gy + math.floor((i - 1) / cols) * ch + local entry = entries[i] + if entry then + local size = math.min(cw, ch) + art(entry, gx + (cw - size) / 2, yy, size, true, s) + else + Theme.fillRounded(gx + cw / 2 - 1, yy + ch / 2 - 1, 2, 2, PAL.line, 0.25, 1) + end + end + Kit.textCenter("micro", occupied .. " / " .. capacity, x, y + h - 20 * m.s, w, PAL.muted) +end + +local function boxRail(imp, s, x, y, w, m) + local gap, h = 8 * m.s, 120 * m.s + local n = w >= 760 * m.s and 5 or w >= 500 * m.s and 4 or 2 + local storageN = n - 1 + local first = s.railFirst or math.floor((s.box - 1) / storageN) * storageN + 1 + first = math.max(1, math.min(first, Store.BOXES - storageN + 1)) + s.railFirst = first + Kit.textBold("small", Strings("Your boxes"), x, y, PAL.heading) + local bw = Kit.tapMin() + button(imp, x + w - 2 * bw - gap, y, bw, bw, "rail-prev", "", + function() s.railFirst = math.max(1, first - storageN) end, false, first == 1, "chevron-left") + button(imp, x + w - bw, y, bw, bw, "rail-next", "", + function() s.railFirst = math.min(Store.BOXES - storageN + 1, first + storageN) end, + false, first + storageN > Store.BOXES, "chevron-right") + y = y + bw + gap + local tw = (w - (n - 1) * gap) / n + thumbnail(imp, s, x, y, tw, h, m, "pc") + for i = 1, storageN do thumbnail(imp, s, x + i * (tw + gap), y, tw, h, m, "box", first + i - 1) end + return bw + gap + h + gap +end + +local function locations(s, pc, party) + local options = {} + if pc then + local source = s.sources[s.sourceIndex] + local total = source and GameVersion.generation(source.version) == 1 and 12 or 14 + if party then options[#options + 1] = { id = 0, label = "Party", icon = "users" } end + for b = 1, total do options[#options + 1] = { id = b, label = "PC box " .. b, icon = "monitor" } end + else + for b, box in ipairs(s.service.state.boxes) do + local n = 0; for _ in pairs(box.mons) do n = n + 1 end + options[#options + 1] = { id = b, label = box.name .. " · " .. n .. "/60", icon = "package" } + end + end + return options +end + +local function controls(imp, s, x, y, w, m, gridW) + local gap, h = 6 * m.s, math.max(Kit.tapMin(), 32 * m.s) + local kind = s.view == "pc" and "pc" or "box" + local selection = kind == "pc" and s.selectedPC or s.selectedBox + local source, n = s.sources[s.sourceIndex], count(selection) + local top = y + local target = kind == "pc" and s.service.state.boxes[s.box].name + or (s.pcBox == 0 and "Choose a PC box" or "PC box " .. s.pcBox) + local game = source and GameVersion.info(source.version).label or "game" + UI.dropdown(imp, x, y, w, h, "transfer-target", "To: " .. (kind == "box" and game .. " · " or "") .. target, + locations(s, kind == "box", false), kind == "pc" and s.box or s.pcBox, function(value) + if kind == "pc" then s.box, s.railFirst = value, nil else s.pcBox = value end + end, kind == "pc" and "package" or "monitor") + y = y + h + gap + local label = kind == "pc" and ("Store %d in Box"):format(n) or ("Send %d to game"):format(n) + local compact = w < 560 * m.s + local multiW, selectW = 86 * m.s, 145 * m.s + local primaryW = compact and w - multiW - gap or w - multiW - selectW - h - 3 * gap + button(imp, x, y, primaryW, h, kind == "pc" and "deposit" or "withdraw", label, function() + local refs = selected(selection) + local function transfer(repair) + perform(imp, function() + if kind == "pc" then return s.service:deposit(source, refs, s.box, s.pcBody, repair) end + return s.service:withdraw(source, refs, s.pcBox, repair) + end, repair and (kind == "pc" and "Pokémon repaired and deposited." or "Pokémon repaired and withdrawn.") + or (kind == "pc" and "Pokémon deposited." or "Pokémon withdrawn.")) + end + local illegal = s.service:audit(source, refs, kind) + if #illegal == 0 then return transfer(false) end + local options = {} + for _, row in ipairs(illegal) do + local fixes = {} + for _, fix in ipairs(row.fixes) do fixes[#fixes + 1] = fix.label end + options[#options + 1] = { id = "info-" .. #options, label = tostring(row.name) .. ": " .. table.concat(fixes, ", "), + icon = "triangle-alert", disabled = true } + end + options[#options + 1] = { id = "repair", label = kind == "pc" and "Repair and store" or "Repair and send", icon = "check" } + options[#options + 1] = { id = "cancel", label = "Cancel", icon = "x" } + UI.open(imp, #illegal == 1 and "Repair illegal Pokémon?" or ("Repair %d illegal Pokémon?"):format(#illegal), + options, nil, function(id) if id == "repair" then transfer(true) end end) + end, true, n == 0 or not source or kind == "box" and s.pcBox == 0) + button(imp, x + primaryW + gap, y, 86 * m.s, h, "multi", "Multi", + function() s.multi = s.multi == false end, s.multi ~= false) + local bx = x + primaryW + multiW + 2 * gap + if compact then y = y + h + gap; bx = x end + local clearW = h + button(imp, bx, y, compact and w - clearW - gap or selectW, h, "select-visible", "Select visible", function() + local rows, first, last = visibleRows(s, kind, gridW, m) + for i = first, last do + local row = rows[i] + if row.entry then + local key = kind == "pc" and row.key or row.box .. ":" .. row.slot + selection[key] = kind == "pc" and { where = row.where, box = row.box, index = row.index } + or { box = row.box, slot = row.slot } + end + end + s.multi = true + end) + button(imp, x + w - clearW, y, clearW, h, "clear-selection", "", function() + s.selectedPC, s.selectedBox = {}, {} + end, false, count(s.selectedPC) + count(s.selectedBox) == 0) + return y + h + gap - top +end + +local function drawBody(imp, s, x, y, w, _, m) + s.view = s.view or "box" + local top, gap, pad = y, 8 * m.s, 10 * m.s + local rowH = math.max(Kit.tapMin(), 32 * m.s) + Kit.textBold("button", Strings(s.summary and "SUMMARY" or "BOX"), x, y, PAL.heading) + if s.service then + Kit.textRight("small", ("%d / %d"):format(Store.count(s.service.state), Store.BOXES * Store.SLOTS), x + w, y + 3, PAL.muted) + end + y = y + Kit.textHeight("button") + gap + if s.notice then y = y + Kit.textWrapped("small", Strings(s.notice), x, y, w, s.service and PAL.muted or PAL.red, 3) + gap end + local queryAffectsPage = not s.summary and (not s.toolsPage or s.toolsPage=="List" + or s.toolsPage=="Filters" or s.toolsPage=="Showcase" + or s.toolsPage=="Arrange" and s.arrangeSection=="Move") + if queryAffectsPage and s.query and s.query ~= "" then + local filtered = s.filterQuery == s.query + local label = filtered and "Filters applied" or ("Search: " .. s.query) + Icons.draw(filtered and "list-filter" or "search", x, y, 14 * m.s, PAL.blue, 1) + Kit.text("micro", Kit.ellipsize("micro", label, w - 24 * m.s), x + 22 * m.s, y, PAL.muted) + y = y + Kit.textHeight("micro") + gap + end + if s.service and s.summary then return y - top + summary(imp, s, x, y, w, m) end + local source = s.sources[s.sourceIndex] + local wide = w >= 740 * m.s + local tools = require("src.import.BoxTools") + local inlineTools = wide and s.service and not s.summary + local sourceW = inlineTools and (w - gap) * .58 or w + local headerH = inlineTools and math.max(m.btnH, rowH) or rowH + local options = {}; for i, save in ipairs(s.sources) do options[i] = { id = i, label = save.label, icon = "gamepad-2" } end + UI.dropdown(imp, x, y, sourceW - headerH - gap, headerH, "choose-save", source and "Game: " .. source.label or "Choose a game", + options, s.sourceIndex, function(index) + if index == s.sourceIndex then return end + UI.change(imp, s, "sourceIndex", index) + s.pcBox, s.pagePC, s.selectedPC, s.movingPC = 1, 1, {}, nil + if s.inspectKind == "pc" then s.inspect, s.inspectKind, s.inspectRef = nil, nil, nil end + if not s.toolsPage then s.view = "pc" end + readPC(s) + end, "gamepad-2") + button(imp, x + sourceW - headerH, y, headerH, headerH, "reload", "", function() BoxPanel.refresh(imp) end) + if not s.service then return y + headerH - top end + if inlineTools then tools.navigation(imp, s, x + sourceW + gap, y, w - sourceW - gap, m) end + y = y + headerH + gap + local toolsH, toolsPage = tools.draw(imp, s, x, y, w, m, { + button = button, perform = perform, selected = selected, selectEntry = selectEntry, + openSummary = function(target) openSummary(target, imp) end, + navigationDone = inlineTools, + }) + y = y + toolsH + if toolsPage then return y - top end + local optionW = 112 * m.s + local qx, qy, qw = x, y, w - optionW - 2 * rowH - 3 * gap + local query = Kit.textfield("box-search", qx, qy, qw, rowH, s.query, Strings("Search Pokémon")) + if query ~= s.query then s.query, s.pageBox, s.pagePC = query, 1, 1 end + UI.button(imp, x + qw + gap, y, rowH, rowH, "sort", "", function() + local tools = require("src.import.BoxTools") + UI.open(imp, "Sort", UI.values(tools.SORTS, tools.SORT_LABELS, "arrow-up-down"), s.sort, + function(value) s.sort, s.pageBox = value, 1 end) + end, { icon = "arrow-up-down" }) + UI.button(imp, x + qw + rowH + 2 * gap, y, rowH, rowH, "filters", "", function() + UI.change(imp, s, "toolsPage", "Filters") + end, { icon = "list-filter" }) + local actionOptions = { + { label = "Rename this Box", icon = "pencil", action = function() + require("src.import.BoxPrompt").open(imp, "Box name", s.service.state.boxes[s.box].name, 32, + function(value) perform(imp, function() return s.service:rename(s.box, value) end, "Box renamed.") end) + end }, + { label = "Select whole Box", icon = "check", action = function() + s.selectedBox, s.multi = {}, true + for slot in pairs(s.service.state.boxes[s.box].mons) do s.selectedBox[s.box .. ":" .. slot] = { box = s.box, slot = slot } end + end }, + { label = "Box theme", icon = "paintbrush", action = function() + local box = s.service.state.boxes[s.box] + local options = UI.values(Store.THEMES, nil, "paintbrush") + for _, option in ipairs(options) do option.disabled = option.id == "Showcase" and not box.wallpaper end + for index, stage in pairs(s.service.state.stages or {}) do + options[#options + 1] = { label = "Stage " .. index .. " · " .. stage.name, icon = "paintbrush", action = function() + perform(imp, function() return s.service:exportStage(index, s.box) end, "Showcase wallpaper saved.") + end } + end + UI.open(imp, "Box theme", options, box.theme or "Launcher", function(theme) + perform(imp, function() return s.service:theme(s.box, theme, theme == "Showcase" and box.wallpaper or nil) end, "Theme saved.") + end) + end }, + } + UI.dropdown(imp, x + w - optionW, y, optionW, rowH, "options", "Options", actionOptions, nil, nil, "ellipsis") + y = qy + rowH + gap + local kind = s.view == "pc" and "pc" or "box" + local selection = kind == "pc" and s.selectedPC or s.selectedBox + local mainY = y + local detailW = wide and math.min(w * 0.22, 230 * m.s) or w + local gx, gw = wide and x + detailW + gap or x, wide and w - detailW - gap or w + local detailH + if wide then detailH = detail(imp, s, x, y, detailW, m, false) + else y = y + detail(imp, s, x, y, w, m, true) + gap end + local stageY = y + local gridW = gw - 2 * pad + local rows, first, last = visibleRows(s, kind, gridW, m) + local cols, cg, _, ch, perPage = gridMetrics(gridW, m) + local gridH = math.max(1, math.ceil((last - first + 1) / cols)) * (ch + cg) + if #rows > perPage then gridH = gridH + Kit.tapMin() + cg end + local stageH = 2 * pad + (wide and rowH + gap or 2 * (rowH + gap)) + gridH + panel(gx, stageY, gw, stageH, kind == "box" and PAL.green or PAL.blue) + if kind == "box" then + require("src.box.Themes").draw(s.service.state.boxes[s.box], gx + pad, stageY + pad, + gw - 2 * pad, stageH - 2 * pad, m.s) + end + local tabW = wide and 140 * m.s or (gw - 2 * pad - gap) / 2 + button(imp, gx + pad, y + pad, tabW, rowH, "view-box", "Box storage", function() UI.change(imp, s, "view", "box", -1) end, kind == "box") + button(imp, gx + pad + tabW + gap, y + pad, tabW, rowH, "view-pc", "Game PC", function() UI.change(imp, s, "view", "pc", 1) end, kind == "pc") + local selectorX, selectorW = gx + pad, gridW + if wide then selectorX = selectorX + 2 * (tabW + gap); selectorW = gridW - 2 * (tabW + gap) end + local selectorY = y + pad + (wide and 0 or rowH + gap) + local movingKey = kind == "pc" and "movingPC" or "moving" + if s[movingKey] or count(selection) > 0 then + local moving = s[movingKey] + local moveW = math.min(150 * m.s, selectorW * .3) + if moving then + local glow = 0.45 + 0.4 * math.sin(Kit.time * 5) + Theme.strokeRounded(selectorX - 3, selectorY - 3, moveW + 6, rowH + 6, PAL.yellow, glow, 3 * m.s) + end + button(imp, selectorX, selectorY, moveW, rowH, "move", moving and "Move where?" or "Move", function() + if s[movingKey] then return end + s[movingKey] = selected(selection); s.query = "" + if kind == "box" then s.sort = "slot" end + s.notice = kind == "box" and "Pick the first destination slot. Occupied slots swap back." + or "Pick a slot. The Pokémon fill that slot and the next free ones." + end, moving ~= nil) + local used = moveW + gap + if moving then + local cancelW = math.min(110 * m.s, selectorW * .2) + LV().btn(imp, selectorX + used, selectorY, cancelW, rowH, "box-move-cancel", Strings("Cancel"), { + kind = "danger", font = "small", icon = "x", + action = function() s[movingKey] = nil; s.notice = nil end, + }) + used = used + cancelW + gap + end + selectorX, selectorW = selectorX + used, selectorW - used + end + local suffix = " · " .. count(selection) .. " selected" + local titleW = selectorW - 2 * (Kit.tapMin() + 4 * Kit.scale) + local title = Kit.ellipsize("small", boxName(s), math.max(0, titleW - Kit.textWidth("small", suffix))) .. suffix + choose(imp, selectorX, selectorY, selectorW, rowH, "active-box", title, + function() changeBox(imp, s, -1) end, function() changeBox(imp, s, 1) end, function() + local pc = s.view == "pc" + UI.open(imp, pc and "Game PC" or "Box storage", locations(s, pc, true), pc and s.pcBox or s.box, function(value) + UI.change(imp, s, pc and "pcBox" or "box", value) + s.pageBox, s.pagePC, s.railFirst = 1, 1, nil + end) + end) + y = selectorY + rowH + gap + grid(imp, s, kind, gx + pad, y, gridW, m) + y = math.max(stageY + stageH, wide and mainY + detailH or 0) + gap + y = y + controls(imp, s, x, y, w, m, gridW) + y = y + boxRail(imp, s, x, y, w, m) + return y - top +end + +function BoxPanel.draw(imp, x, y, w, h, m) + local f=imp._boxOrganizerFooter + if f and imp._boxState==f.s and f.s.toolsPage=="Arrange" then + Kit.occlude(f.x,f.y-10*m.s,f.w,f.h+20*m.s) + end + imp._boxOrganizerFooter = nil + return UI.pages(imp, state(imp), x, y, w, h, m, drawBody) +end + +function BoxPanel.textinput(imp, text) + if imp._boxPopup then + if Kit.focus == "box-chooser-filter" then Kit.textinput(text) end + return true + end + if imp.tab ~= "box" or Kit.focus ~= "box-search" then return false end + Kit.textinput(text) + return true +end + +function BoxPanel.keypressed(imp, key) + if imp.tab ~= "box" or Kit.focus ~= "box-search" then return false end + Kit.keypressed(key) + return true +end + +return BoxPanel diff --git a/src/import/BoxPrompt.lua b/src/import/BoxPrompt.lua new file mode 100644 index 00000000..ecf4ec62 --- /dev/null +++ b/src/import/BoxPrompt.lua @@ -0,0 +1,48 @@ +local Kit = require("src.ui.kit.Kit") +local Prompt = {} + +function Prompt.close(imp, confirmed) + local own = imp._boxPromptKeyboard + if own then + imp._boxPromptKeyboard = nil + local VK = Kit.VirtualKeyboard + if VK.active and VK.onDone == own then VK.close(false) end + end + local prompt = imp._boxPrompt + if not prompt then return end + imp._boxPrompt = nil + Kit.blur() + imp:_disarmTextInput() + if confirmed then prompt.callback(prompt.text) end +end + +function Prompt.open(imp, title, text, maxLen, callback) + Kit.blur() + local onDone + onDone = function(value, confirmed) + if imp._boxPromptKeyboard == onDone then imp._boxPromptKeyboard = nil end + if confirmed then callback(value) end + end + local opts = { title = title, text = text or "", maxLen = maxLen, onDone = onDone } + if Kit.VirtualKeyboard.open(opts) then imp._boxPromptKeyboard = onDone; return end + imp._boxPrompt = { title = title, text = text or "", maxLen = maxLen, callback = callback } + imp:_armTextInput(text) +end + +function Prompt.openKeyboard(imp) + local prompt = imp._boxPrompt + if not prompt then return false end + local onDone + onDone = function(newText, confirmed) + if imp._boxPromptKeyboard == onDone then imp._boxPromptKeyboard = nil end + if confirmed and imp._boxPrompt == prompt then prompt.text = newText end + end + if not Kit.VirtualKeyboard.open({ text = prompt.text, title = prompt.title, + maxLen = prompt.maxLen, onDone = onDone }) then + return false + end + imp._boxPromptKeyboard = onDone + return true +end + +return Prompt diff --git a/src/import/BoxTools.lua b/src/import/BoxTools.lua new file mode 100644 index 00000000..5f266596 --- /dev/null +++ b/src/import/BoxTools.lua @@ -0,0 +1,690 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Store = require("src.box.Store") +local Collection = require("src.box.Collection") +local UI = require("src.import.BoxUI") +local Icons = require("src.ui.kit.Icons") +local PAL = Theme.PAL +local Tools = {} +Tools.PAGES = { "List", "Arrange", "Dashboard", "Teams", "Gifts", "Showcase", "Files", "Events", "Migration" } +Tools.SORTS = { "slot", "name", "species", "level", "hp", "attack", "defense", "speed", + "special", "spAtk", "spDef", "nature", "ability", "gender", "trainer", "friendship" } +Tools.SORT_LABELS = { slot = "Slot order", name = "Nickname", species = "Species", level = "Level", + hp = "HP", attack = "Attack", defense = "Defense", speed = "Speed", special = "Special", + spAtk = "Sp. Attack", spDef = "Sp. Defense", nature = "Nature", ability = "Ability", + gender = "Gender", trainer = "Trainer", friendship = "Friendship" } +Tools.CONTROL_ICONS = { ["stage-slot"] = "save", ["stage-background"] = "paintbrush", ["stage-pattern"] = "grid-2x2", + ["stage-music"] = "music", ["stage-new-piece"] = "package", ["stage-x"] = "arrow-left-right", + ["stage-y"] = "arrow-up-down", ["stage-size"] = "expand", ["stage-rotation"] = "rotate-ccw", + sort = "arrow-up-down", ["arrange-sort"] = "arrow-up-down", ["migration-destination"] = "gamepad-2" } +local ACTION_ICONS = { ["migration-preview"] = "eye", ["exports-folder"] = "folder", arrange = "arrow-up-down", + ["apply-marks"] = "check", compare = "arrow-left-right", ["stage-wallpaper"] = "paintbrush", ["stage-flip"] = "arrow-left-right" } + +local function search(s, query, sort) + local cache = s._toolsSearch + if not cache or cache.state ~= s.service.state or cache.size >= 8 then + cache = { state = s.service.state, results = {}, size = 0 }; s._toolsSearch = cache + end + local key = tostring(query) .. "\0" .. tostring(sort) + if not cache.results[key] then + cache.results[key], cache.size = Store.search(s.service.state, query, sort), cache.size + 1 + end + return cache.results[key] +end + +local function same(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 same(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 + +local function userExport(s, path, index) + local fs = s.service.fs + local bytes = fs.read(path) + if not bytes then return nil, "Could not read the exported PNG." end + if not fs.createDirectory("box/exports") then return nil, "Could not create the export folder." end + local out = "box/exports/showcase-" .. index .. "-" .. os.time() .. ".png" + local ok, why = fs.write(out, bytes) + if not ok or fs.read(out) ~= bytes then return nil, why or "Could not verify the exported PNG." end + local used = false + for _, box in ipairs(s.service.state.boxes) do if box.wallpaper == path then used = true end end + if not used and fs.remove and fs.remove(path) then require("src.box.Themes").forget(path) end + return out +end + +local function findEntry(s, id) + local cache = s._toolsIndex + if not cache or cache.state ~= s.service.state then + cache = { state = s.service.state, byId = {} }; s._toolsIndex = cache + for b = 1, Store.BOXES do + for slot, entry in pairs(s.service.state.boxes[b].mons) do + cache.byId[entry.id] = { box = b, slot = slot, entry = entry } + end + end + end + return cache.byId[id] +end + +function Tools.navigation(imp, s, x, y, w, m) + local gap, h = 8 * m.s, math.max(Kit.tapMin(), m.btnH) + local navigation = { { id = "Pokémon", label = "Boxes", icon = "package" } } + for _, page in ipairs(Tools.PAGES) do navigation[#navigation + 1] = { id = page, label = page, icon = UI.ICONS[page] } end + local helpKey = s.toolsPage or "Box" + UI.dropdown(imp, x, y, UI.HELP[helpKey] and w - h - gap or w, h, "tools-menu", "Tools: " .. (s.toolsPage or "Boxes"), navigation, + s.toolsPage or "Pokémon", function(value) + require("src.box.Showcase").stopMusic(); s.stagePlaying = nil + UI.change(imp, s, "toolsPage", value ~= "Pokémon" and value or nil, value == "Pokémon" and -1 or 1) + s.toolsListPage = 1 + end, UI.ICONS[s.toolsPage] or "package") + if UI.HELP[helpKey] then UI.help(imp, helpKey, x + w - h, y, h) end + return h + gap +end + +function Tools.draw(imp, s, x, y, w, m, api) + local top, gap, h = y, 8 * m.s, math.max(Kit.tapMin(), m.btnH) + local function button(id, label, action, disabled, width, bx, active, icon) + icon = icon or ACTION_ICONS[id] or (id:match("save") and "save" or id:match("remove") and "trash" + or id:match("export") and "upload" or id:match("import") and "download" + or id:match("select") and "check" or id:match("restore") and "undo-2" + or id:match("name") and "pencil" or UI.ICONS[s.toolsPage] or "sliders-horizontal") + api.button(imp, bx or x, y, width or w, h, "tools-" .. id, label, action, active, disabled, icon) + end + local function text(value, muted) + y = y + Kit.textWrapped("small", value, x, y, w, muted and PAL.muted or PAL.text) + gap + end + local function nextRow() y = y + h + gap end + if not api.navigationDone then y = y + Tools.navigation(imp, s, x, y, w, m) end + local page = s.toolsPage + if not page then return y - top, false end + local refs = api.selected(s.selectedBox) + local function operation(fn, message) api.perform(imp, fn, message) end + local function namePrompt(title, initial, callback) + require("src.import.BoxPrompt").open(imp, title, initial, 64, callback) + end + local function cycle(id, label, values, current, apply, column) + local icon = Tools.CONTROL_ICONS[id] or UI.ICONS[page] + local function display(value) + if Tools.SORT_LABELS[value] then return Tools.SORT_LABELS[value] end + if require("src.core.GameVersion").VERSIONS[value] then return require("src.core.GameVersion").info(value).label end + if id == "stage-x" or id == "stage-y" then return math.floor(value * 100 + .5) .. "%" end + if id == "stage-rotation" then return math.floor(value * 180 / math.pi + .5) .. "°" end + return tostring(value) + end + local options = {}; for _, value in ipairs(values) do options[#options + 1] = { id = value, label = display(value), icon = icon } end + local cw = column and (w - gap) / 2 or w + UI.dropdown(imp, x + (column == 2 and cw + gap or 0), y, cw, h, "tools-" .. id, + label .. ": " .. display(current), options, current, apply, icon) + if column ~= 1 then nextRow() end + end + local function actions(items) + local cw = (w - gap) / 2 + for i, item in ipairs(items) do + button(item[1], item[2], item[3], item[4], cw, x + (i - 1) % 2 * (cw + gap), item[5], item[6]) + if i % 2 == 0 or i == #items then nextRow() end + end + end + local function sections(key, names) + s[key] = s[key] or names[1] + cycle(key, "View", names, s[key], function(value) UI.change(imp, s, key, value) end) + return s[key] + end + local function tiles(rows) + local cols = w >= 700 * m.s and 4 or 2 + local cw, cardH = (w - (cols - 1) * gap) / cols, 86 * m.s + for i, row in ipairs(rows) do + local cx = x + (i - 1) % cols * (cw + gap) + Theme.card(cx, y, cw, cardH, { shadow = true }) + Icons.draw(row.icon or "package", cx + 12 * m.s, y + 12 * m.s, 22 * m.s, PAL.blue, 1) + Kit.textBold("button", tostring(row.value), cx + 42 * m.s, y + 12 * m.s, PAL.heading) + Kit.text("small", row.label, cx + 12 * m.s, y + cardH - 29 * m.s, PAL.muted) + if i % cols == 0 or i == #rows then y = y + cardH + gap end + end + end + local function picker() + local options = {} + for b = 1, Store.BOXES do + local box = s.service.state.boxes[b] + local occupied = 0 + for _ in pairs(box.mons) do occupied = occupied + 1 end + options[#options + 1] = { id = b, label = box.name .. " · " .. occupied .. "/60", icon = "package" } + end + UI.dropdown(imp, x, y, w, h, "tools-box-picker", "Box: " .. s.service.state.boxes[s.box].name, + options, s.box, function(b) UI.change(imp, s, "toolsPage", nil, -1); s.box, s.pageBox, s.view, s.railFirst = b, 1, "box", nil end, "package") + nextRow() + end + if page == "Migration" then + local Migration=require("src.box.Migration") + local GameVersion=require("src.core.GameVersion") + local versions={} + for _,version in ipairs(GameVersion.ORDER) do + if require("src.box.Catalog").get(version).ready then versions[#versions+1]=version end + end + local selectionKey={} + for _,ref in ipairs(refs) do selectionKey[#selectionKey+1]=ref.box..":"..ref.slot end + selectionKey=table.concat(selectionKey,",") + if s.migrationPreviewKey~=selectionKey or s.migrationPreviewState~=s.service.state then + s.migrationPreview,s.migrationPreviewKey,s.migrationPreviewState=nil,selectionKey,s.service.state + end + if #versions>0 then + s.migrationVersion=s.migrationVersion or versions[1] + cycle("migration-destination","Destination",versions,s.migrationVersion,function(value) + s.migrationVersion,s.migrationPreview=value,nil + end) + button("migration-preview","Review "..#refs.." selected",function() + local preview,why=Migration.preview(s.service.state,refs,s.migrationVersion) + s.migrationPreview=preview;if not preview then s.notice=why end + end,#refs==0);nextRow() + local preview=s.migrationPreview + if preview then + local options, ready = {}, 0 + for i, row in ipairs(preview.rows) do + options[#options + 1] = { id = i, label = row.name .. (row.converted and " · Ready" or " · Blocked"), icon = row.converted and "check" or "lock" } + if row.converted then ready = ready + 1 end + end + text(ready .. " ready · " .. (#preview.rows - ready) .. " blocked") + s.migrationReviewRow = math.min(s.migrationReviewRow or 1, #preview.rows) + local row = preview.rows[s.migrationReviewRow] + if row then + UI.dropdown(imp, x, y, w, h, "migration-record", row.name, options, s.migrationReviewRow, + function(value) s.migrationReviewRow = value end, "eye"); nextRow() + for _, line in ipairs(row.lines) do text(line, true) end + end + button("migration-apply","Convert selected",function() + operation(function() return s.service:migrate(refs,s.migrationVersion,preview) end,"Migration saved with archived originals.") + s.migrationPreview=nil + end,not preview.allowed or preview.revision~=s.service.state.revision or s.diskStale);nextRow() + end + else text("Import both games first.",true) end + if #refs==1 then + local entry=Store.at(s.service.state,refs[1].box,refs[1].slot) + local originals = entry and entry.archives or {} + if #originals > 0 then + s.archiveIndex = math.min(s.archiveIndex or 1, #originals) + local options = {}; for i, original in ipairs(originals) do options[i] = { id = i, + label = GameVersion.info(original.version).label .. " · " .. original.display.name, icon = "undo-2" } end + UI.dropdown(imp, x, y, w, h, "migration-archive", "Archived original", options, s.archiveIndex, + function(value) s.archiveIndex = value end, "undo-2"); nextRow() + button("migration-restore","Restore original",function() + operation(function() return s.service:restoreOriginal(refs[1],s.archiveIndex) end,"Original restored.") + s.migrationPreview=nil + end);nextRow() + end + end + UI.help(imp, "MigrationPolicy", x + w - h, y, h); nextRow() + elseif page == "Events" then + local Events=require("src.box.Events") + local source=s.sources[s.sourceIndex] + local rows=source and Events.list(source.version,2) or {} + if #rows==0 then text("No tickets for this game.",true) end + local cache=s._eventStatus + if not cache or cache.body~=s.pcBody or cache.version~=(source and source.version) then + cache={body=s.pcBody,version=source and source.version,statuses={}};s._eventStatus=cache + for _,row in ipairs(rows) do + local status,why + if s.pcSave then status,why=Events.status(s.pcSave,row) end + cache.statuses[row.id]={status=status,why=why} + end + end + local options = {}; for _, row in ipairs(rows) do options[#options + 1] = { id = row.id, label = row.name, icon = "mail" } end + if #rows > 0 then + local chosen = rows[1] + for _, row in ipairs(rows) do if row.id == s.eventChoice then chosen = row end end + s.eventChoice = chosen.id + UI.dropdown(imp, x, y, w, h, "event-picker", chosen.name, options, chosen.id, + function(value) s.eventChoice = value end, "mail"); nextRow() + local row = chosen + local status=cache.statuses[row.id].status or "Unavailable" + text(row.route .. " · " .. status) + local pending=status=="Ticket pending" + button("event-"..row.id,pending and "Collect ticket" or row.method=="Record mixing" + and "Receive ticket" or "Receive card",function() + operation(function() return s.service:event(source,row.id,pending) end,(pending or row.method=="Record mixing") + and "Ticket collected. Normal ship requirements still apply." or "Wonder Card received. Collect its ticket here or from the delivery person in-game.") + end,status~="Available" and not pending);nextRow() + if cache.statuses[row.id].why then + UI.help(imp, "Why is this locked?", x, y, h, cache.statuses[row.id].why); nextRow() + end + end + elseif page == "Files" then + local export = function() + local ok, path = s.service:exportGCI() + s.notice = ok and "Exported and verified: " .. path or path + end + actions({ { "gci-import", "Import Box save", function() imp:chooseBoxImport() end }, + { "gci-export", "Export GCI", export, not s.service.state.gciTemplate } }) + button("exports-folder", "Exports folder", function() + if love.system and love.system.openURL then + if s.service.fs.createDirectory then s.service.fs.createDirectory("box/exports") end + love.system.openURL("file://"..love.filesystem.getSaveDirectory().."/box/exports") + end + end, imp.android or imp.ios or imp.isNX); nextRow() + local sync = imp._syncEngine and imp:_syncEngine() + local busy = sync and sync:busy() or false + local cached = s._latestBackup + if not cached or cached.sync ~= sync or cached.busy ~= busy then + cached = { sync = sync, busy = busy, path = not busy and sync and sync.box and sync.box:latestBackup() or nil } + s._latestBackup = cached + end + local backup = cached.path + button("sync-restore", "Restore sync backup", function() + s._latestBackup = nil + local ok, why = sync:restoreBoxBackup(backup) + if ok then require("src.import.BoxPanel").refresh(imp).notice = "Box and linked saves restored." + else s.notice = why end + end, not backup or sync:busy(), w - h - gap) + UI.help(imp, "Backup", x + w - h, y, h); nextRow() + elseif page == "Showcase" then + local Showcase = require("src.box.Showcase") + local fullX, fullW = x, w + local beside = w >= 740 * m.s + if beside then w = (w - 2 * gap) * .48 end + s.stageIndex = s.stageIndex or 1 + local function loadStage(index) + Showcase.stopMusic() + s.stagePlaying = nil + s.stageDrafts=s.stageDrafts or {} + if s.stageDraft then s.stageDrafts[s.stageIndex]=s.stageDraft end + s.stageIndex, s.stagePiece = index, 1 + s.stageDraft = s.stageDrafts[index] or Store.copy(s.service.state.stages and s.service.state.stages[index] or Showcase.new()) + s.stageUndo = {} + end + if not s.stageDraft then loadStage(s.stageIndex) end + local function edit(fn) + local before = Store.copy(s.stageDraft) + fn(s.stageDraft) + if same(before, s.stageDraft) then return end + s.stageUndo = s.stageUndo or {} + s.stageUndo[#s.stageUndo+1] = before + if #s.stageUndo>20 then table.remove(s.stageUndo,1) end + end + cycle("stage-slot", "Saved stage", {1,2,3,4,5}, s.stageIndex, loadStage) + local draft = s.stageDraft + text(draft.name) + local previewH = math.max(190*m.s, math.min(300 * m.s, w*.5)) + require("src.import.ShowcaseEditor").draw(imp,s,x,y,w,previewH,m,edit) + local previewBottom = y + previewH + gap + if beside then x, y, w = fullX + w + 2 * gap, top, fullW - w - 2 * gap + else y = previewBottom end + local function addOptions() + local out={} + if #refs>0 then out[#out+1]={label="Add "..#refs.." selected Pokémon",icon="plus",action=function() + local added,why=Showcase.add(s.stageDraft,refs,s.service.state) + if added then edit(function() s.stageDraft=added end);s.stagePiece=#added.pieces else s.notice=why end + end} end + for _,row in ipairs(search(s,s.query,"species")) do + out[#out+1]={label=(row.entry.display.national and ("#"..row.entry.display.national.." · ") or "").. + row.entry.display.name,entry=row.entry,icon="package",action=function() + local added,why=Showcase.add(s.stageDraft,{{box=row.box,slot=row.slot}},s.service.state) + if added then edit(function() s.stageDraft=added end);s.stagePiece=#added.pieces else s.notice=why end + end} + end + return out + end + UI.dropdown(imp,x,y,(w-gap)/2,h,"stage-add-pokemon","Add Pokémon",addOptions,nil,nil,"plus") + local scenery={} + for _,kind in ipairs(Showcase.PIECES) do scenery[#scenery+1]={label=kind,prop=kind,icon="package",action=function() + if #s.stageDraft.pieces>=1500 then s.notice="The stage is full.";return end + edit(function(d) d.pieces[#d.pieces+1]={kind=kind,x=.5,y=.5,scale=1.5,rotation=0,flip=false} end) + s.stagePiece=#s.stageDraft.pieces + end} end + UI.dropdown(imp,x+(w+gap)/2,y,(w-gap)/2,h,"stage-add-scenery","Add scenery",scenery,nil,nil,"plus") + nextRow() + local function layerOptions() + local out={} + for i=#s.stageDraft.pieces,1,-1 do + local p=s.stageDraft.pieces[i] + local row=p.entryId and findEntry(s,p.entryId) + out[#out+1]={id=i,label=i.." · "..(row and row.entry.display.name or p.kind or "Outside Box"), + icon="package",entry=row and row.entry} + end + return out + end + UI.dropdown(imp,x,y,w-2*(h+gap),h,"stage-layers","Layers · front to back",layerOptions,s.stagePiece, + function(v) s.stagePiece=v end,"copy") + local layer=s.stagePiece + if layer and s.stageDraft.pieces[layer] then + button("stage-back","",function() edit(function(d) s.stagePiece=Showcase.reorder(d,layer,layer-1) end) end, + layer==1,h,x+w-2*h-gap,false,"arrow-down") + button("stage-front","",function() edit(function(d) s.stagePiece=Showcase.reorder(d,layer,layer+1) end) end, + layer==#s.stageDraft.pieces,h,x+w-h,false,"arrow-up") + end + nextRow() + local stageSection = sections("stageSection", { "Scene", "Placement", "Export" }) + if stageSection == "Scene" then + cycle("stage-background", "Background", Showcase.BACKGROUNDS, draft.background, function(value) edit(function(d) d.background=value end) end, 1) + cycle("stage-pattern", "Pattern", Showcase.PATTERNS, draft.pattern, function(value) edit(function(d) d.pattern=value end) end, 2) + cycle("stage-music", "Music", Showcase.MUSIC, draft.music, function(value) + edit(function(d) d.music=value end); Showcase.stopMusic(); s.stagePlaying = nil + end) + button("stage-play", s.stagePlaying and "Stop music" or "Play music", function() + if s.stagePlaying then Showcase.stopMusic(); s.stagePlaying = nil + else local ok, why = Showcase.playMusic(draft); if not ok then s.notice=why else s.stagePlaying = true end end + end,draft.music=="Silent",w,nil,false,s.stagePlaying and "square" or "play");nextRow() + elseif stageSection == "Placement" then + local function pieceLabel(i) + local placement = draft.pieces[i] + local row = placement.entryId and findEntry(s, placement.entryId) + return i .. " · " .. (row and row.entry.display.name or placement.kind or "Outside Box") + end + local function pieces() + local out = {} + for i in ipairs(draft.pieces) do out[i] = { id = i, label = pieceLabel(i), icon = "package" } end + return out + end + s.stagePiece = math.min(s.stagePiece or 1, math.max(1, #draft.pieces)) + UI.dropdown(imp, x, y, w, h, "stage-piece", draft.pieces[s.stagePiece] and pieceLabel(s.stagePiece) or "Choose a placement", + pieces, s.stagePiece, function(value) s.stagePiece = value end, "package"); nextRow() + local piece=draft.pieces[s.stagePiece or 1] + if piece then + local positions={};for i=0,25 do positions[#positions+1]=i/25 end + cycle("stage-x","X",positions,piece.x,function(value) edit(function() piece.x=value end) end, 1) + cycle("stage-y","Y",positions,piece.y,function(value) edit(function() piece.y=value end) end, 2) + cycle("stage-size","Size",{0.25,0.5,0.75,1,1.5,2,3,4},piece.scale,function(value) edit(function() piece.scale=value end) end, 1) + cycle("stage-rotation","Turn",{-math.pi,-math.pi/2,0,math.pi/2,math.pi},piece.rotation,function(value) edit(function() piece.rotation=value end) end, 2) + actions({ { "stage-flip", "Flip horizontally", function() edit(function() piece.flip=not piece.flip end) end }, { "stage-remove", "Remove", function() + edit(function(d) table.remove(d.pieces,s.stagePiece or 1) end);s.stagePiece=math.max(1,math.min(s.stagePiece,#draft.pieces)) + end } }) + else text("Add a pokémon or some furniture first.", true) end + elseif stageSection == "Export" then + actions({ { "stage-export", "Export PNG", function() + local ok,path=s.service:exportStage(s.stageIndex) + if ok then ok,path=userExport(s,path,s.stageIndex) end + s.notice=ok and "PNG exported: "..ok or path + end, not s.service.state.stages or not s.service.state.stages[s.stageIndex] }, + { "stage-wallpaper", "Use as wallpaper", function() + operation(function() return s.service:exportStage(s.stageIndex,s.box) end,"Wallpaper saved.") + end, not s.service.state.stages or not s.service.state.stages[s.stageIndex] } }) + end + button("stage-save", "Save stage", function() + operation(function() return s.service:saveStage(s.stageIndex,draft) end,"Showcase saved.") + end, false, (w - gap) / 2) + UI.dropdown(imp, x + (w + gap) / 2, y, (w - gap) / 2, h, "stage-options", "Options", { + { label = "Save and use as Box theme", icon = "paintbrush", action = function() + operation(function() + local ok,why=s.service:saveStage(s.stageIndex,s.stageDraft) + if not ok then return nil,why end + return s.service:exportStage(s.stageIndex,s.box) + end,"Stage saved as this Box's theme.") + end }, + { label = "Undo last edit", icon = "undo-2", disabled = not s.stageUndo or #s.stageUndo == 0, + action = function() s.stageDraft = table.remove(s.stageUndo); s.stagePiece = 1 end }, + { label = "Rename stage", icon = "pencil", action = function() + namePrompt("Stage name", draft.name, function(value) edit(function(d) d.name = value end) end) + end }, + }, nil, nil, "ellipsis"); nextRow() + y, x, w = math.max(y, previewBottom), fullX, fullW + elseif page == "Gifts" then + local source = s.sources[s.sourceIndex] + local cache = s._giftProgress + if not cache or cache.source ~= source or cache.state ~= s.service.state or cache.body ~= s.pcBody then + local progress, why = s.service:giftProgress(source) + cache = { source = source, state = s.service.state, body = s.pcBody, progress = progress, why = why } + s._giftProgress = cache + end + local progress, why = cache.progress, cache.why + if not progress then text(why, true) + else + text(progress.current .. " stored by this trainer · peak " .. progress.peak) + local giftCols = w >= 700 * m.s and 4 or 2 + local giftW = (w - (giftCols - 1) * gap) / giftCols + for index, award in ipairs(require("src.box.Gifts").AWARDS) do + local status = index < progress.nextAward and "Claimed" or index == progress.nextAward + and progress.eligible and "Ready" or "Locked" + local cx, cardH = x + (index - 1) % giftCols * (giftW + gap), 104 * m.s + Theme.card(cx, y, giftW, cardH, { shadow = true }) + Icons.draw(status == "Locked" and "lock" or status == "Claimed" and "check" or "package", + cx + 12 * m.s, y + 12 * m.s, 24 * m.s, status == "Ready" and PAL.green or PAL.blue, 1) + Kit.textBold("small", award.name .. " egg", cx + 12 * m.s, y + 43 * m.s, PAL.heading) + Kit.text("micro", award.threshold .. " stored · " .. status, cx + 12 * m.s, y + 73 * m.s, PAL.muted) + if index % giftCols == 0 or index == #require("src.box.Gifts").AWARDS then y = y + cardH + gap end + end + button("claim-egg", progress.award and "Claim " .. progress.award.name .. " egg" or "All gift eggs claimed", function() + operation(function() return s.service:claimEgg(s.sources[s.sourceIndex]) end, "Gift egg stored. Withdraw it to hatch in the game.") + end, not progress.eligible); nextRow() + end + elseif page == "List" then + local query = Kit.textfield("box-search", x, y, w, h, s.query, "Search Pokémon") + if query ~= s.query then s.query, s.toolsListPage = query, 1 end + nextRow() + cycle("sort", "Sort", Tools.SORTS, s.sort, function(value) s.sort, s.toolsListPage = value, 1 end) + local rows = search(s, s.query, s.sort) + local first, last, index = Kit.pageBounds(s.toolsListPage or 1, #rows, 8) + s.toolsListPage = index + for i = first, last do + local row, d = rows[i], rows[i].entry.display + local chosen = s.selectedBox[row.box .. ":" .. row.slot] ~= nil + local bh = h + Kit.textHeight("micro") + gap + local spriteW = 56 * m.s + api.button(imp, x + spriteW, y, w - spriteW, bh, "list-" .. row.entry.id, + (chosen and "Selected · " or "") .. d.name .. " · Lv. " .. d.level + .. " · " .. s.service.state.boxes[row.box].name .. " / " .. row.slot, function() + api.selectEntry(s, "box", row) + end, chosen, false, "package") + local art = require("src.box.Catalog").art(row.entry) + if art then require("src.online.OnlineSprites").drawIcon(art, x + 3 * m.s, + y + (bh - 48 * m.s) / 2, 48 * m.s, math.floor((s.artTime or 0) * 4)) end + Kit.text("micro", Kit.ellipsize("micro", table.concat({ d.gender or "—", d.nature or "—", d.ability or "—", + "HP " .. tostring(d.hp or "?"), "Atk " .. tostring(d.attack or "?"), "Def " .. tostring(d.defense or "?"), + "Spe " .. tostring(d.speed or "?") }, " · "), w - spriteW - 20 * m.s), + x + spriteW + 10 * m.s, y + h, chosen and PAL.inverse or PAL.muted) + y = y + bh + gap + end + if #rows == 0 then text("No Pokémon match this view.", true) end + cycle("list-page", "Page", (function() + local pages = {}; for i = 1, math.max(1, math.ceil(#rows / 8)) do pages[i] = i end; return pages + end)(), index, function(value) s.toolsListPage = value end) + actions({ { "summary", "Summary", function() api.openSummary(s) end, not s.inspect, false, "eye" }, + { "compare", "Compare selected", function() s.comparison = not s.comparison end, #refs == 0 or #refs > 6 } }) + if s.comparison and #refs > 0 and #refs <= 6 then + local fields = { {"species","Species"},{"level","Level"},{"types","Types"},{"gender","Gender"}, + {"nature","Nature"},{"ability","Ability"},{"hp","HP"},{"attack","Attack"},{"defense","Defense"}, + {"speed","Speed"},{"special","Special"},{"spAtk","Sp. Atk"},{"spDef","Sp. Def"}, + {"friendship","Friendship"},{"trainer","Trainer"},{"item","Held item"},{"moves","Moves"} } + local entries = {} + for _,ref in ipairs(refs) do + local entry=Store.at(s.service.state,ref.box,ref.slot) + if entry then entries[#entries+1]=entry end + end + local labelW=math.min(w*.22,110*m.s) + local columns=math.max(1,math.min(6,math.floor((w-labelW-gap)/(105*m.s)))) + for start=1,#entries,columns do + local count=math.min(columns,#entries-start+1) + local cellW=(w-labelW-gap)/count + local headerH=0 + for column=1,count do + local entry=entries[start+column-1] + headerH=math.max(headerH,Kit.textWrapped("small",entry.display.name, + x+labelW+gap+(column-1)*cellW,y,cellW-gap,PAL.heading)) + end + y=y+headerH+gap + for _,field in ipairs(fields) do + local values,applicable={},false + for column=1,count do + local value=entries[start+column-1].display[field[1]] + if value~=nil then applicable=true end + values[column]=value==nil and "N/A" or value=="" and "None" or tostring(value) + end + if applicable then + local rowH=Kit.textWrapped("small",field[2],x,y,labelW,PAL.muted) + for column=1,count do + rowH=math.max(rowH,Kit.textWrapped("small",values[column], + x+labelW+gap+(column-1)*cellW,y,cellW-gap,PAL.text)) + end + y=y+rowH+gap + end + end + y=y+gap + end + end + elseif page == "Filters" then + if (s.filterQuery or "") ~= s.query then s.filters = nil end + s.filters = s.filters or {} + local function query() + local parts = {} + for _, key in ipairs({ "type", "gender", "nature", "ability", "trainer", "mark" }) do + local value = s.filters[key] + if value and value ~= "Any" then + for piece in value:lower():gmatch('[^"]+') do parts[#parts + 1] = key .. ':"' .. piece .. '"' end + end + end + if s.filters.shiny then parts[#parts + 1] = "shiny" end + if s.filters.egg then parts[#parts + 1] = "egg" end + s.query, s.pageBox, s.pagePC = table.concat(parts, " "), 1, 1 + s.filterQuery = s.query + end + local choiceCache = s._filterChoices + if not choiceCache or choiceCache.state ~= s.service.state then + choiceCache = { state = s.service.state }; s._filterChoices = choiceCache + end + local function choices(key) + if choiceCache[key] then return choiceCache[key] end + local found, out = {}, { "Any" } + for _, row in ipairs(search(s, "", "slot")) do + local value = row.entry.display[key == "type" and "types" or key] + if value then + local values = key == "type" and (function() + local types = {}; for item in value:gmatch("[^/]+") do types[#types + 1] = item:match("^%s*(.-)%s*$") end + return types + end)() or { tostring(value) } + for _, v in ipairs(values) do if v ~= "" and not found[v] then found[v], out[#out + 1] = true, v end end + end + end + table.sort(out, function(a, b) if a == "Any" then return true elseif b == "Any" then return false end; return a < b end) + choiceCache[key] = out + return out + end + for index, key in ipairs({ "type", "gender", "nature", "ability", "trainer" }) do + cycle("filter-" .. key, key:gsub("^%l", string.upper), choices(key), s.filters[key] or "Any", + function(value) s.filters[key] = value; query() end, index % 2 == 1 and 1 or 2) + end + cycle("filter-mark", "Marked", { "Any", "circle", "square", "triangle", "heart" }, s.filters.mark or "Any", + function(value) s.filters.mark = value; query() end, 2) + for index, key in ipairs({ "shiny", "egg" }) do + button("filter-" .. key, key:gsub("^%l", string.upper) .. " only", function() + s.filters[key] = not s.filters[key]; query() + end, false, (w - gap) / 2, x + (index - 1) * (w + gap) / 2, s.filters[key], "check") + end + nextRow() + actions({ { "filter-clear", "Clear", function() s.filters, s.query, s.filterQuery = {}, "", "" end, false, false, "x" }, + { "filter-view", "Show Pokémon", function() UI.change(imp, s, "toolsPage", nil, -1); s.view = "box" end } }) + elseif page == "Arrange" then + s.arrangeSection = s.arrangeSection or "Sort" + local section = s.arrangeSection + local bw = (w - h - 2 * gap) / 2 + UI.button(imp, x, y, bw, h, "tools-arrange-sort-tab", "Sort a box", + function() UI.change(imp, s, "arrangeSection", "Sort") end,{face="tab",active=section=="Sort",icon="arrow-up-down"}) + UI.button(imp, x + bw + gap, y, bw, h, "tools-arrange-auto-tab", "Auto Box", + function() UI.change(imp, s, "arrangeSection", "Auto Box") end,{face="tab",active=section=="Auto Box",icon="arrow-left-right"}) + UI.dropdown(imp, x + w - h, y, h, h, "tools-arrangeSection", "", + UI.values({ "Move", "Sort", "Auto Box", "Labels" }, nil, "ellipsis"), section, + function(v) UI.change(imp, s, "arrangeSection", v) end, "ellipsis") + nextRow() + if section == "Move" then + actions({ { "select-box", "Select whole Box", function() + s.selectedBox, s.multi = {}, true + for slot in pairs(s.service.state.boxes[s.box].mons) do + s.selectedBox[s.box .. ":" .. slot] = { box = s.box, slot = slot } + end + end }, { "move-group", "Move / swap " .. #refs, function() + s.moving, s.view, s.toolsPage, s.query, s.sort = refs, "box", nil, "", "slot" + s.notice = "Choose the group's first destination slot. Occupied slots swap back." + end, #refs == 0 } }) + picker() + elseif section == "Sort" or section == "Auto Box" then + y = y + require("src.import.BoxOrganizer").draw(imp, s, x, y, w, m, api, section == "Sort" and "sort" or "rules") + elseif section == "Labels" then + s.editMarkings = s.editMarkings or 0 + for i, label in ipairs({ "Circle", "Square", "Triangle", "Heart" }) do + local mask = 2^(i - 1) + local checked = math.floor(s.editMarkings / mask) % 2 == 1 + button("mark-" .. i, label, function() + s.editMarkings = s.editMarkings + (checked and -mask or mask) + end, false, (w - gap) / 2, x + (i - 1) % 2 * ((w - gap) / 2 + gap), checked, "check") + if i % 2 == 0 then nextRow() end + end + actions({ { "apply-marks", "Apply marks", function() + operation(function() return s.service:mark(refs, s.editMarkings) end, "Markings saved.") + end, #refs == 0 }, { "tags", "Set tags", function() + namePrompt("Local tags", "", function(value) + operation(function() return s.service:mark(refs, s.editMarkings, value) end, "Tags saved.") + end) + end, #refs == 0, false, "pencil" } }) + end + elseif page == "Dashboard" then + local dash = s._dashboard + if not dash or dash.state ~= s.service.state or dash.save ~= s.pcSave then + dash = { state = s.service.state, save = s.pcSave, report = Collection.dashboard(s.service.state, s.pcSave) } + s._dashboard = dash + end + local report = dash.report + tiles({ { value = report.total, label = "Stored", icon = "package" }, { value = report.species, label = "Species", icon = "book-open" }, + { value = report.eggs, label = "Eggs", icon = "heart" }, { value = report.shiny, label = "Shiny", icon = "award" } }) + text(report.caught .. " caught in the linked game", true) + local games = {}; for game in pairs(report.byGame) do games[#games + 1] = game end; table.sort(games) + for _, game in ipairs(games) do text(require("src.core.GameVersion").info(game).label .. ": " .. report.byGame[game]) end + local forms = {}; for form in pairs(report.forms) do forms[#forms + 1] = form end; table.sort(forms) + text("Unown forms held: " .. (#forms == 0 and "None" or table.concat(forms, ", "))) + local duplicateOptions = {} + if not s._nationalNames then + s._nationalNames = {} + local Catalog, GameVersion = require("src.box.Catalog"), require("src.core.GameVersion") + local versions = { "emerald" }; for i = #GameVersion.ORDER, 1, -1 do versions[#versions + 1] = GameVersion.ORDER[i] end + for _, version in ipairs(versions) do + local data = Catalog.get(version) + if data.names then + for dex, species in pairs(data.national.toSpecies or {}) do + s._nationalNames[dex] = s._nationalNames[dex] or data.names[species] + end + else + for _, def in pairs(data.pokemon or {}) do + if def.dex then s._nationalNames[def.dex] = s._nationalNames[def.dex] or def.name end + end + end + end + end + local function speciesName(dex) return s._nationalNames[dex] or "Species #" .. dex end + for _, row in ipairs(report.duplicates) do + duplicateOptions[#duplicateOptions + 1] = { id = row.dex, label = speciesName(row.dex) .. " · " .. row.count .. " held", icon = "copy" } + end + UI.dropdown(imp, x, y, w, h, "duplicates", "Duplicates · " .. #report.duplicates, duplicateOptions, nil, + function(dex) UI.change(imp, s, "toolsPage", "List"); s.query = "dex:" .. dex end, "copy"); nextRow() + local missing = {}; for _, dex in ipairs(report.missing) do missing[#missing + 1] = { id = dex, label = speciesName(dex), icon = "book-open" } end + UI.dropdown(imp, x, y, w, h, "missing", "Missing species · " .. #missing, missing, nil, + function(dex) UI.open(imp, speciesName(dex), {}); imp._boxPopup.message = "you don’t have this species in Box yet." end, "book-open"); nextRow() + elseif page == "Teams" then + button("save-team", "Save selected team (" .. #refs .. ")", function() + namePrompt("Team name", "", function(value) + operation(function() return s.service:preset(value, refs) end, "Team preset saved.") + end) + end, #refs == 0 or #refs > 6); nextRow() + local presets = s.service.state.presets or {} + s.teamIndex = math.min(s.teamIndex or 1, math.max(1, #presets)) + if #presets > 0 then + local options = {}; for index, preset in ipairs(presets) do options[index] = { id = index, label = preset.name, icon = "users" } end + UI.dropdown(imp, x, y, w, h, "team-picker", presets[s.teamIndex].name, options, s.teamIndex, + function(value) s.teamIndex = value end, "users"); nextRow() + local index, preset = s.teamIndex, presets[s.teamIndex] + local members, missing = {}, false + for _, id in ipairs(preset.ids) do + local row = findEntry(s, id) + members[#members + 1] = row and row.entry.display.name or "Outside Box (#" .. id .. ")" + if not row then missing = true end + end + text(table.concat(members, " · ")) + actions({ { "select-team-" .. index, "Select team", function() + s.selectedBox = {} + for _, id in ipairs(preset.ids) do + local row = Store.find(s.service.state, id) + if row then s.selectedBox[row.box .. ":" .. row.slot] = { box = row.box, slot = row.slot } end + end + end, missing }, { "deploy-team-" .. index, "Send team", function() + operation(function() return s.service:deployPreset(s.sources[s.sourceIndex], index) end, "Team deployed.") + end, missing or not s.sources[s.sourceIndex], false, "arrow-right" } }) + end + end + return y - top, true +end + +return Tools diff --git a/src/import/BoxUI.lua b/src/import/BoxUI.lua new file mode 100644 index 00000000..a6397d4a --- /dev/null +++ b/src/import/BoxUI.lua @@ -0,0 +1,179 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Transition = require("src.ui.kit.Transition") +local Strings = require("src.core.Strings") +local UI = {} +local PAL = Theme.PAL +UI.ICONS = { List = "book-open", Filters = "list-filter", Arrange = "arrow-left-right", + Themes = "paintbrush", Dashboard = "chart-no-axes-column", Teams = "users", Gifts = "package", + Showcase = "grid-2x2", Files = "folder", Events = "mail", Migration = "arrow-left-right" } +UI.HELP = { + Box = "these pokémon stay here until you send them to a game. Box saves separately, and sync keeps both sides of a move together.", + List = "all your stored pokémon in one list. select up to six to compare them.", + Arrange = "sort and Auto Box work on Box storage or the linked game's PC. top rule wins. preview where they'll go, then apply to save.", + Themes = "just the look of this box. use a saved Showcase stage if you want your own wallpaper.", + Dashboard = "what’s in Box right now. caught is from the linked game’s pokédex, so the numbers can be different.", + Teams = "save a group of up to six. send team moves them into the linked game’s party, so leave enough room.", + Gifts = "this trainer unlocks eggs by storing more pokémon. claim one here, then send it to a game to hatch it.", + Showcase = "make a little scene with your pokémon. edits are a draft until you save. saved stages can become wallpapers.", + Files = "bring in a GameCube Box save or export one back out. exports need its original template and Gen 3 pokémon.", + Backup = "restores Box and its linked saves together. your current collection gets backed up first. older incomplete backups won’t restore.", + Events = "tickets for event trips in the linked game. collect the ticket, then use the normal ship route. story requirements still apply.", + Migration = "same pokémon, new generation. check the changes first. the original stays archived so you can switch back.", + MigrationPolicy = "Gen 2 and Gen 3 never had an official transfer route. G1R converts what fits and blocks what doesn’t.", +} +local HELP_TITLES = { Box = "Box storage", + Backup = "Restore backup", MigrationPolicy = "Conversion rules" } +local function view() return require("src.import.LauncherView") end +function UI.button(imp, x, y, w, h, id, label, action, opts) + opts = opts or {}; opts.action, opts.font = action, opts.font or "small" + view().btn(imp, x, y, w, h, "box-" .. id, Strings(label), opts) +end +function UI.close(imp) + imp._boxPopup = nil; Kit.blur(); Kit._drag = nil + Kit.mouseClicked, Kit.wheelY = false, 0 +end +function UI.open(imp, title, options, current, apply) + Kit.blur(); Kit._drag = nil + imp._boxPopup = { title = title, options = options, current = current, apply = apply, + scroll = 0, index = 1, query = "", reveal = true } + if #options == 0 then imp._boxPopup.message = title == "Choose a game" + and "save a game first. your party and PC boxes will show up here." or "nothing here yet." end + if current ~= nil then + for i, option in ipairs(options) do if option.id == current then imp._boxPopup.index = i end end + end + if options[imp._boxPopup.index] and options[imp._boxPopup.index].disabled then + for i, option in ipairs(options) do + if not option.disabled then imp._boxPopup.index = i; break end + end + end +end +function UI.help(imp, key, x, y, size, message) + UI.button(imp, x, y, size, size, "help-" .. key, "", function() + UI.open(imp, HELP_TITLES[key] or key, {}, nil) + imp._boxPopup.message = message or UI.HELP[key] + end, { face = "bare", icon = "circle-help" }) +end +function UI.dropdown(imp, x, y, w, h, id, label, options, current, apply, icon) + UI.button(imp, x, y, w, h, id, label, function() + UI.open(imp, label, type(options) == "function" and options() or options, current, apply) + end, + { face = "invert", align = "left", icon = icon or "sliders-horizontal", trailingIcon = "chevron-down" }) +end +function UI.values(values, labels, icon) + local options = {} + for i, value in ipairs(values) do options[i] = { id = value, label = labels and labels[value] or tostring(value), icon = icon } end + return options +end +function UI.change(imp, s, key, value, direction) + if s[key] == value then return false end + local old = {}; for k, v in pairs(s) do if k ~= "motionFrom" then old[k] = v end end + s[key] = value; s.motionFrom = old; Kit.blur() + Transition.start("box", "tab", { dir = direction or 1 }) + imp._tabScroll = imp._tabScroll or {}; imp._tabScroll.box = 0 + return true +end +function UI.pages(imp, s, x, y, w, h, m, draw) + local tr = Transition.get("box") + if not tr or not s.motionFrom then s.motionFrom = nil; return draw(imp, s, x, y, w, h, m) end + local blocked = Kit.blockClicks; Kit.blockClicks = true + Kit.pushClip(x, y, w, math.max(h, 1)) + local dir = tr.dir >= 0 and 1 or -1 + local oldH, newH + local ok, err = xpcall(function() + oldH = draw(imp, s.motionFrom, x - dir * tr.p * w, y, w, h, m) + newH = draw(imp, s, x + dir * (1 - tr.p) * w, y, w, h, m) + end, debug.traceback) + Kit.popClip(); Kit.blockClicks = blocked + if not ok then error(err, 0) end + return math.max(oldH or 0, newH or 0) +end +local function filtered(p) + if p.filteredQuery ~= p.query then + p.rows = {} + for _, option in ipairs(p.options) do + local fold=require("src.box.Search").normalize + if p.query == "" or fold(option.label):find(fold(p.query), 1, true) then p.rows[#p.rows + 1] = option end + end + p.filteredQuery = p.query + end + return p.rows +end +local function choose(imp, p, option) + if imp._boxPopup ~= p or not option or option.disabled then return false end + UI.close(imp) + if option.action then option.action() elseif p.apply then p.apply(option.id) end + return true +end +function UI.keypressed(imp, key) + local p = imp._boxPopup + if not p then return false end + if key == "escape" then UI.close(imp) + elseif key == "return" or key == "kpenter" then + if p.message then UI.close(imp) else choose(imp, p, filtered(p)[p.index]) end + elseif not p.message and (key == "up" or key == "down" or key == "home" or key == "end") then + local rows = filtered(p) + if key == "home" then p.index = 1 elseif key == "end" then p.index = #rows + else + local step = key == "up" and -1 or 1 + for _ = 1, math.max(1, #rows) do + p.index = (p.index - 1 + step) % math.max(1, #rows) + 1 + if not (rows[p.index] and rows[p.index].disabled) then break end + end + end + p.reveal = true + elseif Kit.focus == "box-chooser-filter" then Kit.keypressed(key) end + return true +end +function UI.drawPopup(imp, m) + local p = imp._boxPopup + local pad, gap = 16 * m.s, 8 * m.s + local row = math.max(Kit.tapMin(), m.btnH) + local width = math.min((p.message and 390 or 460) * m.s, m.W - 2 * m.pad) + local searchable = not p.message and #p.options > 8 + local inner = width - 2 * pad + local content = p.message and Kit.wrapHeight("small", Strings(p.message), inner, 6) + or math.max(row, math.min(7, #p.options) * (row + gap) - gap) + local x, y, w, h = view().modalPanel(m, width, 2 * pad + row + gap + content + (searchable and row + gap or 0), { scrim = .76, slide = true }) + if Kit.press(0, 0, m.W, m.H) and not Kit.hit(x, y, w, h) then UI.close(imp); return end + UI.button(imp, x + w - pad - row, y + pad, row, row, "popup-close", "", function() UI.close(imp) end, + { face = "bare", icon = "x" }) + Kit.textBold("button", Strings(Kit.ellipsize("button", p.title, w - 2 * pad - row - gap)), x + pad, + y + pad + (row - Kit.textHeight("button")) / 2, PAL.heading) + local bx, by, bw = x + pad, y + pad + row + gap, w - 2 * pad + if p.message then Kit.textWrapped("small", Strings(p.message), bx, by, bw, PAL.text, 6); return end + if searchable then + local query = Kit.textfield("box-chooser-filter", bx, by, bw, row, p.query, Strings("Find an option")) + if query ~= p.query then p.query, p.index, p.scroll, p.reveal = query, 1, 0, true end + by = by + row + gap + end + local rows = filtered(p) + local bh = math.max(1, y + h - pad - by) + local total = math.max(row, #rows * (row + gap) - gap) + local extent = Kit.scrollExtent(total, bh) + if p.reveal then + local at = (p.index - 1) * (row + gap) + p.scroll = Kit.scrollClamp(math.max(at + row - bh, math.min(p.scroll, at)), extent); p.reveal = nil + end + p.scroll = Kit.scrollInput(p.scroll, extent, bx, by, bw, bh) + local cy = Kit.scrollBegin(bx, by, bw, bh, p.scroll, extent) + local first = math.max(1, math.floor(p.scroll / (row + gap)) + 1) + local last = math.min(#rows, math.ceil((p.scroll + bh) / (row + gap))) + for i = first, last do + local option = rows[i] + local active = p.current ~= nil and option.id == p.current or option.active + UI.button(imp, bx, cy + (i - 1) * (row + gap), bw - Kit.scrollGutter(), row, + "popup-option-" .. i, (option.entry or option.prop) and (" " .. option.label) or option.label, function() choose(imp, p, option) end, + { face = "selection", align = "left", active = active, + enabled = not option.disabled, icon = not (option.entry or option.prop) and (option.icon or "check") or nil, + trailingIcon = active and "check" or nil, ring = p.index == i }) + local iy=cy+(i-1)*(row+gap) + if option.entry then + local art=require("src.box.Catalog").art(option.entry) + if art then require("src.online.OnlineSprites").drawIcon(art,bx+8*m.s,iy+4*m.s,row-8*m.s,0) end + elseif option.prop then require("src.box.Showcase").drawProp(option.prop,bx+8*m.s,iy+4*m.s,row-8*m.s) end + end + if #rows == 0 then Kit.text("small", Strings("Nothing matches."), bx, by, PAL.muted) end + Kit.scrollEnd(bx, by, bw, bh, p.scroll, extent) +end +return UI diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index a6ec4c79..e7ab2d2e 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -817,6 +817,20 @@ function LauncherSettings.open(hooks, version) return true end, }, + { + label = Strings("Showcase Music"), + value = function() return volLabel(opts.boxMusicVol) end, + step = function(dir) + opts.boxMusicVol = stepVolume(opts.boxMusicVol, dir or 1) + require("src.box.Showcase").setVolume(opts.boxMusicVol) + return true + end, + }, + { + label = Strings("Showcase Cry"), + value = function() return volLabel(opts.boxCryVol) end, + step = function(dir) opts.boxCryVol = stepVolume(opts.boxCryVol, dir or 1); return true end, + }, }, } local Window = require("src.import.LauncherWindow") @@ -831,6 +845,12 @@ function LauncherSettings.open(hooks, version) select = function(value) Window.observe(0); return Window.apply(value) end, }) end + if hooks and hooks.openExtras then + table.insert(launcher.rows, 1, { + label = Strings("Extras"), actionLabel = Strings("Open"), + action = function() hooks.openExtras(); return false end, + }) + end local RomSources = require("src.import.RomSources") local forgotRoms = false launcher.rows[#launcher.rows + 1] = { diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 37c41677..1a07b70f 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -28,6 +28,7 @@ -- * Layout is explicit pixels off Layout.metrics. No percentages. local Kit = require("src.ui.kit.Kit") +local Icons = require("src.ui.kit.Icons") local CartShape = require("src.import.CartShape") local Theme = require("src.ui.kit.Theme") local Layout = require("src.ui.kit.Layout") @@ -48,6 +49,7 @@ local COMMUNITY_URL = "https://bois.icu" -- synthesizes for the same tap. local ACT_DEDUP = 0.35 local LONG_PRESS_SECONDS = 0.60 +local CONSUMED_TOUCH_MAX = 30 -- Finger travel past this (px) is a drag, not a tap. local TAP_SLOP2 = 16 * 16 local MIN_SKIN_ROWS = 4 @@ -123,6 +125,9 @@ function LauncherView.detach(imp) end Transition.reset() Kit.occlude(nil) + if imp then + imp._consumedTouch, imp._consumedTouchUntil = nil, nil + end if imp and imp._themeVideo then imp._themeVideo:release() imp._themeVideo = nil @@ -216,6 +221,7 @@ function LauncherView.update(imp, dt) -- synthesized echo. local now = love.timer.getTime() local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil + or LauncherView.consumedTouchLive(imp) if not touching and now >= (imp._suppressMouseUntil or 0) and now >= (imp._suppressClickUntil or 0) then local mx, my = love.mouse.getPosition() @@ -272,6 +278,71 @@ function LauncherView.wheelmoved(imp, dx, dy) imp._wheelY = (imp._wheelY or 0) + (dy or 0) end +function LauncherView.consumePress(imp) + imp._prevMouseDown = true +end + +function LauncherView.consumeTouch(imp, id) + imp._consumedTouch = imp._consumedTouch or {} + imp._consumedTouch[tostring(id)] = love.timer.getTime() + LauncherView.consumePress(imp) +end + +function LauncherView.forgetConsumedTouch(imp, id) + local held = imp._consumedTouch + if not held then return end + if id == nil then + imp._consumedTouch = nil + else + held[tostring(id)] = nil + end +end + +local liveTouchIds = {} +local function pruneConsumedTouch(imp) + local held = imp._consumedTouch + if not held or next(held) == nil then return end + local now = love.timer.getTime() + local getTouches = love.touch and love.touch.getTouches + local ids = getTouches and getTouches() or nil + if ids then + for k in pairs(liveTouchIds) do liveTouchIds[k] = nil end + for i = 1, #ids do liveTouchIds[tostring(ids[i])] = true end + end + local dropped = false + for tid, at in pairs(held) do + local age = now - (type(at) == "number" and at or 0) + if age > CONSUMED_TOUCH_MAX + or (ids and not liveTouchIds[tid] and age > ACT_DEDUP) then + held[tid] = nil + dropped = true + end + end + if dropped and next(held) == nil then + imp._consumedTouchUntil = now + ACT_DEDUP + end +end + +function LauncherView.releaseConsumedTouch(imp, id) + local held = imp._consumedTouch + local tid = tostring(id) + if not (held and held[tid]) then return false end + held[tid] = nil + local untilT = love.timer.getTime() + ACT_DEDUP + imp._suppressMouseUntil = untilT + imp._consumedTouchUntil = untilT + return true +end + +function LauncherView.consumedTouchLive(imp) + pruneConsumedTouch(imp) + if imp._consumedTouch ~= nil and next(imp._consumedTouch) ~= nil then + return true + end + local untilT = imp._consumedTouchUntil + return untilT ~= nil and love.timer.getTime() < untilT +end + function LauncherView.touchpressed(imp, id, x, y) if not imp._flex then return end -- Leftover finger from EXIT GAME / Close editor: do not start a tap. @@ -390,6 +461,7 @@ function LauncherView.mousepressed(imp, x, y) if not imp._flex then return end local now = love.timer.getTime() local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil + or LauncherView.consumedTouchLive(imp) if touching or now < (imp._suppressMouseUntil or 0) or now < (imp._suppressClickUntil or 0) then return @@ -1502,11 +1574,10 @@ local GAME_TABS = { } local HEADER_TABS = { + { id = "box", key = "tab-box", glyph = true, beta = true }, { id = "mods", key = "tab-mods" }, - { id = "find", key = "tab-find" }, { id = "online", key = "tab-online", glyph = true, beta = true }, { id = "skins", key = "tab-skins", glyph = true, beta = true }, - { id = "importers", key = "tab-importers", glyph = true }, } LauncherView.HEADER_TABS = HEADER_TABS @@ -1523,12 +1594,12 @@ local function overlayBeta(tx, ty, w, tabH, m) drawBetaTag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh) end -local TAB_ICONS = { mods = "puzzle", find = "search", online = "globe", - skins = "paintbrush", importers = "download" } -local TAB_LABELS = { mods = "MODS", find = "FIND", online = "ONLINE", - skins = "SKINS", importers = "EXTRA" } +local TAB_ICONS = { box = "package", mods = "puzzle", online = "globe", + skins = "paintbrush" } +local TAB_LABELS = { box = "BOX", mods = "MODS", online = "ONLINE", + skins = "SKINS" } for _, t in ipairs(HEADER_TABS) do - t.opts = { face = "tab", font = "tab", icon = TAB_ICONS[t.id] } + t.opts = { face = "tab", font = "tab", icon = TAB_ICONS[t.id], iconScale = t.id == "box" and 1.15 or nil } end local function headerTabMetrics(m) @@ -1611,7 +1682,10 @@ local function headerChrome(imp) } for _, t in ipairs(HEADER_TABS) do local id = t.id - c.tab[id] = function() imp:_switchTab(id) end + c.tab[id] = function() + local target = id == "mods" and (imp.tab == "find" and "find" or imp._modsInnerTab or "mods") or id + imp:_switchTab(target) + end end for _, g in ipairs(GAME_TABS) do local id = g.id @@ -1739,15 +1813,12 @@ local function buildHeader(imp, m) chrome0.game.ring = nil btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game) do - local cw = math.floor(7 * m.s) + local size = math.floor(18 * m.s) local ccx = tx + dropW - math.floor(14 * m.s) local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0) - if love.graphics.polygon then - Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9) - love.graphics.polygon("fill", - ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8) - love.graphics.setColor(1, 1, 1, 1) - end + Icons.draw("chevron-down", ccx - size / 2, ccy - size / 2, size, + gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9) + love.graphics.setColor(1, 1, 1, 1) end Kit.textCenter("micro", Strings("GAMES"), tx, ty + tabH + 3 * m.s, dropW, PAL.muted) tx = tx + dropW + tabGap @@ -1759,7 +1830,7 @@ local function buildHeader(imp, m) ty = ty + tabH + labelH + tabRowGap end local o = t.opts - o.active = imp.tab == t.id + o.active = imp.tab == t.id or t.id == "mods" and imp.tab == "find" o.image = t.icon o.action = chrome.tab[t.id] btn(imp, tx, ty, w, tabH, t.key, "", o) @@ -3827,14 +3898,14 @@ local function modalAmount() return p end -local function modalPanel(m, w, h) +local function modalPanel(m, w, h, opts) -- A near-opaque scrim, not a tint. At 0.82 the header and the wordmark -- still read through the settings panel and the screen looked like two -- layouts fighting rather than one panel on top ("the settings is covering -- the logo"); at this weight the page behind is present but plainly out of -- play, which is what a modal is supposed to say. local amt = modalAmount() - Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93 * amt) + Theme.fill(0, 0, m.W, m.H, PAL.bg, (opts and opts.scrim or 0.93) * amt) Kit.blockClicks = true local pw = math.floor(math.min(w, m.W - 2 * m.pad)) local ph = math.floor(math.min(h, m.H - 2 * m.pad)) @@ -3842,11 +3913,14 @@ local function modalPanel(m, w, h) local py = math.floor((m.H - ph) / 2) modalRect.x, modalRect.y, modalRect.w, modalRect.h = px, py, pw, ph if amt < 1 and not modalTransform and love.graphics and love.graphics.push then - local s = 0.96 + 0.04 * amt love.graphics.push() - love.graphics.translate(m.W / 2, m.H / 2) - love.graphics.scale(s, s) - love.graphics.translate(-m.W / 2, -m.H / 2) + if opts and opts.slide then love.graphics.translate(0, (1 - amt) * 32 * m.s) + else + local s = 0.96 + 0.04 * amt + love.graphics.translate(m.W / 2, m.H / 2) + love.graphics.scale(s, s) + love.graphics.translate(-m.W / 2, -m.H / 2) + end modalTransform = true end Kit.card(px, py, pw, ph, true) @@ -3940,7 +4014,7 @@ local function modalFrame(imp, m, key, w, pad, headH, contentH, footH, gap, view return modalBody(imp, key, x, bodyY, pw - 2 * pad, footY - (footH > 0 and gap or 0) - bodyY, contentH) end - return px, py, pw, footY, open + return px, py, pw, footY, open, ph end function LauncherView.modalTextW(m, w, pad) @@ -4089,8 +4163,12 @@ local function buildPrompt(imp, m, spec) local footH = spec.footnote and (Kit.textHeight("micro") + math.floor(8 * m.s)) or 0 local headH = Kit.textHeight("button") + math.floor(10 * m.s) - local px, py, pw, footY, open = modalFrame(imp, m, spec.modal, w, pad, + local px, py, pw, footY, open, ph = modalFrame(imp, m, spec.modal, w, pad, headH, hintH + fieldH, m.btnH + footH, math.floor(12 * m.s)) + if not imp._modalHeld and Kit.press(0, 0, m.W, m.H) + and not Kit.hit(px, py, pw, ph) then + spec.cancel(); return + end Kit.text("button", Kit.ellipsize("button", spec.title, pw - 2 * pad), px + pad, py + pad, PAL.heading) local top, _, mark, done = open() @@ -5772,9 +5850,9 @@ local function buildSettingsModal(imp, m) rx - bandW + stepW + gap / 2, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw, rowEnabled and PAL.heading or PAL.muted) end - control(rx - bandW, stepW, key .. "-prev", "‹", { + control(rx - bandW, stepW, key .. "-prev", "", { icon = "chevron-left", action=function() if row.step and row.step(-1) then model.save() end end}) - control(rx - stepW, stepW, key .. "-next", "›", { + control(rx - stepW, stepW, key .. "-next", "", { icon = "chevron-right", action=function() if row.step and row.step(1) then model.save() end end}) end end @@ -6023,7 +6101,7 @@ end local Sync = {} -Sync.HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. Keep your own backups too." +Sync.HINT = "Cloud sync keeps your game saves, Box collection and mod list on our server so another device can pick them up. Keep your own backups too." function Sync.title(imp, m, w, pad, fit) local headH = Kit.textHeight("button") + math.floor(12 * m.s) @@ -6124,6 +6202,7 @@ function LauncherView.syncSideText(meta) bits[#bits + 1] = tostring(math.floor(summary.dexCount)) .. " " .. Strings("seen") end + if tonumber(summary.boxCount) then bits[#bits + 1] = tostring(summary.boxCount) .. " " .. Strings("Pokémon") end local when = tonumber(meta.savedAt) if when then bits[#bits + 1] = Strings("saved") .. " " .. os.date("%Y-%m-%d %H:%M", when) @@ -6137,7 +6216,7 @@ function Sync.conflict(imp, m, eng) local pad = math.floor(18 * m.s) local w = Sync.width(m, math.floor(520 * m.s)) local innerW = w - 2 * pad - Kit.scrollGutter() - local lead = row.overlap + local lead = row.box and Strings(row.reviewMessage or "Choose one Box collection and its linked saves. The other collection is kept in a recovery backup.") or row.overlap and Strings("These saves were played at the same time.") or Strings("This save also changed on another device.") local mine = LauncherView.syncSideText(row.localMeta) @@ -6145,8 +6224,8 @@ function Sync.conflict(imp, m, eng) local fit = Sync.fit(m, 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + 2 * (Kit.textHeight("small") + math.floor(12 * m.s)), - 4, 4, { - { font = "small", str = lead, w = innerW, max = 2 }, + row.box and 3 or 4, row.box and 3 or 4, { + { font = "small", str = lead, w = innerW, max = row.box and 4 or 2 }, { font = "micro", str = mine, w = innerW, max = 2 }, { font = "micro", str = theirs, w = innerW, max = 2 }, }) @@ -6171,9 +6250,10 @@ function Sync.conflict(imp, m, eng) cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-other", Strings("Keep the other device"), { kind = "accent", action = function() imp:_syncResolve(key, "remote") end }, fit) - cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-both", - Strings("Keep both"), { - action = function() imp:_syncResolve(key, "both") end }, fit) + if not row.box then + cy = Sync.row(imp, m, px + pad, cy, innerW, "sync-keep-both", + Strings("Keep both"), { action = function() imp:_syncResolve(key, "both") end }, fit) + end done() Sync.row(imp, m, px + pad, footY, pw - 2 * pad, "sync-conflict-close", Strings("Close"), { action = function() imp:_closeSync() end }, fit) @@ -6312,7 +6392,7 @@ function Sync.home(imp, m, eng) local linked = eng:linked() local codes = linked and eng.codes or nil local body = linked - and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.") + and Strings("This device is linked. Game saves and Box sync when the launcher opens, a few seconds after each save or Box transfer, and every few minutes while the app is running.") or Strings(Sync.HINT) local innerW = w - 2 * pad - Kit.scrollGutter() local codesH = codes @@ -6479,6 +6559,7 @@ end -- a click on the scrim lands on whatever button happens to be behind it. -- Keep this list in sync with buildModals below. local MODAL_KEYS = { + "_boxPrompt", "_boxPopup", "_profileRenamePrompt", "_profileSavePrompt", "_settingsText", "_cartSave", "_bugModal", "_settings", "_rename", "_indexPrompt", "_modConfirm", "_appPatchNotes", "_modReleaseNotes", "_findDetails", @@ -6495,7 +6576,7 @@ LauncherView.MODAL_KEYS = MODAL_KEYS local function modalUp(imp) if Kit.FileBrowser and Kit.FileBrowser.active then return true end if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end - return (imp._settingsText or imp._settings or imp._rename + return (imp._boxPopup or imp._boxPrompt or imp._settingsText or imp._settings or imp._rename or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes or imp._appPatchNotes or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup @@ -6531,6 +6612,18 @@ local function buildModals(imp, m) Kit.FileBrowser.draw(m) return true end + if imp._boxPrompt then + local prompt = imp._boxPrompt + buildPrompt(imp, m, { + key = "boxprompt", modal = "_boxPrompt", title = prompt.title, text = prompt.text, + okLabel = Strings("Save"), + commit = function() require("src.import.BoxPrompt").close(imp, true) end, + cancel = function() require("src.import.BoxPrompt").close(imp, false) end, + footnote = Strings("Enter to save - Esc to cancel"), + }) + return true + end + if imp._boxPopup then require("src.import.BoxUI").drawPopup(imp, m); return true end if imp._secretPopup then buildSecretModal(imp, m) return true end if imp._profileRenamePrompt then buildPrompt(imp, m, { @@ -6786,10 +6879,25 @@ local function minPanelHeight(m) end local function buildTabPanel(imp, x, y, w, availH, budgetH, m) - if imp.tab == "mods" then - return buildModsPanel(imp, x, y, w, budgetH, m) - elseif imp.tab == "find" then - return buildFindPanel(imp, x, y, w, budgetH, m) + if imp.tab == "box" then + return require("src.import.BoxPanel").draw(imp, x, y, w, budgetH, m) + elseif imp.tab == "mods" or imp.tab == "find" then + local search = imp.tab == "find" + local gap, h = math.floor(8 * m.s), m.btnH + local bw = math.min((w - gap) / 2, math.floor(180 * m.s)) + btn(imp, x, y, bw, h, "mods-inner-installed", Strings("Installed"), { + face = "tab", font = "small", icon = "puzzle", active = not search, + action = function() imp:_switchTab("mods") end, + }) + btn(imp, x + bw + gap, y, bw, h, "mods-inner-search", Strings("Search"), { + face = "tab", font = "small", icon = "search", active = search, + action = function() imp:_switchTab("find") end, + }) + local offset = h + gap + if search then + return offset + buildFindPanel(imp, x, y + offset, w, budgetH - offset, m) + end + return offset + buildModsPanel(imp, x, y + offset, w, budgetH - offset, m) elseif imp.tab == "skins" then return buildSkinsPanel(imp, x, y, w, budgetH, m) elseif imp.tab == "importers" then @@ -6955,10 +7063,12 @@ function LauncherView.draw(imp) drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m, 0) end + if imp.tab == "box" then require("src.import.BoxOrganizer").drawSticky(imp, m) end buildFooter(imp, m, footY) Kit.blockClicks = Transition.active() local held = imp._modalHeld if held then imp[held.key] = held.value end + Toast.occlude(imp) buildModals(imp, m) endModalDraw(m) if held then imp[held.key] = nil end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index f31aa1ca..052857db 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1798,6 +1798,8 @@ end function RomImporter:focus(f) self._modPickOpenWait = nil if not f then + require("src.import.LauncherView").forgetConsumedTouch(self) + self._touchAt = nil self._activeTouch = nil self._pagePress = nil self._slotPress = nil @@ -1867,6 +1869,9 @@ function RomImporter:focus(f) else self.modNotice = { ok = false, text = text } end + elseif self.pickerPendingKind == "box" then + self.pickerPendingKind, self.pickPending = nil, nil + require("src.import.BoxPanel").importGCI(self, nil, text) elseif pickError:find("picked_save", 1, true) then local version = self.androidPendingVersion or self:_savedropTarget() self.androidPendingVersion = nil @@ -1955,6 +1960,11 @@ function RomImporter:focus(f) end local savName = findPendingSav(false, self.pickSkip) if savName then + if self.pickerPendingKind == "box" then + self.pickerPendingKind = nil + consumePick(self, savName, "picked_save.sav", self:_importBoxFile(savName)) + return + end local version = self.androidPendingVersion or self:_savedropTarget() self.androidPendingVersion = nil self:_importSave(version, savName) @@ -2502,6 +2512,13 @@ function RomImporter:filedropped(file) -- everything else is treated as a ROM. The dropped file itself is passed -- through -- installZip opens it the same way readDroppedFile does here. local name = file:getFilename() or "" + if name:lower():match("%.gci$") + or (self.tab == "box" and name:lower():match("%.sav$") + and file.getSize and file:getSize() == 0x76000) then + self:_switchTab("box") + self:_importBoxFile(file) + return + end if name:lower():match("%.deltaskin$") then self:_installSkinZip(file) return @@ -2541,6 +2558,61 @@ function RomImporter:filedropped(file) self:startData(data, file:getFilename(), file:getFilename()) end +function RomImporter:_importBoxFile(source) + local data, why, size + if type(source) == "string" then + local info = love.filesystem.getInfo(source, "file") + size = info and info.size or externalFileSize(source) + if size == 0x76040 or size == 0x76000 then + if info then data, why = love.filesystem.read(source) + else data, why = readExternalPath(source) end + end + else + local opened + opened, why = source:open("r") + if opened then + size = source:getSize() + if size == 0x76040 or size == 0x76000 then data, why = source:read(size) end + source:close() + end + end + if not data then + why = why or "Choose a native Box GCI (0x76040 bytes) or raw save (0x76000 bytes)." + end + return require("src.import.BoxPanel").importGCI(self, data, why) +end + +function RomImporter:chooseBoxImport() + if self.workState == "working" then return end + if self.nativePicker and love.system.getPickedFile then + self.pickerPendingKind = "box" + if not pickFile("sav") then + self.pickerPendingKind = nil + require("src.import.BoxPanel").importGCI(self, nil, "Could not open the file picker.") + end + return + end + if self.android then + self.pickerPendingKind = "box" + if pickFile("sav") then self.pickPending, self.pickTimer = true, 0 + else + self.pickerPendingKind = nil + require("src.import.BoxPanel").importGCI(self, nil, "Could not open the file picker.") + end + return + end + local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" + if self.isNX or self.ios or isHandheld then + require("src.ui.kit.Kit").FileBrowser.open({ title = "Select Pokémon Box save (.gci / .sav)", + mode = "box", onSelect = function(path) self:_importBoxFile(path) end }) + return + end + local path = chooseImporterFile("Pokémon Box save", { "gci", "sav" }) + if path and path ~= "" then self:_importBoxFile(path) end +end + -- Install a mod .zip from a picker path or a dropped file, then surface the -- result on the mods panel (switching to it so the notice is visible). The -- source is whatever LauncherMods.installZip accepts: an absolute path string @@ -3451,7 +3523,10 @@ function RomImporter:_pollPickedFiles(dt) if pickError then love.filesystem.remove("pick_error.txt") self.pickPending = nil - if self.pickerPendingKind == "cart" then + if self.pickerPendingKind == "box" then + self.pickerPendingKind = nil + require("src.import.BoxPanel").importGCI(self, nil, pickError) + elseif self.pickerPendingKind == "cart" then self.pickerPendingKind = nil self.pickerPendingVersion = nil self._cartNotice = pickError @@ -3513,6 +3588,7 @@ function RomImporter:update(dt) if self._flex and not self._inputBlocked then require("src.import.LauncherView").update(self, dt) end + if self.tab == "box" then require("src.import.BoxPanel").update(self, dt) end -- Drive every in-flight async fetch. These are the operations that used to -- run synchronously inside draw and freeze the window; each pump is a -- non-blocking channel poll, so a frame with nothing in flight costs @@ -3703,6 +3779,9 @@ function RomImporter:update(dt) if Platform.isUWP() and self._skinNotice and self._skinNotice.ok then os.remove(path) end + elseif kind == "box" then + local imported = self:_importBoxFile(path) + if Platform.isUWP() and imported then os.remove(path) end elseif kind == "sav" then local target = version or self:_savedropTarget() self:_importSave(target, path) @@ -3734,6 +3813,8 @@ function RomImporter:update(dt) self.modNotice = { ok = false, text = errorText } elseif kind == "skin" then self._skinNotice = { ok = false, text = errorText } + elseif kind == "box" then + require("src.import.BoxPanel").importGCI(self, nil, errorText) elseif kind == "sav" then self.saveNotice[version] = { ok = false, text = errorText } elseif kind == "cart" then @@ -3886,16 +3967,26 @@ function RomImporter:resumeAfterOverlay() end function RomImporter:_cycleTab(delta) - local order = { "mods", "find", "skins", "importers" } + local order = {} + for _, tab in ipairs(require("src.import.LauncherView").HEADER_TABS) do + order[#order + 1] = tab.id + end local games = SecretGames.order(self) for i = #games, 1, -1 do table.insert(order, 1, games[i]) end - local idx = 1 + local idx + local current = self.tab == "find" and "mods" or self.tab for i, id in ipairs(order) do - if id == self.tab then idx = i; break end + if id == current then idx = i; break end end - self:_switchTab(order[((idx - 1 + delta) % #order) + 1]) + local target + if idx then + target = order[((idx - 1 + delta) % #order) + 1] + else + target = delta > 0 and order[1] or order[#order] + end + self:_switchTab(target == "mods" and (self._modsInnerTab or "mods") or target) end local STICK_NAV_ON = 0.5 @@ -4039,6 +4130,11 @@ function RomImporter:gamepadpressed(_, button) if action == "a" or action == "start" then self:_confirmSecret() end return end + if self._boxPopup and action == "b" then + require("src.import.BoxUI").close(self) + return + end + if okKit and Kit.focus and action == "b" then Kit.blur(); return end -- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor: if action == "y" or button == "y" then @@ -4051,6 +4147,10 @@ function RomImporter:gamepadpressed(_, button) end -- Select button: toggle Virtual Keyboard on handhelds + if self._boxPopup and not self._padCursorActive and not self.isNX then + local key = ({ a = "return", b = "escape", dpup = "up", dpdown = "down" })[action] + if key then require("src.import.BoxUI").keypressed(self, key); return end + end if button == "back" or button == "select" or action == "select" then local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" @@ -4062,7 +4162,10 @@ function RomImporter:gamepadpressed(_, button) Kit.VirtualKeyboard.close(false) return end - if self._indexPrompt then + if self._boxPrompt then + require("src.import.BoxPrompt").openKeyboard(self) + return + elseif self._indexPrompt then Kit.VirtualKeyboard.open({ text = self._indexPrompt.text or "", title = "Add a mod index", @@ -4139,6 +4242,10 @@ function RomImporter:gamepadpressed(_, button) end -- Shoulder buttons: cycle tabs + if (button == "leftshoulder" or button == "rightshoulder") + and require("src.import.LauncherView").modalKey(self) ~= nil then + return + end if button == "leftshoulder" then self:_cycleTab(-1) return @@ -4184,7 +4291,8 @@ function RomImporter:gamepadpressed(_, button) local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel") if okOnline and OnlinePanel.back(self) then return end end - if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return + if self._boxPrompt then require("src.import.BoxPrompt").close(self, false); return + elseif self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return elseif self._rename then self._rename = nil; self:_disarmTextInput(); return elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return @@ -4216,7 +4324,8 @@ function RomImporter:gamepadpressed(_, button) local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel") if okOnline and OnlinePanel.back(self) then return end end - if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return + if self._boxPrompt then require("src.import.BoxPrompt").close(self, false); return + elseif self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return elseif self._rename then self._rename = nil; self:_disarmTextInput(); return elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return @@ -4513,34 +4622,60 @@ end -- multi-monitor coords). Same contract as PadCursor.yieldToPointer for -- the overlay hosts. Touch move/press/release must still reach -- FlexLove.touch* or scroll containers never drag on phones. -function RomImporter:mousepressed(x, y, button) +function RomImporter:mousepressed(x, y, button, istouch) self._padCursorActive = false local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit then Kit.pointerUsed() end - if button ~= 1 or not self._flex then return end - require("src.import.LauncherView").mousepressed(self, x, y) + if istouch or button ~= 1 or not self._flex then return end + local LauncherView = require("src.import.LauncherView") + if LauncherView.consumedTouchLive(self) then + LauncherView.consumePress(self) + return + end + if self.tab == "box" and require("src.import.ShowcaseEditor").pointerPressed(self,"mouse",x,y) then + LauncherView.consumePress(self) + return + end + LauncherView.mousepressed(self, x, y) end function RomImporter:touchpressed(id, x, y, dx, dy, pressure) local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit then Kit.pointerUsed() end if not self._flex then return end + require("src.import.LauncherView").forgetConsumedTouch(self, id) + if self.tab == "box" and require("src.import.ShowcaseEditor").pointerPressed(self,id,x,y) then + require("src.import.LauncherView").consumeTouch(self, id) + return + end require("src.import.LauncherView").touchpressed( self, id, x, y, dx, dy, pressure) end function RomImporter:touchmoved(id, x, y, dx, dy, pressure) if not self._flex then return end + if self.tab == "box" and require("src.import.ShowcaseEditor").pointerMoved(self,id,x,y) then return end require("src.import.LauncherView").touchmoved( self, id, x, y, dx, dy, pressure) end function RomImporter:touchreleased(id, x, y, dx, dy, pressure) + local consumed = require("src.import.LauncherView").releaseConsumedTouch(self, id) if not self._flex then return end + if self.tab == "box" and require("src.import.ShowcaseEditor").pointerReleased(self,id) then return end + if consumed then return end require("src.import.LauncherView").touchreleased( self, id, x, y, dx, dy, pressure) end +function RomImporter:mousemoved(x,y,_,_,istouch) + if istouch then return end + if self.tab=="box" then return require("src.import.ShowcaseEditor").pointerMoved(self,"mouse",x,y) end +end +function RomImporter:mousereleased(_,_,button,istouch) + if button==1 and not istouch and self.tab=="box" then return require("src.import.ShowcaseEditor").pointerReleased(self,"mouse") end +end + -- Switch the active tab (chips, shoulder buttons). The find search caret and -- the soft keyboard drop with the panel they belonged to; each tab's scroll -- offset persists inside the view's per-tab scroll container. @@ -4657,14 +4792,36 @@ end function RomImporter:_switchTab(id) if id == "bug" then return self:_openBugPanel() end if GameVersion.VERSIONS[id] and not SecretGames.shown(self, id) then return end + if self.tab == id and id == "box" and self._boxState then return end + local tabTransition if self.tab and self.tab ~= id then local at = tabOrder() - Transition.start("tabs", "tab", { - dir = ((at[id] or 0) >= (at[self.tab] or 0)) and 1 or -1, + local from = self.tab == "find" and "mods" or self.tab + local to = id == "find" and "mods" or id + local dir = from == "mods" and to == "mods" and (id == "find" and 1 or -1) + or ((at[to] or 0) >= (at[from] or 0) and 1 or -1) + tabTransition = { + dir = dir, from = self.tab, to = id, - }) + } + end + if self.tab == "box" then + require("src.import.BoxUI").close(self) + Transition.clear("box") + require("src.import.BoxPrompt").close(self, false) + require("src.box.Cry").stop() + if self._boxState then + require("src.box.Animation").release(self._boxState.animation) + self._boxState.animation, self._boxState.animationEntry = nil, nil + end + require("src.box.Showcase").stopMusic() + require("src.ui.kit.Kit").blur() + if self._boxState then self._boxState.moving = nil end + require("src.import.LauncherView").forgetConsumedTouch(self) end self.tab = id + if id == "mods" or id == "find" then self._modsInnerTab = id end + if id == "box" then require("src.import.BoxPanel").refresh(self) end if id ~= "find" then self._findVisibleEntries = nil end self._findSearchFocus = false self._skinUrlFocus = false @@ -4678,6 +4835,7 @@ function RomImporter:_switchTab(id) self:_setModScope(id) self:_queueReimport(id) end + if tabTransition then Transition.start("tabs", "tab", tabTransition) end end -- ------- skins tab (touch skins + the desktop Skin Studio) @@ -5025,6 +5183,8 @@ function RomImporter:_pumpSync(dt) self:_syncNoteDownload(row) end end + local notice = type(eng.takeNotice) == "function" and eng:takeNotice() or nil + if notice and self._boxState then self._boxState.notice = notice end if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then if not self._syncModal and not self._syncConflictShown then self._syncConflictShown = true @@ -5036,6 +5196,16 @@ function RomImporter:_pumpSync(dt) end function RomImporter:_syncNoteDownload(row) + if type(row) == "table" and row.box then + if self.tab == "box" then + require("src.import.BoxPrompt").close(self, false) + require("src.import.BoxPanel").refresh(self).notice = "Box sync complete." + elseif self._boxState then + require("src.import.BoxPrompt").close(self, false) + require("src.import.BoxPanel").refresh(self) + end + return + end local version = type(row) == "table" and row.version if type(version) ~= "string" or not GameVersion.VERSIONS[version] then return end local cart = type(row.cart) == "string" and row.cart ~= "" and row.cart or nil @@ -5277,6 +5447,10 @@ function RomImporter:_openSettings() -- The tab rides along: the editor persists the layout into that game's own -- option block, and Gold's is not the flat Gen 1 one (#1100). local hooks = {} + hooks.openExtras = function() + self:_closeSettings() + self:_switchTab("importers") + end local version = self.tab if self.onEditTouchControls then local version = self.tab @@ -5603,6 +5777,16 @@ function RomImporter:keypressed(key) if Kit.VirtualKeyboard.keypressed(key) then return end end end + if self._boxPrompt then + local prompt = self._boxPrompt + if key == "backspace" then prompt.text = utf8Back(prompt.text) + elseif key == "return" or key == "kpenter" then require("src.import.BoxPrompt").close(self, true) + elseif key == "escape" then require("src.import.BoxPrompt").close(self, false) + end + return + end + if self._boxPopup and require("src.import.BoxUI").keypressed(self, key) then return end + if self.tab == "box" and require("src.import.BoxPanel").keypressed(self, key) then return end if self._secretPopup then if key == "return" or key == "kpenter" or key == "space" then self:_confirmSecret() @@ -6446,7 +6630,7 @@ function RomImporter:askIdSync(version) end if not slot then self._idSyncResult = { title = Strings("ID Sync"), - body = Strings("Select a save for this game first. Its trainer ID is the one every other save takes.") } + body = Strings("Pick your save first. Every save will use its trainer name and ID.") } return end local SaveData = require("src.core.SaveData") @@ -6457,16 +6641,19 @@ function RomImporter:askIdSync(version) return SaveData.readSlotSource(scope, slot.id) end) local save = okRead and source and SaveData.decode(source) or nil - local id = save and TrainerIdSync.playerIdOf(save, - TrainerIdSync.generationOf(save, version)) or nil - local who = slot.label or slot.name or slot.id + local profile = save and require("src.core.TrainerIdentity").profile(save, + TrainerIdSync.generationOf(save, version)) + if not profile then + self._idSyncResult = { title = Strings("ID Sync"), body = Strings("This save needs a trainer name and ID before it can sync.") } + return + end self._modConfirm = { title = Strings("ID Sync"), yesLabel = Strings("Sync"), lines = { - Strings("Sync trainer ID with all other saves?"), - id and Strings("Every save takes %s's ID No. %05d. Pokemon caught in any of your saves, or traded between them, are synced too.", who, id) - or Strings("Every save takes %s's ID. Pokemon caught in any of your saves, or traded between them, are synced too.", who), + Strings("Use %s · ID %05d across your saves?", profile.name, profile.id), + Strings("Your Pokemon get the same OT name and ID, including the secret ID in Gen 3. Party, PC, daycare and launcher Box are included."), + Strings("Shiny stays shiny. Personality only changes if needed; a Spinda's spots can change with it."), }, onYes = function() self:_startIdSync(scope, slot.id) end, } @@ -6480,7 +6667,7 @@ function RomImporter:_startIdSync(scope, slotId) return end self._idSync = job - self._busy = { title = Strings("Syncing trainer ID"), detail = "", progress = 0 } + self._busy = { title = Strings("Syncing trainer identity"), detail = "", progress = 0 } end function RomImporter:_pumpIdSync() @@ -6493,22 +6680,28 @@ function RomImporter:_pumpIdSync() local n = math.min(job.at, #job.entries) self._busy.detail = job.phase == "read" and Strings("Reading saves %d / %d", n, #job.entries) - or Strings("Updating saves %d / %d", n, #job.entries) + or Strings("Preparing saves %d / %d", n, #job.entries) end return end self._idSync = nil self:_clearBusy() self.slots = {} + self._boxState = nil if not ok or job.error then self._idSyncResult = { title = Strings("ID Sync failed"), body = tostring(ok and job.error or done) } return end - local body = Strings("Trainer ID %05d is now on every save. %d save(s) updated.", job.newId, job.written) - if #job.failed > 0 then - body = body .. "\n\n" .. Strings("%d save(s) could not be written: %s", #job.failed, job.failed[1]) + local body = Strings("%s · ID %05d is now on your saves. %d save(s) updated.", job.target.name, job.newId, job.written) + if job.boxUpdated then body = body .. "\n" .. Strings("Launcher Box updated too.") end + if job.report.personalities > 0 then + body = body .. "\n" .. Strings("Shiny status kept. %d personality value(s) adjusted.", job.report.personalities) end - local eng = job.written > 0 and self:_syncEngine() or nil + if job.report.spinda > 0 then body = body .. "\n" .. Strings("%d Spinda spot pattern(s) changed.", job.report.spinda) end + if job.report.unknown > 0 then + body = body .. "\n" .. Strings("%d record(s) had no OT name, so their ownership couldn't be checked.", job.report.unknown) + end + local eng = (job.written > 0 or job.boxUpdated) and self:_syncEngine() or nil if eng and eng.state.enabled and eng:linked() then if eng:busy() then eng:noteSaveWritten() @@ -6579,13 +6772,15 @@ function RomImporter:playArena(version, cartId, spec) end function RomImporter:_blurPanelFields() + local Kit = require("src.ui.kit.Kit") + if Kit.focus == "box-search" then Kit.blur() end if self._pcPicker or self._pinModal then return end if self._onlineFocus then self:_commitOnlineField() return end if not (self._findSearchFocus or self._skinUrlFocus) then return end - if self._indexPrompt or self._rename or self._settingsText + if self._boxPrompt or self._indexPrompt or self._rename or self._settingsText or self._profileSavePrompt or self._profileRenamePrompt or (self._syncModal and self._syncFocus) then return @@ -6622,6 +6817,16 @@ end function RomImporter:textinput(text) if self._hostPick then return end + if self.tab == "box" then + local Kit = require("src.ui.kit.Kit") + if Kit.VirtualKeyboard.active and Kit.VirtualKeyboard.textinput(text) then return end + if self._boxPrompt then + local prompt = self._boxPrompt + prompt.text = utf8Cap(prompt.text .. text, prompt.maxLen) + return + end + if require("src.import.BoxPanel").textinput(self, text) then return end + end if self._syncModal and self._syncFocus then self:_syncTypeInto(self._syncFocus, text) return diff --git a/src/import/ShowcaseEditor.lua b/src/import/ShowcaseEditor.lua new file mode 100644 index 00000000..16450629 --- /dev/null +++ b/src/import/ShowcaseEditor.lua @@ -0,0 +1,130 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local UI = require("src.import.BoxUI") +local Store = require("src.box.Store") +local Showcase = require("src.box.Showcase") +local Editor = {} +local PAL = Theme.PAL +local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end +local function angle(x, y) return math.atan2(y, x) end +local function wrap(v) return (v + math.pi) % (2 * math.pi) - math.pi end +local function record(s) + s.stageUndo = s.stageUndo or {} + s.stageUndo[#s.stageUndo + 1] = Store.copy(s.stageDraft) + if #s.stageUndo > 20 then table.remove(s.stageUndo, 1) end +end +local function start(s, kind, index, mx, my, rect) + local p = s.stageDraft.pieces[index] + if not p then s.stageGesture = nil; return end + s.stageGesture = { kind = kind, x = mx, y = my, px = p.x, py = p.y, + scale = p.scale, rotation = p.rotation, index = index, + cx = rect.x + p.x * rect.w, cy = rect.y + p.y * rect.h, + draft = s.stageDraft, stage = s.stageIndex } +end +function Editor.begin(s, kind, mx, my, rect) + if not s.stageDraft.pieces[s.stagePiece] then return end + start(s, kind, s.stagePiece, mx, my, rect) +end +local function current(s, mx, my, rect) + local t = s.stageGesture + if not t or t.draft == s.stageDraft then return t end + if t.stage ~= s.stageIndex then s.stageGesture = nil; return nil end + start(s, t.kind, t.index, mx, my, rect) + return s.stageGesture +end +function Editor.move(s, mx, my, rect) + local t = current(s, mx, my, rect) + local p = t and s.stageDraft.pieces[t.index] + if not p then return end + local x, y, scale, rotation = p.x, p.y, p.scale, p.rotation + if t.kind == "move" then + x, y = clamp(t.px + (mx - t.x) / rect.w, 0, 1), clamp(t.py + (my - t.y) / rect.h, 0, 1) + elseif t.kind == "resize" then + scale = clamp(t.scale * math.exp((mx - t.x - my + t.y) / math.max(70, rect.w * .2)), .25, 4) + elseif t.kind == "rotate" then + rotation = wrap(t.rotation + angle(mx - t.cx, my - t.cy) - angle(t.x - t.cx, t.y - t.cy)) + end + if x == p.x and y == p.y and scale == p.scale and rotation == p.rotation then return end + if not t.recorded then record(s); t.recorded = true end + p.x, p.y, p.scale, p.rotation = x, y, scale, rotation +end +function Editor.finish(s) s.stageGesture = nil end +function Editor.pointerPressed(imp,id,x,y) + local s=imp._boxState + if imp.tab~="box" or not s or s.toolsPage~="Showcase" or not s.stageRect + or imp._boxPopup or imp._modalUpNow or Kit.blockClicks then return false end + local function inside(r) + local mx,my=Kit.mouseX,Kit.mouseY + Kit.mouseX,Kit.mouseY=x,y + local hit=Kit.hit(r.x,r.y,r.w,r.h) + Kit.mouseX,Kit.mouseY=mx,my + return hit + end + if imp._tabRegionRect and not inside(imp._tabRegionRect) then return false end + for _,handle in ipairs(s.stageHandles or {}) do + if inside(handle) then + if handle.kind=="resize" or handle.kind=="rotate" then + Editor.begin(s,handle.kind,x,y,s.stageRect);s._stagePointer=id + else handle.action() end + return true + end + end + if not inside(s.stageRect) then return false end + local r=s.stageRect + s.stagePiece=Showcase.hit(s.stageDraft,r.x,r.y,r.w,r.h,x,y) + if s.stagePiece then Editor.begin(s,"move",x,y,r);s._stagePointer=id end + return true +end +function Editor.pointerMoved(imp,id,x,y) + local s=imp._boxState + if not s or s._stagePointer~=id then return false end + Editor.move(s,x,y,s.stageRect) + return true +end +function Editor.pointerReleased(imp,id) + local s=imp._boxState + if not s or s._stagePointer~=id then return false end + Editor.finish(s);s._stagePointer=nil + return true +end +function Editor.draw(imp, s, x, y, w, h, m, edit) + local rect = { x = x, y = y, w = w, h = h } + Showcase.draw(s.service.state, s.stageDraft, x, y, w, h) + s.stageRect = rect + s.stageHandles = {} + local p = s.stageDraft.pieces[s.stagePiece or 0] + local toolbar + if p then + local size = math.min(w / 8, h / 2) * p.scale + local cx, cy = x + p.x * w, y + p.y * h + Theme.strokeRounded(cx - size / 2 - 4, cy - size / 2 - 4, size + 8, size + 8, PAL.blue, 1, 2, 6) + local bh, gap = math.max(Kit.tapMin(), 30 * m.s), 3 * m.s + local tw = 5 * bh + 4 * gap + local tx, ty = clamp(cx - tw / 2, x + 4, math.max(x + 4, x + w - tw - 4)), + clamp(cy + size / 2 + 10, y + 4, math.max(y + 4, y + h - bh - 4)) + toolbar = { x = tx, y = ty, w = tw, h = bh } + local actions = { + { "resize", "expand", function() Editor.begin(s, "resize", Kit.mouseX, Kit.mouseY, rect) end }, + { "flip-h", "arrow-left-right", function() edit(function() p.flip = not p.flip end) end }, + { "flip-v", "arrow-up-down", function() edit(function() p.flipY = not p.flipY end) end }, + { "rotate", "rotate-ccw", function() Editor.begin(s, "rotate", Kit.mouseX, Kit.mouseY, rect) end }, + { "fine", "sliders-horizontal", function() s.stageSection = "Placement"; s.stageFine = true end }, + } + for index, action in ipairs(actions) do + s.stageHandles[index]={x=tx+(index-1)*(bh+gap),y=ty,w=bh,h=bh,kind=action[1],action=action[3]} + UI.button(imp, tx + (index - 1) * (bh + gap), ty, bh, bh, "stage-handle-" .. action[1], "", action[3], + { icon = action[2], face = "invert" }) + end + end + if not Kit.blockClicks and not imp._boxPopup then + if s.stageGesture then current(s, Kit.mouseX, Kit.mouseY, rect) end + if s.stageGesture and not s._stagePointer then + if Kit.mouseDown then Editor.move(s, Kit.mouseX, Kit.mouseY, rect) else Editor.finish(s) end + elseif Kit.press(x, y, w, h) and not (toolbar and Kit.hit(toolbar.x, toolbar.y, toolbar.w, toolbar.h)) then + local selected = Showcase.hit(s.stageDraft, x, y, w, h, Kit.mouseX, Kit.mouseY) + s.stagePiece = selected + if selected then Editor.begin(s, "move", Kit.mouseX, Kit.mouseY, rect) end + end + end +end +return Editor diff --git a/src/import/online/Toast.lua b/src/import/online/Toast.lua index 532263eb..9e6dec27 100644 --- a/src/import/online/Toast.lua +++ b/src/import/online/Toast.lua @@ -18,10 +18,9 @@ end function Toast.occlude(imp) local rect = type(imp) == "table" and imp._toastRect or nil + Kit.occlude(nil) if rect and rect.shown and Toast.current(imp) then Kit.occlude(rect[1], rect[2], rect[3], rect[4]) - else - Kit.occlude(nil) end end diff --git a/src/import/online/Ui.lua b/src/import/online/Ui.lua index 34e06a7d..eed0e411 100644 --- a/src/import/online/Ui.lua +++ b/src/import/online/Ui.lua @@ -109,10 +109,10 @@ end function Ui.chooser(imp, x, y, w, h, key, text, onPrev, onNext) local bw = math.max(Kit.tapMin(), math.floor(28 * Kit.scale)) local gap = math.floor(4 * Kit.scale) - LV().btn(imp, x, y, bw, h, key .. "-prev", "<", - { kind = "ghost", font = "small", action = onPrev }) - LV().btn(imp, x + w - bw, y, bw, h, key .. "-next", ">", - { kind = "ghost", font = "small", action = onNext }) + LV().btn(imp, x, y, bw, h, key .. "-prev", "", + { kind = "ghost", icon = "chevron-left", action = onPrev }) + LV().btn(imp, x + w - bw, y, bw, h, key .. "-next", "", + { kind = "ghost", icon = "chevron-right", action = onNext }) local inner = math.max(0, w - 2 * bw - 2 * gap) Kit.textCenter("small", Kit.ellipsize("small", text, inner), x + bw + gap, y + (h - Kit.textHeight("small")) / 2, inner, PAL.heading) diff --git a/src/online/OnlineSprites.lua b/src/online/OnlineSprites.lua index da34af0a..7c6426b9 100644 --- a/src/online/OnlineSprites.lua +++ b/src/online/OnlineSprites.lua @@ -266,7 +266,7 @@ local function restore(saved) end end -function OnlineSprites.drawIcon(entry, x, y, size) +function OnlineSprites.drawIcon(entry, x, y, size, frame) if type(entry) ~= "table" or not entry.icon then return false end local img = entry.icon if type(img.getDimensions) ~= "function" then return false end @@ -277,8 +277,13 @@ function OnlineSprites.drawIcon(entry, x, y, size) local quad = entry.iconQuadFor == img and entry.iconQuad or nil if entry.iconW and entry.iconH then local fw, fh = math.min(entry.iconW, iw), math.min(entry.iconH, ih) - quad = quad or love.graphics.newQuad(0, 0, fw, fh, iw, ih) - entry.iconQuad, entry.iconQuadFor = quad, img + local frames = math.max(1, math.floor(ih / fh)) + local index = math.floor(tonumber(frame) or 0) % frames + if entry.iconQuadFor ~= img then entry.iconQuads = {} end + entry.iconQuads = entry.iconQuads or {} + quad = entry.iconQuads[index] or love.graphics.newQuad(0, index * fh, fw, fh, iw, ih) + entry.iconQuads[index] = quad + entry.iconQuadFor = img local s = (size or fw) / math.max(fw, fh) love.graphics.draw(img, quad, x, y, 0, s, s) elseif ih > 16 and entry.mirror then diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 62c9b84f..715058af 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -375,6 +375,15 @@ local function monFp(mon) return table.concat(parts, ",") end +function Gen2Save.rewriteCarrierId(mon, id) + local raw = mon.cartRaw + if type(raw) ~= "string" or #raw < 16 then mon.otId = id; return end + local unchanged = mon.cartRawFp == nil or mon.cartRawFp == monFp(mon) + mon.cartRaw = raw:sub(1, 12) .. ("%04X"):format(id) .. raw:sub(17) + mon.otId = id + if unchanged then mon.cartRawFp = monFp(mon) end +end + -- The first 32 bytes, which a box mon and a party mon share. local function decodeSharedMon(t, o, x, crystal, size) local moves, slots, gap, emptyPp = {}, {}, false, nil diff --git a/src/save_convert/Gen3Save.lua b/src/save_convert/Gen3Save.lua index db866f62..1dfa8bd8 100644 --- a/src/save_convert/Gen3Save.lua +++ b/src/save_convert/Gen3Save.lua @@ -31,6 +31,12 @@ local function build(L) Gen3Save.MSG.emerald = "That is an Emerald save, not Ruby/Sapphire." end Gen3Save.L = L + local BOX_FLAGS_OFFSET = L.FAMILY == "rs" and 0x312F or L.FAMILY == "emerald" and 0x31C7 or 0x30BB + Gen3Save.BOX_FLAGS_OFFSET = BOX_FLAGS_OFFSET + + function Gen3Save.boxFlagsOf(save) + return require("src.core.game3.save_mon").boxFlags(save) + end local U32 = 4294967296 @@ -75,8 +81,31 @@ local function build(L) return REVERSE end - function Gen3Save.decodeString(s, off, len) + local JAPANESE, JAPANESE_REVERSE + local function japanese() + if not JAPANESE then + JAPANESE, JAPANESE_REVERSE = {}, {} + for ch, code in pairs(require("src.ui.game3.frlg_font").JAPANESE_GLYPHS) do + if JAPANESE[code] == nil or ch < JAPANESE[code] then JAPANESE[code] = ch end + JAPANESE_REVERSE[ch] = code + end + end + return JAPANESE, JAPANESE_REVERSE + end + + local function latin(c, kana) return c == 0x00 or c >= 0xA1 and c <= 0xAA or c >= 0xB1 or not kana and c >= 0xAB end + + function Gen3Save.decodeString(s, off, len, language, fullwidth) local map, out = charmap(), {} + local jp = language == L.LANGUAGE_JAPANESE and japanese() or nil + local kana = false + if jp then + for k = 0, len - 1 do + local c = u8(s, off + k) + if c == 0xFF then break end + if c >= 0x01 and c <= 0xA0 then kana = true; break end + end + end local i = 0 while i < len do local c = u8(s, off + i) @@ -86,18 +115,19 @@ local function build(L) out[#out + 1] = sym i = i + 2 else - out[#out + 1] = map[c] or string.format("{%02X}", c) + out[#out + 1] = jp and (fullwidth or not latin(c, kana) or not map[c]) and jp[c] or map[c] or string.format("{%02X}", c) i = i + 1 end end return table.concat(out) end - function Gen3Save.encodeString(str, len, pad) + function Gen3Save.encodeString(str, len, pad, language) local r, out, i = reverse(), {}, 1 + local jr = language == L.LANGUAGE_JAPANESE and select(2, japanese()) or nil str = str or "" local function put(tok) - local code = r[tok] + local code = jr and jr[tok] or r[tok] if code then out[#out + 1] = code elseif EXTRA_REVERSE[tok] and #out + 2 <= len then @@ -130,6 +160,56 @@ local function build(L) return table.concat(t) end + local function rawName(x, key) + return type(x) == "table" and type(x[key]) == "table" and string.char(unpack(x[key])) or nil + end + + local function eggOf(mon) + if mon.isEgg == true or mon.isEggFlag == true then return true end + local flags = type(mon.cartExtra) == "table" and tonumber(mon.cartExtra.flagsRaw) or nil + return flags ~= nil and band(flags, lshift(1, L.BOX_MON_FLAGS.isEgg)) ~= 0 + end + + function Gen3Save.repairJapaneseNames(mon) + if type(mon) ~= "table" or tonumber(mon.language) ~= L.LANGUAGE_JAPANESE then return false end + local B, changed = L.BOX_MON, false + if eggOf(mon) then + local current = mon.otName + if type(current) ~= "string" or not current:find("[\128-\255]") then return false end + local len = B.otNameLength + local bytes = rawName(mon.cartExtra, "otNameRaw") + if not bytes or Gen3Save.decodeString(bytes, 0, len, L.LANGUAGE_JAPANESE, true) ~= current then + bytes = Gen3Save.encodeString(current, len, 0xFF, L.LANGUAGE_JAPANESE) + if Gen3Save.decodeString(bytes, 0, len, L.LANGUAGE_JAPANESE, true) ~= current then return false end + end + local fixed = Gen3Save.decodeString(bytes, 0, len) + if fixed == current or fixed:find("{%x%x}") then return false end + for _, k in ipairs({ "otName", "ot" }) do + if mon[k] == current then mon[k] = fixed end + end + return true + end + local function fix(keys, rawKey, len) + local current = mon[keys[1]] + if type(current) ~= "string" or current == "" then return end + local bytes = rawName(mon.cartExtra, rawKey) + if not bytes or Gen3Save.decodeString(bytes, 0, len) ~= current then + if not current:find("{%x%x}") then return end + bytes = Gen3Save.encodeString(current, len, 0xFF) + if Gen3Save.decodeString(bytes, 0, len) ~= current then return end + end + local fixed = Gen3Save.decodeString(bytes, 0, len, L.LANGUAGE_JAPANESE) + if fixed == current then return end + for _, k in ipairs(keys) do + if mon[k] == current then mon[k] = fixed end + end + changed = true + end + if not mon.isEgg then fix({ "nickname", "name" }, "nicknameRaw", B.nicknameLength) end + fix({ "otName", "ot" }, "otNameRaw", B.otNameLength) + return changed + end + -- src/save.c:614 function Gen3Save.checksum(s, off, size) local sum = 0 @@ -282,6 +362,7 @@ local function build(L) local B = L.BOX_MON local personality, otId = u32(raw, B.personality), u32(raw, B.otId) local flags = u8(raw, B.flags) + local language = u8(raw, B.language) local sec = decryptSecure(raw, personality, otId) local pos = L.SUBSTRUCT_ORDER[personality % 24] local g, a, e, m = pos[1] * L.SUBSTRUCT_SIZE, pos[2] * L.SUBSTRUCT_SIZE, pos[3] * L.SUBSTRUCT_SIZE, pos[4] * L.SUBSTRUCT_SIZE @@ -290,13 +371,12 @@ local function build(L) otIdRaw = otId, otId = otId % 65536, otSecretId = math.floor(otId / 65536), - nickname = Gen3Save.decodeString(raw, B.nickname, B.nicknameLength), - language = u8(raw, B.language), + nickname = Gen3Save.decodeString(raw, B.nickname, B.nicknameLength, language), + language = language, flagsRaw = flags, isBadEgg = band(flags, lshift(1, L.BOX_MON_FLAGS.isBadEgg)) ~= 0, hasSpecies = band(flags, lshift(1, L.BOX_MON_FLAGS.hasSpecies)) ~= 0, isEggFlag = band(flags, lshift(1, L.BOX_MON_FLAGS.isEgg)) ~= 0, - otName = Gen3Save.decodeString(raw, B.otName, B.otNameLength), markings = u8(raw, B.markings), checksum = u16(raw, B.checksum), unknown = u16(raw, B.unknown), @@ -325,6 +405,9 @@ local function build(L) mon.ivs = {} for i, k in ipairs(L.IV_KEYS) do mon.ivs[k] = bits(ivw, (i - 1) * 5, 5) end mon.isEgg = bits(ivw, L.IV_EGG_BIT, 1) == 1 + -- pokeemerald/src/daycare.c:866 + mon.otName = Gen3Save.decodeString(raw, B.otName, B.otNameLength, + not (mon.isEgg or mon.isEggFlag) and language or nil) mon.abilityNum = bits(ivw, L.IV_ABILITY_BIT, 1) local rib = u32(sec, m + S3.ribbons) mon.ribbons = rib @@ -437,10 +520,10 @@ local function build(L) if mon.nicknameBytes then out:fill(B.nickname, B.nicknameLength, 0xFF) out:bytes(B.nickname, mon.nicknameBytes:sub(1, B.nicknameLength)) - elseif mon.nicknameRaw and Gen3Save.decodeString(mon.nicknameRaw, 0, B.nicknameLength) == mon.nickname then + elseif mon.nicknameRaw and Gen3Save.decodeString(mon.nicknameRaw, 0, B.nicknameLength, mon.language) == mon.nickname then out:bytes(B.nickname, mon.nicknameRaw) else - out:bytes(B.nickname, Gen3Save.encodeString(mon.nickname, B.nicknameLength, 0xFF)) + out:bytes(B.nickname, Gen3Save.encodeString(mon.nickname, B.nicknameLength, 0xFF, mon.language)) end out:w8(B.language, mon.language or 2) local F = L.BOX_MON_FLAGS @@ -448,10 +531,16 @@ local function build(L) + (mon.isBadEgg and 2 ^ F.isBadEgg or 0) + 2 ^ F.hasSpecies + ((mon.isEggFlag or mon.isEgg) and 2 ^ F.isEgg or 0) out:w8(B.flags, flags) - if mon.otNameRaw and Gen3Save.decodeString(mon.otNameRaw, 0, B.otNameLength) == mon.otName then + local otLanguage = not (mon.isEggFlag or mon.isEgg) and mon.language or nil + if mon.otNameRaw and Gen3Save.decodeString(mon.otNameRaw, 0, B.otNameLength, otLanguage) == mon.otName then out:bytes(B.otName, mon.otNameRaw) else - out:bytes(B.otName, Gen3Save.encodeString(mon.otName, B.otNameLength, 0xFF)) + local bytes = Gen3Save.encodeString(mon.otName, B.otNameLength, 0xFF, otLanguage) + if not otLanguage and Gen3Save.decodeString(bytes, 0, B.otNameLength) ~= mon.otName then + local jp = Gen3Save.encodeString(mon.otName, B.otNameLength, 0xFF, L.LANGUAGE_JAPANESE) + if Gen3Save.decodeString(jp, 0, B.otNameLength, L.LANGUAGE_JAPANESE, true) == mon.otName then bytes = jp end + end + out:bytes(B.otName, bytes) end out:w8(B.markings, mon.markings or 0) out:w16(B.checksum, secureChecksum(plain)) @@ -694,6 +783,7 @@ local function build(L) for _, f in ipairs(L.OPTIONS_BITS) do out.options[f[1]] = bits(out.optionsWord, f[2], f[3]) end out.options.buttonMode = out.buttonMode Gen3Save.readFields(sb1, L.SB1, out, key) + out.boxFlags = u8(sb1, BOX_FLAGS_OFFSET) out.party = {} for i = 0, math.min(out.partyCount, L.PARTY_SIZE) - 1 do local o = L.PARTY_OFFSET + i * L.PARTY_MON_SIZE @@ -848,6 +938,7 @@ local function build(L) end fields.partyCount = #(t.party or {}) Gen3Save.writeFields(sb1, L.SB1, fields, key) + if t.boxFlags ~= nil then sb1:w8(BOX_FLAGS_OFFSET, t.boxFlags) end sb1:fill(L.PARTY_OFFSET, L.PARTY_SIZE * L.PARTY_MON_SIZE, 0) for i = 0, L.PARTY_SIZE - 1 do -- src/pokemon.c:1737 @@ -1355,6 +1446,7 @@ local function build(L) fameChecker = #fame > 0 and fame or nil, trainerTower = tower, cartImport = { + boxFlags = c.boxFlags, dexSeen = nationalList(seenList), dexOwned = nationalList(ownedList), lastHealLocation = portWarp(c.lastHealLocation), @@ -2103,6 +2195,8 @@ local function build(L) c.trainerTowerBest = tplTower end c.encryptionKey = key + local boxFlags = Gen3Save.boxFlagsOf(save) + if boxFlags ~= nil then c.boxFlags = boxFlags end return c, blocks end @@ -2273,6 +2367,20 @@ function M.sniff(bytes) return "emerald" end +function M.repairJapaneseNames(root) + local codec, seen, changed = M.forVersion("emerald"), {}, false + local function walk(t) + if seen[t] then return end + seen[t] = true + if type(t.personality) == "number" and codec.repairJapaneseNames(t) then changed = true end + for _, v in pairs(t) do + if type(v) == "table" then walk(v) end + end + end + if type(root) == "table" then walk(root) end + return changed +end + M.FAMILY_NAMES = { frlg = "FireRed/LeafGreen", emerald = "Emerald", rs = "Ruby/Sapphire" } function M.sniffMessage(bytes) diff --git a/src/save_convert/gen3_port/rse.lua b/src/save_convert/gen3_port/rse.lua index 6896076f..697ead16 100644 --- a/src/save_convert/gen3_port/rse.lua +++ b/src/save_convert/gen3_port/rse.lua @@ -653,6 +653,8 @@ function Rse.writeSections(codec, encoded, template, save, sections) end end end + local boxFlags = codec.boxFlagsOf and codec.boxFlagsOf(save) + if boxFlags ~= nil and codec.BOX_FLAGS_OFFSET then w1:w8(codec.BOX_FLAGS_OFFSET, boxFlags) end return { sb2 = w2:str(), sb1 = w1:str(), storage = encoded.storage, notes = notes, nativeRecordMixMail = codec.L.FAMILY == "emerald" and hasNativeMixBytes(save.mail) } end diff --git a/src/save_convert/regions/gen3.lua b/src/save_convert/regions/gen3.lua index d6c2146a..63323a79 100644 --- a/src/save_convert/regions/gen3.lua +++ b/src/save_convert/regions/gen3.lua @@ -66,7 +66,8 @@ local EXTRA = { { "sb1", 0x2F58, 0x28, "T2", "dewfordTrends", "G3-05", { kind = "capability", name = "dewfordTrend", set = "FRLG" } }, { "sb1", 0x309C, 0xB, "T2", "giftRibbons", "G3-05", { kind = "capability", name = "ribbons", set = "FRLG" } }, { "sb1", 0x30A7, 0x14, "T2", "externalEventData", "G3-05", { kind = "absent", patterns = { "externalEvent" } } }, - { "sb1", 0x30BB, 0x15, "T2", "externalEventFlags", "G3-05", { kind = "absent", patterns = { "externalEvent" } } }, + { "sb1", 0x30BB, 0x1, "T1", "boxFlags", nil }, + { "sb1", 0x30BC, 0x14, "T2", "externalEventFlagsRest", "G3-05", { kind = "absent", patterns = { "externalEvent" } } }, { "sb1", 0x30E4, 0x8, "T2", "roamerFiller", "G3-05", { kind = "padding" } }, { "sb1", 0x30EC, 0x34, "T2", "enigmaBerry", "G3-05", { kind = "absent", patterns = { "enigmaBerry%s*=", "session%.enigma", "save%.enigma" } } }, { "sb1", 0x3120, 0x36C, "T1", "mysteryGift", nil }, @@ -125,7 +126,8 @@ local EXTRA = { { "sb1", 0x3150, 0x58, "T1", "linkBattleRecords", nil }, { "sb1", 0x31A8, 0xB, "T1", "giftRibbons", nil }, { "sb1", 0x31B3, 0x14, "T2", "externalEventData", "G3-05", { kind = "absent", patterns = { "externalEvent" } } }, - { "sb1", 0x31C7, 0x15, "T2", "externalEventFlags", "G3-05", { kind = "absent", patterns = { "externalEvent" } } }, + { "sb1", 0x31C7, 0x1, "T1", "boxFlags", nil }, + { "sb1", 0x31C8, 0x14, "T2", "externalEventFlagsRest", "G3-05", { kind = "absent", patterns = { "externalEvent" } } }, { "sb1", 0x31F0, 0x8, "T2", "roamerFiller", "G3-05", { kind = "padding" } }, { "sb1", 0x31F8, 0x34, "T2", "enigmaBerry", "G3-05", { kind = "absent", patterns = { "enigmaBerry%s*=", "session%.enigma", "save%.enigma" } } }, { "sb1", 0x322C, 0x36C, "T1", "mysteryGift", nil }, diff --git a/src/sync/SyncBox.lua b/src/sync/SyncBox.lua new file mode 100644 index 00000000..b0263344 --- /dev/null +++ b/src/sync/SyncBox.lua @@ -0,0 +1,469 @@ +local SaveData = require("src.core.SaveData") +local Serializer = require("src.core.SaveSerializer") +local Store = require("src.box.Store") +local Transaction = require("src.box.Transaction") +local Base64 = require("src.core.Base64") +local Version = require("src.core.GameVersion") +local Client = require("src.sync.SyncClient") +local Work = require("src.sync.SyncWork") +local SyncBox = {} +SyncBox.__index = SyncBox +SyncBox.KEY = "box/collection" +SyncBox.MAX_MEMBERS = 240 + +local function split(key) + if type(key) ~= "string" then return nil end + local version, id = key:match("^([^/]+)/([%w%._:%-]+)$") + if not version or not Version.VERSIONS[version] or #id > 64 then return nil end + return version, id +end + +local function pathOf(entry) + if type(entry.slot) ~= "string" or not entry.slot:match("^slot%d+$") then return nil end + local scope = entry.cart and "cart_" .. entry.cart or entry.version + if type(scope) ~= "string" or not scope:match("^[%w%._%-]+$") then return nil end + return "saves/" .. scope .. "/" .. entry.slot .. ".lua" +end + +local CHANGED = "Box or a linked save changed during sync. Sync again." + +local function loadStore(fs) + if not Work.async() then return Store.load(fs) end + local body = fs.read(Store.PATH) + if not body then return Store.load(fs) end + local state, err = Work.call("decode", body) + if not state then return nil, "Box storage could not be decoded: " .. tostring(err) end + local valid, why = Store.validate(state) + if not valid then return nil, why end + return valid, nil, body +end + +local function same(a, b) + for k, v in pairs(a) do if b[k] ~= v then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end + +local memo + +function SyncBox.fingerprint(snapshot) + local bodies, assets = {}, {} + for key, row in pairs(snapshot.saves) do + if row.deleted then bodies[key] = false else bodies[key] = row.blob end + end + for name, encoded in pairs(snapshot.assets or {}) do assets[name] = encoded end + if memo and memo.blob == snapshot.blob and same(memo.bodies, bodies) and same(memo.assets, assets) then + return memo.value + end + local value = Work.call("fingerprint", snapshot.blob, snapshot.assets, bodies) + memo = { blob = snapshot.blob, bodies = bodies, assets = assets, value = value } + return value +end + +function SyncBox.new(fs) + return setmetatable({ fs = SaveData.persistenceFs(fs), saveCache = {}, assetCache = {} }, SyncBox) +end + +function SyncBox:recover() + local ok, applied, notice = Transaction.recover(self.fs) + if notice then self.recoveryNotice = notice end + return ok, applied, notice +end + +function SyncBox:owns(key) + local body = self.fs.read(Store.PATH) + if body == nil then + if self.fs.getInfo(Store.PATH) then return nil, "Box storage could not be read." end + local state, why, recovered = Store.load(self.fs) + if not state then return nil, why end + if not recovered then return false end + body = recovered + end + if body ~= self.membersBody then + local valid, why = Store.validate(Work.call("decode", body)) + if not valid then return nil, why end + self.membersBody, self.members = body, valid.syncMembers or {} + end + return self.members[key] ~= nil +end + +function SyncBox:markDeleted(key) + local state, why, before = Store.load(self.fs) + if not state then return nil, why end + if not before or not (state.syncMembers and state.syncMembers[key]) then return true end + state.syncMembers[key].deleted = true + state.revision, state.savedAt = state.revision + 1, os.time() + return Transaction.commitSync(self.fs, { { path = Store.PATH, before = before, after = Serializer.encode(state) } }) +end + +function SyncBox:snapshot(entries, syncState, extraKeys) + local state, why, body = loadStore(self.fs) + if not state then return nil, why end + local byPath, byKey = {}, {} + for _, entry in ipairs(entries or {}) do + local key = entry.version .. "/" .. entry.playthroughId + local path = pathOf(entry) + byKey[key] = entry + if path then byPath[path] = entry end + end + local members, remoteOnly = Store.copy(state.syncMembers or {}), {} + local pending = syncState and syncState.pendingDeletes or {} + for key in pairs(extraKeys or {}) do + if not members[key] then + local version, id = split(key) + if not version then return nil, "A cloud Box save identity is invalid." end + local entry = byKey[key] + if entry or pending[key] then + members[key] = { version = version, playthroughId = id, path = entry and pathOf(entry) or "saves/" .. version .. "/slot0.lua", + slotId = entry and entry.slot or "slot0", cartId = entry and entry.cart } + else + remoteOnly[key] = true + end + end + end + local function legacy(path) + local entry = byPath[path] + if entry then + local key = entry.version .. "/" .. entry.playthroughId + members[key] = { version = entry.version, playthroughId = entry.playthroughId, + path = path, slotId = entry.slot, cartId = entry.cart } + end + end + local function origin(entry) + if entry.slotId then legacy(pathOf({ version = entry.version, cart = entry.cartId, slot = entry.slotId })) end + for _, original in ipairs(entry.archives or {}) do origin(original) end + end + if state.syncMembers == nil then + for _, box in ipairs(state.boxes) do + for _, entry in pairs(box.mons) do origin(entry) end + end + for _, departure in pairs(state.departures or {}) do legacy(departure.path); origin(departure.entry) end + end + local saves, keys, missing = {}, {}, {} + local todo, jobs = {}, {} + for key in pairs(members) do + local entry, cached = byKey[key], self.saveCache[key] + if entry and not (cached and cached.source == entry.blob and cached.version == entry.version + and cached.id == entry.playthroughId and cached.cart == entry.cart) then + todo[#todo + 1] = key + jobs[#jobs + 1] = { n = 4, entry.blob, entry.version, entry.playthroughId, entry.cart } + end + end + local normalized = Work.batch("normalizeSave", jobs) + for i, key in ipairs(todo) do + local entry, blob = byKey[key], normalized[i][1] + if not blob then return nil, "A save linked to Box could not be read." end + self.saveCache[key] = { source = entry.blob, version = entry.version, id = entry.playthroughId, cart = entry.cart, + blob = blob } + end + for key in pairs(remoteOnly) do keys[key] = true end + for key, member in pairs(members) do + local entry = byKey[key] + keys[key] = true + if entry then + local cached = self.saveCache[key] + if not (cached and cached.source == entry.blob and cached.version == entry.version + and cached.id == entry.playthroughId and cached.cart == entry.cart) then + local save = SaveData.decode(entry.blob) + if not save then return nil, "A save linked to Box could not be read." end + save.meta = type(save.meta) == "table" and save.meta or {} + save.meta.playthroughId = entry.playthroughId + save.version = save.version or entry.version + if entry.cart then save.meta.cartId = entry.cart end + cached = { source = entry.blob, version = entry.version, id = entry.playthroughId, cart = entry.cart, + blob = Serializer.encode(save) } + self.saveCache[key] = cached + end + member.path, member.slotId, member.cartId, member.deleted = pathOf(entry), entry.slot, entry.cart, nil + saves[key] = { version = entry.version, blob = cached.blob, meta = Store.copy(entry.meta), slot = entry.slot } + saves[key].meta.playthroughId = entry.playthroughId + elseif member.deleted or syncState.pendingDeletes and syncState.pendingDeletes[key] then + member.deleted = true + saves[key] = { version = member.version, deleted = true } + else + missing[key] = true + saves[key] = { version = member.version, deleted = true } + end + end + if next(members) then state.syncMembers = members end + local assets, published = {}, state + local wallpapers, pending, encodes = {}, {}, {} + for b, box in ipairs(state.boxes) do + local bytes = box.wallpaper and self.fs.read(box.wallpaper) + wallpapers[b] = bytes + local cached = bytes and self.assetCache[box.wallpaper] + if bytes and not (cached and cached.bytes == bytes) and not pending[box.wallpaper] + and Transaction.assetValid(box.wallpaper, bytes) then + pending[box.wallpaper] = bytes + encodes[#encodes + 1] = { n = 1, bytes, name = box.wallpaper } + end + end + local encoded = Work.batch("base64Encode", encodes) + for i, job in ipairs(encodes) do + self.assetCache[job.name] = { bytes = job[1], encoded = encoded[i][1] } + end + for b, box in ipairs(state.boxes) do + local bytes = wallpapers[b] + if box.wallpaper and bytes == nil and not self.fs.getInfo(box.wallpaper) then + if published == state then published = Store.copy(state) end + published.boxes[b].wallpaper = nil + elseif box.wallpaper then + local cached = self.assetCache[box.wallpaper] + if not (cached and cached.bytes == bytes) then + if not Transaction.assetValid(box.wallpaper, bytes) then return nil, "A Box wallpaper is missing or unreadable." end + cached = { bytes = bytes, encoded = Base64.encode(bytes) } + self.assetCache[box.wallpaper] = cached + end + assets[box.wallpaper] = cached.encoded + end + end + local snapshot = { format = 1, blob = Work.call("encode", published), assets = assets, saves = saves, + meta = { count = Store.count(state), savedAt = state.savedAt or 0 } } + return { payload = snapshot, fingerprint = SyncBox.fingerprint(snapshot), keys = keys, + missing = missing, remoteOnly = remoteOnly, exists = body ~= nil, state = state, body = body } +end + +function SyncBox:remember(snapshot, entries, syncState) + local current, why = self:snapshot(entries, syncState, snapshot.saves) + if not current then return nil, why end + local before = self.fs.read(Store.PATH) + if Work.async() and before ~= current.body then return nil, CHANGED end + local after = Work.call("encode", current.state) + if before == after then return true, false end + local ok, err = Transaction.commitSync(self.fs, { { path = Store.PATH, before = before, after = after } }) + return ok, err or true +end + +function SyncBox.validate(snapshot) + if type(snapshot) ~= "table" or snapshot.format ~= 1 or type(snapshot.blob) ~= "string" + or #snapshot.blob > Client.MAX_BOX_BLOB or type(snapshot.assets) ~= "table" + or type(snapshot.saves) ~= "table" then return nil, "The cloud Box snapshot is invalid." end + local state = Work.call("decode", snapshot.blob, { maxBytes = Client.MAX_BOX_BLOB, maxNodes = 2000000 }) + local valid, why = Store.validate(state) + if not valid then return nil, why end + local assets, references = {}, {} + for _, box in ipairs(state.boxes) do if box.wallpaper then references[box.wallpaper] = true end end + local names, decodes, badAsset = {}, {}, false + for name, encoded in pairs(snapshot.assets) do + if type(encoded) ~= "string" or #encoded > 2800000 or not references[name] then badAsset = true; break end + names[#names + 1] = name + decodes[#decodes + 1] = { n = 1, encoded } + end + local decoded = Work.batch("base64Decode", decodes) + for i, name in ipairs(names) do + local bytes = decoded[i][1] + if not Transaction.assetValid(name, bytes) then return nil, "A cloud Box wallpaper is unreadable." end + assets[name] = bytes + end + if badAsset then return nil, "A cloud Box wallpaper is invalid." end + for name in pairs(references) do if not assets[name] then return nil, "The cloud snapshot is missing a Box wallpaper." end end + local saves, count, rows, blobs, rowError = {}, 0, {}, {}, nil + for key, row in pairs(snapshot.saves) do + count = count + 1 + local version, id = split(key) + if not version or count > SyncBox.MAX_MEMBERS or type(row) ~= "table" or row.version ~= version then + rowError = "A cloud Box save link is invalid."; break + end + if row.deleted then + if row.blob ~= nil then rowError = "A deleted cloud save has conflicting data."; break end + rows[#rows + 1] = { key = key, version = version, id = id, deleted = true } + else + if type(row.blob) ~= "string" or #row.blob > Client.MAX_BLOB or type(row.meta) ~= "table" + or row.meta.playthroughId ~= id then rowError = "A cloud Box game save is invalid."; break end + rows[#rows + 1] = { key = key, version = version, id = id } + blobs[#blobs + 1] = { n = 1, row.blob, row = #rows } + end + end + local decodedSaves = Work.batch("decode", blobs) + for i, job in ipairs(blobs) do rows[job.row].save = decodedSaves[i][1] end + for _, entry in ipairs(rows) do + local key, version, id = entry.key, entry.version, entry.id + if entry.deleted then + saves[key] = { version = version, id = id, deleted = true } + else + local save = entry.save + local native = save and save.engine == "game3" and type(save.name) == "string" + and type(save.party) == "table" and type(save.bag) == "table" + if type(save) ~= "table" or (type(save.player) ~= "table" and not native) + or save.version and save.version ~= version or save.generation and save.generation ~= Version.generation(version) + or save.meta and type(save.meta) ~= "table" + or save.meta and save.meta.playthroughId and save.meta.playthroughId ~= id then + return nil, "A cloud Box game save does not match its playthrough." + end + save.version = version + save.meta = save.meta or {}; save.meta.playthroughId = id + local cart = save.meta.cartId + if cart ~= nil and (type(cart) ~= "string" or #cart > 64 or not cart:match("^[%w%._%-]+$")) then + return nil, "A cloud Box cart identity is invalid." + end + saves[key] = { version = version, id = id, cart = cart, save = save } + end + end + if rowError then return nil, rowError end + for key, member in pairs(state.syncMembers or {}) do + if not saves[key] then return nil, "The cloud snapshot is missing a linked save." end + if not saves[key].deleted and saves[key].cart ~= member.cartId then return nil, "A cloud Box link points to a different cart." end + end + for _, key in ipairs(snapshot.members or {}) do if not saves[key] then return nil, "The cloud snapshot is incomplete." end end + return { state = state, saves = saves, assets = assets } +end + +function SyncBox:archive(snapshot) + local valid, why = SyncBox.validate(snapshot) + if not valid then return nil, why end + local path = "box/conflicts/" .. os.time() .. "-" .. SaveData.newPlaythroughId() .. ".lua" + if not self.fs.createDirectory("box/conflicts") then return nil, "Could not make the Box sync backup directory." end + local body = Work.call("encode", snapshot) + local ok, err = self.fs.write(path, body) + if not ok or self.fs.read(path) ~= body then return nil, err or "The Box sync backup could not be verified." end + local index = Serializer.encode({ path = path }) + ok, err = self.fs.write("box/last-sync-backup.lua", index) + if not ok or self.fs.read("box/last-sync-backup.lua") ~= index then return nil, err or "The Box backup index could not be verified." end + return path +end + +function SyncBox:latestBackup() + local body = self.fs.read("box/last-sync-backup.lua") + local index = Serializer.decode(body) + if type(index) == "table" and type(index.path) == "string" + and index.path:match("^box/conflicts/%d+%-[%w%-]+%.lua$") and self.fs.getInfo(index.path) then return index.path end + if not self.fs.getDirectoryItems then return nil end + local latest + for _, name in ipairs(self.fs.getDirectoryItems("box/conflicts") or {}) do + if name:match("^%d+%-[%w%-]+%.lua$") and (not latest or name > latest) then latest = name end + end + return latest and "box/conflicts/" .. latest +end + +function SyncBox:readBackup(path) + if type(path) ~= "string" or not path:match("^box/conflicts/%d+%-[%w%-]+%.lua$") then + return nil, "Choose a Box sync recovery backup." + end + local body = self.fs.read(path) + if not body or #body > Client.MAX_BOX_BODY * 2 then return nil, "The Box sync backup could not be read." end + local snapshot = Work.call("decode", body, { maxBytes = Client.MAX_BOX_BODY * 2, maxNodes = 4000000 }) + local valid, why = SyncBox.validate(snapshot) + if not valid then return nil, why end + return snapshot, valid +end + +function SyncBox:apply(snapshot, entries, syncState, expected) + local stamp + if Work.async() then + stamp = { [Store.PATH] = self.fs.read(Store.PATH) or false } + local linked = type(snapshot) == "table" and type(snapshot.saves) == "table" and snapshot.saves or {} + for _, entry in ipairs(entries) do + local path = linked[entry.version .. "/" .. entry.playthroughId] ~= nil and pathOf(entry) + if path then stamp[path] = self.fs.read(path) or false end + end + end + local function before(path) + if stamp and stamp[path] ~= nil then return stamp[path] or nil end + return self.fs.read(path) + end + local valid, why = SyncBox.validate(snapshot) + if not valid then return nil, why end + local current, err = self:snapshot(entries, syncState, snapshot.saves) + if not current then return nil, err end + if expected and current.fingerprint ~= expected then return nil, "Box or a linked save changed during sync. Sync again." end + local optionsBefore = self.fs.read("options.lua") + if optionsBefore == nil and self.fs.getInfo("options.lua") then return nil, "Local save-slot settings could not be read." end + local options = {} + if optionsBefore ~= nil then options = Work.call("decode", optionsBefore) end + if type(options) ~= "table" then return nil, "Local save-slot settings could not be read." end + local byKey = {} + for _, entry in ipairs(entries) do byKey[entry.version .. "/" .. entry.playthroughId] = entry end + local jobs, downloads, mapping = {}, {}, {} + local state = Store.copy(valid.state) + state.syncMembers = {} + for key, row in pairs(valid.saves) do + local entry = byKey[key] + local oldLink = valid.state.syncMembers and valid.state.syncMembers[key] + if entry and entry.cart ~= row.cart and not row.deleted then return nil, "A cloud save points to a different cart." end + local cart = row.cart or entry and entry.cart or oldLink and oldLink.cartId + if cart and not row.deleted then + local registered = options.carts and options.carts[cart] + local installed = self.fs.getInfo(require("src.carts.CartStore").fileFor(cart)) + if not registered and not installed then return false, "Install the \"" .. cart .. "\" cart before syncing this Box collection." end + end + local scope = cart and "cart_" .. cart or row.version + local root = cart and "cartSlots" or "saveSlots" + local regKey = cart or row.version + options[root] = options[root] or {} + local reg = Store.copy(options[root][regKey] or { list = {} }) + reg.list = reg.list or {} + local slot = entry and entry.slot + local created = false + if not slot and not row.deleted then + local max = 0 + for _, id in ipairs(reg.list) do max = math.max(max, tonumber(id:match("^slot(%d+)$")) or 0) end + repeat max = max + 1; slot = "slot" .. max until not self.fs.getInfo("saves/" .. scope .. "/" .. slot .. ".lua") + reg.list[#reg.list + 1] = slot; reg.active = reg.active or slot; created = true + end + local path = slot and pathOf({ version = row.version, cart = cart, slot = slot }) + if slot and not path then return nil, "A local save slot is invalid. Restart G1R before syncing Box." end + if oldLink and path then mapping[oldLink.path] = { path = path, slot = slot, cart = cart } end + options.playthroughIds = options.playthroughIds or {} + options.playthroughIds[scope] = options.playthroughIds[scope] or {} + if path then + jobs[#jobs + 1] = { path = path, before = before(path), save = not row.deleted and row.save or nil, deleted = row.deleted } + if row.deleted then + local list = {}; for _, id in ipairs(reg.list) do if id ~= slot then list[#list + 1] = id end end + reg.list = list + if reg.active == slot then reg.active = list[1] end + for _, field in ipairs({ "names", "hashes", "broken" }) do if reg[field] then reg[field][slot] = nil end end + options.playthroughIds[scope][slot] = nil + else + options.playthroughIds[scope][slot] = row.id + if cart then + reg.hashes = reg.hashes or {}; reg.hashes[slot] = row.save.meta.cartHash + reg.broken = reg.broken or {}; reg.broken[slot] = row.save.meta.sealBroken == true or nil + end + end + downloads[#downloads + 1] = { version = row.version, cart = cart, slot = slot, + created = created, removed = row.deleted, device = snapshot.meta and snapshot.meta.device } + end + options[root][regKey] = reg + state.syncMembers[key] = { version = row.version, playthroughId = row.id, cartId = cart, + slotId = slot or "slot0", path = path or "saves/" .. scope .. "/slot0.lua", deleted = row.deleted } + end + local function remap(entry) + if entry.slotId then + local old = pathOf({ version = entry.version, cart = entry.cartId, slot = entry.slotId }) + local target = mapping[old] + if target then entry.slotId, entry.cartId = target.slot, target.cart end + end + for _, original in ipairs(entry.archives or {}) do remap(original) end + end + for _, box in ipairs(state.boxes) do for _, entry in pairs(box.mons) do remap(entry) end end + for _, departure in pairs(state.departures or {}) do + if mapping[departure.path] then departure.path = mapping[departure.path].path end + remap(departure.entry) + end + local encodes = {} + for _, job in ipairs(jobs) do if job.save then encodes[#encodes + 1] = { n = 1, job.save, job = job } end end + local encoded = Work.batch("encode", encodes) + for i, item in ipairs(encodes) do item.job.after, item.job.save = encoded[i][1], nil end + for name, bytes in pairs(valid.assets) do jobs[#jobs + 1] = { kind = "asset", path = name, before = self.fs.read(name), after = bytes } end + jobs[#jobs + 1] = { path = "options.lua", before = optionsBefore, after = Work.call("encode", options) } + jobs[#jobs + 1] = { path = Store.PATH, before = before(Store.PATH), after = Work.call("encode", state) } + local backup, backupErr = self:archive(current.payload) + if not backup then return nil, backupErr end + for path, body in pairs(stamp or {}) do + if self.fs.read(path) ~= (body or nil) then return nil, CHANGED end + end + local ok, failure = Transaction.commitSync(self.fs, jobs) + if not ok then return nil, failure end + for _, row in ipairs(downloads) do SaveData.refreshSlotResolution(row.cart and "cart_" .. row.cart or row.version) end + local Themes = package.loaded["src.box.Themes"] + if type(Themes) == "table" and Themes.forget then + for _, job in ipairs(jobs) do + if job.kind == "asset" then Themes.forget(job.path) end + end + end + require("src.box.Catalog").reset() + downloads[#downloads + 1] = { box = true, backup = backup } + return downloads +end + +return SyncBox diff --git a/src/sync/SyncClient.lua b/src/sync/SyncClient.lua index 7ca31056..0114aa9a 100644 --- a/src/sync/SyncClient.lua +++ b/src/sync/SyncClient.lua @@ -1,4 +1,5 @@ local Json = require("src.link.Json") +local Work = require("src.sync.SyncWork") local SyncClient = {} SyncClient.__index = SyncClient @@ -7,7 +8,10 @@ SyncClient.DEFAULT_URL = os.getenv("POKEPORT_SYNC_URL") or "https://sync.147.182.215.255.sslip.io" SyncClient.MAX_BLOB = 2 * 1024 * 1024 SyncClient.MAX_RESPONSE = 4 * 1024 * 1024 +SyncClient.MAX_BOX_BODY = 64 * 1024 * 1024 +SyncClient.MAX_BOX_BLOB = 32 * 1024 * 1024 SyncClient.TIMEOUT = 25 +SyncClient.ASYNC_DECODE = 64 * 1024 function SyncClient.normalizeCode(code) if type(code) ~= "string" and type(code) ~= "number" then return nil end @@ -72,8 +76,10 @@ end function SyncClient:send(method, path, body, opts) opts = opts or {} local headers = { ["Accept"] = "application/json" } - local payload - if body ~= nil then + local payload = opts.encoded + if payload ~= nil then + headers["Content-Type"] = "application/json" + elseif body ~= nil then if type(body) == "table" and next(body) == nil then payload = "{}" else @@ -92,24 +98,15 @@ function SyncClient:send(method, path, body, opts) local handle = self.transport:begin({ url = url, method = method, body = payload, headers = headers, maxSeconds = opts.maxSeconds or SyncClient.TIMEOUT, + maxBytes = opts.maxResponse or SyncClient.MAX_RESPONSE, }) if handle == nil then return nil, "no network transport" end + self.responseLimits = self.responseLimits or {} + self.responseLimits[handle] = opts.maxResponse or SyncClient.MAX_RESPONSE return handle end -function SyncClient:poll(handle) - if handle == nil then return { status = "error", err = "no request" } end - local res = self.transport:poll(handle) - if res.status == "pending" then return { status = "pending" } end - if res.status ~= "ok" then - return { status = "error", err = res.err or "sync request failed" } - end - local raw = res.body or "" - local code = tonumber(res.code) or 0 - if #raw > SyncClient.MAX_RESPONSE then - return { status = "error", code = code, err = "the reply was too large" } - end - local data, decodeErr = Json.decode(raw, SyncClient.MAX_RESPONSE) +local function reply(code, raw, data, decodeErr) if type(data) ~= "table" then local why = Json.describeUnexpected(raw) or decodeErr or "unreadable reply" if code >= 400 then @@ -128,8 +125,94 @@ function SyncClient:poll(handle) return { status = "ok", code = code, data = data } end +function SyncClient:_pollDeferred(job) + if job.work then + local r = Work.poll(job.work) + if not r then return { status = "pending" } end + job.work = nil + local ok, body = pcall(Work.finish, r, "jsonEncode", { n = 1, job.snapshot }) + job.snapshot = nil + if not ok then + job.failed = "Box could not be encoded for sync." + elseif #body > SyncClient.MAX_BOX_BODY then + job.failed = "The Box collection is too large to sync." + else + local handle, err = self:send("PUT", "/sync/box", nil, { encoded = body, maxResponse = SyncClient.MAX_BOX_BODY }) + if handle == nil then job.failed = err or "could not start the request" else job.inner = handle end + end + end + if job.failed then return { status = "error", err = job.failed } end + return self:poll(job.inner) +end + +function SyncClient:poll(handle) + if handle == nil then return { status = "error", err = "no request" } end + if type(handle) == "table" and handle.deferred then return self:_pollDeferred(handle) end + local decoding = self.decoding and self.decoding[handle] + if decoding then + local r = Work.poll(decoding.work) + if not r then return { status = "pending" } end + self.decoding[handle] = nil + local ok, data, decodeErr = pcall(Work.finish, r, "jsonDecode", { n = 2, decoding.raw, decoding.max }) + if not ok then data, decodeErr = nil, data end + return reply(decoding.code, decoding.raw, data, decodeErr) + end + local res = self.transport:poll(handle) + if res.status == "pending" then return { status = "pending" } end + if res.status ~= "ok" then + return { status = "error", err = res.err or "sync request failed" } + end + local raw = res.body or "" + local code = tonumber(res.code) or 0 + local maxResponse = self.responseLimits and self.responseLimits[handle] or SyncClient.MAX_RESPONSE + if #raw > maxResponse then + return { status = "error", code = code, err = "the reply was too large" } + end + local work = #raw >= SyncClient.ASYNC_DECODE and Work.submit("jsonDecode", raw, maxResponse) or nil + if work then + self.decoding = self.decoding or {} + self.decoding[handle] = { work = work, raw = raw, max = maxResponse, code = code } + return { status = "pending" } + end + local data, decodeErr = Json.decode(raw, maxResponse) + return reply(code, raw, data, decodeErr) +end + function SyncClient:release(handle) + if type(handle) == "table" and handle.deferred then + Work.forget(handle.work) + handle.work, handle.snapshot = nil, nil + if handle.inner ~= nil then self:release(handle.inner) end + return + end + local decoding = self.decoding and self.decoding[handle] + if decoding then + Work.forget(decoding.work) + self.decoding[handle] = nil + end if handle ~= nil then self.transport:release(handle) end + if self.responseLimits then self.responseLimits[handle] = nil end +end + +function SyncClient:getBox(keys) + local list = {} + for key in pairs(keys or {}) do list[#list + 1] = key end + table.sort(list) + return self:send("GET", "/sync/box", nil, { params = #list > 0 and { include = table.concat(list, ",") } or nil, + maxResponse = SyncClient.MAX_BOX_BODY }) +end + +function SyncClient:putBox(snapshot) + if type(snapshot) ~= "table" or type(snapshot.blob) ~= "string" + or #snapshot.blob > SyncClient.MAX_BOX_BLOB then return nil, "Box storage is too large to sync." end + if self:isLinked() then + local work = Work.submit("jsonEncode", snapshot) + if work then return { deferred = true, work = work, snapshot = snapshot } end + end + local ok, body = pcall(Json.encode, snapshot) + if not ok then return nil, "Box could not be encoded for sync." end + if #body > SyncClient.MAX_BOX_BODY then return nil, "The Box collection is too large to sync." end + return self:send("PUT", "/sync/box", nil, { encoded = body, maxResponse = SyncClient.MAX_BOX_BODY }) end function SyncClient:create(deviceLabel) diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index 4e8195c4..20db1816 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -1,6 +1,8 @@ local SyncClient = require("src.sync.SyncClient") local SyncState = require("src.sync.SyncState") local SyncMods = require("src.sync.SyncMods") +local SyncBox = require("src.sync.SyncBox") +local Work = require("src.sync.SyncWork") local SyncEngine = {} SyncEngine.__index = SyncEngine @@ -325,9 +327,10 @@ local function snapshot(value, seen) return out end -local function contents(blob) +local function contents(blob, decoded) if type(blob) ~= "string" or blob == "" or #blob > SyncClient.MAX_BLOB then return nil end - local ok, save = pcall(saveApi().decode, blob) + local ok, save = true, decoded + if save == nil then ok, save = pcall(saveApi().decode, blob) end if not ok or type(save) ~= "table" or (save.meta ~= nil and type(save.meta) ~= "table") then return nil end local native = save.engine == "game3" and save.generation == 3 @@ -347,7 +350,12 @@ local function equalValues(a, b) end local function sameContents(a, b) - local left, right = contents(a), contents(b) + local decoded = {} + for i, blob in ipairs({ a, b }) do + decoded[i] = type(blob) == "string" and blob ~= "" and #blob <= SyncClient.MAX_BLOB + and Work.call("decode", blob) or nil + end + local left, right = contents(a, decoded[1]), contents(b, decoded[2]) return left ~= nil and right ~= nil and equalValues(left, right) end @@ -374,6 +382,8 @@ function SyncEngine.new(opts) eng.client = opts.client or SyncClient.new({ baseUrl = opts.baseUrl, transport = opts.transport }) eng.saves = opts.saves or SyncEngine.defaultSaves() + if opts.box ~= nil then eng.box = opts.box + elseif not opts.saves then eng.box = SyncBox.new(eng.fs) end eng.modDeps = opts.modDeps eng.now = opts.now or os.time eng.persist = opts.persist ~= false @@ -415,7 +425,42 @@ function SyncEngine:linked() end function SyncEngine:busy() - return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil + return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil or self.working ~= nil +end + +function SyncEngine:_step(rec) + self.working = nil + local ok, err = coroutine.resume(rec.co, self, rec.arg) + if coroutine.status(rec.co) == "suspended" then + self.working = rec + return false + end + Work.abandon(rec.co) + return true, ok, err +end + +function SyncEngine:_run(fn, arg, task) + return self:_step({ co = Work.spawn(fn), arg = arg, task = task }) +end + +function SyncEngine:_settle(rec, ok, err) + if not ok then self:_fail(err) return false end + if rec.task and not self.pending and not self.working and #self.queue == 0 and self.phase ~= "error" then + self:_finish() + end + return true +end + +function SyncEngine:_noteRecovery(notice) + notice = notice or (self.box and self.box.recoveryNotice) + if self.box then self.box.recoveryNotice = nil end + if type(notice) == "string" and notice ~= "" then self.notice = notice end +end + +function SyncEngine:takeNotice() + local notice = self.shownNotice + self.shownNotice = nil + return notice end function SyncEngine:_persist() @@ -424,6 +469,10 @@ function SyncEngine:_persist() end function SyncEngine:_fail(message) + if self.working then + Work.abandon(self.working.co) + self.working = nil + end self.phase = "error" self.error = tostring(message or "sync failed") self.status = "Sync failed: " .. self.error @@ -438,7 +487,7 @@ function SyncEngine:_finish() for _, row in ipairs(self.conflicts) do if row.overlap then overlap = true end end - self.status = overlap + self.status = self.conflicts[1].box and "Box and its linked saves changed on another device." or overlap and "These saves were played at the same time." or "This save also changed on another device." return @@ -450,6 +499,9 @@ function SyncEngine:_finish() if type(self.skipped) == "table" and #self.skipped > 0 then self.status = self.skipped[1] end + if self.notice then + self.status, self.shownNotice, self.notice = self.notice, self.notice, nil + end self:_persist() end @@ -468,6 +520,8 @@ end function SyncEngine:cancel() if self.pending then self.client:release(self.pending.handle) end + if self.working then Work.abandon(self.working.co) end + self.working = nil self.pending = nil self.queue = {} self.modApply = nil @@ -485,6 +539,18 @@ end function SyncEngine:noteSaveDeleted(key) if type(key) ~= "string" or key == "" then return false end + if self.box then + local ok, why = self.box:markDeleted(key) + if not ok then + local known = SyncState.rev(self.state, key) + SyncState.forget(self.state, key) + if known ~= nil or self.box:owns(key) ~= false then SyncState.markDeleted(self.state, key, known or 0, self.now()) end + self:_persist() + self:_fail(why) + if self.state.enabled and self:linked() then self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE end + return nil, why + end + end local rev = SyncState.rev(self.state, key) SyncState.forget(self.state, key) if rev == nil or not self:linked() then @@ -501,6 +567,12 @@ end function SyncEngine:update(dt) self.clock = self.clock + (tonumber(dt) or 0) + if self.working then + local rec = self.working + local done, ok, err = self:_step(rec) + if not done then return end + if not self:_settle(rec, ok, err) then return end + end if self.pending then local res = self.client:poll(self.pending.handle) if res.status == "pending" then return end @@ -508,8 +580,8 @@ function SyncEngine:update(dt) self.pending = nil self.client:release(job.handle) if res.status == "ok" then - local ok, err = pcall(job.onOk, self, res) - if not ok then self:_fail(err) end + local done, ok, err = self:_run(job.onOk, res) + if done and not ok then self:_fail(err) end else local handled = false if job.onErr then @@ -520,7 +592,7 @@ function SyncEngine:update(dt) if not handled then self:_fail(res.err) end end end - if self.pending then return end + if self.pending or self.working then return end if self.modApply then self:_stepModApply() return @@ -535,15 +607,12 @@ function SyncEngine:update(dt) self:syncNow() end local steps = 0 - while not self.pending and #self.queue > 0 + while not self.pending and not self.working and #self.queue > 0 and steps < SyncEngine.MAX_STEPS_PER_UPDATE do steps = steps + 1 local task = table.remove(self.queue, 1) - local ok, err = pcall(task, self) - if not ok then self:_fail(err) return end - if not self.pending and #self.queue == 0 and self.phase ~= "error" then - self:_finish() - end + local done, ok, err = self:_run(task, nil, true) + if done and not self:_settle({ task = true }, ok, err) then return end end end @@ -699,7 +768,10 @@ function SyncEngine:setEnabled(enabled) end function SyncEngine:protectPlaythrough(version, playthroughId) + local previous = self.protectedKey self.protectedKey = SyncState.key(version, playthroughId) + if previous and not self.protectedKey and self.box and self._boxKeys and self._boxKeys[previous] + and self.state.enabled and self:linked() then self.uploadAt = self.clock end end function SyncEngine:noteResumed() @@ -714,7 +786,7 @@ end function SyncEngine:syncNow() if not self:linked() then return false, "this device is not linked" end - if self.pending then return false, "sync is busy" end + if self.pending or self.working then return false, "sync is busy" end self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL self.queue = {} self.conflicts = {} @@ -748,11 +820,18 @@ function SyncEngine:_planFrom(remoteState) end local remote = type(remoteState.saves) == "table" and remoteState.saves or {} local tombs = type(remoteState.deleted) == "table" and remoteState.deleted or {} + if self.box then + local recovered, recoveryError, notice = self.box:recover() + self:_noteRecovery(notice) + if not recovered then self:_fail(recoveryError); return end + end local locals = self.saves.list() or {} + self._boxKeys, self._boxSaveRevs = {}, {} + if self.box and not self:_planBox(remoteState, locals) then return end local seen = {} for _, entry in ipairs(locals) do local key = SyncState.key(entry.version, entry.playthroughId) - if key then + if key and not self._boxKeys[key] then seen[key] = true SyncState.clearDeleted(self.state, key) local row = remote[key] @@ -787,7 +866,7 @@ function SyncEngine:_planFrom(remoteState) end for key, pending in pairs(self.state.pendingDeletes or {}) do local row = remote[key] - if not seen[key] then + if not seen[key] and not self._boxKeys[key] then if row and (tonumber(row.rev) or 0) > (tonumber(pending.rev) or 0) then SyncState.clearDeleted(self.state, key) else @@ -797,7 +876,7 @@ function SyncEngine:_planFrom(remoteState) end end for key, row in pairs(remote) do - if not seen[key] and key ~= self.protectedKey then + if not seen[key] and not self._boxKeys[key] and key ~= self.protectedKey then local version, id = SyncState.splitKey(key) if version and id then self:_queueDownload(key, version, id, "replace", tonumber(row.rev)) @@ -807,6 +886,215 @@ function SyncEngine:_planFrom(remoteState) if #self.queue == 0 then self:_finish() end end +function SyncEngine:_planBox(remoteState, locals) + local remote = type(remoteState.box) == "table" and remoteState.box or nil + local extra = {} + for _, key in ipairs(remote and remote.members or {}) do extra[key] = true end + local current, why = self.box:snapshot(locals, self.state, extra) + if not current then self:_fail(why); return false end + if not current.exists and not remote then return true end + if not (remoteState.capabilities and remoteState.capabilities.box == 1) then + self:_fail("The sync server needs its Box update before this collection can sync.") + return false + end + self._boxKeys = current.keys + local remoteSaves, tombs = remoteState.saves or {}, remoteState.deleted or {} + local saveChanged = false + for key in pairs(self._boxKeys) do + local row = remoteSaves[key] or tombs[key] + self._boxSaveRevs[key] = row and tonumber(row.rev) or 0 + local known = self.state.pendingDeletes and self.state.pendingDeletes[key] + known = known and known.rev or SyncState.rev(self.state, key) + if row and self._boxSaveRevs[key] ~= known then saveChanged = true end + end + local changed = current.fingerprint ~= self.state.boxFingerprint + local remoteRev = remote and tonumber(remote.rev) or 0 + local remoteChanged = remoteRev ~= (self.state.boxRev or 0) or saveChanged + if remote and not current.exists then + local dirty = false + for _, entry in ipairs(locals) do + local key = SyncState.key(entry.version, entry.playthroughId) + if self._boxKeys[key] then + dirty = dirty or SyncState.rev(self.state, key) == nil + or unixSeconds(entry.meta and entry.meta.savedAt) ~= unixSeconds(SyncState.stamp(self.state, key)) + end + end + if dirty then self:_queueBoxComparison(current, remoteRev) + else self:_queueBoxDownload(current.fingerprint) end + elseif not remote and saveChanged then self:_queueBoxComparison(current, 0) + elseif not remote then self:_queueBoxUpload(current) + elseif changed and remoteChanged then self:_queueBoxComparison(current, remoteRev) + elseif changed then self:_queueBoxUpload(current) + elseif remoteChanged then self:_queueBoxDownload(current.fingerprint) end + return true +end + +function SyncEngine:_addBoxConflict(current, remote) + self.conflicts[#self.conflicts + 1] = { box = true, key = SyncBox.KEY, version = "Box collection", + entry = current, remote = remote, remoteRev = remote.rev, + localMeta = { summary = { name = "Box", boxCount = current.payload.meta.count }, savedAt = unixSeconds(current.payload.meta.savedAt) }, + remoteMeta = { summary = { name = "Box", boxCount = remote.meta and remote.meta.count }, savedAt = unixSeconds(remote.meta and remote.meta.savedAt) }, + overlap = false } + self.state.pendingConflicts[#self.state.pendingConflicts + 1] = { key = SyncBox.KEY, version = "Box collection" } +end + +function SyncEngine:_queueBoxComparison(current, observedRev) + self:_enqueue(function(eng) + eng.phase, eng.status = "checking", "Checking Box and linked saves..." + local handle, err = eng.client:getBox(eng._boxKeys) + eng:_request(handle, err, function(e, res) + local remote = res.data + local valid, why = SyncBox.validate(remote) + if not valid then e:_fail(why); return end + if type(remote.rev) ~= "number" or remote.rev < observedRev then e:_fail("The cloud Box revision is stale."); return end + if not next(current.missing) and SyncBox.fingerprint(remote) == current.fingerprint then + local remembered, detail = e.box:remember(remote, e.saves.list(), e.state) + if not remembered then e:_fail(detail); return end + e:_acceptBox(remote.rev, remote.saves, current.fingerprint) + else e:_addBoxConflict(current, remote) end + if not e:busy() then e:_finish() end + end) + end) +end + +function SyncEngine:_acceptBox(rev, rows, fingerprint) + self.state.boxRev, self.state.boxFingerprint = rev, fingerprint + for key, row in pairs(rows or {}) do + SyncState.setRev(self.state, key, row.rev, unixSeconds(row.meta and row.meta.savedAt)) + SyncState.clearDeleted(self.state, key) + end + self:_persist() +end + +function SyncEngine:_queueBoxUpload(planned, expectedRev, remote) + self:_enqueue(function(eng) + if eng.protectedKey and eng._boxKeys[eng.protectedKey] then + eng.skipped = { "Box sync will finish after you return to the launcher." }; return + end + local current, why = eng.box:snapshot(eng.saves.list(), eng.state, eng._boxKeys) + if not current then eng:_fail(why); return end + if next(current.missing) then eng:_fail("A save linked to Box is missing. Receive the cloud collection before uploading."); return end + for key in pairs(current.keys) do + if not planned.keys[key] then eng:_fail("Box gained a linked save during sync. Sync again."); return end + end + local carried = {} + for key in pairs(current.remoteOnly or {}) do + local row = remote and type(remote.saves) == "table" and remote.saves[key] + if type(row) ~= "table" then eng:_fail("Box gained a linked save on another device. Sync again."); return end + carried[key] = row.deleted and { version = row.version, deleted = true } + or { version = row.version, blob = row.blob, meta = row.meta } + end + if remote then + local backup, backupError = eng.box:archive(remote) + if not backup then eng:_fail(backupError); return end + end + local payload = current.payload + for key, row in pairs(carried) do payload.saves[key] = row end + payload.baseRev = expectedRev or eng.state.boxRev or 0 + for key, row in pairs(payload.saves) do + row.baseRev = remote and remote.saves[key] and remote.saves[key].rev or eng._boxSaveRevs[key] or 0 + end + eng.phase, eng.status = "uploading", "Uploading Box and linked saves..." + local handle, err = eng.client:putBox(payload) + eng:_request(handle, err, function(e, res) + if not revision(res.data.rev) then e:_fail("The server did not confirm the Box revision."); return end + local rows = {} + for key, row in pairs(payload.saves) do + local rev = res.data.revs and revision(res.data.revs[key]) + if not rev then e:_fail("The server did not confirm every linked save."); return end + if not carried[key] then rows[key] = { rev = rev, meta = row.meta } end + end + local remembered, detail = e.box:remember(payload, e.saves.list(), e.state) + if not remembered then e:_fail(detail); return end + if detail == true then + e.changed = true; e.lastDownloads = e.lastDownloads or {} + e.lastDownloads[#e.lastDownloads + 1] = { box = true } + end + e:_acceptBox(res.data.rev, rows, current.fingerprint) + if next(carried) and e.state.enabled then e.uploadAt = e.clock end + if not e:busy() then e:_finish() end + end, function(e, res) + if res.code == 409 then + e:_queueBoxComparison(current, tonumber(res.data and res.data.rev) or 0) + return true + end + return false + end) + end) +end + +function SyncEngine:_queueBoxDownload(expected, supplied) + self:_enqueue(function(eng) + if eng.protectedKey and eng._boxKeys[eng.protectedKey] then + eng.skipped = { "Box sync will finish after you return to the launcher." }; return + end + local function apply(e, remote) + local locals = e.saves.list() + local downloads, why = e.box:apply(remote, locals, e.state, expected) + if downloads == false then e.skipped = { why }; return end + if not downloads then e:_fail(why); return end + local current, err = e.box:snapshot(e.saves.list(), e.state) + if not current then e:_fail(err); return end + e.lastDownloads = e.lastDownloads or {} + for _, row in ipairs(downloads) do e.lastDownloads[#e.lastDownloads + 1] = row end + e.changed = true + e:_acceptBox(remote.rev, remote.saves, current.fingerprint) + if not e:busy() then e:_finish() end + end + eng.phase, eng.status = "downloading", "Downloading Box and linked saves..." + local handle, err = eng.client:getBox(eng._boxKeys) + eng:_request(handle, err, function(e, res) + local remote = res.data + if supplied then + local same = remote.rev == supplied.rev + for key, row in pairs(supplied.saves) do same = same and remote.saves and remote.saves[key] and remote.saves[key].rev == row.rev end + if not same then + local current, why = e.box:snapshot(e.saves.list(), e.state, e._boxKeys) + if not current then e:_fail(why); return end + e:_queueBoxComparison(current, remote.rev or 0); return + end + end + apply(e, remote) + end) + end) +end + +function SyncEngine:restoreBoxBackup(path) + if not self.box then return nil, "Box storage is unavailable." end + if self:busy() or #self.conflicts > 0 then return nil, "Finish the current sync before restoring Box." end + local recovered, recoveryError, notice = self.box:recover() + self:_noteRecovery(notice) + if not recovered then return nil, recoveryError end + local snapshot, backup = self.box:readBackup(path) + if not snapshot then return nil, backup end + local entries = self.saves.list() + local current, err = self.box:snapshot(entries, self.state, snapshot.saves) + if not current then return nil, err end + local backupState = backup.state + for key in pairs(current.keys) do + local row, member = snapshot.saves[key], backupState.syncMembers and backupState.syncMembers[key] + if not row or row.deleted and not (member and member.deleted) then + return nil, "This backup predates another linked save. Restore a newer complete collection." + end + end + for key in pairs(self._boxKeys or {}) do + if not snapshot.saves[key] then return nil, "This backup predates another linked save. Restore a newer complete collection." end + end + if self.protectedKey and current.keys[self.protectedKey] then + return nil, "Return to the launcher before restoring Box and its linked saves." + end + local downloads, failure = self.box:apply(snapshot, entries, self.state, current.fingerprint) + if not downloads then return nil, failure end + self.changed, self.lastDownloads = true, downloads + self:noteSaveWritten() + self.status = "Box and its linked saves were restored." + if self.notice then + self.status = self.notice .. " " .. self.status + self.shownNotice, self.notice = self.notice, nil + end + return true +end + function SyncEngine:_removeLocal(entry, key, tomb) if key == self.protectedKey then return end local slotId, cartId @@ -828,8 +1116,20 @@ function SyncEngine:_removeLocal(entry, key, tomb) end end +function SyncEngine:_newBoxMember(key) + if not self.box or self._boxKeys and self._boxKeys[key] then return false end + local owned, why = self.box:owns(key) + if owned == nil or owned then + self:_fail(why or "Box gained a linked save during sync. Checking the collection again.") + if owned and self.state.enabled then self.uploadAt = self.clock end + return true + end + return false +end + function SyncEngine:_queueDelete(key, rev) self:_enqueue(function(eng) + if eng:_newBoxMember(key) then return end eng.phase = "uploading" eng.status = "Removing deleted saves..." local version, id = SyncState.splitKey(key) @@ -872,6 +1172,7 @@ end function SyncEngine:_queueComparison(entry, key, row) entry, row = snapshot(entry), snapshot(row) self:_enqueue(function(eng) + if eng:_newBoxMember(key) then return end eng.phase = "checking" eng.status = "Checking save contents..." local function conflict(e, data) @@ -890,6 +1191,7 @@ function SyncEngine:_queueComparison(entry, key, row) if not handle then conflict(eng) return end eng:_request(handle, err, function(e, res) local data = type(res.data) == "table" and res.data or {} + if e:_newBoxMember(key) then return end local fetched, observed = revision(data.rev), revision(row.rev) if fetched and observed and fetched >= observed and sameContents(entry.blob, data.blob) then @@ -907,6 +1209,7 @@ end function SyncEngine:_queueUpload(entry, key, force) entry = snapshot(entry) self:_enqueue(function(eng) + if eng:_newBoxMember(key) then return end eng.phase = "uploading" eng.status = "Uploading saves..." local handle, err = eng.client:putSave({ @@ -941,10 +1244,12 @@ end function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev) self:_enqueue(function(eng) + if eng:_newBoxMember(key) then return end eng.phase = "downloading" eng.status = "Downloading saves..." local handle, err = eng.client:getSave(version, playthroughId) eng:_request(handle, err, function(e, res) + if e:_newBoxMember(key) then return end local data = res.data or {} if type(data.blob) ~= "string" or data.blob == "" then e:_fail("the server sent no save data") @@ -991,6 +1296,21 @@ function SyncEngine:resolveConflict(key, choice) if row.key == key then index = i break end end if not index then return false, "no such conflict" end + local proposed = self.conflicts[index] + if not proposed.box and self:_newBoxMember(key) then return false, "This save is now linked to Box. Sync the complete collection." end + if proposed.box and choice ~= "local" and choice ~= "remote" then return false, "Choose one complete Box collection and its linked saves." end + local current + if proposed.box then + local why + current, why = self.box:snapshot(self.saves.list(), self.state, self._boxKeys) + if not current then return false, why end + if current.fingerprint ~= proposed.entry.fingerprint then + proposed.entry = current + proposed.localMeta = { summary = { name = "Box", boxCount = current.payload.meta.count }, savedAt = unixSeconds(current.payload.meta.savedAt) } + proposed.reviewMessage = "Box changed on this device. Review the updated collection before choosing. The other copy will be kept in a recovery backup." + return false, proposed.reviewMessage + end + end local conflict = table.remove(self.conflicts, index) local kept = {} for _, row in ipairs(self.state.pendingConflicts or {}) do @@ -998,7 +1318,10 @@ function SyncEngine:resolveConflict(key, choice) end self.state.pendingConflicts = kept - if choice == "local" then + if conflict.box then + if choice == "local" then self:_queueBoxUpload(current, conflict.remoteRev, conflict.remote) + else self:_queueBoxDownload(current.fingerprint, conflict.remote) end + elseif choice == "local" then SyncState.setRev(self.state, key, conflict.remoteRev, nil) self:_queueUpload(conflict.entry, key, true) elseif choice == "remote" then diff --git a/src/sync/SyncState.lua b/src/sync/SyncState.lua index 01cb9114..d119375e 100644 --- a/src/sync/SyncState.lua +++ b/src/sync/SyncState.lua @@ -47,6 +47,8 @@ function SyncState.sanitize(raw) out.code1 = code(raw.code1) out.code2 = code(raw.code2) out.lastSyncAt = num(raw.lastSyncAt) or 0 + out.boxRev = num(raw.boxRev) + out.boxFingerprint = str(raw.boxFingerprint) if type(raw.revs) == "table" then for key, rev in pairs(raw.revs) do local n = num(rev) diff --git a/src/sync/SyncWork.lua b/src/sync/SyncWork.lua new file mode 100644 index 00000000..126f9ef9 --- /dev/null +++ b/src/sync/SyncWork.lua @@ -0,0 +1,177 @@ +local ops = require("src.sync.SyncWorkOps")(require) + +local Work = {} +Work.POOL = 2 +Work.MIN_BYTES = 16 * 1024 +Work.ops = ops + +local CMD, RESULT, QUIT = "sync_work_cmd", "sync_work_result", "sync_work_quit" +local WORKER = "src/sync/sync_worker.lua" + +local workers = {} +local cmdCh, resCh, quitCh +local ready +local results = {} +local nextId = 0 +local owned = setmetatable({}, { __mode = "k" }) +local waits = setmetatable({}, { __mode = "k" }) + +local function pack(...) return { n = select("#", ...), ... } end + +local function start() + if ready ~= nil then return ready end + local thread = love and love.thread + if type(thread) ~= "table" or type(thread.newThread) ~= "function" + or type(thread.getChannel) ~= "function" then + ready = false + return false + end + local ok = pcall(function() + cmdCh, resCh, quitCh = thread.getChannel(CMD), thread.getChannel(RESULT), thread.getChannel(QUIT) + quitCh:clear(); cmdCh:clear(); resCh:clear() + end) + if not ok then + cmdCh, resCh, quitCh, ready = nil, nil, nil, false + return false + end + for _ = 1, Work.POOL do + local made, th = pcall(thread.newThread, WORKER) + if made and th and pcall(function() th:start() end) then workers[#workers + 1] = th end + end + ready = #workers > 0 + return ready +end + +function Work.available() + return start() +end + +local function drain() + if not resCh then return end + local msg = resCh:pop() + while msg do + if type(msg) == "table" and msg.id and results[msg.id] == false then results[msg.id] = msg end + msg = resCh:pop() + end + for _, th in ipairs(workers) do + local err = th.getError and th:getError() + if err then + for id, r in pairs(results) do + if r == false then results[id] = { id = id, lost = true } end + end + ready = false + break + end + end +end + +function Work.submit(op, ...) + if not ops[op] or not start() then return nil end + nextId = nextId + 1 + local id = nextId + if not pcall(cmdCh.push, cmdCh, { id = id, op = op, args = pack(...) }) then return nil end + results[id] = false + return id +end + +function Work.poll(id) + drain() + local r = results[id] + if r == false then return nil end + results[id] = nil + return r or { id = id, lost = true } +end + +function Work.forget(id) + if id ~= nil then results[id] = nil end +end + +function Work.finish(r, op, args) + if not r or r.lost then return ops[op](unpack(args, 1, args.n)) end + if r.err then error(r.err, 0) end + local values = r.values or { n = 0 } + return unpack(values, 1, values.n or #values) +end + +function Work.spawn(fn) + local co = coroutine.create(fn) + owned[co] = true + return co +end + +function Work.abandon(co) + for id in pairs(waits[co] or {}) do Work.forget(id) end + waits[co] = nil +end + +function Work.async() + local co = coroutine.running() + return co ~= nil and owned[co] == true and start() == true +end + +local function heavy(args) + local bytes = 0 + for i = 1, args.n do + local v = args[i] + if type(v) == "table" then return true end + if type(v) == "string" then bytes = bytes + #v end + end + return bytes >= Work.MIN_BYTES +end + +local function await(co, ids) + local mine = waits[co] or {} + waits[co] = mine + for _, id in pairs(ids) do mine[id] = true end + local out = {} + while true do + local pending = false + for i, id in pairs(ids) do + if out[i] == nil then + local r = Work.poll(id) + if r then out[i] = r; mine[id] = nil else pending = true end + end + end + if not pending then return out end + coroutine.yield() + end +end + +function Work.call(op, ...) + local args = pack(...) + if not heavy(args) or not Work.async() then return ops[op](...) end + local id = Work.submit(op, ...) + if not id then return ops[op](...) end + return Work.finish(await(coroutine.running(), { id })[1], op, args) +end + +function Work.batch(op, list) + local out, ids = {}, {} + local async = Work.async() + for i, args in ipairs(list) do + args.n = args.n or #args + local id = async and heavy(args) and Work.submit(op, unpack(args, 1, args.n)) or nil + if id then ids[i] = id else out[i] = pack(ops[op](unpack(args, 1, args.n))) end + end + if next(ids) then + local done = await(coroutine.running(), ids) + for i, r in pairs(done) do out[i] = pack(Work.finish(r, op, list[i])) end + end + return out +end + +function Work.shutdown() + if quitCh then quitCh:push(true) end + if cmdCh then + cmdCh:clear() + for _ = 1, #workers do cmdCh:push({ op = "quit" }) end + end + for _, th in ipairs(workers) do pcall(function() th:wait() end) end + workers = {} + results = {} + cmdCh, resCh, quitCh, ready = nil, nil, nil, nil +end + +pcall(function() require("src.core.SessionLifecycle").registerProcessShutdown(Work.shutdown) end) + +return Work diff --git a/src/sync/SyncWorkOps.lua b/src/sync/SyncWorkOps.lua new file mode 100644 index 00000000..e6920cd7 --- /dev/null +++ b/src/sync/SyncWorkOps.lua @@ -0,0 +1,52 @@ +return function(need) + local Json = need("src.link.Json") + local Serializer = need("src.core.SaveSerializer") + local Base64 = need("src.core.Base64") + local ops = {} + + function ops.hash(body) + if love and love.data and love.data.hash then + local digest = love.data.hash("sha256", body) + return "sha256:" .. (digest:gsub(".", function(c) return ("%02x"):format(c:byte()) end)) + end + return "sha512:" .. need("src.core.crypto.sha512").hex(body) + end + + function ops.jsonDecode(raw, maxLength) return Json.decode(raw, maxLength) end + function ops.jsonEncode(value) return Json.encode(value) end + function ops.decode(body, limits) return Serializer.decode(body, limits) end + function ops.encode(value) return Serializer.encode(value) end + function ops.base64Encode(bytes) return Base64.encode(bytes) end + function ops.base64Decode(text) return Base64.decode(text) end + + function ops.normalizeSave(blob, version, id, cart) + local save = Serializer.decode(blob) + if not save then return nil end + save.meta = type(save.meta) == "table" and save.meta or {} + save.meta.playthroughId = id + save.version = save.version or version + if cart then save.meta.cartId = cart end + return Serializer.encode(save) + end + + function ops.fingerprint(blob, assets, bodies) + local state = Serializer.decode(blob) + local paths = {} + for key, member in pairs(state.syncMembers or {}) do + paths[member.path] = key + member.path, member.slotId = nil, nil + end + local function portable(entry) + entry.slotId = nil + for _, original in ipairs(entry.archives or {}) do portable(original) end + end + for _, box in ipairs(state.boxes) do for _, entry in pairs(box.mons) do portable(entry) end end + for _, departure in pairs(state.departures or {}) do + departure.path = paths[departure.path] or departure.path + portable(departure.entry) + end + return ops.hash(Serializer.encode({ box = state, assets = assets, saves = bodies })) + end + + return ops +end diff --git a/src/sync/sync_worker.lua b/src/sync/sync_worker.lua new file mode 100644 index 00000000..5b8110c2 --- /dev/null +++ b/src/sync/sync_worker.lua @@ -0,0 +1,43 @@ +require("love.thread") +require("love.filesystem") +require("love.data") + +local function need(name) + local loaded = package.loaded[name] + if loaded ~= nil then return loaded end + local mod = assert(love.filesystem.load(name:gsub("%.", "/") .. ".lua"))() + package.loaded[name] = mod + return mod +end + +local ops = assert(love.filesystem.load("src/sync/SyncWorkOps.lua"))()(need) + +local cmdCh = love.thread.getChannel("sync_work_cmd") +local resCh = love.thread.getChannel("sync_work_result") +local quitCh = love.thread.getChannel("sync_work_quit") + +local function pack(...) return { n = select("#", ...), ... } end + +while true do + local job = cmdCh:demand() + if quitCh:peek() ~= nil then break end + if type(job) == "table" then + if job.op == "quit" then break end + local fn = ops[job.op] + local args = type(job.args) == "table" and job.args or { n = 0 } + local result = fn and pack(pcall(fn, unpack(args, 1, args.n or #args))) + local reply + if not fn then + reply = { id = job.id, err = "unknown sync work " .. tostring(job.op) } + elseif not result[1] then + reply = { id = job.id, err = tostring(result[2]) } + else + local values = { n = result.n - 1 } + for i = 2, result.n do values[i - 1] = result[i] end + reply = { id = job.id, values = values } + end + if not pcall(resCh.push, resCh, reply) then + resCh:push({ id = job.id, lost = true }) + end + end +end diff --git a/src/ui/kit/Button.lua b/src/ui/kit/Button.lua index cd90726a..c9ea65bf 100644 --- a/src/ui/kit/Button.lua +++ b/src/ui/kit/Button.lua @@ -79,7 +79,10 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused) local active = opts.active or opts.on local invert = false local fill, ink, stroke, strokeA, strokeWidth, doEmboss, doRing, glowA - if face == "selection" then + if face == "bare" then + ink = opts.ink or PAL.heading + doRing = focused + elseif face == "selection" then -- A selected control keeps its geometry, dark face and label layout. -- Selection is an inset accent outline, never a filled keycap. fill = PAL.surface @@ -139,7 +142,9 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused) doRing = opts.ring end if G then - Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius, segments) + if face ~= "bare" then + Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius, segments) + end if doEmboss then local es = enabled and ((hot or focused) and B.embossHot or B.embossRest) or B.embossDisabled Theme.emboss(x, y, w, h, es) @@ -245,7 +250,7 @@ function Button.draw(Kit, x, y, w, h, label, opts, hot, focused) Kit.textCenter(fname, shown, x, top + size + gap, w, ink) elseif opts.icon then local box = math.min(w, h) - local size = math.floor(box * (hasLabel and 0.42 or 0.5)) + local size = math.floor(box * (hasLabel and 0.42 or 0.5) * (opts.iconScale or 1)) local gap = math.floor(7 * Kit.scale) local shown = hasLabel and Kit.ellipsize( diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua index 31d965ec..b7ec8eef 100755 --- a/src/ui/kit/FileBrowser.lua +++ b/src/ui/kit/FileBrowser.lua @@ -98,6 +98,8 @@ local function isMatchingFilter(name, isDir, mode) return (ext == "gb" or ext == "gbc" or ext == "gba" or ext == "zip") elseif mode == "save" then return (ext == "sav" or ext == "lua") + elseif mode == "box" then + return (ext == "gci" or ext == "sav") elseif mode == "mod" then return (ext == "zip") elseif mode == "cart" then diff --git a/src/ui/kit/Icons.lua b/src/ui/kit/Icons.lua index d94b1087..b2438550 100644 --- a/src/ui/kit/Icons.lua +++ b/src/ui/kit/Icons.lua @@ -61,6 +61,7 @@ local atlases = { } local cells = {} Icons.NAMES = {} +Icons.ATLASES = atlases for _, atlas in ipairs(atlases) do for i, name in ipairs(atlas.names) do Icons.NAMES[#Icons.NAMES + 1] = name @@ -69,12 +70,71 @@ for _, atlas in ipairs(atlases) do end Icons.NAMES[#Icons.NAMES + 1] = "triangle-alert" +local vectors = {} +for _, name in ipairs({ "circle-help", "music", "play", "square", "monitor", "gamepad-2", "chart-no-axes-column" }) do + Icons.NAMES[#Icons.NAMES + 1] = name; vectors[name] = true +end +local MUSIC_NOTES = { { .3, .79 }, { .7, .68 } } +local CHART_BARS = { { .2, .45 }, { .45, .2 }, { .7, .32 } } function Icons.has(name) - return name == "triangle-alert" or cells[name] ~= nil + return name == "triangle-alert" or vectors[name] == true or cells[name] ~= nil end function Icons.draw(name, x, y, size, color, alpha) local g = love and love.graphics + if g and name == "circle-help" then + g.push("all") + g.setColor(color[1] / 255, color[2] / 255, color[3] / 255, alpha or 1) + g.setLineWidth(math.max(1, size * 0.075)) + g.circle("line", x + size * .5, y + size * .5, size * .42) + g.line(x + size * .35, y + size * .36, x + size * .39, y + size * .29, + x + size * .53, y + size * .27, x + size * .64, y + size * .34, + x + size * .63, y + size * .43, x + size * .5, y + size * .51, x + size * .5, y + size * .57) + g.circle("fill", x + size * .5, y + size * .71, size * .04) + g.pop() + return + end + if g and vectors[name] then + g.push("all") + g.setColor(color[1] / 255, color[2] / 255, color[3] / 255, alpha or 1) + g.setLineWidth(math.max(1, size * .075)) + if g.setLineJoin then g.setLineJoin("bevel") end + if name == "music" then + g.line(x + size * .42, y + size * .76, x + size * .42, y + size * .25, + x + size * .82, y + size * .15, x + size * .82, y + size * .65) + for i = 1, #MUSIC_NOTES do + local note = MUSIC_NOTES[i] + if g.ellipse then g.ellipse("fill", x + size * note[1], y + size * note[2], size * .12, size * .075) + else g.circle("fill", x + size * note[1], y + size * note[2], size * .09) end + end + elseif name == "play" then + g.polygon("fill", x + size * .28, y + size * .18, x + size * .84, y + size * .5, x + size * .28, y + size * .82) + elseif name == "square" then + g.rectangle("fill", x + size * .22, y + size * .22, size * .56, size * .56, size * .06) + elseif name == "monitor" then + g.rectangle("line", x + size * .1, y + size * .15, size * .8, size * .58, size * .07) + g.line(x + size * .5, y + size * .73, x + size * .5, y + size * .9) + g.line(x + size * .32, y + size * .9, x + size * .68, y + size * .9) + elseif name == "gamepad-2" then + g.line(x + size * .15, y + size * .28, x + size * .85, y + size * .28, + x + size * .94, y + size * .65, x + size * .92, y + size * .78, + x + size * .78, y + size * .78, x + size * .66, y + size * .61, + x + size * .34, y + size * .61, x + size * .22, y + size * .78, + x + size * .08, y + size * .78, x + size * .06, y + size * .65, x + size * .15, y + size * .28) + g.line(x + size * .25, y + size * .46, x + size * .42, y + size * .46) + g.line(x + size * .335, y + size * .37, x + size * .335, y + size * .55) + g.circle("fill", x + size * .73, y + size * .42, size * .035) + g.circle("fill", x + size * .81, y + size * .5, size * .035) + elseif name == "chart-no-axes-column" then + for i = 1, #CHART_BARS do + local bar = CHART_BARS[i] + g.rectangle("fill", x + size * bar[1], y + size * bar[2], size * .11, size * (.82 - bar[2])) + end + g.line(x + size * .1, y + size * .9, x + size * .9, y + size * .9) + end + g.pop() + return + end if g and name == "triangle-alert" then g.push("all") g.setColor(color[1] / 255, color[2] / 255, color[3] / 255, alpha or 1) diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 2179e1b5..07a9b3ad 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -325,6 +325,7 @@ function Kit.beginFrame(mx, my, clicked, wheel) Kit.mouseX, Kit.mouseY = mx or 0, my or 0 Kit.mouseClicked = clicked and true or false Kit.wheelY = wheel or 0 + Kit._fieldHit, Kit._focusDrawn = false, false local down = false if love and love.mouse and love.mouse.isDown then down = love.mouse.isDown(1) and true or false @@ -333,6 +334,7 @@ function Kit.beginFrame(mx, my, clicked, wheel) if not down then Kit._drag = nil end Kit.resetClip() Kit.blockClicks = false + Kit.occlude(nil) if love and love.timer and love.timer.getTime then Kit.time = love.timer.getTime() end @@ -376,6 +378,7 @@ end -- Anything typed while no field had focus is dropped here rather than -- replayed into the next field that gets clicked. function Kit.endFrame() + if Kit.focus and ((Kit.mouseClicked and not Kit._fieldHit) or not Kit._focusDrawn) then Kit.blur() end for i = #edits, 1, -1 do edits[i] = nil end Kit.wheelY = 0 Kit._activateId = nil @@ -567,6 +570,10 @@ function Kit.keypressed(key) if key == "backspace" then edits[#edits + 1] = "\b" return true elseif key == "return" or key == "kpenter" or key == "escape" then edits[#edits + 1] = "\r" return true + elseif key == "tab" or key == "up" or key == "down" then + Kit.blur() + Kit.navigate(key == "up" and "up" or "down") + return true end -- printable keys arrive through textinput; everything else falls through return false @@ -591,6 +598,7 @@ function Kit.gamepadpressed(button) if FileBrowser.active then return FileBrowser.gamepadpressed(action) end + if Kit.focus and action == "b" then Kit.blur() return true end if action == "dpup" then Kit.navigate("up") return true elseif action == "dpdown" then Kit.navigate("down") return true elseif action == "dpleft" then Kit.navigate("left") return true @@ -605,24 +613,38 @@ function Kit.blur() end -- -------------------------------------------------------------- hit testing -local occluderBox = { x = 0, y = 0, w = 0, h = 0 } +local occluderBoxes = {} +Kit._occluders = 0 Kit._occluder = nil Kit._overlay = false function Kit.occlude(x, y, w, h) if x == nil then + Kit._occluders = 0 Kit._occluder = nil return end - occluderBox.x, occluderBox.y, occluderBox.w, occluderBox.h = x, y, w, h - Kit._occluder = occluderBox + local n = Kit._occluders + 1 + local o = occluderBoxes[n] + if not o then + o = {} + occluderBoxes[n] = o + end + o.x, o.y, o.w, o.h = x, y, w, h + Kit._occluders = n + Kit._occluder = occluderBoxes[1] end function Kit.occluded() - local o = Kit._occluder - if not o or Kit._overlay then return false end - return Kit.mouseX >= o.x and Kit.mouseX <= o.x + o.w - and Kit.mouseY >= o.y and Kit.mouseY <= o.y + o.h + if Kit._occluders == 0 or Kit._overlay then return false end + local mx, my = Kit.mouseX, Kit.mouseY + for i = 1, Kit._occluders do + local o = occluderBoxes[i] + if mx >= o.x and mx <= o.x + o.w and my >= o.y and my <= o.y + o.h then + return true + end + end + return false end -- A widget inside a clip region can sit at coordinates outside the visible @@ -634,7 +656,7 @@ function Kit.hit(x, y, w, h) and Kit.mouseY >= c.y and Kit.mouseY <= c.y + c.h) then return false end - if Kit._occluder and Kit.occluded() then return false end + if Kit._occluders > 0 and Kit.occluded() then return false end return Kit.mouseX >= x and Kit.mouseX <= x + w and Kit.mouseY >= y and Kit.mouseY <= y + h end @@ -914,6 +936,7 @@ function Kit.textfield(id, x, y, w, h, value, placeholder) value = VirtualKeyboard.text end + if Kit.press(x, y, w, h) then Kit._fieldHit = true end if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end local focused = (Kit.focus == id) if focused then @@ -928,6 +951,7 @@ function Kit.textfield(id, x, y, w, h, value, placeholder) value = value .. e end end + if focused then Kit._focusDrawn = true end end if G then Theme.fillRounded(x, y, w, h, PAL.bg, 1) @@ -965,11 +989,11 @@ function Kit.pager(x, y, w, page, total, perPage, idPrefix, compact) local gap = 8 * Kit.scale idPrefix = idPrefix or "pager" - if Kit.button(x, y, bw, h, "< Prev", { kind = "ghost", font = "small", + if Kit.button(x, y, bw, h, "Prev", { kind = "ghost", font = "small", icon = "chevron-left", enabled = page > 1, id = idPrefix .. ":prev" }) then page = math.max(1, page - 1) end - if Kit.button(x + bw + gap, y, bw, h, "Next >", { kind = "ghost", + if Kit.button(x + bw + gap, y, bw, h, "Next", { kind = "ghost", icon = "chevron-right", font = "small", enabled = page < pages, id = idPrefix .. ":next" }) then page = math.min(pages, page + 1) end diff --git a/src/ui/kit/Transition.lua b/src/ui/kit/Transition.lua index 467f3474..5f44604b 100644 --- a/src/ui/kit/Transition.lua +++ b/src/ui/kit/Transition.lua @@ -12,10 +12,12 @@ Transition.DURATIONS = { } Transition.LAYERS = { "tabs", "online", "modal" } +Transition.LOCAL = { "box" } Transition.PASSIVE = { "toast" } local ALL = {} for i = 1, #Transition.LAYERS do ALL[#ALL + 1] = Transition.LAYERS[i] end +for i = 1, #Transition.LOCAL do ALL[#ALL + 1] = Transition.LOCAL[i] end for i = 1, #Transition.PASSIVE do ALL[#ALL + 1] = Transition.PASSIVE[i] end local layers = {} diff --git a/tests/engine/box_animation_test.lua b/tests/engine/box_animation_test.lua new file mode 100644 index 00000000..9894802c --- /dev/null +++ b/tests/engine/box_animation_test.lua @@ -0,0 +1,124 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") +local T = require("tests.harness") +local Serializer = require("src.core.SaveSerializer") +local Catalog = require("src.box.Catalog") +local CacheFs = require("src.import.CacheFs") +local Sprites = require("src.online.OnlineSprites") +local MonAnim = require("src.core.game3.mon_anim") +local GameVersion = require("src.core.GameVersion") +local Data = MonAnim.Data +local bodies, images, draws, quads = {}, {}, {}, 0 +local previousRead, previousArt = CacheFs.readAt, Catalog.art +local previousData = Data._cache +Data._cache = { sentinel = true } +CacheFs.readAt = function(path) return bodies[path] end +local front = { + functions = { [0] = "Anim_VerticalSquishBounce" }, + animIds = { [1] = 0, [308] = 0 }, animDelays = { [1] = 2 }, + species = { [1] = { 0, 1 }, [308] = { 0, 1 } }, + lists = { + [0] = { cmds = { { frame = 0, duration = 0 }, { op = "end" } } }, + [1] = { cmds = { { frame = 0, duration = 4 }, { frame = 1, duration = 4 }, + { frame = 0, duration = 4 }, { op = "end" } } }, + }, +} +bodies["emerald/data/generated/gba/pokemon/front_anims.lua"] = Serializer.encode(front) +local frameBytes = 64 * 64 * 4 +bodies["emerald/data/generated/gba/pokemon/front_anim/1.rgba"] = string.rep("A", frameBytes) .. string.rep("B", frameBytes) +bodies["emerald/data/generated/gba/pokemon/front_anim_shiny/1.rgba"] = string.rep("S", frameBytes) .. string.rep("T", frameBytes) +local function image(raw, w, h) + local obj = { raw = raw, w = w or 64, h = h or 64 } + function obj:getDimensions() return self.w, self.h end + function obj:setFilter(a, b) self.filter = { a, b } end + function obj:release() self.released = true end + images[#images + 1] = obj + return obj +end +love.image.newImageData = function(w, h, _, raw) + return { w = w, h = h, raw = raw, release = function(self) self.released = true end } +end +love.graphics.newImage = function(pixels) return image(pixels.raw, pixels.w, pixels.h) end +love.graphics.newQuad = function(x, y, w, h) quads = quads + 1; return { x = x, y = y, w = w, h = h } end +love.graphics.draw = function(...) draws[#draws + 1] = { ... } end +local ordinary = { front = image("ordinary") } +local shiny = { front = image("shiny") } +local spotted = { front = image("personality-specific spots") } +local artVersion = "emerald" +Catalog.art = function(entry) + return entry.mon.species == 308 and spotted or entry.display.shiny and shiny or ordinary, + artVersion, { species = entry.mon.species, personality = entry.mon.personality } +end +local function entry(species, isShiny) + return { version = "red", generation = 1, mon = { species = species, personality = 1001 }, + display = { national = species, shiny = isShiny } } +end +local Animation = require("src.box.Animation") +local mon = entry(1) +local before = Serializer.encode(mon) +local animation = Animation.new(mon) +T.check(animation ~= nil, "imported Emerald metadata starts the real summary animator") +T.eq(animation.sprite.animationData.version, "emerald", "animator uses an explicit Emerald provider") +T.eq(animation.frames[1].raw:sub(1, 1), "B", "alternate pose comes from the normal Emerald sheet") +T.same(animation.frames[1].filter, { "nearest", "nearest" }, "animated pixels use nearest filtering") +local alternate, transformed = false, false +for _ = 1, 300 do + Animation.update(animation, 1 / 60) + if animation.sprite.frame == 1 then + alternate = true + love.graphics.setColor(0.2, 0.3, 0.4, 0.5) + Animation.draw(animation, 10, 20, 128) + T.eq(draws[#draws][1], animation.frames[1], "detail drawing uses the current animation pose") + T.same({ love.graphics.getColor() }, { 0.2, 0.3, 0.4, 0.5 }, "animation restores the surrounding UI colour") + break + end +end +T.check(alternate, "real sprite command stream advances into the alternate pose") +for _ = 1, 300 do + Animation.update(animation, 1 / 60) + local transform = MonAnim.transform(animation.sprite) + transformed = transformed or transform.y2 ~= 0 or transform.sx ~= 1 or transform.sy ~= 1 + if MonAnim.done(animation.sprite) then break end +end +T.check(transformed, "species callback animates its position or affine scale") +T.check(MonAnim.done(animation.sprite), "summary animation finishes instead of looping forever") +local n = animation.sprite.frames +Animation.update(animation, 1) +T.eq(animation.sprite.frames, n, "finished animations remain on their resting pose") +T.eq(Serializer.encode(mon), before, "animation leaves the stored Pokémon unchanged") +T.eq(GameVersion.get(), "red", "Emerald animation does not switch the active game") +T.eq(Data._cache.sentinel, true, "Emerald animation does not replace the active animation cache") +T.eq(CacheFs.prefix, "", "Emerald animation does not remount the imported cache") + +local sparkling = Animation.new(entry(1, true)) +T.eq(sparkling.frames[1].raw:sub(1, 1), "T", "shiny alternate poses retain the shiny palette") +local spinda = Animation.new(entry(308)) +T.check(spinda ~= nil, "one-frame species still get their species motion") +T.eq(spinda.art.front, spotted.front, "Spinda motion retains its personality-specific spots") +T.eq(next(spinda.frames), nil, "Spinda never substitutes a generic second-frame picture") +local egg = entry(1); egg.mon.isEgg = true +T.eq(Animation.new(egg), nil, "eggs keep their egg picture") +artVersion = "sapphire" +T.eq(Animation.new(mon), nil, "non-Emerald fallback artwork stays valid and static") +artVersion = "emerald" +T.eq(Animation.new(entry(999)), nil, "unsupported animation species safely stays static") +Animation.reset() +bodies["emerald/data/generated/gba/pokemon/front_anims.lua"] = nil +T.eq(Animation.new(mon), nil, "older imported caches without animation metadata safely stay static") +T.check(sparkling.released and spinda.released, "refresh releases all current animated pictures") +T.check(animation.released, "finished animation pictures are also released on refresh") +T.eq(Animation.draw(animation, 0, 0, 64), false, "released pictures cannot be drawn") + +local icon = { icon = image("icon sheet", 32, 64), iconW = 32, iconH = 32 } +Sprites.drawIcon(icon, 0, 0, 48, 0) +T.eq(draws[#draws][2].y, 0, "icon animation begins at its first pose") +Sprites.drawIcon(icon, 0, 0, 48, 1) +T.eq(draws[#draws][2].y, 32, "icon animation selects its second pose") +Sprites.drawIcon(icon, 0, 0, 48, 2) +T.eq(draws[#draws][2].y, 0, "icon poses loop within the actual sheet") +T.eq(quads, 2, "steady icon animation reuses its quads") +Sprites.drawIcon(icon, 0, 0, 48) +T.eq(draws[#draws][2].y, 0, "existing callers retain the original static icon") +CacheFs.readAt, Catalog.art, Data._cache = previousRead, previousArt, previousData +Animation.reset() +T.finish() diff --git a/tests/engine/box_art_test.lua b/tests/engine/box_art_test.lua new file mode 100644 index 00000000..e1a80f8c --- /dev/null +++ b/tests/engine/box_art_test.lua @@ -0,0 +1,98 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Serializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local CacheFs = require("src.import.CacheFs") +local Catalog = require("src.box.Catalog") +local Store = require("src.box.Store") +local realSprites = require("src.online.OnlineSprites") +local bodies, available, calls = {}, {}, {} +local readAt = CacheFs.readAt +CacheFs.readAt = function(path) return bodies[path] end +local function put(version, path, value) + bodies[GameVersion.cachePrefix(version) .. path] = Serializer.encode(value) +end +put("gold", "data/generated/pokemon.lua", { + PIKACHU = { dex = 25, name = "PIKACHU" }, UNOWN = { dex = 201, name = "UNOWN" } }) +put("emerald", "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU", [201] = "UNOWN" }) +put("emerald", "data/generated/gba/pokemon/national.lua", { + toNational = { [25] = 25, [201] = 201 }, toSpecies = { [25] = 25, [201] = 201 } }) +put("sapphire", "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU" }) +put("sapphire", "data/generated/gba/pokemon/national.lua", { toNational = { [25] = 25 }, toSpecies = { [25] = 25 } }) + +package.loaded["src.online.OnlineSprites"] = { + reset = function() calls = {} end, + ensure = function(version, mon) + calls[#calls + 1] = { version = version, mon = Store.copy(mon) } + if available[version] then return { front = {}, icon = {} } end + return { front = false, icon = false } + end, +} +Catalog.reset() +local mon = { species = "PIKACHU", nickname = "Spark", hp = 22, level = 10, + dvs = { attack = 2, defense = 10, speed = 10, special = 10 }, opaque = "keep" } +local entry = { version = "gold", generation = 2, mon = mon, display = Catalog.describe("gold", mon) } +local before = Serializer.encode(entry) +available.emerald = true +local art, version = Catalog.art(entry) +T.eq(version, "emerald", "highest imported generation supplies art") +T.eq(calls[1].mon.species, 25, "canonical species maps to destination art id") +T.check(calls[1].mon.isShiny, "Gen 2 shiny DVs choose shiny Gen 3 art") +T.eq(Serializer.encode(entry), before, "art lookup does not alter stored Pokémon") +local n = #calls +Catalog.art(entry) +T.eq(#calls, n, "steady draws reuse resolved art") + +local order = GameVersion.ORDER +GameVersion.ORDER = { "emerald", "gold", "sapphire" } +available.sapphire = true +Catalog.reset() +art, version = Catalog.art(entry) +T.eq(version, "emerald", "Emerald preference is independent of launcher column order") +local _, _, mappedMon = Catalog.art(entry) +T.eq(mappedMon.species, 25, "cached art retains its mapped animation species") +GameVersion.ORDER = order +local namesPath = "emerald/data/generated/gba/pokemon/names.lua" +local emeraldNames = bodies[namesPath] +bodies[namesPath] = nil +Catalog.reset() +art, version = Catalog.art(entry) +T.eq(version, "sapphire", "an unimported Emerald does not displace an available imported game") +bodies[namesPath] = emeraldNames + +available.emerald, available.sapphire = nil, true +Catalog.reset() +art, version = Catalog.art(entry) +T.eq(version, "sapphire", "missing high-generation art falls back to another imported source") +T.eq(Serializer.encode(entry), before, "fallback leaves native data intact") + +available.sapphire, available.gold = nil, true +Catalog.reset() +art, version = Catalog.art(entry) +T.eq(version, "gold", "lower generation is used when newer art is absent") + +available.emerald = true +Catalog.reset() +local unown = { species = "UNOWN", unownLetter = 3, dvs = { attack = 1, defense = 1, speed = 1, special = 1 } } +local record = { version = "gold", generation = 2, mon = unown, display = Catalog.describe("gold", unown) } +Catalog.art(record) +local mapped = calls[1].mon +T.eq(require("src.core.game3.pokemon").unownLetter(mapped.personality), 2, "Unown letter C survives art generation change") +T.eq(unown.personality, nil, "display personality is not written into the native record") +local other = Store.copy(record) +other.mon.unownLetter = 4 +local _, _, otherMon = Catalog.art(other) +T.eq(require("src.core.game3.pokemon").unownLetter(otherMon.personality), 3, "different Gen 2 Unown forms do not share the first form's cached art") + +available.emerald, available.gold = nil, nil +Catalog.reset() +art, version = Catalog.art(entry) +T.eq(art, nil, "missing artwork is a safe placeholder") +T.eq(GameVersion.get(), "red", "artwork lookup does not switch the active game") +T.eq(CacheFs.prefix, "", "artwork lookup does not change the cache prefix") + +CacheFs.readAt = readAt +package.loaded["src.online.OnlineSprites"] = realSprites +Catalog.reset() +T.finish() diff --git a/tests/engine/box_collection_test.lua b/tests/engine/box_collection_test.lua new file mode 100644 index 00000000..ac679e46 --- /dev/null +++ b/tests/engine/box_collection_test.lua @@ -0,0 +1,140 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Store = require("src.box.Store") +local Catalog = require("src.box.Catalog") +local Search = require("src.box.Search") +local Collection = require("src.box.Collection") +local Serializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local CacheFs = require("src.import.CacheFs") +local Service = require("src.box.Service") +local originalRead = CacheFs.readAt +local tables = {} +local function put(version, path, value) tables[GameVersion.cachePrefix(version)..path] = Serializer.encode(value) end +local root = "data/generated/gba/pokemon/" +put("emerald", root.."names.lua", { [25] = "PIKACHU" }) +put("emerald", root.."national.lua", { toNational = { [25] = 25 } }) +put("emerald", root.."meta.lua", { [25] = { genderRatio = 127, growthRate = 0 } }) +put("emerald", root.."stats.lua", { [25] = { hp = 35, atk = 55, def = 40, spe = 90, spa = 50, spd = 50 } }) +put("emerald", root.."abilities.lua", { [25] = { 9, 31 } }) +put("emerald", root.."ability_names.lua", { [9] = "STATIC", [31] = "LIGHTNING ROD" }) +put("emerald", root.."move_names.lua", { [84] = "THUNDERSHOCK" }) +put("emerald", root.."battle_moves.lua", { moves = { [84] = { pp = 30 } } }) +put("emerald", root.."types.lua", { [25] = { 13 } }) +put("emerald", root.."type_names.lua", { [13] = "ELECTRIC" }) +put("emerald", "data/generated/gba/items/pack.lua", { items = { [4] = { name = "POKé BALL" } } }) +put("emerald", "data/generated/gba/region_map/map_sections.lua", { sections = { [7] = { name = "Route 1" } } }) +put("gold", "data/generated/pokemon.lua", { PIKACHU = { name = "PIKACHU", dex = 25, + genderRatio = 127, types = { "ELECTRIC" }, baseStats = { hp = 35, attack = 55, defense = 30, + speed = 90, specialAttack = 50, specialDefense = 40 } } }) +put("gold", "data/generated/moves.lua", { THUNDERSHOCK = { name = "THUNDERSHOCK", pp = 30 } }) +CacheFs.readAt = function(path) return tables[path] end +Catalog.reset() +local mon = { species = 25, personality = 13, exp = 125000, otId = 42, otSecretId = 3, + otName = "Alice", abilityNum = 0, metLocation = 7, metLevel = 5, pokeball = 4, + moves = { 84 }, pp = { 15 }, ppBonusesPacked = 3, friendship = 110, markings = 5, + ivs = { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 }, evs = { spe = 252 } } +local original = Store.copy(mon) +local d = Catalog.describe("emerald", mon) +T.eq(d.level, 50, "boxed Gen3 level derives from native experience") +T.eq(d.nature, "Jolly", "nature derives from personality") +T.eq(d.gender, "Female", "gender uses species ratio and personality") +T.eq(d.ability, "STATIC", "stored ability bit wins over personality parity") +T.eq(d.hp, 110, "boxed HP is calculated from native base stats and IVs") +T.eq(d.speed, 156, "Speed includes EVs and Jolly nature") +T.eq(d.spAtk, 63, "Jolly reduces Special Attack") +T.eq(d.spDef, 70, "Special Defense has the correct nature key") +T.eq(d.location, "Route 1", "location reads selected game's cache") +T.eq(d.moveDetails[1].maxPP, 48, "PP Ups extend ROM move PP") +T.eq(d.moveDetails[1].pp, 15, "current native PP is retained") +T.same(mon, original, "summary computation leaves native Pokémon untouched") +local gapMon=Store.copy(mon) +gapMon.cartExtra={moveSlots={moves={0,84,0,0},pp={0,15,0,0}}} +gapMon.ppBonusesPacked=12 +T.eq(Catalog.describe("emerald",gapMon).moveDetails[1].ppUps,3,"native empty move slots retain the correct PP Up bit position") +local state = Store.new() +for i = 1, 60 do + local m = Store.copy(mon); m.nickname = "Pika " .. i + state.boxes[1].mons[i] = { id = i, version = "emerald", generation = 3, mon = m, display = Catalog.describe("emerald", m) } +end +state.nextId = 61 +local e = state.boxes[1].mons[1] +T.check(Search.compile('trainer:alice nature:jolly gender:female speed>=156 mark:triangle')(e), "metadata and native markings filter together") +T.check(not Search.compile('gender:male')(e), "Male filter does not include Female") +e.display.ability = "LIGHTNING ROD" +T.check(Search.compile('ability:"lightning rod" -egg')(e), "quoted fields support multiword names") +T.check(not Search.compile('mark:heart')(e), "missing native marking is excluded") + +local refs = { { box = 1, slot = 1 }, { box = 1, slot = 2 } } +local overlap = assert(Store.moveGroup(state, refs, 1, 2)) +T.eq(overlap.boxes[1].mons[1].id, 3, "overlapping group swaps displaced entry into vacated origin") +T.eq(overlap.boxes[1].mons[2].id, 1, "first selected entry lands at first destination") +T.eq(overlap.boxes[1].mons[3].id, 2, "group order remains intact") +T.eq(Store.count(overlap), 60, "overlap preserves every Pokémon exactly once") +local across = assert(Store.moveGroup(state, refs, 25, 59)) +T.eq(across.boxes[25].mons[59].id, 1, "group reaches Box 25 slot 59") +T.eq(across.boxes[25].mons[60].id, 2, "group reaches final warehouse slot") +T.eq(Store.count(across), 60, "cross-box group preserves count") +T.check(not Store.moveGroup(state, refs, 25, 60), "group cannot overrun final slot") +T.check(not Store.moveGroup(state, { refs[1], refs[1] }, 2, 1), "duplicate group references rejected") +T.eq(state.boxes[1].mons[1].id, 1, "move planning never changes original state") +local marked = assert(Store.mark(across, { { box = 25, slot = 59 } }, 10, "battle team")) +T.eq(marked.boxes[25].mons[59].mon.markings, 10, "Gen3 changes native marking bits") +T.eq(marked.boxes[25].mons[59].tags, "battle team", "local tags persist separately") +local codec = require("src.save_convert.Gen3Save").forVersion("emerald") +local native = codec.fromPortMon(marked.boxes[25].mons[59].mon, {}, false) +local roundTrip = codec.toPortMon(assert(codec.decodeBoxMon(codec.encodeBoxMon(native))), false) +T.eq(roundTrip.markings, 10, "markings survive encrypted native PK3 round trip") +T.eq(roundTrip.personality, mon.personality, "marking edit preserves native personality") +local older = Store.copy(marked) +older.boxes[25].mons[59] = { id = 1, version = "gold", generation = 2, + mon = { species = "PIKACHU", level = 50, moves = { { id = "THUNDERSHOCK", pp = 15, maxPp = 30 } }, + dvs = { attack = 15, defense = 10, speed = 10, special = 10 } }, display = {} } +older.boxes[25].mons[59].display = Catalog.describe("gold", older.boxes[25].mons[59].mon) +local localMark = assert(Store.mark(older, { { box = 25, slot = 59 } }, 8)) +T.eq(localMark.boxes[25].mons[59].markings, 8, "Gen2 uses a local marking") +T.eq(localMark.boxes[25].mons[59].mon.markings, nil, "Gen2 native mon gains no fictitious marking field") +T.check(Search.compile('mark:heart')(localMark.boxes[25].mons[59]), "local marks participate in filters") +T.eq(localMark.boxes[25].mons[59].display.nature, nil, "Gen2 summary has no fictitious nature") + +local preset = assert(Store.preset(across, "Pair", { { box = 25, slot = 59 }, { box = 25, slot = 60 } })) +preset = assert(Store.moveGroup(preset, { { box = 25, slot = 59 }, { box = 25, slot = 60 } }, 8, 10)) +T.eq(Store.find(preset, preset.presets[1].ids[1]).box, 8, "team references survive rearrangement") +local themed = assert(Store.theme(preset, 8, "Forest")) +local encoded = Serializer.encode(themed) +T.eq(assert(Store.validate(assert(Serializer.decode(encoded)))).boxes[8].theme, "Forest", "Box theme persists through serialization") +T.eq(assert(Serializer.decode(encoded)).presets[1].name, "Pair", "preset name persists") +T.check(not Store.theme(preset, 8, "Unknown"), "invalid theme fails before persistence") +local report = Collection.dashboard(state, { dex = { caught = { [25] = true, [1] = true }, owned = { [25] = true } }, + modData = { cartImport = { dexOwned = { 4 } } } }) +T.eq(report.total, 60, "dashboard counts all holdings") +T.eq(report.species, 1, "dashboard deduplicates species") +T.eq(report.caught, 3, "dashboard combines native caught history without duplicate counting") +T.eq(report.duplicates[1].count, 60, "dashboard reports duplicate holdings") +T.eq(#report.missing, 385, "missing holdings differ from caught history") +Catalog.get("emerald").national.toNational[288]=263 +T.eq(Collection.dashboard(state,{version="emerald",dex={caught={[288]=true}}, + modData={cartImport={dexOwned={263}}}}).caught,1,"dashboard merges internal Gen3 IDs with native national history without double counting") + +local files = { [Store.PATH] = Serializer.encode(preset), ["saves/emerald/slot1.lua"] = Serializer.encode({ + version = "emerald", generation = 3, party = { Store.copy(mon) }, storage = { boxes = {} } }) } +local fs = { read = function(path) return files[path] end, + getInfo = function(path) return files[path] and { type = "file" } end, + createDirectory = function() return true end, + write = function(path, body) files[path] = body; return true end, + remove = function(path) files[path] = nil; return true end } +local source = { version = "emerald", path = "saves/emerald/slot1.lua", slotId = "slot1" } +local service = assert(Service.open(fs)) +T.check(service:deployPreset(source, 1), "team deploy moves actual records into party") +T.eq(#assert(Serializer.decode(files[source.path])).party, 3, "party gets exactly two team members") +T.check(assert(Serializer.decode(files[source.path])).dex.caught[25] or assert(Serializer.decode(files[source.path])).dex.caught["25"], + "receiving a non-egg Pokémon registers caught history") +T.eq(Store.count(service.state), 58, "deployed members leave warehouse") +local beforeBox, beforeGame = files[Store.PATH], files[source.path] +T.check(not service:deployPreset(source, 1), "repeated deployment refuses missing members") +T.eq(files[Store.PATH], beforeBox, "refusal leaves warehouse unchanged") +T.eq(files[source.path], beforeGame, "refusal leaves game unchanged") + +CacheFs.readAt = originalRead; Catalog.reset() +T.finish("Box collection") diff --git a/tests/engine/box_cry_test.lua b/tests/engine/box_cry_test.lua new file mode 100644 index 00000000..6e73ac4f --- /dev/null +++ b/tests/engine/box_cry_test.lua @@ -0,0 +1,133 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") +local T = require("tests.harness") +local Serializer = require("src.core.SaveSerializer") +local CacheFs = require("src.import.CacheFs") +local Catalog = require("src.box.Catalog") +local GameVersion = require("src.core.GameVersion") +local SaveData = require("src.core.SaveData") +local bodies, made, renders, volume, suspended = {}, {}, 0, 7, false +local oldRead, oldOptions = CacheFs.readAt, SaveData.loadOptions +CacheFs.readAt = function(path) return bodies[path] end +SaveData.loadOptions = function() return { boxCryVol = volume, pikaVol = 3 } end +local function put(version, path, value) + bodies[version .. "/" .. path] = type(value) == "table" and Serializer.encode(value) or value +end +local function source(sound) + local s = { sound = sound, plays = 0, stops = 0 } + function s:setVolume(value) self.volume = value end + function s:play() self.plays = self.plays + 1; self.playing = true end + function s:stop() self.stops = self.stops + 1; self.playing = false end + function s:release() self.released = true end + made[#made + 1] = s + return s +end +love.audio = { newSource = function(sound) renders = renders + 1; return source(sound) end } +love.sound = { newSoundData = function(count, rate, bits, channels) + local data = { count = count, rate = rate, bits = bits, channels = channels, peak = 0 } + function data:setSample(_, channel, value) self.peak = math.max(self.peak, math.abs(value or channel)) end + function data:release() self.released = true end + return data +end } +local chip = require("src.core.ChipAudio") +local oldChip, oldSuspended = chip.newCry, chip.isSuspended +chip.isSuspended = function() return suspended end +chip.newCry = function(data, species) + T.eq(data.audio.programBytes, "gold-ROM-programs", "chip cry carries its own version's programs") + T.eq(species, "PIKACHU", "old-generation cry uses canonical species key") + return source({ chip = true }) +end +put("gold", "data/generated/pokemon.lua", { PIKACHU = { name = "PIKACHU", dex = 25 } }) +put("gold", "data/generated/audio.lua", { generation = 2, programFile = "assets/generated/audio/programs.bin", + cries = { PIKACHU = { header = {}, pitch = 0, length = 0 } } }) +put("gold", "assets/generated/audio/programs.bin", "gold-ROM-programs") +for _, version in ipairs({ "ruby", "emerald" }) do + put(version, "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU", [1] = "BULBASAUR" }) + put(version, "data/generated/gba/pokemon/national.lua", { toNational = { [25] = 25, [1] = 1 }, toSpecies = { [25] = 25, [1] = 1 } }) + put(version, "data/generated/gba/audio/index.lua", { cryIds = { [25] = 0, [1] = 1 }, + cries = { [0] = { sampleId = 9 }, [1] = { sampleId = 9 } }, + samples = { [9] = { offset = 0, size = 32, freq = 13379 * 1024 } } }) + put(version, "data/generated/gba/audio/samples.bin", string.rep(string.char(35, 90, 200, 220), 8)) +end +local Cry = require("src.box.Cry") +local function entry(version, species) + return { version = version, generation = GameVersion.generation(version), mon = { species = species }, display = { national = 25 } } +end +Catalog.reset() +local a, version = Cry.play(entry("gold", "PIKACHU")) +T.check(a ~= nil, "Gen 2 cry starts") +T.eq(version, "gold", "source-game cry wins over newer imported audio") +T.eq(a.plays, 1, "selection plays once") +local count = #made +local repeated = Cry.play(entry("gold", "PIKACHU")) +T.eq(repeated, a, "repeat selection reuses synthesized source") +T.eq(#made, count, "cached cry does not allocate a source again") +T.eq(a.plays, 2, "explicit repeat restarts the cry") +local b = Cry.play(entry("emerald", 25)) +T.eq(a.playing, false, "new popup stops the preceding cry") +T.check(b ~= nil and b.playing, "Gen 3 cry plays from imported PCM") +T.eq(b.sound.channels, 2, "Gen 3 cry uses centered stereo") +T.check(b.sound.peak > 0, "actual cry renderer produces non-silent samples") +T.eq(b.volume, 1, "default Showcase Cry volume is applied") +volume = 2 +T.eq(Cry.play(entry("emerald", 25)), b, "volume change keeps reusable source") +T.eq(b.volume, 2 / 7, "Showcase Cry setting updates next playback") +volume = 0 +T.eq(Cry.play(entry("ruby", 25)), nil, "Showcase Cry mute prevents cry playback") +T.eq(b.playing, false, "muted selection also stops prior audio") +volume = 7 +local egg = entry("emerald", 25); egg.mon.isEgg = true +local played = b.plays +T.eq(Cry.play(egg), nil, "egg does not reveal its species by crying") +T.eq(b.plays, played, "egg selection never starts cached species cry") +suspended = true +T.eq(Cry.play(entry("emerald", 25)), nil, "suspended device stays silent") +suspended = false +T.eq(GameVersion.get(), "red", "playback leaves active game unchanged") +T.eq(CacheFs.prefix, "", "playback leaves importer cache prefix unchanged") +local before = Serializer.encode(egg) +Cry.play(egg) +T.eq(Serializer.encode(egg), before, "cry lookup leaves stored Pokémon unchanged") + +bodies["gold/data/generated/audio.lua"] = nil +Catalog.reset() +local fallback, fallbackVersion = Cry.play(entry("gold", "PIKACHU")) +T.check(fallback ~= nil, "missing source audio uses an imported fallback") +T.eq(fallbackVersion, "emerald", "fallback maps national species into the newest available game") +local unknown = entry("emerald", 999); unknown.display.national = nil +T.eq(Cry.play(unknown), nil, "unknown species is safely silent") +local oldAudio = love.audio +love.audio = nil +T.eq(Cry.play(entry("emerald", 25)), nil, "headless/audio-unavailable selection is safe") +love.audio = oldAudio +Cry.play(entry("emerald", 25)) +Cry.stop() +T.eq(fallback.playing, false, "leaving Box stops playback") +Catalog.reset() +T.check(fallback.released, "cache refresh releases old audio sources") + +local active = Cry.play(entry("emerald", 25)) +Cry.setSuspended(true) +T.check(active.released, "device suspension releases cached sources") +package.loaded["src.core.ChipAudio"] = nil +T.eq(Cry.play(entry("emerald", 25)), nil, "Gen 3 suspension works without a loaded chip engine") +Cry.reset() +T.eq(Cry.play(entry("emerald", 25)), nil, "cache refresh cannot lift the device suspension gate") +Cry.setSuspended(false) +local resumed = Cry.play(entry("emerald", 25)) +T.check(resumed and resumed.playing, "device reset allows new playback") +T.check(resumed ~= active, "device reset creates a fresh source") +package.loaded["src.core.ChipAudio"] = chip + +local synth = require("src.core.ChipSynth") +local banks = synth._loadBanksForTest({ audio = { programFile = "wrong-active-game.bin", + bankOrder = { 2 }, programBytes = string.rep("G", 0x4000) } }) +T.eq(banks[2], string.rep("G", 0x4000), "chip synth uses supplied ROM bytes, independent of mount") + +local oldProfile = require("src.core.game3.profile").of("ruby").audio +T.eq(oldProfile.cryDefaultVolume, 125, "Ruby normal cry uses its own profile parameters") +T.check(renders >= 1, "real Gen 3 renderer was exercised") +chip.newCry, chip.isSuspended = oldChip, oldSuspended +CacheFs.readAt, SaveData.loadOptions = oldRead, oldOptions +Cry.reset() +T.finish() diff --git a/tests/engine/box_details_test.lua b/tests/engine/box_details_test.lua new file mode 100644 index 00000000..47cae8a9 --- /dev/null +++ b/tests/engine/box_details_test.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +local T = require("tests.harness") +local Details = require("src.import.BoxDetails") +local Kit = require("src.ui.kit.Kit") +Kit.fonts = require("src.ui.kit.Theme").fonts(1) +local Store = require("src.box.Store") +local entry = { generation = 3, version = "emerald", mon = { + ivs = { hp = 31, atk = 15, def = 0, spa = 14, spd = 18, spe = 20 }, evs = { spe = 252 }, + contest = { cool = 0, beauty = 80, sheen = 15 }, ribbons = { cool = 2, champion = 1 }, + }, display = { stats = { hp = 110, attack = 70, defense = 45, spAtk = 66, spDef = 73, speed = 156 }, + experience = 125000, nature = "Jolly", ability = "STATIC", trainer = "Alice", trainerId = 42, secretId = 3, + metGame = 3, metLevel = 5, location = "Route 1", ball = "POKé BALL", gender = "Female", friendship = 110, + markings = 5, pokerus = 0x23, fateful = false, item = "", moveDetails = { + { name = "THUNDERSHOCK", pp = 15, maxPP = 48, ppUps = 3 }, + { name = "THUNDER WAVE", pp = 0, maxPP = 20, ppUps = 0 }, + } } } +local original = Store.copy(entry) +local model = Details.model(entry) +local function field(rows, label) + for _, row in ipairs(rows) do if row.label == label then return row end end +end +T.eq(field(model.overview, "Experience").value, "125,000", "experience is readable without changing its value") +T.eq(field(model.overview, "Markings").value, "Circle · Triangle", "marks have names rather than a mystery bitmask") +T.eq(field(model.overview, "Pokérus").value, "Strain 2\n3 days left", "virus data is separated into readable lines") +T.eq(field(model.overview, "Held item").value, "None", "no item is explicit") +T.eq(field(model.origin, "Trainer ID").value, "00042", "trainer ID keeps its five-digit form") +T.eq(field(model.origin, "Secret ID").value, "00003", "secret ID keeps leading zeroes") +T.eq(field(model.origin, "Met game").value, "Emerald", "met game uses the save codec's name") +T.eq(field(model.origin, "Fateful encounter").value, "No", "false remains a recorded value") +T.eq(#model.stats, 6, "Gen 3 has six separately formatted stat rows") +T.eq(field(model.stats, "Speed").value, 156, "current speed remains exact") +T.eq(field(model.stats, "Speed").gene, 20, "Speed IV is separate from current speed") +T.eq(field(model.stats, "Speed").effort, 252, "Speed effort remains exact") +T.eq(field(model.stats, "Defense").gene, 0, "zero IV is not replaced by missing") +T.eq(field(model.stats, "HP").effort, 0, "recorded sparse EV tables keep zero effort explicit") +T.eq(field(model.contest, "Cool").value, "0", "zero Contest values stay visible") +T.eq(field(model.ribbons, "Cool").value, "×2", "ribbon counts retain their rank count") +T.eq(field(model.ribbons, "Champion").value, "Earned", "one ribbon has an earned badge") +T.eq(model.moves[1].pp, 15, "move cards retain remaining PP") +T.eq(model.moves[1].maxPP, 48, "move cards retain boosted maximum PP") +T.eq(model.moves[1].ppUps, 3, "PP Ups remain separate") +T.eq(model.moves[2].pp, 0, "exhausted moves remain zero") +T.same(entry, original, "formatting leaves all native fields untouched") + +local older = { generation = 2, version = "gold", mon = { + dvs = { attack = 15, defense = 10, speed = 10, special = 9 }, + statExp = { hp = 100, attack = 400, defense = 500, speed = 600, special = 700 }, +}, display = { item = "", stats = { hp = 100, attack = 60, defense = 80, speed = 90, + specialAttack = 75, specialDefense = 85 }, moveDetails = {} } } +local oldCopy = Store.copy(older) +local second = Details.model(older) +T.eq(#second.stats, 6, "Gen 2 retains six current stats") +T.eq(field(second.stats, "HP").gene, 9, "HP DV is derived from the four stored parity bits") +T.check(second.derivedHP, "derived HP DV is identified") +T.eq(field(second.stats, "Sp. Attack").gene, 9, "Gen 2 Special Attack uses its shared DV") +T.eq(field(second.stats, "Sp. Defense").gene, 9, "Gen 2 Special Defense uses its shared DV") +T.eq(field(second.stats, "Sp. Attack").effort, 700, "Gen 2 shared Special effort remains visible") +T.eq(field(second.stats, "Sp. Defense").effort, 700, "both special stats show the same stored effort") +T.eq(field(second.overview, "Nature"), nil, "older summaries gain no fictional nature") +T.eq(field(second.origin, "Secret ID"), nil, "older summaries gain no fictional secret ID") +T.same(older, oldCopy, "deriving the displayed DV leaves the original untouched") +older.generation, older.version = 1, "red" +older.display.stats = { hp = 100, attack = 60, defense = 80, speed = 90, special = 75 } +local first = Details.model(older) +T.eq(#first.stats, 5, "Gen 1 has its single Special stat") +T.eq(field(first.stats, "Special").effort, 700, "Gen 1 keeps its Special effort") +T.eq(field(first.overview, "Gender"), nil, "Gen 1 has no gender field") + +local incomplete = Store.copy(entry) +incomplete.mon.ivs, incomplete.mon.evs = nil, nil +incomplete.display.trainer, incomplete.display.fateful, incomplete.display.pokerus = nil, nil, nil +local missing = Details.model(incomplete) +T.eq(field(missing.origin, "Original Trainer").value, "Not recorded", "unknown trainer stays explicit") +T.eq(field(missing.origin, "Fateful encounter").value, "Not recorded", "unknown encounter flag is not false") +T.eq(field(missing.stats, "HP").gene, nil, "missing genes are not manufactured as zero") +T.eq(field(missing.stats, "HP").effort, nil, "missing training is not manufactured as zero") +incomplete.display.moveDetails = {}; incomplete.mon.contest = nil +T.eq(#Details.model(incomplete).moves, 0, "empty move lists remain empty") + +local printed, originalPrint = {}, love.graphics.print +love.graphics.print = function(value, ...) printed[#printed + 1] = tostring(value); return originalPrint(value, ...) end +for _, width in ipairs({ 330, 360, 680, 930 }) do + for _, section in ipairs(Details.SECTIONS) do + Kit.scale = 1; Kit.beginFrame(0, 0, false, 0); printed = {} + local ok, height = pcall(Details.draw, {}, entry, section, 10, 10, width, { s = 1 }) + T.check(ok and height > 0, section .. " reflows at " .. width .. (not ok and ": " .. tostring(height) or "")) + if section == "Stats" then + local seen = table.concat(printed, "\n") + T.check(seen:find("Attack", 1, true) and seen:find("IV", 1, true) and seen:find("EV", 1, true), "stat headings and columns render") + T.check(not seen:find("attack 70", 1, true), "stats never fall back to a serialized paragraph") + elseif section == "Moves" then + T.check(table.concat(printed, "\n"):find("15 / 48", 1, true), "move PP is displayed separately") + end + Kit.endFrame() + end +end +love.graphics.print = originalPrint +T.finish() diff --git a/tests/engine/box_events_test.lua b/tests/engine/box_events_test.lua new file mode 100644 index 00000000..2cb5f543 --- /dev/null +++ b/tests/engine/box_events_test.lua @@ -0,0 +1,108 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Events = require("src.box.Events") +local Store = require("src.box.Store") +local Catalog = require("src.box.Catalog") +local CacheFs = require("src.import.CacheFs") +local Serializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local oldRead, tables = CacheFs.readAt, {} +for _, version in ipairs({ "ruby", "sapphire", "emerald", "firered", "leafgreen" }) do + local C = require("src.core.game3.constants").of(version) + local items = {} + for _, row in ipairs(Events.ROWS) do + local id = C:id("items",row.item) + if id then items[id] = { name=row.name,pocket="KEY_ITEMS" } end + end + for id=500,529 do items[id]={name="Key "..id,pocket="KEY_ITEMS"} end + local prefix=GameVersion.cachePrefix(version) + tables[prefix.."data/generated/gba/pokemon/names.lua"] = Serializer.encode({[25]="PIKACHU"}) + tables[prefix.."data/generated/gba/items/pack.lua"] = Serializer.encode({items=items}) +end +CacheFs.readAt=function(path) return tables[path] end;Catalog.reset() +T.check(not Events.allowed("oldSeaMap","emerald",2),"English Emerald cannot receive Old Sea Map") +T.check(Events.allowed("oldSeaMap","emerald",1)~=nil,"Japanese Emerald has historical Old Sea Map distribution") +T.check(not Events.allowed("aurora","emerald",1),"Japanese Emerald has no historical AuroraTicket distribution") +T.check(Events.allowed("aurora","firered",1)~=nil,"Japanese FireRed did receive AuroraTicket") +T.check(not Events.allowed("mystic","emerald",3),"French MysticTicket is not offered without historical distribution") +T.check(not Events.allowed("eon","firered",2),"FRLG does not receive Eon Ticket") +T.eq(#Events.list("gold",2),0,"Gen2 has no Gen3 ticket events") +local Items=require("src.core.game3.items_data") +Items.applyProfile("firered") +local beforeModel,beforePack,beforeCap=Items._bagModel,Items._pack,Store.copy(Items.CAPACITY) +local oldSpace=package.loaded["src.core.game3.scripting.space"] +local liveStore={flags={[500]=true},vars={[17000]=42}} +package.loaded["src.core.game3.scripting.space"]={store=liveStore} +local liveBody=Serializer.encode(liveStore) +for _, version in ipairs({"ruby","sapphire","emerald","firered","leafgreen"}) do + for _, row in ipairs(Events.list(version,2)) do + local original={version=version,flags={},vars={},modData={},bag=require("src.core.game3.bag").new(),storage={boxes={},items={}}} + local save,why=Events.receive(original,row.id,2) + T.check(save~=nil,version.." receives "..row.id..": "..tostring(why)) + if save then + T.same(original.flags,{},"event planning leaves original flags untouched") + if row.id~="eon" then + T.eq(Events.status(save,row),"Ticket pending","Wonder Card is staged before item delivery") + local collected,err=Events.collect(save,row.id,2) + T.check(collected~=nil,version.." actual delivery collects "..row.id..": "..tostring(err)) + if collected then + T.eq(Events.status(collected,row),"Collected","native receipt marks event collected") + T.check(not Events.receive(collected,row.id,2),"event cannot be received twice") + T.check(not Events.collect(collected,row.id,2),"event cannot be collected twice") + local C=require("src.core.game3.constants").of(version) + local flag=C:require("flags",row.id=="aurora" and "FLAG_ENABLE_SHIP_BIRTH_ISLAND" or "FLAG_ENABLE_SHIP_NAVEL_ROCK") + T.check(collected.flags[flag] or collected.flags[tostring(flag)],"delivery enables native island travel") + local codec=require("src.save_convert.Gen3Save").forVersion(version) + local image=assert(codec.encode({playerName="Owner",trainerId=42,secretId=97})) + local port=codec.toPortSave(assert(codec.decode(image)),version) + port.map=codec.mapFor(0,0) + port.flags,port.vars,port.bag=collected.flags,collected.vars,collected.bag + local exported=assert(codec.exportPort(port,{version=version,template=image, + toNational=function(n)return n end,itemId=tonumber,mapLayoutId=function()return 1 end})) + local reimported=codec.toPortSave(assert(codec.decode(exported)),version) + T.eq(Events.status(reimported,row),"Collected",version.." native save retains ticket receipt") + T.check(reimported.flags[flag] or reimported.flags[tostring(flag)],version.." native save retains travel flag") + end + else + T.eq(Events.status(save,row),"Collected","record mixing delivers Eon Ticket through native gift receiver") + T.check(not Events.receive(save,row.id,2),"record mixing Eon Ticket cannot duplicate") + local codec=require("src.save_convert.Gen3Save").forVersion(version) + local image=assert(codec.encode({playerName="Owner",trainerId=42,secretId=97})) + local port=codec.toPortSave(assert(codec.decode(image)),version) + port.map,port.flags,port.vars,port.bag=codec.mapFor(0,0),save.flags,save.vars,save.bag + local exported=assert(codec.exportPort(port,{version=version,template=image, + toNational=function(n)return n end,itemId=tonumber,mapLayoutId=function()return 1 end})) + local reimported=codec.toPortSave(assert(codec.decode(exported)),version) + T.eq(Events.status(reimported,row),"Collected",version.." native save retains record mixing Eon receipt") + local C=require("src.core.game3.constants").of(version) + local flag=C:require("flags",version=="emerald" and "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND" or "FLAG_SYS_HAS_EON_TICKET") + T.check(reimported.flags[flag] or reimported.flags[tostring(flag)],version.." native save retains Southern Island travel flag") + end + end + end +end +T.eq(Items._bagModel,beforeModel,"offline event operations restore active item profile") +T.eq(Items._pack,beforePack,"offline events restore live item pack pointer") +T.same(Items.CAPACITY,beforeCap,"offline events restore live bag capacities") +T.eq(Serializer.encode(liveStore),liveBody,"offline event delivery cannot change active game flags or variables") +package.loaded["src.core.game3.scripting.space"]=oldSpace +T.check(not Events.receive({version="emerald"},"oldSeaMap",2),"activation also rejects historical-language mismatch") +do + local original={version="firered",flags={},vars={},modData={},bag=require("src.core.game3.bag").new()} + local save=assert(Events.receive(original,"aurora",2)) + for i=1,30 do save.bag.pockets.KEY_ITEMS[i]={id=499+i,qty=1} end + local body=Serializer.encode(save) + T.check(not Events.collect(save,"aurora",2),"full Key Items pocket refuses ticket delivery") + T.eq(Serializer.encode(save),body,"full bag does not consume receipt or mutate card") +end +for _,version in ipairs({"emerald","firered","leafgreen"}) do + local C=require("src.core.game3.constants").of(version) + local flag=C:require("flags",version=="emerald" and "FLAG_CAUGHT_HO_OH" or "FLAG_FOUGHT_HO_OH") + local save={version=version,flags={[flag]=true},vars={},modData={},bag=require("src.core.game3.bag").new()} + local row=assert(Events.allowed("mystic",version,2)) + T.eq(Events.status(save,row),"Event already completed",version.." completed encounter is visible before receiving card") + T.check(not Events.receive(save,"mystic",2),"completed encounter cannot create undeliverable pending card") +end +CacheFs.readAt=oldRead;Catalog.reset() +T.finish("Box events") diff --git a/tests/engine/box_file_picker_test.lua b/tests/engine/box_file_picker_test.lua new file mode 100644 index 00000000..35f4c122 --- /dev/null +++ b/tests/engine/box_file_picker_test.lua @@ -0,0 +1,91 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") +local T = require("tests.harness") +local RomImporter = require("src.import.RomImporter") +local BoxPanel = require("src.import.BoxPanel") +local Files = {} +love.filesystem.getInfo = function(path) + return Files[path] and { type = "file", size = #Files[path] } or nil +end +love.filesystem.read = function(path) return Files[path] end +love.filesystem.write = function(path, data) Files[path] = data; return true end +love.filesystem.remove = function(path) Files[path] = nil; return true end +love.filesystem.getDirectoryItems = function() return {} end +local imports, gameImports, bytes, problem = 0, 0 +BoxPanel.importGCI = function(_, data, why) + bytes, problem = data, why + if data then imports = imports + 1; return true end + return false +end +local function importer() + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab, imp._findFetch = "box", nil + imp._importSave = function() gameImports = gameImports + 1 end + return imp +end +local native = string.rep("N", 0x76040) +local raw = native:sub(65) +Files["picked_save.sav"] = native +local imp = importer() +imp.android, imp.pickerPendingKind = true, "box" +imp:focus(true) +T.eq(imports, 1, "legacy mobile pick reaches Box instead of game save import") +T.eq(gameImports, 0, "Box pick does not overwrite a game slot") +T.eq(bytes, native, "bridge extension does not alter GCI bytes") +T.eq(Files["picked_save.sav"], nil, "successful staged copy is consumed") +T.eq(imp.pickerPendingKind, nil, "completed pick clears Box routing") + +Files["picked_save.sav"] = "bad" +imp.pickerPendingKind = "box" +imp:focus(true) +T.eq(imports, 1, "wrong file size never reaches Box parser") +T.check(problem:find("0x76040", 1, true), "bad-size notice describes accepted formats") +T.eq(gameImports, 0, "bad Box pick never falls through to normal saves") +Files["picked_save.sav"] = nil + +Files["pick_error.flag"] = "cancelled: picked_save.sav" +imp.pickerPendingKind, imp.pickPending = "box", true +imp:focus(true) +T.check(problem:find("file manager", 1, true), "legacy cancel is shown on Box") +T.eq(imp.pickPending, nil, "legacy cancel stops polling") + +local pickerKind +love.system.pickFile = function(kind) pickerKind = kind; return true end +love.system.getPickedFile = function() return "native.gci" end +imp = importer() +imp.nativePicker, imp.android = true, false +Files["native.gci"] = native +imp:chooseBoxImport() +T.eq(pickerKind, "sav", "existing bridge save picker accepts generic data files") +T.eq(imp.pickerPendingKind, "box", "modern picker records Box destination") +imp:update(0) +T.eq(imports, 2, "modern picker imports a GCI") +T.eq(Files["native.gci"], native, "ordinary selected input is preserved") + +love.system.getPickedFile = function() return nil end +love.system.getPickError = function() return "Picker cancelled" end +imp.pickerPendingKind = "box" +imp:update(0) +T.eq(problem, "Picker cancelled", "modern cancel reports to Box") + +local closed, readCount = false, 0 +local drop = { + getFilename = function() return "native.sav" end, + getSize = function() return #raw end, + open = function() return true end, + read = function(_, size) readCount = readCount + 1; T.eq(size, #raw, "raw read is bounded"); return raw end, + close = function() closed = true end, +} +imp:filedropped(drop) +T.eq(imports, 3, "raw native save dropped on Box reaches Box") +T.check(closed, "dropped handle closes after read") +imp.tab = "emerald" +imp:filedropped(drop) +T.eq(gameImports, 1, "game tab retains its ordinary save routing") +T.eq(readCount, 1, "Box reader does not read a game-directed drop") +drop.getFilename = function() return "too-large.gci" end +drop.getSize = function() return 1024 * 1024 * 1024 end +imp:filedropped(drop) +T.eq(readCount, 1, "oversized GCI is refused before allocation") +T.eq(imports, 3, "oversized GCI cannot mutate storage") +T.finish() diff --git a/tests/engine/box_gci_test.lua b/tests/engine/box_gci_test.lua new file mode 100644 index 00000000..43ad0332 --- /dev/null +++ b/tests/engine/box_gci_test.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local GCI = require("src.box.GCI") +local Store = require("src.box.Store") +local CacheFs = require("src.import.CacheFs") +local Serializer = require("src.core.SaveSerializer") +local oldRead = CacheFs.readAt +local tables = { + ["data/generated/gba/pokemon/names.lua"] = Serializer.encode({[25]="PIKACHU"}), + ["data/generated/gba/pokemon/move_names.lua"] = Serializer.encode({[84]="THUNDERSHOCK"}), + ["data/generated/gba/pokemon/national.lua"] = Serializer.encode({toNational={[25]=25},toSpecies={[25]=25}}), + ["data/generated/gba/items/pack.lua"] = Serializer.encode({items={}}), +} +CacheFs.readAt = function(path) return path:match("^ruby/") and tables[path:sub(6)] end +require("src.box.Catalog").reset() +local codec = require("src.save_convert.Gen3Save").forVersion("ruby") +local function word(n) return string.char(math.floor(n/16777216)%256,math.floor(n/65536)%256,math.floor(n/256)%256,n%256) end +local function put(s,off,value) return s:sub(1,off)..value..s:sub(off+#value+1) end +local logical = string.rep("\0",35*0x2000) +local originalMons = {} +for _, slot in ipairs({1,6,7,12,13,60,1500}) do + local raw = codec.encodeBoxMon(codec.fromPortMon({ species = 25, personality = slot*123, + nickname = "PIKA"..slot, otName = "Owner", otId = 42, otSecretId = 3, + exp = 1000, moves = { 84 }, pp = { 15 }, ivs = { hp = 7, atk = 8 }, evs = {}, + friendship = 70, pokeball = 4, metLevel = 5, metGame = 2, language = 2 },{},false)) + originalMons[slot] = raw + logical = put(logical,8+(slot-1)*84,raw..word(0x01020304):reverse()) +end +for b = 1,25 do + logical = put(logical,0x1EC38+(b-1)*9,codec.encodeString("BOX "..b,9)) + logical = put(logical,0x1ED19+b-1,string.char((b-1)%16)) +end +local chunks = { string.rep("B",0x2000) } +for bank = 0,1 do + for i = 0,22 do + local id = 22-i + local block = string.rep("\0",4)..word(id)..word(bank+1)..logical:sub(id*GCI.BODY+1,(id+1)*GCI.BODY).."KEEP" + block = put(block,0,word(GCI.checksum(block)));chunks[#chunks+1]=block + end +end +for id = 0,11 do + local body = string.rep(string.char(id+1),GCI.BODY) + local block = string.rep("\0",4)..word(id)..word(0)..body.."TAIL" + block = put(block,0,word(GCI.checksum(block)));chunks[#chunks+1]=block +end +local header = put("GPXE01"..string.rep("\0",58),56,string.char(0,59)) +local bytes = header..table.concat(chunks) +T.eq(#bytes,0x76040,"native GCI size includes 64-byte header and 59 physical blocks") +local decoded = assert(GCI.decode(bytes)) +T.eq(decoded.active.bank,1,"newest complete bank is selected") +T.eq(decoded.logical:sub(9,88),originalMons[1],"logical mon bytes survive shuffled physical blocks") +local state = assert(GCI.import(Store.new(),bytes)) +T.eq(Store.count(state),7,"import retains all occupied slots") +for _, slot in ipairs({1,6,7,12,13,60,1500}) do + local b,index = math.floor((slot-1)/60)+1,(slot-1)%60+1 + T.eq(state.boxes[b].mons[index].mon.personality,slot*123,"native 12-column slot "..slot.." retains position") + T.eq(state.boxes[b].mons[index].depositorId,0x01020304,"depositor ID uses little endian") +end +T.eq(state.boxes[25].name,"BOX 25","native Box 25 name is decoded") +T.eq(state.boxes[25].gciWallpaper,8,"native wallpaper ID is preserved") +local exported = assert(GCI.export(state)); local roundTrip = assert(GCI.decode(exported)) +T.eq(roundTrip.active.count,3,"export advances save count and writes other bank") +T.eq(roundTrip.header,header,"GCI header stays byte-exact") +T.eq(exported:sub(65,64+0x2000),bytes:sub(65,64+0x2000),"native banner stays byte-exact") +T.eq(exported:sub(64+47*0x2000+1),bytes:sub(64+47*0x2000+1),"photo and showcase blocks stay byte-exact") +for _, slot in ipairs({1,6,7,12,13,60,1500}) do + local at = 8+(slot-1)*84 + T.eq(roundTrip.logical:sub(at+1,at+80),originalMons[slot],"unchanged PK3 "..slot.." stays byte-exact") +end +state = assert(Store.mark(state,{{box=1,slot=7}},8)) +local edited = assert(GCI.decode(assert(GCI.export(state)))) +T.eq(assert(codec.decodeBoxMon(edited.logical:sub(8+6*84+1,8+6*84+80))).markings,8,"edited markings export as native PK3") +local brokenNewest = put(bytes,64+(1+23)*0x2000+100,"X") +local recovered = assert(GCI.decode(brokenNewest)) +T.eq(recovered.active.bank,0,"corrupt newest bank recovers complete older bank") +T.eq(recovered.recovered,true,"recovery is reported") +local brokenBoth = put(brokenNewest,64+0x2000+100,"X") +T.check(not GCI.decode(brokenBoth),"two corrupt banks are rejected") +T.check(not GCI.decode(bytes:sub(1,-2)),"truncated native file is rejected") +T.check(not GCI.decode("GXXE"..bytes:sub(5)),"another game's GCI is rejected") +T.check(not GCI.decode(put(bytes,56,string.char(0,58))),"incorrect header block count is rejected") +T.check(not GCI.import(Store.new(),"GPXJ"..bytes:sub(5)),"unsupported Japanese names are refused before mutation") +local wrapped=bytes +for bank=0,1 do + for i=0,22 do + local at=64+(1+bank*23+i)*GCI.BLOCK + local block=put(wrapped:sub(at+1,at+GCI.BLOCK),8,word(bank==0 and 4294967295 or 0)) + block=put(block,0,word(GCI.checksum(block))) + wrapped=put(wrapped,at,block) + end +end +T.eq(assert(GCI.decode(wrapped)).active.bank,1,"save count rollover selects zero after FFFFFFFF") +T.check(not GCI.import(state,bytes),"native import cannot overwrite occupied warehouse") +state.boxes[1].name="Name too long" +T.check(not GCI.export(state),"overlong native name fails without silent truncation") +state.boxes[1].name="BOX 1" +state.boxes[1].mons[1].generation,state.boxes[1].mons[1].version=2,"gold" +T.check(not GCI.export(state),"earlier-generation records cannot silently become PK3") +CacheFs.readAt=oldRead;require("src.box.Catalog").reset() +T.finish("Box GCI") diff --git a/tests/engine/box_gifts_test.lua b/tests/engine/box_gifts_test.lua new file mode 100644 index 00000000..3e9fccfe --- /dev/null +++ b/tests/engine/box_gifts_test.lua @@ -0,0 +1,148 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Store = require("src.box.Store") +local Gifts = require("src.box.Gifts") +local Service = require("src.box.Service") +local Catalog = require("src.box.Catalog") +local Serializer = require("src.core.SaveSerializer") +local CacheFs = require("src.import.CacheFs") +local GameVersion = require("src.core.GameVersion") +local oldRead, tables = CacheFs.readAt, {} +local root = "data/generated/gba/pokemon/" +local names, national, meta, moves, battle = {}, { toSpecies = {}, toNational = {} }, {}, {}, {} +for i, award in ipairs(Gifts.AWARDS) do + local species = ({ 358, 288, 315, 172 })[i] + names[species], national.toSpecies[award.national], national.toNational[species] = award.name, species, award.national + meta[species] = { eggCycles = 20, growthRate = 0, genderRatio = 127 } + for _, move in ipairs(award.moves) do moves[move], battle[move] = "MOVE " .. move, { pp = 20 } end +end +for _, version in ipairs({ "ruby", "sapphire", "firered", "leafgreen", "emerald" }) do + for path, value in pairs({ ["names.lua"] = names, ["national.lua"] = national, ["meta.lua"] = meta, + ["move_names.lua"] = moves, ["battle_moves.lua"] = { moves = battle } }) do + tables[GameVersion.cachePrefix(version)..root..path] = Serializer.encode(value) + end +end +CacheFs.readAt = function(path) return tables[path] end; Catalog.reset() +local source = { version = "firered", path = "saves/firered/slot1.lua", slotId = "slot1" } +local function filesystem(state, flags) + local save = { version = "firered", generation = 3, trainerId = 42, secretId = 97, name = "Owner", + meta = { playthroughId = "first-game" }, party = {}, modData = { cartImport = { boxFlags = flags or 0 } } } + local files = { [Store.PATH] = Serializer.encode(state), [source.path] = Serializer.encode(save) } + local mode, writes = {}, 0 + local fs = { read = function(path) return files[path] end, + getInfo = function(path) return files[path] and { type = "file" } end, + createDirectory = function() return true end, + remove = function(path) files[path] = nil; return true end, + write = function(path, body) + writes = writes + 1 + if writes == mode.fault then files[path] = body:sub(1, math.floor(#body / 2)); return nil, "interruption" end + files[path] = body; return true + end } + return fs, files, mode, save +end +local function stateWith(count, credited) + local state = Store.new() + for i = 1, count do + local mon = { species = 172, level = 5, personality = i, otId = 123, otSecretId = 45, moves = { 84 } } + local b, slot = math.floor((i - 1) / 60) + 1, (i - 1) % 60 + 1 + state.boxes[b].mons[slot] = { id = i, generation = 3, version = "firered", mon = mon, + display = Catalog.describe("firered", mon), depositorId = i <= (credited or count) and 42 + 97 * 65536 or 99 } + end + state.nextId = count + 1 + return state +end +local initialRng = require("src.core.game3.rng").getState() +for index, award in ipairs(Gifts.AWARDS) do + local mon = assert(Gifts.create("firered", index, 0)) + T.eq(mon.personality, 0x0000E97E, award.name .. " uses high then low PID draws") + T.same(mon.ivs, { hp = 17, atk = 19, def = 20, spe = 16, spa = 13, spd = 12 }, award.name .. " uses next two IV draws") + T.same(mon.moves, award.moves, award.name .. " has complete native gift moves") + T.eq(mon.isEgg, true, award.name .. " starts as an egg") + T.eq(mon.language, 1, award.name .. " has original egg language") + T.eq(mon.metLocation, 255, award.name .. " has original egg met location") +end +T.same(require("src.core.game3.rng").getState(), initialRng, "offline gifts do not disturb active game's RNG") +for _, vector in ipairs({ {99,1,false}, {100,1,true}, {499,3,false}, {500,3,true}, {1498,5,false}, {1499,5,true}, {1500,7,false} }) do + local fs, files = filesystem(stateWith(vector[1]), vector[2]) + local service = assert(Service.open(fs)) + T.eq(assert(service:giftProgress(source)).eligible, vector[3], "threshold " .. vector[1] .. " flags " .. vector[2]) + local before = files[source.path] + local ok = service:claimEgg(source, 0) + T.eq(not not ok, vector[3], "claim behavior matches threshold " .. vector[1]) + if ok then + T.eq(Store.count(service.state), vector[1] + 1, "award creates exactly one warehouse record") + T.eq(Gifts.flags(assert(Serializer.decode(files[source.path]))), vector[2] + 2, "award advances native receipt ordinal") + else T.eq(files[source.path], before, "ineligible claim preserves native save") end +end +do + local fs, files = filesystem(Store.new(), 0x80) + local service = assert(Service.open(fs)) + T.check(service:claimEgg(source, 0), "first connection can claim Swablu without depositing") + T.eq(Gifts.flags(assert(Serializer.decode(files[source.path]))), 0x81, "first egg sets usedBoxRS and preserves unrelated native bits") + T.eq(Store.count(service.state), 1, "first connection stores exactly one egg") + T.check(not service:claimEgg(source, 0), "reopening cannot repeat first egg") + local nextSave = assert(Serializer.decode(files[source.path])) + nextSave.meta.playthroughId = "new-game" + T.check(Gifts.identity(source, nextSave) ~= Gifts.identity(source, { meta = {playthroughId="first-game"}, trainerId=42,secretId=97 }), + "reused slot new playthrough has independent progress identity") +end +do + local fs = filesystem(stateWith(101, 99), 1) + T.eq(assert(assert(Service.open(fs)):giftProgress(source)).current, 99, "mixed-source entries do not credit another depositor") + local state = stateWith(100) + local _, _, _, save = filesystem(state, 1) + Gifts.recordProgress(state, source, save) + state.boxes[1].mons[1] = nil + T.eq(assert(Gifts.progress(state, source, save)).peak, 100, "withdrawing cannot erase reached progress") + state.boxes[1].mons[1] = Store.copy(state.boxes[1].mons[2]); state.boxes[1].mons[1].id = 101;state.nextId=102 + T.eq(assert(Gifts.progress(state, source, save)).peak, 100, "redeposit does not increment a lifetime deposit counter") +end +do + local fs, files = filesystem(stateWith(1500), 0) + local service = assert(Service.open(fs)); local before = files[source.path] + T.check(not service:claimEgg(source, 0), "full warehouse refuses even the first gift") + T.eq(files[source.path], before, "full warehouse consumes no native receipt") +end +for fault = 1, 6 do + local fs, files, mode = filesystem(Store.new(), 0) + local service = assert(Service.open(fs)); mode.fault = fault + service:claimEgg(source, 0); mode.fault = nil + service = assert(Service.open(fs)) + local count, flags = Store.count(service.state), Gifts.flags(assert(Serializer.decode(files[source.path]))) + T.check(count == 0 and flags == 0 or count == 1 and flags == 1, "interrupted award " .. fault .. " restores consistent egg and receipt") +end + +local Pokemon = package.loaded["src.core.game3.pokemon"] +package.loaded["src.core.game3.pokemon"] = { name = function(species) return names[species] end, + _names=names,movePp=function(move) return battle[move] and battle[move].pp or 0 end, + currentMapSec = function() return 88 end, applyStats = function() end, + playerSecretId = function(session) return session and tonumber(session.secretId) or 0 end } +local Breeding = require("src.core.game3.breeding") +for _, version in ipairs({"ruby","sapphire","emerald","firered","leafgreen"}) do + local codec = require("src.save_convert.Gen3Save").forVersion(version) + for index, award in ipairs(Gifts.AWARDS) do + local mon = assert(Gifts.create(version, index, 0)) + Breeding.hatchMon({ version = version, name = "Owner", trainerId = 42, secretId = 97, gender = 0 }, mon) + local decoded = assert(codec.decodeBoxMon(codec.encodeBoxMon(codec.fromPortMon(mon, {}, false)))) + T.eq(decoded.isEgg, false, award.name .. " hatches through engine and native egg bit clears") + T.eq(mon.level, 5, award.name .. " hatches at level 5") + T.eq(decoded.personality, 0xE97E, award.name .. " hatch preserves PID") + T.eq(decoded.otId, 42, award.name .. " hatch binds recipient trainer") + T.eq(decoded.moves[#award.moves], award.moves[#award.moves], award.name .. " hatch preserves special move") + end + for _, flags in ipairs({248,249,251,253,255}) do + local image=assert(codec.encode({playerName="Owner",trainerId=42,secretId=97,boxFlags=248})) + local port=codec.toPortSave(assert(codec.decode(image)),version) + port.map=codec.mapFor(0,0) + Gifts.setFlags(port,flags) + local exported=assert(codec.exportPort(port,{version=version,template=image,toNational=function(n)return n end, + itemId=tonumber,mapLayoutId=function()return 1 end})) + T.eq(assert(codec.decode(exported)).boxFlags,flags,version.." native .sav preserves reward receipt and unrelated upper bits") + local imported=codec.toPortSave(assert(codec.decode(exported)),version) + T.eq(Gifts.flags(imported),flags,version.." native receipt returns to Box on import") + end +end +package.loaded["src.core.game3.pokemon"] = Pokemon +CacheFs.readAt = oldRead; Catalog.reset() +T.finish("Box gifts") diff --git a/tests/engine/box_launcher_test.lua b/tests/engine/box_launcher_test.lua new file mode 100644 index 00000000..f9d6703a --- /dev/null +++ b/tests/engine/box_launcher_test.lua @@ -0,0 +1,148 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Kit = require("src.ui.kit.Kit") +local LauncherView = require("src.import.LauncherView") +local RomImporter = require("src.import.RomImporter") +local Store = require("src.box.Store") +local BoxPanel = require("src.import.BoxPanel") +local Transition = require("src.ui.kit.Transition") +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local found = false +for _, tab in ipairs(LauncherView.HEADER_TABS) do if tab.id == "box" then found = true end end +T.check(found, "Box is reachable from the launcher header") + +local function launcher(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end + local imp = RomImporter.new(function() end, { launcher = true }) + local storage = Store.new() + imp.tab = "box" + imp._boxState = { service = { state = storage }, sources = {}, sourceIndex = 1, + box = 1, pcBox = 1, query = "", sort = "slot", pcRows = {}, + selectedBox = {}, selectedPC = {}, pageBox = 1, pagePC = 1, sourcePage = 1 } + return imp +end + +for _, size in ipairs({ { 360, 780 }, { 1024, 768 }, { 1360, 860 } }) do + local imp = launcher(size[1], size[2]) + local seen, printed = {}, love.graphics.print + love.graphics.print = function(text, ...) seen[#seen + 1] = tostring(text); return printed(text, ...) end + local ok, why = pcall(LauncherView.draw, imp) + love.graphics.print = printed + T.check(ok, "Box draws at " .. size[1] .. "×" .. size[2] .. ": " .. tostring(why)) + T.check(table.concat(seen, "\n"):find("1500", 1, true) ~= nil, "capacity is visible") + T.check((imp._tabScrollMax.box or 0) > 0, "Box content remains reachable through launcher scrolling") +end + +local imp = launcher(1024, 768) +Transition.clear() +Kit.focus = "box-search" +imp:textinput("Pikachu") +LauncherView.draw(imp) +T.eq(imp._boxState.query, "Pikachu", "launcher text input reaches Box search") +imp:keypressed("backspace") +LauncherView.draw(imp) +T.eq(imp._boxState.query, "Pikach", "Box search handles backspace") +imp:_switchTab("mods") +T.eq(Kit.focus, nil, "leaving Box releases text focus") +T.eq(BoxPanel.textinput(imp, "x"), false, "Box does not consume another tab's input") + +do + local Catalog = require("src.box.Catalog") + local Cry = require("src.box.Cry") + local Animation = require("src.box.Animation") + local previousCry, cried = Cry.play, {} + local previousAnimation, animated = Animation.new, {} + Animation.new = function(entry) if entry then animated[#animated + 1] = entry.id end end + Cry.play = function(entry) cried[#cried + 1] = entry and entry.id end + local previousArt, previousButton = Catalog.art, LauncherView.btn + Catalog.art = function() return nil end + local demo = launcher(1360, 860) + Transition.clear() + LauncherView.draw(demo) + local previousTime, now = love.timer.getTime, love.timer.getTime() + 1 + love.timer.getTime = function() return now end + local s = demo._boxState + local function entry(id) + return { id = id, version = "emerald", generation = 3, + mon = { species = 25, level = id + 10 }, + display = { name = "Pikachu " .. id, species = "Pikachu", level = id + 10, + types = "ELECTRIC", moves = "THUNDERSHOCK", item = "", national = 25 } } + end + for i = 1, 3 do s.service.state.boxes[1].mons[i] = entry(i) end + s._filteredBox = nil + s.sources = { { version = "emerald", label = "Emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" } } + s.pcRows = { { where = "box", box = 1, index = 4, key = "box:1:4", entry = entry(4) } } + local controls = {} + LauncherView.btn = function(owner, x, y, w, h, id, label, opts) + controls[id] = { x = x, y = y, w = w, h = h, enabled = opts.enabled ~= false } + return previousButton(owner, x, y, w, h, id, label, opts) + end + local function draw(click) + now = now + 0.3 + Kit.scale = 1 + Kit.blockClicks = false + Kit.beginFrame(click and click.x + click.w / 2 or 0, click and click.y + click.h / 2 or 0, click ~= nil, 0) + controls = {} + BoxPanel.draw(demo, 10, 10, 1340, 700, { s = 1, btnH = 38 }) + Kit.endFrame() + local actions = demo._uiActions + demo._uiActions = {} + if actions then demo:runActions(actions) end + end + local function click(id) local rect = assert(controls[id], id); draw(rect); draw() end + draw() + T.eq(controls["box-withdraw"].enabled, false, "withdraw is disabled with no selection") + T.eq(controls["box-box-1:4"].enabled, false, "empty warehouse slot is disabled") + click("box-box-1:1") + click("box-box-1:2") + T.same(cried, { 1, 2 }, "each native grid popup plays its Pokemon cry") + T.same(animated, { 1, 2 }, "each grid popup starts its Pokemon animation") + draw(); draw() + T.eq(#cried, 2, "steady redraws do not repeat the cry") + BoxPanel.update(demo, 1 / 60) + T.eq(#animated, 2, "steady updates and redraws do not restart the animation") + T.check(s.selectedBox["1:1"] and s.selectedBox["1:2"], "multi-select retains both clicked slots") + T.eq(s.inspect.id, 2, "detail card follows the last clicked Pokemon") + T.eq(controls["box-withdraw"].enabled, true, "withdraw becomes enabled after selection") + click("box-summary") + T.eq(cried[#cried], 2, "opening full summary plays its Pokemon cry") + T.eq(animated[#animated], 2, "opening full summary restarts the Pokemon animation") + click("box-summary-next") + T.eq(cried[#cried], 3, "next summary plays the new Pokemon cry") + T.eq(animated[#animated], 3, "next summary starts its Pokemon animation") + T.check(s.selectedBox["1:1"] and s.selectedBox["1:2"] and not s.selectedBox["1:3"], "summary navigation does not change multiselect") + click("box-summary-prev") + T.eq(cried[#cried], 2, "previous summary plays its Pokemon cry") + T.eq(animated[#animated], 2, "previous summary starts its Pokemon animation") + click("box-summary-back") + click("box-multi") + click("box-box-1:3") + T.eq(s.selectedBox["1:1"], nil, "single selection clears previous multi-select refs") + T.check(s.selectedBox["1:3"] ~= nil, "single selection keeps the current Pokemon") + click("box-view-pc") + T.eq(controls["box-pc-box:1:1"].enabled, false, "empty PC slots cannot be selected") + T.eq(controls["box-pc-box:1:4"].enabled, true, "PC preserves native occupied slot positions") + click("box-pc-box:1:4") + T.eq(cried[#cried], 4, "game-save PC popup also plays its Pokemon cry") + T.eq(animated[#animated], 4, "game-save PC popup also starts its Pokemon animation") + click("box-pc-box:1:4") + click("box-select-visible") + T.check(s.selectedPC["box:1:4"] ~= nil, "select visible selects the occupied PC slot") + T.eq(s.selectedBox["1:1"], nil, "PC bulk selection does not also select warehouse Pokemon") + click("box-view-box") + s.railFirst = 22 + draw() + click("box-thumb-box-25") + T.eq(s.box, 25, "box preview rail reaches the last warehouse box") + click("box-active-box-next") + T.eq(s.box, 1, "active box navigation wraps back from box 25") + LauncherView.btn, Catalog.art = previousButton, previousArt + love.timer.getTime = previousTime + Cry.play = previousCry + Animation.new = previousAnimation +end +T.finish() diff --git a/tests/engine/box_migration_test.lua b/tests/engine/box_migration_test.lua new file mode 100644 index 00000000..2fa8cd56 --- /dev/null +++ b/tests/engine/box_migration_test.lua @@ -0,0 +1,148 @@ +package.path="./?.lua;./?/init.lua;"..package.path +love=love or require("tests.love_stub") +local T=require("tests.harness") +local Migration=require("src.box.Migration") +local Store=require("src.box.Store") +local Catalog=require("src.box.Catalog") +local Serializer=require("src.core.SaveSerializer") +local CacheFs=require("src.import.CacheFs") +local GameVersion=require("src.core.GameVersion") +local oldRead,tables=CacheFs.readAt,{} +local function put(v,p,value) tables[GameVersion.cachePrefix(v)..p]=Serializer.encode(value) end +for _,v in ipairs(GameVersion.ORDER) do + local gen=GameVersion.generation(v) + if gen==3 then + local root="data/generated/gba/pokemon/" + put(v,root.."names.lua",{[25]="PIKACHU",[201]="UNOWN",[277]="TREECKO"}) + put(v,root.."national.lua",{toNational={[25]=25,[201]=201,[277]=252},toSpecies={[25]=25,[201]=201,[252]=277}}) + put(v,root.."meta.lua",{[25]={genderRatio=127,growthRate=0},[201]={genderRatio=255,growthRate=0},[277]={genderRatio=31,growthRate=0}}) + put(v,root.."stats.lua",{[25]={hp=35,atk=55,def=40,spe=90,spa=50,spd=50},[201]={hp=48,atk=72,def=48,spe=48,spa=72,spd=48},[277]={hp=40,atk=45,def=35,spe=70,spa=65,spd=55}}) + put(v,root.."move_names.lua",{[84]="THUNDERSHOCK",[57]="SURF",[354]="PSYCHO BOOST"}) + put(v,root.."battle_moves.lua",{moves={[84]={pp=30},[57]={pp=15},[354]={pp=5}}}) + put(v,root.."abilities.lua",{[25]={9},[201]={26},[277]={65}}) + put(v,"data/generated/gba/items/pack.lua",{items={[68]={name="RARE CANDY"},[1]={name="MASTER BALL"}}}) + else + local pokemon={PIKACHU={id="PIKACHU",name="PIKACHU",dex=25,growthRate="MEDIUM_FAST",genderRatio=127,catchRate=190, + baseStats={hp=35,attack=55,defense=30,speed=90,special=50,specialAttack=50,specialDefense=40}}} + if gen==2 then pokemon.UNOWN={id="UNOWN",name="UNOWN",dex=201,growthRate="MEDIUM_FAST",genderRatio=255, + baseStats={hp=48,attack=72,defense=48,speed=48,specialAttack=72,specialDefense=48}} end + put(v,"data/generated/pokemon.lua",pokemon) + put(v,"data/generated/moves.lua",{THUNDERSHOCK={name="THUNDERSHOCK",pp=30},SURF={name="SURF",pp=15}}) + put(v,"data/generated/items.lua",{RARE_CANDY={name="RARE CANDY"},MASTER_BALL={name="MASTER BALL"}}) + end +end +CacheFs.readAt=function(path) return tables[path] end;Catalog.reset() +local function entry(v) + local gen=GameVersion.generation(v) + local mon={species=gen==3 and 25 or "PIKACHU",nickname="PIKA",ot="ALICE",otName="ALICE",otId=42,level=50, + exp=125500,experience=125500,happiness=150,friendship=150, + dvs={attack=15,defense=10,speed=10,special=10},statExp={hp=65535,attack=65535,defense=65535,speed=65535,special=65535}, + moves=gen==3 and {84} or {{id="THUNDERSHOCK",pp=15,ppUps=3,maxPp=48}}} + if gen==3 then + mon.dvs,mon.statExp=nil,nil + mon.personality,mon.otSecretId,mon.pp,mon.ppBonusesPacked=12345,3,{15},3 + mon.ivs={hp=31,atk=31,def=20,spe=20,spa=20,spd=20} + mon.evs={hp=84,atk=84,def=84,spe=84,spa=84,spd=84} + end + return {id=1,version=v,generation=gen,mon=mon,display=Catalog.describe(v,mon),tags="original"} +end +for _,from in ipairs(GameVersion.ORDER) do + for _,to in ipairs(GameVersion.ORDER) do + if GameVersion.generation(from)~=GameVersion.generation(to) then + local original=entry(from) + local before=Serializer.encode(original) + local out,why=Migration.convert(original,to) + T.check(out~=nil,from.." → "..to..": "..tostring(why)) + if out then + local d=Catalog.describe(to,out) + T.eq(d.level,50,"migration retains level") + T.eq(d.experience,125500,"migration retains progress within same growth curve") + T.eq(d.moveDetails[1].pp,15,"migration retains spent PP") + T.eq(d.moveDetails[1].ppUps,3,"migration retains PP Ups") + T.eq(out.otId,42,"migration retains visible trainer ID") + if GameVersion.generation(to)==3 then + local total=0;for _,n in pairs(out.evs) do total=total+n end + T.check(total<=510,"Gen3 effort fits total EV limit") + T.eq(out.nature,0,"new personality has disclosed Hardy nature") + local codec=require("src.save_convert.Gen3Save").forVersion(to) + local round=codec.toPortMon(assert(codec.decodeBoxMon(codec.encodeBoxMon(codec.fromPortMon(out,{},false)))),false) + T.eq(round.personality,out.personality,"converted PK3 personality survives native encryption") + T.eq(round.ppBonusesPacked,3,"converted PP Ups survive native encoding") + end + end + T.eq(Serializer.encode(original),before,"preview never alters original") + end + end +end +local e=entry("emerald") +e.mon.status="PAR" +T.eq(assert(Migration.convert(e,"gold")).status,"paralyze","Gen3 status converts to native Gen2 status") +local statusEntry=entry("gold");statusEntry.mon.status="paralyze" +T.eq(assert(Migration.convert(statusEntry,"emerald")).status,"PAR","Gen2 status converts to native Gen3 status") +e.mon.heldItem=68 +T.check(not Migration.convert(e,"red"),"Gen1 refuses every held item") +T.eq(assert(Migration.convert(e,"gold")).item,"RARE_CANDY","held item maps by exact name") +e.mon.heldItem=999 +T.check(not Migration.convert(e,"gold"),"unknown item refuses migration") +e.mon.heldItem=nil;e.mon.moves={354} +T.check(not Migration.convert(e,"gold"),"newer move refuses migration") +e.mon.moves={84};e.mon.species=277 +T.check(not Migration.convert(e,"gold"),"newer species refuses migration") +e=entry("gold");e.mon.isEgg=true +T.check(not Migration.convert(e,"red"),"Gen1 refuses eggs") +T.check(Migration.convert(e,"emerald")~=nil,"Gen2 eggs may migrate to Gen3") +e=entry("red");e.mon.ot="TOO LONG";e.mon.otName=e.mon.ot +T.check(not Migration.convert(e,"emerald"),"overlong trainer names refuse silent truncation") +e.mon.otName="🙂";e.mon.ot=e.mon.otName +T.check(not Migration.convert(e,"emerald"),"unrepresentable names refuse replacement glyphs") +e=entry("gold");e.mon.species="UNOWN" +local unown=assert(Migration.convert(e,"emerald")) +T.eq(require("src.core.game3.pokemon").unownLetter(unown.personality)+1,require("src.core.gen2.Unown").letterFromDVs(e.mon.dvs),"forward migration retains Unown letter") +local u={id=1,version="emerald",generation=3,mon=unown,display=Catalog.describe("emerald",unown)} +T.eq(require("src.core.gen2.Unown").letterFromDVs(assert(Migration.convert(u,"gold")).dvs),require("src.core.gen2.Unown").letterFromDVs(e.mon.dvs),"reverse migration retains Unown letter") +u.mon.personality=3+256*2+65536 +T.check(not Migration.convert(u,"gold"),"Unown ! is not silently replaced") +do + local state=Store.new();state.nextId=2;state.boxes[1].mons[1]=entry("red") + local refs={{box=1,slot=1}} + local original=Serializer.encode(state.boxes[1].mons[1]) + local preview=assert(Migration.preview(state,refs,"emerald")) + local converted=assert(Migration.apply(state,refs,"emerald",preview)) + T.eq(Store.count(converted),1,"migration creates no extra warehouse Pokémon") + T.eq(converted.boxes[1].mons[1].id,1,"migration preserves preset/stage entry ID") + T.eq(Serializer.encode(converted.boxes[1].mons[1].archives[1]),original,"archive retains full original exactly") + T.check(Store.validate(assert(Serializer.decode(Serializer.encode(converted))))~=nil,"archives persist through serialization") + local restored=assert(Migration.restore(converted,refs[1],1)) + local copy=Store.copy(restored.boxes[1].mons[1]);copy.archives=nil + T.eq(Serializer.encode(copy),original,"restore reproduces native original and local metadata") + T.eq(restored.boxes[1].mons[1].archives[1].generation,3,"restore archives replaced converted version") + T.check(not Migration.apply(converted,refs,"emerald",preview),"stale preview cannot apply twice") + preview.rows[1].converted.otId=999 + T.check(not Migration.apply(state,refs,"emerald",preview),"tampered preview is rejected") + T.check(not Migration.restore(state,refs[1],1),"missing archive fails safely") + converted.boxes[1].mons[1].archives[1].id=9 + T.check(not Store.validate(converted),"corrupt archive identity is rejected on load") +end +for fault=1,4 do + local state=Store.new();state.nextId=2;state.boxes[1].mons[1]=entry("red") + local files={[Store.PATH]=Serializer.encode(state)} + local writes,fail=0,true + local fs={read=function(p)return files[p]end,getInfo=function(p)return files[p] and {type="file"}end, + createDirectory=function()return true end,remove=function(p)files[p]=nil;return true end, + write=function(p,b) + writes=writes+1 + if fail and writes==fault then files[p]=b:sub(1,math.floor(#b/2));return nil,"interruption" end + files[p]=b;return true + end} + local Service=require("src.box.Service") + local service=assert(Service.open(fs)) + local refs={{box=1,slot=1}};local preview=assert(Migration.preview(service.state,refs,"emerald")) + service:migrate(refs,"emerald",preview);fail=false + local recovered=assert(Service.open(fs)).state + local mon=recovered.boxes[1].mons[1] + T.eq(Store.count(recovered),1,"interrupted migration retains one live record") + T.check(mon.generation==1 and not mon.archives or mon.generation==3 and mon.archives and mon.archives[1].generation==1, + "interrupted migration keeps conversion and original archive consistent") +end +CacheFs.readAt=oldRead;Catalog.reset() +T.finish("Box migration") diff --git a/tests/engine/box_organizer_test.lua b/tests/engine/box_organizer_test.lua new file mode 100644 index 00000000..57d2abc5 --- /dev/null +++ b/tests/engine/box_organizer_test.lua @@ -0,0 +1,303 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Store = require("src.box.Store") +local Organizer = require("src.box.Organizer") +local Service = require("src.box.Service") +local Records = require("src.box.Records") +local Transaction = require("src.box.Transaction") +local Serializer = require("src.core.SaveSerializer") +local Catalog = require("src.box.Catalog") +local CacheFs = require("src.import.CacheFs") +local GameVersion = require("src.core.GameVersion") +local originalRead = CacheFs.readAt +local data = {} +local defs = { BULBASAUR = { dex = 1, name = "BULBASAUR", types = { "GRASS" } }, + CHARMANDER = { dex = 4, name = "CHARMANDER", types = { "FIRE" } }, + PIKACHU = { dex = 25, name = "PIKACHU", types = { "ELECTRIC" } }, + MEWTWO = { dex = 150, name = "MEWTWO", types = { "PSYCHIC" } }, + MEW = { dex = 151, name = "MEW", types = { "PSYCHIC" } } } +for _, version in ipairs(GameVersion.ORDER) do + local prefix = GameVersion.cachePrefix(version) + if GameVersion.generation(version) == 3 then + local names, national = {}, { toNational = {}, toSpecies = {} } + for _, def in pairs(defs) do names[def.dex], national.toNational[def.dex], national.toSpecies[def.dex] = def.name, def.dex, def.dex end + data[prefix .. "data/generated/gba/pokemon/names.lua"] = Serializer.encode(names) + data[prefix .. "data/generated/gba/pokemon/national.lua"] = Serializer.encode(national) + else data[prefix .. "data/generated/pokemon.lua"] = Serializer.encode(defs) end +end +CacheFs.readAt = function(path) return data[path] end +Catalog.reset() +local function config(mode) + return { mode = mode or "sort", sort = "species", descending = false, fallback = "keep", + rules = { { firstBox = 1, lastBox = 1, match = "all", conditions = { { field = "shiny", value = true } } } } } +end +local function entry(id, species, level, shiny, egg) + local def = defs[species] + return { id = id, version = "emerald", generation = 3, + mon = { species = def.dex, personality = id * 1001, moves = { 1, 2 }, pp = { 12, 4 }, + opaque = { raw = "\000\255", duplicate = id } }, + display = { name = species, species = species, national = def.dex, level = level, + types = table.concat(def.types, " / "), shiny = shiny or false, egg = egg or false } } +end +local function state() + local s = Store.new() + s.nextId = 7 + s.boxes[1].mons[8] = entry(1, "MEWTWO", 80) + s.boxes[3].mons[6] = entry(2, "MEW", 20, true) + s.boxes[1].mons[11] = entry(3, "PIKACHU", 5, false, true) + s.boxes[2].mons[9] = entry(4, "MEW", 50) + s.boxes[1].mons[4] = entry(5, "BULBASAUR", 15) + s.boxes[2].mons[1] = entry(6, "CHARMANDER", 17) + s.presets = { { name = "Team", ids = { 1, 5 } } } + s.stages = { [1] = { name = "Demo", background = "Forest", pattern = "Plain", music = "Silent", + pieces = { { entryId = 1, x = .5, y = .5, scale = 1, rotation = 0, flip = false } } } } + return s +end +local function inventory(s) + local out = {} + for _, row in ipairs(Store.search(s, "", "slot")) do out[row.entry.id] = row.entry end + return out +end +local original, cfg = state(), config() +local sorted, report = assert(Organizer.applyWarehouse(original, cfg)) +T.eq(report.moved, 6, "all moved slots are reported") +T.eq(sorted.boxes[1].mons[1].id, 5, "lowest dex sorts first") +T.eq(sorted.boxes[1].mons[6].id, 4, "same species uses level to break equal nickname ties") +T.same(inventory(sorted), inventory(original), "whole native records and duplicate species survive") +T.same(sorted.presets, original.presets, "team references stay intact") +T.same(sorted.stages, original.stages, "showcase references stay intact") +T.eq(Organizer.applyWarehouse(sorted, cfg).revision, sorted.revision + 1, "pure planner creates an independent candidate") +local _, repeated = Organizer.applyWarehouse(sorted, cfg) +T.eq(repeated.moved, 0, "sort is idempotent") +cfg.box = 2 +local one = assert(Organizer.applyWarehouse(original, cfg)) +T.same(one.boxes[1], original.boxes[1], "single-box sorting leaves other boxes alone") +T.eq(one.boxes[2].mons[1].id, 6, "single-box sort keeps its own contents") +cfg.box, cfg.sort, cfg.descending = nil, "level", true +local descending = assert(Organizer.applyWarehouse(original, cfg)) +T.eq(descending.boxes[1].mons[1].id, 1, "descending level is honored") +do + local missing, order = state(), config() + missing.boxes[1].mons[4].display.national = 0 + order.descending = true + local result = assert(Organizer.applyWarehouse(missing, order)) + T.eq(result.boxes[1].mons[6].id, 5, "unknown dex stays last even in descending order") + missing.boxes[1].mons[4].display.types = "" + order.sort, order.descending = "type", false + result = assert(Organizer.applyWarehouse(missing, order)) + T.eq(result.boxes[1].mons[6].id, 5, "missing type is unknown instead of an empty-string first entry") +end +cfg = config("rules") +cfg.rules[2] = { firstBox = 2, lastBox = 2, match = "all", conditions = { { field = "species", value = "mew" } } } +cfg.fallback = "sort" +local routed, routing = assert(Organizer.applyWarehouse(original, cfg)) +T.eq(routed.boxes[1].mons[1].id, 2, "earlier shiny rule wins over species") +T.eq(routed.boxes[2].mons[1].id, 4, "species match is exact and case insensitive") +T.eq(Store.find(routed, 1).box, 3, "Mewtwo does not match Mew") +T.eq(routing.ruleCounts[1], 1, "priority counts do not double count") +T.same(inventory(routed), inventory(original), "rules preserve every complete entry") +local _, routedAgain = Organizer.applyWarehouse(routed, cfg) +T.eq(routedAgain.moved, 0, "rules are idempotent") +cfg = config("rules") +cfg.rules[1] = { firstBox = 5, lastBox = 5, match = "all", + conditions = { { field = "egg", value = true }, { field = "type", value = "electric" } } } +local eggs = assert(Organizer.applyWarehouse(original, cfg)) +T.eq(Store.find(eggs, 3).box, 5, "all conditions combine") +T.same(Store.find(eggs, 1), Store.find(original, 1), "unmatched warehouse entries keep exact slots") +cfg.rules[1].match = "any" +cfg.rules[1].conditions = { { field = "species", value = "MEW" }, { field = "type", value = "fire" } } +local any = assert(Organizer.applyWarehouse(original, cfg)) +T.eq(Store.find(any, 6).box, 5, "any condition matches fire") +T.eq(Store.find(any, 4).box, 5, "any condition matches species") +local crowded = Store.new() +crowded.nextId = 62 +for i = 1, 60 do crowded.boxes[1].mons[i] = entry(i, "BULBASAUR", i) end +crowded.boxes[2].mons[1] = entry(61, "BULBASAUR", 5) +cfg.rules[1].conditions = { { field = "species", value = "BULBASAUR" } } +cfg.rules[1].firstBox, cfg.rules[1].lastBox = 1, 1 +local before = Serializer.encode(crowded) +local failed, reason = Organizer.applyWarehouse(crowded, cfg) +T.eq(failed, nil, "full rule destination blocks") +T.check(reason:find("more room", 1, true), "overflow error tells how to fix it") +T.eq(Serializer.encode(crowded), before, "overflow changes nothing") +cfg.rules[1].lastBox = 2 +local spill = assert(Organizer.applyWarehouse(crowded, cfg)) +T.eq(Store.count(spill), 61, "box ranges spill without dropping entries") +T.eq(spill.boxes[2].mons[1].display.level, 60, "each box fills before next") +cfg.rules[1].conditions[1] = { field = "bogus", value = "x" } +T.eq(Organizer.validate(cfg), nil, "unknown conditions are blocked") +T.eq(Organizer.validate({ mode = "sort", sort = "bogus", descending = false }), nil, "unknown sort is blocked") +do + local s, rules = state(), config("rules") + s.boxes[1].mons[8].display.moves = "TACKLE, THUNDERBOLT" + s.boxes[1].mons[8].display.item = "LEFTOVERS" + rules.rules[1] = { firstBox = 5, lastBox = 5, match = "all", conditions = { + { field = "move", value = "thunderbolt" }, { field = "item", value = "leftovers" } } } + T.eq(Store.find(assert(Organizer.applyWarehouse(s, rules)), 1).box, 5, "move and held item combine") + rules.rules[1].conditions[1].value = "THUNDER" + T.eq(Store.find(assert(Organizer.applyWarehouse(s, rules)), 1).box, 1, "move names match exactly") + rules.rules[1].conditions = { { field = "shiny", value = false } } + T.eq(Store.find(assert(Organizer.applyWarehouse(s, rules)), 2).box, 3, "No boolean value excludes shiny") +end + +local function filesystem() + local files, mode, writes = {}, {}, 0 + return { + read = function(path) return files[path] end, + getInfo = function(path) return files[path] and { type = "file" } or nil end, + createDirectory = function() return true end, + remove = function(path) files[path] = nil; return true end, + write = function(path, body) + writes = writes + 1 + if mode.at == writes then + if mode.partial then files[path] = body:sub(1, math.floor(#body / 2)) end + return nil, "write fault" + end + files[path] = body; return true + end, + }, files, mode, function() return writes end +end +do + local full = Store.new() + full.nextId = Store.BOXES * Store.SLOTS + 1 + for b = 1, Store.BOXES do + for slot = 1, Store.SLOTS do + local id = (b - 1) * Store.SLOTS + slot + full.boxes[b].mons[slot] = entry(id, "BULBASAUR", (full.nextId - id) % 100) + end + end + local rules = config("rules") + rules.sort = "level" + rules.rules[1] = { firstBox = 1, lastBox = Store.BOXES, match = "all", + conditions = { { field = "species", value = "BULBASAUR" } } } + local arranged, fullReport = assert(Organizer.applyWarehouse(full, rules)) + T.eq(fullReport.total, 1500, "full warehouse preview includes every slot") + T.eq(Store.count(arranged), 1500, "full warehouse applies without dropping entries") + T.same(inventory(arranged), inventory(full), "full warehouse preserves every complete entry") + T.check(Store.validate(arranged), "full warehouse remains valid") + local _, settled = Organizer.applyWarehouse(arranged, rules) + T.eq(settled.moved, 0, "full warehouse rules settle") +end +local Trade = require("src.online.Trade") +local originalPending = Trade.pendingSentAt +local pending = false +Trade.pendingSentAt = function() return pending and { {} } or {} end +local function fixture(version) + local generation = GameVersion.generation(version) + local mons = {} + for i, species in ipairs({ "MEWTWO", "BULBASAUR", "PIKACHU", "MEW", "BULBASAUR" }) do + mons[i] = { species = generation == 3 and defs[species].dex or species, nickname = "Mon " .. i, + level = 12 + i, hp = 31, moves = { generation == 3 and 1 or "TACKLE" }, pp = { 17 }, + personality = i * 1001, otId = 42, otSecretId = 97, otName = "Demo", + dvs = { attack = 7, defense = 3, speed = 9, special = 4 }, opaque = { raw = "\000\255", id = i } } + end + local save = { version = version, generation = generation, party = { Store.copy(mons[1]) }, + money = 123, rawImport = "original-template", modData = { cartImage = "opaque-cart-image" }, + boxNames = { "First", "Second" }, currentBox = 2, unknown = { keep = true } } + if generation == 3 then + save.storage = { currentBox = 2, opaque = "keep", boxes = { + { name = "First", wallpaper = 3, mons = { [2] = mons[1], [6] = mons[2], [9] = mons[3] } }, + { name = "Second", wallpaper = 4, mons = { [5] = mons[4], [8] = mons[5] } } } } + else save.boxes = { { mons[1], mons[2], mons[3] }, { mons[4], mons[5] } } end + return save +end +local function nativeInventory(save, generation) + local out = {} + for _, row in ipairs(Records.candidates(save, generation)) do + if row.where == "box" then out[#out + 1] = Serializer.encode(row.mon) end + end + table.sort(out); return out +end +for _, version in ipairs(GameVersion.ORDER) do + local fs, files, _, writes = filesystem() + local source = { version = version, slotId = "slot1", path = "saves/" .. version .. "/slot1.lua" } + local save, generation = fixture(version), GameVersion.generation(version) + local body = Serializer.encode(save) + files[source.path] = body + local service = assert(Service.open(fs)) + local preview = assert(service:previewOrganize("pc", source, config())) + T.eq(writes(), 0, version .. " preview writes nothing") + T.check(service:applyOrganize(preview), version .. " applies to linked game") + local after = Serializer.decode(files[source.path]) + T.same(nativeInventory(after, generation), nativeInventory(save, generation), version .. " preserves every native record") + T.same(after.party, save.party, version .. " leaves party alone") + T.same(after.unknown, save.unknown, version .. " keeps unrelated fields") + T.same(after.modData, save.modData, version .. " keeps cartridge image") + T.eq(after.rawImport, save.rawImport, version .. " keeps raw template") + T.eq(after.currentBox, save.currentBox, version .. " keeps selected box") + T.eq(files[source.path .. ".box-bak"], body, version .. " stores exact pre-sort backup") + T.eq(Records.list(after, generation, 1)[1].opaque.id, 2, version .. " writes actual PC arrangement") + if generation == 3 then + T.eq(after.storage.boxes[1].wallpaper, 3, version .. " keeps wallpaper") + T.eq(after.storage.boxes[1].name, "First", version .. " keeps box name") + else T.same(after.boxNames, save.boxNames, version .. " keeps box names") end + T.check(next(service.state.syncMembers) ~= nil, version .. " links organized game to Box sync") + T.eq(Store.count(service.state), 0, version .. " does not copy game Pokémon into warehouse") + local done = assert(service:previewOrganize("pc", source, config())) + T.eq(done.report.moved, 0, version .. " native sort is idempotent") + local rules = config("rules") + local destination = generation == 1 and 12 or 14 + rules.rules[1] = { firstBox = destination, lastBox = destination, match = "all", + conditions = { { field = "species", value = "PIKACHU" } } } + local arranged, ruleReport = assert(Organizer.applyGame(save, version, rules)) + T.eq(Records.list(arranged, generation, destination)[1].opaque.id, 3, version .. " routes game Pokémon to last box") + T.same(nativeInventory(arranged, generation), nativeInventory(save, generation), version .. " game rules preserve complete records") + T.same(arranged.party, save.party, version .. " game rules leave party alone") + T.check(ruleReport.ruleCounts[1] == 1, version .. " game rules report exact match") + rules.rules[1].lastBox = destination + 1 + T.eq(Organizer.applyGame(save, version, rules), nil, version .. " unavailable destination blocks") + rules.rules[1].lastBox = destination + rules.rules[1].conditions = { { field = "tag", value = "private-label" } } + T.eq(Organizer.applyGame(save, version, rules), nil, version .. " rejects warehouse-only tags in Game PC") +end +do + local fs, files, _, writes = filesystem() + local service = assert(Service.open(fs)) + T.check(service:saveOrganizer("My rules", "box", config("rules")), "rules save") + local reopened = assert(Service.open(fs)) + T.same(reopened.state.organizerProfiles[1].config, config("rules"), "saved rules reload exactly") + local SyncBox = require("src.sync.SyncBox") + local snapshot = assert(SyncBox.new(fs):snapshot({}, {})) + local cloud = assert(SyncBox.validate(snapshot.payload)) + T.same(cloud.state.organizerProfiles, reopened.state.organizerProfiles, "saved rules survive cloud snapshot validation") + local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" } + files[source.path] = Serializer.encode(fixture("emerald")) + local preview = assert(service:previewOrganize("pc", source, config())) + files[source.path] = Serializer.encode(fixture("red")) + local n = writes() + T.eq(service:applyOrganize(preview), nil, "changed game refuses stale preview") + T.eq(writes(), n, "stale game writes nothing") + files[source.path] = preview.gameBefore + preview = assert(service:previewOrganize("pc", source, config())) + pending = true + T.eq(service:applyOrganize(preview), nil, "new pending trade blocks apply") + pending = false + files[Store.PATH] = Serializer.encode(state()) + n = writes() + T.eq(service:applyOrganize(preview), nil, "changed warehouse refuses stale preview") + T.eq(writes(), n, "stale warehouse writes nothing") + files[Store.PATH] = service.body + T.check(service:deleteOrganizer(1), "preset deletes") + T.eq(#service.state.organizerProfiles, 0, "preset removed") +end +for _, partial in ipairs({ false, true }) do + for at = 1, 6 do + local fs, files, fault = filesystem() + local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" } + local save = fixture("emerald") + files[source.path] = Serializer.encode(save) + local service = assert(Service.open(fs)) + local preview = assert(service:previewOrganize("pc", source, config())) + fault.at, fault.partial = at, partial + service:applyOrganize(preview) + fault.at = nil + local recovered = assert(Service.open(fs)) + T.same(nativeInventory(Serializer.decode(files[source.path]), 3), nativeInventory(save, 3), "interrupted sort preserves records") + T.eq(files[Transaction.PATH], nil, "interrupted sort journal clears") + T.check(Store.validate(recovered.state), "interrupted sort warehouse validates") + end +end +Trade.pendingSentAt, CacheFs.readAt = originalPending, originalRead +Catalog.reset() +T.finish("box_organizer") diff --git a/tests/engine/box_prompt_test.lua b/tests/engine/box_prompt_test.lua new file mode 100644 index 00000000..8b3438a8 --- /dev/null +++ b/tests/engine/box_prompt_test.lua @@ -0,0 +1,74 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") +local T = require("tests.harness") +local Kit = require("src.ui.kit.Kit") +local Prompt = require("src.import.BoxPrompt") +local Tools = require("src.import.BoxTools") +local View = require("src.import.LauncherView") +local Importer = require("src.import.RomImporter") +local Transition = require("src.ui.kit.Transition") +local Store = require("src.box.Store") +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local imp = Importer.new(function() end, { launcher = true }) +imp.tab = "box" +local saved +local s = { service = { state = Store.new(), preset = function(_, name, refs) + saved = { name = name, refs = refs }; return true +end }, selectedBox = { ["1:1"] = { box = 1, slot = 1 } }, toolsOpen = true, toolsPage = "Teams" } +local controls = {} +Kit.layout(1360, 860) +Tools.draw(imp, s, 0, 0, 1000, { s = 1, btnH = 38 }, { + button = function(_, _, _, _, _, id, _, action) controls[id] = action end, + selected = function() return { { box = 1, slot = 1 } } end, + perform = function(_, fn) return fn() end, +}) +controls["tools-save-team"]() +T.check(imp._boxPrompt ~= nil, "desktop team button opens a visible text modal") +T.eq(Kit.VirtualKeyboard.active, false, "desktop does not depend on handheld keyboard") +T.eq(View.modalKey(imp), "_boxPrompt", "prompt shields underlying launcher controls") +Transition.clear() +imp:textinput("Starter team") +imp:keypressed("return") +T.eq(saved.name, "Starter team", "physical keyboard saves the team name through its UI action") +T.same(saved.refs, { { box = 1, slot = 1 } }, "named team keeps the selected record references") +T.eq(imp._boxPrompt, nil, "confirmed prompt closes") +imp:keypressed("return") +T.eq(saved.name, "Starter team", "closed prompt cannot repeat its callback") + +local result, calls = nil, 0 +local function callback(value) result = value; calls = calls + 1 end +Prompt.open(imp, "Local tags", "", 3, callback) +imp:textinput("éABCD") +T.eq(imp._boxPrompt.text, "éAB", "prompt cap counts UTF-8 characters") +imp:keypressed("backspace") +T.eq(imp._boxPrompt.text, "éA", "backspace removes one complete character") +imp:keypressed("backspace"); imp:keypressed("backspace") +T.eq(imp._boxPrompt.text, "", "backspace removes accented character without leaving invalid bytes") +imp:textinput("tag") +imp:keypressed("escape") +T.eq(calls, 0, "Escape cancels without applying tags") +T.eq(imp._boxPrompt, nil, "Escape removes the modal") + +Prompt.open(imp, "Stage name", "Garden", 64, callback) +imp._boxState = { service = { state = Store.new() }, sources = {}, sourceIndex = 1, + box = 1, pcBox = 1, query = "", sort = "slot", pcRows = {}, selectedBox = {}, selectedPC = {} } +for _, size in ipairs({ { 390, 844 }, { 1360, 860 } }) do + love.graphics.getDimensions = function() return size[1], size[2] end + love.graphics.getPixelDimensions = love.graphics.getDimensions + T.check(pcall(View.draw, imp), "naming modal renders at " .. size[1] .. " pixel width") +end +Transition.clear() +imp:gamepadpressed(nil, "b") +T.eq(imp._boxPrompt, nil, "controller B cancels the prompt") +T.eq(calls, 0, "controller cancellation leaves the stage unchanged") +Prompt.open(imp, "Box name", "Names", 32, callback) +Prompt.close(imp, true) +T.eq(result, "Names", "modal Save uses the same confirmation callback") +T.eq(calls, 1, "Save invokes the callback once") +Prompt.open(imp, "Box name", "Discard", 32, callback) +imp:_switchTab("mods") +T.eq(imp._boxPrompt, nil, "leaving Box cancels a pending name edit") +T.eq(calls, 1, "leaving Box cannot commit an edit") +T.finish() diff --git a/tests/engine/box_showcase_test.lua b/tests/engine/box_showcase_test.lua new file mode 100644 index 00000000..d881f3e9 --- /dev/null +++ b/tests/engine/box_showcase_test.lua @@ -0,0 +1,161 @@ +package.path="./?.lua;./?/init.lua;"..package.path +love=love or require("tests.love_stub") +local T=require("tests.harness") +local Store=require("src.box.Store") +local Showcase=require("src.box.Showcase") +local Serializer=require("src.core.SaveSerializer") +local Service=require("src.box.Service") +local state=Store.new();state.nextId=3 +for i=1,2 do state.boxes[1].mons[i]={id=i,version="red",generation=1, + mon={species="PIKACHU",level=50,otId=42,ot="OWNER",nickname="PIKA",hp=10, + dvs={attack=i,defense=2,speed=3,special=4}},display={name="PIKA",species="PIKACHU",national=25}} end +local stage=assert(Showcase.add(Showcase.new(),{{box=1,slot=1},{box=1,slot=2}},state)) +stage.name,stage.pattern,stage.music="My stage","Stars","Gentle" +stage.pieces[1].rotation,stage.pieces[1].scale,stage.pieces[1].flip=math.pi/2,2,true +stage.pieces[#stage.pieces+1]={kind="Arch",x=.5,y=.8,scale=1,rotation=0,flip=false} +local saved=assert(Showcase.save(state,5,stage)) +T.eq(state.stages,nil,"stage editing leaves saved collection unchanged") +T.eq(saved.stages[5].name,"My stage","fifth stage slot saves its name") +local round=assert(Store.validate(assert(Serializer.decode(Serializer.encode(saved))))) +T.check(math.abs(round.stages[5].pieces[1].rotation-math.pi/2)<1e-12,"placement rotations survive persistence") +T.eq(round.stages[5].pieces[1].flip,true,"placement mirroring survives persistence") +T.eq(round.stages[5].music,"Gentle","selected music persists") +T.eq(#Showcase.resolve(round,stage),3,"furniture and Pokémon retain stage order") +local moved=assert(Store.moveGroup(round,{{box=1,slot=1}},25,60)) +T.eq(Showcase.resolve(moved,stage)[1].entry.id,1,"stage references survive warehouse rearrangement") +T.check(not Showcase.save(state,6,stage),"stage slot outside 1–5 refuses save") +local bad=Store.copy(stage);bad.pieces[1].scale=0 +T.check(not Showcase.save(state,1,bad),"invalid placement refuses save") +bad=Store.copy(stage);bad.pieces[1].x=0/0 +T.check(not Showcase.save(state,1,bad),"NaN coordinates refuse save") +T.check(not Showcase.export(state,1,{}),"unsaved stage cannot be exported") +local files={[Store.PATH]=Serializer.encode(saved),["saves/red/slot1.lua"]=Serializer.encode({version="red",generation=1, + party={Store.copy(state.boxes[1].mons[2].mon)},boxes={}})} +local fs={read=function(p)return files[p]end,getInfo=function(p)return files[p] and {type="file"}end, + createDirectory=function()return true end,write=function(p,b)files[p]=b;return true end,remove=function(p)files[p]=nil;return true end} +local source={version="red",slotId="slot1",path="saves/red/slot1.lua"} +local Catalog=require("src.box.Catalog") +local oldCompatible=Catalog.compatible;Catalog.compatible=function()return true end +local service=assert(Service.open(fs)) +T.check(service:withdraw(source,{{box=1,slot=1}},1),"withdrawal moves a staged record into a game") +T.eq(Showcase.resolve(service.state,stage)[1].missing,true,"withdrawn entry becomes a missing placement") +T.check(service:deposit(source,{{where="box",box=1,index=1}},2),"unique native identity can return to Box") +T.eq(Showcase.resolve(service.state,stage)[1].entry.id,1,"return restores the original stage reference") +T.eq(Store.count(service.state),2,"return creates no extra live record") +Catalog.compatible=oldCompatible +do + local themed=assert(Store.theme(saved,1,"Showcase","box/showcase/125.png","Gentle")) + local persisted=assert(Store.validate(assert(Serializer.decode(Serializer.encode(themed))))) + T.eq(Showcase.boxMusic(persisted,persisted.boxes[1]),"Gentle","applied theme keeps its music after reload") + persisted.stages[5].music="Night" + T.eq(Showcase.boxMusic(persisted,persisted.boxes[1]),"Gentle","later stage edits do not change the applied music") + local legacy=assert(Store.theme(saved,1,"Showcase","box/showcase/125.png")) + T.eq(Showcase.boxMusic(legacy,legacy.boxes[1]),"Gentle","existing stage exports resolve their music") + local cleared=assert(Store.theme(themed,1,"Forest")) + T.eq(cleared.boxes[1].showcaseMusic,nil,"changing theme clears associated music") + T.eq(Showcase.boxMusic(cleared,cleared.boxes[1]),"Silent","ordinary themes stop music") + T.check(not Store.theme(saved,1,"Showcase","box/showcase/125.png","invalid"),"unknown theme tracks are rejected") + local music=require("src.box.ShowcaseMusic") + local oldRequest,oldAudio=music.request,love.audio + local created,played,stopped,released=0,0,0,0 + local pending=2 + music.request=function() + if pending>0 then pending=pending-1;return nil,"pending" end + return {} + end + love.audio={newSource=function() + created=created+1 + return {setLooping=function(_,v) T.eq(v,true,"theme music loops") end, + setVolume=function() end,play=function() played=played+1 end,stop=function() stopped=stopped+1 end, + release=function() released=released+1 end} + end} + love.sound=love.sound or {};local oldNew=love.sound.newSoundData + love.sound.newSoundData=love.sound.newSoundData or function() end + local panel=require("src.import.BoxPanel") + local imp={_boxState={service={state=themed},box=1}} + panel.update(imp,.016);panel.update(imp,.016) + T.eq(created,0,"Box theme renders across frames before creating a source") + T.eq(Showcase.currentMusic(),"Gentle","pending render keeps the requested track") + panel.update(imp,.016);panel.update(imp,.016) + T.eq(created,1,"Box theme creates one source across repeated frames") + T.eq(played,1,"applied Box music starts automatically") + imp._boxState.toolsPage="Showcase";panel.update(imp,.016) + T.eq(stopped,1,"leaving board stops theme music") + T.eq(released,1,"old source is released") + Showcase.playMusic({music="Night"});panel.update(imp,.016) + T.eq(Showcase.currentMusic(),"Night","editor playback survives its update") + imp._boxState.toolsPage=nil;panel.update(imp,.016) + T.eq(Showcase.currentMusic(),"Gentle","returning to Boxes resumes its theme") + imp._boxState.box=2;panel.update(imp,.016) + T.eq(Showcase.currentMusic(),"Silent","switching to plain Box stops track") + Showcase.stopMusic();music.request,love.audio,love.sound.newSoundData=oldRequest,oldAudio,oldNew +end +do + local music=require("src.box.ShowcaseMusic") + local oldNew=love.sound and love.sound.newSoundData + love.sound=love.sound or {} + local samples=0 + love.sound.newSoundData=function(n) return {n=n,setSample=function() samples=samples+1 end} end + local calls,sound,why=0 + repeat calls=calls+1;sound,why=music.request("Dream",0) until sound or why~="pending" or calls>100000 + T.check(sound~=nil and calls>1,"song rendering yields across calls") + T.eq(music.request("Dream",0),sound,"rendered song is cached") + T.eq(samples,sound.n*2,"every sample is written once") + love.sound.newSoundData=oldNew +end +do + local files={["box/showcase/11.png"]="a",["box/showcase/12.png"]="b",["box/showcase/21.png"]="c",["box/showcase/note.txt"]="d"} + local fs={read=function(p)return files[p]end,getInfo=function(p)return files[p] and {type="file"}end,remove=function(p)files[p]=nil;return true end, + getDirectoryItems=function()local out={} for p in pairs(files) do out[#out+1]=p:match("[^/]+$") end return out end} + local kept=Store.new();kept.boxes[2].theme,kept.boxes[2].wallpaper="Showcase","box/showcase/12.png" + T.eq(Showcase.prune(kept,fs,"box/showcase/21.png"),1,"stale exported wallpaper is removed") + T.eq(files["box/showcase/11.png"],nil,"unreferenced export is gone") + T.check(files["box/showcase/12.png"] and files["box/showcase/21.png"] and files["box/showcase/note.txt"],"referenced, new and foreign files survive") + files["box/showcase/31.png"]="e";files[require("src.box.Transaction").PATH]="pending" + T.eq(Showcase.prune(kept,fs),0,"pending transfer blocks wallpaper cleanup") +end +do + local g=love.graphics + local oldRect=g.rectangle + local count=0 + g.rectangle=function() count=count+1 end + local empty=Showcase.new();empty.pattern="Checks" + Showcase.draw(state,empty,0,0,0,100);Showcase.draw(state,empty,0,0,100,-1) + T.eq(count,0,"zero-sized stage draws nothing") + g.rectangle=oldRect +end +do + local g=love.graphics + local oldColor,oldScissor,oldSetScissor=g.setColor,g.getScissor,g.setScissor + local colors,clips={},{} + g.setColor=function(...) colors[#colors+1]={...} end + g.getScissor=function() return 10,20,100,50 end + g.setScissor=function(...) clips[#clips+1]={...} end + for _,background in ipairs(Showcase.BACKGROUNDS) do + colors,clips={},{} + local empty=Showcase.new();empty.background=background + Showcase.draw(state,empty,0,0,568,216) + T.check(math.max(colors[1][1],colors[1][2],colors[1][3])>.1, + background.." uses the launcher's 255 RGB color contract") + T.eq(colors[2][1],1,"pattern white remains white through Theme.col") + T.same(clips[1],{10,20,100,50},"stage respects the existing launcher clip") + T.same(clips[#clips],{10,20,100,50},"stage restores the launcher clip") + end + g.setColor,g.getScissor,g.setScissor=oldColor,oldScissor,oldSetScissor +end +do + local g=love.graphics + local oldLine=g.line + local calls,patterns={},{} + g.line=function(...) calls[#calls+1]={...} end + for _,pattern in ipairs({"Crosses","Bricks","Grid"}) do + calls={};local empty=Showcase.new();empty.pattern=pattern + Showcase.draw(state,empty,0,0,400,200) + patterns[pattern]=Serializer.encode(calls) + end + T.check(patterns.Crosses~=patterns.Bricks,"cross motifs differ from staggered bricks") + T.check(patterns.Crosses~=patterns.Grid,"cross motifs differ from continuous grid") + T.check(patterns.Bricks~=patterns.Grid,"brick seams alternate instead of square grid") + g.line=oldLine +end +T.finish("Box showcase") diff --git a/tests/engine/box_storage_test.lua b/tests/engine/box_storage_test.lua new file mode 100644 index 00000000..f89c06e1 --- /dev/null +++ b/tests/engine/box_storage_test.lua @@ -0,0 +1,263 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Serializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local CacheFs = require("src.import.CacheFs") +local Store = require("src.box.Store") +local Service = require("src.box.Service") +local Transaction = require("src.box.Transaction") +local Records = require("src.box.Records") +local Catalog = require("src.box.Catalog") + +local tables = {} +local function put(version, path, value) + tables[GameVersion.cachePrefix(version) .. path] = Serializer.encode(value) +end +for _, version in ipairs(GameVersion.ORDER) do + if GameVersion.generation(version) == 3 then + put(version, "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU" }) + put(version, "data/generated/gba/pokemon/national.lua", { toNational = { [25] = 25 }, toSpecies = { [25] = 25 } }) + put(version, "data/generated/gba/pokemon/move_names.lua", { [84] = "THUNDERSHOCK" }) + put(version, "data/generated/gba/items/pack.lua", { items = { [139] = { name = "ORAN BERRY" } } }) + else + put(version, "data/generated/pokemon.lua", { PIKACHU = { dex = 25, name = "PIKACHU", types = { "ELECTRIC" } } }) + put(version, "data/generated/moves.lua", { THUNDERSHOCK = { name = "THUNDERSHOCK" } }) + put(version, "data/generated/items.lua", {}) + end +end +local readAt = CacheFs.readAt +CacheFs.readAt = function(path) return tables[path] end +Catalog.reset() + +local function filesystem() + local files, writes, mode = {}, 0, {} + local fs = { + read = function(path) return files[path] end, + getInfo = function(path) return files[path] and { type = "file" } or nil end, + createDirectory = function() return true end, + remove = function(path) + if mode.remove == path then return nil, "remove fault" end + files[path] = nil; return true + end, + write = function(path, body) + writes = writes + 1 + if writes == mode.at then + if mode.kind == "partial" then files[path] = body:sub(1, math.floor(#body / 2)) end + if mode.kind == "crash" then files[path] = body; error("simulated interruption") end + return nil, "write fault" + end + files[path] = body + return true + end, + } + return fs, files, mode +end + +local function fixture(version) + local generation = GameVersion.generation(version) + local mon = { species = generation == 3 and 25 or "PIKACHU", nickname = "Sparky", level = 12, + hp = 31, maxHp = 31, otId = 42, otName = "Player", personality = 987654, + otSecretId = 92, moves = { generation == 3 and { move = 84, pp = 17 } or "THUNDERSHOCK" }, + dvs = { attack = 7, defense = 3, speed = 9, special = 4 }, + opaque = { raw = "\000\255\127", unknown = { [7] = "keep" } } } + if generation == 3 then mon.heldItem = 139 end + local save = { version = version, generation = generation, party = { Store.copy(mon) }, + money = 19374, unknown = { keep = true }, rawImport = "original-template" } + if generation == 3 then + save.storage = { currentBox = 1, boxes = { { name = "First", wallpaper = 3, mons = { [4] = mon } } } } + else save.boxes = { { mon } }; save.currentBox = 1 end + return save, mon, { where = "box", box = 1, index = generation == 3 and 4 or 1 } +end + +for _, version in ipairs(GameVersion.ORDER) do + local fs, files = filesystem() + local save, mon, ref = fixture(version) + local source = { version = version, slotId = "slot1", path = "saves/" .. version .. "/slot1.lua" } + local original = Serializer.encode(save) + files[source.path] = original + local service = assert(Service.open(fs)) + T.eq(Store.count(service.state), 0, version .. " begins with empty Box") + T.check(service:deposit(source, { ref }, 1, original), version .. " deposits") + T.eq(Store.count(service.state), 1, version .. " stored exactly once") + T.same(Store.at(service.state, 1, 1).mon, mon, version .. " preserves the entire native record") + T.same(Serializer.decode(files[source.path]).unknown, save.unknown, version .. " retains unrelated save fields") + T.check(service:withdraw(source, { { box = 1, slot = 1 } }, 1), version .. " withdraws") + T.eq(Store.count(service.state), 0, version .. " leaves no duplicate in Box") + local after = Serializer.decode(files[source.path]) + local returned = Records.list(after, GameVersion.generation(version), 1) + returned = returned[1] + T.same(returned, mon, version .. " returned record is unchanged") + T.eq(after.money, save.money, version .. " keeps money") + T.eq(after.rawImport, save.rawImport, version .. " keeps cartridge template") + T.eq(files[Transaction.PATH], nil, version .. " journal is cleared") +end + +for _, kind in ipairs({ "fail", "partial", "crash" }) do + for at = 1, 6 do + local fs, files, mode = filesystem() + local save, mon, ref = fixture("ruby") + local source = { version = "ruby", slotId = "slot1", path = "saves/ruby/slot1.lua" } + files[source.path] = Serializer.encode(save) + local original = files[source.path] + local service = assert(Service.open(fs)) + mode.at, mode.kind = at, kind + pcall(service.deposit, service, source, { ref }, 1, original) + mode.at = nil + local reopened = Service.open(fs) + if kind == "crash" and at == 1 then + T.check(reopened ~= nil, "journal interruption recovers") + else T.check(reopened ~= nil, kind .. " at " .. at .. " can reopen") end + if reopened then + local count = Store.count(reopened.state) + local native = Serializer.decode(files[source.path]) + local inPC = Records.at(native, 3, ref) ~= nil + T.check((count == 1 and not inPC) or (count == 0 and inPC), kind .. " at " .. at .. " retains exactly one copy") + T.eq(files[Transaction.PATH], nil, kind .. " at " .. at .. " completes recovery") + if count == 1 then + T.eq(files[source.path .. ".bak"], files[source.path], kind .. " at " .. at .. " keeps game recovery consistent") + T.eq(files[Store.PATH .. ".bak"], files[Store.PATH], kind .. " at " .. at .. " keeps Box recovery consistent") + end + end + end +end + +do + local fs, files = filesystem() + local save, _, ref = fixture("gold") + local source = { version = "gold", slotId = "slot1", path = "saves/gold/slot1.lua" } + local body = Serializer.encode(save); files[source.path] = body + local service = assert(Service.open(fs)) + files[source.path] = Serializer.encode({ version = "gold", generation = 2, party = {}, boxes = {} }) + T.check(not service:deposit(source, { ref }, 1, body), "stale selection is refused") + T.eq(Store.count(service.state), 0, "stale selection cannot put a different mon in Box") + files[source.path] = body + T.check(not service:deposit(source, { { where = "party", index = 1 } }, 1, body), "last healthy party mon stays") + T.eq(files[source.path], body, "party refusal is nonmutating") + T.check(service:deposit(source, { ref }, 1, body), "valid box deposit still succeeds") + local red = { version = "red", slotId = "slot1", path = "saves/red/slot1.lua" } + local redSave = fixture("red"); files[red.path] = Serializer.encode(redSave) + local original = files[red.path] + T.check(not service:withdraw(red, { { box = 1, slot = 1 } }, 1), "generation conversion is refused") + T.eq(files[red.path], original, "generation refusal preserves destination") + T.eq(Store.count(service.state), 1, "generation refusal preserves stored record") + T.check(service:move(1, 1, 25, 60), "last storage slot is reachable") + T.check(Store.at(service.state, 25, 60) ~= nil, "move retains entry") + T.check(service:rename(25, "Rare Pokémon"), "Box can be renamed") + T.eq(#Store.search(service.state, "THUNDERSHOCK"), 1, "move names are searchable") + T.eq(#Store.search(service.state, "Rare Pokémon"), 1, "Box names are searchable") + T.eq(#Store.search(service.state, "type:electric level<50 move:thundershock -egg"), 1, + "type, level, move and egg filters combine") + T.eq(#Store.search(service.state, "level>=50"), 0, "numeric level filter excludes lower levels") + T.eq(#Store.search(service.state, "game:gold species:pikachu"), 1, "game and species filters work") + T.eq(#Store.search(service.state, "shiny"), 0, "shiny filter respects native metadata") +end + +do + local save = fixture("gold") + save.party[2] = { species = "PIKACHU", hp = 40, item = "FLOWER_MAIL" } + save.mail = { party = { [2] = { message = "hello" } }, box = { { message = "keep" } } } + local after = assert(Records.remove(save, 2, { { where = "party", index = 1 } })) + T.eq(after.mail.party[1].message, "hello", "remaining party mail shifts with its Pokémon") + T.same(after.mail.box, save.mail.box, "mailbox stays intact") + T.eq(save.mail.party[2].message, "hello", "source save is not modified during planning") +end + +do + local fs, files = filesystem() + files[Store.PATH] = "return { broken = true }" + T.check(not Service.open(fs), "corrupt Box storage is refused") + T.eq(files[Store.PATH], "return { broken = true }", "corrupt storage is not overwritten with an empty Box") +end + +do + local fs, files = filesystem() + local save, _, ref = fixture("emerald") + local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" } + local body = Serializer.encode(save); files[source.path] = body + local service = assert(Service.open(fs)) + local Trade = require("src.online.Trade") + local previous = Trade.pendingSentAt + Trade.pendingSentAt = function() return { { species = 25 } } end + T.check(not service:deposit(source, { ref }, 1, body), "pending outgoing trade blocks deposit") + T.eq(files[source.path], body, "pending trade refusal preserves the game save") + T.eq(Store.count(service.state), 0, "pending trade refusal preserves Box") + Trade.pendingSentAt = previous + T.check(service:deposit(source, { ref }, 1, body), "deposit succeeds after pending trade finishes") + local full = Serializer.decode(files[source.path]) + for i = 1, 30 do full.storage.boxes[1].mons[i] = Store.copy(save.party[1]) end + local fullBody = Serializer.encode(full); files[source.path] = fullBody + local boxBody = files[Store.PATH] + T.check(not service:withdraw(source, { { box = 1, slot = 1 } }, 1), "full destination PC box blocks withdrawal") + T.eq(files[source.path], fullBody, "full PC refusal preserves every destination record") + T.eq(files[Store.PATH], boxBody, "full PC refusal preserves the stored record") +end + +do + local fs, files, mode = filesystem() + local save, _, ref = fixture("ruby") + local source = { version = "ruby", slotId = "slot1", path = "saves/cart_ruby.v1-hack/slot1.lua" } + local body = Serializer.encode(save); files[source.path] = body + local service = assert(Service.open(fs)) + mode.at, mode.kind = 2, "fail" + T.check(not service:deposit(source, { ref }, 1, body), "target write fault leaves a recoverable journal") + T.check(files[Transaction.PATH] ~= nil, "journal remains durable before target recovery") + mode.at = nil + local changed = Store.copy(save); changed.money = 1 + local changedBody = Serializer.encode(changed); files[source.path] = changedBody + local boxBody = files[Store.PATH] + local recovered = Service.open(fs) + T.check(recovered and recovered.recoveryNotice, "externally changed save undoes the pending transfer") + T.eq(files[source.path], changedBody, "undone transfer leaves the changed game intact") + T.eq(files[Store.PATH], boxBody, "undone transfer restores the Box before image") + T.eq(Store.count(recovered.state), 0, "undone transfer does not duplicate the Pokémon") + T.eq(files[Transaction.PATH], nil, "undone transfer no longer blocks boot") +end + +do + local fs, files = filesystem() + local save, _, ref = fixture("ruby") + local source = { version = "ruby", slotId = "slot1", path = "saves/cart_ruby.v1-hack/slot1.lua" } + local body = Serializer.encode(save); files[source.path] = body + local service = assert(Service.open(fs)) + T.check(service:deposit(source, { ref }, 1, body), "cart ID with dots and hyphens deposits") + local boxBody, gameBody = files[Store.PATH], files[source.path] + local nextState = Serializer.decode(boxBody); nextState.boxes[1].name = "next"; nextState.revision = nextState.revision + 1 + local nextSave = Serializer.decode(gameBody); nextSave.money = 5 + local journal = Serializer.encode({ format = 1, jobs = { + { path = Store.PATH, before = boxBody, after = Serializer.encode(nextState) }, + { path = source.path, before = gameBody, after = Serializer.encode(nextSave) } } }) + files[Store.PATH] = Serializer.encode(nextState) + files[Store.PATH .. ".bak"] = files[Store.PATH] + files[source.path] = Serializer.encode({ version = "ruby", generation = 3, party = {}, money = 1 }) + files[Transaction.PATH] = journal + assert(Service.open(fs)) + T.eq(files[Store.PATH], boxBody, "conflict rolls an already written Box back to its before image") + T.eq(files[Store.PATH .. ".bak"], boxBody, "conflict rolls the Box recovery copy back too") + files[Transaction.PATH] = journal:sub(1, math.floor(#journal / 2)) + local reopened = Service.open(fs) + T.check(reopened and reopened.recoveryNotice, "truncated journal is set aside instead of blocking boot") + T.eq(files[Transaction.PATH], nil, "truncated journal is cleared") + T.eq(files[Store.PATH], boxBody, "truncated journal leaves targets untouched") +end + +do + local fs, files = filesystem() + local save, _, ref = fixture("emerald") + local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" } + local body = Serializer.encode(save); files[source.path] = body + local service = assert(Service.open(fs)) + T.check(service:deposit(source, { ref }, 1, body), "deposit before storage loss") + local boxBody = files[Store.PATH] + files[Store.PATH] = nil + local reopened = assert(Service.open(fs)) + T.eq(Store.count(reopened.state), 1, "missing storage recovers from its backup") + T.eq(files[Store.PATH], boxBody, "backup is promoted back to storage") + files[Store.PATH], files[Store.PATH .. ".bak"] = nil, "return {" + T.check(not Service.open(fs), "missing storage with an unreadable backup is refused") + T.eq(files[Store.PATH .. ".bak"], "return {", "unreadable backup is not overwritten") +end + +CacheFs.readAt = readAt +Catalog.reset() +T.finish() diff --git a/tests/engine/box_studio_interaction_test.lua b/tests/engine/box_studio_interaction_test.lua new file mode 100644 index 00000000..28a46372 --- /dev/null +++ b/tests/engine/box_studio_interaction_test.lua @@ -0,0 +1,59 @@ +package.path="./?.lua;./?/init.lua;"..package.path +love=love or require("tests.love_stub") +local T=require("tests.harness") +local Store=require("src.box.Store") +local Search=require("src.box.Search") +local Showcase=require("src.box.Showcase") +local Editor=require("src.import.ShowcaseEditor") +local Organizer=require("src.box.Organizer") +local Kit=require("src.ui.kit.Kit") +local s={stageDraft=Showcase.new(),stagePiece=1,toolsPage="Showcase",stageRect={x=10,y=20,w=400,h=200}} +s.stageDraft.pieces={{kind="Arch",x=.5,y=.5,scale=1,rotation=0}, + {kind="Tree",x=.5,y=.5,scale=1,rotation=0}} +T.eq(Showcase.hit(s.stageDraft,10,20,400,200,210,120),2,"frontmost visible layer is picked") +T.eq(Showcase.reorder(s.stageDraft,2,1),1,"send behind updates array layer") +T.eq(Showcase.hit(s.stageDraft,10,20,400,200,210,120),2,"new frontmost layer is picked") +T.eq(s.stageDraft.pieces[2].kind,"Arch","arch is now in front") +local imp={tab="box",_boxState=s} +Kit.blockClicks=false +T.check(Editor.pointerPressed(imp,"finger",210,120),"touch starts a direct scene drag") +T.check(Editor.pointerMoved(imp,"finger",250,140),"touch move stays in scene") +T.eq(s.stageDraft.pieces[2].x,.6,"scene drag uses normalized X") +T.eq(s.stageDraft.pieces[2].y,.6,"scene drag uses normalized Y") +T.check(Editor.pointerReleased(imp,"finger"),"touch release ends drag") +T.eq(s.stageGesture,nil,"gesture cleared") +T.eq(#s.stageUndo,1,"one undo snapshot for entire drag") +Editor.begin(s,"resize",210,120,s.stageRect) +Editor.move(s,350,80,s.stageRect) +T.check(s.stageDraft.pieces[2].scale>1,"hold resize uses pointer movement") +Editor.move(s,9999,-9999,s.stageRect) +T.eq(s.stageDraft.pieces[2].scale,4,"resize respects maximum") +Editor.finish(s) +Editor.begin(s,"rotate",290,140,s.stageRect) +Editor.move(s,250,180,s.stageRect) +T.check(math.abs(s.stageDraft.pieces[2].rotation)>0,"hold rotation uses angle around piece") +Editor.finish(s) +s.stageDraft.pieces[2].flipY=true +T.check(Showcase.validate(s.stageDraft,1),"vertical flip survives stage validation") +local state=Store.new() +local saved=assert(Showcase.save(state,1,s.stageDraft)) +T.eq(saved.stages[1].pieces[2].flipY,true,"vertical flip persists") +T.eq(saved.stages[1].pieces[2].kind,"Arch","layer order persists") +for _,query in ipairs({"bulba","BULBA","BuLbA","Pokémon","POKÉMON","pokemon"}) do + local entry={version="red",display={name="Bulbasaur Pokémon",species="BULBASAUR"},mon={}} + T.check(Search.compile(query)(entry,"BOX 1"),"lookup folds case/accents: "..query) +end +local entry={id=1,version="red",generation=1,mon={},display={name="Bulbasaur",species="BULBASAUR",types="Grass",national=1,level=10}} +local layout={count=14,capacity=30,names={},rows={{box=1,slot=1,entry=entry}}} +for b=1,14 do layout.names[b]="BOX "..b end +local config={mode="rules",sort="species",descending=false,fallback="sort", + rules={{firstBox=1,lastBox=14,match="all",conditions={{field="species",value="buLBAsaur"}}}}} +local analysis=assert(Organizer.analyze(layout,config)) +T.eq(analysis.ruleCounts[1],1,"live count shares actual case-insensitive rule matcher") +T.eq(analysis.freeBoxes,0,"all reserved boxes are reported") +T.eq(analysis.remaining,0,"remaining count excludes first matching rule") +config.rules[1].conditions[1].value="PIKACHU" +analysis=assert(Organizer.analyze(layout,config)) +T.eq(analysis.remaining,1,"unmatched group is visible before preview") +T.check(Organizer.plan(layout,config)==nil,"full reserved range prevents fallback placement") +T.finish("box_studio_interaction") diff --git a/tests/engine/box_ui_test.lua b/tests/engine/box_ui_test.lua new file mode 100644 index 00000000..74700d0f --- /dev/null +++ b/tests/engine/box_ui_test.lua @@ -0,0 +1,168 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Kit = require("src.ui.kit.Kit") +local UI = require("src.import.BoxUI") +local View = require("src.import.LauncherView") +local Importer = require("src.import.RomImporter") +local Store = require("src.box.Store") +local Transition = require("src.ui.kit.Transition") +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +local clock = 100 +love.timer.getTime = function() return clock end +local controls, texts, original = {}, {}, View.btn +View.btn = function(imp, x, y, w, h, id, label, opts) + controls[id] = { x = x, y = y, w = w, h = h, label = label, opts = opts } + return original(imp, x, y, w, h, id, label, opts) +end +local printText = love.graphics.print +love.graphics.print = function(value, ...) + texts[#texts + 1] = tostring(value); return printText(value, ...) +end +local function launcher(width, height) + love.graphics.getDimensions = function() return width, height end + love.graphics.getPixelDimensions = function() return width, height end + local imp = Importer.new(function() end, { launcher = true }) + local service = { state = Store.new() } + service.read = function(_, source) + service.lastRead = source.path + return { version = source.version, party = {}, boxes = {} }, nil, source.path + end + imp.tab = "box" + imp._boxState = { service = service, sources = { + { version = "red", label = "Red · First", path = "first", slotId = "slot1" }, + { version = "emerald", label = "Emerald · Second", path = "second", slotId = "slot2" } }, + sourceIndex = 1, box = 1, pcBox = 1, query = "", sort = "slot", pcRows = {}, + selectedBox = {}, selectedPC = {}, pageBox = 1, pagePC = 1, view = "box", multi = true } + Transition.reset(); Kit.blur() + return imp +end +local function draw(imp, click) + clock = clock + .3; controls, texts = {}, {} + imp._clickPt = click; View.draw(imp) + local queue = imp._uiActions; imp._uiActions = {} + imp:runActions(queue or {}) +end +local function click(imp, id) + local r = assert(controls[id], id) + draw(imp, { x = r.x + r.w / 2, y = r.y + r.h / 2 }); draw(imp) +end +local function settle(imp) draw(imp); draw(imp) end +local function keyboardChoice(imp, key) + imp:keypressed(key); settle(imp) +end + +for _, size in ipairs({ { 360, 780 }, { 390, 844 }, { 1024, 768 }, { 1360, 860 }, { 640, 360 } }) do + local imp = launcher(size[1], size[2]); settle(imp) + T.check(controls["box-choose-save"].opts.icon and controls["box-tools-menu"].opts.icon, + "game and tools pickers have icons at " .. size[1]) + T.check(not table.concat(texts):find("Game save", 1, true), "ambiguous Game save label is gone") + click(imp, "box-choose-save") + T.eq(View.modalKey(imp), "_boxPopup", "game picker owns the modal input layer") + T.eq(imp._boxPopup.index, 1, "picker opens at the current playthrough") + click(imp, "box-popup-option-2") + T.eq(imp._boxState.sourceIndex, 2, "choosing a game changes the linked playthrough") + T.eq(imp._boxState.service.lastRead, "second", "picker reloads the chosen game's PC") + T.eq(imp._boxState.view, "pc", "choosing a game opens its Game PC") + T.eq(imp._boxPopup, nil, "choice closes the popup") + click(imp, "box-tools-menu") + T.eq(#imp._boxPopup.options, 10, "tools retain every page except the separate Filters control") + keyboardChoice(imp, "end") + T.check(imp._boxPopup.scroll > 0, "keyboard reveals tools below the visible rows") + keyboardChoice(imp, "return") + T.eq(imp._boxState.toolsPage, "Migration", "last tool is reachable without a button wall") + click(imp, "box-help-Migration") + T.eq(imp._boxPopup.message, UI.HELP.Migration, "context help explains the selected tool") + T.check(#imp._boxPopup.message < 150, "context explanation stays short") + local bounds = controls["box-popup-close"] + T.check(bounds.x >= 0 and bounds.y >= 0 and bounds.x + bounds.w <= size[1] + and bounds.y + bounds.h <= size[2], "popup close remains on screen") + keyboardChoice(imp, "escape") +end + +local imp = launcher(1024, 768); settle(imp) +click(imp, "box-tools-menu") +Kit.focus = "box-chooser-filter" +imp:textinput("Dashboard"); settle(imp) +T.eq(imp._boxPopup.query, "Dashboard", "launcher typing reaches the picker search") +T.eq(#imp._boxPopup.rows, 1, "picker search narrows actual options") +keyboardChoice(imp, "return") +T.eq(imp._boxState.toolsPage, "Dashboard", "Enter applies the filtered choice") +imp._boxState.toolsPage = nil; settle(imp) +click(imp, "box-options") +for _, option in ipairs(imp._boxPopup.options) do + if option.label == "Box theme" then option.action() end +end +settle(imp) +T.eq(imp._boxPopup.title, "Box theme", "Options opens the Box theme picker") +keyboardChoice(imp, "end") +local old = imp._boxState.service.state.boxes[1].theme +keyboardChoice(imp, "return") +T.check(imp._boxPopup ~= nil, "disabled Showcase wallpaper cannot be chosen") +T.eq(imp._boxState.service.state.boxes[1].theme, old, "disabled option leaves the box unchanged") +keyboardChoice(imp, "escape") + +imp._boxState.toolsPage, imp._boxState.view = nil, "box"; settle(imp) +click(imp, "box-options") +T.eq(imp._boxPopup.index, 1, "action menu starts at its first enabled action") +for id, r in pairs(controls) do + if id:match("^box%-popup%-option") then T.check(not r.opts.active, "action rows have no false selected checks") end +end +local behind = controls["box-choose-save"] +draw(imp, { x = behind.x + 10, y = behind.y + behind.h / 2 }); settle(imp) +T.eq(imp._boxPopup, nil, "outside tap dismisses without opening the game picker beneath it") +T.eq(imp._boxState.view, "box", "a popup shields the board from taps") +if imp._boxPopup then keyboardChoice(imp, "escape") end +click(imp, "box-options") +click(imp, "box-popup-option-1") +T.check(imp._boxPrompt ~= nil and not imp._boxPopup, "rename opens the existing name prompt") +keyboardChoice(imp, "escape") + +local changes = 0 +UI.open(imp, "Pick", { { id = 1, label = "One" } }, nil, function() changes = changes + 1 end) +settle(imp) +local staleAction = controls["box-popup-option-1"].opts.action +staleAction(); staleAction() +T.eq(changes, 1, "a retired popup action cannot apply twice") +settle(imp) +UI.open(imp, "Box", UI.values((function() local out = {}; for i = 1, 25 do out[i] = i end; return out end)()), 1, + function(value) changes = value end) +settle(imp); imp._padCursorActive = false; imp.isNX = false +for _ = 1, 10 do imp:gamepadpressed(nil, "dpdown"); settle(imp) end +T.eq(imp._boxPopup.index, 11, "controller navigation crosses the visible row boundary") +T.check(imp._boxPopup.scroll > 0, "controller reveals the selected option") +imp:gamepadpressed(nil, "a"); settle(imp) +T.eq(changes, 11, "controller A applies the revealed option") +UI.open(imp, "Box", {}, nil); settle(imp) +imp:gamepadpressed(nil, "b"); settle(imp) +T.eq(imp._boxPopup, nil, "controller B closes help") +UI.open(imp, "Box", {}, nil); settle(imp) +draw(imp, { x = 1, y = 1 }); settle(imp) +T.eq(imp._boxPopup, nil, "outside tap closes without activating the launcher") +UI.open(imp, "Box", {}, nil); Kit.focus = "box-chooser-filter" +imp:_switchTab("mods") +T.eq(imp._boxPopup, nil, "leaving Box dismisses its popup") +T.eq(Kit.focus, nil, "leaving Box releases picker text focus") + +Transition.reset(); Transition.armed = true; Transition.reduceMotion = false +local state = { view = "box" } +UI.change(imp, state, "view", "pc", 1) +Transition.update(clock + .06) +local positions, blocked = {}, {} +UI.pages(imp, state, 10, 20, 300, 400, {}, function(_, s, x) + positions[s.view] = x; blocked[#blocked + 1] = Kit.blockClicks; return 400 +end) +T.check(positions.box < 10 and positions.pc > 10, "both pages slide in the requested direction") +T.check(blocked[1] and blocked[2], "neither moving page accepts input") +Transition.update(clock + 1) +local draws = 0 +UI.pages(imp, state, 10, 20, 300, 400, {}, function() draws = draws + 1; return 400 end) +T.eq(draws, 1, "finished slide draws only the current page") +T.eq(state.motionFrom, nil, "finished slide releases the old view") +Transition.reduceMotion = true +UI.change(imp, state, "view", "box", -1) +T.check(not Transition.active("box"), "reduced motion changes Box instantly") +Transition.reduceMotion = false; Transition.reset() +View.btn, love.graphics.print = original, printText +T.finish() diff --git a/tests/engine/game3_frontier_obedience_2638.lua b/tests/engine/game3_frontier_obedience_2638.lua index 6a3fe7c5..b3cb19dd 100644 --- a/tests/engine/game3_frontier_obedience_2638.lua +++ b/tests/engine/game3_frontier_obedience_2638.lua @@ -24,7 +24,20 @@ end local function run(o) GameVersion.set(o.version or "emerald") local user, foe = mon(o.species or 1, o.own and 1 or 2, o.fateful), mon(1, 3) - local session = { version = o.version or "emerald", name = "PLAYER", trainerId = 1, party = { user } } + user.otSecretId, user.level = o.monSid or 0, o.level or 30 + user.otName = o.monName or user.otName + local session = { version = o.version or "emerald", name = "PLAYER", trainerId = 1, + secretId = o.playerSid or 0, party = { user } } + if o.legacy then user.otSecretId, session.secretId = nil, nil end + if o.sync then + user.personality = 12345 + local Sync = require("src.core.TrainerIdSync") + local owners = {} + Sync.addOwner(owners, { id = 1, sid = 7, name = "MAY" }, 3) + T.check(Sync.rewriteSave(session, 3, { id = 1, sid = 42, name = "PLAYER" }, owners), + o.label .. " syncs original OT identity") + end + Space.store = Flags.newStore() Battle.start({ headless = false, autoFight = false, session = session, playerParty = session.party, foe = { species = 1, level = 30, mon = foe }, wild = o.wild == true, frontier = o.frontier, rng = function(lo, hi) if lo == 1 and hi == 100 then return 1 end return hi end }) @@ -62,4 +75,12 @@ run({ own = true, obey = true, label = "Emerald own OT" }) run({ link = true, obey = true, label = "Emerald link" }) run({ badge = 2, obey = true, label = "Emerald badge two level 30" }) run({ badge = 8, obey = true, label = "Emerald badge eight" }) +run({ own = true, level = 100, playerSid = 42, monSid = 42, obey = true, + label = "Level 100 full own identity with no badges" }) +run({ own = true, level = 100, playerSid = 42, monSid = 7, obey = false, + label = "Level 100 same TID and name but foreign SID" }) +run({ own = true, level = 100, playerSid = 42, monSid = 7, monName = "MAY", sync = true, obey = true, + label = "Level 100 synced name TID SID with no badges" }) +run({ own = true, level = 100, legacy = true, wild = true, obey = true, + label = "Level 100 own mon on a save with no secret id in a wild battle" }) T.finish("game3_frontier_obedience_2638") diff --git a/tests/engine/launcher_box_transition_test.lua b/tests/engine/launcher_box_transition_test.lua new file mode 100644 index 00000000..964773dc --- /dev/null +++ b/tests/engine/launcher_box_transition_test.lua @@ -0,0 +1,37 @@ +package.path="./?.lua;./?/init.lua;"..package.path +love=love or require("tests.love_stub") +local T=require("tests.harness") +local Importer=require("src.import.RomImporter") +local Panel=require("src.import.BoxPanel") +local Transition=require("src.ui.kit.Transition") +local clock=10 +local oldTime,oldRefresh=love.timer.getTime,Panel.refresh +love.timer.getTime=function() return clock end +Panel.refresh=function(self) clock=clock+.7;self._boxState={};return self._boxState end +local imp=setmetatable({_disarmTextInput=function() end,_setModScope=function() end, + _queueReimport=function() end,_ensureSkins=function() clock=clock+.3 end}, {__index=Importer}) +Transition.armed,Transition.reduceMotion=true,false +local tabs={"red","box","mods","online","skins"} +for _,from in ipairs(tabs) do + for _,to in ipairs(tabs) do + if from~=to then + imp.tab=from + imp:_switchTab(to) + Transition.update() + local tr=Transition.get("tabs") + T.check(tr~=nil,from.." → "..to.." starts after loading completes") + T.eq(tr.from,from,"outgoing page retained") + T.eq(tr.to,to,"incoming page retained") + T.eq(tr.p,0,"first frame has whole slide remaining") + clock=clock+.09;Transition.update() + T.check(Transition.progress("tabs")>0 and Transition.progress("tabs")<1,"midpoint still slides") + clock=clock+.1;Transition.update() + T.eq(Transition.get("tabs"),nil,"slide finishes") + end + end +end +Transition.reduceMotion=true;imp.tab="red";imp:_switchTab("box") +T.eq(Transition.get("tabs"),nil,"reduced motion still disables Box slide") +love.timer.getTime,Panel.refresh=oldTime,oldRefresh +Transition.armed,Transition.reduceMotion=false,false;Transition.clear("tabs") +T.finish("launcher_box_transition") diff --git a/tests/engine/launcher_mods_inner_tabs_test.lua b/tests/engine/launcher_mods_inner_tabs_test.lua new file mode 100644 index 00000000..423a0987 --- /dev/null +++ b/tests/engine/launcher_mods_inner_tabs_test.lua @@ -0,0 +1,78 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Kit = require("src.ui.kit.Kit") +local View = require("src.import.LauncherView") +local RomImporter = require("src.import.RomImporter") +local Transition = require("src.ui.kit.Transition") +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +for _, tab in ipairs(View.HEADER_TABS) do + T.check(tab.id ~= "find", "Search has no separate launcher header button") +end + +local originalButton, originalTime = Kit.button, love.timer.getTime +local controls, now = {}, originalTime() + 1 +love.timer.getTime = function() return now end +Kit.button = function(x, y, w, h, label, opts) + if opts and opts.id then + controls[opts.id] = { x = x, y = y, w = w, h = h, active = not not opts.active } + end + return originalButton(x, y, w, h, label, opts) +end + +for _, size in ipairs({ { 390, 844 }, { 1360, 860 } }) do + love.graphics.getDimensions = function() return size[1], size[2] end + love.graphics.getPixelDimensions = love.graphics.getDimensions + local imp = RomImporter.new(function() end, { launcher = true }) + imp.mods, imp.modStraysChecked = {}, true + imp.findLoaded, imp.findSources = true, { { feed = "https://example.invalid/mods.json" } } + imp.findIndex = { mods = {}, carts = {} } + imp._ensureMods = function() end + imp._ensureFind = function() end + imp:_switchTab("mods") + local function draw(click) + now = now + 0.5 + Transition.clear() + controls = {} + imp._clickPt = click and { x = click.x + click.w / 2, y = click.y + click.h / 2 } or nil + View.draw(imp) + local actions = imp._uiActions + imp._uiActions = {} + if actions then imp:runActions(actions) end + end + local function click(id) + local rect = assert(controls[id], id) + draw(rect) + draw() + end + draw() + T.check(controls["mods-inner-installed"].active, "Installed starts selected") + T.check(not controls["mods-inner-search"].active, "Search starts inactive") + T.check(controls["tab-find"] == nil, "Find header control is absent") + T.check(controls["mods-inner-search"].x + controls["mods-inner-search"].w <= size[1], + "inner tabs fit the window") + click("mods-inner-search") + T.eq(imp.tab, "find", "Search opens the existing discovery page") + T.check(controls["tab-mods"].active, "Mods remains the selected header button in Search") + T.check(controls["mods-inner-search"].active, "Search inner tab is selected") + T.eq(imp._busy, nil, "opening Search does not raise a blocking loader") + imp.findQuery, imp.findGame, imp.findCategory = "sprite", "emerald", "graphics" + imp._findSearchFocus = true + click("mods-inner-installed") + T.eq(imp.tab, "mods", "Installed returns to local mods") + T.eq(imp._findSearchFocus, false, "leaving Search releases its text focus") + click("mods-inner-search") + T.eq(imp.findQuery, "sprite", "Search query survives inner tab switches") + T.eq(imp.findGame, "emerald", "game filter survives inner tab switches") + T.eq(imp.findCategory, "graphics", "category filter survives inner tab switches") + imp:_cycleTab(1) + T.eq(imp.tab, "online", "controller navigation treats both inner pages as Mods") + draw() + click("tab-mods") + T.eq(imp.tab, "find", "Mods header remembers its last selected inner page") +end + +Kit.button, love.timer.getTime = originalButton, originalTime +T.finish("launcher mods inner tabs") diff --git a/tests/engine/launcher_skins_tab.lua b/tests/engine/launcher_skins_tab.lua index a56f7077..22ad57f8 100644 --- a/tests/engine/launcher_skins_tab.lua +++ b/tests/engine/launcher_skins_tab.lua @@ -56,15 +56,16 @@ check(view:find('skins = "paintbrush"', 1, true) ~= nil, "the skins tab uses the shared Lucide paintbrush icon") check(read("assets/launcher/lucide/icons.png"):sub(1, 4) == "\137PNG", "the shared icon atlas is packaged with the launcher") --- the tab has to be next to Find, which is what the request was local order = view:match("local HEADER_TABS = %{(.-)%}\n") check(order ~= nil, "HEADER_TABS found") if order then - local findAt = order:find('id = "find"', 1, true) + local modsAt = order:find('id = "mods"', 1, true) local skinsAt = order:find('id = "skins"', 1, true) local onlineAt = order:find('id = "online"', 1, true) - check(findAt and skinsAt and skinsAt > findAt, - "the skins tab sits immediately after Find") + check(modsAt and skinsAt and skinsAt > modsAt, + "the skins tab follows Mods") + check(order:find('id = "find"', 1, true) == nil, + "Find lives inside Mods instead of the launcher header") check(onlineAt ~= nil, "the online tab is in the header") local onlineRow = order:match('{ id = "online".-}') check(onlineRow and onlineRow:find("beta = true", 1, true) ~= nil, @@ -101,12 +102,16 @@ local GameVersion = require("src.core.GameVersion") local cycled, probe = {}, nil probe = setmetatable({ tab = GameVersion.ORDER[1] }, { __index = RomImporter }) probe._switchTab = function(self, id) self.tab = id; cycled[#cycled + 1] = id end -for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end +for _ = 1, #GameVersion.ORDER + #require("src.import.LauncherView").HEADER_TABS do + RomImporter._cycleTab(probe, 1) +end local reached = " " .. table.concat(cycled, " ") .. " " check(reached:find(" skins ", 1, true) ~= nil, "shoulder-button tab cycling reaches the skins tab") check(reached:find(" bug ", 1, true) == nil, "shoulder-button tab cycling no longer stops on the bug panel") +check(reached:find(" importers ", 1, true) == nil, + "shoulder-button tab cycling leaves Extras in settings") for _, id in ipairs(GameVersion.ORDER) do if id ~= GameVersion.ORDER[1] then check(reached:find(" " .. id .. " ", 1, true) ~= nil, diff --git a/tests/engine/sync_box_test.lua b/tests/engine/sync_box_test.lua new file mode 100644 index 00000000..11d4dded --- /dev/null +++ b/tests/engine/sync_box_test.lua @@ -0,0 +1,521 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Serializer = require("src.core.SaveSerializer") +local Store = require("src.box.Store") +local Service = require("src.box.Service") +local Transaction = require("src.box.Transaction") +local Box = require("src.sync.SyncBox") +local Engine = require("src.sync.SyncEngine") +local State = require("src.sync.SyncState") +local Json = require("src.link.Json") +local Base64 = require("src.core.Base64") +require("src.import.CacheFs").readAt = function(path) + if path:match("data/generated/pokemon.lua$") then + return Serializer.encode({ PIKACHU = { dex = 25, name = "PIKACHU" } }) + elseif path:match("data/generated/moves.lua$") then + return Serializer.encode({ THUNDERSHOCK = { name = "THUNDERSHOCK" } }) + end +end +require("src.box.Catalog").reset() + +local function filesystem() + local files, mode = {}, { writes = 0 } + local fs = { + read = function(p) return files[p] end, + getInfo = function(p) return files[p] and { type = "file" } end, + createDirectory = function() return true end, + getDirectoryItems = function(dir) + local out = {}; for p in pairs(files) do + if p:sub(1, #dir + 1) == dir .. "/" then out[#out + 1] = p:sub(#dir + 2) end + end; return out + end, + remove = function(p) files[p] = nil; return true end, + write = function(p, body) + mode.writes = mode.writes + 1 + if mode.writes == mode.at then + if mode.kind == "partial" then files[p] = body:sub(1, math.floor(#body / 2)) end + if mode.kind == "crash" then files[p] = body; error("interrupted") end + return nil, "disk fault" + end + files[p] = body; return true + end, + } + return fs, files, mode +end + +local function linked() + local s = State.defaults(); s.account, s.deviceToken, s.enabled = "test", "token", true; return s +end + +local mon = { species = "PIKACHU", nickname = "Sparky", level = 12, + hp = 31, maxHp = 31, otId = 42, otName = "Player", moves = { "THUNDERSHOCK" } } +local function game(id, boxed) + return { version = "red", generation = 1, player = { name = "RED" }, + party = {}, boxes = { boxed and { Store.copy(mon) } or {} }, currentBox = 1, + meta = { playthroughId = id, savedAt = 1700000000 } } +end +local function writeGame(files, id, slot, boxed) + files["saves/red/" .. slot .. ".lua"] = Serializer.encode(game(id, boxed)) +end +local function savesFor(files) + local adapter = { ordinaryWrites = 0 } + function adapter.list() + local out = {} + for path, body in pairs(files) do + local scope, slot = path:match("^saves/([%w_%-]+)/(slot%d+)%.lua$") + if scope then + local save = Serializer.decode(body) + if save and save.meta and save.meta.playthroughId then + out[#out + 1] = { version = save.version, slot = slot, + cart = scope:match("^cart_(.+)$"), playthroughId = save.meta.playthroughId, + blob = body, meta = Store.copy(save.meta) } + end + end + end + table.sort(out, function(a, b) return a.playthroughId < b.playthroughId end) + return out + end + function adapter.write() adapter.ordinaryWrites = adapter.ordinaryWrites + 1; return "slot99" end + function adapter.remove() error("linked deletion must use compound transaction") end + return adapter +end +local function warehouse(files) + local s = Store.new(); s.savedAt, s.nextId = 1700000000, 2 + s.boxes[1].mons[1] = { id = 1, version = "red", generation = 1, slotId = "slot1", + mon = Store.copy(mon), display = { name = "PIKACHU" }, tags = "favorite", markings = 3 } + s.syncMembers = { ["red/source"] = { version = "red", playthroughId = "source", + slotId = "slot1", path = "saves/red/slot1.lua" } } + files[Store.PATH] = Serializer.encode(s); writeGame(files, "source", "slot1", false) + return s +end + +local function cloud() + local c = { rev = 0, saves = {}, requests = {} } + function c:transport() + local transport = {} + function transport:begin(req) + c.requests[#c.requests + 1] = req + local method, path = req.method, req.url:match("http://sync%.test([^?]*)") + local data, code = {}, 200 + if method == "GET" and path == "/sync/state" then + data = { capabilities = { box = 1 }, saves = {}, deleted = {} } + if c.legacy then data.capabilities = nil end + for key, row in pairs(c.saves) do + data[row.deleted and "deleted" or "saves"][key] = { rev = row.rev, meta = row.meta } + end + if c.box then data.box = { rev = c.rev, meta = c.box.meta, members = c.box.members } end + elseif method == "GET" and path == "/sync/box" then + data = Store.copy(c.box or { format = 1, blob = Serializer.encode(Store.new()), assets = {}, meta = { count = 0 }, members = {} }) + data.rev, data.saves = c.rev, Store.copy(c.saves) + for version, id in req.url:gmatch("([%w_]+)/([%w%-]+)") do + local key = version .. "/" .. id + if version == "red" and not data.saves[key] then data.saves[key] = { version = version, deleted = true, rev = 0 } end + end + if c.onGet then c.onGet(); c.onGet = nil end + elseif method == "PUT" and path == "/sync/box" then + local body = assert(Json.decode(req.body)) + if body.baseRev ~= c.rev then code = 409 end + for key, row in pairs(body.saves) do if row.baseRev ~= (c.saves[key] and c.saves[key].rev or 0) then code = 409 end end + if code == 200 then + c.rev = c.rev + 1; c.box = body; c.box.members = {}; data = { rev = c.rev, revs = {} } + for key, row in pairs(body.saves) do + row.rev = row.baseRev + 1; c.saves[key] = Store.copy(row) + data.revs[key] = row.rev; c.box.members[#c.box.members + 1] = key + end + else data = { error = "conflict", rev = c.rev } end + elseif method == "PUT" and path == "/sync/save" then + local body = assert(Json.decode(req.body)); data.rev = 1 + c.saves[body.version .. "/" .. body.meta.playthroughId] = { rev = 1, meta = body.meta, blob = body.blob } + elseif method == "GET" and path == "/sync/save" then + data = Store.copy(c.saves["red/source"]) + if c.onGetSave then c.onGetSave(); c.onGetSave = nil end + else code, data = 404, { error = "missing route" } end + return { status = "ok", code = code, body = Json.encode(data) } + end + function transport:poll(handle) return handle end + function transport:release() end + return transport + end + return c +end + +local function engine(fs, files, c, state) + local saves = savesFor(files) + local e = Engine.new({ fs = fs, box = Box.new(fs), saves = saves, + state = state or linked(), persist = false, baseUrl = "http://sync.test", transport = c:transport() }) + return e, saves +end +local function pump(e) + for _ = 1, 40 do e:update(0.01) end + T.check(not e:busy(), "sync settles") +end +local function run(e) assert(e:syncNow()); pump(e) end +local function count(files) + local s = assert(Serializer.decode(files[Store.PATH])); local n = Store.count(s) + for _, e in ipairs(savesFor(files).list()) do + local save = assert(Serializer.decode(e.blob)) + for _, b in ipairs(save.boxes or {}) do n = n + #b end + end + return n +end + +do + local fs, files = filesystem(); writeGame(files, "source", "slot1", true) + files[Store.PATH] = Serializer.encode(Store.new()) + local c = cloud(); c.saves["red/source"] = { version = "red", rev = 1, blob = files["saves/red/slot1.lua"], meta = game("source").meta } + local state = linked(); State.setRev(state, "red/source", 1, 1700000000) + local e, adapter = engine(fs, files, c, state) + e._boxKeys = {} + c.onGetSave = function() warehouse(files) end + e:_queueDownload("red/source", "red", "source", "replace", 1) + pump(e) + T.eq(adapter.ordinaryWrites, 0, "deposit during standalone fetch blocks stale game replacement") + T.eq(count(files), 1, "concurrent deposit keeps one Pokémon") + T.eq(c.rev, 1, "standalone fetch restarts as compound Box sync") + T.eq(#Serializer.decode(c.saves["red/source"].blob).boxes[1], 0, "cloud receives transferred game state with warehouse") +end + +do + local fs, files = filesystem(); writeGame(files, "source", "slot1", true) + local c = cloud(); local e = engine(fs, files, c) + local entry = savesFor(files).list()[1]; e._boxKeys = {} + e:_queueUpload(entry, "red/source", false) + warehouse(files) + pump(e) + for _, req in ipairs(c.requests) do + T.check(not req.url:find("/sync/save", 1, true), "queued independent write yields to new Box membership") + end + T.eq(c.rev, 1, "new Box member uploads as complete collection") +end + +do + local fs, files = filesystem(); warehouse(files) + local c = cloud(); local e = engine(fs, files, c) + run(e) + T.eq(e.phase, "idle", "first upload succeeds") + T.eq(c.rev, 1, "warehouse revision advances") + T.eq(c.saves["red/source"].rev, 1, "source save advances in same request") + T.eq(#c.requests, 2, "state plus one compound upload") + T.eq(c.requests[2].url, "http://sync.test/sync/box", "no standalone linked save upload") + run(e); T.eq(c.rev, 1, "unchanged collection does not upload again") + local fs2, files2 = filesystem() + writeGame(files2, "unrelated", "slot1", true) + files2["options.lua"] = Serializer.encode({ saveSlots = { red = { list = { "slot1" }, active = "slot1" } }, custom = "local setting" }) + local e2, adapter = engine(fs2, files2, c); run(e2) + T.eq(e2.phase, "idle", "new device receives collection") + T.eq(adapter.ordinaryWrites, 0, "linked save bypasses ordinary download") + local state2 = Serializer.decode(files2[Store.PATH]) + T.eq(state2.syncMembers["red/source"].slotId, "slot2", "destination slot can differ") + T.eq(state2.boxes[1].mons[1].slotId, "slot2", "Pokémon origin remapped") + T.eq(Serializer.decode(files2["options.lua"]).custom, "local setting", "device settings preserved") + T.eq(count(files2), 2, "local unrelated Pokémon and one synced Pokémon retained") + T.eq(c.saves["red/unrelated"].rev, 1, "independent saves still sync individually") + local before = c.rev; run(e2); T.eq(c.rev, before, "remapped slots do not cause ping-pong") +end + +do + local fs, files = filesystem(); local s = warehouse(files) + local entry = s.boxes[1].mons[1] + entry.archives = { { id = 1, version = "red", generation = 1, slotId = "slot1", mon = { species = "PIKACHU", opaque = "\0\255" }, display = {} } } + s.presets = { { name = "Team", ids = { 1 } } } + s.stages = { require("src.box.Showcase").new() }; s.progress = { trainer = 7 }; s.gciTemplate = "native\0banner" + s.boxes[1].theme, s.boxes[1].wallpaper = "Showcase", "box/showcase/1.png" + s.boxes[1].showcaseMusic = "Night" + local png = "\137PNG\r\n\26\n" .. string.rep("\0", 8) .. "\0\0\4\112\0\0\1\176" .. "fixture" + files[s.boxes[1].wallpaper] = png; files[Store.PATH] = Serializer.encode(s) + local payload = assert(Box.new(fs):snapshot(savesFor(files).list(), linked())).payload + T.check(Box.validate(payload), "full snapshot validates") + local fs2, files2 = filesystem(); local box2 = Box.new(fs2) + T.check(box2:apply(payload, {}, linked()), "full snapshot applies") + local copy = Serializer.decode(files2[Store.PATH]) + T.same(copy.presets, s.presets, "teams survive") + T.same(copy.stages, s.stages, "stages survive") + T.eq(copy.boxes[1].showcaseMusic,"Night","applied theme music survives device sync") + T.same(copy.progress, s.progress, "gift progress survives") + T.same(copy.boxes[1].mons[1].archives, entry.archives, "archived native records survive") + T.eq(copy.gciTemplate, s.gciTemplate, "native template survives") + T.eq(files2["box/showcase/1.png"], png, "wallpaper pixels survive") + local before = Store.copy(files2) + for _, mutate in ipairs({ + function(p) p.saves["red/source"].meta.playthroughId = "other" end, + function(p) p.saves["red/source"].blob = "return { player={},version='gold' }" end, + function(p) p.assets["box/showcase/1.png"] = nil end, + function(p) p.assets["../../escape"] = Base64.encode(png) end, + function(p) p.blob = "return os.execute('bad')" end, + function(p) p.saves["red/source"].blob = "return {}" end, + }) do + local bad = Store.copy(payload); mutate(bad) + T.check(not box2:apply(bad, savesFor(files2).list(), linked()), "malformed snapshot rejected") + T.same(files2, before, "invalid snapshot changes no file") + end + local cart = Store.copy(payload) + local save = Serializer.decode(cart.saves["red/source"].blob); save.meta.cartId = "missing-cart" + cart.saves["red/source"].blob = Serializer.encode(save) + local cartState = Serializer.decode(cart.blob) + cartState.syncMembers["red/source"].cartId, cartState.syncMembers["red/source"].path = "missing-cart", "saves/cart_missing-cart/slot1.lua" + cart.blob = Serializer.encode(cartState) + local cleanFs, cleanFiles = filesystem() + local result, why = Box.new(cleanFs):apply(cart, {}, linked()) + T.eq(result, false, "missing cart defers entire collection") + T.check(why:find("Install", 1, true), "cart instruction given") + T.eq(next(cleanFiles), nil, "no partial warehouse or save installed") +end + +do + local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c) + files["saves/red/slot1.lua"] = nil + T.eq(e:noteSaveDeleted("red/source"), false, "never uploaded deletion stays local") + run(e) + T.eq(e.phase, "idle", "unsynced deleted source does not block first Box sync") + T.eq(c.saves["red/source"].deleted, true, "first snapshot records departed source tomb") +end + +do + local fsA, a = filesystem(); warehouse(a); local c = cloud(); local A = engine(fsA, a, c); run(A) + local fsB, b = filesystem(); local B = engine(fsB, b, c); run(B) + assert(assert(Service.open(fsA)):rename(1, "remote edit")); run(A) + assert(assert(Service.open(fsB)):rename(1, "local edit")); run(B) + local before = b[Store.PATH] + c.rev = c.rev + 1 + local remote = Serializer.decode(c.box.blob); remote.boxes[1].name = "remote changed again"; c.box.blob = Serializer.encode(remote) + c.saves["red/source"].rev = c.saves["red/source"].rev + 1 + assert(B:resolveConflict(Box.KEY, "remote")); pump(B) + T.eq(#B.conflicts, 1, "remote changing after prompt requires new choice") + T.eq(b[Store.PATH], before, "stale prompt does not overwrite local state") + assert(assert(Service.open(fsB)):rename(1, "local changed again")) + T.eq(B:resolveConflict(Box.KEY, "local"), false, "local changing after prompt requires review") + T.eq(#B.conflicts, 1, "new local content keeps conflict open") + T.check(B.conflicts[1].reviewMessage ~= nil, "prompt explains updated local state") + assert(B:resolveConflict(Box.KEY, "local")); pump(B) + T.eq(Serializer.decode(c.box.blob).boxes[1].name, "local changed again", "local choice uploads complete state") + T.eq(assert(B.box:readBackup(B.box:latestBackup())).blob, Serializer.encode(remote), "remote losing state preserved") +end + +for _, version in ipairs(require("src.core.GameVersion").ORDER) do + local generation = require("src.core.GameVersion").generation(version) + local fs, files = filesystem(); local s = Store.new() + local id, key, path = "edition-test", version .. "/edition-test", "saves/" .. version .. "/slot7.lua" + local save = { version = version, generation = generation, player = { name = "TEST" }, meta = { playthroughId = id }, opaque = "\0\255" } + if generation == 3 then save.player, save.engine, save.name, save.party, save.bag = nil, "game3", "TEST", {}, {} end + s.syncMembers = { [key] = { version = version, playthroughId = id, slotId = "slot7", path = path } } + files[Store.PATH], files[path] = Serializer.encode(s), Serializer.encode(save) + local payload = assert(Box.new(fs):snapshot(savesFor(files).list(), linked())).payload + T.check(Box.validate(payload), version .. " native shape validates") + local fs2, files2 = filesystem(); local box = Box.new(fs2) + T.check(box:apply(payload, {}, linked()), version .. " applies") + local target = "saves/" .. version .. "/slot1.lua" + T.eq(Serializer.decode(files2[target]).opaque, save.opaque, version .. " native bytes preserved") + T.eq(Serializer.decode(files2[Store.PATH]).syncMembers[key].path, target, version .. " routing persisted") + files2[target .. ".bak"], files2[target .. ".tmp"] = files2[target], files2[target] + payload.saves[key] = { version = version, deleted = true } + T.check(box:apply(payload, savesFor(files2).list(), linked()), version .. " linked deletion applies") + T.eq(files2[target], nil, version .. " primary removed") + T.eq(files2[target .. ".bak"], nil, version .. " backup cannot resurrect deletion") + T.eq(files2[target .. ".tmp"], nil, version .. " staged copy cannot resurrect deletion") +end + +for _, kind in ipairs({ "fail", "partial", "crash" }) do + for at = 1, 18 do + local fs, files, mode = filesystem(); warehouse(files) + local source = "saves/red/slot1.lua" + files[source .. ".tmp"] = files[source] + files["options.lua"] = Serializer.encode({ custom = "before", saveSlots = { red = { list = { "slot1" }, active = "slot1" } } }) + local oldBox = files[Store.PATH]; local newBox = Serializer.decode(oldBox); newBox.boxes[1].name = "wallpaper synced" + newBox.boxes[1].theme, newBox.boxes[1].wallpaper = "Showcase", "box/showcase/1.png" + local png = "\137PNG\r\n\26\n" .. string.rep("\0", 8) .. "\0\0\4\112\0\0\1\176" .. "bytes" + local save = Serializer.decode(files[source]); save.boxes[1] = { Store.copy(mon) }; newBox.boxes[1].mons = {} + mode.at, mode.kind = at, kind + pcall(Transaction.commitSync, fs, { + { path = source, before = files[source], after = Serializer.encode(save) }, + { kind = "asset", path = "box/showcase/1.png", after = png }, + { path = "options.lua", before = files["options.lua"], after = Serializer.encode({ custom = "after" }) }, + { path = Store.PATH, before = oldBox, after = Serializer.encode(newBox) }, + }) + mode.at = nil + T.check(Transaction.recover(fs), "batch " .. kind .. " " .. at .. " recovers") + T.eq(count(files), 1, "batch keeps one Pokémon") + local changed = Serializer.decode(files[Store.PATH]).boxes[1].name == "wallpaper synced" + if changed then + T.eq(files["box/showcase/1.png"], png, "committed PNG intact") + T.eq(Serializer.decode(files["options.lua"]).custom, "after", "options and Box commit together") + T.eq(files[source .. ".tmp"], nil, "old staged save cleared") + else T.eq(files["box/showcase/1.png"], nil, "failed initial journal leaves no wallpaper") end + end +end + +do + local fsA, a = filesystem(); warehouse(a) + local c = cloud(); local A = engine(fsA, a, c); run(A) + local fsB, b = filesystem(); local B = engine(fsB, b, c); run(B) + local source = { version = "red", slotId = "slot1", path = "saves/red/slot1.lua" } + assert(assert(Service.open(fsA)):withdraw(source, { { box = 1, slot = 1 } }, 1)) + assert(assert(Service.open(fsB)):rename(1, "B chose this")) + run(A); run(B) + T.eq(#B.conflicts, 1, "two changed collections conflict") + T.eq(B.conflicts[1].box, true, "one conflict covers collection and saves") + T.eq(count(a), 1, "withdraw leaves exactly one Pokémon") + T.eq(count(b), 1, "conflict leaves local Pokémon intact") + T.eq(B:resolveConflict(Box.KEY, "both"), false, "cannot duplicate collection with keep-both") + T.eq(#B.conflicts, 1, "invalid choice retains conflict") + T.check(B:resolveConflict(Box.KEY, "remote"), "whole remote choice accepted"); pump(B) + T.eq(count(b), 1, "remote choice preserves exactly one Pokémon") + T.eq(Store.count(Serializer.decode(b[Store.PATH])), 0, "remote withdrawal applied") + local current = Serializer.decode(b[Store.PATH]) + T.eq(current.departures[1].path, source.path, "departed destination remapped") + local backup = B.box:latestBackup(); T.check(backup ~= nil, "losing copy has reachable backup") + T.eq(assert(B.box:readBackup(backup)).meta.count, 1, "backup holds losing warehouse") + T.check(B:restoreBoxBackup(backup), "whole snapshot can be restored") + T.eq(count(b), 1, "restore keeps exactly one Pokémon") + T.eq(Store.count(Serializer.decode(b[Store.PATH])), 1, "restore includes warehouse") + run(B); T.eq(Serializer.decode(c.box.blob).boxes[1].mons[1].mon.species, "PIKACHU", "restored collection uploads") + local extra = Serializer.decode(b[Store.PATH]); extra.syncMembers["red/new"] = { version = "red", playthroughId = "new", path = "saves/red/slot2.lua", slotId = "slot2" } + b[Store.PATH] = Serializer.encode(extra); writeGame(b, "new", "slot2", false) + local original = b[Store.PATH] + T.check(not B:restoreBoxBackup(backup), "backup missing newly linked save cannot restore") + T.eq(b[Store.PATH], original, "incomplete restore leaves collection unchanged") + local ghost = { format = 1, blob = Serializer.encode(Store.new()), assets = {}, + saves = { ["red/source"] = { version = "red", deleted = true, rev = 0 }, ["red/new"] = { version = "red", deleted = true, rev = 0 } } } + local ghostPath = assert(B.box:archive(ghost)) + T.check(not B:restoreBoxBackup(ghostPath), "never-seen cloud source is not a complete deletion backup") + T.eq(b[Store.PATH], original, "placeholder backup cannot remove new linked saves") +end + +do + local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c) + e:protectPlaythrough("red", "source"); run(e) + T.eq(c.rev, 0, "active member defers entire upload") + T.check(e.status:find("launcher", 1, true), "deferred status explains next step") + e:protectPlaythrough(nil); T.eq(e.uploadAt, e.clock, "return schedules immediate retry") + pump(e); T.eq(c.rev, 1, "return uploads collection") + local s = Serializer.decode(files[Store.PATH]); s.boxes[1].name = "same second"; files[Store.PATH] = Serializer.encode(s) + run(e); T.eq(c.rev, 2, "same timestamp content changes still upload") + local state = Serializer.decode(files[Store.PATH]); state.boxes[1].mons = {}; state.departures = nil + files[Store.PATH] = Serializer.encode(state); run(e) + T.check(c.saves["red/source"] ~= nil, "empty collection retains save membership") + files["saves/red/slot1.lua.bak"], files["saves/red/slot1.lua.tmp"] = "old", "old" + files["saves/red/slot1.lua"] = nil; assert(e:noteSaveDeleted("red/source")); run(e) + T.eq(c.saves["red/source"].deleted, true, "linked deletion uploads in compound snapshot") + local fs2, files2 = filesystem(); local e2 = engine(fs2, files2, c); run(e2) + T.eq(#savesFor(files2).list(), 0, "deleted member is not resurrected") +end + +do + local fs, files = filesystem(); warehouse(files); local c = cloud(); c.legacy = true + local e = engine(fs, files, c); run(e) + T.eq(e.phase, "error", "old server blocks Box upload") + T.eq(#c.requests, 1, "old server receives no half-transfer") +end + +do + local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c); run(e) + local fs2, files2 = filesystem(); local e2 = engine(fs2, files2, c) + c.onGet = function() warehouse(files2); local s = Serializer.decode(files2[Store.PATH]); s.boxes[1].name = "edited during fetch"; files2[Store.PATH] = Serializer.encode(s) end + run(e2); T.eq(e2.phase, "error", "edit during download blocks apply") + T.eq(Serializer.decode(files2[Store.PATH]).boxes[1].name, "edited during fetch", "new local edits preserved") +end + +for _, kind in ipairs({ "fail", "partial", "crash" }) do + for at = 1, 13 do + local fs, files, mode = filesystem(); warehouse(files) + local box = Box.new(fs); local s = Serializer.decode(files[Store.PATH]); s.boxes[1].mons = {} + local payload = assert(box:snapshot(savesFor(files).list(), linked())).payload + payload.blob = Serializer.encode(s); local save = Serializer.decode(payload.saves["red/source"].blob) + save.boxes[1] = { Store.copy(mon) }; payload.saves["red/source"].blob = Serializer.encode(save) + mode.at, mode.kind = at, kind + pcall(box.apply, box, payload, savesFor(files).list(), linked()) + mode.at = nil + T.check(box:recover(), kind .. " " .. at .. " recovers interrupted group") + T.eq(count(files), 1, kind .. " " .. at .. " retains exactly one Pokémon") + T.eq(files[Transaction.PATH], nil, "recovery journal cleared") + end +end + +do + local fs, files, mode = filesystem(); warehouse(files) + local before = files[Store.PATH]; local nextState = Serializer.decode(before); nextState.boxes[1].name = "cloud" + mode.at = 2 + Transaction.commitSync(fs, { { path = Store.PATH, before = before, after = Serializer.encode(nextState) } }) + mode.at = nil; local other = Serializer.decode(before); other.boxes[1].name = "external edit"; files[Store.PATH] = Serializer.encode(other) + local ok, applied, notice = Transaction.recover(fs) + T.check(ok and not applied and notice, "external edit sets the pending transfer aside") + T.eq(Serializer.decode(files[Store.PATH]).boxes[1].name, "external edit", "external edit not overwritten") + T.eq(files[Transaction.PATH], nil, "conflicting journal no longer blocks boot") + local kept = notice and notice:match("(box/conflicts/[^ ]+%.lua)") + T.check(kept and files[kept] ~= nil, "conflicting journal is kept for inspection") +end + +local function link(files, id, slot) + writeGame(files, id, slot, false) + local s = Serializer.decode(files[Store.PATH]) + s.syncMembers["red/" .. id] = { version = "red", playthroughId = id, path = "saves/red/" .. slot .. ".lua", slotId = slot } + files[Store.PATH] = Serializer.encode(s) +end + +do + local fsA, a = filesystem(); warehouse(a); local c = cloud(); local A = engine(fsA, a, c); run(A) + local fsB, b = filesystem(); local B = engine(fsB, b, c); run(B) + link(b, "added", "slot5"); run(B) + T.eq(B.phase, "idle", "device B uploads a newly linked save") + local rev = c.rev + run(A) + T.eq(#A.conflicts, 0, "save linked on another device is not a local Box change") + T.eq(A.phase, "idle", "receiving the new member settles") + local got = false + for _, entry in ipairs(savesFor(a).list()) do if entry.playthroughId == "added" then got = true end end + T.check(got, "newly linked save downloads with the collection") + T.eq(c.rev, rev, "receiving the collection uploads nothing") + run(A); T.eq(c.rev, rev, "no ping-pong after receiving a new member") +end + +do + local fsA, a = filesystem(); warehouse(a); local c = cloud(); local A = engine(fsA, a, c); run(A) + local fsB, b = filesystem(); local B = engine(fsB, b, c); run(B) + link(b, "added", "slot5"); assert(assert(Service.open(fsB)):rename(1, "B edit")); run(B) + assert(assert(Service.open(fsA)):rename(1, "A edit")); run(A) + T.eq(#A.conflicts, 1, "real edits on both devices still conflict") + T.check(A:resolveConflict(Box.KEY, "local"), "local choice accepted with a remote-only member"); pump(A) + T.eq(A.phase, "idle", "local choice uploads despite a member it never had") + T.eq(Serializer.decode(c.box.blob).boxes[1].name, "A edit", "local collection wins") + T.check(c.saves["red/added"] and not c.saves["red/added"].deleted, "remote-only member is carried, not dropped") + run(A) + local got = false + for _, entry in ipairs(savesFor(a).list()) do if entry.playthroughId == "added" then got = true end end + T.check(got, "carried member downloads on the next sync") + T.eq(Serializer.decode(a[Store.PATH]).boxes[1].name, "A edit", "local collection kept after receiving the member") +end + +do + local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c) + c.saves["red/source"] = { version = "red", rev = 3, blob = files["saves/red/slot1.lua"], meta = game("source").meta } + run(e) + T.eq(e.phase, "conflict", "server without a Box but a changed linked save asks once") + T.check(e:resolveConflict(Box.KEY, "local"), "local choice accepted"); pump(e) + T.eq(e.phase, "idle", "first Box upload initializes the server") + T.eq(c.rev, 1, "server now has the Box") +end + +do + local fs, files = filesystem(); warehouse(files) + local payload = assert(Box.new(fs):snapshot(savesFor(files).list(), linked())).payload + local fs2, files2 = filesystem(); files2["options.lua"] = "return {" + local box = Box.new(fs2) + T.check(not box:apply(payload, {}, linked()), "unreadable options refuse the Box apply") + T.eq(files2["options.lua"], "return {", "unreadable options are not replaced") + T.eq(files2[Store.PATH], nil, "no Box written when options are unreadable") +end + +do + local fs, files = filesystem(); warehouse(files); local c = cloud(); local e = engine(fs, files, c); run(e) + files["saves/red/slot1.lua"] = nil + e.box.markDeleted = function() return nil, "disk fault" end + local ok = e:noteSaveDeleted("red/source") + T.eq(ok, nil, "failed Box mark is reported") + T.check(e.state.pendingDeletes["red/source"] ~= nil, "deletion is still recorded") + T.eq(State.rev(e.state, "red/source"), nil, "deleted save is forgotten") + e.box.markDeleted = nil + run(e) + T.eq(c.saves["red/source"].deleted, true, "deletion reaches the cloud without resurrecting") +end + +T.finish("sync_box") diff --git a/tests/engine/trainer_id_sync.lua b/tests/engine/trainer_id_sync.lua index 114c15b7..760b32a4 100644 --- a/tests/engine/trainer_id_sync.lua +++ b/tests/engine/trainer_id_sync.lua @@ -30,96 +30,17 @@ local function fresh() return files end -do - local ids = { [100] = true, [200] = true, [300] = true } - local save = { - version = "red", - player = { id = 200, name = "RED" }, - party = { - { species = "PIKACHU", otId = 200 }, - { species = "ABRA", otId = 100, traded = true }, - { species = "MEW", otId = 4242, traded = true }, - { species = "ODDISH" }, - }, - boxes = { { { species = "ZUBAT", otId = 300 } } }, - daycare = { mon = { species = "EKANS", otId = 200 } }, - } - T.check(TrainerIdSync.rewriteSave(save, 1, 100, ids), "gen1 save reports a change") - T.eq(save.player.id, 100, "gen1 player id takes the new id") - T.eq(save.party[1].otId, 100, "own catch follows the id") - T.eq(save.party[2].otId, 100, "mon traded from another save follows the id") - T.eq(save.party[2].traded, nil, "and is no longer flagged traded") - T.eq(save.party[3].otId, 4242, "a real trade keeps its OT") - T.eq(save.party[3].traded, true, "and stays traded") - T.eq(save.party[4].otId, nil, "an unstamped mon is left for load-time stamping") - T.eq(save.boxes[1][1].otId, 100, "boxed mons follow the id") - T.eq(save.daycare.mon.otId, 100, "daycare mon follows the id") - T.check(not TrainerIdSync.rewriteSave(save, 1, 100, ids), "a second pass is a no-op") -end - -do - local raw = ("00"):rep(12) .. "00C8" .. ("00"):rep(30) - local save = { player = { id = 200 }, party = { { cartRaw = raw } } } - TrainerIdSync.rewriteSave(save, 1, 100, { [100] = true, [200] = true }) - T.eq(save.party[1].cartRaw:sub(25, 28), "0064", "raw gen1 carrier gets its OT bytes rewritten") - T.eq(#save.party[1].cartRaw, #raw, "without changing its length") -end - -do - local save = { - version = "gold", player = { id = 200 }, - party = { { otId = 200, traded = true } }, - dayCare = { man = { mon = { otId = 200 } }, egg = { otId = 200 } }, - hallOfFame = { teams = { { mons = { { otId = 200 } } } } }, - mail = { party = { { authorId = 200 } }, box = { { authorId = 9 } } }, - } - TrainerIdSync.rewriteSave(save, 2, 100, { [100] = true, [200] = true }) - T.eq(save.player.id, 100, "gen2 player id") - T.eq(save.dayCare.man.mon.otId, 100, "gen2 daycare parent") - T.eq(save.dayCare.egg.otId, 100, "gen2 daycare egg") - T.eq(save.hallOfFame.teams[1].mons[1].otId, 100, "gen2 hall of fame") - T.eq(save.mail.party[1].authorId, 100, "gen2 own mail") - T.eq(save.mail.box[1].authorId, 9, "gen2 foreign mail untouched") -end - -do - local bit = require("bit") - local function shinyKey(tid, sid) return bit.bxor(tid, sid) end - local save = { - engine = "game3", version = "emerald", trainerId = 200, secretId = 0x1234, - party = { { otId = 200, otSecretId = 0x1234 }, { otId = 300, otSecretId = 0x0F0F } }, - storage = { boxes = { [3] = { mons = { [7] = { otId = 200, otSecretId = 0x1234 } } } } }, - modData = { emerald_daycare = { daycare = { { otId = 200, otSecretId = 0x1234 } } } }, - hallOfFameTeams = { { { trainerId = 200, otSecretId = 0x1234 } } }, - secretBases = { { trainerId = { 200, 0, 0x34, 0x12 } } }, - } - TrainerIdSync.rewriteSave(save, 3, 100, { [100] = true, [200] = true, [300] = true }) - T.eq(save.trainerId, 100, "gen3 trainer id") - T.eq(shinyKey(save.trainerId, save.secretId), shinyKey(200, 0x1234), - "gen3 secret id is reseated so own shinies stay shiny") - T.eq(save.party[1].otId, 100, "gen3 party mon") - T.eq(save.party[1].otSecretId, save.secretId, "own mon keeps matching the save's secret id") - T.eq(shinyKey(save.party[2].otId, save.party[2].otSecretId), shinyKey(300, 0x0F0F), - "mon from another save keeps its shininess") - T.eq(save.storage.boxes[3].mons[7].otId, 100, "gen3 sparse box mon") - T.eq(save.modData.emerald_daycare.daycare[1].otId, 100, "gen3 daycare mon") - T.eq(save.hallOfFameTeams[1][1].trainerId, 100, "gen3 hall of fame") - local b = save.secretBases[1].trainerId - T.eq(b[1] + b[2] * 256, 100, "own secret base TID") - T.eq(b[3] + b[4] * 256, save.secretId, "own secret base SID") -end - do local files = fresh() local red = SaveData.createSlot("red") SaveData.writeSlot("red", red, { version = "red", player = { id = 111, name = "RED" }, - party = { { species = "PIKACHU", otId = 111 } } }) + party = { { species = "PIKACHU", otId = 111, ot = "RED" } } }) local gold = SaveData.createSlot("gold") SaveData.writeSlot("gold", gold, { version = "gold", generation = 2, player = { id = 222, name = "GOLD" }, - party = { { species = "CYNDAQUIL", otId = 222 }, { species = "ABRA", otId = 111, traded = true } } }) + party = { { species = "CYNDAQUIL", otId = 222, ot = "GOLD" }, { species = "ABRA", otId = 111, ot = "RED", traded = true } } }) local em = SaveData.createSlot("emerald") SaveData.writeSlot("emerald", em, { version = "emerald", engine = "game3", generation = 3, - trainerId = 333, secretId = 5, name = "MAY", party = { { otId = 333, otSecretId = 5 } } }) + trainerId = 333, secretId = 5, name = "MAY", party = { { species = 25, otId = 333, otSecretId = 5, otName = "MAY", personality = 12345 } } }) local job = TrainerIdSync.newJob({ scope = "gold", slot = gold }) local steps, last = 0, -1 @@ -162,10 +83,10 @@ do local red = SaveData.createSlot("red") SaveData.writeSlot("red", red, { version = "red", meta = { savedAt = 500 }, - player = { id = 111, name = "RED" }, party = { { species = "PIKACHU", otId = 111 } } }) + player = { id = 111, name = "RED" }, party = { { species = "PIKACHU", otId = 111, ot = "RED" } } }) local gold = SaveData.createSlot("gold") SaveData.writeSlot("gold", gold, { version = "gold", generation = 2, meta = { savedAt = 500 }, - player = { id = 222, name = "GOLD" }, party = { { species = "CYNDAQUIL", otId = 222 } } }) + player = { id = 222, name = "GOLD" }, party = { { species = "CYNDAQUIL", otId = 222, ot = "GOLD" } } }) local server, puts = {}, {} local transport = { sent = {}, handles = {} } @@ -228,6 +149,23 @@ do T.eq(eng.phase, "idle", "and the engine settles") end +do + local nick = ("50"):rep(11) + local ot = "91848350" .. ("00"):rep(7) + local struct = ("A5"):rep(12) .. "006F" .. ("00"):rep(19) + local save = { version = "red", player = { id = 111, name = "RED" }, party = {}, + daycare = { cartRaw = "01" .. nick .. ot .. struct } } + local owners = {} + TrainerIdSync.addOwner(owners, { id = 111, name = "RED" }, 1) + local changed = TrainerIdSync.rewriteSave(save, 1, { id = 222, name = "BLUE", sid = 0 }, owners) + T.check(changed, "raw cart day-care block counts as a change") + local raw = save.daycare.cartRaw + T.eq(#raw, 112, "raw day-care block keeps its size") + T.eq(raw:sub(71, 74), "00DE", "raw day-care OT id rewritten") + T.eq(raw:sub(25, 34), "818B9484" .. "50", "raw day-care OT name rewritten") + T.eq(raw:sub(1, 24), "01" .. nick, "raw day-care nickname untouched") +end + love.filesystem = realFS T.finish("trainer_id_sync") diff --git a/tests/engine/trainer_identity_sync_test.lua b/tests/engine/trainer_identity_sync_test.lua new file mode 100644 index 00000000..38ace4c2 --- /dev/null +++ b/tests/engine/trainer_identity_sync_test.lua @@ -0,0 +1,265 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local bit = require("bit") +local Sync = require("src.core.TrainerIdSync") +local Identity = require("src.core.TrainerIdentity") +local SaveData = require("src.core.SaveData") +local Store = require("src.box.Store") +local Records = require("src.box.Records") +local Gifts = require("src.box.Gifts") +local Transaction = require("src.box.Transaction") +local Pokemon = require("src.core.game3.pokemon") +local Engine = require("src.core.game3.battle.engine") +local target = { id = 100, sid = 42, name = "RED" } +local other = { id = 200, sid = 0x1234, name = "MAY" } +local owners = {} +Sync.addOwner(owners, target, 3) +Sync.addOwner(owners, other, 3) +local function mon(id, sid, name, pid, species) + return { species = species or 25, otId = id, otSecretId = sid, otName = name, + ot = name, personality = pid or 12345, nickname = "PIKA", moves = { 84 }, + level = 100, hp = 99, maxHp = 99, ivs = { hp = 31 }, opaque = "keep" } +end +local function nativeShiny(m) + return bit.bxor(m.otId, m.otSecretId, m.personality % 65536, math.floor(m.personality / 65536)) < 8 +end +local function shinyMon(profile, low, species) + return mon(profile.id, profile.sid, profile.name, + bit.bxor(profile.id, profile.sid, low) * 65536 + low, species) +end + +for gen = 1, 3 do + local own = mon(other.id, other.sid, other.name) + local foreignName = mon(other.id, other.sid, "BLUE") + local foreignSid = mon(other.id, other.sid + 1, other.name) + local save = { version = gen == 1 and "red" or gen == 2 and "gold" or "emerald", + player = { id = other.id, name = other.name }, name = other.name, + trainerId = other.id, secretId = other.sid, party = { own, foreignName, foreignSid }, + boxes = { { Store.copy(own) } }, daycare = { mon = Store.copy(own) }, + storage = { boxes = { [3] = { mons = { [7] = Store.copy(own) } } } }, + savedPlayerParty = { Store.copy(own) }, frontier = { towerPlayer = { party = { Store.copy(own) } } }, + dayCare = { man = { mon = Store.copy(own) }, lady = { mon = Store.copy(own) }, egg = Store.copy(own) }, + modData = { emerald_daycare = { daycare = { Store.copy(own), Store.copy(own) }, + route5Daycare = { mon = Store.copy(own) } } }, + mail = gen == 2 and { party = { { author = "MAY", authorId = 200 }, { author = "BLUE", authorId = 200 } } } + or gen == 3 and { { playerName = "MAY", trainerId = other.id + other.sid * 65536 } } or nil, + secretBases = { { trainerName = "MAY", trainerId = { 200, 0, 0x34, 0x12 } } }, + hallOfFameTeams = { { { trainerId = 200, otSecretId = other.sid, personality = 12345 } } } } + T.check(Sync.rewriteSave(save, gen, target, owners), "Gen " .. gen .. " sync rewrites") + local profile = Identity.profile(save, gen) + T.eq(profile.id, target.id, "public ID follows selected save") + T.eq(profile.name, target.name, "player name follows selected save") + if gen == 3 then + T.eq(profile.sid, target.sid, "secret ID follows selected save") + T.eq(foreignSid.otSecretId, other.sid + 1, "colliding public ID with foreign SID untouched") + T.check(not Pokemon.isTradedMon(own, save), "synced mon passes native ownership check") + T.check(not Engine.isTradedMon({ session = save }, own), "battle ownership uses actual session") + T.check(Engine.isTradedMon({ session = save }, foreignSid), "battle detects different full OT ID") + T.eq(save.mail[1].trainerId, target.id + target.sid * 65536, "mail full identity syncs") + T.eq(save.secretBases[1].trainerName, "RED", "own secret base name syncs") + T.same(save.secretBases[1].trainerId, { 100, 0, 42, 0 }, "own secret base full ID syncs") + T.eq(save.hallOfFameTeams[1][1].trainerId, 200, "nameless historical OT is not guessed") + elseif gen == 2 then + T.eq(save.mail.party[1].authorId, 100, "own mail ID") + T.eq(save.mail.party[1].author, "RED", "own mail name") + T.eq(save.mail.party[2].authorId, 200, "foreign mail collision protected") + end + T.eq(own.otName, "RED", "OT name synced") + T.eq(own.ot, "RED", "OT alias synced") + T.eq(own.otId, 100, "OT ID synced") + T.eq(own.opaque, "keep", "opaque mon data retained") + T.eq(foreignName.otName, "BLUE", "foreign name untouched") + T.eq(foreignName.otId, 200, "ID collision does not relabel foreign mon") + for _, m in ipairs(Sync.monsOf(save, gen)) do + if m ~= foreignName and m ~= foreignSid then T.eq(m.otId, 100, "every live collection synced") end + end + local afterOwners = {} + Sync.addOwner(afterOwners, target, 3) + local body = SaveData.encode(save) + T.check(not Sync.rewriteSave(save, gen, target, afterOwners), "second sync has no changes") + T.eq(SaveData.encode(save), body, "second sync retains exact serialized record") +end + +for _, species in ipairs({ 25, 290, 308 }) do + for low = 0, 65535, 257 do + local m = shinyMon(other, low, species) + local pid = assert(Identity.personality(m, target)) + T.eq(pid % 25, m.personality % 25, "nature retained") + T.eq(pid % 256, low % 256, "gender byte retained") + T.eq(pid % 2, m.personality % 2, "ability parity retained") + if species == 290 then + T.eq(math.floor(pid / 65536) % 10 < 5, math.floor(m.personality / 65536) % 10 < 5, + "Wurmple evolution retained") + end + m.personality, m.otId, m.otSecretId = pid, target.id, target.sid + T.check(nativeShiny(m), "shiny remains shiny under canonical ID") + local ordinary = mon(other.id, other.sid, other.name, low + 10000 * 65536, species) + local nextTarget = { id = ordinary.personality % 65536, sid = math.floor(ordinary.personality / 65536) } + local adjusted = assert(Identity.personality(ordinary, nextTarget)) + T.eq(adjusted % 25, ordinary.personality % 25, "accidental shiny correction keeps nature") + T.eq(adjusted % 65536, ordinary.personality % 65536, "accidental shiny correction keeps lower word") + ordinary.personality, ordinary.otId, ordinary.otSecretId = adjusted, nextTarget.id, nextTarget.sid + T.check(not nativeShiny(ordinary), "non-shiny remains non-shiny") + end +end + +for low = 0, 255 do + local m = shinyMon(other, low, 201) + local pid, why = Identity.personality(m, target) + T.check(pid ~= nil or why ~= nil, "Unown either preserves its traits or refuses conversion") + if pid then + T.eq(Pokemon.unownLetter(pid), Pokemon.unownLetter(m.personality), "Unown letter retained") + T.eq(pid % 25, m.personality % 25, "Unown nature retained") + T.eq(pid % 2, m.personality % 2, "Unown parity retained") + m.personality, m.otId, m.otSecretId = pid, target.id, target.sid + T.check(nativeShiny(m), "Unown shininess retained") + end +end + +do + local raw = ("00"):rep(12) .. "00C8" .. ("00"):rep(30) + local carrier = { cartRaw = raw, cartOt = "8C809850" .. ("AB"):rep(7) } + local save = { player = { id = 200, name = "MAY" }, party = { carrier } } + T.check(Sync.rewriteSave(save, 1, target, owners), "Gen 1 raw carrier sync") + T.eq(carrier.cartRaw:sub(25, 28), "0064", "Gen 1 carrier numeric ID patched") + T.eq(Identity.monName(carrier), "RED", "Gen 1 carrier OT name patched") + T.eq(carrier.cartOt:sub(9), ("AB"):rep(7), "raw name tail preserved") + T.eq(#carrier.cartRaw, #raw, "raw record length preserved") + local raw2 = ("00"):rep(6) .. "00C8" .. ("00"):rep(24) + local c2 = { species = 99, otId = 200, ot = "MAY", cartRaw = raw2 } + T.check(Sync.rewriteSave({ player = { id = 200, name = "MAY" }, party = { c2 } }, 2, target, owners), + "Gen 2 carrier sync") + T.eq(c2.cartRaw:sub(13, 16), "0064", "Gen 2 raw ID patched") + T.check(c2.cartRawFp ~= nil, "Gen 2 carrier fingerprint updated") +end + +local realFs = love.filesystem +local function fresh() + local files, fault = {}, {} + love.filesystem = { + read = function(path) return files[path] end, + getInfo = function(path) return files[path] and { type = "file" } end, + createDirectory = function() return true end, + remove = function(path) files[path] = nil; return true end, + write = function(path, body) + if path == fault.path then + if fault.partial then files[path] = body:sub(1, math.floor(#body / 2)) end + return nil, "disk fault" + end + files[path] = body; return true + end, + } + SaveData.resetSlotState() + local a = SaveData.createSlot("emerald") + SaveData.writeSlot("emerald", a, { version = "emerald", engine = "game3", name = other.name, + trainerId = other.id, secretId = other.sid, meta = { playthroughId = "em-one" }, + party = { shinyMon(other, 123, 25) } }) + local b = SaveData.createSlot("firered") + SaveData.writeSlot("firered", b, { version = "firered", engine = "game3", name = target.name, + trainerId = target.id, secretId = target.sid, meta = { playthroughId = "fr-one" }, + party = { mon(target.id, target.sid, target.name) } }) + return files, fault, a, b +end +local function finish(job) + for _ = 1, 1000 do if job:step() then return end end + error("ID Sync did not finish") +end + +do + local files, _, a, b = fresh() + local state = Store.new() + state.nextId = 3 + local stored = { id = 1, generation = 3, version = "emerald", slotId = a, + mon = shinyMon(other, 123, 308), display = {}, depositorId = other.id + other.sid * 65536, + tags = "keep", archives = { { id = 1, generation = 2, version = "gold", display = {}, + mon = { species = "PIKACHU", otId = other.id, ot = other.name } } } } + state.boxes[1].mons[1] = stored + state.departures = { [2] = { entry = { id = 2, generation = 3, version = "emerald", + mon = shinyMon(other, 123), display = {} }, path = "saves/emerald/" .. a .. ".lua" } } + state.departures[2].identity = Records.identity(3, state.departures[2].entry.mon) + state.stages = {} + state.presets = { { name = "Team", ids = { 1, 2 } } } + local source = { version = "emerald", path = "saves/emerald/" .. a .. ".lua" } + local oldSave = SaveData.decode(files[source.path]) + state.progress = { [Gifts.identity(source, oldSave)] = 500 } + files[Store.PATH] = SaveData.encode(state) + local job = Sync.newJob({ scope = "firered", slot = b }) + finish(job) + T.eq(job.error, nil, "warehouse job succeeds") + T.check(job.boxUpdated, "warehouse is included in coordinated commit") + local saved = SaveData.decode(files[source.path]) + local box = SaveData.decode(files[Store.PATH]) + T.eq(box.boxes[1].mons[1].mon.otName, "RED", "stored OT name follows") + T.check(nativeShiny(box.boxes[1].mons[1].mon), "stored shiny preserved") + T.eq(box.boxes[1].mons[1].archives[1].mon.ot, "RED", "restorable archive identity follows") + T.eq(box.boxes[1].mons[1].depositorId, target.id + target.sid * 65536, "depositor ID follows") + T.eq(box.boxes[1].mons[1].tags, "keep", "labels preserved") + T.same(box.presets, state.presets, "team references preserved") + T.eq(box.progress[Gifts.identity(source, saved)], 500, "gift peak survives identity change") + T.eq(box.departures[2].identity, Records.identity(3, box.departures[2].entry.mon), "return identity refreshed") + T.eq(box.departures[2].identity, Records.identity(3, saved.party[1]), "return tracking matches updated game mon") + T.check(box.syncMembers["emerald/em-one"] ~= nil, "changed save linked to warehouse sync") + T.eq(files[Transaction.PATH], nil, "completed journal removed") + local before = SaveData.encode(box) + local again = Sync.newJob({ scope = "firered", slot = b }) + finish(again) + T.eq(again.error, nil, "repeat job succeeds") + T.eq(again.written, 0, "repeat job writes no saves") + T.eq(files[Store.PATH], before, "repeat job leaves warehouse unchanged") +end + +for _, mode in ipairs({ "changed", "unreadable", "pending", "partial" }) do + local files, fault, a, b = fresh() + local path = "saves/emerald/" .. a .. ".lua" + local original, source = files[path], files["saves/firered/" .. b .. ".lua"] + local Trade = require("src.online.Trade") + local pendingSentAt = Trade.pendingSentAt + if mode == "pending" then Trade.pendingSentAt = function() return { {} } end end + if mode == "unreadable" then files[path], files[path .. ".bak"] = "invalid save", "invalid backup" end + local job = Sync.newJob({ scope = "firered", slot = b }) + if mode == "changed" then + while job.phase == "read" do job:step() end + files[path] = original .. "\n-- changed externally" + elseif mode == "partial" then fault.path, fault.partial = path, true end + finish(job) + T.check(job.error ~= nil, mode .. " reports a failure") + T.eq(job.written, 0, mode .. " does not claim success") + T.eq(files["saves/firered/" .. b .. ".lua"], source, mode .. " source is untouched") + if mode == "partial" then + fault.path = nil + T.check(Transaction.recover(love.filesystem), "partial identity commit recovers") + T.eq(SaveData.decode(files[path]).party[1].otName, "RED", "recovery completes exact intended identity") + T.eq(files[path .. ".bak"], files[path], "recovery backup follows committed side") + else T.eq(files[Transaction.PATH], nil, mode .. " starts no transaction") end + Trade.pendingSentAt = pendingSentAt +end +love.filesystem = realFs + +do + local Gen3Save = require("src.save_convert.Gen3Save") + local codec = Gen3Save.forVersion("emerald") + local JP = codec.L.LANGUAGE_JAPANESE + local egg = { personality = 12345, otId = 1, otSecretId = 2, otName = "BRYAN", language = JP, + isEgg = true, isEggFlag = true, species = 1, nicknameBytes = codec.L.EGG_NICKNAME, + ivs = {}, evs = {}, contest = {}, moves = { 1, 0, 0, 0 }, pp = { 35, 0, 0, 0 } } + local decoded = codec.decodeBoxMon(codec.encodeBoxMon(egg)) + T.eq(decoded.otName, "BRYAN", "egg OT name decodes in the player's charset") + T.eq(decoded.nickname, "タマゴ", "egg nickname keeps its Japanese bytes") + local healed = { personality = 1, language = JP, isEgg = true, otName = "BRYAN", ot = "BRYAN", + cartExtra = { otNameRaw = { codec.encodeString("BRYAN", 8, 0xFF):byte(1, -1) } } } + T.check(Gen3Save.repairJapaneseNames({ healed }), "fullwidth egg OT name is healed") + T.eq(healed.otName, "BRYAN", "healed egg OT name") + T.eq(healed.ot, "BRYAN", "healed egg OT alias") + local flagged = { personality = 1, language = JP, cartExtra = { flagsRaw = 6 }, otName = "ASH1" } + T.check(Gen3Save.repairJapaneseNames({ flagged }), "egg flag byte alone marks an egg for healing") + T.eq(flagged.otName, "ASH1", "fullwidth digits heal too") + local japanese = { personality = 1, language = JP, otName = "さとし", nickname = "ピカチュウ", species = 25, + ivs = {}, evs = {}, contest = {}, moves = { 1, 0, 0, 0 }, pp = { 35, 0, 0, 0 } } + local bytes = codec.encodeBoxMon(japanese) + local back = codec.decodeBoxMon(bytes) + T.eq(back.otName, "さとし", "Japanese OT name decodes") + T.eq(codec.encodeBoxMon(back), bytes, "Japanese names round-trip their bytes") +end + +T.finish("trainer_identity_sync") diff --git a/tests/game3_stitchsave_schema_fields_test.lua b/tests/game3_stitchsave_schema_fields_test.lua index 1a5f3b32..37924c08 100644 --- a/tests/game3_stitchsave_schema_fields_test.lua +++ b/tests/game3_stitchsave_schema_fields_test.lua @@ -77,7 +77,7 @@ do flags = {}, vars = {}, } local loaded = Schema.fromSaveTable(old) - eq(loaded.secretId, nil, "no secretId in, no secretId out") + eq(loaded.secretId, 4242, "no secretId in, the owned mon's secret id is recovered at load") eq(loaded.safari, nil, "no safari block") eq(loaded.mail, nil, "no mail pool") eq(#loaded.party, 1, "the party is not quarantined") diff --git a/tests/game3_stitchsave_summary_test.lua b/tests/game3_stitchsave_summary_test.lua index 25239293..934d14d3 100644 --- a/tests/game3_stitchsave_summary_test.lua +++ b/tests/game3_stitchsave_summary_test.lua @@ -206,11 +206,11 @@ do dex = { seen = {}, owned = {} }, flags = {}, vars = {}, } local fixed = Schema.fromSaveTable(secretSave) - eq(fixed.party[1].otSecretId, rolled.secretId, - "a stale per-boot secret id on an own mon is repaired") + eq(fixed.party[1].otSecretId, ((rolled.secretId or 0) + 1) % 0x10000, + "a same-name same-TID mon with a different secret id keeps it on load") local ownLine = memoLine(fixed.party[1], fixed) - check(ownLine:find("a trade", 1, true) == nil, - "so it stops reading as a trade (" .. ownLine:gsub("\n", " ") .. ")") + check(ownLine:find("Apparently", 1, true) ~= nil, + "so it still reads as another trainer's mon (" .. ownLine:gsub("\n", " ") .. ")") eq(fixed.party[2].otSecretId, 4242, "a foreign mon keeps its own secret half") end diff --git a/tests/game3_trade_rules_test.lua b/tests/game3_trade_rules_test.lua index 2dae29c4..4efb5dfc 100644 --- a/tests/game3_trade_rules_test.lua +++ b/tests/game3_trade_rules_test.lua @@ -193,7 +193,7 @@ eq(order[1], "start", "a caller that faded for itself gets no lead-in") print("[test] 4. OT identity, the outsider rule and the met stamps") -- pokefirered/src/pokemon.c:5974 IsOtherTrainer -check(not Pokemon.isOtherTrainer(4242, "RED", session), "the player's own mon is not an outsider") +check(not Pokemon.isOtherTrainer(4242 + (session.secretId or 0) * 65536, "RED", session), "the player's own mon is not an outsider") check(Pokemon.isOtherTrainer(1985, "RED", session), "a different trainer id is an outsider") check(Pokemon.isOtherTrainer(4242, "BLUE", session), "the same id with another name is an outsider") -- pokefirered/src/pokemon.c:5985 @@ -355,4 +355,44 @@ eq(Trade.refusalText(Trade.CAN_TRADE_MON), nil, "a tradeable mon has no refusal check(Trade.badEggText():find("can't be taken", 1, true) ~= nil, "the bad-egg abort is pret's cable club text: " .. Trade.badEggText()) +print("[test] 8. The player's secret id settles before ownership is compared") +do + local Catching = require("src.core.game3.battle.catching") + local Schema = require("src.core.game3.save_schema_firered") + local legacy = { version = "firered", name = "RED", trainerId = 4242, party = {} } + check(Party.giveMon(legacy, 4, 5, ""), "a gift mon on a save with no secret id") + local starter = legacy.party[1] + check(starter.otSecretId ~= nil, "the gift mon is stamped with a secret id") + eq(Catching.playerSecretId(legacy), starter.otSecretId, "a wild battle reuses the gift mon's secret id") + check(not Pokemon.isTradedMon(starter, legacy), "the gift mon stays the player's own after a wild battle") + local bare = { name = "RED", trainerId = 4242, party = { { species = 4, otId = 4242, otName = "RED" } } } + eq(Catching.playerSecretId(bare), 0, "an own mon with no secret id pins the player's to zero, not a random roll") + check(not Pokemon.isTradedMon(bare.party[1], bare), "so that mon is never counted as traded") + + local function save(secretId, party) + return { schemaVersion = 1, engine = "game3", version = "firered", name = "RED", trainerId = 4242, + secretId = secretId, party = party, dex = { seen = {}, owned = {} }, + map = "FR_PALLET_TOWN", x = 5, y = 6, facing = "down", flags = {}, vars = {} } + end + local loaded = Schema.fromSaveTable(save(42, { + { species = 4, speciesId = 4, level = 5, hp = 20, maxHp = 20, otId = 4242, otName = "RED" }, + { species = 7, speciesId = 7, level = 5, hp = 20, maxHp = 20, otId = 4242, otName = "RED", otSecretId = 7 }, + })) + eq(loaded.party[1].otSecretId, 42, "a missing own secret id is filled from the player's") + eq(loaded.party[2].otSecretId, 7, "a foreign same-name same-TID mon keeps its secret id on load") + check(Pokemon.isTradedMon(loaded.party[2], loaded), "and is still an outsider") + local again = Schema.fromSaveTable(Schema.toSaveTable(loaded)) + eq(again.party[2].otSecretId, 7, "a second save and load still leaves the foreign secret id alone") + check(Pokemon.isTradedMon(again.party[2], again), "and it is still an outsider after the reload") + check(not Pokemon.isTradedMon(again.party[1], again), "the player's own mon stays own") + + local old = Schema.fromSaveTable(save(nil, { + { species = 4, speciesId = 4, level = 5, hp = 20, maxHp = 20, otId = 4242, otName = "RED" }, + })) + eq(old.secretId, 0, "a save with no secret ids anywhere settles the player's to zero at load") + eq(old.party[1].otSecretId, 0, "and stamps it on the own mon") + eq(Catching.playerSecretId(old), 0, "a later wild battle keeps that secret id") + check(not Pokemon.isTradedMon(old.party[1], old), "so the starter is never traded") +end + finish() diff --git a/tests/native/box_sync/conf.lua b/tests/native/box_sync/conf.lua new file mode 100644 index 00000000..aff60b38 --- /dev/null +++ b/tests/native/box_sync/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity = "g1r-box-sync-native-" .. tostring(os.getenv("BOX_TEST_RUN")) + t.version = "11.5" + t.window.width, t.window.height = 1024, 768 + t.window.title = "Box sync verification" +end diff --git a/tests/native/box_sync/main.lua b/tests/native/box_sync/main.lua new file mode 100644 index 00000000..b611af11 --- /dev/null +++ b/tests/native/box_sync/main.lua @@ -0,0 +1,144 @@ +io.stdout:setvbuf("no") +function love.errorhandler(why) + print("BOX_NATIVE_FAIL bootstrap: " .. tostring(why)) + return function() return 1 end +end +local repo = assert(os.getenv("BOX_TEST_REPO")) +local url = assert(os.getenv("BOX_TEST_URL")) +assert(url:match("^http://127%.0%.0%.1:%d+$"), "loopback server required") +assert(love.filesystem.getInfo("src/net/fetch_worker.lua"), "native test must bundle the project sources") +package.path = repo .. "/?.lua;" .. repo .. "/?/init.lua;" .. package.path +local realFs = love.filesystem +local SaveData = require("src.core.SaveData") +local Serializer = require("src.core.SaveSerializer") +local Store = require("src.box.Store") +local Engine = require("src.sync.SyncEngine") +local SyncState = require("src.sync.SyncState") +local SyncBox = require("src.sync.SyncBox") +local Fetch = require("src.net.Fetch") +local checks, stage, shots = 0, "starting", {} +local function check(value, label) + checks = checks + 1; assert(value, label) +end +local function deviceFs(name) + local fs = setmetatable({}, { __index = realFs }) + for _, method in ipairs({ "read", "write", "getInfo", "remove", "createDirectory", "getDirectoryItems" }) do + fs[method] = function(path, ...) return realFs[method](name .. "/" .. path, ...) end + end + fs.getSaveDirectory = function() return realFs.getSaveDirectory() .. "/" .. name end + fs.getSource, fs.getWorkingDirectory, fs.getSourceBaseDirectory = realFs.getSource, realFs.getWorkingDirectory, realFs.getSourceBaseDirectory + fs.getRealDirectory, fs.load = realFs.getRealDirectory, realFs.load + return fs +end +local function context(fs) + love.filesystem = fs; SaveData.resetSlotState() +end +local function write(fs, path, value) + local parent = path:match("^(.*)/[^/]+$"); if parent then assert(fs.createDirectory(parent)) end + assert(fs.write(path, Serializer.encode(value))) +end +local function newEngine(fs) + context(fs) + local state = SyncState.defaults(); state.enabled = true + return Engine.new({ fs = fs, state = state, baseUrl = url }) +end +local function pump(e, fs) + local deadline = love.timer.getTime() + 15 + repeat + context(fs); e:update(1 / 60); coroutine.yield() + assert(love.timer.getTime() < deadline, "sync timeout") + until not e:busy() + check(e.phase ~= "error", tostring(e.error)) +end +local function read(fs, p) + local body = fs.read(p) + return assert(Serializer.decode(body)) +end +local function run() + local fsA, fsB = deviceFs("a"), deviceFs("b") + local state = Store.new(); state.nextId = 2 + state.boxes[1].mons[1] = { id = 1, version = "red", generation = 1, slotId = "slot1", + mon = { species = "PIKACHU", level = 12, opaque = "\0\255" }, display = {} } + state.syncMembers = { ["red/loopback"] = { version = "red", playthroughId = "loopback", slotId = "slot1", path = "saves/red/slot1.lua" } } + state.presets = { { name = "Native team", ids = { 1 } } } + state.stages = { require("src.box.Showcase").new() } + state.gciTemplate = string.rep("GCI template fixture", 300000) + local pixels = love.image.newImageData(1136, 432) + pixels:mapPixel(function() return 0.15, 0.35, 0.5, 1 end) + state.boxes[1].theme, state.boxes[1].wallpaper = "Showcase", "box/showcase/1.png" + assert(fsA.createDirectory("box/showcase")); local png = pixels:encode("png"):getString() + assert(fsA.write(state.boxes[1].wallpaper, png)); pixels:release() + write(fsA, Store.PATH, state) + write(fsA, "saves/red/slot1.lua", { version = "red", generation = 1, player = { name = "RED" }, party = {}, boxes = { {} }, + pokedex = { seen = {}, owned = {} }, inventory = {}, meta = { playthroughId = "loopback", savedAt = 1700000000 } }) + local A = newEngine(fsA); stage = "uploading native HTTP snapshot" + assert(A:createAccount("native test A")); pump(A, fsA) + check(A.state.boxRev == 1, "first collection revision") + check(#A.box:snapshot(A.saves.list(), A.state).payload.blob > 4 * 1024 * 1024, "reply exceeds ordinary save limit") + write(fsB, "saves/red/slot1.lua", { version = "red", player = { name = "LOCAL" }, meta = { playthroughId = "independent", savedAt = 1700000000 } }) + write(fsB, "options.lua", { saveSlots = { red = { list = { "slot1" }, active = "slot1" } }, custom = "device B" }) + local B = newEngine(fsB); stage = "receiving on second device" + assert(B:linkDevice(A.state.code1, A.state.code2, "native test B")); pump(B, fsB) + local remote = read(fsB, Store.PATH) + check(remote.syncMembers["red/loopback"].slotId == "slot2", "production adapter remaps slots") + check(remote.boxes[1].mons[1].slotId == "slot2", "origin remapped") + check(remote.gciTemplate == state.gciTemplate, "large template unchanged") + check(remote.presets[1].name == "Native team", "team synced") + check(remote.stages[1].name == state.stages[1].name, "stage synced") + check(fsB.read("box/showcase/1.png") == png, "real PNG synced") + check(read(fsB, "options.lua").custom == "device B", "device options retained") + check(read(fsB, "saves/red/slot2.lua").meta.playthroughId == "loopback", "production save routing") + local receivedRev = B.state.boxRev; assert(B:syncNow()); pump(B, fsB) + check(B.state.boxRev == receivedRev, "no redundant upload after remapping") + context(fsA); local s = read(fsA, Store.PATH); s.boxes[1].name = "A offline edit"; write(fsA, Store.PATH, s) + assert(A:syncNow()); pump(A, fsA) + context(fsB); s = read(fsB, Store.PATH); s.boxes[1].name = "B offline edit"; write(fsB, Store.PATH, s) + assert(B:syncNow()); pump(B, fsB) + check(B.conflicts[1] and B.conflicts[1].box, "production HTTP conflict") + check(not B:resolveConflict(SyncBox.KEY, "both"), "whole collection choice required") + stage = "capturing Box conflict" + local imp = require("src.import.RomImporter").new(function() end, { launcher = true }) + imp._sync, imp._syncTransportOk = B, true; imp:_openSync() + local previousDraw = love.draw + local pending + love.draw = function() + imp:draw() + if pending then + local p = pending; pending = nil + love.graphics.captureScreenshot(function(image) + local encoded = image:encode("png"):getString() + assert(realFs.write(p, encoded)); shots[#shots + 1] = p + end) + end + end + for _, view in ipairs({ { 1024, 768, "conflict-desktop.png" }, { 390, 844, "conflict-portrait.png" } }) do + love.window.setMode(view[1], view[2], { resizable = true }) + for _ = 1, 3 do imp:update(1 / 60); coroutine.yield() end + pending = view[3]; while pending do coroutine.yield() end + for _ = 1, 3 do coroutine.yield() end + end + love.draw = previousDraw + check(#shots == 2, "desktop and portrait conflict rendered") + assert(B:resolveConflict(SyncBox.KEY, "remote")); pump(B, fsB) + check(read(fsB, Store.PATH).boxes[1].name == "A offline edit", "remote collection selected") + check(B.box:latestBackup() ~= nil, "recoverable losing snapshot") + check(SyncState.load(fsB).boxRev == B.state.boxRev, "sync baseline persisted") + stage = "complete" + love.filesystem = realFs + Fetch.shutdown() + print(("BOX_NATIVE_PASS %d checks; screenshots: %s"):format(checks, realFs.getSaveDirectory())) + love.event.quit(0) +end +local co +function love.load() co = coroutine.create(run) end +function love.update() + if coroutine.status(co) == "dead" then return end + local ok, why = coroutine.resume(co) + if not ok then + print("BOX_NATIVE_FAIL " .. stage .. ": " .. tostring(why)); love.filesystem = realFs + Fetch.shutdown(); love.event.quit(1) + end +end +function love.draw() + love.graphics.clear(0.04, 0.06, 0.1); love.graphics.print(stage, 30, 30) +end diff --git a/tests/native/box_ui/conf.lua b/tests/native/box_ui/conf.lua new file mode 100644 index 00000000..c71b7206 --- /dev/null +++ b/tests/native/box_ui/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity = "g1r-box-ui-native-" .. assert(os.getenv("BOX_UI_RUN")) + t.window.title = "Box UI verification" + t.window.width, t.window.height = 1360, 860 + t.window.resizable = true +end diff --git a/tests/native/box_ui/main.lua b/tests/native/box_ui/main.lua new file mode 100644 index 00000000..76ffd73a --- /dev/null +++ b/tests/native/box_ui/main.lua @@ -0,0 +1,476 @@ +local root = assert(os.getenv("BOX_UI_REPO")) +io.stdout:setvbuf("no") +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local imp +local co, pending, controls, checks = nil, nil, {}, 0 +local awards={} +local function check(value, label) checks = checks + 1; assert(value, label) end +function love.load() + local CacheFs = require("src.import.CacheFs") + local CacheBlob = require("src.import.CacheBlob") + CacheFs.readAt = function(path) + local file + for cache in assert(os.getenv("BOX_UI_CACHES")):gmatch("[^:]+") do + file=io.open(cache.."/"..path,"rb") + if file then break end + end + if not file then return nil end + local bytes = file:read("*a"); file:close() + return CacheBlob.decode(path, bytes) + end + local SaveData = require("src.core.SaveData") + local Serializer = require("src.core.SaveSerializer") + local Store = require("src.box.Store") + local Service = require("src.box.Service") + local Catalog = require("src.box.Catalog") + local state = Store.new() + local save = { version = "emerald", generation = 3, engine = "game3", name = "Demo", + trainerId=42,secretId=97,flags={},vars={},modData={},bag={pockets={}}, + party = { { species = 25, level = 20, hp = 52, otId = 42, otSecretId = 97, personality = 345 } }, + storage = { currentBox = 1, boxes = { { name = "Demo PC", wallpaper = 1, mons = {} } } } } + local species = {1,4,7,25,133,152,155,158,175,246,151,251,52,54,56,58,60,63,66,69,72,74,77,79,81,83,84,86,88,90,92,95,96,98,100,102,104,108,109,111,113,114,115,116,118,120,122,123,127,128,129,131,132,137,147,149,154,157,160,165} + for i, id in ipairs(species) do + local mon = { species = id, level = 12 + i, hp = 40, personality = i * 1001, + otId = 42, otSecretId = 97, otName = "Demo", moves = { 33,45 }, pp={30,35}, friendship=70, language=2, + ivs={hp=15,atk=15,def=15,spe=15,spa=15,spd=15},evs={},heldItem=0, + exp=require("src.core.game3.summary_data").expForLevel(Catalog.get("emerald").meta[id].growthRate,12+i), metGame=3,metLevel=5,metLocation=7,pokeball=4 } + if i == 2 then + mon.markings, mon.pokerus, mon.modernFatefulEncounter = 5, 0x23, false + mon.contest = { cool=0, beauty=80, cute=70, smart=45, tough=54, sheen=15 } + mon.ribbons = { cool=2, champion=1, artist=1 } + end + if i <= 24 then save.storage.boxes[1].mons[i] = Store.copy(mon) end + state.boxes[1].mons[i] = { id = i, version = "emerald", generation = 3, + mon = mon, display = Catalog.describe("emerald", mon) } + end + state.boxes[1].name, state.boxes[2].name, state.boxes[3].name = "Kanto collection", "Starters", "Rare finds" + for i = 1, 12 do + local entry = Store.copy(state.boxes[1].mons[i]); entry.id = 60 + i + state.boxes[2].mons[i] = entry + end + for i = 1, 6 do + local entry = Store.copy(state.boxes[1].mons[i + 8]); entry.id = 72 + i + state.boxes[3].mons[i * 3] = entry + end + state.nextId = 79 + local Showcase=require("src.box.Showcase") + local stage=Showcase.new();stage.name="Starter garden";stage.pattern="Dots" + stage=assert(Showcase.add(stage,{{box=1,slot=1},{box=1,slot=2},{box=1,slot=3}},state)) + for i,piece in ipairs(stage.pieces) do piece.x=.28+(i-1)*.22;piece.y=.5;piece.scale=1.4 end + stage.pieces[#stage.pieces+1]={kind="Platform",x=.5,y=.75,scale=2,rotation=0,flip=false} + state=assert(Showcase.save(state,1,stage)) + local fs = SaveData.persistenceFs() + fs.createDirectory("box"); fs.createDirectory("saves/emerald") + fs.write(Store.PATH, Serializer.encode(state)) + fs.write("saves/emerald/slot1.lua", Serializer.encode(save)) + local second = Store.copy(save); second.name = "Other trainer"; second.trainerId = 99 + if os.getenv("BOX_UI_ID_SYNC_ONLY") then + second.name, second.secretId = "May", 123 + for _, mon in ipairs(require("src.core.TrainerIdSync").monsOf(second, 3)) do + mon.otId, mon.otSecretId, mon.otName = 99, 123, "May" + mon.personality = require("bit").bxor(99, 123, 12345) * 65536 + 12345 + end + local opts = SaveData.loadOptions() + opts.saveSlots = opts.saveSlots or {} + opts.saveSlots.emerald = { active = "slot1", list = { "slot1", "slot2" } } + SaveData.saveOptions(opts) + end + fs.write("saves/emerald/slot2.lua", Serializer.encode(second)) + imp = require("src.import.RomImporter").new(function() end, { launcher = true }) + imp.tab = "box" + imp.mods, imp.modStraysChecked = {}, true + imp.findErrors, imp._findFetch, imp.findLoading, imp.findNotice = {}, nil, false, nil + imp._refreshFindSources = function() end + imp._refreshFind = function() end + imp.findLoaded, imp.findSources = true, { { feed = "https://example.invalid/mods.json" } } + imp.findIndex = { mods = {}, carts = {} } + imp._ensureMods = function() end + imp._ensureFind = function() end + local pcRows = {} + for i, mon in ipairs(save.storage.boxes[1].mons) do + pcRows[i] = { where = "box", box = 1, index = i, key = "box:1:" .. i, + source = "Demo PC", mon = mon, entry = { version = "emerald", generation = 3, + mon = mon, display = Catalog.describe("emerald", mon) } } + end + imp._boxState = { service = assert(Service.open()), sources = { + { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua", label = "Emerald · Demo" }, + { version = "emerald", slotId = "slot2", path = "saves/emerald/slot2.lua", label = "Emerald · Other trainer" } }, + sourceIndex = 1, box = 1, pcBox = 1, query = "", sort = "slot", pcRows = pcRows, + pcBody = Serializer.encode(save), selectedBox = {}, selectedPC = {}, pageBox = 1, + pagePC = 1, sourcePage = 1, pcSave = save, multi = true, view = "box" } + for _, slot in ipairs({ 1, 2 }) do imp._boxState.selectedBox["1:" .. slot] = { box = 1, slot = slot } end + imp._boxState.inspect = imp._boxState.service.state.boxes[1].mons[2] + local View = require("src.import.LauncherView") + local original = View.btn + View.btn = function(owner, x, y, w, h, id, label, opts) + controls[id] = { x=x, y=y, w=w, h=h, label=label, enabled=opts.enabled ~= false, icon=opts.icon } + return original(owner, x, y, w, h, id, label, opts) + end + local Icons=require("src.ui.kit.Icons") + local drawIcon=Icons.draw + Icons.draw=function(icon,x,y,...) + if icon=="award" then awards[#awards+1]={x=x,y=y} end + return drawIcon(icon,x,y,...) + end + co = coroutine.create(run) + +end +local UI = require("src.import.BoxUI") +local Transition = require("src.ui.kit.Transition") +local function wait(seconds) + local untilAt = love.timer.getTime() + (seconds or .3) + repeat coroutine.yield() until love.timer.getTime() >= untilAt +end +local function click(id) + wait() + local o=imp._boxState and imp._boxState.organizer + local r = assert(controls[id], "missing control " .. id.." (rule "..tostring(o and o.ruleIndex).. + ", mode "..tostring(o and o.config.mode)..", page "..tostring(imp._boxState and imp._boxState.toolsPage)..")") + if not imp._boxPopup and not imp._modConfirm and not imp._idSyncResult then + local view = imp._tabRegionRect + if r.y < view.y or r.y+r.h > view.y+view.h then + imp._tabScroll.box = math.max(0, math.min(imp._tabScrollMax.box, imp._tabScroll.box + r.y - view.y - 20)) + wait(); r = assert(controls[id]) + end + end + check(r.w > 0 and r.y >= 0 and r.y+r.h <= love.graphics.getHeight(), id .. " reachable") + imp:mousepressed(r.x+r.w/2, r.y+r.h/2, 1) + if imp.mousereleased then imp:mousereleased(r.x+r.w/2, r.y+r.h/2, 1) end + wait() +end +local function shot(name) + wait(); pending = name + repeat coroutine.yield() until pending == nil + wait(.05) +end +local function close() + imp:keypressed("escape"); wait() + check(not imp._boxPopup, "escape closed the popup") +end +local function page(name) + imp._tabScroll.box = 0 + if name=="Filters" then + imp._boxState.toolsPage=nil;wait();click("box-filters") + check(imp._boxState.toolsPage=="Filters","filter button opens filters") + return + end + click("box-tools-menu") + local option + for i,row in ipairs(imp._boxPopup.options) do if row.id == name then option=i end end + assert(option, name) + imp._boxPopup.index, imp._boxPopup.reveal = option, true + imp:keypressed("return"); wait() + check(imp._boxState.toolsPage == (name ~= "Pokémon" and name or nil), "tool picker chose " .. name) +end +local function chooseValue(id, value) + click(id) + local index + for i, option in ipairs(imp._boxPopup.options) do if option.id == value then index = i; break end end + assert(index, "missing choice " .. tostring(value)) + imp._boxPopup.index, imp._boxPopup.reveal = index, true + imp:keypressed("return"); wait() +end +local function boxInventory(state) + local out = {} + for _, box in ipairs(state.boxes) do + for _, entry in pairs(box.mons) do out[entry.id] = require("src.core.SaveSerializer").encode(entry.mon) end + end + return require("src.core.SaveSerializer").encode(out) +end +local function pcInventory(save) + local out = {} + for _, row in ipairs(require("src.box.Records").candidates(save, 3)) do + if row.where == "box" then out[#out + 1] = require("src.core.SaveSerializer").encode(row.mon) end + end + table.sort(out) + return require("src.core.SaveSerializer").encode(out) +end +function run() + wait() + if os.getenv("BOX_UI_STUDIO_ONLY") then + page("Showcase") + shot("ui-40-showcase-editor-desktop.png") + click("box-stage-add-pokemon") + check(#imp._boxPopup.options>50,"Pokémon picker makes stored collection available") + shot("ui-41-showcase-add-pokemon-desktop.png") + imp._boxPopup.index=2;imp:keypressed("return");wait() + local s=imp._boxState + check(#s.stageDraft.pieces==5,"picker adds a Pokémon directly to the stage") + click("box-stage-add-scenery") + local tree + for i,row in ipairs(imp._boxPopup.options) do if row.label=="Tree" then tree=i end end + imp._boxPopup.index=tree;imp:keypressed("return");wait() + check(s.stageDraft.pieces[s.stagePiece].kind=="Tree","new scenery is a real tree") + local r=s.stageRect + local p=s.stageDraft.pieces[s.stagePiece] + local cx,cy=r.x+p.x*r.w,r.y+p.y*r.h + imp:mousepressed(cx,cy,1);imp:mousemoved(cx+30,cy-20);imp:mousereleased(cx+30,cy-20,1);wait() + check(p.x>.5 and p.y<.5,"native scene dragging changes placement") + local handles=s.stageHandles + local resize=handles[1] + imp:mousepressed(resize.x+resize.w/2,resize.y+resize.h/2,1) + imp:mousemoved(resize.x+resize.w/2+35,resize.y+resize.h/2-10) + imp:mousereleased(0,0,1);wait() + check(p.scale>1.5,"hold resize changes scenery scale") + local flip=s.stageHandles[3] + imp:mousepressed(flip.x+flip.w/2,flip.y+flip.h/2,1);wait() + check(p.flipY,"vertical flip toggles from pop-out") + flip=s.stageHandles[3] + imp:mousepressed(flip.x+flip.w/2,flip.y+flip.h/2,1);wait() + check(not p.flipY,"vertical flip can be restored from pop-out") + click("box-tools-stage-back") + check(s.stagePiece==#s.stageDraft.pieces-1,"layer arrow sends selected object backward") + shot("ui-42-showcase-selected-tools-desktop.png") + chooseValue("box-tools-stageSection","Scene") + chooseValue("box-tools-stage-music","Night") + local Showcase=require("src.box.Showcase") + local fingerprints={} + for i,pattern in ipairs({"Crosses","Bricks","Grid"}) do + chooseValue("box-tools-stage-pattern",pattern) + imp._tabScroll.box=0 + shot("ui-"..(46+i).."-showcase-"..pattern:lower().."-desktop.png") + local canvas=love.graphics.newCanvas(400,200) + love.graphics.push("all");love.graphics.setCanvas(canvas);love.graphics.origin();love.graphics.setScissor() + local empty=Showcase.new();empty.pattern=pattern;Showcase.draw(s.service.state,empty,0,0,400,200) + love.graphics.pop() + local data=canvas:newImageData();fingerprints[i]=love.data.hash("sha256",data:getString()) + data:release();canvas:release() + end + check(fingerprints[1]~=fingerprints[2] and fingerprints[2]~=fingerprints[3] and fingerprints[1]~=fingerprints[3], + "Crosses, Bricks and Grid render different native pixels") + click("box-stage-options") + imp._boxPopup.index=1;imp:keypressed("return");wait() + check(s.service.state.boxes[1].theme=="Showcase","saved scene is applied as Box theme") + check(s.service.fs.getInfo(s.service.state.boxes[1].wallpaper),"scene wallpaper PNG exists") + check(s.service.state.boxes[1].showcaseMusic=="Night","applying scene saves its chosen music") + page("Pokémon") + local track,owner,music=Showcase.currentMusic() + check(track=="Night" and owner=="box" and music:isPlaying(),"applied scene music plays on native Box board") + shot("ui-50-applied-stage-desktop.png") + page("Themes");click("box-theme-picker") + local saved + for i,row in ipairs(imp._boxPopup.options) do if row.label:find("Stage 1",1,true) then saved=i end end + check(saved~=nil,"saved stages are selectable from Themes") + shot("ui-43-saved-stage-theme-desktop.png");close() + page("Showcase");love.window.setMode(390,844,{resizable=true});imp._tabScroll.box=0;wait() + shot("ui-44-showcase-editor-portrait.png") + page("Pokémon") + s.query="bULba";wait();shot("ui-45-search-context-portrait.png") + check(#require("src.box.Store").search(s.service.state,s.query,"slot")>0,"mixed-case Pokémon lookup matches") + page("List");shot("ui-46-list-sprites-portrait.png") + love.window.setMode(1360,860,{resizable=true});s.query="";page("Pokémon") + local Store=require("src.box.Store") + local edited=Store.copy(s.service.state);edited.revision=edited.revision+1 + local shiny=edited.boxes[1].mons[1] + shiny.mon.personality=require("bit").bxor(42,97,12345)*65536+12345 + shiny.display=require("src.box.Catalog").describe("emerald",shiny.mon) + check(shiny.display.shiny,"move fixture is actually shiny") + assert(s.service:commit(edited));s.selectedBox={["1:1"]={box=1,slot=1}};s.inspect=nil;wait() + local function marked(r) + for _,a in ipairs(awards) do if a.x>=r.x and a.x<=r.x+r.w and a.y>=r.y and a.y<=r.y+r.h then return true end end + return false + end + check(marked(controls["box-box-1:1"]),"occupied shiny slot has its marker") + click("box-options");imp._boxPopup.index=1;imp:keypressed("return");wait() + chooseValue("box-active-box-picker",2);click("box-box-2:20") + chooseValue("box-active-box-picker",1) + check(s.service.state.boxes[1].mons[1]==nil,"moving Pokémon empties its old slot") + check(not marked(controls["box-box-1:1"]),"empty former slot has no leftover shiny marker") + shot("ui-51-moved-shiny-empty-slot-desktop.png") + imp:_switchTab("mods");wait() + imp:_switchTab("box") + local tr=assert(Transition.get("tabs"),"entering Box keeps its slide") + check(tr.from=="mods" and tr.to=="box" and love.timer.getTime()-tr.t0<.05,"native Box slide starts after refresh") + wait(.03) + tr=assert(Transition.get("tabs"),"default Box transition survives initial native rendering") + check(tr.p>0 and tr.p<1,"default native Box slide renders an intermediate frame") + tr.t0=love.timer.getTime() + tr.dur=1.5 + shot("ui-52-box-tab-slide-desktop.png") + tr.dur=.18;wait() + print("BOX_UI_NATIVE_PASS "..checks.." checks; "..love.filesystem.getSaveDirectory()) + love.event.quit(0);return + end + if os.getenv("BOX_UI_ID_SYNC_ONLY") then + imp.activeSlot.emerald = "slot1" + imp:askIdSync("emerald"); wait() + check(imp._modConfirm.lines[1]:find("Demo", 1, true), "confirmation names actual trainer") + check(imp._modConfirm.lines[2]:find("secret ID", 1, true), "confirmation covers full trainer identity") + shot("ui-36-id-sync-confirm-desktop.png") + love.window.setMode(390,844,{resizable=true}); wait() + shot("ui-37-id-sync-confirm-portrait.png") + local accept = imp._modConfirm.onYes + imp._modConfirm = nil + accept() + while imp._idSync do wait(.05) end + check(imp._idSyncResult and imp._idSyncResult.title == "ID Sync", "native launcher job succeeds") + local SaveData = require("src.core.SaveData") + local other = SaveData.decode(SaveData.readSlotSource("emerald", "slot2")) + check(other.name == "Demo" and other.trainerId == 42 and other.secretId == 97, + "native disk save matches selected trainer") + check(not require("src.core.game3.pokemon").isTradedMon(other.party[1], other), + "native mon obeys after reload") + check(require("src.core.game3.pokemon").isShiny(other.party[1]), "native shiny remains shiny after reload") + check(not require("src.core.TrainerIdSync").newJob({scope="emerald",slot="slot1"}).error, + "completed journal leaves storage usable") + shot("ui-38-id-sync-result-portrait.png") + love.window.setMode(1360,860,{resizable=true}); wait() + shot("ui-39-id-sync-result-desktop.png") + print("BOX_UI_NATIVE_PASS "..checks.." checks; "..love.filesystem.getSaveDirectory()) + love.event.quit(0) + return + end + if not os.getenv("BOX_UI_ORGANIZER_ONLY") then + if not os.getenv("BOX_UI_DETAILS_ONLY") then + check(controls["box-choose-save"].y == controls["box-tools-menu"].y, "desktop toolbar shares one row") + check(controls["box-withdraw"].icon ~= nil, "transfer has an icon") + check(controls["box-withdraw"].label:find("Send 2 to game",1,true), "transfer direction is named") + shot("ui-01-board-desktop.png") + click("box-choose-save"); check(#imp._boxPopup.options==2,"game picker shows playthroughs") + shot("ui-02-game-picker-desktop.png"); close() + click("box-help-Game"); check(imp._boxPopup.message==UI.HELP.Game,"game help explains the PC") + shot("ui-03-game-help-desktop.png"); close() + click("box-tools-menu"); check(#imp._boxPopup.options==11,"all tools remain reachable; filters have their own button") + shot("ui-04-tool-picker-desktop.png"); close() + page("Filters"); shot("ui-05-filters-desktop.png") + click("box-tools-filter-type"); shot("ui-06-filter-picker-desktop.png"); close() + page("Gifts"); shot("ui-07-gifts-desktop.png") + page("Dashboard"); shot("ui-08-dashboard-desktop.png") + page("Showcase"); shot("ui-09-showcase-desktop.png") + click("box-tools-stageSection"); imp._boxPopup.index=2; imp:keypressed("return"); wait() + check(imp._boxState.stageSection=="Placement","placement section chosen") + shot("ui-10-placement-desktop.png") + page("Pokémon") + click("box-options"); shot("ui-11-options-desktop.png"); close() + love.window.setMode(390,844,{resizable=true}); imp._tabScroll.box=0; wait() + shot("ui-12-board-portrait.png") + click("box-tools-menu"); shot("ui-13-tool-picker-portrait.png"); close() + page("Filters"); shot("ui-14-filters-portrait.png") + click("box-help-Game"); shot("ui-15-help-portrait.png"); close() + page("Showcase"); shot("ui-16-showcase-portrait.png") + page("Pokémon") + click("box-view-pc"); check(imp._boxState.view=="pc","game PC view chosen") + imp._tabScroll.box=0; shot("ui-17-game-pc-portrait.png") + click("box-view-box") + click("box-transfer-target"); shot("ui-18-destination-portrait.png") + imp:keypressed("end"); wait(); imp:keypressed("return"); wait() + check(imp._boxState.pcBox==14,"destination picker reaches the last PC box") + shot("ui-19-transfer-toolbar-portrait.png") + imp._tabScroll.box=0 + Transition.armed=true; UI.change(imp,imp._boxState,"view","pc",1) + local tr=assert(Transition.get("box")); tr.t0=love.timer.getTime()-.03 + pending="ui-20-slide-portrait.png"; coroutine.yield() + wait() + check(not Transition.active("box"),"slide settles") + end + love.window.setMode(1360,860,{resizable=true}); imp._tabScroll.box=0; wait() + click("box-summary"); check(imp._boxState.summary,"summary opens") + shot("ui-21-summary-desktop.png") + click("box-summary-section"); imp._boxPopup.index=2; imp:keypressed("return"); wait() + check(imp._boxState.summarySection=="Moves","moves section chosen") + shot("ui-22-moves-desktop.png") + click("box-summary-section"); imp._boxPopup.index=3; imp:keypressed("return"); wait() + check(imp._boxState.summarySection=="Stats","stats section chosen") + shot("ui-23-stats-desktop.png") + click("box-summary-section"); imp._boxPopup.index=4; imp:keypressed("return"); wait() + shot("ui-24-origin-desktop.png") + local r=assert(controls["box-summary-back"]) + local viewport=imp._tabRegionRect + imp._tabScroll.box=math.max(0,math.min(imp._tabScrollMax.box,imp._tabScroll.box+r.y-viewport.y-20)); wait() + shot("ui-25-ribbons-desktop.png") + love.window.setMode(390,844,{resizable=true}); imp._tabScroll.box=0; wait() + click("box-summary-section"); imp._boxPopup.index=1; imp:keypressed("return"); wait() + shot("ui-26-overview-portrait.png") + click("box-summary-section"); imp._boxPopup.index=2; imp:keypressed("return"); wait() + shot("ui-27-moves-portrait.png") + click("box-summary-section"); imp._boxPopup.index=3; imp:keypressed("return"); wait() + shot("ui-28-stats-portrait.png") + r=assert(controls["box-summary-back"]); viewport=imp._tabRegionRect + imp._tabScroll.box=math.max(0,math.min(imp._tabScrollMax.box,imp._tabScroll.box+r.y-viewport.y-20)); wait() + shot("ui-29-contest-portrait.png") + end + if not os.getenv("BOX_UI_DETAILS_ONLY") then + if imp._boxState.summary then click("box-summary-back") end + love.window.setMode(390,844,{resizable=true}); imp._tabScroll.box = 0; wait() + page("Arrange") + chooseValue("box-tools-arrangeSection", "Auto Box") + chooseValue("box-organize-first-box", 2) + chooseValue("box-organize-field", "species") + chooseValue("box-organize-value", "PIKACHU") + check(imp._boxState.organizer.config.rules[1].firstBox == 2, "Auto Box destination is selected") + check(imp._boxState.organizer.config.rules[1].conditions[1].value == "PIKACHU", "Auto Box species condition is selected") + imp._tabScroll.box = 0; shot("ui-30-auto-box-portrait.png") + local warehouseBefore = boxInventory(imp._boxState.service.state) + click("box-organize-preview") + check(boxInventory(imp._boxState.service.state) == warehouseBefore, "rule preview leaves warehouse unchanged") + check(imp._boxState.organizer.preview.report.moved > 0, "rule preview reports actual moves") + check(imp._boxState.organizer.ruleIndex==1,"sticky preview does not click rule underneath") + shot("ui-31-auto-box-preview-portrait.png") + close() + love.window.setMode(1360,860,{resizable=true}); imp._tabScroll.box = 0; wait() + shot("ui-32-auto-box-desktop.png") + check(imp._boxState.organizer.preview ~= nil, "closing movement review keeps the plan") + click("box-organize-apply") + check(boxInventory(imp._boxState.service.state) == warehouseBefore, "Auto Box preserves every native record and entry ID") + local at = require("src.box.Store").find(imp._boxState.service.state, 4) + check(at.box == 2, "Auto Box applies species rule") + click("box-organize-presets") + imp._boxPopup.index = #imp._boxPopup.options; imp:keypressed("return"); wait() + check(imp._boxPrompt ~= nil, "preset uses the existing name prompt") + imp:textinput("Pikachu box"); imp:keypressed("return"); wait() + check(imp._boxState.service.state.organizerProfiles[1].name == "Pikachu box", "preset saved from UI") + chooseValue("box-organize-field", "egg") + click("box-organize-presets") + imp._boxPopup.index = 1; imp:keypressed("return"); wait() + check(imp._boxState.organizer.config.rules[1].conditions[1].value == "PIKACHU", "saved preset reloads its conditions") + click("box-organize-rule-options") + imp._boxPopup.index = 1; imp:keypressed("return"); wait() + check(#imp._boxState.organizer.config.rules[1].conditions == 2, "rule editor adds a second condition") + click("box-organize-rule-options") + imp._boxPopup.index = 4; imp:keypressed("return"); wait() + check(imp._boxState.organizer.config.rules[1].match == "any", "rule editor switches to any condition") + click("box-organize-rule-options") + imp._boxPopup.index = 2; imp:keypressed("return"); wait() + check(#imp._boxState.organizer.config.rules[1].conditions == 1, "rule editor removes selected condition") + chooseValue("box-tools-arrangeSection", "Sort") + click("box-organize-target-pc") + imp._tabScroll.box = 0; shot("ui-33-game-sort-desktop.png") + local pcBefore = pcInventory(imp._boxState.pcSave) + local partyBefore = require("src.core.SaveSerializer").encode(imp._boxState.pcSave.party) + click("box-organize-preview") + check(imp._boxState.organizer.preview.report.moved > 0, "Game PC preview reports actual moves") + check(pcInventory(imp._boxState.pcSave) == pcBefore, "Game PC preview leaves save unchanged") + shot("ui-34-game-sort-preview-desktop.png") + close() + click("box-organize-apply") + check(pcInventory(imp._boxState.pcSave) == pcBefore, "Game PC apply preserves all native records") + check(require("src.core.SaveSerializer").encode(imp._boxState.pcSave.party) == partyBefore, "Game PC sort leaves party untouched") + local source = imp._boxState.sources[imp._boxState.sourceIndex] + check(imp._boxState.service.fs.getInfo(source.path .. ".box-bak"), "Game PC sort keeps a native save backup") + page("Pokémon"); click("box-view-pc") + imp._boxState.pcBox = 1; imp._tabScroll.box = 0 + shot("ui-35-sorted-game-pc-desktop.png") + end + require("src.box.Cry").stop(); require("src.box.Showcase").stopMusic() + print("BOX_UI_NATIVE_PASS "..checks.." checks; "..love.filesystem.getSaveDirectory()) + love.event.quit(0) +end +function love.update(dt) + if imp then imp:update(dt) end + if co and coroutine.status(co)~="dead" then + local ok, why=coroutine.resume(co) + if not ok then print("BOX_UI_NATIVE_FAIL "..tostring(why)); love.event.quit(1) end + end +end +function love.draw() + if imp then controls={};awards={};imp:draw() end + if pending then + local name=pending; pending=nil + love.graphics.captureScreenshot(function(data) + assert(love.filesystem.write(name,data:encode("png"):getString())) + end) + end +end +function love.errorhandler(why) + print("BOX_UI_NATIVE_FAIL "..tostring(why)); return function() return 1 end +end diff --git a/tests/online_panel_c1.lua b/tests/online_panel_c1.lua index c9c3c30f..65aa97be 100644 --- a/tests/online_panel_c1.lua +++ b/tests/online_panel_c1.lua @@ -64,7 +64,7 @@ do local b1, b2, b3, b4 = head:byte(17, 20) local width = ((b1 or 0) * 16777216) + ((b2 or 0) * 65536) + ((b3 or 0) * 256) + (b4 or 0) - T.eq(width, #Icons.NAMES * 96, "and the atlas has one 96px cell per name") + T.eq(width, #Icons.ATLASES[1].names * 96, "and the atlas has one 96px cell per name") end T.eq(OnlinePanel.sanitizePin("12a3 4567"), "1234", "a PIN keeps four digits") diff --git a/tests/quick.list b/tests/quick.list index c760c67e..05cc4ba7 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -1,3 +1,4 @@ +tests/emerald_tent_save_reload_2638_test.lua tests/engine/advanced_palette_map.lua tests/engine/amnesia_rest_share_anim_bug1881.lua tests/engine/android_asymmetric_display.lua @@ -20,6 +21,7 @@ tests/engine/ball_toss_sound_sync_bug2611.lua tests/engine/battle_ai_item_messages_2589.lua tests/engine/battle_ai_item_sfx_bug2541.lua tests/engine/battle_already_out_bug1953.lua +tests/engine/battle_arrow_cell_bug2674.lua tests/engine/battle_ball_dodge_romtext.lua tests/engine/battle_checkpoint_boundary.lua tests/engine/battle_checkpoint_capture.lua @@ -47,6 +49,7 @@ tests/engine/boulder_dust_obp1_bug2650.lua tests/engine/box_menu_message_pages_test.lua tests/engine/box_release_confirmation_romtext.lua tests/engine/buena_prize_box_bug1935.lua +tests/engine/cache_contract_emerald_test.lua tests/engine/cache_fs_blue_mount_test.lua tests/engine/cache_fs_gold_nx_load_test.lua tests/engine/cache_fs_headless_test.lua @@ -107,6 +110,12 @@ tests/engine/fly_map_arrows_bug1892.lua tests/engine/formulas.lua tests/engine/frame_cap_display.lua tests/engine/frame_cap_legacy_loop_2106.lua +tests/engine/game3_em_bike_doors_2637.lua +tests/engine/game3_em_catch_prompt_2637.lua +tests/engine/game3_frontier_obedience_2638.lua +tests/engine/game3_post_dex_background_2637.lua +tests/engine/game3_speed_hotkey_test.lua +tests/engine/game3_tent_save_reload_2638.lua tests/engine/game_display_chord_test.lua tests/engine/game_speed_categories_test.lua tests/engine/game2_faithful_res.lua @@ -204,7 +213,9 @@ tests/engine/game3_virtual_objects_drawhook_test.lua tests/engine/game3_virtual_objects_map_load_test.lua tests/engine/game3_virtual_objects_test.lua tests/engine/gate_gen2_mod_facade.lua +tests/engine/gate_gen3_mod_api.lua tests/engine/gate_gen3_mod_facade.lua +tests/engine/gen1_field_fx_palette_h13.lua tests/engine/gen2_always_hit_bug1272.lua tests/engine/gen2_anim_bake_scissor_bug1895.lua tests/engine/gen2_anim_param_feed_sweep.lua @@ -282,6 +293,7 @@ tests/engine/gen2_night_shade_top_row_bug1921.lua tests/engine/gen2_no_checkhit_chain_audit.lua tests/engine/gen2_offmap_object_bug1923.lua tests/engine/gen2_options_menu_translation_test.lua +tests/engine/gen2_orientation_bug2586_test.lua tests/engine/gen2_pack_menu_item_list_palette_test.lua tests/engine/gen2_pack_sfx_bug1893.lua tests/engine/gen2_pack_switch_cursor_bug2042.lua @@ -422,6 +434,7 @@ tests/engine/launcher_osk_touch_bug1861.lua tests/engine/launcher_page_scroll.lua tests/engine/launcher_patch_notes.lua tests/engine/launcher_return_pointer_bug2079.lua +tests/engine/launcher_secret_emerald_test.lua tests/engine/launcher_skins_options_reads_bug2038.lua tests/engine/launcher_skins_tab.lua tests/engine/launcher_skins_ux.lua @@ -452,6 +465,7 @@ tests/engine/menu_click_bug570.lua tests/engine/menu_hold_scroll_bug1898.lua tests/engine/menu_keep_open_2281.lua tests/engine/menu_row_offset_bug564.lua +tests/engine/menu_speed_bug2578_test.lua tests/engine/mew_dock_private_artifact_gate.lua tests/engine/minimize_blob_slot_bug1940.lua tests/engine/mod_developer_mode_parity_test.lua @@ -591,6 +605,7 @@ tests/engine/saffron_cries_bug2248_2249.lua tests/engine/save_confirm_layout_bug1522.lua tests/engine/save_convert_extra_flags.lua tests/engine/save_convert_toggle_objects.lua +tests/engine/save_editor_buena_points_2640.lua tests/engine/save_export_map_window_bug1691.lua tests/engine/save_export_portable_bug752.lua tests/engine/save_export_same_map_moved_2468.lua @@ -721,22 +736,27 @@ tests/game3_boat_subpriority_test.lua tests/game3_braille_dig_test.lua tests/game3_braille_regi_field_moves_test.lua tests/game3_cache.lua +tests/game3_carried_actor_ghost_test.lua tests/game3_cart_cache.lua tests/game3_compat_connections_view_test.lua +tests/game3_controls_menu_test.lua tests/game3_corner_screen_test.lua tests/game3_cry_modes_test.lua tests/game3_cut_grass_mow_test.lua +tests/game3_cycling_road_bike_test.lua tests/game3_display_fit_test.lua tests/game3_doors_viewport_test.lua tests/game3_elevation_oam_priority_test.lua tests/game3_event_flags_test.lua tests/game3_field_garbage_test.lua tests/game3_flags_extract_preserve_test.lua +tests/game3_fly_cycling_state_test.lua tests/game3_fullscreen_display_test.lua tests/game3_gift_rse_test.lua tests/game3_grass_oam_order_test.lua tests/game3_growth_coins_test.lua tests/game3_held_items_test.lua +tests/game3_import_cache_version_test.lua tests/game3_import_hardening_test.lua tests/game3_item_pc_give_test.lua tests/game3_latin_glyphs_test.lua @@ -746,6 +766,7 @@ tests/game3_link_relay_transport_test.lua tests/game3_link_rse_groups_test.lua tests/game3_link_union_chat_test.lua tests/game3_menu_sfx_test.lua +tests/game3_message_speed_test.lua tests/game3_mods_menu_test.lua tests/game3_mon_pic_center_test.lua tests/game3_multichoice_window_test.lua @@ -784,8 +805,26 @@ tests/game3_u12_healthbox_hp_text_test.lua tests/game3_ui_braille_test.lua tests/game3_weather_test.lua tests/gen2_fly_map_test.lua +tests/gen2_menus_test.lua +tests/modkit/cases/bag_capacity.lua +tests/parity_F.lua +tests/parity_ball_shake_anim.lua +tests/parity_battle_intro_chrome.lua +tests/parity_e4_after.lua +tests/parity_flavor.lua +tests/parity_flavor_talk_branches.lua +tests/parity_lance.lua +tests/parity_party_row_columns.lua +tests/parity_rival_end_text_bug2660.lua +tests/parity_static.lua +tests/parity_text_markers.lua +tests/parity_text_scroll_bounds.lua +tests/parity_text_wrap.lua +tests/parity_viridian_gambler_bug2632.lua tests/save_compat/gen1_cache_selection_test.lua tests/save_compat/gen2_coverage_test.lua tests/save_compat/gen2_event_clear_test.lua tests/save_compat/gen3_cache_selection_test.lua tests/save_compat/regions_test.lua +tests/save_editor_split_stack_bug2671_test.lua +tests/save_editor_task6_tests.lua diff --git a/tests/save_compat/trainer_identity_sync_test.lua b/tests/save_compat/trainer_identity_sync_test.lua new file mode 100644 index 00000000..f3e740e6 --- /dev/null +++ b/tests/save_compat/trainer_identity_sync_test.lua @@ -0,0 +1,80 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Sync = require("src.core.TrainerIdSync") +local Identity = require("src.core.TrainerIdentity") +local Compat = require("src.save_convert.Compat") +local K = require("tests.save_compat._codec") +local G1 = require("tests.fixtures.save.gen1_build") +local G2 = require("tests.fixtures.save.gen2_build") +local Gen3Save = require("src.save_convert.Gen3Save") +local bit = require("bit") +local target = { id = 100, sid = 42, name = "RED" } +K.gen2Data.pokemon = { CYNDAQUIL = { index = 155, dex = 155, name = "CYNDAQUIL" } } + +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do + local gen = (version == "red" or version == "blue" or version == "yellow") and 1 or 2 + local builder = gen == 1 and G1 or G2 + local spec = { version = version, player = "MAY", playerId = 200, + party = { builder.mon({ otId = 200, ot = "MAY" }), + builder.mon({ species = gen == 1 and 0x1F or 252, otId = 200, ot = "MAY" }), + builder.mon({ otId = 200, ot = "BLUE" }) } } + local bytes = builder.build(spec) + local save = assert(K.import(gen, version, bytes)) + local raw = save.party[2].cartRaw + T.check(raw ~= nil, version .. " exercises an opaque carrier") + local own = assert(Identity.profile(save, gen)) + local owners = {}; Sync.addOwner(owners, own, gen) + T.check(Sync.rewriteSave(save, gen, target, owners), version .. " sync prepares") + local out, why = K.export(gen, version, save, bytes) + T.check(out ~= nil, version .. " exports: " .. tostring(why)) + if out then + T.eq(#Compat.check(out, version).errors, 0, version .. " native checksum/reader rules pass") + local back = assert(K.import(gen, version, out)) + T.eq(back.player.id, 100, version .. " native player ID") + T.eq(back.player.name, "RED", version .. " native player name") + T.eq(back.party[1].otId, 100, version .. " native own OT ID") + T.eq(Identity.monName(back.party[1]), "RED", version .. " native own OT name") + T.eq(Identity.rawId(back.party[2], gen), 100, version .. " raw carrier native OT ID") + T.eq(Identity.monName(back.party[2]), "RED", version .. " raw carrier native OT name") + T.eq(back.party[3].otId, 200, version .. " foreign ID collision retained") + T.eq(Identity.monName(back.party[3]), "BLUE", version .. " foreign OT retained") + local at = gen == 1 and 25 or 13 + local afterRaw = back.party[2].cartRaw + T.eq(afterRaw:sub(1, at - 1) .. afterRaw:sub(at + 4), + raw:sub(1, at - 1) .. raw:sub(at + 4), version .. " opaque carrier bytes retained apart from OT ID") + end +end + +for _, version in ipairs({ "firered", "leafgreen", "ruby", "sapphire", "emerald" }) do + local Codec = Gen3Save.forVersion(version) + local old = { id = 200, sid = 0x1234, name = "MAY" } + local low = 0x1298 + local pid = bit.bxor(old.id, old.sid, low) * 65536 + low + local raw = Codec.encodeBoxMon({ species = 25, personality = pid, otId = old.id, + otSecretId = old.sid, otName = old.name, nickname = "PIKA", language = 2, + moves = { 84, 45 }, pp = { 11, 23 }, ivs = { hp = 31, atk = 23 }, + exp = 1000, friendship = 120, markings = 5, abilityNum = pid % 2, + unknown = 0x1234, metLocation = 88 }) + local cart = assert(Codec.decodeBoxMon(raw)) + local native = Codec.toPortMon(cart, false) + local save = { name = old.name, trainerId = old.id, secretId = old.sid, party = { native } } + local owners = {}; Sync.addOwner(owners, old, 3) + T.check(Sync.rewriteSave(save, 3, target, owners), version .. " identity/PID changes prepare") + cart.personality, cart.otId, cart.otSecretId, cart.otIdRaw, cart.otName = + native.personality, native.otId, native.otSecretId, nil, native.otName + local updated = assert(Codec.decodeBoxMon(Codec.encodeBoxMon(cart))) + T.eq(updated.otId, 100, version .. " encrypted record TID") + T.eq(updated.otSecretId, 42, version .. " encrypted record SID") + T.eq(updated.otName, "RED", version .. " native OT name") + T.eq(updated.isBadEgg, false, version .. " encrypted checksum remains valid") + T.eq(updated.personality % 25, pid % 25, version .. " native nature retained") + T.eq(updated.personality % 256, pid % 256, version .. " native gender retained") + T.eq(updated.abilityNum, cart.abilityNum, version .. " native ability retained") + T.eq(updated.unknown, 0x1234, version .. " opaque header retained") + T.eq(updated.pp[1], 11, version .. " PP retained") + T.eq(updated.metLocation, 88, version .. " met location retained") + T.check(bit.bxor(updated.otId, updated.otSecretId, updated.personality % 65536, + math.floor(updated.personality / 65536)) < 8, version .. " native shiny remains shiny") +end +T.finish("trainer_identity_sync_native_codecs")