Open Goldenrod City, Whitney's gym and the Radio Tower quiz (#32)
Enable Goldenrod City and its buildings, the department store with its elevator and rooftop, the Underground and its rooms, the Radio Tower floors and the Route 35 gate. Port what their scripts need: the herb shop, bargain shop, pharmacy and rooftop sale marts with ROM stock, prices and text; the Name Rater; the haircut brothers' happiness tables; the Lucky Number show over the party and boxes with its saved number and Friday countdown (RTC day timers with the 140-day wrap); coin and money balance boxes; Game Corner prize registration through NewPokedexEntry; gift Pokémon to the box when the party is full, with OT, nickname and MAIL (Kenya's 15-letter first line now stores). Saves gain hallOfFame, timers and luckyNumber, with defaults for older saves, and wUndergroundSwitchPositions joins the saved script bytes. The playthrough continues to the Bicycle, Whitney, the PLAINBADGE and the radio card. SlotMachine and CardFlip are still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4ef244fc7
commit
0930b780fc
19 files changed
+1587
-117
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
||||||
|
|
||||||
**Current milestone: from the opening to Route 34.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
**Current milestone: from the opening to Goldenrod City.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||||
|
|
||||||
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
||||||
|
|
||||||
@@ -18,7 +18,9 @@ The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Poké
|
|||||||
|
|
||||||
Route 31 leads to Violet City, Sprout Tower and Falkner's gym. South of Violet, Route 32, Union Cave and Route 33 lead to Azalea Town: follow Kurt into the Slowpoke Well to stop Team Rocket, beat Bugsy, then herd the Farfetch'd in Ilex Forest for HM01 Cut and reach Route 34. Kurt makes balls from apricorns overnight. In the Ruins of Alph, solve the chamber puzzles to unlock wild Unown, which appear in their letters and fill the Unown Dex.
|
Route 31 leads to Violet City, Sprout Tower and Falkner's gym. South of Violet, Route 32, Union Cave and Route 33 lead to Azalea Town: follow Kurt into the Slowpoke Well to stop Team Rocket, beat Bugsy, then herd the Farfetch'd in Ilex Forest for HM01 Cut and reach Route 34. Kurt makes balls from apricorns overnight. In the Ruins of Alph, solve the chamber puzzles to unlock wild Unown, which appear in their letters and fill the Unown Dex.
|
||||||
|
|
||||||
Goldenrod City and beyond, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
In Goldenrod, get the Bicycle, challenge Whitney for the Plain Badge and answer the Radio Tower quiz for the radio card. Visit the department store and Underground shops, the Name Rater and haircut brothers, check the Lucky Number show, or exchange Game Corner coins for prizes. The Route 35 gate gives you Kenya with Randy's MAIL.
|
||||||
|
|
||||||
|
Game Corner minigames, the routes beyond Goldenrod, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
|||||||
| `game/scripts/screen.ts`, `game/rendering/tile-screen.ts`, `game/rom/font.ts` | `home/tilemap.asm`, `engine/gfx/load_font.asm` (`_LoadStandardFont`, `LoadFrame`) | Tile screens: a 20x18 tilemap with background palettes and sprites over it, built by a minigame or special from ROM graphics and answered with buttons. Blinking sprites follow bit 4 of the frame counter. Overlay screens (a menu box) leave the map visible around them. The standard font and the option's textbox frame load as 2bpp tiles. |
|
| `game/scripts/screen.ts`, `game/rendering/tile-screen.ts`, `game/rom/font.ts` | `home/tilemap.asm`, `engine/gfx/load_font.asm` (`_LoadStandardFont`, `LoadFrame`) | Tile screens: a 20x18 tilemap with background palettes and sprites over it, built by a minigame or special from ROM graphics and answered with buttons. Blinking sprites follow bit 4 of the frame counter. Overlay screens (a menu box) leave the map visible around them. The standard font and the option's textbox frame load as 2bpp tiles. |
|
||||||
| `game/scripts/unown-puzzle.ts` | `engine/games/unown_puzzle.asm`, `engine/events/specials.asm` (`UnownPuzzle`), `engine/gfx/cgb_layouts.asm` (`_CGB_UnownPuzzle`) | `_UnownPuzzle`: `InitUnownPuzzlePiecePositions` with Random, the d-pad rules, picking up and placing pieces, `CheckSolvedUnownPuzzle`, and the graphics (`ConvertLoadedPuzzlePieces`' doubling, the piece borders, the cursor and START/CANCEL, the OAM layouts) and palettes from the ROM. Sounds are not played. |
|
| `game/scripts/unown-puzzle.ts` | `engine/games/unown_puzzle.asm`, `engine/events/specials.asm` (`UnownPuzzle`), `engine/gfx/cgb_layouts.asm` (`_CGB_UnownPuzzle`) | `_UnownPuzzle`: `InitUnownPuzzlePiecePositions` with Random, the d-pad rules, picking up and placing pieces, `CheckSolvedUnownPuzzle`, and the graphics (`ConvertLoadedPuzzlePieces`' doubling, the piece borders, the cursor and START/CANCEL, the OAM layouts) and palettes from the ROM. Sounds are not played. |
|
||||||
| `game/scripts/ruins.ts` | `engine/events/unown_walls.asm`, `data/events/unown_walls.asm` | `DisplayUnownWords` as an overlay screen with the chamber tileset's Unown letters, `HoOhChamber` and `OmanyteChamber`. |
|
| `game/scripts/ruins.ts` | `engine/events/unown_walls.asm`, `data/events/unown_walls.asm` | `DisplayUnownWords` as an overlay screen with the chamber tileset's Unown letters, `HoOhChamber` and `OmanyteChamber`. |
|
||||||
|
| `game/scripts/goldenrod.ts`, `game/world/day-timers.ts` | `engine/events/name_rater.asm`, `haircut.asm`, `lucky_number.asm`, `engine/overworld/time.asm`, `engine/menus/intro_menu.asm` | Name Rater ownership checks, ROM haircut happiness tables, Lucky Number party and box matching with first-winner ties, saved number and Friday countdown with 140-day rollover. |
|
||||||
|
| Goldenrod shops, gifts and prizes | `engine/items/mart.asm`, `engine/pokemon/move_mon.asm` (`GivePoke`), `engine/events/specials.asm` (`GameCornerPrizeMonCheckDex`), `engine/pokedex/new_pokedex_entry.asm` | All five mart types, gift OT/nickname and MAIL, full-party gifts to the box, coin commands and balance boxes, and first-owned prize registration with two A/B pages. Boxed gifts approximate an unspecified stale enemy held item as zero. SlotMachine and CardFlip remain refused. |
|
||||||
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
||||||
|
|
||||||
Supported event opcodes are explicit in the interpreter switch. Audio commands consume their operands, but sound sequencing is pending; those opcodes do not imply complete audiovisual behavior. `pause` holds the current text or waits the original number of overworld ticks.
|
Supported event opcodes are explicit in the interpreter switch. Audio commands consume their operands, but sound sequencing is pending; those opcodes do not imply complete audiovisual behavior. `pause` holds the current text or waits the original number of overworld ticks.
|
||||||
|
|||||||
+15
-3
@@ -16,6 +16,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
|||||||
| Opening story: New Bark Town to Mr. Pokémon, the rival and the return to Elm | 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 |
|
| Route 29, Cherrygrove City, Route 30 and their buildings | Done |
|
||||||
| Route 31, Violet City, Sprout Tower and Falkner's gym | Done (Dark Cave: Violet entrance only) |
|
| Route 31, Violet City, Sprout Tower and Falkner's gym | Done (Dark Cave: Violet entrance only) |
|
||||||
|
| Goldenrod City, Whitney and local shops and gifts | Done (Game Corner minigames pending) |
|
||||||
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
||||||
| 1. Complete the overworld foundation | Done |
|
| 1. Complete the overworld foundation | Done |
|
||||||
| 2. Opening story and first battle slice | Mostly done |
|
| 2. Opening story and first battle slice | Mostly done |
|
||||||
@@ -362,11 +363,11 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- The pack's TM/HM pocket holds counts by number: TMs and HMs are received, listed with their moves, given (TMs) and taught with ABLE/NOT ABLE, the compatibility and "already knows" checks and LearnMove. A TM is used up and makes the Pokémon happier; an HM stays.
|
- The pack's TM/HM pocket holds counts by number: TMs and HMs are received, listed with their moves, given (TMs) and taught with ABLE/NOT ABLE, the compatibility and "already knows" checks and LearnMove. A TM is used up and makes the Pokémon happier; an HM stays.
|
||||||
- The Johto badges raise the battle Pokémon's stats (BadgeStatBoosts, with the PlainBadge/MineralBadge swap and the Glacier Badge register bug), compared against the unmodified routine for all 256 badge bytes in `tests/reference-badges.spec.ts`.
|
- The Johto badges raise the battle Pokémon's stats (BadgeStatBoosts, with the PlainBadge/MineralBadge swap and the Glacier Badge register bug), compared against the unmodified routine for all 256 badge bytes in `tests/reference-badges.spec.ts`.
|
||||||
- `tests/playthrough.test.ts` now continues to Route 31 (Mom's call), Sprout Tower (Elder Li's Flash), Falkner (the ZEPHYRBADGE, TM31 and the aide's call), the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. `tests/violet.test.ts` covers the other answers.
|
- `tests/playthrough.test.ts` now continues to Route 31 (Mom's call), Sprout Tower (Elder Li's Flash), Falkner (the ZEPHYRBADGE, TM31 and the aide's call), the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. `tests/violet.test.ts` covers the other answers.
|
||||||
- Kenya's MAIL is checked as the original does, but it is given at the Route 35 gate, which is closed; its text (a 15-letter first line) is one the port's MAIL model cannot yet store. Wade's calls, rematches and gifts come with the phone (board #30).
|
- Kenya's MAIL is checked as the original does; the Goldenrod increment below opens the Route 35 gate and adds storage of its gift text. Wade's calls, rematches and gifts come with the phone (board #30).
|
||||||
|
|
||||||
## Implemented: Route 32 to Route 34, the Ruins of Alph and Azalea Town (board #32)
|
## Implemented: Route 32 to Route 34, the Ruins of Alph and Azalea Town (board #32)
|
||||||
|
|
||||||
- Route 32 with its Pokémon Center, the Ruins of Alph (outside, the four chambers, their item and word rooms, the inner chamber and the research center) and both gates, Union Cave's three floors, Route 33, Azalea Town with its Pokémon Center, Mart, Kurt's house, the charcoal kiln and the gym, the Slowpoke Well, the Ilex Forest gates and forest, Route 34 and the Day-Care are open. Goldenrod City, Route 36 and the Pokémon Centers' second floor stay closed.
|
- Route 32 with its Pokémon Center, the Ruins of Alph (outside, the four chambers, their item and word rooms, the inner chamber and the research center) and both gates, Union Cave's three floors, Route 33, Azalea Town with its Pokémon Center, Mart, Kurt's house, the charcoal kiln and the gym, the Slowpoke Well, the Ilex Forest gates and forest, Route 34 and the Day-Care are open. Goldenrod City opens in the next increment below; Route 36 and the Pokémon Centers' second floor stay closed.
|
||||||
- The story runs: the Miracle Seed, Kurt's run to the Slowpoke Well, the Rocket grunts there, the Lure Ball, Bugsy and the HIVEBADGE, the rival at Azalea's west end, the Farfetch'd herding in Ilex Forest (`wFarfetchdPosition`), HM01 from the charcoal master and the Cut tree.
|
- The story runs: the Miracle Seed, Kurt's run to the Slowpoke Well, the Rocket grunts there, the Lure Ball, Bugsy and the HIVEBADGE, the rival at Azalea's west end, the Farfetch'd herding in Ilex Forest (`wFarfetchdPosition`), HM01 from the charcoal master and the Cut tree.
|
||||||
- Saved WRAM bytes that scripts read and write directly (`readmem`, `writemem`, `loadmem`) are kept by PRET label in the save's `memory` (`SAVED_WRAM`, verified by `check:reference`): so far `wFarfetchdPosition` and `wKurtApricornQuantity`.
|
- Saved WRAM bytes that scripts read and write directly (`readmem`, `writemem`, `loadmem`) are kept by PRET label in the save's `memory` (`SAVED_WRAM`, verified by `check:reference`): so far `wFarfetchdPosition` and `wKurtApricornQuantity`.
|
||||||
- Kurt makes balls: `SelectApricornForKurt` lists the apricorns in `ApricornBalls` order with their counts, asks how many with the mart's 1-99 stepper and takes them; `verbosegiveitemvar` hands over the balls the next day (`VAR_KURT_APRICORNS`, `ENGINE_KURT_MAKING_BALLS` cleared by the daily reset).
|
- Kurt makes balls: `SelectApricornForKurt` lists the apricorns in `ApricornBalls` order with their counts, asks how many with the mart's 1-99 stepper and takes them; `verbosegiveitemvar` hands over the balls the next day (`VAR_KURT_APRICORNS`, `ENGINE_KURT_MAKING_BALLS` cleared by the daily reset).
|
||||||
@@ -376,11 +377,22 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- `tests/playthrough.test.ts` continues through Route 32, Union Cave, Azalea Town, the Slowpoke Well, Bugsy, the rival and Ilex Forest to the Route 34 gate; its helpers now live in `tests/playthrough-driver.ts`, and `PLAYTHROUGH_SAVES` keeps each stage's save. `tests/ruins.test.ts` solves the Kabuto puzzle through the world and catches Unown; `tests/azalea.test.ts` covers Kurt's apricorns.
|
- `tests/playthrough.test.ts` continues through Route 32, Union Cave, Azalea Town, the Slowpoke Well, Bugsy, the rival and Ilex Forest to the Route 34 gate; its helpers now live in `tests/playthrough-driver.ts`, and `PLAYTHROUGH_SAVES` keeps each stage's save. `tests/ruins.test.ts` solves the Kabuto puzzle through the world and catches Unown; `tests/azalea.test.ts` covers Kurt's apricorns.
|
||||||
- Still closed or refused here: the Day-Care's deposits and Eggs (board #29), the radios' `MapRadio` and trainers' rematch counts (board #30), the research center's `UnownPrinter` (it needs all 26 letters), the Pokédex's Unown mode, the GS Ball and Celebi shrine (not reachable without the mobile event), and sound.
|
- Still closed or refused here: the Day-Care's deposits and Eggs (board #29), the radios' `MapRadio` and trainers' rematch counts (board #30), the research center's `UnownPrinter` (it needs all 26 letters), the Pokédex's Unown mode, the GS Ball and Celebi shrine (not reachable without the mobile event), and sound.
|
||||||
|
|
||||||
|
## Implemented: Goldenrod City (board #32, increment B)
|
||||||
|
|
||||||
|
- Goldenrod City and its buildings, the department store and elevator, the Underground and its connecting rooms, the Radio Tower and the Route 35 gate are open. The continuous playthrough gets the Bicycle, trains on Route 34, beats Whitney, collects the PLAINBADGE and TM45, and answers the radio card quiz.
|
||||||
|
- The herb shop, bargain shop, pharmacy and rooftop sale use their ROM stock, prices and dialogue. Bargain stock sells once per visit and a purchase closes the shop for the day; the rooftop sale changes stock after the Hall of Fame.
|
||||||
|
- The Name Rater checks the Pokémon's OT and ID. The haircut brothers use the ROM happiness tables. The Lucky Number show checks the party and all boxes, keeps the first winner in a prize tier, and saves its number and Friday countdown with the RTC's 140-day wrap.
|
||||||
|
- Kenya arrives with Randy's name, ID and MAIL. Gift Pokémon can go to the current box when the party is full. Coin vendors and prize menus show the balance; a newly owned prize species shows both Pokédex entry pages before the nickname prompt. An already caught species skips registration.
|
||||||
|
- Saves gain `hallOfFame`, `timers` and `luckyNumber`, with defaults for older saves. `wUndergroundSwitchPositions` joins the saved script bytes. Goldenrod's Pokémon Center handles the GS Ball availability check; other Battle Tower actions remain refused.
|
||||||
|
- `tests/goldenrod.test.ts` covers shops, naming, Kenya and save reload, prize registration, Lucky Number party/box winners, save validation and boxed gifts. `tests/day-timers.test.ts` covers countdown expiry and rollover without a ROM. The 20-stage playthrough reaches Whitney and the radio card.
|
||||||
|
- Local validation (2026-10-06): 676 unit tests passed, 3 skipped (scratch tests excluded); without a ROM, 163 passed and 516 skipped. Both builds, reference/profile checks, vendor integrity and release isolation passed. Browser checks: 157 passed, 21 skipped, 12 known WebKit menu failures (#70). CI and physical-device acceptance were not run.
|
||||||
|
- Remaining: SlotMachine and CardFlip, radio broadcasts and rematches (#30), breeding (#29), the later Rocket takeover, routes beyond the Route 35 gate, mobile/link features and audio. This increment does not complete #32 or start #33. Boxed gifts still approximate the original stale enemy held-item byte as zero when no item is specified.
|
||||||
|
|
||||||
## Native battles: current state
|
## Native battles: current state
|
||||||
|
|
||||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
||||||
|
|
||||||
This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, story progression beyond Violet City and Falkner's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, online Link, friends and real-world steps come later.
|
This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, story progression beyond Goldenrod City and Whitney's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, online Link, friends and real-world steps come later.
|
||||||
|
|
||||||
## 1. Complete the overworld foundation
|
## 1. Complete the overworld foundation
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -24,6 +24,15 @@ const MAIL_CHARACTERS = /^[A-Za-z0-9 ,?!.\-/'&()“”[\]:;é♂♀¥…×]*$/u;
|
|||||||
|
|
||||||
/** A message the mail entry could have written. */
|
/** A message the mail entry could have written. */
|
||||||
export function validMailMessage(message: unknown): message is string {
|
export function validMailMessage(message: unknown): message is string {
|
||||||
|
return (
|
||||||
|
storedMailMessage(message) &&
|
||||||
|
(message.split("\n").length === 1 ||
|
||||||
|
[...message.split("\n")[0]].length === MAIL_LINE_LENGTH)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** A message a MAIL can hold: the entry's, or a gift's (Kenya's MAIL
|
||||||
|
* breaks its first line a character early, within the same 32 bytes). */
|
||||||
|
function storedMailMessage(message: unknown): message is string {
|
||||||
if (typeof message !== "string") return false;
|
if (typeof message !== "string") return false;
|
||||||
const lines = message.split("\n");
|
const lines = message.split("\n");
|
||||||
return (
|
return (
|
||||||
@@ -31,8 +40,7 @@ export function validMailMessage(message: unknown): message is string {
|
|||||||
lines.every(
|
lines.every(
|
||||||
(line) =>
|
(line) =>
|
||||||
[...line].length <= MAIL_LINE_LENGTH && MAIL_CHARACTERS.test(line),
|
[...line].length <= MAIL_LINE_LENGTH && MAIL_CHARACTERS.test(line),
|
||||||
) &&
|
)
|
||||||
(lines.length === 1 || [...lines[0]].length === MAIL_LINE_LENGTH)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +59,7 @@ export function validMail(data: PokemonData, value: unknown): value is Mail {
|
|||||||
return (
|
return (
|
||||||
Boolean(mail) &&
|
Boolean(mail) &&
|
||||||
typeof mail === "object" &&
|
typeof mail === "object" &&
|
||||||
validMailMessage(mail.message) &&
|
storedMailMessage(mail.message) &&
|
||||||
typeof mail.author === "string" &&
|
typeof mail.author === "string" &&
|
||||||
/^[A-Za-z0-9 -]{1,7}$/.test(mail.author) &&
|
/^[A-Za-z0-9 -]{1,7}$/.test(mail.author) &&
|
||||||
integer(mail.authorId, 0, 0xffff) &&
|
integer(mail.authorId, 0, 0xffff) &&
|
||||||
|
|||||||
@@ -1072,6 +1072,67 @@ export const JOHTO = {
|
|||||||
SelectApricornForKurt: 0x88018,
|
SelectApricornForKurt: 0x88018,
|
||||||
"Kurt_PrintTextWhichApricorn.WhichApricornText": 0x88007,
|
"Kurt_PrintTextWhichApricorn.WhichApricornText": 0x88007,
|
||||||
"Kurt_PrintTextHowMany.HowManyShouldIMakeText": 0x88013,
|
"Kurt_PrintTextHowMany.HowManyShouldIMakeText": 0x88013,
|
||||||
|
HerbShopLadyIntroText: 0x15e4a,
|
||||||
|
HerbalLadyHowManyText: 0x15e4f,
|
||||||
|
HerbalLadyFinalPriceText: 0x15e54,
|
||||||
|
HerbalLadyThanksText: 0x15e59,
|
||||||
|
HerbalLadyPackFullText: 0x15e5e,
|
||||||
|
HerbalLadyNoMoneyText: 0x15e63,
|
||||||
|
HerbalLadyComeAgainText: 0x15e68,
|
||||||
|
BargainShopIntroText: 0x15e6d,
|
||||||
|
BargainShopFinalPriceText: 0x15e72,
|
||||||
|
BargainShopThanksText: 0x15e77,
|
||||||
|
BargainShopPackFullText: 0x15e7c,
|
||||||
|
BargainShopSoldOutText: 0x15e81,
|
||||||
|
BargainShopNoFundsText: 0x15e86,
|
||||||
|
BargainShopComeAgainText: 0x15e8b,
|
||||||
|
PharmacyIntroText: 0x15e90,
|
||||||
|
PharmacyHowManyText: 0x15e95,
|
||||||
|
PharmacyFinalPriceText: 0x15e9a,
|
||||||
|
PharmacyThanksText: 0x15e9f,
|
||||||
|
PharmacyPackFullText: 0x15ea4,
|
||||||
|
PharmacyNoMoneyText: 0x15ea9,
|
||||||
|
PharmacyComeAgainText: 0x15eae,
|
||||||
|
RooftopSaleMart1: 0x15aee,
|
||||||
|
RooftopSaleMart2: 0x15aff,
|
||||||
|
BargainShopData: 0x15c51,
|
||||||
|
DefaultMart: 0x16214,
|
||||||
|
NameRater: 0xc2b9,
|
||||||
|
GameCornerPrizeMonCheckDex: 0xc230,
|
||||||
|
NameRaterHelloText: 0xfb80f,
|
||||||
|
NameRaterWhichMonText: 0xfb814,
|
||||||
|
NameRaterBetterNameText: 0xfb819,
|
||||||
|
NameRaterWhatNameText: 0xfb81e,
|
||||||
|
NameRaterFinishedText: 0xfb823,
|
||||||
|
NameRaterComeAgainText: 0xfb828,
|
||||||
|
NameRaterPerfectNameText: 0xfb82d,
|
||||||
|
NameRaterEggText: 0xfb832,
|
||||||
|
NameRaterSameNameText: 0xfb837,
|
||||||
|
NameRaterNamedText: 0xfb83c,
|
||||||
|
OlderHaircutBrother: 0x7413,
|
||||||
|
YoungerHaircutBrother: 0x7418,
|
||||||
|
DaisysGrooming: 0x741d,
|
||||||
|
BillsGrandfather: 0x73f7,
|
||||||
|
HappinessData_OlderHaircutBrother: 0x7459,
|
||||||
|
HappinessData_YoungerHaircutBrother: 0x7462,
|
||||||
|
HappinessData_DaisysGrooming: 0x746b,
|
||||||
|
GetFirstPokemonHappiness: 0x718d,
|
||||||
|
CheckFirstMonIsEgg: 0x71ac,
|
||||||
|
PlaceMoneyTopRight: 0x24ae8,
|
||||||
|
DisplayCoinCaseBalance: 0x24b25,
|
||||||
|
DisplayMoneyAndCoinBalance: 0x24b4e,
|
||||||
|
MoneyString: 0x24b83,
|
||||||
|
CoinString: 0x24b89,
|
||||||
|
WasSentToBillsPCText: 0xe3d9,
|
||||||
|
CheckForLuckyNumberWinners: 0x4d87a,
|
||||||
|
CheckLuckyNumberShowFlag: 0xc434,
|
||||||
|
ResetLuckyNumberShowFlag: 0xc422,
|
||||||
|
PrintTodaysLuckyNumber: 0x4d9d3,
|
||||||
|
"CheckForLuckyNumberWinners.LuckyNumberMatchPartyText": 0x4d9c9,
|
||||||
|
"CheckForLuckyNumberWinners.LuckyNumberMatchPCText": 0x4d9ce,
|
||||||
|
BattleTowerAction: 0x170687,
|
||||||
|
GameboyCheck: 0xc478,
|
||||||
|
UnlockMysteryGift: 0x1050b9,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Saved WRAM bytes that map scripts read and write directly (readmem,
|
/** Saved WRAM bytes that map scripts read and write directly (readmem,
|
||||||
@@ -1080,6 +1141,7 @@ export const JOHTO = {
|
|||||||
export const SAVED_WRAM = {
|
export const SAVED_WRAM = {
|
||||||
wFarfetchdPosition: 0xd964,
|
wFarfetchdPosition: 0xd964,
|
||||||
wKurtApricornQuantity: 0xdca4,
|
wKurtApricornQuantity: 0xdca4,
|
||||||
|
wUndergroundSwitchPositions: 0xd963,
|
||||||
} as const;
|
} as const;
|
||||||
export type SavedByte = keyof typeof SAVED_WRAM;
|
export type SavedByte = keyof typeof SAVED_WRAM;
|
||||||
/** Work RAM the native port reads by address but does not save, keyed by
|
/** Work RAM the native port reads by address but does not save, keyed by
|
||||||
@@ -1168,5 +1230,34 @@ export const NATIVE_MAPS: Record<string, string> = {
|
|||||||
"11:23": "Route 34 gate",
|
"11:23": "Route 34 gate",
|
||||||
"11:1": "Route 34",
|
"11:1": "Route 34",
|
||||||
"11:24": "Day-Care",
|
"11:24": "Day-Care",
|
||||||
|
"11:2": "Goldenrod City",
|
||||||
|
"11:3": "Goldenrod Gym",
|
||||||
|
"11:4": "Bike shop",
|
||||||
|
"11:5": "Happiness rater’s house",
|
||||||
|
"11:6": "Bill’s house",
|
||||||
|
"11:7": "Magnet Train station",
|
||||||
|
"11:8": "Flower shop",
|
||||||
|
"11:9": "PP speech house",
|
||||||
|
"11:10": "Name rater’s house",
|
||||||
|
"11:11": "Dept. Store 1F",
|
||||||
|
"11:12": "Dept. Store 2F",
|
||||||
|
"11:13": "Dept. Store 3F",
|
||||||
|
"11:14": "Dept. Store 4F",
|
||||||
|
"11:15": "Dept. Store 5F",
|
||||||
|
"11:16": "Dept. Store 6F",
|
||||||
|
"11:17": "Dept. Store elevator",
|
||||||
|
"11:18": "Dept. Store rooftop",
|
||||||
|
"11:19": "Game Corner",
|
||||||
|
"11:20": "Pokémon Center",
|
||||||
|
"3:53": "Goldenrod Underground",
|
||||||
|
"3:54": "Underground switch room",
|
||||||
|
"3:55": "Dept. Store B1F",
|
||||||
|
"3:56": "Underground warehouse",
|
||||||
|
"3:17": "Radio Tower 1F",
|
||||||
|
"3:18": "Radio Tower 2F",
|
||||||
|
"3:19": "Radio Tower 3F",
|
||||||
|
"3:20": "Radio Tower 4F",
|
||||||
|
"3:21": "Radio Tower 5F",
|
||||||
|
"10:14": "Route 35 gate",
|
||||||
};
|
};
|
||||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { BATTLE, JOHTO, RAM, SYMBOLS } from "../rom/profile";
|
||||||
|
import { changedHappiness } from "../battle/battle";
|
||||||
|
import { readString } from "./text";
|
||||||
|
import { NICKNAME_PATTERN } from "../party/pokemon";
|
||||||
|
import type { Gen, SpecialContext } from "./special-context";
|
||||||
|
import {
|
||||||
|
checkCountdown,
|
||||||
|
daysUntilNextFriday,
|
||||||
|
initCountdown,
|
||||||
|
rtcDay,
|
||||||
|
} from "../world/day-timers";
|
||||||
|
import { OW } from "../world/constants";
|
||||||
|
|
||||||
|
/** HAPPINESS_THRESHOLD_1 and _2 (constants/pokemon_data_constants.asm). */
|
||||||
|
const HAPPINESS_THRESHOLD_1 = 100,
|
||||||
|
HAPPINESS_THRESHOLD_2 = 200;
|
||||||
|
|
||||||
|
/** ChangeHappiness outside battle for party slot `index`: HappinessChanges
|
||||||
|
* by tier; Eggs are skipped. */
|
||||||
|
export function changeHappiness(
|
||||||
|
context: SpecialContext,
|
||||||
|
index: number,
|
||||||
|
action: number,
|
||||||
|
) {
|
||||||
|
const mon = context.state.party[index];
|
||||||
|
if (!mon || mon.egg) return;
|
||||||
|
const tier =
|
||||||
|
mon.happiness < HAPPINESS_THRESHOLD_1
|
||||||
|
? 0
|
||||||
|
: mon.happiness < HAPPINESS_THRESHOLD_2
|
||||||
|
? 1
|
||||||
|
: 2;
|
||||||
|
mon.happiness = changedHappiness(
|
||||||
|
mon.happiness,
|
||||||
|
context.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GetFirstPokemonHappiness: the first Pokémon that is not an Egg; its
|
||||||
|
* happiness, and its species name in wStringBuffer1 and 3. */
|
||||||
|
export function firstPokemonHappiness(context: SpecialContext): number {
|
||||||
|
const mon = context.state.party.find((member) => !member.egg);
|
||||||
|
if (!mon) return 0;
|
||||||
|
const name = context.data.speciesName(mon.species);
|
||||||
|
context.strings[RAM.stringBuffer1] = context.strings[RAM.stringBuffer3] =
|
||||||
|
name;
|
||||||
|
return mon.happiness;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CheckFirstMonIsEgg: TRUE when the first party slot holds an Egg, with
|
||||||
|
* its species name (EGG) in wStringBuffer1 and 3. */
|
||||||
|
export function checkFirstMonIsEgg(context: SpecialContext): number {
|
||||||
|
const first = context.state.party[0];
|
||||||
|
if (!first) return 0;
|
||||||
|
const name = first.egg
|
||||||
|
? context.data.speciesName(EGG)
|
||||||
|
: context.data.speciesName(first.species);
|
||||||
|
context.strings[RAM.stringBuffer1] = context.strings[RAM.stringBuffer3] =
|
||||||
|
name;
|
||||||
|
return Number(Boolean(first.egg));
|
||||||
|
}
|
||||||
|
/** EGG (constants/pokemon_constants.asm). */
|
||||||
|
const EGG = 0xfd;
|
||||||
|
|
||||||
|
/** HaircutOrGrooming (engine/events/haircut.asm): a Pokémon from the
|
||||||
|
* party; an Egg answers 1. Otherwise one Random byte picks a row of the
|
||||||
|
* happiness table (cumulative chances, -1 for the rest), whose second
|
||||||
|
* byte is the answer and third the HAPPINESS_* change. B answers 0. */
|
||||||
|
export function* haircut(context: SpecialContext, table: number): Gen<number> {
|
||||||
|
const slot = yield* context.selectMon();
|
||||||
|
if (slot < 0) return 0;
|
||||||
|
const mon = context.state.party[slot];
|
||||||
|
if (mon.egg) return 1;
|
||||||
|
context.strings[RAM.stringBuffer1] = context.strings[RAM.stringBuffer3] =
|
||||||
|
mon.nickname;
|
||||||
|
const r = context.reader;
|
||||||
|
let roll = context.random();
|
||||||
|
let row = table;
|
||||||
|
// .loop: subtract each chance until one goes below zero.
|
||||||
|
for (;;) {
|
||||||
|
roll -= r.byte(row);
|
||||||
|
if (roll < 0) break;
|
||||||
|
row += 3;
|
||||||
|
}
|
||||||
|
changeHappiness(context, slot, r.byte(row + 2));
|
||||||
|
return r.byte(row + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** _NameRater (engine/events/name_rater.asm): yes, a Pokémon from the
|
||||||
|
* party, then a new nickname for one the player caught (its OT and ID are
|
||||||
|
* the player's); an Egg or a traded Pokémon is refused. An empty name or
|
||||||
|
* the same name keeps the old one. */
|
||||||
|
export function* nameRater(context: SpecialContext): Gen {
|
||||||
|
const { state } = context;
|
||||||
|
const finish = function* (text: number): Gen {
|
||||||
|
context.write(text);
|
||||||
|
yield* context.flush();
|
||||||
|
};
|
||||||
|
context.write(JOHTO.NameRaterHelloText);
|
||||||
|
if (!(yield { kind: "yesno", pages: context.take() }))
|
||||||
|
return yield* finish(JOHTO.NameRaterComeAgainText);
|
||||||
|
context.write(JOHTO.NameRaterWhichMonText);
|
||||||
|
const slot = yield* context.selectMon();
|
||||||
|
if (slot < 0) return yield* finish(JOHTO.NameRaterComeAgainText);
|
||||||
|
const mon = state.party[slot];
|
||||||
|
if (mon.egg) return yield* finish(JOHTO.NameRaterEggText);
|
||||||
|
context.strings[RAM.stringBuffer1] = mon.nickname;
|
||||||
|
// CheckIfMonIsYourOT: the OT name, then the ID.
|
||||||
|
const player = context.player();
|
||||||
|
if (mon.ot !== player.name || mon.otId !== player.id)
|
||||||
|
return yield* finish(JOHTO.NameRaterPerfectNameText);
|
||||||
|
context.write(JOHTO.NameRaterBetterNameText);
|
||||||
|
if (!(yield { kind: "yesno", pages: context.take() }))
|
||||||
|
return yield* finish(JOHTO.NameRaterComeAgainText);
|
||||||
|
context.write(JOHTO.NameRaterWhatNameText);
|
||||||
|
yield* context.flush();
|
||||||
|
// _NamingScreen with NAME_MON: the species' name, "'S" and "NICKNAME?".
|
||||||
|
const r = context.reader;
|
||||||
|
let second = SYMBOLS.nicknameStrings;
|
||||||
|
while (r.byte(second++) !== 0x50);
|
||||||
|
const name = yield {
|
||||||
|
kind: "name",
|
||||||
|
pages: [
|
||||||
|
`${context.data.speciesName(mon.species)}${readString(r, SYMBOLS.nicknameStrings, context.text())}\n${readString(r, second, context.text())}`,
|
||||||
|
],
|
||||||
|
current: mon.nickname,
|
||||||
|
max: 10,
|
||||||
|
};
|
||||||
|
if (typeof name !== "string" || (name && !NICKNAME_PATTERN.test(name)))
|
||||||
|
throw new Error("Invalid nickname.");
|
||||||
|
// IsNewNameEmpty (only spaces) and CompareNewToOld keep the old name.
|
||||||
|
let result: number = JOHTO.NameRaterSameNameText;
|
||||||
|
if (!/^ *$/.test(name) && name !== mon.nickname) {
|
||||||
|
mon.nickname = name;
|
||||||
|
result = JOHTO.NameRaterFinishedText;
|
||||||
|
}
|
||||||
|
context.strings[RAM.stringBuffer1] = mon.nickname;
|
||||||
|
context.write(JOHTO.NameRaterNamedText);
|
||||||
|
context.write(result);
|
||||||
|
yield* context.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CheckLuckyNumberShowFlag: whether wLuckyNumberDayTimer has run out. */
|
||||||
|
export function checkLuckyNumberShowFlag(context: SpecialContext): number {
|
||||||
|
const { done, timer } = checkCountdown(
|
||||||
|
context.state.timers.luckyNumber,
|
||||||
|
context.today(),
|
||||||
|
);
|
||||||
|
context.state.timers.luckyNumber = timer;
|
||||||
|
return Number(done);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ResetLuckyNumberShowFlag: the countdown restarts until next Friday,
|
||||||
|
* the show opens again, and LoadOrRegenerateLuckyIDNumber draws a new
|
||||||
|
* number unless one was drawn today (two Random bytes, low byte first). */
|
||||||
|
export function resetLuckyNumberShowFlag(context: SpecialContext) {
|
||||||
|
const { state } = context,
|
||||||
|
today = context.today();
|
||||||
|
state.timers.luckyNumber = initCountdown(
|
||||||
|
daysUntilNextFriday(context.weekday()),
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
state.flags.delete(OW.ENGINE_LUCKY_NUMBER_SHOW);
|
||||||
|
const day = rtcDay(today) + 1;
|
||||||
|
if (state.luckyNumber.day === day) return;
|
||||||
|
const low = context.random(),
|
||||||
|
high = context.random();
|
||||||
|
state.luckyNumber = { id: (high << 8) | low, day };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PrintTodaysLuckyNumber: five digits with leading zeros in
|
||||||
|
* wStringBuffer3. */
|
||||||
|
export function printTodaysLuckyNumber(context: SpecialContext) {
|
||||||
|
context.strings[RAM.stringBuffer3] = String(
|
||||||
|
context.state.luckyNumber.id,
|
||||||
|
).padStart(5, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CheckForLuckyNumberWinners (engine/events/lucky_number.asm): every
|
||||||
|
* Pokémon that is not an Egg, in the party, then the current box, then
|
||||||
|
* the other boxes, compares its OT ID's five digits from the right with
|
||||||
|
* the lucky number. 5 digits win 1st place, 3 or 4 2nd, 2 3rd; a later
|
||||||
|
* match only replaces the named species if its prize tier is better.
|
||||||
|
* A winning replacement in a box makes the text say the PC. Returns the
|
||||||
|
* place, or 0. */
|
||||||
|
export function* checkForLuckyNumberWinners(
|
||||||
|
context: SpecialContext,
|
||||||
|
): Gen<number> {
|
||||||
|
const { state } = context;
|
||||||
|
if (!state.party.length) return 0;
|
||||||
|
const lucky = String(state.luckyNumber.id).padStart(5, "0");
|
||||||
|
let place = 0,
|
||||||
|
species = 0,
|
||||||
|
inBox = false;
|
||||||
|
const compare = (mon: { species: number; otId: number; egg?: boolean }) => {
|
||||||
|
if (mon.egg) return false;
|
||||||
|
const id = String(mon.otId).padStart(5, "0");
|
||||||
|
let digits = 0;
|
||||||
|
while (digits < 5 && id[4 - digits] === lucky[4 - digits]) digits++;
|
||||||
|
const tier = digits === 5 ? 1 : digits >= 3 ? 2 : digits === 2 ? 3 : 0;
|
||||||
|
if (!tier || (place && place <= tier)) return false;
|
||||||
|
place = tier;
|
||||||
|
species = mon.species;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
for (const mon of state.party) compare(mon);
|
||||||
|
const boxes = state.storage.boxes,
|
||||||
|
current = state.storage.current;
|
||||||
|
for (const mon of boxes[current]) if (compare(mon)) inBox = true;
|
||||||
|
boxes.forEach((box, index) => {
|
||||||
|
if (index === current) return;
|
||||||
|
for (const mon of box) if (compare(mon)) inBox = true;
|
||||||
|
});
|
||||||
|
if (!place) return 0;
|
||||||
|
context.strings[RAM.stringBuffer1] = context.data.speciesName(species);
|
||||||
|
context.write(
|
||||||
|
inBox
|
||||||
|
? JOHTO["CheckForLuckyNumberWinners.LuckyNumberMatchPCText"]
|
||||||
|
: JOHTO["CheckForLuckyNumberWinners.LuckyNumberMatchPartyText"],
|
||||||
|
);
|
||||||
|
yield* context.flush();
|
||||||
|
return place;
|
||||||
|
}
|
||||||
+491
-87
@@ -23,6 +23,7 @@ import {
|
|||||||
type SavedByte,
|
type SavedByte,
|
||||||
} from "../rom/profile";
|
} from "../rom/profile";
|
||||||
import { PokemonData } from "../rom/pokemon";
|
import { PokemonData } from "../rom/pokemon";
|
||||||
|
import { PokedexData } from "../rom/pokedex";
|
||||||
import { C } from "../battle/constants";
|
import { C } from "../battle/constants";
|
||||||
import type { BugContest } from "../battle/battle";
|
import type { BugContest } from "../battle/battle";
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +41,8 @@ import {
|
|||||||
currentBox,
|
currentBox,
|
||||||
emptyStorage,
|
emptyStorage,
|
||||||
receivePCItem,
|
receivePCItem,
|
||||||
|
restorePP,
|
||||||
|
toBoxMon,
|
||||||
type PokemonStorage,
|
type PokemonStorage,
|
||||||
} from "../party/storage";
|
} from "../party/storage";
|
||||||
import {
|
import {
|
||||||
@@ -64,10 +67,24 @@ import type { FlyMapView } from "./fly-map";
|
|||||||
import type { TileScreen } from "./screen";
|
import type { TileScreen } from "./screen";
|
||||||
import { unownPuzzle } from "./unown-puzzle";
|
import { unownPuzzle } from "./unown-puzzle";
|
||||||
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
||||||
|
import {
|
||||||
|
checkFirstMonIsEgg,
|
||||||
|
checkForLuckyNumberWinners,
|
||||||
|
checkLuckyNumberShowFlag,
|
||||||
|
firstPokemonHappiness,
|
||||||
|
haircut,
|
||||||
|
nameRater,
|
||||||
|
printTodaysLuckyNumber,
|
||||||
|
resetLuckyNumberShowFlag,
|
||||||
|
} from "./goldenrod";
|
||||||
|
import type { SpecialContext } from "./special-context";
|
||||||
import type { Palette } from "../world/types";
|
import type { Palette } from "../world/types";
|
||||||
|
import type { DayTimer } from "../world/day-timers";
|
||||||
|
/** The RTC day countdowns the native state keeps. */
|
||||||
|
export type DayTimerName = "luckyNumber";
|
||||||
import { rockMonEncounter, treeMonEncounter } from "./treemons";
|
import { rockMonEncounter, treeMonEncounter } from "./treemons";
|
||||||
import type { TrainerCardView } from "./trainer-card";
|
import type { TrainerCardView } from "./trainer-card";
|
||||||
import type { PokedexView } from "./pokedex";
|
import { Pokedex, type PokedexView } from "./pokedex";
|
||||||
import {
|
import {
|
||||||
defaultOptions,
|
defaultOptions,
|
||||||
type GameOptions,
|
type GameOptions,
|
||||||
@@ -139,6 +156,8 @@ export interface ScriptState {
|
|||||||
* (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */
|
* (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */
|
||||||
flash: boolean;
|
flash: boolean;
|
||||||
noWildEncounters: boolean;
|
noWildEncounters: boolean;
|
||||||
|
/** STATUSFLAGS_HALL_OF_FAME_F: the League has been beaten. */
|
||||||
|
hallOfFame: boolean;
|
||||||
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
|
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
|
||||||
* and wRegisteredItem: the item Select uses; zeros when none. */
|
* and wRegisteredItem: the item Select uses; zeros when none. */
|
||||||
registeredItem: { which: number; item: number };
|
registeredItem: { which: number; item: number };
|
||||||
@@ -159,6 +178,10 @@ export interface ScriptState {
|
|||||||
* wFirstUnownSeen, the letter of the first Unown met in battle. */
|
* wFirstUnownSeen, the letter of the first Unown met in battle. */
|
||||||
unownDex: number[];
|
unownDex: number[];
|
||||||
firstUnownSeen: number;
|
firstUnownSeen: number;
|
||||||
|
/** The RTC day countdowns by name (wLuckyNumberDayTimer is `luckyNumber`). */
|
||||||
|
timers: Partial<Record<DayTimerName, DayTimer>>;
|
||||||
|
/** sLuckyIDNumber and sLuckyNumberDay (wCurDay + 1 when drawn). */
|
||||||
|
luckyNumber: { id: number; day: number };
|
||||||
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
|
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
|
||||||
options: GameOptions;
|
options: GameOptions;
|
||||||
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
|
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
|
||||||
@@ -193,6 +216,7 @@ export const emptyScriptState = (): ScriptState => ({
|
|||||||
digWarp: null,
|
digWarp: null,
|
||||||
flash: false,
|
flash: false,
|
||||||
noWildEncounters: false,
|
noWildEncounters: false,
|
||||||
|
hallOfFame: false,
|
||||||
registeredItem: { which: 0, item: 0 },
|
registeredItem: { which: 0, item: 0 },
|
||||||
mailbox: [],
|
mailbox: [],
|
||||||
repel: 0,
|
repel: 0,
|
||||||
@@ -202,6 +226,8 @@ export const emptyScriptState = (): ScriptState => ({
|
|||||||
memory: {},
|
memory: {},
|
||||||
unownDex: [],
|
unownDex: [],
|
||||||
firstUnownSeen: 0,
|
firstUnownSeen: 0,
|
||||||
|
timers: {},
|
||||||
|
luckyNumber: { id: 0, day: 0 },
|
||||||
options: defaultOptions(),
|
options: defaultOptions(),
|
||||||
});
|
});
|
||||||
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||||
@@ -234,6 +260,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
|||||||
digWarp: state.digWarp && { ...state.digWarp },
|
digWarp: state.digWarp && { ...state.digWarp },
|
||||||
flash: state.flash,
|
flash: state.flash,
|
||||||
noWildEncounters: state.noWildEncounters,
|
noWildEncounters: state.noWildEncounters,
|
||||||
|
hallOfFame: state.hallOfFame,
|
||||||
registeredItem: { ...state.registeredItem },
|
registeredItem: { ...state.registeredItem },
|
||||||
mailbox: state.mailbox.map(copyMail),
|
mailbox: state.mailbox.map(copyMail),
|
||||||
repel: state.repel,
|
repel: state.repel,
|
||||||
@@ -243,6 +270,10 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
|||||||
memory: { ...state.memory },
|
memory: { ...state.memory },
|
||||||
unownDex: [...state.unownDex],
|
unownDex: [...state.unownDex],
|
||||||
firstUnownSeen: state.firstUnownSeen,
|
firstUnownSeen: state.firstUnownSeen,
|
||||||
|
timers: Object.fromEntries(
|
||||||
|
Object.entries(state.timers).map(([name, timer]) => [name, { ...timer }]),
|
||||||
|
),
|
||||||
|
luckyNumber: { ...state.luckyNumber },
|
||||||
options: { ...state.options },
|
options: { ...state.options },
|
||||||
...(state.bugContest && {
|
...(state.bugContest && {
|
||||||
bugContest: {
|
bugContest: {
|
||||||
@@ -320,6 +351,13 @@ const NUM_NPC_TRADES = 7,
|
|||||||
LANDMARK_GIFT = 0x7e,
|
LANDMARK_GIFT = 0x7e,
|
||||||
PARTYMENUACTION_CHOOSE_POKEMON = 0,
|
PARTYMENUACTION_CHOOSE_POKEMON = 0,
|
||||||
PARTYMENUACTION_GIVE_MON = 6;
|
PARTYMENUACTION_GIVE_MON = 6;
|
||||||
|
/** BATTLETOWERACTION_GSBALL and GBCHECK_CGB (constants). */
|
||||||
|
const BATTLETOWERACTION_GSBALL = 0x0b,
|
||||||
|
GBCHECK_CGB = 2;
|
||||||
|
/** RANDY_OT_ID (engine/pokemon/move_mon.asm): the ID of every gift
|
||||||
|
* Pokémon given to the party. MAIL_MSG_LENGTH: two MAIL lines. */
|
||||||
|
const RANDY_OT_ID = 1001,
|
||||||
|
MAIL_MSG_LENGTH = 32;
|
||||||
/** checkpokemail's results. */
|
/** checkpokemail's results. */
|
||||||
const POKEMAIL_WRONG_MAIL = 0,
|
const POKEMAIL_WRONG_MAIL = 0,
|
||||||
POKEMAIL_CORRECT = 1,
|
POKEMAIL_CORRECT = 1,
|
||||||
@@ -327,6 +365,56 @@ const POKEMAIL_WRONG_MAIL = 0,
|
|||||||
POKEMAIL_NO_MAIL = 3,
|
POKEMAIL_NO_MAIL = 3,
|
||||||
POKEMAIL_LAST_MON = 4;
|
POKEMAIL_LAST_MON = 4;
|
||||||
|
|
||||||
|
/** MARTTYPE_* (constants/mart_constants.asm). */
|
||||||
|
const MARTTYPE_STANDARD = 0,
|
||||||
|
MARTTYPE_BITTER = 1,
|
||||||
|
MARTTYPE_BARGAIN = 2,
|
||||||
|
MARTTYPE_PHARMACY = 3,
|
||||||
|
MARTTYPE_ROOFTOP = 4;
|
||||||
|
/** NUM_MARTS (constants/mart_constants.asm). */
|
||||||
|
const NUM_MARTS = 34;
|
||||||
|
/** GetMartDialogGroup's texts per mart type: the quantity question (none
|
||||||
|
* for the bargain shop, which sells one of each), the price, no money,
|
||||||
|
* pack full and thanks, and the bargain shop's sold out. */
|
||||||
|
interface MartTexts {
|
||||||
|
howMany?: number;
|
||||||
|
finalPrice: number;
|
||||||
|
noMoney: number;
|
||||||
|
packFull: number;
|
||||||
|
thanks: number;
|
||||||
|
soldOut?: number;
|
||||||
|
}
|
||||||
|
const MART_TEXTS = {
|
||||||
|
standard: {
|
||||||
|
howMany: SYMBOLS.martHowManyText,
|
||||||
|
finalPrice: SYMBOLS.martFinalPriceText,
|
||||||
|
noMoney: SYMBOLS.martNoMoneyText,
|
||||||
|
packFull: SYMBOLS.martPackFullText,
|
||||||
|
thanks: SYMBOLS.martThanksText,
|
||||||
|
},
|
||||||
|
herb: {
|
||||||
|
howMany: JOHTO.HerbalLadyHowManyText,
|
||||||
|
finalPrice: JOHTO.HerbalLadyFinalPriceText,
|
||||||
|
noMoney: JOHTO.HerbalLadyNoMoneyText,
|
||||||
|
packFull: JOHTO.HerbalLadyPackFullText,
|
||||||
|
thanks: JOHTO.HerbalLadyThanksText,
|
||||||
|
},
|
||||||
|
bargain: {
|
||||||
|
finalPrice: JOHTO.BargainShopFinalPriceText,
|
||||||
|
noMoney: JOHTO.BargainShopNoFundsText,
|
||||||
|
packFull: JOHTO.BargainShopPackFullText,
|
||||||
|
thanks: JOHTO.BargainShopThanksText,
|
||||||
|
soldOut: JOHTO.BargainShopSoldOutText,
|
||||||
|
},
|
||||||
|
pharmacy: {
|
||||||
|
howMany: JOHTO.PharmacyHowManyText,
|
||||||
|
finalPrice: JOHTO.PharmacyFinalPriceText,
|
||||||
|
noMoney: JOHTO.PharmacyNoMoneyText,
|
||||||
|
packFull: JOHTO.PharmacyPackFullText,
|
||||||
|
thanks: JOHTO.PharmacyThanksText,
|
||||||
|
},
|
||||||
|
} satisfies Record<string, MartTexts>;
|
||||||
|
|
||||||
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
|
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
|
||||||
/** The mart's lists and quantity box, which the PC's item storage reuses. */
|
/** The mart's lists and quantity box, which the PC's item storage reuses. */
|
||||||
export type MartMode =
|
export type MartMode =
|
||||||
@@ -348,7 +436,13 @@ export type ScriptEffect =
|
|||||||
/** Pages the player acknowledges. With holdTicks the last page instead
|
/** Pages the player acknowledges. With holdTicks the last page instead
|
||||||
* stays for a fixed time, as `writetext` followed by `pause` does. */
|
* stays for a fixed time, as `writetext` followed by `pause` does. */
|
||||||
| { kind: "text"; pages: string[]; holdTicks?: number }
|
| { kind: "text"; pages: string[]; holdTicks?: number }
|
||||||
| { kind: "yesno"; pages: string[] }
|
| {
|
||||||
|
kind: "yesno";
|
||||||
|
pages: string[];
|
||||||
|
/** A money or coin box shown with the choice (PlaceMoneyTopRight,
|
||||||
|
* DisplayCoinCaseBalance, DisplayMoneyAndCoinBalance). */
|
||||||
|
balance?: string;
|
||||||
|
}
|
||||||
| { kind: "weekday"; pages: string[]; names: string[] }
|
| { kind: "weekday"; pages: string[]; names: string[] }
|
||||||
| { kind: "turn"; object: number; direction: Direction }
|
| { kind: "turn"; object: number; direction: Direction }
|
||||||
| { kind: "face"; object: number; target: number }
|
| { kind: "face"; object: number; target: number }
|
||||||
@@ -458,6 +552,8 @@ export type ScriptEffect =
|
|||||||
selected: number;
|
selected: number;
|
||||||
/** Left and right answer "left" or "right" (MoveScreenLoop). */
|
/** Left and right answer "left" or "right" (MoveScreenLoop). */
|
||||||
sides?: boolean;
|
sides?: boolean;
|
||||||
|
/** A money or coin box shown with the menu. */
|
||||||
|
balance?: string;
|
||||||
}
|
}
|
||||||
/** A native message for presentation the port does not have yet; the
|
/** A native message for presentation the port does not have yet; the
|
||||||
* script continues. */
|
* script continues. */
|
||||||
@@ -543,6 +639,8 @@ export interface ScriptHost {
|
|||||||
/** GetFacingObject: the visible object on the facing tile, with its
|
/** GetFacingObject: the visible object on the facing tile, with its
|
||||||
* MAPOBJECT_MOVEMENT. */
|
* MAPOBJECT_MOVEMENT. */
|
||||||
facingObject(): { id: number; movement: number } | undefined;
|
facingObject(): { id: number; movement: number } | undefined;
|
||||||
|
/** The game clock's day number (wCurDay's source, see day-timers). */
|
||||||
|
day?(): number;
|
||||||
/** The map's tileset graphics and background palettes as shown now,
|
/** The map's tileset graphics and background palettes as shown now,
|
||||||
* for specials that draw over the map. */
|
* for specials that draw over the map. */
|
||||||
tileset?(): { graphics: Uint8Array; palettes: Palette[] };
|
tileset?(): { graphics: Uint8Array; palettes: Palette[] };
|
||||||
@@ -663,6 +761,9 @@ export class ScriptRun {
|
|||||||
private numbers: Record<number, number> = {};
|
private numbers: Record<number, number> = {};
|
||||||
/** wCurItem: the item named by itemnotify and pocketisfull. */
|
/** wCurItem: the item named by itemnotify and pocketisfull. */
|
||||||
private currentItem = 0;
|
private currentItem = 0;
|
||||||
|
/** A money or coin box the next choice shows (PlaceMoneyTopRight and
|
||||||
|
* the coin displays); it stays until the window closes. */
|
||||||
|
private balance?: string;
|
||||||
/** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */
|
/** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */
|
||||||
private fruitTree = 0;
|
private fruitTree = 0;
|
||||||
private fruit = 0;
|
private fruit = 0;
|
||||||
@@ -1581,63 +1682,32 @@ export class ScriptRun {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 0x2d: {
|
case 0x2d: {
|
||||||
|
// givepoke: a gift names its nickname and OT (two pointers in
|
||||||
|
// the script's bank) after the flag.
|
||||||
const species = byte(),
|
const species = byte(),
|
||||||
level = byte(),
|
level = byte(),
|
||||||
item = byte(),
|
item = byte(),
|
||||||
gift = byte();
|
gift = byte()
|
||||||
if (gift)
|
? { nickname: near(), ot: near() }
|
||||||
throw new UnsupportedScript(offset, opcode, " (gift with OT)");
|
: undefined;
|
||||||
// A MAIL item needs its message (GivePokeMail).
|
value = yield* this.givePoke(species, level, item, gift, write, take);
|
||||||
if (item && data.itemIsMail(item))
|
break;
|
||||||
throw new UnsupportedScript(offset, opcode, " (MAIL)");
|
|
||||||
if (this.state.party.length >= PARTY_LENGTH)
|
|
||||||
throw new UnsupportedScript(
|
|
||||||
offset,
|
|
||||||
opcode,
|
|
||||||
" (full party; PC boxes)",
|
|
||||||
);
|
|
||||||
const player = this.host.player();
|
|
||||||
// GeneratePartyMonStats draws the Attack/Defense byte first.
|
|
||||||
const dvs = (this.host.random() << 8) | this.host.random();
|
|
||||||
const mon = generatePartyMon(data, {
|
|
||||||
species,
|
|
||||||
level,
|
|
||||||
item,
|
|
||||||
otId: player.id,
|
|
||||||
ot: this.host.text().player,
|
|
||||||
dvs,
|
|
||||||
caughtData: caughtData(
|
|
||||||
this.host.timeOfDay(),
|
|
||||||
level,
|
|
||||||
player.gender,
|
|
||||||
this.host.landmark(),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
this.state.party.push(mon);
|
|
||||||
updateUnownDex(this.state, mon);
|
|
||||||
this.state.pokedex.seen.add(species);
|
|
||||||
this.state.pokedex.caught.add(species);
|
|
||||||
// GiveANickname_YesNo reads the species name from wStringBuffer1.
|
|
||||||
this.strings[RAM.stringBuffer1] = mon.nickname;
|
|
||||||
write(SYMBOLS.caughtAskNicknameText);
|
|
||||||
if (yield { kind: "yesno", pages: take() }) {
|
|
||||||
// The naming screen's title: "<NAME>'S" / "NICKNAME?".
|
|
||||||
let second = SYMBOLS.nicknameStrings;
|
|
||||||
while (r.byte(second++) !== 0x50);
|
|
||||||
const suffix = readString(r, SYMBOLS.nicknameStrings, this.text());
|
|
||||||
const title = readString(r, second, this.text());
|
|
||||||
const name = yield {
|
|
||||||
kind: "name",
|
|
||||||
pages: [`${mon.nickname}${suffix}\n${title}`],
|
|
||||||
current: mon.nickname,
|
|
||||||
max: 10,
|
|
||||||
};
|
|
||||||
if (typeof name !== "string") throw new Error("Invalid nickname.");
|
|
||||||
if (name && !NICKNAME_PATTERN.test(name))
|
|
||||||
throw new Error("Invalid nickname.");
|
|
||||||
if (name) mon.nickname = name;
|
|
||||||
}
|
}
|
||||||
value = 0;
|
case 0x2f: {
|
||||||
|
// givepokemail: GivePokeMail on the last party Pokémon, with the
|
||||||
|
// MAIL item and message from the script's data and the holder's
|
||||||
|
// OT and ID as the author.
|
||||||
|
const data = near();
|
||||||
|
const mon = this.state.party.at(-1);
|
||||||
|
if (!mon) throw new UnsupportedScript(offset, opcode, " (no party)");
|
||||||
|
mon.item = r.byte(data);
|
||||||
|
mon.mail = {
|
||||||
|
message: readString(r, data + 1, this.text(), MAIL_MSG_LENGTH),
|
||||||
|
author: mon.ot,
|
||||||
|
authorId: mon.otId,
|
||||||
|
species: mon.species,
|
||||||
|
type: mon.item,
|
||||||
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 0x40:
|
case 0x40:
|
||||||
@@ -1705,7 +1775,10 @@ export class ScriptRun {
|
|||||||
menuHeader = near();
|
menuHeader = near();
|
||||||
break;
|
break;
|
||||||
case 0x50:
|
case 0x50:
|
||||||
break; // closewindow: the native menu has already closed.
|
// closewindow: the native menu has already closed, and with it
|
||||||
|
// any money box.
|
||||||
|
this.balance = undefined;
|
||||||
|
break;
|
||||||
case 0x58: {
|
case 0x58: {
|
||||||
// _2dmenu over the loaded header's MenuData: its strings, the
|
// _2dmenu over the loaded header's MenuData: its strings, the
|
||||||
// default option, and wMenuCursorPosition (0 for B).
|
// default option, and wMenuCursorPosition (0 for B).
|
||||||
@@ -1735,6 +1808,48 @@ export class ScriptRun {
|
|||||||
value = answer < 0 ? 0 : answer + 1;
|
value = answer < 0 ? 0 : answer + 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 0x59: {
|
||||||
|
// verticalmenu: VerticalMenu over the loaded header's static
|
||||||
|
// MenuData (flags, count, then the strings); wMenuCursorY, or 0
|
||||||
|
// for B.
|
||||||
|
if (menuHeader === undefined)
|
||||||
|
throw new UnsupportedScript(offset, opcode, " (no menu header)");
|
||||||
|
const bank = r.bank(menuHeader),
|
||||||
|
menuData = r.address(bank, r.word(menuHeader + 5)),
|
||||||
|
initial = r.byte(menuHeader + 7);
|
||||||
|
const count = r.byte(menuData + 1);
|
||||||
|
let entry = menuData + 2;
|
||||||
|
const items: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
items.push(readString(r, entry, this.text()));
|
||||||
|
while (r.byte(entry++) !== 0x50);
|
||||||
|
}
|
||||||
|
const answer = yield {
|
||||||
|
kind: "menu",
|
||||||
|
pages: take(),
|
||||||
|
items,
|
||||||
|
selected: Math.max(0, initial - 1),
|
||||||
|
...(this.balance && { balance: this.balance }),
|
||||||
|
};
|
||||||
|
if (typeof answer !== "number" || !Number.isInteger(answer) || answer >= items.length)
|
||||||
|
throw new Error("Invalid menu choice.");
|
||||||
|
value = answer < 0 ? 0 : answer + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 0x25:
|
||||||
|
case 0x26:
|
||||||
|
case 0x27: {
|
||||||
|
// givecoins (capped at MAX_COINS), takecoins (a shortfall leaves
|
||||||
|
// 0 coins) and checkcoins.
|
||||||
|
const amount = word(),
|
||||||
|
coins = this.state.coins;
|
||||||
|
if (opcode === 0x25) this.state.coins = Math.min(C.MAX_COINS, coins + amount);
|
||||||
|
else if (opcode === 0x26) this.state.coins = Math.max(0, coins - amount);
|
||||||
|
else
|
||||||
|
value =
|
||||||
|
coins > amount ? OW.HAVE_MORE : coins === amount ? OW.HAVE_AMOUNT : OW.HAVE_LESS;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 0x96: {
|
case 0x96: {
|
||||||
// trade: NPCTrade.
|
// trade: NPCTrade.
|
||||||
const index = byte();
|
const index = byte();
|
||||||
@@ -1864,6 +1979,8 @@ export class ScriptRun {
|
|||||||
case 0x47:
|
case 0x47:
|
||||||
break; // opentext: the native dialogue opens with its first page.
|
break; // opentext: the native dialogue opens with its first page.
|
||||||
case 0x49:
|
case 0x49:
|
||||||
|
// closetext: the money box closes with the text box.
|
||||||
|
this.balance = undefined;
|
||||||
yield* flush();
|
yield* flush();
|
||||||
break;
|
break;
|
||||||
case 0x54:
|
case 0x54:
|
||||||
@@ -1879,7 +1996,11 @@ export class ScriptRun {
|
|||||||
write(near());
|
write(near());
|
||||||
break;
|
break;
|
||||||
case 0x4e: {
|
case 0x4e: {
|
||||||
const answer = yield { kind: "yesno", pages: take() };
|
const answer = yield {
|
||||||
|
kind: "yesno",
|
||||||
|
pages: take(),
|
||||||
|
...(this.balance && { balance: this.balance }),
|
||||||
|
};
|
||||||
value = answer ? 1 : 0;
|
value = answer ? 1 : 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2081,10 +2202,10 @@ export class ScriptRun {
|
|||||||
case 0x94: {
|
case 0x94: {
|
||||||
const type = byte(),
|
const type = byte(),
|
||||||
martId = word();
|
martId = word();
|
||||||
if (type !== 0 /* MARTTYPE_STANDARD */)
|
if (type > MARTTYPE_ROOFTOP)
|
||||||
throw new UnsupportedScript(offset, opcode, " (mart type)");
|
throw new UnsupportedScript(offset, opcode, " (mart type)");
|
||||||
yield* flush();
|
yield* flush();
|
||||||
yield* this.pokemart(martId, pages);
|
yield* this.pokemart(type, martId, pages);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -2094,26 +2215,85 @@ export class ScriptRun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OpenMartDialog/MartDialog/StandardMart (engine/items/mart.asm), for
|
/** OpenMartDialog (engine/items/mart.asm) by MARTTYPE_*: the standard
|
||||||
* MARTTYPE_STANDARD only. Welcome, then BUY/SELL/QUIT until the player
|
* mart's BUY/SELL/QUIT loop, or straight to BuyMenu for the herb shop,
|
||||||
* quits; buying and selling loop over their own item lists until the
|
* the bargain shop, the pharmacy and the rooftop sale, each with its own
|
||||||
* player backs out, then the menu asks "anything else?" and repeats. */
|
* texts. The standard, herb and pharmacy marts read Marts[martId] with
|
||||||
|
* the items' prices; the bargain shop and rooftop sale have their own
|
||||||
|
* lists and prices. */
|
||||||
private *pokemart(
|
private *pokemart(
|
||||||
|
type: number,
|
||||||
martId: number,
|
martId: number,
|
||||||
pages: string[],
|
pages: string[],
|
||||||
): Generator<ScriptEffect, void, Input> {
|
): Generator<ScriptEffect, void, Input> {
|
||||||
const r = this.reader;
|
const r = this.reader,
|
||||||
|
data = this.host.data;
|
||||||
const write = (address: number) => {
|
const write = (address: number) => {
|
||||||
const content = readText(r, address, this.text());
|
const content = readText(r, address, this.text());
|
||||||
if (content) pages.push(...content.split("\n\n"));
|
if (content) pages.push(...content.split("\n\n"));
|
||||||
};
|
};
|
||||||
const take = () => pages.splice(0, pages.length);
|
const take = () => pages.splice(0, pages.length);
|
||||||
|
// MartTextbox: the text, then a button.
|
||||||
const entry = r.near(SYMBOLS.marts + martId * 2, r.bank(SYMBOLS.marts));
|
const textbox = function* (address: number): Generator<ScriptEffect, void, Input> {
|
||||||
let p = entry;
|
write(address);
|
||||||
const count = r.byte(p++);
|
if (pages.length) yield { kind: "text", pages: take() };
|
||||||
const stock = Array.from({ length: count }, () => r.byte(p++));
|
};
|
||||||
|
// GetMart: a mart beyond NUM_MARTS reads DefaultMart.
|
||||||
|
const martStock = () => {
|
||||||
|
const entry =
|
||||||
|
martId < NUM_MARTS
|
||||||
|
? r.near(SYMBOLS.marts + martId * 2, r.bank(SYMBOLS.marts))
|
||||||
|
: JOHTO.DefaultMart;
|
||||||
|
const count = r.byte(entry);
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const item = r.byte(entry + 1 + i);
|
||||||
|
return { item, price: data.itemPrice(item) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// ReadMart: a count, then each item with its own price.
|
||||||
|
const pricedStock = (list: number) =>
|
||||||
|
Array.from({ length: r.byte(list) }, (_, i) => ({
|
||||||
|
item: r.byte(list + 1 + i * 3),
|
||||||
|
price: r.word(list + 2 + i * 3),
|
||||||
|
}));
|
||||||
|
switch (type) {
|
||||||
|
case MARTTYPE_STANDARD:
|
||||||
|
break;
|
||||||
|
case MARTTYPE_BITTER:
|
||||||
|
yield* textbox(JOHTO.HerbShopLadyIntroText);
|
||||||
|
yield* this.martBuy(martStock(), pages, MART_TEXTS.herb);
|
||||||
|
yield* textbox(JOHTO.HerbalLadyComeAgainText);
|
||||||
|
return;
|
||||||
|
case MARTTYPE_PHARMACY:
|
||||||
|
yield* textbox(JOHTO.PharmacyIntroText);
|
||||||
|
yield* this.martBuy(martStock(), pages, MART_TEXTS.pharmacy);
|
||||||
|
yield* textbox(JOHTO.PharmacyComeAgainText);
|
||||||
|
return;
|
||||||
|
case MARTTYPE_BARGAIN: {
|
||||||
|
yield* textbox(JOHTO.BargainShopIntroText);
|
||||||
|
const bought = yield* this.martBuy(
|
||||||
|
pricedStock(JOHTO.BargainShopData),
|
||||||
|
pages,
|
||||||
|
MART_TEXTS.bargain,
|
||||||
|
);
|
||||||
|
// wBargainShopFlags: any purchase closes the shop for the day.
|
||||||
|
if (bought) this.state.flags.add(OW.ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED);
|
||||||
|
yield* textbox(JOHTO.BargainShopComeAgainText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case MARTTYPE_ROOFTOP:
|
||||||
|
yield* textbox(SYMBOLS.martWelcomeText);
|
||||||
|
yield* this.martBuy(
|
||||||
|
pricedStock(
|
||||||
|
this.state.hallOfFame ? JOHTO.RooftopSaleMart2 : JOHTO.RooftopSaleMart1,
|
||||||
|
),
|
||||||
|
pages,
|
||||||
|
// The rooftop sale shares the standard mart's texts.
|
||||||
|
MART_TEXTS.standard,
|
||||||
|
);
|
||||||
|
yield* textbox(SYMBOLS.martComeAgainText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let first = true;
|
let first = true;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const choice = yield {
|
const choice = yield {
|
||||||
@@ -2127,7 +2307,7 @@ export class ScriptRun {
|
|||||||
};
|
};
|
||||||
first = false;
|
first = false;
|
||||||
const option = typeof choice === "number" ? choice : 2;
|
const option = typeof choice === "number" ? choice : 2;
|
||||||
if (option === 0) yield* this.martBuy(stock, pages);
|
if (option === 0) yield* this.martBuy(martStock(), pages, MART_TEXTS.standard);
|
||||||
else if (option === 1) yield* this.martSell(pages);
|
else if (option === 1) yield* this.martSell(pages);
|
||||||
else {
|
else {
|
||||||
write(SYMBOLS.martComeAgainText);
|
write(SYMBOLS.martComeAgainText);
|
||||||
@@ -2137,12 +2317,16 @@ export class ScriptRun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** BuyMenu/BuyMenuLoop: pick a stocked item, a 1-99 quantity, confirm the
|
/** BuyMenu/BuyMenuLoop: pick a stocked item, then the quantity
|
||||||
* price, then check funds and bag room before handing it over. */
|
* (SelectQuantityToBuy, at the item's price or the sale's), or for the
|
||||||
|
* bargain shop one of each item per visit (BargainShopAskPurchaseQuantity
|
||||||
|
* and wBargainShopFlags); confirm the price, then check funds and bag
|
||||||
|
* room before handing it over. Returns whether anything was bought. */
|
||||||
private *martBuy(
|
private *martBuy(
|
||||||
stock: number[],
|
stock: { item: number; price: number }[],
|
||||||
pages: string[],
|
pages: string[],
|
||||||
): Generator<ScriptEffect, void, Input> {
|
texts: MartTexts,
|
||||||
|
): Generator<ScriptEffect, boolean, Input> {
|
||||||
const r = this.reader,
|
const r = this.reader,
|
||||||
data = this.host.data;
|
data = this.host.data;
|
||||||
const write = (address: number) => {
|
const write = (address: number) => {
|
||||||
@@ -2153,11 +2337,13 @@ export class ScriptRun {
|
|||||||
const flush = function* (): Generator<ScriptEffect, void, Input> {
|
const flush = function* (): Generator<ScriptEffect, void, Input> {
|
||||||
if (pages.length) yield { kind: "text", pages: take() };
|
if (pages.length) yield { kind: "text", pages: take() };
|
||||||
};
|
};
|
||||||
|
const soldOut = new Set<number>();
|
||||||
|
let bought = false;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const entries: MartEntry[] = stock.map((item) => ({
|
const entries: MartEntry[] = stock.map(({ item, price }) => ({
|
||||||
item,
|
item,
|
||||||
name: data.itemName(item),
|
name: data.itemName(item),
|
||||||
price: data.itemPrice(item),
|
price,
|
||||||
}));
|
}));
|
||||||
const selection = yield {
|
const selection = yield {
|
||||||
kind: "martlist",
|
kind: "martlist",
|
||||||
@@ -2165,39 +2351,52 @@ export class ScriptRun {
|
|||||||
entries,
|
entries,
|
||||||
selected: 0,
|
selected: 0,
|
||||||
};
|
};
|
||||||
const item = typeof selection === "number" ? stock[selection] : undefined;
|
const index = typeof selection === "number" ? selection : -1;
|
||||||
if (item === undefined) return;
|
const entry = stock[index];
|
||||||
const price = data.itemPrice(item);
|
if (!entry) return bought;
|
||||||
const chosen = yield {
|
const { item, price } = entry;
|
||||||
|
let chosen = 1;
|
||||||
|
if (texts.soldOut !== undefined) {
|
||||||
|
if (soldOut.has(index)) {
|
||||||
|
write(texts.soldOut);
|
||||||
|
yield* flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const answer = yield {
|
||||||
kind: "martquantity",
|
kind: "martquantity",
|
||||||
mode: "buy",
|
mode: "buy",
|
||||||
item,
|
item,
|
||||||
name: data.itemName(item),
|
name: data.itemName(item),
|
||||||
price,
|
price,
|
||||||
max: 99,
|
max: 99,
|
||||||
prompt: readText(r, SYMBOLS.martHowManyText, this.text()),
|
prompt: readText(r, texts.howMany!, this.text()),
|
||||||
selected: 1,
|
selected: 1,
|
||||||
};
|
};
|
||||||
if (typeof chosen !== "number" || chosen < 1) continue;
|
if (typeof answer !== "number" || answer < 1) continue;
|
||||||
|
chosen = answer;
|
||||||
|
}
|
||||||
const total = price * chosen;
|
const total = price * chosen;
|
||||||
this.strings[RAM.stringBuffer2] = data.itemName(item);
|
this.strings[RAM.stringBuffer2] = data.itemName(item);
|
||||||
this.numbers[RAM.itemQuantityChange] = chosen;
|
this.numbers[RAM.itemQuantityChange] = chosen;
|
||||||
this.numbers[RAM.moneyTemp] = total;
|
this.numbers[RAM.moneyTemp] = total;
|
||||||
write(SYMBOLS.martFinalPriceText);
|
write(texts.finalPrice);
|
||||||
const confirmed = yield { kind: "yesno", pages: take() };
|
const confirmed = yield { kind: "yesno", pages: take() };
|
||||||
if (!confirmed) continue;
|
if (!confirmed) continue;
|
||||||
if (total > this.state.money) {
|
if (total > this.state.money) {
|
||||||
write(SYMBOLS.martNoMoneyText);
|
write(texts.noMoney);
|
||||||
yield* flush();
|
yield* flush();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!receiveItem(data, this.state.bag, item, chosen)) {
|
if (!receiveItem(data, this.state.bag, item, chosen)) {
|
||||||
write(SYMBOLS.martPackFullText);
|
write(texts.packFull);
|
||||||
yield* flush();
|
yield* flush();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
soldOut.add(index);
|
||||||
|
bought = true;
|
||||||
this.state.money -= total;
|
this.state.money -= total;
|
||||||
write(SYMBOLS.martThanksText);
|
write(texts.thanks);
|
||||||
yield* flush();
|
yield* flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2379,6 +2578,145 @@ export class ScriptRun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GivePoke (engine/pokemon/move_mon.asm): TryAddMonToParty, or with a
|
||||||
|
* full party SendMonIntoBox ("was sent to BILL's PC"); 2 when the box is
|
||||||
|
* full too, else 0 for the party and 1 for the box. A gift takes its
|
||||||
|
* nickname and OT from the script and its caught data from the byte after
|
||||||
|
* the OT's name (SetGiftMonCaughtData); in the party its ID is
|
||||||
|
* RANDY_OT_ID, in a box two Random bytes. Other Pokémon get the player's
|
||||||
|
* OT, their caught data and GiveANickname_YesNo. */
|
||||||
|
private *givePoke(
|
||||||
|
species: number,
|
||||||
|
level: number,
|
||||||
|
item: number,
|
||||||
|
gift: { nickname: number; ot: number } | undefined,
|
||||||
|
write: (address: number) => void,
|
||||||
|
take: () => string[],
|
||||||
|
): Generator<ScriptEffect, number, Input> {
|
||||||
|
const r = this.reader,
|
||||||
|
data = this.host.data,
|
||||||
|
state = this.state,
|
||||||
|
player = this.host.player();
|
||||||
|
// A MAIL item needs its message (givepokemail follows for gifts).
|
||||||
|
if (item && data.itemIsMail(item) && !gift)
|
||||||
|
throw new Error("A MAIL gift needs its message.");
|
||||||
|
const toBox = state.party.length >= PARTY_LENGTH;
|
||||||
|
if (toBox && currentBox(state.storage).length >= MONS_PER_BOX) return 2;
|
||||||
|
const giftCaught = gift
|
||||||
|
? LANDMARK_GIFT | ((r.byte(this.stringEnd(gift.ot)) & 1) << 7)
|
||||||
|
: undefined;
|
||||||
|
let dvs: number;
|
||||||
|
if (toBox) {
|
||||||
|
// LoadEnemyMon outside battle takes the DVs GetTrainerDVs gives
|
||||||
|
// wOtherTrainerClass, which CleanUpBattleRAM leaves at 0: the two
|
||||||
|
// bytes before TrainerClassDVs.
|
||||||
|
const p = SYMBOLS.trainerClassDVs - 2;
|
||||||
|
dvs = (r.byte(p) << 8) | r.byte(p + 1);
|
||||||
|
} else dvs = (this.host.random() << 8) | this.host.random();
|
||||||
|
const mon = generatePartyMon(data, {
|
||||||
|
species,
|
||||||
|
level,
|
||||||
|
item,
|
||||||
|
otId: player.id,
|
||||||
|
ot: this.host.text().player,
|
||||||
|
dvs,
|
||||||
|
caughtData:
|
||||||
|
giftCaught ??
|
||||||
|
caughtData(this.host.timeOfDay(), level, player.gender, this.host.landmark()),
|
||||||
|
});
|
||||||
|
if (gift) {
|
||||||
|
mon.nickname = readString(r, gift.nickname, this.text(), 10);
|
||||||
|
mon.ot = readString(r, gift.ot, this.text(), 7);
|
||||||
|
if (toBox) mon.otId = (this.host.random() << 8) | this.host.random();
|
||||||
|
else mon.otId = RANDY_OT_ID;
|
||||||
|
}
|
||||||
|
state.pokedex.seen.add(species);
|
||||||
|
state.pokedex.caught.add(species);
|
||||||
|
this.strings[RAM.stringBuffer1] = data.speciesName(species);
|
||||||
|
if (toBox) {
|
||||||
|
const box = toBoxMon(mon);
|
||||||
|
restorePP(data, box);
|
||||||
|
currentBox(state.storage).unshift(box);
|
||||||
|
updateUnownDex(state, mon, false);
|
||||||
|
} else {
|
||||||
|
state.party.push(mon);
|
||||||
|
updateUnownDex(state, mon);
|
||||||
|
}
|
||||||
|
if (!gift) {
|
||||||
|
// GiveANickname_YesNo reads the species name from wStringBuffer1.
|
||||||
|
write(SYMBOLS.caughtAskNicknameText);
|
||||||
|
if (yield { kind: "yesno", pages: take() }) {
|
||||||
|
// The naming screen's title: "<NAME>'S" / "NICKNAME?".
|
||||||
|
let second = SYMBOLS.nicknameStrings;
|
||||||
|
while (r.byte(second++) !== 0x50);
|
||||||
|
const suffix = readString(r, SYMBOLS.nicknameStrings, this.text());
|
||||||
|
const title = readString(r, second, this.text());
|
||||||
|
const name = yield {
|
||||||
|
kind: "name",
|
||||||
|
pages: [`${mon.nickname}${suffix}\n${title}`],
|
||||||
|
current: mon.nickname,
|
||||||
|
max: 10,
|
||||||
|
};
|
||||||
|
if (typeof name !== "string") throw new Error("Invalid nickname.");
|
||||||
|
if (name && !NICKNAME_PATTERN.test(name))
|
||||||
|
throw new Error("Invalid nickname.");
|
||||||
|
// InitName: a name of spaces keeps the species name.
|
||||||
|
if (name && !/^ *$/.test(name)) mon.nickname = name;
|
||||||
|
}
|
||||||
|
if (toBox) currentBox(state.storage)[0].nickname = mon.nickname;
|
||||||
|
}
|
||||||
|
if (!toBox) return 0;
|
||||||
|
write(JOHTO.WasSentToBillsPCText);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
/** The address after a string's terminator. */
|
||||||
|
private stringEnd(address: number) {
|
||||||
|
let p = address;
|
||||||
|
while (this.reader.byte(p++) !== 0x50);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What translated specials outside this file work with. */
|
||||||
|
private specialContext(
|
||||||
|
pages: string[],
|
||||||
|
take: () => string[],
|
||||||
|
): SpecialContext {
|
||||||
|
const r = this.reader;
|
||||||
|
const write = (address: number) => {
|
||||||
|
const content = readText(r, address, this.text());
|
||||||
|
if (content) pages.push(...content.split("\n\n"));
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
reader: r,
|
||||||
|
data: this.host.data,
|
||||||
|
state: this.state,
|
||||||
|
strings: this.strings,
|
||||||
|
numbers: this.numbers,
|
||||||
|
text: () => this.text(),
|
||||||
|
random: () => this.host.random(),
|
||||||
|
player: () => ({ id: this.host.player().id, name: this.host.text().player }),
|
||||||
|
today: () => {
|
||||||
|
if (!this.host.day) throw new Error("No game clock.");
|
||||||
|
return this.host.day();
|
||||||
|
},
|
||||||
|
weekday: () => this.host.clock().weekday,
|
||||||
|
write,
|
||||||
|
take,
|
||||||
|
flush: function* () {
|
||||||
|
if (pages.length) yield { kind: "text", pages: take() };
|
||||||
|
},
|
||||||
|
selectMon: () => this.selectMonFromParty(take()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** PlaceMoneyTopRight's box: the money with the ¥ sign. */
|
||||||
|
private moneyBalance() {
|
||||||
|
return `${readString(this.reader, JOHTO.MoneyString, this.text())} ¥${this.state.money}`;
|
||||||
|
}
|
||||||
|
/** DisplayCoinCaseBalance's box: COIN and the coins. */
|
||||||
|
private coinBalance() {
|
||||||
|
return `${readString(this.reader, JOHTO.CoinString, this.text())} ${this.state.coins}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** SelectApricornForKurt (engine/events/kurt.asm): the apricorns in the
|
/** SelectApricornForKurt (engine/events/kurt.asm): the apricorns in the
|
||||||
* Items pocket (FindApricornsInBag, in ApricornBalls' order) with their
|
* Items pocket (FindApricornsInBag, in ApricornBalls' order) with their
|
||||||
* counts, then how many (BuySellToss_InterpretJoypad's 1 to the count,
|
* counts, then how many (BuySellToss_InterpretJoypad's 1 to the count,
|
||||||
@@ -2904,6 +3242,72 @@ export class ScriptRun {
|
|||||||
}
|
}
|
||||||
case JOHTO.SelectApricornForKurt:
|
case JOHTO.SelectApricornForKurt:
|
||||||
return yield* this.selectApricornForKurt(pages, take);
|
return yield* this.selectApricornForKurt(pages, take);
|
||||||
|
case JOHTO.NameRater:
|
||||||
|
yield* nameRater(this.specialContext(pages, take));
|
||||||
|
return value;
|
||||||
|
case JOHTO.GameCornerPrizeMonCheckDex:
|
||||||
|
if (this.state.pokedex.caught.has(value)) return value;
|
||||||
|
this.state.pokedex.seen.add(value);
|
||||||
|
this.state.pokedex.caught.add(value);
|
||||||
|
if (pages.length) yield { kind: "text", pages: take() };
|
||||||
|
yield* new Pokedex({
|
||||||
|
data: this.host.data,
|
||||||
|
dex: new PokedexData(r),
|
||||||
|
...this.state.pokedex,
|
||||||
|
firstUnownSeen: this.state.firstUnownSeen,
|
||||||
|
}).newEntry(value);
|
||||||
|
return value;
|
||||||
|
case JOHTO.OlderHaircutBrother:
|
||||||
|
return yield* haircut(
|
||||||
|
this.specialContext(pages, take),
|
||||||
|
JOHTO.HappinessData_OlderHaircutBrother,
|
||||||
|
);
|
||||||
|
case JOHTO.YoungerHaircutBrother:
|
||||||
|
return yield* haircut(
|
||||||
|
this.specialContext(pages, take),
|
||||||
|
JOHTO.HappinessData_YoungerHaircutBrother,
|
||||||
|
);
|
||||||
|
case JOHTO.DaisysGrooming:
|
||||||
|
return yield* haircut(
|
||||||
|
this.specialContext(pages, take),
|
||||||
|
JOHTO.HappinessData_DaisysGrooming,
|
||||||
|
);
|
||||||
|
case JOHTO.GetFirstPokemonHappiness:
|
||||||
|
return firstPokemonHappiness(this.specialContext(pages, take));
|
||||||
|
case JOHTO.CheckFirstMonIsEgg:
|
||||||
|
return checkFirstMonIsEgg(this.specialContext(pages, take));
|
||||||
|
case JOHTO.CheckLuckyNumberShowFlag:
|
||||||
|
return checkLuckyNumberShowFlag(this.specialContext(pages, take));
|
||||||
|
case JOHTO.ResetLuckyNumberShowFlag:
|
||||||
|
resetLuckyNumberShowFlag(this.specialContext(pages, take));
|
||||||
|
return value;
|
||||||
|
case JOHTO.PrintTodaysLuckyNumber:
|
||||||
|
printTodaysLuckyNumber(this.specialContext(pages, take));
|
||||||
|
return value;
|
||||||
|
case JOHTO.CheckForLuckyNumberWinners:
|
||||||
|
return yield* checkForLuckyNumberWinners(this.specialContext(pages, take));
|
||||||
|
case JOHTO.BattleTowerAction:
|
||||||
|
// BATTLETOWERACTION_GSBALL reads sGSBallFlag, which only the
|
||||||
|
// mobile/Virtual Console event sets. The Battle Tower's other
|
||||||
|
// actions are not ported yet.
|
||||||
|
if (value === BATTLETOWERACTION_GSBALL) return 0;
|
||||||
|
return undefined;
|
||||||
|
case JOHTO.GameboyCheck:
|
||||||
|
// The port is a Game Boy Color.
|
||||||
|
return GBCHECK_CGB;
|
||||||
|
case JOHTO.UnlockMysteryGift:
|
||||||
|
// sMysteryGiftUnlocked only adds MYSTERY GIFT to the main menu,
|
||||||
|
// which belongs to the link features.
|
||||||
|
return value;
|
||||||
|
case JOHTO.PlaceMoneyTopRight:
|
||||||
|
this.balance = this.moneyBalance();
|
||||||
|
return value;
|
||||||
|
case JOHTO.DisplayCoinCaseBalance:
|
||||||
|
this.balance = this.coinBalance();
|
||||||
|
return value;
|
||||||
|
case JOHTO.DisplayMoneyAndCoinBalance:
|
||||||
|
this.balance = `${this.moneyBalance()}\n${this.coinBalance()}`;
|
||||||
|
return value;
|
||||||
case JOHTO.HoOhChamber:
|
case JOHTO.HoOhChamber:
|
||||||
if (hoOhChamber(this.state.party))
|
if (hoOhChamber(this.state.party))
|
||||||
this.state.events.add(EVENTS.wallOpenedInHoOhChamber);
|
this.state.events.add(EVENTS.wallOpenedInHoOhChamber);
|
||||||
|
|||||||
@@ -131,6 +131,20 @@ export class Pokedex {
|
|||||||
yield { kind: "pokedex" };
|
yield { kind: "pokedex" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** NewPokedexEntry: registration shows both pages in order, each
|
||||||
|
* waiting for A or B, with no entry action menu. */
|
||||||
|
*newEntry(species: number): Gen {
|
||||||
|
for (const page of [1, 2] as const) {
|
||||||
|
const view = this.entryView(page, 0, species);
|
||||||
|
view.entry!.actions = [];
|
||||||
|
for (;;) {
|
||||||
|
const button = yield* this.button(view);
|
||||||
|
if (button === "a" || button === "b") break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield { kind: "pokedex" };
|
||||||
|
}
|
||||||
|
|
||||||
private *button(view: PokedexView): Gen<string> {
|
private *button(view: PokedexView): Gen<string> {
|
||||||
const button = yield { kind: "pokedex", view };
|
const button = yield { kind: "pokedex", view };
|
||||||
if (typeof button !== "string" || !POKEDEX_BUTTONS.includes(button))
|
if (typeof button !== "string" || !POKEDEX_BUTTONS.includes(button))
|
||||||
@@ -299,9 +313,12 @@ export class Pokedex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** DisplayDexEntry. */
|
/** DisplayDexEntry. */
|
||||||
private entryView(page: 1 | 2, action: number): PokedexView {
|
private entryView(
|
||||||
|
page: 1 | 2,
|
||||||
|
action: number,
|
||||||
|
species = this.selected(),
|
||||||
|
): PokedexView {
|
||||||
const { data, dex, caught } = this.host;
|
const { data, dex, caught } = this.host;
|
||||||
const species = this.selected();
|
|
||||||
const entry = dex.entry(species);
|
const entry = dex.entry(species);
|
||||||
const owned = caught.has(species);
|
const owned = caught.has(species);
|
||||||
const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"),
|
const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { RomReader } from "../rom/reader";
|
||||||
|
import type { PokemonData } from "../rom/pokemon";
|
||||||
|
import type { ScriptEffect, ScriptState } from "./interpreter";
|
||||||
|
import type { TextContext } from "./text";
|
||||||
|
|
||||||
|
type Input = number | string | undefined;
|
||||||
|
export type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||||
|
|
||||||
|
/** What a special routine translated outside the interpreter works with:
|
||||||
|
* the script's draft state, its text buffers and pages, and the game. */
|
||||||
|
export interface SpecialContext {
|
||||||
|
reader: RomReader;
|
||||||
|
data: PokemonData;
|
||||||
|
state: ScriptState;
|
||||||
|
/** wStringBuffer1 and the other RAM strings text_ram prints. */
|
||||||
|
strings: Record<number, string>;
|
||||||
|
/** Native values for text_decimal, keyed by RAM address. */
|
||||||
|
numbers: Record<number, number>;
|
||||||
|
text(): TextContext;
|
||||||
|
/** The Random routine: one byte. */
|
||||||
|
random(): number;
|
||||||
|
/** wPlayerID and the player's name. */
|
||||||
|
player(): { id: number; name: string };
|
||||||
|
/** The game clock's day number and weekday (wCurDay's and GetWeekday's
|
||||||
|
* source). */
|
||||||
|
today(): number;
|
||||||
|
weekday(): number;
|
||||||
|
/** Print a text into the pending pages. */
|
||||||
|
write(address: number): void;
|
||||||
|
/** Take the pending pages (for an effect that shows them). */
|
||||||
|
take(): string[];
|
||||||
|
/** Show the pending pages and wait for a button, if there are any. */
|
||||||
|
flush(): Gen;
|
||||||
|
/** SelectMonFromParty after the pending pages: a party index, or -1. */
|
||||||
|
selectMon(): Gen<number>;
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ export const OW = {
|
|||||||
ENGINE_UNLOCKED_UNOWNS_A_TO_K: 43,
|
ENGINE_UNLOCKED_UNOWNS_A_TO_K: 43,
|
||||||
ENGINE_UNOWN_DEX: 12,
|
ENGINE_UNOWN_DEX: 12,
|
||||||
PREDEFPAL_UNOWN_PUZZLE: 76,
|
PREDEFPAL_UNOWN_PUZZLE: 76,
|
||||||
|
ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED: 86,
|
||||||
|
ENGINE_LUCKY_NUMBER_SHOW: 78,
|
||||||
ENGINE_HIVEBADGE: 28,
|
ENGINE_HIVEBADGE: 28,
|
||||||
ENGINE_PLAINBADGE: 29,
|
ENGINE_PLAINBADGE: 29,
|
||||||
ENGINE_FOGBADGE: 30,
|
ENGINE_FOGBADGE: 30,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/** The RTC's day counter wraps every 20 weeks (FixDays keeps wCurDay below
|
||||||
|
* 140), and day differences wrap with it (_CalcDaysSince). */
|
||||||
|
export const RTC_DAYS = 20 * 7;
|
||||||
|
|
||||||
|
/** A day countdown (InitNDaysCountdown's two bytes): the days remaining
|
||||||
|
* and the wCurDay it was last checked on. */
|
||||||
|
export interface DayTimer {
|
||||||
|
remaining: number;
|
||||||
|
day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wCurDay for a native day number. */
|
||||||
|
export const rtcDay = (day: number) => ((day % RTC_DAYS) + RTC_DAYS) % RTC_DAYS;
|
||||||
|
|
||||||
|
/** InitNDaysCountdown. */
|
||||||
|
export function initCountdown(days: number, today: number): DayTimer {
|
||||||
|
return { remaining: days, day: rtcDay(today) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CheckDayDependentEventHL: CalcDaysSince (which also moves the stored
|
||||||
|
* day to today) and UpdateTimeRemaining. True (carry) once the countdown
|
||||||
|
* has run out; a timer never started (`undefined`, all zeros in the
|
||||||
|
* original) is already out. */
|
||||||
|
export function checkCountdown(timer: DayTimer | undefined, today: number) {
|
||||||
|
const current = rtcDay(today);
|
||||||
|
const start = timer ?? { remaining: 0, day: 0 };
|
||||||
|
const elapsed = (current - start.day + RTC_DAYS) % RTC_DAYS;
|
||||||
|
const remaining = Math.max(0, start.remaining - elapsed);
|
||||||
|
return { done: remaining === 0, timer: { remaining, day: current } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The days until next Friday (RestartLuckyNumberCountdown's
|
||||||
|
* .GetDaysUntilNextFriday): 7 on Fridays and 6 on Saturdays. */
|
||||||
|
export function daysUntilNextFriday(weekday: number) {
|
||||||
|
const FRIDAY = 5;
|
||||||
|
const days = FRIDAY - weekday;
|
||||||
|
return days > 0 ? days : days + 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validDayTimer(value: unknown): value is DayTimer {
|
||||||
|
const timer = value as DayTimer;
|
||||||
|
return (
|
||||||
|
Boolean(timer) &&
|
||||||
|
typeof timer === "object" &&
|
||||||
|
Number.isInteger(timer.remaining) &&
|
||||||
|
timer.remaining >= 0 &&
|
||||||
|
timer.remaining <= 255 &&
|
||||||
|
Number.isInteger(timer.day) &&
|
||||||
|
timer.day >= 0 &&
|
||||||
|
timer.day < RTC_DAYS
|
||||||
|
);
|
||||||
|
}
|
||||||
+53
-6
@@ -55,10 +55,12 @@ import {
|
|||||||
type ScriptHost,
|
type ScriptHost,
|
||||||
type Portrait,
|
type Portrait,
|
||||||
type ScriptState,
|
type ScriptState,
|
||||||
|
type DayTimerName,
|
||||||
} from "../scripts/interpreter";
|
} from "../scripts/interpreter";
|
||||||
import { IntroRun } from "../scripts/intro";
|
import { IntroRun } from "../scripts/intro";
|
||||||
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
|
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
|
||||||
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
|
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
|
||||||
|
import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers";
|
||||||
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
|
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
|
||||||
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
|
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
|
||||||
import {
|
import {
|
||||||
@@ -109,6 +111,9 @@ import {
|
|||||||
type Warp,
|
type Warp,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
/** The day countdowns a save may hold. */
|
||||||
|
const DAY_TIMERS: readonly DayTimerName[] = ["luckyNumber"];
|
||||||
|
|
||||||
export interface NativeSave {
|
export interface NativeSave {
|
||||||
decorations: Decorations;
|
decorations: Decorations;
|
||||||
format: "crystal-native";
|
format: "crystal-native";
|
||||||
@@ -163,6 +168,8 @@ export interface NativeSave {
|
|||||||
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
|
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
|
||||||
flash: boolean;
|
flash: boolean;
|
||||||
noWildEncounters: boolean;
|
noWildEncounters: boolean;
|
||||||
|
/** STATUSFLAGS_HALL_OF_FAME_F. */
|
||||||
|
hallOfFame: boolean;
|
||||||
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
|
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
|
||||||
registeredItem: { which: number; item: number };
|
registeredItem: { which: number; item: number };
|
||||||
/** sMailboxes: the PC's MAIL. */
|
/** sMailboxes: the PC's MAIL. */
|
||||||
@@ -178,6 +185,9 @@ export interface NativeSave {
|
|||||||
/** wUnownDex and wFirstUnownSeen. */
|
/** wUnownDex and wFirstUnownSeen. */
|
||||||
unownDex: number[];
|
unownDex: number[];
|
||||||
firstUnownSeen: number;
|
firstUnownSeen: number;
|
||||||
|
/** RTC day countdowns by name, and the Lucky Number show's number. */
|
||||||
|
timers: Partial<Record<DayTimerName, DayTimer>>;
|
||||||
|
luckyNumber: { id: number; day: number };
|
||||||
/** The option bytes, saved with the game as sOptions is. */
|
/** The option bytes, saved with the game as sOptions is. */
|
||||||
options: GameOptions;
|
options: GameOptions;
|
||||||
}
|
}
|
||||||
@@ -274,7 +284,7 @@ export interface Pose {
|
|||||||
inGrass?: boolean;
|
inGrass?: boolean;
|
||||||
}
|
}
|
||||||
export type Choice =
|
export type Choice =
|
||||||
| { kind: "yesno"; selected: 0 | 1 }
|
| { kind: "yesno"; selected: 0 | 1; balance?: string }
|
||||||
| { kind: "weekday"; names: string[]; selected: number }
|
| { kind: "weekday"; names: string[]; selected: number }
|
||||||
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
|
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
|
||||||
| { kind: "clock"; labels: string[]; selected: number }
|
| { kind: "clock"; labels: string[]; selected: number }
|
||||||
@@ -295,6 +305,8 @@ export type Choice =
|
|||||||
selected: number;
|
selected: number;
|
||||||
/** Left and right answer "left" or "right". */
|
/** Left and right answer "left" or "right". */
|
||||||
sides?: boolean;
|
sides?: boolean;
|
||||||
|
/** A money or coin box shown with the menu. */
|
||||||
|
balance?: string;
|
||||||
}
|
}
|
||||||
/** The mart's BUY/SELL/QUIT box. */
|
/** The mart's BUY/SELL/QUIT box. */
|
||||||
| { kind: "martmenu"; prompt: string; selected: number }
|
| { kind: "martmenu"; prompt: string; selected: number }
|
||||||
@@ -2103,6 +2115,7 @@ export class GameWorld {
|
|||||||
environment: () => this.assets.map(mapId).environment,
|
environment: () => this.assets.map(mapId).environment,
|
||||||
phoneService: () => this.assets.map(mapId).phoneService,
|
phoneService: () => this.assets.map(mapId).phoneService,
|
||||||
facingObject: () => this.facingObject(),
|
facingObject: () => this.facingObject(),
|
||||||
|
day: () => this.gameDay(),
|
||||||
tileset: () => ({
|
tileset: () => ({
|
||||||
graphics: this.assets.tileset(this.map).graphics,
|
graphics: this.assets.tileset(this.map).graphics,
|
||||||
palettes: this.assets.palettes(this.map, this.timeOfDay),
|
palettes: this.assets.palettes(this.map, this.timeOfDay),
|
||||||
@@ -2408,7 +2421,11 @@ export class GameWorld {
|
|||||||
this.dialogue = {
|
this.dialogue = {
|
||||||
pages: effect.pages.length ? effect.pages : [""],
|
pages: effect.pages.length ? effect.pages : [""],
|
||||||
index: 0,
|
index: 0,
|
||||||
choice: { kind: "yesno", selected: 1 },
|
choice: {
|
||||||
|
kind: "yesno",
|
||||||
|
selected: 1,
|
||||||
|
...(effect.balance && { balance: effect.balance }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
case "weekday":
|
case "weekday":
|
||||||
@@ -2718,6 +2735,7 @@ export class GameWorld {
|
|||||||
...(effect.details && { details: effect.details }),
|
...(effect.details && { details: effect.details }),
|
||||||
selected: effect.selected,
|
selected: effect.selected,
|
||||||
...(effect.sides && { sides: true }),
|
...(effect.sides && { sides: true }),
|
||||||
|
...(effect.balance && { balance: effect.balance }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
@@ -3332,12 +3350,18 @@ export class GameWorld {
|
|||||||
|
|
||||||
/** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the
|
/** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the
|
||||||
* first idle check on a new game day clears the daily engine flags. */
|
* first idle check on a new game day clears the daily engine flags. */
|
||||||
private checkDailyReset() {
|
/** The game clock's day number, which moves with SetDayOfWeek's
|
||||||
const now = this.gameNow(this.state.clock);
|
* offset as the RTC's days do. */
|
||||||
const day =
|
private gameDay(clock = this.liveState.clock) {
|
||||||
|
const now = this.gameNow(clock);
|
||||||
|
return (
|
||||||
Math.floor(
|
Math.floor(
|
||||||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
|
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
|
||||||
) + this.state.clock.weekdayOffset;
|
) + clock.weekdayOffset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private checkDailyReset() {
|
||||||
|
const day = this.gameDay(this.state.clock);
|
||||||
if (this.state.dailyReset === day) return;
|
if (this.state.dailyReset === day) return;
|
||||||
this.dailyFlags ??= this.assets.dailyEngineFlags();
|
this.dailyFlags ??= this.assets.dailyEngineFlags();
|
||||||
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
||||||
@@ -4362,6 +4386,7 @@ export class GameWorld {
|
|||||||
digWarp: state.digWarp && { ...state.digWarp },
|
digWarp: state.digWarp && { ...state.digWarp },
|
||||||
flash: state.flash,
|
flash: state.flash,
|
||||||
noWildEncounters: state.noWildEncounters,
|
noWildEncounters: state.noWildEncounters,
|
||||||
|
hallOfFame: state.hallOfFame,
|
||||||
registeredItem: { ...state.registeredItem },
|
registeredItem: { ...state.registeredItem },
|
||||||
mailbox: state.mailbox.map(copyMail),
|
mailbox: state.mailbox.map(copyMail),
|
||||||
repel: state.repel,
|
repel: state.repel,
|
||||||
@@ -4371,6 +4396,8 @@ export class GameWorld {
|
|||||||
memory: { ...state.memory },
|
memory: { ...state.memory },
|
||||||
unownDex: [...state.unownDex],
|
unownDex: [...state.unownDex],
|
||||||
firstUnownSeen: state.firstUnownSeen,
|
firstUnownSeen: state.firstUnownSeen,
|
||||||
|
timers: structuredClone(state.timers),
|
||||||
|
luckyNumber: { ...state.luckyNumber },
|
||||||
options: { ...state.options },
|
options: { ...state.options },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4485,6 +4512,7 @@ export class GameWorld {
|
|||||||
) ||
|
) ||
|
||||||
typeof save.flash !== "boolean" ||
|
typeof save.flash !== "boolean" ||
|
||||||
typeof save.noWildEncounters !== "boolean" ||
|
typeof save.noWildEncounters !== "boolean" ||
|
||||||
|
typeof save.hallOfFame !== "boolean" ||
|
||||||
!save.registeredItem ||
|
!save.registeredItem ||
|
||||||
!integer(save.registeredItem.which, 0, 255) ||
|
!integer(save.registeredItem.which, 0, 255) ||
|
||||||
!integer(save.registeredItem.item, 0, 255) ||
|
!integer(save.registeredItem.item, 0, 255) ||
|
||||||
@@ -4503,6 +4531,14 @@ export class GameWorld {
|
|||||||
!save.unownDex.every((letter) => integer(letter, 1, 26)) ||
|
!save.unownDex.every((letter) => integer(letter, 1, 26)) ||
|
||||||
new Set(save.unownDex).size !== save.unownDex.length ||
|
new Set(save.unownDex).size !== save.unownDex.length ||
|
||||||
!integer(save.firstUnownSeen, 0, 26) ||
|
!integer(save.firstUnownSeen, 0, 26) ||
|
||||||
|
!save.timers ||
|
||||||
|
typeof save.timers !== "object" ||
|
||||||
|
Object.entries(save.timers).some(
|
||||||
|
([name, timer]) => !DAY_TIMERS.includes(name as never) || !validDayTimer(timer),
|
||||||
|
) ||
|
||||||
|
!save.luckyNumber ||
|
||||||
|
!integer(save.luckyNumber.id, 0, 0xffff) ||
|
||||||
|
!integer(save.luckyNumber.day, 0, RTC_DAYS) ||
|
||||||
!validOptions(save.options)
|
!validOptions(save.options)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -4569,6 +4605,7 @@ export class GameWorld {
|
|||||||
digWarp: save.digWarp && { ...save.digWarp },
|
digWarp: save.digWarp && { ...save.digWarp },
|
||||||
flash: save.flash,
|
flash: save.flash,
|
||||||
noWildEncounters: save.noWildEncounters,
|
noWildEncounters: save.noWildEncounters,
|
||||||
|
hallOfFame: save.hallOfFame,
|
||||||
registeredItem: { ...save.registeredItem },
|
registeredItem: { ...save.registeredItem },
|
||||||
mailbox: save.mailbox.map(copyMail),
|
mailbox: save.mailbox.map(copyMail),
|
||||||
repel: save.repel,
|
repel: save.repel,
|
||||||
@@ -4578,6 +4615,8 @@ export class GameWorld {
|
|||||||
memory: { ...save.memory },
|
memory: { ...save.memory },
|
||||||
unownDex: [...save.unownDex],
|
unownDex: [...save.unownDex],
|
||||||
firstUnownSeen: save.firstUnownSeen,
|
firstUnownSeen: save.firstUnownSeen,
|
||||||
|
timers: structuredClone(save.timers),
|
||||||
|
luckyNumber: { ...save.luckyNumber },
|
||||||
options: { ...save.options },
|
options: { ...save.options },
|
||||||
specialPhoneCall: save.specialPhoneCall,
|
specialPhoneCall: save.specialPhoneCall,
|
||||||
rivalName: save.rivalName,
|
rivalName: save.rivalName,
|
||||||
@@ -4686,6 +4725,7 @@ export function migrateSave(
|
|||||||
...(!Object.hasOwn(save, "noWildEncounters") && {
|
...(!Object.hasOwn(save, "noWildEncounters") && {
|
||||||
noWildEncounters: false,
|
noWildEncounters: false,
|
||||||
}),
|
}),
|
||||||
|
...(!Object.hasOwn(save, "hallOfFame") && { hallOfFame: false }),
|
||||||
...(!Object.hasOwn(save, "registeredItem") && {
|
...(!Object.hasOwn(save, "registeredItem") && {
|
||||||
registeredItem: { which: 0, item: 0 },
|
registeredItem: { which: 0, item: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -4712,6 +4752,10 @@ export function migrateSave(
|
|||||||
unownDex: [],
|
unownDex: [],
|
||||||
firstUnownSeen: 0,
|
firstUnownSeen: 0,
|
||||||
}),
|
}),
|
||||||
|
...(!Object.hasOwn(save, "timers") && {
|
||||||
|
timers: {},
|
||||||
|
luckyNumber: { id: 0, day: 0 },
|
||||||
|
}),
|
||||||
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
||||||
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
|
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
|
||||||
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
|
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
|
||||||
@@ -4759,6 +4803,7 @@ export function migrateSave(
|
|||||||
digWarp: null,
|
digWarp: null,
|
||||||
flash: false,
|
flash: false,
|
||||||
noWildEncounters: false,
|
noWildEncounters: false,
|
||||||
|
hallOfFame: false,
|
||||||
registeredItem: { which: 0, item: 0 },
|
registeredItem: { which: 0, item: 0 },
|
||||||
mailbox: [],
|
mailbox: [],
|
||||||
repel: 0,
|
repel: 0,
|
||||||
@@ -4768,6 +4813,8 @@ export function migrateSave(
|
|||||||
memory: {},
|
memory: {},
|
||||||
unownDex: [],
|
unownDex: [],
|
||||||
firstUnownSeen: 0,
|
firstUnownSeen: 0,
|
||||||
|
timers: {},
|
||||||
|
luckyNumber: { id: 0, day: 0 },
|
||||||
intro: false,
|
intro: false,
|
||||||
gameTime: emptyGameTime(),
|
gameTime: emptyGameTime(),
|
||||||
options: defaultOptions(),
|
options: defaultOptions(),
|
||||||
|
|||||||
+10
-1
@@ -179,6 +179,7 @@ function showChoice(choice: Choice | undefined) {
|
|||||||
container.hidden = true;
|
container.hidden = true;
|
||||||
} else if (choice.kind === "yesno")
|
} else if (choice.kind === "yesno")
|
||||||
container.replaceChildren(
|
container.replaceChildren(
|
||||||
|
...balanceBox(choice.balance),
|
||||||
button("YES", choice.selected === 1, () => runtime.choose(1)),
|
button("YES", choice.selected === 1, () => runtime.choose(1)),
|
||||||
button("NO", choice.selected === 0, () => runtime.choose(0)),
|
button("NO", choice.selected === 0, () => runtime.choose(0)),
|
||||||
);
|
);
|
||||||
@@ -296,13 +297,21 @@ function showChoice(choice: Choice | undefined) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// As in the original there is no CANCEL entry: B leaves the menu.
|
// As in the original there is no CANCEL entry: B leaves the menu.
|
||||||
container.replaceChildren(list);
|
container.replaceChildren(...balanceBox(choice.balance), list);
|
||||||
// On a small screen the box scrolls; keep the highlighted entry in view.
|
// On a small screen the box scrolls; keep the highlighted entry in view.
|
||||||
list
|
list
|
||||||
.querySelector('[aria-selected="true"]')
|
.querySelector('[aria-selected="true"]')
|
||||||
?.scrollIntoView({ block: "nearest" });
|
?.scrollIntoView({ block: "nearest" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** The money or coin box a script placed beside a choice. */
|
||||||
|
function balanceBox(balance: string | undefined): HTMLElement[] {
|
||||||
|
if (!balance) return [];
|
||||||
|
const box = document.createElement("output");
|
||||||
|
box.className = "choice-balance";
|
||||||
|
box.textContent = balance;
|
||||||
|
return [box];
|
||||||
|
}
|
||||||
/** The naming screen as a text field and OK: an empty name keeps the
|
/** The naming screen as a text field and OK: an empty name keeps the
|
||||||
* current one. */
|
* current one. */
|
||||||
function nameField(
|
function nameField(
|
||||||
|
|||||||
@@ -1914,3 +1914,14 @@ dialog dd {
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PlaceMoneyTopRight and the coin case's box beside a choice. */
|
||||||
|
.choice-balance {
|
||||||
|
display: block;
|
||||||
|
justify-self: end;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 3px double currentColor;
|
||||||
|
white-space: pre;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
checkCountdown,
|
||||||
|
daysUntilNextFriday,
|
||||||
|
} from "../src/game/world/day-timers";
|
||||||
|
|
||||||
|
describe("RTC day countdowns", () => {
|
||||||
|
it("expires at the deadline, including across the 140-day wrap", () => {
|
||||||
|
const start = { remaining: 3, day: 139 };
|
||||||
|
const next = checkCountdown(start, 140);
|
||||||
|
expect(next).toEqual({ done: false, timer: { remaining: 2, day: 0 } });
|
||||||
|
expect(checkCountdown(next.timer, 140)).toEqual(next);
|
||||||
|
expect(checkCountdown(next.timer, 142)).toEqual({
|
||||||
|
done: true,
|
||||||
|
timer: { remaining: 0, day: 2 },
|
||||||
|
});
|
||||||
|
expect(checkCountdown(start, 150).done).toBe(true);
|
||||||
|
expect(checkCountdown(undefined, 140).done).toBe(true);
|
||||||
|
expect(start).toEqual({ remaining: 3, day: 139 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a Friday prize closed until the following Friday", () => {
|
||||||
|
expect(
|
||||||
|
Array.from({ length: 7 }, (_, day) => daysUntilNextFriday(day)),
|
||||||
|
).toEqual([5, 4, 3, 2, 1, 7, 6]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { RomReader } from "../src/game/rom/reader";
|
||||||
|
import { GameWorld, migrateSave } from "../src/game/world/world";
|
||||||
|
import { toBoxMon } from "../src/game/party/storage";
|
||||||
|
import { OW } from "../src/game/world/constants";
|
||||||
|
import { rtcDay } from "../src/game/world/day-timers";
|
||||||
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
|
import { ScriptRun, emptyScriptState } from "../src/game/scripts/interpreter";
|
||||||
|
import { defaultTextContext } from "../src/game/scripts/text";
|
||||||
|
import { PokemonData } from "../src/game/rom/pokemon";
|
||||||
|
import { settle } from "./world-driver";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))(
|
||||||
|
"Goldenrod City with the local ROM",
|
||||||
|
() => {
|
||||||
|
let bytes: Uint8Array;
|
||||||
|
beforeAll(() => {
|
||||||
|
bytes = new Uint8Array(readFileSync(romPath));
|
||||||
|
});
|
||||||
|
const UNDERGROUND = "3:53",
|
||||||
|
ROOFTOP = "11:18",
|
||||||
|
NAME_RATER = "11:10",
|
||||||
|
RADIO_TOWER_1F = "3:17",
|
||||||
|
ROUTE_35_GATE = "10:14",
|
||||||
|
GAME_CORNER = "11:19",
|
||||||
|
POKECENTER = "11:20";
|
||||||
|
const POKE_BALL = 5,
|
||||||
|
NUGGET = 0x24,
|
||||||
|
HYPER_POTION = 0x10,
|
||||||
|
MASTER_BALL = 1,
|
||||||
|
COIN_CASE = 0x36,
|
||||||
|
FLOWER_MAIL = 0x9e,
|
||||||
|
SPEAROW = 21,
|
||||||
|
ABRA = 63,
|
||||||
|
CYNDAQUIL = 155;
|
||||||
|
|
||||||
|
/** A world on `date` with a level 20 Cyndaquil caught by the player. */
|
||||||
|
const world = (date: string) => {
|
||||||
|
let seed = 11;
|
||||||
|
const game = new GameWorld(
|
||||||
|
new CrystalAssets(bytes),
|
||||||
|
() => new Date(date),
|
||||||
|
() => {
|
||||||
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||||
|
return (seed >>> 16) & 255;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
game.state.party.push(
|
||||||
|
generatePartyMon(game.assets.pokemon, {
|
||||||
|
species: CYNDAQUIL,
|
||||||
|
level: 20,
|
||||||
|
item: 0,
|
||||||
|
otId: game.playerId,
|
||||||
|
ot: game.name,
|
||||||
|
dvs: 0x8888,
|
||||||
|
caughtData: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
game.state.money = 20000;
|
||||||
|
return game;
|
||||||
|
};
|
||||||
|
/** Stand beside the object at (x, y) on `map` (across a counter if one
|
||||||
|
* is in between), face it and press A. */
|
||||||
|
const talk = (game: GameWorld, map: string, x: number, y: number) => {
|
||||||
|
const target = game.assets.map(map);
|
||||||
|
const STEP = {
|
||||||
|
up: [0, -1],
|
||||||
|
down: [0, 1],
|
||||||
|
left: [-1, 0],
|
||||||
|
right: [1, 0],
|
||||||
|
} as const;
|
||||||
|
for (const [face, [dx, dy]] of Object.entries(STEP) as [
|
||||||
|
keyof typeof STEP,
|
||||||
|
readonly [number, number],
|
||||||
|
][]) {
|
||||||
|
const across =
|
||||||
|
game.assets.collision(target, x - dx, y - dy) === 0x90 ? 2 : 1;
|
||||||
|
const px = x - dx * across,
|
||||||
|
py = y - dy * across;
|
||||||
|
try {
|
||||||
|
game.warpTo(map, px, py);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
settle(game);
|
||||||
|
game.move(face);
|
||||||
|
settle(game);
|
||||||
|
game.interact();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`No place to talk to ${x},${y} on ${map}`);
|
||||||
|
};
|
||||||
|
/** Advance to the next choice, keeping the pages shown. */
|
||||||
|
const next = (game: GameWorld, pages: string[] = []) => {
|
||||||
|
for (let tick = 0; tick < 4000; tick++) {
|
||||||
|
const dialogue = game.dialogue;
|
||||||
|
if (dialogue) {
|
||||||
|
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||||
|
if (page && pages.at(-1) !== page) pages.push(page);
|
||||||
|
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
|
||||||
|
return dialogue.choice;
|
||||||
|
game.advanceDialogue();
|
||||||
|
} else if (game.busy) game.tick();
|
||||||
|
else return undefined;
|
||||||
|
}
|
||||||
|
throw new Error(`No choice: ${game.diagnostic}`);
|
||||||
|
};
|
||||||
|
const finish = (game: GameWorld) => {
|
||||||
|
const pages: string[] = [];
|
||||||
|
for (let step = 0; step < 100; step++) {
|
||||||
|
const choice = next(game, pages);
|
||||||
|
if (!choice) {
|
||||||
|
expect(game.notice, game.diagnostic).toBe("");
|
||||||
|
return pages.join(" ");
|
||||||
|
}
|
||||||
|
game.choose(choice.kind === "yesno" ? 0 : -1);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Interaction did not finish: ${game.dialogue?.choice?.kind}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("runs the herb shop on Sunday with its own texts", () => {
|
||||||
|
const game = world("2026-09-20T12:00:00");
|
||||||
|
talk(game, UNDERGROUND, 7, 21);
|
||||||
|
const pages: string[] = [];
|
||||||
|
let choice = next(game, pages);
|
||||||
|
expect(choice?.kind).toBe("martlist");
|
||||||
|
if (choice?.kind !== "martlist") return;
|
||||||
|
const first = choice.entries[0];
|
||||||
|
expect(game.assets.pokemon.itemName(first.item)).toBe("ENERGYPOWDER");
|
||||||
|
game.choose(0);
|
||||||
|
choice = next(game, pages);
|
||||||
|
expect(choice).toMatchObject({
|
||||||
|
kind: "martquantity",
|
||||||
|
price: first.price,
|
||||||
|
});
|
||||||
|
game.choose(2);
|
||||||
|
expect(next(game, pages)?.kind).toBe("yesno");
|
||||||
|
game.choose(1);
|
||||||
|
const text = finish(game);
|
||||||
|
expect(game.state.bag.items).toContainEqual({
|
||||||
|
item: first.item,
|
||||||
|
quantity: 2,
|
||||||
|
});
|
||||||
|
expect(game.state.money).toBe(20000 - first.price * 2);
|
||||||
|
expect(pages.join(" ") + text).toMatch(/bitter/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sells one of each at the bargain shop, then closes for the day", () => {
|
||||||
|
const game = world("2026-09-21T08:00:00");
|
||||||
|
talk(game, UNDERGROUND, 7, 11);
|
||||||
|
let choice = next(game);
|
||||||
|
if (choice?.kind !== "martlist") throw new Error("No bargain list.");
|
||||||
|
expect(choice.entries[0]).toMatchObject({ item: NUGGET, price: 4500 });
|
||||||
|
game.choose(0);
|
||||||
|
expect(next(game)?.kind).toBe("yesno");
|
||||||
|
game.choose(1);
|
||||||
|
choice = next(game);
|
||||||
|
expect(choice?.kind).toBe("martlist");
|
||||||
|
// The Nugget is sold out for the rest of this visit.
|
||||||
|
game.choose(0);
|
||||||
|
const pages: string[] = [];
|
||||||
|
choice = next(game, pages);
|
||||||
|
expect(pages.join(" ")).toMatch(/sold out/i);
|
||||||
|
finish(game);
|
||||||
|
expect(game.state.bag.items).toContainEqual({
|
||||||
|
item: NUGGET,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
expect(game.state.money).toBe(20000 - 4500);
|
||||||
|
expect(
|
||||||
|
game.state.flags.has(OW.ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the rooftop sale's prices, and the second list after the Hall of Fame", () => {
|
||||||
|
const game = world("2026-09-22T12:00:00");
|
||||||
|
// Run the new day's reset before enabling its sale.
|
||||||
|
settle(game);
|
||||||
|
const ENGINE_GOLDENROD_DEPT_STORE_SALE_IS_ON = 97;
|
||||||
|
game.state.flags.add(ENGINE_GOLDENROD_DEPT_STORE_SALE_IS_ON);
|
||||||
|
talk(game, ROOFTOP, 1, 4);
|
||||||
|
let choice = next(game);
|
||||||
|
if (choice?.kind !== "martlist") throw new Error("No sale list.");
|
||||||
|
expect(choice.entries[0]).toMatchObject({ item: POKE_BALL, price: 150 });
|
||||||
|
finish(game);
|
||||||
|
game.state.hallOfFame = true;
|
||||||
|
talk(game, ROOFTOP, 1, 4);
|
||||||
|
choice = next(game);
|
||||||
|
if (choice?.kind !== "martlist") throw new Error("No sale list.");
|
||||||
|
expect(choice.entries[0]).toMatchObject({
|
||||||
|
item: HYPER_POTION,
|
||||||
|
price: 1000,
|
||||||
|
});
|
||||||
|
finish(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames the player's own Pokémon and refuses a traded one", () => {
|
||||||
|
const game = world("2026-09-22T12:00:00");
|
||||||
|
talk(game, NAME_RATER, 2, 4);
|
||||||
|
expect(next(game)?.kind).toBe("yesno");
|
||||||
|
game.choose(1);
|
||||||
|
expect(next(game)?.kind).toBe("menu");
|
||||||
|
game.choose(0);
|
||||||
|
expect(next(game)?.kind).toBe("yesno");
|
||||||
|
game.choose(1);
|
||||||
|
expect(next(game)?.kind).toBe("name");
|
||||||
|
game.choose("BLAZE");
|
||||||
|
finish(game);
|
||||||
|
expect(game.state.party[0].nickname).toBe("BLAZE");
|
||||||
|
game.state.party[0].otId ^= 1;
|
||||||
|
talk(game, NAME_RATER, 2, 4);
|
||||||
|
game.choose(1);
|
||||||
|
next(game);
|
||||||
|
game.choose(0);
|
||||||
|
const text = finish(game);
|
||||||
|
expect(text).toMatch(/perfect/i);
|
||||||
|
expect(game.state.party[0].nickname).toBe("BLAZE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives Kenya with Randy's MAIL", () => {
|
||||||
|
const game = world("2026-09-22T12:00:00");
|
||||||
|
talk(game, ROUTE_35_GATE, 0, 4);
|
||||||
|
expect(next(game)?.kind).toBe("yesno");
|
||||||
|
game.choose(1);
|
||||||
|
finish(game);
|
||||||
|
const kenya = game.state.party.at(-1)!;
|
||||||
|
expect(kenya).toMatchObject({
|
||||||
|
species: SPEAROW,
|
||||||
|
nickname: "KENYA",
|
||||||
|
ot: "RANDY",
|
||||||
|
otId: 1001,
|
||||||
|
item: FLOWER_MAIL,
|
||||||
|
});
|
||||||
|
expect(kenya.mail).toMatchObject({
|
||||||
|
message: "DARK CAVE leads\nto another road",
|
||||||
|
author: "RANDY",
|
||||||
|
authorId: 1001,
|
||||||
|
species: SPEAROW,
|
||||||
|
});
|
||||||
|
// The save keeps the gift's MAIL.
|
||||||
|
const reloaded = world("2026-09-22T12:00:00");
|
||||||
|
reloaded.restore(JSON.parse(JSON.stringify(game.save())));
|
||||||
|
expect(reloaded.state.party.at(-1)!.mail?.message).toBe(
|
||||||
|
"DARK CAVE leads\nto another road",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trades coins for a prize Pokémon with the coin box on screen", () => {
|
||||||
|
const game = world("2026-09-22T12:00:00");
|
||||||
|
game.state.bag.keyItems = [COIN_CASE];
|
||||||
|
game.state.coins = 250;
|
||||||
|
talk(game, GAME_CORNER, 18, 2);
|
||||||
|
const choice = next(game);
|
||||||
|
expect(choice).toMatchObject({ kind: "menu", balance: "COIN 250" });
|
||||||
|
if (choice?.kind !== "menu") return;
|
||||||
|
expect(choice.items[0]).toMatch(/^ABRA\s+100$/);
|
||||||
|
game.choose(0);
|
||||||
|
expect(next(game)?.kind).toBe("yesno");
|
||||||
|
game.choose(1);
|
||||||
|
expect(next(game)).toMatchObject({
|
||||||
|
kind: "pokedex",
|
||||||
|
view: { entry: { species: ABRA, page: 1, caught: true, actions: [] } },
|
||||||
|
});
|
||||||
|
// A save during registration replays the purchase without a partial
|
||||||
|
// coin debit, Pokémon reward or Pokédex registration.
|
||||||
|
expect(game.save().coins).toBe(250);
|
||||||
|
expect(game.save().party).toHaveLength(1);
|
||||||
|
expect(game.save().pokedex.caught).not.toContain(ABRA);
|
||||||
|
game.choose("right");
|
||||||
|
expect(next(game)).toMatchObject({
|
||||||
|
kind: "pokedex",
|
||||||
|
view: { entry: { page: 1 } },
|
||||||
|
});
|
||||||
|
game.choose("b");
|
||||||
|
expect(next(game)).toMatchObject({
|
||||||
|
kind: "pokedex",
|
||||||
|
view: { entry: { page: 2 } },
|
||||||
|
});
|
||||||
|
game.choose("a");
|
||||||
|
finish(game);
|
||||||
|
expect(game.state.coins).toBe(150);
|
||||||
|
expect(game.state.party.at(-1)?.species).toBe(ABRA);
|
||||||
|
expect(game.state.pokedex.caught.has(ABRA)).toBe(true);
|
||||||
|
// A second prize of the same species skips registration.
|
||||||
|
talk(game, GAME_CORNER, 18, 2);
|
||||||
|
expect(next(game)?.kind).toBe("menu");
|
||||||
|
game.choose(0);
|
||||||
|
expect(next(game)?.kind).toBe("yesno");
|
||||||
|
game.choose(1);
|
||||||
|
expect(next(game)?.kind).toBe("yesno"); // nickname
|
||||||
|
finish(game);
|
||||||
|
expect(game.state.coins).toBe(50);
|
||||||
|
expect(
|
||||||
|
game.state.party.filter((mon) => mon.species === ABRA),
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["party", "current box", "other box", "tied box", "worse box"])(
|
||||||
|
"finds Lucky Number winners: %s",
|
||||||
|
(location) => {
|
||||||
|
const game = world("2026-09-22T12:00:00");
|
||||||
|
// A number drawn today stays; the show is open again.
|
||||||
|
const today = Math.floor(Date.UTC(2026, 8, 22) / 86_400_000);
|
||||||
|
game.state.luckyNumber = { id: 12345, day: rtcDay(today) + 1 };
|
||||||
|
game.state.party[0].otId = 12345;
|
||||||
|
if (location !== "party") {
|
||||||
|
const boxed = toBoxMon(
|
||||||
|
generatePartyMon(game.assets.pokemon, {
|
||||||
|
species: ABRA,
|
||||||
|
level: 5,
|
||||||
|
item: 0,
|
||||||
|
otId: location === "worse box" ? 22345 : 12345,
|
||||||
|
ot: "KAWA",
|
||||||
|
dvs: 0,
|
||||||
|
caughtData: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
game.state.storage.boxes[location === "other box" ? 1 : 0].push(
|
||||||
|
boxed,
|
||||||
|
);
|
||||||
|
if (location === "current box" || location === "other box")
|
||||||
|
game.state.party[0].otId = 11111;
|
||||||
|
}
|
||||||
|
talk(game, RADIO_TOWER_1F, 8, 6);
|
||||||
|
const text = finish(game);
|
||||||
|
const boxWins = location === "current box" || location === "other box";
|
||||||
|
expect(text).toContain(boxWins ? "ABRA" : "CYNDAQUIL");
|
||||||
|
expect(text).toContain(boxWins ? "PC" : "party");
|
||||||
|
expect(game.state.bag.balls).toContainEqual({
|
||||||
|
item: MASTER_BALL,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
expect(game.state.flags.has(OW.ENGINE_LUCKY_NUMBER_SHOW)).toBe(true);
|
||||||
|
// Until next Friday (three days from Tuesday) the show is over.
|
||||||
|
expect(game.state.timers.luckyNumber).toEqual({
|
||||||
|
remaining: 3,
|
||||||
|
day: rtcDay(today),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("migrates older saves and preserves or rejects the new daily state", () => {
|
||||||
|
const game = world("2026-09-22T12:00:00");
|
||||||
|
const old = JSON.parse(JSON.stringify(game.save()));
|
||||||
|
delete old.hallOfFame;
|
||||||
|
delete old.timers;
|
||||||
|
delete old.luckyNumber;
|
||||||
|
expect(migrateSave(old, () => 0)).toMatchObject({
|
||||||
|
hallOfFame: false,
|
||||||
|
timers: {},
|
||||||
|
luckyNumber: { id: 0, day: 0 },
|
||||||
|
});
|
||||||
|
game.state.hallOfFame = true;
|
||||||
|
game.state.timers.luckyNumber = { remaining: 7, day: 139 };
|
||||||
|
game.state.luckyNumber = { id: 65535, day: 140 };
|
||||||
|
const save = JSON.parse(JSON.stringify(game.save()));
|
||||||
|
const reloaded = world("2026-09-22T12:00:00");
|
||||||
|
reloaded.restore(save);
|
||||||
|
expect(reloaded.save()).toMatchObject({
|
||||||
|
hallOfFame: true,
|
||||||
|
timers: { luckyNumber: { remaining: 7, day: 139 } },
|
||||||
|
luckyNumber: { id: 65535, day: 140 },
|
||||||
|
});
|
||||||
|
for (const invalid of [
|
||||||
|
{ hallOfFame: 1 },
|
||||||
|
{ timers: { luckyNumber: { remaining: -1, day: 0 } } },
|
||||||
|
{ timers: { luckyNumber: { remaining: 7, day: 140 } } },
|
||||||
|
{ luckyNumber: { id: 65536, day: 1 } },
|
||||||
|
])
|
||||||
|
expect(() => reloaded.restore({ ...save, ...invalid })).toThrow(
|
||||||
|
"Invalid native save",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters Goldenrod's Pokémon Center past the GS Ball check", () => {
|
||||||
|
const game = world("2026-09-22T12:00:00");
|
||||||
|
game.restore({ ...game.save(), map: POKECENTER, x: 3, y: 6 });
|
||||||
|
settle(game);
|
||||||
|
game.move("down");
|
||||||
|
settle(game);
|
||||||
|
expect(game.notice, game.diagnostic).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a given Pokémon to the box when the party is full", () => {
|
||||||
|
const reader = new RomReader(bytes);
|
||||||
|
const data = new PokemonData(reader);
|
||||||
|
const state = emptyScriptState();
|
||||||
|
for (let i = 0; i < 6; i++)
|
||||||
|
state.party.push(
|
||||||
|
generatePartyMon(data, {
|
||||||
|
species: CYNDAQUIL,
|
||||||
|
level: 5,
|
||||||
|
item: 0,
|
||||||
|
otId: 1,
|
||||||
|
ot: "KAWA",
|
||||||
|
dvs: 0,
|
||||||
|
caughtData: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// givepoke EEVEE, 20 then end, in a free spot of the ROM.
|
||||||
|
const rom = new Uint8Array(bytes);
|
||||||
|
rom.set([0x2d, 133, 20, 0, 0, 0x91], 0x3ff0);
|
||||||
|
const run = new ScriptRun(new RomReader(rom), 0x3ff0, state, {
|
||||||
|
mapId: "11:2",
|
||||||
|
lastTalked: 0,
|
||||||
|
sceneOwner: () => undefined,
|
||||||
|
timeOfDay: () => 1,
|
||||||
|
clock: () => ({ weekday: 2, hours: 12, minutes: 0 }),
|
||||||
|
clockDay: () => 2,
|
||||||
|
text: () => defaultTextContext("KAWA"),
|
||||||
|
data,
|
||||||
|
player: () => ({ id: 1, gender: 0 }),
|
||||||
|
random: () => 0,
|
||||||
|
landmark: () => 10,
|
||||||
|
facing: () => 0,
|
||||||
|
position: () => ({ x: 0, y: 0 }),
|
||||||
|
objectPosition: () => undefined,
|
||||||
|
playerCollision: () => 0,
|
||||||
|
environment: () => 0,
|
||||||
|
phoneService: () => 0,
|
||||||
|
facingObject: () => undefined,
|
||||||
|
});
|
||||||
|
let effect = run.resume();
|
||||||
|
expect(effect).toMatchObject({ kind: "yesno" }); // a nickname?
|
||||||
|
effect = run.resume(0);
|
||||||
|
expect(effect?.kind).toBe("text");
|
||||||
|
if (effect?.kind === "text")
|
||||||
|
expect(effect.pages.join(" ").replace(/\n/g, " ")).toContain(
|
||||||
|
"EEVEE was sent to BILL's PC.",
|
||||||
|
);
|
||||||
|
expect(run.resume()).toBeUndefined();
|
||||||
|
expect(run.state.party).toHaveLength(6);
|
||||||
|
const box = run.state.storage.boxes[run.state.storage.current];
|
||||||
|
expect(box[0]).toMatchObject({
|
||||||
|
species: 133,
|
||||||
|
level: 20,
|
||||||
|
ot: "KAWA",
|
||||||
|
otId: 1,
|
||||||
|
});
|
||||||
|
expect(run.state.pokedex.caught.has(133)).toBe(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -175,9 +175,11 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
for (const warp of map.warps) {
|
for (const warp of map.warps) {
|
||||||
expect(warp.x).toBeLessThan(map.width);
|
expect(warp.x).toBeLessThan(map.width);
|
||||||
expect(warp.y).toBeLessThan(map.height);
|
expect(warp.y).toBeLessThan(map.height);
|
||||||
if (warp.targetMap in NATIVE_MAPS)
|
// -1 returns through the saved entrance warp (e.g. elevators).
|
||||||
|
if (warp.targetWarp !== 0xff && warp.targetMap in NATIVE_MAPS)
|
||||||
expect(
|
expect(
|
||||||
assets.map(warp.targetMap).warps[warp.targetWarp - 1],
|
assets.map(warp.targetMap).warps[warp.targetWarp - 1],
|
||||||
|
`${map.id} -> ${warp.targetMap}, warp ${warp.targetWarp}`,
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,16 @@ export class Playthrough {
|
|||||||
this.world.restore(dump.save);
|
this.world.restore(dump.save);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The usable move with the most power, or the first with PP. */
|
/** The usable move with the most power, or the first with PP; a move
|
||||||
strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => {
|
* Disable blocks (its slot in the side's disable count) is skipped. */
|
||||||
|
strongest = (
|
||||||
|
mon: { moves: readonly number[]; pp: readonly number[] },
|
||||||
|
disableCount = 0,
|
||||||
|
) => {
|
||||||
|
const disabled = disableCount ? ((disableCount >> 4) & 0xf) - 1 : -1;
|
||||||
let best = -1;
|
let best = -1;
|
||||||
mon.moves.forEach((move, i) => {
|
mon.moves.forEach((move, i) => {
|
||||||
if (!move || !(mon.pp[i] & 63)) return;
|
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
|
||||||
if (
|
if (
|
||||||
best < 0 ||
|
best < 0 ||
|
||||||
this.assets.pokemon.move(move).power >
|
this.assets.pokemon.move(move).power >
|
||||||
@@ -82,19 +87,23 @@ export class Playthrough {
|
|||||||
if (prompt?.kind === "action") {
|
if (prompt?.kind === "action") {
|
||||||
const { engine } = battle,
|
const { engine } = battle,
|
||||||
mon = engine.player.mon;
|
mon = engine.player.mon;
|
||||||
const ball = world.state.bag.balls.findIndex(
|
const ball = world.liveState.bag.balls.findIndex(
|
||||||
(slot) => slot.item === C.POKE_BALL,
|
(slot) => slot.item === C.POKE_BALL,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
engine.battleMode === C.WILD_BATTLE &&
|
engine.battleMode === C.WILD_BATTLE &&
|
||||||
engine.enemy.mon.species === this.wanted &&
|
engine.enemy.mon.species === this.wanted &&
|
||||||
!world.state.party.some((member) => member.species === this.wanted) &&
|
!world.liveState.party.some((member) => member.species === this.wanted) &&
|
||||||
ball >= 0
|
ball >= 0
|
||||||
)
|
)
|
||||||
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
|
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
|
||||||
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||||
world.battleAction({ action: "run" });
|
world.battleAction({ action: "run" });
|
||||||
else world.battleAction({ action: "fight", move: this.strongest(mon) });
|
else
|
||||||
|
world.battleAction({
|
||||||
|
action: "fight",
|
||||||
|
move: this.strongest(mon, engine.player.disableCount),
|
||||||
|
});
|
||||||
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
||||||
// SHIFT's party menu: keep the Pokémon out (or send the first
|
// SHIFT's party menu: keep the Pokémon out (or send the first
|
||||||
// that can fight when one must go).
|
// that can fight when one must go).
|
||||||
@@ -102,7 +111,7 @@ export class Playthrough {
|
|||||||
world.battleChoose(
|
world.battleChoose(
|
||||||
prompt.cancel
|
prompt.cancel
|
||||||
? -1
|
? -1
|
||||||
: world.state.party.findIndex(
|
: world.liveState.party.findIndex(
|
||||||
(mon, i) =>
|
(mon, i) =>
|
||||||
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
|
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
|
||||||
),
|
),
|
||||||
@@ -409,6 +418,7 @@ export class Playthrough {
|
|||||||
* on the way battles and moves, so the approach is planned again. */
|
* on the way battles and moves, so the approach is planned again. */
|
||||||
talkTo = (id: number) => {
|
talkTo = (id: number) => {
|
||||||
const world = this.world;
|
const world = this.world;
|
||||||
|
const COUNTER = 0x90;
|
||||||
for (let attempt = 0; attempt < 4; attempt++)
|
for (let attempt = 0; attempt < 4; attempt++)
|
||||||
for (const [dx, dy, face] of [
|
for (const [dx, dy, face] of [
|
||||||
[-1, 0, "right"],
|
[-1, 0, "right"],
|
||||||
@@ -417,8 +427,11 @@ export class Playthrough {
|
|||||||
[0, -1, "down"],
|
[0, -1, "down"],
|
||||||
] as const) {
|
] as const) {
|
||||||
const target = world.objects().find((object) => object.id === id)!;
|
const target = world.objects().find((object) => object.id === id)!;
|
||||||
const x = target.x + dx,
|
// Across a counter the player stands one tile further away.
|
||||||
y = target.y + dy;
|
const across =
|
||||||
|
world.assets.collision(world.map, target.x + dx, target.y + dy) === COUNTER;
|
||||||
|
const x = target.x + dx * (across ? 2 : 1),
|
||||||
|
y = target.y + dy * (across ? 2 : 1);
|
||||||
try {
|
try {
|
||||||
if (!(world.x === x && world.y === y)) this.walk(x, y);
|
if (!(world.x === x && world.y === y)) this.walk(x, y);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -36,16 +36,26 @@ const ROUTE_32 = "10:1",
|
|||||||
SLOWPOKE_WELL_B1F = "3:40",
|
SLOWPOKE_WELL_B1F = "3:40",
|
||||||
ILEX_GATE = "11:22",
|
ILEX_GATE = "11:22",
|
||||||
ILEX_FOREST = "3:52",
|
ILEX_FOREST = "3:52",
|
||||||
ROUTE_34_GATE = "11:23";
|
ROUTE_34_GATE = "11:23",
|
||||||
|
ROUTE_34 = "11:1",
|
||||||
|
GOLDENROD = "11:2",
|
||||||
|
GOLDENROD_CENTER = "11:20",
|
||||||
|
GOLDENROD_GYM = "11:3",
|
||||||
|
BIKE_SHOP = "11:4",
|
||||||
|
RADIO_TOWER_1F = "3:17";
|
||||||
const MIRACLE_SEED = 0x75,
|
const MIRACLE_SEED = 0x75,
|
||||||
LURE_BALL = 0xa0,
|
LURE_BALL = 0xa0,
|
||||||
HM01 = 0xf3,
|
HM01 = 0xf3,
|
||||||
CUT = 15;
|
CUT = 15,
|
||||||
|
BICYCLE = 7;
|
||||||
const EVENT_CLEARED_SLOWPOKE_WELL = 0x2b,
|
const EVENT_CLEARED_SLOWPOKE_WELL = 0x2b,
|
||||||
EVENT_BEAT_BUGSY = 0x4be,
|
EVENT_BEAT_BUGSY = 0x4be,
|
||||||
EVENT_RIVAL_AZALEA_TOWN = 0x6bf,
|
EVENT_RIVAL_AZALEA_TOWN = 0x6bf,
|
||||||
EVENT_HERDED_FARFETCHD = 0x29,
|
EVENT_HERDED_FARFETCHD = 0x29,
|
||||||
ENGINE_HIVEBADGE = 0x1c;
|
ENGINE_HIVEBADGE = 0x1c,
|
||||||
|
ENGINE_PLAINBADGE = 0x1d,
|
||||||
|
ENGINE_RADIO_CARD = 0,
|
||||||
|
EVENT_BEAT_WHITNEY = 0x4bf;
|
||||||
const PHONE_BUG_CATCHER_WADE = 16;
|
const PHONE_BUG_CATCHER_WADE = 16;
|
||||||
const FLASH = 148,
|
const FLASH = 148,
|
||||||
HM05 = 0xf7;
|
HM05 = 0xf7;
|
||||||
@@ -498,5 +508,56 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
stairs(ROUTE_34_GATE);
|
stairs(ROUTE_34_GATE);
|
||||||
reload();
|
reload();
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
|
it("reaches Goldenrod City and gets a Bicycle", () => {
|
||||||
|
traceFailures();
|
||||||
|
stairs(ROUTE_34);
|
||||||
|
cross("up", GOLDENROD);
|
||||||
|
heal(GOLDENROD_CENTER, GOLDENROD);
|
||||||
|
enter(BIKE_SHOP, "up");
|
||||||
|
talkTo(p.world.map.objects[0].id); // the clerk, across the counter
|
||||||
|
play([1]);
|
||||||
|
expect(p.world.state.bag.keyItems).toContain(BICYCLE);
|
||||||
|
exit(GOLDENROD);
|
||||||
|
reload();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it("trains on Route 34 and beats Whitney for the PLAINBADGE", () => {
|
||||||
|
traceFailures();
|
||||||
|
const restInGoldenrod = () => {
|
||||||
|
cross("up", GOLDENROD);
|
||||||
|
heal(GOLDENROD_CENTER, GOLDENROD);
|
||||||
|
cross("down", ROUTE_34);
|
||||||
|
};
|
||||||
|
cross("down", ROUTE_34);
|
||||||
|
train(30, restInGoldenrod);
|
||||||
|
restInGoldenrod();
|
||||||
|
cross("up", GOLDENROD);
|
||||||
|
enter(GOLDENROD_GYM, "up");
|
||||||
|
const whitney = p.world.map.objects[0].id;
|
||||||
|
talkTo(whitney);
|
||||||
|
play();
|
||||||
|
expect(p.world.state.events.has(EVENT_BEAT_WHITNEY)).toBe(true);
|
||||||
|
// She cries; Bridget explains on the way out, then the badge.
|
||||||
|
exit(GOLDENROD);
|
||||||
|
expect(p.log.join(" ")).toContain("WHITNEY cry.");
|
||||||
|
enter(GOLDENROD_GYM, "up");
|
||||||
|
talkTo(whitney);
|
||||||
|
play();
|
||||||
|
expect(p.world.state.flags.has(ENGINE_PLAINBADGE)).toBe(true);
|
||||||
|
expect(p.world.state.bag.tmsHms[44]).toBe(1); // TM45 ATTRACT
|
||||||
|
exit(GOLDENROD);
|
||||||
|
reload();
|
||||||
|
}, 240_000);
|
||||||
|
|
||||||
|
it("answers the Radio Tower quiz for the radio card", () => {
|
||||||
|
traceFailures();
|
||||||
|
enter(RADIO_TOWER_1F, "up");
|
||||||
|
talkTo(p.world.map.objects[5].id);
|
||||||
|
play([1, 1, 1, 0, 1, 0]);
|
||||||
|
expect(p.world.state.flags.has(ENGINE_RADIO_CARD)).toBe(true);
|
||||||
|
exit(GOLDENROD);
|
||||||
|
reload();
|
||||||
|
}, 120_000);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Reference in new issue
Block a user