Merge pull request 'Port capture and the Route 29 catching tutorial' (#1) from capture-and-catching-tutorial into main
Checks / web (push) Canceled after 0s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
kawa committed 2026-09-24 21:02:28 +02:00
commit dfe5c9becb
20 files changed
+1101 -65

No files matched your search

+3 -1
View File
@@ -10,7 +10,9 @@ 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. 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.
Catching, Mr. Pokémon's house and the Mystery Egg errand, Route 31 and beyond, party and bag menus, decorations and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. 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.
Route 31 and beyond, the PC, party and bag menus, other items in battle, decorations and audio are still being ported. Catching with six Pokémon in your party needs the PC and is refused for now. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run ## Run
+1 -1
View File
@@ -48,4 +48,4 @@ Asset caches and the ROM reader remain in memory for lazy extraction. Neither RO
The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling. The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling.
`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Trainer battles share that core, with ROM trainer decoders, the trainer's send-out and switch AI, and explicit AI support checks. Map trainers (sight and talk) are started by `game/world` and run the ROM's own trainer scripts. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state. `game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Trainer battles share that core, with ROM trainer decoders, the trainer's send-out and switch AI, and explicit AI support checks. Map trainers (sight and talk) are started by `game/world` and run the ROM's own trainer scripts. Battles receive the script's draft party, bag and Pokédex, so a catch, a thrown ball or a nickname commits only with the script that started the battle. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
+35 -1
View File
@@ -87,7 +87,7 @@ A trainer sighting whose script is refused is not restarted until the player mov
## Next slice ## Next slice
Route 29, Cherrygrove, Route 30 and Mr. Pokémon's house are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle and Elm's special call. NPCs roam. Next: capture with the catching tutorial, then party, bag and Pokégear menus over the native models. Other specials stay refused until translated. Route 29, Cherrygrove, Route 30 and Mr. Pokémon's house are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle, Elm's special call, capture and the catching tutorial. NPCs roam. Next: the PC and boxes, then party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
## Reflect and Light Screen ## Reflect and Light Screen
@@ -120,3 +120,37 @@ the house, both rival triggers with all three parties, a rival loss, the call
indoors, outdoors and on reload, the officer's naming (including blank names indoors, outdoors and on reload, the officer's naming (including blank names
and reload at the naming screen), and the Egg and Poké Ball handovers. Browser and reload at the naming screen), and the Egg and Poké Ball handovers. Browser
tests cover the house, the rival battle and the naming field. tests cover the house, the rival battle and the naming field.
## Capture and the catching tutorial
`PokeBallEffect`, `BallMultiplierFunctionTable` (Poké, Great, Ultra and
Master Balls), `GetPokeBallWobble` with `WobbleProbabilities`,
`UseBallInTrainerBattle` and `BattleMenu_Pack`'s `.UseItem` in
`engine/items/item_effects.asm`, `engine/battle_anims/pokeball_wobble.asm`
and `engine/battle/core.asm`. The catch rate is computed with the original
8- and 16-bit arithmetic and kept bugs (no bonus for BRN/PSN/PAR, overflow
above 341 maximum HP). Only the random draws are kept from the throw
animation: one per shake until the ball clicks or the Pokémon breaks free.
The caught Pokémon follows the second `LoadEnemyMon` (the wild item roll
is redrawn and then restored), `GeneratePartyMonStats` with `wBattleMode`
set (wild moves, PP and HP) and `SetCaughtData`. Pokédex registration,
`NewDexDataText`, `AskGiveNicknameText` and the naming field with
`InitName`'s default follow. The ball is taken from the selected slot, as
`RemoveItemFromPocket` does with `wCurItemQuantity`.
`Script_catchtutorial` and `CatchTutorial` run the battle as
`BATTLETYPE_TUTORIAL`: `<PLAYER>` reads "DUDE" from the ROM, `DoBattle`
skips the send-out, and the auto input's PACK and ball choice go straight
to `PokeBallEffect`, which always catches and keeps nothing (`.FinishTutorial`).
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
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
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.
+89 -5
View File
@@ -8,6 +8,24 @@ PRET's [disassembly](https://github.com/pret/pokecrystal) is pinned at `7a7881d0
The emulator-backed prototype is now at `/reference.html`. The default application is the native port. The previous plan to progressively modify an emulated ROM has been superseded. The emulator-backed prototype is now at `/reference.html`. The default application is the native port. The previous plan to progressively modify an emulated ROM has been superseded.
## Status overview
| Area | Status |
| ---------------------------------------------------------------------------- | --------------------------------------- |
| Exploration foundation, rendering, saves and shell | Done |
| Opening story: New Bark Town to Mr. Pokémon, the rival and the return to Elm | Done |
| Route 29, Cherrygrove City, Route 30 and their buildings | Done |
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
| 1. Complete the overworld foundation | In progress |
| 2. Opening story and first battle slice | Mostly done |
| 3. Complete Crystal | Early |
| 4. Modern mobile presentation and delivery | First increment done |
| 5. World map and Pokédex | Not started (current-area minimap only) |
| 6. Internet Link and friends | Not started |
| 7. Optional Pokéwalker-style egg assistance | Not started |
The numbered sections below keep their full scope. Each one opens with a checklist: `[x]` is done and tested, `[ ]` is remaining. The "Implemented" sections record what each completed increment delivered.
## Implemented: first native exploration milestone ## Implemented: first native exploration milestone
- Bounds-checked ROM access, banked pointers and native LZ decompression. - Bounds-checked ROM access, banked pointers and native LZ decompression.
@@ -57,7 +75,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
- The battle engine gained `recoil` (Struggle). A local stress run of 360 Route 29 battles across all starters and times of day finishes without refusals. - The battle engine gained `recoil` (Struggle). A local stress run of 360 Route 29 battles across all starters and times of day finishes without refusals.
- Save format 3 gains `fruitTrees`, `dailyReset` and `lastSpawnMap`; older backups migrate by absence. - Save format 3 gains `fruitTrees`, `dailyReset` and `lastSpawnMap`; older backups migrate by absence.
- NPCs roam as `HandleNPCStep` does: random walks (Y, X, XY) and slow/fast spins from `SpriteMovementData`, `CanObjectMoveInDirection` (side walls, land, objects' current and previous tiles, radius stored plus one, screen edge), random sleeps, freezing during text and `applymovement`, and deletion/respawn at home beyond the object window. Wandering positions are not saved; objects start from home on reload. - NPCs roam as `HandleNPCStep` does: random walks (Y, X, XY) and slow/fast spins from `SpriteMovementData`, `CanObjectMoveInDirection` (side walls, land, objects' current and previous tiles, radius stored plus one, screen edge), random sleeps, freezing during text and `applymovement`, and deletion/respawn at home beyond the object window. Wandering positions are not saved; objects start from home on reload.
- Still refused: the catching tutorial (`catchtutorial`), reachable after the Mystery Egg errand. - The catching tutorial (`catchtutorial`) runs since the capture section below.
## Implemented: first scripted trainer battle slice ## Implemented: first scripted trainer battle slice
@@ -92,14 +110,38 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
- Save format 3 gained `rivalName` and `specialPhoneCall` (migrated by absence to "???" and 0). - Save format 3 gained `rivalName` and `specialPhoneCall` (migrated by absence to "???" and 0).
- Next: the catching tutorial on Route 29 (`catchtutorial`) with capture; then player switching and items in battle. - Next: the catching tutorial on Route 29 (`catchtutorial`) with capture; then player switching and items in battle.
## In progress: native battles ## Implemented: capture and the catching tutorial
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience and move learning; 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, 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. Capture, player switching, items in battle and the rest of the opening quest remain unfinished. - Battle PACK opens the Balls pocket. Throwing a ball runs `PokeBallEffect`: the catch rate with the Great/Ultra multipliers and the original HP arithmetic, the sleep/freeze bonus and `HELD_CATCH_CHANCE`, then one `Random` draw. Master Balls always catch. The original bugs are kept: burn, poison and paralysis add nothing, and a maximum HP above 341 overflows the formula. A maximum HP of 342 would divide by zero and is refused.
- `GetPokeBallWobble` runs with `WobbleProbabilities` for each shake and picks the break-free message. Every throw uses the ball and, if the Pokémon breaks free, the turn. In trainer battles the ball is blocked, but the ball and turn are still used.
- A caught Pokémon is reloaded as `LoadEnemyMon` does, which redraws the wild item roll (the item itself is kept). It keeps its current HP, status, item and DVs, and gets back the moves and full PP it appeared with. It joins the party with `SetCaughtData`, is registered as caught (with the new-data message once the Pokédex is owned) and can be nicknamed; a blank name keeps the species name. The Pokédex page itself is not presented yet.
- Route 29's `Route29Tutorial1/2` and `CatchingTutorialDudeScript` run from the ROM. `catchtutorial` runs the battle as "DUDE": no Pokémon is sent out, the Dude's own Poké Ball always catches, and the Rattata is only seen, not kept. Messages advance on their own, as with `CatchTutorial`'s auto input.
- A catch with six Pokémon in the party would go to the PC (`SendMonIntoBox`), which is not ported, so the battle is refused and discarded. Other balls (Kurt's balls, Park and Safari) and non-ball items in battle are not usable yet.
- A local stress run of 2,160 wild battles (three starters, ten early species, six levels) caught 2,047 Pokémon without refusals.
- Next: the PC and boxes, player switching and other items in battle, then party and bag menus.
This is still not a complete new game. Party/bag/Pokégear menus, story progression beyond Cherrygrove, decorations, animated terrain, surfing and audio remain unfinished. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later. ## 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, items other than balls in battle and the PC remain unfinished.
This is still not a complete new game. Party/bag/Pokégear menus, the PC, story progression beyond Route 30 and Mr. Pokémon's house, decorations, animated terrain, surfing and audio remain unfinished. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
## 1. Complete the overworld foundation ## 1. Complete the overworld foundation
- [x] Map headers for all 26 groups, location names, connection offsets and connected-border drawing
- [x] Walking, collisions, doors, stairs, ledges and directional warp mats
- [x] Coordinate events, scene scripts, map callbacks and scripted movement
- [x] NPC random walks and spins, trainer sight lines
- [x] Item balls, hidden items and fruit trees with the daily reset
- [x] Connected transitions for every enabled map (New Bark Town to Route 30)
- [ ] Dynamic warps, elevators, ice, currents and the remaining forced movement
- [ ] Surfing, cycling, Cut trees and Strength boulders
- [ ] Decorations and tile changes (`changeblock`)
- [ ] Remaining NPC movement types (followers, bouncing) and movement commands
- [ ] Terrain animation, layering and special palettes
- [ ] Reference comparison of player turn timing
- [ ] Acceptance: outdoor, interior, cave and connected-map traces match the reference engine
Map headers, location names, connection offsets, connected-border drawing, coordinate events, scene scripts, map callbacks and scripted movement are in place (see above). Next: enable connected transitions as their maps are ported; add dynamic warps, elevators, ice, currents, remaining forced movement, surfing, cycling, cut trees and boulders. Player turn timing still needs a reference comparison. Map headers, location names, connection offsets, connected-border drawing, coordinate events, scene scripts, map callbacks and scripted movement are in place (see above). Next: enable connected transitions as their maps are ported; add dynamic warps, elevators, ice, currents, remaining forced movement, surfing, cycling, cut trees and boulders. Player turn timing still needs a reference comparison.
Port decorations, tile changes (`changeblock`), the remaining NPC movement types (followers, bouncing, Strength boulders) and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them. Port decorations, tile changes (`changeblock`), the remaining NPC movement types (followers, bouncing, Strength boulders) and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them.
@@ -108,11 +150,26 @@ Acceptance: representative outdoor/interior/cave/connected-map traces produce ma
## 2. Opening story and first battle slice ## 2. Opening story and first battle slice
- [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] 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
- [ ] The PC and boxes (catching with a full party)
- [ ] 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.
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. 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. 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 and the return to Elm run now. Next: capture and the catching tutorial; then the introduction's naming and clock screens. 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 and the catching tutorial run now. Next: the PC, then the introduction's naming and clock screens.
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. 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.
@@ -120,6 +177,14 @@ Acceptance: finish the opening quest, including encounters and the rival battle,
## 3. Complete Crystal ## 3. Complete Crystal
- [x] Pokémon Center healing, standard Poké Marts and Reflect/Light Screen
- [ ] Remaining battle commands, move and item effects, AI layers and special battles
- [ ] Evolution, breeding, happiness, eggs and PC storage
- [ ] Field moves, gyms, Rocket events, the phone and Pokégear, puzzles, minigames, daily events, roaming Pokémon and the postgame
- [ ] Johto beyond Route 30, the League, Kanto and the final battle
- [ ] Music and sound through Web Audio
- [ ] Acceptance: main-game and postgame playthroughs, battle regression vectors, long sessions and save recovery
Port remaining battle commands, move/item effects, AI, special battles, evolutions, breeding, happiness, eggs and PC storage. Gen II behavior is the baseline; list intentional changes separately. Port remaining battle commands, move/item effects, AI, special battles, evolutions, breeding, happiness, eggs and PC storage. Gen II behavior is the baseline; list intentional changes separately.
Complete field moves, gyms, Rocket events, phone/Pokégear, shops, healing, puzzles, minigames, daily events, roaming Pokémon and postgame. Cover Johto, the League, Kanto and the final battle. Track unsupported script/special handlers and add fixtures before translating each one. Complete field moves, gyms, Rocket events, phone/Pokégear, shops, healing, puzzles, minigames, daily events, roaming Pokémon and postgame. Cover Johto, the League, Kanto and the final battle. Track unsupported script/special handlers and add fixtures before translating each one.
@@ -130,6 +195,16 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
## 4. Modern mobile presentation and delivery ## 4. Modern mobile presentation and delivery
- [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
- [ ] 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
- [ ] Installable web app with offline app shell, safe updates and quota handling
- [ ] Acceptance on physical iPhone and Android devices
The first presentation increment replaces the landing page and handheld frame with a restrained Crystal-style interface. Game, Savegames, Map, Pokédex, Link and Friends stay above a viewport-filling native canvas. The renderer draws more world tiles as space grows instead of stretching a 160 × 144 image. The game and Map tab share a live local minimap; the atlas and unsupported tabs remain honest planned states. The linked UI reference could not be inspected through automated access because the site returned a Cloudflare challenge, so exact screenshot matching is not claimed. The first presentation increment replaces the landing page and handheld frame with a restrained Crystal-style interface. Game, Savegames, Map, Pokédex, Link and Friends stay above a viewport-filling native canvas. The renderer draws more world tiles as space grows instead of stretching a 160 × 144 image. The game and Map tab share a live local minimap; the atlas and unsupported tabs remain honest planned states. The linked UI reference could not be inspected through automated access because the site returned a Cloudflare challenge, so exact screenshot matching is not claimed.
Build touch interfaces directly over native models: party, pack, battle commands, PC and Pokégear. Keep UI separate from gameplay calculations. Add scalable text, configurable controls and accessible focus/announcements. Build touch interfaces directly over native models: party, pack, battle commands, PC and Pokégear. Keep UI separate from gameplay calculations. Add scalable text, configurable controls and accessible focus/announcements.
@@ -146,6 +221,11 @@ Acceptance: installation, offline startup, portrait/landscape, touch/audio, back
## 5. World map and Pokédex ## 5. World map and Pokédex
- [x] Current-area minimap in the game and Map tab
- [x] Seen and caught sets saved from real game state
- [ ] Johto/Kanto atlas with visited areas and Fly destinations
- [ ] Pokédex tab with ROM-derived records and habitat views
Promote the current-area minimap into a Johto/Kanto atlas after all map groups, connections and location names can be decoded from the selected ROM. Derive region geometry, visited areas, player location and Fly destinations from native map and story state. Do not ship copied map artwork or claim navigation through maps whose collisions and events are still unsupported. Promote the current-area minimap into a Johto/Kanto atlas after all map groups, connections and location names can be decoded from the selected ROM. Derive region geometry, visited areas, player location and Fly destinations from native map and story state. Do not ship copied map artwork or claim navigation through maps whose collisions and events are still unsupported.
Build the Pokédex from typed ROM decoders for species names, base data, evolutions, moves and graphics. Seen, caught, forms and habitat or area views must come from native game state and encounter tables. Keep ROM-derived records unavailable until a verified ROM is selected. Build the Pokédex from typed ROM decoders for species names, base data, evolutions, moves and graphics. Seen, caught, forms and habitat or area views must come from native game state and encounter tables. Keep ROM-derived records unavailable until a verified ROM is selected.
@@ -156,6 +236,8 @@ Acceptance: atlas positions and Pokédex records match pinned PRET fixtures and
## 6. Internet Link and friends ## 6. Internet Link and friends
- [ ] Not started. Link and Friends tabs show their planned scope only.
Implement a game-level protocol over native state. This architecture does not need to tunnel serial timing or synchronize GB CPU cycles. Implement a game-level protocol over native state. This architecture does not need to tunnel serial timing or synchronize GB CPU cycles.
Separate `LinkTransport`, `TradeSession` and `BattleSession`. Start with local peers, then private invite rooms using signaling and WebRTC data channels or a relay. Negotiate compatible schema/game versions and exchange only required game data. Never transmit ROMs or extracted assets. Separate `LinkTransport`, `TradeSession` and `BattleSession`. Start with local peers, then private invite rooms using signaling and WebRTC data channels or a relay. Negotiate compatible schema/game versions and exchange only required game data. Never transmit ROMs or extracted assets.
@@ -170,6 +252,8 @@ Acceptance: cross-network trades/battles and induced duplicate messages, latency
## 7. Optional Pokéwalker-style egg assistance ## 7. Optional Pokéwalker-style egg assistance
- [ ] Not started. Depends on eggs, hatching and a native companion app.
This optional mobile feature uses real-world walking to assist egg hatching after native party, breeding, eggs and faithful in-game hatching are complete. The pinned Crystal engine decrements party egg counters from overworld walking in [`CountStep`](https://github.com/pret/pokecrystal/blob/7a7881d0d62e0ddbd82dcf10e7116807487ac651/engine/overworld/events.asm#L866-L900) and [`DoEggStep`](https://github.com/pret/pokecrystal/blob/7a7881d0d62e0ddbd82dcf10e7116807487ac651/engine/pokemon/breeding.asm#L174-L196); battles do not advance them. A battle-based bonus would therefore be an explicit, separately enabled extension rather than Crystal behavior. This optional mobile feature uses real-world walking to assist egg hatching after native party, breeding, eggs and faithful in-game hatching are complete. The pinned Crystal engine decrements party egg counters from overworld walking in [`CountStep`](https://github.com/pret/pokecrystal/blob/7a7881d0d62e0ddbd82dcf10e7116807487ac651/engine/overworld/events.asm#L866-L900) and [`DoEggStep`](https://github.com/pret/pokecrystal/blob/7a7881d0d62e0ddbd82dcf10e7116807487ac651/engine/pokemon/breeding.asm#L174-L196); battles do not advance them. A battle-based bonus would therefore be an explicit, separately enabled extension rather than Crystal behavior.
Separate a platform `StepProvider`, local activity ledger and reward rules. Request read access to steps only, after an explicit explanation and opt-in. Keep real-world steps distinct from the in-game tile counter, show what interval will be credited and provide a disconnect/delete control. Do not request distance, heart rate, workouts or permission to write health records. Keep raw activity data on device by default. Separate a platform `StepProvider`, local activity ledger and reward rules. Request read access to steps only, after an explicit explanation and opt-in. Keep real-world steps distinct from the in-game tile counter, show what interval will be credited and provide a disconnect/delete control. Do not request distance, heart rate, workouts or permission to write health records. Keep raw activity data on device by default.
+41
View File
@@ -289,3 +289,44 @@ Next: capture and the Route 29 catching tutorial (`catchtutorial`), the
first remaining refusal in the opening quest. Then the ringing presentation, first remaining refusal in the opening quest. Then the ringing presentation,
player switching and items in battle. Joey's post-Egg phone scripts remain player switching and items in battle. Joey's post-Egg phone scripts remain
unsupported. unsupported.
## Capture and the catching tutorial: 2026-09-24
Continued the roadmap with the first refusal remaining in the opening quest.
- Battle PACK opens the Balls pocket and runs `PokeBallEffect`: the Poké,
Great, Ultra and Master Ball multipliers, the original HP arithmetic
(including the overflow above 341 maximum HP), the sleep/freeze bonus
(BRN/PSN/PAR add nothing), `HELD_CATCH_CHANCE` and the catch draw.
`GetPokeBallWobble` draws per shake from `WobbleProbabilities` and picks
the break-free text. Trainer battles block the ball but still use it and
the turn. `catchRate` is exported for pure vectors.
- A catch reloads the Pokémon as `LoadEnemyMon` does (the wild item roll is
redrawn, then the original item restored), keeps HP/status/item/DVs,
restores the moves and PP it appeared with, adds it to the party with
`SetCaughtData`, registers it and offers a nickname (`InitName` default
for blank names). `NewDexDataText` shows when the Pokédex is owned.
`caughtData` is now shared with `givepoke`.
- `catchtutorial` runs `BATTLETYPE_TUTORIAL`: "DUDE" from the ROM as
`<PLAYER>`, no send-out, an automatic catch that keeps nothing, and
prompts that continue on their own. Both Route 29 triggers, declining and
`CatchingTutorialDudeScript`'s repeat offer run from the ROM.
- Text: `TX_LOW` ($05) is read as a line break (`ItemUsedText` needs it).
- UI: PACK lists the Balls pocket (B closes it); the in-battle nickname
prompt reuses the naming field. The help dialog and README describe the
current milestone.
- Refused: a catch with six Pokémon (the PC is not ported), other balls,
non-ball items in battle and a transformed target.
- Stress (local, not committed): 2,160 wild battles, 2,047 catches, no
refusals.
Checks: 207 unit tests with the local ROM (82 passed, 125 skipped without
it); 54 Chromium/WebKit browser tests; native and reference builds;
reference, release and vendor checks; `git diff --check`. The existing
keyboard/D-pad test now steps through the enabled PACK button. No
emulator comparison of the capture routines was run. Not committed.
Next: the PC and boxes (catching with a full party, `SendMonIntoBox`, Bill's
call), then player switching and other items in battle, and party and bag
menus. The Dude's back picture, ball animation and Pokédex page are not
presented.
+2
View File
@@ -315,6 +315,8 @@ for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?
throw new Error(`Battle constant mismatch: ${name} is ${literal}, PRET has ${expected}`); throw new Error(`Battle constant mismatch: ${name} is ${literal}, PRET has ${expected}`);
} }
} }
if (!profile.includes(`pokedex: ${pretConstants.get("ENGINE_POKEDEX")},`))
throw new Error("ENGINE_POKEDEX does not match the reference.");
if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREES")},`)) if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREES")},`))
throw new Error("ENGINE_ALL_FRUIT_TREES does not match the reference."); throw new Error("ENGINE_ALL_FRUIT_TREES does not match the reference.");
if (!profile.includes(`NUM_SPRITEMOVEDATA = ${pretConstants.get("NUM_SPRITEMOVEDATA")};`)) if (!profile.includes(`NUM_SPRITEMOVEDATA = ${pretConstants.get("NUM_SPRITEMOVEDATA")};`))
+264 -6
View File
@@ -1,7 +1,13 @@
import { RomReader } from "../rom/reader"; import { RomReader } from "../rom/reader";
import { BATTLE, RAM } from "../rom/profile"; import { BATTLE, RAM, SYMBOLS } from "../rom/profile";
import type { PokemonData } from "../rom/pokemon"; import type { PokemonData } from "../rom/pokemon";
import { calcStats, type PartyMon } from "../party/pokemon"; import type { Bag } from "../party/bag";
import {
NICKNAME_PATTERN,
PARTY_LENGTH,
calcStats,
type PartyMon,
} from "../party/pokemon";
import { readString, readTextEnd, type TextContext } from "../scripts/text"; import { readString, readTextEnd, type TextContext } from "../scripts/text";
import { BattleData } from "./data"; import { BattleData } from "./data";
import { C, COMMAND } from "./constants"; import { C, COMMAND } from "./constants";
@@ -91,9 +97,16 @@ export type BattleEffect =
/** Choose a move to forget (0-3); 4 cancels, like B on the list. */ /** Choose a move to forget (0-3); 4 cancels, like B on the list. */
| { kind: "forget"; moves: number[]; learning: number } | { kind: "forget"; moves: number[]; learning: number }
/** The level-up stat box (PrintTempMonStats), acknowledged with a button. */ /** The level-up stat box (PrintTempMonStats), acknowledged with a button. */
| { kind: "stats"; labels: string[]; values: number[] }; | { kind: "stats"; labels: string[]; values: number[] }
export type BattleAction = { action: "fight"; move: number } | { action: "run" }; /** The naming screen for a caught Pokémon: answered with the name, or ""
export type BattleInput = number | BattleAction | undefined; * to keep `current`. */
| { kind: "name"; title: string; current: string; max: number };
export type BattleAction =
| { action: "fight"; move: number }
| { action: "run" }
/** PACK: use the item in this slot of its pocket (only Balls so far). */
| { action: "item"; item: number; slot: number };
export type BattleInput = number | string | BattleAction | undefined;
export type BattleOutcome = { result: number; prizeMoney?: number }; export type BattleOutcome = { result: number; prizeMoney?: number };
export interface BattleTrainer { export interface BattleTrainer {
@@ -125,6 +138,14 @@ export interface BattleOptions {
trainer?: BattleTrainer; trainer?: BattleTrainer;
canLose?: boolean; canLose?: boolean;
text: () => TextContext; text: () => TextContext;
/** The player's pack, needed to throw Poké Balls. */
bag?: Bag;
/** ENGINE_POKEDEX: a first catch shows the new-data message. */
hasPokedex?: boolean;
/** SetCaughtData's word for a Pokémon caught at this level. */
caughtData?: (level: number) => number;
/** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */
tutorial?: boolean;
} }
const SUB1 = 1, const SUB1 = 1,
@@ -248,6 +269,43 @@ export function applyTypeMatchups(
/** A native translation of Crystal's battle engine. /** A native translation of Crystal's battle engine.
* The move effect scripts are the ROM's own; each battle command is a * The move effect scripts are the ROM's own; each battle command is a
* native translation. Unsupported AI, switching, contest and item paths are refused. */ * native translation. Unsupported AI, switching, contest and item paths are refused. */
/** PokeBallEffect's wFinalCatchRate for Poké, Great and Ultra Balls, in
* the original 8- and 16-bit registers. Returns undefined when the
* formula would divide by zero (a maximum HP of 342). */
export function catchRate(input: {
rate: number;
ball: number;
hp: number;
maxHp: number;
status: number;
/** The player's battle Pokémon's held item effect. */
held: { effect: number; parameter: number };
}): number | undefined {
let rate = input.rate;
// BallMultiplierFunctionTable
if (input.ball === C.ULTRA_BALL) rate = Math.min(0xff, rate << 1);
else if (input.ball === C.GREAT_BALL) rate = Math.min(0xff, rate + (rate >> 1));
// A maximum HP above 341 overflows the HP term (a kept bug).
let hp = (input.hp << 1) & 0xffff,
max = (input.maxHp * 3) & 0xffff;
if (max >> 8) {
max >>= 2;
hp >>= 2;
if (!(hp & 0xff)) hp |= 1;
}
const divisor = max & 0xff,
remaining = hp & 0xff;
if (!divisor) return undefined;
let chance = Math.floor((rate * ((divisor - remaining) & 0xff)) / divisor) & 0xff;
if (!chance) chance = 1;
// Only sleep and freeze add to the chance; BRN/PSN/PAR do not (a kept bug).
if (input.status & ((1 << C.FRZ) | C.SLP_MASK)) chance = Math.min(0xff, chance + 10);
// HELD_CATCH_CHANCE adds its parameter, but no item has that effect.
if (input.held.effect === C.HELD_CATCH_CHANCE)
chance = Math.min(0xff, chance + input.held.parameter);
return chance;
}
export class Battle { export class Battle {
readonly data: BattleData; readonly data: BattleData;
readonly player = new Side(); readonly player = new Side();
@@ -268,6 +326,14 @@ export class Battle {
weather: number = C.WEATHER_NONE; weather: number = C.WEATHER_NONE;
/** wCurOTMon: the trainer's party index in battle. */ /** wCurOTMon: the trainer's party index in battle. */
curOTMon = 0; curOTMon = 0;
/** wWildMon after a ball: the caught species, or 0. */
wildMon = 0;
/** wFinalCatchRate from the last ball that ran the catch formula. */
private finalCatchRate = 0;
/** wWildMonMoves/wWildMonPP: the wild Pokémon's moves and PP when it
* appeared, which a caught Pokémon keeps. */
private wildMoves: number[] = [];
private wildPP: number[] = [];
/** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */ /** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */
private enemySwitchMonIndex = 0; private enemySwitchMonIndex = 0;
private battleHasJustStarted = 0; private battleHasJustStarted = 0;
@@ -294,6 +360,7 @@ export class Battle {
this.data = new BattleData(options.data.reader); this.data = new BattleData(options.data.reader);
this.matchups = this.data.typeMatchups(); this.matchups = this.data.typeMatchups();
if (options.trainer) this.battleMode = C.TRAINER_BATTLE; if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
} }
private get reader(): RomReader { private get reader(): RomReader {
return this.options.data.reader; return this.options.data.reader;
@@ -317,6 +384,11 @@ export class Battle {
const base = this.options.text(); const base = this.options.text();
return { return {
...base, ...base,
// CatchTutorial copies "DUDE" over wPlayerName for the battle.
player:
this.battleType === C.BATTLETYPE_TUTORIAL
? readString(this.reader, BATTLE["CatchTutorial.Dude"], base)
: base.player,
trainer: this.options.trainer trainer: this.options.trainer
? `${this.options.trainer.className} ${this.options.trainer.name}`.trim() ? `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
: base.trainer, : base.trainer,
@@ -339,8 +411,10 @@ export class Battle {
/** StdBattleTextbox / BattleTextbox with a ROM text. */ /** StdBattleTextbox / BattleTextbox with a ROM text. */
private *say(address: number): Gen { private *say(address: number): Gen {
const { text, prompt } = readTextEnd(this.reader, address, this.context()); const { text, prompt } = readTextEnd(this.reader, address, this.context());
// The tutorial's auto input presses A at every prompt.
const waits = prompt && this.battleType !== C.BATTLETYPE_TUTORIAL;
for (const page of text.split("\n\n").filter(Boolean)) for (const page of text.split("\n\n").filter(Boolean))
yield { kind: "text", text: page, prompt }; yield { kind: "text", text: page, prompt: waits };
} }
/** text_asm routines that battle texts run between fragments. */ /** text_asm routines that battle texts run between fragments. */
private textAsm(offset: number): number { private textAsm(offset: number): number {
@@ -381,6 +455,9 @@ export class Battle {
return BATTLE.BattleMonNicknameText; return BATTLE.BattleMonNicknameText;
case at("Text_1_2_and_Poof"): case at("Text_1_2_and_Poof"):
return BATTLE["Text_1_2_and_Poof.MoveForgotText"]; return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
case at("Text_GotchaMonWasCaught"):
// Capture music (not ported), then WaitButtonText.
return BATTLE.WaitButtonText;
default: default:
throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`); throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`);
} }
@@ -472,6 +549,8 @@ export class Battle {
}; };
this.options.pokedex.seen.add(species); this.options.pokedex.seen.add(species);
this.enemy.stats = [...this.enemy.mon.stats]; this.enemy.stats = [...this.enemy.mon.stats];
this.wildMoves = [...moves];
this.wildPP = [...this.enemy.mon.pp];
} }
/** InitBattleMon from the party, then status penalties. */ /** InitBattleMon from the party, then status penalties. */
private initBattleMon() { private initBattleMon() {
@@ -543,10 +622,23 @@ export class Battle {
) )
throw new UnsupportedBattle("Invalid trainer reward."); throw new UnsupportedBattle("Invalid trainer reward.");
} }
if (this.options.tutorial && (trainer || !this.options.wild))
throw new UnsupportedBattle("The catching tutorial needs a wild Pokémon.");
this.loadEnemyMon(); this.loadEnemyMon();
this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0); this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0);
if (this.curBattleMon < 0) if (this.curBattleMon < 0)
throw new UnsupportedBattle("No Pokémon can fight."); throw new UnsupportedBattle("No Pokémon can fight.");
if (this.battleType === C.BATTLETYPE_TUTORIAL) {
// DoBattle goes straight to BattleMenu without sending anyone out.
// The auto input picks PACK, then TutorialPack's Balls pocket, and
// BattleMenu_Pack uses the Dude's own POKé BALL.
yield* this.say(BATTLE.WildPokemonAppearedText);
yield this.hud();
yield* this.pokeBallEffect(C.POKE_BALL, -1);
if (!this.wildMon) throw new UnsupportedBattle("The tutorial ball did not catch.");
this.battleResult &= 0xc0; // BattleMenu_Pack.UseItem's .run: WIN
return { result: this.battleResult };
}
if (trainer) { if (trainer) {
yield* this.say(BATTLE.WantsToBattleText); yield* this.say(BATTLE.WantsToBattleText);
yield* this.say(BATTLE.BattleText_EnemySentOut); yield* this.say(BATTLE.BattleText_EnemySentOut);
@@ -699,6 +791,20 @@ export class Battle {
for (;;) { for (;;) {
const input = yield { kind: "action" }; const input = yield { kind: "action" };
if (!input || typeof input !== "object") continue; if (!input || typeof input !== "object") continue;
if (input.action === "item") {
const used = yield* this.battleMenuPack(input.item, input.slot);
if (this.wildMon) {
// BattleMenu_Pack.UseItem's .run: a caught Pokémon ends the
// battle as a WIN, keeping the result's flag bits.
this.wildMon = 0;
this.battleResult &= 0xc0;
this.battleEnded = true;
return true;
}
if (!used) continue;
this.battlePlayerAction = C.BATTLEPLAYERACTION_USEITEM;
return false;
}
if (input.action === "run") { if (input.action === "run") {
const escaped = yield* this.tryToRunAway(); const escaped = yield* this.tryToRunAway();
if (escaped) { if (escaped) {
@@ -714,6 +820,158 @@ export class Battle {
return false; 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. */
private *battleMenuPack(item: number, slot: number): Gen<boolean> {
const pocket = this.options.bag?.balls;
if (!pocket || !Number.isInteger(slot) || pocket[slot]?.item !== item) return false;
return yield* this.pokeBallEffect(item, slot);
}
/** PokeBallEffect (engine/items/item_effects.asm). `slot` is the pocket
* index the ball came from (wCurItemQuantity); the tutorial uses the
* Dude's own ball and passes -1. Returns false if no ball was used. */
private *pokeBallEffect(item: number, slot: number): Gen<boolean> {
const data = this.options.data,
tutorial = this.battleType === C.BATTLETYPE_TUTORIAL;
// _DoItemEffect: GetItemName, then CopyName1 into wStringBuffer2.
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item);
if (this.battleMode !== C.WILD_BATTLE) {
// UseBallInTrainerBattle: the blocked-ball animation, then
// UseDisposableItem. The turn is used.
yield* this.say(BATTLE.BallBlockedText);
yield* this.say(BATTLE.BallDontBeAThiefText);
this.tossBall(item, slot);
return true;
}
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never))
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
// A full party sends the Pokémon into the current box (SendMonIntoBox).
// The tutorial never keeps its Pokémon, so it does not reach the box.
if (!tutorial && this.options.party.length >= PARTY_LENGTH)
throw new UnsupportedBattle("PC boxes are not ported yet.");
this.wildMon = 0;
yield* this.say(BATTLE.ItemUsedText);
let caught = tutorial || item === C.MASTER_BALL;
if (!caught) {
const held = this.data.heldEffect(this.player.mon?.item ?? 0);
const chance = catchRate({
rate: this.enemyBase.catchRate,
ball: item,
hp: this.enemy.mon.hp,
maxHp: this.enemy.mon.maxHp,
status: this.enemy.mon.status,
held,
});
if (chance === undefined) throw new UnsupportedBattle("The catch rate divides by zero.");
this.finalCatchRate = chance;
caught = this.random() <= chance;
}
// BattleAnim_ThrowPokeBall's .Shake loop runs GetPokeBallWobble until
// the ball clicks or the Pokémon breaks free.
let wobbles = 0;
for (;;) {
wobbles++;
if (wobbles === 4) break;
if (caught) continue;
let entry = BATTLE.WobbleProbabilities;
while (this.reader.byte(entry) < this.finalCatchRate) entry += 2;
if (this.random() >= this.reader.byte(entry + 1)) break;
}
if (!caught) {
yield* this.say(
[
BATTLE.BallBrokeFreeText,
BATTLE.BallAppearedCaughtText,
BATTLE.BallAlmostHadItText,
BATTLE.BallSoCloseText,
][wobbles - 1],
);
if (!tutorial) this.tossBall(item, slot);
return true;
}
// Caught: LoadEnemyMon runs again with SUBSTATUS_TRANSFORMED set, which
// keeps the DVs, redraws the wild item (restored afterwards) and
// recalculates stats. Status, HP and item are kept; moves and PP return
// to wWildMonMoves and wWildMonPP.
if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED))
throw new UnsupportedBattle("Catching a transformed Pokémon is not ported yet.");
if (this.random() >= percent(75) + 1) this.random();
const enemy = this.enemy.mon,
species = enemy.species,
level = enemy.level;
this.wildMon = species;
if (tutorial) {
// .FinishTutorial: the Dude's Pokémon is not kept, and his ball is
// not tossed.
yield* this.say(BATTLE.Text_GotchaMonWasCaught);
return true;
}
yield* this.say(BATTLE.Text_GotchaMonWasCaught);
const pokedex = this.options.pokedex,
caughtBefore = pokedex.caught.has(species);
pokedex.seen.add(species);
pokedex.caught.add(species);
if (!caughtBefore && this.options.hasPokedex) {
// NewPokedexEntry's Pokédex page is not presented yet.
yield* this.say(BATTLE.NewDexDataText);
}
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
// the wild Pokémon, then SetCaughtData.
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
const name = data.speciesName(species);
const mon: PartyMon = {
species,
item: enemy.item,
moves: [...this.wildMoves] as PartyMon["moves"],
otId: this.options.playerId,
ot: this.options.playerName,
nickname: name,
exp: data.expAtLevel(data.baseData(species).growthRate, level),
statExp: [0, 0, 0, 0, 0],
dvs: enemy.dvs,
pp: [...this.wildPP] as PartyMon["pp"],
happiness: C.BASE_HAPPINESS,
pokerus: 0,
caughtData: this.options.caughtData(level),
level,
status: enemy.status,
hp: enemy.hp,
stats: calcStats(data, species, enemy.dvs, level),
};
this.options.party.push(mon);
this.strings[RAM.stringBuffer1] = name;
yield* this.say(BATTLE.AskGiveNicknameText);
if (yield { kind: "yesno", text: "" }) {
// NamingScreen (NAME_MON): "<SPECIES>'S" / "NICKNAME?".
let second = SYMBOLS.nicknameStrings;
while (this.reader.byte(second++) !== 0x50);
const context = this.context(),
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
title = readString(this.reader, second, context);
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
throw new UnsupportedBattle("Invalid nickname.");
// InitName: a name of zero or more spaces takes the species name.
if (!/^ *$/.test(answer)) mon.nickname = answer;
}
this.tossBall(item, slot);
return true;
}
/** 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. */ /** MoveSelectionScreen's checks for a chosen move. */
private *moveSelection(index: number): Gen<boolean> { private *moveSelection(index: number): Gen<boolean> {
const mon = this.player.mon; const mon = this.player.mon;
+6
View File
@@ -122,6 +122,10 @@ export const C = {
DITTO: 132, DITTO: 132,
UNOWN: 201, UNOWN: 201,
NO_ITEM: 0, NO_ITEM: 0,
MASTER_BALL: 1,
ULTRA_BALL: 2,
GREAT_BALL: 4,
POKE_BALL: 5,
LUCKY_PUNCH: 30, LUCKY_PUNCH: 30,
METAL_POWDER: 35, METAL_POWDER: 35,
STICK: 105, STICK: 105,
@@ -145,6 +149,7 @@ export const C = {
HELD_PREVENT_SLEEP: 23, HELD_PREVENT_SLEEP: 23,
HELD_PREVENT_PARALYZE: 24, HELD_PREVENT_PARALYZE: 24,
HELD_PREVENT_CONFUSE: 25, HELD_PREVENT_CONFUSE: 25,
HELD_CATCH_CHANCE: 70,
HELD_ESCAPE: 72, HELD_ESCAPE: 72,
HELD_CRITICAL_UP: 73, HELD_CRITICAL_UP: 73,
HELD_QUICK_CLAW: 74, HELD_QUICK_CLAW: 74,
@@ -230,6 +235,7 @@ export const C = {
BASE_PRIORITY: 1, BASE_PRIORITY: 1,
// Battle modes, types and results // Battle modes, types and results
BATTLEPLAYERACTION_USEITEM: 1,
WILD_BATTLE: 1, WILD_BATTLE: 1,
TRAINER_BATTLE: 2, TRAINER_BATTLE: 2,
HELD_AMULET_COIN: 76, HELD_AMULET_COIN: 76,
+15
View File
@@ -104,6 +104,21 @@ export function initialMoves(
return moves as PartyMon["moves"]; return moves as PartyMon["moves"];
} }
/** SetCaughtData (SetBoxmonOrEggmonCaughtData): time of day and level,
* then the player's gender bit and the map's landmark. */
export function caughtData(
timeOfDay: number,
level: number,
gender: number,
landmark: number,
): number {
return (
(((((timeOfDay + 1) & 3) << 6) | level) & 0xff) << 8 |
(gender << 7) |
landmark
);
}
/** GeneratePartyMonStats for a Pokémon given by a script (not a wild or /** GeneratePartyMonStats for a Pokémon given by a script (not a wild or
* trainer battle): random DVs, level-up moves, full PP and HP. */ * trainer battle): random DVs, level-up moves, full PP and HP. */
export function generatePartyMon( export function generatePartyMon(
+14
View File
@@ -170,12 +170,26 @@ export const EVENTS = {
/** Engine flag indices named directly by translated routines. */ /** Engine flag indices named directly by translated routines. */
export const ENGINE_FLAGS = { export const ENGINE_FLAGS = {
pokedex: 11,
allFruitTrees: 84, allFruitTrees: 84,
} as const; } as const;
/** Battle engine tables and texts, keyed by their PRET labels so that /** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */ * \`npm run check:reference\` can verify each address by name. */
export const BATTLE = { export const BATTLE = {
ItemUsedText: 0xf83d,
BallBrokeFreeText: 0xedb5,
BallAppearedCaughtText: 0xedba,
BallAlmostHadItText: 0xedbf,
BallSoCloseText: 0xedc4,
Text_GotchaMonWasCaught: 0xedc9,
WaitButtonText: 0xede6,
NewDexDataText: 0xedf0,
AskGiveNicknameText: 0xedf5,
BallBlockedText: 0xf824,
BallDontBeAThiefText: 0xf829,
WobbleProbabilities: 0xf9ba,
"CatchTutorial.Dude": 0x4e5da,
LightScreenEffectText: 0x814b4, LightScreenEffectText: 0x814b4,
ReflectEffectText: 0x814c7, ReflectEffectText: 0x814c7,
BattleText_MonsLightScreenFell: 0x808e7, BattleText_MonsLightScreenFell: 0x808e7,
+4
View File
@@ -88,6 +88,10 @@ export class NativeRuntime implements GameRuntime {
this.world?.battleChoose(answer); this.world?.battleChoose(answer);
this.publish(); this.publish();
} }
battleName(name: string) {
this.world?.battleName(name);
this.publish();
}
private tick = (now: number) => { private tick = (now: number) => {
try { try {
const world = this.world!; const world = this.world!;
+22 -7
View File
@@ -22,6 +22,7 @@ import {
import { import {
NICKNAME_PATTERN, NICKNAME_PATTERN,
PARTY_LENGTH, PARTY_LENGTH,
caughtData,
generatePartyMon, generatePartyMon,
type PartyMon, type PartyMon,
} from "../party/pokemon"; } from "../party/pokemon";
@@ -162,6 +163,8 @@ export type ScriptEffect =
wild?: { species: number; level: number }; wild?: { species: number; level: number };
trainer?: { classId: number; id: number; winText?: number; lossText?: number }; trainer?: { classId: number; id: number; winText?: number; lossText?: number };
canLose?: boolean; canLose?: boolean;
/** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */
tutorial?: boolean;
} }
/** newloadmap: the world enters this map after the script ends. */ /** newloadmap: the world enters this map after the script ends. */
| { kind: "warp"; map: string; x: number; y: number }; | { kind: "warp"; map: string; x: number; y: number };
@@ -674,6 +677,17 @@ export class ScriptRun {
value = result & 0x3f; // Script_startbattle also updates wScriptVar. value = result & 0x3f; // Script_startbattle also updates wScriptVar.
break; break;
} }
case 0x61: {
// catchtutorial: CatchTutorial runs StartBattle as the Dude, then
// Script_reloadmap. The battle result is not read.
if (byte() !== C.BATTLETYPE_TUTORIAL || !wild || trainer)
throw new UnsupportedScript(offset, opcode, " (battle type)");
yield* flush();
yield synchronous({ kind: "battle", wild, tutorial: true });
wild = undefined;
canLose = false;
break;
}
case 0x60: case 0x60:
// Script_reloadmapafterbattle: a loss continues with the whiteout. // Script_reloadmapafterbattle: a loss continues with the whiteout.
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript; if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
@@ -772,11 +786,6 @@ export class ScriptRun {
const player = this.host.player(); const player = this.host.player();
// GeneratePartyMonStats draws the Attack/Defense byte first. // GeneratePartyMonStats draws the Attack/Defense byte first.
const dvs = (this.host.random() << 8) | this.host.random(); const dvs = (this.host.random() << 8) | this.host.random();
// SetCaughtData: time of day and level, then gender and landmark.
const caughtData =
((((((this.host.timeOfDay() + 1) & 3) << 6) | level) & 0xff) << 8) |
(player.gender << 7) |
this.host.landmark();
const mon = generatePartyMon(data, { const mon = generatePartyMon(data, {
species, species,
level, level,
@@ -784,7 +793,12 @@ export class ScriptRun {
otId: player.id, otId: player.id,
ot: this.host.text().player, ot: this.host.text().player,
dvs, dvs,
caughtData, caughtData: caughtData(
this.host.timeOfDay(),
level,
player.gender,
this.host.landmark(),
),
}); });
this.state.party.push(mon); this.state.party.push(mon);
this.state.pokedex.seen.add(species); this.state.pokedex.seen.add(species);
@@ -1263,7 +1277,8 @@ export class ScriptRun {
return number; return number;
case 0x10: case 0x10:
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be // .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be
// deposited yet (no capture or PC), so the current box is empty. // deposited yet (catching with a full party is refused and the PC
// is not ported), so the current box is empty.
return MONS_PER_BOX; return MONS_PER_BOX;
case 0x12: case 0x12:
return this.host.position().x; return this.host.position().x;
+2
View File
@@ -219,7 +219,9 @@ export function readTextEnd(
// Button waits inside one message become page boundaries in the DOM. // Button waits inside one message become page boundaries in the DOM.
output = output.trimEnd() + "\n\n"; output = output.trimEnd() + "\n\n";
break; break;
case 0x05:
case 0x07: case 0x07:
// TX_LOW writes on the box's lower line; TX_SCROLL starts a new one.
output += "\n"; output += "\n";
break; break;
case 0x08: { case 0x08: {
+24 -2
View File
@@ -1,6 +1,6 @@
import { CrystalAssets } from "../rom/assets"; import { CrystalAssets } from "../rom/assets";
import { TrainerData } from "../rom/trainers"; import { TrainerData } from "../rom/trainers";
import { INITIAL_POSITION, NUM_FRUIT_TREES, SYMBOLS } from "../rom/profile"; import { ENGINE_FLAGS, INITIAL_POSITION, NUM_FRUIT_TREES, SYMBOLS } from "../rom/profile";
import { import {
PLAYER, PLAYER,
ScriptRun, ScriptRun,
@@ -15,6 +15,7 @@ import type { MovementStep } from "../scripts/movement";
import { emptyBag, validBag, type Bag } from "../party/bag"; import { emptyBag, validBag, type Bag } from "../party/bag";
import { import {
NICKNAME_PATTERN, NICKNAME_PATTERN,
caughtData,
validPartyMon, validPartyMon,
type PartyMon, type PartyMon,
} from "../party/pokemon"; } from "../party/pokemon";
@@ -164,7 +165,7 @@ export interface BattleView {
/** A choice the battle is waiting for, if any. */ /** A choice the battle is waiting for, if any. */
prompt?: Extract< prompt?: Extract<
BattleEffect, BattleEffect,
{ kind: "action" | "yesno" | "forget" | "stats" } { kind: "action" | "yesno" | "forget" | "stats" | "name" }
>; >;
/** Yes/No cursor, as on the original boxes. */ /** Yes/No cursor, as on the original boxes. */
selected: 0 | 1; selected: 0 | 1;
@@ -1098,6 +1099,16 @@ export class GameWorld {
trainer, trainer,
canLose: effect.canLose, canLose: effect.canLose,
text: () => this.host(PLAYER).text(), text: () => this.host(PLAYER).text(),
bag: draft.bag,
hasPokedex: draft.flags.has(ENGINE_FLAGS.pokedex),
caughtData: (level) =>
caughtData(
this.clockTimeOfDay,
level,
this.avatar === "kris" ? 1 : 0,
this.assets.map(this.mapId).landmark,
),
tutorial: effect.tutorial,
}); });
this.battle = { engine, selected: 1 }; this.battle = { engine, selected: 1 };
this.battleRun = engine.run(); this.battleRun = engine.run();
@@ -1346,6 +1357,17 @@ export class GameWorld {
battleAction(action: BattleAction) { battleAction(action: BattleAction) {
if (this.battle?.prompt?.kind === "action") this.pumpBattle(action); if (this.battle?.prompt?.kind === "action") this.pumpBattle(action);
} }
/** Name a caught Pokémon ("" keeps the species name). */
battleName(name: string) {
const prompt = this.battle?.prompt;
if (
prompt?.kind !== "name" ||
name.length > prompt.max ||
(name !== "" && !NICKNAME_PATTERN.test(name))
)
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) or the move to forget (0-3, 4 cancels). */
battleChoose(answer: number) { battleChoose(answer: number) {
const prompt = this.battle?.prompt; const prompt = this.battle?.prompt;
+77 -40
View File
@@ -127,39 +127,48 @@ function showChoice(choice: Choice | undefined) {
); );
container.firstElementChild?.setAttribute("aria-label", "Previous day"); container.firstElementChild?.setAttribute("aria-label", "Previous day");
container.children[2]?.setAttribute("aria-label", "Next day"); container.children[2]?.setAttribute("aria-label", "Next day");
} else if (choice.kind === "name") { } else if (choice.kind === "name")
// The naming screen: an empty name keeps the current one. container.replaceChildren(
const input = document.createElement("input"); ...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
input.maxLength = choice.max; );
input.placeholder = choice.current; }
input.setAttribute("aria-label", `Name (empty keeps ${choice.current})`); /** The naming screen as a text field and OK: an empty name keeps the
input.autocomplete = "off"; * current one. */
input.spellcheck = false; function nameField(
const submit = () => { current: string,
const name = input.value.trim(); max: number,
if (name && !NICKNAME_PATTERN.test(name)) { answer: (name: string) => void,
input.setCustomValidity( ): HTMLElement[] {
"Use letters, digits, spaces and simple punctuation.", const input = document.createElement("input");
); input.maxLength = max;
input.reportValidity(); input.placeholder = current;
return; input.setAttribute("aria-label", `Name (empty keeps ${current})`);
} input.autocomplete = "off";
runtime.choose(name); input.spellcheck = false;
canvas.focus({ preventScroll: true }); const submit = () => {
}; const name = input.value.trim();
input.addEventListener("input", () => input.setCustomValidity("")); if (name && !NICKNAME_PATTERN.test(name)) {
input.addEventListener("keydown", (event) => { input.setCustomValidity(
if (event.key === "Enter") submit(); "Use letters, digits, spaces and simple punctuation.",
}); );
const ok = document.createElement("button"); input.reportValidity();
ok.type = "button"; return;
ok.textContent = "OK"; }
ok.addEventListener("click", () => { answer(name);
if (session.state === "playing") submit(); canvas.focus({ preventScroll: true });
}); };
container.replaceChildren(input, ok); input.addEventListener("input", () => input.setCustomValidity(""));
queueMicrotask(() => input.focus({ preventScroll: true })); input.addEventListener("keydown", (event) => {
} if (event.key === "Enter") submit();
});
const ok = document.createElement("button");
ok.type = "button";
ok.textContent = "OK";
ok.addEventListener("click", () => {
if (session.state === "playing") submit();
});
queueMicrotask(() => input.focus({ preventScroll: true }));
return [input, ok];
} }
type MartChoice = Extract< type MartChoice = Extract<
Choice, Choice,
@@ -288,6 +297,8 @@ function drawPic(target: HTMLCanvasElement, tiles: Uint8Array, size: number, pal
const battlePics = { foe: "", player: "" }; const battlePics = { foe: "", player: "" };
let battleMenuKey = ""; let battleMenuKey = "";
let showMoves = false; let showMoves = false;
/** BattlePack's Balls pocket, the only pocket usable in battle so far. */
let showPack = false;
let battleCursor = 0; let battleCursor = 0;
let battleSelectionKey = ""; let battleSelectionKey = "";
@@ -303,8 +314,8 @@ function highlightBattleChoice() {
function handleBattleInput(button: Button): boolean { function handleBattleInput(button: Button): boolean {
const world = runtime.world; const world = runtime.world;
if (!world?.battle) return false; if (!world?.battle) return false;
if (button === "b" && showMoves) { if (button === "b" && (showMoves || showPack)) {
showMoves = false; showMoves = showPack = false;
return true; return true;
} }
const buttons = battleButtons(); const buttons = battleButtons();
@@ -330,7 +341,7 @@ function showBattle(world: GameWorld) {
screen.hidden = !view; screen.hidden = !view;
if (!view) { if (!view) {
battlePics.foe = battlePics.player = battleMenuKey = ""; battlePics.foe = battlePics.player = battleMenuKey = "";
showMoves = false; showMoves = showPack = false;
battleCursor = 0; battleCursor = 0;
battleSelectionKey = ""; battleSelectionKey = "";
return; return;
@@ -365,15 +376,18 @@ function showBattle(world: GameWorld) {
get("battle-player-hp-text").textContent = `${own.hp}/ ${own.maxHp}`; get("battle-player-hp-text").textContent = `${own.hp}/ ${own.maxHp}`;
setHp(get("battle-player-hp"), own.hp, own.maxHp); setHp(get("battle-player-hp"), own.hp, own.maxHp);
} }
get("battle-text").textContent = view.message?.text ?? "";
const prompt = view.prompt; const prompt = view.prompt;
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}`; get("battle-text").textContent =
prompt?.kind === "name" ? prompt.title : (view.message?.text ?? "");
if (prompt?.kind !== "action") showMoves = showPack = false;
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}`;
if (selectionKey !== battleSelectionKey) { if (selectionKey !== battleSelectionKey) {
battleCursor = 0; battleCursor = 0;
battleSelectionKey = selectionKey; battleSelectionKey = selectionKey;
} }
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1; if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
const key = JSON.stringify([prompt, view.selected, showMoves, own?.pp, view.message?.prompt]); const balls = world.liveState.bag.balls;
const key = JSON.stringify([prompt, view.selected, showMoves, showPack, own?.pp, balls, view.message?.prompt]);
if (key === battleMenuKey) return; if (key === battleMenuKey) return;
battleMenuKey = key; battleMenuKey = key;
const menu = get("battle-menu"); const menu = get("battle-menu");
@@ -415,6 +429,21 @@ function showBattle(world: GameWorld) {
showMoves = false; showMoves = false;
showWorld(world); showWorld(world);
}, { wide: true })); }, { wide: true }));
} else if (showPack) {
balls.forEach(({ item, quantity }, slot) =>
buttons.push(
button(data.itemName(item), () => {
showPack = false;
runtime.battleAction({ action: "item", item, slot });
}, { detail: `×${quantity}` }),
),
);
if (!balls.length)
buttons.push(button("No BALLS", () => {}, { disabled: "The BALL pocket is empty." }));
buttons.push(button("BACK", () => {
showPack = false;
showWorld(world);
}, { wide: true }));
} else { } else {
buttons.push( buttons.push(
button("FIGHT", () => { button("FIGHT", () => {
@@ -422,7 +451,10 @@ function showBattle(world: GameWorld) {
showWorld(world); showWorld(world);
}), }),
button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }), button("PKMN", () => {}, { disabled: "Switching Pokémon is not ported yet." }),
button("PACK", () => {}, { disabled: "Using items in battle is not ported yet." }), button("PACK", () => {
showPack = true;
showWorld(world);
}),
button("RUN", () => runtime.battleAction({ action: "run" })), button("RUN", () => runtime.battleAction({ action: "run" })),
); );
} }
@@ -436,6 +468,11 @@ function showBattle(world: GameWorld) {
buttons.push(button(data.moveName(move), () => runtime.battleChoose(index))), buttons.push(button(data.moveName(move), () => runtime.battleChoose(index))),
); );
buttons.push(button("CANCEL", () => runtime.battleChoose(4), { wide: true })); buttons.push(button("CANCEL", () => runtime.battleChoose(4), { wide: true }));
} else if (prompt?.kind === "name") {
menu.replaceChildren(
...nameField(prompt.current, prompt.max, (name) => runtime.battleName(name)),
);
return;
} else if (prompt?.kind === "stats") { } else if (prompt?.kind === "stats") {
prompt.labels.forEach((label, index) => prompt.labels.forEach((label, index) =>
buttons.push(button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " })), buttons.push(button(`${label} ${prompt.values[index]}`, () => {}, { disabled: " " })),
+1 -1
View File
@@ -74,5 +74,5 @@ export function renderShell(root: HTMLElement) {
<p id="save-status" class="save-status">Session backups stay in this browser.</p> <p id="save-status" class="save-status">Session backups stay in this browser.</p>
</div> </div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream"> <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 Cherrygrove</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: wild battles, items, the Poké Mart and the Pokémon Center. Catching, trainers, 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 from the battle PACK. Party and bag menus, the PC 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>`;
} }
+51
View File
@@ -111,6 +111,8 @@ test("keyboard and touch D-pad navigate battle moves, cancel and run", async ({
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible(); 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). // A real pointer press on the on-screen D-pad (synthetic events cannot be captured).
await page.locator('[data-game-button="down"]').click(); await page.locator('[data-game-button="down"]').click();
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/); await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/);
await page.keyboard.press("KeyX"); await page.keyboard.press("KeyX");
expect(await finish(page, "run")).toContain("Got away safely"); expect(await finish(page, "run")).toContain("Got away safely");
@@ -155,3 +157,52 @@ test("an unsupported battle effect closes the UI without persisting partial chan
expect(state.pokedex.seen).not.toContain(16); expect(state.pokedex.seen).not.toContain(16);
expect(state.money).toBe(3001); expect(state.money).toBe(3001);
}); });
test("battle PACK throws Poké Balls, names the catch and saves it", async ({ page }) => {
await start(page, "catch");
const menu = page.locator("#battle-menu");
const messages: string[] = [];
let named = false;
for (let guard = 0; guard < 120 && !named; guard++) {
messages.push(await page.locator("#battle-text").innerText());
const pack = menu.getByRole("button", { name: "PACK", exact: true });
const nickname = menu.getByRole("textbox");
if (await nickname.count()) {
await expect(page.locator("#battle-text")).toHaveText("PIDGEY'S\nNICKNAME?");
await nickname.fill("PIDGE");
await menu.getByRole("button", { name: "OK", exact: true }).click();
named = true;
} else if (await pack.count()) {
await pack.click();
const ball = menu.getByRole("button", { name: /POKé BALL/ });
await expect(ball).toContainText("×");
await ball.click();
} else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) {
await menu.getByRole("button", { name: "YES", exact: true }).click();
} else if (await menu.getByRole("button", { name: /Continue/ }).count()) {
await menu.getByRole("button", { name: /Continue/ }).click();
} else await page.clock.runFor(1200);
}
expect(named).toBe(true);
expect(messages.join("\n")).toContain("Gotcha!");
await finish(page);
await expect(page.locator("#battle")).toBeHidden();
const { state } = await exportState(page);
expect(state.party).toHaveLength(2);
expect(state.party[1]).toMatchObject({ species: 16, nickname: "PIDGE", level: 3 });
expect(state.pokedex.caught).toContain(16);
expect(state.bag.balls[0].quantity).toBeLessThan(10);
});
test("battle PACK closes with B and returns to the command menu", async ({ page }) => {
await start(page, "catch");
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 expect(menu.getByRole("button", { name: /POKé BALL/ })).toBeVisible();
await page.keyboard.press("KeyZ");
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible();
});
+2 -1
View File
@@ -2,7 +2,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon"; import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world"; import { GameWorld } from "../src/game/world/world";
export type BattleScenario = "win" | "loss" | "unsupported"; export type BattleScenario = "win" | "loss" | "unsupported" | "catch";
/** Test-only encounters on an existing floor: production maps stay gated. /** Test-only encounters on an existing floor: production maps stay gated.
* Everything after selection runs through the ROM's real wild-battle script. */ * Everything after selection runs through the ROM's real wild-battle script. */
@@ -28,6 +28,7 @@ export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenar
mon.pp = [10, 0, 0, 0]; mon.pp = [10, 0, 0, 0];
} }
world.state.party.push(mon); world.state.party.push(mon);
if (scenario === "catch") world.state.bag.balls = [{ item: 5, quantity: 10 }]; // POKé BALL
world.state.pokedex.seen.add(155); world.state.pokedex.seen.add(155);
world.state.pokedex.caught.add(155); world.state.pokedex.caught.add(155);
world.state.money = 3001; world.state.money = 3001;
+263
View File
@@ -0,0 +1,263 @@
import { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import {
Battle,
UnsupportedBattle,
catchRate,
type BattleInput,
type BattleOptions,
type BattleOutcome,
} from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { generatePartyMon, validPartyMon, type PartyMon } from "../src/game/party/pokemon";
import type { Bag } from "../src/game/party/bag";
import { defaultTextContext } from "../src/game/scripts/text";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe("catch rate formula", () => {
const none = { effect: 0, parameter: 0 };
const rate = (input: Partial<Parameters<typeof catchRate>[0]>) =>
catchRate({ rate: 255, ball: C.POKE_BALL, hp: 20, maxHp: 20, status: 0, held: none, ...input });
it("follows PokeBallEffect's HP term and ball multipliers", () => {
expect(rate({})).toBe(85); // 255 * (60 - 40) / 60
expect(rate({ hp: 1 })).toBe(246); // 255 * 58 / 60
expect(rate({ rate: 45, ball: C.GREAT_BALL, hp: 10 })).toBe(44); // 67 * 40 / 60
expect(rate({ rate: 200, ball: C.ULTRA_BALL })).toBe(85); // doubled, capped at 255
expect(rate({ rate: 1 })).toBe(1); // a zero quotient becomes 1
// Above 255 (3 * max HP), both terms are divided by four; a zero HP
// byte becomes 1.
expect(rate({ hp: 1, maxHp: 100 })).toBe(251); // 255 * (75 - 1) / 75
});
it("keeps the original status, held-item and high-HP bugs", () => {
expect(rate({ status: 1 })).toBe(95); // sleep: +10
expect(rate({ status: 1 << C.FRZ })).toBe(95);
for (const status of [1 << 3, 1 << 4, 1 << 6]) expect(rate({ status })).toBe(85); // PSN/BRN/PAR
expect(rate({ held: { effect: C.HELD_CATCH_CHANCE, parameter: 5 } })).toBe(90);
// Max HP 400: the low bytes wrap, so a full-HP Pokémon gets 67, not 85.
expect(rate({ hp: 400, maxHp: 400 })).toBe(67);
// Max HP 342 divides by zero in the original.
expect(rate({ hp: 342, maxHp: 342 })).toBeUndefined();
});
});
describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => {
let data: CrystalAssets["pokemon"];
beforeAll(() => {
data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
});
const RATTATA = 19,
CYNDAQUIL = 155,
TACKLE = 33;
const starter = (): PartyMon => {
const mon = generatePartyMon(data, {
species: CYNDAQUIL, level: 5, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
mon.moves = [TACKLE, 0, 0, 0];
mon.pp = [35, 0, 0, 0];
return mon;
};
const setup = (options: Partial<BattleOptions> & { seed?: number } = {}) => {
let seed = options.seed ?? 1,
draws = 0;
const bag: Bag = options.bag ?? { items: [], balls: [{ item: C.POKE_BALL, quantity: 5 }], keyItems: [] };
const pokedex = { seen: new Set<number>(), caught: new Set<number>() };
const party = options.party ?? [starter()];
const battle = new Battle({
data,
party,
pokedex,
playerId: 1234,
playerName: "CHRIS",
timeOfDay: 1,
random: () => {
draws++;
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
wild: { species: RATTATA, level: 3 },
text: () => defaultTextContext("CHRIS"),
bag,
hasPokedex: true,
caughtData: (level) => 0x4000 | (level << 8) | 1,
...options,
});
return { battle, bag, pokedex, party, draws: () => draws };
};
/** Run a battle: `actions` answer the battle menu, `answers` the Yes/No
* and naming prompts. Returns the texts and the outcome. */
const play = (
battle: Battle,
actions: BattleInput[],
answers: BattleInput[] = [],
until?: () => boolean,
) => {
const run = battle.run();
const texts: string[] = [];
let input: BattleInput;
for (let guard = 0; guard < 2000; guard++) {
const next = run.next(input);
input = undefined;
if (next.done) return { texts, outcome: next.value as BattleOutcome };
const effect = next.value;
if (effect.kind === "text") texts.push(effect.text.replace(/\n/g, " "));
else if (effect.kind === "action") {
if (until?.() || !actions.length) return { texts, outcome: undefined };
input = actions.shift();
} else if (effect.kind === "yesno" || effect.kind === "name") input = answers.shift();
else if (effect.kind === "forget") input = 4;
}
throw new Error("The battle did not finish.");
};
const ball = (slot = 0) => ({ action: "item" as const, item: C.POKE_BALL, slot });
it("catches a wild Pokémon into the party with its original moves, PP and current HP", () => {
// Weaken it first so the seeded throws succeed.
for (let seed = 1; seed < 50; seed++) {
const { battle, bag, pokedex, party } = setup({ seed });
const { texts, outcome } = play(
battle,
[{ action: "fight", move: 0 }, ball(), ball(), ball(), ball(), ball()],
[1, "RATTY"],
);
if (!outcome || party.length !== 2) continue;
expect(outcome.result).toBe(C.WIN);
expect(texts).toContain("CHRIS used the POKé BALL.");
expect(texts.some((text) => text.startsWith("Gotcha! RATTATA was caught!"))).toBe(true);
expect(texts).toContain("RATTATA's data was newly added to the #DEX.".replace("#", "POKé"));
expect(texts).toContain("Give a nickname to RATTATA?");
const caught = party[1];
const enemy = battle.enemy.mon;
expect(caught).toMatchObject({
species: RATTATA,
nickname: "RATTY",
level: 3,
otId: 1234,
ot: "CHRIS",
dvs: enemy.dvs,
hp: enemy.hp,
status: enemy.status,
item: enemy.item,
happiness: C.BASE_HAPPINESS,
caughtData: 0x4000 | (3 << 8) | 1,
exp: data.expAtLevel(data.baseData(RATTATA).growthRate, 3),
statExp: [0, 0, 0, 0, 0],
});
expect(caught.hp).toBeLessThan(caught.stats[0]);
// wWildMonMoves and wWildMonPP: full PP of the moves it appeared with.
expect(caught.pp).toEqual(caught.moves.map((move) => (move ? data.move(move).pp : 0)));
expect(validPartyMon(data, caught)).toBe(true);
expect(pokedex.caught.has(RATTATA)).toBe(true);
const thrown = texts.filter((text) => text === "CHRIS used the POKé BALL.").length;
expect(bag.balls).toEqual(thrown === 5 ? [] : [{ item: C.POKE_BALL, quantity: 5 - thrown }]);
return;
}
throw new Error("No seed caught the Pokémon.");
});
it("uses the turn when the Pokémon breaks free, with the wobble message", () => {
const messages = [
"Oh no! The POKéMON broke free!",
"Aww! It appeared to be caught!",
"Aargh! Almost had it!",
"Shoot! It was so close too!",
];
const seen = new Set<string>();
for (let seed = 1; seed < 200 && seen.size < 4; seed++) {
const { battle, bag, party } = setup({ seed });
const { texts } = play(battle, [ball()], [], () => bag.balls[0]?.quantity === 4);
if (party.length !== 1) continue;
const failure = messages.find((message) => texts.includes(message));
expect(failure, texts.join(" | ")).toBeDefined();
seen.add(failure!);
// The wild Pokémon then moves, and the ball is gone.
expect(texts.some((text) => text.startsWith("Enemy RATTATA used")), texts.join(" | ")).toBe(true);
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 4 }]);
}
expect(seen.size).toBe(4);
});
it("catches with a Master Ball without drawing a catch or wobble random", () => {
const { battle, bag, party, draws } = setup({
bag: { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] },
});
let before = 0;
const run = battle.run();
let input: BattleInput;
for (let guard = 0; guard < 100; guard++) {
const next = run.next(input);
input = undefined;
if (next.done) break;
if (next.value.kind === "action") {
before = draws();
input = { action: "item", item: C.MASTER_BALL, slot: 0 };
} else if (next.value.kind === "yesno") input = 0;
}
// Only LoadEnemyMon's wild item roll (one or two draws) remains.
expect(draws() - before).toBeGreaterThanOrEqual(1);
expect(draws() - before).toBeLessThanOrEqual(2);
expect(party).toHaveLength(2);
expect(party[1].nickname).toBe("RATTATA");
expect(bag.balls).toEqual([]);
});
it("keeps the species name for a blank nickname and skips the Pokédex text without a Pokédex", () => {
const { battle, party } = setup({
hasPokedex: false,
bag: { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] },
});
const { texts } = play(battle, [{ action: "item", item: C.MASTER_BALL, slot: 0 }], [1, " "]);
expect(party[1].nickname).toBe("RATTATA");
expect(texts.join(" ")).not.toContain("newly added");
});
it("ignores a slot that does not hold the ball", () => {
const { battle, bag } = setup();
const { outcome } = play(battle, [{ action: "item", item: C.GREAT_BALL, slot: 0 }, { action: "run" }]);
expect(outcome).toBeDefined();
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 5 }]);
});
it("refuses to send a Pokémon to the PC with a full party", () => {
const { battle } = setup({ party: Array.from({ length: 6 }, starter) });
expect(() => play(battle, [ball()])).toThrow(UnsupportedBattle);
});
it("blocks balls in trainer battles and still uses the ball and turn", () => {
const joey = generatePartyMon(data, {
species: RATTATA, level: 2, item: 0, otId: 0, ot: "JOEY", dvs: 0x9888, caughtData: 0,
});
const { battle, bag, party } = setup({
trainer: {
name: "JOEY", className: "YOUNGSTER", party: [joey], aiFlags: 0, switchFlags: 0, reward: 10,
},
});
const { texts } = play(battle, [ball()], [], () => bag.balls[0]?.quantity === 4);
expect(texts).toContain("The trainer blocked the BALL!");
expect(texts).toContain("Don't be a thief!");
expect(texts.join(" ")).not.toContain("used the POKé BALL");
expect(texts.some((text) => text.includes("RATTATA used"))).toBe(true);
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 4 }]);
expect(party).toHaveLength(1);
});
it("runs the Dude's tutorial without touching the player's party or pack", () => {
const { battle, bag, party, pokedex } = setup({ tutorial: true, bag: undefined });
const { texts, outcome } = play(battle, []);
expect(outcome?.result).toBe(C.WIN);
expect(texts).toEqual([
"Wild RATTATA appeared!",
"DUDE used the POKé BALL.",
"Gotcha! RATTATA was caught!",
]);
expect(party).toHaveLength(1);
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 5 }]);
expect(pokedex.seen.has(RATTATA)).toBe(true);
expect(pokedex.caught.has(RATTATA)).toBe(false);
expect(battle.player.mon).toBeUndefined();
});
});
+185
View File
@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon, validPartyMon } from "../src/game/party/pokemon";
import { C } from "../src/game/battle/constants";
import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const ROUTE_29 = "24:3",
DUDE = 2,
RATTATA = 19,
PIDGEY = 16;
const EVENT_GOT_A_POKEMON_FROM_ELM = 26,
EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31,
EVENT_DUDE_TALKED_TO_YOU = 65,
EVENT_LEARNED_TO_CATCH_POKEMON = 66;
describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the world", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** After returning the Egg: Route 29 is in its catching-tutorial scene. */
const tutorialWorld = (position = { x: 54, y: 8 }) => {
let seed = 3;
const world = new GameWorld(assets(), () => new Date("2026-09-24T12:00:00"), () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
});
const mon = generatePartyMon(world.assets.pokemon, {
species: 158, level: 8, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0,
});
world.state.party = [mon];
world.state.pokedex.seen.add(158);
world.state.pokedex.caught.add(158);
for (const event of [EVENT_GOT_A_POKEMON_FROM_ELM, EVENT_GAVE_MYSTERY_EGG_TO_ELM])
world.state.events.add(event);
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 5 }];
world.restore({ ...world.save(), map: ROUTE_29, ...position, direction: "left", scenes: { [ROUTE_29]: 1 } });
return world;
};
const dude = (world: GameWorld) => world.objects().find((object) => object.id === DUDE)!;
it.each([
[8, "Tutorial1"],
[9, "Tutorial2"],
])("runs the Dude's demonstration from the trigger at y=%i (%s)", (y) => {
const world = tutorialWorld({ x: 54, y });
const before = world.save();
expect(world.move("left")).toBe(true);
const pages = settle(world).join("\n").replace(/\n/g, " ");
expect(world.notice, world.diagnostic).toBe("");
expect(pages).toContain("show you how to catch POKéMON?");
expect(pages).toContain("That's how you do it");
expect(world.state.events.has(EVENT_DUDE_TALKED_TO_YOU)).toBe(true);
expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true);
expect(world.state.scenes[ROUTE_29]).toBe(0);
// Only the Pokédex sighting remains; the Dude's catch is not kept.
expect(world.state.pokedex.seen.has(RATTATA)).toBe(true);
expect(world.state.pokedex.caught.has(RATTATA)).toBe(false);
expect(world.state.party).toEqual(before.party);
expect(world.state.bag).toEqual(before.bag);
expect(dude(world)).toMatchObject({ x: 50, y: 12 });
// Nothing replays after reload.
world.restore(world.save());
expect(settle(world)).toEqual([]);
});
it("shows the battle as the Dude with automatic messages", () => {
const world = tutorialWorld();
expect(world.move("left")).toBe(true);
const messages: string[] = [];
for (let tick = 0; tick < 5000 && !world.battle; tick++) {
if (world.dialogue?.choice) world.choose(1);
else if (world.dialogue) world.advanceDialogue();
else world.tick();
}
expect(world.battle?.engine.player.mon).toBeUndefined();
// A save during the battle holds the state before the scene.
const saved = world.save();
expect(saved.events).not.toContain(EVENT_DUDE_TALKED_TO_YOU);
expect(saved.pokedex.seen).not.toContain(RATTATA);
while (world.battle) {
const text = world.battle.message?.text;
if (text && messages.at(-1) !== text) messages.push(text);
expect(world.battle.prompt).toBeUndefined();
expect(world.battle.message?.prompt).not.toBe(true);
world.tick();
}
expect(messages.map((text) => text.replace(/\n/g, " "))).toEqual([
"Wild RATTATA appeared!",
"DUDE used the POKé BALL.",
"Gotcha! RATTATA was caught!",
]);
settle(world);
expect(world.notice, world.diagnostic).toBe("");
// Reloading the mid-battle save replays the whole scene.
world.restore(saved);
expect(world.x).toBe(54);
expect(world.move("left")).toBe(true);
settle(world);
expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true);
});
it("lets the player decline, then offers the demonstration again when talked to", () => {
const world = tutorialWorld();
expect(world.move("left")).toBe(true);
const declined = settle(world, [0]).join("\n").replace(/\n/g, " ");
expect(world.notice, world.diagnostic).toBe("");
expect(declined).toContain("Oh. Fine, then");
expect(world.state.scenes[ROUTE_29]).toBe(0);
expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(false);
expect(dude(world)).toMatchObject({ x: 50, y: 12 });
// CatchingTutorialDudeScript: stand below him and talk.
world.restore({ ...world.save(), x: 50, y: 13, direction: "up" });
world.interact();
const pages = settle(world).join("\n").replace(/\n/g, " ");
expect(world.notice, world.diagnostic).toBe("");
expect(pages).toContain("That's how you do it");
expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true);
expect(world.state.pokedex.seen.has(RATTATA)).toBe(true);
});
it("catches a wild Pokémon through the world and keeps it after reload", () => {
const world = battleFixtureWorld(assets());
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }];
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
expect(world.battle).toBeDefined();
let named = false;
for (let tick = 0; tick < 20_000 && world.busy; tick++) {
const view = world.battle;
if (!view) {
if (world.dialogue) world.advanceDialogue();
else world.tick();
continue;
}
const prompt = view.prompt;
if (prompt?.kind === "action")
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
else if (prompt?.kind === "yesno") world.battleChoose(1);
else if (prompt?.kind === "name") {
expect(prompt.title).toBe("PIDGEY'S\nNICKNAME?");
world.battleName("PIDGE");
named = true;
} else if (view.message?.prompt) world.interact();
else world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
expect(named).toBe(true);
expect(world.state.party).toHaveLength(2);
const caught = world.state.party[1];
expect(caught).toMatchObject({ species: PIDGEY, nickname: "PIDGE", level: 3, ot: world.name });
// SetCaughtData: day (2 << 6 | level), Chris, New Bark Town's landmark.
expect(caught.caughtData >> 8).toBe((2 << 6) | 3);
expect(caught.caughtData & 0x80).toBe(0);
expect(caught.caughtData & 0x7f).toBe(world.assets.map("24:7").landmark);
expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true);
expect(world.state.bag.balls[0].quantity).toBeLessThan(10);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
expect(validPartyMon(world.assets.pokemon, world.state.party[1])).toBe(true);
});
it("rolls back the whole battle when a full party would need the PC", () => {
const world = battleFixtureWorld(assets());
const mon = world.state.party[0];
world.state.party = Array.from({ length: 6 }, () => structuredClone(mon));
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }];
const before = world.save();
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
for (let tick = 0; tick < 200 && world.battle; tick++) {
if (world.battle.prompt?.kind === "action")
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
else if (world.battle.message?.prompt) world.interact();
else world.tick();
}
expect(world.battle).toBeUndefined();
expect(world.notice).toMatch(/not been ported/);
expect(world.state.bag).toEqual({ ...before.bag });
expect(world.state.party).toHaveLength(6);
});
});