Port the start menu, the party menu and the pack outside battle
Checks / web (push) Canceled after 0s

Start opens the original start menu. POKéMON shows the party with the
Pokémon submenu (stats, switching, move order, held items); PACK shows the
four pockets with the field item effects for healing, PP and the Bicycle,
plus giving and tossing. The menu runs on a script draft, and its cursors
persist until a battle clears them. Select keeps the Key Items shortcut.

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 16:10:22 +02:00
1 parent 60e06ae678
commit 1af2beacdc
18 files changed
+1918 -49

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.
Use Start or Select for 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 and PP Ups, give or toss items, and ride the Bicycle. Select opens Key Items, including the Bicycle when owned. 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, full party and bag menus, 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, evolution, 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.
## Run
+7 -2
View File
@@ -56,8 +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. Start/Select opens the Key Items
menu, pausing overworld ticks while choosing the Bicycle. The bedroom PC edits
Surf, Cut and Strength use the ROM scripts. Select opens the Key Items
menu, pausing overworld ticks while choosing the Bicycle. 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.
The start menu, party menu and pocket cursors live in the world's memory,
not in saves, and are cleared after each battle. The bedroom PC edits
decorations on the script draft and reloads the room when the menu commits.
`scripts/intro.ts` runs a new game's introduction as a native generator on a
script draft; the session starts it only when nothing is saved, and the name,
+55 -1
View File
@@ -27,11 +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. |
| `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. |
| 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. 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/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. |
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
@@ -278,3 +279,56 @@ through the party menu, use a Potion from the Items pocket and page
through the stats screen. `WithdrawMonText` still assumes the leftover
multiplicand high byte is zero; a whole battle has not been compared with
the original.
## Start menu, party menu and pack
`StartMenu.SetUpMenuItems` builds the list from the Pokédex and Pokégear
engine flags and the party, and `StartMenu.Items` supplies each item's
string and description. The start menu runs as `StartMenuRun`, a native
generator on a copy of the script state, so nothing commits before it
closes and a save meanwhile keeps the previous state. POKéDEX, POKéGEAR,
the trainer card and OPTION are not ported and give a notice.
The party menu prints `PartyMenuStrings` for the `PARTYMENUACTION_*` in use.
`GetMonSubmenuItems` reads `MonMenuOptions`: field moves first, then STATS,
SWITCH, MOVE, ITEM (MAIL when `MailItems` holds the held item) and CANCEL
when fewer than `NUM_MONMENU_ITEMS` are listed; the strings come from
`MonMenuOptionStrings` and move names. `SwitchPartyMons`/`_SwitchPartyMons`
swap two party entries. `MoveScreenLoop` swaps two moves and their PP; the
original's flag-dependent write into `wBattleMon` after a swap outside
battle is not modelled (the next battle reloads that structure), and left
and right do not move to another Pokémon's moves. `GiveTakePartyMonItem`,
`TryGiveItemToPartymon` (including the swap and the full-pack refund) and
`TakePartyItem` use `GiveItemToPokemon`/`ReceiveItemFromPokemon` on the
chosen pack slot. Giving MAIL needs `ComposeMailMessage` and is refused
before anything changes.
The pack's submenu follows `.ItemBallsKey_LoadSubmenu`: `CANT_TOSS`,
`CANT_SELECT` and the field menu (`CheckItemMenu`, the help byte's high
nibble) pick one of the six `MenuHeader_*` menus. `TossMenu` confirms the
quantity and tosses from the chosen slot. `UseItem` dispatches by field menu
and then `_DoItemEffect` by the `ItemEffects` routine address, with the
field side of `RestoreHPEffect`, `StatusHealingEffect`, `FullRestoreEffect`,
`ReviveEffect`/`RevivalHerbEffect`, `EnergypowderEffect`/`EnergyRootEffect`,
`HealPowderEffect` and `RestorePPEffect` (Ether, Max Ether, Elixer, Max
Elixer, MysteryBerry and PP Up with `ApplyPPUp`). Items are used from the
Items pocket slot they were chosen from (`wCurItemQuantity`).
`ItemActionTextWaitButton` prints `PrintPartyMenuActionText` with the HP
change. `ChangeHappiness` outside battle does not touch a battle Pokémon.
`BicycleEffect` runs `BikeFunction`'s checks; a queued script runs once the
menu closes, and a refusal gives Oak's warning because
`wFieldMoveSucceeded` and `wItemEffectSucceeded` share a byte.
`wBattleMenuCursorPosition`, `wPartyMenuCursor` and `wLastPocket` persist
between openings in memory and are cleared when a battle ends
(`CleanUpBattleRAM`). The party cursor is passed to the next battle, whose
`ChangeHappiness` compares it with `wCurPartyMon`.
Tests: `tests/start-menu.test.ts` (local ROM) covers the item list and
descriptions, the cursor, the submenu with field moves, switching, move
reordering, giving, swapping and taking items (including a full pack and
MAIL), every ported field effect, the PP Up step and its maxed-out refusal,
tossing, the Bicycle outdoors and indoors, unported effects and saving
while the menu is open. A Chromium/WebKit test opens the menu with Start,
switches Pokémon, changes pockets and tosses a Potion. These are not
emulator comparisons.
+16 -5
View File
@@ -162,11 +162,20 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- The stats screen shows its three pages from BattleMonMenu and Bill's PC.
- The reference suite compares the send-out and withdraw texts, `RestoreHealth`, `RestorePP`, `ChangeHappiness` and `CalcPlayerStats` with the original routines.
## Implemented: the start menu, party menu and pack
- Start opens `StartMenu` with the items `SetUpMenuItems` lists for the player's progress (POKéDEX, POKéMON, PACK, POKéGEAR, the player's name, SAVE, OPTION, EXIT) and their ROM descriptions. POKéDEX, POKéGEAR, the trainer card and OPTION give a notice; SAVE points to the automatic session saves.
- POKéMON: the party menu with the ROM's prompts and `GetMonSubmenuItems`: field moves the Pokémon knows (from `MonMenuOptions`), STATS, SWITCH, MOVE, ITEM or MAIL, and CANCEL when there is room. STATS opens the stats screen; SWITCH asks "Move to where?" and swaps the two Pokémon; MOVE (`MoveScreenLoop`) swaps two moves with their PP; ITEM gives an item from the pack (key items and untossable items "can't be held"), swaps it with the held item or takes it back, with the pack-full checks of `TryGiveItemToPartymon` and `TakePartyItem`.
- 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, evolution stones, Rare Candy, vitamins and the other field items (each gives a notice and changes nothing).
## 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. Full party/bag/Pokégear menus, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The Key Items menu 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 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.
## 1. Complete the overworld foundation
@@ -212,8 +221,9 @@ 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. Start/Select opens Key
Items; using the Bicycle follows the native outdoor/indoor rules. Browser
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
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.
@@ -243,7 +253,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. Next: party and bag menus outside battle, MOVE PKMN W/O MAIL and evolution.
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. Next: evolution, then MOVE PKMN W/O MAIL.
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.
@@ -273,7 +283,8 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
- [x] Adaptive canvas, live local minimap, keyboard and on-screen controls
- [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
- [ ] Party, pack, PC and Pokégear menus outside battle
- [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
- [ ] 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
+42 -1
View File
@@ -1,6 +1,6 @@
# Recovered Claude session
Latest continuation: see **Controlled capture comparisons: 2026-09-30**
Latest continuation: see **Start menu, party menu and pack: 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
@@ -688,3 +688,44 @@ verified. This handoff-only follow-up records that delivery.
Next: party and bag menus outside battle, MOVE PKMN W/O MAIL and evolution.
PLAN section 2 is complete within its recorded acceptance boundaries.
## Start menu, party menu and pack: 2026-09-30
Resumed from `60e06ae` (clean tree) with "continue" from the handoff: the
next plan items were the party and bag menus outside battle, then
evolution.
- `src/game/scripts/start-menu.ts` (`StartMenuRun`): Start opens the
original start menu (items and descriptions from `StartMenu.Items`, list
from `SetUpMenuItems`). POKéMON: party menu (`PartyMenuStrings`), the
submenu from `MonMenuOptions` (field moves first), STATS, SWITCH,
MOVE (swap moves with PP), ITEM (GIVE from the pack with swap and
pack-full refund, TAKE). PACK: four pockets with left/right, the item
submenus from `MenuHeader_*.MenuData` by permissions and field menu,
TOSS with quantity and confirmation, GIVE, and USE through `ItemEffects`
addresses: HP items, status healers, Full Restore, revives, bitter
herbs, Ethers/Elixers/MysteryBerry and PP Up (one step, because
`wUsePPUp` aliases `wTempPP`), and the Bicycle (queued script after the
menu closes; Oak's warning where it cannot be used).
- It runs on a script draft; saves while open keep the prior state. Menu
cursors persist in memory and reset after battle (`CleanUpBattleRAM`);
the party cursor now reaches the battle's `ChangeHappiness` quirk.
- Select keeps the Key Items shortcut (browser tests moved from Enter to
Shift). New `pack` script effect/choice; `MENU` profile table checked by
`check:reference`; menu, pocket and PP constants moved into the verified
`C` block; `ENGINE_POKEGEAR` added and verified. The text decoder gained
`<POKE>` ($24), needed for "POKéGEAR".
- Notices without changes: POKéDEX, POKéGEAR, trainer card, OPTION, field
moves from the menu, MAIL (giving or reading), SEL, evolution stones,
Rare Candy, vitamins and other field items.
Checks: 353 unit tests with the local ROM (16 new in
`tests/start-menu.test.ts`); 109 Chromium/WebKit browser tests passed, 19
skipped intentionally (one new start-menu flow in both browsers); native
and reference builds, reference, vendor and release checks; `git diff
--check`. No emulator comparison of the menus; no CI or physical-device run.
Next: evolution (`EvolveAfterBattle` after battles and from stones/Rare
Candy, with the level-up flags the battle must record), then vitamins,
MOVE PKMN W/O MAIL, SEL/registered items and field moves from the menu.
+4 -2
View File
@@ -202,8 +202,8 @@ for (const [key, symbol] of Object.entries(ramNames)) {
if (!match || Number(match[1]) !== ram.get(symbol))
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
}
// BATTLE, OVERWORLD, PC, INTRO and STATS entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS"]) {
// BATTLE, OVERWORLD, PC, INTRO, STATS and MENU entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
if (!block) throw new Error(`${table} symbol table is missing.`);
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
@@ -323,6 +323,8 @@ for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?
throw new Error(`Battle constant mismatch: ${name} is ${literal}, PRET has ${expected}`);
}
}
if (!profile.includes(`pokegear: ${pretConstants.get("ENGINE_POKEGEAR")},`))
throw new Error("ENGINE_POKEGEAR does not match the reference.");
if (!profile.includes(`pokedex: ${pretConstants.get("ENGINE_POKEDEX")},`))
throw new Error("ENGINE_POKEDEX does not match the reference.");
if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREES")},`))
+6 -3
View File
@@ -169,6 +169,8 @@ export interface BattleOptions {
tutorial?: boolean;
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
landmark?: number;
/** wPartyMenuCursor as the last party menu outside battle left it. */
partyMenuCursor?: number;
}
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
@@ -424,9 +426,9 @@ export class Battle {
private battleHasJustStarted = 0;
/** wCurPartyMon: the party index a menu picked. */
private curPartyMon = 0;
/** wPartyMenuCursor: the 1-based cursor of the last party menu, which
* no ported field menu sets before a battle. */
private partyMenuCursor = 0;
/** wPartyMenuCursor: the 1-based cursor of the last party menu, from
* the start menu's until the battle's own party menu sets it. */
private partyMenuCursor: number;
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
lastPlayerMon = 0;
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
@@ -455,6 +457,7 @@ export class Battle {
constructor(private options: BattleOptions) {
this.data = new BattleData(options.data.reader);
this.matchups = this.data.typeMatchups();
this.partyMenuCursor = options.partyMenuCursor ?? 0;
if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
}
+20
View File
@@ -239,6 +239,26 @@ export const C = {
BATTLEPLAYERACTION_SWITCH: 2,
ITEMMENU_CURRENT: 4,
ITEMMENU_PARTY: 5,
ITEMMENU_CLOSE: 6,
// Menus outside battle (constants/menu_constants.asm)
MONMENU_FIELD_MOVE: 0,
MONMENUITEM_STATS: 15,
MONMENUITEM_SWITCH: 16,
MONMENUITEM_ITEM: 17,
MONMENUITEM_CANCEL: 18,
MONMENUITEM_MOVE: 19,
MONMENUITEM_MAIL: 20,
NUM_MONMENU_ITEMS: 8,
PARTYMENUACTION_CHOOSE_POKEMON: 0,
PARTYMENUACTION_HEALING_ITEM: 1,
PARTYMENUACTION_MOVE: 4,
PARTYMENUACTION_GIVE_ITEM: 8,
KEY_ITEM_POCKET: 2,
TM_HM_POCKET: 3,
PP_UP: 0x3e,
SKETCH: 0xa6,
PP_UP_MASK: 0xc0,
PP_UP_ONE: 0x40,
PARTYMENUTEXT_HEAL_HP: 0xf5,
PARTYMENUTEXT_HEAL_CONFUSION: 0xf9,
PARTYMENUTEXT_REVIVE: 0xf7,
+14
View File
@@ -170,6 +170,20 @@ export class PokemonData {
);
return Boolean(permissions & 0x80);
}
/** ItemAttributes permissions, bit 6 (CANT_SELECT_F): CheckSelectableItem. */
itemUnselectable(item: number): boolean {
this.checkItem(item);
const permissions = this.reader.byte(
SYMBOLS.itemAttributes + (item - 1) * 7 + 4,
);
return Boolean(permissions & 0x40);
}
/** CheckItemMenu: the field menu (ITEMMENU_*), the help byte's high
* nibble. CheckItemContext reads the low one in battle. */
itemFieldMenu(item: number): number {
this.checkItem(item);
return this.reader.byte(SYMBOLS.itemAttributes + (item - 1) * 7 + 6) >> 4;
}
pocketName(pocket: Pocket): string {
const r = this.reader,
bank = r.bank(SYMBOLS.itemPocketNames);
+37
View File
@@ -173,6 +173,7 @@ export const EVENTS = {
/** Engine flag indices named directly by translated routines. */
export const ENGINE_FLAGS = {
pokegear: 4,
pokedex: 11,
allFruitTrees: 84,
} as const;
@@ -404,6 +405,42 @@ export const PC = {
Script_ReceivePhoneCall: 0x90241,
} as const;
/** The start menu, the party menu and its Pokémon submenu, and the pack
* outside battle: menu data, strings and texts, keyed by their PRET labels
* so that \`npm run check:reference\` can verify each address by name. */
export const MENU = {
"StartMenu.Items": 0x126eb,
PartyMenuStrings: 0x504d2,
MonMenuOptionStrings: 0x24caf,
MonMenuOptions: 0x24cd9,
"GiveTakeItemMenuData.Items": 0x12ca3,
String_MoveWhere: 0x1316b,
MailItems: 0xb9e80,
PokemonSwapItemText: 0x12caf,
PokemonHoldItemText: 0x12cb4,
PokemonRemoveMailText: 0x12cb9,
PokemonNotHoldingText: 0x12cbe,
ItemStorageFullText: 0x12cc3,
PokemonTookItemText: 0x12cc8,
PokemonAskSwapItemText: 0x12ccd,
ItemCantHeldText: 0x12cd2,
"GiveItem.AnEggCantHoldAnItemText": 0x1048d,
"MenuHeader_UsableKeyItem.MenuData": 0x10251,
"MenuHeader_UsableItem.MenuData": 0x1027c,
"MenuHeader_UnusableItem.MenuData": 0x102a1,
"MenuHeader_UnusableKeyItem.MenuData": 0x102b8,
"MenuHeader_HoldableKeyItem.MenuData": 0x102d5,
"MenuHeader_HoldableItem.MenuData": 0x102fa,
AskThrowAwayText: 0x10ae4,
AskQuantityThrowAwayText: 0x10ae9,
ThrewAwayText: 0x10aee,
YouDontHaveAMonText: 0x10af8,
RaiseThePPOfWhichMoveText: 0xf725,
PPIsMaxedOutText: 0xf72f,
PPsIncreasedText: 0xf734,
BicycleEffect: 0xee08,
} as const;
/** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const BATTLE = {
+12 -3
View File
@@ -71,9 +71,13 @@ export class NativeRuntime implements GameRuntime {
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno")
)
world.moveCursor(button as Direction);
if ((button === "start" || button === "select") && !world.busy)
world.notice =
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
if (button === "start") world.openStartMenu();
this.publish();
}
/** Start: the start menu, while the player stands still. */
openStartMenu() {
this.fieldMenuOpen = false;
this.world?.openStartMenu();
this.publish();
}
useBicycle() {
@@ -82,6 +86,11 @@ export class NativeRuntime implements GameRuntime {
this.world.notice = "You can’t use the BICYCLE here.";
this.publish();
}
/** Choose a pack entry from a pointer control. */
choosePackEntry(pocket: number, index: number) {
this.world?.choosePackEntry(pocket, index);
this.publish();
}
/** Answer a choice from a pointer or keyboard control outside the canvas. */
choose(answer: number | string) {
this.world?.choose(answer);
+9
View File
@@ -250,6 +250,15 @@ export type ScriptEffect =
| { kind: "notice"; text: string }
/** The Pokémon stats screen, closed with a button. */
| { kind: "stats"; mon: PartyMon }
/** The pack's pockets (Pack and DepositSellPack), opened on `pocket`;
* left and right change pockets. Answered with the pocket index × 256
* plus the entry's index, or -1 for B. */
| {
kind: "pack";
pockets: { name: string; entries: MartEntry[] }[];
pocket: number;
selected: number;
}
/** warpcheck: the player's tile events run again after the script. */
| { kind: "warpcheck" }
/** LoadMemScript: a script (with a phone caller's script) that runs at
+912
View File
@@ -0,0 +1,912 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { BATTLE, ENGINE_FLAGS, MENU, RAM } from "../rom/profile";
import { C } from "../battle/constants";
import {
changedHappiness,
restoreHealth,
restoredPP,
} from "../battle/battle";
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
import { maxPP, type PartyMon } from "../party/pokemon";
import {
copyScriptState,
type MartEntry,
type ScriptEffect,
type ScriptState,
} from "./interpreter";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** StartMenu.Items indexes (STARTMENUITEM_*). */
const STARTMENUITEM_POKEDEX = 0,
STARTMENUITEM_POKEMON = 1,
STARTMENUITEM_PACK = 2,
STARTMENUITEM_STATUS = 3,
STARTMENUITEM_SAVE = 4,
STARTMENUITEM_OPTION = 5,
STARTMENUITEM_EXIT = 6,
STARTMENUITEM_POKEGEAR = 7;
/** The pack's pockets in Pack_InterpretJoypad order (*_POCKET). */
const POCKETS = ["item", "ball", "key", "tm"] as const;
type PocketIndex = 0 | 1 | 2 | 3;
/** The pack's item submenus: the strings of each MenuHeader_*.MenuData
* and the action of each line. */
type ItemAction = "use" | "give" | "toss" | "sel" | "quit";
const ITEM_MENUS: Record<string, ItemAction[]> = {
"MenuHeader_UsableKeyItem.MenuData": ["use", "give", "toss", "sel", "quit"],
"MenuHeader_UsableItem.MenuData": ["use", "give", "toss", "quit"],
"MenuHeader_UnusableItem.MenuData": ["use", "quit"],
"MenuHeader_UnusableKeyItem.MenuData": ["use", "sel", "quit"],
"MenuHeader_HoldableKeyItem.MenuData": ["give", "toss", "sel", "quit"],
"MenuHeader_HoldableItem.MenuData": ["give", "toss", "quit"],
};
const {
EGG,
ITEMMENU_CURRENT,
ITEMMENU_PARTY,
ITEMMENU_CLOSE,
MONMENU_FIELD_MOVE,
MONMENUITEM_STATS,
MONMENUITEM_SWITCH,
MONMENUITEM_ITEM,
MONMENUITEM_CANCEL,
MONMENUITEM_MOVE,
MONMENUITEM_MAIL,
NUM_MONMENU_ITEMS,
PARTYMENUACTION_CHOOSE_POKEMON,
PARTYMENUACTION_HEALING_ITEM,
PARTYMENUACTION_MOVE,
PARTYMENUACTION_GIVE_ITEM,
KEY_ITEM_POCKET,
TM_HM_POCKET,
PP_UP,
SKETCH,
PP_UP_MASK,
PP_UP_ONE,
} = C;
const NOT_PORTED = "is not ported yet.";
/** Menu cursors in work RAM, which CleanUpBattleRAM clears after every
* battle: wBattleMenuCursorPosition (the start menu's 1-based position,
* shared with the battle menu), wPartyMenuCursor (1-based) and wLastPocket. */
export interface MenuCursors {
start: number;
party: number;
pocket: number;
}
/** What the start menu needs from the world. */
export interface StartMenuHost {
reader: RomReader;
data: PokemonData;
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;
}
/** The overworld start menu (engine/menus/start_menu.asm) with its
* POKéMON party menu and submenu (engine/pokemon/mon_menu.asm,
* mon_submenu.asm, party_menu.asm) and PACK (engine/items/pack.asm and
* the field side of engine/items/item_effects.asm). The original draws its
* own screens; here menus, lists and messages are presented natively. It
* runs on a draft like any script, so a refused step changes nothing. */
export class StartMenuRun {
readonly state: ScriptState;
/** A script queued by an item (QueueScript), which runs after the menu
* closes, with the item named as UseItem names it. */
deferred?: number;
deferredItem?: number;
/** The menu cursors kept in work RAM between openings. */
readonly cursors: MenuCursors;
private strings: Record<number, string> = {};
private numbers: Record<number, number> = {};
private iterator: Gen;
private done = false;
constructor(
state: ScriptState,
private host: StartMenuHost,
cursors: MenuCursors = { start: 0, party: 0, pocket: 0 },
) {
this.state = copyScriptState(state);
this.cursors = { ...cursors };
this.iterator = this.run();
}
/** Advance until the next effect, or undefined when the menu closed. */
resume(input?: Input): ScriptEffect | undefined {
if (this.done) return undefined;
const next = this.iterator.next(input);
if (next.done) {
this.done = true;
return undefined;
}
return next.value;
}
// ------------------------------------------------------------ helpers
private get data() {
return this.host.data;
}
private get reader() {
return this.host.reader;
}
private text(): TextContext {
const base = this.host.text();
return {
...base,
rival: this.state.rivalName,
strings: { ...base.strings, ...this.strings },
numbers: { ...base.numbers, ...this.numbers },
};
}
private string(address: number) {
return readString(this.reader, address, this.text());
}
/** Consecutive "@"-terminated strings, as static menus store them. */
private stringList(address: number, count: number) {
const r = this.reader,
result: string[] = [];
let p = address;
for (let i = 0; i < count; i++) {
result.push(this.string(p));
while (r.byte(p++) !== 0x50);
}
return result;
}
private pages(address: number) {
return readText(this.reader, address, this.text())
.split("\n\n")
.filter(Boolean);
}
/** PrintText or a menu textbox that waits for a button. */
private *print(address: number): Gen {
const pages = this.pages(address);
if (pages.length) yield { kind: "text", pages };
}
private *yesNo(address: number): Gen<boolean> {
return Boolean(yield { kind: "yesno", pages: this.pages(address) });
}
/** A menu of `items`; the chosen index, or -1 for B. */
private *menu(
pages: string[],
items: string[],
details?: string[],
selected = 0,
): Gen<number> {
const choice = yield { kind: "menu", pages, items, details, selected };
return typeof choice === "number" &&
Number.isInteger(choice) &&
choice >= 0 &&
choice < items.length
? choice
: -1;
}
private *notice(text: string): Gen {
yield { kind: "notice", text };
}
/** GetItemName and CopyName1: the item in wStringBuffer1 and 2. */
private nameItem(item: number) {
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] =
this.data.itemName(item);
}
/** GetCurNickname copied to wMonOrItemNameBuffer. */
private nameMon(slot: number) {
this.strings[RAM.monOrItemNameBuffer] = this.state.party[slot].nickname;
}
private isMail(item: number) {
const r = this.reader;
for (let p = MENU.MailItems; r.byte(p) !== 0xff; p++)
if (r.byte(p) === item) return true;
return false;
}
// --------------------------------------------------------- start menu
/** StartMenu.SetUpMenuItems: no Link mode or Bug-Catching Contest yet. */
private menuItems() {
const items: number[] = [];
if (this.state.flags.has(ENGINE_FLAGS.pokedex))
items.push(STARTMENUITEM_POKEDEX);
if (this.state.party.length) items.push(STARTMENUITEM_POKEMON);
items.push(STARTMENUITEM_PACK);
if (this.state.flags.has(ENGINE_FLAGS.pokegear))
items.push(STARTMENUITEM_POKEGEAR);
items.push(
STARTMENUITEM_STATUS,
STARTMENUITEM_SAVE,
STARTMENUITEM_OPTION,
STARTMENUITEM_EXIT,
);
return items;
}
/** StartMenu.Items: (routine, string, description) pointers. */
private itemString(item: number, field: 1 | 2) {
const r = this.reader,
entry = MENU["StartMenu.Items"] + item * 6 + field * 2;
return this.string(r.near(entry, r.bank(entry)));
}
private *run(): Gen {
for (;;) {
const items = this.menuItems();
const choice = yield* this.menu(
[],
items.map((item) => this.itemString(item, 1)),
// The menu account (on by default) describes the highlighted item.
items.map((item) => this.itemString(item, 2).replace("\n", " ")),
Math.min(items.length - 1, Math.max(0, this.cursors.start - 1)),
);
if (choice < 0) return;
this.cursors.start = choice + 1;
const item = items[choice];
switch (item) {
case STARTMENUITEM_POKEMON:
if (yield* this.pokemon()) return;
break;
case STARTMENUITEM_PACK:
if (yield* this.pack()) return;
break;
case STARTMENUITEM_EXIT:
return;
case STARTMENUITEM_SAVE:
yield* this.notice(
"The game saves itself as you play. Use the Savegames tab for backups.",
);
break;
default:
yield* this.notice(`${this.itemString(item, 1)} ${NOT_PORTED}`);
}
}
}
// --------------------------------------------------------- party menu
/** The party menu with PlacePartyMenuText's prompt. Returns the party
* index, or -1 when cancelled. `from` marks SwitchPartyMons' source. */
private *partyMenu(action: number, from?: number): Gen<number> {
const r = this.reader,
entry = MENU.PartyMenuStrings + action * 2;
const prompt = this.string(r.near(entry, r.bank(entry)));
const party = this.state.party;
const choice = yield* this.menu(
[prompt],
party.map((mon, slot) => (slot === from ? `▷${mon.nickname}` : mon.nickname)),
party.map(
(mon) =>
`:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`,
),
// InitPartyMenuWithCancel: the last row chosen, if still listed.
this.cursors.party >= 1 && this.cursors.party <= party.length
? this.cursors.party - 1
: 0,
);
if (choice >= 0) this.cursors.party = choice + 1;
return choice;
}
/** StartMenu_Pokemon: the party menu until B. Returns true when an action
* closes every menu (a field move). */
private *pokemon(): Gen<boolean> {
if (!this.state.party.length) return false;
for (;;) {
const slot = yield* this.partyMenu(PARTYMENUACTION_CHOOSE_POKEMON);
if (slot < 0) return false;
yield* this.pokemonActionSubmenu(slot);
}
}
/** GetMonSubmenuItems: field moves the Pokémon knows, then STATS,
* SWITCH, MOVE, ITEM or MAIL, and CANCEL if there is room. */
private monSubmenuItems(mon: PartyMon) {
if (mon.species === EGG)
return [MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_CANCEL];
const r = this.reader,
items: number[] = [];
for (const move of mon.moves) {
if (!move) continue;
// IsFieldMove: MonMenuOptions' field moves, before the options.
for (let p = MENU.MonMenuOptions; r.byte(p) === MONMENU_FIELD_MOVE; p += 3)
if (r.byte(p + 2) === move) {
items.push(r.byte(p + 1));
break;
}
}
items.push(
MONMENUITEM_STATS,
MONMENUITEM_SWITCH,
MONMENUITEM_MOVE,
this.isMail(mon.item) ? MONMENUITEM_MAIL : MONMENUITEM_ITEM,
);
if (items.length !== NUM_MONMENU_ITEMS) items.push(MONMENUITEM_CANCEL);
return items;
}
/** GetMonMenuString: a field move's name, or MonMenuOptionStrings. */
private monMenuString(item: number) {
const r = this.reader;
let p = MENU.MonMenuOptions;
while (r.byte(p + 1) !== item) p += 3;
const value = r.byte(p + 2);
if (r.byte(p) === MONMENU_FIELD_MOVE) return this.data.moveName(value);
return this.stringList(MENU.MonMenuOptionStrings, value)[value - 1];
}
/** PokemonActionSubmenu (MonSubmenu); B is CANCEL. */
private *pokemonActionSubmenu(slot: number): Gen {
const mon = this.state.party[slot];
const items = this.monSubmenuItems(mon);
const choice = yield* this.menu(
[mon.nickname],
items.map((item) => this.monMenuString(item)),
);
const item = choice < 0 ? MONMENUITEM_CANCEL : items[choice];
switch (item) {
case MONMENUITEM_STATS:
// OpenPartyStats
yield { kind: "stats", mon };
return;
case MONMENUITEM_SWITCH:
return yield* this.switchPartyMons(slot);
case MONMENUITEM_ITEM:
return yield* this.giveTakePartyMonItem(slot);
case MONMENUITEM_MOVE:
return yield* this.managePokemonMoves(slot);
case MONMENUITEM_CANCEL:
return;
case MONMENUITEM_MAIL:
return yield* this.notice(`MAIL ${NOT_PORTED}`);
default:
return yield* this.notice(
`Using ${this.monMenuString(item)} from the menu ${NOT_PORTED}`,
);
}
}
/** SwitchPartyMons and _SwitchPartyMons: "Move to where?" without a
* CANCEL row (B still cancels); the two Pokémon trade places. */
private *switchPartyMons(from: number): Gen {
const party = this.state.party;
if (party.length < 2) return;
const to = yield* this.partyMenu(PARTYMENUACTION_MOVE, from);
if (to < 0 || to === from) return;
[party[from], party[to]] = [party[to], party[from]];
}
/** GiveTakePartyMonItem: GIVE opens the pack, TAKE returns the item. */
private *giveTakePartyMonItem(slot: number): Gen {
const mon = this.state.party[slot];
if (mon.species === EGG) return;
const choice = yield* this.menu(
[mon.nickname],
this.stringList(MENU["GiveTakeItemMenuData.Items"] + 2, 2),
);
if (choice < 0) return;
this.nameMon(slot);
if (choice === 1) return yield* this.takePartyItem(slot);
// GiveTakePartyMonItem.GiveItem: DepositSellPack until an item is
// chosen that a Pokémon can hold, or B.
for (;;) {
const chosen = yield* this.choosePackItem();
if (!chosen) return;
const { pocket, index, item } = chosen;
if (pocket === KEY_ITEM_POCKET || this.data.itemUnsellable(item)) {
yield* this.print(MENU.ItemCantHeldText);
continue;
}
return yield* this.tryGiveItemToPartymon(slot, pocket, index, item);
}
}
/** TryGiveItemToPartymon: hold the item, or offer to swap it for the one
* held. GiveItemToPokemon tosses one from the pack slot it came from. */
private *tryGiveItemToPartymon(
slot: number,
pocket: PocketIndex,
index: number,
item: number,
): Gen {
const mon = this.state.party[slot];
// ComposeMailMessage follows a given MAIL; it is refused before
// anything changes.
if (this.isMail(item)) return yield* this.notice(`Writing MAIL ${NOT_PORTED}`);
this.nameItem(item);
const held = mon.item;
if (!held) {
this.giveItemToPokemon(pocket, index, item);
yield* this.print(MENU.PokemonHoldItemText);
mon.item = item;
return;
}
if (this.isMail(held)) return yield* this.print(MENU.PokemonRemoveMailText);
this.strings[RAM.stringBuffer1] = this.data.itemName(held);
if (!(yield* this.yesNo(MENU.PokemonAskSwapItemText))) return;
this.giveItemToPokemon(pocket, index, item);
if (!receiveItem(this.data, this.state.bag, held, 1)) {
receiveItem(this.data, this.state.bag, item, 1);
return yield* this.print(MENU.ItemStorageFullText);
}
yield* this.print(MENU.PokemonSwapItemText);
mon.item = item;
}
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
const list = this.pocketSlots(pocket);
if (!list || !removeFromSlot(list, index, item, 1))
throw new Error("The item is not in the pack.");
}
/** TakePartyItem: the held item goes back to the pack. */
private *takePartyItem(slot: number): Gen {
const mon = this.state.party[slot];
if (!mon.item) return yield* this.print(MENU.PokemonNotHoldingText);
if (!receiveItem(this.data, this.state.bag, mon.item, 1))
return yield* this.print(MENU.ItemStorageFullText);
this.strings[RAM.stringBuffer1] = this.data.itemName(mon.item);
mon.item = 0;
yield* this.print(MENU.PokemonTookItemText);
}
/** ManagePokemonMoves (MoveScreenLoop): choose a move, then "Where?";
* the moves and their PP trade places. Left and right, which move to
* another party member's moves, are not offered. */
private *managePokemonMoves(slot: number): Gen {
const mon = this.state.party[slot];
if (mon.species === EGG) return;
const where = this.string(MENU.String_MoveWhere);
let cursor = 0;
for (;;) {
const count = mon.moves.filter(Boolean).length;
const names = mon.moves.slice(0, count).map((move) => this.data.moveName(move));
const details = mon.moves
.slice(0, count)
.map(
(move, index) =>
`PP ${mon.pp[index] & C.PP_MASK}/${maxPP(this.data, move, mon.pp[index] >> 6) & C.PP_MASK}`,
);
const heading = `${mon.nickname} :L${mon.level}`;
const first = yield* this.menu([heading], names, details, cursor);
if (first < 0) return;
const second = yield* this.menu([where], names, details, first);
if (second < 0) {
cursor = first;
continue;
}
for (const list of [mon.moves, mon.pp])
[list[first], list[second]] = [list[second], list[first]];
cursor = second;
}
}
// --------------------------------------------------------------- pack
private pocketSlots(pocket: PocketIndex): ItemSlot[] | undefined {
const bag = this.state.bag;
return pocket === 0 ? bag.items : pocket === 1 ? bag.balls : undefined;
}
/** A pocket's list: quantities for items and balls, none for key items,
* and no TMs or HMs yet. */
private pocketEntries(pocket: PocketIndex): MartEntry[] {
const bag = this.state.bag;
const slots: ItemSlot[] =
pocket === KEY_ITEM_POCKET
? bag.keyItems.map((item) => ({ item, quantity: 1 }))
: (this.pocketSlots(pocket) ?? []);
return slots.map(({ item, quantity }) => ({
item,
name: this.data.itemName(item),
price: 0,
...(pocket !== KEY_ITEM_POCKET && { owned: quantity }),
}));
}
/** The pack's pockets from wLastPocket; the chosen pocket, index and
* item, or undefined for B. */
private *choosePackItem(): Gen<
{ pocket: PocketIndex; index: number; item: number } | undefined
> {
for (;;) {
const answer = yield {
kind: "pack",
pockets: POCKETS.map((pocket, index) => ({
name: this.data.pocketName(pocket),
entries: this.pocketEntries(index as PocketIndex),
})),
pocket: this.cursors.pocket,
selected: 0,
};
if (typeof answer !== "number" || !Number.isInteger(answer) || answer < 0)
return undefined;
const pocket = (answer >> 8) as PocketIndex,
index = answer & 0xff;
if (pocket > TM_HM_POCKET) continue;
this.cursors.pocket = pocket;
const item = this.pocketEntries(pocket)[index]?.item;
if (item !== undefined) return { pocket, index, item };
}
}
/** StartMenu_Pack (Pack): returns true when an item closes the menus to
* run a script (PACKSTATE_QUITRUNSCRIPT). */
private *pack(): Gen<boolean> {
for (;;) {
const chosen = yield* this.choosePackItem();
if (!chosen) return false;
if (yield* this.itemSubmenu(chosen.pocket, chosen.index, chosen.item))
return true;
}
}
/** Pack.ItemBallsKey_LoadSubmenu: the submenu the item's permissions and
* field menu select. */
private *itemSubmenu(
pocket: PocketIndex,
index: number,
item: number,
): Gen<boolean> {
const data = this.data;
const usable = data.itemFieldMenu(item) !== 0;
const label = data.itemUnsellable(item)
? data.itemUnselectable(item)
? "MenuHeader_UnusableItem.MenuData"
: "MenuHeader_UnusableKeyItem.MenuData"
: data.itemUnselectable(item)
? usable
? "MenuHeader_UsableItem.MenuData"
: "MenuHeader_HoldableItem.MenuData"
: usable
? "MenuHeader_UsableKeyItem.MenuData"
: "MenuHeader_HoldableKeyItem.MenuData";
const actions = ITEM_MENUS[label];
const choice = yield* this.menu(
[data.itemName(item)],
this.stringList(MENU[label as keyof typeof MENU] + 2, actions.length),
);
switch (choice < 0 ? "quit" : actions[choice]) {
case "use":
return yield* this.useItem(index, item);
case "give":
yield* this.giveItem(pocket, index, item);
return false;
case "toss":
yield* this.tossMenu(pocket, index, item);
return false;
case "sel":
yield* this.notice(`Registering items for SELECT ${NOT_PORTED}`);
return false;
default:
return false;
}
}
/** TossMenu: how many, confirm, then TossItem from the chosen slot. */
private *tossMenu(pocket: PocketIndex, index: number, item: number): Gen {
const list = this.pocketSlots(pocket);
const slot = list?.[index];
if (!list || slot?.item !== item) return;
this.nameItem(item);
const quantity = yield {
kind: "martquantity",
mode: "toss",
item,
name: this.data.itemName(item),
price: 0,
max: slot.quantity,
prompt: readText(this.reader, MENU.AskThrowAwayText, this.text()),
selected: 1,
};
if (
typeof quantity !== "number" ||
!Number.isInteger(quantity) ||
quantity < 1 ||
quantity > slot.quantity
)
return;
this.nameItem(item);
this.numbers[RAM.itemQuantityChange] = quantity;
if (!(yield* this.yesNo(MENU.AskQuantityThrowAwayText))) return;
removeFromSlot(list, index, item, quantity);
this.nameItem(item);
yield* this.print(MENU.ThrewAwayText);
}
/** The pack's GiveItem: "To which PKMN?" until a Pokémon that is not an
* Egg is chosen, or B. */
private *giveItem(pocket: PocketIndex, index: number, item: number): Gen {
if (!this.state.party.length)
return yield* this.print(MENU.YouDontHaveAMonText);
for (;;) {
const slot = yield* this.partyMenu(PARTYMENUACTION_GIVE_ITEM);
if (slot < 0) return;
if (this.state.party[slot].species === EGG) {
yield* this.print(MENU["GiveItem.AnEggCantHoldAnItemText"]);
continue;
}
this.nameMon(slot);
return yield* this.tryGiveItemToPartymon(slot, pocket, index, item);
}
}
/** UseItem: the field menu decides. Returns true when the pack quits to
* run the script an ITEMMENU_CLOSE item queued. */
private *useItem(index: number, item: number): Gen<boolean> {
const menu = this.data.itemFieldMenu(item);
if (menu < ITEMMENU_CURRENT) {
yield* this.print(BATTLE.OakThisIsntTheTimeText);
return false;
}
if (menu === ITEMMENU_PARTY && !this.state.party.length) {
yield* this.print(MENU.YouDontHaveAMonText);
return false;
}
const used = yield* this.doItemEffect(index, item);
if (menu !== ITEMMENU_CLOSE) return false;
if (!used) yield* this.print(BATTLE.OakThisIsntTheTimeText);
return used;
}
/** _DoItemEffect: ItemEffects by ROM address. Returns
* wItemEffectSucceeded. */
private *doItemEffect(index: number, item: number): Gen<boolean> {
const r = this.reader;
this.nameItem(item);
const effect = r.near(
BATTLE.ItemEffects + (item - 1) * 2,
r.bank(BATTLE.ItemEffects),
);
const use = { item, index };
switch (effect) {
case BATTLE.RestoreHPEffect:
return yield* this.statusHealerResult(yield* this.itemRestoreHP(use));
case BATTLE.EnergypowderEffect:
case BATTLE.EnergyRootEffect: {
const code = yield* this.itemRestoreHP(use);
if (!code)
yield* this.looksBitter(
effect === BATTLE.EnergyRootEffect
? C.HAPPINESS_ENERGYROOT
: C.HAPPINESS_BITTERPOWDER,
);
return yield* this.statusHealerResult(code);
}
case BATTLE.StatusHealingEffect: {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
return yield* this.statusHealerResult(
yield* this.useStatusHealer(use, target),
);
}
case BATTLE.HealPowderEffect: {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const code = yield* this.useStatusHealer(use, target);
if (!code) yield* this.looksBitter(C.HAPPINESS_BITTERPOWDER);
return yield* this.statusHealerResult(code);
}
case BATTLE.FullRestoreEffect:
return yield* this.fullRestoreEffect(use);
case BATTLE.ReviveEffect:
case BATTLE.RevivalHerbEffect: {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const code = yield* this.revivePokemon(use, target);
if (!code && effect === BATTLE.RevivalHerbEffect)
yield* this.looksBitter(C.HAPPINESS_REVIVALHERB);
return yield* this.statusHealerResult(code);
}
case BATTLE.RestorePPEffect:
return yield* this.restorePPEffect(use);
case MENU.BicycleEffect: {
const script = this.host.bicycleScript(this.state);
if (script === undefined) return false;
this.deferred = script;
this.deferredItem = item;
return true;
}
}
yield* this.notice(`Using ${this.data.itemName(item)} ${NOT_PORTED}`);
return false;
}
/** UseItem_SelectMon: the party menu for an item. The party index, or
* -1 when cancelled or an Egg was chosen. */
private *useItemSelectMon(): Gen<number> {
const slot = yield* this.partyMenu(PARTYMENUACTION_HEALING_ITEM);
if (slot < 0) return -1;
if (this.state.party[slot].species === EGG) {
yield* this.print(BATTLE.ItemCantUseOnEggText);
return -1;
}
return slot;
}
/** StatusHealer_Jumptable: 0 used, 1 "won't have any effect", 2 cancelled. */
private *statusHealerResult(code: number): Gen<boolean> {
if (code === 1) yield* this.print(BATTLE.ItemWontHaveEffectText);
return code === 0;
}
/** ItemActionTextWaitButton: PrintPartyMenuActionText for the Pokémon. */
private *itemActionText(target: number, text: number): Gen {
this.strings[RAM.stringBuffer1] = this.state.party[target].nickname;
const r = this.reader,
table = BATTLE["PrintPartyMenuActionText.MenuActionTexts"];
yield* this.print(r.near(table + (text & 0x0f) * 2, r.bank(table)));
}
/** UseDisposableItem: one from the Items pocket slot it was used from. */
private useDisposableItem({ item, index }: FieldItemUse) {
if (!removeFromSlot(this.state.bag.items, index, item, 1))
throw new Error("The item is not in the pack.");
}
/** ChangeHappiness outside battle, then LooksBitterMessage. */
private *looksBitter(action: number) {
this.changeHappiness(this.cursors.party - 1, action);
yield* this.print(BATTLE.ItemLooksBitterText);
}
private changeHappiness(slot: number, action: number) {
const mon = this.state.party[slot];
if (mon.species === EGG) return;
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
mon.happiness = changedHappiness(
mon.happiness,
this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
);
}
/** The new HP, with HealHP_SFX_GFX's delta for the action text. */
private setHP(mon: PartyMon, hp: number) {
this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp;
mon.hp = hp;
}
/** ItemRestoreHP: HealingHPAmounts up to the maximum HP. */
private *itemRestoreHP(use: FieldItemUse): Gen<number> {
const target = yield* this.useItemSelectMon();
if (target < 0) return 2;
const mon = this.state.party[target];
if (!mon.hp || mon.hp >= mon.stats[0]) return 1;
const r = this.reader;
let amount: number | undefined;
for (let p = BATTLE.HealingHPAmounts; r.byte(p) !== 0xff; p += 3)
if (r.byte(p) === use.item) {
amount = r.word(p + 1);
break;
}
if (amount === undefined) throw new Error("Missing healing amount.");
this.setHP(mon, restoreHealth(mon.hp, mon.stats[0], amount));
yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP);
this.useDisposableItem(use);
return 0;
}
/** GetItemHealingAction: StatusHealingActions' text and status mask. */
private healingAction(item: number) {
const r = this.reader;
let p = BATTLE.StatusHealingActions;
while (r.byte(p) !== item) {
if (r.byte(p) === 0xff) throw new Error("Missing status healing action.");
p += 3;
}
return { text: r.byte(p + 1), mask: r.byte(p + 2) };
}
/** UseStatusHealer outside battle: 0 if used, 1 if it had no effect. */
private *useStatusHealer(use: FieldItemUse, target: number): Gen<number> {
const mon = this.state.party[target];
if (!mon.hp) return 1;
const { text, mask } = this.healingAction(use.item);
if (!(mon.status & mask)) return 1;
mon.status = 0;
yield* this.itemActionText(target, text);
this.useDisposableItem(use);
return 0;
}
/** FullRestoreEffect: full HP and no status; at full HP only the status. */
private *fullRestoreEffect(use: FieldItemUse): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.state.party[target];
if (!mon.hp) return yield* this.statusHealerResult(1);
if (mon.hp >= mon.stats[0])
return yield* this.statusHealerResult(
yield* this.useStatusHealer(use, target),
);
this.setHP(mon, mon.stats[0]);
mon.status = 0;
yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP);
this.useDisposableItem(use);
return true;
}
/** RevivePokemon: a Revive restores half the maximum HP, the others all
* of it. 0 if used, 1 if the Pokémon had not fainted. */
private *revivePokemon(use: FieldItemUse, target: number): Gen<number> {
const mon = this.state.party[target];
if (mon.hp) return 1;
this.setHP(mon, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0]);
yield* this.itemActionText(target, C.PARTYMENUTEXT_REVIVE);
this.useDisposableItem(use);
return 0;
}
/** RestorePPEffect for Ethers, Elixers, the MysteryBerry and PP Up. B on
* the move list goes back to the party menu. */
private *restorePPEffect(use: FieldItemUse): Gen<boolean> {
const data = this.data,
item = use.item;
for (;;) {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.state.party[target];
if (item === C.ELIXER || item === C.MAX_ELIXER) {
// Elixer_RestorePPofAllMoves
let restored = false;
for (let slot = 0; slot < 4; slot++)
if (mon.moves[slot] && this.restorePP(mon, slot, item)) restored = true;
if (!restored) return yield* this.statusHealerResult(1);
yield* this.print(BATTLE.PPRestoredText);
this.useDisposableItem(use);
return true;
}
const ask =
item === PP_UP
? MENU.RaiseThePPOfWhichMoveText
: BATTLE.RestoreThePPOfWhichMoveText;
for (;;) {
// MoveSelectionScreen with wMoveSelectionMenuType 2.
const count = mon.moves.filter(Boolean).length;
const move = yield* this.menu(
this.pages(ask),
mon.moves.slice(0, count).map((id) => data.moveName(id)),
mon.moves
.slice(0, count)
.map((id, slot) => `PP ${mon.pp[slot] & C.PP_MASK}/${maxPP(data, id, mon.pp[slot] >> 6) & C.PP_MASK}`),
);
if (move < 0) break;
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] =
data.moveName(mon.moves[move]);
if (item !== PP_UP) {
if (!this.restorePP(mon, move, item))
return yield* this.statusHealerResult(1);
yield* this.print(BATTLE.PPRestoredText);
this.useDisposableItem(use);
return true;
}
if (mon.moves[move] === SKETCH || mon.pp[move] >= PP_UP_MASK) {
yield* this.print(MENU.PPIsMaxedOutText);
continue;
}
// ApplyPPUp: wUsePPUp shares wTempPP's byte, so ComputeMaxPP adds
// a single step (a fifth of the base PP, at most 7) to the PP.
const base = data.move(mon.moves[move]).pp;
mon.pp[move] =
(mon.pp[move] + PP_UP_ONE + Math.min(Math.floor(base / 5), 7)) & 0xff;
yield* this.print(MENU.PPsIncreasedText);
this.useDisposableItem(use);
return true;
}
}
}
/** RestorePP up to GetMaxPPOfMove; false if the PP was already full. */
private restorePP(mon: PartyMon, slot: number, item: number) {
const max =
maxPP(this.data, mon.moves[slot], mon.pp[slot] >> 6) & C.PP_MASK;
const pp = restoredPP(mon.pp[slot], max, item);
if (pp === undefined) return false;
mon.pp[slot] = pp;
return true;
}
}
/** An item used from a pack slot (wCurItem and wCurItemQuantity). */
interface FieldItemUse {
item: number;
index: number;
}
const STATUS_NAMES: [number, string][] = [
[0x08, " PSN"],
[0x10, " BRN"],
[0x20, " FRZ"],
[0x40, " PAR"],
];
function statusName(status: number) {
if (status & 7) return " SLP";
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
}
+2
View File
@@ -4,6 +4,8 @@ import { RAM } from "../rom/profile";
const CHARACTERS: Record<number, string> = {
0x1f: " ",
0x22: "\n",
// PlacePOKe: "<PO><KE>".
0x24: "POKé",
0x25: "",
0x4a: "Pokémon",
0x4b: "\n",
+111 -19
View File
@@ -27,6 +27,7 @@ import {
type ScriptState,
} from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import { StartMenuRun, type MenuCursors } from "../scripts/start-menu";
import type { MovementPose, MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
import {
@@ -213,6 +214,13 @@ export type Choice =
entries: MartEntry[];
selected: number;
}
/** The pack's pockets; `selected` is an entry of pocket `pocket`. */
| {
kind: "pack";
pockets: { name: string; entries: MartEntry[] }[];
pocket: number;
selected: number;
}
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
@@ -250,7 +258,7 @@ export interface BattleView {
const BATTLE_MESSAGE_TICKS = 30;
interface ActiveScript {
run: ScriptRun | IntroRun;
run: ScriptRun | IntroRun | StartMenuRun;
effect?: ScriptEffect;
/** Committed save data for the whole interaction, used for mid-script saves. */
committed: NativeSave;
@@ -506,6 +514,8 @@ export class GameWorld {
* ChangeDirectionScript holds input for three ticks. */
private turnTo?: Direction;
private turnWait = 0;
/** Start menu, party menu and pack cursors (work RAM, not saved). */
private menuCursors: MenuCursors = { start: 0, party: 0, pocket: 0 };
/** OBJECT_ACTION_BUMP this tick: walking in place against an obstacle. */
private bumping = false;
/** A screen shake object (earthquake, step_shake) in progress. */
@@ -1599,6 +1609,41 @@ export class GameWorld {
this.pump();
}
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */
openStartMenu(): boolean {
if (this.busy || this.introPending) return false;
this.clearNotice();
this.script = {
run: new StartMenuRun(
this.state,
{
reader: this.assets.reader,
data: this.assets.pokemon,
text: () => this.host(PLAYER).text(),
bicycleScript: (state) => this.bicycleScript(state),
},
this.menuCursors,
),
committed: this.save(),
rollback: {
state: this.state,
mapId: this.mapId,
player: copyActor(this.player),
actors: new Map(
[...this.actors].map(([id, actor]) => [id, copyActor(actor)]),
),
spawned: new Set(this.spawned),
blocks: new Uint8Array(this.map.blocks),
},
waitTicks: 0,
moving: new Map(),
scene: false,
};
this.pump();
return true;
}
/** NewGame's PlayerProfileSetup and OakSpeech, before the player
* appears in the bedroom. Saves made meanwhile start it again. */
beginIntro() {
@@ -1823,6 +1868,7 @@ export class GameWorld {
),
tutorial: effect.tutorial,
landmark: this.assets.map(this.mapId).landmark,
partyMenuCursor: this.menuCursors.party,
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -1909,6 +1955,18 @@ export class GameWorld {
},
};
return true;
case "pack":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "pack",
pockets: effect.pockets,
pocket: effect.pocket,
selected: effect.selected,
},
};
return true;
case "martquantity":
this.dialogue = {
pages: [""],
@@ -2265,8 +2323,14 @@ export class GameWorld {
}
if (!script.scene || script.run.deferred !== undefined)
this.sceneCheck = true;
if (script.run instanceof StartMenuRun) this.menuCursors = script.run.cursors;
if (script.run.deferred !== undefined)
this.startScript(script.run.deferred, PLAYER);
this.startScript(script.run.deferred, PLAYER, {
usedItem:
script.run instanceof StartMenuRun
? script.run.deferredItem
: undefined,
});
}
private abortScript(error: unknown) {
const script = this.script!;
@@ -2316,6 +2380,8 @@ export class GameWorld {
);
this.battle = undefined;
this.battleRun = undefined;
// CleanUpBattleRAM
this.menuCursors = { start: 0, party: 0, pocket: 0 };
this.pump(next.value.result);
return;
}
@@ -2837,7 +2903,8 @@ export class GameWorld {
const choice = this.dialogue.choice;
if (choice && this.dialogue.index === this.dialogue.pages.length - 1) {
// The naming screen is answered with text through choose().
if (choice.kind !== "name") this.choose(choice.selected);
if (choice.kind === "pack") this.choosePackEntry();
else if (choice.kind !== "name") this.choose(choice.selected);
} else this.advanceDialogue();
return;
}
@@ -3065,8 +3132,15 @@ export class GameWorld {
!this.state.bag.keyItems.some((slot) => slot === OW.BICYCLE)
)
return false;
const map = this.map,
state = this.state;
const script = this.bicycleScript(this.state);
if (script === undefined) return false;
this.startScript(script, PLAYER, { usedItem: OW.BICYCLE });
return true;
}
/** BikeFunction.TryBike: the script QueueScript runs, or undefined when
* .CheckEnvironment or the player's state refuses the Bicycle. */
private bicycleScript(state: ScriptState): number | undefined {
const map = this.map;
const outdoors =
map.environment === OW.TOWN || map.environment === OW.ROUTE;
const allowed =
@@ -3075,20 +3149,14 @@ export class GameWorld {
map.environment === OW.GATE) &&
this.assets.permission(this.assets.collision(map, this.x, this.y)) ===
OW.LAND_TILE;
if (!allowed) return false;
const usedItem = OW.BICYCLE;
if (!allowed) return undefined;
if (state.playerState === OW.PLAYER_NORMAL)
this.startScript(OVERWORLD.Script_GetOnBike, PLAYER, { usedItem });
else if (state.playerState === OW.PLAYER_BIKE)
this.startScript(
state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
? OVERWORLD.Script_CantGetOffBike
: OVERWORLD.Script_GetOffBike,
PLAYER,
{ usedItem },
);
else return false;
return true;
return OVERWORLD.Script_GetOnBike;
if (state.playerState === OW.PLAYER_BIKE)
return state.flags.has(OW.ENGINE_ALWAYS_ON_BIKE)
? OVERWORLD.Script_CantGetOffBike
: OVERWORLD.Script_GetOffBike;
return undefined;
}
/** GetPlayerSprite: Chris's or Kris's sprite for wPlayerState. */
@@ -3147,7 +3215,8 @@ export class GameWorld {
else if (
choice.kind === "martlist" ||
choice.kind === "martquantity" ||
choice.kind === "menu"
choice.kind === "menu" ||
choice.kind === "pack"
)
this.choose(-1);
return;
@@ -3191,6 +3260,20 @@ export class GameWorld {
if (direction === "up") choice.selected = (choice.selected + 1) % length;
if (direction === "down")
choice.selected = (choice.selected + length - 1) % length;
} else if (choice.kind === "pack") {
// Pack_InterpretJoypad: left and right change pockets.
const pockets = choice.pockets.length;
if (direction === "left" || direction === "right") {
choice.pocket =
(choice.pocket + (direction === "left" ? pockets - 1 : 1)) % pockets;
choice.selected = 0;
return;
}
const length = choice.pockets[choice.pocket].entries.length;
if (!length) return;
if (direction === "up") choice.selected = Math.max(0, choice.selected - 1);
if (direction === "down")
choice.selected = Math.min(length - 1, choice.selected + 1);
} else if (choice.kind === "martlist") {
const length = choice.entries.length;
if (!length) return;
@@ -3214,6 +3297,15 @@ export class GameWorld {
} else if (direction === "up") choice.selected = (choice.selected + 1) % 7;
else if (direction === "down") choice.selected = (choice.selected + 6) % 7;
}
/** Choose the highlighted pack entry, if its pocket has one. */
choosePackEntry(pocket?: number, index?: number) {
const choice = this.dialogue?.choice;
if (choice?.kind !== "pack") return;
pocket ??= choice.pocket;
index ??= choice.selected;
if (!choice.pockets[pocket]?.entries[index]) return;
this.choose(pocket * 256 + index);
}
/** Answer the current choice: 1/0 for yes/no, a weekday index, or a
* name ("" keeps the current one). */
choose(answer: number | string) {
+53 -7
View File
@@ -230,12 +230,13 @@ function nameField(
}
type MartChoice = Extract<
Choice,
{ kind: "martmenu" | "martlist" | "martquantity" }
{ kind: "martmenu" | "martlist" | "martquantity" | "pack" }
>;
function martChoice(choice: Choice): MartChoice | undefined {
return choice.kind === "martmenu" ||
choice.kind === "martlist" ||
choice.kind === "martquantity"
choice.kind === "martquantity" ||
choice.kind === "pack"
? choice
: undefined;
}
@@ -248,13 +249,18 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
martMenuKey = "";
return;
}
// The PC's item storage reuses these lists without prices.
// The PC's item storage and the pack reuse these lists without prices.
const shop =
mart.kind === "martmenu" || mart.mode === "buy" || mart.mode === "sell";
get("mart").setAttribute("aria-label", shop ? "Poké Mart" : "Item storage");
mart.kind === "martmenu" ||
(mart.kind !== "pack" && (mart.mode === "buy" || mart.mode === "sell"));
get("mart").setAttribute(
"aria-label",
shop ? "Poké Mart" : mart.kind === "pack" ? "Pack" : "Item storage",
);
get("mart-money").hidden = !shop;
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
get("mart-prompt").textContent = mart.kind === "martlist" ? "" : mart.prompt;
get("mart-prompt").textContent =
mart.kind === "martlist" || mart.kind === "pack" ? "" : mart.prompt;
const key = JSON.stringify(mart);
if (key === martMenuKey) return;
martMenuKey = key;
@@ -291,6 +297,40 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
);
return;
}
if (mart.kind === "pack") {
// Pocket tabs (left and right on the keyboard), then the pocket's items.
const tabs = mart.pockets.map((pocket, index) => {
const tab = button(pocket.name, () => {
const world = runtime.world;
const choice = world?.dialogue?.choice;
if (choice?.kind !== "pack" || !world) return;
choice.pocket = index;
choice.selected = 0;
showWorld(world);
});
tab.setAttribute("aria-pressed", String(mart.pocket === index));
return tab;
});
const entries = mart.pockets[mart.pocket].entries;
const buttons = entries.map((entry, index) =>
button(
entry.name,
() => runtime.choosePackEntry(mart.pocket, index),
{
detail: entry.owned === undefined ? undefined : `×${entry.owned}`,
selected: mart.selected === index,
},
),
);
if (!entries.length) {
const empty = button("Empty", () => {}, { wide: true });
empty.disabled = true;
buttons.push(empty);
}
buttons.push(button("BACK", () => runtime.choose(-1), { wide: true }));
menu.replaceChildren(...tabs, ...buttons);
return;
}
if (mart.kind === "martlist") {
const buttons = mart.entries.map((entry, index) =>
button(entry.name, () => runtime.choose(index), {
@@ -433,7 +473,13 @@ function showFieldMenu(world: GameWorld) {
function handleFieldInput(button: Button): boolean {
const world = runtime.world;
if (!world || world.battle) return false;
if ((button === "start" || button === "select") && !world.busy) {
// 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;
+59 -4
View File
@@ -43,11 +43,11 @@ async function backup(page: Page) {
state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")),
};
}
test("Start opens Key Items, freezes movement, and uses the Bicycle with keyboard and touch", async ({
test("Select opens Key Items, freezes movement, and uses the Bicycle with keyboard and touch", async ({
page,
}) => {
await start(page);
await page.keyboard.press("Enter");
await page.keyboard.press("ShiftLeft");
await expect(page.locator("#field-menu")).toBeVisible();
await page.keyboard.down("ArrowDown");
await page.clock.runFor(1000);
@@ -92,10 +92,10 @@ test("B cancels the pack and an indoor Bicycle attempt leaves the player walking
page,
}) => {
await start(page, true);
await page.keyboard.press("Enter");
await page.keyboard.press("ShiftLeft");
await page.keyboard.press("KeyZ");
await expect(page.locator("#field-menu")).toBeHidden();
await page.keyboard.press("Enter");
await page.keyboard.press("ShiftLeft");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
@@ -184,3 +184,58 @@ test("the Pokémon Center PC deposits a Pokémon and an item", async ({
expect(state.pcItems).toEqual([{ item: 18, quantity: 2 }]);
expect(state.bag.items).toEqual([{ item: 18, quantity: 3 }]);
});
test("Start opens the start menu: SWITCH reorders the party and the pack tosses an item", async ({
page,
}) => {
await start(page, "center");
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
pack = page.locator("#mart");
await page.keyboard.press("Enter");
await expect(choices.getByRole("option", { name: /^POKéMON/ })).toBeVisible();
await expect(choices.getByRole("option", { name: /^PACK/ })).toBeVisible();
// Movement is frozen while the menu is open.
await page.keyboard.down("ArrowLeft");
await page.clock.runFor(500);
await page.keyboard.up("ArrowLeft");
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "9");
await choices.getByRole("option", { name: /^POKéMON/ }).click();
await expect(text).toHaveText("Choose a POKéMON.");
await choices.getByRole("option", { name: /^CYNDAQUIL/ }).click();
await choices.getByRole("option", { name: "SWITCH", exact: true }).click();
await expect(text).toHaveText("Move to where?");
await choices.getByRole("option", { name: /^PIDGEY/ }).click();
await expect(choices.getByRole("option").first()).toHaveAccessibleName(
/^PIDGEY/,
);
// B leaves the party menu for the start menu.
await page.keyboard.press("KeyZ");
await choices.getByRole("option", { name: /^PACK/ }).click();
await expect(pack).toHaveAttribute("aria-label", "Pack");
await expect(
pack.getByRole("button", { name: "ITEM POCKET", exact: true }),
).toHaveAttribute("aria-pressed", "true");
// Right twice: the Balls pocket, then Key Items.
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await expect(pack.getByRole("button", { name: "BICYCLE" })).toBeVisible();
await pack.getByRole("button", { name: "ITEM POCKET", exact: true }).click();
await pack.getByRole("button", { name: /POTION/ }).click();
await choices.getByRole("option", { name: "TOSS", exact: true }).click();
await expect(page.locator("#mart-prompt")).toContainText("Throw away how");
await pack.getByRole("button", { name: "TOSS", exact: true }).click();
await expect(text).toContainText("Throw away 1 POTION(S)?");
await choices.getByRole("button", { name: /YES/ }).click();
await expect(text).toContainText("Threw away");
await page.keyboard.press("KeyX");
await pack.getByRole("button", { name: "BACK", exact: true }).click();
await choices.getByRole("option", { name: /^EXIT/ }).click();
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
const { state } = await backup(page);
expect(state.party.map((mon: { species: number }) => mon.species)).toEqual([
16, 155,
]);
expect(state.bag.items).toEqual([{ item: 18, quantity: 4 }]);
});
+557
View File
@@ -0,0 +1,557 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon, maxPP } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK_TOWN = "24:4",
PLAYERS_HOUSE = "24:6",
CYNDAQUIL = 155,
PIDGEY = 16;
const BICYCLE = 7,
ANTIDOTE = 9,
FULL_RESTORE = 0x0e,
POTION = 0x12,
RARE_CANDY = 0x20,
REVIVE = 0x27,
PP_UP = 0x3e,
ETHER = 0x3f,
ELIXER = 0x41,
ENERGYPOWDER = 0x79,
LEFTOVERS = 0x92,
FLOWER_MAIL = 0x9e,
BERRY = 0xad;
const CUT = 0x0f,
TACKLE = 0x21,
LEER = 0x2b,
SMOKESCREEN = 0x6c;
const POISONED = 0x08;
describe.skipIf(!existsSync(romPath))("the start menu", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** Standing in New Bark Town (outdoors) with two Pokémon and a pack. */
function fixture(map = NEW_BARK_TOWN, x = 13, y = 7) {
const world = new GameWorld(
assets(),
() => new Date("2026-09-25T12:00:00"),
() => 0x42,
);
const data = world.assets.pokemon;
const mon = (species: number, level: number) =>
generatePartyMon(data, {
species,
level,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
world.state.party = [mon(CYNDAQUIL, 8), mon(PIDGEY, 4)];
world.state.bag.items = [
{ item: POTION, quantity: 5 },
{ item: ANTIDOTE, quantity: 1 },
];
world.state.bag.keyItems = [BICYCLE];
world.state.scenes[NEW_BARK_TOWN] = 1;
world.restore({
...world.save(),
map,
x,
y,
direction: "down",
objects: [],
});
return world;
}
/** Acknowledge pages up to the next choice; returns the pages seen. */
function until(world: GameWorld) {
const pages: string[] = [];
for (let tick = 0; tick < 500; tick++) {
const dialogue = world.dialogue;
if (!dialogue) {
if (!world.busy) return pages;
world.tick();
continue;
}
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
if (page && pages.at(-1) !== page) pages.push(page);
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) return pages;
if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
throw new Error("No choice appeared.");
}
function menuItems(world: GameWorld) {
const choice = world.dialogue?.choice;
return choice?.kind === "menu" ? choice.items : [];
}
/** Choose the menu item matching `label` (the whole label, or a regex). */
function pick(world: GameWorld, label: string | RegExp) {
const items = menuItems(world);
const index = items.findIndex((item) =>
typeof label === "string" ? item === label : label.test(item),
);
expect(index, `${label} in ${items.join(", ")}`).toBeGreaterThanOrEqual(0);
world.choose(index);
return until(world);
}
/** Choose the pack entry for `item` in its pocket. */
function pickItem(world: GameWorld, item: number) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "pack") throw new Error(`Expected the pack, got ${choice?.kind}`);
for (const [pocket, { entries }] of choice.pockets.entries()) {
const index = entries.findIndex((entry) => entry.item === item);
if (index >= 0) {
world.choosePackEntry(pocket, index);
return until(world);
}
}
throw new Error(`Item ${item} is not in the pack.`);
}
function open(world: GameWorld) {
expect(world.openStartMenu()).toBe(true);
return until(world);
}
/** B until every menu has closed. */
function close(world: GameWorld) {
for (let i = 0; i < 20 && world.busy; i++) {
if (world.dialogue?.choice) world.advanceDialogue();
until(world);
}
settle(world);
expect(world.diagnostic).toBe("");
}
it("lists the original items for the player's progress", () => {
const world = fixture();
open(world);
expect(menuItems(world)).toEqual([
"POKéMON",
"PACK",
"CHRIS",
"SAVE",
"OPTION",
"EXIT",
]);
const choice = world.dialogue?.choice;
expect(choice?.kind === "menu" && choice.details?.[1]).toBe("Contains items");
pick(world, "EXIT");
expect(world.busy).toBe(false);
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.flags.add(ENGINE_FLAGS.pokegear);
open(world);
expect(menuItems(world)).toEqual([
"POKéDEX",
"POKéMON",
"PACK",
"POKéGEAR",
"CHRIS",
"SAVE",
"OPTION",
"EXIT",
]);
// The cursor stays on the last choice.
pick(world, "POKéGEAR");
expect(world.notice).toBe("POKéGEAR is not ported yet.");
close(world);
open(world);
const reopened = world.dialogue?.choice;
expect(reopened?.kind === "menu" && reopened.items[reopened.selected]).toBe(
"POKéGEAR",
);
close(world);
});
it("opens only while the player stands still", () => {
const world = fixture();
world.tick("left");
expect(world.activity).toBe("walking");
expect(world.openStartMenu()).toBe(false);
});
it("shows the Pokémon submenu with field moves first", () => {
const world = fixture();
world.state.party[1].moves = [TACKLE, CUT, 0, 0];
open(world);
expect(pick(world, "POKéMON")).toEqual(["Choose a POKéMON."]);
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]);
pick(world, "CYNDAQUIL");
expect(menuItems(world)).toEqual(["STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]);
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.");
// STATS shows the stats screen, and B returns to the party.
pick(world, "PIDGEY");
world.choose(menuItems(world).indexOf("STATS"));
expect(world.statsScreen?.species).toBe(PIDGEY);
world.advanceDialogue();
expect(until(world)).toEqual(["Choose a POKéMON."]);
expect(world.statsScreen).toBeUndefined();
close(world);
});
it("switches two Pokémon, and B cancels the move", () => {
const world = fixture();
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
expect(pick(world, "SWITCH")).toEqual(["Move to where?"]);
expect(menuItems(world)).toEqual(["▷CYNDAQUIL", "PIDGEY"]);
world.advanceDialogue();
until(world);
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]);
pick(world, "CYNDAQUIL");
pick(world, "SWITCH");
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual(["PIDGEY", "CYNDAQUIL"]);
// Nothing commits until the menu closes.
expect(world.save().party?.[0].species).toBe(CYNDAQUIL);
close(world);
expect(world.state.party.map((mon) => mon.species)).toEqual([PIDGEY, CYNDAQUIL]);
// wPartyMenuCursor stays on the row chosen last, in the next menu too.
open(world);
pick(world, "POKéMON");
const party = world.dialogue?.choice;
expect(party?.kind === "menu" && party.selected).toBe(1);
close(world);
});
it("reorders moves with their PP", () => {
const world = fixture();
const mon = world.state.party[0];
expect(mon.moves).toEqual([TACKLE, LEER, SMOKESCREEN, 0]);
mon.pp[0] = 30;
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
expect(pick(world, "MOVE")).toEqual(["CYNDAQUIL :L8"]);
expect(menuItems(world)).toEqual(["TACKLE", "LEER", "SMOKESCREEN"]);
expect(pick(world, "TACKLE")).toEqual(["Where?"]);
// B while choosing where cancels only the move being moved.
world.advanceDialogue();
until(world);
pick(world, "TACKLE");
pick(world, "SMOKESCREEN");
expect(menuItems(world)).toEqual(["SMOKESCREEN", "LEER", "TACKLE"]);
close(world);
expect(world.state.party[0].moves).toEqual([SMOKESCREEN, LEER, TACKLE, 0]);
expect(world.state.party[0].pp).toEqual([20, 30, 30, 0]);
});
it("gives, swaps and takes held items", () => {
const world = fixture();
world.state.bag.items.push({ item: LEFTOVERS, quantity: 1 });
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
expect(menuItems(world)).toEqual(["GIVE", "TAKE"]);
expect(pick(world, "TAKE")).toEqual([
"CYNDAQUIL isn't holding anything.",
"Choose a POKéMON.",
]);
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
pick(world, "GIVE");
// Key items cannot be held; the pack stays open.
expect(pickItem(world, BICYCLE)).toEqual(["This item can't be held."]);
expect(pickItem(world, POTION)).toEqual([
"Made CYNDAQUIL hold POTION.",
"Choose a POKéMON.",
]);
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
pick(world, "GIVE");
expect(pickItem(world, LEFTOVERS)).toEqual([
"CYNDAQUIL is already holding",
"POTION. Switch items?",
]);
world.choose(1);
expect(until(world)).toEqual([
"Took CYNDAQUIL's POTION and",
"made it hold LEFTOVERS.",
"Choose a POKéMON.",
]);
close(world);
expect(world.state.party[0].item).toBe(LEFTOVERS);
expect(world.state.bag.items).toEqual([
{ item: POTION, quantity: 5 },
{ item: ANTIDOTE, quantity: 1 },
]);
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
expect(pick(world, "TAKE")[0]).toBe("Took LEFTOVERS from CYNDAQUIL.");
close(world);
expect(world.state.party[0].item).toBe(0);
expect(world.state.bag.items.at(-1)).toEqual({ item: LEFTOVERS, quantity: 1 });
});
it("keeps a held item when the pack is full", () => {
const world = fixture();
world.state.party[0].item = LEFTOVERS;
world.state.bag.items = Array.from({ length: 20 }, (_, i) => ({
item: [POTION, ANTIDOTE, BERRY][i % 3],
quantity: 99,
}));
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
expect(pick(world, "TAKE")[0]).toBe("Item storage space full.");
close(world);
expect(world.state.party[0].item).toBe(LEFTOVERS);
});
it("refuses to compose MAIL without changing anything", () => {
const world = fixture();
world.state.bag.items.push({ item: FLOWER_MAIL, quantity: 1 });
open(world);
pick(world, "PACK");
pickItem(world, FLOWER_MAIL);
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
expect(pick(world, "GIVE")).toEqual(["To which POKéMON?"]);
pick(world, "CYNDAQUIL");
expect(world.notice).toBe("Writing MAIL is not ported yet.");
close(world);
expect(world.state.party[0].item).toBe(0);
expect(world.state.bag.items.at(-1)).toEqual({ item: FLOWER_MAIL, quantity: 1 });
});
it("uses healing items from the pack on the party", () => {
const world = fixture();
const [cyndaquil, pidgey] = world.state.party;
cyndaquil.hp = 5;
pidgey.status = POISONED;
world.state.bag.items.push(
{ item: REVIVE, quantity: 1 },
{ item: FULL_RESTORE, quantity: 1 },
);
open(world);
pick(world, "PACK");
// The pack opens on the Items pocket, with the ROM's pocket names.
const pack = world.dialogue?.choice;
expect(
pack?.kind === "pack" && [pack.pocket, pack.pockets.map((pocket) => pocket.name)],
).toEqual([0, ["ITEM POCKET", "BALL POCKET", "KEY POCKET", "TM POCKET"]]);
pickItem(world, POTION);
expect(menuItems(world)).toEqual(["USE", "GIVE", "TOSS", "QUIT"]);
expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]);
// Full HP: no effect, the Potion stays and the pack is shown again.
expect(pick(world, "PIDGEY")).toEqual(["It won't have any effect."]);
expect(world.dialogue?.choice?.kind).toBe("pack");
pickItem(world, POTION);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL recovered 20HP!"]);
pickItem(world, ANTIDOTE);
pick(world, "USE");
expect(pick(world, "PIDGEY")).toEqual(["PIDGEY's cured of poison."]);
// A Revive on a Pokémon that has not fainted has no effect.
pickItem(world, REVIVE);
pick(world, "USE");
expect(pick(world, "PIDGEY")).toEqual(["It won't have any effect."]);
close(world);
const [healed, cured] = world.state.party;
expect(healed.hp).toBe(25);
expect(cured.status).toBe(0);
expect(world.state.bag.items).toEqual([
{ item: POTION, quantity: 4 },
{ item: REVIVE, quantity: 1 },
{ item: FULL_RESTORE, quantity: 1 },
]);
world.state.party[1].hp = 0;
world.state.party[0].hp = 1;
world.state.party[0].status = POISONED;
open(world);
pick(world, "PACK");
pickItem(world, REVIVE);
pick(world, "USE");
expect(pick(world, "PIDGEY")).toEqual(["PIDGEY is revitalized."]);
pickItem(world, FULL_RESTORE);
pick(world, "USE");
const max = world.state.party[0].stats[0];
expect(pick(world, "CYNDAQUIL")).toEqual([`CYNDAQUIL recovered ${max - 1}HP!`]);
close(world);
expect(world.state.party[1].hp).toBe(world.state.party[1].stats[0] >> 1);
expect(world.state.party[0].hp).toBe(max);
expect(world.state.party[0].status).toBe(0);
expect(world.state.bag.items).toEqual([{ item: POTION, quantity: 4 }]);
});
it("makes bitter herbs cost happiness", () => {
const world = fixture();
world.state.party[0].hp = 1;
world.state.bag.items.push({ item: ENERGYPOWDER, quantity: 1 });
const happiness = world.state.party[0].happiness;
open(world);
pick(world, "PACK");
pickItem(world, ENERGYPOWDER);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual([
"CYNDAQUIL recovered 24HP!",
"It looks bitter…",
]);
close(world);
expect(world.state.party[0].happiness).toBe(happiness - 5);
});
it("restores and raises PP", () => {
const world = fixture();
world.state.party[0].pp = [10, 30, 3, 0];
world.state.bag.items.push(
{ item: ETHER, quantity: 1 },
{ item: ELIXER, quantity: 1 },
{ item: PP_UP, quantity: 2 },
);
open(world);
pick(world, "PACK");
pickItem(world, ETHER);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["Restore the PP of which move?"]);
expect(menuItems(world)).toEqual(["TACKLE", "LEER", "SMOKESCREEN"]);
// B returns to the party menu.
world.advanceDialogue();
expect(until(world)).toEqual(["Use on which POKéMON?"]);
pick(world, "CYNDAQUIL");
// Full PP: no effect.
expect(pick(world, "LEER")).toEqual(["It won't have any effect."]);
pickItem(world, ETHER);
pick(world, "USE");
pick(world, "CYNDAQUIL");
expect(pick(world, "TACKLE")).toEqual(["PP was restored."]);
pickItem(world, ELIXER);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["PP was restored."]);
pickItem(world, PP_UP);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["Raise the PP of which move?"]);
expect(pick(world, "TACKLE")).toEqual(["TACKLE's PP increased."]);
close(world);
// Tackle: 10, +10 (Ether), +10 (Elixer), then a PP Up adds a fifth of
// the base PP (7 of 35) to both the current PP and the maximum.
const pp = world.state.party[0].pp;
expect(pp).toEqual([0x40 | 37, 30, 13, 0]);
expect(maxPP(world.assets.pokemon, TACKLE, 1) & 63).toBe(42);
const items = world.state.bag.items;
expect(items.find((slot) => slot.item === PP_UP)?.quantity).toBe(1);
expect(items.some((slot) => slot.item === ETHER || slot.item === ELIXER)).toBe(false);
});
it("refuses a PP Up on a move that has had three", () => {
const world = fixture();
world.state.party[0].pp[1] = 0xc0 | 30;
world.state.bag.items.push({ item: PP_UP, quantity: 1 });
open(world);
pick(world, "PACK");
pickItem(world, PP_UP);
pick(world, "USE");
pick(world, "CYNDAQUIL");
// PPIsMaxedOutText, then the move list again.
expect(pick(world, "LEER")).toEqual([
"LEER's PP is maxed out.",
"Raise the PP of which move?",
]);
close(world);
expect(world.state.party[0].pp[1]).toBe(0xc0 | 30);
expect(world.state.bag.items.at(-1)).toEqual({ item: PP_UP, quantity: 1 });
});
it("tosses items after confirming the quantity", () => {
const world = fixture();
open(world);
pick(world, "PACK");
pickItem(world, POTION);
pick(world, "TOSS");
const choice = world.dialogue?.choice;
expect(choice?.kind === "martquantity" && [choice.prompt, choice.max]).toEqual([
"Throw away how\nmany?",
5,
]);
world.choose(3);
expect(until(world)).toEqual(["Throw away 3 POTION(S)?"]);
world.choose(0);
until(world);
pickItem(world, POTION);
pick(world, "TOSS");
world.choose(3);
until(world);
world.choose(1);
expect(until(world)).toEqual(["Threw away POTION(S)."]);
close(world);
expect(world.state.bag.items[0]).toEqual({ item: POTION, quantity: 2 });
});
it("gets on the Bicycle from the Key pocket, or gives Oak's warning indoors", () => {
const world = fixture();
open(world);
pick(world, "PACK");
pickItem(world, BICYCLE);
expect(menuItems(world)).toEqual(["USE", "SEL", "QUIT"]);
world.choose(0);
expect(settle(world).join(" ")).toContain("got on the\nBICYCLE.");
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.playerState).toBe(1);
const indoors = fixture(PLAYERS_HOUSE, 7, 4);
open(indoors);
pick(indoors, "PACK");
pickItem(indoors, BICYCLE);
expect(pick(indoors, "USE")).toEqual([
"OAK: CHRIS! This isn't the time to use that!",
]);
close(indoors);
expect(indoors.state.playerState).toBe(0);
});
it("offers no USE without a field menu, and a notice for unported effects", () => {
const world = fixture();
world.state.bag.items.push(
{ item: BERRY, quantity: 1 },
{ item: RARE_CANDY, quantity: 1 },
);
world.state.bag.balls = [{ item: 5, quantity: 3 }];
open(world);
pick(world, "PACK");
// Poké Balls have no field menu: GIVE and TOSS only.
pickItem(world, 5);
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
pick(world, "QUIT");
pickItem(world, RARE_CANDY);
pick(world, "USE");
expect(world.notice).toBe("Using RARE CANDY is not ported yet.");
close(world);
expect(world.state.bag.items.find((slot) => slot.item === RARE_CANDY)).toBeTruthy();
});
it("saves the state from before the menu while it is open", () => {
const world = fixture();
world.state.party[0].hp = 5;
open(world);
pick(world, "PACK");
pickItem(world, POTION);
pick(world, "USE");
pick(world, "CYNDAQUIL");
const saved = world.save();
expect(saved.party?.[0].hp).toBe(5);
expect(saved.bag?.items[0].quantity).toBe(5);
const reloaded = fixture();
reloaded.restore(saved);
expect(reloaded.busy).toBe(false);
expect(reloaded.state.party[0].hp).toBe(5);
close(world);
expect(world.state.party[0].hp).toBe(25);
});
});