mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-02 23:55:22 -04:00
360 lines
14 KiB
Lua
360 lines
14 KiB
Lua
package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua"
|
|
_G.love = require("tests.love_stub")
|
|
local native = os.getenv("POKEPORT_TOUCH_NATIVE") == "1"
|
|
local version = os.getenv("POKEPORT_TOUCH_VERSION") or (native and "firered" or "red")
|
|
if native then
|
|
require("src.core.GameVersion").set(version)
|
|
require("src.import.gba.versions").select(version)
|
|
end
|
|
if native then
|
|
require("tests.game3_cache").mountOrSkip("save_editor_touch_controls_test", "pokemon/meta.lua")
|
|
end
|
|
local App = require("tools.save-editor.App")
|
|
local Ops, Gen, Kit = require("Ops"), require("Gen"), require("Kit")
|
|
local Touch, Limits, Actions = require("TouchEditor"), require("ValueLimits"), require("MonActions")
|
|
local Copy = require("src.mods.Merge").deepCopy
|
|
local SD = require("src.core.SaveData")
|
|
local path = os.tmpname() .. "-touch.lua"
|
|
local f = assert(io.open(path, "wb"))
|
|
f:write(SD.encode(Gen.newGame(version)))
|
|
f:close()
|
|
App.load(path, { version = version, embedded = true })
|
|
local S = App.getState()
|
|
assert(S.save and not S.loadError, S.status)
|
|
Ops.partyAdd(S)
|
|
Ops.selectParty(S, 1)
|
|
local mon = S.editingMon
|
|
local count = 0
|
|
local function check(ok, msg)
|
|
count = count + 1
|
|
assert(ok, msg)
|
|
end
|
|
-- Fractional wheels and a short drag preserve their precise pixel distance.
|
|
Kit.layout(360, 640)
|
|
Kit.beginFrame(50, 50, false, -0.25)
|
|
local state = { offset = 0 }
|
|
local _, shift = Kit.list(state, "offset", 0, 0, 200, 150, 30, 60)
|
|
check(shift == 12 * Kit.scale, "partial wheel does not jump a whole row")
|
|
Kit.endFrame()
|
|
Kit._touchDrag = { x = 50, startY = 50 }
|
|
Kit.touchDown = true
|
|
Kit.beginFrame(50, 33, false, 0)
|
|
Kit.dragAdd(17)
|
|
Kit.list(state, "offset", 0, 0, 200, 150, 30, 60)
|
|
check(math.abs(state._listState.offset.pixels - (12 * Kit.scale + 17)) < 0.001, "17px drag moves list 17px")
|
|
Kit.endFrame()
|
|
Kit.touchDown = false
|
|
-- Real App touch events drive the modal, including SDL's synthesized hold.
|
|
local oldDimensions, oldSafe, oldDown = love.graphics.getDimensions, love.window.getSafeArea, love.mouse.isDown
|
|
love.graphics.getDimensions = function()
|
|
return 360, 640
|
|
end
|
|
love.window.getSafeArea = function()
|
|
return 0, 0, 360, 640
|
|
end
|
|
local held = false
|
|
love.mouse.isDown = function()
|
|
return held
|
|
end
|
|
S.tab, S.mobileInspector, S.monSection = "party", true, "main"
|
|
local initialLevel = mon.level
|
|
local function levelPopup()
|
|
Touch.open(S, Kit, {
|
|
mode = "number",
|
|
title = "Level",
|
|
value = initialLevel,
|
|
limits = function()
|
|
return Limits.mon(S, mon, "level")
|
|
end,
|
|
apply = function(v)
|
|
return Ops.setLevel(S, mon, v)
|
|
end,
|
|
})
|
|
App.draw()
|
|
App.draw()
|
|
end
|
|
levelPopup()
|
|
local rect = S.editPopup.sliderRect
|
|
held = true
|
|
App.touchpressed("slider", rect.x + rect.w * 0.7, rect.y + rect.h / 2)
|
|
App.draw()
|
|
App.touchmoved("slider", rect.x + rect.w * 0.9, rect.y + rect.h / 2)
|
|
App.draw()
|
|
check(S.editPopup.value > initialLevel and mon.level == initialLevel, "touch slider previews without mutating")
|
|
App.touchreleased("slider", rect.x + rect.w * 0.9, rect.y + rect.h / 2)
|
|
App.draw()
|
|
check(S.editPopup ~= nil, "synthetic mouse hold does not activate behind modal")
|
|
held = false
|
|
App.keypressed("escape")
|
|
App.draw()
|
|
levelPopup()
|
|
rect = S.editPopup.wheelRect
|
|
for _ = 1, 4 do
|
|
Kit.beginFrame(rect.x + rect.w / 2, rect.y + rect.h / 2, false, 0.25)
|
|
Touch.draw(S, Kit, 360, 640)
|
|
Kit.endFrame()
|
|
end
|
|
check(S.editPopup.value == initialLevel + 1, "fractional wheel motion accumulates whole values")
|
|
App.touchpressed("wheel", rect.x + rect.w / 2, rect.y + rect.h / 2)
|
|
App.draw()
|
|
App.touchmoved("wheel", rect.x + rect.w / 2 - 50, rect.y + rect.h / 2)
|
|
App.draw()
|
|
check(S.editPopup.value > initialLevel and mon.level == initialLevel, "touch number wheel fine-tunes a draft")
|
|
App.touchreleased("wheel", rect.x + rect.w / 2 - 50, rect.y + rect.h / 2)
|
|
App.draw()
|
|
App.mousepressed(1, 1, 1)
|
|
App.draw()
|
|
check(not S.editPopup and mon.level == initialLevel, "outside tap cancels a dragged value")
|
|
levelPopup()
|
|
S.editPopup.typing, S.editPopup.draft = true, "999"
|
|
check(not Touch.commit(S, Kit) and S.editPopup.error ~= nil, "invalid exact entry stays open with a range error")
|
|
Kit.audit = {}
|
|
App.draw()
|
|
local maxButton
|
|
for _, control in ipairs(Kit.audit) do
|
|
if control.label == "Max" then
|
|
maxButton = control
|
|
end
|
|
end
|
|
Kit.audit = nil
|
|
assert(maxButton, "numeric popup has a Max button")
|
|
local maxX, maxY = maxButton.x + maxButton.w / 2, maxButton.y + maxButton.h / 2
|
|
App.touchpressed("max", maxX, maxY)
|
|
App.touchreleased("max", maxX, maxY)
|
|
App.draw()
|
|
check(
|
|
not S.editPopup.typing and not S.editPopup.error and S.editPopup.value == 100,
|
|
"Max replaces an invalid typed draft and clears its error"
|
|
)
|
|
check(Touch.commit(S, Kit) and mon.level == 100, "Apply uses the tapped Max instead of stale text")
|
|
require("History").undo(S)
|
|
Ops.selectParty(S, 1)
|
|
mon = S.editingMon
|
|
Touch.close(S, Kit)
|
|
love.graphics.getDimensions, love.window.getSafeArea, love.mouse.isDown = oldDimensions, oldSafe, oldDown
|
|
-- Changing a slider is only a draft. Outside/escape cancel it; Apply commits once.
|
|
local oldLevel = mon.level
|
|
Touch.open(S, Kit, {
|
|
mode = "number",
|
|
title = "Level",
|
|
value = oldLevel,
|
|
limits = function()
|
|
return Limits.mon(S, mon, "level")
|
|
end,
|
|
apply = function(v)
|
|
return Ops.setLevel(S, mon, v)
|
|
end,
|
|
})
|
|
Touch.keypressed(S, Kit, "up")
|
|
check(mon.level == oldLevel, "wheel draft does not change save")
|
|
Touch.keypressed(S, Kit, "escape")
|
|
check(mon.level == oldLevel and not S.editPopup, "escape cancels draft")
|
|
local before = #S.undoStack
|
|
Touch.open(S, Kit, {
|
|
mode = "number",
|
|
title = "Level",
|
|
value = oldLevel + 5,
|
|
limits = function()
|
|
return Limits.mon(S, mon, "level")
|
|
end,
|
|
apply = function(v)
|
|
return Ops.setLevel(S, mon, v)
|
|
end,
|
|
})
|
|
check(Touch.commit(S, Kit) and mon.level == oldLevel + 5, "apply changes level by five")
|
|
check(#S.undoStack == math.min(require("History").LIMIT, before + 1), "value apply has one undo snapshot")
|
|
require("History").undo(S)
|
|
Ops.selectParty(S, 1)
|
|
mon = S.editingMon
|
|
check(mon.level == oldLevel, "undo restores coupled level/stats/experience")
|
|
-- Named results never depend on the old bare number labels.
|
|
Touch.open(S, Kit, {
|
|
mode = "choice",
|
|
title = "Status",
|
|
options = { { "healthy", "Healthy" }, { "PSN", "Poisoned" } },
|
|
apply = function(v)
|
|
return Ops.setMonStatus(S, mon, v)
|
|
end,
|
|
})
|
|
S.editPopup.query = "poison"
|
|
S.editPopup.index = 2
|
|
check(#Touch.results(S.editPopup) == 1, "named popup filters")
|
|
check(Touch.commit(S, Kit) and mon.status == "PSN", "search commits a named choice")
|
|
-- Corrupted properties repair together and metadata is retained.
|
|
mon.level, mon.hp, mon.status = 105, -50, "garbage"
|
|
mon.nickname = "ABCDEFGHIJKLMNO"
|
|
mon.customPreserved = { note = "keep me" }
|
|
if native then
|
|
mon.ivs.hp = 99
|
|
mon.evs = { hp = 255, atk = 255, def = 255 }
|
|
mon.language = 6
|
|
mon.metLevel = 120
|
|
else
|
|
mon.dvs.attack = 99
|
|
mon.dvs.hp = 0
|
|
mon.statExp.hp = 999999
|
|
end
|
|
local Paint, PAL = require("src.ui.kit.Button"), require("Theme").PAL
|
|
local realPaint, marked = Paint.draw, {}
|
|
Paint.draw = function(owner, x, y, w, h, label, opts, hot, focused)
|
|
if owner == Kit and opts.invalid and opts.id then
|
|
marked[opts.id] = opts
|
|
check(opts.ink == PAL.red and opts.stroke == PAL.red, "invalid control uses red ink and outline")
|
|
end
|
|
return realPaint(owner, x, y, w, h, label, opts, hot, focused)
|
|
end
|
|
local unchanged = SD.encode(S.save)
|
|
for _, section in ipairs({ "main", "stats", "origin" }) do
|
|
S.monSection, S.inspectorScroll = section, 0
|
|
App.draw()
|
|
end
|
|
check(marked["value-level"] ~= nil, "saved invalid level is visibly marked")
|
|
check(marked["choice-status"] ~= nil, "saved invalid choice is visibly marked")
|
|
check(marked[native and "value-iv-hp" or "value-dv-attack"] ~= nil, "saved invalid stat is visibly marked")
|
|
if native then
|
|
check(marked["choice-language"] ~= nil, "unsupported saved language is visibly marked")
|
|
check(marked["value-ev-spa"] ~= nil, "shared EV limit marks affected controls")
|
|
end
|
|
check(SD.encode(S.save) == unchanged, "highlighting never changes the saved values")
|
|
local highlights = require("Legality").highlights(require("Legality").mon(S, mon), mon)
|
|
check(highlights.sections.main > 0 and highlights.sections.stats > 0, "sections expose their error counts")
|
|
before = #S.undoStack
|
|
local ok = Ops.fixMonErrors(S, mon)
|
|
check(ok, S.status)
|
|
marked = {}
|
|
for _, section in ipairs({ "main", "stats", "origin" }) do
|
|
S.monSection, S.inspectorScroll = section, 0
|
|
App.draw()
|
|
end
|
|
check(next(marked) == nil, "red issue styling clears after repair while origin stays unchecked")
|
|
local coupled = Copy(mon)
|
|
require("MonOps").setLevel(S.data, coupled, 50, Gen.ofState(S))
|
|
coupled.metLevel = 0
|
|
local wrongExp = Limits.expAt(S, coupled, 50) - 1
|
|
coupled.exp, coupled.experience = wrongExp, wrongExp
|
|
local coupledIssues = require("Legality").highlights(require("Legality").mon(S, coupled), coupled)
|
|
check(coupledIssues.fields.experience ~= nil, "experience/level mismatch marks the experience field")
|
|
marked = {}
|
|
Kit.beginFrame(-1, -1, false, 0)
|
|
Touch.value(S, Kit, "experience", "Experience", wrongExp, function()
|
|
return Limits.mon(S, coupled, "experience")
|
|
end, 0, 0, 280, function() end, coupledIssues.fields.experience)
|
|
Kit.endFrame()
|
|
check(marked["value-experience"] ~= nil, "in-range number with a coupled error still paints red")
|
|
Paint.draw = realPaint
|
|
check(require("Legality").mon(S, mon).errors == 0, "repair passes actual property validator")
|
|
check(mon.customPreserved.note == "keep me", "repair preserves unrelated metadata")
|
|
check(#S.undoStack == math.min(require("History").LIMIT, before + 1), "repair is one undo")
|
|
require("History").undo(S)
|
|
Ops.selectParty(S, 1)
|
|
mon = S.editingMon
|
|
check(mon.level == 105 and mon.hp == -50, "repair undo restores original bad values")
|
|
Ops.fixMonErrors(S, mon)
|
|
before = #S.undoStack
|
|
check(Ops.maxMon(S, mon), S.status)
|
|
check(mon.level == 100 and mon.hp == (mon.maxHp or mon.stats.hp), "max sets level and fills resulting HP")
|
|
check(require("Legality").mon(S, mon).errors == 0, "max passes actual property validator")
|
|
check(#S.undoStack == math.min(require("History").LIMIT, before + 1), "max is one undo")
|
|
if native then
|
|
Ops.clearEvs(S, mon)
|
|
Ops.setEv(S, mon, "hp", 255)
|
|
Ops.setEv(S, mon, "atk", 255)
|
|
check(Limits.mon(S, mon, "ev-def").hi == 0, "full EV budget visibly caps another stat")
|
|
Ops.setEv(S, mon, "atk", 200)
|
|
check(Limits.mon(S, mon, "ev-def").hi == 55, "lowering EVs exposes newly available max")
|
|
local hp = Limits.mon(S, mon, "current-hp").hi
|
|
Ops.setLevel(S, mon, 50)
|
|
check(Limits.mon(S, mon, "current-hp").hi < hp, "HP cap follows level")
|
|
Ops.setPpUps(S, mon, 1, 0)
|
|
local pp = Limits.mon(S, mon, "pp-1").hi
|
|
Ops.setPpUps(S, mon, 1, 3)
|
|
check(Limits.mon(S, mon, "pp-1").hi > pp, "PP cap follows PP Ups")
|
|
local locations = require("NamedChoices").locations(S)
|
|
local found = false
|
|
for _, entry in ipairs(locations) do
|
|
if entry[2]:lower():find("route", 1, true) then
|
|
found = true
|
|
end
|
|
end
|
|
check(found, "native locations come from named ROM sections")
|
|
end
|
|
local candidates = Actions.encounters(S)
|
|
check(#candidates > 0, "wild encounter recipes exist for loaded game")
|
|
local beforeMon = Copy(mon)
|
|
local rng = native and require("src.core.game3.rng").getState()
|
|
before = #S.undoStack
|
|
check(Ops.randomizeMon(S, mon), S.status)
|
|
check(require("Legality").mon(S, mon).errors == 0, "randomized Pokémon passes properties")
|
|
check(require("Legality").mon(S, mon).status == "unchecked", "randomization does not falsely certify origin")
|
|
check(#S.undoStack == math.min(require("History").LIMIT, before + 1), "randomization is one undo")
|
|
if native then
|
|
local now = require("src.core.game3.rng").getState()
|
|
check(
|
|
now.value == rng.value and now.value2 == rng.value2 and now.wild == rng.wild,
|
|
"randomization does not advance gameplay RNG"
|
|
)
|
|
end
|
|
require("History").undo(S)
|
|
Ops.selectParty(S, 1)
|
|
mon = S.editingMon
|
|
check(mon.species == beforeMon.species and mon.level == beforeMon.level, "randomize undo restores original")
|
|
-- Save-wide repair fixes party and box entries without skipping box slot gaps.
|
|
Ops.cloneMonToBox(S, mon)
|
|
S.save.party[1].level = -2
|
|
S.editingMon.hp = -5
|
|
before = #S.undoStack
|
|
check(Ops.fixAllErrors(S), S.status)
|
|
check(require("Legality").save(S).errors == 0, "fix all repairs party and boxes")
|
|
check(#S.undoStack == math.min(require("History").LIMIT, before + 1), "fix all is one undo")
|
|
if native or Gen.ofState(S) == 2 then
|
|
for i = 1, 100 do
|
|
check(Ops.randomizeMon(S, S.save.party[1]), S.status)
|
|
check(require("Legality").mon(S, S.save.party[1]).errors == 0, "random recipe passes validator " .. i)
|
|
end
|
|
end
|
|
check(App.save(), "bulk-edited save writes")
|
|
check(App.reload(), "bulk-edited save reloads")
|
|
S = App.getState()
|
|
check(require("Legality").save(S).errors == 0, "bulk edits survive save round trip")
|
|
-- Result clicks and keyboard adds report inside the open picker, where the
|
|
-- dimmed status bar cannot provide visible confirmation.
|
|
local picker = require("ItemPicker")
|
|
local item = assert(Ops.itemSearch(S, "potion")[1])
|
|
local oldClock, oldPrint = love.timer.getTime, love.graphics.print
|
|
local toastClock, printed = 50, {}
|
|
love.timer.getTime = function() return toastClock end
|
|
love.graphics.print = function(text, ...)
|
|
printed[tostring(text)] = true
|
|
return oldPrint(text, ...)
|
|
end
|
|
local function pickerFrame()
|
|
printed = {}
|
|
App.draw()
|
|
end
|
|
S.tab = "items"
|
|
Ops.openItemPicker(S, Kit, "bag")
|
|
pickerFrame()
|
|
pickerFrame()
|
|
check(picker.commit(S, Kit, item), "bag add succeeds")
|
|
pickerFrame()
|
|
check(printed["Added to Bag"] and S.itemPicker ~= nil, "bag toast renders without closing picker")
|
|
toastClock = toastClock + 2.6
|
|
pickerFrame()
|
|
check(not printed["Added to Bag"], "toast disappears automatically")
|
|
S.itemPicker.dest = "pc"
|
|
App.textinput("potion")
|
|
App.keypressed("return")
|
|
pickerFrame()
|
|
check(printed["Added to PC"], "keyboard add renders PC toast")
|
|
local realAdd = Ops.addToBag
|
|
Ops.addToBag = function(state) return Ops.say(state, "Bag is full") end
|
|
S.itemPicker.dest = "bag"
|
|
check(not picker.commit(S, Kit, item), "refused add does not report success")
|
|
pickerFrame()
|
|
check(printed["Item not added"] and not printed["Added to Bag"], "refused add has visible failure feedback")
|
|
Ops.addToBag = realAdd
|
|
Ops.closeItemPicker(S, Kit)
|
|
love.timer.getTime, love.graphics.print = oldClock, oldPrint
|
|
os.remove(path)
|
|
print("save editor touch controls: " .. count .. " checks passed (" .. version .. ")")
|