From 0930b780fc5b3b41ac373071b4eccf6cf2bb8bb9 Mon Sep 17 00:00:00 2001 From: kawa Date: Wed, 7 Oct 2026 09:26:07 +0200 Subject: [PATCH] Open Goldenrod City, Whitney's gym and the Radio Tower quiz (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 6 +- docs/NATIVE_PORT.md | 2 + docs/PLAN.md | 18 +- src/game/party/mail.ts | 14 +- src/game/rom/profile.ts | 91 +++++ src/game/scripts/goldenrod.ts | 223 +++++++++++ src/game/scripts/interpreter.ts | 596 +++++++++++++++++++++++----- src/game/scripts/pokedex.ts | 21 +- src/game/scripts/special-context.ts | 36 ++ src/game/world/constants.ts | 2 + src/game/world/day-timers.ts | 52 +++ src/game/world/world.ts | 59 ++- src/main.ts | 11 +- src/ui/styles.css | 11 + tests/day-timers.test.ts | 27 ++ tests/goldenrod.test.ts | 451 +++++++++++++++++++++ tests/native.test.ts | 4 +- tests/playthrough-driver.ts | 31 +- tests/playthrough.test.ts | 67 +++- 19 files changed, 1596 insertions(+), 126 deletions(-) create mode 100644 src/game/scripts/goldenrod.ts create mode 100644 src/game/scripts/special-context.ts create mode 100644 src/game/world/day-timers.ts create mode 100644 tests/day-timers.test.ts create mode 100644 tests/goldenrod.test.ts diff --git a/README.md b/README.md index eb1cda8..88d701e 100644 --- a/README.md +++ b/README.md @@ -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. -**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. @@ -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. -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 diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index f80e4d9..3c166a2 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -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/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/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. | 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. diff --git a/docs/PLAN.md b/docs/PLAN.md index 52eb392..9f68b77 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 | | 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) | +| 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 | | 1. Complete the overworld foundation | 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 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. -- 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) -- 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. - 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). @@ -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. - 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 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 diff --git a/src/game/party/mail.ts b/src/game/party/mail.ts index 10157d9..245dffe 100644 --- a/src/game/party/mail.ts +++ b/src/game/party/mail.ts @@ -24,6 +24,15 @@ const MAIL_CHARACTERS = /^[A-Za-z0-9 ,?!.\-/'&()“”[\]:;é♂♀¥…×]*$/u; /** A message the mail entry could have written. */ 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; const lines = message.split("\n"); return ( @@ -31,8 +40,7 @@ export function validMailMessage(message: unknown): message is string { lines.every( (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 ( Boolean(mail) && typeof mail === "object" && - validMailMessage(mail.message) && + storedMailMessage(mail.message) && typeof mail.author === "string" && /^[A-Za-z0-9 -]{1,7}$/.test(mail.author) && integer(mail.authorId, 0, 0xffff) && diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index b3b74cc..ee258cc 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -1072,6 +1072,67 @@ export const JOHTO = { SelectApricornForKurt: 0x88018, "Kurt_PrintTextWhichApricorn.WhichApricornText": 0x88007, "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; /** Saved WRAM bytes that map scripts read and write directly (readmem, @@ -1080,6 +1141,7 @@ export const JOHTO = { export const SAVED_WRAM = { wFarfetchdPosition: 0xd964, wKurtApricornQuantity: 0xdca4, + wUndergroundSwitchPositions: 0xd963, } as const; export type SavedByte = keyof typeof SAVED_WRAM; /** Work RAM the native port reads by address but does not save, keyed by @@ -1168,5 +1230,34 @@ export const NATIVE_MAPS: Record = { "11:23": "Route 34 gate", "11:1": "Route 34", "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; diff --git a/src/game/scripts/goldenrod.ts b/src/game/scripts/goldenrod.ts new file mode 100644 index 0000000..43569bb --- /dev/null +++ b/src/game/scripts/goldenrod.ts @@ -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 { + 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 { + 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; +} diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index 3c9f804..504586d 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -23,6 +23,7 @@ import { type SavedByte, } from "../rom/profile"; import { PokemonData } from "../rom/pokemon"; +import { PokedexData } from "../rom/pokedex"; import { C } from "../battle/constants"; import type { BugContest } from "../battle/battle"; import { @@ -40,6 +41,8 @@ import { currentBox, emptyStorage, receivePCItem, + restorePP, + toBoxMon, type PokemonStorage, } from "../party/storage"; import { @@ -64,10 +67,24 @@ import type { FlyMapView } from "./fly-map"; import type { TileScreen } from "./screen"; import { unownPuzzle } from "./unown-puzzle"; 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 { DayTimer } from "../world/day-timers"; +/** The RTC day countdowns the native state keeps. */ +export type DayTimerName = "luckyNumber"; import { rockMonEncounter, treeMonEncounter } from "./treemons"; import type { TrainerCardView } from "./trainer-card"; -import type { PokedexView } from "./pokedex"; +import { Pokedex, type PokedexView } from "./pokedex"; import { defaultOptions, type GameOptions, @@ -139,6 +156,8 @@ export interface ScriptState { * (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */ flash: 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) * and wRegisteredItem: the item Select uses; zeros when none. */ registeredItem: { which: number; item: number }; @@ -159,6 +178,10 @@ export interface ScriptState { * wFirstUnownSeen, the letter of the first Unown met in battle. */ unownDex: number[]; firstUnownSeen: number; + /** The RTC day countdowns by name (wLuckyNumberDayTimer is `luckyNumber`). */ + timers: Partial>; + /** sLuckyIDNumber and sLuckyNumberDay (wCurDay + 1 when drawn). */ + luckyNumber: { id: number; day: number }; /** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */ options: GameOptions; /** wParkBallsRemaining and wContestMon while the Bug-Catching Contest @@ -193,6 +216,7 @@ export const emptyScriptState = (): ScriptState => ({ digWarp: null, flash: false, noWildEncounters: false, + hallOfFame: false, registeredItem: { which: 0, item: 0 }, mailbox: [], repel: 0, @@ -202,6 +226,8 @@ export const emptyScriptState = (): ScriptState => ({ memory: {}, unownDex: [], firstUnownSeen: 0, + timers: {}, + luckyNumber: { id: 0, day: 0 }, options: defaultOptions(), }); export const copyScriptState = (state: ScriptState): ScriptState => ({ @@ -234,6 +260,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({ digWarp: state.digWarp && { ...state.digWarp }, flash: state.flash, noWildEncounters: state.noWildEncounters, + hallOfFame: state.hallOfFame, registeredItem: { ...state.registeredItem }, mailbox: state.mailbox.map(copyMail), repel: state.repel, @@ -243,6 +270,10 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({ memory: { ...state.memory }, unownDex: [...state.unownDex], firstUnownSeen: state.firstUnownSeen, + timers: Object.fromEntries( + Object.entries(state.timers).map(([name, timer]) => [name, { ...timer }]), + ), + luckyNumber: { ...state.luckyNumber }, options: { ...state.options }, ...(state.bugContest && { bugContest: { @@ -320,6 +351,13 @@ const NUM_NPC_TRADES = 7, LANDMARK_GIFT = 0x7e, PARTYMENUACTION_CHOOSE_POKEMON = 0, 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. */ const POKEMAIL_WRONG_MAIL = 0, POKEMAIL_CORRECT = 1, @@ -327,6 +365,56 @@ const POKEMAIL_WRONG_MAIL = 0, POKEMAIL_NO_MAIL = 3, 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; + /** 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. */ export type MartMode = @@ -348,7 +436,13 @@ export type ScriptEffect = /** Pages the player acknowledges. With holdTicks the last page instead * stays for a fixed time, as `writetext` followed by `pause` does. */ | { 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: "turn"; object: number; direction: Direction } | { kind: "face"; object: number; target: number } @@ -458,6 +552,8 @@ export type ScriptEffect = selected: number; /** Left and right answer "left" or "right" (MoveScreenLoop). */ 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 * script continues. */ @@ -543,6 +639,8 @@ export interface ScriptHost { /** GetFacingObject: the visible object on the facing tile, with its * MAPOBJECT_MOVEMENT. */ 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, * for specials that draw over the map. */ tileset?(): { graphics: Uint8Array; palettes: Palette[] }; @@ -663,6 +761,9 @@ export class ScriptRun { private numbers: Record = {}; /** wCurItem: the item named by itemnotify and pocketisfull. */ 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. */ private fruitTree = 0; private fruit = 0; @@ -1581,63 +1682,32 @@ export class ScriptRun { break; } case 0x2d: { + // givepoke: a gift names its nickname and OT (two pointers in + // the script's bank) after the flag. const species = byte(), level = byte(), item = byte(), - gift = byte(); - if (gift) - throw new UnsupportedScript(offset, opcode, " (gift with OT)"); - // A MAIL item needs its message (GivePokeMail). - if (item && data.itemIsMail(item)) - 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: "'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; + gift = byte() + ? { nickname: near(), ot: near() } + : undefined; + value = yield* this.givePoke(species, level, item, gift, write, take); + break; + } + 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; } case 0x40: @@ -1705,7 +1775,10 @@ export class ScriptRun { menuHeader = near(); break; 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: { // _2dmenu over the loaded header's MenuData: its strings, the // default option, and wMenuCursorPosition (0 for B). @@ -1735,6 +1808,48 @@ export class ScriptRun { value = answer < 0 ? 0 : answer + 1; 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: { // trade: NPCTrade. const index = byte(); @@ -1864,6 +1979,8 @@ export class ScriptRun { case 0x47: break; // opentext: the native dialogue opens with its first page. case 0x49: + // closetext: the money box closes with the text box. + this.balance = undefined; yield* flush(); break; case 0x54: @@ -1879,7 +1996,11 @@ export class ScriptRun { write(near()); break; 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; break; } @@ -2081,10 +2202,10 @@ export class ScriptRun { case 0x94: { const type = byte(), martId = word(); - if (type !== 0 /* MARTTYPE_STANDARD */) + if (type > MARTTYPE_ROOFTOP) throw new UnsupportedScript(offset, opcode, " (mart type)"); yield* flush(); - yield* this.pokemart(martId, pages); + yield* this.pokemart(type, martId, pages); break; } default: @@ -2094,26 +2215,85 @@ export class ScriptRun { } } - /** OpenMartDialog/MartDialog/StandardMart (engine/items/mart.asm), for - * MARTTYPE_STANDARD only. Welcome, then BUY/SELL/QUIT until the player - * quits; buying and selling loop over their own item lists until the - * player backs out, then the menu asks "anything else?" and repeats. */ + /** OpenMartDialog (engine/items/mart.asm) by MARTTYPE_*: the standard + * mart's BUY/SELL/QUIT loop, or straight to BuyMenu for the herb shop, + * the bargain shop, the pharmacy and the rooftop sale, each with its own + * 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( + type: number, martId: number, pages: string[], ): Generator { - const r = this.reader; + const r = this.reader, + data = this.host.data; const write = (address: number) => { const content = readText(r, address, this.text()); if (content) pages.push(...content.split("\n\n")); }; const take = () => pages.splice(0, pages.length); - - const entry = r.near(SYMBOLS.marts + martId * 2, r.bank(SYMBOLS.marts)); - let p = entry; - const count = r.byte(p++); - const stock = Array.from({ length: count }, () => r.byte(p++)); - + // MartTextbox: the text, then a button. + const textbox = function* (address: number): Generator { + write(address); + if (pages.length) yield { kind: "text", pages: take() }; + }; + // 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; for (;;) { const choice = yield { @@ -2127,7 +2307,7 @@ export class ScriptRun { }; first = false; 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 { write(SYMBOLS.martComeAgainText); @@ -2137,12 +2317,16 @@ export class ScriptRun { } } - /** BuyMenu/BuyMenuLoop: pick a stocked item, a 1-99 quantity, confirm the - * price, then check funds and bag room before handing it over. */ + /** BuyMenu/BuyMenuLoop: pick a stocked item, then the quantity + * (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( - stock: number[], + stock: { item: number; price: number }[], pages: string[], - ): Generator { + texts: MartTexts, + ): Generator { const r = this.reader, data = this.host.data; const write = (address: number) => { @@ -2153,11 +2337,13 @@ export class ScriptRun { const flush = function* (): Generator { if (pages.length) yield { kind: "text", pages: take() }; }; + const soldOut = new Set(); + let bought = false; for (;;) { - const entries: MartEntry[] = stock.map((item) => ({ + const entries: MartEntry[] = stock.map(({ item, price }) => ({ item, name: data.itemName(item), - price: data.itemPrice(item), + price, })); const selection = yield { kind: "martlist", @@ -2165,39 +2351,52 @@ export class ScriptRun { entries, selected: 0, }; - const item = typeof selection === "number" ? stock[selection] : undefined; - if (item === undefined) return; - const price = data.itemPrice(item); - const chosen = yield { - kind: "martquantity", - mode: "buy", - item, - name: data.itemName(item), - price, - max: 99, - prompt: readText(r, SYMBOLS.martHowManyText, this.text()), - selected: 1, - }; - if (typeof chosen !== "number" || chosen < 1) continue; + const index = typeof selection === "number" ? selection : -1; + const entry = stock[index]; + if (!entry) return bought; + 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", + mode: "buy", + item, + name: data.itemName(item), + price, + max: 99, + prompt: readText(r, texts.howMany!, this.text()), + selected: 1, + }; + if (typeof answer !== "number" || answer < 1) continue; + chosen = answer; + } const total = price * chosen; this.strings[RAM.stringBuffer2] = data.itemName(item); this.numbers[RAM.itemQuantityChange] = chosen; this.numbers[RAM.moneyTemp] = total; - write(SYMBOLS.martFinalPriceText); + write(texts.finalPrice); const confirmed = yield { kind: "yesno", pages: take() }; if (!confirmed) continue; if (total > this.state.money) { - write(SYMBOLS.martNoMoneyText); + write(texts.noMoney); yield* flush(); continue; } if (!receiveItem(data, this.state.bag, item, chosen)) { - write(SYMBOLS.martPackFullText); + write(texts.packFull); yield* flush(); continue; } + soldOut.add(index); + bought = true; this.state.money -= total; - write(SYMBOLS.martThanksText); + write(texts.thanks); 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 { + 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: "'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 * Items pocket (FindApricornsInBag, in ApricornBalls' order) with their * counts, then how many (BuySellToss_InterpretJoypad's 1 to the count, @@ -2904,6 +3242,72 @@ export class ScriptRun { } case JOHTO.SelectApricornForKurt: 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: if (hoOhChamber(this.state.party)) this.state.events.add(EVENTS.wallOpenedInHoOhChamber); diff --git a/src/game/scripts/pokedex.ts b/src/game/scripts/pokedex.ts index 014cf8a..4681c00 100644 --- a/src/game/scripts/pokedex.ts +++ b/src/game/scripts/pokedex.ts @@ -131,6 +131,20 @@ export class 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 { const button = yield { kind: "pokedex", view }; if (typeof button !== "string" || !POKEDEX_BUTTONS.includes(button)) @@ -299,9 +313,12 @@ export class Pokedex { } /** 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 species = this.selected(); const entry = dex.entry(species); const owned = caught.has(species); const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"), diff --git a/src/game/scripts/special-context.ts b/src/game/scripts/special-context.ts new file mode 100644 index 0000000..4c0db14 --- /dev/null +++ b/src/game/scripts/special-context.ts @@ -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 = Generator; + +/** 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; + /** Native values for text_decimal, keyed by RAM address. */ + numbers: Record; + 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; +} diff --git a/src/game/world/constants.ts b/src/game/world/constants.ts index 38236ea..32ac2c7 100644 --- a/src/game/world/constants.ts +++ b/src/game/world/constants.ts @@ -51,6 +51,8 @@ export const OW = { ENGINE_UNLOCKED_UNOWNS_A_TO_K: 43, ENGINE_UNOWN_DEX: 12, PREDEFPAL_UNOWN_PUZZLE: 76, + ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED: 86, + ENGINE_LUCKY_NUMBER_SHOW: 78, ENGINE_HIVEBADGE: 28, ENGINE_PLAINBADGE: 29, ENGINE_FOGBADGE: 30, diff --git a/src/game/world/day-timers.ts b/src/game/world/day-timers.ts new file mode 100644 index 0000000..6857e18 --- /dev/null +++ b/src/game/world/day-timers.ts @@ -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 + ); +} diff --git a/src/game/world/world.ts b/src/game/world/world.ts index 40836c7..9cd553c 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -55,10 +55,12 @@ import { type ScriptHost, type Portrait, type ScriptState, + type DayTimerName, } from "../scripts/interpreter"; import { IntroRun } from "../scripts/intro"; import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map"; 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 { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas"; import { @@ -109,6 +111,9 @@ import { type Warp, } from "./types"; +/** The day countdowns a save may hold. */ +const DAY_TIMERS: readonly DayTimerName[] = ["luckyNumber"]; + export interface NativeSave { decorations: Decorations; format: "crystal-native"; @@ -163,6 +168,8 @@ export interface NativeSave { /** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */ flash: boolean; noWildEncounters: boolean; + /** STATUSFLAGS_HALL_OF_FAME_F. */ + hallOfFame: boolean; /** wWhichRegisteredItem and wRegisteredItem, for Select. */ registeredItem: { which: number; item: number }; /** sMailboxes: the PC's MAIL. */ @@ -178,6 +185,9 @@ export interface NativeSave { /** wUnownDex and wFirstUnownSeen. */ unownDex: number[]; firstUnownSeen: number; + /** RTC day countdowns by name, and the Lucky Number show's number. */ + timers: Partial>; + luckyNumber: { id: number; day: number }; /** The option bytes, saved with the game as sOptions is. */ options: GameOptions; } @@ -274,7 +284,7 @@ export interface Pose { inGrass?: boolean; } export type Choice = - | { kind: "yesno"; selected: 0 | 1 } + | { kind: "yesno"; selected: 0 | 1; balance?: string } | { kind: "weekday"; names: string[]; selected: number } /** InitClock's hour or minute: Up and Down wrap through `labels`. */ | { kind: "clock"; labels: string[]; selected: number } @@ -295,6 +305,8 @@ export type Choice = selected: number; /** Left and right answer "left" or "right". */ sides?: boolean; + /** A money or coin box shown with the menu. */ + balance?: string; } /** The mart's BUY/SELL/QUIT box. */ | { kind: "martmenu"; prompt: string; selected: number } @@ -2103,6 +2115,7 @@ export class GameWorld { environment: () => this.assets.map(mapId).environment, phoneService: () => this.assets.map(mapId).phoneService, facingObject: () => this.facingObject(), + day: () => this.gameDay(), tileset: () => ({ graphics: this.assets.tileset(this.map).graphics, palettes: this.assets.palettes(this.map, this.timeOfDay), @@ -2408,7 +2421,11 @@ export class GameWorld { this.dialogue = { pages: effect.pages.length ? effect.pages : [""], index: 0, - choice: { kind: "yesno", selected: 1 }, + choice: { + kind: "yesno", + selected: 1, + ...(effect.balance && { balance: effect.balance }), + }, }; return true; case "weekday": @@ -2718,6 +2735,7 @@ export class GameWorld { ...(effect.details && { details: effect.details }), selected: effect.selected, ...(effect.sides && { sides: true }), + ...(effect.balance && { balance: effect.balance }), }, }; return true; @@ -3332,12 +3350,18 @@ export class GameWorld { /** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the * first idle check on a new game day clears the daily engine flags. */ - private checkDailyReset() { - const now = this.gameNow(this.state.clock); - const day = + /** The game clock's day number, which moves with SetDayOfWeek's + * offset as the RTC's days do. */ + private gameDay(clock = this.liveState.clock) { + const now = this.gameNow(clock); + return ( Math.floor( 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; this.dailyFlags ??= this.assets.dailyEngineFlags(); for (const flag of this.dailyFlags) this.state.flags.delete(flag); @@ -4362,6 +4386,7 @@ export class GameWorld { digWarp: state.digWarp && { ...state.digWarp }, flash: state.flash, noWildEncounters: state.noWildEncounters, + hallOfFame: state.hallOfFame, registeredItem: { ...state.registeredItem }, mailbox: state.mailbox.map(copyMail), repel: state.repel, @@ -4371,6 +4396,8 @@ export class GameWorld { memory: { ...state.memory }, unownDex: [...state.unownDex], firstUnownSeen: state.firstUnownSeen, + timers: structuredClone(state.timers), + luckyNumber: { ...state.luckyNumber }, options: { ...state.options }, }; } @@ -4485,6 +4512,7 @@ export class GameWorld { ) || typeof save.flash !== "boolean" || typeof save.noWildEncounters !== "boolean" || + typeof save.hallOfFame !== "boolean" || !save.registeredItem || !integer(save.registeredItem.which, 0, 255) || !integer(save.registeredItem.item, 0, 255) || @@ -4503,6 +4531,14 @@ export class GameWorld { !save.unownDex.every((letter) => integer(letter, 1, 26)) || new Set(save.unownDex).size !== save.unownDex.length || !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) ) { throw new Error( @@ -4569,6 +4605,7 @@ export class GameWorld { digWarp: save.digWarp && { ...save.digWarp }, flash: save.flash, noWildEncounters: save.noWildEncounters, + hallOfFame: save.hallOfFame, registeredItem: { ...save.registeredItem }, mailbox: save.mailbox.map(copyMail), repel: save.repel, @@ -4578,6 +4615,8 @@ export class GameWorld { memory: { ...save.memory }, unownDex: [...save.unownDex], firstUnownSeen: save.firstUnownSeen, + timers: structuredClone(save.timers), + luckyNumber: { ...save.luckyNumber }, options: { ...save.options }, specialPhoneCall: save.specialPhoneCall, rivalName: save.rivalName, @@ -4686,6 +4725,7 @@ export function migrateSave( ...(!Object.hasOwn(save, "noWildEncounters") && { noWildEncounters: false, }), + ...(!Object.hasOwn(save, "hallOfFame") && { hallOfFame: false }), ...(!Object.hasOwn(save, "registeredItem") && { registeredItem: { which: 0, item: 0 }, }), @@ -4712,6 +4752,10 @@ export function migrateSave( unownDex: [], firstUnownSeen: 0, }), + ...(!Object.hasOwn(save, "timers") && { + timers: {}, + luckyNumber: { id: 0, day: 0 }, + }), ...(!Object.hasOwn(save, "intro") && { intro: false }), ...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }), ...(!Object.hasOwn(save, "options") && { options: defaultOptions() }), @@ -4759,6 +4803,7 @@ export function migrateSave( digWarp: null, flash: false, noWildEncounters: false, + hallOfFame: false, registeredItem: { which: 0, item: 0 }, mailbox: [], repel: 0, @@ -4768,6 +4813,8 @@ export function migrateSave( memory: {}, unownDex: [], firstUnownSeen: 0, + timers: {}, + luckyNumber: { id: 0, day: 0 }, intro: false, gameTime: emptyGameTime(), options: defaultOptions(), diff --git a/src/main.ts b/src/main.ts index e3a8cbf..a8549d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -179,6 +179,7 @@ function showChoice(choice: Choice | undefined) { container.hidden = true; } else if (choice.kind === "yesno") container.replaceChildren( + ...balanceBox(choice.balance), button("YES", choice.selected === 1, () => runtime.choose(1)), 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. - container.replaceChildren(list); + container.replaceChildren(...balanceBox(choice.balance), list); // On a small screen the box scrolls; keep the highlighted entry in view. list .querySelector('[aria-selected="true"]') ?.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 * current one. */ function nameField( diff --git a/src/ui/styles.css b/src/ui/styles.css index c42dd52..f4c7657 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -1914,3 +1914,14 @@ dialog dd { image-rendering: pixelated; 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; +} diff --git a/tests/day-timers.test.ts b/tests/day-timers.test.ts new file mode 100644 index 0000000..8da807b --- /dev/null +++ b/tests/day-timers.test.ts @@ -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]); + }); +}); diff --git a/tests/goldenrod.test.ts b/tests/goldenrod.test.ts new file mode 100644 index 0000000..1456d8e --- /dev/null +++ b/tests/goldenrod.test.ts @@ -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); + }); + }, +); diff --git a/tests/native.test.ts b/tests/native.test.ts index 2b19368..8eef7fb 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -175,9 +175,11 @@ describe.skipIf(!existsSync(romPath))( for (const warp of map.warps) { expect(warp.x).toBeLessThan(map.width); 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( assets.map(warp.targetMap).warps[warp.targetWarp - 1], + `${map.id} -> ${warp.targetMap}, warp ${warp.targetWarp}`, ).toBeDefined(); } } diff --git a/tests/playthrough-driver.ts b/tests/playthrough-driver.ts index a50e72f..46a89af 100644 --- a/tests/playthrough-driver.ts +++ b/tests/playthrough-driver.ts @@ -51,11 +51,16 @@ export class Playthrough { this.world.restore(dump.save); } - /** The usable move with the most power, or the first with PP. */ - strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => { + /** The usable move with the most power, or the first with PP; a move + * 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; mon.moves.forEach((move, i) => { - if (!move || !(mon.pp[i] & 63)) return; + if (!move || !(mon.pp[i] & 63) || i === disabled) return; if ( best < 0 || this.assets.pokemon.move(move).power > @@ -82,19 +87,23 @@ export class Playthrough { if (prompt?.kind === "action") { const { engine } = battle, mon = engine.player.mon; - const ball = world.state.bag.balls.findIndex( + const ball = world.liveState.bag.balls.findIndex( (slot) => slot.item === C.POKE_BALL, ); if ( engine.battleMode === C.WILD_BATTLE && 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 ) world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball }); else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp) 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); // SHIFT's party menu: keep the Pokémon out (or send the first // that can fight when one must go). @@ -102,7 +111,7 @@ export class Playthrough { world.battleChoose( prompt.cancel ? -1 - : world.state.party.findIndex( + : world.liveState.party.findIndex( (mon, i) => 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. */ talkTo = (id: number) => { const world = this.world; + const COUNTER = 0x90; for (let attempt = 0; attempt < 4; attempt++) for (const [dx, dy, face] of [ [-1, 0, "right"], @@ -417,8 +427,11 @@ export class Playthrough { [0, -1, "down"], ] as const) { const target = world.objects().find((object) => object.id === id)!; - const x = target.x + dx, - y = target.y + dy; + // Across a counter the player stands one tile further away. + 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 { if (!(world.x === x && world.y === y)) this.walk(x, y); } catch { diff --git a/tests/playthrough.test.ts b/tests/playthrough.test.ts index 3c5dd7f..bf8bc4a 100644 --- a/tests/playthrough.test.ts +++ b/tests/playthrough.test.ts @@ -36,16 +36,26 @@ const ROUTE_32 = "10:1", SLOWPOKE_WELL_B1F = "3:40", ILEX_GATE = "11:22", 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, LURE_BALL = 0xa0, HM01 = 0xf3, - CUT = 15; + CUT = 15, + BICYCLE = 7; const EVENT_CLEARED_SLOWPOKE_WELL = 0x2b, EVENT_BEAT_BUGSY = 0x4be, EVENT_RIVAL_AZALEA_TOWN = 0x6bf, 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 FLASH = 148, HM05 = 0xf7; @@ -498,5 +508,56 @@ describe.skipIf(!existsSync(romPath))( stairs(ROUTE_34_GATE); reload(); }, 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); }, );