diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 169ce889..31257a28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,6 +501,41 @@ jobs: headless: name: headless suites (no ROM) runs-on: ubuntu-latest + needs: headless-groups + if: ${{ always() }} + steps: + - name: require every headless group to pass + env: + HEADLESS_RESULT: ${{ needs.headless-groups.result }} + run: | + if [ "$HEADLESS_RESULT" != "success" ]; then + echo "Headless groups finished with $HEADLESS_RESULT" >&2 + exit 1 + fi + + headless-groups: + name: headless / ${{ matrix.name }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - group: engine + name: engine + - group: gen2 + name: Gen 2 + - group: save-codecs + name: save codecs + - group: save-records + name: Gen 3 save records + - group: save-world + name: Gen 3 save world + - group: save-editor + name: save editor + - group: mods + name: mod tools + - group: game3 + name: Gen 3 runtime steps: - uses: actions/checkout@v7 @@ -516,8 +551,8 @@ jobs: - name: interpreter version run: luajit -v - - name: run every ROM-free tier - run: ./scripts/test.sh + - name: run ROM-free group + run: ./scripts/test.sh --group "${{ matrix.group }}" fixture-dataset: name: fixture dataset integrity diff --git a/scripts/test.sh b/scripts/test.sh index 1350c8ed..d34d3165 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -10,6 +10,9 @@ # # scripts/test.sh every tier this checkout can run # scripts/test.sh --quick skip the slow content tier +# scripts/test.sh --group NAME one ROM-free CI group +# scripts/test.sh --list show selected tiers without running them +# scripts/test.sh --list-groups list the ROM-free CI groups # scripts/test.sh --bless re-pin the fingerprint goldens # WITH_SHOTS=1 scripts/test.sh also capture and diff golden shots # (fails today -- see the T5 block below) @@ -33,17 +36,40 @@ LUA54=${LUA54:-lua5.4} BLESS=0 QUICK=0 SHOTS=${WITH_SHOTS:-0} +GROUP=all +LIST=0 +ACTIVE_GROUP=engine +SELECTED_TIERS=0 +ROM_FREE_GROUPS=(engine gen2 save-codecs save-records save-world save-editor mods game3) -for arg in "$@"; do +while [ $# -gt 0 ]; do + arg="$1" case "$arg" in --bless) BLESS=1 ;; --bless-shots) SHOTS=1; BLESS=1 ;; --quick) QUICK=1 ;; - --help|-h) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --group) [ $# -ge 2 ] || { echo "--group needs a name" >&2; exit 2; }; GROUP="$2"; shift ;; + --list) LIST=1 ;; + --list-groups) printf '%s\n' "${ROM_FREE_GROUPS[@]}"; exit 0 ;; + --help|-h) awk 'NR == 1 { next } /^set -uo pipefail/ { exit } { sub(/^# ?/, ""); print }' "$0"; exit 0 ;; *) echo "unknown option: $arg" >&2; exit 2 ;; esac + shift done +case "$GROUP" in + all|engine|gen2|save-codecs|save-records|save-world|save-editor|mods|game3) ;; + *) echo "unknown test group: $GROUP (use --list-groups)" >&2; exit 2 ;; +esac +if [ "$GROUP" != all ] && { [ "$BLESS" = 1 ] || [ "$SHOTS" = 1 ]; }; then + echo "--group selects ROM-free tests; blessing and screenshots require the full suite" >&2 + exit 2 +fi +if [ "$LIST" = 1 ] && { [ "$BLESS" = 1 ] || [ "$SHOTS" = 1 ]; }; then + echo "--list cannot bless goldens or capture screenshots" >&2 + exit 2 +fi + if ! command -v "$LUA" >/dev/null 2>&1; then echo "no lua interpreter '$LUA' on PATH (set LUA=...)" >&2 exit 2 @@ -133,6 +159,13 @@ fi FAILED=() run_tier() { local label="$1"; shift + [ "$GROUP" = all ] || [ "$GROUP" = "$ACTIVE_GROUP" ] || return 0 + SELECTED_TIERS=$((SELECTED_TIERS + 1)) + if [ "$LIST" = 1 ]; then + printf '[tier] %s\n' "$label" + printf '[command]'; printf ' %q' "$@"; printf '\n' + return 0 + fi echo "" echo "==============================================================" echo " $label" @@ -175,15 +208,26 @@ run_tier "T0 URI launch arguments" "$LUA" tests/engine/launch_uri_args_test.lua run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua # Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache), # so it runs here rather than behind the Red content gate below. +ACTIVE_GROUP=gen2 run_tier "T2 Gen 2 / Crystal suites" "$LUA" tests/run_gen2.lua -run_tier "T2 save compat (R1/R2 round trips, reader validator)" "$LUA" tests/run_save_compat.lua +ACTIVE_GROUP=save-codecs +run_tier "T2 save compat: codecs + cross-generation contracts" "$LUA" tests/run_save_compat.lua --group codecs +ACTIVE_GROUP=save-records +run_tier "T2 save compat: Gen 3 records + gifts" "$LUA" tests/run_save_compat.lua --group records +ACTIVE_GROUP=save-world +run_tier "T2 save compat: Gen 3 towns + TV" "$LUA" tests/run_save_compat.lua --group world # The mobile UI and input suites use committed fixtures, so they must run # even on CI's ROM-free checkout. Keep the native-cache property suite below. +ACTIVE_GROUP=save-editor run_tier "T2 save editor: mobile properties (fixtures)" \ env POKEPORT_DATA_DIR=tests/fixture_data "$LUA" tests/save_editor_mobile_properties_tests.lua run_tier "T2 save editor: Gen 3 IV / EV / PP (fixtures)" "$LUA" tests/save_editor_gen3_ev_iv_tests.lua +run_tier "T2 save editor: touch value controls" \ + env POKEPORT_DATA_DIR=tests/fixture_data "$LUA" tests/save_editor_touch_controls_test.lua run_tier "T2 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua run_tier "T2 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua +run_tier "T2 save editor: gold / gen2 (fixtures)" "$LUA" tests/save_editor_gen2_tests.lua +ACTIVE_GROUP=mods run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua run_tier "T4 modkit dev tooling (fixture)" "$LUA" tests/modkit_tests.lua @@ -298,10 +342,12 @@ run_game3_tier() { rm -rf "$tmp" return 0 } +ACTIVE_GROUP=game3 run_tier "T6 game3 top-level scenario suites" run_game3_tier # This suite loads the imported FireRed tables itself and self-skips when # absent. It must not depend on also having a Red dataset installed. +ACTIVE_GROUP=save-editor if [ -n "$GAME3_IDENTITY" ]; then run_tier "T6 save editor: native property legality" \ env POKEPORT_IDENTITY="$GAME3_IDENTITY" "$LUA" tests/save_editor_property_legality_test.lua @@ -311,6 +357,9 @@ elif [ -n "$GAME3_GBA_CACHE" ]; then else run_tier "T6 save editor: native property legality" "$LUA" tests/save_editor_property_legality_test.lua fi +run_tier "T6 save editor: native bulk actions" \ + env POKEPORT_TOUCH_NATIVE=1 POKEPORT_IDENTITY="${POKEPORT_IDENTITY:-$GAME3_IDENTITY}" \ + POKEPORT_GBA_CACHE="${POKEPORT_GBA_CACHE:-$GAME3_GBA_CACHE}" "$LUA" tests/save_editor_touch_controls_test.lua run_emerald_tier() { local t rc=0 @@ -322,8 +371,10 @@ run_emerald_tier() { || { echo "FAIL tests/game3_cart_random_sessions_test.lua (emerald)"; rc=1; } return $rc } +ACTIVE_GROUP=game3 run_tier "T6e emerald suites" run_emerald_tier +ACTIVE_GROUP=mods run_tier "T4 title checkpoint cold restart" \ bash tests/integration/title_checkpoint_cold_start.sh @@ -388,6 +439,7 @@ run_content_behavior() { # The Red dataset is the source tree's when tools/build_data.py wrote one, and # otherwise the imported Red cache, through Data:load's POKEPORT_DATA_DIR hook. +ACTIVE_GROUP=content HAVE_RED_DATA=0 if [ -f data/generated/maps.lua ]; then HAVE_RED_DATA=1 @@ -411,7 +463,6 @@ if [ "$HAVE_RED_DATA" = "1" ]; then run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua - run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua run_tier "T3 save editor: bag / PC move" "$LUA" tests/save_editor_item_move_bug1951_test.lua run_tier "T3 save editor: mobile properties" "$LUA" tests/save_editor_mobile_properties_tests.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua @@ -488,6 +539,10 @@ fi # ------- verdict +if [ "$LIST" = 1 ]; then + echo "Listed $SELECTED_TIERS tiers for $GROUP (tests were not run)" + exit 0 +fi echo "" echo "==============================================================" if [ ${#FAILED[@]} -eq 0 ]; then diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 7e2812ee..6536c606 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1522,7 +1522,7 @@ 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 = "IMPORT" } + skins = "SKINS", importers = "EXTRA" } for _, t in ipairs(HEADER_TABS) do t.opts = { face = "tab", font = "tab", icon = TAB_ICONS[t.id] } end diff --git a/src/ui/kit/Icons.lua b/src/ui/kit/Icons.lua index 4991584a..d94b1087 100644 --- a/src/ui/kit/Icons.lua +++ b/src/ui/kit/Icons.lua @@ -68,12 +68,24 @@ for _, atlas in ipairs(atlases) do end end +Icons.NAMES[#Icons.NAMES + 1] = "triangle-alert" function Icons.has(name) - return cells[name] ~= nil + return name == "triangle-alert" or cells[name] ~= nil end function Icons.draw(name, x, y, size, color, alpha) local g = love and love.graphics + if g and name == "triangle-alert" 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.polygon("line", x + size * 0.5, y + size * 0.1, + x + size * 0.08, y + size * 0.88, x + size * 0.92, y + size * 0.88) + g.line(x + size * 0.5, y + size * 0.35, x + size * 0.5, y + size * 0.59) + g.circle("fill", x + size * 0.5, y + size * 0.74, size * 0.045) + g.pop() + return + end if not g or not g.newQuad or not g.newImage then return end diff --git a/tests/drivers/save_editor_mobile_redesign.lua b/tests/drivers/save_editor_mobile_redesign.lua index 91fd6436..3a2c4e0c 100644 --- a/tests/drivers/save_editor_mobile_redesign.lua +++ b/tests/drivers/save_editor_mobile_redesign.lua @@ -223,22 +223,56 @@ return function(game) shot(section, 390, 844) shot("full-label-" .. section, 320, 568) end - if Gen.ofState(S) == 3 then - for _, choice in ipairs({ - { "main", "nature" }, - { "origin", "language" }, - { "origin", "pokeball" }, - { "origin", "metGame" }, - }) do - S.monSection, S.propertyChoice, S.inspectorScroll = choice[1], choice[2], 300 - shot("full-label-choice-" .. choice[2], 320, 568) - end - S.monSection, S.propertyChoice, S.inspectorScroll = "origin", "language", 500 - shot("language-choices", 390, 844, function() - S.inspectorScroll = 500 - end) - S.propertyChoice, S.inspectorScroll = nil, 0 + local Touch=require("TouchEditor") + local mon=S.editingMon + for _,size in ipairs({{320,568},{390,844},{640,360}}) do + Touch.open(S,Kit,{mode="number",id="level",title="Level",value=mon.level, + limits=function() return require("ValueLimits").mon(S,mon,"level") end, + apply=function(v) return Ops.setLevel(S,mon,v) end}) + shot("value-wheel-"..size[1],size[1],size[2]) + S.editPopup.scroll=10000 + shot("value-wheel-footer-"..size[1],size[1],size[2]) + Touch.close(S,Kit) end + if Gen.ofState(S)==3 then + Touch.open(S,Kit,{mode="choice",title="Found at",value=mon.metLocation, + options=require("NamedChoices").locations(S),apply=function(v) return Ops.setMonProperty(S,mon,"metLocation",v) end}) + shot("searchable-locations",390,844) + S.editPopup.query="route" + shot("searchable-locations-filtered",320,568) + Touch.close(S,Kit) + Ops.setEv(S,mon,"hp",255);Ops.setEv(S,mon,"atk",200) + S.monSection,S.inspectorScroll="stats",0 + shot("ev-budget",390,844) + Touch.open(S,Kit,{mode="number",title="Defense EV",value=0, + limits=function() return require("ValueLimits").mon(S,mon,"ev-def") end, + apply=function(v) return Ops.setEv(S,mon,"def",v) end}) + shot("ev-budget-wheel",390,844) + Touch.close(S,Kit) + end + Touch.open(S,Kit,{mode="help",title="Randomize Pokémon",help="Replaces this Pokémon with a wild one from this game. Real level range, normal moves. Undo brings yours back."}) + shot("action-help",320,568) + Touch.close(S,Kit) + local originalMon = require("src.mods.Merge").deepCopy(mon) + mon.level, mon.hp, mon.status = 105, -20, "UNKNOWN" + if Gen.ofState(S) == 3 then + mon.ivs.hp, mon.language = 99, 6 + mon.evs.hp, mon.evs.atk, mon.evs.def = 255, 255, 100 + else + mon.dvs.attack = 25 + end + S.monSection, S.inspectorScroll = "main", 0 + shot("invalid-saved-main", 390, 844) + S.monSection, S.inspectorScroll = "stats", 0 + shot("invalid-saved-stats", 390, 844) + if Gen.ofState(S) == 3 then + S.monSection, S.inspectorScroll = "origin", 0 + shot("invalid-saved-origin", 320, 568) + S.inspectorScroll = 300 + shot("invalid-saved-language", 320, 568) + end + for k in pairs(mon) do mon[k] = nil end + for k, v in pairs(originalMon) do mon[k] = v end S.monSection = "main" shot("selected-buttons", 390, 844) shot("rounded-buttons", 390, 844) diff --git a/tests/engine/headless_ci_groups_test.lua b/tests/engine/headless_ci_groups_test.lua new file mode 100644 index 00000000..29c977e9 --- /dev/null +++ b/tests/engine/headless_ci_groups_test.lua @@ -0,0 +1,122 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if package.config:sub(1, 1) == "\\" then + print("[skip] Ubuntu CI runner requires a POSIX shell") + os.exit(0) +end +local T = require("tests.harness") +local Runner = require("tests.tier_runner") +local Groups = require("tests.save_compat._groups") + +local function capture(command) + local pipe = assert(io.popen(command)) + local text = pipe:read("*a") + pipe:close() + return text +end + +local names = {} +for name in capture("bash scripts/test.sh --list-groups"):gmatch("[^\r\n]+") do + names[#names + 1] = name +end +T.eq(#names, 8, "eight named ROM-free groups") +local workflow = assert(io.open(".github/workflows/ci.yml", "rb")) +local text = workflow:read("*a") +workflow:close() +local aggregate = assert(text:match(" headless:\n(.-) headless%-groups:")) +T.check(aggregate:find("name: headless suites (no ROM)", 1, true), "required branch check keeps its name") +T.check(aggregate:find("needs: headless-groups", 1, true), "required check waits for every matrix child") +T.check(aggregate:find("if: ${{ always() }}", 1, true), "required check runs after failed or skipped groups") +T.check(aggregate:find("HEADLESS_RESULT: ${{ needs.headless-groups.result }}", 1, true), + "required check reads the combined matrix result") +local gate = assert(aggregate:match(" run: |\n(.*)")):gsub("\n ", "\n"):gsub("^ ", "") +local gatePath = os.tmpname() +local gateFile = assert(io.open(gatePath, "wb")) +gateFile:write(gate) +gateFile:close() +for _, status in ipairs({ "success", "failure", "cancelled", "skipped" }) do + local result = os.execute("HEADLESS_RESULT=" .. status .. " bash " .. gatePath .. " >/dev/null 2>&1") + T.eq(result == 0 or result == true, status == "success", "required check handles " .. status) +end +os.remove(gatePath) +local headless = assert(text:match(" headless%-groups:\n(.-) fixture%-dataset:")) +local configured = {} +for name in headless:gmatch("%- group: ([%w%-]+)") do + T.check(not configured[name], "CI group occurs once: " .. name) + configured[name] = true +end +T.check(headless:find('run: ./scripts/test.sh --group "${{ matrix.group }}"', 1, true), + "CI executes the selected group") +T.check(headless:find("fail-fast: false", 1, true), "one failing group does not cancel the others") + +local prefix = "POKEPORT_TEST_CACHES=/tmp/no-headless-group-cache POKEPORT_IDENTITY=ci-group-plan " + .. "RED_CACHE= BLUE_CACHE= YELLOW_CACHE= GOLD_CACHE= SILVER_CACHE= CRYSTAL_CACHE= " +local function plan(group) + local result = {} + local output = capture(prefix .. "bash scripts/test.sh --quick --list --group " .. group) + T.check(output:find("tests were not run", 1, true), "listing does not execute tests: " .. group) + for label in output:gmatch("%[tier%] ([^\r\n]+)") do + T.check(not result[label], "tier occurs once in " .. group .. ": " .. label) + result[label] = true + end + return result +end +local all, assigned = plan("all"), {} +for _, name in ipairs(names) do + T.check(configured[name], "CI includes " .. name) + configured[name] = nil + local selected = plan(name) + T.check(next(selected) ~= nil, "group is not empty: " .. name) + for label in pairs(selected) do + T.check(all[label], "group tier belongs to the full suite: " .. label) + T.check(not assigned[label], "tier belongs to exactly one CI group: " .. label) + assigned[label] = name + end +end +T.check(next(configured) == nil, "CI has no unknown groups") +for label in pairs(all) do + T.check(assigned[label], "full ROM-free tier is covered in CI: " .. label) +end + +local counts, seen = {}, {} +for _, path in ipairs(Runner.suites("tests/save_compat")) do + local group = Groups.of(path) + T.check(Groups.valid[group], "every save suite has a group: " .. path) + counts[group] = (counts[group] or 0) + 1 + seen[path:match("([^/]+)%.lua$")] = true +end +for name in pairs(Groups.named) do + T.check(seen[name], "named save suite exists: " .. name) +end +for group in pairs(Groups.valid) do + T.check((counts[group] or 0) > 0, "save group is not empty: " .. group) +end +T.eq(Groups.of("tests/save_compat/future_codec_test.lua"), "codecs", "new save tests remain covered") +local invalid = os.execute("bash scripts/test.sh --group nonexistent >/dev/null 2>&1") +T.check(invalid ~= 0 and invalid ~= true, "unknown group fails instead of running the full suite") +invalid = os.execute("luajit tests/run_save_compat.lua --group nonexistent >/dev/null 2>&1") +T.check(invalid ~= 0 and invalid ~= true, "unknown save group fails") + +-- Exercise real child-process success, failure and an empty filter. +local dir = os.tmpname() +os.remove(dir) +assert(os.execute("mkdir -p " .. dir) == 0) +for name, exitCode in pairs({ pass = 0, fail = 7 }) do + local file = assert(io.open(dir .. "/" .. name .. ".lua", "wb")) + file:write("exit " .. exitCode .. "\n") + file:close() +end +local oldInterpreter, oldPrint = arg[-1], print +arg[-1], print = "sh", function() end +local passed, passedTotal = Runner.run({ dir }, "filtered pass", function(p) return p:match("/pass%.lua$") end) +local failed, failedTotal = Runner.run({ dir }, "filtered fail", function(p) return p:match("/fail%.lua$") end) +local empty = Runner.run({ dir }, "empty", function() return false end) +arg[-1], print = oldInterpreter, oldPrint +T.eq(passed, 0, "successful selected child passes") +T.eq(passedTotal, 1, "unselected failing child is not run") +T.eq(failed, 1, "selected child failure propagates") +T.eq(failedTotal, 1, "failure reports the selected count") +T.eq(empty, 1, "empty group cannot pass vacuously") +os.remove(dir .. "/pass.lua") +os.remove(dir .. "/fail.lua") +os.execute("rmdir " .. dir) +T.finish("headless CI groups") diff --git a/tests/run_save_compat.lua b/tests/run_save_compat.lua index 51ae526b..5811c117 100644 --- a/tests/run_save_compat.lua +++ b/tests/run_save_compat.lua @@ -1,3 +1,14 @@ package.path = "./?.lua;./?/init.lua;" .. package.path -require("tests.tier_runner").main({ "tests/save_compat" }, "save_compat") +local Runner = require("tests.tier_runner") +local Groups = require("tests.save_compat._groups") +if not arg[1] then + Runner.main({ "tests/save_compat" }, "save_compat") +elseif arg[1] == "--group" and Groups.valid[arg[2]] and not arg[3] then + local group = arg[2] + Runner.main({ "tests/save_compat" }, "save_compat/" .. group, + function(path) return Groups.of(path) == group end) +else + io.stderr:write("usage: tests/run_save_compat.lua [--group codecs|records|world]\n") + os.exit(2) +end diff --git a/tests/save_compat/_groups.lua b/tests/save_compat/_groups.lua new file mode 100644 index 00000000..93e29d37 --- /dev/null +++ b/tests/save_compat/_groups.lua @@ -0,0 +1,17 @@ +local Groups = {} + +Groups.valid = { codecs = true, records = true, world = true } +Groups.named = { + gen3_sec_frextra_test = "records", + gen3_sec_records_test = "records", + gen3_sec_town_test = "world", + gen3_sec_tv_test = "world", +} + +function Groups.of(path) + local name = path:match("([^/]+)%.lua$") + -- New codec suites stay covered without updating a hand-maintained list. + return Groups.named[name] or "codecs" +end + +return Groups diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua index df376a95..cb516960 100644 --- a/tests/save_editor_gen2_tests.lua +++ b/tests/save_editor_gen2_tests.lua @@ -746,7 +746,9 @@ do local labels = {} for _, r in ipairs(Kit.audit) do labels[r.label] = true end Kit.audit = nil - check(labels["property-caughtTime"] ~= nil, "the Crystal origin form offers the caught time") + local timeChoice=false + for label in pairs(labels) do if label:match("^Time found:") then timeChoice=true end end + check(timeChoice, "the Crystal origin form offers named caught times") S.inspectorScroll=300 Kit.audit={} MonEditor.draw(S,Kit,0,0,1280,720) @@ -887,5 +889,37 @@ do end end +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local S = newState(version) + S.data = require("src.mods.Merge").deepCopy(data) + S.data.maps = { ROUTE_29 = { landmark = 3 } } + S.data.encounters = { + grass = { ROUTE_29 = { slots = { NITE = { { species = "TOTODILE", level = 5 } } } } }, + fishGroups = { NO_MAP = { { species = "CYNDAQUIL", level = 90 } } }, + } + Ops.partyAdd(S) + local mon = S.save.party[1] + Ops.setHeldItem(S, mon, "FLOWER_MAIL") + mon.level, mon.hp, mon.dvs.attack, mon.statExp.hp, mon.pokerus = 110, -10, 20, 999999, 255 + check(Ops.fixMonErrors(S, mon), version .. " repairs malformed properties") + eq(require("Legality").mon(S, mon).errors, 0, version .. " repaired values pass validator") + check(Ops.maxMon(S, mon), version .. " max out succeeds") + eq(mon.level, 100, version .. " max reaches level 100") + eq(mon.happiness, 255, version .. " max fills friendship") + eq(mon.statExp.hp, 65535, version .. " max fills stat experience") + eq(mon.dvs.attack, 15, version .. " max fills DVs") + eq(require("Legality").mon(S, mon).errors, 0, version .. " maxed values pass validator") + check(Ops.randomizeMon(S, mon), version .. " randomize succeeds with native DV properties") + eq(mon.species, "TOTODILE", version .. " randomize uses available encounters") + eq(mon.level, 5, version .. " randomize uses encounter level") + if version == "crystal" then + eq(mon.caughtLocation, 3, "Crystal randomize keeps the encounter landmark") + eq(mon.caughtTime, 3, "Crystal randomize keeps the nighttime slot") + end + eq(require("Legality").mon(S, mon).errors, 0, version .. " random values pass validator") + eq(require("src.core.gen2.Mail").state(S.save).party[1], nil, + version .. " randomize clears the replaced held mail") +end + print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tests/save_editor_touch_controls_test.lua b/tests/save_editor_touch_controls_test.lua new file mode 100644 index 00000000..21ff19e3 --- /dev/null +++ b/tests/save_editor_touch_controls_test.lua @@ -0,0 +1,320 @@ +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") +os.remove(path) +print("save editor touch controls: " .. count .. " checks passed (" .. version .. ")") diff --git a/tests/tier_runner.lua b/tests/tier_runner.lua index 724ee7eb..306ff20b 100644 --- a/tests/tier_runner.lua +++ b/tests/tier_runner.lua @@ -37,31 +37,37 @@ end -- runs every suite in `dirs`, prints one line per suite, returns the -- number that failed -function Runner.run(dirs, label) +function Runner.run(dirs, label, include) local lua = interpreter() local failed, total = 0, 0 for _, dir in ipairs(dirs) do for _, path in ipairs(Runner.suites(dir)) do - total = total + 1 - local status = os.execute(("%s %s"):format(lua, path)) - local ok = status == 0 or status == true - if ok then - print("ok " .. path) - else - failed = failed + 1 - print("FAIL " .. path) + if not include or include(path) then + total = total + 1 + local status = os.execute(("%s %s"):format(lua, path)) + local ok = status == 0 or status == true + if ok then + print("ok " .. path) + else + failed = failed + 1 + print("FAIL " .. path) + end end end end + if total == 0 then + print("FAIL no suites selected for " .. label) + return 1, 0 + end print(("\n%s: %d/%d suites passed"):format(label, total - failed, total)) print(("%s"):format(failed == 0 and "ALL TESTS PASSED" or failed .. " FAILURES")) return failed, total end -function Runner.main(dirs, label) - local failed = Runner.run(dirs, label) +function Runner.main(dirs, label, include) + local failed = Runner.run(dirs, label, include) os.exit(failed == 0 and 0 or 1) end diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 4241c81e..8ba011d0 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -52,6 +52,7 @@ local clickX, clickY -- Wheel notches queued by App.wheelmoved since the last draw, handed to Kit -- there like mouseClicked is: LOVE delivers events before love.draw, so a -- notch is always spent by the frame that follows it (#595). +local TouchEditor = require("TouchEditor") local wheelY = 0 local touch @@ -135,7 +136,8 @@ local function applyLoaded(path, statusVerb) S.formMon, S.nicknameMon = nil, nil S.monDrafts, S.trainerDrafts, S.walletDrafts = {}, {}, {} S.propertyChoice, S.itemMenu = nil, nil - S.navPopup = nil + S.navPopup, S.editPopup = nil, nil + S._listState = nil S.mobileInspector = nil S._quitArmed = false S._openArmed = false @@ -305,7 +307,8 @@ end function App.unload() Motion.reset() touch = nil - Kit.touchDown = nil + Kit.touchDown, Kit.ignoreMouseDown = nil, nil + Kit._touchDrag, Kit._pointerDrag, Kit._tapPending, Kit._dragDelta = nil, nil, nil, 0 S = nil mods = nil App.dataVersion = nil @@ -346,19 +349,25 @@ local function handlePadAction(action) local mx, my = PadInput.pointer() App.mousepressed(mx, my, 1) elseif action == "b" then - if S.navPopup then + if S.editPopup then + TouchEditor.close(S, Kit) + elseif S.navPopup then Chooser.close(S) else App.close() end elseif action == "tab_prev" then - if S.navPopup then + if S.editPopup then + TouchEditor.keypressed(S, Kit, S.editPopup.mode == "number" and "left" or "up") + elseif S.navPopup then Chooser.keypressed(S, "up") else cycleTab(-1) end elseif action == "tab_next" then - if S.navPopup then + if S.editPopup then + TouchEditor.keypressed(S, Kit, S.editPopup.mode == "number" and "right" or "down") + elseif S.navPopup then Chooser.keypressed(S, "down") else cycleTab(1) @@ -491,6 +500,9 @@ function App.update(dt) end function App.mousepressed(x, y, button) + if touch then + return + end if button == 1 then mouseClicked = true clickX, clickY = x, y @@ -506,6 +518,10 @@ function App.touchpressed(id, x, y) end touch = { id = id, x = x, y = y, startX = x, startY = y, moved = false } Kit.touchDown = true + Kit.ignoreMouseDown = true + Kit._pointerDrag, Kit._tapPending = nil, nil + Kit._touchDrag = { x = x, startY = y } + Kit._dragDelta = 0 PadInput.yieldToPointer() end @@ -513,10 +529,14 @@ function App.touchmoved(id, x, y) if not touch or touch.id ~= id then return end + local lastY = touch.y touch.x, touch.y = x, y if math.abs(x - touch.startX) + math.abs(y - touch.startY) > 10 then touch.moved = true end + if touch.moved then + Kit.dragAdd(lastY - y) + end end function App.touchreleased(id, x, y) @@ -525,10 +545,11 @@ function App.touchreleased(id, x, y) end App.touchmoved(id, x, y) if not touch.moved then - App.mousepressed(x, y, 1) + mouseClicked, clickX, clickY = true, x, y end touch = nil Kit.touchDown = false + Kit.ignoreMouseDown = true end function App.textinput(text) @@ -804,6 +825,7 @@ function App.draw() or (S.itemPicker ~= nil) or (S.movePicker ~= nil) or (S.navPopup ~= nil) + or (S.editPopup ~= nil) or Motion.active() Theme.field(width, height) @@ -898,7 +920,9 @@ function App.draw() Kit.blockClicks = false -- Scrim still covers the full window (including unsafe bands); the card -- itself is centred in the safe rect so search fields clear the notch. - if S.navPopup then + if S.editPopup then + TouchEditor.draw(S, Kit, width, height) + elseif S.navPopup then Chooser.draw(S, Kit, width, height) else SpeciesPicker.draw(S, Kit, width, height) @@ -918,6 +942,9 @@ function App.keypressed(key) if not S or S.missingCache then return end + if TouchEditor.keypressed(S, Kit, key) then + return + end if Chooser.keypressed(S, key) then return end @@ -1028,7 +1055,7 @@ function App.wheelmoved(x, y) if not S or S.missingCache then return end - if S.navPopup then + if S.navPopup or S.editPopup or S.speciesPicker or S.movePicker or S.itemPicker then wheelY = wheelY + (y or 0) return end diff --git a/tools/save-editor/Chooser.lua b/tools/save-editor/Chooser.lua index 23841332..671f503a 100644 --- a/tools/save-editor/Chooser.lua +++ b/tools/save-editor/Chooser.lua @@ -12,7 +12,8 @@ local function id(option) end local function label(option) - return option.label or option[2] + local text = option.label or option[2] + return (option.errors or 0) > 0 and (text .. " (" .. option.errors .. ")") or text end function Chooser.close(S) @@ -64,6 +65,7 @@ function Chooser.navigation(S, kit, key, title, options, x, y, w, h, after) if kit.button(x, y, w, h, text, { id = "navigate-" .. key, + invalid = (current.errors or 0) > 0, face = "invert", font = "small", icon = icon, @@ -202,6 +204,7 @@ function Chooser.draw(S, kit, width, height) if kit.button(cx, cy + (i - 1) * (row + gap) - popup.scroll, contentW, row, text, { face = "selection", + invalid = (option.errors or 0) > 0, active = active(S, popup, option), font = "small", align = "left", diff --git a/tools/save-editor/History.lua b/tools/save-editor/History.lua index f0567024..6f0000d1 100644 --- a/tools/save-editor/History.lua +++ b/tools/save-editor/History.lua @@ -19,7 +19,7 @@ local function restore(S, snapshot) S.editingMon, S.formMon, S.nicknameMon = nil, nil, nil S.monDrafts, S.trainerDrafts, S.walletDrafts = {}, {}, {} S.propertyChoice, S.itemMenu = nil, nil - S.navPopup = nil + S.navPopup, S.editPopup = nil, nil S.speciesPicker, S.movePicker, S.itemPicker = nil, nil, nil S.revision = (S.revision or 0) + 1 require("Gen").ensureBoxes(S.save) diff --git a/tools/save-editor/InspectorBody.lua b/tools/save-editor/InspectorBody.lua index 4675c8cf..1beb9fe6 100644 --- a/tools/save-editor/InspectorBody.lua +++ b/tools/save-editor/InspectorBody.lua @@ -6,8 +6,11 @@ local PAL = require("Theme").PAL local Body = {} local Motion = require("Motion") local Chooser = require("Chooser") +local Touch = require("TouchEditor") +local Limits = require("ValueLimits") +local Named = require("NamedChoices") -local function drawSection(S, Kit, x, y, w, h) +local function drawSection(S, Kit, x, y, w, h, report, issues) local mon = S.editingMon if not mon then Kit.textWrapped( @@ -32,6 +35,10 @@ local function drawSection(S, Kit, x, y, w, h) { "extras", "Extras" }, { "checks", "Checks" }, } + for _, entry in ipairs(sections) do + entry.errors = issues.sections[entry[1]] + entry.icon = entry.errors > 0 and "triangle-alert" or Kit.navigationIcon(entry[2]) + end S.monSection = S.monSection or "main" local navY = y + pad local navX = cx @@ -71,16 +78,35 @@ local function drawSection(S, Kit, x, y, w, h) local function text(str, color) cy = cy + Kit.textWrapped("small", str, cx, cy, inner, color or PAL.caption) + gap end - local function button(label, fn, kind) - local opts = { kind = kind, font = "small" } + local function hint(id) + cy = cy + Touch.issue(Kit, issues.fields[id], cx, cy, inner) + end + if report.errors > 0 then + text(report.errors .. " saved value errors. Check the red fields.", PAL.red) + end + local function button(label, fn, kind, help, id) + local issue = id and issues.fields[id] + local opts = { kind = kind, font = "small", invalid = issue ~= nil } local buttonH = Kit.buttonHeight(label, inner, opts) - if Kit.button(cx, cy, inner, buttonH, label, opts) then + if help then + buttonH = Touch.action(S, Kit, label, help, fn, cx, cy, inner, kind, issue) + elseif Kit.button(cx, cy, inner, buttonH, label, opts) then fn() end cy = cy + buttonH + gap + if issue and not help then hint(id) end end local function field(id, label, value, fn, sanitize) - text(label, PAL.text) + if type(value) == "number" then + label = label:gsub(" %(.-%)", "") + cy = cy + + Touch.value(S, Kit, id, label, value, function() + return Limits.mon(S, mon, id) + end, cx, cy, inner, fn, issues.fields[id]) + + 2 * gap + return + end + text(label, issues.fields[id] and PAL.red or PAL.text) local key = "property-" .. id local draft = S.monDrafts[id] if Kit.focus ~= key and draft == nil then @@ -106,7 +132,7 @@ local function drawSection(S, Kit, x, y, w, h) row, draft or tostring(value or ""), "value", - { sanitize = sanitize, onSubmit = apply } + { sanitize = sanitize, onSubmit = apply, invalid = issues.fields[id] ~= nil } ) if Kit.button(cx + inner - applyW, cy, applyW, row, "Set", { kind = "accent", font = "small" }) @@ -115,65 +141,28 @@ local function drawSection(S, Kit, x, y, w, h) Kit.blur() end cy = cy + row + 2 * gap + hint(id) end local function choice(id, label, value, options, apply) - local shown = "Unknown (" .. tostring(value) .. ")" - for _, v in ipairs(options) do - if v[1] == value then - shown = v[2] - end - end - text(label, PAL.text) - if - Kit.button(cx, cy, inner, row, shown, { - face = "invert", - font = "small", - trailingIcon = S.propertyChoice == id and "chevron-up" or "chevron-down", - }) - then - if S.propertyChoice == id then - S.propertyChoice = nil - else - S.propertyChoice = id - S._choiceScroll = S.inspectorScroll - end - Kit.blur() - end - cy = cy + row + gap - if S.propertyChoice == id then - local cw = (inner - gap) / 2 - for i, v in ipairs(options) do - if - Kit.chip( - cx + (i - 1) % 2 * (cw + gap), - cy + math.floor((i - 1) / 2) * (row + gap), - cw, - row, - v[2], - v[1] == value, - PAL.blue - ) - then - apply(v[1]) - S.propertyChoice = nil - S.inspectorScroll = math.min(S._choiceScroll or 0, S.inspectorScroll or 0) - end - end - cy = cy + math.ceil(#options / 2) * (row + gap) - end - cy = cy + gap + cy = cy + Touch.choice(S, Kit, id, label, value, options, cx, cy, inner, apply, nil, issues.fields[id]) + 2 * gap end local function props(list) for _, d in ipairs(list) do if d.toggle then local on = P.get(mon, d) on = on == true or on == 1 - if Kit.chip(cx, cy, inner, row, d.label .. ": " .. (on and "ON" or "OFF"), on) then + local issue = issues.fields[d.key] + local shown = issue and tostring(P.get(mon, d)) or (on and "ON" or "OFF") + local label = d.label .. ": " .. shown + local opts = { face = "selection", active = on, font = "small", invalid = issue ~= nil } + local height = Kit.buttonHeight(label, inner, opts) + if Kit.button(cx, cy, inner, height, label, opts) then Ops.setMonProperty(S, mon, d.key, not on) end - cy = cy + row + gap - elseif d.choices then - choice(d.key, d.label, P.get(mon, d), d.choices, function(v) + cy = cy + height + gap + hint(d.key) + elseif Named.property(S, d) then + choice(d.key, d.label, P.get(mon, d), Named.property(S, d), function(v) return Ops.setMonProperty(S, mon, d.key, v) end) else @@ -188,7 +177,7 @@ local function drawSection(S, Kit, x, y, w, h) text(def.name or tostring(mon.species or mon.speciesId), PAL.heading) button("Change species", function() Ops.openSpeciesPicker(S, Kit) - end, "accent") + end, "accent", nil, "species") if S.nicknameMon ~= mon then S.nicknameMon, S.nicknameDraft = mon, mon.nickname or "" end @@ -203,6 +192,7 @@ local function drawSection(S, Kit, x, y, w, h) S.nicknameDraft or "", "no nickname", { + invalid = issues.fields.nickname ~= nil, sanitize = function(v) return Ops.nicknameSanitize(S, v) end, @@ -213,6 +203,7 @@ local function drawSection(S, Kit, x, y, w, h) Kit.blur() end cy = cy + row + gap + hint("nickname") button("Clear nickname", function() Ops.clearNickname(S, mon) S.nicknameDraft = "" @@ -235,22 +226,30 @@ local function drawSection(S, Kit, x, y, w, h) return Ops.setCurrentHp(S, mon, v) end ) - button("Status: " .. tostring(mon.status or "healthy"), function() - local choices = g == 1 and { "", "SLP", "PSN", "BRN", "FRZ", "PAR" } - or { "", "SLP", "PSN", "BRN", "FRZ", "PAR", "TOX" } - local at = 1 - for i, v in ipairs(choices) do - if v == (mon.status or "") then - at = i - end - end - local want = choices[at % #choices + 1] - Ops.setMonStatus(S, mon, want ~= "" and want or nil) + local statuses = { + { "healthy", "Healthy" }, + { "SLP", "Asleep" }, + { "PSN", "Poisoned" }, + { "BRN", "Burned" }, + { "FRZ", "Frozen" }, + { "PAR", "Paralyzed" }, + } + if g >= 2 then + statuses[#statuses + 1] = { "TOX", "Badly poisoned" } + end + local savedStatus = mon.status or "healthy" + if type(savedStatus) == "number" then + savedStatus = ({ [0]="healthy",[8]="PSN",[16]="BRN",[32]="FRZ",[64]="PAR",[128]="TOX" })[savedStatus] + or (savedStatus >= 1 and savedStatus <= 7 and "SLP") or savedStatus + end + choice("status", "Status", savedStatus, statuses, function(v) + return Ops.setMonStatus(S, mon, v ~= "healthy" and v or nil) end) if g >= 2 then - button("Held item: " .. tostring(mon.heldItem or mon.item or "none"), function() + local held = S.data.items and S.data.items[mon.heldItem or mon.item] + button("Held item: " .. tostring(held and held.name or "None"), function() Ops.openItemPicker(S, Kit, "held") - end, "accent") + end, "accent", nil, "heldItem") button("Clear held item", function() Ops.setHeldItem(S, mon, nil) end, "danger") @@ -265,7 +264,7 @@ local function drawSection(S, Kit, x, y, w, h) if g == 3 then local Pokemon = require("src.core.game3.pokemon") local Summary = require("src.core.game3.summary_data") - local nature = (mon.personality or 0) % 25 + local nature = mon.nature or (tonumber(mon.personality) or 0) % 25 local natures = {} for id = 0, 24 do local ok, name = pcall(function() @@ -276,39 +275,57 @@ local function drawSection(S, Kit, x, y, w, h) choice("nature", "Nature", nature, natures, function(v) return Ops.setNature(S, mon, v) end) - local slot = mon.abilityNum or (mon.personality or 0) % 2 - button( - "Ability: " - .. tostring( - Pokemon.abilityName(mon.ability or Pokemon.abilityId(mon.species, mon.personality)) - ) - .. " / slot " - .. (slot + 1), - function() - Ops.setAbility(S, mon, 1 - slot) - end, - "accent" - ) - local gender = Pokemon.gender(mon.species, mon.personality) - button("Gender: " .. gender, function() - Ops.setMonGender(S, mon, gender == "M" and "F" or "M") - end) - button("Shiny: " .. (Pokemon.isShiny(mon) and "ON" or "OFF"), function() - Ops.setShiny(S, mon, not Pokemon.isShiny(mon)) - end, "warn") - text( - "Nature, ability, gender and shiny changes regenerate PID. Use Checks to review the result." - ) - elseif g == 2 then - field("pokerus", "Pokerus (packed strain / days)", mon.pokerus or 0, function(v) - local n = tonumber(v) - if not require("Legality").integer(n, 0, 255) then - return Ops.say(S, "Pokerus must be 0-255") + local slot = mon.abilityNum or (tonumber(mon.personality) or 0) % 2 + local abilities = {} + for i, id in ipairs(Pokemon.abilities(mon.species)) do + if id and id ~= 0 then + abilities[#abilities + 1] = { i - 1, Pokemon.abilityName(id) } end - return Ops.setPokerus(S, mon, n) + end + choice("ability", "Ability", slot, abilities, function(v) + return Ops.setAbility(S, mon, v) + end) + local gender = mon.gender or Pokemon.gender(mon.species, tonumber(mon.personality) or 0) + local ratio = (Pokemon.speciesMeta(mon.species) or {}).genderRatio or 255 + local genders = ratio == 255 and { { "U", "Genderless" } } + or ratio == 0 and { { "M", "Male" } } + or ratio == 254 and { { "F", "Female" } } + or { { "M", "Male" }, { "F", "Female" } } + choice("gender", "Gender", gender, genders, function(v) + return Ops.setMonGender(S, mon, v) + end) + local shiny = mon.isShiny + if shiny == nil then shiny = Pokemon.isShiny(mon) end + button("Shiny: " .. (shiny and "ON" or "OFF"), function() + Ops.setShiny(S, mon, not shiny) + end, "warn", "Changes shininess and personality. Review Checks after editing.", "shiny") + elseif g == 2 then + local d = P.find(S, "pokerus") + choice("pokerus", "Pokérus", mon.pokerus or 0, Named.property(S, d), function(v) + return Ops.setPokerus(S, mon, v) end) text("Gender, shininess and Unown form follow DVs.") + for _, id in ipairs({ "gender", "shiny", "form" }) do hint(id) end end + button("Fix all errors", function() + Ops.fixMonErrors(S, mon) + end, "good", "Fixes invalid values, stats and PP. Origin warnings still need checking.") + button( + "Randomize Pokémon", + function() + Ops.randomizeMon(S, mon) + end, + "accent", + "Replaces this Pokémon with a wild one from this game. Real level range, normal moves. Undo brings yours back." + ) + button( + "Max out Pokémon", + function() + Ops.maxMon(S, mon) + end, + "good", + "Level 100, max IVs or DVs, friendship and PP. Spare EVs go to the strongest stats. Fully heals." + ) button("Full heal", function() Ops.healMon(S, mon) end, "good") @@ -326,7 +343,7 @@ local function drawSection(S, Kit, x, y, w, h) tostring(stats.defense or mon.defense or "?"), tostring(stats.speed or mon.speed or "?") ), - PAL.heading + issues.fields.calculated and PAL.red or PAL.heading ) text( g == 1 and ("Special: " .. tostring(stats.special or "?")) @@ -336,16 +353,17 @@ local function drawSection(S, Kit, x, y, w, h) .. " / Sp. Def: " .. tostring(stats.specialDefense or stats.spDef or mon.spDef or "?") ), - PAL.heading + issues.fields.calculated and PAL.red or PAL.heading ) + hint("calculated") text( g == 3 and "IVs 0-31. EVs 0-255 with a total limit of 510." or "DVs 0-15. HP DV follows the other DVs. Stat experience 0-65535." ) if g == 3 then - button("Max all (31)", function() + button("Max all IVs", function() Ops.maxIvs(S, mon) - end, "good") + end, "good", "Sets all six IVs to 31. Origin checks may still need review.") button("Clear EVs", function() Ops.clearEvs(S, mon) end, "danger") @@ -353,16 +371,42 @@ local function drawSection(S, Kit, x, y, w, h) for _, k in ipairs(keys) do total = total + (mon.evs and mon.evs[k] or 0) end - text("Total EVs: " .. total .. " / 510", total > 510 and PAL.red or PAL.green) + text( + "EVs: " .. total .. " / 510 · " .. math.max(0, 510 - total) .. " free", + total > 510 and PAL.red or PAL.green + ) + else + button("Max all DVs", function() + Ops.maxDvs(S, mon) + end, "good", "Sets DVs to 15. In Gen 2 this can change gender and shininess.") + button("Max stat training", function() + Ops.maxStatExp(S, mon) + end, "good", "Fills stat experience for every stat.") + text("HP DV: " .. tostring(mon.dvs and mon.dvs.hp or 0) .. " / 15 · follows the other DVs", issues.fields["dv-hp"] and PAL.red) + hint("dv-hp") end for _, k in ipairs(keys) do if g == 3 then - field("iv-" .. k, k:upper() .. " IV", mon.ivs and mon.ivs[k] or 0, function(v) - return Ops.setIv(S, mon, k, tonumber(v) or 0) - end) - field("ev-" .. k, k:upper() .. " EV", mon.evs and mon.evs[k] or 0, function(v) - return Ops.setEv(S, mon, k, tonumber(v) or 0) - end) + field( + "iv-" .. k, + ( + { hp = "HP", atk = "Attack", def = "Defense", spa = "Sp. Atk", spd = "Sp. Def", spe = "Speed" } + )[k] .. " IV", + mon.ivs and mon.ivs[k] or 0, + function(v) + return Ops.setIv(S, mon, k, tonumber(v) or 0) + end + ) + field( + "ev-" .. k, + ( + { hp = "HP", atk = "Attack", def = "Defense", spa = "Sp. Atk", spd = "Sp. Def", spe = "Speed" } + )[k] .. " EV", + mon.evs and mon.evs[k] or 0, + function(v) + return Ops.setEv(S, mon, k, tonumber(v) or 0) + end + ) else if k ~= "hp" then field("dv-" .. k, k:upper() .. " DV", mon.dvs and mon.dvs[k] or 0, function(v) @@ -386,7 +430,7 @@ local function drawSection(S, Kit, x, y, w, h) local md = id and S.data.moves and S.data.moves[id] button("Slot " .. slot .. ": " .. tostring(md and md.name or id or "empty"), function() Ops.openMovePicker(S, Kit, slot) - end, "accent") + end, "accent", nil, "move" .. slot) if id and id ~= 0 then local pp = type(mv) == "table" and mv.pp or mon.pp and mon.pp[slot] or 0 local ups = MonOps.getPpUps(mon, slot) @@ -417,18 +461,22 @@ local function drawSection(S, Kit, x, y, w, h) end elseif S.monSection == "extras" then if g == 3 then - text("Contest conditions") + text("Contest conditions", issues.fields.contest and PAL.red) + hint("contest") props(P.contest) - text("Ribbons: setting a ribbon does not establish award or event eligibility.") + text("Ribbons: setting a ribbon does not establish award or event eligibility.", issues.fields.ribbons and PAL.red) + hint("ribbons") props(P.ribbons) else text("This generation has no contest conditions or ribbons.") end else - local report = require("Legality").mon(S, mon) + button("Fix all errors", function() + Ops.fixMonErrors(S, mon) + end, "good", "Fixes invalid values, stats and PP. Does not invent encounter or event history.") text( report.errors > 0 and (report.errors .. " property errors") - or "Property checks passed; encounter legality is unchecked", + or "Values pass. Origin still needs checking.", report.errors > 0 and PAL.red or PAL.yellow ) for _, check in ipairs(report.checks) do @@ -443,6 +491,10 @@ local function drawSection(S, Kit, x, y, w, h) Kit.scrollbar(cx, bodyY, inner, bodyH, S.inspectorScroll, S._formHeight, bodyH) end function Body.draw(S, Kit, x, y, w, h) - Motion.pages(S, Kit, "monSection", x, y, w, h, drawSection) + local report = require("Legality").mon(S, S.editingMon) + local issues = require("Legality").highlights(report, S.editingMon) + Motion.pages(S, Kit, "monSection", x, y, w, h, function(state, kit, px, py, pw, ph) + drawSection(state, kit, px, py, pw, ph, report, issues) + end) end return Body diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index ffa1a267..2ca5fb57 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -73,19 +73,47 @@ function Kit.beginFrame(mx, my, clicked, wheel) Kit._fieldHit = false Kit._focusDrawn = false Kit.wheelY = wheel or 0 - -- Held-button state is polled, not evented: the editor is hosted both - -- standalone and inside the launcher, and neither routes mousereleased - -- here. Touch drag scrolling (#715) rides this poll, so it works in both - -- hosts without new plumbing. The stub has no love.mouse.isDown; a frame - -- without it simply has no drags. + -- Mouse taps dispatch on release, so dragging never activates a row. + -- The real touch stream supplies its own precise drag deltas. SDL + -- synthesized mouse holds are suppressed until the finger is up. local down = false if love and love.mouse and love.mouse.isDown then down = love.mouse.isDown(1) and true or false end + if Kit.ignoreMouseDown then + if not down then + Kit.ignoreMouseDown = nil + end + down = false + end Kit.mouseDown = down if Kit.touchDown == true then Kit.mouseDown = true end + if Kit.mouseDown and not Kit.touchDown then + local d = Kit._pointerDrag + if not d then + Kit._pointerDrag = { x = mx, y = my, lastY = my, startY = my, moved = false } + else + if math.abs(my - d.startY) + math.abs(mx - d.x) > 10 then + d.moved = true + end + if d.moved then + Kit.dragAdd(d.lastY - my) + end + d.lastY = my + end + if clicked then + Kit._tapPending = true + end + Kit.mouseClicked = false + elseif Kit._pointerDrag then + local d = Kit._pointerDrag + if Kit._tapPending and not d.moved then + Kit.mouseClicked = true + end + Kit._pointerDrag, Kit._tapPending = nil, nil + end if not Kit.mouseDown then Kit._drag = nil end @@ -106,6 +134,10 @@ function Kit.endFrame() edits[i] = nil end Kit.wheelY = 0 + Kit._dragDelta = 0 + if not Kit.touchDown then + Kit._touchDrag = nil + end end -- Use the launcher's font scale, with reflow and scrolling on small screens. @@ -443,8 +475,17 @@ local ACTION_ICONS = { Prev = "chevron-left", Next = "chevron-right", } +local ERROR_FILL = { 58, 31, 37 } +local function errorFace(opts) + if not opts or not opts.invalid then return opts or {} end + local copy = {} + for key, value in pairs(opts) do copy[key] = value end + copy.face, copy.fill, copy.ink, copy.stroke = "invert", ERROR_FILL, PAL.red, PAL.red + copy.trailingIcon, copy.ring = copy.trailingIcon or "triangle-alert", false + return copy +end function Kit.buttonWidth(label, opts, h) - opts = opts or {} + opts = errorFace(opts) h = h or Kit.controlH() if opts.iconOnly then return h @@ -462,7 +503,7 @@ end function Kit.buttonHeight(label, w, opts) local h = Kit.controlH() - local height = Button.labelLayout(Kit, w, h, label, opts or {}).height + 4 * Kit.scale + local height = Button.labelLayout(Kit, w, h, label, errorFace(opts)).height + 4 * Kit.scale return math.max(h, math.ceil(height)) end @@ -474,7 +515,7 @@ function Kit.button(x, y, w, h, label, opts) for key, value in pairs(opts or {}) do faceOpts[key] = value end - opts = faceOpts + opts = errorFace(faceOpts) opts.radius = Theme.radius() opts.segments = Theme.CONTROL.segments opts.emboss = false @@ -492,6 +533,9 @@ function Kit.button(x, y, w, h, label, opts) opts.labelLayout = Button.labelLayout(Kit, w, h, label, opts) end Button.draw(Kit, x, y, w, h, shown, opts, hot, false) + if opts.invalid then + Theme.stroke(x, y, w, h, Theme.radius(), PAL.red, 0.95, 2 * Kit.scale) + end return opts.enabled ~= false and Kit.press(x, y, w, h) or false end @@ -667,16 +711,17 @@ function Kit.textfield(id, x, y, w, h, value, placeholder, opts) end if G then local r = Theme.radius() - Theme.fillRounded(x, y, w, h, PAL.rowBg, 0.7, r) + local invalid = opts and opts.invalid + Theme.fillRounded(x, y, w, h, invalid and ERROR_FILL or PAL.rowBg, 0.7, r) Theme.stroke( x, y, w, h, r, - focused and PAL.blue or PAL.cardBorder, - focused and 0.8 or 0.3, - focused and 1.5 * Kit.scale or 1 + invalid and PAL.red or focused and PAL.blue or PAL.cardBorder, + invalid and 0.95 or focused and 0.8 or 0.3, + invalid and 2 * Kit.scale or focused and 1.5 * Kit.scale or 1 ) local pad = 10 * Kit.scale local ty = y + (h - Kit.textHeight("mono")) / 2 @@ -684,7 +729,7 @@ function Kit.textfield(id, x, y, w, h, value, placeholder, opts) Kit.text("mono", placeholder or "", x + pad, ty, PAL.faint) else local shown = Theme.ellipsizeLeft(font("mono"), value, w - 2 * pad) - local tw = Kit.text("mono", shown, x + pad, ty, PAL.heading) + local tw = Kit.text("mono", shown, x + pad, ty, invalid and PAL.red or PAL.heading) -- caret: blinks only while focused, parked at the end of the text if focused and (Kit.time % 1) < 0.55 then Theme.col(PAL.blue, 1) @@ -848,14 +893,19 @@ function Kit.scrollPixels(x, y, w, h, offset, contentH) return offset end - if Kit.mouseDown and maxOffset > 0 and h > 0 then - local key = "px:" .. math.floor(x) .. ":" .. math.floor(y) - local d = Kit._drag - if not d and Kit.hit(x, y, w, h) then - Kit._drag = { key = key, startY = Kit.mouseY, base = offset } - elseif d and d.key == key then - offset = Theme.clamp(d.base + (d.startY - Kit.mouseY), 0, maxOffset) - end + local d = Kit._pointerDrag or Kit._touchDrag + if + d + and maxOffset > 0 + and h > 0 + and Kit._dragDelta ~= 0 + and d.x >= x + and d.x <= x + w + and d.startY >= y + and d.startY <= y + h + then + offset = Theme.clamp(offset + (Kit._dragDelta or 0), 0, maxOffset) + Kit._dragDelta = 0 end if (Kit.wheelY or 0) == 0 then @@ -865,11 +915,45 @@ function Kit.scrollPixels(x, y, w, h, offset, contentH) return offset end local notch = 48 * Kit.scale - local delta = (Kit.wheelY > 0) and -notch or notch + local delta = -Kit.wheelY * notch Kit.wheelY = 0 return Theme.clamp(offset + delta, 0, maxOffset) end +function Kit.dragAdd(delta) + Kit._dragDelta = (Kit._dragDelta or 0) + delta +end + +-- Lists keep the old row offset for keyboard/pager callers, but draw from a +-- pixel offset so a finger moves the content by exactly the distance travelled. +function Kit.list(S, key, x, y, w, h, total, stride, cols) + cols = cols or 1 + S._listState = S._listState or {} + local st = S._listState[key] or { pixels = (S[key] or 0) / cols * stride } + S._listState[key] = st + if st.first ~= nil and S[key] ~= st.first then + st.pixels = (S[key] or 0) / cols * stride + end + if st.stride and (st.stride ~= stride or st.cols ~= cols) then + st.pixels = (S[key] or 0) / cols * stride + end + st.stride, st.cols = stride, cols + local content = math.max(0, math.ceil(total / cols) * stride) + st.pixels = Kit.scrollPixels(x, y, w, h, st.pixels, content) + local firstRow = math.floor(st.pixels / stride) + local shift = st.pixels - firstRow * stride + S[key], st.first = firstRow * cols, firstRow * cols + st.content, st.view = content, h + return math.min(total - S[key], math.ceil((h + shift) / stride) * cols), shift +end + +function Kit.listScrollbar(S, key, x, y, w, h) + local st = S._listState and S._listState[key] + if st then + Kit.scrollbar(x, y, w, h, st.pixels, st.content, st.view) + end +end + -- Thin scrollbar in the card's right padding, clear of control faces and -- their corner arcs. Pure indicator (the drag above and the -- pager are the controls): on a phone the old layout looked "stuck" because diff --git a/tools/save-editor/Legality.lua b/tools/save-editor/Legality.lua index 11b59ba2..b2813206 100644 --- a/tools/save-editor/Legality.lua +++ b/tools/save-editor/Legality.lua @@ -58,6 +58,10 @@ function L.mon(S, mon) add("error", "species", "Species is missing from the active game catalog") end range("level", mon.level, 1, 100) + if type(mon.hp) ~= "number" or mon.hp ~= mon.hp or mon.hp ~= math.floor(mon.hp) + or mon.hp < 0 or mon.hp == math.huge then + add("error", "current HP", "Current HP must be a nonnegative whole number") + end for _, key in ipairs({ "happiness", "friendship" }) do if mon[key] ~= nil then range(key, mon[key], 0, 255) @@ -388,6 +392,113 @@ function L.mon(S, mon) end return r end +-- Match validator fields to the controls that edit them. Warnings are not errors. +function L.highlights(report, mon) + local out = + { fields = {}, sections = { main = 0, stats = 0, moves = 0, origin = 0, extras = 0, checks = report.errors } } + local main = { + species = true, + nickname = true, + level = true, + experience = true, + ["current-hp"] = true, + friendship = true, + status = true, + nature = true, + gender = true, + ability = true, + shiny = true, + heldItem = true, + } + local function section(id) + if main[id] then + return "main" + end + if id:match("^iv%-") or id:match("^ev%-") or id:match("^dv%-") or id:match("^se%-") or id == "calculated" then + return "stats" + end + if id:match("^move%d") or id:match("^pp%-") or id:match("^ppup%-") then + return "moves" + end + if id:match("^contest%.") or id:match("^ribbon%.") then + return "extras" + end + return "origin" + end + for _, check in ipairs(report.checks) do + if check.kind == "error" then + local ids, f = {}, check.field + local function add(id) + ids[#ids + 1] = id + end + local key = f:match("^IV (.+)$") + or f:match("^EV (.+)$") + or f:match("^DV (.+)$") + or f:match("^Stat experience (.+)$") + if key then + add((f:match("^IV ") and "iv-" or f:match("^EV ") and "ev-" or f:match("^DV ") and "dv-" or "se-") .. key) + elseif f == "evs" or f == "ivs" then + for _, k in ipairs(IV_KEYS) do + add((f == "evs" and "ev-" or "iv-") .. k) + end + elseif f == "dvs" or f == "statExp" then + for _, k in ipairs(DV_KEYS) do + add((f == "dvs" and "dv-" or "se-") .. k) + end + elseif f == "moves" or f == "pp" or f == "ppBonuses" or f == "packed PP Ups" then + for i = 1, 4 do + add("move" .. i) + add("pp-" .. i) + add("ppup-" .. i) + end + elseif f:match("^move%d$") then + local slot = f:match("(%d)$") + add(f) + if check.message:find("PP Ups", 1, true) then + add("ppup-" .. slot) + end + if check.message:find("PP", 1, true) and not check.message:find("Empty move slot has PP Ups", 1, true) then + add("pp-" .. slot) + end + elseif f:match("^PP %d$") then + add("pp-" .. f:match("(%d)$")) + elseif f:match("PP Ups %d$") then + add("ppup-" .. f:match("(%d)$")) + elseif f == "contest" then + add("contest") + for _, d in ipairs(Properties.contest) do + if not mon or type(mon.contest) ~= "table" or (mon.contest[d.child] or 0) ~= 0 then + add(d.key) + end + end + elseif f == "ribbons" or f == "ribbon word" then + add("ribbons") + else + add( + ({ + ["current HP"] = "current-hp", + happiness = "friendship", + ["ability slot"] = "ability", + ["dvs.hp"] = "dv-hp", + sleep = "status", + })[f] + or (f:match("^stats") and "calculated") + or f + ) + end + local counted = {} + for _, id in ipairs(ids) do + out.fields[id] = out.fields[id] or check.message + local page = (id == "contest" or id == "ribbons") and "extras" or section(id) + if not counted[page] then + out.sections[page] = out.sections[page] + 1 + counted[page] = true + end + end + end + end + return out +end function L.save(S) local report = { entries = {}, errors = 0, warnings = 0 } local function inspect(mon, label, box, slot) diff --git a/tools/save-editor/MonActions.lua b/tools/save-editor/MonActions.lua new file mode 100644 index 00000000..86376b08 --- /dev/null +++ b/tools/save-editor/MonActions.lua @@ -0,0 +1,402 @@ +local Copy = require("src.mods.Merge").deepCopy +local Gen = require("Gen") +local M = require("MonOps") +local P = require("Properties") +local Limits = require("ValueLimits") +local A = {} +local function clamp(v, lo, hi) + v = tonumber(v) + if not v or v ~= v or v == math.huge or v == -math.huge then + v = lo + end + return math.max(lo, math.min(hi, math.floor(v))) +end +local function replace(mon, staged) + for k in pairs(mon) do + mon[k] = nil + end + for k, v in pairs(staged) do + mon[k] = v + end +end +local function cleanName(S, name, max) + local nameClean = require("Ops").nicknameSanitize(S, tostring(name or "")) + local chars = {} + for ch in nameClean:gmatch("[%z\1-\127\194-\244][\128-\191]*") do + if #chars < max then + chars[#chars + 1] = ch + end + end + return table.concat(chars) +end +function A.repair(S, original) + local Ops, L = require("Ops"), require("Legality") + if type(original) ~= "table" or not Ops.speciesUsable(S, original.species or original.speciesId) then + return nil, "Choose a known species first" + end + local mon, g = Copy(original), Gen.ofState(S) + local tables = g == 3 and { "ivs", "evs", "moves", "pp", "ppBonuses", "contest", "stats" } + or { "dvs", "statExp", "moves", "pp", "ppBonuses", "stats" } + for _, key in ipairs(tables) do + if type(mon[key]) ~= "table" then + mon[key] = {} + end + end + mon.level = clamp(mon.level, 1, 100) + mon.nickname = cleanName(S, mon.nickname, 10) + for _, d in ipairs(P.all(S)) do + local v = P.get(mon, d) + if d.text then + v = cleanName(S, v, d.max) + elseif d.toggle then + v = v == true or v == 1 + else + v = clamp(v, d.lo, d.hi) + if d.values and P.parse(d, v) == nil then + v = d.default or d.choices[1][1] + end + end + P.write(mon, d, v) + end + if mon.happiness ~= nil then + mon.happiness = clamp(mon.happiness, 0, 255) + end + if mon.friendship ~= nil then + mon.friendship = clamp(mon.friendship, 0, 255) + end + if g == 3 then + local remaining = 510 + for _, k in ipairs(Limits.EV_KEYS) do + mon.ivs[k] = clamp(mon.ivs[k], 0, 31) + mon.evs[k] = clamp(mon.evs[k], 0, math.min(255, remaining)) + remaining = remaining - mon.evs[k] + end + local Pokemon = require("src.core.game3.pokemon") + local pair = Pokemon.abilities(mon.species or mon.speciesId) + mon.abilityNum = pair[2] and pair[2] ~= 0 and mon.personality % 2 or 0 + mon.ability, mon.abilityId, mon.isShiny = pair[mon.abilityNum + 1], pair[mon.abilityNum + 1], nil + mon.metLevel = math.min(mon.metLevel or 0, mon.level) + if mon.egg or mon.isEgg then + mon.level, mon.metLevel, mon.language, mon.pokeball = 5, 0, 1, 4 + for _, d in ipairs(P.contest) do + P.write(mon, d, 0) + end + end + local bit = require("bit") + local word = P.get(mon, { ribbon = true, shift = 0, width = 32 }) + word = bit.band(clamp(word, 0, 4294967295), 0x87FFFFFF) + mon.ribbons = word < 0 and word + 4294967296 or word + else + for _, k in ipairs({ "attack", "defense", "speed", "special" }) do + mon.dvs[k] = clamp(mon.dvs[k], 0, 15) + end + M.syncHpDv(mon.dvs) + for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do + mon.statExp[k] = clamp(mon.statExp[k], 0, 65535) + end + if g == 2 then + M.setDv(S.data, mon, "attack", mon.dvs.attack, g) + end + end + local strain = math.floor(clamp(mon.pokerus, 0, 255) / 16) + if g == 2 then + strain = math.min(8, strain) + end + local days = strain == 0 and 0 or math.min(clamp(mon.pokerus, 0, 255) % 16, strain % 4 + 1) + if mon.pokerus ~= nil then + mon.pokerus = strain * 16 + days + end + local held = mon.heldItem or mon.item + if held and held ~= 0 and held ~= "NONE" and (g == 1 or not Ops.itemHoldable(S, held)) then + mon.heldItem, mon.item = nil, nil + end + local status = { SLP = true, PSN = true, BRN = true, FRZ = true, PAR = true, TOX = g ~= 1 } + if type(mon.status) == "number" then + local allowed = { [0] = true, [8] = true, [16] = true, [32] = true, [64] = true, [128] = true } + if not allowed[mon.status] and not (mon.status >= 1 and mon.status <= 7) then + mon.status = nil + end + elseif not status[mon.status] then + mon.status = nil + end + mon.sleep = mon.status == "SLP" and clamp(mon.sleep, 1, 7) or nil + local seen, moves = {}, {} + for slot = 1, 4 do + local mv = mon.moves[slot] + local id = type(mv) == "table" and (mv.moveId or mv.id) or mv + if id and id ~= 0 and Ops.moveUsable(S, id) and not seen[id] then + moves[slot], seen[id] = mv, true + end + end + mon.moves = moves + mon.ppBonusesPacked = clamp(mon.ppBonusesPacked, 0, 255) + if next(moves) == nil and not (mon.egg or mon.isEgg) then + local temp = { data = S.data, save = S.save, version = S.version } + Ops.resetMoves(temp, mon) + end + for slot = 1, 4 do + local mv = mon.moves[slot] + if mv then + local ups = clamp(M.getPpUps(mon, slot), 0, 3) + if mon.egg or mon.isEgg or M.getBasePp(S.data, mon, slot) == 1 then + ups = 0 + end + M.setPpUps(S.data, mon, slot, ups, g) + M.setPp(S.data, mon, slot, clamp(type(mv) == "table" and mv.pp or mon.pp[slot], 0, 255), g) + else + M.clearMove(mon, slot) + mon.pp[slot] = 0 + M.setPpUps(S.data, mon, slot, 0, g) + end + end + local lo = Limits.expAt(S, mon, mon.level) + local hi = mon.level == 100 and lo or Limits.expAt(S, mon, mon.level + 1) - 1 + local exp = clamp(Gen.exp(mon), lo, hi) + if g == 2 then + mon.experience = exp + else + mon.exp = exp + end + if mon.exp ~= nil then + mon.exp = exp + end + if mon.experience ~= nil then + mon.experience = exp + end + M.recalc(S.data, mon, g) + mon.hp = clamp(mon.hp, 0, mon.maxHp or mon.stats.hp) + local report = L.mon(S, mon) + if report.errors > 0 then + for _, check in ipairs(report.checks) do + if check.kind == "error" then + return nil, check.message + end + end + end + return mon +end +function A.fixMon(S, mon) + local Ops, L = require("Ops"), require("Legality") + if L.mon(S, mon).errors == 0 then + return Ops.say(S, "No property errors to fix") + end + local staged, err = A.repair(S, mon) + if not staged then + return Ops.say(S, err) + end + replace(mon, staged) + return Ops.mark(S, "Fixed property errors. Check origin warnings separately.") +end +function A.fixAll(S) + local fixed, left = 0, 0 + for _, entry in ipairs(require("Legality").save(S).entries) do + if entry.report.errors > 0 then + local staged = A.repair(S, entry.mon) + if staged then + replace(entry.mon, staged) + fixed = fixed + 1 + else + left = left + 1 + end + end + end + local msg = "Fixed " .. fixed .. " Pokémon" + if left > 0 then + msg = msg .. "; " .. left .. " need a manual choice" + end + if fixed == 0 then + return require("Ops").say(S, left > 0 and msg or "No property errors to fix") + end + return require("Ops").mark(S, msg .. ". Origin warnings stay for review.") +end +function A.maxMon(S, mon) + local Ops, g = require("Ops"), Gen.ofState(S) + local staged, err = A.repair(S, mon) + if not staged then + return Ops.say(S, err) + end + if staged.egg or staged.isEgg then + return Ops.say(S, "Hatch the egg before maxing it out") + end + M.setLevel(S.data, staged, 100, g) + M.setHappiness(S.data, staged, 255, g) + if g == 3 then + M.maxIvs(S.data, staged, g) + -- Keep the existing spread; spend unused points on the strongest stats. + local stats = staged.stats or {} + local map = { + hp = "hp", + atk = "attack", + def = "defense", + spa = "specialAttack", + spd = "specialDefense", + spe = "speed", + } + local keys = Copy(Limits.EV_KEYS) + table.sort(keys, function(a, b) + local av, bv = stats[map[a]] or 0, stats[map[b]] or 0 + return av ~= bv and av > bv or av == bv and a < b + end) + for _, k in ipairs(keys) do + M.setEv(S.data, staged, k, math.max(staged.evs[k] or 0, 252), g) + end + else + for _, k in ipairs({ "attack", "defense", "speed", "special" }) do + M.setDv(S.data, staged, k, 15, g) + end + for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do + staged.statExp[k] = 65535 + end + end + M.maxAllPpUps(S.data, staged, g) + M.recalc(S.data, staged, g) + staged.hp, staged.status = staged.maxHp or staged.stats.hp, nil + staged.sleep, staged.toxicCounter = nil, nil + local result = require("Legality").mon(S, staged) + if result.errors > 0 then + for _, check in ipairs(result.checks) do + if check.kind == "error" then + return Ops.say(S, "Max refused: " .. check.message) + end + end + end + replace(mon, staged) + return Ops.mark(S, "Maxed level, stats, friendship and PP; fully healed") +end +-- pokefirered/src/wild_encounter.c:49 sUnownLetterSlots, by chamber. +local UNOWN_LETTERS = { + MAPSEC_MONEAN_CHAMBER = { 0, 27 }, + MAPSEC_LIPTOO_CHAMBER = { 2, 3, 7, 20, 14 }, + MAPSEC_WEEPTH_CHAMBER = { 13, 18, 8, 4 }, + MAPSEC_DILFORD_CHAMBER = { 15, 11, 9, 17, 16 }, + MAPSEC_SCUFIB_CHAMBER = { 24, 19, 6, 5, 10 }, + MAPSEC_RIXY_CHAMBER = { 21, 22, 23, 12, 1 }, + MAPSEC_VIAPOIS_CHAMBER = { 25, 26 }, +} +local function random(lo, hi) + return math.random(lo, hi) +end +function A.encounters(S) + if S._randomEncounters then + return S._randomEncounters + end + local candidates, g = {}, Gen.ofState(S) + local tables = S.data.gen2Encounters or S.data.encounters or {} + if g == 2 and (tables.grass or tables.water) then + tables = { grass = tables.grass, water = tables.water } + end + if g == 3 then + local E = require("src.core.game3.encounters") + E.ensureLoaded() + tables = {} + for map in pairs(S.data.maps or {}) do + if type(map) == "string" then + tables[map] = E.tableFor(map) + end + end + end + local function walk(t, map, time) + if type(t) ~= "table" then + return + end + local sp = t.species or t.pokemon + local lo = tonumber(t.minLevel or t.level) + local hi = tonumber(t.maxLevel or t.level) + if sp and lo and hi and require("Ops").speciesUsable(S, sp) then + local def = map and S.data.maps and S.data.maps[map] + local location = def and tonumber(g == 3 and def.regionMapSectionId or def.landmark) + if (g ~= 3 and not Gen.hasCaughtData(S.save, S.version)) or location then + candidates[#candidates + 1] = + { species = sp, lo = lo, hi = hi, map = map, location = location, time = time } + end + else + for key, v in pairs(t) do + local nextMap = type(key) == "string" and S.data.maps and S.data.maps[key] and key or map + local nextTime = ({ MORN = 1, DAY = 2, NITE = 3 })[key] or time + walk(v, nextMap, nextTime) + end + end + end + walk(tables) + S._randomEncounters = candidates + return candidates +end +function A.randomize(S, mon) + local Ops, g = require("Ops"), Gen.ofState(S) + local candidates = A.encounters(S) + if #candidates == 0 then + return Ops.say(S, "This game's encounter data is unavailable") + end + local row = candidates[random(1, #candidates)] + local staged = M.create(S.data, row.species, random(row.lo, row.hi), g) + staged.ot = S.save.player and S.save.player.name or S.save.name or "RED" + staged.otName = staged.ot + staged.otId = S.save.player and S.save.player.id or S.save.trainerId or 0 + if g == 3 then + -- Independent stream: editing never advances the running game's RNG. + local R = require("src.core.game3.rng") + local seed = random(0, 65535) + random(0, 65535) * 65536 + local function next16() + seed = (R.mulU32(seed, 1103515245) + 24691) % 4294967296 + return math.floor(seed / 65536) + end + local v = Gen.versionOf(S.save, S.version) + local isRse = require("src.core.GameVersion").layout(v) == "rse" + local constants = require("src.core.game3.constants").of(isRse and "emerald" or "firered") + local sections = constants.region_map_sections.byName + local safari = row.location == sections.MAPSEC_SAFARI_ZONE or row.location == sections.MAPSEC_KANTO_SAFARI_ZONE + if safari then + staged.pokeball = 5 + end + if tonumber(staged.species) == constants:id("species", "SPECIES_UNOWN") and not isRse then + local letters = UNOWN_LETTERS[constants:name("region_map_sections", row.location, "MAPSEC_")] + if not letters then + return Ops.say(S, "No verified Unown form for this location") + end + local letter = letters[random(1, #letters)] + -- Unown uses high-half first; normal Random32 uses low-half first. + repeat + local high, low = next16(), next16() + staged.personality = low + high * 65536 + until require("src.core.game3.pokemon").unownLetter(staged.personality) == letter + else + -- GenerateWildMon -> CreateMonWithNature -> CreateBoxMon. Preserve + -- the native nature rejection loop and its following two IV draws. + if isRse and safari then + next16() + end + local nature = next16() % 25 + repeat + local low, high = next16(), next16() + staged.personality = low + high * 65536 + until staged.personality % 25 == nature + end + local iv1, iv2 = next16(), next16() + staged.ivs = { + hp = iv1 % 32, + atk = math.floor(iv1 / 32) % 32, + def = math.floor(iv1 / 1024) % 32, + spe = iv2 % 32, + spa = math.floor(iv2 / 32) % 32, + spd = math.floor(iv2 / 1024) % 32, + } + staged.abilityNum = nil + staged.otSecretId = S.save.secretId or 0 + staged.otGender = S.save.gender or 0 + staged.language, staged.metLocation, staged.metLevel = 2, row.location, staged.level + staged.metGame = require("src.core.GameVersion").gameCode(Gen.versionOf(S.save, S.version)) + elseif g == 2 and Gen.hasCaughtData(S.save, S.version) then + staged.caughtLevel = math.min(63, staged.level) + staged.caughtLocation, staged.caughtTime = row.location, row.time or 2 + staged.caughtByGender = Gen.playerGender(S.save) == "female" and "girl" or "boy" + end + M.recalc(S.data, staged, g) + staged.hp = staged.maxHp or staged.stats.hp + local report = require("Legality").mon(S, staged) + if report.errors > 0 then + return Ops.say(S, "Randomization refused: generated values failed checks") + end + replace(mon, staged) + return Ops.mark(S, "Randomized " .. Ops.monName(S, mon) .. " from this game's wild encounters") +end +return A diff --git a/tools/save-editor/NamedChoices.lua b/tools/save-editor/NamedChoices.lua new file mode 100644 index 00000000..21d1e1a9 --- /dev/null +++ b/tools/save-editor/NamedChoices.lua @@ -0,0 +1,79 @@ +local Gen = require("Gen") +local C = {} +function C.locations(S) + local rows = {} + if Gen.ofState(S) == 3 then + local pack = require("src.core.game3.encounters").loadCacheFile("region_map/map_sections.lua") + local sections = pack and pack.sections + local Maps = not sections and require("src.import.gba.map_sections_extract") + for id = 0, 252 do + local info = sections and sections[id] + if not sections then + local ok, result = pcall(Maps.getInfo, id, nil, 0) + if ok and result and result.resolved then + info = result + end + end + if info and info.name and info.name ~= "" then + local name = tostring(info.rawName or info.name):gsub("\n", " ") + if info.id and info.id:find("POKECENTER", 1, true) then + name = name .. " · Pokémon Center" + end + rows[#rows + 1] = { id, name } + end + end + rows[#rows + 1] = { 253, "Special egg" } + rows[#rows + 1] = { 254, "In-game trade" } + rows[#rows + 1] = { 255, "Fateful encounter" } + else + rows = { { 0, "Unknown" } } + for _, rec in pairs(S.data.gen2Landmarks and S.data.gen2Landmarks.landmarks or {}) do + if type(rec) == "table" and rec.index and rec.index > 0 and rec.index < 126 then + rows[#rows + 1] = { rec.index, tostring(rec.name or rec.id):gsub("\n", " ") } + end + end + rows[#rows + 1], rows[#rows + 2] = { 127, "Event" }, { 126, "Gift" } + end + table.sort(rows, function(a, b) + return a[2] < b[2] + end) + return rows +end +function C.property(S, d) + if d.choices then + return d.choices + end + if d.key == "metLocation" or d.key == "caughtLocation" then + if not S._locationChoices then + S._locationChoices = C.locations(S) + end + return S._locationChoices + elseif d.key == "caughtTime" then + return { { 0, "Unknown" }, { 1, "Morning" }, { 2, "Day" }, { 3, "Night" } } + elseif d.key == "pokerus" then + local rows = { { 0, "None" } } + for strain = 1, Gen.ofState(S) == 2 and 8 or 15 do + rows[#rows + 1] = { strain * 16, "Cured · strain " .. strain } + for days = 1, strain % 4 + 1 do + rows[#rows + 1] = { + strain * 16 + days, + "Strain " .. strain .. " · " .. days .. (days == 1 and " day left" or " days left"), + } + end + end + return rows + elseif d.key == "markings" then + local rows, names = {}, { "Circle", "Square", "Triangle", "Heart" } + for mask = 0, 15 do + local shown = {} + for i, n in ipairs(names) do + if math.floor(mask / 2 ^ (i - 1)) % 2 == 1 then + shown[#shown + 1] = n + end + end + rows[#rows + 1] = { mask, #shown == 0 and "None" or table.concat(shown, " + ") } + end + return rows + end +end +return C diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 1bf91c00..431ed78e 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -2253,13 +2253,66 @@ function Ops.cloneMonToBox(S, mon) return Ops.say(S,"All boxes are full") end +function Ops.fixMonErrors(S, mon) + local was = mon and (mon.item or mon.heldItem) + local ok = require("MonActions").fixMon(S, mon) + if ok then + syncPartyMailHeldItem(S, mon, was, mon.item or mon.heldItem) + end + return ok +end +function Ops.fixAllErrors(S) + local before = {} + for i, mon in ipairs(S.save.party or {}) do + before[i] = mon.item or mon.heldItem + end + local ok = require("MonActions").fixAll(S) + if ok then + for i, mon in ipairs(S.save.party or {}) do + syncPartyMailHeldItem(S, mon, before[i], mon.item or mon.heldItem) + end + end + return ok +end +function Ops.randomizeMon(S, mon) + local was = mon and (mon.item or mon.heldItem) + local ok = require("MonActions").randomize(S, mon) + if ok then + syncPartyMailHeldItem(S, mon, was, mon.item or mon.heldItem) + syncPartyMailSpecies(S, mon) + end + return ok +end +function Ops.maxMon(S, mon) + return require("MonActions").maxMon(S, mon) +end +function Ops.maxDvs(S, mon) + for _, k in ipairs({ "attack", "defense", "speed", "special" }) do + MonOps.setDv(S.data, mon, k, 15, Gen.ofState(S)) + end + return Ops.mark(S, "Maxed DVs") +end +function Ops.maxStatExp(S, mon) + for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do + Ops.setStatExp(S, mon, k, 65535) + end + return true +end +function Ops.itemMax(S, id, pc) + if not Ops.itemStacks(S, id) then + return 1 + end + return Gen.ofState(S) == 3 and g3Max(S, id, pc) or Ops.stackMax(S) +end + -- Wrap only mutations. Nested helpers share one snapshot and one undo entry. for name, fn in pairs(Ops) do local mutation=type(fn)=="function" and (name:match("^set") or name:match("^clear") or name:match("^toggle") or name:match("^max") or name:match("^addTo") or name:match("^bag[A-Z]") or name:match("^pc[A-Z]") or name:match("^dex[A-Z]")) local extras={partyAdd=true,partyRemove=true,partyMove=true,boxAdd=true,boxAddSpecies=true, - deposit=true,withdraw=true,release=true,healMon=true,resetMoves=true,cloneMonToBox=true,addMoney=true,addCoins=true} + deposit=true,withdraw=true,release=true,healMon=true,resetMoves=true,cloneMonToBox=true,addMoney=true,addCoins=true, + fixMonErrors=true,fixAllErrors=true,randomizeMon=true} local excluded={pcItems=true,pcOrder=true,pcCanMax=true,pcCanMaxAll=true,bagCanMax=true, bagCanMaxAll=true,dexCounts=true,dexList=true,dexSort=true,clearSelection=true} if (mutation or extras[name]) and not excluded[name] then diff --git a/tools/save-editor/Properties.lua b/tools/save-editor/Properties.lua index 9e247ac8..eacd04a0 100644 --- a/tools/save-editor/Properties.lua +++ b/tools/save-editor/Properties.lua @@ -33,8 +33,8 @@ local G3 = { }, } ), - field("metLevel", "Met level (0 hatched / 1-100 caught)", 0, 100), - field("metLocation", "Met location ID", 0, 255), + field("metLevel", "Found at level", 0, 100), + field("metLocation", "Found at", 0, 255), field( "metGame", "Origin game", @@ -76,8 +76,8 @@ local G3 = { }, } ), - field("pokerus", "Pokerus (packed strain / days)", 0, 255), - field("markings", "Markings (bit mask)", 0, 15), + field("pokerus", "Pokérus", 0, 255), + field("markings", "Markings", 0, 15), field("isEgg", "Egg", nil, nil, { toggle = true, alias = "egg" }), field("modernFatefulEncounter", "Fateful encounter", nil, nil, { toggle = true }), } @@ -118,13 +118,13 @@ function P.identity(S) out[#out + 1] = d end elseif Gen.ofState(S) == 2 then - out[#out + 1] = field("pokerus", "Pokerus (packed strain / days)", 0, 255) + out[#out + 1] = field("pokerus", "Pokérus", 0, 255) out[#out + 1] = field("isEgg", "Egg", nil, nil, { toggle = true, alias = "egg" }) if Gen.hasCaughtData(S.save, S.version) then out[#out + 1] = field("caughtLevel", "Caught level", 0, 63) - out[#out + 1] = field("caughtLocation", "Caught landmark ID", 0, 127) + out[#out + 1] = field("caughtLocation", "Found at", 0, 127) out[#out + 1] = - field("caughtTime", "Caught time (0 unknown / 1 morning / 2 day / 3 night)", 0, 3) + field("caughtTime", "Time found", 0, 3) end end return out diff --git a/tools/save-editor/TouchEditor.lua b/tools/save-editor/TouchEditor.lua new file mode 100644 index 00000000..79a260f5 --- /dev/null +++ b/tools/save-editor/TouchEditor.lua @@ -0,0 +1,430 @@ +local Theme = require("Theme") +local Chrome = require("PickerChrome") +local PAL = Theme.PAL +local E = {} +local function limits(p) + local l = type(p.limits) == "function" and p.limits() or p.limits + return l or { lo = 0, hi = 255 } +end +function E.close(S, Kit) + S.editPopup = nil + Kit.blur() + Kit.mouseClicked, Kit.wheelY, Kit._dragDelta = false, 0, 0 +end +function E.open(S, Kit, p) + Kit.blur() + p.opened, p.scroll, p.query = true, 0, "" + S.editPopup, Kit.blockClicks = p, true +end +function E.help(S, Kit, title, message, x, y) + local tap, size = Kit.tapMin(), 22 * Kit.scale + if Kit.press(x, y, tap, tap) then + E.open(S, Kit, { mode = "help", title = title, help = message }) + end + Theme.col(PAL.cardBorder, 0.8) + love.graphics.circle("line", x + tap / 2, y + tap / 2, size / 2) + Kit.textCenter("small", "?", x, y + (tap - Kit.textHeight("small")) / 2, tap, PAL.caption) +end +function E.issue(Kit, message, x, y, w) + if not message then return 0 end + local size, gap = 14 * Kit.scale, 6 * Kit.scale + Kit.icon("triangle-alert", x, y + 2 * Kit.scale, size, PAL.red) + return math.max(size, Kit.textWrapped("tiny", message, x + size + gap, y, w - size - gap, PAL.red)) + gap +end +function E.action(S, Kit, label, help, fn, x, y, w, kind, issue) + local gap, tap = 8 * Kit.scale, Kit.tapMin() + local opts = { kind = kind, font = "small", invalid = issue ~= nil } + local h = Kit.buttonHeight(label, w - tap - gap, opts) + if Kit.button(x, y, w - tap - gap, h, label, opts) then + fn() + end + E.help(S, Kit, label, help, x + w - tap, y + (h - tap) / 2) + return h + (issue and gap + E.issue(Kit, issue, x, y + h + gap, w) or 0) +end +function E.value(S, Kit, id, title, value, getLimits, x, y, w, apply, issue) + local l = type(getLimits) == "function" and getLimits() or getLimits + if not require("Legality").integer(value, l.lo, l.hi) then + issue = issue or "Saved value must be a whole number from " .. l.lo .. " to " .. l.hi + end + local tap, gap = Kit.tapMin(), 8 * Kit.scale + local opts = { + font = "small", + align = "left", + trailingIcon = "pencil", + face = "invert", + id = "value-" .. id, + invalid = issue ~= nil, + } + local label = title .. ": " .. tostring(value) + local h = Kit.buttonHeight(label, w - tap - gap, opts) + if Kit.button(x, y, w - tap - gap, h, label, opts) then + E.open(S, Kit, { mode = "number", id = id, title = title, value = value, savedValue = value, + issue = issue, limits = getLimits, apply = apply }) + end + E.help(S, Kit, title, l.help, x + w - tap, y + (h - tap) / 2) + local n = tonumber(value) + local ratio = n and n == n and Theme.clamp((n - l.lo) / math.max(1, l.hi - l.lo), 0, 1) or 0 + local color = issue and PAL.red or PAL.blue + local trackY, trackH = y + h + 6 * Kit.scale, 5 * Kit.scale + Theme.fillRounded(x, trackY, w, trackH, PAL.cardBorder, 0.3, trackH / 2) + if ratio > 0 then + Theme.fillRounded(x, trackY, w * ratio, trackH, color, 0.9, trackH / 2) + end + local caption = l.lo .. " to " .. l.hi + if l.remaining ~= nil then + caption = caption .. " · " .. l.remaining .. " points free" + end + local ch = Kit.textWrapped("tiny", caption, x, trackY + trackH + 4 * Kit.scale, w, issue and PAL.red or PAL.caption) + local height = h + 15 * Kit.scale + ch + return height + (issue and gap + E.issue(Kit, issue, x, y + height + gap, w) or 0) +end +function E.choice(S, Kit, id, title, value, options, x, y, w, apply, help, issue) + local shown = (issue and "Invalid saved value " or "Unknown saved value ") .. tostring(value) + for _, o in ipairs(options) do + if o[1] == value then + shown = o[2] + end + end + local opts = { + face = "invert", + font = "small", + align = "left", + trailingIcon = "chevron-down", + id = "choice-" .. id, + invalid = issue ~= nil, + } + local label = title .. ": " .. shown + local h = Kit.buttonHeight(label, w, opts) + if Kit.button(x, y, w, h, label, opts) then + E.open(S, Kit, { + mode = "choice", + id = id, + title = title, + value = value, + options = options, + apply = apply, + help = help, + issue = issue, + }) + end + return h + (issue and 8 * Kit.scale + E.issue(Kit, issue, x, y + h + 8 * Kit.scale, w) or 0) +end +function E.results(p) + local rows, query = {}, (p.query or ""):lower() + for _, o in ipairs(p.options or {}) do + if (o[2] .. " " .. tostring(o[1])):lower():find(query, 1, true) then + rows[#rows + 1] = o + end + end + return rows +end +function E.commit(S, Kit) + local p = S.editPopup + if not p then + return false + end + local value = p.value + if p.mode == "number" then + local l = limits(p) + if p.typing then + value = tonumber(Kit.flushText("touch-exact", p.draft)) + end + if not value or value ~= math.floor(value) or value < l.lo or value > l.hi then + p.error = "Choose a whole number from " .. l.lo .. " to " .. l.hi + return false + end + elseif p.mode == "choice" then + local rows = E.results(p) + local first = rows[p.index or 1] or rows[1] + if not first then + return false + end + value = first[1] + end + if p.apply then + p.apply(value) + end + E.close(S, Kit) + return true +end +function E.keypressed(S, Kit, key) + local p = S.editPopup + if not p then + return false + end + if key == "escape" then + E.close(S, Kit) + elseif key == "return" or key == "kpenter" then + if p.mode == "choice" then + p.query = Kit.flushText("touch-search", p.query) + end + E.commit(S, Kit) + elseif p.mode == "number" and not p.typing then + local l = limits(p) + local delta = ({ left = -1, right = 1, down = -5, up = 5 })[key] + if delta then + p.value = Theme.clamp(p.value + delta, l.lo, l.hi) + end + if key == "home" then + p.value = l.lo + elseif key == "end" then + p.value = l.hi + end + elseif p.mode == "choice" and (key == "up" or key == "down" or key == "home" or key == "end") then + local n = #E.results(p) + if n > 0 then + if key == "home" then + p.index = 1 + elseif key == "end" then + p.index = n + else + p.index = Theme.clamp((p.index or 1) + (key == "up" and -1 or 1), 1, n) + end + p.reveal, p.keyboard = true, true + end + else + Kit.keypressed(key) + end + return true +end +function E.draw(S, Kit, width, height) + local p = S.editPopup + if not p then + return + end + Kit.resetClip() + Kit.blockClicks, p.opened = p.opened == true, nil + local x, y, w, h, pad = Chrome.card(Kit, width, height) + local row, gap, s = Kit.controlH(), 8 * Kit.scale, Kit.scale + if p.mode == "help" then + local oldH = h + local _, lines = Kit.fonts.small:getWrap(p.help or "", w - 2 * pad) + h = math.min(h, row + 2 * pad + gap + #lines * Kit.textHeight("small")) + y = y + (oldH - h) / 2 + end + p.rect = { x = x, y = y, w = w, h = h } + Theme.col(PAL.bgBot, 0.78) + love.graphics.rectangle("fill", 0, 0, width, height) + if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then + E.close(S, Kit) + return + end + Kit.card(x, y, w, h) + local cx, inner, cy = x + pad, w - 2 * pad, y + pad + Kit.textWrapped("small", p.title, cx, cy + 6 * s, inner - row - gap, PAL.heading) + if Kit.iconButton(cx + inner - row, cy, row, row, "x", "Close") then + E.close(S, Kit) + return + end + cy = cy + row + gap + if p.issue then + local message = p.savedValue ~= nil and ("Saved: " .. tostring(p.savedValue) .. ". " .. p.issue) or p.issue + cy = cy + E.issue(Kit, message, cx, cy, inner) + gap + end + if p.mode == "help" then + Kit.textWrapped("small", p.help or "", cx, cy, inner, PAL.text) + return + end + if p.mode == "choice" then + p.query = Kit.textfield("touch-search", cx, cy, inner, row, p.query, "Search by name") + cy = cy + row + gap + if p.lastQuery ~= p.query then + p.index, p.lastQuery, p.scroll = 1, p.query, 0 + end + local bodyH, rows = y + h - pad - cy, E.results(p) + local heights, total = {}, 0 + for i, o in ipairs(rows) do + heights[i] = { + top = total, + h = Kit.buttonHeight(o[2], inner, { font = "small", align = "left", trailingIcon = "check" }), + } + total = total + heights[i].h + gap + end + if p.reveal and heights[p.index] then + local selected = heights[p.index] + p.scroll = Theme.clamp( + math.max(selected.top + selected.h - bodyH, math.min(p.scroll, selected.top)), + 0, + math.max(0, total - bodyH) + ) + p.reveal = nil + end + p.scroll = Kit.scrollPixels(cx, cy, inner, bodyH, p.scroll, total) + Kit.pushClip(cx, cy, inner, bodyH) + for i, o in ipairs(rows) do + local r = heights[i] + if r.top + r.h >= p.scroll and r.top <= p.scroll + bodyH then + if + Kit.button(cx, cy + r.top - p.scroll, inner, r.h, o[2], { + font = "small", + align = "left", + face = "selection", + active = o[1] == p.value, + trailingIcon = o[1] == p.value and "check" or nil, + ring = p.keyboard and p.index == i or nil, + }) + then + p.apply(o[1]) + Kit.popClip() + E.close(S, Kit) + return + end + end + end + if #rows == 0 then + Kit.textWrapped("small", "Nothing matches that.", cx, cy + gap, inner, PAL.caption) + end + Kit.popClip() + Kit.scrollbar(cx, cy, inner, bodyH, p.scroll, total, bodyH) + return + end + local l = limits(p) + p.value = Theme.clamp(tonumber(p.value) or l.lo, l.lo, l.hi) + local function setValue(value) + p.value = Theme.clamp(value, l.lo, l.hi) + p.typing, p.draft, p.error = nil, nil, nil + Kit.blur() + end + local function spinWheel() + p.wheelDelta = (p.wheelDelta or 0) + Kit.wheelY + local delta = p.wheelDelta >= 0 and math.floor(p.wheelDelta) or math.ceil(p.wheelDelta) + if delta ~= 0 then + setValue(p.value + delta) + p.wheelDelta = p.wheelDelta - delta + end + Kit.wheelY = 0 + end + local helpH = Kit.textWrapped("tiny", l.help or "", cx, cy, inner, PAL.caption) + cy = cy + helpH + gap + local bodyY, bodyH = cy, math.max(0, y + h - pad - row - gap - cy) + if p.roll and math.abs(Kit.mouseY - p.roll.y) > math.abs(Kit.mouseX - p.roll.x) + 8 * s then + p.roll = nil + p.verticalRoll = true + end + if not Kit.mouseDown then + p.verticalRoll = nil + end + if p.slider or p.roll then + Kit._dragDelta = 0 + end + if + p.wheelRect + and not Kit.blockClicks + and Kit.hit(p.wheelRect.x, p.wheelRect.y, p.wheelRect.w, p.wheelRect.h) + and Kit.wheelY ~= 0 + then + spinWheel() + end + p.scroll = Kit.scrollPixels( + cx, + bodyY, + inner, + bodyH, + p.scroll, + p.contentH or (6 * row + 7 * gap + 2 * Kit.textHeight("tiny")) + ) + Kit.pushClip(cx, bodyY, inner, bodyH) + cy = cy - p.scroll + local contentStart = cy + Kit.textCenter("title", tostring(p.value), cx, cy + gap, inner, PAL.heading) + cy = cy + row + gap + local ratio = (p.value - l.lo) / math.max(1, l.hi - l.lo) + local trackX, trackW = cx + 12 * s, inner - 24 * s + p.sliderRect = { x = cx, y = cy, w = inner, h = row } + local trackY = cy + row / 2 + Theme.fillRounded(trackX, trackY - 4 * s, trackW, 8 * s, PAL.cardBorder, 0.4, 4 * s) + if ratio > 0 then + Theme.fillRounded(trackX, trackY - 4 * s, trackW * ratio, 8 * s, PAL.blue, 1, 4 * s) + end + Theme.col(PAL.blue) + love.graphics.circle("fill", trackX + trackW * ratio, trackY, 11 * s) + if Kit.press(cx, cy, inner, row) then + setValue(math.floor(l.lo + Theme.clamp((Kit.mouseX - trackX) / trackW, 0, 1) * (l.hi - l.lo) + 0.5)) + end + if not Kit.blockClicks and Kit.mouseDown and Kit.hit(cx, cy, inner, row) then + p.slider = true + end + if p.slider and Kit.mouseDown then + setValue(math.floor(l.lo + Theme.clamp((Kit.mouseX - trackX) / trackW, 0, 1) * (l.hi - l.lo) + 0.5)) + else + p.slider = nil + end + cy = cy + row + Kit.text("tiny", tostring(l.lo), cx, cy, PAL.caption) + Kit.textRight("tiny", "Max " .. l.hi, cx + inner, cy, PAL.caption) + cy = cy + Kit.textHeight("tiny") + gap + -- A horizontal number wheel: one tick per value, with a stable centre. + local tick = math.max(30 * s, Kit.textWidth("small", tostring(l.hi)) + 16 * s) + p.wheelRect = { x = cx, y = cy, w = inner, h = row } + Theme.fillRounded(cx, cy, inner, row, PAL.rowBg, 0.6, 12 * s) + Theme.fillRounded(cx + (inner - tick) / 2, cy + 4 * s, tick, row - 8 * s, PAL.blue, 0.12, 8 * s) + Kit.pushClip(cx, cy, inner, row) + for i = -5, 5 do + local n = p.value + i + if n >= l.lo and n <= l.hi then + Kit.textCenter( + "small", + tostring(n), + cx + inner / 2 + i * tick - tick / 2, + cy + (row - Kit.textHeight("small")) / 2, + tick, + i == 0 and PAL.blueInk or PAL.faint + ) + end + end + Kit.popClip() + if not Kit.blockClicks and Kit.mouseDown and Kit.hit(cx, cy, inner, row) and not p.roll and not p.verticalRoll then + p.roll = { x = Kit.mouseX, y = Kit.mouseY, value = p.value } + end + if p.roll and Kit.mouseDown then + setValue(p.roll.value + math.floor((p.roll.x - Kit.mouseX) / (tick / 2) + 0.5)) + else + p.roll = nil + end + if Kit.hit(cx, cy, inner, row) and not Kit.blockClicks and Kit.wheelY ~= 0 then + spinWheel() + end + cy = cy + row + gap + Kit.textCenter("tiny", "Roll the numbers to fine-tune", cx, cy, inner, PAL.caption) + cy = cy + Kit.textHeight("tiny") + gap + local bw = (inner - 3 * gap) / 4 + for i, d in ipairs({ -5, -1, 1, 5 }) do + local label = (d > 0 and "+" or "") .. d + if + Kit.button( + cx + (i - 1) * (bw + gap), + cy, + bw, + row, + label, + { font = "small", enabled = p.value + d >= l.lo and p.value + d <= l.hi } + ) + then + setValue(p.value + d) + end + end + cy = cy + row + gap + local half = (inner - gap) / 2 + if Kit.button(cx, cy, half, row, "Min", { font = "small" }) then + setValue(l.lo) + end + if Kit.button(cx + half + gap, cy, half, row, "Max", { font = "small", kind = "good" }) then + setValue(l.hi) + end + cy = cy + row + gap + if p.typing then + p.draft = Kit.textfield("touch-exact", cx, cy, inner, row, p.draft, "Exact value") + elseif Kit.button(cx, cy, inner, row, "Type a value", { font = "small", icon = "pencil" }) then + p.typing, p.draft = true, tostring(p.value) + end + cy = cy + row + gap + if p.error then + cy = cy + Kit.textWrapped("tiny", p.error, cx, cy, inner, PAL.red) + gap + end + p.contentH = cy - contentStart + Kit.popClip() + if Kit.button(cx, y + h - pad - row, inner, row, "Apply", { kind = "good", font = "small", icon = "check" }) then + E.commit(S, Kit) + end +end +return E diff --git a/tools/save-editor/ValueLimits.lua b/tools/save-editor/ValueLimits.lua new file mode 100644 index 00000000..9a624a9c --- /dev/null +++ b/tools/save-editor/ValueLimits.lua @@ -0,0 +1,88 @@ +local Gen = require("Gen") +local MonOps = require("MonOps") +local L = {} +L.EV_KEYS = { "hp", "atk", "def", "spa", "spd", "spe" } +function L.evTotal(mon) + local total = 0 + for _, k in ipairs(L.EV_KEYS) do + total = total + (tonumber(mon.evs and mon.evs[k]) or 0) + end + return total +end +function L.expAt(S, mon, level) + local def = S.data and S.data.pokemon and S.data.pokemon[mon.species or mon.speciesId] + local g = Gen.ofState(S) + if g == 3 then + return require("src.core.game3.summary_data").expForLevel( + require("src.core.game3.pokemon").growthRate(mon.species or mon.speciesId), + level + ) + elseif g == 2 then + local Mon = require("src.battle.gen2.Mon") + return Mon.experienceForLevel(Mon.growthFor(S.data, def and def.growthRate), level) + end + return require("src.pokemon.Growth").expForLevel( + def and def.growthRate or 0, + level, + S.data.growth_rates + ) +end +function L.mon(S, mon, id) + local egg = mon.egg or mon.isEgg + local lo, hi, help = 0, 255, "Slide for big changes. Roll the numbers for small ones." + local key = id:match("^ev%-(.+)") + if key then + hi = + math.max(0, math.min(255, 510 - L.evTotal(mon) + (tonumber(mon.evs and mon.evs[key]) or 0))) + help = "Training points. All six stats share 510. Lower one to free up another." + elseif id:match("^iv%-") then + hi, help = 31, "Natural stat strength. Higher is stronger." + elseif id:match("^dv%-") then + hi, help = 15, "Natural stat strength. These also set HP, and in Gen 2, gender and shininess." + elseif id:match("^se%-") then + hi, help = 65535, "Stat training. Each stat has its own limit." + elseif id == "level" then + lo, hi, help = + egg and 5 or math.min(100, math.max(1, tonumber(mon.metLevel) or 1)), + egg and 5 or 100, + "Changes level and recalculates stats. Moves stay the same." + elseif id == "experience" then + lo, hi, help = + L.expAt(S, mon, egg and 5 or math.min(100, math.max(1, tonumber(mon.metLevel) or 1))), + L.expAt(S, mon, egg and 5 or 100), + "Total experience. Level updates to match." + elseif id == "hp" or id == "current-hp" then + hi, help = + mon.maxHp or (mon.stats and mon.stats.hp) or 0, + "Current health. The limit follows level and stats. Max fills it." + elseif id == "happiness" or id == "friendship" then + help = egg and "Egg hatch countdown. Lower means closer to hatching." + or "Friendship. Max makes it as friendly as it can be." + elseif id:match("^ppup%-%d") or id:match("^pp%-%d") then + local slot = tonumber(id:match("(%d+)$")) + local base = MonOps.getBasePp(S.data, mon, slot) + if id:match("^ppup%-") then + hi, help = + (egg or base == 1) and 0 or 3, "Raises this move's PP limit. Then use Max on PP to fill it." + else + hi = MonOps.calcMaxPp(base, MonOps.getPpUps(mon, slot), Gen.ofState(S)) + help = "Uses left for this move. PP Ups raise the limit." + end + else + local d = require("Properties").find(S, id) + if d then + lo, hi, help = d.lo or 0, d.hi or 255, d.help or help + end + if id == "metLevel" then + hi = math.min(hi, tonumber(mon.level) or hi) + end + end + return { + lo = lo, + hi = hi, + help = help, + step = id == "level" and 5 or 1, + remaining = key and math.max(0, 510 - L.evTotal(mon)) or nil, + } +end +return L diff --git a/tools/save-editor/panels/Boxes.lua b/tools/save-editor/panels/Boxes.lua index a7f42a17..4041989c 100644 --- a/tools/save-editor/panels/Boxes.lua +++ b/tools/save-editor/panels/Boxes.lua @@ -143,10 +143,9 @@ local function drawView(S, Kit, x, y, w, h) end local bodyH = math.max(0, footer - gap - cy) if S.boxView == "party" then - local visible = math.max(1, math.floor(bodyH / (row + gap))) - S.dockOffset = Kit.scroll(cx, cy, inner, bodyH, S.dockOffset or 0, #S.save.party, visible) + local drawn, shift = Kit.list(S, "dockOffset", cx, cy, inner, bodyH, #S.save.party, row + gap) Kit.pushClip(cx, cy, inner, bodyH) - for i = 1, visible do + for i = 1, drawn do local slot = S.dockOffset + i local mon = S.save.party[slot] if not mon then @@ -155,7 +154,7 @@ local function drawView(S, Kit, x, y, w, h) if Kit.button( cx, - cy + (i - 1) * (row + gap), + cy + (i - 1) * (row + gap) - shift, inner, row, tostring(slot) diff --git a/tools/save-editor/panels/Checks.lua b/tools/save-editor/panels/Checks.lua index 1d0e36c7..06984fc4 100644 --- a/tools/save-editor/panels/Checks.lua +++ b/tools/save-editor/panels/Checks.lua @@ -1,6 +1,7 @@ local L = require("Legality") local PAL = require("Theme").PAL local M = {} +local Touch = require("TouchEditor") local Motion = require("Motion") function M.draw(S, Kit, x, y, w, h) Kit.card(x, y, w, h) @@ -11,7 +12,7 @@ function M.draw(S, Kit, x, y, w, h) S._checksRevision, S._checksSave = S.revision, S.save end local r = S.checkReport - local message = ("%d Pokemon / %d property errors. Encounter legality remains unchecked."):format( + local message = ("%d Pokémon · %d errors. Origin checks still need review."):format( #r.entries, r.errors ) @@ -23,6 +24,20 @@ function M.draw(S, Kit, x, y, w, h) inner, r.errors > 0 and PAL.red or PAL.yellow ) + gap + local actionH = Touch.action( + S, + Kit, + "Fix all errors", + "Fixes invalid values, stats and PP across your party and boxes. Anything needing a manual choice stays for review.", + function() + require("Ops").fixAllErrors(S) + end, + cx, + y + pad + head, + inner, + "good" + ) + head = head + actionH + gap local top = y + pad + head local bodyH = math.max(0, h - 2 * pad - head) S.checkScroll = diff --git a/tools/save-editor/panels/Dex.lua b/tools/save-editor/panels/Dex.lua index 8dab184a..3e834eb1 100644 --- a/tools/save-editor/panels/Dex.lua +++ b/tools/save-editor/panels/Dex.lua @@ -99,15 +99,14 @@ function M.draw(S, Kit, x, y, w, h) local gridH = math.max(0, pagerY - 12 * s - gridTop) local perCol = math.max(1, math.floor((gridH + gap) / (cardH + gap))) local perPage = perCol * cols - S.dexOffset = Ops.clamp(S.dexOffset or 0, 0, math.max(0, #species - perPage)) - S.dexOffset = Kit.scroll(cx, gridTop, inner, gridH, S.dexOffset, #species, perPage, cols) + local drawn, shift = Kit.list(S, "dexOffset", cx, gridTop, inner, gridH, #species, cardH + gap, cols) local chipW = (colW - 2 * cardPad - gap) / 2 local ownedKey = Gen.dexOwnedKey(S.save) Kit.pushClip(cx, gridTop, inner, gridH) - for i = 1, math.min(perPage, #species - S.dexOffset) do + for i = 1, drawn do local id = species[S.dexOffset + i] local ci, ri = (i - 1) % cols, math.floor((i - 1) / cols) - local rx, ry = cx + ci * (colW + gap), gridTop + ri * (cardH + gap) + local rx, ry = cx + ci * (colW + gap), gridTop + ri * (cardH + gap) - shift local def = S.data.pokemon[id] local spId = def and (def.speciesId or def.dex) local isSeen = dex.seen[id] == true or (spId and dex.seen[spId] == true) @@ -145,7 +144,7 @@ function M.draw(S, Kit, x, y, w, h) end end Kit.popClip() - Kit.scrollbar(cx, gridTop, inner, gridH, S.dexOffset, #species, perPage) + Kit.listScrollbar(S, "dexOffset", cx, gridTop, inner, gridH) S.dexOffset = Kit.pager(cx, pagerY, inner, S.dexOffset, #species, perPage) end return M diff --git a/tools/save-editor/panels/Events.lua b/tools/save-editor/panels/Events.lua index 4d82944a..dee9760f 100644 --- a/tools/save-editor/panels/Events.lua +++ b/tools/save-editor/panels/Events.lua @@ -354,8 +354,7 @@ local function drawSection(S, Kit, x, y, w, h) local perCol = math.max(1, math.floor(gridH / (rowH + rowGap))) local perPage = perCol * cols - S.eventsOffset = Ops.clamp(S.eventsOffset or 0, 0, math.max(0, #rows - perPage)) - S.eventsOffset = Kit.scroll(cx, gridTop, inner, gridH, S.eventsOffset, #rows, perPage, cols) + local drawn, shift = Kit.list(S, "eventsOffset", cx, gridTop, inner, gridH, #rows, rowH + rowGap, cols) if #rows == 0 then Kit.emptyBox( @@ -368,12 +367,12 @@ local function drawSection(S, Kit, x, y, w, h) end Kit.pushClip(cx, gridTop, inner, gridH) - for i = 1, math.min(perPage, #rows - S.eventsOffset) do + for i = 1, drawn do local row = rows[S.eventsOffset + i] local ci = (i - 1) % cols local ri = math.floor((i - 1) / cols) local rx = cx + ci * (colW + colGap) - local ry = gridTop + ri * (rowH + rowGap) + local ry = gridTop + ri * (rowH + rowGap) - shift if row.header then Kit.text( "mono", @@ -438,7 +437,7 @@ local function drawSection(S, Kit, x, y, w, h) end Kit.popClip() - Kit.scrollbar(cx, gridTop, inner, gridH, S.eventsOffset, #rows, perPage) + Kit.listScrollbar(S, "eventsOffset", cx, gridTop, inner, gridH) -- "Clear all" button if clearKey then diff --git a/tools/save-editor/panels/ItemPicker.lua b/tools/save-editor/panels/ItemPicker.lua index 33c1b6c7..f7523ead 100644 --- a/tools/save-editor/panels/ItemPicker.lua +++ b/tools/save-editor/panels/ItemPicker.lua @@ -141,21 +141,20 @@ function Picker.draw(S, Kit, width, height) local hits = Picker.results(S) local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) - p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) -- wheel / touch drag scroll the modal list too; the shield is already -- lowered for this layer, so Kit.scroll works here and only here - p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage) + local drawn, shift = Kit.list(p, "offset", cx, cy, inner, listH, #hits, rowH + rowGap) if #hits == 0 then Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") else Kit.pushClip(cx, cy, inner, listH) - for i = 1, perPage do + for i = 1, drawn do local id = hits[p.offset + i] if not id then break end - local ry = cy + (i - 1) * (rowH + rowGap) + local ry = cy + (i - 1) * (rowH + rowGap) - shift if Kit.row(cx, ry, inner, rowH, false, PAL.green, 9 * s) then Picker.commit(S, Kit, id) end @@ -185,7 +184,7 @@ function Picker.draw(S, Kit, width, height) end end Kit.popClip() - Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage) + Kit.listScrollbar(p, "offset", cx, cy, inner, listH) end p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage) diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index 60744ba8..93dd7dbf 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -1,8 +1,8 @@ local Ops = require("Ops") local Gen = require("Gen") local Bag = require("src.inventory.Bag") -local PAL = require("Theme").PAL local M = {} +local Touch = require("TouchEditor") local Motion = require("Motion") local Chooser = require("Chooser") local function drawView(S, Kit, x, y, w, h) @@ -62,44 +62,34 @@ local function drawView(S, Kit, x, y, w, h) cy = cy + row + gap end if S.itemView == "wallet" then - local ch = 2 * (row + gap + Kit.textHeight("small") + gap) + (row + gap) * 2 + local bodyH = math.max(0, y + h - pad - cy) S.walletScroll = - Kit.scrollPixels(cx, cy, inner, math.max(0, y + h - pad - cy), S.walletScroll or 0, ch) - Kit.pushClip(cx, cy, inner, math.max(0, y + h - pad - cy)) - cy = cy - S.walletScroll + Kit.scrollPixels(cx, cy, inner, bodyH, S.walletScroll or 0, S._walletHeight or 0) + Kit.pushClip(cx, cy, inner, bodyH) + local start = cy - S.walletScroll + cy = start for _, f in ipairs({ - { "money", "Money", Gen.money(S.save), Ops.maxMoney }, - { "coins", "Coins", Gen.coins(S.save), Ops.maxCoins }, + { "money", "Money", Gen.money(S.save), 999999 }, + { "coins", "Coins", Gen.coins(S.save), 9999 }, }) do - Kit.text("small", f[2], cx, cy, PAL.text) - cy = cy + Kit.textHeight("small") + gap - S.walletDrafts = S.walletDrafts or {} - local function apply(v) - if Ops.setTrainerProperty(S, f[1], v) then - S.walletDrafts[f[1]] = nil - end - end - local bw = math.max(row, 64 * s) - S.walletDrafts[f[1]] = Kit.textfield( - "wallet-" .. f[1], - cx, - cy, - inner - bw - gap, - row, - S.walletDrafts[f[1]] or tostring(f[3]), - "value", - { onSubmit = apply } - ) - if Kit.button(cx + inner - bw, cy, bw, row, "Set", { kind = "accent", font = "small" }) then - apply(S.walletDrafts[f[1]]) - Kit.blur() - end - cy = cy + row + gap - if Kit.button(cx, cy, inner, row, "Max " .. f[2], { kind = "good", font = "small" }) then - f[4](S) - end - cy = cy + row + gap + cy = cy + + Touch.value( + S, + Kit, + "wallet-" .. f[1], + f[2], + f[3], + { lo = 0, hi = f[4], help = "Max fills it. Type a value for an exact amount." }, + cx, + cy, + inner, + function(v) + return Ops.setTrainerProperty(S, f[1], v) + end + ) + + gap end + S._walletHeight = cy - start Kit.popClip() return elseif S.itemView == "badges" then @@ -189,30 +179,53 @@ local function drawView(S, Kit, x, y, w, h) local actionMin = Kit.buttonWidth("Confirm?", { font = "small", iconStack = true }, row) local actionCols = inner >= 5 * actionMin + 4 * gap and 5 or 3 local actionRows = math.ceil(5 / actionCols) - local itemH = actionRows * row + (actionRows - 1) * gap + Kit.textHeight("small") + 3 * gap + local itemH = actionRows * row + (actionRows - 1) * gap + row + 3 * gap local visible = math.max(1, math.floor(bodyH / itemH)) local offsetKey = prefix .. "Offset" - S[offsetKey] = Kit.scroll(cx, cy, inner, bodyH, S[offsetKey] or 0, #order, visible) + local drawn, shift = Kit.list(S, offsetKey, cx, cy, inner, bodyH, #order, itemH) Kit.pushClip(cx, cy, inner, bodyH) - for i = 1, visible do + for i = 1, drawn do local id = order[S[offsetKey] + i] if id == nil then break end - local by = cy + (i - 1) * itemH + local by = cy + (i - 1) * itemH - shift local def = S.data.items[id] - Kit.text( - "small", - Kit.ellipsize( - "small", - tostring(def and def.name or id) .. " x" .. tostring(quantities[id] or 0), - inner - ), - cx, - by, - PAL.text - ) - by = by + Kit.textHeight("small") + gap + local count = quantities[id] or 0 + local max = Ops.itemMax(S, id, pc) + local issue = not require("Legality").integer(count, 1, max) + and ("Saved stack must be a whole number from 1 to " .. max) or nil + local opts = { font = "small", align = "left", trailingIcon = "pencil", face = "invert", invalid = issue ~= nil } + if + Kit.button( + cx, + by, + inner, + row, + tostring(def and def.name or id):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + .. " ×" + .. count, + opts + ) + then + Touch.open(S, Kit, { + mode = "number", + id = "item-" .. tostring(id), + title = def and def.name or tostring(id), + value = count, + savedValue = count, + issue = issue, + limits = { + lo = 1, + hi = max, + help = "Set the stack size. Max fills it; Drop removes it.", + }, + apply = function(v) + return call("Adjust", id, v - count) + end, + }) + end + by = by + row + gap local labels = { "Decrease", "Increase", diff --git a/tools/save-editor/panels/MapBrowser.lua b/tools/save-editor/panels/MapBrowser.lua index 82c561fb..5e71e2fd 100644 --- a/tools/save-editor/panels/MapBrowser.lua +++ b/tools/save-editor/panels/MapBrowser.lua @@ -310,18 +310,17 @@ local function drawSection(S, Kit, x, y, w, h) local mGap = 4 * s local listBodyH = (compactList and lr.y + lr.h - pad or pagerY - 10 * s) - listTop local perPage = math.max(1, math.floor(listBodyH / (mRowH + mGap))) - S.mapListOffset = Ops.clamp(S.mapListOffset or 0, 0, math.max(0, #ids - perPage)) -- wheel and touch drag reach the list too (#715): App routes the wheel to -- zoom on this tab, so the list rides Kit's drag path and the pager alone -- on desktop -- on a phone the drag is the difference between "stuck" and -- scrollable. - S.mapListOffset = - Kit.scroll(lr.x + pad, listTop, listInner, listBodyH, S.mapListOffset, #ids, perPage) + local drawn, shift = + Kit.list(S, "mapListOffset", lr.x + pad, listTop, listInner, listBodyH, #ids, mRowH + mGap) Kit.pushClip(lr.x + pad, listTop, listInner, math.max(0, listBodyH)) - for i = 1, math.min(perPage, #ids - S.mapListOffset) do + for i = 1, drawn do local id = ids[S.mapListOffset + i] - local ry = listTop + (i - 1) * (mRowH + mGap) + local ry = listTop + (i - 1) * (mRowH + mGap) - shift if Kit.row(lr.x + pad, ry, listInner, mRowH, id == S.mapId, PAL.blue, 7 * s) then MapBrowser.select(S, id) if stacked then @@ -340,7 +339,7 @@ local function drawSection(S, Kit, x, y, w, h) Kit.text("mono", "no map matches", lr.x + pad + 9 * s, listTop + 8 * s, PAL.faint) end Kit.popClip() - Kit.scrollbar(lr.x + pad, listTop, listInner, listBodyH, S.mapListOffset, #ids, perPage) + Kit.listScrollbar(S, "mapListOffset", lr.x + pad, listTop, listInner, listBodyH) if not compactList then S.mapListOffset = Kit.pager(lr.x + pad, pagerY, listInner, S.mapListOffset, #ids, perPage) end diff --git a/tools/save-editor/panels/MovePicker.lua b/tools/save-editor/panels/MovePicker.lua index 166640ef..3723fb30 100644 --- a/tools/save-editor/panels/MovePicker.lua +++ b/tools/save-editor/panels/MovePicker.lua @@ -103,10 +103,9 @@ function Picker.draw(S, Kit, width, height) local hits = Picker.results(S) local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) - p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) -- wheel / touch drag scroll the modal list too; the shield is already -- lowered for this layer, so Kit.scroll works here and only here (#715) - p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage) + local drawn, shift = Kit.list(p, "offset", cx, cy, inner, listH, #hits, rowH + rowGap) local mon = S.editingMon local mv = mon and mon.moves and mon.moves[p.slot] @@ -116,12 +115,12 @@ function Picker.draw(S, Kit, width, height) Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") else Kit.pushClip(cx, cy, inner, listH) - for i = 1, perPage do + for i = 1, drawn do local id = hits[p.offset + i] if not id then break end - local ry = cy + (i - 1) * (rowH + rowGap) + local ry = cy + (i - 1) * (rowH + rowGap) - shift local def = S.data.moves[id] local usable = Ops.moveUsable(S, id) local current = (currentId ~= nil) @@ -162,7 +161,7 @@ function Picker.draw(S, Kit, width, height) ) end Kit.popClip() - Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage) + Kit.listScrollbar(p, "offset", cx, cy, inner, listH) end p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage) diff --git a/tools/save-editor/panels/Party.lua b/tools/save-editor/panels/Party.lua index 4d899410..7be6e4dd 100644 --- a/tools/save-editor/panels/Party.lua +++ b/tools/save-editor/panels/Party.lua @@ -67,17 +67,15 @@ local function drawRoster(S, Kit, x, y, listW, h) -- The list used to `break` past the fold, silently hiding party slots on -- a short window; it scrolls instead now (#715), same offset contract as -- every other list in the editor. - local visible = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) - S.partyOffset = - Kit.scroll(cx, listTop, innerW, listH, S.partyOffset or 0, #S.save.party, visible) + local drawn, shift = Kit.list(S, "partyOffset", cx, listTop, innerW, listH, #S.save.party, rowH + rowGap) Kit.pushClip(cx, listTop, innerW, listH) - for i = 1, visible do + for i = 1, drawn do local slot = S.partyOffset + i local mon = S.save.party[slot] if not mon then break end - local ry = listTop + (i - 1) * (rowH + rowGap) + local ry = listTop + (i - 1) * (rowH + rowGap) - shift local selected = (S.editingMon == mon) if Kit.row(cx, ry, innerW, rowH, selected, PAL.green) then Ops.selectParty(S, slot) @@ -146,7 +144,7 @@ local function drawRoster(S, Kit, x, y, listW, h) ) end Kit.popClip() - Kit.scrollbar(cx, listTop, innerW, listH, S.partyOffset, #S.save.party, visible) + Kit.listScrollbar(S, "partyOffset", cx, listTop, innerW, listH) end local halfW = (innerW - 10 * s) / 2 diff --git a/tools/save-editor/panels/SpeciesPicker.lua b/tools/save-editor/panels/SpeciesPicker.lua index b44d0158..6ca02488 100644 --- a/tools/save-editor/panels/SpeciesPicker.lua +++ b/tools/save-editor/panels/SpeciesPicker.lua @@ -114,21 +114,20 @@ function Picker.draw(S, Kit, width, height) local hits = Picker.results(S) local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy) local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap))) - p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage)) -- wheel / touch drag scroll the modal list too; the shield is already -- lowered for this layer, so Kit.scroll works here and only here (#715) - p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage) + local drawn, shift = Kit.list(p, "offset", cx, cy, inner, listH, #hits, rowH + rowGap) if #hits == 0 then Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.") else Kit.pushClip(cx, cy, inner, listH) - for i = 1, perPage do + for i = 1, drawn do local id = hits[p.offset + i] if not id then break end - local ry = cy + (i - 1) * (rowH + rowGap) + local ry = cy + (i - 1) * (rowH + rowGap) - shift local def = S.data.pokemon[id] -- A record the formulas cannot use still lists, greyed: hiding it would -- make a modded species look like it never registered (#541). @@ -165,7 +164,7 @@ function Picker.draw(S, Kit, width, height) ) end Kit.popClip() - Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage) + Kit.listScrollbar(p, "offset", cx, cy, inner, listH) end p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage) diff --git a/tools/save-editor/panels/Trainer.lua b/tools/save-editor/panels/Trainer.lua index e6511cfa..a565c07d 100644 --- a/tools/save-editor/panels/Trainer.lua +++ b/tools/save-editor/panels/Trainer.lua @@ -2,6 +2,7 @@ local Ops = require("Ops") local Gen = require("Gen") local PAL = require("Theme").PAL local M = {} +local Touch = require("TouchEditor") function M.draw(S, Kit, x, y, w, h) Kit.card(x, y, w, h) local pad, gap, row = 14 * Kit.scale, 10 * Kit.scale, Kit.controlH() @@ -13,45 +14,71 @@ function M.draw(S, Kit, x, y, w, h) "Trainer name", (S.save.player and S.save.player.name) or S.save.name or S.save.playerName or "", }, - { "id", "Trainer ID (0-65535)", (S.save.player and S.save.player.id) or S.save.trainerId or 0 }, - { "money", "Money (0-999999)", Gen.money(S.save) }, - { "coins", "Coins (0-9999)", Gen.coins(S.save) }, + { "id", "Trainer ID", (S.save.player and S.save.player.id) or S.save.trainerId or 0 }, + { "money", "Money", Gen.money(S.save) }, + { "coins", "Coins", Gen.coins(S.save) }, } if g == 3 then - table.insert(fields, 3, { "secretId", "Secret ID (0-65535)", S.save.secretId or 0 }) + table.insert(fields, 3, { "secretId", "Secret ID", S.save.secretId or 0 }) end - local contentH = pad * 2 - + #fields * (Kit.textHeight("small") + gap + row + gap) - + (Gen.hasPlayerGender(S.save, S.version) and row + gap or 0) + local contentH = S._trainerHeight or 0 S.trainerScroll = Kit.scrollPixels(x, y, w, h, S.trainerScroll or 0, contentH) Kit.pushClip(x, y, w, h) local cy = y + pad - S.trainerScroll S.trainerDrafts = S.trainerDrafts or {} for _, f in ipairs(fields) do - Kit.text("small", f[2], cx, cy, PAL.text) - cy = cy + Kit.textHeight("small") + gap - local key = "trainer-" .. f[1] - local setW = math.max(row, 64 * Kit.scale) - local function apply(v) - if Ops.setTrainerProperty(S, f[1], v) then - S.trainerDrafts[f[1]] = nil + if f[1] ~= "name" then + local hi = ({ id = 65535, secretId = 65535, money = 999999, coins = 9999 })[f[1]] + cy = cy + + Touch.value( + S, + Kit, + "trainer-" .. f[1], + f[2], + f[3], + { + lo = 0, + hi = hi, + help = f[1] == "money" and "Your wallet. Max fills it." + or f[1] == "coins" and "Game Corner coins. Max fills the coin case." + or "Part of your trainer identity. Changing it can affect who owns a Pokémon.", + }, + cx, + cy, + inner, + function(v) + return Ops.setTrainerProperty(S, f[1], v) + end + ) + + gap + else + Kit.text("small", f[2], cx, cy, PAL.text) + cy = cy + Kit.textHeight("small") + gap + local key = "trainer-" .. f[1] + local setW = math.max(row, 64 * Kit.scale) + local function apply(v) + if Ops.setTrainerProperty(S, f[1], v) then + S.trainerDrafts[f[1]] = nil + end end + S.trainerDrafts[f[1]] = Kit.textfield( + key, + cx, + cy, + inner - setW - gap, + row, + S.trainerDrafts[f[1]] or tostring(f[3]), + "Name", + { onSubmit = apply } + ) + if + Kit.button(cx + inner - setW, cy, setW, row, "Set", { kind = "accent", font = "small" }) + then + apply(S.trainerDrafts[f[1]]) + Kit.blur() + end + cy = cy + row + gap end - S.trainerDrafts[f[1]] = Kit.textfield( - key, - cx, - cy, - inner - setW - gap, - row, - S.trainerDrafts[f[1]] or tostring(f[3]), - "value", - { onSubmit = apply } - ) - if Kit.button(cx + inner - setW, cy, setW, row, "Set", { kind = "accent", font = "small" }) then - apply(S.trainerDrafts[f[1]]) - Kit.blur() - end - cy = cy + row + gap end if Gen.hasPlayerGender(S.save, S.version) then local half = (inner - gap) / 2 @@ -70,6 +97,11 @@ function M.draw(S, Kit, x, y, w, h) end end end + S._trainerHeight = cy + - (y + pad - S.trainerScroll) + + 2 * pad + + (Gen.hasPlayerGender(S.save, S.version) and row + gap or 0) Kit.popClip() + Kit.scrollbar(x, y, w, h, S.trainerScroll, S._trainerHeight, h) end return M