mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d03a38067 | |||
| 79ed37699e | |||
| abf0f9e98a | |||
| 3955721bfc | |||
| c3855cebec | |||
| 41e6507665 | |||
| 5482ac590d | |||
| 28440eb936 | |||
| f606840216 | |||
| 6a990bf79f | |||
| ab5fed60fe |
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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() } }
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user