Port field moves from the menu, SEL with Select, and moving Pokémon in Bill's PC
Checks / web (push) Canceled after 0s

Field moves chosen in the party menu run their original checks and the
ROM's from-menu scripts (Cut, Surf, Strength, Flash, Whirlpool, Waterfall,
Sweet Scent), and Softboiled and Milk Drink share HP. SEL registers a key
item that Select then uses, replacing the Key Items shortcut, and Bill's
PC moves Pokémon between the party and every box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 17:22:56 +02:00
1 parent 45275ba83b
commit 3456aa4e11
25 files changed
+1127 -184

No files matched your search

+2 -2
View File
@@ -12,11 +12,11 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls.
Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Select opens Key Items, including the Bicycle when owned. The poster opens the Town Map.
Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map.
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
Route 31 and beyond, trade evolutions, the Pokégear, Pokédex and trainer card screens, field moves from the menu, MAIL, moving Pokémon between boxes, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
Route 31 and beyond, trade evolutions, the Pokégear, Pokédex and trainer card screens, Fly, Dig, Teleport, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run
+6 -4
View File
@@ -34,7 +34,7 @@ flowchart LR
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
| `reference/public` | Licensed emulator files for the reference build only |
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the game clock's minute offset, the weekday offset and DST flag, whether the introduction is still pending; positions of objects that scripts moved; and the party, bag, Pokédex sets, money and room decoration slots. Older saves without decorations migrate to the original bed and Town Map poster. Decoration imports validate slot categories, ownership and unique doll placement. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the game clock's minute offset, the weekday offset and DST flag, whether the introduction is still pending; positions of objects that scripts moved; and the party, bag, Pokédex sets, money, registered item and room decoration slots. Older saves without decorations migrate to the original bed and Town Map poster. Decoration imports validate slot categories, ownership and unique doll placement. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
@@ -56,11 +56,13 @@ state in version-3 saves, with defaults for older saves. Forced terrain and
turn-in-place timing run through the idle movement path. Dynamic warps and
elevators retain their return destination. Block mutations use a per-map working
copy and a revision counter, so rollback and the minimap see the same terrain.
Surf, Cut and Strength use the ROM scripts. Select opens the Key Items
menu, pausing overworld ticks while choosing the Bicycle. Start opens
Surf, Cut and Strength use the ROM scripts. Select runs the registered item
through the same module as the start menu. Start opens
`scripts/start-menu.ts`, a native run on a script draft like the introduction:
the party menu and pack change only the draft, which commits when the menu
closes, and an item that queues a script (the Bicycle) starts it afterwards.
closes, and an item or field move that queues a script (the Bicycle, Cut,
Surf …) starts it afterwards with the context the world checked (the
Pokémon, the block to cut, the surfing state or a Sweet Scent encounter).
The start menu, party menu and pocket cursors live in the world's memory,
not in saves, and are cleared after each battle. `scripts/evolution.ts`
translates `EvolveAfterBattle`, `LearnLevelMoves` and `LearnMove` over a
+55 -3
View File
@@ -27,12 +27,12 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only maps explicitly listed in NATIVE_MAPS can be entered. Location names, scene-variable owners and connection offsets come from the ROM. |
| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Native tileset animation and special palettes; Johto, Ice Path, restless Ilex Forest and Tin Tower each match 96 reference VRAM frames. 192 palette cases match the original routine. |
| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Variable sprites and Pokémon icons decoded; grass priority supported. Owned room decorations support selection, removal, slot placement, descriptions and validated saves. |
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Forced movement, Surf, Cut, Strength, cycling rules, pits and elevators implemented. Fourteen reference traces compare turn timing, actors and event flags; map-entry boundaries verify the completed destination. Bicycle access uses the Key Items menu (Select) or the pack. |
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Forced movement, Surf, Cut, Strength, cycling rules, pits and elevators implemented. Fourteen reference traces compare turn timing, actors and event flags; map-entry boundaries verify the completed destination. The Bicycle is used from the pack or with Select. Field moves chosen from the party menu are checked here (`menuFieldMove`). |
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. |
| 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. |
| `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), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Moving Pokémon, the stats screen and printing are 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, MAIL, SEL and the other field items give a notice. |
| `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/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. |
| `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. |
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. |
@@ -385,3 +385,55 @@ to its limit. `tests/battle-world.test.ts` wins a wild battle whose level-up
evolves Cyndaquil, and checks that a non-participant that could evolve does
not. A Chromium/WebKit test evolves through a Rare Candy. No emulator
comparison of these routines yet.
## Field moves from the menu, SEL and MOVE PKMN W/O MAIL
`PokemonActionSubmenu` passes a field move to the world, which evaluates
the menu function's checks on the menu's draft for the chosen party slot:
`CheckBadge` with `BadgeRequiredText`, `CheckMapForSomethingToCut`
(`CheckCutCollision` and `CutTreeBlockPointers`), `SurfFunction` (Fog
Badge, forced cycling, already surfing, water permission, `CheckDirection`
and `CheckFacingObject`), `StrengthFunction`, `FlashFunction`
(`SpecialAerodactylChamber` by map attributes, which sets
`EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER`, or `DARKNESS_PALSET`),
`TryWhirlpoolMenu`, `CheckMapCanWaterfall`, and the checks of Fly, Dig,
Teleport, Headbutt and Rock Smash. A success queues `Script_CutFromMenu`,
`SurfFromMenuScript`, `Script_StrengthFromMenu`, `Script_UseFlash`,
`Script_WhirlpoolFromMenu`, `Script_WaterfallFromMenu` or
`SweetScentFromMenu.SweetScent` with the field-move context the interpreter
already reads (`GetPartyNickname`, the replacement block, the surfing
state). New interpreter support: `callasm BlindingFlash` (sets the Flash
flag), `callasm SweetScentEncounter` (the encounter the world drew with
`CanEncounterWildMon`, `GetMapEncounterRate` and `ChooseWildEncounter`, no
rate roll) and `UseFlashTextScript`'s sound-only `text_asm`. Fly's map, Dig
and Teleport's warps, `TreeMonEncounter` and `RockMonEncounter` are not
ported and give a notice after their checks.
`Softboiled_MilkDrinkFunction` runs in the menu with `GetOneFifthMaxHP`,
`RemoveHP` and `RestoreHealth`, refusing the user itself and Pokémon that
fainted or are at full HP.
`RegisterItem` stores `wWhichRegisteredItem` (pocket in bits 6-7, 1-based
slot in bits 0-5) and `wRegisteredItem`, now saved as `registeredItem`.
`SelectMenu` runs as a start-menu run: `CheckRegisteredItem` (items by slot,
key items anywhere in their pocket, balls never because `.CheckBall`'s
carry test is inverted, TMs never; a miss clears the registration) and
`UseRegisteredItem` by field menu, with `wUsingItemWithSelect` choosing
`Script_GetOnBike_Register`/`Script_GetOffBike_Register`. `MayRegisterItemText`
and `ItemsOakWarningText` come from the ROM.
`BillsPC_MovePKMNMenu`: `IsAnyMonHoldingMail`, then
`StartMoveMonWOMail_SaveGame`'s question (the native session saves by
itself, so no save messages follow). `_MovePKMNWithoutMail` treats the party
as box 0; lists are presented per box. `BillsPC_CheckMail_PreventBlackout`,
`BillsPC_CheckSpaceInDestination` and `MovePKMNWithoutMail_InsertMon` with
`.CheckTrivialMove`, `CalcBufferMonStats` for a boxed Pokémon joining the
party, and `InsertPokemonIntoBox`'s `RestorePPOfDepositedPokemon`.
Tests: `tests/overworld-rom.test.ts` uses Surf, Cut, Strength, Flash, Sweet
Scent and Rock Smash from the menu (badge refusals, failure texts, the dark
map and the encounter); `tests/start-menu.test.ts` covers Softboiled, SEL,
Select with and without a registration, indoors, and a registration that
left its slot; `tests/pc.test.ts` moves Pokémon party to box, box to party,
within the party and within a full box, and refuses the last Pokémon and a
full destination. Browser tests register the Bicycle and ride it with
Select from the keyboard and the on-screen button.
+19 -8
View File
@@ -130,7 +130,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Capture with a full party runs `SendMonIntoBox`: the Pokémon goes first in the current box, with restored PP and `SetBoxMonCaughtData`, can be nicknamed and shows `BallSentToPCText`. If the box is already full the ball is not used (`Ball_BoxIsFullMessage`). A catch that fills the box sets `BATTLERESULT_BOX_FULL`; `reloadmapafterbattle` then queues Bill's call (`Script_SpecialBillCall`) as a memory script that runs at the next `PlayerEvents` and is not saved, as in the original.
- `VAR_BOXSPACE` reads the current box.
- Save format 3 gained `storage` (boxes, the current box and box names; a null name is the ROM default) and `pcItems`, migrated by absence to empty storage.
- Not ported: MOVE PKMN W/O MAIL (a notice returns to the menu; the stats screen is now ported), printing, a `givepoke` with a full party (refused; no open map reaches it) and the tutorial with a full party and box (the original repeats the unusable ball). Changing boxes does not show the save messages; the native session saves continuously.
- Not ported: printing (MOVE PKMN W/O MAIL and the stats screen are ported since), a `givepoke` with a full party (refused; no open map reaches it) and the tutorial with a full party and box (the original repeats the unusable ball). Changing boxes does not show the save messages; the native session saves continuously.
- Next: the introduction's naming and clock screens, player switching and other items in battle, then party and bag menus.
## Implemented: the introduction's naming and clock screens
@@ -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.
- 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`.
- Select keeps opening the Key Items shortcut, standing in for a registered item. Not ported: field moves from the menu, MAIL, SEL (registering items), 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 and Escape Rope (each gives a notice and changes nothing). Rare Candy, evolution stones and vitamins run since the evolution section below.
- 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 and Escape Rope (each gives a notice and changes nothing). Rare Candy, evolution stones and vitamins run since the evolution section below; field moves, SEL and Select since the section after it.
## Implemented: evolution
@@ -180,11 +180,21 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- PACK: Rare Candy (`RareCandyEffect`: one level at the level's minimum experience; the HP gains the maximum HP's increase even for a fainted Pokémon; the caught-location happiness bonus; "grew to level", the stat box, new moves and a possible evolution), evolution stones (`EvoStoneEffect`: a forced `EvolvePokemon`; the party menu shows ABLE or NOT ABLE with the original's ten-byte, three-byte-step check; an Everstone or no matching evolution has no effect) and vitamins (`VitaminEffect`: ten more in the stat experience's high byte, up to 100, stats recomputed without changing the current HP, happiness).
- Not presented: the evolution music, cries and the palette changes. Trade evolutions need Link.
## Implemented: field moves from the menu, SEL and moving Pokémon
- Field moves in the Pokémon submenu run their menu functions: the world checks the badge (`CheckBadge`: "Sorry! A new BADGE is required."), the facing tile, water or object, then the menu closes and the ROM's `…FromMenu` script runs for the chosen Pokémon (no "Want to …?" question): Cut (`CheckMapForSomethingToCut`), Surf (including "already surfing" and forced cycling), Strength, Flash (a `PALETTE_DARK` map or the Aerodactyl chamber; `BlindingFlash` sets the Flash flag), Whirlpool and Waterfall. Failures print the ROM's messages and return to the party menu.
- Sweet Scent runs `SweetScentFromMenu`: where wild Pokémon can appear and the map has a rate, `ChooseWildEncounter` picks one without the rate roll and a battle starts; elsewhere "Looks like there's nothing here…".
- Softboiled and Milk Drink give a fifth of the user's maximum HP to another Pokémon that has not fainted and is not at full HP; the user needs more than that fifth.
- Fly (outdoors with the Storm Badge), Dig (in caves), Teleport (outdoors with a spawn point), Headbutt (facing a tree) and Rock Smash (facing a rock) run their checks and messages, then give a notice: the Fly map, dungeon and spawn warps, tree encounters and rock encounters are not ported.
- SEL registers an item that `CANT_SELECT` allows (in practice key items) in `wWhichRegisteredItem` and `wRegisteredItem`, which saves keep. Select runs `SelectMenu`: `CheckRegisteredItem` (a slot that no longer holds the item clears the registration; a registered ball is never found, as in the original) and `UseRegisteredItem` by field menu. The Bicycle uses `Script_GetOnBike_Register` and `Script_GetOffBike_Register`, without a message; elsewhere Oak objects. Without a registration Select explains SEL. This replaces the earlier Key Items shortcut.
- Bill's PC's MOVE PKMN W/O MAIL: refused while a party Pokémon holds MAIL, then "Each time you move a POKéMON, data will be saved. OK?". Choose the party or a box, a Pokémon (MOVE, STATS, CANCEL), then a box and the position to insert before. The party keeps its last Pokémon and its last healthy one, a different full box or party has "no room", a Pokémon moved down its own list lands one place earlier (`.CheckTrivialMove`), a boxed Pokémon joining the party gets its stats with full HP and no status (`CalcBufferMonStats`), and one entering a box gets its PP restored. The original's left/right box switching is presented as a list of boxes; after "no room" the box is chosen again rather than the position.
- Save format 3 gained `registeredItem`, migrated by absence to none.
## 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.
This is still not a complete new game. The Pokégear, Pokédex and trainer card screens, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The start menu opens the party menu and the pack, and the Key Items shortcut supports the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
This is still not a complete new game. The Pokégear, Pokédex and trainer card screens, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
## 1. Complete the overworld foundation
@@ -196,7 +206,7 @@ This is still not a complete new game. The Pokégear, Pokédex and trainer card
- [x] Connected transitions for every enabled map (New Bark Town to Route 30)
- [x] Dynamic warps, elevators, ice, currents and forced movement
- [x] Native Surf, Cut and Strength interactions, cycling rules and boulder holes
- [x] Bicycle access from the Key Items menu with keyboard and touch controls
- [x] Bicycle access from the pack or a registered Select, with keyboard and touch controls
- [x] Tile changes (`changeblock`, `changemapblocks`) with rollback and minimap refresh
- [x] Room decorations, ownership, selection, removal, save validation and migration
- [x] Additional NPC movement types, variable sprites, Pokémon icons and movement-command decoding
@@ -230,8 +240,8 @@ pixel-perfect screenshots or full Celebi story support.
The bedroom PC selects and puts away owned decorations, including left/right
doll placement. Room blocks, sprites and descriptions reflect saved choices.
The Town Map poster opens the native ROM-backed map. Select opens Key Items
(Start now opens the start menu); using the Bicycle follows the native
The Town Map poster opens the native ROM-backed map. Start opens the start
menu and Select the registered item; using the Bicycle follows the native
outdoor/indoor rules. Browser
checks cover both controls and decoration flows in Chromium and WebKit.
Full item/Pokémon PC storage and the other bag pockets belong to later work.
@@ -262,7 +272,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Next: MOVE PKMN W/O MAIL, SEL and field moves from the menu, then the Pokégear.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too. Next: the Pokégear, then the trainer card and options.
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
@@ -294,7 +304,8 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
- [x] Save, backup export and import for the single native save
- [x] Touch battle commands, move list, PKMN party menu, PACK (Items and Balls pockets) and naming fields
- [x] Start menu, party menu and pack outside battle (switching, moves, held items, healing and PP items, tossing, the Bicycle)
- [ ] Pokégear, Pokédex, trainer card and options screens; MOVE PKMN W/O MAIL
- [x] Bill's PC: MOVE PKMN W/O MAIL between the party and every box
- [ ] Pokégear, Pokédex, trainer card and options screens
- [ ] Scalable text, configurable controls and fuller accessibility
- [ ] Save slots with migration, atomic writes and import previews
- [ ] Installable web app with offline app shell, safe updates and quota handling
+34 -1
View File
@@ -1,6 +1,7 @@
# Recovered Claude session
Latest continuation: see **Evolution: 2026-09-30** at the end of this file. Earlier sections are historical snapshots.
Latest continuation: see **Field moves, SEL and moving Pokémon: 2026-09-30**
at the end of this file. Earlier sections are historical snapshots.
Recovered on 2026-09-23 from the local Claude Code transcript
`~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`.
@@ -762,3 +763,35 @@ menus; no CI or physical-device run.
Next: MOVE PKMN W/O MAIL, SEL/registered items with Select, field moves
from the menu (Cut, Surf, Strength already have their ROM scripts), then
the Pokégear. Trade evolutions wait for Link.
The evolution increment was committed and pushed as `45275ba`.
## Field moves, SEL and moving Pokémon: 2026-09-30
Resumed with "Continue" after `45275ba`, taking the recorded next items.
- Field moves from the party menu: the world's `menuFieldMove` runs each
menu function's checks (badges, facing tile/water/object, dark map) and
either queues the ROM's `…FromMenu` script with the chosen Pokémon or
returns the ROM's failure texts. Cut, Surf, Strength, Flash, Whirlpool,
Waterfall and Sweet Scent work; Softboiled/Milk Drink run in the menu;
Fly, Dig, Teleport, Headbutt and Rock Smash give a notice after their
checks. Encounter selection was split out of `randomEncounter` so Sweet
Scent reuses it without the rate roll.
- SEL and Select: `registeredItem` saved (migrated by absence), Select runs
`SelectMenu` (bike via the `_Register` scripts, Oak's warning, "may be
registered", registration cleared when the slot changed). The invented
Key Items shortcut (UI, runtime flag and CSS) is removed.
- Bill's PC MOVE PKMN W/O MAIL, with lists per box.
- `check:reference` caught `SPRITEMOVEDATA_SMASHABLE_ROCK` written as 18
instead of $18; fixed and covered by a Rock Smash test.
Checks: 371 unit tests with the local ROM; 111 Chromium/WebKit browser
tests passed, 19 skipped intentionally (the two Key Items browser tests now
cover SEL/Select); native and reference builds, reference, vendor and
release checks; `git diff --check`. No emulator comparison; no CI or
physical-device run.
Next: the Pokégear (clock, map, phone and radio cards), then the trainer
card and options; later the Fly map and Teleport/Dig warps (they need
script commands after a warp).
+1
View File
@@ -238,6 +238,7 @@ const eventNames = {
playersHouseDoll1: "EVENT_PLAYERS_HOUSE_2F_DOLL_1",
playersHouseDoll2: "EVENT_PLAYERS_HOUSE_2F_DOLL_2",
playersHouseBigDoll: "EVENT_PLAYERS_HOUSE_2F_BIG_DOLL",
wallOpenedInAerodactylChamber: "EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER",
};
for (const [key, name] of Object.entries(eventNames)) {
const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile);
+2
View File
@@ -256,6 +256,8 @@ export const C = {
KEY_ITEM_POCKET: 2,
TM_HM_POCKET: 3,
PP_UP: 0x3e,
SOFTBOILED: 0x87,
MILK_DRINK: 0xd0,
RARE_CANDY: 0x20,
HP_UP: 0x1a,
PROTEIN: 0x1b,
+9
View File
@@ -85,6 +85,15 @@ export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
return { ...mon, level, status: 0, hp: stats[0], stats };
}
/** A boxed Pokémon in MOVE PKMN W/O MAIL's wBufferMon: its stored level,
* CalcBufferMonStats' stats with stat experience, full HP (none for an
* Egg) and no status. */
export function bufferMon(data: PokemonData, box: BoxMon): PartyMon {
const mon = structuredClone(box);
const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
return { ...mon, status: 0, hp: mon.species === 0xfd ? 0 : stats[0], stats };
}
/** ReceiveItem into wNumPCItems. */
export function receivePCItem(
items: ItemSlot[],
+1
View File
@@ -124,6 +124,7 @@ export class CrystalAssets {
tileset: r.byte(header + 1),
environment: r.byte(header + 2),
paletteMode: r.byte(header + 7) & 15,
attributes,
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
warps: [],
backgroundEvents: [],
+34
View File
@@ -169,6 +169,7 @@ export const EVENTS = {
playersHouseDoll1: 1858,
playersHouseDoll2: 1859,
playersHouseBigDoll: 1860,
wallOpenedInAerodactylChamber: 809,
} as const;
/** Engine flag indices named directly by translated routines. */
@@ -219,6 +220,26 @@ export const OVERWORLD = {
IconPointers: 0x8ebbf,
AskSurfScript: 0xca2c,
"UsedSurfScript.stubbed_fn": 0xc9a2,
Script_CutFromMenu: 0xc7fe,
SurfFromMenuScript: 0xc983,
Script_StrengthFromMenu: 0xcd29,
Script_UseFlash: 0xc8e6,
UseFlashTextScript: 0xc8f3,
"UseFlashTextScript.BlankText": 0xc908,
BlindingFlash: 0x8c7e1,
Script_WhirlpoolFromMenu: 0xce0b,
Script_WaterfallFromMenu: 0xcb1c,
"SweetScentFromMenu.SweetScent": 0x506c8,
SweetScentEncounter: 0x506ef,
"CheckBadge.BadgeRequiredText": 0xc73d,
"FieldMoveFailed.CantUseItemText": 0xc780,
CutNothingText: 0xc7c9,
CantSurfText: 0xc9ae,
AlreadySurfingText: 0xc9b3,
"EscapeRopeOrDig.CantUseDigText": 0xcc26,
"TeleportFunction.CantUseTeleportText": 0xccb6,
"Softboiled_MilkDrinkFunction.ItemCantUseOnMonText": 0xf44a,
RuinsOfAlphAerodactylChamber_MapAttributes: 0x958e8,
AskCutScript: 0xd1a9,
"AskCutScript.CheckMap": 0xd1ba,
CantCutScript: 0xd1cd,
@@ -237,7 +258,9 @@ export const OVERWORLD = {
ChrisStateSprites: 0x141b7,
KrisStateSprites: 0x141c0,
Script_GetOnBike: 0xd13e,
Script_GetOnBike_Register: 0xd14e,
Script_GetOffBike: 0xd158,
Script_GetOffBike_Register: 0xd16b,
Script_CantGetOffBike: 0xd171,
UpdatePlayerSprite: 0xe4a,
PlayMapMusic: 0x3cdf,
@@ -387,6 +410,12 @@ export const PC = {
PCString_BoxFull: 0xe3575,
PCString_PartyFull: 0xe3586,
PCString_NoReleasingEGGS: 0xe3597,
PCString_MoveToWhere: 0xe3500,
PCString_TheresNoRoom: 0xe3521,
"_MovePKMNWithoutMail.MenuData": 0xe28cb,
MoveMonWOMailSaveText: 0x152a6,
"BillsPC_MovePKMNMenu.PCMonHoldingMailText": 0xe4f9,
"MovePKMNWithoutMail_InsertMon.Saving_LeaveOn": 0xe3233,
"BillsPC_PlaceChooseABoxString.ChooseABox": 0xe37a1,
"BillsPC_PlaceEmptyBoxString_SFX.NoMonString": 0xe37d3,
"BillsPC_PrintBoxName.Current": 0xe36f1,
@@ -415,6 +444,11 @@ export const MENU = {
MonMenuOptions: 0x24cd9,
"GiveTakeItemMenuData.Items": 0x12ca3,
String_MoveWhere: 0x1316b,
"MonMenu_Softboiled_MilkDrink.PokemonNotEnoughHPText": 0x12f00,
MayRegisterItemText: 0x13340,
RegisteredItemText: 0x10afd,
CantRegisterText: 0x10b02,
ItemsOakWarningText: 0x12a67,
MailItems: 0xb9e80,
PokemonSwapItemText: 0x12caf,
PokemonHoldItemText: 0x12cb4,
+5 -9
View File
@@ -9,7 +9,6 @@ import { WorldRenderer } from "./rendering/renderer";
export class NativeRuntime implements GameRuntime {
readonly id = "crystal-native-world-v1";
world?: GameWorld;
fieldMenuOpen = false;
private renderer?: WorldRenderer;
private pressed = new Set<Button>();
private raf = 0;
@@ -26,7 +25,6 @@ export class NativeRuntime implements GameRuntime {
) {}
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
this.fieldMenuOpen = false;
const assets = new CrystalAssets(bytes);
const world = new GameWorld(assets);
this.world = world;
@@ -72,18 +70,17 @@ export class NativeRuntime implements GameRuntime {
)
world.moveCursor(button as Direction);
if (button === "start") world.openStartMenu();
if (button === "select") world.useSelect();
this.publish();
}
/** Start: the start menu, while the player stands still. */
openStartMenu() {
this.fieldMenuOpen = false;
this.world?.openStartMenu();
this.publish();
}
useBicycle() {
this.fieldMenuOpen = false;
if (this.world && !this.world.useBicycle())
this.world.notice = "You can’t use the BICYCLE here.";
/** Select: the registered item (SelectMenu). */
useSelect() {
this.world?.useSelect();
this.publish();
}
/** Choose a pack entry from a pointer control. */
@@ -128,7 +125,7 @@ export class NativeRuntime implements GameRuntime {
let ticks = 0;
while (this.pending >= 1 / TICKS_PER_SECOND && ticks < 6) {
this.pending -= 1 / TICKS_PER_SECOND;
if (!this.fieldMenuOpen) world.tick(held);
world.tick(held);
ticks++;
}
if (ticks === 6) this.pending = 0;
@@ -247,7 +244,6 @@ export class NativeRuntime implements GameRuntime {
restoreState(bytes: Uint8Array) {
if (!this.world || bytes.length > 64_000)
throw new Error("Invalid native save size.");
this.fieldMenuOpen = false;
this.world.restore(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
);
+17
View File
@@ -102,6 +102,9 @@ export interface ScriptState {
* (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */
flash: boolean;
noWildEncounters: boolean;
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
* and wRegisteredItem: the item Select uses; zeros when none. */
registeredItem: { which: number; item: number };
}
export const emptyScriptState = (): ScriptState => ({
decorations: emptyDecorations(),
@@ -126,6 +129,7 @@ export const emptyScriptState = (): ScriptState => ({
backupWarp: null,
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
});
export const copyScriptState = (state: ScriptState): ScriptState => ({
decorations: { ...state.decorations },
@@ -153,6 +157,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
backupWarp: state.backupWarp && { ...state.backupWarp },
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
});
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
@@ -472,6 +477,11 @@ export class ScriptRun {
rival: this.state.rivalName,
strings: { ...this.host.text().strings, ...this.strings },
numbers: { ...this.host.text().numbers, ...this.numbers },
// UseFlashTextScript: SFX_FLASH, then an empty text.
asm: (offset) =>
offset === OVERWORLD.UseFlashTextScript + 4
? OVERWORLD["UseFlashTextScript.BlankText"]
: undefined,
};
}
@@ -759,6 +769,13 @@ export class ScriptRun {
) {
const tile = this.host.playerCollision();
value = Number(!(tile === 0x33 || tile === 0x3b)); // CheckWaterfallTile
} else if (routine === OVERWORLD.BlindingFlash) {
// STATUSFLAGS_FLASH_F; the fades and palettes are presentation.
this.state.flash = true;
} else if (routine === OVERWORLD.SweetScentEncounter) {
// Chosen by the world when the move was used (CanEncounterWildMon,
// GetMapEncounterRate and ChooseWildEncounter).
value = Number(this.options.wild !== undefined);
} else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) {
// StubbedTrainerRankings_Surf: the cut Trainer Rankings feature.
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
+129 -7
View File
@@ -1,14 +1,23 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { ENGINE_FLAGS, PC, RAM } from "../rom/profile";
import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { BOX_NAME_PATTERN, PARTY_LENGTH } from "../party/pokemon";
import {
BOX_NAME_PATTERN,
PARTY_LENGTH,
type PartyMon,
} from "../party/pokemon";
import {
MONS_PER_BOX,
NUM_BOXES,
bufferMon,
currentBox,
depositMon,
receivePCItem,
restorePP,
toBoxMon,
withdrawnMon,
type BoxMon,
} from "../party/storage";
import { readString, readText, type TextContext } from "./text";
import type { MartEntry, ScriptEffect, ScriptState } from "./interpreter";
@@ -422,15 +431,128 @@ export class PcSession {
if (choice === BILLSPC_WITHDRAW) yield* this.withdrawMons();
else if (choice === BILLSPC_DEPOSIT) yield* this.depositMons();
else if (choice === BILLSPC_CHANGE_BOX) yield* this.changeBox();
else if (choice === BILLSPC_MOVE)
yield {
kind: "notice",
text: "Moving Pokémon between boxes is not ported yet.",
};
else if (choice === BILLSPC_MOVE) yield* this.movePKMNMenu();
else return;
}
}
// ------------------------------------------------ MOVE PKMN W/O MAIL
/** BillsPC_MovePKMNMenu: refused while a party Pokémon holds MAIL
* (IsAnyMonHoldingMail), then StartMoveMonWOMail_SaveGame's question. The
* native session saves by itself, so no save messages follow. */
private *movePKMNMenu(): Gen {
const r = this.host.reader;
const mail = new Set<number>();
for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++) mail.add(r.byte(p));
if (this.state.party.some((mon) => mail.has(mon.item)))
return yield* this.print(PC["BillsPC_MovePKMNMenu.PCMonHoldingMailText"]);
const ok = yield { kind: "yesno", pages: this.pages(PC.MoveMonWOMailSaveText) };
if (ok) yield* this.moveWithoutMail();
}
/** wBillsPC_LoadedBox: 0 is the party, 1-14 the boxes. */
private loadedBox(box: number): (PartyMon | BoxMon)[] {
return box ? this.state.storage.boxes[box - 1] : this.state.party;
}
private loadedBoxName(box: number) {
return box
? this.boxName(box - 1)
: this.string(PC["BillsPC_BoxName.PartyPKMN"]);
}
/** Choose the party or a box (left and right in the original). */
private *chooseLoadedBox(prompt: string, selected: number): Gen<number> {
const boxes = Array.from({ length: NUM_BOXES + 1 }, (_, box) => box);
return yield* this.menu(
[prompt],
boxes.map((box) => this.loadedBoxName(box)),
boxes.map((box) => `${this.loadedBox(box).length}/${box ? MONS_PER_BOX : PARTY_LENGTH}`),
selected,
);
}
/** _MovePKMNWithoutMail: choose a Pokémon in the party or a box, MOVE it
* (STATS shows it) and choose where it goes; the list is presented per
* box instead of with left and right. */
private *moveWithoutMail(): Gen {
const data = this.host.data;
const choose = this.string(PC.PCString_ChooseaPKMN),
where = this.string(PC.PCString_MoveToWhere),
cancel = this.string(PC["BillsPC_RefreshTextboxes.CancelString"]),
options = this.strings(PC["_MovePKMNWithoutMail.MenuData"] + 2, 3);
let loaded = this.state.storage.current + 1;
for (;;) {
const from = yield* this.chooseLoadedBox(choose, loaded);
if (from < 0) return;
loaded = from;
const list = this.loadedBox(from);
const index = yield* this.menu(
[`${this.loadedBoxName(from)}\n${choose}`],
[...list.map((mon) => mon.nickname), cancel],
this.monDetails(list),
);
if (index < 0 || index >= list.length) continue;
const action = yield* this.menu([this.string(PC.PCString_WhatsUp)], options);
if (action === 1) {
const mon = list[index];
yield {
kind: "stats",
mon: from ? bufferMon(data, mon as BoxMon) : structuredClone(mon as PartyMon),
};
continue;
}
if (action !== 0) continue;
// BillsPC_CheckMail_PreventBlackout, even within the party.
if (!from && !(yield* this.partyMonMayLeave(index))) continue;
// Where: a box, then the position to insert before.
for (;;) {
const to = yield* this.chooseLoadedBox(where, from);
if (to < 0) break;
const destination = this.loadedBox(to);
const position = yield* this.menu(
[`${this.loadedBoxName(to)}\n${where}`],
[...destination.map((mon) => mon.nickname), "▶"],
this.monDetails(destination),
destination.length,
);
if (position < 0) continue;
// BillsPC_CheckSpaceInDestination
if (
to !== from &&
destination.length >= (to ? MONS_PER_BOX : PARTY_LENGTH)
) {
yield* this.placeString(this.string(PC.PCString_TheresNoRoom));
continue;
}
yield* this.placeString(
this.string(PC["MovePKMNWithoutMail_InsertMon.Saving_LeaveOn"]),
10,
);
this.insertMon(from, index, to, position);
loaded = to;
break;
}
}
}
/** MovePKMNWithoutMail_InsertMon: remove, then insert before `position`
* (one earlier when moving down the same list, .CheckTrivialMove). A
* boxed Pokémon joining the party gets CalcBufferMonStats' full HP;
* InsertPokemonIntoBox restores the PP. */
private insertMon(from: number, index: number, to: number, position: number) {
const data = this.host.data;
const source = this.loadedBox(from);
const [moved] = source.splice(index, 1);
if (from === to && index < position) position--;
if (to) {
const stored = from ? (moved as BoxMon) : toBoxMon(moved as PartyMon);
restorePP(data, stored);
this.state.storage.boxes[to - 1].splice(position, 0, stored);
} else
this.state.party.splice(
position,
0,
from ? bufferMon(data, moved as BoxMon) : (moved as PartyMon),
);
}
/** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one,
* or the last that has not fainted, stays. No MAIL exists yet. */
private *partyMonMayLeave(index: number): Gen<boolean> {
+155 -22
View File
@@ -1,6 +1,6 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { BATTLE, ENGINE_FLAGS, MENU, RAM } from "../rom/profile";
import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile";
import { C } from "../battle/constants";
import {
changedHappiness,
@@ -79,6 +79,22 @@ export interface MenuCursors {
pocket: number;
}
/** The world's verdict on a field move chosen from the party menu: a
* script to run once the menu closes (with the context the world checked),
* the messages of a failed attempt, or a notice for what is not ported. */
export type MenuFieldMove =
| {
script: number;
fieldMove: {
mon: number;
surfState?: number;
block?: { x: number; y: number; block: number };
};
wild?: { species: number; level: number };
}
| { texts: number[] }
| { notice: string };
/** What the start menu needs from the world. */
export interface StartMenuHost {
reader: RomReader;
@@ -86,11 +102,13 @@ export interface StartMenuHost {
text(): TextContext;
/** BikeFunction: the script that getting on or off the Bicycle queues
* here, or undefined when .CheckEnvironment refuses it. */
bicycleScript(state: ScriptState): number | undefined;
bicycleScript(state: ScriptState, select: boolean): number | undefined;
/** wTimeOfDay: morning 0, day 1, night 2. */
timeOfDay(): number;
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
landmark(): number;
/** A field move's checks for party slot `mon`, on the menu's draft. */
fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove;
}
/** The overworld start menu (engine/menus/start_menu.asm) with its
@@ -105,6 +123,8 @@ export class StartMenuRun {
* closes, with the item named as UseItem names it. */
deferred?: number;
deferredItem?: number;
/** The field move context and wild Pokémon a queued script needs. */
deferredMove?: Extract<MenuFieldMove, { script: number }>;
/** The menu cursors kept in work RAM between openings. */
readonly cursors: MenuCursors;
private strings: Record<number, string> = {};
@@ -112,14 +132,19 @@ export class StartMenuRun {
private iterator: Gen;
private done = false;
/** wUsingItemWithSelect: the registered item is being used. */
private usingSelect = false;
/** `select` runs SelectMenu (the Select button) instead of StartMenu. */
constructor(
state: ScriptState,
private host: StartMenuHost,
cursors: MenuCursors = { start: 0, party: 0, pocket: 0 },
select = false,
) {
this.state = copyScriptState(state);
this.cursors = { ...cursors };
this.iterator = this.run();
this.iterator = select ? this.selectMenu() : this.run();
}
/** Advance until the next effect, or undefined when the menu closed. */
@@ -270,6 +295,55 @@ export class StartMenuRun {
}
}
// -------------------------------------------------------- select menu
/** SelectMenu: use the registered item, or suggest registering one. */
private *selectMenu(): Gen {
const registered = this.checkRegisteredItem();
if (!registered) return yield* this.print(MENU.MayRegisterItemText);
const { item, index } = registered;
// UseRegisteredItem by the item's field menu.
const menu = this.data.itemFieldMenu(item);
if (menu < ITEMMENU_CURRENT) return yield* this.print(MENU.ItemsOakWarningText);
this.usingSelect = menu === ITEMMENU_CLOSE;
const used = yield* this.doItemEffect(index, item);
this.usingSelect = false;
if (menu === ITEMMENU_CLOSE && used === false)
yield* this.print(MENU.ItemsOakWarningText);
}
/** CheckRegisteredItem: the registered item if it is still in its slot
* (or, for key items, anywhere in the pocket); otherwise the
* registration is cleared. A registered ball is never found, because
* .CheckBall's slot test is inverted. */
private checkRegisteredItem(): { item: number; index: number } | undefined {
const registered = this.state.registeredItem,
bag = this.state.bag;
const pocket = registered.which >> 6,
index = (registered.which & 0x3f) - 1;
let found: { item: number; index: number } | undefined;
if (registered.which) {
if (pocket === 0) {
if (index < bag.items.length && bag.items[index].item === registered.item)
found = { item: registered.item, index };
} else if (pocket === KEY_ITEM_POCKET) {
if (bag.keyItems.includes(registered.item))
found = { item: registered.item, index: -1 };
}
}
if (!found) this.state.registeredItem = { which: 0, item: 0 };
return found;
}
/** RegisterItem: CANT_SELECT items cannot be registered. */
private *registerItem(pocket: PocketIndex, index: number, item: number): Gen {
if (this.data.itemUnselectable(item)) return yield* this.print(MENU.CantRegisterText);
this.state.registeredItem = {
which: ((pocket << 6) & 0xc0) | ((index + 1) & 0x3f),
item,
};
this.nameItem(item);
yield* this.print(MENU.RegisteredItemText);
}
// --------------------------------------------------------- party menu
/** The party menu with PlacePartyMenuText's prompt. Returns the party
@@ -314,7 +388,7 @@ export class StartMenuRun {
for (;;) {
const slot = yield* this.partyMenu(PARTYMENUACTION_CHOOSE_POKEMON);
if (slot < 0) return false;
yield* this.pokemonActionSubmenu(slot);
if (yield* this.pokemonActionSubmenu(slot)) return true;
}
}
@@ -353,8 +427,9 @@ export class StartMenuRun {
return this.stringList(MENU.MonMenuOptionStrings, value)[value - 1];
}
/** PokemonActionSubmenu (MonSubmenu); B is CANCEL. */
private *pokemonActionSubmenu(slot: number): Gen {
/** PokemonActionSubmenu (MonSubmenu); B is CANCEL. Returns true when a
* field move closes every menu to run its script. */
private *pokemonActionSubmenu(slot: number): Gen<boolean> {
const mon = this.state.party[slot];
const items = this.monSubmenuItems(mon);
const choice = yield* this.menu(
@@ -366,22 +441,77 @@ export class StartMenuRun {
case MONMENUITEM_STATS:
// OpenPartyStats
yield { kind: "stats", mon };
return;
break;
case MONMENUITEM_SWITCH:
return yield* this.switchPartyMons(slot);
yield* this.switchPartyMons(slot);
break;
case MONMENUITEM_ITEM:
return yield* this.giveTakePartyMonItem(slot);
yield* this.giveTakePartyMonItem(slot);
break;
case MONMENUITEM_MOVE:
return yield* this.managePokemonMoves(slot);
yield* this.managePokemonMoves(slot);
break;
case MONMENUITEM_CANCEL:
return;
break;
case MONMENUITEM_MAIL:
return yield* this.notice(`MAIL ${NOT_PORTED}`);
yield* this.notice(`MAIL ${NOT_PORTED}`);
break;
default:
return yield* this.notice(
`Using ${this.monMenuString(item)} from the menu ${NOT_PORTED}`,
);
return yield* this.fieldMove(slot, item);
}
return false;
}
/** MonMenu_Cut and the other field moves: Softboiled and Milk Drink run
* here; the others ask the world, which checks the map. */
private *fieldMove(slot: number, item: number): Gen<boolean> {
const r = this.reader;
let p = MENU.MonMenuOptions;
while (r.byte(p + 1) !== item) p += 3;
const move = r.byte(p + 2);
if (move === C.SOFTBOILED || move === C.MILK_DRINK) {
yield* this.softboiledMilkDrink(slot);
return false;
}
const result = this.host.fieldMove(this.state, slot, move);
if ("script" in result) {
this.deferred = result.script;
this.deferredMove = result;
return true;
}
if ("notice" in result) yield* this.notice(result.notice);
else for (const text of result.texts) yield* this.print(text);
return false;
}
/** MonMenu_Softboiled_MilkDrink: the user needs more than a fifth of
* its maximum HP, and gives that much to another Pokémon that has not
* fainted and is not at full HP. */
private *softboiledMilkDrink(user: number): Gen {
const party = this.state.party,
source = party[user];
const fifth = Math.floor(source.stats[0] / 5);
if (source.hp <= fifth) {
yield* this.print(MENU["MonMenu_Softboiled_MilkDrink.PokemonNotEnoughHPText"]);
return;
}
for (;;) {
const target = yield* this.partyMenu(PARTYMENUACTION_HEALING_ITEM);
if (target < 0) break;
const mon = party[target];
if (target === user || !mon.hp || mon.hp >= mon.stats[0]) {
yield* this.print(
OVERWORLD["Softboiled_MilkDrinkFunction.ItemCantUseOnMonText"],
);
continue;
}
source.hp = Math.max(0, source.hp - fifth);
this.setHP(mon, restoreHealth(mon.hp, mon.stats[0], fifth));
yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP);
break;
}
// .skip: the cursor returns to the user.
this.cursors.party = user + 1;
}
/** SwitchPartyMons and _SwitchPartyMons: "Move to where?" without a
@@ -591,7 +721,7 @@ export class StartMenuRun {
yield* this.tossMenu(pocket, index, item);
return false;
case "sel":
yield* this.notice(`Registering items for SELECT ${NOT_PORTED}`);
yield* this.registerItem(pocket, index, item);
return false;
default:
return false;
@@ -660,13 +790,16 @@ export class StartMenuRun {
}
const used = yield* this.doItemEffect(index, item);
if (menu !== ITEMMENU_CLOSE) return false;
if (!used) yield* this.print(BATTLE.OakThisIsntTheTimeText);
return used;
if (used === false) yield* this.print(BATTLE.OakThisIsntTheTimeText);
return used === true;
}
/** _DoItemEffect: ItemEffects by ROM address. Returns
* wItemEffectSucceeded. */
private *doItemEffect(index: number, item: number): Gen<boolean> {
* wItemEffectSucceeded, or "unported" after a notice. */
private *doItemEffect(
index: number,
item: number,
): Gen<boolean | "unported"> {
const r = this.reader;
this.nameItem(item);
const effect = r.near(
@@ -722,7 +855,7 @@ export class StartMenuRun {
case MENU.EvoStoneEffect:
return yield* this.evoStoneEffect(use);
case MENU.BicycleEffect: {
const script = this.host.bicycleScript(this.state);
const script = this.host.bicycleScript(this.state, this.usingSelect);
if (script === undefined) return false;
this.deferred = script;
this.deferredItem = item;
@@ -730,7 +863,7 @@ export class StartMenuRun {
}
}
yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`);
return false;
return "unported";
}
/** UseItem_SelectMon: the party menu for an item. The party index, or
+1 -1
View File
@@ -85,7 +85,7 @@ export interface TextContext {
battle?: { turn: number; player: string; enemy: string; enemyPrefix: string };
/** Native handlers for text_asm, keyed by the command's ROM offset. They
* return where text continues, as the original routine returns in hl. */
asm?: (offset: number) => number;
asm?: (offset: number) => number | undefined;
}
export const defaultTextContext = (player: string): TextContext => ({
+9
View File
@@ -51,6 +51,7 @@ export const OW = {
ENGINE_HIVEBADGE: 28,
ENGINE_PLAINBADGE: 29,
ENGINE_FOGBADGE: 30,
ENGINE_STORMBADGE: 32,
ENGINE_GLACIERBADGE: 33,
ENGINE_RISINGBADGE: 34,
// Map environments (constants/map_data_constants.asm).
@@ -99,6 +100,14 @@ export const OW = {
WHIRLPOOL: 250,
WATERFALL: 127,
HEADBUTT: 29,
FLY: 0x13,
DIG: 0x5b,
TELEPORT: 0x64,
FLASH: 0x94,
SWEET_SCENT: 0xe6,
ROCK_SMASH: 0xf9,
// SpriteMovementData (constants/map_object_constants.asm).
SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18,
BICYCLE: 7,
PIKACHU: 25,
EGG: 0xfd,
+2
View File
@@ -57,6 +57,8 @@ export interface GameMap {
tileset: number;
environment: number;
paletteMode: number;
/** The map attributes' ROM offset, which some routines compare. */
attributes?: number;
blocks: Uint8Array;
warps: Warp[];
backgroundEvents: BackgroundEvent[];
+230 -22
View File
@@ -7,6 +7,7 @@ import { CrystalAssets } from "../rom/assets";
import { TrainerData } from "../rom/trainers";
import {
ENGINE_FLAGS,
EVENTS,
INITIAL_POSITION,
NUM_FRUIT_TREES,
OVERWORLD,
@@ -27,7 +28,11 @@ import {
type ScriptState,
} from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import { StartMenuRun, type MenuCursors } from "../scripts/start-menu";
import {
StartMenuRun,
type MenuCursors,
type MenuFieldMove,
} from "../scripts/start-menu";
import { PartyEvolution } from "../scripts/evolution";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
@@ -107,6 +112,8 @@ export interface NativeSave {
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
flash: boolean;
noWildEncounters: boolean;
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
registeredItem: { which: number; item: number };
}
/** A random byte from the platform's secure generator. */
@@ -1364,7 +1371,20 @@ export class GameWorld {
)
return;
if (isIce(collision)) return;
// GetMapEncounterRate: CheckOnWater picks the water rate.
const table = this.encounterTable(state);
if (!(this.random() < table.rate)) return;
const chosen = this.chooseWildEncounter(table);
if (!chosen) return;
if (!state.party.some((mon) => mon.hp > 0)) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
committedPosition: previous,
wild: chosen,
});
}
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
* which a Cleanse Tag halves. */
private encounterTable(state: ScriptState) {
const collision = this.assets.collision(this.map, this.x, this.y);
const water = this.assets.permission(collision) === OW.WATER_TILE,
kanto = this.inKanto();
const grass = water
@@ -1376,10 +1396,17 @@ export class GameWorld {
let rate = (water ? sea?.rate : grass?.rates[this.clockTimeOfDay]) ?? 0;
// ApplyCleanseTagEffectOnEncounterRate
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
if (!(this.random() < rate)) return;
// ChooseWildEncounter: 1-100 against GrassMonProbTable or
// WaterMonProbTable, whose entries are (percent, slot offset).
if (!grass && !sea) return;
return { water, grass, sea, rate };
}
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
* varies the level. Undefined when no valid Pokémon is chosen. */
private chooseWildEncounter({
water,
grass,
sea,
}: ReturnType<GameWorld["encounterTable"]>) {
if (!grass && !sea) return undefined;
let roll: number;
do roll = this.random();
while (roll >= 100);
@@ -1399,12 +1426,167 @@ export class GameWorld {
}
// ValidateTempWildMonSpecies; Unown needs unlocked Unown (not ported).
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
return;
if (!state.party.some((mon) => mon.hp > 0)) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
committedPosition: previous,
wild: chosen,
});
return undefined;
return chosen;
}
/** CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
* on ice. */
private canEncounterWildMon(state: ScriptState) {
const map = this.map,
collision = this.assets.collision(map, this.x, this.y);
return (
!state.noWildEncounters &&
(map.environment === OW.CAVE ||
map.environment === OW.DUNGEON ||
this.assets.grassCollisions().includes(collision)) &&
!isIce(collision)
);
}
/** A field move chosen from the party menu (CutFunction, SurfFunction and
* the rest in engine/events/overworld.asm), checked on the menu's draft
* for party slot `mon`: a script queued for when the menu closes, the
* messages of a failed attempt, or a notice for what is not ported. */
private menuFieldMove(
state: ScriptState,
mon: number,
move: number,
): MenuFieldMove {
const [dx, dy] = VECTORS[this.player.direction];
const x = this.player.x + dx,
y = this.player.y + dy;
const tile = this.assets.collision(this.map, x, y) ?? 0;
const env = this.map.environment,
outdoors = env === OW.TOWN || env === OW.ROUTE;
const failed = { texts: [OVERWORLD["FieldMoveFailed.CantUseItemText"]] };
const badge = (flag: number) =>
state.flags.has(flag)
? undefined
: { texts: [OVERWORLD["CheckBadge.BadgeRequiredText"]] };
const facingObject = () =>
this.objects().find((object) => object.x === x && object.y === y);
switch (move) {
case OW.CUT: {
const block = this.fieldMoveBlock(x, y, tile, "cut");
return (
badge(OW.ENGINE_HIVEBADGE) ??
(block
? {
script: OVERWORLD.Script_CutFromMenu,
fieldMove: { mon, block },
}
: { texts: [OVERWORLD.CutNothingText] })
);
}
case OW.SURF: {
const noBadge = badge(OW.ENGINE_FOGBADGE);
if (noBadge) return noBadge;
const cant = { texts: [OVERWORLD.CantSurfText] };
if (state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)) return cant;
if (
state.playerState === OW.PLAYER_SURF ||
state.playerState === OW.PLAYER_SURF_PIKA
)
return { texts: [OVERWORLD.AlreadySurfingText] };
const current = this.assets.collision(this.map, this.x, this.y);
if (
this.assets.permission(tile) !== OW.WATER_TILE ||
this.sideBlocked(current, tile, this.player.direction) ||
facingObject()
)
return cant;
return {
script: OVERWORLD.SurfFromMenuScript,
fieldMove: {
mon,
// GetSurfType: Pikachu surfs on its own sprite.
surfState:
state.party[mon].species === OW.PIKACHU
? OW.PLAYER_SURF_PIKA
: OW.PLAYER_SURF,
},
};
}
case OW.STRENGTH:
return (
badge(OW.ENGINE_PLAINBADGE) ?? {
script: OVERWORLD.Script_StrengthFromMenu,
fieldMove: { mon },
}
);
case OW.FLASH: {
const noBadge = badge(OW.ENGINE_ZEPHYRBADGE);
if (noBadge) return noBadge;
// SpecialAerodactylChamber opens its wall; otherwise the map must
// be dark (DARKNESS_PALSET).
const chamber =
this.map.attributes ===
OVERWORLD.RuinsOfAlphAerodactylChamber_MapAttributes;
if (chamber) state.events.add(EVENTS.wallOpenedInAerodactylChamber);
return chamber || this.timeOfDay === 3
? { script: OVERWORLD.Script_UseFlash, fieldMove: { mon } }
: failed;
}
case OW.WHIRLPOOL: {
const noBadge = badge(OW.ENGINE_GLACIERBADGE);
if (noBadge) return noBadge;
const block = isWhirlpool(tile)
? this.fieldMoveBlock(x, y, tile, "whirlpool")
: undefined;
return block
? {
script: OVERWORLD.Script_WhirlpoolFromMenu,
fieldMove: { mon, block },
}
: failed;
}
case OW.WATERFALL: {
const noBadge = badge(OW.ENGINE_RISINGBADGE);
if (noBadge) return noBadge;
// CheckMapCanWaterfall: facing up at a waterfall tile.
return this.player.direction === "up" &&
(tile === OW.COLL_WATERFALL || tile === OW.COLL_CURRENT_DOWN)
? { script: OVERWORLD.Script_WaterfallFromMenu, fieldMove: { mon } }
: failed;
}
case OW.FLY:
return (
badge(OW.ENGINE_STORMBADGE) ??
(outdoors ? { notice: "The Fly map is not ported yet." } : failed)
);
case OW.DIG:
return env === OW.CAVE || env === OW.DUNGEON
? { notice: "Digging out of a cave is not ported yet." }
: { texts: [OVERWORLD["EscapeRopeOrDig.CantUseDigText"]] };
case OW.TELEPORT:
return outdoors && state.lastSpawnMap !== null
? { notice: "Teleport is not ported yet." }
: { texts: [OVERWORLD["TeleportFunction.CantUseTeleportText"]] };
case OW.HEADBUTT:
return tile === OW.COLL_HEADBUTT_TREE ||
tile === OW.COLL_HEADBUTT_TREE_1D
? { notice: "Headbutt trees are not ported yet." }
: failed;
case OW.ROCK_SMASH:
return facingObject()?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK
? { notice: "Smashing rocks is not ported yet." }
: failed;
case OW.SWEET_SCENT: {
// SweetScentEncounter, drawn now: no rate roll, and no battle
// without a rate or a valid Pokémon.
let wild: { species: number; level: number } | undefined;
if (this.canEncounterWildMon(state)) {
const table = this.encounterTable(state);
if (table.rate) wild = this.chooseWildEncounter(table);
}
return {
script: OVERWORLD["SweetScentFromMenu.SweetScent"],
fieldMove: { mon },
wild,
};
}
}
return { notice: "This field move is not ported yet." };
}
/** IsInJohto: Kanto landmarks start at KANTO_LANDMARK; the Fast Ship
@@ -1655,9 +1837,13 @@ export class GameWorld {
text: () => this.host(PLAYER).text(),
};
}
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
useSelect(): boolean {
return this.openStartMenu(true);
}
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */
openStartMenu(): boolean {
openStartMenu(select = false): boolean {
if (this.busy || this.introPending) return false;
this.clearNotice();
this.script = {
@@ -1667,11 +1853,14 @@ export class GameWorld {
reader: this.assets.reader,
data: this.assets.pokemon,
text: () => this.host(PLAYER).text(),
bicycleScript: (state) => this.bicycleScript(state),
bicycleScript: (state, select) => this.bicycleScript(state, select),
timeOfDay: () => this.clockTimeOfDay,
landmark: () => this.assets.map(this.mapId).landmark,
fieldMove: (state, mon, move) =>
this.menuFieldMove(state, mon, move),
},
this.menuCursors,
select,
),
committed: this.save(),
rollback: {
@@ -2396,12 +2585,17 @@ export class GameWorld {
this.sceneCheck = true;
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
if (script.run.deferred !== undefined)
this.startScript(script.run.deferred, PLAYER, {
usedItem:
this.startScript(
script.run.deferred,
PLAYER,
script.run instanceof StartMenuRun
? script.run.deferredItem
: undefined,
});
? {
usedItem: script.run.deferredItem,
fieldMove: script.run.deferredMove?.fieldMove,
wild: script.run.deferredMove?.wild,
}
: {},
);
}
private abortScript(error: unknown) {
const script = this.script!;
@@ -3228,7 +3422,7 @@ export class GameWorld {
}
/** BikeFunction.TryBike: the script QueueScript runs, or undefined when
* .CheckEnvironment or the player's state refuses the Bicycle. */
private bicycleScript(state: ScriptState): number | undefined {
private bicycleScript(state: ScriptState, select = false): number | undefined {
const map = this.map;
const outdoors =
map.environment === OW.TOWN || map.environment === OW.ROUTE;
@@ -3239,11 +3433,16 @@ export class GameWorld {
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
OW.LAND_TILE;
if (!allowed) return undefined;
// .CheckIfRegistered: Select uses the scripts without a message.
if (state.playerState === OW.PLAYER_NORMAL)
return OVERWORLD.Script_GetOnBike;
return select
? OVERWORLD.Script_GetOnBike_Register
: OVERWORLD.Script_GetOnBike;
if (state.playerState === OW.PLAYER_BIKE)
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
? OVERWORLD.Script_CantGetOffBike
: select
? OVERWORLD.Script_GetOffBike_Register
: OVERWORLD.Script_GetOffBike;
return undefined;
}
@@ -3484,6 +3683,7 @@ export class GameWorld {
backupWarp: state.backupWarp && { ...state.backupWarp },
flash: state.flash,
noWildEncounters: state.noWildEncounters,
registeredItem: { ...state.registeredItem },
};
}
restore(value: unknown) {
@@ -3579,7 +3779,10 @@ export class GameWorld {
!/^\d{1,2}:\d{1,3}$/.test(save.backupWarp.map) ||
!integer(save.backupWarp.warp, 1, 254))) ||
typeof save.flash !== "boolean" ||
typeof save.noWildEncounters !== "boolean"
typeof save.noWildEncounters !== "boolean" ||
!save.registeredItem ||
!integer(save.registeredItem.which, 0, 255) ||
!integer(save.registeredItem.item, 0, 255)
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
@@ -3641,6 +3844,7 @@ export class GameWorld {
backupWarp: save.backupWarp && { ...save.backupWarp },
flash: save.flash,
noWildEncounters: save.noWildEncounters,
registeredItem: { ...save.registeredItem },
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
};
@@ -3738,6 +3942,9 @@ export function migrateSave(
...(!Object.hasOwn(save, "noWildEncounters") && {
noWildEncounters: false,
}),
...(!Object.hasOwn(save, "registeredItem") && {
registeredItem: { which: 0, item: 0 },
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
// Clocks saved before InitClock was ported follow the device time.
...(save.clock !== null &&
@@ -3779,6 +3986,7 @@ export function migrateSave(
backupWarp: null,
flash: false,
noWildEncounters: false,
registeredItem: { which: 0, item: 0 },
intro: false,
} as unknown as NativeSave;
}
+6 -58
View File
@@ -1,5 +1,4 @@
import { drawTownMap } from "./game/rendering/town-map";
import { OW } from "./game/world/constants";
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { bindTabs } from "./ui/tabs";
@@ -48,7 +47,6 @@ function showWorld(world: GameWorld) {
get("game-notice").textContent = world.notice;
showBattle(world);
showStatsScreen(world);
showFieldMenu(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -438,64 +436,14 @@ let statsView: { slot?: number; page: number } | undefined;
let battleCursor = 0;
let battleSelectionKey = "";
let fieldCursor = 0;
function fieldButtons() {
return [
...get("field-items").querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
];
}
function showFieldMenu(world: GameWorld) {
const panel = get("field-menu");
panel.hidden = !runtime.fieldMenuOpen;
if (panel.hidden) return;
const buttons = world.state.bag.keyItems.map((item) => {
const button = document.createElement("button");
button.textContent = world.assets.pokemon.itemName(item);
button.disabled = item !== OW.BICYCLE;
button.onclick = () => {
if (session.state !== "playing") return;
runtime.useBicycle();
canvas.focus({ preventScroll: true });
};
return button;
});
const close = document.createElement("button");
close.textContent = "CLOSE";
close.onclick = () => {
runtime.fieldMenuOpen = false;
showFieldMenu(world);
canvas.focus();
};
get("field-items").replaceChildren(...buttons, close);
fieldButtons().forEach((button, i) =>
button.classList.toggle("battle-selected", i === fieldCursor),
);
}
/** Start opens the start menu and Select uses the registered item, while
* the player stands still (StartMenuScript and SelectMenuScript). */
function handleFieldInput(button: Button): boolean {
const world = runtime.world;
if (!world || world.battle) return false;
// Start opens the start menu; Select, standing in for a registered
// item, opens the Key Items shortcut.
if (button === "start" && !runtime.fieldMenuOpen && !world.busy) {
runtime.openStartMenu();
return true;
}
if (button === "select" && !world.busy) {
runtime.fieldMenuOpen = !runtime.fieldMenuOpen;
fieldCursor = 0;
return true;
}
if (!runtime.fieldMenuOpen) return false;
if (button === "b") runtime.fieldMenuOpen = false;
else if (button === "a") fieldButtons()[fieldCursor]?.click();
else if (["up", "down", "left", "right"].includes(button)) {
const length = fieldButtons().length;
fieldCursor =
(fieldCursor + (button === "up" || button === "left" ? -1 : 1) + length) %
length;
}
if (!world || world.battle || world.busy) return false;
if (button === "start") runtime.openStartMenu();
else if (button === "select") runtime.useSelect();
else return false;
return true;
}
-1
View File
@@ -33,7 +33,6 @@ export function renderShell(root: HTMLElement) {
</div>
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
<div id="field-menu" class="field-menu" hidden aria-label="Pack"><p>KEY ITEMS</p><div id="field-items"></div></div>
<div id="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p>
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-tabs" class="pack-tabs" aria-label="Pockets" hidden></div><div id="mart-menu" class="mart-menu"></div></div>
-24
View File
@@ -1117,30 +1117,6 @@ dialog dd {
}
}
.field-menu {
position: absolute;
inset: 1rem 1rem auto auto;
z-index: 6;
max-height: calc(100% - 2rem);
overflow: auto;
min-width: 10rem;
padding: 1rem;
border: 2px solid #27332a;
border-radius: 0.4rem;
background: #f8f4df;
color: #27332a;
}
.field-menu[hidden] {
display: none;
}
.field-menu p {
margin: 0 0 0.5rem;
}
#field-items {
display: grid;
gap: 0.3rem;
}
.town-map-view {
position: absolute;
inset: 0;
+39 -20
View File
@@ -46,25 +46,34 @@ async function backup(page: Page) {
state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")),
};
}
test("Select opens Key Items, freezes movement, and uses the Bicycle with keyboard and touch", async ({
test("SEL registers the Bicycle, and Select rides it with keyboard and touch", async ({
page,
}) => {
await start(page);
await page.keyboard.press("ShiftLeft");
await expect(page.locator("#field-menu")).toBeVisible();
await page.keyboard.down("ArrowDown");
await page.clock.runFor(1000);
await page.keyboard.up("ArrowDown");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "6");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
.click();
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
pack = page.locator("#mart");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^PACK/ }).click();
// Right twice: the Balls pocket, then Key Items.
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await pack.getByRole("button", { name: "BICYCLE", exact: true }).click();
await choices.getByRole("option", { name: "SEL", exact: true }).click();
await expect(text).toContainText("Registered the");
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyZ");
await page.keyboard.press("KeyZ");
await expect(choices).toBeHidden();
await page.keyboard.press("ShiftLeft");
await page.clock.runFor(500);
await expect(page.locator("#field-menu")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.playerState).toBe(1);
await page.locator("#tab-game").click();
await page.locator("#play").click();
await page.getByRole("button", { name: "select", exact: true }).click();
await page.clock.runFor(500);
expect((await backup(page)).state.playerState).toBe(0);
});
test("the bedroom PC puts away a decoration and saves the result", async ({
page,
@@ -91,19 +100,29 @@ test("the bedroom PC puts away a decoration and saves the result", async ({
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.decorations.bed).toBe(0);
});
test("B cancels the pack and an indoor Bicycle attempt leaves the player walking", async ({
test("Select without a registered item explains SEL, and the Bicycle stays packed indoors", async ({
page,
}) => {
await start(page, true);
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
pack = page.locator("#mart");
await page.keyboard.press("ShiftLeft");
await expect(text).toContainText("An item in your");
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyX");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^PACK/ }).click();
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowLeft");
await pack.getByRole("button", { name: "BICYCLE", exact: true }).click();
await choices.getByRole("option", { name: "USE", exact: true }).click();
await expect(text).toContainText("This isn't the");
await page.keyboard.press("KeyX");
await pack.getByRole("button", { name: "BACK", exact: true }).click();
await page.keyboard.press("KeyZ");
await expect(page.locator("#field-menu")).toBeHidden();
await page.keyboard.press("ShiftLeft");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
.click();
await expect(page.locator("#game-notice")).toContainText("can’t use");
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.playerState).toBe(0);
});
+163
View File
@@ -419,6 +419,169 @@ describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
expect(used).toContain("used\nWHIRLPOOL!");
expect(world.map.blocks).not.toEqual(before);
});
/** Start → POKéMON → the first Pokémon → `move`; returns the pages
* shown until the menu closed or a choice (the party menu) returned. */
const useFromMenu = (world: GameWorld, move: string) => {
expect(world.openStartMenu()).toBe(true);
const choose = (label: string) => {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error(`No menu for ${label}`);
const index = choice.items.findIndex((item) => item.startsWith(label));
expect(index, `${label} in ${choice.items}`).toBeGreaterThanOrEqual(0);
world.choose(index);
};
choose("POKéMON");
choose("SWIMMY");
choose(move);
const pages: string[] = [];
for (let i = 0; i < 50 && world.dialogue && !world.dialogue.choice; i++) {
pages.push(world.dialogue.pages[world.dialogue.index]);
world.advanceDialogue();
}
return pages;
};
/** B out of every menu, then let any queued script finish. */
const closeMenus = (world: GameWorld) => {
for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue();
return settle(world).join("\n");
};
it("surfs from the party menu, after the badge and facing checks", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x0f, 0x39]); // CUT, SURF
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
expect(world.dialogue).toBeUndefined();
// CUT and SURF come first in the submenu.
expect(useFromMenu(world, "SURF")).toEqual(["Sorry! A new BADGE\nis required."]);
closeMenus(world);
world.state.flags.add(30); // ENGINE_FOGBADGE
world.restore({ ...world.save(), direction: "up" });
expect(useFromMenu(world, "SURF")).toEqual(["You can't SURF\nhere."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "right" });
const shown = [...useFromMenu(world, "SURF"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
// No "Want to SURF?" question from the menu.
expect(shown).not.toContain("?");
expect(shown).toContain("SWIMMY used\nSURF!");
expect([world.x, world.y, world.state.playerState]).toEqual([18, 7, 4]);
expect(useFromMenu(world, "SURF")).toEqual(["You're already\nSURFING."]);
closeMenus(world);
});
it("cuts from the party menu, or finds nothing to cut", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
[1, 2, 3, 12, 25].includes(map.tileset) &&
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x0f]);
world.state.flags.add(28); // ENGINE_HIVEBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "down" });
expect(useFromMenu(world, "CUT")).toEqual(["There's nothing to\nCUT here."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "up" });
const before = world.map.blocks.slice();
const shown = [...useFromMenu(world, "CUT"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nCUT!");
expect(world.map.blocks).not.toEqual(before);
});
it("uses Strength from the party menu", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x46]);
world.state.flags.add(29); // ENGINE_PLAINBADGE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
const shown = [...useFromMenu(world, "STRENGTH"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSTRENGTH!");
expect(world.state.flags.has(24)).toBe(true); // ENGINE_STRENGTH_ACTIVE
});
it("lights a dark cave with Flash, which fails elsewhere", () => {
// Dark Cave's Violet entrance (PALETTE_DARK).
const DARK_CAVE = "3:78";
const { assets, world } = fixture([DARK_CAVE]);
const map = assets.map(DARK_CAVE);
expect(map.paletteMode).toBe(4);
let spot = { map: DARK_CAVE, x: 0, y: 0 };
search: for (let y = 1; y < map.height - 1; y++)
for (let x = 1; x < map.width - 1; x++)
if (
assets.permission(assets.collision(map, x, y)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y)
) {
spot = { map: DARK_CAVE, x, y };
break search;
}
world.state.party = partyWith(assets, [0x94]); // FLASH
world.state.flags.add(27); // ENGINE_ZEPHYRBADGE
world.warpTo(spot.map, spot.x, spot.y);
expect(world.timeOfDay).toBe(3);
const shown = [...useFromMenu(world, "FLASH"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("A blinding FLASH\nlights the area!");
expect(world.state.flash).toBe(true);
expect(world.timeOfDay).toBe(2);
expect(useFromMenu(world, "FLASH")).toEqual(["Can't use that\nhere."]);
closeMenus(world);
});
it("recognises a smashable rock for Rock Smash, which is not ported further", () => {
// A rock (SPRITEMOVEDATA_SMASHABLE_ROCK) in Dark Cave at 27, 6.
const spot = { map: "3:78", x: 27, y: 6 };
const { assets, world } = fixture([spot.map]);
expect(
assets.map(spot.map).objects.some(
(o) => o.x === spot.x && o.y === spot.y && o.movement === 0x18,
),
).toBe(true);
world.state.party = partyWith(assets, [0xf9]); // ROCK_SMASH
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "down" });
expect(useFromMenu(world, "ROCK SMASH")).toEqual(["Can't use that\nhere."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "up" });
useFromMenu(world, "ROCK SMASH");
expect(world.notice).toBe("Smashing rocks is not ported yet.");
closeMenus(world);
expect(world.diagnostic).toBe("");
});
it("draws a wild Pokémon with Sweet Scent in grass, and nothing elsewhere", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0xe6]); // SWEET_SCENT
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
const nothing = [...useFromMenu(world, "SWEET SCENT"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(nothing).toContain("SWIMMY used\nSWEET SCENT!");
expect(nothing).toContain("nothing");
const grass = find(assets, (map, x, y) =>
map.id === "24:3" &&
assets.grassCollisions().includes(assets.collision(map, x, y)) &&
!map.objects.some((o) => o.x === x && o.y === y),
);
world.warpTo(grass.map, grass.x, grass.y);
useFromMenu(world, "SWEET SCENT");
for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue();
for (let tick = 0; tick < 200 && !world.battle; tick++) {
if (world.dialogue) world.advanceDialogue();
world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
expect(world.battle).toBeDefined();
});
});
describe.skipIf(!existsSync(romPath))("Strength boulders and stone tables with the local ROM", () => {
+109
View File
@@ -226,6 +226,115 @@ describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
expect(world.save()).toEqual(saved);
});
it("moves Pokémon between the party and boxes without mail", () => {
const world = fixture();
const data = world.assets.pokemon;
const [cyndaquil, pidgey] = world.state.party;
pidgey.hp = 1;
pidgey.pp = [1, 0, 0, 0];
const boxed = toBoxMon(
generatePartyMon(data, {
species: 19, // RATTATA
level: 6,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x1234,
caughtData: 0,
}),
);
world.state.storage.boxes[0] = [boxed];
open(world);
pick(world, "BILL's PC");
expect(pick(world, "MOVE POKéMON W/O MAIL")).toEqual([
"Each time you move a POKéMON, data will be saved. OK?",
]);
world.choose(1);
until(world);
// The party, then the fourteen boxes, with the current box highlighted.
const boxes = world.dialogue?.choice;
expect(boxes?.kind === "menu" && [boxes.items.length, boxes.selected]).toEqual([15, 1]);
expect(menuItems(world)[0]).toBe("PARTY POKéMON");
// PIDGEY from the party to the end of BOX1: its PP are restored.
pick(world, "PARTY POKéMON");
pick(world, /^PIDGEY/);
expect(menuItems(world)).toEqual(["MOVE", "STATS", "CANCEL"]);
pick(world, "MOVE");
pick(world, "BOX1");
expect(menuItems(world)).toEqual(["RATTATA", "▶"]);
pick(world, "▶");
// RATTATA from BOX1 to the front of the party, with full HP.
pick(world, "BOX1");
pick(world, /^RATTATA/);
pick(world, "MOVE");
pick(world, "PARTY POKéMON");
pick(world, /^CYNDAQUIL/);
expect(world.liveState.party.map((mon) => mon.species)).toEqual([19, 155]);
// Within the party: RATTATA to the end.
pick(world, "PARTY POKéMON");
pick(world, /^RATTATA/);
pick(world, "MOVE");
pick(world, "PARTY POKéMON");
pick(world, "▶");
close(world);
expect(world.state.party.map((mon) => mon.species)).toEqual([155, 19]);
const rattata = world.state.party[1];
expect(rattata.hp).toBe(rattata.stats[0]);
expect(rattata.stats).toEqual(calcStats(data, 19, 0x1234, 6, rattata.statExp));
expect(world.state.storage.boxes[0].map((mon) => mon.species)).toEqual([16]);
expect(world.state.storage.boxes[0][0].pp[0]).toBe(data.move(pidgey.moves[0]).pp);
expect(cyndaquil.species).toBe(155);
});
it("keeps the last Pokémon and refuses a full destination when moving", () => {
const world = fixture();
const data = world.assets.pokemon;
world.state.party = [world.state.party[0]];
const full = Array.from({ length: 20 }, () =>
toBoxMon(
generatePartyMon(data, {
species: 16,
level: 3,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0,
caughtData: 0,
}),
),
);
world.state.storage.boxes[1] = full;
world.state.storage.boxes[2] = [structuredClone(full[0])];
world.state.storage.boxes[2][0].nickname = "SPARE";
open(world);
pick(world, "BILL's PC");
pick(world, "MOVE POKéMON W/O MAIL");
world.choose(1);
until(world);
pick(world, "PARTY POKéMON");
expect(pick(world, /^CYNDAQUIL/)).toEqual(["What's up?"]);
expect(pick(world, "MOVE")).toContain("It's your last POKéMON!");
// BOX3 into the full BOX2: no room, and the position is chosen again.
pick(world, "BOX3");
pick(world, /^SPARE/);
pick(world, "MOVE");
pick(world, "BOX2");
expect(pick(world, "▶")).toContain("There's no room!");
// B leaves the destination list for the list of boxes to move from.
world.advanceDialogue();
until(world);
// Within the full BOX2 a Pokémon can still move.
pick(world, "BOX2");
pick(world, /^PIDGEY/);
pick(world, "MOVE");
pick(world, "BOX2");
expect(pick(world, "▶")).toContain("Saving… Leave ON!");
close(world);
expect(world.state.storage.boxes[1]).toHaveLength(20);
expect(world.state.storage.boxes[2].map((mon) => mon.nickname)).toEqual(["SPARE"]);
expect(world.state.party).toHaveLength(1);
});
it("opens the stats screen from Bill's PC for party and boxed Pokémon", () => {
const world = fixture();
const data = world.assets.pokemon;
+99 -2
View File
@@ -33,6 +33,7 @@ const BICYCLE = 7,
LEFTOVERS = 0x92,
FLOWER_MAIL = 0x9e,
BERRY = 0xad;
const SOFTBOILED = 0x87;
const CUT = 0x0f,
GUST = 0x10,
SAND_ATTACK = 0x1c,
@@ -197,8 +198,11 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
pick(world, "CANCEL");
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual(["CUT", "STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]);
pick(world, "CUT");
expect(world.notice).toBe("Using CUT from the menu is not ported yet.");
// CutFunction's CheckBadge, then the party menu again.
expect(pick(world, "CUT")).toEqual([
"Sorry! A new BADGE is required.",
"Choose a POKéMON.",
]);
// STATS shows the stats screen, and B returns to the party.
pick(world, "PIDGEY");
world.choose(menuItems(world).indexOf("STATS"));
@@ -209,6 +213,56 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
close(world);
});
it("shares HP with Softboiled, which needs more than a fifth of it", () => {
const world = fixture();
const data = world.assets.pokemon;
const chansey = generatePartyMon(data, {
species: 113,
level: 20,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
chansey.moves = [SOFTBOILED, 0, 0, 0];
world.state.party.push(chansey);
world.state.party[0].hp = 3;
const fifth = Math.floor(chansey.stats[0] / 5);
open(world);
pick(world, "POKéMON");
pick(world, "CHANSEY");
expect(pick(world, "SOFTBOILED")).toEqual(["Use on which POKéMON?"]);
// Not on itself, and not on a Pokémon at full HP.
expect(pick(world, "CHANSEY")).toEqual([
"That can't be used on this POKéMON.",
"Use on which POKéMON?",
]);
expect(pick(world, "PIDGEY")[0]).toBe("That can't be used on this POKéMON.");
expect(pick(world, "CYNDAQUIL")).toEqual([
`CYNDAQUIL recovered ${Math.min(fifth, world.liveState.party[0].stats[0] - 3)}HP!`,
"Choose a POKéMON.",
]);
// The cursor returns to the user.
const party = world.dialogue?.choice;
expect(party?.kind === "menu" && party.selected).toBe(2);
close(world);
expect(world.state.party[2].hp).toBe(chansey.stats[0] - fifth);
expect(world.state.party[0].hp).toBe(
Math.min(world.state.party[0].stats[0], 3 + fifth),
);
world.state.party[2].hp = fifth;
open(world);
pick(world, "POKéMON");
pick(world, "CHANSEY");
expect(pick(world, "SOFTBOILED")).toEqual([
"Not enough HP!",
"Choose a POKéMON.",
]);
close(world);
});
it("switches two Pokémon, and B cancels the move", () => {
const world = fixture();
open(world);
@@ -525,6 +579,49 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
expect(indoors.state.playerState).toBe(0);
});
it("registers the Bicycle with SEL, and Select rides it without a message", () => {
const world = fixture();
// Nothing registered yet.
expect(world.useSelect()).toBe(true);
expect(until(world)).toEqual([
"An item in your PACK may be",
"registered for use on SELECT Button.",
]);
close(world);
open(world);
pick(world, "PACK");
pickItem(world, BICYCLE);
expect(pick(world, "SEL")).toEqual(["Registered the BICYCLE."]);
close(world);
expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE });
// The registration is saved.
world.restore(world.save());
expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE });
expect(world.useSelect()).toBe(true);
expect(settle(world)).toEqual([]);
expect(world.diagnostic).toBe("");
expect(world.state.playerState).toBe(1);
expect(world.useSelect()).toBe(true);
expect(settle(world)).toEqual([]);
expect(world.state.playerState).toBe(0);
const indoors = fixture(PLAYERS_HOUSE, 7, 4);
indoors.state.registeredItem = { which: 0x81, item: BICYCLE };
indoors.useSelect();
expect(until(indoors)).toEqual(["OAK: CHRIS! This isn't the time to use that!"]);
close(indoors);
expect(indoors.state.playerState).toBe(0);
});
it("forgets a registered item that left its slot", () => {
const world = fixture();
world.state.registeredItem = { which: 0x02, item: POTION };
world.useSelect();
expect(until(world)[0]).toBe("An item in your PACK may be");
close(world);
expect(world.state.registeredItem).toEqual({ which: 0, item: 0 });
});
it("offers no USE without a field menu, and a notice for unported effects", () => {
const world = fixture();
world.state.bag.items.push(