Finish the opening milestone's switching, items and acceptance run

- Decode evolution lists from the ROM; all 251 match the pinned PRET source.
- Compare CalcMonStats and CalcExpAtLevel against the original routines in
  the reference emulator.
- Add a continuous opening playthrough from the intro to the catching
  tutorial, reloading a JSON save after every stage.
- Port player switching in battle: the PKMN menu, forced choices after a
  faint, Shift mode's offer and double faints, with the original texts.
- Port HP and status healing items in battle through the ROM's ItemEffects,
  HealingHPAmounts and StatusHealingActions tables.
- Add the battle party menu and the Items pocket to the UI.

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 11:04:11 +02:00
1 parent eb03a20d8f
commit f8e0f9c65a
20 files changed
+1902 -96

No files matched your search

+2 -2
View File
@@ -10,13 +10,13 @@ With a Pokémon, head west along Route 29 to Cherrygrove City. Wild Pokémon app
North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you from a distance and walks over; Youngster Mikey battles when you talk to him. Each sends out two Pokémon, and beaten trainers remember you. The Berry house gives a Berry, and a Potion is hidden on the route.
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.
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, and when one faints you choose the next. PACK's Items pocket uses Potions, Berries and status healers such as Antidote.
Use Start or Select for 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, the stats screen, other items in battle and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
Route 31 and beyond, full party and bag menus, moving Pokémon between boxes, the stats screen, battle items such as X Attack or Poké Doll, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run
+53 -4
View File
@@ -32,7 +32,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. |
| 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/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, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes. |
| `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. |
@@ -46,7 +46,7 @@ The opening scenes currently run end to end from the ROM: Mom's Pokégear, weekd
`tests/reference-parity.spec.ts` loads the user's ROM into the reference emulator and places a small original test trampoline in emulated WRAM. It calls the ROM's `FarDecompress` routine at `$0b40`, then sets a return sentinel. Every output byte is compared with the native decoder for six supported map tilesets. This executes the original routine rather than comparing two copies of our code.
The same suite compares 400 damage cases and 400 type-effectiveness cases per browser against the original battle routines. Battle unit tests cover win, escape, loss, experience, level-up and move-learning flows. This establishes parity only for the compared routines and inputs. It does not prove complete gameplay, rendering, battles, audio, timing or physical-device parity. Extend controlled comparisons as each subsystem is translated.
The same suite compares 400 damage cases and 400 type-effectiveness cases per browser against the original battle routines, 300 `CalcMonStats` cases (with and without stat experience, including levels above 100 that reach the 999 cap) and every `CalcExpAtLevel` value for the six growth rates and levels 1–100. Battle unit tests cover win, escape, loss, experience, level-up and move-learning flows, and `tests/playthrough.test.ts` plays the whole opening quest from a new game through `GameWorld`'s inputs, with save/reload after each stage. This establishes parity only for the compared routines and inputs. It does not prove complete gameplay, rendering, battles, audio, timing or physical-device parity. Extend controlled comparisons as each subsystem is translated.
## Validation
@@ -80,7 +80,7 @@ Native saves are version 3. Version 2 added scene values by owning map, phone co
`home/trainers.asm` and `engine/events/trainer_scripts.asm`: talking to an `OBJECTTYPE_TRAINER` object runs `TalkToTrainerScript` with the object's header copied as `wTempTrainer`; `_CheckTrainerBattle` runs `SeenByTrainerScript` with `wSeenTrainerDistance`. The world checks sight in `PlayerEvents` order: before warps, coordinate events and encounters at the end of a step (a seen player's tile events are then skipped, as `DisableEvents` does), and on idle ticks before scene scripts and input. Walls and objects do not block sight. `TrainerWalkToPlayer`/`ComputePathToWalkToPlayer` build the raw `wMovementBuffer` bytes, which `applymovementlasttalked` decodes with the normal movement reader. `wRunningTrainerBattleScript` lives in the script run: zero at the start, set by `loadmem` after the battle so `endifjustbattled` ends the after-script. Trainer commands without a map trainer are refused. `MomTriesToBuySomething` after scripted battles does nothing while Mom's savings (not ported) are zero.
`engine/battle/core.asm` and `engine/battle/ai/items.asm`, `switch.asm`: when a trainer's Pokémon faints and others remain, `UpdateBattleStateAndExperienceAfterEnemyFaint` zeroes its party HP and runs `NewEnemyMonStatus`; `EnemyPartyMonEntrance` then sends out the Pokémon chosen by `FindMonInOTPartyToSwitchIntoBattle`. When every candidate is threatened it draws `BattleRandom & 7`, which tests pin. `AI_SwitchOrTryItem` runs before either side moves: `CheckAbleToSwitch`, the Often/Rarely/Sometimes thresholds, `AI_TrySwitch` and `AI_Switch` (withdraw text, HP and status back to the party). `CheckTypeMatchup` ignores the type its switch-AI callers load and uses `BATTLE_VARS_MOVE_TYPE`, so every such matchup uses the enemy's chosen move type; `FindEnemyMonsWithASuperEffectiveMove` never resets its result between Pokémon; `FindEnemyMonsWithAtLeastQuarterMaxHP` rotates HP bits instead of multiplying. All three bugs are kept. `UsedMoveText` records `wPlayerUsedMoves`. Refused: the Shift-mode offer to switch (`OfferSwitch`), `AskUseNextPokemon` with other Pokémon left, Pursuit on a switching Pokémon, Perish Song, Spikes, and trainer items. Side effects of the type scans on the move structs (overwritten types and animation) are not modelled; the next `ParseEnemyAction`/`ParsePlayerAction` reloads them.
`engine/battle/core.asm` and `engine/battle/ai/items.asm`, `switch.asm`: when a trainer's Pokémon faints and others remain, `UpdateBattleStateAndExperienceAfterEnemyFaint` zeroes its party HP and runs `NewEnemyMonStatus`; `EnemyPartyMonEntrance` then sends out the Pokémon chosen by `FindMonInOTPartyToSwitchIntoBattle`. When every candidate is threatened it draws `BattleRandom & 7`, which tests pin. `AI_SwitchOrTryItem` runs before either side moves: `CheckAbleToSwitch`, the Often/Rarely/Sometimes thresholds, `AI_TrySwitch` and `AI_Switch` (withdraw text, HP and status back to the party). `CheckTypeMatchup` ignores the type its switch-AI callers load and uses `BATTLE_VARS_MOVE_TYPE`, so every such matchup uses the enemy's chosen move type; `FindEnemyMonsWithASuperEffectiveMove` never resets its result between Pokémon; `FindEnemyMonsWithAtLeastQuarterMaxHP` rotates HP bits instead of multiplying. All three bugs are kept. `UsedMoveText` records `wPlayerUsedMoves`. Refused: Pursuit on a switching Pokémon, Perish Song, Spikes, and trainer items. Side effects of the type scans on the move structs (overwritten types and animation) are not modelled; the next `ParseEnemyAction`/`ParsePlayerAction` reloads them.
`BGEVENT_ITEM` runs `HiddenItemScript` with `wHiddenItemData`; `SetMemEvent` sets the item's flag.
@@ -147,7 +147,7 @@ Prompts continue on their own. The Dude's back picture, the ball animation,
capture music and the Pokédex page (`NewPokedexEntry`) are not presented.
`TX_LOW` text is now read as a line break.
Refused: `SendMonIntoBox` (a catch with a full party), the other ball
Refused: the other ball
multipliers, Park/Safari Balls, a transformed target and a catch-rate
division by zero. Pure vectors cover the catch formula; local-ROM tests
cover capture, every break-free message, Master Ball draws, blank
@@ -155,3 +155,52 @@ nicknames, the blocked trainer ball, full-party rollback, both tutorial
triggers, declining and the repeat offer, and mid-battle reloads. Browser
tests cover PACK, catching and naming. No emulator comparison of the
capture routines has been run.
## Switching and healing items in battle
`engine/battle/core.asm`: `BattleMenu_PKMN` and `TryPlayerSwitch` ("is
already out", "can't be recalled" while wrapped or blocked by Mean Look,
`CheckIfCurPartyMonIsFitToFight` with its fainted and Egg texts),
`PlayerSwitch` and `BattleMonEntrance`. The switch runs `ParseEnemyAction`
first, and `ParsePlayerAction` then skips it, so the random draws happen
once. `SendOutMonText` and `WithdrawMonText` choose their cheer from the
enemy's HP as a byte percentage of max HP / 4, including
`wEnemyHPAtTimeOfPlayerSwitch`. A maximum HP below 4 would divide by zero
and freeze the original; it is refused. `PursuitSwitch` reads the enemy
move's effect as `GetMoveEffect` does, and Pursuit is refused.
After a faint, `HandlePlayerMonFaint` runs `AskUseNextPokemon` (wild
battles only; NO tries to run, compared with the first party Pokémon's
stored Speed) and `ForcePlayerMonChoice`, whose party menu cannot be
cancelled. A double faint in a trainer battle goes through `DoubleSwitch`,
and a fainted Pokémon gains no share of the experience. When a trainer
sends out the next Pokémon, `CheckWhetherToAskSwitch` and `OfferSwitch` run
Shift mode's offer (the only battle style, since options are not ported);
switching clears both participant sets as the original does.
`NewBattleMonStatus` also clears the enemy's wrap count and Mean Look.
`engine/items/item_effects.asm` and `pack.asm`: `BattlePack` shows the Items
and Balls pockets. `CheckItemContext` gives Oak's "This isn't the time"
text for items with no battle use. `ItemEffects` is read from the ROM:
`RestoreHPEffect` (`ItemRestoreHP`, `HealingHPAmounts`, `RestoreHealth`) and
`StatusHealingEffect` (`UseStatusHealer`, `StatusHealingActions`,
`HealStatus`, `IsItemUsedOnConfusedMon`) run with `UseItem_SelectMon`, the
party menu action texts and `UseDisposableItem`. "Won't have any effect"
and a cancelled menu keep the item and the turn. `wItemEffectSucceeded`
shares `wBattlePlayerAction`'s byte, so a used item takes the turn.
`CalcPlayerStats` reapplies the paralysis and burn penalties to the side
named by `hBattleTurn` after a switch of turns, which can be the enemy.
Because `hBattleTurn` at the battle menu is not modelled everywhere, a
status heal that would reapply a penalty to a paralysed or burned enemy is
refused. Other item effects (X items, Poké Doll, Full Restore, revives,
Ether) are refused.
Tests: local-ROM battles cover switching, cancelling, the withdrawal texts,
forced choices (including a fainted pick and a cancelled forced menu), run
after NO, Shift mode's offer (accepted and declined), a trainer double
faint, Potion, Max Potion, a Berry on a benched Pokémon, no-effect cases,
Antidote and PSNCUREBERRY, Parlyz Heal restoring Speed, Oak's warning and a
refused X Attack. Browser tests switch through the party menu and its
SWITCH/STATS/CANCEL submenu and use a Potion from the Items pocket. The
stats screen is not ported. No emulator comparison of these routines has
been run.
+20 -8
View File
@@ -144,11 +144,23 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Save format 3 gained `intro` and `clock.minuteOffset`, migrated by absence to a finished intro on the device clock.
- Intentional change: the steppers start at the device's current time rather than 10:00, so confirming keeps the device clock.
- Not presented: the palette fades, Wooper's cry, the picture slide during name selection, `ShrinkPlayer` and the Route 30 music (no audio yet).
- Next: player switching and other items in battle; MOVE PKMN W/O MAIL and the stats screen with the party menu; a continuous opening playthrough.
- Next: MOVE PKMN W/O MAIL and the stats screen with the party menu.
## Implemented: evolution data, reference vectors and the opening playthrough
- `PokemonData.evolutions` decodes the evolution list before each learnset: level, item, trade (with or without a held item), happiness by time of day, and Tyrogue's four-byte stat records. All 251 lists match the pinned PRET source. Evolving itself is not ported yet.
- The reference suite also runs the original `CalcMonStats` (300 cases, with and without stat experience, including levels above 100 that reach the 999 cap) and `CalcExpAtLevel` (every growth rate at levels 1–100).
- `tests/playthrough.test.ts` plays a new game from the introduction to the catching tutorial with a seeded RNG: Mom, Elm, the starter and Potion, Route 29, Cherrygrove, Route 30, Mr. Pokémon, Elm's call, the rival battle, the officer, the Egg, the aide's Poké Balls and the Dude's demonstration. Wild battles are fought, or fled below half HP. Each stage ends with a JSON save loaded into a fresh world. It uses no fixtures or hand-set flags, but drives `GameWorld` directly rather than the browser UI.
## Implemented: switching and healing items in battle
- PKMN opens the party menu; SWITCH sends out the chosen Pokémon (STATS is not ported). The texts, the trapped and fainted checks, and the enemy's turn after the switch follow the original. Both Pokémon share the experience.
- After a faint, wild battles ask "Use next POKéMON?" (NO tries to run) and the party menu cannot be cancelled. Trainers' next Pokémon come with Shift mode's offer to switch; a double faint sends out both sides.
- PACK's Items pocket uses HP items (Potion, Berry and the rest of `HealingHPAmounts`) and status healers (Antidote, Parlyz Heal, Awakening, PSNCUREBERRY and the rest of `StatusHealingActions`) on any party Pokémon. Items with no battle use get Oak's warning. X items, Poké Doll, Full Restore and revives are refused.
## 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 and type effectiveness. 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, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching and items other than balls in battle remain unfinished.
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. Other battle items (X items, Poké Doll, Full Restore, revives) remain unfinished.
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.
@@ -209,24 +221,24 @@ 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
- [ ] Decoder for evolutions (not needed before the first evolution)
- [x] Decoder for evolutions (the evolution itself is not ported yet)
- [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
- [x] The Cherrygrove rival battle, Elm's call and the return to Elm
- [x] Capture and the catching tutorial
- [x] Battles: stats, damage, types, accuracy, criticals, status, turn order, fainting, experience, move learning, trainer AI, trainer send-outs and prize money
- [ ] Player switching, and items other than balls, in battle
- [x] Player switching, and HP and status healing items, in battle (X items, Poké Doll, Full Restore and revives are refused)
- [x] The PC and boxes (catching with a full party)
- [x] The introduction's naming and clock screens
- [ ] Controlled-seed comparisons with the original beyond damage and type effectiveness
- [ ] Acceptance: one continuous playthrough of the opening quest with save/reload. The scenes are tested individually from fixtures so far.
- [ ] Controlled-seed comparisons with the original for random-driven routines (accuracy, criticals, damage variation, capture). Damage, types, stats and experience curves are compared.
- [x] Acceptance: one continuous playthrough of the opening quest with save/reload (`tests/playthrough.test.ts`, driven through the world's inputs rather than the browser UI)
Introduce native player, party, Pokémon, inventory, money and quest models. Read species, moves, growth curves, evolutions, encounters, trainers and items through typed ROM decoders. Define serialization/migrations before persisting new systems.
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 and the introduction's naming and clock screens run now. Next: player switching and other items in battle.
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. Next: controlled-seed comparisons of the random-driven battle routines.
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.
@@ -255,7 +267,7 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
- [x] Crystal-style shell with Game, Savegames, Map, Pokédex, Link and Friends tabs
- [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, PACK (Balls pocket) and naming fields
- [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
- [ ] Scalable text, configurable controls and fuller accessibility
- [ ] Save slots with migration, atomic writes and import previews
+54 -1
View File
@@ -1,6 +1,6 @@
# Recovered Claude session
Latest continuation: see **The introduction's naming and clock: 2026-09-25** at the end of
Latest continuation: see **Remaining opening-milestone items: 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
@@ -512,3 +512,56 @@ pushed to `main` as `b13fd20`.
Next (PLAN section 2): player switching and non-ball items in battle; MOVE
PKMN W/O MAIL and the stats screen with the party menu; a continuous opening
playthrough.
## Remaining opening-milestone items: 2026-09-30
The introduction was committed and pushed as `b13fd20` (handoff note
`eb03a20`). Asked to go over what was left and do the quickest items
first, this session took PLAN section 2's open items in that order:
1. Evolution decoder: `PokemonData.evolutions` returns typed level, item,
trade, happiness and stat records (Tyrogue's are four bytes). A
local-ROM test compares all 251 lists with the pinned
`evos_attacks.asm`. Evolving is not ported.
2. Reference vectors: `tests/reference-parity.spec.ts` now also calls the
original `CalcMonStats` (300 cases, including levels above 100 for the
999 cap; the trampoline sets BC, DE and HL) and `CalcExpAtLevel` (all
600 growth/level pairs). A mutation (removing the cap) was confirmed to
fail. Symbol loading and the ROM hand-off are shared helpers now. The
checklist item stays open for random-driven routines (accuracy,
criticals, damage variation, capture).
3. Continuous playthrough: `tests/playthrough.test.ts` plays a new game
from the intro to the catching tutorial through `GameWorld` inputs with
a seeded RNG, reloading a JSON save after every stage. Wild battles are
fought, or fled below half HP (fighting everything whited out on Route
29). The rival battle is lost with this seed (`CANLOSE`), and the story
continues. The stages share one world, so run the whole file.
4. Switching and healing items in battle (details in NATIVE_PORT):
- PKMN/`TryPlayerSwitch`/`PlayerSwitch`/`BattleMonEntrance` with
`SendOutMonText`/`WithdrawMonText`; `AskUseNextPokemon`,
`ForcePlayerMonChoice`, `DoubleSwitch`; Shift mode's
`OfferSwitch`. New `party` prompt (`menu`, `cancel`) answered through
`battleChoose` (a slot or -1), and `{ action: "pkmn" }`.
- Items pocket: `CheckItemContext`, `ItemEffects` dispatch by ROM
address, `RestoreHPEffect` and `StatusHealingEffect` with the ROM's
tables and party menu texts. `PARTYMENUTEXT_*` values are PRET's
($F0-based; `check:reference` caught a wrong value).
- UI: PKMN party list with the SWITCH/STATS/CANCEL submenu (STATS
disabled), and ITEM/BALL POCKET tabs in PACK (opens on Items). The
keyboard test now passes through the enabled PKMN button.
Known limits: `hBattleTurn` at the battle menu is not modelled everywhere,
so a status heal that would make `CalcPlayerStats` reapply a penalty to a
paralysed or burned enemy is refused. `WithdrawMonText` treats the
multiplicand's leftover high byte as zero. X items, Poké Doll, Full
Restore, revives and the stats screen are not ported.
Checks: 324 unit tests with the local ROM (130 passed, 194 skipped
without it); 93 Chromium/WebKit browser tests passed, 19 skipped
intentionally; native and reference builds; reference, release and
vendor checks; `git diff --check`. Not committed.
Next (PLAN section 2): controlled-seed comparisons of random-driven
battle routines. Then PLAN section 3/4 items: the party and bag menus
outside battle (with the stats screen and MOVE PKMN W/O MAIL), evolution
itself, and the remaining battle items.
+1
View File
@@ -188,6 +188,7 @@ const ramNames = {
callerContact: "wCallerContact",
surfingPlayerState: "wSurfingPlayerState",
strengthSpecies: "wStrengthSpecies",
curHPAnimDeltaHP: "wCurHPAnimDeltaHP",
moneyTemp: "hMoneyTemp",
};
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
+403 -36
View File
@@ -108,10 +108,16 @@ export type BattleEffect =
| { kind: "stats"; labels: string[]; values: number[] }
/** The naming screen for a caught Pokémon: answered with the name, or ""
* to keep `current`. */
| { kind: "name"; title: string; current: string; max: number };
| { kind: "name"; title: string; current: string; max: number }
/** The battle party menu: answered with a party index, or -1 to go back
* when `cancel` allows it. With `menu`, the choice goes through the
* SWITCH/STATS/CANCEL submenu (BattleMonMenu) first. */
| { kind: "party"; text: string; menu: boolean; cancel: boolean };
export type BattleAction =
| { action: "fight"; move: number }
| { action: "run" }
/** PKMN: open the party menu to switch. */
| { action: "pkmn" }
/** 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;
@@ -349,6 +355,14 @@ export class Battle {
/** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */
private enemySwitchMonIndex = 0;
private battleHasJustStarted = 0;
/** wCurPartyMon: the party index a menu picked. */
private curPartyMon = 0;
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
lastPlayerMon = 0;
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
private enemyIsSwitching = 0;
/** wEnemyHPAtTimeOfPlayerSwitch, which WithdrawMonText compares with. */
private enemyHPAtSwitch = 0;
/** wPlayerUsedMoves: the player's distinct moves since sending out. */
private playerUsedMoves = [0, 0, 0, 0];
private curDamage = 0;
@@ -464,7 +478,24 @@ export class Battle {
case at("Text_MonGainedExpPoint"):
return this.numbers[RAM.stringBuffer2 + 2] ? BATTLE.BoostedExpPointsText : BATTLE.ExpPointsText;
case at("GoMonText"):
case at("DoItMonText"):
case at("GoForItMonText"):
case at("YourFoesWeakGetmMonText"):
return BATTLE.BattleMonNicknameText;
case at("WithdrawMonText.WithdrawMonText"): {
// The enemy's HP lost since the send-out, as a percentage. The
// multiplicand's high byte is left over from earlier arithmetic;
// it is taken as zero here.
const lost = (this.enemyHPAtSwitch - this.enemy.mon.hp) & 0xffff;
const percent = this.hpPercent(lost);
return !percent
? BATTLE.ThatsEnoughComeBackText
: percent < 30
? BATTLE.ComeBackText
: percent < 70
? BATTLE.OKComeBackText
: BATTLE.GoodComeBackText;
}
case at("Text_1_2_and_Poof"):
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
case at("Text_GotchaMonWasCaught"):
@@ -593,8 +624,15 @@ export class Battle {
if (mon.status & (1 << C.PAR)) mon.stats[C.SPEED] = Math.max(1, mon.stats[C.SPEED] >> 2);
if (mon.status & (1 << C.BRN)) mon.stats[C.ATTACK] = Math.max(1, mon.stats[C.ATTACK] >> 1);
}
/** NewBattleMonStatus (with ResetPlayerStatLevels) or NewEnemyMonStatus
* at the start of a battle. */
private newStatus(side: Side) {
if (side === this.player) this.playerUsedMoves = [0, 0, 0, 0];
if (side === this.player) {
this.playerUsedMoves = [0, 0, 0, 0];
this.enemy.lastCounterMove = 0;
this.enemy.wrapCount = 0;
this.enemy.reset(SUB5, C.SUBSTATUS_CANT_RUN);
}
side.sub = [0, 0, 0, 0, 0, 0];
side.levels = [7, 7, 7, 7, 7, 7, 7];
side.lastMove = 0;
@@ -661,7 +699,7 @@ export class Battle {
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
this.newStatus(this.player);
this.newStatus(this.enemy);
yield* this.say(BATTLE.GoMonText);
yield* this.sendOutMonText();
yield this.hud();
yield* this.battleTurn();
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
@@ -696,6 +734,7 @@ export class Battle {
private *battleTurn(): Gen {
for (let turns = 0; turns < 1000; turns++) {
this.curDamage = 0;
this.enemyIsSwitching = 0;
this.player.justGotFrozen = 0;
this.enemy.justGotFrozen = 0;
this.updateBattleMonInParty();
@@ -817,6 +856,10 @@ export class Battle {
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
return false;
}
if (input.action === "pkmn") {
if (yield* this.battleMenuPKMN()) return false;
continue;
}
if (input.action === "run") {
const escaped = yield* this.tryToRunAway();
if (escaped) {
@@ -832,12 +875,141 @@ export class Battle {
return false;
}
}
/** BattleMenu_Pack with BattlePack's USE on a Balls-pocket slot. Returns
* true if the item was used, which takes the player's turn. */
/** BattleMenu_Pack with BattlePack's USE on a Balls- or Items-pocket
* slot. Returns true if the item was used, which takes the player's turn
* (wItemEffectSucceeded shares wBattlePlayerAction's byte). */
private *battleMenuPack(item: number, slot: number): Gen<boolean> {
const pocket = this.options.bag?.balls;
const data = this.options.data,
kind = data.itemPocket(item);
const pocket = kind === "ball" ? this.options.bag?.balls : kind === "item" ? this.options.bag?.items : undefined;
if (!pocket || !Number.isInteger(slot) || pocket[slot]?.item !== item) return false;
return yield* this.pokeBallEffect(item, slot);
if (kind === "ball") return yield* this.pokeBallEffect(item, slot);
// _DoItemEffect names the item in wStringBuffer1 and wStringBuffer2.
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item);
// CheckItemContext: the low nibble of the item's help byte.
const context = this.reader.byte(SYMBOLS.itemAttributes + (item - 1) * 7 + 6) & 0x0f;
if (context < C.ITEMMENU_CURRENT) {
yield* this.say(BATTLE.OakThisIsntTheTimeText);
return false;
}
// ItemEffects: the routine this item runs.
const effect = this.reader.near(BATTLE.ItemEffects + (item - 1) * 2, this.reader.bank(BATTLE.ItemEffects));
if (context === C.ITEMMENU_PARTY && effect === BATTLE.RestoreHPEffect)
return yield* this.restoreHPEffect(item, slot);
if (context === C.ITEMMENU_PARTY && effect === BATTLE.StatusHealingEffect)
return yield* this.statusHealingEffect(item, slot);
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
}
/** UseItem_SelectMon: the party menu for an item. Returns the party
* index, or -1 when cancelled or an Egg was chosen. */
private *useItemSelectMon(): Gen<number> {
const slot = yield* this.partyMenu(BATTLE.UseOnWhichPKMNString, false, true);
if (slot < 0) return -1;
if (this.options.party[slot].species === C.EGG) {
yield* this.say(BATTLE.ItemCantUseOnEggText);
return -1;
}
return slot;
}
/** ItemRestoreHP (RestoreHPEffect): HealingHPAmounts, up to the maximum.
* The Pokémon out takes the party HP, which UpdateBattleMonInParty
* copied at the start of the turn. */
private *restoreHPEffect(item: number, slot: number): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.options.party[target];
if (!mon.hp || mon.hp >= mon.stats[0]) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
const r = this.reader;
let amount: number | undefined;
for (let p = BATTLE.HealingHPAmounts; r.byte(p) !== 0xff; p += 3)
if (r.byte(p) === item) amount = r.word(p + 1);
if (amount === undefined) throw new UnsupportedBattle("Missing healing amount.");
// RestoreHealth: a 16-bit sum that overflows or passes the maximum is full.
const before = mon.hp,
sum = mon.hp + amount;
mon.hp = sum > 0xffff || sum > mon.stats[0] ? mon.stats[0] : sum;
if (target === this.curBattleMon) this.player.mon.hp = mon.hp;
yield this.hud();
this.numbers[RAM.curHPAnimDeltaHP] = mon.hp - before;
yield* this.partyMenuActionText(target, C.PARTYMENUTEXT_HEAL_HP);
this.tossItem("items", item, slot);
return true;
}
/** StatusHealingEffect and UseStatusHealer, with StatusHealingActions. */
private *statusHealingEffect(item: number, slot: number): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.options.party[target],
r = this.reader;
let p = BATTLE.StatusHealingActions;
while (r.byte(p) !== item) {
if (r.byte(p) === 0xff) throw new UnsupportedBattle("Missing status healing action.");
p += 3;
}
let text = r.byte(p + 1);
const mask = r.byte(p + 2),
battleMon = target === this.curBattleMon;
if (!mon.hp) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
if (!(mon.status & mask)) {
// IsItemUsedOnConfusedMon: a full heal still cures the confusion of
// the Pokémon out.
if (!(battleMon && mask === 0xff && this.player.has(SUB3, C.SUBSTATUS_CONFUSED))) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
text = C.PARTYMENUTEXT_HEAL_CONFUSION;
}
mon.status = 0;
if (battleMon) {
// HealStatus
this.player.mon.status = 0;
this.player.reset(SUB5, C.SUBSTATUS_TOXIC);
this.player.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
if (mask === 0xff) this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
this.calcPlayerStats();
}
yield this.hud();
yield* this.partyMenuActionText(target, text);
this.tossItem("items", item, slot);
return true;
}
/** CalcPlayerStats: stat stages, then the paralysis and burn penalties
* for the side that hBattleTurn names after BattleCommand_SwitchTurn,
* which is the enemy if hBattleTurn held the enemy's turn. */
private calcPlayerStats() {
const player = this.player;
for (let stat = 0; stat < 5; stat++) {
const [numerator, denominator] = this.data.statMultiplier(player.levels[stat]);
const value = Math.floor((player.stats[stat] * numerator) / denominator);
player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
}
const side = this.turn === 0 ? this.player : this.enemy;
// hBattleTurn at the battle menu is not modelled everywhere (AIDamageCalc
// leaves it on the enemy), so a penalty that would hit the enemy again
// is refused rather than guessed.
if (side === this.enemy && this.enemy.mon.status & ((1 << C.PAR) | (1 << C.BRN)))
throw new UnsupportedBattle("Healing status while the enemy is paralyzed or burned is not ported yet.");
this.applyStatusEffects(side);
}
/** PrintPartyMenuActionText with the Pokémon's nickname. */
private *partyMenuActionText(target: number, text: number): Gen {
this.strings[RAM.stringBuffer1] = this.options.party[target].nickname;
const table = BATTLE["PrintPartyMenuActionText.MenuActionTexts"];
yield* this.say(this.reader.near(table + (text & 0x0f) * 2, this.reader.bank(table)));
}
/** UseDisposableItem: toss one from the pocket slot it was used from. */
private tossItem(pocketName: "items" | "balls", item: number, slot: number) {
const pocket = this.options.bag?.[pocketName];
if (!pocket) throw new UnsupportedBattle("Missing pack.");
const index = pocket[slot]?.item === item ? slot : pocket.findIndex((entry) => entry.item === item);
if (index < 0) throw new UnsupportedBattle("The item is not in the pack.");
if (!--pocket[index].quantity) pocket.splice(index, 1);
}
/** PokeBallEffect (engine/items/item_effects.asm). `slot` is the pocket
@@ -853,7 +1025,7 @@ export class Battle {
// UseDisposableItem. The turn is used.
yield* this.say(BATTLE.BallBlockedText);
yield* this.say(BATTLE.BallDontBeAThiefText);
this.tossBall(item, slot);
this.tossItem("balls", item, slot);
return true;
}
// With a full party the Pokémon would go to the current box; if that is
@@ -912,7 +1084,7 @@ export class Battle {
BATTLE.BallSoCloseText,
][wobbles - 1],
);
if (!tutorial) this.tossBall(item, slot);
if (!tutorial) this.tossItem("balls", item, slot);
return true;
}
@@ -946,7 +1118,7 @@ export class Battle {
const name = data.speciesName(species);
if (this.options.party.length >= PARTY_LENGTH) {
yield* this.sendToPC(species, level, name);
this.tossBall(item, slot);
this.tossItem("balls", item, slot);
return true;
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
@@ -972,7 +1144,7 @@ export class Battle {
};
this.options.party.push(mon);
mon.nickname = yield* this.askNickname(name);
this.tossBall(item, slot);
this.tossItem("balls", item, slot);
return true;
}
/** AskGiveNicknameText, then NamingScreen (NAME_MON) if accepted: the
@@ -1026,13 +1198,6 @@ export class Battle {
}
/** TossItem after a throw: RemoveItemFromPocket takes the ball from the
* selected slot if it matches, otherwise from the first matching slot. */
private tossBall(item: number, slot: number) {
const pocket = this.options.bag?.balls;
if (!pocket) throw new UnsupportedBattle("Missing pack.");
let index = pocket[slot]?.item === item ? slot : pocket.findIndex((entry) => entry.item === item);
if (index < 0) throw new UnsupportedBattle("The ball is not in the pack.");
if (!--pocket[index].quantity) pocket.splice(index, 1);
}
/** MoveSelectionScreen's checks for a chosen move. */
private *moveSelection(index: number): Gen<boolean> {
@@ -1077,6 +1242,14 @@ export class Battle {
const player = this.player;
if (this.checkPlayerLockedIn() || player.has(SUB5, C.SUBSTATUS_ENCORED) || player.has(SUB3, C.SUBSTATUS_BIDE))
throw new UnsupportedBattle("Locked-in, encored and Bide moves are not ported yet.");
if (this.battlePlayerAction === C.BATTLEPLAYERACTION_SWITCH) {
// PlayerSwitch already ran ParseEnemyAction.
player.furyCutterCount = 0;
player.protectCount = 0;
player.rageCounter = 0;
player.reset(SUB4, C.SUBSTATUS_RAGE);
return true;
}
if (this.battlePlayerAction === 0) {
this.turn = 0;
this.updateMoveData();
@@ -1089,6 +1262,7 @@ export class Battle {
if (player.move.effect !== C.EFFECT_PROTECT && player.move.effect !== C.EFFECT_ENDURE)
player.protectCount = 0;
} else {
player.reset(SUB3, C.SUBSTATUS_BIDE);
player.furyCutterCount = 0;
player.protectCount = 0;
player.rageCounter = 0;
@@ -1099,6 +1273,7 @@ export class Battle {
}
/** ParseEnemyAction: use the trainer AI selection or a random wild move. */
private parseEnemyAction() {
if (this.enemyIsSwitching) return;
const enemy = this.enemy,
mon = enemy.mon;
if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn())
@@ -1279,8 +1454,9 @@ export class Battle {
this.battleEnded = true;
}
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape. */
private *tryToRunAway(): Gen<boolean> {
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape.
* The Speed compared is the battle Pokémon's, except after a faint. */
private *tryToRunAway(playerSpeed = this.player.mon.stats[C.SPEED]): Gen<boolean> {
if (this.options.trainer) {
yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle);
return false;
@@ -1292,8 +1468,7 @@ export class Battle {
if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_ESCAPE)
throw new UnsupportedBattle("Escape items are not ported yet.");
this.numFleeAttempts = (this.numFleeAttempts + 1) & 0xff;
const playerSpeed = this.player.mon.stats[C.SPEED],
enemySpeed = this.enemy.mon.stats[C.SPEED];
const enemySpeed = this.enemy.mon.stats[C.SPEED];
let escaped = playerSpeed >= enemySpeed;
if (!escaped) {
const divisor = (enemySpeed >> 2) & 0xff;
@@ -2390,36 +2565,61 @@ export class Battle {
this.battleEnded = true;
return;
}
// AskUseNextPokemon and ForcePlayerMonChoice.
if (!this.player.mon.hp) throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
if (!this.player.mon.hp) {
// Trainer battles never ask, so both sides send out a new Pokémon.
yield* this.askUseNextPokemon();
yield* this.forcePlayerMonChoice();
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
return yield* this.doubleSwitch();
}
// HandleEnemySwitch: the player's Pokémon stays in.
yield* this.enemyPartyMonEntrance();
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
yield* this.enemyPartyMonEntrance(false);
this.battlePlayerAction = 0;
}
/** CheckEnemyTrainerDefeated: every party HP is zero. */
private enemyTrainerDefeated() {
return this.options.trainer!.party.every((mon) => mon.hp === 0);
}
/** EnemyPartyMonEntrance after a faint, while the player's Pokémon stands. */
private *enemyPartyMonEntrance(): Gen {
/** EnemyPartyMonEntrance. `setMode` skips Shift mode's offer, as after a
* double faint. */
private *enemyPartyMonEntrance(setMode: boolean): Gen {
this.enemySwitchMonIndex = 0;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; // ResetEnemyStatLevels
this.breakAttraction();
yield* this.enemySwitch();
if (setMode) yield* this.enemySwitchSetMode();
else yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon; // ResetBattleParticipants
this.turn = 1;
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
this.enemy.move.anim = 0;
this.battlePlayerAction = 0;
}
/** EnemySwitch and EnemySwitch_SetMode. The Shift-mode offer to switch
* too (CheckWhetherToAskSwitch) needs player switching and is refused. */
/** EnemySwitch: in Shift mode the player may switch too (OfferSwitch). */
private *enemySwitch(): Gen {
if (!this.battleHasJustStarted && this.options.party.length > 1 && this.partyMon.hp)
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
if (!this.checkWhetherToAskSwitch()) return yield* this.enemySwitchSetMode();
this.loadEnemyMonToSwitchTo();
const switching = yield* this.offerSwitch();
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
if (!switching) return;
this.participants = 0;
this.battlePlayerAction = 0;
this.enemyIsSwitching = 1;
yield* this.playerSwitch();
}
private *enemySwitchSetMode(): Gen {
this.loadEnemyMonToSwitchTo();
this.enemyIsSwitching = 1;
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
}
/** ResetEnemyBattleVars, CheckWhetherSwitchmonIsPredetermined (else the
* type-matchup choice) and LoadEnemyMonToSwitchTo. */
private loadEnemyMonToSwitchTo() {
this.resetEnemyBattleVars();
// CheckWhetherSwitchmonIsPredetermined, else the type-matchup choice.
const index = this.enemySwitchMonIndex
? this.enemySwitchMonIndex - 1
: this.battleHasJustStarted
@@ -2427,11 +2627,172 @@ export class Battle {
: this.findMonInOTPartyToSwitchIntoBattle();
if (!this.options.trainer!.party[index]?.hp)
throw new UnsupportedBattle("Invalid trainer switch.");
// LoadEnemyMonToSwitchTo: LoadEnemyMon records wCurOTMon.
// LoadEnemyMon records wCurOTMon.
this.curOTMon = index;
this.loadEnemyMon();
yield* this.say(BATTLE.BattleText_EnemySentOut);
}
/** CheckWhetherToAskSwitch: Shift mode (the only battle style so far)
* asks when the player has another Pokémon and its own is still up. */
private checkWhetherToAskSwitch() {
return (
!this.battleHasJustStarted &&
this.options.party.length > 1 &&
this.partyMon.hp > 0
);
}
/** OfferSwitch. Returns true if the player chose a Pokémon to send out. */
private *offerSwitch(): Gen<boolean> {
yield* this.say(BATTLE.BattleText_EnemyIsAboutToUseWillPlayerChangeMon);
if (!(yield { kind: "yesno", text: "" })) return false;
const slot = yield* this.pickSwitchMon();
if (slot < 0) return false;
this.lastPlayerMon = this.curBattleMon;
this.curBattleMon = slot;
this.enemy.curMove = 0;
this.player.curMove = 0;
return true;
}
// ------------------------------------------------------------ switching
/** The battle party menu. Returns a party index, or -1 if cancelled;
* without `cancel`, B is refused (ForcePickPartyMonInBattle's SFX_WRONG). */
private *partyMenu(text: number, menu: boolean, cancel: boolean): Gen<number> {
const prompt = readString(this.reader, text, this.context());
for (;;) {
const answer = yield { kind: "party", text: prompt, menu, cancel };
if (typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < this.options.party.length)
return (this.curPartyMon = answer);
if (cancel) return -1;
}
}
/** CheckIfCurPartyMonIsFitToFight, with its texts. */
private *checkFitToFight(slot: number): Gen<boolean> {
const mon = this.options.party[slot];
if (mon.hp) return true;
if (!this.battleHasJustStarted)
yield* this.say(mon.species === C.EGG ? BATTLE.BattleText_AnEGGCantBattle : BATTLE.BattleText_TheresNoWillToBattle);
return false;
}
/** PickSwitchMonInBattle: a fit Pokémon other than the one out, or -1. */
private *pickSwitchMon(): Gen<number> {
for (;;) {
const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, true);
if (slot < 0) return -1;
if (!(yield* this.checkFitToFight(slot))) continue;
if (slot === this.curBattleMon) {
yield* this.say(BATTLE.BattleText_MonIsAlreadyOut);
continue;
}
return slot;
}
}
/** BattleMenu_PKMN with BattleMonMenu's SWITCH, then TryPlayerSwitch.
* Returns true if the player switched, which takes the turn. STATS is
* a presentation screen, and CANCEL returns to the party menu. */
private *battleMenuPKMN(): Gen<boolean> {
for (;;) {
const slot = yield* this.partyMenu(BATTLE.ChooseAMonString, true, true);
if (slot < 0) return false;
if (slot === this.curBattleMon) {
yield* this.say(BATTLE.BattleText_MonIsAlreadyOut);
continue;
}
if (this.player.wrapCount || this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN)) {
yield* this.say(BATTLE.BattleText_MonCantBeRecalled);
continue;
}
if (!(yield* this.checkFitToFight(slot))) continue;
this.lastPlayerMon = this.curBattleMon;
this.battlePlayerAction = C.BATTLEPLAYERACTION_SWITCH;
this.curBattleMon = slot;
yield* this.playerSwitch();
return true;
}
}
/** PlayerSwitch: the enemy chooses its move, then BattleMonEntrance. */
private *playerSwitch(): Gen {
this.parseEnemyAction();
yield* this.say(BATTLE["WithdrawMonText.WithdrawMonText"]);
this.player.reset(SUB4, C.SUBSTATUS_RAGE);
this.turn = 1;
// PursuitSwitch reads the effect of wCurEnemyMove as GetMoveEffect
// does, even for no move (0 reads past the table).
const r = this.reader;
if (r.byte(SYMBOLS.moves + ((this.enemy.curMove - 1) & 0xff) * 7 + 1) === C.EFFECT_PURSUIT)
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
this.curPartyMon = this.curBattleMon;
yield* this.sendOutPlayerMon();
}
/** PlayerPartyMonEntrance: send out wCurPartyMon. */
private *playerPartyMonEntrance(): Gen {
this.lastPlayerMon = this.curBattleMon;
this.curBattleMon = this.curPartyMon;
yield* this.sendOutPlayerMon();
}
/** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and
* PlayerPartyMonEntrance. */
private *sendOutPlayerMon(): Gen {
this.participants |= 1 << this.curBattleMon; // AddBattleParticipant
this.initBattleMon();
yield* this.sendOutMonText();
this.newStatus(this.player); // with ResetPlayerStatLevels
this.breakAttraction();
yield this.hud();
this.turn = 0;
if (this.player.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
}
/** SendOutMonText: the cheer depends on the enemy's remaining HP. */
private *sendOutMonText(): Gen {
const hp = this.enemy.mon.hp;
let text: number = BATTLE.GoMonText;
if (hp) {
this.enemyHPAtSwitch = hp;
const percent = this.hpPercent(hp);
if (percent < 70)
text =
percent >= 40
? BATTLE.DoItMonText
: percent >= 10
? BATTLE.GoForItMonText
: BATTLE.YourFoesWeakGetmMonText;
}
yield* this.say(text);
}
/** HP × 25 / (max HP / 4), low byte, as the send-out and withdraw texts
* compute it. A maximum HP below 4 divides by zero, which freezes the
* original. */
private hpPercent(hp: number) {
const divisor = (this.enemy.mon.maxHp >> 2) & 0xff;
if (!divisor) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
return Math.floor((hp * 25) / divisor) & 0xff;
}
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
* with the first party Pokémon's Speed. Returns true if the player fled. */
private *askUseNextPokemon(): Gen<boolean> {
if (this.battleMode !== C.WILD_BATTLE) return false;
yield* this.say(BATTLE.BattleText_UseNextMon);
if (yield { kind: "yesno", text: "" }) return false;
return yield* this.tryToRunAway(this.options.party[0].stats[3]);
}
/** ForcePlayerMonChoice. Returns false when the enemy fainted too: the
* choice is then sent out by DoubleSwitch. */
private *forcePlayerMonChoice(): Gen<boolean> {
for (;;) {
const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, false);
if (yield* this.checkFitToFight(slot)) break;
}
this.battlePlayerAction = 0;
if (!this.enemy.mon.hp) return false;
yield* this.playerPartyMonEntrance();
return true;
}
/** DoubleSwitch: the player's choice, then the trainer's next Pokémon. */
private *doubleSwitch(): Gen {
yield* this.playerPartyMonEntrance();
yield* this.enemyPartyMonEntrance(true);
this.battlePlayerAction = 0;
}
/** ResetEnemyBattleVars. wEnemyItemState is unused while trainer items are refused. */
private resetEnemyBattleVars() {
@@ -2753,7 +3114,13 @@ export class Battle {
return;
}
}
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
if (yield* this.askUseNextPokemon()) {
this.battleEnded = true;
return;
}
if (yield* this.forcePlayerMonChoice()) return;
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
yield* this.doubleSwitch();
}
private updateFaintedPlayerMon() {
this.participants &= ~(1 << this.curBattleMon);
+6
View File
@@ -236,6 +236,12 @@ export const C = {
// Battle modes, types and results
BATTLEPLAYERACTION_USEITEM: 1,
BATTLEPLAYERACTION_SWITCH: 2,
ITEMMENU_CURRENT: 4,
ITEMMENU_PARTY: 5,
PARTYMENUTEXT_HEAL_HP: 0xf5,
PARTYMENUTEXT_HEAL_CONFUSION: 0xf9,
EGG: 0xfd,
WILD_BATTLE: 1,
TRAINER_BATTLE: 2,
HELD_AMULET_COIN: 76,
+66
View File
@@ -38,6 +38,25 @@ export interface MoveData {
/** Item types, which select a bag pocket (constants/item_data_constants.asm). */
export type Pocket = "item" | "key" | "ball" | "tm";
const POCKETS: readonly Pocket[] = ["item", "key", "ball", "tm"];
/** One entry of a species' evolution list (data/pokemon/evos_attacks.asm).
* A trade's `item` is null for the ROM's -1 (no held item needed). */
export type Evolution =
| { method: "level"; level: number; species: number }
| { method: "item"; item: number; species: number }
| { method: "trade"; item: number | null; species: number }
| {
method: "happiness";
time: "anytime" | "mornday" | "nite";
species: number;
}
| {
method: "stat";
level: number;
attack: "gt" | "lt" | "eq";
species: number;
};
const HAPPINESS_TIMES = ["anytime", "mornday", "nite"] as const;
const STAT_TRIGGERS = ["gt", "lt", "eq"] as const;
/** Decoders for species, moves and items. All data is read from the
* user's ROM on demand and cached in memory only. */
@@ -175,6 +194,53 @@ export class PokemonData {
};
}
/** The evolution list before the learnset, in ROM order (EvolveAfterBattle
* tries entries in this order). EVOLVE_STAT records are four bytes long;
* the others three. */
evolutions(species: number): Evolution[] {
this.checkSpecies(species);
const r = this.reader;
let p = r.near(
SYMBOLS.evosAttacks + (species - 1) * 2,
r.bank(SYMBOLS.evosAttacks),
);
const evolutions: Evolution[] = [];
for (let method = r.byte(p); method !== 0; method = r.byte(p)) {
if (evolutions.length > 8) throw new Error("Invalid evolution data.");
const a = r.byte(p + 1);
let evolution: Evolution;
if (method === 1)
evolution = { method: "level", level: a, species: r.byte(p + 2) };
else if (method === 2)
evolution = { method: "item", item: a, species: r.byte(p + 2) };
else if (method === 3)
evolution = {
method: "trade",
item: a === 0xff ? null : a,
species: r.byte(p + 2),
};
else if (method === 4) {
const time = HAPPINESS_TIMES[a - 1];
if (!time) throw new Error("Invalid evolution data.");
evolution = { method: "happiness", time, species: r.byte(p + 2) };
} else if (method === 5) {
const attack = STAT_TRIGGERS[r.byte(p + 2) - 1];
if (!attack) throw new Error("Invalid evolution data.");
evolution = {
method: "stat",
level: a,
attack,
species: r.byte(p + 3),
};
p++;
} else throw new Error("Invalid evolution data.");
this.checkSpecies(evolution.species);
evolutions.push(evolution);
p += 3;
}
return evolutions;
}
/** Level-up moves (data/pokemon/evos_attacks.asm). Like FillMoves, the
* evolution list is skipped up to its terminating zero byte. */
learnset(species: number): { level: number; move: number }[] {
+27
View File
@@ -157,6 +157,7 @@ export const RAM = {
callerContact: 0xd03f,
surfingPlayerState: 0xd1eb,
strengthSpecies: 0xd1ef,
curHPAnimDeltaHP: 0xd1f3,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
} as const;
@@ -468,6 +469,32 @@ export const BATTLE = {
HasNoPPLeftText: 0x80fba,
HeldStatusHealingEffects: 0x3de44,
HPIsFullText: 0x81534,
DoItMonText: 0x3f2d8,
GoForItMonText: 0x3f2df,
YourFoesWeakGetmMonText: 0x3f2e6,
"WithdrawMonText.WithdrawMonText": 0x3f2fa,
ThatsEnoughComeBackText: 0x3f348,
OKComeBackText: 0x3f34d,
GoodComeBackText: 0x3f352,
ComeBackText: 0x3f35b,
BattleText_UseNextMon: 0x80a83,
BattleText_MonIsAlreadyOut: 0x80c0d,
BattleText_MonCantBeRecalled: 0x80c22,
BattleText_TheresNoWillToBattle: 0x80b0b,
BattleText_AnEGGCantBattle: 0x80b26,
BattleText_EnemyIsAboutToUseWillPlayerChangeMon: 0x80aca,
ChooseAMonString: 0x504e4,
WhichPKMNString: 0x50504,
ItemEffects: 0xe73c,
RestoreHPEffect: 0xf186,
StatusHealingEffect: 0xefcc,
StatusHealingActions: 0xf071,
HealingHPAmounts: 0xf3af,
"PrintPartyMenuActionText.MenuActionTexts": 0x5057b,
ItemWontHaveEffectText: 0xf81f,
ItemCantUseOnEggText: 0xf810,
OakThisIsntTheTimeText: 0x10af3,
UseOnWhichPKMNString: 0x504f3,
HungOnText: 0x80e99,
HurtByBurnText: 0x807f8,
HurtByPoisonText: 0x807e2,
+9 -3
View File
@@ -241,7 +241,7 @@ export interface BattleView {
/** A choice the battle is waiting for, if any. */
prompt?: Extract<
BattleEffect,
{ kind: "action" | "yesno" | "forget" | "stats" | "name" }
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" }
>;
/** Yes/No cursor, as on the original boxes. */
selected: 0 | 1;
@@ -2346,10 +2346,15 @@ export class GameWorld {
return;
this.pumpBattle(name);
}
/** Answer a battle Yes/No (1/0) or the move to forget (0-3, 4 cancels). */
/** Answer a battle Yes/No (1/0), the move to forget (0-3, 4 cancels)
* or a party menu (a party index, or -1 to go back). */
battleChoose(answer: number) {
const prompt = this.battle?.prompt;
if (prompt?.kind === "yesno" || prompt?.kind === "forget")
if (
prompt?.kind === "yesno" ||
prompt?.kind === "forget" ||
prompt?.kind === "party"
)
this.pumpBattle(answer);
}
/** A in battle: continue a prompt message, confirm Yes/No or the stat box. */
@@ -2366,6 +2371,7 @@ export class GameWorld {
const prompt = view.prompt;
if (prompt?.kind === "yesno") return this.pumpBattle(0);
if (prompt?.kind === "forget") return this.pumpBattle(4);
if (prompt?.kind === "party" && prompt.cancel) return this.pumpBattle(-1);
if (prompt?.kind === "stats") return this.pumpBattle();
if (!prompt && view.message?.prompt) this.pumpBattle();
}
+86 -12
View File
@@ -379,8 +379,11 @@ function drawPic(
const battlePics = { foe: "", player: "" };
let battleMenuKey = "";
let showMoves = false;
/** BattlePack's Balls pocket, the only pocket usable in battle so far. */
/** BattlePack, opened on the last pocket used (wLastPocket). */
let showPack = false;
let packPocket: "item" | "ball" = "item";
/** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */
let partyChoice: number | undefined;
let battleCursor = 0;
let battleSelectionKey = "";
@@ -460,6 +463,11 @@ function handleBattleInput(button: Button): boolean {
showMoves = showPack = false;
return true;
}
if (button === "b" && partyChoice !== undefined) {
partyChoice = undefined;
showWorld(world);
return true;
}
const buttons = battleButtons();
if (!buttons.length) return false;
if (button === "a") {
@@ -530,22 +538,38 @@ function showBattle(world: GameWorld) {
}
const prompt = view.prompt;
get("battle-text").textContent =
prompt?.kind === "name" ? prompt.title : (view.message?.text ?? "");
prompt?.kind === "name"
? prompt.title
: prompt?.kind === "party"
? prompt.text
: (view.message?.text ?? "");
if (prompt?.kind !== "action") showMoves = showPack = false;
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}`;
if (prompt?.kind !== "party") partyChoice = undefined;
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`;
if (selectionKey !== battleSelectionKey) {
battleCursor = 0;
battleSelectionKey = selectionKey;
}
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
const balls = world.liveState.bag.balls;
const bag = world.liveState.bag;
// The Pokémon out has its live battle HP; the others their party values.
const party = world.liveState.party.map((mon, index) =>
index === view.engine.curBattleMon && own
? { ...mon, hp: own.hp, status: own.status }
: mon,
);
const key = JSON.stringify([
prompt,
view.selected,
showMoves,
showPack,
partyChoice,
packPocket,
own?.pp,
balls,
bag.items,
bag.balls,
prompt?.kind === "party" &&
party.map(({ hp, status, level }) => [hp, status, level]),
view.message?.prompt,
]);
if (key === battleMenuKey) return;
@@ -610,7 +634,19 @@ function showBattle(world: GameWorld) {
),
);
} else if (showPack) {
balls.forEach(({ item, quantity }, slot) =>
for (const pocket of ["item", "ball"] as const)
buttons.push(
button(
data.pocketName(pocket),
() => {
packPocket = pocket;
showWorld(world);
},
{ pressed: packPocket === pocket },
),
);
const slots = packPocket === "item" ? bag.items : bag.balls;
slots.forEach(({ item, quantity }, slot) =>
buttons.push(
button(
data.itemName(item),
@@ -622,10 +658,11 @@ function showBattle(world: GameWorld) {
),
),
);
if (!balls.length)
if (!slots.length)
buttons.push(
button("No BALLS", () => {}, {
disabled: "The BALL pocket is empty.",
button("Empty", () => {}, {
disabled: `The ${data.pocketName(packPocket)} is empty.`,
wide: true,
}),
);
buttons.push(
@@ -644,9 +681,7 @@ function showBattle(world: GameWorld) {
showMoves = true;
showWorld(world);
}),
button("PKMN", () => {}, {
disabled: "Switching Pokémon is not ported yet.",
}),
button("PKMN", () => runtime.battleAction({ action: "pkmn" })),
button("PACK", () => {
showPack = true;
showWorld(world);
@@ -663,6 +698,45 @@ function showBattle(world: GameWorld) {
pressed: view.selected === 0,
}),
);
} else if (prompt?.kind === "party" && partyChoice !== undefined) {
// BattleMonMenu. STATS opens the stats screen, which is not ported.
const slot = partyChoice;
buttons.push(
button("SWITCH", () => {
partyChoice = undefined;
runtime.battleChoose(slot);
}),
button("STATS", () => {}, {
disabled: "The stats screen is not ported yet.",
}),
button("CANCEL", () => {
partyChoice = undefined;
showWorld(world);
}),
);
} else if (prompt?.kind === "party") {
party.forEach((mon, slot) =>
buttons.push(
button(
mon.nickname,
() => {
if (!prompt.menu) return runtime.battleChoose(slot);
partyChoice = slot;
showWorld(world);
},
{
detail: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${
mon.status ? ` ${statusName(mon.status)}` : ""
}`,
wide: true,
},
),
),
);
if (prompt.cancel)
buttons.push(
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
);
} else if (prompt?.kind === "forget") {
prompt.moves.forEach((move, index) =>
buttons.push(
+1 -1
View File
@@ -73,5 +73,5 @@ export function renderShell(root: HTMLElement) {
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
</div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls from the battle PACK. The Pokémon Center PC stores Pokémon and items. Party and bag menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls and heal with Potions from the battle PACK, and switch Pokémon with PKMN. The Pokémon Center PC stores Pokémon and items. Party and bag menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
}
+64 -1
View File
@@ -58,7 +58,7 @@ async function finish(page: Page, action: "fight" | "run" = "fight") {
if (action === "run") await menu.getByRole("button", { name: "RUN", exact: true }).click();
else {
await fight.click();
await menu.getByRole("button", { name: /TACKLE|TRANSFORM/ }).click();
await menu.getByRole("button", { name: /TACKLE|TRANSFORM|SCRATCH/ }).click();
}
} else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) {
await menu.getByRole("button", { name: "YES", exact: true }).click();
@@ -115,6 +115,8 @@ test("keyboard and touch D-pad navigate battle moves, cancel and run", async ({
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible();
// A real pointer press on the on-screen D-pad (synthetic events cannot be captured).
await page.locator('[data-game-button="down"]').click();
await expect(menu.getByRole("button", { name: "PKMN", exact: true })).toHaveClass(/battle-selected/);
await page.keyboard.press("ArrowDown");
await expect(menu.getByRole("button", { name: "PACK", exact: true })).toHaveClass(/battle-selected/);
await page.keyboard.press("ArrowDown");
await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/);
@@ -178,6 +180,7 @@ test("battle PACK throws Poké Balls, names the catch and saves it", async ({ pa
named = true;
} else if (await pack.count()) {
await pack.click();
await menu.getByRole("button", { name: "BALL POCKET", exact: true }).click();
const ball = menu.getByRole("button", { name: /POKé BALL/ });
await expect(ball).toContainText("×");
await ball.click();
@@ -206,7 +209,67 @@ test("battle PACK closes with B and returns to the command menu", async ({ page
await page.clock.runFor(1200);
}
await menu.getByRole("button", { name: "PACK", exact: true }).click();
// BattlePack opens on the Items pocket first.
await expect(menu.getByRole("button", { name: "ITEM POCKET", exact: true })).toHaveAttribute("aria-pressed", "true");
await menu.getByRole("button", { name: "BALL POCKET", exact: true }).click();
await expect(menu.getByRole("button", { name: /POKé BALL/ })).toBeVisible();
await page.keyboard.press("KeyZ");
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible();
});
test("battle PKMN switches through the party menu and its submenu, then shares the experience", async ({ page }) => {
await start(page, "switch");
const menu = page.locator("#battle-menu");
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PKMN", exact: true }).count()); tick++) {
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
await page.clock.runFor(1200);
}
await menu.getByRole("button", { name: "PKMN", exact: true }).click();
await expect(page.locator("#battle-text")).toHaveText("Choose a POKéMON.");
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
// BattleMonMenu: B returns to the party list.
await expect(menu.getByRole("button", { name: "STATS", exact: true })).toBeDisabled();
await page.keyboard.press("KeyZ");
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
await menu.getByRole("button", { name: "SWITCH", exact: true }).click();
// The withdrawal and send-out texts continue on their own.
const shown: string[] = [];
for (let tick = 0; tick < 20 && (await page.locator("#battle-player-name").innerText()) !== "TOTODILE"; tick++) {
shown.push(await page.locator("#battle-text").innerText());
await page.clock.runFor(300);
}
await expect(page.locator("#battle-player-name")).toHaveText("TOTODILE");
expect(shown.join("\n").replace(/\n/g, " ")).toContain("CYNDAQUIL, that's enough! Come back!");
const messages = await finish(page);
// Pidgey's 23 experience points are halved for the two participants.
expect(messages.replace(/\n/g, " ")).toContain("CYNDAQUIL gained 11 EXP. Points!");
expect(messages.replace(/\n/g, " ")).toContain("TOTODILE gained 11 EXP. Points!");
const { state } = await exportState(page);
expect(state.party.map((mon: { species: number }) => mon.species)).toEqual([155, 158]);
expect(state.party[1].exp).toBe(135 + 11);
});
test("battle PACK uses a Potion from the Items pocket on the Pokémon out", async ({ page }) => {
await start(page, "potion");
const menu = page.locator("#battle-menu");
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PACK", exact: true }).count()); tick++) {
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
await page.clock.runFor(1200);
}
await menu.getByRole("button", { name: "PACK", exact: true }).click();
await menu.getByRole("button", { name: /^POTION/ }).click();
await expect(page.locator("#battle-text")).toHaveText("Use on which POKéMON?");
await menu.getByRole("button", { name: /^CYNDAQUIL/ }).click();
const shown: string[] = [];
for (let tick = 0; tick < 20 && !shown.some((text) => text.includes("recovered")); tick++) {
shown.push((await page.locator("#battle-text").innerText()).replace(/\n/g, " "));
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
else await page.clock.runFor(300);
}
// From 3 HP, a Potion's 20 stops at the level 5 maximum of 19.
expect(shown).toContain("CYNDAQUIL recovered 16HP!");
await finish(page, "run");
const { state } = await exportState(page);
expect(state.bag.items).toEqual([]);
});
+103
View File
@@ -0,0 +1,103 @@
import type { CrystalAssets } from "../src/game/rom/assets";
import type { TrainerData } from "../src/game/rom/trainers";
import type {
Battle,
BattleEffect,
BattleInput,
BattleOptions,
} from "../src/game/battle/battle";
import type { Bag } from "../src/game/party/bag";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { defaultTextContext } from "../src/game/scripts/text";
export const partyMon = (
assets: CrystalAssets,
species: number,
level: number,
): PartyMon =>
generatePartyMon(assets.pokemon, {
species,
level,
item: 0,
otId: 1234,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
/** Battle options for a wild Pokémon or a ROM trainer, with a seeded
* stand-in for the hardware Random routine. */
export function battleOptions(
assets: CrystalAssets,
trainers: TrainerData,
party: PartyMon[],
enemy:
| { wild: { species: number; level: number } }
| { trainer: [number, number] },
{ seed = 1, bag }: { seed?: number; bag?: Bag } = {},
): BattleOptions {
const base = {
data: assets.pokemon,
party,
bag,
playerId: 1234,
playerName: "CHRIS",
timeOfDay: 1,
pokedex: { seen: new Set<number>(), caught: new Set<number>() },
random: () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
text: () => defaultTextContext("CHRIS"),
};
if ("wild" in enemy) return { ...base, wild: enemy.wild };
const trainer = trainers.trainer(...enemy.trainer);
return {
...base,
trainer: {
name: trainer.name,
className: trainer.className,
party: trainers.createParty(trainer),
aiFlags: trainer.attributes.aiMoveFlags,
switchFlags: trainer.attributes.aiItemSwitchFlags,
reward: trainer.reward,
},
};
}
/** Run a battle. Menus take queued answers per prompt kind, then the
* defaults: the first move, YES, cancelling a move to forget and the
* first party slot. */
export function playBattle(
battle: Battle,
queued: Partial<Record<BattleEffect["kind"], BattleInput[]>> = {},
) {
const texts: string[] = [],
prompts: BattleEffect[] = [];
const run = battle.run();
let input: BattleInput;
for (let step = 0; step < 3000; step++) {
const next = run.next(input);
if (next.done) return { texts, prompts, result: next.value.result };
const effect = next.value;
input = undefined;
if (effect.kind === "text") texts.push(effect.text.replace(/\n/g, " "));
else if (effect.kind !== "hud") {
prompts.push(effect);
const answer = queued[effect.kind]?.shift();
input =
answer !== undefined
? answer
: effect.kind === "action"
? { action: "fight", move: 0 }
: effect.kind === "yesno"
? 1
: effect.kind === "forget"
? 4
: effect.kind === "party"
? 0
: undefined;
}
}
throw new Error("Battle did not end.");
}
+10 -1
View File
@@ -2,7 +2,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world";
export type BattleScenario = "win" | "loss" | "unsupported" | "catch";
export type BattleScenario = "win" | "loss" | "unsupported" | "catch" | "switch" | "potion";
/** Test-only encounters on an existing floor: production maps stay gated.
* Everything after selection runs through the ROM's real wild-battle script. */
@@ -28,7 +28,16 @@ export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenar
mon.pp = [10, 0, 0, 0];
}
world.state.party.push(mon);
if (scenario === "switch")
world.state.party.push(generatePartyMon(assets.pokemon, {
species: 158, level: 5, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
}));
if (scenario === "catch") world.state.bag.balls = [{ item: 5, quantity: 10 }]; // POKé BALL
if (scenario === "potion") {
mon.hp = 3;
world.state.bag.items = [{ item: 18, quantity: 1 }]; // POTION
}
world.state.pokedex.seen.add(155);
world.state.pokedex.caught.add(155);
world.state.money = 3001;
+208
View File
@@ -0,0 +1,208 @@
import { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { TrainerData } from "../src/game/rom/trainers";
import { Battle, UnsupportedBattle } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import type { Bag } from "../src/game/party/bag";
import type { PartyMon } from "../src/game/party/pokemon";
import { battleOptions, partyMon, playBattle } from "./battle-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const PIDGEY = 16,
CYNDAQUIL = 155,
TOTODILE = 158;
const BRIGHTPOWDER = 0x03,
ANTIDOTE = 0x09,
PARLYZ_HEAL = 0x0d,
MAX_POTION = 0x0f,
POTION = 0x12,
X_ATTACK = 0x31,
PSNCUREBERRY = 0x4a,
BERRY = 0xad;
const PSN = 1 << 3,
PAR = 1 << 6;
describe.skipIf(!existsSync(romPath))("healing items in battle", () => {
let assets: CrystalAssets, trainers: TrainerData;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
trainers = new TrainerData(assets.reader);
});
const setup = (party: PartyMon[], items: Bag["items"], level = 3) => {
const bag: Bag = { items, balls: [], keyItems: [] };
const battle = new Battle(
battleOptions(
assets,
trainers,
party,
{ wild: { species: PIDGEY, level } },
{ bag },
),
);
return { battle, bag };
};
const use = (item: number, slot = 0) => ({
action: "item" as const,
item,
slot,
});
it("restores HP with a Potion, takes the turn and uses up the Potion", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.hp = 5;
const { battle, bag } = setup([mon], [{ item: POTION, quantity: 2 }]);
const { texts, prompts } = playBattle(battle, { action: [use(POTION)] });
expect(prompts[1]).toEqual({
kind: "party",
text: "Use on which POKéMON?",
menu: false,
cancel: true,
});
const healed = texts.indexOf("CYNDAQUIL recovered 20HP!");
expect(healed).toBeGreaterThan(0);
// The item takes the turn: the wild Pokémon moves next.
expect(texts[healed + 1]).toMatch(/^Enemy PIDGEY used /);
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
});
it("stops at the maximum HP and reports only the HP actually restored", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.hp = mon.stats[0] - 3;
const { battle, bag } = setup([mon], [{ item: MAX_POTION, quantity: 1 }]);
const { texts } = playBattle(battle, { action: [use(MAX_POTION)] });
expect(texts).toContain("CYNDAQUIL recovered 3HP!");
expect(bag.items).toEqual([]);
});
it("heals a Pokémon that is not out without changing the one in battle", () => {
const out = partyMon(assets, CYNDAQUIL, 10),
resting = partyMon(assets, TOTODILE, 10);
resting.hp = 1;
const { battle } = setup([out, resting], [{ item: BERRY, quantity: 1 }]);
const { texts } = playBattle(battle, { action: [use(BERRY)], party: [1] });
expect(texts).toContain("TOTODILE recovered 10HP!");
expect(resting.hp).toBe(11);
expect(battle.curBattleMon).toBe(0);
});
it("does nothing at full HP or on a fainted Pokémon, and keeps the turn", () => {
const out = partyMon(assets, CYNDAQUIL, 10),
fainted = partyMon(assets, TOTODILE, 10);
fainted.hp = 0;
const { battle, bag } = setup(
[out, fainted],
[{ item: POTION, quantity: 1 }],
);
const { texts, prompts } = playBattle(battle, {
action: [use(POTION), use(POTION)],
party: [0, 1],
});
expect(
texts.filter((text) => text === "It won't have any effect."),
).toHaveLength(2);
expect(prompts.slice(0, 5).map((prompt) => prompt.kind)).toEqual([
"action",
"party",
"action",
"party",
"action",
]);
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
});
it("returns to the battle menu when the party menu is cancelled", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.hp = 5;
const { battle, bag } = setup([mon], [{ item: POTION, quantity: 1 }]);
const { prompts } = playBattle(battle, {
action: [use(POTION)],
party: [-1],
});
expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([
"action",
"party",
"action",
]);
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
});
it("cures poison with an Antidote or a PSNCUREBERRY, and only poison", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.status = PSN;
const { battle, bag } = setup(
[mon],
[
{ item: PARLYZ_HEAL, quantity: 1 },
{ item: ANTIDOTE, quantity: 1 },
{ item: PSNCUREBERRY, quantity: 1 },
],
);
const { texts } = playBattle(battle, {
action: [use(PARLYZ_HEAL, 0), use(ANTIDOTE, 1)],
});
expect(texts[texts.indexOf("It won't have any effect.") + 1]).toBe(
"CYNDAQUIL's cured of poison.",
);
expect(mon.status).toBe(0);
expect(bag.items.map(({ item }) => item)).toEqual([
PARLYZ_HEAL,
PSNCUREBERRY,
]);
const berry = partyMon(assets, CYNDAQUIL, 10);
berry.status = PSN;
const second = setup([berry], [{ item: PSNCUREBERRY, quantity: 1 }]);
expect(
playBattle(second.battle, { action: [use(PSNCUREBERRY)] }).texts,
).toContain("CYNDAQUIL's cured of poison.");
expect(second.bag.items).toEqual([]);
});
it("restores the paralysed Pokémon's Speed with a Parlyz Heal", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.status = PAR;
const { battle } = setup([mon], [{ item: PARLYZ_HEAL, quantity: 1 }]);
const speeds: number[] = [];
const run = battle.run();
let input: Parameters<typeof run.next>[0];
for (let step = 0, actions = 0; step < 500; step++) {
const next = run.next(input);
if (next.done) break;
input = undefined;
const effect = next.value;
if (effect.kind === "action") {
speeds.push(battle.player.mon.stats[C.SPEED]);
input = actions++ === 0 ? use(PARLYZ_HEAL) : { action: "run" };
} else if (effect.kind === "party") input = 0;
if (actions > 1 && effect.kind === "action") break;
}
expect(speeds[0]).toBe(Math.max(1, mon.stats[3] >> 2));
expect(speeds[1]).toBe(mon.stats[3]);
expect(battle.player.mon.status).toBe(0);
});
it("answers an item with no battle use with Oak's warning", () => {
const { battle, bag } = setup(
[partyMon(assets, CYNDAQUIL, 10)],
[{ item: BRIGHTPOWDER, quantity: 1 }],
);
const { texts } = playBattle(battle, { action: [use(BRIGHTPOWDER)] });
expect(texts[texts.indexOf("Go! CYNDAQUIL!") + 1]).toBe(
"OAK: CHRIS! This isn't the time to use that!",
);
expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]);
});
it("refuses items whose battle effects are not ported", () => {
const { battle } = setup(
[partyMon(assets, CYNDAQUIL, 10)],
[{ item: X_ATTACK, quantity: 1 }],
);
expect(() => playBattle(battle, { action: [use(X_ATTACK)] })).toThrow(
UnsupportedBattle,
);
});
});
+222
View File
@@ -0,0 +1,222 @@
import { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { TrainerData } from "../src/game/rom/trainers";
import { Battle } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import type { PartyMon } from "../src/game/party/pokemon";
import { battleOptions, partyMon, playBattle } from "./battle-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const PIDGEY = 16,
RATTATA = 19,
CYNDAQUIL = 155,
TOTODILE = 158;
describe.skipIf(!existsSync(romPath))("switching Pokémon in battle", () => {
let assets: CrystalAssets, trainers: TrainerData;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
trainers = new TrainerData(assets.reader);
});
const mon = (species: number, level: number) =>
partyMon(assets, species, level);
const options = (
party: PartyMon[],
enemy: Parameters<typeof battleOptions>[3],
seed = 1,
) => battleOptions(assets, trainers, party, enemy, { seed });
const play = playBattle;
it("switches from the PKMN menu: the enemy still attacks and both share the experience", () => {
const first = mon(CYNDAQUIL, 10),
second = mon(TOTODILE, 10);
const battle = new Battle(
options([first, second], { wild: { species: PIDGEY, level: 3 } }),
);
const { texts, prompts } = play(battle, {
action: [{ action: "pkmn" }],
party: [0, 1],
});
expect(prompts[1]).toEqual({
kind: "party",
text: "Choose a POKéMON.",
menu: true,
cancel: true,
});
const switched = texts.indexOf("CYNDAQUIL is already out.");
expect(switched).toBeGreaterThan(0);
// The enemy's HP is untouched, so the withdrawal is "that's enough".
expect(texts.slice(switched + 1, switched + 4)).toEqual([
"CYNDAQUIL, that's enough! Come back!",
"Go! TOTODILE!",
expect.stringMatching(/^Enemy PIDGEY used /),
]);
expect(battle.curBattleMon).toBe(1);
expect(battle.lastPlayerMon).toBe(0);
// Base experience 55 × level 3 ÷ 7 = 23, halved for two participants.
expect(texts).toContain("CYNDAQUIL gained 11 EXP. Points!");
expect(texts).toContain("TOTODILE gained 11 EXP. Points!");
});
it("goes back to the battle menu when the party menu is cancelled", () => {
const battle = new Battle(
options([mon(CYNDAQUIL, 10), mon(TOTODILE, 10)], {
wild: { species: PIDGEY, level: 3 },
}),
);
const { prompts, texts } = play(battle, {
action: [{ action: "pkmn" }],
party: [-1],
});
expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([
"action",
"party",
"action",
]);
expect(texts).toContain("CYNDAQUIL gained 23 EXP. Points!");
expect(battle.curBattleMon).toBe(0);
});
it("measures the withdrawal against the enemy's HP when the Pokémon went out", () => {
const battle = new Battle(
options(
[mon(CYNDAQUIL, 12), mon(TOTODILE, 10)],
{ wild: { species: RATTATA, level: 12 } },
3,
),
);
// Tackle once, then switch: the lost HP picks the text.
const { texts } = play(battle, {
action: [{ action: "fight", move: 0 }, { action: "pkmn" }],
party: [1],
});
const withdrawal = texts.find((text) => text.startsWith("CYNDAQUIL, "))!;
expect([
"CYNDAQUIL, come back!",
"CYNDAQUIL, OK! Come back!",
"CYNDAQUIL, good! Come back!",
]).toContain(withdrawal);
});
it("asks for the next Pokémon after a faint and refuses a fainted choice", () => {
const first = mon(CYNDAQUIL, 5),
second = mon(TOTODILE, 10),
fainted = mon(PIDGEY, 5);
first.hp = 1;
fainted.hp = 0;
const battle = new Battle(
options(
[first, fainted, second],
{ wild: { species: RATTATA, level: 8 } },
5,
),
);
const { texts, prompts } = play(battle, { party: [1, 2] });
expect(texts).toContain("CYNDAQUIL fainted!");
expect(texts).toContain("Use next POKéMON?");
const forced = prompts.find((prompt) => prompt.kind === "party");
expect(forced).toEqual({
kind: "party",
text: "Which POKéMON?",
menu: false,
cancel: false,
});
expect(texts).toContain("There's no will to battle!");
const sentOut = texts.findIndex((text) => text.endsWith("TOTODILE!"));
expect(texts[sentOut]).toMatch(
/^(Go!|Do it!|Go for it,|Your foe's weak! Get'm,) TOTODILE!$/,
);
expect(battle.curBattleMon).toBe(2);
// The fainted Pokémon did not share the experience.
expect(first.exp).toBe(assets.pokemon.expAtLevel(3, 5));
});
it("keeps asking for a Pokémon when the forced party menu is cancelled", () => {
const first = mon(CYNDAQUIL, 5);
first.hp = 1;
const battle = new Battle(
options(
[first, mon(TOTODILE, 10)],
{ wild: { species: RATTATA, level: 8 } },
5,
),
);
const { prompts } = play(battle, { party: [-1, -1, 1] });
expect(prompts.filter((prompt) => prompt.kind === "party")).toHaveLength(3);
expect(battle.curBattleMon).toBe(1);
});
it("runs after declining the next Pokémon, with the first party Pokémon's Speed", () => {
const first = mon(CYNDAQUIL, 5);
first.hp = 1;
const battle = new Battle(
options(
[first, mon(TOTODILE, 10)],
{ wild: { species: RATTATA, level: 8 } },
5,
),
);
// The party's stored Speed, not the fainted battle Pokémon's, decides.
first.stats[3] = 999;
const { texts, result } = play(battle, { yesno: [0] });
expect(texts.slice(-2)).toEqual(["Use next POKéMON?", "Got away safely!"]);
expect(result).toBe(C.DRAW);
});
it("offers a switch in Shift mode when a trainer sends out the next Pokémon", () => {
// Youngster Mikey: Pidgey, then Rattata.
const first = mon(CYNDAQUIL, 15),
second = mon(TOTODILE, 15);
const battle = new Battle(options([first, second], { trainer: [22, 2] }));
const { texts } = play(battle, { yesno: [1], party: [1] });
const offer = texts.indexOf("YOUNGSTER MIKEY is about to use RATTATA.");
expect(offer).toBeGreaterThan(0);
expect(texts.slice(offer + 1, offer + 5)).toEqual([
"Will CHRIS change POKéMON?",
"YOUNGSTER MIKEY sent out RATTATA!",
expect.stringMatching(/^CYNDAQUIL, /),
"Go! TOTODILE!",
]);
expect(texts).toContain("YOUNGSTER MIKEY was defeated!");
});
it("keeps the Pokémon in when the offer is declined", () => {
const battle = new Battle(
options([mon(CYNDAQUIL, 15), mon(TOTODILE, 15)], { trainer: [22, 2] }),
);
const { texts } = play(battle, { yesno: [0] });
const offer = texts.findIndex((text) => text.includes("Will CHRIS change"));
expect(texts[offer + 1]).toBe("YOUNGSTER MIKEY sent out RATTATA!");
expect(texts.some((text) => text.startsWith("CYNDAQUIL, "))).toBe(false);
expect(battle.curBattleMon).toBe(0);
});
it("sends out both sides without an offer after a double faint", () => {
// Struggle's recoil faints the attacker as the trainer's Pidgey faints.
const first = mon(CYNDAQUIL, 15);
first.hp = 1;
first.pp = [0, 0, 0, 0];
const battle = new Battle(
options([first, mon(TOTODILE, 15)], { trainer: [22, 2] }),
);
const { texts, prompts } = play(battle, { party: [1] });
const fainted = texts.indexOf("Enemy PIDGEY fainted!");
expect(fainted).toBeGreaterThan(0);
// The fainted Pokémon is no longer a participant, so nobody gains
// experience for the Pidgey.
expect(texts.slice(fainted, fainted + 4)).toEqual([
"Enemy PIDGEY fainted!",
"CYNDAQUIL fainted!",
"Go! TOTODILE!",
"YOUNGSTER MIKEY sent out RATTATA!",
]);
expect(texts.some((text) => text.includes("change POKéMON"))).toBe(false);
expect(prompts.find((prompt) => prompt.kind === "party")).toMatchObject({
cancel: false,
});
expect(texts).toContain("YOUNGSTER MIKEY was defeated!");
});
});
+103
View File
@@ -12,6 +12,7 @@ import {
runScript,
} from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Evolution } from "../src/game/rom/pokemon";
import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo } from "./world-driver";
@@ -613,6 +614,108 @@ describe.skipIf(!existsSync(romPath))(
);
},
);
it("decodes evolution lists, including stat and happiness triggers", () => {
const data = load().pokemon;
expect(data.evolutions(155)).toEqual([
{ method: "level", level: 14, species: 156 },
]);
expect(data.evolutions(133).map((e) => e.method)).toEqual([
"item",
"item",
"item",
"happiness",
"happiness",
]);
expect(data.evolutions(133)[4]).toEqual({
method: "happiness",
time: "nite",
species: 197,
});
expect(data.evolutions(61)).toContainEqual({
method: "trade",
item: 82, // KING'S ROCK
species: 186,
});
expect(data.evolutions(64)).toEqual([
{ method: "trade", item: null, species: 65 },
]);
expect(data.evolutions(236)).toEqual([
{ method: "stat", level: 20, attack: "lt", species: 107 },
{ method: "stat", level: 20, attack: "gt", species: 106 },
{ method: "stat", level: 20, attack: "eq", species: 237 },
]);
expect(data.evolutions(251)).toEqual([]);
// The four-byte stat records must not shift the learnset.
expect(data.learnset(236)[0]).toEqual({ level: 1, move: 33 });
});
it.skipIf(
!existsSync(".reference/pokecrystal/data/pokemon/evos_attacks.asm"),
)("matches every evolution list against the pinned PRET source", () => {
const data = load().pokemon;
const root = ".reference/pokecrystal";
const constants = (file: string, first: number) => {
const ids = new Map<string, number>();
let value = first;
for (const line of readFileSync(`${root}/${file}`, "utf8").split(
"\n",
)) {
if (/^DEF NUM_/.test(line)) break;
const name = /^\s*const (\w+)/.exec(line)?.[1];
if (name) ids.set(name, value++);
}
return ids;
};
const species = constants("constants/pokemon_constants.asm", 1),
items = constants("constants/item_constants.asm", 0);
const id = (map: Map<string, number>, name: string) => {
const value = map.get(name);
if (value === undefined) throw new Error(`Unknown ${name}`);
return value;
};
const lists: Evolution[][] = [];
let current: Evolution[] | null = null;
for (const line of readFileSync(
`${root}/data/pokemon/evos_attacks.asm`,
"utf8",
).split("\n")) {
if (/^\w+EvosAttacks:/.test(line)) lists.push((current = []));
const args = /^\s*db (EVOLVE_\w+), (.+)$/.exec(line);
if (!args || !current) {
if (/^\s*db 0 ; no more evolutions/.test(line)) current = null;
continue;
}
const [a, b, c] = args[2].split(",").map((s) => s.trim());
if (args[1] === "EVOLVE_LEVEL")
current.push({ method: "level", level: +a, species: id(species, b) });
else if (args[1] === "EVOLVE_ITEM")
current.push({
method: "item",
item: id(items, a),
species: id(species, b),
});
else if (args[1] === "EVOLVE_TRADE")
current.push({
method: "trade",
item: a === "-1" ? null : id(items, a),
species: id(species, b),
});
else if (args[1] === "EVOLVE_HAPPINESS")
current.push({
method: "happiness",
time: a.slice(3).toLowerCase() as "anytime",
species: id(species, b),
});
else
current.push({
method: "stat",
level: +a,
attack: b.slice(4, 6).toLowerCase() as "gt",
species: id(species, c),
});
}
expect(lists).toHaveLength(251);
lists.forEach((list, i) => expect(data.evolutions(i + 1)).toEqual(list));
});
it("gives the chosen starter and the aide's Potion, then saves them", () => {
let seed = 7;
const random = () => (seed = (seed * 73 + 41) & 255);
+321
View File
@@ -0,0 +1,321 @@
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { C } from "../src/game/battle/constants";
import { findPath } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK = "24:4",
ELMS_LAB = "24:5",
ROUTE_29 = "24:3",
CHERRYGROVE = "26:3",
ROUTE_30 = "26:1",
MR_POKEMONS_HOUSE = "26:10";
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
* without fixtures or story flags set by hand. Every stage ends with a
* save serialized to JSON and loaded into a fresh world. The stages share
* one world and run in order, so run the file rather than a single stage. */
describe.skipIf(!existsSync(romPath))(
"a continuous opening playthrough",
() => {
const bytes = existsSync(romPath)
? new Uint8Array(readFileSync(romPath))
: new Uint8Array();
const now = () => new Date("2026-09-22T12:00:00");
let seed = 0x2a;
const random = () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
};
const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined;
let world: GameWorld;
const log: string[] = [];
/** Run scripts and battles until the player is free again. Battles use
* the first move with PP; a wild battle below half HP is run from, as
* a cautious player would. New moves are learned when there is room,
* and other questions take the queued answers or their defaults. */
const play = (answers: (number | string)[] = []) => {
for (let tick = 0; tick < 100_000; tick++) {
expect(world.notice, world.diagnostic).toBe("");
const battle = world.battle;
if (battle) {
if (battle.message && log.at(-1) !== battle.message.text)
log.push(battle.message.text);
const prompt = battle.prompt;
if (prompt?.kind === "action") {
const { engine } = battle,
mon = engine.player.mon;
if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
world.battleAction({ action: "run" });
else
world.battleAction({
action: "fight",
move: Math.max(
0,
mon.pp.findIndex((pp, i) => mon.moves[i] && pp & 63),
),
});
} else if (prompt?.kind === "yesno") world.battleChoose(1);
else if (prompt?.kind === "forget") world.battleChoose(4);
else if (prompt?.kind === "name") world.battleName("");
else if (prompt?.kind === "stats" || battle.message?.prompt)
world.interact();
else world.tick();
continue;
}
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1;
if (log.at(-1) !== page) log.push(page);
if (last && dialogue.choice) {
const answer = answers.shift();
if (dialogue.choice.kind === "name")
world.choose(typeof answer === "string" ? answer : "");
else
world.choose(
typeof answer === "number"
? answer
: dialogue.choice.kind === "yesno"
? 1
: dialogue.choice.kind === "clock"
? dialogue.choice.selected
: 0,
);
} else if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
continue;
}
if (!world.busy) {
world.tick(); // scene scripts and phone calls start on idle ticks
if (!world.busy) return;
continue;
}
world.tick();
}
throw new Error(`The world did not settle: ${world.diagnostic}`);
};
const STEP: Record<Direction, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
/** Walk to a tile on the current map, replanning after ledges, roaming
* NPCs and scenes that move the player. A warp target leaves the map. */
const walk = (x: number, y: number) => {
const map = world.mapId,
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
for (let attempt = 0; attempt < 40; attempt++) {
play();
if (warp && world.mapId !== map) return;
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
if (world.x === x && world.y === y) return;
for (const direction of findPath(world, x, y)) {
const [dx, dy] = STEP[direction];
const from = [world.x, world.y];
if (!world.move(direction)) {
for (let tick = 0; tick < 64; tick++) world.tick();
break;
}
play();
if (
world.mapId !== map ||
world.x !== from[0] + dx ||
world.y !== from[1] + dy
)
break;
}
}
throw new Error(
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}`,
);
};
/** Step across a map connection from the nearest usable edge tile. */
const cross = (direction: Direction, target: string) => {
const map = world.mapId;
const [dx, dy] = STEP[direction];
const edge = Array.from(
{ length: dx ? world.map.height : world.map.width },
(_, i) =>
dx
? [dx < 0 ? 0 : world.map.width - 1, i]
: [i, dy < 0 ? 0 : world.map.height - 1],
).flatMap(([x, y]) => {
try {
return [{ x, y, length: findPath(world, x, y).length }];
} catch {
return [];
}
});
edge.sort((a, b) => a.length - b.length);
for (const { x, y } of edge) {
walk(x, y);
if (world.move(direction)) {
play();
if (world.mapId === target) return;
}
if (world.mapId !== map) break;
}
throw new Error(
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
);
};
/** Stand in front of a warp leading to `target` and take it. */
const enter = (target: string, direction: Direction) => {
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
expect(warp, `a warp to ${target}`).toBeDefined();
const [dx, dy] = STEP[direction];
walk(warp.x - dx, warp.y - dy);
expect(world.move(direction)).toBe(true);
play();
expect(world.mapId).toBe(target);
};
/** Leave through an exit mat, which needs the exit direction pressed. */
const exit = (target: string, direction: Direction = "down") => {
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
walk(warp.x, warp.y);
if (world.mapId !== target) {
expect(world.move(direction)).toBe(true);
play();
}
expect(world.mapId).toBe(target);
};
const reload = () => {
const saved = JSON.parse(JSON.stringify(world.save()));
world = new GameWorld(assets!, now, random);
world.restore(saved);
expect(world.save()).toEqual(saved);
};
beforeEach(() => {
log.length = 0;
});
const traceFailures = () =>
onTestFailed(() => {
console.log(
`${world.mapId} ${world.x},${world.y}; party ${JSON.stringify(
world.state.party.map(({ species, level, hp }) => ({
species,
level,
hp,
})),
)}\n${log.slice(-30).join("\n---\n")}`,
);
});
it("plays the introduction and Mom's scene", () => {
world = new GameWorld(assets!, now, random);
traceFailures();
world.beginIntro();
play([0, "", 1, "", 1, 0, "KAWA"]);
expect(world.intro).toBe(false);
expect(world.name).toBe("KAWA");
reload();
walk(7, 0);
expect(world.mapId).toBe("24:6");
walk(9, 3);
expect(world.move("down")).toBe(true);
play([1, 1, 1, 1, 1]);
expect(log).toContain("KAWA received\nPOKéGEAR.");
reload();
});
it("meets Elm, takes Cyndaquil and the aide's Potion", () => {
traceFailures();
walk(6, 7);
expect(world.move("down")).toBe(true);
play();
expect(world.mapId).toBe(NEW_BARK);
enter(ELMS_LAB, "up");
play([0, 1]);
walk(6, 4);
world.move("up");
world.interact();
play([1, 1, ""]);
expect(world.state.party.map((mon) => mon.species)).toEqual([155]);
walk(4, 7);
expect(world.move("down")).toBe(true);
play();
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
reload();
});
it("walks to Mr. Pokémon's house through Route 29, Cherrygrove and Route 30", () => {
traceFailures();
exit(NEW_BARK);
cross("left", ROUTE_29);
cross("left", CHERRYGROVE);
reload();
cross("up", ROUTE_30);
enter(MR_POKEMONS_HOUSE, "up");
play();
expect(world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG
expect(world.state.flags.has(11)).toBe(true); // the Pokédex
reload();
});
it("takes Elm's call outside and meets the rival in Cherrygrove", () => {
traceFailures();
exit(ROUTE_30);
expect(log.join("\n")).toContain("It's a disaster!");
expect(world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON
reload();
cross("down", CHERRYGROVE);
// The rival's scene blocks the way east to Route 29.
cross("right", ROUTE_29);
const rival = log.join("\n");
expect(rival).toContain("RIVAL ?");
expect(rival).toContain("My name's ???.");
expect(world.state.scenes[CHERRYGROVE]).toBe(0);
reload();
});
it("names the rival for the officer and returns the Egg to Elm", () => {
traceFailures();
cross("right", NEW_BARK);
enter(ELMS_LAB, "up");
walk(4, 6);
expect(world.move("up")).toBe(true);
play(["SILVER"]);
expect(world.state.rivalName).toBe("SILVER");
reload();
walk(5, 3);
world.move("up"); // Elm stands at 5,2: this only turns the player.
world.interact();
play();
expect(world.state.bag.keyItems).toEqual([]);
walk(4, 8);
expect(world.state.bag.balls).toEqual([
{ item: C.POKE_BALL, quantity: 5 },
]);
reload();
});
it("watches the catching tutorial on Route 29 and reaches Cherrygrove", () => {
traceFailures();
exit(NEW_BARK);
cross("left", ROUTE_29);
cross("left", CHERRYGROVE);
const pages = log.join("\n").replace(/\n/g, " ");
expect(pages).toContain("show you how to catch POKéMON?");
expect(pages).toContain("That's how you do it");
expect(world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON
expect(world.state.scenes[ROUTE_29]).toBe(0);
reload();
const save = world.save();
expect(save.party[0].species).toBe(155);
expect(save.pokedex.caught).toContain(155);
expect(save.rivalName).toBe("SILVER");
expect(save.intro).toBe(false);
});
},
);
+143 -27
View File
@@ -1,23 +1,41 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { BattleData } from "../src/game/battle/data";
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { calcStat } from "../src/game/party/pokemon";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test("native decompression matches original GB assembly executing in the reference emulator", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires the local user ROM.");
/** Banked addresses from the private PRET rebuild's symbol file. */
function loadSymbols() {
const symbols = new Map(
readFileSync(symbolsPath, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
return match
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
: [];
}),
);
return {
at: (name: string) => symbols.get(name)!.address,
routine: (name: string) => symbols.get(name)!,
};
}
/** Open the reference page and hand it the ROM through an isolated file
* input, without an HTTP ROM endpoint. */
async function openReference(page: Page) {
await page.goto("/reference.html");
// File chooser supplies bytes to an isolated test input, without an HTTP ROM endpoint.
await page.evaluate(() => {
const input = document.createElement("input");
input.type = "file";
@@ -25,6 +43,13 @@ test("native decompression matches original GB assembly executing in the referen
document.body.append(input);
});
await page.locator("#reference-fixture").setInputFiles(romPath);
}
test("native decompression matches original GB assembly executing in the reference emulator", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires the local user ROM.");
await openReference(page);
const result = await page.evaluate(async () => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
@@ -113,23 +138,11 @@ test("native decompression matches original GB assembly executing in the referen
test("native damage and type effectiveness match the original battle routines", async ({
page,
}) => {
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
const symbols = new Map(
readFileSync(symbolsPath, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
return match
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
: [];
}),
);
const at = (name: string) => symbols.get(name)!.address;
const routine = (name: string) => symbols.get(name)!;
const { at, routine } = loadSymbols();
const bytes = new Uint8Array(readFileSync(romPath));
const assets = new CrystalAssets(bytes);
const data = new BattleData(assets.reader);
@@ -164,14 +177,7 @@ test("native damage and type effectiveness match the original battle routines",
identified: next(4) === 0,
damage: next(1000),
}));
await page.goto("/reference.html");
await page.evaluate(() => {
const input = document.createElement("input");
input.type = "file";
input.id = "reference-fixture";
document.body.append(input);
});
await page.locator("#reference-fixture").setInputFiles(romPath);
await openReference(page);
const addresses = {
curDamage: at("wCurDamage"),
critical: at("wCriticalHit"),
@@ -294,3 +300,113 @@ test("native damage and type effectiveness match the original battle routines",
).toEqual([original.damage, original.modifier, original.missed]);
});
});
test("native stats and experience curves match the original routines", async ({ page }) => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
const { at, routine } = loadSymbols();
const pokemon = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
let seed = 0x5eed;
const next = (limit: number) => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 8) % limit;
};
// Legal stats stay below 999; levels above 100 reach the MAX_STAT_VALUE cap.
const statCases = Array.from({ length: 300 }, () => ({
base: Array.from({ length: 6 }, () => (next(4) === 0 ? 200 + next(56) : 1 + next(255))),
level: next(6) === 0 ? 101 + next(155) : next(5) === 0 ? 100 : 1 + next(100),
dvs: next(0x10000),
statExp: Array.from({ length: 5 }, () => (next(3) === 0 ? 0xffff - next(256) : next(0x10000))),
useStatExp: next(4) !== 0,
}));
await openReference(page);
const addresses = {
baseStats: at("wBaseStats"),
level: at("wCurPartyLevel"),
growthRate: at("wBaseGrowthRate"),
product: at("hProduct"),
calcMonStats: routine("CalcMonStats"),
calcExpAtLevel: routine("CalcExpAtLevel"),
};
const results = await page.evaluate(
async ({ addresses, statCases }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
const reference = new BinjgbEmulator();
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
const read = (address: number, length: number) =>
Array.from({ length }, (_, i) => reference.readMemory(address + i));
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set BC, DE
// and HL; CALL the original routine; write a sentinel; loop.
const call = (
target: { bank: number; address: number },
bc: number,
de: number,
hl: number,
frames = 2,
) => {
const program = [
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8, 0x21, hl & 0xff, hl >> 8,
0xcd, target.address & 0xff, target.address >> 8,
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
];
write(0xc6ff, 0);
program.forEach((value, index) => write(0xc000 + index, value));
reference.setProgramCounter(0xc000);
reference.advanceFrames(frames);
return reference.readMemory(0xc6ff) === 0x42;
};
write(0xff70, 1); // WRAM bank 1 holds the base data and party level
// A party struct at $c100 (stat experience at +11, DVs at +21) and
// the six computed stats at $c140.
const mon = 0xc100,
out = 0xc140;
const stats = statCases.map((input) => {
input.base.forEach((value, i) => write(addresses.baseStats + i, value));
write(addresses.level, input.level);
input.statExp.forEach((value, i) => {
write(mon + 11 + i * 2, value >> 8);
write(mon + 12 + i * 2, value);
});
write(mon + 21, input.dvs >> 8);
write(mon + 22, input.dvs);
// B = stat experience flag; HL = MON_STAT_EXP - 1; DE = output.
// GetSquareRoot counts up to 255 per stat, so allow more frames.
const returned = call(addresses.calcMonStats, Number(input.useStatExp) << 8, out, mon + 10, 8);
const bytes = read(out, 12);
return { returned, stats: Array.from({ length: 6 }, (_, i) => (bytes[i * 2] << 8) | bytes[i * 2 + 1]) };
});
const experience: { returned: boolean; exp: number }[] = [];
for (let growth = 0; growth < 6; growth++)
for (let level = 1; level <= 100; level++) {
write(addresses.growthRate, growth);
// D = level
const returned = call(addresses.calcExpAtLevel, 0, level << 8, 0);
const [high, middle, low] = read(addresses.product + 1, 3);
experience.push({ returned, exp: (high << 16) | (middle << 8) | low });
}
reference.dispose();
return { stats, experience };
},
{ addresses, statCases },
);
statCases.forEach((input, index) => {
const original = results.stats[index];
expect(original.returned, `stat case ${index} returned`).toBe(true);
const native = input.base.map((base, stat) =>
calcStat(base, input.dvs, stat, input.level, input.useStatExp ? input.statExp : undefined),
);
expect(native, `stat case ${index}: ${JSON.stringify(input)}`).toEqual(original.stats);
});
expect(results.stats.some(({ stats }) => stats.includes(999))).toBe(true);
results.experience.forEach((original, index) => {
const growth = Math.floor(index / 100),
level = (index % 100) + 1;
expect(original.returned, `experience ${growth}/${level} returned`).toBe(true);
expect(pokemon.expAtLevel(growth, level), `experience ${growth}/${level}`).toBe(original.exp);
});
});