Port Repels and the other non-fishing field items

Repel, Super Repel and Max Repel count down 100, 200 or 250 steps where
CountStep does, keep weaker wild Pokémon away from the first party member
that has not fainted, and print "REPEL's effect wore off." on the last
step. The Coin Case and the Blue Card show their balances, Sacred Ash
revives the party once one Pokémon has fainted, and the Normal and
Gorgeous Boxes send their trophy home.

The Itemfinder looks for unfound hidden items on the original's screen
around the player. The Card Key, the Basement Key and the Squirtbottle
run the original's checks for their doors and Sudowoodo, whose maps are
not open yet. Saves gain the Repel's steps, the coins and the Blue Card's
points.

Part of #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 00:32:33 +02:00
1 parent 10e1560212
commit 4cb4676c7d
13 files changed
+821 -21

No files matched your search

+30 -4
View File
@@ -32,7 +32,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. | | Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. |
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. | | Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. | | `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. |
| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`). MAIL and the other field items give a notice. | | `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`). MAIL and the rods give a notice. |
| `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. | | `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. |
| `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. | | `game/scripts/trainer-card.ts` | `engine/menus/trainer_card.asm`, `engine/gfx/player_gfx.asm` (`GetCardPic`), `engine/gfx/cgb_layouts.asm` (`_CGB_TrainerCard`), `home/game_time.asm` | `TrainerCard`'s two reachable pages with their joypad rules, strings from `.Name_Money`, `.Dex_PlayTime` and `.Badges`, the caught count, and the badges from `TrainerCard_JohtoBadgesOAM` with their eight-frame steps. `rendering/trainer-card.ts` draws the card picture, leader faces and badges from the ROM with the card's palettes and attribute map. `world/game-time.ts` is `GameTimer` (capped at 999:59:59). Text is native; the blinking colon's and badges' phase start at the page load. |
| `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. | | `game/scripts/options.ts` | `engine/menus/options_menu.asm`, `data/default_options.asm`, `engine/battle/core.asm` (`CheckWhetherToAskSwitch`), `engine/menus/start_menu.asm` | `_Option` with `OptionsControl` and each option's routine on the four option bytes, `DefaultOptions`, and the two settings consumed so far: `BATTLE_SHIFT` and `MENU_ACCOUNT`. Strings come from the ROM; the screen is native. |
@@ -317,7 +317,15 @@ and then `_DoItemEffect` by the `ItemEffects` routine address, with the
field side of `RestoreHPEffect`, `StatusHealingEffect`, `FullRestoreEffect`, field side of `RestoreHPEffect`, `StatusHealingEffect`, `FullRestoreEffect`,
`ReviveEffect`/`RevivalHerbEffect`, `EnergypowderEffect`/`EnergyRootEffect`, `ReviveEffect`/`RevivalHerbEffect`, `EnergypowderEffect`/`EnergyRootEffect`,
`HealPowderEffect` and `RestorePPEffect` (Ether, Max Ether, Elixer, Max `HealPowderEffect` and `RestorePPEffect` (Ether, Max Ether, Elixer, Max
Elixer, MysteryBerry and PP Up with `ApplyPPUp`). Items are used from the Elixer, MysteryBerry and PP Up with `ApplyPPUp`), and since the remaining
field items increment `UseRepel` (`UseItemText`), `CoinCaseEffect`,
`BlueCardEffect`, `_SacredAsh` (`CheckAnyFaintedMon`, then the queued
`SacredAshScript`), `OpenBox` (`DecorationIDs`, then the decoration's
event flag), `ItemFinder` (the world's `CheckForHiddenItems` picks the
queued script), `_CardKey`, `_BasementKey` (map and facing tile in
`wPlayerMapX/Y` coordinates) and `_Squirtbottle`, whose
`.CheckCanUseSquirtbottle` asks the world's `GetFacingObject` and leaves the
object in `hLastTalked`. Items are used from the
Items pocket slot they were chosen from (`wCurItemQuantity`). Items pocket slot they were chosen from (`wCurItemQuantity`).
`ItemActionTextWaitButton` prints `PrintPartyMenuActionText` with the HP `ItemActionTextWaitButton` prints `PrintPartyMenuActionText` with the HP
change. `ChangeHappiness` outside battle does not touch a battle Pokémon. change. `ChangeHappiness` outside battle does not touch a battle Pokémon.
@@ -339,6 +347,24 @@ while the menu is open. A Chromium/WebKit test opens the menu with Start,
switches Pokémon, changes pockets and tosses a Potion. These are not switches Pokémon, changes pockets and tosses a Potion. These are not
emulator comparisons. emulator comparisons.
`DoRepelStep` runs in the world where `CountStep` calls it, after
`CheckSpecialPhoneCall`; the poison, happiness, egg, Day-Care and bike
steps of `CountStep` are not ported yet. `RepelWoreOffScript` starts with
the step's committed position, like a coordinate event, and its committed
save keeps the last Repel step; a wild battle's committed save likewise
keeps the step's count. `TryWildEncounter`'s `CheckRepelEffect` follows
`ChooseWildEncounter`. `text_ram` of `wPlayerName`, `wRivalName` and
`wMomsName` reads the text context's names.
Tests: `tests/field-items.test.ts` (local ROM) uses each Repel, walks with
one through a test-only grass table (weaker Pokémon kept away, a fainted
lead, an equal level let through, the last step's message and its replay
after a reload), and covers the Coin Case, the Blue Card, Sacred Ash with
and without a fainted Pokémon, both boxes, the save migration, the
Itemfinder at each edge of the screen around Route 30's hidden Potion,
the keys (a start-menu run with the unopened maps' positions) and the
Squirtbottle (the interpreter facing a test Sudowoodo on Route 36).
## Evolution ## Evolution
The battle's experience routine sets a party member's bit in The battle's experience routine sets a party member's bit in
@@ -430,8 +456,8 @@ animations as timed `fly` effects (65 and 33 ticks). The Escape Rope
chamber's wall there (`SpecialKabutoChamber`) and using up one rope; a chamber's wall there (`SpecialKabutoChamber`) and using up one rope; a
failure prints nothing before Oak's warning. `JumpRoamMons` (no roaming failure prints nothing before Oak's warning. `JumpRoamMons` (no roaming
Pokémon yet) and the fades are not modelled. A destination map that is not Pokémon yet) and the fades are not modelled. A destination map that is not
ported gives a notice. `TreeMonEncounter` and `RockMonEncounter` are not ported gives a notice. `TreeMonEncounter` and `RockMonEncounter` run since
ported and give a notice after their checks. the Headbutt and Rock Smash increment.
`Softboiled_MilkDrinkFunction` runs in the menu with `GetOneFifthMaxHP`, `Softboiled_MilkDrinkFunction` runs in the menu with `GetOneFifthMaxHP`,
`RemoveHP` and `RestoreHealth`, refusing the user itself and Pokémon that `RemoveHP` and `RestoreHealth`, refusing the user itself and Pokémon that
fainted or are at full HP. fainted or are at full HP.
+11 -1
View File
@@ -169,7 +169,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte. - PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte.
- PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out". - PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out".
- The menu runs on a script draft, like the PC. Changes commit when it closes; a save made while it is open keeps the state from before it. The start menu cursor, the party menu cursor and the last pocket stay in memory between openings and are cleared after a battle, as `CleanUpBattleRAM` does. The party cursor reaches the next battle's `ChangeHappiness`. - The menu runs on a script draft, like the PC. Changes commit when it closes; a save made while it is open keeps the state from before it. The start menu cursor, the party menu cursor and the last pocket stay in memory between openings and are cleared after a battle, as `CleanUpBattleRAM` does. The party cursor reaches the next battle's `ChangeHappiness`.
- Not ported: MAIL, moving a Pokémon's moves to another party member with left and right, item reordering with Select and the remaining field items such as Repel (each gives a notice and changes nothing; the Escape Rope runs since the Fly section below). Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it. - Not ported: MAIL, switching to another party member's moves with left and right, item reordering with Select and the fishing rods (each gives a notice and changes nothing; the Escape Rope runs since the Fly section below, Repels and the other items of the last section since then). Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it.
## Implemented: evolution ## Implemented: evolution
@@ -240,6 +240,16 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Talking to a smashable rock runs `SmashRockScript`: "Maybe a POKéMON can break this." without Rock Smash (`HasRockSmash`), otherwise the question; ROCK SMASH from the party menu skips it and acts on the facing rock as `hLastTalked`. The earthquake, the rock's flicker and its disappearance run from the ROM's script, then `RockMonEncounter`: the map's `RockMonMaps` set and a 40% chance of a wild battle. - Talking to a smashable rock runs `SmashRockScript`: "Maybe a POKéMON can break this." without Rock Smash (`HasRockSmash`), otherwise the question; ROCK SMASH from the party menu skips it and acts on the facing rock as `hLastTalked`. The earthquake, the rock's flicker and its disappearance run from the ROM's script, then `RockMonEncounter`: the map's `RockMonMaps` set and a 40% chance of a wild battle.
- Unreachable in normal play so far: no open map has rocks, and no Pokémon available yet learns Headbutt (the Ilex Forest tutor) or Rock Smash (a TM on Route 36). Route 29 and Route 30 have tree sets. - Unreachable in normal play so far: no open map has rocks, and no Pokémon available yet learns Headbutt (the Ilex Forest tutor) or Rock Smash (a TM on Route 36). Route 29 and Route 30 have tree sets.
## Implemented: the remaining field items
- Repel, Super Repel and Max Repel set `wRepelEffect` to 100, 200 or 250 steps ("used the REPEL."), unless "The REPEL used earlier is still in effect.". Each step counts one off where `CountStep` does, after trainer, warp and coordinate events and a special call; the last one prints "REPEL's effect wore off." instead of looking for a wild Pokémon. Under a Repel, wild Pokémon below the level of the first party member that has not fainted are not met (`CheckRepelEffect`); Sweet Scent and Headbutt ignore it, as in the original. A save during the message or a battle replays the step with its Repel step.
- The Coin Case shows `wCoins` and the Blue Card `wBlueCardBalance`; nothing earns coins or points yet (the Game Corner and the radio's Buena show).
- Sacred Ash: when a Pokémon that is not an Egg has fainted, the menu closes and `SacredAshScript` heals the whole party; otherwise Oak objects.
- The Normal Box and the Gorgeous Box hold the silver and the gold trophy doll (`SetSpecificDecorationFlag`), sent home for the bedroom PC.
- The Itemfinder answers "Yes! ITEMFINDER indicates there's an item nearby." when a hidden item not found yet lies on the original's screen around the player (`CheckForHiddenItems`: four squares left and up, five right, four down), and "Nope!" otherwise. Route 30's hidden Potion is the only one on an open map.
- The Card Key and the Basement Key run their door scripts only facing the Radio Tower 3F slot or the Goldenrod Underground door (Oak objects elsewhere). The Squirtbottle sprinkles water, and on Route 36 starts the Sudowoodo battle when facing it (`WateredWeirdTreeScript`). None of these maps is open yet.
- Save format 3 gained `repel`, `coins` and `blueCardBalance`, migrated by absence to 0.
## Native battles: current state ## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items. The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items.
+2
View File
@@ -191,6 +191,8 @@ const ramNames = {
curHPAnimDeltaHP: "wCurHPAnimDeltaHP", curHPAnimDeltaHP: "wCurHPAnimDeltaHP",
visitedSpawns: "wVisitedSpawns", visitedSpawns: "wVisitedSpawns",
tempWildMonSpecies: "wTempWildMonSpecies", tempWildMonSpecies: "wTempWildMonSpecies",
coins: "wCoins",
blueCardBalance: "wBlueCardBalance",
moneyTemp: "hMoneyTemp", moneyTemp: "hMoneyTemp",
}; };
const profile = readFileSync("src/game/rom/profile.ts", "utf8"); const profile = readFileSync("src/game/rom/profile.ts", "utf8");
+4
View File
@@ -175,6 +175,7 @@ export const C = {
BASE_HAPPINESS: 70, BASE_HAPPINESS: 70,
START_MONEY: 3000, START_MONEY: 3000,
MAX_MONEY: 999999, MAX_MONEY: 999999,
MAX_COINS: 9999,
// Status bits and masks // Status bits and masks
SLP_MASK: 7, SLP_MASK: 7,
@@ -240,6 +241,9 @@ export const C = {
ITEMMENU_CURRENT: 4, ITEMMENU_CURRENT: 4,
ITEMMENU_PARTY: 5, ITEMMENU_PARTY: 5,
ITEMMENU_CLOSE: 6, ITEMMENU_CLOSE: 6,
// Trophy dolls from the Normal and Gorgeous Box (constants/deco_constants.asm)
DECOFLAG_GOLD_TROPHY_DOLL: 43,
DECOFLAG_SILVER_TROPHY_DOLL: 44,
// Menus outside battle (constants/menu_constants.asm) // Menus outside battle (constants/menu_constants.asm)
MONMENU_FIELD_MOVE: 0, MONMENU_FIELD_MOVE: 0,
MONMENUITEM_STATS: 15, MONMENUITEM_STATS: 15,
+29
View File
@@ -160,6 +160,8 @@ export const RAM = {
curHPAnimDeltaHP: 0xd1f3, curHPAnimDeltaHP: 0xd1f3,
visitedSpawns: 0xdca5, visitedSpawns: 0xdca5,
tempWildMonSpecies: 0xd22e, tempWildMonSpecies: 0xd22e,
coins: 0xd855,
blueCardBalance: 0xdc4b,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */ /** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3, moneyTemp: 0xffc3,
} as const; } as const;
@@ -195,6 +197,7 @@ export const OVERWORLD = {
"DecorationAttributes": 0x26a4f, "DecorationAttributes": 0x26a4f,
"DecorationNames": 0x26b8d, "DecorationNames": 0x26b8d,
"DecorationIDs": 0x26f2b,
"_PlayerDecorationMenu.category_pointers": 0x267aa, "_PlayerDecorationMenu.category_pointers": 0x267aa,
"PlayersHousePC": 0xc2e7, "PlayersHousePC": 0xc2e7,
"PutAwayTheDecoText": 0x26ed1, "PutAwayTheDecoText": 0x26ed1,
@@ -262,6 +265,15 @@ export const OVERWORLD = {
HideSprites: 0x3016, HideSprites: 0x3016,
SkipUpdateMapSprites: 0x154f1, SkipUpdateMapSprites: 0x154f1,
WaitSFX: 0x3c55, WaitSFX: 0x3c55,
FadeInFromWhite: 0x8c079,
RepelWoreOffScript: 0x13619,
"ItemFinder.Script_FoundSomething": 0x125ad,
"ItemFinder.Script_FoundNothing": 0x125ba,
"ItemFinder.ItemfinderSound": 0x12599,
"_CardKey.CardKeyScript": 0x507af,
"_BasementKey.BasementKeyScript": 0x507e1,
"_Squirtbottle.SquirtbottleScript": 0x5073c,
"_Squirtbottle.CheckCanUseSquirtbottle": 0x50753,
Flypoints: 0x91c5e, Flypoints: 0x91c5e,
"TownMapBubble.Where": 0x91bf4, "TownMapBubble.Where": 0x91bf4,
RuinsOfAlphKabutoChamber_MapAttributes: 0x958d0, RuinsOfAlphKabutoChamber_MapAttributes: 0x958d0,
@@ -502,6 +514,23 @@ export const MENU = {
BicycleEffect: 0xee08, BicycleEffect: 0xee08,
EvoStoneEffect: 0xee0f, EvoStoneEffect: 0xee0f,
EscapeRopeEffect: 0xf44f, EscapeRopeEffect: 0xf44f,
RepelEffect: 0xf46a,
SuperRepelEffect: 0xf462,
MaxRepelEffect: 0xf466,
RepelUsedEarlierIsStillInEffectText: 0xf47d,
CoinCaseEffect: 0xf59a,
"CoinCaseEffect.CoinCaseCountText": 0xf5a0,
BlueCardEffect: 0xf58f,
"BlueCardEffect.BlueCardBalanceText": 0xf595,
SacredAshEffect: 0xf753,
SacredAshScript: 0x50821,
NormalBoxEffect: 0xf763,
GorgeousBoxEffect: 0xf767,
"OpenBox.SentTrophyHomeText": 0xf778,
ItemfinderEffect: 0xf5b8,
CardKeyEffect: 0xf745,
BasementKeyEffect: 0xf74c,
SquirtbottleEffect: 0xf73e,
VitaminEffect: 0xee3d, VitaminEffect: 0xee3d,
RareCandyEffect: 0xef14, RareCandyEffect: 0xef14,
ItemStatRoseText: 0xeea6, ItemStatRoseText: 0xeea6,
+33
View File
@@ -129,6 +129,12 @@ export interface ScriptState {
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below) /** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
* and wRegisteredItem: the item Select uses; zeros when none. */ * and wRegisteredItem: the item Select uses; zeros when none. */
registeredItem: { which: number; item: number }; registeredItem: { which: number; item: number };
/** wRepelEffect: the steps a Repel has left; 0 when none is in effect. */
repel: number;
/** wCoins: the Coin Case's coins. */
coins: number;
/** wBlueCardBalance: the Blue Card's points. */
blueCardBalance: number;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */ /** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
options: GameOptions; options: GameOptions;
} }
@@ -160,6 +166,9 @@ export const emptyScriptState = (): ScriptState => ({
flash: false, flash: false,
noWildEncounters: false, noWildEncounters: false,
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
repel: 0,
coins: 0,
blueCardBalance: 0,
options: defaultOptions(), options: defaultOptions(),
}); });
export const copyScriptState = (state: ScriptState): ScriptState => ({ export const copyScriptState = (state: ScriptState): ScriptState => ({
@@ -193,6 +202,9 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
flash: state.flash, flash: state.flash,
noWildEncounters: state.noWildEncounters, noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem }, registeredItem: { ...state.registeredItem },
repel: state.repel,
coins: state.coins,
blueCardBalance: state.blueCardBalance,
options: { ...state.options }, options: { ...state.options },
}); });
@@ -222,6 +234,8 @@ const FACING_VECTORS = [
[-1, 0], [-1, 0],
[1, 0], [1, 0],
] as const; ] as const;
/** MAP_ROUTE_36 (constants/map_constants.asm). */
const ROUTE_36 = "10:3";
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */ /** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10; const CONTACT_LIST_SIZE = 10;
@@ -401,6 +415,9 @@ export interface ScriptHost {
environment(): number; environment(): number;
/** GetMapPhoneService: nonzero where the phone has no signal. */ /** GetMapPhoneService: nonzero where the phone has no signal. */
phoneService(): number; phoneService(): number;
/** GetFacingObject: the visible object on the facing tile, with its
* MAPOBJECT_MOVEMENT. */
facingObject(): { id: number; movement: number } | undefined;
} }
export class UnsupportedScript extends Error { export class UnsupportedScript extends Error {
@@ -921,6 +938,20 @@ export class ScriptRun {
const mon = this.partyMove(OW.ROCK_SMASH); const mon = this.partyMove(OW.ROCK_SMASH);
if (mon >= 0) this.curPartyMon = mon; if (mon >= 0) this.curPartyMon = mon;
value = mon < 0 ? 1 : 0; value = mon < 0 ? 1 : 0;
} else if (
routine === OVERWORLD["_Squirtbottle.CheckCanUseSquirtbottle"]
) {
// Route 36's Sudowoodo; GetFacingObject leaves the facing object
// in hLastTalked for WateredWeirdTreeScript.
let sudowoodo = false;
if (this.host.mapId === ROUTE_36) {
const facing = this.host.facingObject();
if (facing) this.lastTalked = facing.id;
sudowoodo = facing?.movement === OW.SPRITEMOVEDATA_SUDOWOODO;
}
value = Number(sudowoodo);
} else if (routine === OVERWORLD["ItemFinder.ItemfinderSound"]) {
// Four rounds of the Itemfinder's two sound effects.
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) { } else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature. // StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
} else if ( } else if (
@@ -2256,6 +2287,7 @@ export class ScriptRun {
case OVERWORLD.PlayMapMusic: case OVERWORLD.PlayMapMusic:
case OVERWORLD.UpdateTimePals: case OVERWORLD.UpdateTimePals:
case OVERWORLD.WaitSFX: case OVERWORLD.WaitSFX:
case OVERWORLD.FadeInFromWhite:
// The native renderer draws the player from wPlayerState each frame; // The native renderer draws the player from wPlayerState each frame;
// music and palette fades are presentation. // music and palette fades are presentation.
return value; return value;
@@ -2448,6 +2480,7 @@ export function runScript(
playerCollision: () => 0, playerCollision: () => 0,
environment: () => 0, environment: () => 0,
phoneService: () => 0, phoneService: () => 0,
facingObject: () => undefined,
}); });
const pages: string[] = []; const pages: string[] = [];
for (let effect = run.resume(); effect; effect = run.resume()) { for (let effect = run.resume(); effect; effect = run.resume()) {
+133
View File
@@ -24,6 +24,7 @@ import { TrainerCard } from "./trainer-card";
import { OptionsMenu, menuAccount } from "./options"; import { OptionsMenu, menuAccount } from "./options";
import { flyMap } from "./fly-map"; import { flyMap } from "./fly-map";
import { OW } from "../world/constants"; import { OW } from "../world/constants";
import { DecorationData } from "../world/decorations";
import { readString, readText, type TextContext } from "./text"; import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined; type Input = number | string | undefined;
@@ -77,6 +78,18 @@ const {
PP_UP_ONE, PP_UP_ONE,
} = C; } = C;
const NOT_PORTED = "is not ported yet."; const NOT_PORTED = "is not ported yet.";
/** MAP_RADIO_TOWER_3F and MAP_GOLDENROD_UNDERGROUND
* (constants/map_constants.asm). */
const RADIO_TOWER_3F = "3:19",
GOLDENROD_UNDERGROUND = "3:53";
/** wPlayerDirection's UP, as the host's facing() numbers it. */
const FACING_UP = 1;
const FACING_VECTORS = [
[0, 1],
[0, -1],
[-1, 0],
[1, 0],
] as const;
/** Menu cursors in work RAM, which CleanUpBattleRAM clears after every /** Menu cursors in work RAM, which CleanUpBattleRAM clears after every
* battle: wBattleMenuCursorPosition (the start menu's 1-based position, * battle: wBattleMenuCursorPosition (the start menu's 1-based position,
@@ -129,6 +142,8 @@ export interface StartMenuHost {
landmarkOf(mapId: string): number; landmarkOf(mapId: string): number;
/** wGameTime, for the trainer card. */ /** wGameTime, for the trainer card. */
gameTime(): GameTime; gameTime(): GameTime;
/** CheckForHiddenItems: an unfound hidden item on the screen. */
hiddenItemOnScreen(state: ScriptState): boolean;
} }
/** The overworld start menu (engine/menus/start_menu.asm) with its /** The overworld start menu (engine/menus/start_menu.asm) with its
@@ -946,11 +961,129 @@ export class StartMenuRun {
this.deferredItem = item; this.deferredItem = item;
return true; return true;
} }
case MENU.RepelEffect:
case MENU.SuperRepelEffect:
case MENU.MaxRepelEffect:
return yield* this.useRepel(
use,
effect === MENU.RepelEffect
? 100
: effect === MENU.SuperRepelEffect
? 200
: 250,
);
case MENU.CoinCaseEffect:
this.numbers[RAM.coins] = this.state.coins;
yield* this.print(MENU["CoinCaseEffect.CoinCaseCountText"]);
return true;
case MENU.BlueCardEffect:
this.numbers[RAM.blueCardBalance] = this.state.blueCardBalance;
yield* this.print(MENU["BlueCardEffect.BlueCardBalanceText"]);
return true;
case MENU.SacredAshEffect:
// _SacredAsh: SacredAshScript heals the party once the menu closes,
// if a Pokémon that is not an Egg has fainted (CheckAnyFaintedMon).
if (!this.state.party.some((mon) => mon.species !== EGG && !mon.hp))
return false;
this.useDisposableItem(use);
this.deferred = MENU.SacredAshScript;
this.deferredItem = item;
return true;
case MENU.ItemfinderEffect:
// ItemFinder: one script or the other, and always a success.
this.deferred = this.host.hiddenItemOnScreen(this.state)
? OVERWORLD["ItemFinder.Script_FoundSomething"]
: OVERWORLD["ItemFinder.Script_FoundNothing"];
this.deferredItem = item;
return true;
case MENU.CardKeyEffect:
// _CardKey: facing up at the slot on Radio Tower 3F.
return this.useKey(
item,
RADIO_TOWER_3F,
{ x: 18, y: 6 },
OVERWORLD["_CardKey.CardKeyScript"],
FACING_UP,
);
case MENU.BasementKeyEffect:
// _BasementKey: facing the door in the Goldenrod Underground.
return this.useKey(
item,
GOLDENROD_UNDERGROUND,
{ x: 22, y: 10 },
OVERWORLD["_BasementKey.BasementKeyScript"],
);
case MENU.SquirtbottleEffect:
// _Squirtbottle: its script checks for Sudowoodo.
this.deferred = OVERWORLD["_Squirtbottle.SquirtbottleScript"];
this.deferredItem = item;
return true;
case MENU.NormalBoxEffect:
case MENU.GorgeousBoxEffect:
return yield* this.openBox(
use,
effect === MENU.NormalBoxEffect
? C.DECOFLAG_SILVER_TROPHY_DOLL
: C.DECOFLAG_GOLD_TROPHY_DOLL,
);
} }
yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`); yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`);
return "unported"; return "unported";
} }
/** _CardKey and _BasementKey: the key's script runs once the menu closes
* if the player faces `tile` (in wPlayerMapX/Y coordinates, which count
* the map's border) on `mapId`, and `facing` if given. */
private useKey(
item: number,
mapId: string,
tile: { x: number; y: number },
script: number,
facing?: number,
): boolean {
const host = this.host.scriptHost(),
direction = host.facing(),
[dx, dy] = FACING_VECTORS[direction],
{ x, y } = host.position();
if (
host.mapId !== mapId ||
(facing !== undefined && direction !== facing) ||
x + dx + 4 !== tile.x ||
y + dy + 4 !== tile.y
)
return false;
this.deferred = script;
this.deferredItem = item;
return true;
}
/** UseRepel: a Repel for `steps` steps, unless one is in effect. */
private *useRepel(use: FieldItemUse, steps: number): Gen<boolean> {
if (this.state.repel) {
yield* this.print(MENU.RepelUsedEarlierIsStillInEffectText);
return false;
}
this.state.repel = steps;
yield* this.useItemText(use);
return true;
}
/** UseItemText: "<PLAYER> used the …", then UseDisposableItem. */
private *useItemText(use: FieldItemUse): Gen {
yield* this.print(BATTLE.ItemUsedText);
this.useDisposableItem(use);
}
/** OpenBox: SetSpecificDecorationFlag gives the trophy doll, which the
* player sends home. */
private *openBox(use: FieldItemUse, flag: number): Gen<boolean> {
const r = this.reader,
decoration = r.byte(OVERWORLD.DecorationIDs + flag);
this.state.events.add(
new DecorationData(r, this.data).attribute(decoration).event,
);
yield* this.print(MENU["OpenBox.SentTrophyHomeText"]);
this.useDisposableItem(use);
return true;
}
/** UseItem_SelectMon: the party menu for an item. The party index, or /** UseItem_SelectMon: the party menu for an item. The party index, or
* -1 when cancelled or an Egg was chosen. */ * -1 when cancelled or an Egg was chosen. */
private *useItemSelectMon( private *useItemSelectMon(
+9 -1
View File
@@ -181,7 +181,15 @@ export function readTextEnd(
// Only the outermost text is trimmed; nested fragments keep their spacing. // Only the outermost text is trimmed; nested fragments keep their spacing.
const finish = (value: string) => (depth === 0 ? value.trim() : value); const finish = (value: string) => (depth === 0 ? value.trim() : value);
const ram = (address: number) => { const ram = (address: number) => {
const value = context.strings[address]; const value =
context.strings[address] ??
(address === RAM.playerName
? context.player
: address === RAM.rivalName
? context.rival
: address === RAM.momsName
? context.mom
: undefined);
if (value === undefined) if (value === undefined)
throw new Error( throw new Error(
`Text reads RAM 0x${address.toString(16)}, which has no native value.`, `Text reads RAM 0x${address.toString(16)}, which has no native value.`,
+1
View File
@@ -178,6 +178,7 @@ export const OW = {
NUM_FLYPOINTS: 24, NUM_FLYPOINTS: 24,
// SpriteMovementData (constants/map_object_constants.asm). // SpriteMovementData (constants/map_object_constants.asm).
SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18, SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18,
SPRITEMOVEDATA_SUDOWOODO: 0x17,
// Headbutt and Rock Smash sets (constants/pokemon_data_constants.asm). // Headbutt and Rock Smash sets (constants/pokemon_data_constants.asm).
NUM_TREEMON_SETS: 8, NUM_TREEMON_SETS: 8,
BICYCLE: 7, BICYCLE: 7,
+71 -7
View File
@@ -150,6 +150,10 @@ export interface NativeSave {
noWildEncounters: boolean; noWildEncounters: boolean;
/** wWhichRegisteredItem and wRegisteredItem, for Select. */ /** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number }; registeredItem: { which: number; item: number };
/** wRepelEffect, wCoins and wBlueCardBalance. */
repel: number;
coins: number;
blueCardBalance: number;
/** The option bytes, saved with the game as sOptions is. */ /** The option bytes, saved with the game as sOptions is. */
options: GameOptions; options: GameOptions;
} }
@@ -472,6 +476,8 @@ const forcedDirection = (tile: number): Direction | undefined => {
const TURN_WAIT_TICKS = 3; const TURN_WAIT_TICKS = 3;
/** STEP_BIKE: 4 ticks at 4 pixels per tick (bikes and ice). */ /** STEP_BIKE: 4 ticks at 4 pixels per tick (bikes and ice). */
const BIKE_TICKS = 4; const BIKE_TICKS = 4;
/** BGEVENT_ITEM: a hidden item's bg_event (constants/script_constants.asm). */
const BGEVENT_ITEM = 7;
/** An object's state as LoadMapObjects creates it from the map data. */ /** An object's state as LoadMapObjects creates it from the map data. */
const spawnActor = (object: MapObject): Actor => ({ const spawnActor = (object: MapObject): Actor => ({
x: object.x, x: object.x,
@@ -1461,10 +1467,24 @@ export class GameWorld {
); );
if (event) if (event)
this.startScript(event.script, PLAYER, { committedPosition: previous }); this.startScript(event.script, PLAYER, { committedPosition: previous });
else if (!this.checkSpecialPhoneCall(previous)) else if (!this.checkSpecialPhoneCall(previous) && !this.repelStep(previous))
this.randomEncounter(previous); this.randomEncounter(previous);
} }
/** CountStep's DoRepelStep: a Repel loses a step, and the step that ends
* it runs RepelWoreOffScript instead of the encounter check. A save
* during that text replays the step with the Repel's last step. */
private repelStep(previous: { x: number; y: number }): boolean {
const state = this.state;
if (!state.repel) return false;
if (--state.repel) return false;
this.startScript(OVERWORLD.RepelWoreOffScript, PLAYER, {
committedPosition: previous,
});
if (this.script) this.script.committed.repel = 1;
return true;
}
/** CountStep / CheckSpecialPhoneCall: a queued call waits for a step /** CountStep / CheckSpecialPhoneCall: a queued call waits for a step
* after trainer, warp and coordinate events, before encounters. Its * after trainer, warp and coordinate events, before encounters. Its
* SpecialPhoneCallList entry supplies the condition and caller script, * SpecialPhoneCallList entry supplies the condition and caller script,
@@ -1511,11 +1531,17 @@ export class GameWorld {
if (!(this.random() < table.rate)) return; if (!(this.random() < table.rate)) return;
const chosen = this.chooseWildEncounter(table); const chosen = this.chooseWildEncounter(table);
if (!chosen) return; if (!chosen) return;
if (!state.party.some((mon) => mon.hp > 0)) return; // CheckRepelEffect: under a Repel, only Pokémon at least as high in
// level as the first one that has not fainted.
const lead = state.party.find((mon) => mon.hp > 0);
if (!lead) return;
if (state.repel && chosen.level < lead.level) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, { this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
committedPosition: previous, committedPosition: previous,
wild: chosen, wild: chosen,
}); });
// The step replays after a reload, so it keeps its Repel step.
if (state.repel && this.script) this.script.committed.repel++;
} }
/** GetMapEncounterRate: CheckOnWater picks the water table and rate, /** GetMapEncounterRate: CheckOnWater picks the water table and rate,
* which a Cleanse Tag halves. */ * which a Cleanse Tag halves. */
@@ -1599,8 +1625,6 @@ export class GameWorld {
state.flags.has(flag) state.flags.has(flag)
? undefined ? undefined
: { texts: [OVERWORLD["CheckBadge.BadgeRequiredText"]] }; : { texts: [OVERWORLD["CheckBadge.BadgeRequiredText"]] };
const facingObject = () =>
this.objects().find((object) => object.x === x && object.y === y);
switch (move) { switch (move) {
case OW.CUT: { case OW.CUT: {
const block = this.fieldMoveBlock(x, y, tile, "cut"); const block = this.fieldMoveBlock(x, y, tile, "cut");
@@ -1628,7 +1652,7 @@ export class GameWorld {
if ( if (
this.assets.permission(tile) !== OW.WATER_TILE || this.assets.permission(tile) !== OW.WATER_TILE ||
this.sideBlocked(current, tile, this.player.direction) || this.sideBlocked(current, tile, this.player.direction) ||
facingObject() this.facingObject()
) )
return cant; return cant;
return { return {
@@ -1715,7 +1739,7 @@ export class GameWorld {
: failed; : failed;
case OW.ROCK_SMASH: { case OW.ROCK_SMASH: {
// GetFacingObject leaves the rock in hLastTalked. // GetFacingObject leaves the rock in hLastTalked.
const rock = facingObject(); const rock = this.facingObject();
return rock?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK return rock?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
? { ? {
script: OVERWORLD.RockSmashFromMenuScript, script: OVERWORLD.RockSmashFromMenuScript,
@@ -1950,8 +1974,30 @@ export class GameWorld {
this.assets.collision(this.map, this.player.x, this.player.y), this.assets.collision(this.map, this.player.x, this.player.y),
environment: () => this.assets.map(mapId).environment, environment: () => this.assets.map(mapId).environment,
phoneService: () => this.assets.map(mapId).phoneService, phoneService: () => this.assets.map(mapId).phoneService,
facingObject: () => this.facingObject(),
}; };
} }
/** GetFacingObject: the visible object on the tile the player faces. */
private facingObject() {
const [dx, dy] = VECTORS[this.player.direction];
const x = this.player.x + dx,
y = this.player.y + dy;
return this.objects().find((object) => object.x === x && object.y === y);
}
/** CheckForHiddenItems: a BGEVENT_ITEM not found yet on the screen
* around the player, from four squares left of and above the player to
* five right of and four below it. */
private hiddenItemOnScreen(state: ScriptState) {
const r = this.assets.reader,
{ x, y } = this.player;
return this.map.backgroundEvents.some(
(event) =>
event.kind === BGEVENT_ITEM &&
((y + 4 - event.y) & 0xff) < 9 &&
((x + 5 - event.x) & 0xff) < 10 &&
!state.events.has(r.word(event.script)),
);
}
private weekdayName(day: number) { private weekdayName(day: number) {
const r = this.assets.reader; const r = this.assets.reader;
return readString( return readString(
@@ -2079,6 +2125,7 @@ export class GameWorld {
scriptHost: () => this.host(PLAYER), scriptHost: () => this.host(PLAYER),
landmarkOf: (mapId) => this.assets.map(mapId).landmark, landmarkOf: (mapId) => this.assets.map(mapId).landmark,
gameTime: () => this.gameTime, gameTime: () => this.gameTime,
hiddenItemOnScreen: (state) => this.hiddenItemOnScreen(state),
}, },
this.menuCursors, this.menuCursors,
select, select,
@@ -3597,7 +3644,7 @@ export class GameWorld {
if (set !== (event.kind === 5)) return; if (set !== (event.kind === 5)) return;
script = r.near(event.script + 2, r.bank(event.script)); script = r.near(event.script + 2, r.bank(event.script));
} else if (event.kind === 0) script = event.script; } else if (event.kind === 0) script = event.script;
else if (event.kind === 7) { else if (event.kind === BGEVENT_ITEM) {
// BGEVENT_ITEM: hiddenitem data (event flag, item) for HiddenItemScript. // BGEVENT_ITEM: hiddenitem data (event flag, item) for HiddenItemScript.
const hidden = { const hidden = {
event: r.word(event.script), event: r.word(event.script),
@@ -4054,6 +4101,9 @@ export class GameWorld {
flash: state.flash, flash: state.flash,
noWildEncounters: state.noWildEncounters, noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem }, registeredItem: { ...state.registeredItem },
repel: state.repel,
coins: state.coins,
blueCardBalance: state.blueCardBalance,
options: { ...state.options }, options: { ...state.options },
}; };
} }
@@ -4161,6 +4211,9 @@ export class GameWorld {
!save.registeredItem || !save.registeredItem ||
!integer(save.registeredItem.which, 0, 255) || !integer(save.registeredItem.which, 0, 255) ||
!integer(save.registeredItem.item, 0, 255) || !integer(save.registeredItem.item, 0, 255) ||
!integer(save.repel, 0, 255) ||
!integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.blueCardBalance, 0, 255) ||
!validOptions(save.options) !validOptions(save.options)
) { ) {
throw new Error( throw new Error(
@@ -4228,6 +4281,9 @@ export class GameWorld {
flash: save.flash, flash: save.flash,
noWildEncounters: save.noWildEncounters, noWildEncounters: save.noWildEncounters,
registeredItem: { ...save.registeredItem }, registeredItem: { ...save.registeredItem },
repel: save.repel,
coins: save.coins,
blueCardBalance: save.blueCardBalance,
options: { ...save.options }, options: { ...save.options },
specialPhoneCall: save.specialPhoneCall, specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName, rivalName: save.rivalName,
@@ -4338,6 +4394,11 @@ export function migrateSave(
...(!Object.hasOwn(save, "registeredItem") && { ...(!Object.hasOwn(save, "registeredItem") && {
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
}), }),
...(!Object.hasOwn(save, "repel") && {
repel: 0,
coins: 0,
blueCardBalance: 0,
}),
...(!Object.hasOwn(save, "intro") && { intro: false }), ...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }), ...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }), ...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
@@ -4386,6 +4447,9 @@ export function migrateSave(
flash: false, flash: false,
noWildEncounters: false, noWildEncounters: false,
registeredItem: { which: 0, item: 0 }, registeredItem: { which: 0, item: 0 },
repel: 0,
coins: 0,
blueCardBalance: 0,
intro: false, intro: false,
gameTime: emptyGameTime(), gameTime: emptyGameTime(),
options: defaultOptions(), options: defaultOptions(),
+413
View File
@@ -0,0 +1,413 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { DecorationData } from "../src/game/world/decorations";
import { generatePartyMon } from "../src/game/party/pokemon";
import {
PLAYER,
ScriptRun,
emptyScriptState,
type ScriptHost,
} from "../src/game/scripts/interpreter";
import { StartMenuRun } from "../src/game/scripts/start-menu";
import { defaultTextContext } from "../src/game/scripts/text";
import { OVERWORLD } from "../src/game/rom/profile";
import { close, useItem } from "./menu-driver";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const PLAYERS_ROOM = "24:7",
ROUTE_30 = "26:1",
RADIO_TOWER_3F = "3:19",
GOLDENROD_UNDERGROUND = "3:53",
ROUTE_36 = "10:3";
const CYNDAQUIL = 155,
PIDGEY = 16;
const POTION = 0x12,
REPEL = 0x14,
SUPER_REPEL = 0x2a,
MAX_REPEL = 0x2b,
COIN_CASE = 0x36,
BLUE_CARD = 0x74,
SACRED_ASH = 0x9c,
NORMAL_BOX = 0xa7,
GORGEOUS_BOX = 0xa8,
ITEMFINDER = 0x37,
CARD_KEY = 0x7f,
BASEMENT_KEY = 0x85,
SQUIRTBOTTLE = 0xaf;
const SPRITEMOVEDATA_SUDOWOODO = 0x17;
const OAK_WARNING = "OAK: CHRIS! This isn't the time to use that!";
describe.skipIf(!existsSync(romPath))("field items", () => {
/** The player's room, where every floor tile is grass for a test-only
* table of level-`wildLevel` Pidgey met on every step past the cooldown. */
function fixture(wildLevel = 3, map = PLAYERS_ROOM, x = 3, y = 3) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
assets.grassCollisions = () => [
assets.collision(assets.map(PLAYERS_ROOM), 3, 2),
];
assets.grassWildMons = (id) =>
id === PLAYERS_ROOM
? {
rates: [256, 256, 256],
slots: [0, 1, 2].map(() =>
Array.from({ length: 7 }, () => ({
species: PIDGEY,
level: wildLevel,
})),
),
}
: undefined;
let seed = 1;
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
const mon = (level: number) =>
generatePartyMon(assets.pokemon, {
species: CYNDAQUIL,
level,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
world.state.party = [mon(5), mon(8)];
world.state.bag.items = [{ item: POTION, quantity: 1 }];
world.restore({ ...world.save(), map, x, y, objects: [] });
return world;
}
/** Step left and right until a wild battle or a page appears, at most
* `limit` steps; returns the number of steps taken. */
function walk(world: GameWorld, limit: number) {
for (let step = 1; step <= limit; step++) {
expect(world.move(step & 1 ? "left" : "right")).toBe(true);
if (world.battle || world.dialogue) return step;
}
return limit;
}
it("keeps weaker wild Pokémon away while a Repel lasts", () => {
const world = fixture();
world.state.bag.items.push({ item: REPEL, quantity: 2 });
expect(useItem(world, REPEL)).toEqual(["CHRIS used the REPEL."]);
// The pack stays open (ITEMMENU_CURRENT); a second one is refused.
expect(world.dialogue?.choice?.kind).toBe("pack");
world.advanceDialogue();
close(world);
expect(world.state.repel).toBe(100);
expect(world.state.bag.items.at(-1)).toEqual({ item: REPEL, quantity: 1 });
expect(useItem(world, REPEL)).toEqual([
"The REPEL used earlier is still in effect.",
]);
close(world);
expect(world.state.bag.items.at(-1)).toEqual({ item: REPEL, quantity: 1 });
// The Level 3 Pidgey never reaches the Level 5 lead.
expect(walk(world, 20)).toBe(20);
expect(world.battle).toBeUndefined();
expect(world.state.repel).toBe(80);
// A fainted lead hands the check to the next Pokémon (Level 8).
world.state.party[0].hp = 0;
expect(walk(world, 10)).toBe(10);
expect(world.battle).toBeUndefined();
});
it("lets wild Pokémon of the lead's level through", () => {
const world = fixture(5);
world.state.repel = 100;
expect(walk(world, 10)).toBe(5);
expect(world.battle).toBeDefined();
// The step that met the Pokémon replays after a reload, with its
// Repel step: the save made during the battle has the earlier count.
expect(world.save().repel).toBe(96);
expect(world.state.repel).toBe(95);
});
it("wears off on its last step, which meets no Pokémon", () => {
const world = fixture();
world.state.repel = 6;
// Five steps pass the cooldown under the Repel.
expect(walk(world, 5)).toBe(5);
expect(world.battle).toBeUndefined();
expect(world.move("left")).toBe(true);
expect(world.dialogue?.pages).toEqual(["REPEL's effect\nwore off."]);
expect(world.battle).toBeUndefined();
expect(world.state.repel).toBe(0);
// A save during the message replays the Repel's last step.
const saved = world.save();
expect([saved.repel, saved.x]).toEqual([1, 2]);
settle(world);
expect(walk(world, 1)).toBe(1);
expect(world.battle).toBeDefined();
const replayed = fixture();
replayed.restore(saved);
expect(replayed.move("left")).toBe(true);
expect(replayed.dialogue?.pages).toEqual(["REPEL's effect\nwore off."]);
});
it("gives Super and Max Repels 200 and 250 steps", () => {
for (const [item, steps, name] of [
[SUPER_REPEL, 200, "SUPER REPEL"],
[MAX_REPEL, 250, "MAX REPEL"],
] as const) {
const world = fixture();
world.state.bag.items.push({ item, quantity: 1 });
expect(useItem(world, item)).toEqual([`CHRIS used the ${name}.`]);
close(world);
expect(world.state.repel).toBe(steps);
expect(world.state.bag.items.some((slot) => slot.item === item)).toBe(
false,
);
}
});
it("shows the Coin Case's coins and the Blue Card's points", () => {
const world = fixture();
world.state.bag.keyItems = [COIN_CASE, BLUE_CARD];
world.state.coins = 1234;
world.state.blueCardBalance = 7;
expect(useItem(world, COIN_CASE)).toEqual(["Coins: 1234"]);
world.advanceDialogue();
close(world);
expect(useItem(world, BLUE_CARD)).toEqual(["You now have 7 points."]);
close(world);
expect(world.state.bag.keyItems).toEqual([COIN_CASE, BLUE_CARD]);
});
it("revives the whole party with Sacred Ash, only after a faint", () => {
const world = fixture();
world.state.bag.items.push({ item: SACRED_ASH, quantity: 2 });
// CheckAnyFaintedMon: nobody has fainted, so Oak objects.
expect(useItem(world, SACRED_ASH)).toEqual([OAK_WARNING]);
close(world);
expect(world.state.bag.items.at(-1)).toEqual({
item: SACRED_ASH,
quantity: 2,
});
const [first, second] = world.state.party;
first.hp = 0;
first.status = 0;
second.hp = 1;
second.status = 0x08;
second.pp[0] = 0;
// The menu closes and SacredAshScript runs.
expect(useItem(world, SACRED_ASH)).toEqual([
"CHRIS's POKéMON were all healed!",
]);
close(world);
for (const mon of world.state.party) {
expect([mon.hp, mon.status]).toEqual([mon.stats[0], 0]);
expect(mon.pp[0]).toBeGreaterThan(0);
}
expect(world.state.bag.items.at(-1)).toEqual({
item: SACRED_ASH,
quantity: 1,
});
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("sends the trophy in a Normal or Gorgeous Box home", () => {
for (const [item, trophy] of [
[NORMAL_BOX, "SILVER TROPHY"],
[GORGEOUS_BOX, "GOLD TROPHY"],
] as const) {
const world = fixture();
world.state.bag.items.push({ item, quantity: 1 });
const before = new Set(world.state.events);
expect(useItem(world, item)).toEqual([
"There was a trophy inside!",
"CHRIS sent the trophy home.",
]);
close(world);
const added = [...world.state.events].filter((id) => !before.has(id));
const decorations = new DecorationData(
world.assets.reader,
world.assets.pokemon,
);
const owned = Array.from({ length: 52 }, (_, i) => i + 1).filter((id) =>
added.includes(decorations.attribute(id).event),
);
expect(owned.map((id) => decorations.name(id))).toEqual([trophy]);
expect(world.state.bag.items.some((slot) => slot.item === item)).toBe(
false,
);
}
});
it("saves the Repel, coins and points, and migrates saves without them", () => {
const world = fixture();
Object.assign(world.state, { repel: 42, coins: 9999, blueCardBalance: 30 });
const saved = world.save();
expect(saved).toMatchObject({
repel: 42,
coins: 9999,
blueCardBalance: 30,
});
world.restore(saved);
expect(world.save()).toEqual(saved);
const { repel: _, coins: __, blueCardBalance: ___, ...legacy } = saved;
expect(migrateSave(legacy, () => 0)).toMatchObject({
repel: 0,
coins: 0,
blueCardBalance: 0,
});
for (const invalid of [
{ repel: 256 },
{ coins: 10000 },
{ blueCardBalance: -1 },
])
expect(() => world.restore({ ...saved, ...invalid })).toThrow(
"Invalid native save",
);
});
it("finds hidden items on the screen with the Itemfinder", () => {
// Route 30's hidden Potion at 14, 9 is seen from 9-18 across and 5-13
// down: four squares left of and above the player, five right of it
// and four below.
const nearby = "Yes! ITEMFINDER indicates there's an item nearby.",
nope = "Nope! ITEMFINDER isn't responding.";
for (const [x, y, pages] of [
[18, 9, [nearby]],
[19, 9, [nope]],
[9, 6, [nearby]],
[7, 6, [nope]],
[12, 5, [nearby]],
[12, 4, [nope]],
[12, 13, [nearby]],
[12, 14, [nope]],
] as const) {
const world = fixture(3, ROUTE_30, x, y);
world.state.bag.keyItems = [ITEMFINDER];
expect(useItem(world, ITEMFINDER), `${x}, ${y}`).toEqual(pages);
close(world);
expect(world.state.bag.keyItems).toEqual([ITEMFINDER]);
}
// Once found, the item no longer answers.
const world = fixture(3, ROUTE_30, 14, 10);
world.state.bag.keyItems = [ITEMFINDER];
world.state.events.add(
world.assets.reader.word(
world.map.backgroundEvents.find((event) => event.kind === 7)!.script,
),
);
expect(useItem(world, ITEMFINDER)).toEqual([nope]);
close(world);
});
it("uses the keys only at their doors", () => {
// Away from the Radio Tower and the Underground, Oak objects.
const world = fixture();
world.state.bag.keyItems = [CARD_KEY, BASEMENT_KEY];
for (const key of [CARD_KEY, BASEMENT_KEY]) {
expect(useItem(world, key)).toEqual([OAK_WARNING]);
close(world);
}
expect(world.state.bag.keyItems).toEqual([CARD_KEY, BASEMENT_KEY]);
// Those maps are not open yet: a menu run with their positions.
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const use = (
item: number,
mapId: string,
facing: number,
position: { x: number; y: number },
) => {
const state = emptyScriptState();
state.bag.keyItems = [item];
const run = new StartMenuRun(state, {
reader: assets.reader,
data: assets.pokemon,
text: () => defaultTextContext("CHRIS"),
scriptHost: () =>
({
mapId,
facing: () => facing,
position: () => position,
}) as ScriptHost,
} as unknown as ConstructorParameters<typeof StartMenuRun>[1]);
let effect = run.resume();
// PACK, the key, USE.
const menu = (label: string) => {
if (effect?.kind !== "menu")
throw new Error(`Expected a menu, got ${effect?.kind}`);
effect = run.resume(effect.items.indexOf(label));
};
menu("PACK");
effect = run.resume((2 << 8) | 0);
menu("USE");
return effect?.kind === "text"
? effect.pages.map((page) => page.replace(/\n/g, " "))
: run.deferred;
};
const UP = 1,
DOWN = 0,
LEFT = 2;
// _CardKey: facing up at the slot, 18, 6 with the map's border.
expect(use(CARD_KEY, RADIO_TOWER_3F, UP, { x: 14, y: 3 })).toBe(
OVERWORLD["_CardKey.CardKeyScript"],
);
expect(use(CARD_KEY, RADIO_TOWER_3F, LEFT, { x: 15, y: 2 })).toEqual([
OAK_WARNING,
]);
expect(use(CARD_KEY, ROUTE_36, UP, { x: 14, y: 3 })).toEqual([OAK_WARNING]);
// _BasementKey: facing the door at 22, 10 from any side.
expect(
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, DOWN, { x: 18, y: 5 }),
).toBe(OVERWORLD["_BasementKey.BasementKeyScript"]);
expect(
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, LEFT, { x: 19, y: 6 }),
).toBe(OVERWORLD["_BasementKey.BasementKeyScript"]);
expect(
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, UP, { x: 18, y: 5 }),
).toEqual([OAK_WARNING]);
});
it("sprinkles water with the Squirtbottle, which only Sudowoodo minds", () => {
const world = fixture();
world.state.bag.keyItems = [SQUIRTBOTTLE];
expect(useItem(world, SQUIRTBOTTLE)).toEqual([
"CHRIS sprinkled water.",
"But nothing happened…",
]);
close(world);
expect(world.state.bag.keyItems).toEqual([SQUIRTBOTTLE]);
// Route 36 is not open yet: the script against a host facing Sudowoodo.
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const facing = { id: 5, movement: SPRITEMOVEDATA_SUDOWOODO };
const host = {
mapId: ROUTE_36,
lastTalked: PLAYER,
text: () => defaultTextContext("CHRIS"),
data: assets.pokemon,
facingObject: () => facing,
sceneOwner: () => undefined,
} as unknown as ScriptHost;
const run = new ScriptRun(
assets.reader,
OVERWORLD["_Squirtbottle.SquirtbottleScript"],
emptyScriptState(),
host,
{},
);
const first = run.resume();
expect(first?.kind === "text" && first.pages).toEqual([
"CHRIS used the\nSQUIRTBOTTLE.",
]);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { expect } from "vitest";
import type { GameWorld } from "../src/game/world/world";
import { settle } from "./world-driver";
/** Acknowledge pages up to the next choice; returns the pages seen. */
export function until(world: GameWorld) {
const pages: string[] = [];
for (let tick = 0; tick < 500; tick++) {
const dialogue = world.dialogue;
if (!dialogue) {
if (!world.busy) return pages;
world.tick();
continue;
}
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
if (page && pages.at(-1) !== page) pages.push(page);
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) return pages;
if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
throw new Error("No choice appeared.");
}
export function menuItems(world: GameWorld) {
const choice = world.dialogue?.choice;
return choice?.kind === "menu" ? choice.items : [];
}
/** Choose the menu item matching `label` (the whole label, or a regex). */
export function pick(world: GameWorld, label: string | RegExp) {
const items = menuItems(world);
const index = items.findIndex((item) =>
typeof label === "string" ? item === label : label.test(item),
);
expect(index, `${label} in ${items.join(", ")}`).toBeGreaterThanOrEqual(0);
world.choose(index);
return until(world);
}
/** Choose the pack entry for `item` in its pocket. */
export function pickItem(world: GameWorld, item: number) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "pack")
throw new Error(`Expected the pack, got ${choice?.kind}`);
for (const [pocket, { entries }] of choice.pockets.entries()) {
const index = entries.findIndex((entry) => entry.item === item);
if (index >= 0) {
world.choosePackEntry(pocket, index);
return until(world);
}
}
throw new Error(`Item ${item} is not in the pack.`);
}
export function open(world: GameWorld) {
expect(world.openStartMenu()).toBe(true);
return until(world);
}
/** PACK, the item, then USE: the pages shown up to the next choice. */
export function useItem(world: GameWorld, item: number) {
open(world);
pick(world, "PACK");
pickItem(world, item);
return pick(world, "USE");
}
/** B until every menu has closed; returns the pages shown meanwhile. */
export function close(world: GameWorld) {
const pages: string[] = [];
for (let i = 0; i < 20 && world.busy; i++) {
if (world.dialogue?.choice) world.advanceDialogue();
pages.push(...until(world));
}
pages.push(...settle(world));
expect(world.diagnostic).toBe("");
return pages;
}
+6 -8
View File
@@ -20,7 +20,7 @@ const BICYCLE = 7,
ANTIDOTE = 9, ANTIDOTE = 9,
FULL_RESTORE = 0x0e, FULL_RESTORE = 0x0e,
POTION = 0x12, POTION = 0x12,
REPEL = 0x14, OLD_ROD = 0x3a,
THUNDERSTONE = 0x17, THUNDERSTONE = 0x17,
HP_UP = 0x1a, HP_UP = 0x1a,
RARE_CANDY = 0x20, RARE_CANDY = 0x20,
@@ -627,10 +627,8 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
it("offers no USE without a field menu, and a notice for unported effects", () => { it("offers no USE without a field menu, and a notice for unported effects", () => {
const world = fixture(); const world = fixture();
world.state.bag.items.push( world.state.bag.items.push({ item: BERRY, quantity: 1 });
{ item: BERRY, quantity: 1 }, world.state.bag.keyItems.push(OLD_ROD);
{ item: REPEL, quantity: 1 },
);
world.state.bag.balls = [{ item: 5, quantity: 3 }]; world.state.bag.balls = [{ item: 5, quantity: 3 }];
open(world); open(world);
pick(world, "PACK"); pick(world, "PACK");
@@ -638,11 +636,11 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
pickItem(world, 5); pickItem(world, 5);
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]); expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
pick(world, "QUIT"); pick(world, "QUIT");
pickItem(world, REPEL); pickItem(world, OLD_ROD);
pick(world, "USE"); pick(world, "USE");
expect(world.notice).toBe("Using REPEL is not ported yet."); expect(world.notice).toBe("Using OLD ROD is not ported yet.");
close(world); close(world);
expect(world.state.bag.items.find((slot) => slot.item === REPEL)).toBeTruthy(); expect(world.state.bag.keyItems).toContain(OLD_ROD);
}); });
it("raises a level with a Rare Candy, forgetting a move to learn a new one", () => { it("raises a level with a Rare Candy, forgetting a move to learn a new one", () => {