Open the Radio Tower takeover, Ice Path, Blackthorn and Dragon's Den (#32)
The Rocket-held Radio Tower, the Underground's switch room and warehouse, Ice Path, Blackthorn City and its gym, Routes 45 and 46, Dark Cave's Blackthorn entrance, Dragon's Den and the Dragon Shrine are open. The story plays through the Basement Key, the switches and the director, the Clear Bell, Ice Path's falling boulder, Clair's gym and the shrine's test for the RISINGBADGE, TM24 and the elder's Dratini. New specials: GiveDratini and MoveDeletion. A save keeps an object that a script made disappear (or appear against its flag) as Continue does, so the Radio Tower director no longer blocks the 5F stairs after a reload. A whiteout from a battle started in an scall no longer refuses. The playthrough driver pushes boulders into their holes (planned breadth first), clears whirlpools, chooses the move a TM replaces and heals with the strongest potion; the run gains six stages to the Rising Badge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
061ca4d59d
commit
9c3579937c
15 files changed
+855
-43
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
||||||
|
|
||||||
**Current milestone: from the opening to Mahogany Town and the Glacier Badge.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
**Current milestone: from the opening to Blackthorn City and the Rising Badge.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||||
|
|
||||||
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
||||||
|
|
||||||
@@ -26,7 +26,9 @@ West of Ecruteak, Routes 38 and 39 lead past the MooMoo farm, where seven Berrie
|
|||||||
|
|
||||||
East of Ecruteak, Route 42 runs past Mt. Mortar to Mahogany Town. North of town, Team Rocket charges a toll at the Route 43 gate, and the Lake of Rage is full of Gyarados forced to evolve. Stop the red one, then join Lance in the hideout under Mahogany's souvenir shop: get past the cameras and traps, learn the passwords, beat the executives and make the Electrodes faint for HM06 Whirlpool. Pryce's gym is an ice floor to slide across for the Glacier Badge. The fishing guru by the lake measures your Magikarp against the record.
|
East of Ecruteak, Route 42 runs past Mt. Mortar to Mahogany Town. North of town, Team Rocket charges a toll at the Route 43 gate, and the Lake of Rage is full of Gyarados forced to evolve. Stop the red one, then join Lance in the hideout under Mahogany's souvenir shop: get past the cameras and traps, learn the passwords, beat the executives and make the Electrodes faint for HM06 Whirlpool. Pryce's gym is an ice floor to slide across for the Glacier Badge. The fishing guru by the lake measures your Magikarp against the record.
|
||||||
|
|
||||||
The Radio Tower takeover and the routes beyond Mahogany, the Battle Tower and Whirl Islands, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
Team Rocket then takes over Goldenrod's Radio Tower. Fight up to the fake director for the Basement Key, open the Underground's locked door, work the switches past your rival to free the real director in the warehouse, and use his Card Key to reach the executive on the top floor. The director gives you the Clear Bell. East of Mahogany, Route 44 leads into Ice Path: push a boulder down a hole so it stops your slide on the ice below, and come out in Blackthorn City. Clair's gym hides its way forward behind boulders that fall from the floor above. Beat her, pass the elder's test in the Dragon Shrine for the Rising Badge, and receive a Dratini that knows Extremespeed. The Move Deleter makes a Pokémon forget a move.
|
||||||
|
|
||||||
|
Tin Tower, Whirl Islands, the routes beyond Blackthorn, the Battle Tower and Whirl Islands, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
|||||||
| Bug-Catching Contest | `engine/events/bug_contest/*.asm`, `maps/Route35NationalParkGate.asm`, `maps/Route36NationalParkGate.asm`, `engine/menus/start_menu.asm` | Registration, Park Balls, contestant selection, the timer on the game clock, out of balls, QUIT, judging from `BugContestantPointers` with ties to the earlier entry, prizes and `CheckPartyFullAfterContest`. The contest state saves. The status box is a text page above the start menu. No sound. |
|
| Bug-Catching Contest | `engine/events/bug_contest/*.asm`, `maps/Route35NationalParkGate.asm`, `maps/Route36NationalParkGate.asm`, `engine/menus/start_menu.asm` | Registration, Park Balls, contestant selection, the timer on the game clock, out of balls, QUIT, judging from `BugContestantPointers` with ties to the earlier entry, prizes and `CheckPartyFullAfterContest`. The contest state saves. The status box is a text page above the start menu. No sound. |
|
||||||
| `game/scripts/cianwood.ts`, Routes 38–41, Olivine and Cianwood | `engine/events/shuckle.asm` (`GiveShuckle`, `ReturnShuckie`), `engine/pokemon/bills_pc_top.asm` (`CheckCurPartyMonFainted`), `engine/pokemon/caught_data.asm` (`SetGiftPartyMonCaughtData`), `engine/events/poke_seer.asm`, `engine/events/print_photo.asm`, `engine/printer/printer.asm` (`PrintPartymon`, `CheckPrinterStatus`), `engine/printer/printer_serial.asm`, `engine/events/specials.asm` (`PlaySlowCry`) | Shuckie is a level 15 party-only gift with MANIA's OT, ID 518, nickname, a Berry and `LANDMARK_GIFT` caught data; it sets `ENGINE_GOT_SHUCKIE_TODAY`. `ReturnShuckie` returns all five codes; `CheckCurPartyMonFainted` looks at the rest of the party, and Mania answers `SHUCKIE_FAINTED` with his happiness speech, as the original does. `PokeSeer` follows `ReadCaughtData`, including the missing `cp [hl]` that compares only the OT ID's high byte, `GetCaughtLocation`'s event and gift landmarks, `GetCaughtLevel`'s ??? that leaves `wSeerCaughtLevel` unchanged, and `SeerAdvice`'s inclusive thresholds. `wSeerCaughtLevel` lives in the "Miscellaneous WRAM 1" union: the port keeps the Seer's own last value until a load, while in the original other users of those bytes may change it. `PhotoStudio` has no printer to talk to: the handshake reads $ff, so `CheckPrinterStatus` reports `PRINTER_ERROR_2`, and `SendScreenToPrinter` retries until B sets `hPrinter`. The port shows "Printer Error 2" with "Press B to Cancel" on one page, then the no-photo text; the print page behind the box and its alternation with "CHECKING LINK" are not drawn. `PlaySlowCry` is audio. The story maps run on ported script commands; `tests/olivine-cianwood.test.ts` and six playthrough stages cover them. |
|
| `game/scripts/cianwood.ts`, Routes 38–41, Olivine and Cianwood | `engine/events/shuckle.asm` (`GiveShuckle`, `ReturnShuckie`), `engine/pokemon/bills_pc_top.asm` (`CheckCurPartyMonFainted`), `engine/pokemon/caught_data.asm` (`SetGiftPartyMonCaughtData`), `engine/events/poke_seer.asm`, `engine/events/print_photo.asm`, `engine/printer/printer.asm` (`PrintPartymon`, `CheckPrinterStatus`), `engine/printer/printer_serial.asm`, `engine/events/specials.asm` (`PlaySlowCry`) | Shuckie is a level 15 party-only gift with MANIA's OT, ID 518, nickname, a Berry and `LANDMARK_GIFT` caught data; it sets `ENGINE_GOT_SHUCKIE_TODAY`. `ReturnShuckie` returns all five codes; `CheckCurPartyMonFainted` looks at the rest of the party, and Mania answers `SHUCKIE_FAINTED` with his happiness speech, as the original does. `PokeSeer` follows `ReadCaughtData`, including the missing `cp [hl]` that compares only the OT ID's high byte, `GetCaughtLocation`'s event and gift landmarks, `GetCaughtLevel`'s ??? that leaves `wSeerCaughtLevel` unchanged, and `SeerAdvice`'s inclusive thresholds. `wSeerCaughtLevel` lives in the "Miscellaneous WRAM 1" union: the port keeps the Seer's own last value until a load, while in the original other users of those bytes may change it. `PhotoStudio` has no printer to talk to: the handshake reads $ff, so `CheckPrinterStatus` reports `PRINTER_ERROR_2`, and `SendScreenToPrinter` retries until B sets `hPrinter`. The port shows "Printer Error 2" with "Press B to Cancel" on one page, then the no-photo text; the print page behind the box and its alternation with "CHECKING LINK" are not drawn. `PlaySlowCry` is audio. The story maps run on ported script commands; `tests/olivine-cianwood.test.ts` and six playthrough stages cover them. |
|
||||||
| `game/scripts/lake-of-rage.ts`, Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | `engine/events/magikarp.asm` (`CheckMagikarpLength`, `CalcMagikarpLength`, `PrintMagikarpLength`, `MagikarpHouseSign`), `engine/menus/intro_menu.asm` (`InitializeMagikarpHouse`), `engine/pokemon/search_party.asm` (`FindThatSpecies`), `engine/overworld/scripting.asm` (`DoScene`), `engine/events/money.asm` (`TakeMoney`) | The Magikarp is measured from its own DVs and OT ID with the battle code's `CalcMagikarpLength` (whose `.BCLessThanDE` compares only the high byte). A longer one, compared feet then inches, becomes the record with the Magikarp's OT name as holder; the record starts at 3′6″ by RALPH and is saved as `magikarpRecord`. `FindThatSpecies` reads `wPartySpecies`, where an Egg is EGG, so an Egg is never found. `setmapscene`/`setscene` on a map without a scene variable (Route 43, the Lake of Rage): `GetMapSceneID` leaves `de` alone and `DoScene` still writes the byte through it when it is nonzero. At the Route 43 gate `de` is `EventFlagAction`'s byte offset of `EVENT_CLEARED_ROCKET_HIDEOUT` (4), the MBC3 RAM-enable register, so nothing changes. After the Red Gyarados, `de` is whatever `closetext` left; the port consumes the byte and changes nothing, and that write was not checked in an emulator. `TakeMoney` leaves a short account at zero, so the Route 43 Rockets take all of a smaller balance. The Rocket Hideout's cameras, traps, passwords, executives and Electrodes and Pryce's ice floor run on ported primitives; `PlayCurMonCry` is audio. `tests/mahogany.test.ts` and five playthrough stages cover them. |
|
| `game/scripts/lake-of-rage.ts`, Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | `engine/events/magikarp.asm` (`CheckMagikarpLength`, `CalcMagikarpLength`, `PrintMagikarpLength`, `MagikarpHouseSign`), `engine/menus/intro_menu.asm` (`InitializeMagikarpHouse`), `engine/pokemon/search_party.asm` (`FindThatSpecies`), `engine/overworld/scripting.asm` (`DoScene`), `engine/events/money.asm` (`TakeMoney`) | The Magikarp is measured from its own DVs and OT ID with the battle code's `CalcMagikarpLength` (whose `.BCLessThanDE` compares only the high byte). A longer one, compared feet then inches, becomes the record with the Magikarp's OT name as holder; the record starts at 3′6″ by RALPH and is saved as `magikarpRecord`. `FindThatSpecies` reads `wPartySpecies`, where an Egg is EGG, so an Egg is never found. `setmapscene`/`setscene` on a map without a scene variable (Route 43, the Lake of Rage): `GetMapSceneID` leaves `de` alone and `DoScene` still writes the byte through it when it is nonzero. At the Route 43 gate `de` is `EventFlagAction`'s byte offset of `EVENT_CLEARED_ROCKET_HIDEOUT` (4), the MBC3 RAM-enable register, so nothing changes. After the Red Gyarados, `de` is whatever `closetext` left; the port consumes the byte and changes nothing, and that write was not checked in an emulator. `TakeMoney` leaves a short account at zero, so the Route 43 Rockets take all of a smaller balance. The Rocket Hideout's cameras, traps, passwords, executives and Electrodes and Pryce's ice floor run on ported primitives; `PlayCurMonCry` is audio. `tests/mahogany.test.ts` and five playthrough stages cover them. |
|
||||||
|
| `game/scripts/blackthorn.ts`, the Radio Tower takeover, Ice Path, Blackthorn and Dragon's Den | `engine/events/dratini.asm` (`GiveDratini`), `engine/events/move_deleter.asm` (`MoveDeletion`), `engine/pokemon/mon_menu.asm` (`ChooseMoveToDelete`), `engine/events/whiteout.asm` (`Script_Whiteout`), `data/maps/setup_scripts.asm` (`MapSetupScript_Continue`) | `GiveDratini` searches the party backwards by species and writes `.Movesets` 0 (WRAP, THUNDER WAVE, TWISTER, EXTREMESPEED) or 1 with each move's base PP; a value of 2 or more changes nothing. `MoveDeletion` follows the routine's order of questions; `ChooseMoveToDelete` is shown as a list of the moves, and `.DeleteMove` shifts the later moves and PP bytes, PP Ups included. A save records objects whose visibility no longer matches their flags: `MapSetupScript_Continue` skips `LoadMapObjects`, so a flagless object a script made disappear (the Radio Tower director) stays gone after Continue and one that appeared with its flag set stays visible; a later visit rebuilds them from the map. A battle lost inside an `scall` jumps to `Script_Whiteout` without unwinding; its `newloadmap` is followed by `endall`, which drops the caller. The switch room's doors, the warehouse's reset, the Ice Path and Blackthorn Gym stone tables, whirlpools and the Dragon Shrine quiz run on ported primitives. `tests/blackthorn.test.ts`, `tests/radio-tower.test.ts` and six playthrough stages cover them. |
|
||||||
| `game/scripts/slot-machine.ts`, `game/scripts/card-flip.ts` | `engine/games/slot_machine.asm`, `engine/games/card_flip.asm`, `engine/gfx/cgb_layouts.asm` | Both on a realtime tile screen (two frames per tick, buffered buttons). Slots: reel strips, spin rates and scrolling, bias tables with the lucky machine and seven streak, reel 3's Golem and Chansey replay with their sprites, payouts. Card Flip: deck, 12×11 board, cursor sprites and `ChooseCard_HandleJoypad`, paytable. Tilemaps, all 48 cursor positions, selected reel states and whole Golem/Chansey/Egg sequences are checked against the ROM (`tests/reference-game-corner.spec.ts`, which runs `CardFlip_UpdateCursorOAM`, `Slots_UpdateReelPositionAndOAM`/`.SpinReel` and the sprite-animation engine's own Golem/Chansey/Egg routines): the cursor, the reel symbols and the three creatures each anchor an OAM-relative pixel pair 8 (x) and 16 (y) beyond the screen's, as `cursorSprites` reads; `depixel`'s own two arguments end up swapped by `_InitSpriteAnimStruct` (confirmed against the reference emulator), so Golem/Chansey/Egg's X and Y bases were swapped to match, and Golem's own OAM table X-flips its right half. `PlaySpriteAnimations` is compared frame by frame over a three-Golem run and a Chansey round with two eggs (shadow OAM, `wSlotsDelay` and `hSCY`): frames last 8 ticks (the first draw plus 7 decrements); Golem's frames 3 and 4 flip the whole OAM set (offsets become −8−offset); a sprite removed by `DeinitializeSprite` is still drawn that tick, because `UpdateAnimFrame` runs after the animation function; the Golem's roll toggles `hSCY` by ±2, which the tile screen shows with the space tiles `_SlotMachine` filled the BG map with. The rows scrolled in use palette 0, since the attribute map beyond row 18 is not reproduced. No sound. |
|
| `game/scripts/slot-machine.ts`, `game/scripts/card-flip.ts` | `engine/games/slot_machine.asm`, `engine/games/card_flip.asm`, `engine/gfx/cgb_layouts.asm` | Both on a realtime tile screen (two frames per tick, buffered buttons). Slots: reel strips, spin rates and scrolling, bias tables with the lucky machine and seven streak, reel 3's Golem and Chansey replay with their sprites, payouts. Card Flip: deck, 12×11 board, cursor sprites and `ChooseCard_HandleJoypad`, paytable. Tilemaps, all 48 cursor positions, selected reel states and whole Golem/Chansey/Egg sequences are checked against the ROM (`tests/reference-game-corner.spec.ts`, which runs `CardFlip_UpdateCursorOAM`, `Slots_UpdateReelPositionAndOAM`/`.SpinReel` and the sprite-animation engine's own Golem/Chansey/Egg routines): the cursor, the reel symbols and the three creatures each anchor an OAM-relative pixel pair 8 (x) and 16 (y) beyond the screen's, as `cursorSprites` reads; `depixel`'s own two arguments end up swapped by `_InitSpriteAnimStruct` (confirmed against the reference emulator), so Golem/Chansey/Egg's X and Y bases were swapped to match, and Golem's own OAM table X-flips its right half. `PlaySpriteAnimations` is compared frame by frame over a three-Golem run and a Chansey round with two eggs (shadow OAM, `wSlotsDelay` and `hSCY`): frames last 8 ticks (the first draw plus 7 decrements); Golem's frames 3 and 4 flip the whole OAM set (offsets become −8−offset); a sprite removed by `DeinitializeSprite` is still drawn that tick, because `UpdateAnimFrame` runs after the animation function; the Golem's roll toggles `hSCY` by ±2, which the tile screen shows with the space tiles `_SlotMachine` filled the BG map with. The rows scrolled in use palette 0, since the attribute map beyond row 18 is not reproduced. No sound. |
|
||||||
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -20,6 +20,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
|||||||
| Routes 35–37, the National Park, Ecruteak and Morty | Done (Tin Tower closed) |
|
| Routes 35–37, the National Park, Ecruteak and Morty | Done (Tin Tower closed) |
|
||||||
| Routes 38–41, Olivine, Cianwood, Chuck and Jasmine | Done (port, Battle Tower closed) |
|
| Routes 38–41, Olivine, Cianwood, Chuck and Jasmine | Done (port, Battle Tower closed) |
|
||||||
| Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | Done |
|
| Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | Done |
|
||||||
|
| The Radio Tower takeover, Ice Path, Blackthorn, Clair and Dragon's Den | Done |
|
||||||
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
||||||
| 1. Complete the overworld foundation | Done |
|
| 1. Complete the overworld foundation | Done |
|
||||||
| 2. Opening story and first battle slice | Mostly done |
|
| 2. Opening story and first battle slice | Mostly done |
|
||||||
@@ -428,7 +429,16 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- New specials: `CheckMagikarpLength`, `MagikarpHouseSign` and `FindPartyMonThatSpecies` (`game/scripts/lake-of-rage.ts`). The Magikarp record and holder are saved as `magikarpRecord` (3′6″ by RALPH in a new game, and in older saves).
|
- New specials: `CheckMagikarpLength`, `MagikarpHouseSign` and `FindPartyMonThatSpecies` (`game/scripts/lake-of-rage.ts`). The Magikarp record and holder are saved as `magikarpRecord` (3′6″ by RALPH in a new game, and in older saves).
|
||||||
- Engine fixes: `takemoney` leaves a short account at zero, as `TakeMoney` does (it used to leave it unchanged). `setmapscene` and `setscene` on a map without a scene variable consume the scene byte instead of refusing; see NATIVE_PORT.md for where the original writes it.
|
- Engine fixes: `takemoney` leaves a short account at zero, as `TakeMoney` does (it used to leave it unchanged). `setmapscene` and `setscene` on a map without a scene variable consume the scene byte instead of refusing; see NATIVE_PORT.md for where the original writes it.
|
||||||
- The playthrough driver's path search slides on ice. Tests: `tests/mahogany.test.ts` covers the record, its holder and reload, every guru answer, an Egg, the Elixer on standby and the toll. The playthrough continues for five more stages, from Olivine to Pryce.
|
- The playthrough driver's path search slides on ice. Tests: `tests/mahogany.test.ts` covers the record, its holder and reload, every guru answer, an Egg, the Elixer on standby and the toll. The playthrough continues for five more stages, from Olivine to Pryce.
|
||||||
- Remaining: the Radio Tower takeover, Ice Path, Blackthorn, Clair, Dragon's Den, Routes 45 and 46, Tin Tower, Whirl Islands, radio and rematches (#30), breeding (#29), mobile and link features, and audio.
|
- Remaining: the Radio Tower takeover, Ice Path, Blackthorn, Clair and Dragon's Den follow in increment F below; Tin Tower, Whirl Islands, radio and rematches (#30), breeding (#29), mobile and link features, and audio.
|
||||||
|
|
||||||
|
## Implemented: the Radio Tower takeover, Ice Path, Blackthorn, Clair and Dragon's Den (board #32, increment F)
|
||||||
|
|
||||||
|
- Ice Path's five floors, Blackthorn City with its gym, Pokémon Center, Mart, Move Deleter's house, Emy's house and the dragon speech house, Routes 45 and 46, Dark Cave's Blackthorn entrance, Dragon's Den and the Dragon Shrine are open. The Radio Tower, the Underground, its switch room and warehouse change for the takeover.
|
||||||
|
- The story runs: the Rocket-held Radio Tower to the fake director and the Basement Key, the Underground's locked door, the switch room's doors and the rival, the director and the Card Key, the 3F shutters, the executive and the Clear Bell; Ice Path's falling boulder; Clair's gym, whose 2F boulders fall to bridge 1F; Clair; the Dragon Shrine's five questions, the RISINGBADGE, TM24 and the elder's Dratini.
|
||||||
|
- New specials (`game/scripts/blackthorn.ts`): `GiveDratini` (the Extremespeed moves after a test without mistakes, the normal ones otherwise, on the last Dratini in the party) and `MoveDeletion` (one-move and Egg refusals, every way to say no, and moves and PP bytes shifting up).
|
||||||
|
- Engine fixes: a save keeps an object that disappeared without an event flag hidden (the Radio Tower director walks off the 5F stairs), and one that appeared with its flag set shown, as Continue keeps the saved object structs. `Script_Whiteout`'s `newloadmap` may run inside a call (a lost battle started from an `scall`); its `endall` drops the caller.
|
||||||
|
- The playthrough driver gained `push` and `boulders` (pushes planned breadth first over the boulders and the player's reachable area, each into its own stone-table hole), clears whirlpools in `sail`, can choose the move `teach` forgets, and heals with the strongest potion carried. Tests: `tests/blackthorn.test.ts` (Move Deleter, Dratini) and `tests/radio-tower.test.ts` (switches through a reload and the warehouse reset, a lost rival battle, the director after a save). The playthrough continues for six more stages: the Radio Tower, the Underground, the Clear Bell, Ice Path, training and Clair, and the Dragon Shrine.
|
||||||
|
- Remaining: Tin Tower and Suicune, Whirl Islands and Lugia, the Battle Tower, radio and rematches (#30, including Buena's password show), breeding (#29), mobile and link features, and audio.
|
||||||
|
|
||||||
## Native battles: current state
|
## Native battles: current state
|
||||||
|
|
||||||
|
|||||||
@@ -1068,6 +1068,17 @@ export const JOHTO = {
|
|||||||
MagikarpHouseSign: 0xfbcd2,
|
MagikarpHouseSign: 0xfbcd2,
|
||||||
FindPartyMonThatSpecies: 0xc276,
|
FindPartyMonThatSpecies: 0xc276,
|
||||||
PlayCurMonCry: 0xc472,
|
PlayCurMonCry: 0xc472,
|
||||||
|
GiveDratini: 0x8b170,
|
||||||
|
"GiveDratini.Movesets": 0x8b1c4,
|
||||||
|
MoveDeletion: 0x2c547,
|
||||||
|
"MoveDeletion.DeleterIntroText": 0x2c5ef,
|
||||||
|
"MoveDeletion.DeleterAskWhichMonText": 0x2c5f4,
|
||||||
|
"MoveDeletion.DeleterAskWhichMoveText": 0x2c5ea,
|
||||||
|
"MoveDeletion.AskDeleteMoveText": 0x2c5d6,
|
||||||
|
"MoveDeletion.DeleterForgotMoveText": 0x2c5db,
|
||||||
|
"MoveDeletion.MailEggText": 0x2c5e0,
|
||||||
|
"MoveDeletion.DeleterNoComeAgainText": 0x2c5e5,
|
||||||
|
"MoveDeletion.MoveKnowsOneText": 0x2c5d1,
|
||||||
"CheckMagikarpLength.MagikarpGuruMeasureText": 0xfbba9,
|
"CheckMagikarpLength.MagikarpGuruMeasureText": 0xfbba9,
|
||||||
"MagikarpHouseSign.KarpGuruRecordText": 0xfbce8,
|
"MagikarpHouseSign.KarpGuruRecordText": 0xfbce8,
|
||||||
"GiveShuckle": 0x7305,
|
"GiveShuckle": 0x7305,
|
||||||
@@ -1317,6 +1328,25 @@ export const NATIVE_MAPS: Record<string, string> = {
|
|||||||
"3:58": "Mt. Mortar",
|
"3:58": "Mt. Mortar",
|
||||||
"3:59": "Mt. Mortar 2F",
|
"3:59": "Mt. Mortar 2F",
|
||||||
"3:60": "Mt. Mortar B1F",
|
"3:60": "Mt. Mortar B1F",
|
||||||
|
"3:61": "Ice Path 1F",
|
||||||
|
"3:62": "Ice Path B1F",
|
||||||
|
"3:63": "Ice Path B2F",
|
||||||
|
"3:64": "Ice Path B2F",
|
||||||
|
"3:65": "Ice Path B3F",
|
||||||
|
"3:79": "Dark Cave",
|
||||||
|
"3:80": "Dragon’s Den 1F",
|
||||||
|
"3:81": "Dragon’s Den B1F",
|
||||||
|
"3:82": "Dragon Shrine",
|
||||||
|
"5:1": "Blackthorn Gym 1F",
|
||||||
|
"5:2": "Blackthorn Gym 2F",
|
||||||
|
"5:3": "Dragon speech house",
|
||||||
|
"5:4": "Emy’s house",
|
||||||
|
"5:5": "Blackthorn Mart",
|
||||||
|
"5:6": "Pokémon Center",
|
||||||
|
"5:7": "Move Deleter’s house",
|
||||||
|
"5:8": "Route 45",
|
||||||
|
"5:9": "Route 46",
|
||||||
|
"5:10": "Blackthorn City",
|
||||||
"9:1": "Hidden Power house",
|
"9:1": "Hidden Power house",
|
||||||
"9:2": "Fishing guru’s house",
|
"9:2": "Fishing guru’s house",
|
||||||
"9:3": "Route 43 Mahogany gate",
|
"9:3": "Route 43 Mahogany gate",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { JOHTO, RAM } from "../rom/profile";
|
||||||
|
import type { Gen, SpecialContext } from "./special-context";
|
||||||
|
|
||||||
|
const DRATINI = 147;
|
||||||
|
|
||||||
|
/** GiveDratini (engine/events/dratini.asm): wScriptVar 0 (the shrine quiz
|
||||||
|
* answered without a mistake) gives the last Dratini in the party WRAP,
|
||||||
|
* THUNDER WAVE, TWISTER and EXTREMESPEED, 1 its normal level 15 moves;
|
||||||
|
* each move's PP is its base PP. Other values change nothing. */
|
||||||
|
export function giveDratini(c: SpecialContext, value: number) {
|
||||||
|
if (value >= 2) return;
|
||||||
|
const party = c.state.party;
|
||||||
|
// .CheckForDratini: from the last party slot backwards, by species.
|
||||||
|
let slot = party.length - 1;
|
||||||
|
while (slot >= 0 && party[slot].species !== DRATINI) slot--;
|
||||||
|
if (slot < 0) return;
|
||||||
|
const mon = party[slot];
|
||||||
|
// .Movesets: four moves and a 0 each; .GiveMoves stops at the 0.
|
||||||
|
const set = JOHTO["GiveDratini.Movesets"] + value * 5;
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const move = c.reader.byte(set + i);
|
||||||
|
if (!move) break;
|
||||||
|
mon.moves[i] = move;
|
||||||
|
mon.pp[i] = c.data.move(move).pp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MoveDeletion (engine/events/move_deleter.asm): the Move Deleter. A
|
||||||
|
* Pokémon with one move, an Egg and every refusal end with their own
|
||||||
|
* text; otherwise the chosen move goes and the later moves and their PP
|
||||||
|
* bytes (PP Ups included) move up a slot. */
|
||||||
|
export function* moveDeletion(c: SpecialContext): Gen {
|
||||||
|
const say = function* (address: number): Gen {
|
||||||
|
c.write(address);
|
||||||
|
yield* c.flush();
|
||||||
|
};
|
||||||
|
c.write(JOHTO["MoveDeletion.DeleterIntroText"]);
|
||||||
|
if (!(yield { kind: "yesno", pages: c.take() }))
|
||||||
|
return yield* say(JOHTO["MoveDeletion.DeleterNoComeAgainText"]);
|
||||||
|
c.write(JOHTO["MoveDeletion.DeleterAskWhichMonText"]);
|
||||||
|
const slot = yield* c.selectMon();
|
||||||
|
if (slot < 0) return yield* say(JOHTO["MoveDeletion.DeleterNoComeAgainText"]);
|
||||||
|
const mon = c.state.party[slot];
|
||||||
|
if (mon.egg) return yield* say(JOHTO["MoveDeletion.MailEggText"]);
|
||||||
|
if (!mon.moves[1]) return yield* say(JOHTO["MoveDeletion.MoveKnowsOneText"]);
|
||||||
|
c.write(JOHTO["MoveDeletion.DeleterAskWhichMoveText"]);
|
||||||
|
const moves = mon.moves.filter(Boolean);
|
||||||
|
// ChooseMoveToDelete: the move list, A on a move or B to cancel.
|
||||||
|
const index = yield {
|
||||||
|
kind: "menu",
|
||||||
|
pages: c.take(),
|
||||||
|
items: moves.map((move) => c.data.moveName(move)),
|
||||||
|
selected: 0,
|
||||||
|
};
|
||||||
|
if (typeof index !== "number" || index < 0 || index >= moves.length)
|
||||||
|
return yield* say(JOHTO["MoveDeletion.DeleterNoComeAgainText"]);
|
||||||
|
c.strings[RAM.stringBuffer1] = c.data.moveName(moves[index]);
|
||||||
|
c.write(JOHTO["MoveDeletion.AskDeleteMoveText"]);
|
||||||
|
if (!(yield { kind: "yesno", pages: c.take() }))
|
||||||
|
return yield* say(JOHTO["MoveDeletion.DeleterNoComeAgainText"]);
|
||||||
|
// .DeleteMove: shift the moves and PP after the slot up; clear the last.
|
||||||
|
for (let i = index; i < 3; i++) {
|
||||||
|
mon.moves[i] = mon.moves[i + 1];
|
||||||
|
mon.pp[i] = mon.pp[i + 1];
|
||||||
|
}
|
||||||
|
mon.moves[3] = 0;
|
||||||
|
mon.pp[3] = 0;
|
||||||
|
yield* say(JOHTO["MoveDeletion.DeleterForgotMoveText"]);
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ import { unownPuzzle } from "./unown-puzzle";
|
|||||||
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
||||||
import { slotMachine } from "./slot-machine";
|
import { slotMachine } from "./slot-machine";
|
||||||
import { cardFlip } from "./card-flip";
|
import { cardFlip } from "./card-flip";
|
||||||
|
import { giveDratini, moveDeletion } from "./blackthorn";
|
||||||
import {
|
import {
|
||||||
checkMagikarpLength,
|
checkMagikarpLength,
|
||||||
findPartyMonThatSpecies,
|
findPartyMonThatSpecies,
|
||||||
@@ -1584,9 +1585,14 @@ export class ScriptRun {
|
|||||||
};
|
};
|
||||||
} else throw new UnsupportedScript(offset, opcode, " (map setup)");
|
} else throw new UnsupportedScript(offset, opcode, " (map setup)");
|
||||||
// The script resumes where it stopped; a caller's stack is not
|
// The script resumes where it stopped; a caller's stack is not
|
||||||
// carried over to the new map here.
|
// carried over to the new map here. Script_Whiteout's newloadmap
|
||||||
if (stack.length)
|
// can run inside a call (a battle lost in an scall'd script jumps
|
||||||
throw new UnsupportedScript(offset, opcode, " (inside a call)");
|
// there without unwinding), but its endall then drops the stack.
|
||||||
|
if (stack.length) {
|
||||||
|
if (r.byte(p) !== 0x93)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (inside a call)");
|
||||||
|
stack.length = 0;
|
||||||
|
}
|
||||||
// MAPSETUP_DOOR's bytes fall through into MAPSETUP_TRAIN's
|
// MAPSETUP_DOOR's bytes fall through into MAPSETUP_TRAIN's
|
||||||
// UpdateRoamMons; MAPSETUP_TELEPORT and MAPSETUP_FLY fall
|
// UpdateRoamMons; MAPSETUP_TELEPORT and MAPSETUP_FLY fall
|
||||||
// through into MAPSETUP_WARP's bytes, but only after their own
|
// through into MAPSETUP_WARP's bytes, but only after their own
|
||||||
@@ -3420,6 +3426,12 @@ export class ScriptRun {
|
|||||||
this.specialContext(pages, take),
|
this.specialContext(pages, take),
|
||||||
this.state.magikarpRecord,
|
this.state.magikarpRecord,
|
||||||
);
|
);
|
||||||
|
case JOHTO.GiveDratini:
|
||||||
|
giveDratini(this.specialContext(pages, take), value);
|
||||||
|
return value;
|
||||||
|
case JOHTO.MoveDeletion:
|
||||||
|
yield* moveDeletion(this.specialContext(pages, take));
|
||||||
|
return value;
|
||||||
case JOHTO.MagikarpHouseSign:
|
case JOHTO.MagikarpHouseSign:
|
||||||
magikarpHouseSign(this.specialContext(pages, take), this.state.magikarpRecord);
|
magikarpHouseSign(this.specialContext(pages, take), this.state.magikarpRecord);
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
+41
-11
@@ -149,8 +149,18 @@ export interface NativeSave {
|
|||||||
clock: { minuteOffset: number; weekdayOffset: number; dst: boolean };
|
clock: { minuteOffset: number; weekdayOffset: number; dst: boolean };
|
||||||
/** The introduction has not finished: it starts again on load. */
|
/** The introduction has not finished: it starts again on load. */
|
||||||
intro: boolean;
|
intro: boolean;
|
||||||
/** Current-map objects moved or turned by scripts, as saved object structs. */
|
/** Current-map objects moved or turned by scripts, as saved object structs.
|
||||||
objects: { id: number; x: number; y: number; direction: Direction }[];
|
* `hidden` marks an object whose flags would show it but that a script
|
||||||
|
* made disappear (one without an event flag); `shown`, one that appeared
|
||||||
|
* although its flag is set. Continue keeps the object structs as saved. */
|
||||||
|
objects: {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
direction: Direction;
|
||||||
|
hidden?: true;
|
||||||
|
shown?: true;
|
||||||
|
}[];
|
||||||
/** wPlayerID, drawn at a new game. */
|
/** wPlayerID, drawn at a new game. */
|
||||||
playerId: number;
|
playerId: number;
|
||||||
party: PartyMon[];
|
party: PartyMon[];
|
||||||
@@ -269,6 +279,9 @@ interface Actor {
|
|||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
fixedFacing: boolean;
|
fixedFacing: boolean;
|
||||||
moved: boolean;
|
moved: boolean;
|
||||||
|
/** An appear or disappear this visit: its visibility, not its flag,
|
||||||
|
* is what a save keeps. */
|
||||||
|
toggled?: boolean;
|
||||||
/** OBJECT_ACTION_FISHING, from fish_cast_rod or fish_got_bite until
|
/** OBJECT_ACTION_FISHING, from fish_cast_rod or fish_got_bite until
|
||||||
* PutTheRodAway: the fishing frames replace the standing ones. */
|
* PutTheRodAway: the fishing frames replace the standing ones. */
|
||||||
fishing?: boolean;
|
fishing?: boolean;
|
||||||
@@ -2548,6 +2561,7 @@ export class GameWorld {
|
|||||||
script.run.state.events.delete(object.eventFlag);
|
script.run.state.events.delete(object.eventFlag);
|
||||||
else script.run.state.events.add(object.eventFlag);
|
else script.run.state.events.add(object.eventFlag);
|
||||||
}
|
}
|
||||||
|
this.actor(object).toggled = true;
|
||||||
if (effect.kind === "appear") {
|
if (effect.kind === "appear") {
|
||||||
this.actor(object).hidden = false;
|
this.actor(object).hidden = false;
|
||||||
if (this.assets.sprite(this.spriteOf(object)))
|
if (this.assets.sprite(this.spriteOf(object)))
|
||||||
@@ -4507,14 +4521,25 @@ export class GameWorld {
|
|||||||
rivalName: state.rivalName,
|
rivalName: state.rivalName,
|
||||||
clock: { ...state.clock },
|
clock: { ...state.clock },
|
||||||
intro: this.introPending,
|
intro: this.introPending,
|
||||||
objects: [...this.actors]
|
objects: this.map.objects
|
||||||
.filter(([, actor]) => actor.moved)
|
.flatMap((object) => {
|
||||||
.map(([id, actor]) => ({
|
const actor = this.actors.get(object.id);
|
||||||
id,
|
const spawned = this.spawned.has(object.id),
|
||||||
x: actor.x,
|
flagged = this.visible(object, state),
|
||||||
y: actor.y,
|
differs = Boolean(actor?.toggled) && spawned !== flagged;
|
||||||
direction: actor.direction,
|
if (!actor?.moved && !differs) return [];
|
||||||
}))
|
const { x, y, direction } = actor ?? spawnActor(object);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: object.id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
direction,
|
||||||
|
...(differs && !spawned && { hidden: true as const }),
|
||||||
|
...(differs && spawned && { shown: true as const }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
.sort((a, b) => a.id - b.id),
|
.sort((a, b) => a.id - b.id),
|
||||||
playerId: this.playerId,
|
playerId: this.playerId,
|
||||||
party: structuredClone(state.party),
|
party: structuredClone(state.party),
|
||||||
@@ -4746,7 +4771,9 @@ export class GameWorld {
|
|||||||
!map.objects.some((candidate) => candidate.id === object.id) ||
|
!map.objects.some((candidate) => candidate.id === object.id) ||
|
||||||
!integer(object.x, 0, 127) ||
|
!integer(object.x, 0, 127) ||
|
||||||
!integer(object.y, 0, 127) ||
|
!integer(object.y, 0, 127) ||
|
||||||
!DIRECTION_ORDER.includes(object.direction)
|
!DIRECTION_ORDER.includes(object.direction) ||
|
||||||
|
(Object.hasOwn(object, "hidden") && object.hidden !== true) ||
|
||||||
|
(Object.hasOwn(object, "shown") && object.shown !== true)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid native save. A saved object is not on this map.",
|
"Invalid native save. A saved object is not on this map.",
|
||||||
@@ -4853,6 +4880,9 @@ export class GameWorld {
|
|||||||
// Saved objects were placed by scripts; they roam from there.
|
// Saved objects were placed by scripts; they roam from there.
|
||||||
if (actor.npc)
|
if (actor.npc)
|
||||||
Object.assign(actor.npc, { homeX: object.x, homeY: object.y });
|
Object.assign(actor.npc, { homeX: object.x, homeY: object.y });
|
||||||
|
if (object.hidden) this.spawned.delete(object.id);
|
||||||
|
if (object.shown) this.spawned.add(object.id);
|
||||||
|
if (object.hidden || object.shown) actor.toggled = true;
|
||||||
}
|
}
|
||||||
this.player = {
|
this.player = {
|
||||||
x: save.x,
|
x: save.x,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ test("the Map tab shows the Johto atlas with the player and open areas", async (
|
|||||||
await expect(open.first()).toHaveText("NEW BARK TOWN");
|
await expect(open.first()).toHaveText("NEW BARK TOWN");
|
||||||
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
|
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
|
||||||
await expect(page.locator("#atlas-open")).toContainText("AZALEA TOWN");
|
await expect(page.locator("#atlas-open")).toContainText("AZALEA TOWN");
|
||||||
await expect(page.locator("#atlas-open")).not.toContainText("BLACKTHORN CITY");
|
await expect(page.locator("#atlas-open")).toContainText("BLACKTHORN CITY");
|
||||||
|
// Mt. Silver's cave belongs to Kanto's postgame (board #33).
|
||||||
|
await expect(page.locator("#atlas-open")).not.toContainText("SILVER CAVE");
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
+4
-1
@@ -18,6 +18,7 @@ const NEW_BARK_TOWN = "24:4",
|
|||||||
LANDMARK_VIOLET_CITY = 6,
|
LANDMARK_VIOLET_CITY = 6,
|
||||||
LANDMARK_AZALEA_TOWN = 12,
|
LANDMARK_AZALEA_TOWN = 12,
|
||||||
LANDMARK_BLACKTHORN_CITY = 0x29,
|
LANDMARK_BLACKTHORN_CITY = 0x29,
|
||||||
|
LANDMARK_SILVER_CAVE = 0x2e,
|
||||||
LANDMARK_PALLET_TOWN = 47;
|
LANDMARK_PALLET_TOWN = 47;
|
||||||
|
|
||||||
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/maps/landmarks.asm`))(
|
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/maps/landmarks.asm`))(
|
||||||
@@ -100,7 +101,9 @@ describe.skipIf(!existsSync(romPath))("the region atlas", () => {
|
|||||||
expect(at(LANDMARK_ROUTE_29)).toMatchObject({ open: true });
|
expect(at(LANDMARK_ROUTE_29)).toMatchObject({ open: true });
|
||||||
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
|
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
|
||||||
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: true, flypoint: { visited: false } });
|
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||||
expect(at(LANDMARK_BLACKTHORN_CITY)).toMatchObject({ open: false, flypoint: { visited: false } });
|
expect(at(LANDMARK_BLACKTHORN_CITY)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||||
|
// Mt. Silver's cave belongs to Kanto's postgame (board #33).
|
||||||
|
expect(at(LANDMARK_SILVER_CAVE)).toMatchObject({ open: false, flypoint: { visited: false } });
|
||||||
// Kanto stays closed, so asking for it shows Johto.
|
// Kanto stays closed, so asking for it shows Johto.
|
||||||
expect(world.atlas("kanto").region).toBe("johto");
|
expect(world.atlas("kanto").region).toBe("johto");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { GameWorld } from "../src/game/world/world";
|
||||||
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
|
import { settle } from "./world-driver";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("Blackthorn City with the local ROM", () => {
|
||||||
|
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||||
|
|
||||||
|
const MOVE_DELETERS_HOUSE = "5:7",
|
||||||
|
DRAGON_SHRINE = "3:82";
|
||||||
|
const TYPHLOSION = 157,
|
||||||
|
DRATINI = 147,
|
||||||
|
SCENE_DRAGONSHRINE_NOOP = 1;
|
||||||
|
const WRAP = 35,
|
||||||
|
LEER = 43,
|
||||||
|
THUNDER_WAVE = 86,
|
||||||
|
TWISTER = 239,
|
||||||
|
EXTREMESPEED = 245;
|
||||||
|
const EVENT_GOT_DRATINI = 0xbd,
|
||||||
|
EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 0xc1;
|
||||||
|
|
||||||
|
const world = () => {
|
||||||
|
let seed = 13;
|
||||||
|
return new GameWorld(
|
||||||
|
new CrystalAssets(bytes),
|
||||||
|
() => new Date("2026-09-22T12:00:00"),
|
||||||
|
() => {
|
||||||
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||||
|
return (seed >>> 16) & 255;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const mon = (game: GameWorld, species: number, level = 30) =>
|
||||||
|
generatePartyMon(game.assets.pokemon, {
|
||||||
|
species,
|
||||||
|
level,
|
||||||
|
item: 0,
|
||||||
|
otId: game.playerId,
|
||||||
|
ot: "KAWA",
|
||||||
|
dvs: 0x8888,
|
||||||
|
caughtData: 0,
|
||||||
|
});
|
||||||
|
/** Enter `map`, stand at (x, y) facing `face`, press A and answer. */
|
||||||
|
const talk = (
|
||||||
|
game: GameWorld,
|
||||||
|
map: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
face: "up" | "down" | "left" | "right",
|
||||||
|
answers: (number | string)[] = [],
|
||||||
|
) => {
|
||||||
|
game.warpTo(map, x, y);
|
||||||
|
settle(game);
|
||||||
|
game.restore({ ...game.save(), x, y, direction: face });
|
||||||
|
game.interact();
|
||||||
|
const shown = settle(game, answers).join(" ");
|
||||||
|
expect(game.notice, game.diagnostic).toBe("");
|
||||||
|
return shown;
|
||||||
|
};
|
||||||
|
/** The Move Deleter stands at (2, 3). */
|
||||||
|
const deleter = (game: GameWorld, answers: number[]) =>
|
||||||
|
talk(game, MOVE_DELETERS_HOUSE, 2, 4, "up", answers);
|
||||||
|
|
||||||
|
describe("the Move Deleter (MoveDeletion)", () => {
|
||||||
|
it("deletes the chosen move and moves the later moves and PP up", () => {
|
||||||
|
const game = world();
|
||||||
|
const typhlosion = mon(game, TYPHLOSION);
|
||||||
|
typhlosion.moves = [33, 43, 108, 52];
|
||||||
|
typhlosion.pp = [35, 30 | 0xc0, 20, 25 | 0x40];
|
||||||
|
game.state.party = [typhlosion];
|
||||||
|
// YES, the first Pokémon, its second move (LEER), YES.
|
||||||
|
const shown = deleter(game, [1, 0, 1, 1]);
|
||||||
|
expect(shown).toContain("the MOVE DELETER.");
|
||||||
|
expect(shown).toContain("Oh, make it forget\nLEER?");
|
||||||
|
expect(shown).toContain("forgot the move.");
|
||||||
|
// The PP Up bits move with each move's PP.
|
||||||
|
expect(game.state.party[0].moves).toEqual([33, 108, 52, 0]);
|
||||||
|
expect(game.state.party[0].pp).toEqual([35, 20, 25 | 0x40, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a Pokémon with one move and an Egg, and says goodbye to every no", () => {
|
||||||
|
const game = world();
|
||||||
|
const one = mon(game, TYPHLOSION);
|
||||||
|
one.moves = [33, 0, 0, 0];
|
||||||
|
one.pp = [35, 0, 0, 0];
|
||||||
|
const egg = { ...mon(game, TYPHLOSION, 5), egg: true };
|
||||||
|
game.state.party = [one, egg, mon(game, TYPHLOSION)];
|
||||||
|
expect(deleter(game, [1, 0])).toContain("knows\nonly one move.");
|
||||||
|
expect(deleter(game, [1, 1])).toContain("An EGG doesn't");
|
||||||
|
const before = structuredClone(game.state.party[2]);
|
||||||
|
expect(deleter(game, [0])).toContain("Come visit me");
|
||||||
|
expect(deleter(game, [1, -1])).toContain("Come visit me");
|
||||||
|
expect(deleter(game, [1, 2, -1])).toContain("Come visit me");
|
||||||
|
expect(deleter(game, [1, 2, 0, 0])).toContain("Come visit me");
|
||||||
|
expect(game.state.party[2]).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the Dragon Shrine elder's Dratini (GiveDratini)", () => {
|
||||||
|
/** After the test (the shrine's scene done), talk to the elder. */
|
||||||
|
const elder = (game: GameWorld) => {
|
||||||
|
game.state.scenes[DRAGON_SHRINE] = SCENE_DRAGONSHRINE_NOOP;
|
||||||
|
return talk(game, DRAGON_SHRINE, 5, 2, "up", [0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("gives a Dratini with Extremespeed after a test without mistakes", () => {
|
||||||
|
const game = world();
|
||||||
|
game.state.party = [mon(game, TYPHLOSION)];
|
||||||
|
const shown = elder(game);
|
||||||
|
expect(shown).toContain("Take this DRATINI");
|
||||||
|
expect(game.state.events.has(EVENT_GOT_DRATINI)).toBe(true);
|
||||||
|
const dratini = game.state.party[1];
|
||||||
|
expect(dratini).toMatchObject({ species: DRATINI, level: 15 });
|
||||||
|
expect(dratini.moves).toEqual([WRAP, THUNDER_WAVE, TWISTER, EXTREMESPEED]);
|
||||||
|
// Each move's base PP, as .GiveMoves reads it from Moves.
|
||||||
|
expect(dratini.pp).toEqual([20, 20, 20, 5]);
|
||||||
|
// Re-entering clears EVENT_TEMPORARY_UNTIL_MAP_RELOAD_7.
|
||||||
|
expect(elder(game)).toContain("CLAIR appears to");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the normal moves after a mistake, to the last Dratini only", () => {
|
||||||
|
const game = world();
|
||||||
|
game.state.events.add(EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG);
|
||||||
|
const earlier = mon(game, DRATINI, 20);
|
||||||
|
game.state.party = [earlier, mon(game, TYPHLOSION)];
|
||||||
|
const before = structuredClone(earlier);
|
||||||
|
elder(game);
|
||||||
|
expect(game.state.party[2].moves).toEqual([WRAP, LEER, THUNDER_WAVE, TWISTER]);
|
||||||
|
expect(game.state.party[2].pp).toEqual([20, 30, 20, 20]);
|
||||||
|
expect(game.state.party[0]).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Dratini for later when the party is full", () => {
|
||||||
|
const game = world();
|
||||||
|
game.state.party = Array.from({ length: 6 }, () => mon(game, TYPHLOSION));
|
||||||
|
expect(elder(game)).toContain("party is full.");
|
||||||
|
expect(game.state.events.has(EVENT_GOT_DRATINI)).toBe(false);
|
||||||
|
game.state.party.pop();
|
||||||
|
elder(game);
|
||||||
|
expect(game.state.party[5].species).toBe(DRATINI);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ const NEW_BARK_TOWN = "24:4",
|
|||||||
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
|
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
|
||||||
const SPAWN_NEW_BARK = 14,
|
const SPAWN_NEW_BARK = 14,
|
||||||
SPAWN_CHERRYGROVE = 15,
|
SPAWN_CHERRYGROVE = 15,
|
||||||
SPAWN_BLACKTHORN = 25;
|
SPAWN_MT_SILVER = 26;
|
||||||
const ENGINE_STORMBADGE = 32,
|
const ENGINE_STORMBADGE = 32,
|
||||||
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
|
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
|
||||||
const FLY = 0x13,
|
const FLY = 0x13,
|
||||||
@@ -203,11 +203,11 @@ describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope",
|
|||||||
it("refuses to fly to a flypoint that is not ported", () => {
|
it("refuses to fly to a flypoint that is not ported", () => {
|
||||||
const { world } = fixture([FLY], ROUTE_29, 50, 10);
|
const { world } = fixture([FLY], ROUTE_29, 50, 10);
|
||||||
world.state.flags.add(ENGINE_STORMBADGE);
|
world.state.flags.add(ENGINE_STORMBADGE);
|
||||||
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_BLACKTHORN);
|
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_MT_SILVER);
|
||||||
useMove(world, "FLY");
|
useMove(world, "FLY");
|
||||||
for (let press = 0; press < 4 && flyMap(world)?.name !== "BLACKTHORN CITY"; press++)
|
for (let press = 0; press < 4 && flyMap(world)?.name !== "SILVER CAVE"; press++)
|
||||||
world.moveCursor("down");
|
world.moveCursor("down");
|
||||||
expect(flyMap(world)?.name).toBe("BLACKTHORN CITY");
|
expect(flyMap(world)?.name).toBe("SILVER CAVE");
|
||||||
world.choose("a");
|
world.choose("a");
|
||||||
until(world);
|
until(world);
|
||||||
expect(world.notice).toBe("This destination is not ported yet.");
|
expect(world.notice).toBe("This destination is not ported yet.");
|
||||||
|
|||||||
+123
-13
@@ -8,7 +8,7 @@ import type { Direction } from "../src/game/world/types";
|
|||||||
import { C } from "../src/game/battle/constants";
|
import { C } from "../src/game/battle/constants";
|
||||||
import { applyTypeMatchups } from "../src/game/battle/battle";
|
import { applyTypeMatchups } from "../src/game/battle/battle";
|
||||||
import { BattleData } from "../src/game/battle/data";
|
import { BattleData } from "../src/game/battle/data";
|
||||||
import { afterRestore, findPath, landing } from "./world-driver";
|
import { afterRestore, findPath, isWhirlpool, landing } from "./world-driver";
|
||||||
|
|
||||||
const STEP: Record<Direction, [number, number]> = {
|
const STEP: Record<Direction, [number, number]> = {
|
||||||
up: [0, -1],
|
up: [0, -1],
|
||||||
@@ -93,8 +93,8 @@ export class Playthrough {
|
|||||||
* the bag through the battle's own item action (BattleMenu_Pack), as a
|
* the bag through the battle's own item action (BattleMenu_Pack), as a
|
||||||
* cautious player who restocked before a hard fight would: a carried
|
* cautious player who restocked before a hard fight would: a carried
|
||||||
* Awakening wakes the active Pokémon before Sleep/Dream Eater can lock
|
* Awakening wakes the active Pokémon before Sleep/Dream Eater can lock
|
||||||
* it out of a whole fight, and a carried Super Potion heals it below
|
* it out of a whole fight, and the strongest carried potion heals it
|
||||||
* half HP. New moves are learned when there is room, and other
|
* below half HP. New moves are learned when there is room, and other
|
||||||
* questions take the queued answers or their defaults. */
|
* questions take the queued answers or their defaults. */
|
||||||
play = (answers: (number | string)[] = []) => {
|
play = (answers: (number | string)[] = []) => {
|
||||||
const world = this.world,
|
const world = this.world,
|
||||||
@@ -112,9 +112,10 @@ export class Playthrough {
|
|||||||
const ball = world.liveState.bag.balls.findIndex(
|
const ball = world.liveState.bag.balls.findIndex(
|
||||||
(slot) => slot.item === C.POKE_BALL || slot.item === C.GREAT_BALL,
|
(slot) => slot.item === C.POKE_BALL || slot.item === C.GREAT_BALL,
|
||||||
);
|
);
|
||||||
const potion = world.liveState.bag.items.findIndex(
|
// The strongest potion carried: Max, then Hyper, then Super.
|
||||||
(slot) => slot.item === C.SUPER_POTION,
|
const potion = [C.MAX_POTION, C.HYPER_POTION, C.SUPER_POTION]
|
||||||
);
|
.map((item) => world.liveState.bag.items.findIndex((slot) => slot.item === item))
|
||||||
|
.find((index) => index >= 0) ?? -1;
|
||||||
const awakening = world.liveState.bag.items.findIndex(
|
const awakening = world.liveState.bag.items.findIndex(
|
||||||
(slot) => slot.item === C.AWAKENING,
|
(slot) => slot.item === C.AWAKENING,
|
||||||
);
|
);
|
||||||
@@ -142,7 +143,11 @@ export class Playthrough {
|
|||||||
mon.hp * 2 < mon.maxHp &&
|
mon.hp * 2 < mon.maxHp &&
|
||||||
potion >= 0
|
potion >= 0
|
||||||
)
|
)
|
||||||
world.battleAction({ action: "item", item: C.SUPER_POTION, slot: potion });
|
world.battleAction({
|
||||||
|
action: "item",
|
||||||
|
item: world.liveState.bag.items[potion].item,
|
||||||
|
slot: potion,
|
||||||
|
});
|
||||||
else
|
else
|
||||||
world.battleAction({
|
world.battleAction({
|
||||||
action: "fight",
|
action: "fight",
|
||||||
@@ -240,18 +245,27 @@ export class Playthrough {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
/** Walk and Surf to a tile: where a step leaves the shore, face the
|
/** Walk and Surf to a tile: where a step leaves the shore, face the
|
||||||
* water and answer AskSurfScript's question with YES. */
|
* water and answer AskSurfScript's question with YES; a whirlpool in
|
||||||
sail = (x: number, y: number) => {
|
* the way is faced and cleared (TryWhirlpoolOW, then YES) when
|
||||||
|
* `whirlpools` is set. */
|
||||||
|
sail = (x: number, y: number, { whirlpools = false } = {}) => {
|
||||||
const world = this.world,
|
const world = this.world,
|
||||||
map = world.mapId;
|
map = world.mapId;
|
||||||
for (let attempt = 0; attempt < 40; attempt++) {
|
for (let attempt = 0; attempt < 40; attempt++) {
|
||||||
this.play();
|
this.play();
|
||||||
expect(world.mapId, `sailing to ${x},${y}`).toBe(map);
|
expect(world.mapId, `sailing to ${x},${y}`).toBe(map);
|
||||||
if (world.x === x && world.y === y) return;
|
if (world.x === x && world.y === y) return;
|
||||||
for (const direction of findPath(world, x, y, { water: true })) {
|
for (const direction of findPath(world, x, y, { water: true, whirlpools })) {
|
||||||
const [dx, dy] = STEP[direction];
|
const [dx, dy] = STEP[direction];
|
||||||
const from = [world.x, world.y];
|
const from = [world.x, world.y];
|
||||||
const ahead = world.assets.collision(world.map, from[0] + dx, from[1] + dy);
|
const ahead = world.assets.collision(world.map, from[0] + dx, from[1] + dy);
|
||||||
|
if (isWhirlpool(ahead)) {
|
||||||
|
// Stepping onto a whirlpool spins the player back: turn first.
|
||||||
|
if (world.save().direction !== direction) world.move(direction);
|
||||||
|
world.interact();
|
||||||
|
this.play([1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (world.state.playerState !== PLAYER_SURF && world.assets.permission(ahead) === 1) {
|
if (world.state.playerState !== PLAYER_SURF && world.assets.permission(ahead) === 1) {
|
||||||
world.move(direction); // only turns to face the water
|
world.move(direction); // only turns to face the water
|
||||||
world.interact();
|
world.interact();
|
||||||
@@ -596,8 +610,8 @@ export class Playthrough {
|
|||||||
this.play();
|
this.play();
|
||||||
};
|
};
|
||||||
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
|
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
|
||||||
* a full move list forgets the first move. */
|
* a full move list forgets move `forget` (the first by default). */
|
||||||
teach = (item: number, slot: number) => {
|
teach = (item: number, slot: number, forget = 0) => {
|
||||||
const world = this.world;
|
const world = this.world;
|
||||||
expect(world.openStartMenu()).toBe(true);
|
expect(world.openStartMenu()).toBe(true);
|
||||||
this.choose("PACK");
|
this.choose("PACK");
|
||||||
@@ -612,7 +626,7 @@ export class Playthrough {
|
|||||||
expect(this.toChoice().kind).toBe("yesno");
|
expect(this.toChoice().kind).toBe("yesno");
|
||||||
world.choose(1);
|
world.choose(1);
|
||||||
this.toChoice();
|
this.toChoice();
|
||||||
world.choose(0);
|
world.choose(forget);
|
||||||
}
|
}
|
||||||
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++)
|
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++)
|
||||||
this.toChoice();
|
this.toChoice();
|
||||||
@@ -623,6 +637,102 @@ export class Playthrough {
|
|||||||
}
|
}
|
||||||
this.play();
|
this.play();
|
||||||
};
|
};
|
||||||
|
/** Push Strength boulder `id` one tile in `direction` from its far
|
||||||
|
* side: the first bump only turns the player, a boulder Strength is not
|
||||||
|
* yet active for asks first (AskStrengthScript, then YES), and the
|
||||||
|
* boulder moves on its own movement function a few frames later. */
|
||||||
|
push = (id: number, direction: Direction) => {
|
||||||
|
const world = this.world;
|
||||||
|
const boulder = () => world.objects().find((object) => object.id === id);
|
||||||
|
const from = boulder()!;
|
||||||
|
const [dx, dy] = STEP[direction];
|
||||||
|
this.walk(from.x - dx, from.y - dy);
|
||||||
|
const moved = () => {
|
||||||
|
const now = boulder();
|
||||||
|
return !now || now.x !== from.x || now.y !== from.y;
|
||||||
|
};
|
||||||
|
for (let attempt = 0; attempt < 3 && !moved(); attempt++) {
|
||||||
|
world.move(direction);
|
||||||
|
for (let tick = 0; tick < 40; tick++) world.tick();
|
||||||
|
this.play();
|
||||||
|
if (moved()) break;
|
||||||
|
world.interact();
|
||||||
|
this.play([1]);
|
||||||
|
}
|
||||||
|
expect(moved(), `boulder ${id} pushed ${direction}`).toBe(true);
|
||||||
|
};
|
||||||
|
/** Push Strength boulders into their holes: each goal boulder must reach
|
||||||
|
* its own stone-table warp (a boulder on another pit only stops there),
|
||||||
|
* and `movable` boulders may be pushed out of the way. The pushes are
|
||||||
|
* planned breadth first over boulder positions and the player's reachable
|
||||||
|
* area, then made one by one with `push`. */
|
||||||
|
boulders = (goals: { id: number; x: number; y: number }[], movable: number[] = []) => {
|
||||||
|
const world = this.world,
|
||||||
|
map = world.map;
|
||||||
|
const ids = [...goals.map((goal) => goal.id), ...movable];
|
||||||
|
const land = (x: number, y: number) =>
|
||||||
|
x >= 0 &&
|
||||||
|
y >= 0 &&
|
||||||
|
x < map.width &&
|
||||||
|
y < map.height &&
|
||||||
|
world.assets.permission(world.assets.collision(map, x, y)) === 0;
|
||||||
|
const warps = new Set(map.warps.map((warp) => `${warp.x},${warp.y}`));
|
||||||
|
const fixed = new Set(
|
||||||
|
world
|
||||||
|
.objects()
|
||||||
|
.filter((object) => !ids.includes(object.id))
|
||||||
|
.map((object) => `${object.x},${object.y}`),
|
||||||
|
);
|
||||||
|
const at = (id: number) => {
|
||||||
|
const object = world.objects().find((candidate) => candidate.id === id);
|
||||||
|
return object ? `${object.x},${object.y}` : "gone";
|
||||||
|
};
|
||||||
|
/** The player's reachable tiles, walking around boulders and pits. */
|
||||||
|
const reach = (x: number, y: number, boulders: string[]) => {
|
||||||
|
const blocked = new Set([...boulders, ...fixed]);
|
||||||
|
const seen = new Set([`${x},${y}`]);
|
||||||
|
const queue = [[x, y]];
|
||||||
|
for (let i = 0; i < queue.length; i++)
|
||||||
|
for (const [dx, dy] of Object.values(STEP)) {
|
||||||
|
const nx = queue[i][0] + dx,
|
||||||
|
ny = queue[i][1] + dy,
|
||||||
|
key = `${nx},${ny}`;
|
||||||
|
if (seen.has(key) || !land(nx, ny) || blocked.has(key) || warps.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
queue.push([nx, ny]);
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
};
|
||||||
|
type Node = { boulders: string[]; x: number; y: number; pushes: [number, Direction][] };
|
||||||
|
const queue: Node[] = [{ boulders: ids.map(at), x: world.x, y: world.y, pushes: [] }];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let plan: Node | undefined;
|
||||||
|
for (let i = 0; i < queue.length && i < 500_000 && !plan; i++) {
|
||||||
|
const node = queue[i];
|
||||||
|
const area = reach(node.x, node.y, node.boulders);
|
||||||
|
const key = `${node.boulders.join(";")}|${[...area].sort()[0]}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
if (goals.every((_, index) => node.boulders[index] === "gone")) plan = node;
|
||||||
|
node.boulders.forEach((boulder, index) => {
|
||||||
|
if (boulder === "gone") return;
|
||||||
|
const [bx, by] = boulder.split(",").map(Number);
|
||||||
|
for (const [direction, [dx, dy]] of Object.entries(STEP) as [Direction, [number, number]][]) {
|
||||||
|
const target = `${bx + dx},${by + dy}`;
|
||||||
|
if (!area.has(`${bx - dx},${by - dy}`)) continue;
|
||||||
|
if (!land(bx + dx, by + dy) || node.boulders.includes(target) || fixed.has(target)) continue;
|
||||||
|
const goal = goals[index];
|
||||||
|
const falls = goal && goal.x === bx + dx && goal.y === by + dy;
|
||||||
|
if (warps.has(target) && !falls) continue;
|
||||||
|
const boulders = [...node.boulders];
|
||||||
|
boulders[index] = falls ? "gone" : target;
|
||||||
|
queue.push({ boulders, x: bx, y: by, pushes: [...node.pushes, [ids[index], direction]] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(plan, "a way to push the boulders into their holes").toBeDefined();
|
||||||
|
for (const [id, direction] of plan!.pushes) this.push(id, direction);
|
||||||
|
};
|
||||||
/** Cut the tree at (x, y) from a walkable side (TryCutOW, then YES). */
|
/** Cut the tree at (x, y) from a walkable side (TryCutOW, then YES). */
|
||||||
cut = (x: number, y: number) => {
|
cut = (x: number, y: number) => {
|
||||||
const world = this.world;
|
const world = this.world;
|
||||||
|
|||||||
@@ -172,6 +172,41 @@ const RED_SCALE = 0x42,
|
|||||||
EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 0x6ce,
|
EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 0x6ce,
|
||||||
ENGINE_GLACIERBADGE = 0x21,
|
ENGINE_GLACIERBADGE = 0x21,
|
||||||
ENGINE_ROCKETS_IN_RADIO_TOWER = 0x13;
|
ENGINE_ROCKETS_IN_RADIO_TOWER = 0x13;
|
||||||
|
const RADIO_TOWER = ["3:17", "3:18", "3:19", "3:20", "3:21"],
|
||||||
|
UNDERGROUND = "3:53",
|
||||||
|
SWITCH_ROOM = "3:54",
|
||||||
|
DEPT_STORE_B1F = "3:55",
|
||||||
|
WAREHOUSE = "3:56",
|
||||||
|
DEPT_STORE_1F = "11:11",
|
||||||
|
ELEVATOR = "11:17",
|
||||||
|
ROUTE_44 = "2:6",
|
||||||
|
ICE_PATH = ["3:61", "3:62", "3:63", "3:64", "3:65"],
|
||||||
|
BLACKTHORN = "5:10",
|
||||||
|
BLACKTHORN_CENTER = "5:6",
|
||||||
|
BLACKTHORN_MART = "5:5",
|
||||||
|
BLACKTHORN_GYM = ["5:1", "5:2"],
|
||||||
|
ROUTE_45 = "5:8",
|
||||||
|
DRAGONS_DEN = ["3:80", "3:81"],
|
||||||
|
DRAGON_SHRINE = "3:82";
|
||||||
|
const BASEMENT_KEY = 0x85,
|
||||||
|
CARD_KEY = 0x7f,
|
||||||
|
CLEAR_BELL = 0x46,
|
||||||
|
HM06 = 0xf8,
|
||||||
|
DRATINI = 147,
|
||||||
|
EXTREMESPEED = 245,
|
||||||
|
EVENT_BEAT_ROCKET_EXECUTIVEM_3 = 0x573,
|
||||||
|
EVENT_USED_BASEMENT_KEY = 0x49,
|
||||||
|
EVENT_RIVAL_GOLDENROD_UNDERGROUND = 0x6c1,
|
||||||
|
EVENT_RECEIVED_CARD_KEY = 0x4a,
|
||||||
|
EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER = 0x25,
|
||||||
|
EVENT_CLEARED_RADIO_TOWER = 0x21,
|
||||||
|
EVENT_GOT_CLEAR_BELL = 0x78,
|
||||||
|
EVENT_BOULDER_IN_ICE_PATH_2A = 0x70e,
|
||||||
|
EVENT_BEAT_CLAIR = 0x4c4,
|
||||||
|
EVENT_GOT_TM24_DRAGONBREATH = 0xf,
|
||||||
|
EVENT_GOT_DRATINI = 0xbd,
|
||||||
|
EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 0xc1,
|
||||||
|
ENGINE_RISINGBADGE = 0x22;
|
||||||
|
|
||||||
/** The acceptance run for PLAN section 2: a new game played from the
|
/** The acceptance run for PLAN section 2: a new game played from the
|
||||||
* introduction to the catching tutorial through the world's own inputs,
|
* introduction to the catching tutorial through the world's own inputs,
|
||||||
@@ -1185,5 +1220,216 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
reload();
|
reload();
|
||||||
writeTestSave("mahogany-town");
|
writeTestSave("mahogany-town");
|
||||||
}, 240_000);
|
}, 240_000);
|
||||||
|
/** Mahogany to Goldenrod the long way round: Route 42, Ecruteak,
|
||||||
|
* Routes 37 and 36, the National Park and Route 35. */
|
||||||
|
const mahoganyToGoldenrod = () => {
|
||||||
|
cross("left", ROUTE_42, { water: true });
|
||||||
|
sail(2, 8); // Route 42's pond, then ashore by the gate
|
||||||
|
stairs(ROUTE_42_GATE);
|
||||||
|
stairs(ECRUTEAK);
|
||||||
|
cross("down", ROUTE_37);
|
||||||
|
cross("down", ROUTE_36);
|
||||||
|
stairs(ROUTE_36_NATIONAL_PARK_GATE);
|
||||||
|
stairs(NATIONAL_PARK);
|
||||||
|
stairs(ROUTE_35_NATIONAL_PARK_GATE);
|
||||||
|
stairs(ROUTE_35);
|
||||||
|
stairs(ROUTE_35_GATE);
|
||||||
|
stairs(GOLDENROD);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("walks back to Goldenrod and climbs the Rocket-held Radio Tower to the fake director", () => {
|
||||||
|
traceFailures();
|
||||||
|
mahoganyToGoldenrod();
|
||||||
|
heal(GOLDENROD_CENTER, GOLDENROD);
|
||||||
|
enter(RADIO_TOWER[0], "up");
|
||||||
|
for (const floor of RADIO_TOWER.slice(1)) stairs(floor);
|
||||||
|
walk(0, 3); // FakeDirectorScript
|
||||||
|
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEM_3)).toBe(true);
|
||||||
|
expect(p.world.state.bag.keyItems).toContain(BASEMENT_KEY);
|
||||||
|
expect(p.log.join(" ")).toContain("UNDERGROUND\nWAREHOUSE.");
|
||||||
|
travel(GOLDENROD, RADIO_TOWER);
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("opens the basement door, works the Underground switches and frees the director", () => {
|
||||||
|
traceFailures();
|
||||||
|
// Fresh PP before the rival waiting in the switch room.
|
||||||
|
heal(GOLDENROD_CENTER, GOLDENROD);
|
||||||
|
stairs(SWITCH_ROOM);
|
||||||
|
stairs(UNDERGROUND);
|
||||||
|
walk(18, 7);
|
||||||
|
p.world.move("up");
|
||||||
|
p.world.interact();
|
||||||
|
play();
|
||||||
|
expect(p.world.state.events.has(EVENT_USED_BASEMENT_KEY)).toBe(true);
|
||||||
|
// The door leads to the Underground's other corner.
|
||||||
|
p.world.move("up");
|
||||||
|
play();
|
||||||
|
stairs(SWITCH_ROOM);
|
||||||
|
// From all off, switches 3, 2 and 1 leave doors 3, 5, 6, 8, 9 and 11
|
||||||
|
// open: a way from the switches down to the warehouse.
|
||||||
|
for (const [x, y] of [[2, 1], [10, 1], [16, 1]]) {
|
||||||
|
walk(x, y + 1);
|
||||||
|
p.world.move("up");
|
||||||
|
p.world.interact();
|
||||||
|
play([1]);
|
||||||
|
}
|
||||||
|
expect(p.world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 6 });
|
||||||
|
expect(p.world.state.events.has(EVENT_RIVAL_GOLDENROD_UNDERGROUND)).toBe(true);
|
||||||
|
stairs(WAREHOUSE);
|
||||||
|
// The warehouse resets the switches when entered.
|
||||||
|
expect(p.world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 0 });
|
||||||
|
talkTo(p.world.map.objects[3].id);
|
||||||
|
play();
|
||||||
|
expect(p.world.state.events.has(EVENT_RECEIVED_CARD_KEY)).toBe(true);
|
||||||
|
expect(p.world.state.bag.keyItems).toContain(CARD_KEY);
|
||||||
|
// Out through the department store's basement and elevator.
|
||||||
|
stairs(DEPT_STORE_B1F);
|
||||||
|
stairs(ELEVATOR);
|
||||||
|
walk(3, 1);
|
||||||
|
p.world.move("up");
|
||||||
|
p.world.interact();
|
||||||
|
play([1]); // 1F
|
||||||
|
walk(2, 3);
|
||||||
|
p.world.move("down");
|
||||||
|
play();
|
||||||
|
expect(p.world.mapId).toBe(DEPT_STORE_1F);
|
||||||
|
exit(GOLDENROD);
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("opens the 3F shutters with the Card Key and beats the executive for the Clear Bell", () => {
|
||||||
|
traceFailures();
|
||||||
|
heal(GOLDENROD_CENTER, GOLDENROD);
|
||||||
|
enter(RADIO_TOWER[0], "up");
|
||||||
|
stairs(RADIO_TOWER[1]);
|
||||||
|
stairs(RADIO_TOWER[2]);
|
||||||
|
walk(14, 3); // CardKeySlotScript faces up
|
||||||
|
p.world.move("up");
|
||||||
|
p.world.interact();
|
||||||
|
play();
|
||||||
|
expect(p.world.state.events.has(EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER)).toBe(true);
|
||||||
|
walk(17, 0); // the stairs behind the shutters
|
||||||
|
expect(p.world.mapId).toBe(RADIO_TOWER[3]);
|
||||||
|
stairs(RADIO_TOWER[4]);
|
||||||
|
walk(16, 5); // RadioTower5FRocketBossScript
|
||||||
|
expect(p.world.state.events.has(EVENT_CLEARED_RADIO_TOWER)).toBe(true);
|
||||||
|
expect(p.world.state.flags.has(ENGINE_ROCKETS_IN_RADIO_TOWER)).toBe(false);
|
||||||
|
expect(p.world.state.events.has(EVENT_GOT_CLEAR_BELL)).toBe(true);
|
||||||
|
expect(p.world.state.bag.keyItems).toContain(CLEAR_BELL);
|
||||||
|
// The director walks out and disappears; a save keeps him gone.
|
||||||
|
reload();
|
||||||
|
travel(RADIO_TOWER[0], RADIO_TOWER.slice(1));
|
||||||
|
exit(GOLDENROD);
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("crosses Route 44 and Ice Path's boulders to Blackthorn City", () => {
|
||||||
|
traceFailures();
|
||||||
|
toRoute36();
|
||||||
|
cross("up", ROUTE_37);
|
||||||
|
cross("up", ECRUTEAK);
|
||||||
|
stairs(ROUTE_42_GATE);
|
||||||
|
stairs(ROUTE_42);
|
||||||
|
cross("right", MAHOGANY, { water: true });
|
||||||
|
heal(MAHOGANY_CENTER, MAHOGANY);
|
||||||
|
cross("right", ROUTE_44);
|
||||||
|
stairs(ICE_PATH[0]);
|
||||||
|
stairs(ICE_PATH[1]);
|
||||||
|
// B1F: drop boulder 2 down its hole, then fall through another; on
|
||||||
|
// B2F's ice it stops a slide on the way to B3F.
|
||||||
|
p.boulders([{ id: 3, x: 4, y: 7 }], [2]);
|
||||||
|
expect(p.world.state.events.has(EVENT_BOULDER_IN_ICE_PATH_2A)).toBe(false);
|
||||||
|
walk(5, 12);
|
||||||
|
expect(p.world.mapId).toBe(ICE_PATH[2]);
|
||||||
|
walk(9, 11);
|
||||||
|
expect(p.world.mapId).toBe(ICE_PATH[4]);
|
||||||
|
travel(BLACKTHORN, [ICE_PATH[0], ICE_PATH[1], ICE_PATH[3], ICE_PATH[4]]);
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("trains on Route 45, drops the gym's boulders and beats Clair", () => {
|
||||||
|
traceFailures();
|
||||||
|
// Clair's Kingdra outclasses a level 53 lead; a player trains first.
|
||||||
|
cross("down", ROUTE_45);
|
||||||
|
train(62, () => {
|
||||||
|
cross("up", BLACKTHORN);
|
||||||
|
heal(BLACKTHORN_CENTER, BLACKTHORN);
|
||||||
|
cross("down", ROUTE_45);
|
||||||
|
});
|
||||||
|
cross("up", BLACKTHORN);
|
||||||
|
enter(BLACKTHORN_MART, "up");
|
||||||
|
buy(p.world.map.objects[0].id, C.MAX_POTION, 4);
|
||||||
|
exit(BLACKTHORN);
|
||||||
|
heal(BLACKTHORN_CENTER, BLACKTHORN);
|
||||||
|
enter(BLACKTHORN_GYM[0], "up");
|
||||||
|
stairs(BLACKTHORN_GYM[1]);
|
||||||
|
// Boulders 1 and 3 fall to 1F and bridge the way to Clair.
|
||||||
|
p.boulders(
|
||||||
|
[
|
||||||
|
{ id: 4, x: 8, y: 3 },
|
||||||
|
{ id: 6, x: 8, y: 7 },
|
||||||
|
],
|
||||||
|
[5, 7, 8, 9],
|
||||||
|
);
|
||||||
|
expect(route(BLACKTHORN_GYM[0], BLACKTHORN_GYM, 4, { x: 5, y: 4 })).toBe(true);
|
||||||
|
talkTo(p.world.map.objects[0].id);
|
||||||
|
play([1]);
|
||||||
|
expect(p.world.state.events.has(EVENT_BEAT_CLAIR)).toBe(true);
|
||||||
|
expect(p.world.state.flags.has(ENGINE_RISINGBADGE)).toBe(false);
|
||||||
|
expect(p.log.join(" ")).toContain("DRAGON'S DEN");
|
||||||
|
travel(BLACKTHORN, BLACKTHORN_GYM);
|
||||||
|
reload();
|
||||||
|
}, 360_000);
|
||||||
|
|
||||||
|
it("passes the Dragon Shrine's test for the RISINGBADGE, TM24 and Dratini", () => {
|
||||||
|
traceFailures();
|
||||||
|
heal(BLACKTHORN_CENTER, BLACKTHORN);
|
||||||
|
// Krabby learns Whirlpool in place of Leer for Dragon's Den.
|
||||||
|
p.teach(HM06, 4, 1);
|
||||||
|
expect(p.world.state.party[4].moves).toContain(250);
|
||||||
|
const den = p.world.map.warps.find((warp) => warp.targetMap === DRAGONS_DEN[0])!;
|
||||||
|
sail(den.x, den.y + 1);
|
||||||
|
p.world.move("up");
|
||||||
|
play();
|
||||||
|
walk(3, 4); // the ladder down to 1F's lower part
|
||||||
|
p.world.move("up");
|
||||||
|
play();
|
||||||
|
stairs(DRAGONS_DEN[1]);
|
||||||
|
sail(19, 31, { whirlpools: true });
|
||||||
|
walk(19, 30);
|
||||||
|
// DragonShrineTakeTestScript: five questions, all answered right.
|
||||||
|
p.world.move("up");
|
||||||
|
play([0, 0, 1, 0, 1]);
|
||||||
|
expect(p.world.mapId).toBe(DRAGON_SHRINE);
|
||||||
|
expect(p.world.state.flags.has(ENGINE_RISINGBADGE)).toBe(true);
|
||||||
|
expect(p.world.state.events.has(EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG)).toBe(false);
|
||||||
|
// Clair waits by the door with TM24 (DragonsDenB1F_ClairScene).
|
||||||
|
stairs(DRAGONS_DEN[1]);
|
||||||
|
expect(p.world.state.events.has(EVENT_GOT_TM24_DRAGONBREATH)).toBe(true);
|
||||||
|
expect(p.world.state.bag.tmsHms[23]).toBe(1); // TM24
|
||||||
|
// Back inside, the elder gives Dratini with Extremespeed.
|
||||||
|
p.world.move("up");
|
||||||
|
play();
|
||||||
|
talkTo(p.world.map.objects[0].id);
|
||||||
|
play();
|
||||||
|
expect(p.world.state.events.has(EVENT_GOT_DRATINI)).toBe(true);
|
||||||
|
const dratini = p.world.state.party.at(-1)!;
|
||||||
|
expect(dratini).toMatchObject({ species: DRATINI, level: 15 });
|
||||||
|
expect(dratini.moves).toEqual([35, 86, 239, EXTREMESPEED]);
|
||||||
|
expect(dratini.pp).toEqual([20, 20, 20, 5]);
|
||||||
|
// Out: the whirlpool is back after the map reload.
|
||||||
|
stairs(DRAGONS_DEN[1]);
|
||||||
|
sail(20, 4, { whirlpools: true });
|
||||||
|
p.world.move("up");
|
||||||
|
play();
|
||||||
|
walk(5, 14);
|
||||||
|
p.world.move("up");
|
||||||
|
play();
|
||||||
|
stairs(BLACKTHORN);
|
||||||
|
sail(17, 14);
|
||||||
|
reload();
|
||||||
|
writeTestSave("blackthorn-city");
|
||||||
|
}, 240_000);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { GameWorld } from "../src/game/world/world";
|
||||||
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
|
import { Playthrough } from "./playthrough-driver";
|
||||||
|
import { findPath } from "./world-driver";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("the Radio Tower takeover with the local ROM", () => {
|
||||||
|
const assets = existsSync(romPath)
|
||||||
|
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const SWITCH_ROOM = "3:54",
|
||||||
|
WAREHOUSE = "3:56",
|
||||||
|
RADIO_TOWER_5F = "3:21",
|
||||||
|
GOLDENROD = "11:2";
|
||||||
|
const TYPHLOSION = 157,
|
||||||
|
SCENE_GOLDENRODUNDERGROUNDSWITCHROOMENTRANCES_NOOP = 1,
|
||||||
|
SCENE_RADIOTOWER5F_ROCKET_BOSS = 1;
|
||||||
|
const EVENT_SWITCH_1 = 0x2d3,
|
||||||
|
EVENT_DOOR_1_OPEN = 0x2d7,
|
||||||
|
EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 0x6ce,
|
||||||
|
EVENT_CLEARED_RADIO_TOWER = 0x21,
|
||||||
|
EVENT_GOT_CLEAR_BELL = 0x78,
|
||||||
|
ENGINE_ROCKETS_IN_RADIO_TOWER = 0x13;
|
||||||
|
/** RADIOTOWER5F_DIRECTOR, the first object (no event flag). */
|
||||||
|
const DIRECTOR = 2;
|
||||||
|
|
||||||
|
/** A playthrough driver on a fresh world with one Pokémon at `level`. */
|
||||||
|
const driver = (level: number) => {
|
||||||
|
const p = new Playthrough(assets!);
|
||||||
|
p.world = new GameWorld(assets!, p.now, p.random);
|
||||||
|
p.world.state.party = [
|
||||||
|
generatePartyMon(assets!.pokemon, {
|
||||||
|
species: TYPHLOSION,
|
||||||
|
level,
|
||||||
|
item: 0,
|
||||||
|
otId: p.world.playerId,
|
||||||
|
ot: "KAWA",
|
||||||
|
dvs: 0xffff,
|
||||||
|
caughtData: 0,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
/** The switches, from switch 1 on the right, and the doors' events. */
|
||||||
|
const SWITCHES = [
|
||||||
|
[16, 1],
|
||||||
|
[10, 1],
|
||||||
|
[2, 1],
|
||||||
|
] as const;
|
||||||
|
const flags = (world: GameWorld) =>
|
||||||
|
Array.from({ length: 15 }, (_, i) => +world.state.events.has(EVENT_SWITCH_1 + i)).join("");
|
||||||
|
|
||||||
|
it("keeps the switches and doors through a reload until the warehouse resets them", () => {
|
||||||
|
const p = driver(70);
|
||||||
|
const { world } = p;
|
||||||
|
world.state.scenes[SWITCH_ROOM] = SCENE_GOLDENRODUNDERGROUNDSWITCHROOMENTRANCES_NOOP;
|
||||||
|
world.warpTo(SWITCH_ROOM, 23, 3);
|
||||||
|
p.play();
|
||||||
|
// Every door is shut, so the warehouse (22, 10) is out of reach.
|
||||||
|
expect(() => findPath(world, 22, 10)).toThrow();
|
||||||
|
for (const [x, y] of [SWITCHES[2], SWITCHES[1], SWITCHES[0]]) {
|
||||||
|
p.walk(x, y + 1);
|
||||||
|
world.move("up");
|
||||||
|
world.interact();
|
||||||
|
p.play([1]);
|
||||||
|
}
|
||||||
|
// Positions 3, 5 and 6 in turn: doors 3, 5, 6, 8, 9 and 11 stay open.
|
||||||
|
expect(world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 6 });
|
||||||
|
expect(flags(world)).toBe("111000101101101");
|
||||||
|
expect(findPath(world, 22, 10).length).toBeGreaterThan(0);
|
||||||
|
// The tiles callback reopens the doors after a reload.
|
||||||
|
p.reload();
|
||||||
|
expect(flags(p.world)).toBe("111000101101101");
|
||||||
|
expect(findPath(p.world, 22, 10).length).toBeGreaterThan(0);
|
||||||
|
p.walk(22, 10);
|
||||||
|
expect(p.world.mapId).toBe(WAREHOUSE);
|
||||||
|
// GoldenrodUndergroundWarehouseResetSwitchesCallback.
|
||||||
|
expect(flags(p.world)).toBe("0".repeat(15));
|
||||||
|
expect(p.world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 0 });
|
||||||
|
expect(p.world.state.events.has(EVENT_DOOR_1_OPEN)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whites out cleanly when the rival in the switch room wins", () => {
|
||||||
|
const p = driver(2);
|
||||||
|
const { world } = p;
|
||||||
|
world.state.money = 3000;
|
||||||
|
world.warpTo(SWITCH_ROOM, 23, 3);
|
||||||
|
p.play();
|
||||||
|
// UndergroundRivalScene1: the battle runs inside an scall, and the loss
|
||||||
|
// jumps to Script_Whiteout, whose endall drops the caller.
|
||||||
|
world.restore({ ...world.save(), x: 18, y: 4, direction: "right" });
|
||||||
|
world.move("right");
|
||||||
|
p.play();
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
expect(p.log.join(" ")).toContain("whited\nout!");
|
||||||
|
expect(world.mapId).not.toBe(SWITCH_ROOM);
|
||||||
|
expect(world.state.money).toBe(1500);
|
||||||
|
expect(world.state.party[0].hp).toBe(world.state.party[0].stats[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the director gone after a save once he walks out on 5F", () => {
|
||||||
|
const p = driver(80);
|
||||||
|
const { world } = p;
|
||||||
|
world.state.flags.add(ENGINE_ROCKETS_IN_RADIO_TOWER);
|
||||||
|
world.state.scenes[RADIO_TOWER_5F] = SCENE_RADIOTOWER5F_ROCKET_BOSS;
|
||||||
|
world.warpTo(RADIO_TOWER_5F, 15, 5);
|
||||||
|
p.play();
|
||||||
|
p.world.move("right");
|
||||||
|
p.play();
|
||||||
|
p.world.move("right");
|
||||||
|
p.play();
|
||||||
|
expect(world.state.events.has(EVENT_CLEARED_RADIO_TOWER)).toBe(true);
|
||||||
|
expect(world.state.events.has(EVENT_GOT_CLEAR_BELL)).toBe(true);
|
||||||
|
expect(world.state.events.has(EVENT_RADIO_TOWER_ROCKET_TAKEOVER)).toBe(true);
|
||||||
|
// He has no event flag: disappear only removes his object struct,
|
||||||
|
// which Continue keeps as saved.
|
||||||
|
expect(world.objects().some((object) => object.id === DIRECTOR)).toBe(false);
|
||||||
|
expect(world.save().objects).toContainEqual(
|
||||||
|
expect.objectContaining({ id: DIRECTOR, hidden: true }),
|
||||||
|
);
|
||||||
|
p.reload();
|
||||||
|
expect(p.world.objects().some((object) => object.id === DIRECTOR)).toBe(false);
|
||||||
|
// The stairs he walked out by are free.
|
||||||
|
expect(findPath(p.world, 12, 0).length).toBeGreaterThan(0);
|
||||||
|
// A later visit rebuilds the objects from the map: he is back.
|
||||||
|
p.travel(GOLDENROD, ["3:17", "3:18", "3:19", "3:20", "3:21"]);
|
||||||
|
p.world.warpTo(RADIO_TOWER_5F, 0, 1);
|
||||||
|
p.play();
|
||||||
|
expect(p.world.objects().some((object) => object.id === DIRECTOR)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+19
-7
@@ -91,6 +91,8 @@ const isWarpHazard = (tile: number) =>
|
|||||||
/** COLL_WATER and the unused COLL_WATER_21: open water a surfer crosses
|
/** COLL_WATER and the unused COLL_WATER_21: open water a surfer crosses
|
||||||
* freely, unlike currents, waterfalls and whirlpools. */
|
* freely, unlike currents, waterfalls and whirlpools. */
|
||||||
const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21;
|
const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21;
|
||||||
|
/** COLL_WHIRLPOOL and COLL_WHIRLPOOL_2C: water once Whirlpool clears it. */
|
||||||
|
export const isWhirlpool = (tile: number) => tile === 0x24 || tile === 0x2c;
|
||||||
|
|
||||||
/** COLL_ICE and COLL_ICE_2B: a step onto them keeps sliding (.CheckForced). */
|
/** COLL_ICE and COLL_ICE_2B: a step onto them keeps sliding (.CheckForced). */
|
||||||
const isIce = (tile: number) => tile === 0x23 || tile === 0x2b;
|
const isIce = (tile: number) => tile === 0x23 || tile === 0x2b;
|
||||||
@@ -105,13 +107,18 @@ function open(
|
|||||||
direction: Direction,
|
direction: Direction,
|
||||||
water: boolean,
|
water: boolean,
|
||||||
target?: { x: number; y: number },
|
target?: { x: number; y: number },
|
||||||
|
whirlpools = false,
|
||||||
) {
|
) {
|
||||||
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
|
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
|
||||||
const x = fromX + dx,
|
const x = fromX + dx,
|
||||||
y = fromY + dy;
|
y = fromY + dy;
|
||||||
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false;
|
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false;
|
||||||
const tile = world.assets.collision(world.map, x, y);
|
const tile = world.assets.collision(world.map, x, y);
|
||||||
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile))) return false;
|
if (
|
||||||
|
world.assets.permission(tile) !== 0 &&
|
||||||
|
!(water && (isOpenWater(tile) || (whirlpools && isWhirlpool(tile))))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
if (
|
if (
|
||||||
edges(world.assets.collision(world.map, fromX, fromY)).includes(direction) ||
|
edges(world.assets.collision(world.map, fromX, fromY)).includes(direction) ||
|
||||||
edges(tile).includes(OPPOSITE[direction])
|
edges(tile).includes(OPPOSITE[direction])
|
||||||
@@ -135,9 +142,13 @@ export function landing(
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
{ water = false, target }: { water?: boolean; target?: { x: number; y: number } } = {},
|
{
|
||||||
|
water = false,
|
||||||
|
target,
|
||||||
|
whirlpools = false,
|
||||||
|
}: { water?: boolean; target?: { x: number; y: number }; whirlpools?: boolean } = {},
|
||||||
): { x: number; y: number } | undefined {
|
): { x: number; y: number } | undefined {
|
||||||
if (!open(world, x, y, direction, water, target)) return undefined;
|
if (!open(world, x, y, direction, water, target, whirlpools)) return undefined;
|
||||||
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
|
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
|
||||||
x += dx;
|
x += dx;
|
||||||
y += dy;
|
y += dy;
|
||||||
@@ -152,13 +163,14 @@ export function landing(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Shortest walkable path on the current map, avoiding other warps.
|
/** Shortest walkable path on the current map, avoiding other warps.
|
||||||
* `water` also crosses open water, for a player who can Surf; a step onto
|
* `water` also crosses open water, for a player who can Surf, and
|
||||||
* ice slides on until blocked. */
|
* `whirlpools` the whirlpools a player with Whirlpool can clear; a step
|
||||||
|
* onto ice slides on until blocked. */
|
||||||
export function findPath(
|
export function findPath(
|
||||||
world: GameWorld,
|
world: GameWorld,
|
||||||
targetX: number,
|
targetX: number,
|
||||||
targetY: number,
|
targetY: number,
|
||||||
{ water = false } = {},
|
{ water = false, whirlpools = false } = {},
|
||||||
): Direction[] {
|
): Direction[] {
|
||||||
const target = { x: targetX, y: targetY };
|
const target = { x: targetX, y: targetY };
|
||||||
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
|
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
|
||||||
@@ -170,7 +182,7 @@ export function findPath(
|
|||||||
visited.add(key);
|
visited.add(key);
|
||||||
if (node.x === targetX && node.y === targetY) return node.path;
|
if (node.x === targetX && node.y === targetY) return node.path;
|
||||||
for (const [direction] of DIRECTIONS) {
|
for (const [direction] of DIRECTIONS) {
|
||||||
const end = landing(world, node.x, node.y, direction, { water, target });
|
const end = landing(world, node.x, node.y, direction, { water, target, whirlpools });
|
||||||
if (end) queue.push({ ...end, path: [...node.path, direction] });
|
if (end) queue.push({ ...end, path: [...node.path, direction] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user