From fe1714d50e9331c8b782c06e83647111847151c3 Mon Sep 17 00:00:00 2001 From: Myles Resnick Date: Thu, 30 Jul 2026 11:42:27 -0400 Subject: [PATCH] iOS: stop fusing mods; Pokewalker moves to its own repo Follow-up to #452, from dogfooding the merged build on a real iPhone. A mod fused into game.love sits in the read-only app bundle: the mod manager's Delete can't remove it and it reappears every launch, and an imported newer version can never fully replace it. iOS now packs no mods, matching every other platform - mods install as .zips at runtime with a real install/upgrade/delete lifecycle. With nothing fusing it, mods/pokewalker moves out of this tree to its canonical home, https://github.com/mresnick67/Gen1ReComp-Pokewalker (already at 0.2.0 there with move learning and a paged credit dialog; in-tree 0.1.0 was stale). docs/ios-install.md points at it. The event:save.loaded coverage-debt entry returns to gate_meta_coverage: its coverage came from the mod's test suite and leaves with it. Local reproduction of the ci workflow on this branch: all tiers, mod lint, fingerprint + meta-coverage gates green. Co-Authored-By: Claude Fable 5 --- docs/ios-install.md | 8 +- mods/pokewalker/CHANGELOG.md | 17 --- mods/pokewalker/README.md | 92 -------------- mods/pokewalker/main.lua | 144 ---------------------- mods/pokewalker/manifest.json | 16 --- mods/pokewalker/mod.card | 33 ----- mods/pokewalker/tests/pokewalker_test.lua | 61 --------- scripts/build_ios.sh | 9 +- tests/engine/gate_meta_coverage.lua | 1 + 9 files changed, 11 insertions(+), 370 deletions(-) delete mode 100644 mods/pokewalker/CHANGELOG.md delete mode 100644 mods/pokewalker/README.md delete mode 100644 mods/pokewalker/main.lua delete mode 100644 mods/pokewalker/manifest.json delete mode 100644 mods/pokewalker/mod.card delete mode 100644 mods/pokewalker/tests/pokewalker_test.lua 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",