diff --git a/docs/ios-install.md b/docs/ios-install.md index 5d503300..41cebb3b 100644 --- a/docs/ios-install.md +++ b/docs/ios-install.md @@ -93,9 +93,11 @@ ZIP**, then double-click the zip to unpack it. ## Optional goodies -- **Pokéwalker mode** (real steps → EXP): in-game **mod manager → - POKEWALKER → SYNC STEPS on**, allow step access when iOS asks, and go - for a walk. +- **Pokéwalker mode** (real steps → EXP): install the mod from + [Gen1ReComp-Pokewalker](https://github.com/mresnick67/Gen1ReComp-Pokewalker/releases) + (launcher → **MODS** → **Import mod .zip**), then in-game **mod + manager → POKEWALKER → SYNC STEPS on**, allow step access when iOS + asks, and go for a walk. - **Mods**: launcher → **MODS** tab → **Import mod .zip**. - **Save import/export**: buttons on each game's tab, using the normal iOS file picker. diff --git a/mods/pokewalker/CHANGELOG.md b/mods/pokewalker/CHANGELOG.md deleted file mode 100644 index 2f4118a6..00000000 --- a/mods/pokewalker/CHANGELOG.md +++ /dev/null @@ -1,17 +0,0 @@ -# Changelog - -All notable changes to this mod are documented here. Format follows -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - -## [1.0.0] - 2026-07-30 - -### Added - -- Opt-in SYNC STEPS option: Apple Health step counts (delivered by the iOS - build's native bridge as `steps_pending.json`) convert to EXP. -- STEPS PER EXP option (10 / 20 / 50, default 20). -- GIVE EXP TO option: lead mon (default) or whole party split. -- Level-ups applied with the engine's growth curves and rare-candy stat - math; walk-report textbox at quiet moments. -- Guardrails: steps anchored to the last sync (never credited twice), - 50,000-step clamp per sync, engine `levelCap` respected. diff --git a/mods/pokewalker/README.md b/mods/pokewalker/README.md deleted file mode 100644 index 9b831710..00000000 --- a/mods/pokewalker/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# Pokéwalker (Apple Health) — a Gen1Recomp mod - -Your real-world steps become EXP for your Pokémon party — the HeartGold/ -SoulSilver Pokéwalker, except it's the iPhone already in your pocket. - -A mod for [gen1recomp](https://github.com/bryanthaboi/gen1recomp) -(the Gen 1 Recompilation Project). Opt-in, data-safe, and dormant on any -platform that doesn't provide the native step source (see -[Requirements](#requirements)). - -## Install - -Grab `pokewalker-.modpkg` from -[Releases](https://github.com/mresnick67/Gen1ReComp-Pokewalker/releases) -(or use GitHub's *Code → Download ZIP* — the importer handles both), then: - -- **In the launcher:** MODS tab → **Import mod .zip** → pick the file, or - drag it onto the window on desktop. -- **iOS:** you can also drop the zip into the app's folder in the Files - app; it installs on next launch. - -Then, in the **mod manager → POKEWALKER → options**, turn on **SYNC -STEPS**. iOS asks for read-only access to your step count the first time. - -## Options - -| Option | Values | Default | -|---|---|---| -| SYNC STEPS | on / off | **off** | -| STEPS PER EXP | 10 / 20 / 50 | 20 | -| GIVE EXP TO | lead mon / whole party (split) | lead mon | - -## Mechanics & guardrails - -- EXP applies through the engine's own growth curves and rare-candy stat - math, so levels, stats, and HP top-ups are exact. -- Steps are anchored to the last sync — the same walk is never credited - twice — and any single sync is clamped to 50,000 steps. -- The engine `levelCap` constant is respected. -- Credits land at quiet moments (save load, map change, battle end) with a - walk-report textbox. - -## Requirements - -The Lua mod is platform-neutral, but it feeds on a **native step bridge** -that currently ships in an iOS build of gen1recomp. Without the bridge the -mod loads and stays dormant — safe to install anywhere. - -### The bridge contract (for porters) - -Any platform can light this mod up by providing: - -- `love.system.syncHealthSteps()` → `boolean` — kick off an async step - query (requesting OS permission on first use). On completion, write - **`steps_pending.json`** to the LÖVE save directory: - - ```json - { "steps": 4312, "from": "2026-07-30T08:00:00Z", "to": "2026-07-30T17:00:00Z" } - ``` - - Count steps from a persisted anchor (last successful sync) so a walk is - never delivered twice, and **merge** with an unconsumed pending file - rather than overwriting it. The mod consumes and deletes the file. - -The reference iOS implementation is a small Swift class (HealthKit -`HKStatisticsQuery` over `stepCount`) exposed to Lua through a one-line -`wrap_System.cpp` addition. Open an issue here if you're porting the -bridge (Android: Health Connect / Google Fit would slot straight in). - -## Known limitations (v1) - -- Level-ups granted while walking don't prompt for new moves, and level - evolutions wait for the next in-battle level — same behavior as - over-leveling with rare candies. -- Steps sync on launch/activation; no background delivery yet. - -## Developing - -From a gen1recomp checkout with this mod at `mods/pokewalker` and an -imported data cache: - -```sh -luajit mods/pokewalker/tests/pokewalker_test.lua -python3 tools/modkit.py validate mods/pokewalker --base imported -python3 tools/modkit.py pack mods/pokewalker -``` - -## License - -MIT — see [LICENSE](LICENSE). Not affiliated with Nintendo, Game Freak, -or The Pokémon Company. This mod contains no ROM-derived content -(`modkit lint` clean). diff --git a/mods/pokewalker/main.lua b/mods/pokewalker/main.lua deleted file mode 100644 index 39231689..00000000 --- a/mods/pokewalker/main.lua +++ /dev/null @@ -1,144 +0,0 @@ --- Pokéwalker: Apple Health steps become party EXP (iOS builds). --- --- The Swift side (mobile/ios/native/GRHealthBridge.swift) owns HealthKit: --- love.system.syncHealthSteps() requests read access on first use, counts --- steps since the last sync anchor, and drops steps_pending.json in the --- save dir. This mod consumes that file at quiet moments (save loaded, map --- transitions, battle end), converts steps to EXP, and applies level-ups --- with the same stat math the engine uses. --- --- Opt-in: everything is inert until SYNC STEPS is enabled in this mod's --- options (the HealthKit permission sheet appears on first enable). On --- non-iOS platforms love.system.syncHealthSteps does not exist and the mod --- stays dormant. --- --- Known v1 limits (documented in README.md): level-ups applied here do not --- prompt for new moves (like over-leveling past a learnset entry with rare --- candies) and do not trigger level evolutions until the next battle candy --- or level gained in battle. - -local PENDING = "steps_pending.json" - -return function(mod) - mod.options:define({ - { key = "enabled", label = "SYNC STEPS", type = "toggle", default = false }, - { key = "rate", label = "STEPS PER EXP", type = "choice", default = "20", - choices = { { "10", "10" }, { "20", "20" }, { "50", "50" } } }, - { key = "target", label = "GIVE EXP TO", type = "choice", default = "lead", - choices = { { "LEAD MON", "lead" }, { "WHOLE PARTY", "party" } } }, - }) - - local Json = require("src.link.Json") - local Growth = require("src.pokemon.Growth") - local Stats = require("src.pokemon.Stats") - local game - - local function active() - return love.system.syncHealthSteps ~= nil and mod.options:get("enabled") - end - - -- Ask the native side to refresh steps_pending.json. Async: results are - -- picked up by a later consume() (next map change / battle end). - local function requestSync() - if active() then love.system.syncHealthSteps() end - end - - -- Add EXP to one mon, bumping levels with the engine's own stat math - -- (mirrors the rare-candy path in src/inventory/ItemEffects.lua). - -- Returns the EXP actually absorbed and any levels gained. - local function applyToMon(mon, xp, data) - local def = data.pokemon[mon.species] - if not def or not mon.level then return 0, {} end - local cap = (data.constants and data.constants.levelCap) or 100 - if mon.level >= cap then return 0, {} end - local maxExp = Growth.expForLevel(def.growthRate, cap, data.growth_rates) - local before = mon.exp or 0 - mon.exp = math.min(maxExp, before + xp) - local absorbed = mon.exp - before - if absorbed <= 0 then return 0, {} end - local levels = {} - local newLevel = Growth.levelForExp(def.growthRate, mon.exp, cap, - data.growth_rates) - while mon.level < newLevel do - mon.level = mon.level + 1 - local old = mon.stats - mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) - mon.hp = math.min(mon.stats.hp, - (mon.hp or 0) + (mon.stats.hp - (old and old.hp or 0))) - levels[#levels + 1] = mon.level - end - return absorbed, levels - end - - -- A short walk-report textbox, shown only when the overworld is idle; - -- when a script is already running the report is silently skipped (the - -- EXP is applied regardless, and the log has the numbers). - local function report(steps, total, leveled) - local msg = ("You walked %d steps!\nYour party gained %d EXP."):format(steps, total) - if #leveled > 0 then - msg = msg .. ("\n%s grew to L%d!"):format(leveled[1].name, leveled[1].level) - end - -- The report is decoration: it must never break the credit. mod.world - -- materializes lazily (and can itself error in headless contexts), so - -- the access lives inside the pcall too. - pcall(function() - local world = mod.world - if world then world:queueScript({ { "show_text", msg } }) end - end) - end - - local function consume() - if not (game and active()) then return end - local raw = love.filesystem.read(PENDING) - if not raw then return end - local decoded = Json.decode(raw) - local steps = decoded and tonumber(decoded.steps) or 0 - love.filesystem.remove(PENDING) - if steps <= 0 then return end - - local party = game.save and game.save.party - if not party or #party == 0 then return end - local rate = tonumber(mod.options:get("rate")) or 20 - local xp = math.floor(steps / rate) - if xp <= 0 then return end - - local total, leveled = 0, {} - local targets = {} - if mod.options:get("target") == "party" then - for _, mon in ipairs(party) do targets[#targets + 1] = mon end - else - targets[1] = party[1] - end - local share = math.max(1, math.floor(xp / #targets)) - for _, mon in ipairs(targets) do - local absorbed, levels = applyToMon(mon, share, game.data) - total = total + absorbed - for _, level in ipairs(levels) do - leveled[#leveled + 1] = - { name = mon.nickname or mon.species, level = level } - end - end - if total <= 0 then return end - mod.log:info("credited %d steps -> %d EXP (%d level-ups)", - steps, total, #leveled) - report(steps, total, leveled) - end - - -- game.ready is the sanctioned way to obtain the Game object; the party - -- only exists once a save is loaded or created. - mod.events:on("game.ready", function(payload) - game = payload.game - requestSync() - end) - mod.events:on("save.loaded", function() - requestSync() - consume() - end) - mod.events:on("save.created", function() requestSync() end) - -- Quiet moments where a walk report can safely appear. - mod.events:on("map.entered", function() consume() end) - mod.events:on("battle.ended", function() consume() end) - -- Flipping SYNC STEPS on triggers the HealthKit permission sheet - -- immediately rather than on the next boot. - mod.events:on("mod.options_changed", function() requestSync() end) -end diff --git a/mods/pokewalker/manifest.json b/mods/pokewalker/manifest.json deleted file mode 100644 index 5a067330..00000000 --- a/mods/pokewalker/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "pokewalker", - "name": "Pokewalker (Apple Health)", - "version": "1.0.0", - "api": 2, - "entry": "main.lua", - "profile": "content", - "category": "MECHANIC", - "game_version": ">=0.0.0-0 <2.0.0", - "permissions": ["network", "engine_internals"], - "priority": 100, - "dependencies": [], - "optional_dependencies": [], - "conflicts": [], - "description": "Your real-world steps (Apple Health) become EXP for your party. Opt-in: enable SYNC STEPS in this mod's options. Needs an iOS build with the Health bridge; dormant elsewhere." -} diff --git a/mods/pokewalker/mod.card b/mods/pokewalker/mod.card deleted file mode 100644 index 17bdb745..00000000 --- a/mods/pokewalker/mod.card +++ /dev/null @@ -1,33 +0,0 @@ --- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling --- and the manager detail pane; never by the loader's merge. -return { - summary = "Real-world steps from Apple Health become EXP for your party.", - author = "mresnick67", - contact = "https://github.com/mresnick67/Gen1ReComp-Pokewalker", - tags = { "mechanic", "ios", "health", "opt-in", "field" }, - differences = { - changed = {}, - added = { - "an opt-in SYNC STEPS option: Apple Health step counts convert to " - .. "EXP at a configurable rate (10/20/50 steps per EXP)", - "EXP lands on the lead mon or splits across the party, applied with " - .. "the engine's own growth curves and rare-candy stat math", - "a walk-report textbox at quiet moments (save load, map change, " - .. "battle end)", - }, - known = { - "needs an iOS build that ships the native Health bridge " - .. "(love.system.syncHealthSteps); on every other platform the mod " - .. "loads but stays dormant", - "level-ups granted while walking do not prompt for new moves and do " - .. "not trigger level evolutions until the next in-battle level " - .. "(same as over-leveling with rare candies)", - "steps sync on launch/activation; no background delivery yet", - }, - }, - credits = { - { who = "Nintendo's Pokewalker (HGSS)", for_ = "the idea this recreates" }, - { who = "bryanthaboi/gen1recomp", for_ = "the engine and mod platform" }, - }, - compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, -} diff --git a/mods/pokewalker/tests/pokewalker_test.lua b/mods/pokewalker/tests/pokewalker_test.lua deleted file mode 100644 index b53aaeba..00000000 --- a/mods/pokewalker/tests/pokewalker_test.lua +++ /dev/null @@ -1,61 +0,0 @@ --- Standalone: luajit mods/pokewalker/tests/pokewalker_test.lua --- Exercises the stated effect: opt-in gating, the native-bridge seam, and --- steps converting to EXP through the engine's own growth math. --- --- Shipped-mod suites are auto-run by the ROM-free T4 tier, so this runs --- against the committed fixture dataset (T.fixtures), never data/generated. -package.path = "./?.lua;./?/init.lua;" .. package.path - -local T = require("tests.modkit") -local Data = T.fixtures.fresh() - --- The native step bridge only exists inside the iOS/Android apps; stand it --- in so the mod sees the same surface it does on device. -local syncCalls = 0 -love.system = love.system or {} -love.system.syncHealthSteps = function() - syncCalls = syncCalls + 1 - return true -end - -local run = T.sdk.loadMod("mods/pokewalker", { data = Data }) -T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") - -local events = run.loader.events -local Growth = require("src.pokemon.Growth") -local Pokemon = require("src.pokemon.Pokemon") -local mon = Pokemon.new(Data, "FIXMON_A", 5) -local game = { data = Data, save = { party = { mon } } } - --- Dormant until opted in: seeded steps survive every event untouched and --- the native bridge is never poked (no permission prompt without consent). -love.filesystem.write("steps_pending.json", '{"steps": 20000}') -events:emit("game.ready", { game = game }) -events:emit("map.entered", {}) -T.check(love.filesystem.read("steps_pending.json") ~= nil, - "opt-out leaves pending steps untouched") -T.eq(syncCalls, 0, "opt-out never calls the native bridge") - --- Opted in: 20000 steps at the default 20 steps/EXP credit the lead mon. -run.loader.modOptions.pokewalker = { enabled = true } -local def = Data.pokemon[mon.species] -local expBefore = mon.exp -events:emit("save.loaded", {}) -T.eq(mon.exp, expBefore + 1000, "20000 steps at 20 steps/EXP = +1000 EXP") -local expectedLevel = Growth.levelForExp(def.growthRate, mon.exp, 100, - Data.growth_rates) -T.check(mon.level > 5, "enough EXP to actually level (fixture curve)") -T.eq(mon.level, expectedLevel, "level matches the engine growth curve") -T.check(mon.stats.hp > 0 and mon.hp <= mon.stats.hp, - "stat recalc keeps HP within the new maximum") -T.check(love.filesystem.read("steps_pending.json") == nil, - "pending file is consumed exactly once") -T.check(syncCalls > 0, "opt-in requests a native sync") - --- A consumed file plus more events must not double-credit. -local expAfter = mon.exp -events:emit("map.entered", {}) -T.eq(mon.exp, expAfter, "no pending file, no phantom EXP") - -run.release() -T.finish("pokewalker") diff --git a/scripts/build_ios.sh b/scripts/build_ios.sh index 6824a2d4..d5dc1e16 100755 --- a/scripts/build_ios.sh +++ b/scripts/build_ios.sh @@ -211,13 +211,14 @@ pack_game_love() { rm -f "$LOVE_FILE" # Same payload as scripts/build.sh / build_android.sh: game sources plus # tools/save-editor, which the launcher's Edit button opens in-process. - # mods/pokewalker rides inside game.love on iOS only: physfs merges the - # fused archive with the save dir, so the loader discovers it like any - # installed mod, and its Apple Health sync is a no-op everywhere else. + # Deliberately NO fused mods: a mod inside game.love sits in the + # read-only app bundle, so the mod manager's Delete can't remove it and + # it reappears every launch. Mods install as .zips at runtime instead + # (launcher -> MODS -> Import mod .zip), the same lifecycle as every + # other platform. (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ main.lua conf.lua src data assets tools/save-editor \ tools/rom_manifest.json tools/rom_manifest_blue.json \ - mods/pokewalker \ -x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \ -x 'data/generated/*' -x 'assets/generated/*') # NOTE: grep -q here would race pipefail — it exits on first match, unzip diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 108237c9..2c29d47c 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -121,6 +121,7 @@ local DEBT = { ["event:pokemon.evolved"] = "M7", ["event:pokemon.level_up"] = "M7", ["event:pokemon.move_learned"] = "M7", + ["event:save.loaded"] = "M11", ["event:save.loading"] = "M11", ["event:save.writing"] = "M11", ["event:trade.completed"] = "M12",