From 45275ba83bc2f8ab4a04e45c85a0a709dc39c06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= Date: Wed, 30 Sep 2026 16:21:59 +0200 Subject: [PATCH] Port evolution, Rare Candy, evolution stones and vitamins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pokémon that levelled up evolve after a won battle, following EvolveAfterBattle's checks over the ROM's evolution bytes, with the evolution screen that B can stop and move learning outside battle. The pack uses Rare Candy, stones (with ABLE/NOT ABLE labels) and vitamins, and its pocket tabs now sit in their own row. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/ARCHITECTURE.md | 7 +- docs/NATIVE_PORT.md | 55 +++++- docs/PLAN.md | 18 +- docs/SESSION_HANDOFF.md | 37 +++- src/game/battle/battle.ts | 16 +- src/game/battle/constants.ts | 23 +++ src/game/rom/profile.ts | 13 ++ src/game/scripts/evolution.ts | 296 +++++++++++++++++++++++++++++++ src/game/scripts/interpreter.ts | 3 + src/game/scripts/start-menu.ts | 143 ++++++++++++++- src/game/world/world.ts | 115 +++++++++++- src/main.ts | 9 +- src/ui/shell.ts | 2 +- src/ui/styles.css | 17 ++ tests/battle-world.test.ts | 40 +++++ tests/foundation-browser.spec.ts | 39 +++- tests/foundation-fixture.ts | 12 +- tests/start-menu.test.ts | 222 ++++++++++++++++++++++- 19 files changed, 1032 insertions(+), 39 deletions(-) create mode 100644 src/game/scripts/evolution.ts diff --git a/README.md b/README.md index 59d01e9..298289c 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls. -Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers 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. +Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Select opens Key Items, including the Bicycle when owned. The poster opens the Town Map. 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, 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. +Route 31 and beyond, trade evolutions, the Pokégear, Pokédex and trainer card screens, field moves from the menu, MAIL, moving Pokémon between boxes, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. ## Run diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bbae70c..beafa27 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,7 +62,12 @@ menu, pausing overworld ticks while choosing the Bicycle. Start opens 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 +not in saves, and are cleared after each battle. `scripts/evolution.ts` +translates `EvolveAfterBattle`, `LearnLevelMoves` and `LearnMove` over a +draft. After a won battle the world runs it as an interlude on the battle's +script draft, before the script receives the battle result; the start menu +runs it inline for Rare Candy and stones. The `evolve` effect is a timed +picture sequence that B may stop. 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, diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index 2f5ccbc..38da339 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -32,7 +32,8 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta | Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. | | 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/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu, MAIL, SEL and the other field items give a notice. | +| `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. | | `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. | | `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. | | 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. | @@ -332,3 +333,55 @@ 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. + +## Evolution + +The battle's experience routine sets a party member's bit in +`wEvolvableFlags` when it levels up. `ExitBattle` runs `EvolveAfterBattle` +only when `wBattleResult & $0f` is a win; the world then presents it before +the script receives the battle result, on the same draft, so the evolution +commits with the battle. `CleanUpBattleRAM`'s cursor reset follows. + +`EvolveAfterBattle` reads `EvosAttacksPointers` and walks each Pokémon's +entries with the original's pointer steps (`.dont_evolve_1/2/3`): trades are +skipped without Link, a forced evolution (a stone) skips every non-item +entry by two bytes, items need `wCurItem` and `wForceEvolution`, levels and +happiness (`HAPPINESS_TO_EVOLVE`, `TR_*` against `wTimeOfDay`) and Tyrogue's +`EVOLVE_STAT` check the copied Pokémon, and an Everstone blocks all but +items. The first match evolves; a stopped evolution goes on to the next +Pokémon. `UpdateSpeciesNameIfNotNicknamed` compares the nickname with the +old species' name (`wBaseDexNo`, which the animation restores). +`CalcMonStats` uses the stat experience; HP rises by the maximum HP's +increase. `LearnLevelMoves` then offers moves at exactly the current level, +and `SetSeenAndCaughtMon` records the species. + +`EvolutionAnimation` is presented as a picture schedule in overworld ticks: +40 ticks on the old picture, then seven rounds of a B-checking wait (7 … 1 +ticks) and that many flashes (two ticks each way). The waits and flashes +are approximations of the original frame timing; B is accepted only during +the waits and never when `wForceEvolution` is set. No music, cry, palette +or `AnimateFrontpic` is presented. + +`LearnMove` outside battle: the first empty slot, otherwise `ForgetMove` +(yes/no, then a move list; `IsHMMove` refuses HM moves), `Text_1_2_and_Poof` +with its `text_asm` continuation, or "Stop learning" (NO asks again) and +"did not learn". + +PACK items: `RareCandyEffect` (`CalcExpAtLevel` for the new level, the HP +rise that also revives a fainted Pokémon, `LevelUpHappinessMod` against the +caught landmark, `PARTYMENUTEXT_LEVEL_UP`, a `PrintTempMonStats` page, +`LearnLevelMoves` and an unforced `EvolvePokemon`), `EvoStoneEffect` +(`PARTYMENUACTION_EVO_STONE`'s ABLE/NOT ABLE labels copy ten bytes and step +three at a time, as the original does; bytes past the copy read as the end +here, where the original reads stale RAM) and `VitaminEffect` +(`StatExpItemPointerOffsets`, the high byte limit of 100, `StatStrings`, +`ItemStatRoseText` and `HAPPINESS_USEDITEM`). + +Tests: `tests/start-menu.test.ts` covers the Rare Candy level-up, stat page, +HM refusal, forgetting and declining a move, the revived fainted Pokémon, +evolution with and without B, a stone (ABLE labels, no effect on another +species or with an Everstone, B ignored, a kept nickname) and a vitamin up +to its limit. `tests/battle-world.test.ts` wins a wild battle whose level-up +evolves Cyndaquil, and checks that a non-participant that could evolve does +not. A Chromium/WebKit test evolves through a Rare Candy. No emulator +comparison of these routines yet. diff --git a/docs/PLAN.md b/docs/PLAN.md index e86c6ec..79eb3f9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -169,7 +169,16 @@ The numbered sections below keep their full scope. Each one opens with a checkli - PACK: the Items, Balls, Key and TM pockets (left and right change pockets; there are no TMs or HMs yet), and each item's submenu chosen by its permissions and field menu (`USE`, `GIVE`, `TOSS`, `SEL`, `QUIT`). TOSS asks how many and confirms. USE follows `UseItem`: Oak's warning without a field use, "You don't have a POKéMON!" for party items without one, and `_DoItemEffect` by the item's `ItemEffects` address: Potions and the other `HealingHPAmounts` items, status healers, Full Restore, Revive and Max Revive, the bitter Energypowder, Energy Root, Heal Powder and Revival Herb, Ethers, Elixers, the MysteryBerry and PP Up. The Bicycle closes the menu and runs `Script_GetOnBike` or `Script_GetOffBike` outdoors; elsewhere Oak objects, because `wFieldMoveSucceeded` shares `wItemEffectSucceeded`'s byte. - PP Up keeps a quirk: `wUsePPUp` shares `wTempPP`'s byte, so `ComputeMaxPP` adds one step (a fifth of the base PP, at most 7) to the current PP as well as the maximum. Sketch and moves with three PP Ups are "maxed out". - The menu runs on a script draft, like the PC. Changes commit when it closes; a save made while it is open keeps the state from before it. The start menu cursor, the party menu cursor and the last pocket stay in memory between openings and are cleared after a battle, as `CleanUpBattleRAM` does. The party cursor reaches the next battle's `ChangeHappiness`. -- Select keeps opening the Key Items shortcut, standing in for a registered item. Not ported: field moves from the menu, MAIL, SEL (registering items), moving a Pokémon's moves to another party member with left and right, item reordering with Select, evolution stones, Rare Candy, vitamins and the other field items (each gives a notice and changes nothing). +- Select keeps opening the Key Items shortcut, standing in for a registered item. Not ported: field moves from the menu, MAIL, SEL (registering items), moving a Pokémon's moves to another party member with left and right, item reordering with Select and the remaining field items such as Repel and Escape Rope (each gives a notice and changes nothing). Rare Candy, evolution stones and vitamins run since the evolution section below. + +## Implemented: evolution + +- `EvolveAfterBattle` runs after every won battle for the party members that levelled up (`wEvolvableFlags`, now recorded by the battle's experience routine), before the script resumes. Level, happiness (220 or more, by time of day) and Tyrogue's Attack/Defense evolutions follow the original checks, including the Everstone. The evolution data is walked byte by byte like the original, so a forced evolution skips a four-byte `EVOLVE_STAT` record the way the original does. Trades never evolve without Link mode. +- The evolution screen shows "What? … is evolving!", then the old picture flashing into the new one on the original schedule (80 frames, then seven rounds of flashes). B stops it only during the waits between flashes, and never for a stone. A stopped evolution prints "Huh? … stopped evolving!" and skips that Pokémon's other entries. +- After evolving: the species name replaces the nickname only if the nickname was the old species name, stats are recomputed with stat experience, HP rises by the maximum HP's increase, moves learned at exactly the current level are offered, and the Pokédex records the new species as seen and caught. +- Learning a move outside battle follows `LearnMove`: an empty slot, or "delete an older move?", the choice of a move (HM moves can't be forgotten), "1, 2 and… Poof!", or "Stop learning …?" and "did not learn". +- PACK: Rare Candy (`RareCandyEffect`: one level at the level's minimum experience; the HP gains the maximum HP's increase even for a fainted Pokémon; the caught-location happiness bonus; "grew to level", the stat box, new moves and a possible evolution), evolution stones (`EvoStoneEffect`: a forced `EvolvePokemon`; the party menu shows ABLE or NOT ABLE with the original's ten-byte, three-byte-step check; an Everstone or no matching evolution has no effect) and vitamins (`VitaminEffect`: ten more in the stat experience's high byte, up to 100, stats recomputed without changing the current HP, happiness). +- Not presented: the evolution music, cries and the palette changes. Trade evolutions need Link. ## Native battles: current state @@ -235,7 +244,7 @@ their scripts and story progression are supported. - [x] Native player, party, Pokémon, bag, money and quest models with versioned saves - [x] Typed ROM decoders for species, moves, growth rates, items, encounters and trainers -- [x] Decoder for evolutions (the evolution itself is not ported yet) +- [x] Decoder for evolutions, and evolution after battles and from items (see section 3) - [x] Mom, Elm's introduction, the starter, naming and the aide's Potion - [x] Route 29, Cherrygrove City, the Poké Mart and the Pokémon Center - [x] Route 30's trainers, Mr. Pokémon's house, the Mystery Egg and the Pokédex @@ -253,7 +262,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game. -Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle. Next: evolution, then MOVE PKMN W/O MAIL. +Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Next: MOVE PKMN W/O MAIL, SEL and field moves from the menu, then the Pokégear. 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. @@ -263,7 +272,8 @@ Acceptance: finish the opening quest, including encounters and the rival battle, - [x] Pokémon Center healing, standard Poké Marts and Reflect/Light Screen - [ ] Remaining battle commands, move and item effects, AI layers and special battles -- [ ] Evolution, breeding, happiness, eggs and PC storage +- [x] Evolution by level, happiness, Tyrogue's stats and stones, with Rare Candy and vitamins (trade evolutions need Link) +- [ ] Breeding, eggs and the rest of PC storage - [ ] Field moves, gyms, Rocket events, the phone and Pokégear, puzzles, minigames, daily events, roaming Pokémon and the postgame - [ ] Johto beyond Route 30, the League, Kanto and the final battle - [ ] Music and sound through Web Audio diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 89add4f..66d2cef 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -1,7 +1,6 @@ # Recovered Claude session -Latest continuation: see **Start menu, party menu and pack: 2026-09-30** -at the end of this file. Earlier sections are historical snapshots. +Latest continuation: see **Evolution: 2026-09-30** at the end of this file. Earlier sections are historical snapshots. Recovered on 2026-09-23 from the local Claude Code transcript `~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`. @@ -729,3 +728,37 @@ and reference builds, reference, vendor and release checks; `git diff 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. + +The start menu increment was committed and pushed as `1af2bea`. + +## Evolution: 2026-09-30 + +Continued in the same session after `1af2bea`. + +- `src/game/scripts/evolution.ts` (`PartyEvolution`): `EvolveAfterBattle` + walking the raw evolution bytes with the original's skip counts + (forced evolutions misread `EVOLVE_STAT` like the original), the texts, + a timed `evolve` effect (40 ticks, then seven wait/flash rounds; B only in + the waits and never for stones), nickname update only for unnicknamed + Pokémon, stats with stat experience, HP rise, `LearnLevelMoves`, + `LearnMove`/`ForgetMove` outside battle, and the Pokédex. +- The battle records `wEvolvableFlags` on level-up and returns them; after a + win the world runs the evolution as a script interlude before resuming + the battle's script (same draft, commits together). +- PACK: Rare Candy (level, exp, HP rise that revives, happiness, level-up + text, stat page, moves, evolution), evolution stones (ABLE/NOT ABLE party + labels with the original 10-byte/3-step check; Everstone and no-match + have no effect) and vitamins (stat experience high byte +10 up to 100). +- UI: the picture box shows the flashing evolution; the pack's pocket tabs + moved into their own compact row (`#mart-tabs`). + +Checks: 360 unit tests with the local ROM (5 new start-menu evolution +tests, 2 new battle-world tests); 111 Chromium/WebKit browser tests passed, +19 skipped intentionally (a new Rare Candy evolution flow in both +browsers); native and reference builds, reference, vendor and release +checks; `git diff --check`. No emulator comparison of evolution or the +menus; no CI or physical-device run. + +Next: MOVE PKMN W/O MAIL, SEL/registered items with Select, field moves +from the menu (Cut, Surf, Strength already have their ROM scripts), then +the Pokégear. Trade evolutions wait for Link. diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index bd2a137..8319acd 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -126,7 +126,13 @@ export type BattleAction = /** PACK: use the item in this slot of its pocket (only Balls so far). */ | { action: "item"; item: number; slot: number }; export type BattleInput = number | string | BattleAction | undefined; -export type BattleOutcome = { result: number; prizeMoney?: number }; +/** wBattleResult, the prize money and wEvolvableFlags (a bit per party + * index that levelled up). */ +export type BattleOutcome = { + result: number; + prizeMoney?: number; + evolvable?: number; +}; export interface BattleTrainer { name: string; @@ -406,6 +412,9 @@ export class Battle { numFleeAttempts = 0; /** wBattleParticipantsNotFainted, which shares the experience. */ participants = 0; + /** wEvolvableFlags: party members that levelled up, which + * EvolveAfterBattle checks after a win. */ + private evolvable = 0; /** wBattleParticipantsIncludingFainted, which revives consult. */ private participantsAll = 0; /** wEnemyMonBaseStats: five base stats, catch rate and base exp. */ @@ -790,11 +799,11 @@ export class Battle { const prizeMoney = trainer.reward * (amuletCoin ? 2 : 1); this.numbers[RAM.battleReward] = prizeMoney; yield* this.say(BATTLE.GotMoneyForWinningText); - return { result: this.battleResult, prizeMoney }; + return { result: this.battleResult, prizeMoney, evolvable: this.evolvable }; } if (trainer && this.options.canLose && trainer.lossText) yield* this.say(trainer.lossText); - return { result: this.battleResult }; + return { result: this.battleResult, evolvable: this.evolvable }; } private *battleTurn(): Gen { @@ -3503,6 +3512,7 @@ export class Battle { values: [mon.stats[1], mon.stats[2], mon.stats[4], mon.stats[5], mon.stats[3]], }; for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level); + this.evolvable |= 1 << index; } this.resetBattleParticipants(); } diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts index 6db0346..b08624a 100644 --- a/src/game/battle/constants.ts +++ b/src/game/battle/constants.ts @@ -256,6 +256,29 @@ export const C = { KEY_ITEM_POCKET: 2, TM_HM_POCKET: 3, PP_UP: 0x3e, + RARE_CANDY: 0x20, + HP_UP: 0x1a, + PROTEIN: 0x1b, + IRON: 0x1c, + CARBOS: 0x1d, + CALCIUM: 0x1f, + EVERSTONE: 0x70, + PARTYMENUACTION_EVO_STONE: 5, + PARTYMENUTEXT_LEVEL_UP: 0xf8, + HAPPINESS_USEDITEM: 0x02, + // Evolution (constants/pokemon_data_constants.asm) + EVOLVE_LEVEL: 1, + EVOLVE_ITEM: 2, + EVOLVE_TRADE: 3, + EVOLVE_HAPPINESS: 4, + EVOLVE_STAT: 5, + TR_ANYTIME: 1, + TR_MORNDAY: 2, + TR_NITE: 3, + ATK_GT_DEF: 1, + ATK_LT_DEF: 2, + ATK_EQ_DEF: 3, + HAPPINESS_TO_EVOLVE: 220, SKETCH: 0xa6, PP_UP_MASK: 0xc0, PP_UP_ONE: 0x40, diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index 007f2d6..2f70e08 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -439,6 +439,19 @@ export const MENU = { PPIsMaxedOutText: 0xf72f, PPsIncreasedText: 0xf734, BicycleEffect: 0xee08, + EvoStoneEffect: 0xee0f, + VitaminEffect: 0xee3d, + RareCandyEffect: 0xef14, + ItemStatRoseText: 0xeea6, + StatStrings: 0xeeab, + StatExpItemPointerOffsets: 0xeeeb, + "PlacePartyMonEvoStoneCompatibility.string_able": 0x502a3, + "PlacePartyMonEvoStoneCompatibility.string_not_able": 0x502a8, + EvosAttacksPointers: 0x425b1, + EvolvingText: 0x42482, + CongratulationsYourPokemonText: 0x42473, + EvolvedIntoText: 0x42478, + StoppedEvolvingText: 0x4247d, } as const; /** Battle engine tables and texts, keyed by their PRET labels so that diff --git a/src/game/scripts/evolution.ts b/src/game/scripts/evolution.ts new file mode 100644 index 0000000..70884a9 --- /dev/null +++ b/src/game/scripts/evolution.ts @@ -0,0 +1,296 @@ +import type { RomReader } from "../rom/reader"; +import type { PokemonData } from "../rom/pokemon"; +import { BATTLE, MENU, RAM } from "../rom/profile"; +import { C } from "../battle/constants"; +import { calcStats, type PartyMon } from "../party/pokemon"; +import type { ScriptEffect, ScriptState } from "./interpreter"; +import { readText, type TextContext } from "./text"; + +type Input = number | string | undefined; +type Gen = Generator; + +/** DelayFrames 50 after EvolvingText, in overworld ticks. */ +const EVOLVING_TICKS = 25; + +/** What evolution and move learning outside battle need from the world. */ +export interface EvolutionHost { + reader: RomReader; + data: PokemonData; + /** wTimeOfDay: morning 0, day 1, night 2 (NITE_F). */ + timeOfDay(): number; + /** The text context, before this module's RAM strings are added. */ + text(): TextContext; +} + +/** EvolveAfterBattle and EvolvePokemon (engine/pokemon/evolve.asm), with + * LearnLevelMoves and LearnMove (engine/pokemon/learn.asm) outside battle. + * The party and Pokédex are changed on the given draft state. */ +export class PartyEvolution { + /** Native values for the RAM strings and numbers the texts read. */ + readonly strings: Record = {}; + readonly numbers: Record = {}; + + constructor( + private state: ScriptState, + private host: EvolutionHost, + ) {} + + private get reader() { + return this.host.reader; + } + private get data() { + return this.host.data; + } + private text(): TextContext { + const base = this.host.text(); + return { + ...base, + rival: this.state.rivalName, + strings: { ...base.strings, ...this.strings }, + numbers: { ...base.numbers, ...this.numbers }, + asm: (offset) => { + // Text_1_2_and_Poof: SFX_SWITCH_POKEMON, then MoveForgotText. + if (offset === BATTLE.Text_1_2_and_Poof + 4) + return BATTLE["Text_1_2_and_Poof.MoveForgotText"]; + throw new Error(`Text routine at 0x${offset.toString(16)} is not ported.`); + }, + }; + } + private pages(address: number) { + return readText(this.reader, address, this.text()) + .split("\n\n") + .filter(Boolean); + } + private *print(address: number, holdTicks?: number): Gen { + const pages = this.pages(address); + if (pages.length) yield { kind: "text", pages, holdTicks }; + } + + /** EvolveAfterBattle for the party members whose bit is set in `flags` + * (wEvolvableFlags). `forced` is wForceEvolution, set by evolution + * stones, and `item` is wCurItem. Returns wMonTriedToEvolve. The + * evolution data is walked byte by byte as the original walks it, so the + * same entries are skipped when a forced evolution meets a four-byte + * EVOLVE_STAT record. */ + *evolveAfterBattle(flags: number, forced = false, item = 0): Gen { + const r = this.reader, + party = this.state.party; + let tried = false; + for (let slot = 0; slot < party.length; slot++) { + if (!(flags & (1 << slot))) continue; + const mon = party[slot]; + const oldSpecies = mon.species; + // CopyMonToTempMon: the checks read this copy. + const temp = structuredClone(mon); + const everstone = () => mon.item === C.EVERSTONE; + let p = r.near( + MENU.EvosAttacksPointers + (oldSpecies - 1) * 2, + r.bank(MENU.EvosAttacksPointers), + ); + for (let guard = 0; ; guard++) { + if (guard > 64) throw new Error("Invalid evolution data."); + const method = r.byte(p++); + if (!method) break; + // No Link mode: trades never evolve here. + if (method === C.EVOLVE_TRADE) { + p += 2; + continue; + } + if (method === C.EVOLVE_ITEM) { + const needed = r.byte(p++); + if (item !== needed || !forced) { + p += 1; + continue; + } + } else if (forced) { + p += 2; + continue; + } else if (method === C.EVOLVE_LEVEL) { + const level = r.byte(p++); + if (temp.level < level || everstone()) { + p += 1; + continue; + } + } else if (method === C.EVOLVE_HAPPINESS) { + if (temp.happiness < C.HAPPINESS_TO_EVOLVE || everstone()) { + p += 2; + continue; + } + const time = r.byte(p++); + const night = this.host.timeOfDay() === C.NITE_F; + if ( + (time === C.TR_MORNDAY && night) || + (time !== C.TR_ANYTIME && time !== C.TR_MORNDAY && !night) + ) { + p += 1; + continue; + } + } else { + // EVOLVE_STAT (Tyrogue): level, then Attack against Defense. + if (temp.level < r.byte(p) || everstone()) { + p += 3; + continue; + } + const [attack, defense] = [temp.stats[1], temp.stats[2]]; + const result = + attack === defense + ? C.ATK_EQ_DEF + : attack < defense + ? C.ATK_LT_DEF + : C.ATK_GT_DEF; + p++; + if (r.byte(p) !== result) { + p += 2; + continue; + } + p++; + } + tried = true; + yield* this.evolve(slot, temp, r.byte(p), !forced); + break; + } + } + return tried; + } + + /** EvolveAfterBattle's .proceed: the animation (B stops it unless the + * evolution is forced), then the new species, stats, moves and Pokédex. */ + private *evolve( + slot: number, + temp: PartyMon, + species: number, + cancelable: boolean, + ): Gen { + const data = this.data, + mon = this.state.party[slot]; + const oldSpecies = mon.species; + this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = + mon.nickname; + yield* this.print(MENU.EvolvingText, EVOLVING_TICKS); + const stopped = yield { + kind: "evolve", + from: oldSpecies, + to: species, + cancelable, + }; + if (stopped === 1) { + yield* this.print(MENU.StoppedEvolvingText); + yield { kind: "picture", species: 0 }; + return; + } + this.strings[RAM.stringBuffer1] = data.speciesName(species); + yield { + kind: "text", + pages: [ + ...this.pages(MENU.CongratulationsYourPokemonText), + ...this.pages(MENU.EvolvedIntoText), + ], + }; + yield { kind: "picture", species: 0 }; + // UpdateSpeciesNameIfNotNicknamed: wBaseDexNo still names the old + // species (the animation restores it). + if (mon.nickname === data.speciesName(oldSpecies)) + temp.nickname = data.speciesName(species); + // CalcMonStats with stat experience; the HP gains the max HP's rise. + temp.species = species; + const oldMax = mon.stats[0]; + temp.stats = calcStats(data, species, temp.dvs, temp.level, temp.statExp); + temp.hp = (temp.hp + temp.stats[0] - oldMax) & 0xffff; + Object.assign(mon, temp); + yield* this.learnLevelMoves(slot, temp.level); + // SetSeenAndCaughtMon + this.state.pokedex.seen.add(species); + this.state.pokedex.caught.add(species); + } + + /** LearnLevelMoves: the moves the species learns at exactly `level`, + * unless the Pokémon knows them. */ + *learnLevelMoves(slot: number, level: number): Gen { + const mon = this.state.party[slot]; + for (const learned of this.data.learnset(mon.species)) { + if (learned.level !== level || mon.moves.includes(learned.move)) + continue; + // GetMoveName and CopyName1 + this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = + this.data.moveName(learned.move); + yield* this.learnMove(slot, learned.move); + } + } + + /** LearnMove outside battle, with ForgetMove's choice. Returns true if + * the move was learned. */ + *learnMove(slot: number, move: number): Gen { + const data = this.data, + mon = this.state.party[slot]; + this.strings[RAM.monOrItemNameBuffer] = mon.nickname; + for (;;) { + let index = mon.moves.indexOf(0); + if (index < 0) { + index = yield* this.forgetMove(mon); + if (index < 0) { + const stop = yield { + kind: "yesno", + pages: this.pages(BATTLE.StopLearningMoveText), + }; + if (!stop) continue; + yield* this.print(BATTLE.DidNotLearnMoveText); + return false; + } + this.strings[RAM.stringBuffer1] = data.moveName(mon.moves[index]); + yield* this.print(BATTLE.Text_1_2_and_Poof); + } + mon.moves[index] = move; + mon.pp[index] = data.move(move).pp; + yield* this.print(BATTLE.LearnedMoveText); + return true; + } + } + /** ForgetMove: the move slot to replace, or -1 to stop. */ + private *forgetMove(mon: PartyMon): Gen { + const ask = yield { + kind: "yesno", + pages: this.pages(BATTLE.AskForgetMoveText), + }; + if (!ask) return -1; + const hmMoves = new Set(); + for (let p = BATTLE["IsHMMove.HMMoves"]; this.reader.byte(p) !== 0xff; p++) + hmMoves.add(this.reader.byte(p)); + for (;;) { + const choice = yield { + kind: "menu", + pages: this.pages(BATTLE.MoveAskForgetText), + items: mon.moves.map((move) => this.data.moveName(move)), + selected: 0, + }; + if (typeof choice !== "number" || choice < 0 || choice > 3) return -1; + if (hmMoves.has(mon.moves[choice])) { + yield* this.print(BATTLE.MoveCantForgetHMText); + continue; + } + return choice; + } + } +} + +/** PlacePartyMonEvoStoneCompatibility: ABLE if an EVOLVE_ITEM entry needs + * `item`. Like the original, it copies ten bytes and steps three at a time, + * so a four-byte EVOLVE_STAT record or a fourth entry is misread; bytes + * past the copy read as the end of the list here. */ +export function evoStoneCompatible( + reader: RomReader, + species: number, + item: number, +): boolean { + const p = reader.near( + MENU.EvosAttacksPointers + (species - 1) * 2, + reader.bank(MENU.EvosAttacksPointers), + ); + const buffer = Array.from({ length: 10 }, (_, i) => reader.byte(p + i)); + for (let i = 0; i < buffer.length; ) { + const method = buffer[i++] ?? 0; + if (!method) return false; + i += 2; + if (method === C.EVOLVE_ITEM && buffer[i - 2] === item) return true; + } + return false; +} diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index 0b13b25..ce31b38 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -250,6 +250,9 @@ export type ScriptEffect = | { kind: "notice"; text: string } /** The Pokémon stats screen, closed with a button. */ | { kind: "stats"; mon: PartyMon } + /** EvolutionAnimation: `from` flashes into `to`. Answered with 1 when + * B stopped it (only if `cancelable`), otherwise 0. */ + | { kind: "evolve"; from: number; to: number; cancelable: boolean } /** 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. */ diff --git a/src/game/scripts/start-menu.ts b/src/game/scripts/start-menu.ts index 6ca8a84..700333d 100644 --- a/src/game/scripts/start-menu.ts +++ b/src/game/scripts/start-menu.ts @@ -8,7 +8,8 @@ import { restoredPP, } from "../battle/battle"; import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag"; -import { maxPP, type PartyMon } from "../party/pokemon"; +import { calcStats, maxPP, type PartyMon } from "../party/pokemon"; +import { PartyEvolution, evoStoneCompatible } from "./evolution"; import { copyScriptState, type MartEntry, @@ -86,6 +87,10 @@ export interface StartMenuHost { /** BikeFunction: the script that getting on or off the Bicycle queues * here, or undefined when .CheckEnvironment refuses it. */ bicycleScript(state: ScriptState): number | undefined; + /** wTimeOfDay: morning 0, day 1, night 2. */ + timeOfDay(): number; + /** GetWorldMapLocation for the current map (LevelUpHappinessMod). */ + landmark(): number; } /** The overworld start menu (engine/menus/start_menu.asm) with its @@ -269,7 +274,11 @@ export class StartMenuRun { /** 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 { + private *partyMenu( + action: number, + from?: number, + item = 0, + ): Gen { const r = this.reader, entry = MENU.PartyMenuStrings + action * 2; const prompt = this.string(r.near(entry, r.bank(entry))); @@ -277,9 +286,17 @@ export class StartMenuRun { 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"}`, + party.map((mon) => + // PlacePartyMonEvoStoneCompatibility replaces the HP bar. + action === C.PARTYMENUACTION_EVO_STONE + ? mon.species === EGG + ? "" + : this.string( + evoStoneCompatible(r, mon.species, item) + ? MENU["PlacePartyMonEvoStoneCompatibility.string_able"] + : MENU["PlacePartyMonEvoStoneCompatibility.string_not_able"], + ) + : `: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 @@ -698,6 +715,12 @@ export class StartMenuRun { } case BATTLE.RestorePPEffect: return yield* this.restorePPEffect(use); + case MENU.RareCandyEffect: + return yield* this.rareCandyEffect(use); + case MENU.VitaminEffect: + return yield* this.vitaminEffect(use); + case MENU.EvoStoneEffect: + return yield* this.evoStoneEffect(use); case MENU.BicycleEffect: { const script = this.host.bicycleScript(this.state); if (script === undefined) return false; @@ -712,8 +735,11 @@ export class StartMenuRun { /** UseItem_SelectMon: the party menu for an item. The party index, or * -1 when cancelled or an Egg was chosen. */ - private *useItemSelectMon(): Gen { - const slot = yield* this.partyMenu(PARTYMENUACTION_HEALING_ITEM); + private *useItemSelectMon( + action: number = PARTYMENUACTION_HEALING_ITEM, + item = 0, + ): Gen { + const slot = yield* this.partyMenu(action, undefined, item); if (slot < 0) return -1; if (this.state.party[slot].species === EGG) { yield* this.print(BATTLE.ItemCantUseOnEggText); @@ -825,6 +851,109 @@ export class StartMenuRun { return 0; } + /** Evolution and move learning on this menu's draft. */ + private evolution() { + return new PartyEvolution(this.state, this.host); + } + + /** RareCandyEffect: one level at the level's minimum experience, the + * stats (and HP, even of a fainted Pokémon) raised, the level-up + * happiness, new moves and a possible evolution. */ + private *rareCandyEffect(use: FieldItemUse): Gen { + const data = this.data; + const target = yield* this.useItemSelectMon(); + if (target < 0) return false; + const mon = this.state.party[target]; + this.strings[RAM.stringBuffer1] = mon.nickname; + if (mon.level >= C.MAX_LEVEL) { + yield* this.print(BATTLE.ItemWontHaveEffectText); + return false; + } + const level = ++mon.level; + mon.exp = data.expAtLevel(data.baseData(mon.species).growthRate, level); + const oldMax = mon.stats[0]; + mon.stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp); + mon.hp = (mon.hp + mon.stats[0] - oldMax) & 0xffff; + // LevelUpHappinessMod: more on the map where it was caught. + this.changeHappiness( + target, + (mon.caughtData & 0x7f) === this.host.landmark() + ? C.HAPPINESS_GAINLEVELATHOME + : C.HAPPINESS_GAINLEVEL, + ); + this.numbers[RAM.curPartyLevel] = level; + yield* this.itemActionText(target, C.PARTYMENUTEXT_LEVEL_UP); + // PrintTempMonStats: Attack, Defense, Sp. Atk, Sp. Def and Speed. + const labels = readString( + this.reader, + BATTLE["PrintTempMonStats.StatNames"], + this.text(), + 64, + ) + .split("\n") + .filter(Boolean); + const values = [1, 2, 4, 5, 3].map((stat) => mon.stats[stat]); + yield { + kind: "text", + pages: [labels.map((label, i) => `${label} ${values[i]}`).join("\n")], + }; + const evolution = this.evolution(); + yield* evolution.learnLevelMoves(target, level); + // EvolvePokemon, not forced: a level or happiness evolution. + yield* evolution.evolveAfterBattle(1 << target, false, use.item); + this.useDisposableItem(use); + return true; + } + + /** VitaminEffect: ten more in the high byte of a stat's experience, up + * to 100, then CalcMonStats (the current HP stays). */ + private *vitaminEffect(use: FieldItemUse): Gen { + const r = this.reader; + const target = yield* this.useItemSelectMon(); + if (target < 0) return false; + const mon = this.state.party[target]; + this.strings[RAM.stringBuffer1] = mon.nickname; + // GetStatExpRelativePointer: the stat experience word for the item. + let p = MENU.StatExpItemPointerOffsets; + while (r.byte(p) !== use.item) p += 2; + const offset = r.byte(p + 1), + stat = offset >> 1; + if (mon.statExp[stat] >> 8 >= 100) { + yield* this.print(BATTLE.ItemWontHaveEffectText); + return false; + } + mon.statExp[stat] += 10 << 8; + mon.stats = calcStats(this.data, mon.species, mon.dvs, mon.level, mon.statExp); + const entry = MENU.StatStrings + offset; + this.strings[RAM.stringBuffer2] = this.string(r.near(entry, r.bank(entry))); + yield* this.print(MENU.ItemStatRoseText); + this.changeHappiness(target, C.HAPPINESS_USEDITEM); + this.useDisposableItem(use); + return true; + } + + /** EvoStoneEffect: a forced EvolvePokemon with the stone as wCurItem. + * An Everstone or no matching evolution has no effect. */ + private *evoStoneEffect(use: FieldItemUse): Gen { + const target = yield* this.useItemSelectMon( + C.PARTYMENUACTION_EVO_STONE, + use.item, + ); + if (target < 0) return false; + if (this.state.party[target].item === C.EVERSTONE) { + yield* this.print(BATTLE.ItemWontHaveEffectText); + return false; + } + if ( + !(yield* this.evolution().evolveAfterBattle(1 << target, true, use.item)) + ) { + yield* this.print(BATTLE.ItemWontHaveEffectText); + return false; + } + this.useDisposableItem(use); + return true; + } + /** 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 { diff --git a/src/game/world/world.ts b/src/game/world/world.ts index 9ae1c0d..a1a4be8 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -28,6 +28,7 @@ import { } from "../scripts/interpreter"; import { IntroRun } from "../scripts/intro"; import { StartMenuRun, type MenuCursors } from "../scripts/start-menu"; +import { PartyEvolution } from "../scripts/evolution"; import type { MovementPose, MovementStep } from "../scripts/movement"; import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag"; import { @@ -254,6 +255,26 @@ export interface BattleView { /** Yes/No cursor, as on the original boxes. */ selected: 0 | 1; } +/** EvolutionAnimation in overworld ticks (two frames each): 80 frames on + * the old species, then .AnimationSequence's seven rounds of a B-checking + * wait (14, 12 … 2 frames) followed by that many flashes of the new and + * old pictures (about four frames each). */ +function evolutionSchedule(from: number, to: number) { + const frames: { species: number; cancel: boolean }[] = []; + const show = (species: number, ticks: number, cancel = false) => { + for (let i = 0; i < ticks; i++) frames.push({ species, cancel }); + }; + show(from, 40); + for (let round = 1; round <= 7; round++) { + show(from, 8 - round, true); + for (let flash = 0; flash < round; flash++) { + show(to, 2); + show(from, 2); + } + } + return frames; +} + /** Ticks a `done` battle message stays before the battle continues. */ const BATTLE_MESSAGE_TICKS = 30; @@ -288,6 +309,12 @@ interface ActiveScript { warp?: { map: string; x: number; y: number; facing?: Direction }; /** warpcheck: the player's tile events run on the tick after the script. */ warpCheck?: boolean; + /** Routines that run before the script resumes with `result`: the + * evolutions ExitBattle starts after a won battle. */ + interlude?: { + run: Generator; + result: number; + }; /** LoadMemScript: queued for the next PlayerEvents once this commits. */ memScript?: MemScript; /** The trainer whose sight started this script, if any. */ @@ -479,6 +506,17 @@ export class GameWorld { picture: number | undefined; /** The introduction's trainer or player picture, if any. */ portrait: Portrait | undefined; + /** An evolution animation in progress: the frame shown on each tick, and + * whether B may stop it then. */ + evolution: + | { + from: number; + to: number; + cancelable: boolean; + step: number; + schedule: { species: number; cancel: boolean }[]; + } + | undefined; /** True from a new game until the introduction finishes. */ private introPending = false; /** The battle in progress, if any. */ @@ -1609,6 +1647,14 @@ export class GameWorld { this.pump(); } + private evolutionHost() { + return { + reader: this.assets.reader, + data: this.assets.pokemon, + timeOfDay: () => this.clockTimeOfDay, + text: () => this.host(PLAYER).text(), + }; + } /** Start in PlayerEvents (StartMenuScript): the start menu opens while * the player stands still. Its changes commit when it closes. */ openStartMenu(): boolean { @@ -1622,6 +1668,8 @@ export class GameWorld { data: this.assets.pokemon, text: () => this.host(PLAYER).text(), bicycleScript: (state) => this.bicycleScript(state), + timeOfDay: () => this.clockTimeOfDay, + landmark: () => this.assets.map(this.mapId).landmark, }, this.menuCursors, ), @@ -1679,8 +1727,21 @@ export class GameWorld { if (!script) return; try { for (;;) { - const effect = script.run.resume(input); - input = undefined; + let effect: ScriptEffect | undefined; + const interlude = script.interlude; + if (interlude) { + const next = interlude.run.next(input); + input = undefined; + if (next.done) { + script.interlude = undefined; + input = interlude.result; + continue; + } + effect = next.value; + } else { + effect = script.run.resume(input); + input = undefined; + } script.effect = effect; if (!effect) { this.finishScript(); @@ -1955,6 +2016,16 @@ export class GameWorld { }, }; return true; + case "evolve": + this.picture = effect.from; + this.evolution = { + from: effect.from, + to: effect.to, + cancelable: effect.cancelable, + step: 0, + schedule: evolutionSchedule(effect.from, effect.to), + }; + return true; case "pack": this.dialogue = { pages: [""], @@ -2350,6 +2421,7 @@ export class GameWorld { this.emote = undefined; this.picture = undefined; this.portrait = undefined; + this.evolution = undefined; if (script.sighted !== undefined) this.refusedSighting = { map: this.mapId, @@ -2380,9 +2452,19 @@ export class GameWorld { ); this.battle = undefined; this.battleRun = undefined; - // CleanUpBattleRAM + // ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM. + const { result, evolvable } = next.value; this.menuCursors = { start: 0, party: 0, pocket: 0 }; - this.pump(next.value.result); + if (this.script && evolvable && !(result & 0x0f)) { + this.script.interlude = { + run: new PartyEvolution( + this.script.run.state, + this.evolutionHost(), + ).evolveAfterBattle(evolvable), + result, + }; + this.pump(); + } else this.pump(result); return; } const effect = next.value; @@ -2793,6 +2875,13 @@ export class GameWorld { } const script = this.script; if (script) { + const evolution = this.evolution; + if (evolution) { + const frame = evolution.schedule[++evolution.step]; + if (frame) this.picture = frame.species; + else this.finishEvolution(false); + return; + } const dialogue = this.dialogue; if (dialogue) { if ( @@ -3198,9 +3287,25 @@ export class GameWorld { ) state.playerState = OW.PLAYER_NORMAL; } + /** EvolutionAnimation ends: the new species, or the old one if B + * stopped it. */ + private finishEvolution(stopped: boolean) { + const evolution = this.evolution!; + this.evolution = undefined; + this.picture = stopped ? evolution.from : evolution.to; + this.pump(stopped ? 1 : 0); + } /** B, or A on a page without a choice. */ advanceDialogue() { if (this.battle) return this.battleB(); + const evolution = this.evolution; + if (evolution) { + // .WaitFrames_CheckPressedB: only between flashes, and never for a + // forced (evolution stone) evolution. + if (evolution.cancelable && evolution.schedule[evolution.step]?.cancel) + this.finishEvolution(true); + return; + } const dialogue = this.dialogue; if (!dialogue || !this.script) return; const last = dialogue.index === dialogue.pages.length - 1; @@ -3587,9 +3692,11 @@ export class GameWorld { this.emote = undefined; this.picture = undefined; this.portrait = undefined; + this.evolution = undefined; this.battle = undefined; this.battleRun = undefined; this.memScript = undefined; + this.menuCursors = { start: 0, party: 0, pocket: 0 }; this.clearNotice(); this.introPending = false; // A save made during the introduction starts it again. diff --git a/src/main.ts b/src/main.ts index d882853..706bbb8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -264,7 +264,9 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) { const key = JSON.stringify(mart); if (key === martMenuKey) return; martMenuKey = key; - const menu = get("mart-menu"); + const menu = get("mart-menu"), + tabs = get("mart-tabs"); + tabs.hidden = mart.kind !== "pack"; const button = ( label: string, action: () => void, @@ -299,7 +301,7 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) { } 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 pocketTabs = mart.pockets.map((pocket, index) => { const tab = button(pocket.name, () => { const world = runtime.world; const choice = world?.dialogue?.choice; @@ -328,7 +330,8 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) { buttons.push(empty); } buttons.push(button("BACK", () => runtime.choose(-1), { wide: true })); - menu.replaceChildren(...tabs, ...buttons); + tabs.replaceChildren(...pocketTabs); + menu.replaceChildren(...buttons); return; } if (mart.kind === "martlist") { diff --git a/src/ui/shell.ts b/src/ui/shell.ts index 8707fd3..22d859c 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -36,7 +36,7 @@ export function renderShell(root: HTMLElement) { diff --git a/src/ui/styles.css b/src/ui/styles.css index 826e018..da19649 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -597,6 +597,23 @@ dialog h2 { margin: 0 0 6px; white-space: pre-line; } +.pack-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px; + margin-bottom: 8px; +} +.pack-tabs button { + padding: 4px; + font-size: 0.75rem; + color: var(--ink); + background: var(--cream-dark); + border: 3px double var(--ink); +} +.pack-tabs button[aria-pressed="true"] { + color: #fff; + background: var(--blue); +} .mart-menu { display: grid; gap: 6px; diff --git a/tests/battle-world.test.ts b/tests/battle-world.test.ts index 20921ae..65bed76 100644 --- a/tests/battle-world.test.ts +++ b/tests/battle-world.test.ts @@ -54,6 +54,46 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R expect(world.save()).toEqual(saved); }); + it("evolves a Pokémon that levelled up once the battle is won", () => { + const world = fixture(); + const data = world.assets.pokemon; + const mon = generatePartyMon(data, { + species: 155, level: 13, item: 0, otId: world.playerId, + ot: "CHRIS", dvs: 0x823b, caughtData: 0, + }); + mon.exp = data.expAtLevel(data.baseData(155).growthRate, 14) - 1; + world.state.party[0] = mon; + encounter(world); + const messages = finish(world); + expect(world.notice, world.diagnostic).toBe(""); + expect(messages).toContain("What? CYNDAQUIL\nis evolving!"); + expect(messages).toContain("evolved into\nQUILAVA!"); + const evolved = world.state.party[0]; + expect([evolved.species, evolved.nickname, evolved.level]).toEqual([156, "QUILAVA", 14]); + expect(evolved.stats).toEqual(calcStats(data, 156, evolved.dvs, 14, evolved.statExp)); + expect(world.state.pokedex.caught.has(156)).toBe(true); + const saved = world.save(); + world.restore(saved); + expect(world.save()).toEqual(saved); + }); + + it("considers only the Pokémon that levelled up (wEvolvableFlags)", () => { + const world = fixture(); + const data = world.assets.pokemon; + // A level-14 Cyndaquil that does not take part could evolve, but + // EvolveAfterBattle only checks the one that grew to level 6. + world.state.party.push(generatePartyMon(data, { + species: 155, level: 14, item: 0, otId: world.playerId, + ot: "CHRIS", dvs: 0x823b, caughtData: 0, + })); + encounter(world); + const messages = finish(world); + expect(world.notice, world.diagnostic).toBe(""); + expect(world.state.party[0].level).toBe(6); + expect(messages).not.toContain("evolving"); + expect(world.state.party.map((mon) => mon.species)).toEqual([155, 155]); + }); + it("struggles with recoil once every move is out of PP", () => { const world = fixture(); world.state.party[0].pp = [0, 0, 0, 0]; diff --git a/tests/foundation-browser.spec.ts b/tests/foundation-browser.spec.ts index 1fd640a..49dcece 100644 --- a/tests/foundation-browser.spec.ts +++ b/tests/foundation-browser.spec.ts @@ -9,7 +9,10 @@ test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."), ); -async function start(page: Page, room: boolean | "poster" | "center" = false) { +async function start( + page: Page, + room: boolean | "poster" | "center" | "candy" = false, +) { await page.route("**/src/game/runtime.ts", async (route) => { const response = await route.fetch(), source = await response.text(); @@ -239,3 +242,37 @@ test("Start opens the start menu: SWITCH reorders the party and the pack tosses ]); expect(state.bag.items).toEqual([{ item: 18, quantity: 4 }]); }); + +test("a Rare Candy levels up and evolves a Pokémon through the pack", async ({ + page, +}) => { + await start(page, "candy"); + const text = page.locator("#dialogue-text"), + choices = page.locator("#dialogue-choice"), + pack = page.locator("#mart"); + await page.keyboard.press("Enter"); + await choices.getByRole("option", { name: /^PACK/ }).click(); + await pack.getByRole("button", { name: /RARE CANDY/ }).click(); + await choices.getByRole("option", { name: "USE", exact: true }).click(); + await choices.getByRole("option", { name: /^CYNDAQUIL/ }).click(); + await expect(text).toContainText("grew to"); + await page.keyboard.press("KeyX"); + await expect(text).toContainText("ATTACK"); + await page.keyboard.press("KeyX"); + await expect(text).toContainText("is evolving!"); + await page.clock.runFor(1500); + await expect(page.locator("#pokepic")).toBeVisible(); + await page.clock.runFor(8000); + await expect(text).toContainText("Congratulations!"); + await page.keyboard.press("KeyX"); + await expect(text).toContainText("QUILAVA!"); + await page.keyboard.press("KeyX"); + await expect(pack).toHaveAttribute("aria-label", "Pack"); + 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[0].species, state.party[0].level]).toEqual([156, 14]); + expect(state.bag.items).toEqual([{ item: 18, quantity: 5 }]); +}); diff --git a/tests/foundation-fixture.ts b/tests/foundation-fixture.ts index ae91e93..10c192a 100644 --- a/tests/foundation-fixture.ts +++ b/tests/foundation-fixture.ts @@ -4,10 +4,11 @@ import { OW } from "../src/game/world/constants"; import { generatePartyMon } from "../src/game/party/pokemon"; /** `room` places the player at the bedroom PC or poster, "center" at - * Cherrygrove's Pokémon Center PC with two Pokémon; otherwise outdoors. */ + * Cherrygrove's Pokémon Center PC with two Pokémon ("candy": the first at + * level 13, with a Rare Candy); otherwise outdoors. */ export function foundationFixture( assets: CrystalAssets, - room: boolean | "poster" | "center", + room: boolean | "poster" | "center" | "candy", ) { const world = new GameWorld( assets, @@ -16,11 +17,11 @@ export function foundationFixture( ); world.state.scenes["24:4"] = 1; world.state.bag.keyItems.push(OW.BICYCLE); - if (room === "center") { + if (room === "center" || room === "candy") { world.state.party = [155, 16].map((species, i) => generatePartyMon(assets.pokemon, { species, - level: 8 - i * 4, + level: room === "candy" && !i ? 13 : 8 - i * 4, item: 0, otId: world.playerId, ot: "CHRIS", @@ -29,10 +30,11 @@ export function foundationFixture( }), ); world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION + if (room === "candy") world.state.bag.items.push({ item: 0x20, quantity: 1 }); } const save = world.save(); const [map, x, y] = - room === "center" + room === "center" || room === "candy" ? ["26:5", 9, 2] : room === "poster" ? ["24:7", 6, 1] diff --git a/tests/start-menu.test.ts b/tests/start-menu.test.ts index f0fbdca..3c4f8ea 100644 --- a/tests/start-menu.test.ts +++ b/tests/start-menu.test.ts @@ -2,7 +2,7 @@ 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 { calcStats, generatePartyMon, maxPP } from "../src/game/party/pokemon"; import { ENGINE_FLAGS } from "../src/game/rom/profile"; import { settle } from "./world-driver"; @@ -12,21 +12,30 @@ const romPath = const NEW_BARK_TOWN = "24:4", PLAYERS_HOUSE = "24:6", CYNDAQUIL = 155, - PIDGEY = 16; + QUILAVA = 156, + PIDGEY = 16, + PIKACHU = 25, + RAICHU = 26; const BICYCLE = 7, ANTIDOTE = 9, FULL_RESTORE = 0x0e, POTION = 0x12, + REPEL = 0x14, + THUNDERSTONE = 0x17, + HP_UP = 0x1a, RARE_CANDY = 0x20, REVIVE = 0x27, PP_UP = 0x3e, ETHER = 0x3f, ELIXER = 0x41, + EVERSTONE = 0x70, ENERGYPOWDER = 0x79, LEFTOVERS = 0x92, FLOWER_MAIL = 0x9e, BERRY = 0xad; const CUT = 0x0f, + GUST = 0x10, + SAND_ATTACK = 0x1c, TACKLE = 0x21, LEER = 0x2b, SMOKESCREEN = 0x6c; @@ -520,7 +529,7 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => { const world = fixture(); world.state.bag.items.push( { item: BERRY, quantity: 1 }, - { item: RARE_CANDY, quantity: 1 }, + { item: REPEL, quantity: 1 }, ); world.state.bag.balls = [{ item: 5, quantity: 3 }]; open(world); @@ -529,11 +538,214 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => { pickItem(world, 5); expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]); pick(world, "QUIT"); + pickItem(world, REPEL); + pick(world, "USE"); + expect(world.notice).toBe("Using REPEL is not ported yet."); + close(world); + expect(world.state.bag.items.find((slot) => slot.item === REPEL)).toBeTruthy(); + }); + + it("raises a level with a Rare Candy, forgetting a move to learn a new one", () => { + const world = fixture(); + const pidgey = world.state.party[1]; + pidgey.moves = [TACKLE, CUT, LEER, GUST]; + pidgey.pp = [35, 30, 30, 35]; + pidgey.hp = 0; + world.state.bag.items.push({ item: RARE_CANDY, quantity: 2 }); + const data = world.assets.pokemon; + open(world); + pick(world, "PACK"); pickItem(world, RARE_CANDY); pick(world, "USE"); - expect(world.notice).toBe("Using RARE CANDY is not ported yet."); + const pages = pick(world, "PIDGEY"); + expect(pages.slice(0, 2)).toEqual([ + "PIDGEY grew to level 5!", + pages[1], + ]); + expect(pages[1]).toMatch(/^ATTACK \d+ DEFENSE \d+ SPCL\.ATK \d+ SPCL\.DEF \d+ SPEED \d+$/); + expect(pages.slice(2)).toEqual([ + "PIDGEY is trying to learn SAND-ATTACK.", + "But PIDGEY can't learn more than four moves.", + "Delete an older move to make room for SAND-ATTACK?", + ]); + world.choose(1); + expect(until(world)).toEqual(["Which move should be forgotten?"]); + expect(menuItems(world)).toEqual(["TACKLE", "CUT", "LEER", "GUST"]); + expect(pick(world, "CUT")).toEqual([ + "HM moves can't be forgotten now.", + "Which move should be forgotten?", + ]); + expect(pick(world, "LEER")).toEqual([ + "1, 2 and… Poof!", + "PIDGEY forgot LEER.", + "And…", + "PIDGEY learned SAND-ATTACK!", + ]); close(world); - expect(world.state.bag.items.find((slot) => slot.item === RARE_CANDY)).toBeTruthy(); + const mon = world.state.party[1]; + expect(mon.level).toBe(5); + expect(mon.exp).toBe(data.expAtLevel(data.baseData(PIDGEY).growthRate, 5)); + expect(mon.moves).toEqual([TACKLE, CUT, SAND_ATTACK, GUST]); + expect(mon.pp[2]).toBe(data.move(SAND_ATTACK).pp); + // The Rare Candy adds the maximum HP's rise even to a fainted Pokémon. + expect(mon.hp).toBe(mon.stats[0] - pidgey.stats[0]); + expect(mon.hp).toBeGreaterThan(0); + expect(world.state.bag.items.at(-1)).toEqual({ item: RARE_CANDY, quantity: 1 }); + }); + + it("can decline to learn the move", () => { + const world = fixture(); + world.state.party[1].moves = [TACKLE, CUT, LEER, GUST]; + world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 }); + open(world); + pick(world, "PACK"); + pickItem(world, RARE_CANDY); + pick(world, "USE"); + pick(world, "PIDGEY"); + world.choose(0); + expect(until(world)).toEqual(["Stop learning SAND-ATTACK?"]); + world.choose(1); + expect(until(world)).toEqual(["PIDGEY did not learn SAND-ATTACK."]); + close(world); + expect(world.state.party[1].moves).toEqual([TACKLE, CUT, LEER, GUST]); + expect(world.state.party[1].level).toBe(5); + }); + + it("evolves after a Rare Candy, and B can stop it", () => { + for (const stop of [false, true]) { + const world = fixture(); + const data = world.assets.pokemon; + world.state.party[0] = generatePartyMon(data, { + species: CYNDAQUIL, + level: 13, + item: 0, + otId: world.playerId, + ot: "CHRIS", + dvs: 0x9abc, + caughtData: 0, + }); + world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 }); + open(world); + pick(world, "PACK"); + pickItem(world, RARE_CANDY); + pick(world, "USE"); + world.choose(0); + // Acknowledge pages until the evolution animation starts. + const pages: string[] = []; + for (let i = 0; i < 200 && !world.evolution; i++) { + const dialogue = world.dialogue; + if (dialogue) { + pages.push(dialogue.pages[dialogue.index].replace(/\n/g, " ")); + if (dialogue.holdTicks !== undefined) world.tick(); + else world.advanceDialogue(); + } else world.tick(); + } + expect(pages[0]).toBe("CYNDAQUIL grew to level 14!"); + expect(pages.at(-1)).toBe("What? CYNDAQUIL is evolving!"); + expect(world.picture).toBe(CYNDAQUIL); + // B does nothing before the flashes begin. + world.advanceDialogue(); + expect(world.evolution).toBeDefined(); + for (let tick = 0; tick < 40; tick++) world.tick(); + if (stop) { + world.advanceDialogue(); + expect(until(world)).toEqual(["Huh? CYNDAQUIL stopped evolving!"]); + } else { + expect(until(world)).toEqual([ + "Congratulations! Your CYNDAQUIL", + "evolved into QUILAVA!", + ]); + } + close(world); + const mon = world.state.party[0]; + expect(mon.level).toBe(14); + expect([mon.species, mon.nickname]).toEqual( + stop ? [CYNDAQUIL, "CYNDAQUIL"] : [QUILAVA, "QUILAVA"], + ); + expect(mon.stats).toEqual( + calcStats(data, mon.species, mon.dvs, 14, mon.statExp), + ); + expect(world.state.pokedex.caught.has(QUILAVA)).toBe(!stop); + expect(world.state.bag.items.some((slot) => slot.item === RARE_CANDY)).toBe(false); + } + }); + + it("evolves with a stone, which B cannot stop, unless held back by an Everstone", () => { + const world = fixture(); + const data = world.assets.pokemon; + world.state.party[1] = generatePartyMon(data, { + species: PIKACHU, + level: 10, + item: EVERSTONE, + otId: world.playerId, + ot: "CHRIS", + dvs: 0x9abc, + caughtData: 0, + }); + world.state.party[1].nickname = "SPARKY"; + world.state.bag.items.push({ item: THUNDERSTONE, quantity: 2 }); + open(world); + pick(world, "PACK"); + pickItem(world, THUNDERSTONE); + expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]); + const party = world.dialogue?.choice; + expect(party?.kind === "menu" && party.details).toEqual(["NOT ABLE", "ABLE"]); + expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]); + pickItem(world, THUNDERSTONE); + pick(world, "USE"); + expect(pick(world, "SPARKY")).toEqual(["It won't have any effect."]); + close(world); + world.state.party[1].item = 0; + open(world); + pick(world, "PACK"); + pickItem(world, THUNDERSTONE); + pick(world, "USE"); + world.choose(1); + for (let i = 0; i < 20 && !world.evolution; i++) { + if (world.dialogue?.holdTicks !== undefined) world.tick(); + else world.advanceDialogue(); + } + for (let tick = 0; tick < 60; tick++) world.tick(); + world.advanceDialogue(); + expect(until(world)).toEqual([ + "Congratulations! Your SPARKY", + "evolved into RAICHU!", + ]); + close(world); + const mon = world.state.party[1]; + // A nickname stays. + expect([mon.species, mon.nickname]).toEqual([RAICHU, "SPARKY"]); + expect(world.state.bag.items.at(-1)).toEqual({ item: THUNDERSTONE, quantity: 1 }); + }); + + it("raises stat experience with a vitamin, up to its limit", () => { + const world = fixture(); + const data = world.assets.pokemon; + const mon = world.state.party[0]; + const hp = mon.hp, + happiness = mon.happiness; + world.state.bag.items.push({ item: HP_UP, quantity: 2 }); + open(world); + pick(world, "PACK"); + pickItem(world, HP_UP); + pick(world, "USE"); + expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL's HEALTH rose."]); + close(world); + const raised = world.state.party[0]; + expect(raised.statExp[0]).toBe(2560); + expect(raised.stats).toEqual(calcStats(data, CYNDAQUIL, raised.dvs, 8, raised.statExp)); + expect(raised.hp).toBe(hp); + expect(raised.happiness).toBeGreaterThan(happiness); + + raised.statExp[0] = 100 << 8; + open(world); + pick(world, "PACK"); + pickItem(world, HP_UP); + pick(world, "USE"); + expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]); + close(world); + expect(world.state.party[0].statExp[0]).toBe(100 << 8); + expect(world.state.bag.items.at(-1)).toEqual({ item: HP_UP, quantity: 1 }); }); it("saves the state from before the menu while it is open", () => {