Compare commits

...

11 Commits

Author SHA1 Message Date
bryanthaboi 4d03a38067 Merge pull request #1060 from bryanthaboi/dev
bugs and slugs
2026-08-10 17:11:22 -04:00
bryanthaboi 79ed37699e Merge pull request #1055 from AverageConsumer/codex/mod-grid-navigation-hooks
Mod API: allow alternate battle grids to own navigation
2026-08-10 17:08:24 -04:00
bryanthaboi abf0f9e98a Merge pull request #1057 from ShaneMcGovernIE/shanemcgovernie-fix-index-unexpected-character
Handle non-JSON update responses
2026-08-10 17:07:43 -04:00
bryanthaboi 3955721bfc Merge pull request #1058 from castdrian/ios-documents
feat(ios): expose app data in Documents dir
2026-08-10 17:07:05 -04:00
bryanthaboi c3855cebec more buggies CLOSES #960, CLOSES #961, CLOSES #968, CLOSES #995, CLOSES #1006, CLOSES #1009, CLOSES #1013, CLOSES #1021, CLOSES #1031, CLOSES #1044, CLOSES #1049, CLOSES #1050, CLOSES #1045,, 2026-08-10 17:06:24 -04:00
Adrian Castro 41e6507665 docs(ios): document public Documents storage 2026-08-10 22:56:15 +02:00
Adrian Castro 5482ac590d fix(ios): guard missing game payload 2026-08-10 22:56:15 +02:00
Adrian Castro 28440eb936 fix(ios): expose runtime data in Documents 2026-08-10 22:56:15 +02:00
Shane McGovern f606840216 Handle non-JSON update responses
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-10 20:51:57 +01:00
AverageConsumer 6a990bf79f feat(mods): allow alternate battle grids to own navigation 2026-08-10 21:22:09 +02:00
github-actions ab5fed60fe chore(ios): update app-repo.json [skip ci] 2026-08-10 15:16:03 -04:00
31 changed files with 2215 additions and 192 deletions
+9 -6
View File
@@ -45,6 +45,8 @@ return {
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" },
-- callfar OaksLabPikachuMovementScript, before ShowObject (#1021)
{ "pikachu_make_way" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "place_npc", RIVAL, 4, 7, "up" },
{ "move_npc_to", RIVAL, 4, 3 },
@@ -192,12 +194,13 @@ return {
end
rows[#rows + 1] = { "face_player_dir", "up" }
rows[#rows + 1] = { "face_object", OAK1, "down" }
-- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter
-- Pikachu keeps its species name
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
rows[#rows + 1] = { "set_field", "pikachuInBall", true }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
ow.runner:run(rows, { npc = npc, onDone = done })
@@ -296,9 +299,9 @@ return {
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
-- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball.
-- The overworld follower itself is still an open port
-- (docs/yellow-version.md runtime backlog); the story beat plays.
-- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009)
table.insert(rows, { "face_player_dir", "up" })
table.insert(rows, { "set_field", "pikachuInBall", false })
table.insert(rows, { "play_cry", "PIKACHU" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
+16 -7
View File
@@ -400,15 +400,24 @@ M.POKEMON_FAN_CLUB = {
TEXT_POKEMONFANCLUB_CHAIRMAN = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "show_text", "_PokemonFanClubChairmanIntroText" }, -- 4
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 5
{ "jump_if_true", "nothing_left" }, -- 3
-- YesNoChoice (scripts/PokemonFanClub.asm): NO forfeits the voucher (#1050)
{ "ask", "_PokemonFanClubChairmanIntroText" }, -- 4
{ "jump_if_false", "no_story" }, -- 5
{ "show_text", "_PokemonFanClubChairmanStoryText" }, -- 6
-- give-then-print like scripts/PokemonFanClub.asm (GiveItem
-- fills wStringBuffer; the received text reads it)
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 6
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 7
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 8
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 9
{ "give_item", "BIKE_VOUCHER", 1, false }, -- 7
{ "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 8
{ "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 9
{ "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 10
{ "jump", "end" }, -- 11
{ "label", "no_story" }, -- 12
{ "show_text", "_PokemonFanClubNoStoryText" }, -- 13
{ "jump", "end" }, -- 14
-- .nothingleft: the gift is done, he only reminisces now
{ "label", "nothing_left" }, -- 15
{ "show_text", "_PokemonFanClubChairFinalText" }, -- 16
},
},
}
+38 -25
View File
@@ -27,11 +27,16 @@ local function ask(game, s, cb)
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
end
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
-- fill text placeholders; key on the hram/wram symbol first, since one
-- string can carry two different NUM slots (#1006)
local function fill(s, subs)
s = s:gsub("{PLAYER}", subs.player or "")
s = s:gsub("{NUM:[^}]*}", function() return tostring(subs.num or "") end)
s = s:gsub("{RAM:[^}]*}", function() return subs.ram or "" end)
s = s:gsub("{NUM:([%w_]*)[^}]*}", function(name)
return tostring(subs[name] or subs.num or "")
end)
s = s:gsub("{RAM:([%w_]*)[^}]*}", function(name)
return subs[name] or subs.ram or ""
end)
return s
end
@@ -82,9 +87,12 @@ local function oaksAide(threshold, itemId, repeatText)
{ ram = itemName, player = game.save.player.name }), done)
end)
else
-- .notEnoughOwnedMons prints owned then requirement, two counts
push(game, fill(t._OaksAideUhOhText or
"You have only\ncaught {NUM:}!",
{ num = owned, ram = itemName }), done)
{ num = owned, ram = itemName,
hOaksAideNumMonsOwned = owned,
hOaksAideRequirement = threshold }), done)
end
end)
end
@@ -245,27 +253,32 @@ M.FIGHTING_DOJO = {
M.SILPH_CO_7F = {
talk = {
TEXT_SILPHCO7F_SILPH_WORKER_M1 = function(game, ow, npc, done)
local t = text(game)
if game.save.flags.EVENT_GOT_LAPRAS then
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText
or "How is LAPRAS\ndoing?", done)
return
end
push(game, t._SilphCo7FSilphWorkerM1ThankYouText
or "Thank you for\nsaving us!\fI want you to\nhave this LAPRAS!",
function()
game.save.flags.EVENT_GOT_LAPRAS = true
local Commands = require("src.script.Commands")
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
"LAPRAS", 15)
push(game, ("%s got\nLAPRAS!"):format(game.save.player.name),
function()
push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText
or "It's a good\nswimmer!", done)
end)
end)
end,
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1049)
TEXT_SILPHCO7F_SILPH_WORKER_M1 = {
{ "face_player" },
{ "check_flag", "EVENT_GOT_LAPRAS" },
{ "jump_if_true", "has_lapras" },
{ "show_text", "_SilphCo7FSilphWorkerM1HaveThisPokemonText" },
{ "give_pokemon", "LAPRAS", 15 },
{ "jump_if_false", "box_full" },
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
{ "set_flag", "EVENT_GOT_LAPRAS" },
{ "play_sound", "Get_Item1" },
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
{ "jump", "end" },
{ "label", "box_full" },
{ "show_text", "_BoxIsFullText" },
{ "jump", "end" },
-- SilphCo7F.asm .saved_silph gates the thanks on Giovanni
{ "label", "has_lapras" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", "saved" },
{ "show_text", "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" },
{ "jump", "end" },
{ "label", "saved" },
{ "show_text", "_SilphCo7FSilphWorkerM1SavedText" },
},
},
}
+87 -104
View File
@@ -1,130 +1,113 @@
# iOS build (LÖVE 12.0)
# iOS build
> **Native ROM/mod/save import.** The iOS build ships a Swift
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
> that `patch_love_src.py` wires into the LÖVE tree on every build:
>
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
> `picked_save.sav`; exports signal via `export_done.flag`).
> - The Info.plist overlay enables `UIFileSharingEnabled` +
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
> into the LÖVE save dir on every activation — drop a ROM, open the app,
> and it imports with no taps.
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
> for picker results (iOS pickers are in-process modals, so Android's
> refocus rescan never fires).
>
> The note below about a missing "UIDocumentPicker handoff" is
> resolved by this bridge.
This directory contains the macOS/Xcode build used to package Gen1 Recomp as
an iOS app with LÖVE 12.0.
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
dependencies from the official [LÖVE source](https://github.com/love2d/love)
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
## User data location
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
The app uses the public iOS Documents directory as its LÖVE save directory.
There is no `pokemon-love2d` subdirectory and the app does not create a
README file there. When browsing `On My iPhone > gen1recomp` in Files, the
directory contains the app's runtime data directly, including:
## Quick start (simulator)
- installed mods and downloaded ROMs
- save files and save-state data
- options, caches, logs, and other files created by the game
The build enables `UIFileSharingEnabled` and
`LSSupportsOpeningDocumentsInPlace`, so the same directory is available in
Files and Finder. Files copied into the app's Documents directory are used by
the game on its next activation.
Existing installations are migrated automatically. Files from the old
private `Application Support/pokemon-love2d` directory are merged into
Documents on launch; conflicts are retained with a `.legacy` suffix.
## Build
Run these commands from the repository root:
```bash
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
scripts/build_ios.sh --fetch
scripts/build_ios.sh
```
The embedded `game.love` contains no ROM or generated game data. The current
first-boot importer has desktop file pickers only, so a production iOS release
still needs a UIDocumentPicker handoff that passes the selected ROM to LÖVE.
`--fetch` downloads the pinned LÖVE source and matching Apple dependencies
into the gitignored `love-src/` directory. It is only needed when that tree is
missing. The default build targets the iOS Simulator in Debug configuration.
Default output: an unsigned Simulator `.app` under `mobile/ios/build/`
(no Apple Developer account required). A convenience copy also lands under
`dist/ios/<Config>-<sdk>/`.
Install on a booted simulator (example):
For a physical device or a release build:
```bash
xcrun simctl install booted mobile/ios/build/Build/Products/Debug-iphonesimulator/PokemonRed.app
xcrun simctl launch booted com.theboisclub.pokemonred
scripts/build_ios.sh --device --install
scripts/build_ios.sh --device --release --install
```
Or open `mobile/ios/love-src/platform/xcode/love.xcodeproj` in Xcode,
select the `love-ios` target, and Run on a Simulator after
`scripts/build_ios.sh --package-only` (or a full build) has placed `game.love`.
Device builds require a paired, unlocked device and a valid Apple signing
identity. Set `DEVELOPMENT_TEAM` or `CODE_SIGN_IDENTITY` when automatic
signing cannot select the intended account. Add `--ipa` to create
`dist/ios/gen1recomp.ipa`.
## Device / Release
The script verifies the final app before packaging it:
```bash
scripts/build_ios.sh --device # Debug, physical device SDK
scripts/build_ios.sh --device --release # Release configuration
- the public Documents plist settings are present
- the native picker bridge is present
- `game.love` exists and is non-empty
If the payload is missing, the build fails instead of producing a blank app.
## Useful options
| Option | Purpose |
| --- | --- |
| `--fetch` | Fetch LÖVE 12.0 and Apple dependencies when `love-src/` is missing |
| `--device` | Build for `iphoneos` instead of the Simulator |
| `--release` | Use the Release configuration |
| `--install` | Install a device build on the first connected device |
| `--ipa` | Create an IPA after a device build |
| `--version X.Y.Z` | Stamp the engine and app version |
| `--package-only` | Package `game.love` and apply the iOS plist overlay without Xcode |
`scripts/build.sh ios` delegates to this script and forwards the iOS release
option.
## Output
Simulator and device app bundles are copied to:
```text
dist/ios/Debug-iphonesimulator/gen1recomp.app
dist/ios/Release-iphonesimulator/gen1recomp.app
dist/ios/Debug-iphoneos/gen1recomp.app
dist/ios/Release-iphoneos/gen1recomp.app
```
Device builds need a signing identity and provisioning profile configured in
Xcode (or via `DEVELOPMENT_TEAM` / `CODE_SIGN_IDENTITY` env vars). This repo
does **not** store certificates, profiles, or App Store Connect secrets.
The intermediate Xcode products are under `mobile/ios/build/`. Both locations
are gitignored.
Manual out-of-band steps:
1. Apple Developer account + App ID for `com.theboisclub.pokemonred`
2. Development or Distribution certificate + provisioning profile
3. In Xcode: open `love.xcodeproj` → target `love-ios` → Signing & Capabilities
→ select your Team (or set `DEVELOPMENT_TEAM=XXXXXXXXXX` when invoking
`scripts/build_ios.sh --device`)
4. Archive / export an `.ipa` from Xcode Organizer for TestFlight / Ad Hoc
## Layout
| Path | Role |
|------|------|
| `LOVE_VERSION` | Engine pin (`12.0`) |
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
| `build/` | `xcodebuild` derived data (**gitignored**) |
Game payload lands at:
`love-src/platform/xcode/ios/resources/game.love`
and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
## Apple libraries dependency
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
SDL3 framework from
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
Re-run it if either dependency directory is absent.
The bundled game payload is staged at
`love-src/platform/xcode/ios/resources/game.love` and copied into the final
app bundle. The payload contains the game, not user-generated ROMs, mods, or
saves; those are created at runtime in Documents.
## App identity
| Field | Value |
|-------|--------|
| Display name | Pokemon Red |
| `PRODUCT_NAME` | PokemonRed |
| Bundle ID | `com.theboisclub.pokemonred` |
| Orientations | Portrait only (`UIInterfaceOrientationPortrait`) |
| Field | Default |
| --- | --- |
| Display name | `gen1recomp` |
| Product name | `gen1recomp` |
| Bundle identifier | `com.theboisclub.gen1recomp` |
| Save directory | Public `Documents` root |
| Orientation | Portrait |
Overrides are applied by the build script (`xcodebuild` settings + plist overlay)
so refreshing `love-src/` does not lose branding.
Set `GEN1_BUNDLE_ID` to use a different bundle identifier for local device
builds.
## Flags (`scripts/build_ios.sh`)
## Prerequisites
| Flag | Meaning |
|------|---------|
| *(default)* | Simulator, Debug, no signing |
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
| `--release` | `Release` configuration instead of `Debug` |
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
- macOS with Xcode and `xcodebuild`
- the iOS and iOS Simulator platforms installed in Xcode
- a fetched `love-src/` tree, or the `--fetch` option
- the matching iOS libraries and SDL3 framework under `love-src/`
Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
## Preconditions
- macOS (Darwin) with Xcode + `xcodebuild` on `PATH`
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
- `love-src/` present (`--fetch`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
Use `xcodebuild -showsdks` to confirm that the required SDKs are installed.
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.76",
"date": "2026-08-10",
"size": 9610081,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.76/gen1recomp-0.1.76-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #617 Old Man in Viridian\n- #889 Pokemon yellow can't export save file right\n- #915 wrong sprite used in opening\n- #916 Trainer sprite at end of fly animation\n- #931 Mod update checks fail\n- #932 Bugs reset settings\n- #933 Add TitleState override for player\n- #945 Cannot edit Trainer Class's Battle Theme\n- #969 Linux x86_64 AppImage Shows up as \"LOVE\" instead of \"gen1recomp\"\n- #1016 Mod API: support variable-size overworld sprites\n- #1039 Encounter rate grace period not working\n- #1040 Player sprite walks right through rival after defeating him at the end of the game\n\n## Contributors\n\n- @ArmstrongThomas\n- @AverageConsumer\n- @Bortlesboat\n- @bryanthaboi\n- @crusty\n- @dlloa\n- @jherediagu\n- @KikiManjaro\n- @martin2844\n- @MaxTomahawk\n- @ShaneMcGovernIE\n- @steve1337\n- @swuff-star\n- @thibautbus\n- @Yukitty\n- hernan"
},
{
"version": "0.1.75",
"date": "2026-08-06",
+3
View File
@@ -16,6 +16,9 @@ static void GRBootstrapInstall(void)
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
Class bridge = NSClassFromString(@"GRPickerBridge");
if ([bridge respondsToSelector:@selector(preparePublicDocuments)]) {
[bridge performSelector:@selector(preparePublicDocuments)];
}
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
[bridge performSelector:@selector(sweepInbox)];
}
+83 -10
View File
@@ -26,8 +26,6 @@ public final class GRPickerBridge: NSObject {
// silently doing nothing.
private static var liveDelegates: [PickerDelegate] = []
// conf.lua t.identity where LÖVE puts the fused save directory on iOS
// (<sandbox>/Library/Application Support/<identity>).
private static let loveIdentity = "pokemon-love2d"
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
@@ -177,10 +175,10 @@ public final class GRPickerBridge: NSObject {
// UIApplicationDidBecomeActive (see GRBootstrap.m).
@objc public static func sweepInbox() {
let fm = FileManager.default
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
let appSupport = fm.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first else { return }
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
migrateLegacySaveDirectory()
guard let docs = documentsDirectory(),
let saveDir = publicSaveDirectory() else { return }
guard docs.standardizedFileURL != saveDir.standardizedFileURL else { return }
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
guard let items = try? fm.contentsOfDirectory(at: docs,
includingPropertiesForKeys: nil) else { return }
@@ -197,15 +195,21 @@ public final class GRPickerBridge: NSObject {
}
}
@objc public static func preparePublicDocuments() {
migrateLegacySaveDirectory()
if let saveDir = publicSaveDirectory() {
ensureDirectory(saveDir)
}
}
// MARK: - Helpers
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
var dir = cstr.map { String(cString: $0) } ?? ""
if dir.isEmpty {
guard let appSupport = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
else { return nil }
dir = appSupport.appendingPathComponent(loveIdentity).path
migrateLegacySaveDirectory()
guard let saveDir = publicSaveDirectory() else { return nil }
dir = saveDir.path
}
let url = URL(fileURLWithPath: dir, isDirectory: true)
ensureDirectory(url)
@@ -217,6 +221,75 @@ public final class GRPickerBridge: NSObject {
withIntermediateDirectories: true)
}
private static func documentsDirectory() -> URL? {
FileManager.default.urls(for: .documentDirectory,
in: .userDomainMask).first
}
private static func publicSaveDirectory() -> URL? {
documentsDirectory()
}
private static func legacySaveDirectory() -> URL? {
FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first?
.appendingPathComponent(loveIdentity, isDirectory: true)
}
private static func migrateLegacySaveDirectory() {
let fm = FileManager.default
guard let destination = publicSaveDirectory(),
let legacy = legacySaveDirectory(),
fm.fileExists(atPath: legacy.path) else {
return
}
ensureDirectory(destination)
mergeDirectory(from: legacy, to: destination)
try? fm.removeItem(at: legacy)
}
private static func mergeDirectory(from source: URL, to destination: URL) {
let fm = FileManager.default
ensureDirectory(destination)
guard let items = try? fm.contentsOfDirectory(at: source,
includingPropertiesForKeys: nil)
else { return }
for item in items {
let target = destination.appendingPathComponent(item.lastPathComponent)
var sourceIsDirectory = ObjCBool(false)
fm.fileExists(atPath: item.path, isDirectory: &sourceIsDirectory)
var targetIsDirectory = ObjCBool(false)
let targetExists = fm.fileExists(atPath: target.path,
isDirectory: &targetIsDirectory)
if sourceIsDirectory.boolValue && targetExists && targetIsDirectory.boolValue {
mergeDirectory(from: item, to: target)
continue
}
if targetExists {
if !sourceIsDirectory.boolValue && !targetIsDirectory.boolValue &&
fm.contentsEqual(atPath: item.path, andPath: target.path) {
try? fm.removeItem(at: item)
} else {
moveToLegacyName(item, in: destination)
}
continue
}
try? fm.moveItem(at: item, to: target)
}
}
private static func moveToLegacyName(_ item: URL, in destination: URL) {
let fm = FileManager.default
let base = item.lastPathComponent + ".legacy"
var target = destination.appendingPathComponent(base)
var suffix = 2
while fm.fileExists(atPath: target.path) {
target = destination.appendingPathComponent("\(base).\(suffix)")
suffix += 1
}
try? fm.moveItem(at: item, to: target)
}
private static func copyItem(at src: URL, into dir: URL, named name: String) {
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
+63 -3
View File
@@ -27,6 +27,8 @@ NATIVE_SRC = IOS_DIR / "native"
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
APPLE_MM = LOVE_SRC / "src" / "common" / "apple.mm"
FILESYSTEM_CPP = LOVE_SRC / "src" / "modules" / "filesystem" / "physfs" / "Filesystem.cpp"
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
@@ -245,19 +247,20 @@ def fail(msg):
sys.exit(1)
def pristine(path: Path) -> str:
def pristine(path: Path, patched_markers=None) -> str:
"""Text of `path` before any of our patching: backed by a `.orig` stash.
The stash is only trusted if it is itself unpatched; that protects
against a stash accidentally taken after an earlier patch run.
"""
patched_markers = tuple(patched_markers or (MARKER, ID_FILE_PICKER))
orig = path.with_suffix(path.suffix + ".orig")
if orig.is_file():
text = orig.read_text()
if MARKER not in text and ID_FILE_PICKER not in text:
if not any(marker in text for marker in patched_markers):
return text
text = path.read_text()
if MARKER in text or ID_FILE_PICKER in text:
if any(marker in text for marker in patched_markers):
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
orig.write_text(text)
@@ -299,6 +302,62 @@ def patch_wrap_system():
"(pickFile/createFile/syncHealthSteps/httpDownload)")
def patch_public_documents():
text = pristine(
APPLE_MM,
("#ifdef LOVE_IOS\n"
"\t\t\tnsdir = NSDocumentDirectory;\n"
"#else\n",),
)
original = (
"\t\tcase USER_DIRECTORY_APPSUPPORT:\n"
"\t\t\tnsdir = NSApplicationSupportDirectory;\n"
"\t\t\tbreak;"
)
replacement = (
"\t\tcase USER_DIRECTORY_APPSUPPORT:\n"
"#ifdef LOVE_IOS\n"
"\t\t\tnsdir = NSDocumentDirectory;\n"
"#else\n"
"\t\t\tnsdir = NSApplicationSupportDirectory;\n"
"#endif\n"
"\t\t\tbreak;"
)
if original not in text:
fail(f"iOS app-support path anchor not found in {APPLE_MM}")
APPLE_MM.write_text(text.replace(original, replacement, 1))
filesystem_text = pristine(
FILESYSTEM_CPP,
("#ifdef LOVE_IOS\n"
"\t\t\tsuffix.clear();\n"
"#else\n",),
)
filesystem_original = (
"\t\tstd::string suffix;\n"
"\t\tif (isFused())\n"
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR) + saveIdentity;\n"
"\t\telse\n"
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR LOVE_APPDATA_FOLDER LOVE_PATH_SEPARATOR) + saveIdentity;"
)
filesystem_replacement = (
"\t\tstd::string suffix;\n"
"#ifdef LOVE_IOS\n"
"\t\t\tsuffix.clear();\n"
"#else\n"
"\t\tif (isFused())\n"
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR) + saveIdentity;\n"
"\t\telse\n"
"\t\t\tsuffix = std::string(LOVE_PATH_SEPARATOR LOVE_APPDATA_FOLDER LOVE_PATH_SEPARATOR) + saveIdentity;\n"
"#endif"
)
if filesystem_original not in filesystem_text:
fail(f"iOS save directory suffix anchor not found in {FILESYSTEM_CPP}")
FILESYSTEM_CPP.write_text(filesystem_text.replace(filesystem_original,
filesystem_replacement, 1))
print("patch_love_src: iOS save directory routed to Documents root")
def patch_pbxproj():
text = pristine(PBXPROJ)
@@ -362,6 +421,7 @@ def main():
if not LOVE_SRC.is_dir():
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
copy_native_files()
patch_public_documents()
patch_wrap_system()
patch_pbxproj()
+33
View File
@@ -217,6 +217,14 @@ apply_ios_branding() {
cp "$OVERLAY_PLIST" "$dest"
}
verify_documents_overlay() {
local sharing in_place
sharing="$(/usr/libexec/PlistBuddy -c 'Print :UIFileSharingEnabled' "$OVERLAY_PLIST" 2>/dev/null || true)"
in_place="$(/usr/libexec/PlistBuddy -c 'Print :LSSupportsOpeningDocumentsInPlace' "$OVERLAY_PLIST" 2>/dev/null || true)"
[ "$sharing" = "true" ] && [ "$in_place" = "true" ] \
|| fail "iOS plist overlay must enable UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace"
}
apply_ios_icon() {
local source="$ROOT/assets/logo/gen1recomp_cover.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
@@ -578,6 +586,25 @@ verify_native_bridge() {
say "native bridge present (pickFile, createFile)"
}
verify_documents_configuration() {
local app="$1"
local plist="$app/Info.plist"
local sharing in_place
[ -f "$plist" ] || fail "built iOS app is missing Info.plist: $plist"
sharing="$(/usr/libexec/PlistBuddy -c 'Print :UIFileSharingEnabled' "$plist" 2>/dev/null || true)"
in_place="$(/usr/libexec/PlistBuddy -c 'Print :LSSupportsOpeningDocumentsInPlace' "$plist" 2>/dev/null || true)"
[ "$sharing" = "true" ] && [ "$in_place" = "true" ] \
|| fail "built iOS app does not expose its Documents folder in $(basename "$app")"
say "public Documents exposure present (file sharing + in-place access)"
}
verify_game_payload() {
local app="$1"
[ -s "$app/game.love" ] \
|| fail "built iOS app is missing game.love: $app"
say "game.love present ($(du -h "$app/game.love" | cut -f1))"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -618,6 +645,8 @@ run_xcodebuild() {
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
MARKETING_VERSION="$marketing_version"
CURRENT_PROJECT_VERSION="$project_version"
INFOPLIST_KEY_UIFileSharingEnabled=YES
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace=YES
ONLY_ACTIVE_ARCH=NO
DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES
)
@@ -693,6 +722,8 @@ run_xcodebuild() {
fi
fi
verify_documents_configuration "$app"
# Fuse even if the pbxproj wire-up failed, LÖVE runs any bundled *.love.
# Byte-compare, never just existence: xcodebuild's incremental Copy Bundle
# Resources can leave a previous build's game.love in a surviving .app, and
@@ -703,6 +734,7 @@ run_xcodebuild() {
cp "$LOVE_FILE" "$app/game.love"
fi
verify_game_payload "$app"
verify_native_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
@@ -774,6 +806,7 @@ install_to_device() {
# --------------------------------------------------------------- main
apply_ios_branding
verify_documents_overlay
apply_ios_icon
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
+9 -2
View File
@@ -145,6 +145,13 @@ function BattleState:statusHUDVisible()
self) ~= false
end
function BattleState:moveGridNavigation()
if self:wideLayout() then return true end
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
return Runtime.call("battle.move_grid_navigation", function() return false end,
self) == true
end
local Rulesets = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.modern_clean"),
@@ -1994,7 +2001,7 @@ function BattleState:update(dt)
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
-- four directions navigate it; nil means no direction was pressed and
-- A / B / SELECT below behave the same in either layout.
local grid = self:wideLayout()
local grid = self:moveGridNavigation()
and WideBattle.navigate(self.moveIndex, #moves, input)
if grid then
self.moveIndex = grid
@@ -2044,7 +2051,7 @@ function BattleState:update(dt)
local moves = self.mimicMoves
-- the copy menu shares the widescreen move grid, so it navigates the
-- same way there (the classic layout keeps the vertical list)
local grid = self:wideLayout()
local grid = self:moveGridNavigation()
and WideBattle.navigate(self.mimicIndex, #moves, input)
if grid then
self.mimicIndex = grid
+14 -4
View File
@@ -661,9 +661,8 @@ end
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
-- script runner is present; mods that pre-set gift.nickname skip it.
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
-- skipNickname suppresses the AskName prompt: Yellow's lab Pikachu is
-- added straight through AddPartyMon (pokeyellow scripts/OaksLab.asm
-- OaksLabPlayerReceivedMonText) -- the starter Pikachu keeps its name.
-- skipNickname suppresses AskName for callers that name the gift themselves;
-- no vanilla script uses it (pokeyellow scripts/OaksLab.asm, #1013)
function Commands.give_pokemon(ctx, species, level, skipNickname)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
@@ -1097,6 +1096,17 @@ function Commands.march_in_place(ctx, objIndex, on)
ow.marchers[npc] = on and true or nil
end
-- pikachu_make_way: callfar OaksLabPikachuMovementScript (pokeyellow
-- scripts/OaksLab_2.asm); a no-op without a Yellow follower (#1021)
function Commands.pikachu_make_way(ctx)
local ow = ctx.overworld
if not ow then return end
local runner = ctx.runner
local started = require("src.world.PikachuFollower")
.oaksLabMakeWay(ctx.game, ow, function() runner:resume() end)
if started then runner:yield() end
end
-- play_music <songId> [opts]: switch map music now; opts.keep marks it
-- to survive the next warp (the story files' keepMusic idiom).
-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm
@@ -1359,7 +1369,7 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "wait",
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera", "play_once" }) do
"emote", "fade", "pan_camera", "play_once", "pikachu_make_way" }) do
local meta = Commands.meta[verb] or {}
Commands.meta[verb] = meta
meta.blocking = true
+2
View File
@@ -207,6 +207,8 @@ local function changeBox(game)
if not yes then return end
game.save.currentBox = item.value
if game.writeSave then game:writeSave() end
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
require("src.core.Sound").play(game.data, "Save")
list:close()
end,
}))
+8 -6
View File
@@ -2,8 +2,8 @@
-- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text.
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
-- joypad during the flash: holding B aborts the evolution -- the mon keeps
-- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
-- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm
-- routes past the poll entirely (wLinkState == LINK_STATE_TRADING, #213),
-- and stone evolutions, where the B press is read but thrown away because
@@ -47,6 +47,8 @@ function EvolutionState:sgbPalettes(game)
end
local FLASH_FRAMES = 220
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
local CANCEL_GRACE_FRAMES = 80
local function frontSprite(game, species, mon)
local path, trueColor = require("src.pokemon.Sprites").path(
@@ -83,10 +85,10 @@ function EvolutionState:update(dt)
self.t = self.t + 1
if self.done then return end
local game = self.game
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
-- a cancelable evolution, aborts when B is held -- the mon keeps its
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
if self.cancelable and game.input:isDown("b") then
-- evolution.asm Evolution_CheckForCancel reads hJoy5, a fresh edge rather
-- than a hold, so B held from the level-up box must not cancel (#968, #1031)
if self.cancelable and self.t > CANCEL_GRACE_FRAMES
and game.input:wasPressed("b") then
self.done = true
self.canceled = true
local TextBox = require("src.render.TextBox")
+36 -1
View File
@@ -107,6 +107,23 @@ PartyMenu.iconFrames = {
PIKACHU = { rest = 0, alt = 3 }, -- Yellow: PikachuSprite tile 0 <-> 12
}
local function gridIndex(index, count, direction)
if count < 1 then return nil end
local row, col = math.floor((index - 1) / 2), (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local step = direction == "up" and -1 or direction == "down" and 1
if not step then return nil end
local rows = math.ceil(count / 2)
for offset = 1, rows do
local other = ((row + step * offset) % rows) * 2 + col + 1
if other <= count then return other end
end
return index
end
-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel
-- height) only matters for the fallback, which keeps the old uniform
-- behavior for icons outside the table (BALL/HELIX y-bob instead).
@@ -307,6 +324,13 @@ function PartyMenu:close()
if self.game.stack:top() == self then self.game.stack:pop() end
end
function PartyMenu:gridNavigation()
if not self.battle
or not Runtime.wantsHook("ui.party.grid_navigation") then return false end
return Runtime.call("ui.party.grid_navigation", function() return false end,
self) == true
end
function PartyMenu:update(dt)
-- icon animation counter; 320 = a whole cycle at every HP speed
self.blink = ((self.blink or 0) + 1) % 320
@@ -531,7 +555,18 @@ function PartyMenu:update(dt)
return
end
if input:wasPressed("up") then
local grid
if self:gridNavigation() then
local direction = input:wasPressed("left") and "left"
or input:wasPressed("right") and "right"
or input:wasPressed("up") and "up"
or input:wasPressed("down") and "down"
grid = gridIndex(self.index, #party, direction)
end
if grid then
self.index = grid
self.game.partyMenuSavedIndex = self.index
elseif input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or math.max(1, #party)
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
elseif input:wasPressed("down") then
+9 -3
View File
@@ -164,8 +164,14 @@ local function toss(game)
}))
end
function PlayerPC.new(game)
-- opts.direct marks the bedroom PC (OpenRedsPC), the one entry outside the main menu
function PlayerPC.new(game, opts)
game.save.pcItems = game.save.pcItems or {}
-- ExitPlayerPC (players_pc.asm) rings SFX_TURN_OFF_PC only while
-- BIT_USING_GENERIC_PC is clear (#960)
local logOff = function()
if opts and opts.direct then Sound.play(game.data, "Turn_Off_PC") end
end
return Menu.new(game, {
-- keepOpen so B in the item lists returns here instead of dropping the
-- whole PC session (players_pc.asm re-shows the PC menu); same pattern
@@ -173,10 +179,10 @@ function PlayerPC.new(game)
{ label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end },
{ label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end },
{ label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end },
{ label = Strings("LOG OFF") },
{ label = Strings("LOG OFF"), onSelect = logOff },
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true, onCancel = logOff })
end
return PlayerPC
+7 -2
View File
@@ -52,8 +52,13 @@ end
-- falls back to require.
function Check.parseRelease(jsonText, Json)
Json = Json or require("src.link.Json")
local doc = Json.decode(jsonText)
if type(doc) ~= "table" or not doc.tag_name then
local notJson = Json.describeUnexpected(jsonText)
if notJson then return nil, notJson end
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "no tag_name in release json"
end
if not doc.tag_name then
return nil, "no tag_name in release json"
end
local version = stripV(doc.tag_name)
+33 -9
View File
@@ -1230,7 +1230,7 @@ function OverworldState:handleInput()
if self:checkLedgeHop(dir) then return end
if self:checkBoulderPush(dir) then return end
end
local result, why = self.player:tryMove(dir, self.map, self.entities)
local result = self.player:tryMove(dir, self.map, self.entities)
-- a collision while standing on a warp square fires the warp when the
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
@@ -1243,7 +1243,9 @@ function OverworldState:handleInput()
return result
end
end
if result == "blocked" and why ~= "entity" then
-- CollisionCheckOnLand (home/overworld.asm): a sprite takes the same
-- .collision branch as an impassable tile (#960)
if result == "blocked" then
if (self.bumpCooldown or 0) <= 0 then
require("src.core.Sound").play(Game.data, "Collision")
self.bumpCooldown = 16
@@ -2039,7 +2041,8 @@ function OverworldState:tryHiddenObject(fx, fy)
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
require("src.core.Sound").play(Game.data, "Turn_On_PC")
Screens.push(Game, "PlayerPC")
-- direct access: ExitPlayerPC rings SFX_TURN_OFF_PC (players_pc.asm, #960)
Screens.push(Game, "PlayerPC", { direct = true })
else
self:openPC()
end
@@ -2745,6 +2748,8 @@ function OverworldState:openPC(onDone)
label = (Game.save.player.name or "RED") .. "'s PC",
keepOpen = true,
onSelect = function()
-- pc.asm .playersPC plays SFX_ENTER_PC before the farcall (#960)
require("src.core.Sound").play(Game.data, "Enter_PC")
Screens.push(Game, "PlayerPC")
done()
end,
@@ -2756,6 +2761,8 @@ function OverworldState:openPC(onDone)
label = Strings("PROF.OAK's PC"),
keepOpen = true,
onSelect = function()
-- pc.asm OaksPC plays SFX_ENTER_PC before the farcall (#960)
require("src.core.Sound").play(Game.data, "Enter_PC")
self:openOaksPC(done)
end,
})
@@ -2939,17 +2946,29 @@ function OverworldState:nurseHeal(onDone, npc)
-- line: it comes back on the counter facing the player
Follower.setVisible(self, true)
if npc then npc:facePlayer(self.player) end
self:finishNurseHeal(bye, onDone)
self:finishNurseHeal(bye, onDone, npc)
end
end))
end)
end }))
end
function OverworldState:finishNurseHeal(bye, onDone)
-- pokecenter.asm bows the nurse between the two PrintText calls (#995)
function OverworldState:finishNurseHeal(bye, onDone, npc)
local t = Game.data.text
local fit = t._PokemonFightingFitText or Strings("Your POKéMON are\nfighting fit!")
Game.stack:push(TextBox.new(Game, fit .. "\f" .. bye, onDone))
Game.stack:push(TextBox.new(Game, fit, function()
local function farewell()
Game.stack:push(TextBox.new(Game, bye, function()
if npc then npc:facePlayer(self.player) end
if onDone then onDone() end
end))
end
if not npc then farewell() return end
npc.facing = "up"
-- bubble = false is the silent world hold, this port's DelayFrames
self.emote = { npc = npc, frames = 20, bubble = false, onDone = farewell }
end))
end
-- The Cable Club link receptionist (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST ->
@@ -4215,6 +4234,14 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.doorWarp = nil
local arriveWarp = self.arriveWarp
self.arriveWarp = nil
-- PlayMapChangeSound (home/overworld.asm) plays before the tail-called
-- GBFadeOutToBlack, so the SFX starts with the fade (#961)
if doorWarp then
local dest = Game.data.maps[mapId]
local outdoor = dest and Map.isOutdoor(dest)
require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside")
end
Game.stack:push(Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts)
-- the departure-side hide from flyAnim/teleportOut ends here, on the new
@@ -4251,9 +4278,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.player.spinDrop = true
end
if doorWarp then
local outdoor = Map.isOutdoor(self.map.def)
require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside")
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
-- warp that lands on a door tile auto-steps south once, indoor or
-- outdoor. Auto-walk leaves the mat, so the arrival disable
+29
View File
@@ -122,6 +122,13 @@ local function shouldSpawn(game, ow)
if not GameVersion.isYellow() then return false end
local save = game.save
if not (save.flags and save.flags.EVENT_GOT_STARTER) then return false end
-- save.pikachuInBall mirrors DisablePikachuOverworldSpriteDrawing (pokeyellow
-- scripts/OaksLab.asm); nil falls back to the rival-fight flag (#1009)
if save.pikachuInBall == nil then
if not save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then return false end
elseif save.pikachuInBall then
return false
end
if save.onBike or (ow.player and ow.player.surfing) then return false end
if not (game.data.sprites and game.data.sprites.SPRITE_PIKACHU) then
return false
@@ -885,6 +892,28 @@ function PikachuFollower.onBillExitedMachine(game, ow)
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
end
-- OaksLabPikachuMovementScript (pokeyellow scripts/OaksLab_2.asm): the
-- companion clears the cell the rival stops on (#1021)
function PikachuFollower.oaksLabMakeWay(game, ow, done)
if not GameVersion.isYellow() then return false end
local npc = findFollower(ow)
if not npc or not ow.player then return false end
local p = ow.player
local steps, facing
if p.cellY == 3 then -- .movement2, b = SPRITE_FACING_LEFT
if not (npc.cellY == p.cellY and npc.cellX < p.cellX) then return false end
steps, facing = { { "down", 1 }, { "right", 1 } }, "up"
else -- OaksLabPikachuMovementData1, b = SPRITE_FACING_DOWN
if npc.cellY <= p.cellY then return false end
steps, facing = { { "left", 1 }, { "up", 1 } }, "right"
end
movePikachu(ow, npc, steps, function()
npc.facing = facing -- PIKAMOVEMENT_LOOK_UP / _LOOK_RIGHT ends each table
if done then done() end
end)
return true
end
-- ---------------------------------------------------------------------
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
-- engine/events/pokecenter.asm once the heal is accepted): the companion
@@ -248,7 +248,7 @@ return function(game)
-- ---- case 2: B-cancel settles on the OLD form's colours -----------------
local karp = startEvo("MAGIKARP", "GYARADOS")
U.wait(24)
U.wait(100) -- past the 80-frame pre-animLoop delay before the poll starts
U.hold(game, "b", 20) -- evolution.asm Evolution_CheckForCancel
if not waitFor(function() return findText("stopped evolving") ~= nil end, 240) then
check(false, "holding B prints \"stopped evolving\"")
@@ -1,13 +1,14 @@
-- Driver: cancel an evolution with the B button (#213).
--
-- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic
-- flash: holding B aborts the evolution (the mon keeps its species and
-- pokered engine/movie/evolution.asm polls hJoy5 during the pic flash: a
-- fresh B press aborts the evolution (the mon keeps its species and
-- _StoppedEvolvingText prints). Trade evolutions (wLinkState ==
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
--
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
-- assert the mon stays CATERPIE with "stopped evolving" text on screen.
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220),
-- press B, and assert the mon stays CATERPIE with the "stopped evolving"
-- text on screen.
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
-- a subsequent level-up set must offer again (EvolveAfterBattle parity).
-- Case 2 (control): let the flash run to completion with no input and
@@ -80,11 +81,11 @@ return function(game)
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
U.wait(20) -- into the flash, well under FLASH_FRAMES=220
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220
U.log("case1 flash", "t=", top().t, "species=", mon.species)
U.shot(game, DIR .. "/evo213_1_evolving.png")
U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel
U.hold(game, "b", 20) -- Gen1 hJoy5 B-cancel
-- the flash aborts: EvolutionState is no longer the top (the stopped
-- text overlays it and then pops it)
@@ -118,7 +119,7 @@ return function(game)
if not waitFor(evoTop, 300) then
error("EvolutionState never opened after level-up re-offer")
end
U.wait(20)
U.wait(100) -- past the same 80-frame pre-animLoop delay
U.hold(game, "b", 20) -- cancel so case 2 stays independent
if not waitFor(function() return not evoTop() end, 240) then
error("level-up re-offer did not abort on B")
@@ -0,0 +1,484 @@
-- Ear check for the PC, bump, door/stairs and battle menu SFX (#960, #961, #1044, #1045).
-- POKEPORT_DRIVER=tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua POKEPORT_IDENTITY=sfx960 POKEPORT_TOUCH=0 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Boxes = require("src.pokemon.Boxes")
local Menu = require("src.ui.Menu")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local FADE = Timing.WARP_FADE_OUT
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- every cue this run makes, forwarded so playback is untouched; the frame
local cues = {}
local realPlay = Sound.play
Sound.play = function(data, name)
cues[#cues + 1] = { name = name, frame = U.frame() }
return realPlay(data, name)
end
local function since(mark)
local names = {}
for i = mark + 1, #cues do names[#names + 1] = cues[i].name end
return #names > 0 and table.concat(names, ", ") or "nothing"
end
local function heard(mark, want)
for i = mark + 1, #cues do
if cues[i].name == want then return cues[i] end
end
return nil
end
local function countOf(mark, want)
local n = 0
for i = mark + 1, #cues do if cues[i].name == want then n = n + 1 end end
return n
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local sfxVol, musicVol = opts.sfxVol or 0, opts.musicVol or 0
if sfxVol == 0 then
U.log("FAIL SFX volume is 0. Every line below is about a sound that is or")
U.log(" is not there, and at 0 none of them are. Set SFX to 7 in OPTION")
U.log(" and start over, or this run proves nothing at all.")
end
check(("sfx volume %d/7, music volume %d/7"):format(sfxVol, musicVol),
sfxVol > 0)
-- an unresolved key is silent in exactly the way these bugs were
local sfx = (game.data.audio or {}).sfx or {}
for _, key in ipairs({ "Turn_On_PC", "Turn_Off_PC", "Enter_PC", "Collision",
"Save", "Go_Inside", "Go_Outside", "Press_AB" }) do
check("sfx " .. key .. " is in the generated audio", sfx[key] ~= nil)
end
-- positions come from data/events/hidden_events.asm and data/maps/objects/*.asm
local extras = game.data.field.hiddenExtras or {}
local pcTiles = extras.pcTiles or {}
local bedroomPC = (pcTiles.REDS_HOUSE_2F or {})[1]
local centerPC = (pcTiles.VIRIDIAN_POKECENTER or {})[1]
check("REDS_HOUSE_2F carries the OpenRedsPC tile", bedroomPC ~= nil)
check("VIRIDIAN_POKECENTER carries a PC tile", centerPC ~= nil)
local function warpTo(mapId, destMap)
for _, w in ipairs((game.data.maps[mapId] or {}).warps or {}) do
if w.destMap == destMap then return w end
end
return nil
end
local houseDoor = warpTo("PALLET_TOWN", "REDS_HOUSE_1F")
local houseExit = warpTo("REDS_HOUSE_1F", "LAST_MAP")
local houseStairs = warpTo("REDS_HOUSE_1F", "REDS_HOUSE_2F")
local centerDoor = warpTo("VIRIDIAN_CITY", "VIRIDIAN_POKECENTER")
check("PALLET_TOWN has the door into REDS_HOUSE_1F", houseDoor ~= nil)
check("REDS_HOUSE_1F has an exit mat and the stairs up",
houseExit ~= nil and houseStairs ~= nil)
check("VIRIDIAN_CITY has the door into its POKéMON CENTER", centerDoor ~= nil)
-- ---- helpers ------------------------------------------------------------
local DIRS = { up = { 0, -1 }, down = { 0, 1 },
left = { -1, 0 }, right = { 1, 0 } }
local ORDER = { "down", "up", "left", "right" }
local function pressStep(dir)
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
U.wait(1)
end
local function stand(mapId, x, y, facing)
U.teleport(game, mapId, x, y, facing)
U.wait(15)
return game.overworld
end
-- the cell you stand on to face (cx, cy) from `dir`, i.e. one step back
local function cellBehind(cx, cy, dir)
local d = DIRS[dir]
return cx - d[1], cy - d[2]
end
-- first walkable free neighbour of (cx, cy), plus the facing that looks at
local function approach(ow, cx, cy)
for _, dir in ipairs(ORDER) do
local sx, sy = cellBehind(cx, cy, dir)
if ow.map:isWalkableCell(sx, sy) and not ow:npcAtCell(sx, sy) then
return sx, sy, dir
end
end
end
-- any free walkable cell on the map with a solid one next to it, so the
local function findWall(ow)
local map = ow.map
for cy = 0, map.heightCells - 1 do
for cx = 0, map.widthCells - 1 do
if map:isWalkableCell(cx, cy) and not map:warpAtCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
for _, d in ipairs(ORDER) do
local dd = DIRS[d]
if not map:isWalkableCell(cx + dd[1], cy + dd[2]) then
return cx, cy, d
end
end
end
end
end
end
local function npcNamed(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
end
-- PlayMapChangeSound (home/overworld.asm:690) plays before GBFadeOutToBlack,
local function takeDoor(dir, want, label)
local mark = #cues
local from = game.overworld.map.id
local cue, switched
for _ = 1, 300 do
if cue or switched then
game.input.state[dir] = false
U.wait(1)
else
pressStep(dir)
end
cue = cue or heard(mark, want)
local ow = game.overworld
if not switched and ow and ow.map.id ~= from then switched = U.frame() end
if cue and switched then break end
end
game.input.state[dir] = false
U.wait(50) -- PlayerStepOutFromDoor walks off the mat before anything else
if not (cue and switched) then
check(("%s: %s played and the map changed"):format(label, want), false)
U.log(" cues on the way through:", since(mark))
return
end
check(("%s: %s fired %d frames before the map switched (the fade is %d)")
:format(label, want, switched - cue.frame, FADE),
switched - cue.frame >= FADE - 8)
end
-- CollisionCheckOnLand (home/overworld.asm): a sprite in the way takes the
local function bumpInto(dir, label)
local mark = #cues
for _ = 1, 10 do pressStep(dir) end
game.input.state[dir] = false
U.wait(24) -- past the 16-frame bumpCooldown, so the next bump is its own
check(label .. " rings Collision", heard(mark, "Collision") ~= nil)
end
local function topIs(class)
local top = game.stack:top()
return top ~= nil and getmetatable(top) == class
end
local function rowIndex(menu, label)
for i, item in ipairs(menu.items or {}) do
if item.label == label then return i end
end
end
-- rows come and go with save state and the ui.pc.items hook, so pick each
local function choose(menu, label)
local i = rowIndex(menu, label)
if not i then
check("the menu has a " .. label .. " row", false)
return false
end
menu.index = i
menu:clampScroll()
U.wait(2)
U.tap(game, "a")
U.wait(26)
return true
end
local function mash(cond, tries)
for _ = 1, tries or 40 do
if cond() then return true end
U.tap(game, "a")
U.wait(6)
end
return cond()
end
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 20) }
game.save.party[1].moves = {
{ id = "TACKLE", pp = 35, maxPP = 35 },
{ id = "VINE_WHIP", pp = 10, maxPP = 10 },
}
Boxes.ensure(game.save)
game.save.currentBox = 1
-- ---- the door into the house, from outside (#961) -----------------------
if houseDoor then
local sx, sy = cellBehind(houseDoor.x, houseDoor.y, "up")
stand("PALLET_TOWN", sx, sy, "up")
takeDoor("up", "Go_Inside", "walking into RED's house")
end
-- walking into MOM, and into a wall (#960); teleport is only a fallback
local ow = game.overworld
if ow.map.id ~= "REDS_HOUSE_1F" and houseExit then
ow = stand("REDS_HOUSE_1F", houseExit.x, houseExit.y, "up")
end
local mom = npcNamed(ow, "REDSHOUSE1F_MOM")
check("MOM is loaded on REDS_HOUSE_1F", mom ~= nil)
if mom then
local sx, sy, dir = approach(ow, mom.cellX, mom.cellY)
if sx then
ow = stand("REDS_HOUSE_1F", sx, sy, dir)
-- the teleport rebuilt the npc list, so pin her on the state we bump
local pinned = npcNamed(ow, "REDSHOUSE1F_MOM")
if pinned then pinned.frozen = true end
U.shot(game, DIR .. "/bug960_mom.png")
bumpInto(dir, "walking into MOM")
else
check("MOM has a free cell to be walked into from", false)
end
-- the control: a wall bump was audible before #960 too, so silence here
local wx, wy, wdir = findWall(game.overworld)
if wdir then
ow = stand("REDS_HOUSE_1F", wx, wy, wdir)
bumpInto(wdir, "walking into the wall to the " .. wdir)
else
check("REDS_HOUSE_1F has a wall to bump into", false)
end
end
-- ---- the stairs up (#961) -----------------------------------------------
if houseStairs then
local sx, sy = cellBehind(houseStairs.x, houseStairs.y, "up")
stand("REDS_HOUSE_1F", sx, sy, "up")
-- the destination decides the cue here, not the tile underfoot, so an
takeDoor("up", "Go_Inside", "taking the stairs up")
end
-- ---- the bedroom PC (#960) ---------------------------------------------
if bedroomPC then
-- OpenRedsPC's hidden_event is gated on SPRITE_FACING_UP, so the cell
local sx, sy = cellBehind(bedroomPC.x, bedroomPC.y, "up")
ow = stand("REDS_HOUSE_2F", sx, sy, "up")
check("the cell below the bedroom PC is walkable",
ow.map:isWalkableCell(sx, sy))
local mark = #cues
U.tap(game, "a")
U.wait(26)
check("A on the bedroom PC opens a menu", topIs(Menu))
check("...and turns it on with Turn_On_PC", heard(mark, "Turn_On_PC") ~= nil)
U.shot(game, DIR .. "/bug960_bedroom_pc.png")
if topIs(Menu) then
mark = #cues
choose(game.stack:top(), Strings("LOG OFF"))
check("LOG OFF on the bedroom PC rings Turn_Off_PC (#960)",
heard(mark, "Turn_Off_PC") ~= nil)
U.log(" cues:", since(mark))
check("...and the PC closed", game.stack:top() == game.overworld)
end
-- B out of the same menu is ExitPlayerPC's other entry and rings it too
U.tap(game, "a")
U.wait(26)
if topIs(Menu) then
local mark2 = #cues
U.tap(game, "b")
U.wait(26)
check("backing out of the bedroom PC with B rings it as well",
heard(mark2, "Turn_Off_PC") ~= nil)
end
end
-- ---- back out of the house, to the street (#961) ------------------------
if houseExit then
local sx, sy = cellBehind(houseExit.x, houseExit.y, "down")
ow = stand("REDS_HOUSE_1F", sx, sy, "down")
if not ow.map:isWalkableCell(sx, sy) then
U.log(("(%d, %d) is blocked; using the other half of the mat")
:format(sx, sy))
ow = stand("REDS_HOUSE_1F", sx + 1, sy, "down")
end
takeDoor("down", "Go_Outside", "stepping out onto the street")
end
-- ---- into the POKéMON CENTER, for the PC main menu -----------------------
if centerDoor then
local sx, sy = cellBehind(centerDoor.x, centerDoor.y, "up")
stand("VIRIDIAN_CITY", sx, sy, "up")
takeDoor("up", "Go_Inside", "walking into the POKéMON CENTER")
end
-- ---- the PC main menu: Enter_PC, and the silence under it (#960) --------
if centerPC then
local sx, sy = cellBehind(centerPC.x, centerPC.y, "up")
ow = stand("VIRIDIAN_POKECENTER", sx, sy, "up")
for _, n in ipairs(ow.npcs or {}) do n.frozen = true end -- the GENTLEMAN walks
local mark = #cues
U.tap(game, "a")
U.wait(26)
check("A on the Center PC opens the PC main menu", topIs(Menu))
check("...with Turn_On_PC", heard(mark, "Turn_On_PC") ~= nil)
local mine = (game.save.player.name or "RED") .. "'s PC"
if topIs(Menu) then
mark = #cues
choose(game.stack:top(), mine)
check(mine .. " rings Enter_PC (#960)", heard(mark, "Enter_PC") ~= nil)
check("...and the item PC opened", topIs(Menu))
-- BIT_USING_GENERIC_PC: reached this way, ExitPlayerPC is silent and
mark = #cues
U.tap(game, "b")
U.wait(26)
check("backing out of it again is silent, as the ROM is",
heard(mark, "Turn_Off_PC") == nil)
U.log(" cues:", since(mark))
end
-- ---- CHANGE BOX (#1044) ----------------------------------------------
-- the box PC reads SOMEONE'S PC until EVENT_MET_BILL (pokemon_pc.asm)
local flags = game.save.flags or {}
local boxPC = (flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET)
and "BILL'S PC" or Strings("SOMEONE'S PC")
if topIs(Menu) then
mark = #cues
choose(game.stack:top(), boxPC)
check(boxPC .. " rings Enter_PC too", heard(mark, "Enter_PC") ~= nil)
check("...and the box menu opened", topIs(Menu))
end
if topIs(Menu) and rowIndex(game.stack:top(), Strings("CHANGE BOX")) then
choose(game.stack:top(), Strings("CHANGE BOX"))
U.shot(game, DIR .. "/bug1044_change_box.png")
local before = game.save.currentBox
U.tap(game, "down") -- BOX 1 is the current one, so move off it
U.wait(8)
mark = #cues
-- A picks the box, then the "data will be saved" prompt and its YES
U.tap(game, "a")
U.wait(20)
mash(function() return game.save.currentBox ~= before end, 40)
U.wait(60) -- the 15-frame answer hold, then the write
check(("CHANGE BOX switched box %s -> %s")
:format(tostring(before), tostring(game.save.currentBox)),
game.save.currentBox ~= before)
check("...and rang the SAVE jingle (#1044)", heard(mark, "Save") ~= nil)
U.log(" cues:", since(mark))
end
-- ---- LOG OFF from the main menu --------------------------------------
for _ = 1, 6 do
if game.stack:top() == game.overworld then break end
U.tap(game, "b")
U.wait(20)
end
U.tap(game, "a")
U.wait(26)
if topIs(Menu) and rowIndex(game.stack:top(), Strings("LOG OFF")) then
local mark2 = #cues
choose(game.stack:top(), Strings("LOG OFF"))
check("LOG OFF on the PC main menu rings Turn_Off_PC",
heard(mark2, "Turn_Off_PC") ~= nil)
end
for _ = 1, 8 do
if game.stack:top() == game.overworld then break end
U.tap(game, "b")
U.wait(20)
end
end
-- the battle menu click (#1045) already landed in HEAD 12c2677; confirmation only
ow = stand("ROUTE_1", 5, 5, "down")
if not ow.map:isWalkableCell(5, 5) then
local fx, fy
for cy = 0, ow.map.heightCells - 1 do
for cx = 0, ow.map.widthCells - 1 do
if ow.map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
end
if fx then break end
end
if fx then ow = stand("ROUTE_1", fx, fy, "down") end
end
local battle = BattleState.newWild(game, "SNORLAX", 50)
battle.onFinish = function(result) ow:afterBattle(result, battle) end
-- SPLASH so the foe's turn can never end the run under the menu presses
battle.enemy.mon.moves = { { id = "SPLASH", pp = 40, maxPP = 40 } }
battle.enemy.curMoves = battle.enemy.mon.moves
ow:pushBattle(battle)
U.wait(220) -- the send-out intro runs before the menu is reachable
mash(function() return battle.phase == "menu" end, 60)
check("the wild SNORLAX battle reached its FIGHT menu",
battle.phase == "menu")
-- one press, then long enough for the click to be its own sound
local function click(btn, label, want)
local mark = #cues
U.tap(game, btn)
U.wait(28)
local n = countOf(mark, "Press_AB")
check(("%s: %s gave %d click%s, expected %d")
:format(label, btn:upper(), n, n == 1 and "" or "s", want),
n == want)
end
if battle.phase == "menu" then
click("a", "A on FIGHT (#1045a)", 1)
check("...and the move list opened", battle.phase == "moveSelect")
U.shot(game, DIR .. "/bug1045_move_list.png")
click("b", "B out of the move list (#1045c)", 1)
check("...and the FIGHT menu came back", battle.phase == "menu")
click("a", "A on FIGHT again", 1)
click("a", "A on a move (#1045b)", 1)
mash(function() return battle.phase == "menu" end, 120)
if battle.phase == "menu" then
battle.menuIndex = 4 -- RUN shares the FIGHT/PKMN/ITEM call site
U.wait(4)
click("a", "A on RUN", 1)
end
end
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- over to you --------------------------------------------------------
if centerPC then
local sx, sy = cellBehind(centerPC.x, centerPC.y, "up")
ow = stand("VIRIDIAN_POKECENTER", sx, sy, "up")
for _, n in ipairs(ow.npcs or {}) do n.frozen = true end
end
U.log("Everything above has been pressed once already; you are parked at the")
U.log("Viridian POKéMON CENTER PC to do it again by ear.")
U.log("A opens the PC main menu. RED's PC clicks in on the two-note ENTER PC")
U.log("chirp and LOG OFF closes with the descending power-down. B out of RED's")
U.log("PC is silent on purpose; a power-down there is the near miss, not a pass.")
U.log("SOMEONE'S PC, CHANGE BOX, any other box, YES: the SAVE jingle rings")
U.log("after the box has changed, the same jingle the SAVE menu plays. A")
U.log("jingle before the switch, or none at all, is #1044 back.")
U.log("The bedroom PC upstairs at home is the other half of #960: it beeps on,")
U.log("and LOG OFF or B rings the power-down there. That one is not silent.")
U.log("The COOLTRAINER at (4,3) and the NURSE at (3,1) are pinned; walk into")
U.log("either and it thuds like a wall. Walking into a wall is the control --")
U.log("it always thudded, so a silent wall means the device, not the fix.")
U.log(("Walk out over the exit mat at the bottom of the room: the door sound")
.. (" starts as the screen begins to darken, %d frames ahead of")
:format(FADE))
U.log("Viridian City. One that lands on the new map, or after it, is #961.")
U.log("In any battle, A on FIGHT/PKMN/ITEM/RUN, A on a move and B out of the")
U.log("move list all click. Silence on any of the three is #1045.")
U.log("Shots: " .. DIR .. "/bug960_*.png, bug1044_*.png, bug1045_*.png")
-- keeps naming cues with their frame after the hand-off, so a door sound
local reported = #cues
while true do
if #cues > reported then
for i = reported + 1, #cues do
U.log("cue", cues[i].name, "frame", cues[i].frame)
end
reported = #cues
end
coroutine.yield()
end
end
@@ -0,0 +1,410 @@
-- Pikachu stays in its ball until the rival fight (#1009) and clears (4,3) for the rival (#1021); the Route 15 leg only instruments #920.
-- pokeyellow scripts/OaksLab.asm, OaksLab_2.asm.
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local MapScripts = require("src.script.MapScripts")
local Commands = require("src.script.Commands")
local PF = require("src.world.PikachuFollower")
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
local BattleState = require("src.battle.BattleState")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local LAB = "OAKS_LAB"
local RIVAL = 1 -- OAKSLAB_RIVAL, object index 1
local GIFT = { x = 5, y = 3 } -- where OaksLabRLE_PlayerWalksToOak ends
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function idle()
while true do coroutine.yield() end
end
local function ow() return game.overworld end
local function follower() return PF.current(game.overworld) end
local function ctx()
return { game = game, save = game.save, overworld = game.overworld }
end
local function where(npc)
if not npc then return "gone" end
return "(" .. npc.cellX .. "," .. npc.cellY .. ") facing "
.. tostring(npc.facing)
end
-- one player step; false when refused, so a caller can route around a map edit
local function stepOnce(dir)
local p = ow().player
local x0, y0 = p.cellX, p.cellY
for _ = 1, 60 do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
p = ow().player
if p.cellX ~= x0 or p.cellY ~= y0 then break end
end
game.input.state[dir] = false
for _ = 1, 40 do
if not ow().player.moving then break end
U.wait(1)
end
U.wait(3)
p = ow().player
return p.cellX ~= x0 or p.cellY ~= y0
end
-- ---------------------------------------------------------------- checks
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
U.log("Red and Blue have no follower and no Yellow lab script, so every")
U.log("line below would fail for the wrong reason.")
idle()
end
check("SPRITE_PIKACHU resolves in the sprite table",
game.data.sprites ~= nil and game.data.sprites.SPRITE_PIKACHU ~= nil)
check("PikachuFollower.oaksLabMakeWay exists",
type(PF.oaksLabMakeWay) == "function")
check("the pikachu_make_way verb exists",
type(Commands.pikachu_make_way) == "function")
check("and it blocks the runner while the walk plays",
Commands.meta.pikachu_make_way ~= nil
and Commands.meta.pikachu_make_way.blocking == true)
local lab = require("data.scripts.oaks_lab_yellow")
local oakRows = lab.talk.TEXT_OAKSLAB_OAK1
local function rowIndex(pred)
for i, r in ipairs(oakRows) do
if pred(r) then return i end
end
return nil
end
local iGramps = rowIndex(function(r)
return r[1] == "show_text" and r[2] == "_OaksLabRivalGrampsText"
end)
local iMakeWay = rowIndex(function(r) return r[1] == "pikachu_make_way" end)
local iShow = rowIndex(function(r)
return r[1] == "show_object" and r[3] == "OAKSLAB_RIVAL"
end)
check("Oak's parcel branch has a make-way row", iMakeWay ~= nil)
-- the callfar sits after the GRAMPS text and before ShowObject; a row on
check("it sits between the GRAMPS text and the rival's ShowObject",
iGramps ~= nil and iMakeWay ~= nil and iShow ~= nil
and iGramps < iMakeWay and iMakeWay < iShow)
local sfx = game.save.options and game.save.options.sfxVol
if sfx == 0 then
U.log("sfxVol is 0. Pikachu's cry as it bursts out of the ball will be")
U.log("silent and a mute run reads exactly like a missing cry -- turn")
U.log("the sound back up before judging the escape scene.")
end
-- leg 1: in the ball. Level 30 only so the lab battle ends fast
game.save.flags = game.save.flags or {}
local flags = game.save.flags
flags.EVENT_GOT_STARTER = true
flags.EVENT_CHOSE_PIKACHU = true
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true
flags.EVENT_GOT_POKEDEX = nil
flags.EVENT_OAK_GOT_PARCEL = nil
game.save.party = { Pokemon.new(game.data, "PIKACHU", 30) }
game.save.player.name = "bryan"
game.save.onBike = false
game.save.pikachuInBall = true
U.teleport(game, LAB, GIFT.x, GIFT.y, "up")
U.wait(10)
Commands.show_object(ctx(), LAB, "OAKSLAB_OAK1")
U.wait(5)
-- asked after the teleport: the registry only fills on first require
check("the Yellow lab module is the one bound to the map",
MapScripts.talkScript(LAB, "TEXT_OAKSLAB_OAK1") == oakRows)
check("straight after the gift there is no follower on the map",
follower() == nil)
-- save compat: nil pikachuInBall falls back to the rival-fight flag, not false
game.save.pikachuInBall = nil
U.wait(10)
check("a pre-#1009 save before the rival fight still has none",
follower() == nil)
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
U.wait(20)
check("a pre-#1009 save past the rival fight keeps its follower",
follower() ~= nil)
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
game.save.pikachuInBall = true
U.wait(20)
check("and the in-ball byte puts it away again", follower() == nil)
U.shot(game, SHOT_DIR .. "/bug1009_in_ball.png")
U.log("captured", SHOT_DIR .. "/bug1009_in_ball.png",
"- player alone at the gift spot")
-- --------------------------------------------- leg 2: out of the ball
Commands.show_object(ctx(), LAB, "OAKSLAB_RIVAL")
U.wait(5)
-- OaksLabRivalChallengesPlayerScript fires from y >= 6 with the starter held
local walkedClean = true
for _ = 1, 10 do
if ow().player.cellY >= 6 then break end
if follower() then walkedClean = false end
if not stepOnce("down") then
if not stepOnce("left") then break end
end
end
check("crossed the lab to the door row with no follower behind",
walkedClean and follower() == nil)
check("reached the row the rival challenges from", ow().player.cellY >= 6)
-- the escape only spawns the companion once the overworld is back on top
local sawBattle, escapedAt, spawnCell = false, nil, nil
for _ = 1, 2500 do
local o = game.overworld
local top = game.stack:top()
if top ~= o then
if getmetatable(top) == BattleState then sawBattle = true end
U.tap(game, "a")
U.wait(3)
else
local npc = follower()
if npc and not escapedAt then
escapedAt = U.frame()
spawnCell = { x = npc.cellX, y = npc.cellY, facing = npc.facing }
U.shot(game, SHOT_DIR .. "/bug1009_escaped.png")
end
if escapedAt and not o.runner:isRunning() and #o.scriptMoves == 0 then
break
end
U.wait(2)
end
end
check("the rival battle actually ran", sawBattle)
check("the escape scene put a follower on the map", escapedAt ~= nil)
check("EVENT_BATTLED_RIVAL_IN_OAKS_LAB is set",
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB == true)
check("the ball is open (save.pikachuInBall false, not nil)",
game.save.pikachuInBall == false)
if spawnCell then
local p = ow().player
U.log("it appeared at", spawnCell.x, spawnCell.y, "with the player at",
p.cellX, p.cellY, "facing", p.facing)
-- OaksLabPikachuEscapesPokeballScript faces the player up and uses spawn
check("it burst out on the cell behind the player, not beside him",
spawnCell.x == p.cellX and spawnCell.y == p.cellY + 1)
U.log("captured", SHOT_DIR .. "/bug1009_escaped.png")
end
-- leg 3: Oak's .DeliverParcelText needs parcel held, no balls, no Pokedex
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS = nil
flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = nil
flags.EVENT_GOT_POKEDEX = nil
game.save.pikachuInBall = false
game.save.inventory = game.save.inventory or {}
game.save.inventory.POKE_BALL = nil
if game.save.pokedex then game.save.pokedex.owned = {} end
Bag.add(game.save, "OAKS_PARCEL", 1, game.data)
-- the SPRITE_FACING_LEFT case of TryApplyPikachuMovementData
U.teleport(game, LAB, 4, 3, "right")
U.wait(10)
Commands.show_object(ctx(), LAB, "OAKSLAB_OAK1")
-- he walked out after the lab battle; ShowObject brings him back mid-scene
Commands.hide_object(ctx(), LAB, "OAKSLAB_RIVAL")
U.wait(5)
check("the follower is back for the parcel scene", follower() ~= nil)
stepOnce("right")
-- Oak stands on (5,2) and blocks, so this only turns the player up
U.hold(game, "up", 10)
U.wait(10)
local p = ow().player
local pika = follower()
U.log("player at", p.cellX, p.cellY, "facing", p.facing,
"| Pikachu at", where(pika))
check("the player is below Oak on row 3", p.cellY == 3 and p.facing == "up")
local onRivalCell = pika ~= nil and pika.cellX == 4 and pika.cellY == 3
check("Pikachu is standing on the rival's landing cell (4,3)", onRivalCell)
if not onRivalCell then
U.log("Without it on (4,3) the movement data does not apply and the")
U.log("scene below proves nothing about #1021.")
end
U.shot(game, SHOT_DIR .. "/bug1021_before.png")
-- the rival's own walk sits in the same scriptMoves list, so ask only
local function stillWalking(o, npc)
if npc.moving then return true end
for _, mv in ipairs(o.scriptMoves) do
if mv.entity == npc then return true end
end
return false
end
-- end pose snapshotted at walk stop; the idle roll turns it later (Func_fc803)
U.tap(game, "a")
local leftCell, rivalSeen, rivalArrived, midShot = nil, nil, nil, false
local settled
local trace, last = {}, nil
for f = 1, 1200 do
local o = game.overworld
local top = game.stack:top()
local npc = follower()
if npc then
local cell = npc.cellX .. "," .. npc.cellY
if cell ~= last then
last = cell
trace[#trace + 1] = "f" .. f .. " " .. cell
end
if not leftCell and not (npc.cellX == 4 and npc.cellY == 3) then
leftCell = f
end
if leftCell and not settled and not stillWalking(o, npc) then
settled = { x = npc.cellX, y = npc.cellY, facing = npc.facing }
end
end
local rival = o:npcByIndex(RIVAL)
if rival and not rivalSeen then rivalSeen = f end
if rival and not rivalArrived and rival.cellX == 4 and rival.cellY == 3
and not rival.moving then
rivalArrived = f
end
if leftCell and rival and rival.moving and not midShot then
midShot = true
U.shot(game, SHOT_DIR .. "/bug1021_stepped_aside.png")
end
if rivalArrived and settled then break end
if top ~= o then U.tap(game, "a") end
U.wait(2)
end
U.log("Pikachu trace:", table.concat(trace, " | "))
check("Pikachu moved off (4,3)", leftCell ~= nil)
check("the rival came in and reached (4,3)", rivalArrived ~= nil)
if leftCell and rivalSeen then
-- the callfar runs before ShowObject: the cell is clear before the rival
check("it started clearing the cell before the rival appeared",
leftCell <= rivalSeen)
end
if settled then
U.log("the walk ended with Pikachu at", settled.x, settled.y,
"facing", settled.facing)
end
-- OaksLabPikachuMovementData2: STEP_DOWN, STEP_RIGHT, LOOK_UP
check("it ended one below the player looking up",
settled ~= nil and settled.x == 5 and settled.y == 4
and settled.facing == "up")
U.shot(game, SHOT_DIR .. "/bug1021_rival_in_place.png")
-- leg 4: nothing is fixed for #920; this only records ow.npcs and trail.ledgeHop
local eastMap = game.data.maps.FUCHSIA_CITY
and game.data.maps.FUCHSIA_CITY.connections
and game.data.maps.FUCHSIA_CITY.connections.east
eastMap = eastMap and eastMap.map or "ROUTE_15"
U.log("Fuchsia's east connection is", eastMap)
U.teleport(game, "FUCHSIA_CITY", 10, 12, "down")
U.wait(10)
local city = ow().map
local row = nil
for y = 0, city.heightCells - 1 do
if city:isWalkableCell(city.widthCells - 1, y)
and city:isWalkableCell(city.widthCells - 2, y) then
row = row or y
end
end
if row then
U.log("crossing the east seam on row", row)
U.teleport(game, "FUCHSIA_CITY", city.widthCells - 2, row, "right")
U.wait(10)
for _ = 1, 6 do
if ow().map.id == eastMap then break end
if not stepOnce("right") then break end
end
end
-- the seam row is blocked (gate rebuild, mod): drop in on the route itself
local function dropOnRoute()
U.teleport(game, eastMap, 4, 8, "right")
U.wait(10)
local m = ow().map
if m:isWalkableCell(4, 8) then return end
for y = 0, m.heightCells - 1 do
for x = 0, 9 do
if m:isWalkableCell(x, y) then
U.teleport(game, eastMap, x, y, "right")
U.wait(10)
return
end
end
end
end
if ow().map.id ~= eastMap then
U.log("could not walk the seam; dropping straight on to", eastMap)
dropOnRoute()
end
check("standing on " .. eastMap, ow().map.id == eastMap)
local worstGap, lostAt, steps = 0, nil, 0
local back = { x = ow().player.cellX, y = ow().player.cellY }
for i = 1, 10 do
if not stepOnce("right") then
if not stepOnce("down") then break end
end
local o = game.overworld
if o.map.id ~= eastMap then
-- the route's gate is a warp, and an arrival respawn is not the stall
U.log("step", i, "walked into", o.map.id, "- stepping back out")
U.teleport(game, eastMap, back.x, back.y, "left")
U.wait(10)
break
end
steps = i
back.x, back.y = o.player.cellX, o.player.cellY
local npc = follower()
local p2 = o.player
local hop = o.pikachuTrail and o.pikachuTrail.ledgeHop
if npc then
local gap = math.abs(npc.cellX - p2.cellX)
+ math.abs(npc.cellY - p2.cellY)
if gap > worstGap then worstGap = gap end
U.log("step", i, "player", p2.cellX, p2.cellY, "| Pikachu", where(npc),
"| gap", gap, "| ledgeHop", tostring(hop))
else
lostAt = lostAt or i
U.log("step", i, "player", p2.cellX, p2.cellY,
"| Pikachu is not in ow.npcs at all | ledgeHop", tostring(hop))
end
end
U.log("walked", steps, "steps east on", eastMap)
check("the follower stayed in ow.npcs the whole way", lostAt == nil)
check("it never fell more than two cells behind", worstGap <= 2)
if lostAt then
U.log("it dropped out of ow.npcs on step", lostAt,
"- that is the shape #920 would take")
end
-- an arrival parks the follower under the player (#863), so walk one step
stepOnce("left")
U.wait(20)
U.shot(game, SHOT_DIR .. "/bug920_east_of_fuchsia.png")
U.log("Six shots, in story order. bug1009_in_ball: the player alone in the")
U.log("lab with the starter already in the party. bug1009_escaped: the cry,")
U.log("then Pikachu on the cell behind him. bug1021_stepped_aside: it walks")
U.log("down and right on the GRAMPS text while the rival is still off the")
U.log("map, ending below the player looking up. The near miss to watch for")
U.log("is the rival arriving first and Pikachu shuffling around him after,")
U.log("or the walk playing under the box instead of after it.")
U.log("Talk to Pikachu here for the control case: it answers with a bubble")
U.log("and a cry, which is the same sprite and the same audio path.")
U.log("#920 is unfixed. The pad is yours on Route 15; the step log above is")
U.log("what the triage wants from a stall -- whether it is still in ow.npcs")
U.log("and whether trail.ledgeHop stayed set after a ledge.")
idle()
end
@@ -0,0 +1,153 @@
-- A level-up evolution survives the B held from the level-up box (#968, #1031); a fresh press still cancels (#290, #213).
-- pokered engine/movie/evolution.asm EvolveMon, Evolution_CheckForCancel.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- TextBox and EvolutionState both require Sound inside update, so seeding
package.loaded["src.core.Sound"] = {
play = function() end,
playCry = function() end,
}
local Fixtures = require("tests.modkit.fixtures")
local Evolution = require("src.pokemon.Evolution")
local EvolutionState = require("src.ui.EvolutionState")
local Input = require("src.core.Input")
local Pokemon = require("src.pokemon.Pokemon")
local StateStack = require("src.core.StateStack")
local TextBox = require("src.render.TextBox")
local Data = Fixtures.fresh()
require("src.render.Font").load(Data)
-- FIXMON_A evolves into FIXMON_B at 16 (tests/fixture_data/pokemon.lua)
local EVO_LEVEL = 16
-- x is the default keyboard B (src/core/Input.lua DEFAULT_BINDINGS)
local B_KEY = "x"
local function newGame()
local game = { data = Data }
local mon = Pokemon.new(Data, "FIXMON_A", EVO_LEVEL)
game.save = {
party = { mon },
player = { name = "RED", id = 1 },
options = { textSpeed = 5 },
flags = {},
pokedex = { seen = {}, owned = {} },
}
game.stack = setmetatable({}, { __index = StateStack })
game.stack:init()
game.input = Input
Input:init()
return game, mon
end
-- one fixed step, in Game:step's order: promote the queued edges, then
local function step(game)
game.input:step()
game.stack:update(1 / 60)
end
-- the post-battle sequence: grew-to-level box, then Evolution.checkParty
local function levelUpBox(game, mon)
game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!",
function() Evolution.checkParty(game, nil, { [mon] = true }) end))
end
-- one B edge on the box, still held when the movie takes over: the bug's handoff
local function dismissWithB(game, mon)
levelUpBox(game, mon)
local box = game.stack:top()
for _ = 1, 900 do
if box.done then break end
step(game)
end
if not box.done then return nil, "the level-up text never finished typing" end
Input:keypressed(B_KEY)
step(game)
local top = game.stack:top()
if getmetatable(top) ~= EvolutionState then
return nil, "the evolution screen never opened"
end
return top
end
local function textOf(box)
local out = {}
for _, page in ipairs(box.pages) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- B held out of the text box: the movie must run to the end and evolve.
do
local game, mon = newGame()
local evo, why = dismissWithB(game, mon)
if check(evo ~= nil, "the level-up box handed off to the movie: " .. tostring(why)) then
check(Input:isDown("b"),
"B is still physically down as the movie starts, which is what "
.. "the old isDown poll cancelled on")
eq(evo.cancelable, true,
"and this is a cancelable level-up evolution, so the movie really "
.. "is reading the button (#290)")
-- never released: no second edge ever reaches the movie
for _ = 1, 400 do
if evo.done then break end
step(game)
end
check(Input:isDown("b"), "B was held for the whole movie")
eq(evo.canceled, false, "the held B did not cancel the evolution")
eq(mon.species, "FIXMON_B", "the mon actually evolved")
eq(mon.stats.hp, require("src.pokemon.Stats")
.calc(Data.pokemon.FIXMON_B, EVO_LEVEL, mon.dvs, mon.statExp).hp,
"and Evolution.apply recalculated its stats on the new species")
local top = game.stack:top()
check(getmetatable(top) == TextBox and textOf(top):find("evolved into"),
"the congratulations text is what closes the movie")
end
end
-- A deliberate fresh press after the 80-frame delay still cancels.
do
local game, mon = newGame()
local evo = assert(dismissWithB(game, mon))
Input:keyreleased(B_KEY)
for _ = 1, 400 do
if evo.t > 80 then break end
step(game)
end
check(evo.t > 80 and not evo.done,
"the movie is past the DelayFrames window and still running")
Input:keypressed(B_KEY)
step(game)
eq(evo.canceled, true, "a fresh B press cancels the evolution (#213)")
eq(mon.species, "FIXMON_A", "and the mon keeps its species")
local top = game.stack:top()
check(getmetatable(top) == TextBox and textOf(top):find("stopped evolving"),
"_StoppedEvolvingText prints instead of the congratulations")
end
-- A press inside the 80 frames is not polled at all, so the mon still
do
local game, mon = newGame()
local evo = assert(dismissWithB(game, mon))
Input:keyreleased(B_KEY)
for _ = 1, 10 do step(game) end
Input:keypressed(B_KEY)
step(game)
Input:keyreleased(B_KEY)
check(evo.t <= 80, "the press landed inside the delay window")
eq(evo.canceled, false, "a B press during the delay is never polled")
for _ = 1, 400 do
if evo.done then break end
step(game)
end
eq(mon.species, "FIXMON_B", "so the evolution still completes")
end
T.finish()
+102
View File
@@ -0,0 +1,102 @@
-- Nurse Joy bows between the two closing lines (#995).
-- pokered engine/events/pokecenter.asm.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
-- the ROM-extracted strings the fixture text table does not carry
Data.text._PokemonFightingFitText = "Thank you for\nwaiting.\fYour POKéMON are\nfighting fit!"
Data.text._PokemonCenterFarewellText = "We hope to see\nyou again!"
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local stackStub = { push = function(_, item) pushed[#pushed + 1] = item end }
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local fakeGame = { data = Data, stack = stackStub }
T.check(setUpvalue(OW.finishNurseHeal, "TextBox", textBoxStub),
"TextBox upvalue on finishNurseHeal")
T.check(setUpvalue(OW.finishNurseHeal, "Game", fakeGame),
"Game upvalue on finishNurseHeal")
local FIT = Data.text._PokemonFightingFitText
local BYE = Data.text._PokemonCenterFarewellText
local player = { cellX = 3, cellY = 5 }
local faced
local function newNurse()
faced = 0
return {
facing = "down",
facePlayer = function(self) faced = faced + 1; self.facing = "down" end,
}
end
local fakeSelf
local function reset()
pushed = {}
fakeSelf = setmetatable({ player = player }, { __index = OW })
end
-- === with the nurse on the counter: fit line, bow, farewell
reset()
local nurse = newNurse()
local finished = 0
fakeSelf:finishNurseHeal(BYE, function() finished = finished + 1 end, nurse)
T.eq(#pushed, 1, "the fighting-fit line goes up on its own")
T.eq(pushed[1].text, FIT, "first box is exactly the fighting-fit text")
T.check(pushed[1].text:find(BYE, 1, true) == nil,
"the farewell is no longer merged into it with a page break (#995)")
pushed[1].onDone()
T.eq(#pushed, 1, "the farewell waits for the bow")
T.eq(nurse.facing, "up", "image index $14: the nurse bows")
T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
local hold = fakeSelf.emote or {}
T.eq(hold.npc, nurse, "the hold is anchored on the nurse")
T.eq(hold.frames, 20, "DelayFrames $14 is 20 frames")
T.eq(hold.bubble, false, "no emotion bubble is drawn over her")
T.check(not hold.skippable, "the bow cannot be skipped with A/B")
T.eq(finished, 0, "the pokecenter is still busy during the bow")
-- OverworldState:update counts emote.frames down and then calls onDone
if hold.onDone then hold.onDone() end
T.eq(#pushed, 2, "the farewell follows the bow")
local farewell = pushed[2] or {}
T.eq(farewell.text, BYE, "second box is the farewell text")
T.eq(nurse.facing, "up", "she is still bowed while the farewell prints")
if farewell.onDone then farewell.onDone() end
T.eq(nurse.facing, "down", "the trailing UpdateSprites faces her back")
T.eq(faced, 1, "she is turned back exactly once")
T.eq(finished, 1, "control returns to the player once, after the farewell")
-- === no nurse sprite (the Yellow/rest-stop callers): no bow, same text
reset()
finished = 0
fakeSelf:finishNurseHeal(BYE, function() finished = finished + 1 end)
T.eq(pushed[1].text, FIT, "npc-less caller still opens with the fit line")
pushed[1].onDone()
T.check(fakeSelf.emote == nil, "nothing to bow, so no world hold")
T.eq(#pushed, 2, "the farewell follows immediately")
farewell = pushed[2] or {}
T.eq(farewell.text, BYE, "npc-less caller still closes with the farewell")
if farewell.onDone then farewell.onDone() end
T.eq(finished, 1, "npc-less caller returns control once")
T.finish("nurse_bow_bug995")
@@ -0,0 +1,149 @@
-- Oak's aide quotes the REQUIREMENT, not your current count (#1006).
-- pokered engine/events/oaks_aide.asm .notEnoughOwnedMons.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
-- the ROM-extracted strings the fixture text table does not carry; labels
-- and wording match pokered data/text/text_1.asm
Data.text._OaksAideHiText =
"Hi! Remember me?\nI'm PROF.OAK's\vAIDE!\fIf you caught " ..
"{NUM:hOaksAideRequirement, 1, 3}\nkinds of POKéMON,\vI'm supposed to\v" ..
"give you an\v{RAM:wOaksAideRewardItemName}!\fSo, {PLAYER}! Have\n" ..
"you caught at\vleast {NUM:hOaksAideRequirement, 1, 3} kinds of\vPOKéMON?"
Data.text._OaksAideUhOhText =
"Let's see...\nUh-oh! You have\vcaught only " ..
"{NUM:hOaksAideNumMonsOwned, 1, 3}\vkinds of POKéMON!\fYou need " ..
"{NUM:hOaksAideRequirement, 1, 3} kinds\nif you want the\v" ..
"{RAM:wOaksAideRewardItemName}."
Data.text._OaksAideComeBackText =
"Oh. I see.\fWhen you get {NUM:hOaksAideRequirement, 1, 3}\nkinds, come " ..
"back\vfor {RAM:wOaksAideRewardItemName}."
Data.text._OaksAideHereYouGoText =
"Great! You have\ncaught {NUM:hOaksAideNumMonsOwned, 1, 3} kinds \v" ..
"of POKéMON!\vCongratulations!\fHere you go!"
Data.text._OaksAideGotItemText =
"{PLAYER} got the\n{RAM:wOaksAideRewardItemName}!"
-- the two rewards this suite drives (Route2Gate / Route11Gate2F pass them
Data.items.HM_FLASH = { id = "HM_FLASH", index = 196, name = "HM FLASH" }
Data.items.ITEMFINDER = { id = "ITEMFINDER", index = 6, name = "ITEMFINDER" }
local SaveData = require("src.core.SaveData")
local pushed = {}
local realTextBox = package.loaded["src.render.TextBox"]
-- story4's push/ask require TextBox lazily, so a package.loaded stub is
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local story4 = dofile("data/scripts/story4.lua")
local ROUTE_11 = story4.ROUTE_11_GATE_2F.talk.TEXT_ROUTE11GATE2F_OAKS_AIDE
local ROUTE_2 = story4.ROUTE_2_GATE.talk.TEXT_ROUTE2GATE_OAKS_AIDE
T.check(type(ROUTE_11) == "function" and type(ROUTE_2) == "function",
"both aides are wired to the shared oaksAide handler")
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
local function reset(ownedCount)
game.save = SaveData.newGame()
game.save.player.name = "RED"
local owned = {}
for i = 1, ownedCount do owned["SPECIES_" .. i] = true end
game.save.pokedex = { seen = {}, owned = owned }
pushed = {}
end
local function lastText()
return tostring(pushed[#pushed] and pushed[#pushed].text)
end
local function has(fragment)
return lastText():find(fragment, 1, true) ~= nil
end
local function held(id)
return game.save.inventory[id] or 0
end
-- A press on the box that is up
local function dismiss()
local box = pushed[#pushed]
if box and box.onDone then box.onDone() end
end
-- === the aide asks for his own threshold, whatever the player owns
reset(12)
local done = false
ROUTE_11(game, {}, {}, function() done = true end)
local offer = pushed[1]
T.check(offer.opts and offer.opts.choice ~= nil,
"the aide's opener is the YesNoChoice question")
T.check(has("least 30 kinds"), "opener asks for the aide's 30 kinds")
T.check(has("give you an\vITEMFINDER"), "opener names the reward item")
T.check(not has("{NUM"), "no placeholder survives into the opener")
-- === YES with too few kinds: both decimals are filled, and differently
offer.opts.choice(true)
T.check(has("caught only 12"), "Uh-oh line reports the kinds actually owned")
T.check(has("You need 30 kinds"), "Uh-oh line then states the requirement")
T.check(not has("You need 12 kinds"),
"the requirement is not overwritten by the owned count (#1006)")
T.check(has("want the\vITEMFINDER"), "Uh-oh line still names the reward")
dismiss()
T.check(done, "the Uh-oh branch completes the talk")
T.eq(held("ITEMFINDER"), 0, "no reward below the threshold")
T.check(not game.save.flags.EVENT_GOT_ITEMFINDER,
"the aide can still be asked again")
-- === the threshold tracks the aide, not a constant: Route 2 wants 10
reset(3)
ROUTE_2(game, {}, {}, function() end)
pushed[1].opts.choice(true)
T.check(has("caught only 3"), "Route 2 Uh-oh reports 3 kinds owned")
T.check(has("You need 10 kinds"), "Route 2 states its own 10-kind threshold")
T.check(has("want the\vHM FLASH"), "Route 2 names the HM FLASH reward")
-- === NO: ComeBackText quotes the requirement, nothing is given
reset(12)
done = false
ROUTE_11(game, {}, {}, function() done = true end)
pushed[1].opts.choice(false)
T.check(has("When you get 30"), "come-back line quotes the requirement")
T.check(has("back\vfor ITEMFINDER"), "come-back line names the reward")
dismiss()
T.check(done, "declining completes the talk")
T.eq(held("ITEMFINDER"), 0, "declining gives nothing")
-- === YES at the threshold: HereYouGo carries the OWNED count, then the
reset(30)
done = false
ROUTE_11(game, {}, {}, function() done = true end)
pushed[1].opts.choice(true)
T.check(has("caught 30 kinds"), "congratulation line carries the owned count")
dismiss()
T.check(has("RED got the\nITEMFINDER!"), "the item line names player and item")
dismiss()
T.check(done, "the reward branch completes the talk")
T.eq(held("ITEMFINDER"), 1, "ITEMFINDER lands in the bag")
T.check(game.save.flags.EVENT_GOT_ITEMFINDER, "the aide's event flag is set")
-- === repeat visit: the explanation text, no second ITEMFINDER
pushed = {}
done = false
ROUTE_11(game, {}, {}, function() done = true end)
T.eq(#pushed, 1, "a served player gets exactly one box")
T.check(pushed[1].opts == nil or pushed[1].opts.choice == nil,
"the repeat line is not a question")
T.eq(held("ITEMFINDER"), 1, "no second ITEMFINDER")
if realTextBox ~= nil then
package.loaded["src.render.TextBox"] = realTextBox
else
package.loaded["src.render.TextBox"] = nil
end
T.finish("oaks_aide_requirement_bug1006")
@@ -0,0 +1,113 @@
-- Yellow's starter Pikachu gets the nickname prompt (#1013).
-- pokeyellow scripts/OaksLab.asm OaksLabPlayerReceivesPikachuScript.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local pushed = {}
local realTextBox = package.loaded["src.render.TextBox"]
-- Commands requires TextBox at load time; stub it before the first require
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local Commands = require("src.script.Commands")
local SaveData = require("src.core.SaveData")
-- === the lab scene: one PIKACHU gift row, and no skipNickname on it
local lab = dofile("data/scripts/oaks_lab_yellow.lua")
local ball = lab.talk.TEXT_OAKSLAB_EEVEE_POKE_BALL
T.check(type(ball) == "function", "the Eevee ball builds its rows per playthrough")
local captured
local function buildScene(cellX, cellY)
captured = nil
local game = { data = Data, save = SaveData.newGame() }
game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true
local ow = {
player = { cellX = cellX, cellY = cellY },
runner = { run = function(_, rows) captured = rows end },
}
ball(game, ow, { def = {} }, function() end)
return captured or {}
end
local function indexOf(rows, verb, arg)
for i, row in ipairs(rows) do
if row[1] == verb and (arg == nil or row[2] == arg) then return i end
end
end
-- the shove branch (player on the table row, py == 4) and the plain
for _, spot in ipairs({ { 9, 4 }, { 5, 6 } }) do
local rows = buildScene(spot[1], spot[2])
local where = ("from (%d,%d)"):format(spot[1], spot[2])
local gives = 0
for _, row in ipairs(rows) do
if row[1] == "give_pokemon" then
gives = gives + 1
T.eq(row[2], "PIKACHU", "the gift species is PIKACHU " .. where)
T.eq(row[3], 5, "the gift is level 5 " .. where)
T.check(row[4] == nil,
"no skipNickname: AskName is left to run " .. where .. " (#1013)")
end
end
T.eq(gives, 1, "exactly one give_pokemon row " .. where)
local give = indexOf(rows, "give_pokemon")
local received = indexOf(rows, "show_text", "_OaksLabReceivedText")
local got = indexOf(rows, "set_flag", "EVENT_GOT_STARTER")
T.check(received and give and received < give,
"the received line prints before the mon is added " .. where)
T.check(give and got and give < got,
"AddPartyMon runs before SetEvent EVENT_GOT_STARTER " .. where)
end
-- === nothing in the Yellow lab names a Kanto starter (#1014): only a mod
local source = assert(io.open("data/scripts/oaks_lab_yellow.lua", "r"))
local text = source:read("*a")
source:close()
for _, species in ipairs({ "CHARMANDER", "SQUIRTLE", "BULBASAUR" }) do
T.check(not text:find(species, 1, true),
"the Yellow lab script never names " .. species)
end
-- === give_pokemon offers AskName with a runner and no skipNickname
local function giveThrough(skipNickname)
pushed = {}
local game = { data = Data, save = SaveData.newGame(),
stack = { push = function(_, box) pushed[#pushed + 1] = box end } }
local runner = {
yield = function() return coroutine.yield() end,
resume = function(self, ...) coroutine.resume(self.co, ...) end,
}
local ctx = { game = game, save = game.save, runner = runner }
runner.co = coroutine.create(function()
Commands.give_pokemon(ctx, "FIXMON_A", 5, skipNickname)
end)
local ok, err = coroutine.resume(runner.co)
T.check(ok, "give_pokemon runs cleanly: " .. tostring(err))
return game.save
end
local save = giveThrough(nil)
T.eq(#pushed, 1, "a plain gift puts one box up")
T.check(tostring(pushed[1].text):find("nickname", 1, true) ~= nil,
"that box is AskName's question")
T.check(pushed[1].opts and pushed[1].opts.choice ~= nil,
"AskName is a YES/NO, not a plain box")
T.eq(#save.party, 1, "the gift joined the party")
save = giveThrough(true)
T.eq(#pushed, 0, "skipNickname suppresses the prompt")
T.eq(#save.party, 1, "the gift still joined the party")
if realTextBox ~= nil then
package.loaded["src.render.TextBox"] = realTextBox
else
package.loaded["src.render.TextBox"] = nil
end
T.finish("oaks_lab_yellow_starter_bug1013")
+2 -1
View File
@@ -118,8 +118,9 @@ for _, item in ipairs(menu.items) do
if item.label == "PROF.OAK's PC" then oak = item end
end
T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had")
plays = {} -- drop the menu's Turn_On_PC; the session's jingle is what counts
oak.onSelect()
-- drop the menu's Turn_On_PC and the row's Enter_PC
plays = {}
T.eq(pushed[2].kind, "text", "selection opens the access text")
T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil,
"first session box is the access text")
+14
View File
@@ -47,6 +47,20 @@ eq(bad, nil, "non-X.Y.Z tag rejected")
check(badErr ~= nil, "rejection carries an error string")
eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected")
-- Network failures can return a plain-text or HTML body instead of JSON. The
-- launcher must describe that response rather than leaking the decoder's
-- low-level "unexpected character 'E'" assertion.
do
local release, err = Check.parseRelease("Error: API rate limit exceeded")
check(release == nil and tostring(err):find("not JSON", 1, true) ~= nil,
"plain-text update failure is reported as non-JSON")
release, err = Check.parseRelease("<!DOCTYPE html><html>502 Bad Gateway</html>")
check(release == nil and tostring(err):find("HTML", 1, true) ~= nil,
"HTML update failure is identified")
check(tostring(err):find("unexpected character", 1, true) == nil,
"decoder assertion is not exposed")
end
-- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './'
-- prefix and CRLF line endings; unrelated lines are skipped
local sums =
+36
View File
@@ -12,6 +12,7 @@ local Zoom = require("src.render.Zoom")
local ListMenu = require("src.ui.ListMenu")
local NamingScreen = require("src.ui.NamingScreen")
local TextBox = require("src.render.TextBox")
local PartyMenu = require("src.ui.PartyMenu")
local Player = require("src.world.Player")
local Music = require("src.core.Music")
@@ -192,6 +193,41 @@ do
"battle status HUD returns when the hook is removed")
end
-- ------- grid navigation ownership (alternate menu renderers)
do
local BattleState = require("src.battle.BattleState")
local battle = { wideLayout = function() return false end }
check(not BattleState.moveGridNavigation(battle),
"classic move navigation stays a list without a mod")
local unsub = wrap("battle.move_grid_navigation", function() return true end)
check(BattleState.moveGridNavigation(battle),
"a mod can opt the classic move menu into grid navigation")
unsub()
battle.wideLayout = function() return true end
check(BattleState.moveGridNavigation(battle),
"the native wide move grid remains enabled without a mod")
local game = {
save = { party = { {}, {}, {}, {}, {}, {} } },
input = { wasPressed = function(_, key) return key == "down" end },
}
local field = PartyMenu.new(game)
unsub = wrap("ui.party.grid_navigation", function() return true end)
field:update(0)
check(field.index == 2,
"field party navigation remains a native list when the hook is active")
game.partyMenuSavedIndex = nil
local menu = PartyMenu.new(game, { battle = {} })
menu:update(0)
check(menu.index == 3 and game.partyMenuSavedIndex == 3,
"a battle party can follow and remember a companion grid")
unsub()
menu:update(0)
check(menu.index == 4,
"removing the hook restores native list navigation immediately")
end
-- ------- music.volume (distance / indoor muffling)
do
+121
View File
@@ -0,0 +1,121 @@
-- Parity: the Fan Club Chairman asks before telling his story (#1050).
-- pokered scripts/PokemonFanClub.asm PokemonFanClubChairmanText.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity Fan Club chairman")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local Commands = require("src.script.Commands")
local Flags = require("src.script.Flags")
local ChoiceBox = require("src.ui.ChoiceBox")
local mapScripts = require("data.scripts.init")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local MAP, TEXT = "POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CHAIRMAN"
local script = mapScripts.talkScript(MAP, TEXT)
check(type(script) == "table", "the chairman is a row list")
eq(#ScriptRunner.validate(script), 0,
"the rows validate cleanly (labels resolve after the renumbering)")
-- === the intro is the question, not a plain box ===
local asks = 0
for _, row in ipairs(type(script) == "table" and script or {}) do
if row[1] == "ask" then
asks = asks + 1
eq(row[2], "_PokemonFanClubChairmanIntroText",
"the YesNoChoice rides the intro text (#1050)")
end
end
eq(asks, 1, "exactly one question, right after the intro")
-- === harness: run the talk script headless, recording show_text ids ===
local shown = {}
local origShow = Commands.show_text
-- forward extraOpts: Commands.ask rides show_text's 4th argument
Commands.show_text = function(ctx, textId, subs, ...)
table.insert(shown, textId)
return origShow(ctx, textId, subs, ...)
end
-- pressFn returns the Input.pressed table for this frame (default: A)
local function runScript(pressFn)
shown = {}
local ow = { map = { id = MAP, def = { label = MAP } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(script, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = pressFn and pressFn() or { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
local function shownIs(want, msg)
eq(table.concat(shown, ","), table.concat(want, ","), msg)
end
-- press B while the YES/NO box is up, A otherwise: the NO answer
local function declines()
if getmetatable(StateStack:top()) == ChoiceBox then return { b = true } end
return { a = true }
end
local function held(id) return Game.save.inventory[id] or 0 end
-- === 1) YES: story, voucher, received line, explanation ===
Game.save = SaveData.newGame()
check(runScript(), "chairman script completes on YES")
shownIs({ "_PokemonFanClubChairmanIntroText",
"_PokemonFanClubChairmanStoryText",
"_PokemonFanClubReceivedBikeVoucherText",
"_PokemonFanClubExplainBikeVoucherText" },
"YES hears the RAPIDASH story out and collects the voucher")
eq(held("BIKE_VOUCHER"), 1, "the BIKE VOUCHER is in the bag")
check(Flags.get(Game.save, "EVENT_RECEIVED_BIKE_VOUCHER"),
"EVENT_GOT_BIKE_VOUCHER is set")
-- === 2) NO: the brush-off, and the voucher stays with the chairman ===
Game.save = SaveData.newGame()
check(runScript(declines), "chairman script completes on NO")
shownIs({ "_PokemonFanClubChairmanIntroText", "_PokemonFanClubNoStoryText" },
"NO skips the story and the gift (#1050)")
eq(held("BIKE_VOUCHER"), 0, "declining leaves the voucher unclaimed")
check(not Flags.get(Game.save, "EVENT_RECEIVED_BIKE_VOUCHER"),
"declining leaves the event clear, so he can be asked again")
-- === 3) asking again after NO still works, and YES then pays out ===
check(runScript(), "second visit completes")
shownIs({ "_PokemonFanClubChairmanIntroText",
"_PokemonFanClubChairmanStoryText",
"_PokemonFanClubReceivedBikeVoucherText",
"_PokemonFanClubExplainBikeVoucherText" },
"a player who said NO can come back for the voucher")
eq(held("BIKE_VOUCHER"), 1, "the voucher arrives on the second visit")
-- === 4) served player: .nothingleft, no question at all ===
check(runScript(), "post-voucher script completes")
shownIs({ "_PokemonFanClubChairFinalText" },
"with the voucher collected he only reminisces")
eq(held("BIKE_VOUCHER"), 1, "no second voucher")
Commands.show_text = origShow
S.finish()
+135
View File
@@ -0,0 +1,135 @@
-- Parity: the Silph Co. 7F worker's LAPRAS gift offers the nickname prompt (#1049).
-- pokered scripts/SilphCo7F.asm SilphCo7FSilphWorkerM1Text.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity Silph LAPRAS")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local Commands = require("src.script.Commands")
local Flags = require("src.script.Flags")
local Pokemon = require("src.pokemon.Pokemon")
local Boxes = require("src.pokemon.Boxes")
local mapScripts = require("data.scripts.init")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local MAP, TEXT = "SILPH_CO_7F", "TEXT_SILPHCO7F_SILPH_WORKER_M1"
-- === 1) the worker is command rows, so the run carries a ScriptRunner ===
local script = mapScripts.talkScript(MAP, TEXT)
check(type(script) == "table",
"the LAPRAS worker is a row list, not a bare callback (#1049)")
eq(#ScriptRunner.validate(script), 0, "the rows validate cleanly")
local gives = 0
for _, row in ipairs(type(script) == "table" and script or {}) do
if row[1] == "give_pokemon" then
gives = gives + 1
eq(row[2], "LAPRAS", "the gift species is LAPRAS")
eq(row[3], 15, "the gift is level 15 (lb bc, LAPRAS, 15)")
check(row[4] == nil, "no skipNickname: AskName is left to run")
end
end
eq(gives, 1, "exactly one give_pokemon row")
-- === harness: run the talk script headless, A every frame, recording show_text ids
local shown = {}
local origShow = Commands.show_text
-- forward extraOpts: Commands.ask rides show_text's 4th argument
Commands.show_text = function(ctx, textId, subs, ...)
table.insert(shown, textId)
return origShow(ctx, textId, subs, ...)
end
local function runScript()
shown = {}
local ow = { map = { id = MAP, def = { label = MAP } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(script, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
local function shownIs(want, msg)
eq(table.concat(shown, ","), table.concat(want, ","), msg)
end
-- === 2) the gift itself: thanks, nickname prompt, GotMonText, blurb ===
Game.save = SaveData.newGame()
check(runScript(), "LAPRAS gift script completes")
shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText",
"_DoYouWantToNicknameText", "_GotMonText",
"_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
"thanks, nickname prompt, got-mon line, then the LAPRAS blurb")
eq(#Game.save.party, 1, "LAPRAS joins the party")
local lapras = Game.save.party[1] or {}
eq(lapras.species, "LAPRAS", "gift species is LAPRAS")
eq(lapras.level, 15, "LAPRAS is level 15")
eq(lapras.nickname, "AAAAAAAAAA",
"the nickname prompt reaches the NamingScreen (A-mash)")
check(Flags.get(Game.save, "EVENT_GOT_LAPRAS"), "BIT_GOT_LAPRAS is set")
check(Game.save.pokedex.owned.LAPRAS, "LAPRAS is registered owned")
-- === 3) after the gift he worries about the PRESIDENT, and only after
check(runScript(), "post-gift script completes")
shownIs({ "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" },
"before Giovanni: the worried line, and no second LAPRAS")
eq(#Game.save.party, 1, "no second LAPRAS")
Flags.set(Game.save, "EVENT_BEAT_SILPH_CO_GIOVANNI")
check(runScript(), "post-Giovanni script completes")
shownIs({ "_SilphCo7FSilphWorkerM1SavedText" },
"after Giovanni: saved at last")
-- === 4) party full, box has room: SendNewMonToBox still asks the name ===
Game.save = SaveData.newGame()
for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end
check(runScript(), "full-party gift script completes")
shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText",
"_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText",
"_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
"full party: nickname, sent-to-box, got-mon line, blurb")
local boxed = false
for _, box in ipairs(Boxes.ensure(Game.save)) do
for _, m in ipairs(box) do
if m.species == "LAPRAS" then boxed = true end
end
end
check(boxed, "full-party LAPRAS lands in a box")
check(Flags.get(Game.save, "EVENT_GOT_LAPRAS"), "full-party gift sets the flag")
-- === 5) party AND every box full: BoxIsFullText, no got-mon line, flag
Game.save = SaveData.newGame()
for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end
Boxes.ensure(Game.save)
for b = 1, Boxes.COUNT do
for s = 1, Boxes.CAPACITY do Game.save.boxes[b][s] = { species = "PIDGEY" } end
end
check(runScript(), "full-everything script completes")
shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText", "_BoxIsFullText" },
"no room: the box-full line, never a got-mon line for a mon you lack")
check(not Flags.get(Game.save, "EVENT_GOT_LAPRAS"),
"a failed give leaves BIT_GOT_LAPRAS clear, so the gift stays claimable")
Commands.show_text = origShow
S.finish()