further save editor yum

This commit is contained in:
bryanthaboi
2026-10-02 06:21:06 -04:00
parent 20ab97abf8
commit d555a24693
34 changed files with 2340 additions and 314 deletions
+37 -2
View File
@@ -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
+59 -4
View File
@@ -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
+1 -1
View File
@@ -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
+13 -1
View File
@@ -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
+49 -15
View File
@@ -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)
+122
View File
@@ -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")
+12 -1
View File
@@ -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
+17
View File
@@ -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
+35 -1
View File
@@ -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
+320
View File
@@ -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 .. ")")
+17 -11
View File
@@ -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
+35 -8
View File
@@ -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
+4 -1
View File
@@ -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",
+1 -1
View File
@@ -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)
+168 -116
View File
@@ -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
+106 -22
View File
@@ -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
+111
View File
@@ -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)
+402
View File
@@ -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
+79
View File
@@ -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
+54 -1
View File
@@ -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
+7 -7
View File
@@ -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
+430
View File
@@ -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
+88
View File
@@ -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
+3 -4
View File
@@ -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)
+16 -1
View File
@@ -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 =
+4 -5
View File
@@ -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
+4 -5
View File
@@ -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
+4 -5
View File
@@ -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)
+64 -51
View File
@@ -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",
+5 -6
View File
@@ -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
+4 -5
View File
@@ -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)
+4 -6
View File
@@ -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
+4 -5
View File
@@ -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)
+61 -29
View File
@@ -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