mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-07 09:27:38 -04:00
fixes to id sync and also box
This commit is contained in:
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+163
@@ -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
|
||||
@@ -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
|
||||
+185
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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%256<ratio and "Female" or "Male"
|
||||
end
|
||||
local function gender2(def,dvs)
|
||||
local gender=Mon.vanillaGender(def,dvs)
|
||||
return gender=="unknown" and "Genderless" or gender=="male" and "Male" or "Female"
|
||||
end
|
||||
local pidCache={}
|
||||
local function pidFor(mon,def,national,dvs,shiny)
|
||||
local tid=clamp(mon.otId,0,65535)
|
||||
local wantedGender=gender2({genderRatio=def.genderRatio},dvs)
|
||||
local letter=national==201 and Unown.letterFromDVs(dvs)-1
|
||||
local key=table.concat({tid,tostring(def.genderRatio),wantedGender,tostring(letter),tostring(shiny)},"|")
|
||||
local hit=pidCache[key]
|
||||
if hit~=nil then return hit or nil end
|
||||
local found=false
|
||||
for highVariant=0,31 do
|
||||
local first,step=0,1
|
||||
if not shiny then first,step=(-(0x4000+highVariant)*65536)%25,25 end
|
||||
for low=first,65535,step do
|
||||
local high=shiny and bit.bxor(low,tid,highVariant%8) or (0x4000+highVariant)
|
||||
local pid=high*65536+low
|
||||
if pid%25==0 and gender3(def.genderRatio,pid)==wantedGender
|
||||
and (bit.bxor(tid,high,low)<8)==shiny
|
||||
and (not letter or Pokemon.unownLetter(pid)==letter) then found=pid;break end
|
||||
end
|
||||
if found or shiny and highVariant==7 then break end
|
||||
end
|
||||
pidCache[key]=found
|
||||
return found or nil
|
||||
end
|
||||
local function dvsFor(mon,def,national,generation)
|
||||
local iv=mon.ivs or {}
|
||||
local target={attack=math.floor((iv.atk or 0)/2),defense=math.floor((iv.def or 0)/2),
|
||||
speed=math.floor((iv.spe or 0)/2),special=math.floor(((iv.spa or 0)+(iv.spd or 0))/4)}
|
||||
local shiny=Pokemon.isShiny({personality=mon.personality,otId=mon.otId,otSecretId=mon.otSecretId})
|
||||
local source=Catalog.get(mon._boxVersion)
|
||||
local ratio=source.meta[mon.species] and source.meta[mon.species].genderRatio
|
||||
if ratio==nil then return nil,"The source gender data is missing." end
|
||||
local gender=gender3(ratio,mon.personality)
|
||||
local letter=national==201 and Pokemon.unownLetter(mon.personality)+1
|
||||
if letter and letter>26 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 distance<score then best,score=d,distance;if distance==0 then break end end
|
||||
end
|
||||
end
|
||||
if not best then return nil,"Gen 2 cannot preserve this combination of gender, shininess and form." end
|
||||
best.hp=Mon.hpDV(best)
|
||||
return best
|
||||
end
|
||||
local function expAt(data,species,level)
|
||||
if data.generation==3 then
|
||||
return require("src.core.game3.summary_data").expForLevel(data.meta[species].growthRate,level)
|
||||
end
|
||||
return require("src.pokemon.Growth").expForLevel(data.pokemon[species].growthRate,level)
|
||||
end
|
||||
function Migration.convert(entry,version)
|
||||
local from,to=Catalog.get(entry.version),Catalog.get(version)
|
||||
local g1,g2=entry.generation,GameVersion.generation(version)
|
||||
if not g2 or g1==g2 then return nil,"Choose a different generation; same-generation Pokémon can be withdrawn directly." end
|
||||
if not from.ready or not to.ready then return nil,"Import both games' ROMs before converting." end
|
||||
local mon=entry.mon
|
||||
if mon.isBadEgg then return nil,"A Bad Egg cannot be migrated." end
|
||||
if mon.isEgg and g2==1 then return nil,"Gen 1 cannot represent eggs." end
|
||||
if mon.mail~=nil and mon.mail~=255 then return nil,"Remove held mail before converting." end
|
||||
local d=Catalog.describe(entry.version,mon)
|
||||
if type(mon.otId)~="number" or mon.otId%1~=0 or mon.otId<0 or mon.otId>65535 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+322
-199
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-18
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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] = {
|
||||
|
||||
+149
-39
@@ -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
|
||||
|
||||
+233
-28
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+119
-11
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
+98
-15
@@ -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)
|
||||
|
||||
+342
-19
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+61
-1
@@ -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)
|
||||
|
||||
+34
-10
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user