Port the Pokégear, phone calls and Mom's bank
Checks / web (push) Canceled after 0s

POKéGEAR in the start menu runs the clock, Town Map and phone cards with
the original joypad rules. Calls to Elm and Mom run their callee scripts,
with the out-of-area and out-of-hours cases and HangUp's corrected timing.
Mom saves a quarter of trainer prizes once asked, talking to her at home
opens BankOfMom (GET, SAVE, CHANGE and the DST checks) with its digit
amount box, and MomTriesToBuySomething buys items and dolls with her
savings. Saves gain Mom's money, her next item and its trigger balance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-01 11:38:22 +02:00
1 parent 3456aa4e11
commit 209778cabe
29 files changed
+2724 -45

No files matched your search

+2 -2
View File
@@ -12,11 +12,11 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls. Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls.
Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map. Start opens the start menu. POKéMON shows your party: open a Pokémon's stats, change the order, rearrange its moves, or give and take held items. PACK shows your pockets: use Potions, status healers, revives, Ethers, PP Ups, vitamins, Rare Candy and evolution stones, give or toss items, and ride the Bicycle. Pokémon evolve when they level up after a won battle (press B during the flashes to stop it), and learn new moves outside battle too. Field moves work from the party menu too: Cut, Surf, Strength, Flash, Whirlpool, Waterfall, Sweet Scent, and Softboiled or Milk Drink to share HP. In PACK, SEL registers a key item such as the Bicycle, and Select then uses it. POKéGEAR shows the clock, the Town Map card with a cursor over every landmark, and the phone: call Mom or Professor Elm. Call Mom after the catching lesson and she offers to save some of your trainer prize money. Once the Mystery Egg is delivered, talk to her at home to save or take money, or to switch Daylight Saving Time; with enough saved, she buys items and dolls for you and calls to say so. Bill's PC can move Pokémon between the party and any box. The poster opens the Town Map.
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations. The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
Route 31 and beyond, trade evolutions, the Pokégear, Pokédex and trainer card screens, Fly, Dig, Teleport, 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. Route 31 and beyond, trade evolutions, the Pokégear's radio, the Pokédex and trainer card screens, Fly, Dig, Teleport, 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.
## Run ## Run
+7 -1
View File
@@ -34,7 +34,7 @@ flowchart LR
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build | | `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
| `reference/public` | Licensed emulator files for the reference build only | | `reference/public` | Licensed emulator files for the reference build only |
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the game clock's minute offset, the weekday offset and DST flag, whether the introduction is still pending; positions of objects that scripts moved; and the party, bag, Pokédex sets, money, registered item and room decoration slots. Older saves without decorations migrate to the original bed and Town Map poster. Decoration imports validate slot categories, ownership and unique doll placement. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID. `GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the game clock's minute offset, the weekday offset and DST flag, whether the introduction is still pending; positions of objects that scripts moved; and the party, bag, Pokédex sets, money, Mom's savings, registered item and room decoration slots. Older saves without decorations migrate to the original bed and Town Map poster. Decoration imports validate slot categories, ownership and unique doll placement. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival. Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
@@ -81,6 +81,12 @@ boxes and the PC's items. A `memscript` effect queues Bill's call (like
`LoadMemScript`); the world keeps it in memory only and runs it before scene `LoadMemScript`); the world keeps it in memory only and runs it before scene
scripts. scripts.
The wall/poster Town Map is a ROM-backed canvas presented by a script effect. The wall/poster Town Map is a ROM-backed canvas presented by a script effect.
`scripts/pokegear.ts` runs the Pokégear inside the start menu run: each card
is a `pokegear` effect answered with a button, and a phone call runs the
callee's ROM script in the event interpreter on the same draft. The world
keeps the card on screen (`world.pokegear`) during the call's texts.
`scripts/mom.ts` runs `BankOfMom` as a special; its amount box is a
`mombank` effect answered with buttons in the same way.
`TilesetAnimator` translates the ROM animation command tables, advancing two `TilesetAnimator` translates the ROM animation command tables, advancing two
video frames per overworld tick. It owns mutable graphics separately from the video frames per overworld tick. It owns mutable graphics separately from the
+93 -1
View File
@@ -33,6 +33,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. | | Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. | | `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes, MOVE PKMN W/O MAIL, stats), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Printing is not ported. |
| `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`). MAIL and the other field items give a notice. | | `game/scripts/start-menu.ts` | `engine/menus/start_menu.asm`, `engine/pokemon/mon_menu.asm`, `mon_submenu.asm`, `party_menu.asm`, `switchpartymons.asm`, `engine/items/pack.asm`, `engine/items/item_effects.asm` | The start menu, the party menu with the Pokémon submenu (STATS, SWITCH, MOVE, ITEM) and the pack (USE, GIVE, TOSS) with the field item effects listed below, including Rare Candy, stones and vitamins. Menu strings, prompts and texts come from the ROM; screens are native. Field moves from the menu (checked by the world), Softboiled and Milk Drink, SEL and the Select button (`SelectMenu`). MAIL and the other field items give a notice. |
| `game/scripts/pokegear.ts` | `engine/pokegear/pokegear.asm`, `engine/phone/phone.asm`, `engine/phone/scripts/mom.asm`, `elm.asm`, `engine/rtc/print_hours_mins.asm` | `PokeGear` with each card's joypad handling: the clock (`Pokegear_UpdateClock`, `PrintHoursMins`), the map card's landmark cursor (`TownMap_InitCursorAndPlayerIconPositions`, `TownMap_GetKantoLandmarkLimits`), the phone list (`PokegearPhone_UpdateDisplayList`, `GetCallerClassAndName`, `PokegearPhoneContactSubmenu`, `CheckCanDeletePhoneNumber`) and calls (`MakePhoneCallFromPokegear`, `CheckPhoneContactTimeOfDay`, `GetMapPhoneService`, `HangUp`). Callee scripts run in the event interpreter on the start menu's draft. Screens are native; the radio's stations are not ported. |
| `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. | | `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. |
| `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. | | `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. |
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. | | `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. |
@@ -88,7 +89,7 @@ Native production is `dist/`; the optional reference output is `dist-reference/`
## Save schema ## Save schema
Native saves are version 3. Version 2 added scene values by owning map, phone contacts, the weekday offset and DST flag, and positions of objects that scripts moved on the current map. Version 3 adds the player ID, party, bag, Pokédex seen/caught sets and money. Early version-3 backups without money receive the original starting balance; explicitly invalid balances are rejected. Restores recompute each Pokémon's stats from its DVs and reject any mismatch, and check every item against its ROM pocket. Older saves migrate with new-game defaults; a missing player ID is drawn as a new game draws it. Objects are restored from the save on continue, and only the TILES callback runs, as in `MapSetupScript_Continue`. Native saves are version 3. Version 2 added scene values by owning map, phone contacts, the weekday offset and DST flag, and positions of objects that scripts moved on the current map. Version 3 adds the player ID, party, bag, Pokédex seen/caught sets and money, and later Mom's savings (`momsMoney`, migrated by absence to 0). Early version-3 backups without money receive the original starting balance; explicitly invalid balances are rejected. Restores recompute each Pokémon's stats from its DVs and reject any mismatch, and check every item against its ROM pocket. Older saves migrate with new-game defaults; a missing player ID is drawn as a new game draws it. Objects are restored from the save on continue, and only the TILES callback runs, as in `MapSetupScript_Continue`.
## Scripted trainer battle foundation ## Scripted trainer battle foundation
@@ -437,3 +438,94 @@ left its slot; `tests/pc.test.ts` moves Pokémon party to box, box to party,
within the party and within a full box, and refuses the last Pokémon and a within the party and within a full box, and refuses the last Pokémon and a
full destination. Browser tests register the Bicycle and ride it with full destination. Browser tests register the Bicycle and ride it with
Select from the keyboard and the on-screen button. Select from the keyboard and the on-screen button.
### The Pokégear, phone calls and Mom's savings
`PokeGear` runs from the start menu as a generator (`Pokegear`) that yields
each card as a `pokegear` effect and is answered with the button pressed, so
the original's joypad rules (`PokegearClock_Joypad`,
`PokegearMap_ContinueMap`, `PokegearPhone_Joypad`, `PokegearRadio_Joypad`
and the contact submenu's loop) stay in the rules layer. The world routes A,
B, the directions, Start and Select to it and refreshes the clock card from
the game clock every tick, as `Pokegear_UpdateClock` runs every frame.
Texts, menu strings, caller names and the AM/PM strings come from the ROM
(`POKEGEAR` profile table, verified by `check:reference`). The map header's
palette byte's high nybble is decoded as `GetMapPhoneService`.
A call runs the callee script from `PhoneContacts` (or
`PhoneOutOfAreaScript`/`PhoneScript_JustTalkToThem`) in the event
interpreter with the world's script host, on the start menu's draft; its
state is adopted when it ends. The callee's last text waits for A or B, as
`PokegearPhone_FinishPhoneCall` does, then `HangUp` shows "Click!" (20
frames) and the boops (120 frames). The two "……" rings last 12 ticks each,
an estimate from `Sfx_Call`'s notes; no sound is played.
Interpreter additions: `random` (`Script_random`'s rejection of draws past
the largest multiple), `givemoney`, `takemoney` (unchanged when short, like
`TakeMoney`), `checkmoney` (`HAVE_MORE`/`HAVE_AMOUNT`/`HAVE_LESS`) and
`getmoney` on `YOUR_MONEY` or `MOMS_MONEY`, `getcurlandmarkname` and
`getlandmarkname` (`GetLandmarkName`; `<BSP>` prints as a space). The money
and landmark macros put the account or landmark before the string buffer.
`WinTrainerBattle.give_money` now splits the reward: the reward is added
four times, to Mom for as many quarters as `wMomSavingMoney`'s mask says
(SOME|HALF counts as four) unless `.CheckMaxedOutMomMoney` finds her
account full, and to the wallet otherwise; the text is `SentSomeToMomText`,
`SentHalfToMomText` or `SentAllToMomText`. Only `ENGINE_MOM_SAVING_MONEY`
(the SOME bit) is reachable: nothing in the ROM sets the HALF and ALL bits.
After a trainer battle, `Script_reloadmapafterbattle` calls
`MomTriesToBuySomething` (see Mom's bank below).
Tests: `tests/pokegear.test.ts` (local ROM) covers the cards and their
switching, the live clock, the map cursor's wrapping, phone scrolling over
ten slots, the contact submenu, Elm's call, "Just go talk to that person!",
Mom's lecture and balance call with commits after the menu closes, no
service and the save migration. `tests/trainer-battle.test.ts` and
`tests/trainer-world.test.ts` cover Mom's quarter, her full account and the
purchase refusal. A browser test drives the cards and a call to Elm from the
keyboard and the on-screen D-pad.
### Mom's bank and her purchases
`BankOfMom` (`scripts/mom.ts`) follows the ROM's jumptable: the yes/no
questions and the GET/SAVE/CHANGE/CANCEL menu (strings from
`BankOfMom_MenuHeader`) are ordinary effects, and the deposit/withdraw box is
a `mombank` effect answered with the button pressed, like the Pokégear's
cards, so `Mom_WithdrawDepositMenuJoypad`'s rules stay in the rules layer.
`DigitQuantities` is read from the ROM. Texts, strings and the routine come
from the `MOM` profile table, verified by `check:reference`. Refusal texts
end in `prompt` and the routine returns to the same jumptable entry, which
prints its question again and resets the amount; natively the refusal is a
page before the next box. The last text is left for the script's
`waitbutton`.
`DSTChecks` reads `hHours` from the game clock. `SetClockForward` and
`SetClockBack` move `wStartHour` with a carry into `wStartDay`; the native
clock keeps `minuteOffset` within ±720 minutes, so `shiftClockHour` wraps it
and moves `weekdayOffset` the other way, which keeps the weekday and the
daily reset day unchanged.
`MomTriesToBuySomething` runs at `Script_reloadmapafterbattle` after a
trainer battle. `CheckBalance_MomItem2` compares her savings with the next
MomItems_2 trigger; failing that, `.check_have_2300` adds ¥2300 to
`wMomItemTriggerBalance` while it is below her savings, and only an exact
match buys a `RandomRange(5)` MomItems_1 entry (and steps once more). The
trigger balance keeps those increments even when nothing is bought, as in
the original. `Mom_GiveItemOrDoll` puts an item in the PC (a full PC cancels
the purchase after the steps) or sets the doll's decoration event. The ROM's
`.Script` memscript runs `.ASMFunction` (the cost and `wWhichMomItem`) and
then `Script_ReceivePhoneCall` with `Mom_GetScriptPointer`'s item or doll
script. Natively `.ASMFunction`'s changes are made when the call is queued
and the queued script is `Script_ReceivePhoneCall` itself with that caller
script; the call starts at the next `PlayerEvents`, so nothing can observe
the difference. `wMapReentryScriptQueueFlag`'s early return is the world's
rule that keeps an already queued script.
Tests: `tests/mom.test.ts` (local ROM; the clock shift runs without it)
covers the first visit, declining, deposits and withdrawals with each
refusal, the digit cursor and step limits, B and zero-amount cancels,
CHANGE, the Daylight Saving Time branches and the near-midnight refusal.
`tests/trainer-world.test.ts` covers a MomItems_2 purchase and its call, an
exact ¥2300 step, a step without a purchase, a doll, a full PC and the save
migration. A browser test deposits through the keyboard and the on-screen
buttons and checks the backup.
+25 -3
View File
@@ -190,11 +190,31 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Bill's PC's MOVE PKMN W/O MAIL: refused while a party Pokémon holds MAIL, then "Each time you move a POKéMON, data will be saved. OK?". Choose the party or a box, a Pokémon (MOVE, STATS, CANCEL), then a box and the position to insert before. The party keeps its last Pokémon and its last healthy one, a different full box or party has "no room", a Pokémon moved down its own list lands one place earlier (`.CheckTrivialMove`), a boxed Pokémon joining the party gets its stats with full HP and no status (`CalcBufferMonStats`), and one entering a box gets its PP restored. The original's left/right box switching is presented as a list of boxes; after "no room" the box is chosen again rather than the position. - Bill's PC's MOVE PKMN W/O MAIL: refused while a party Pokémon holds MAIL, then "Each time you move a POKéMON, data will be saved. OK?". Choose the party or a box, a Pokémon (MOVE, STATS, CANCEL), then a box and the position to insert before. The party keeps its last Pokémon and its last healthy one, a different full box or party has "no room", a Pokémon moved down its own list lands one place earlier (`.CheckTrivialMove`), a boxed Pokémon joining the party gets its stats with full HP and no status (`CalcBufferMonStats`), and one entering a box gets its PP restored. The original's left/right box switching is presented as a list of boxes; after "no room" the box is chosen again rather than the position.
- Save format 3 gained `registeredItem`, migrated by absence to none. - Save format 3 gained `registeredItem`, migrated by absence to none.
## Implemented: the Pokégear, phone calls and Mom's savings
- POKéGEAR in the start menu runs `PokeGear` and returns to the start menu. The clock card shows the weekday (`GearTodayText`) and `PrintHoursMins`'s 12-hour time with the ROM's AM and PM, follows the game clock while open, and exits on A, B, Start or Select. Left and right change cards in the original order, skipping the cards the player lacks (the map card from the Guide Gent, the phone card from Mom).
- The map card draws the ROM's Johto or Kanto Town Map with the player's landmark (a special landmark uses the backup map's, as `TownMap_InitCursorAndPlayerIconPositions` does) and a cursor that up and down step through the region's landmarks with wrapping; Kanto's routes need the Hall of Fame. The cursor keeps its place while the Pokégear stays open. The landmark name comes from the ROM.
- The phone card lists `wPhoneList`'s ten slots four at a time, with `GetCallerClassAndName` (non-trainer names, or a trainer's name over its class) and dashes for empty slots, and the signal bars from the map header's phone service nybble. A on a contact opens CALL/CANCEL, or CALL/DELETE/CANCEL for numbers `CheckCanDeletePhoneNumber` allows (never Mom or Elm). Deleting asks first.
- Calls follow `PokegearPhone_MakePhoneCall` and `MakePhoneCallFromPokegear`: "You're out of the service area." without service; otherwise two "……" rings, then `PhoneOutOfAreaScript` outside the callee's hours, `PhoneScript_JustTalkToThem` on the callee's map, or the callee script from `PhoneContacts`. The script runs on the start menu's draft, so its changes commit with the menu. The call ends with `HangUp`'s "Click!" and boops. Elm's and Mom's callee scripts run; trainer contacts are not reachable yet.
- Mom's savings: `wMomsMoney` is saved as `momsMoney` (migrated by absence to 0). The interpreter gained `givemoney`, `takemoney`, `checkmoney` and `getmoney` for both accounts, `getcurlandmarkname`, `getlandmarkname` and `random`. After the catching lesson, calling Mom runs her lecture, which sets `ENGINE_MOM_SAVING_MONEY` on a yes; later calls report where the player is and her balance. While she saves, a won trainer battle sends a quarter of the prize to her (`WinTrainerBattle.give_money`, "Sent some to MOM!"), unless her account is full.
- `HangUp` now shows "Click!" for its 20 frames and the boops' "……" for the remaining 120, instead of "Click!" for twice the whole duration.
- Not ported: the radio card (it is only obtainable in Goldenrod; it gives a notice); trainer callee scripts; random incoming calls; ringtones, music and the original card graphics.
## Implemented: Mom's bank and her purchases
- Talking to Mom after the Mystery Egg runs `BankOfMom`. The first time she offers to save money (`MomLeavingText1`–`3`); until New Bark Town's flypoint callback clears `EVENT_FIRST_TIME_BANKING_WITH_MOM` she only cheers the player on. Later visits ask "Or is this about your money?": GET, SAVE, CHANGE or CANCEL.
- GET and SAVE open `Mom_ContinueMenuSetup`'s box (SAVED, HELD, then WITHDRAW or DEPOSIT and a six-digit amount with a blinking digit). `Mom_WithdrawDepositMenuJoypad`: left and right move the digit cursor (starting on the last digit, stopping at both ends), up and down add or take the digit's `DigitQuantities` step through `GiveMoney`/`TakeMoney`'s ¥999999 and zero limits, A confirms and B cancels. A zero amount cancels. Refusals ("You don't have that much.", "You can't save that much.", "You haven't saved that much.", "You can't take that much.") return to a fresh box. CHANGE starts or stops her savings.
- Declining the money question runs `DSTChecks`: switch Daylight Saving Time on or off, moving the clock an hour, unless that would change the day (within an hour of midnight), when Mom has lost the Pokégear's booklet.
- `wMomSavingMoney` stays two engine flags: nothing in the ROM sets the half and all bits.
- After a won trainer battle with phone service, `MomTriesToBuySomething` buys MomItems_2's next item once her savings reach its trigger, or a random MomItems_1 item when `wMomItemTriggerBalance`'s ¥2300 steps land exactly on her savings. Items go to the PC (nothing happens when it is full), dolls to the room's decorations, and her call follows ("I bought it with your money. Sorry!").
- Save format 3 gained `momItem` and `momItemTriggerBalance` (`wWhichMomItem`, `wMomItemTriggerBalance`), migrated by absence to 0 and ¥2300.
- The amount box has no key repeat: like the other native menus, a held direction counts once.
## Native battles: current state ## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items. 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. Only the special Kurt and Park balls remain unported among battle items.
This is still not a complete new game. The Pokégear, Pokédex and trainer card screens, story progression beyond Route 30 and Mr. Pokémon's house, 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, a Johto/Kanto atlas, Pokédex records, 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 and trainer card screens, Mom's bank, story progression beyond Route 30 and Mr. Pokémon's house, 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, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
## 1. Complete the overworld foundation ## 1. Complete the overworld foundation
@@ -272,7 +292,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game. Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too. Next: the Pokégear, then the trainer card and options. Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases. Next: the trainer card and options.
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
@@ -305,7 +325,9 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
- [x] Touch battle commands, move list, PKMN party menu, PACK (Items and Balls pockets) and naming fields - [x] Touch battle commands, move list, PKMN party menu, PACK (Items and Balls pockets) and naming fields
- [x] Start menu, party menu and pack outside battle (switching, moves, held items, healing and PP items, tossing, the Bicycle) - [x] Start menu, party menu and pack outside battle (switching, moves, held items, healing and PP items, tossing, the Bicycle)
- [x] Bill's PC: MOVE PKMN W/O MAIL between the party and every box - [x] Bill's PC: MOVE PKMN W/O MAIL between the party and every box
- [ ] Pokégear, Pokédex, trainer card and options screens - [x] Pokégear clock, map and phone cards (the radio card waits for its stations)
- [x] Mom's bank: the amount box with its digit cursor, on keyboard, D-pad and on-screen buttons
- [ ] Pokédex, trainer card and options screens
- [ ] Scalable text, configurable controls and fuller accessibility - [ ] Scalable text, configurable controls and fuller accessibility
- [ ] Save slots with migration, atomic writes and import previews - [ ] Save slots with migration, atomic writes and import previews
- [ ] Installable web app with offline app shell, safe updates and quota handling - [ ] Installable web app with offline app shell, safe updates and quota handling
+92 -1
View File
@@ -1,6 +1,6 @@
# Recovered Claude session # Recovered Claude session
Latest continuation: see **Field moves, SEL and moving Pokémon: 2026-09-30** Latest continuation: see **Mom's bank and her purchases: 2026-10-01**
at the end of this file. Earlier sections are historical snapshots. at the end of this file. Earlier sections are historical snapshots.
Recovered on 2026-09-23 from the local Claude Code transcript Recovered on 2026-09-23 from the local Claude Code transcript
@@ -795,3 +795,94 @@ physical-device run.
Next: the Pokégear (clock, map, phone and radio cards), then the trainer Next: the Pokégear (clock, map, phone and radio cards), then the trainer
card and options; later the Fly map and Teleport/Dig warps (they need card and options; later the Fly map and Teleport/Dig warps (they need
script commands after a warp). script commands after a warp).
## The Pokégear, phone calls and Mom's savings: 2026-10-01
Resumed with "Continue the plan" after `3456aa4`, taking the recorded next
item (the Pokégear).
- `src/game/scripts/pokegear.ts` (`Pokegear`): POKéGEAR in the start menu
runs the clock, map and phone cards with the original joypad rules,
yielding a `pokegear` effect per card answered with a button name. The
world routes A/B/directions/Start/Select to it, keeps `world.pokegear` on
screen during call texts and refreshes the clock every tick. The radio
card (Goldenrod only) gives a notice.
- Calls: no-service text, two "……" rings, out-of-hours or same-map scripts,
otherwise the callee script from `PhoneContacts` in `ScriptRun` on the
start menu's draft (adopted with `Object.assign` when it ends), then
`HangUp`. Elm's and Mom's callee scripts run; Mom's lecture after the Dude
sets `ENGINE_MOM_SAVING_MONEY`.
- Interpreter: `random`, `givemoney`/`takemoney`/`checkmoney`/`getmoney`
for both accounts, `getcurlandmarkname`, `getlandmarkname` (operand order
checked against the macros), `ScriptHost.phoneService`, `landmarkName`.
`HangUp` is now "Click!" 10 ticks, then "……" 60 ticks (it was "Click!" for
140 ticks, double the original's 140 frames); two `mr-pokemon` assertions
were updated.
- Mom's savings: `momsMoney` in `ScriptState` and saves (migrated by absence
to 0). Battles take `mom: { saving, money }` and return `momMoney`;
`WinTrainerBattle.give_money` sends a quarter while she saves some.
`MomTriesToBuySomething` refuses once her balance reaches ¥900 (not
reachable in the open area).
- Map headers decode `phoneService`. New `POKEGEAR` profile table and OW
constants (landmark limits, card/Mom engine flags, money results), all
verified by `check:reference`.
- UI: `#pokegear` panel (card labels, clock, Town Map canvas with the
cursor frame, phone list with ▶/▷ and the submenu); the textbox is the
native dialogue, hidden on the map card; the minimap hides while it is
open. `drawTownMap` takes an optional card (region, player, cursor).
Checks: 381 unit tests with the local ROM (131 passed, 250 skipped without
it; new `tests/pokegear.test.ts`, plus Mom tests in `trainer-battle` and
`trainer-world`); 113 Chromium/WebKit browser tests passed, 19 skipped
intentionally (a new Pokégear flow in both browsers); native and reference
builds; reference, vendor and release checks; `git diff --check`. No
emulator comparison of the Pokégear; no CI or physical-device run. Not
committed.
Next: `BankOfMom` (talking to Mom after the Egg still refuses with special
34), then Mom's purchases (`MomTriesToBuySomething`, `wWhichMomItem`,
`wMomItemTriggerBalance`), the trainer card and options; later the Fly map
and Teleport/Dig warps.
## Mom's bank and her purchases: 2026-10-01
Resumed with "Continue the plan" on top of the uncommitted Pokégear
increment above (still not committed), taking the recorded next items.
- `src/game/scripts/mom.ts` (`bankOfMom`): `BankOfMom` as a special. First
visit: `MomLeavingText1`–`3` and the saving question; later: "Or is this
about your money?", then GET/SAVE/CHANGE/CANCEL from
`BankOfMom_MenuHeader`. The amount box is a `mombank` effect answered
with button names (the world routes A, B and directions to it like the
Pokégear, via `buttonChoice`). `DigitQuantities` is read from the ROM;
`GiveMoney`/`TakeMoney` limits, cursor 0–5 starting on 5, zero or B
cancels, refusals return to a fresh box. Declining runs `DSTChecks`
(near-midnight booklet refusal; `shiftClockHour` wraps `minuteOffset`
into ±720 and moves `weekdayOffset` the other way).
- `MomTriesToBuySomething` is ported (it had to be: deposits make the ¥900
trigger reachable at once, and it refused after every trainer win):
MomItems_2 triggers, the ¥2300 `wMomItemTriggerBalance` steps with the
exact-match `RandomRange(5)` MomItems_1 purchase, PC items (full PC
cancels), doll decoration events, and the call through the existing
`memscript` path (`Script_ReceivePhoneCall` with
`Mom_GetScriptPointer`'s item or doll script). `.ASMFunction`'s cost and
`wWhichMomItem` are applied when the call is queued. `randomRange` is
shared with `Script_random`.
- Saves gained `momItem` (0–10) and `momItemTriggerBalance`, migrated by
absence to 0 and ¥2300.
- New `MOM` profile table and extra `POKEGEAR` entries (MomItems lists,
item/doll scripts), verified by `check:reference`.
- UI: the amount box in the dialogue choice (`.mom-bank`, a blinking digit
that respects reduced motion) with ◀ ▼ ▲ ▶, OK and CANCEL buttons.
Checked visually at desktop and 390px widths.
Checks: 398 unit tests with the local ROM (132 passed, 266 skipped without
it; new `tests/mom.test.ts`, purchase tests replace the refusal test in
`tests/trainer-world.test.ts`); 115 Chromium/WebKit browser tests passed,
19 skipped intentionally (new deposit flow in both browsers); native and
reference builds; reference, vendor and release checks; `git diff --check`.
No emulator comparison of the bank or purchases; no CI or physical-device
run. Not committed.
Next: the trainer card and options; later the Fly map and Teleport/Dig
warps.
+2 -2
View File
@@ -202,8 +202,8 @@ for (const [key, symbol] of Object.entries(ramNames)) {
if (!match || Number(match[1]) !== ram.get(symbol)) if (!match || Number(match[1]) !== ram.get(symbol))
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`); throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
} }
// BATTLE, OVERWORLD, PC, INTRO, STATS and MENU entries are keyed by their PRET labels. // BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR and MOM entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU"]) { for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1]; const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
if (!block) throw new Error(`${table} symbol table is missing.`); if (!block) throw new Error(`${table} symbol table is missing.`);
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) { for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
+42 -4
View File
@@ -131,6 +131,8 @@ export type BattleInput = number | string | BattleAction | undefined;
export type BattleOutcome = { export type BattleOutcome = {
result: number; result: number;
prizeMoney?: number; prizeMoney?: number;
/** The part of the prize sent to Mom's savings. */
momMoney?: number;
evolvable?: number; evolvable?: number;
}; };
@@ -177,6 +179,9 @@ export interface BattleOptions {
landmark?: number; landmark?: number;
/** wPartyMenuCursor as the last party menu outside battle left it. */ /** wPartyMenuCursor as the last party menu outside battle left it. */
partyMenuCursor?: number; partyMenuCursor?: number;
/** wMomSavingMoney's MOM_SAVING_MONEY_MASK bits and wMomsMoney: how much
* of a trainer's prize goes to Mom. */
mom?: { saving: number; money: number };
} }
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */ /** BATTLERESULT_BOX_FULL: a catch filled the current box. */
@@ -796,16 +801,49 @@ export class Battle {
yield this.hud(); yield this.hud();
} }
if (trainer.winText) yield* this.say(trainer.winText); if (trainer.winText) yield* this.say(trainer.winText);
const prizeMoney = trainer.reward * (amuletCoin ? 2 : 1); return {
this.numbers[RAM.battleReward] = prizeMoney; result: this.battleResult,
yield* this.say(BATTLE.GotMoneyForWinningText); ...(yield* this.giveMoney(trainer.reward, amuletCoin)),
return { result: this.battleResult, prizeMoney, evolvable: this.evolvable }; evolvable: this.evolvable,
};
} }
if (trainer && this.options.canLose && trainer.lossText) if (trainer && this.options.canLose && trainer.lossText)
yield* this.say(trainer.lossText); yield* this.say(trainer.lossText);
return { result: this.battleResult, evolvable: this.evolvable }; return { result: this.battleResult, evolvable: this.evolvable };
} }
/** WinTrainerBattle.give_money: the reward (TrainerClassAttributes'
* base times the last Pokémon's level, which `reward` holds four times)
* is added four times, to Mom for as many quarters as her saving mode
* takes unless her account is full, and to the wallet for the rest. Each
* account stops at MAX_MONEY. The text shows four times the reward. */
private *giveMoney(
reward: number,
amuletCoin: boolean,
): Gen<{ prizeMoney: number; momMoney?: number }> {
const quarter = (reward / 4) * (amuletCoin ? 2 : 1);
const mom = this.options.mom ?? { saving: 0, money: 0 },
maxedOut = mom.money >= C.MAX_MONEY;
if (![0, 1, 2, 3].includes(mom.saving))
throw new UnsupportedBattle("Unsupported Mom saving mode.");
// .CheckMaxedOutMomMoney, then SOME|HALF counts as all four quarters.
const toMom = maxedOut ? 0 : mom.saving === 3 ? 4 : mom.saving;
this.numbers[RAM.battleReward] = quarter * 4;
yield* this.say(
maxedOut || !mom.saving
? BATTLE.GotMoneyForWinningText
: [
BATTLE.SentSomeToMomText,
BATTLE.SentHalfToMomText,
BATTLE.SentAllToMomText,
][mom.saving - 1],
);
return {
prizeMoney: quarter * (4 - toMom),
...(toMom ? { momMoney: quarter * toMom } : {}),
};
}
private *battleTurn(): Gen { private *battleTurn(): Gen {
for (let turns = 0; turns < 1000; turns++) { for (let turns = 0; turns < 1000; turns++) {
this.curDamage = 0; this.curDamage = 0;
+30 -7
View File
@@ -3,12 +3,19 @@ import { OVERWORLD, SYMBOLS } from "../rom/profile";
import { decompress } from "../rom/lz"; import { decompress } from "../rom/lz";
import { drawTile } from "./pixels"; import { drawTile } from "./pixels";
/** The ROM's Town Map, used by wall maps and the bedroom poster. */ /** The ROM's Town Map, used by wall maps, the bedroom poster and the
export function drawTownMap(canvas: HTMLCanvasElement, world: GameWorld) { * Pokégear's map card, which chooses the region and marks the landmark
* under its cursor. */
export function drawTownMap(
canvas: HTMLCanvasElement,
world: GameWorld,
card?: { kanto: boolean; player: number; cursor: number; name: string },
) {
const { assets } = world, const { assets } = world,
r = assets.reader; r = assets.reader;
const landmark = world.map.landmark; const landmark = card?.player ?? world.map.landmark;
const map = landmark >= 47 ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap; const kanto = card?.kanto ?? landmark >= 47;
const map = kanto ? OVERWORLD.KantoMap : OVERWORLD.JohtoMap;
const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000); const graphics = decompress(r, OVERWORLD.TownMapGFX, 0x1000);
const palette = const palette =
world.avatar === "kris" world.avatar === "kris"
@@ -37,8 +44,24 @@ export function drawTownMap(canvas: HTMLCanvasElement, world: GameWorld) {
} }
context.putImageData(image, 0, 0); context.putImageData(image, 0, 0);
// Landmark coordinates are the same ones TownMapPlayerIcon uses. // Landmark coordinates are the same ones TownMapPlayerIcon uses.
const p = SYMBOLS.landmarks + landmark * 4; const at = (index: number) => {
const p = SYMBOLS.landmarks + index * 4;
return { x: r.byte(p) - 8, y: r.byte(p + 1) - 16 };
};
const player = at(landmark);
context.fillStyle = "#b32628"; context.fillStyle = "#b32628";
context.fillRect(r.byte(p) - 10, r.byte(p + 1) - 18, 4, 4); context.fillRect(player.x - 2, player.y - 2, 4, 4);
canvas.setAttribute("aria-label", `Town Map: ${world.map.location}`); if (card) {
// The Pokégear's arrow cursor, drawn as a frame around the landmark.
const cursor = at(card.cursor);
context.strokeStyle = "#2366a5";
context.lineWidth = 2;
context.strokeRect(cursor.x - 5, cursor.y - 5, 10, 10);
}
canvas.setAttribute(
"aria-label",
card
? `Town Map: ${card.name}${card.cursor === card.player ? ", you are here" : ""}`
: `Town Map: ${world.map.location}`,
);
} }
+1
View File
@@ -124,6 +124,7 @@ export class CrystalAssets {
tileset: r.byte(header + 1), tileset: r.byte(header + 1),
environment: r.byte(header + 2), environment: r.byte(header + 2),
paletteMode: r.byte(header + 7) & 15, paletteMode: r.byte(header + 7) & 15,
phoneService: r.byte(header + 7) >> 4,
attributes, attributes,
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight), blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
warps: [], warps: [],
+64
View File
@@ -513,6 +513,9 @@ export const BATTLE = {
WantsToBattleText: 0x807a9, WantsToBattleText: 0x807a9,
BattleText_EnemyWasDefeated: 0x809da, BattleText_EnemyWasDefeated: 0x809da,
GotMoneyForWinningText: 0x809be, GotMoneyForWinningText: 0x809be,
SentSomeToMomText: 0x809fc,
SentHalfToMomText: 0x80a2a,
SentAllToMomText: 0x80a3d,
BattleText_EnemySentOut: 0x80af8, BattleText_EnemySentOut: 0x80af8,
BattleText_TheresNoEscapeFromTrainerBattle: 0x80b49, BattleText_TheresNoEscapeFromTrainerBattle: 0x80b49,
AccuracyLevelMultipliers: 0x34eb2, AccuracyLevelMultipliers: 0x34eb2,
@@ -671,6 +674,67 @@ export const BATTLE = {
WontRiseAnymoreText: 0x81272, WontRiseAnymoreText: 0x81272,
} as const; } as const;
/** The Pokégear (engine/pokegear/pokegear.asm) and the phone calls it
* makes (engine/phone/phone.asm): texts, strings and scripts, keyed by
* their PRET labels so that \`npm run check:reference\` can verify each
* address by name. */
export const POKEGEAR = {
PokegearPressButtonText: 0x914d3,
PokegearAskWhoCallText: 0x914ce,
PokegearAskDeleteText: 0x914d8,
"Pokegear_UpdateClock.GearTodayText": 0x90faf,
"PokegearPhone_MakePhoneCall.GearEllipseText": 0x9124c,
"PokegearPhone_MakePhoneCall.GearOutOfServiceText": 0x91251,
"PokegearPhoneContactSubmenu.CallDeleteCancelStrings": 0x9143f,
"PokegearPhoneContactSubmenu.CallCancelStrings": 0x9145b,
NonTrainerCallerNames: 0x903d6,
PhoneOutOfAreaScript: 0x90657,
PhoneScript_JustTalkToThem: 0x90660,
PhoneEllipseText: 0x90336,
String_AM: 0x1dd6fc,
String_PM: 0x1dd6ff,
"Mom_GetScriptPointer.ItemScript": 0xfd0fd,
"Mom_GetScriptPointer.DollScript": 0xfd10a,
MomItems_1: 0xfd136,
"MomItems_1.End": 0xfd15e,
MomItems_2: 0xfd15e,
"MomItems_2.End": 0xfd1ae,
} as const;
/** BankOfMom (engine/events/mom.asm): its texts, strings, menu and digit
* steps, keyed by their PRET labels. */
export const MOM = {
BankOfMom: 0x16218,
"DSTChecks.TimesetAskAdjustDSTText": 0x164f4,
"DSTChecks.MomLostGearBookletText": 0x164f9,
"DSTChecks.TimesetAskDSTText": 0x164fe,
"DSTChecks.TimesetDSTText": 0x16503,
"DSTChecks.TimesetAskNotDSTText": 0x16508,
"DSTChecks.TimesetNotDSTText": 0x1650d,
"Mom_WithdrawDepositMenuJoypad.DigitQuantities": 0x16613,
MomLeavingText1: 0x16649,
MomLeavingText2: 0x1664e,
MomLeavingText3: 0x16653,
MomIsThisAboutYourMoneyText: 0x16658,
MomBankWhatDoYouWantToDoText: 0x1665d,
MomStoreMoneyText: 0x16662,
MomTakeMoneyText: 0x16667,
MomSaveMoneyText: 0x1666c,
MomHaventSavedThatMuchText: 0x16671,
MomNotEnoughRoomInWalletText: 0x16676,
MomInsufficientFundsInWalletText: 0x1667b,
MomNotEnoughRoomInBankText: 0x16680,
MomStartSavingMoneyText: 0x16685,
MomStoredMoneyText: 0x1668a,
MomTakenMoneyText: 0x1668f,
MomJustDoWhatYouCanText: 0x16694,
Mom_SavedString: 0x16699,
Mon_WithdrawString: 0x1669f,
Mom_DepositString: 0x166a8,
Mom_HeldString: 0x166b0,
"BankOfMom_MenuHeader.MenuData": 0x166bd,
} as const;
export const MAP_GROUP_COUNT = 26; export const MAP_GROUP_COUNT = 26;
export const NUM_POKEMON = 251; export const NUM_POKEMON = 251;
export const NUM_TRAINER_CLASSES = 67; export const NUM_TRAINER_CLASSES = 67;
+187 -6
View File
@@ -10,8 +10,10 @@ import {
NATIVE_ROUTINES, NATIVE_ROUTINES,
NUM_FRUIT_TREES, NUM_FRUIT_TREES,
EVENTS, EVENTS,
MOM,
OVERWORLD, OVERWORLD,
PC, PC,
POKEGEAR,
RAM, RAM,
SPECIAL_ROUTINES, SPECIAL_ROUTINES,
SYMBOLS, SYMBOLS,
@@ -32,6 +34,7 @@ import {
copyStorage, copyStorage,
currentBox, currentBox,
emptyStorage, emptyStorage,
receivePCItem,
type PokemonStorage, type PokemonStorage,
} from "../party/storage"; } from "../party/storage";
import { import {
@@ -47,6 +50,8 @@ import { DIRECTION_ORDER, type Direction } from "../world/types";
import { OW } from "../world/constants"; import { OW } from "../world/constants";
import { readMovement, type MovementStep } from "./movement"; import { readMovement, type MovementStep } from "./movement";
import { PcSession } from "./pc"; import { PcSession } from "./pc";
import { bankOfMom, type MomBankView } from "./mom";
import type { PokegearView } from "./pokegear";
import { import {
defaultTextContext, defaultTextContext,
readString, readString,
@@ -81,6 +86,13 @@ export interface ScriptState {
pokedex: { seen: Set<number>; caught: Set<number> }; pokedex: { seen: Set<number>; caught: Set<number> };
/** wMoney: a 24-bit amount. */ /** wMoney: a 24-bit amount. */
money: number; money: number;
/** wMomsMoney: what Mom has saved, up to MAX_MONEY. */
momsMoney: number;
/** wWhichMomItem: the next MomItems_2 entry Mom buys. */
momItem: number;
/** wMomItemTriggerBalance: the next multiple of MOM_MONEY at which Mom
* buys from MomItems_1. */
momItemTriggerBalance: number;
/** wFruitTreeFlags: trees (FRUITTREE_* ids) picked since the last reset. */ /** wFruitTreeFlags: trees (FRUITTREE_* ids) picked since the last reset. */
fruitTrees: Set<number>; fruitTrees: Set<number>;
/** Local calendar day of the last CheckDailyResetTimer reset; null until /** Local calendar day of the last CheckDailyResetTimer reset; null until
@@ -120,6 +132,9 @@ export const emptyScriptState = (): ScriptState => ({
pcItems: [], pcItems: [],
pokedex: { seen: new Set(), caught: new Set() }, pokedex: { seen: new Set(), caught: new Set() },
money: C.START_MONEY, money: C.START_MONEY,
momsMoney: 0,
momItem: 0,
momItemTriggerBalance: OW.MOM_MONEY,
fruitTrees: new Set(), fruitTrees: new Set(),
dailyReset: null, dailyReset: null,
lastSpawnMap: null, lastSpawnMap: null,
@@ -148,6 +163,9 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
caught: new Set(state.pokedex.caught), caught: new Set(state.pokedex.caught),
}, },
money: state.money, money: state.money,
momsMoney: state.momsMoney,
momItem: state.momItem,
momItemTriggerBalance: state.momItemTriggerBalance,
fruitTrees: new Set(state.fruitTrees), fruitTrees: new Set(state.fruitTrees),
dailyReset: state.dailyReset, dailyReset: state.dailyReset,
lastSpawnMap: state.lastSpawnMap, lastSpawnMap: state.lastSpawnMap,
@@ -255,6 +273,12 @@ export type ScriptEffect =
| { kind: "notice"; text: string } | { kind: "notice"; text: string }
/** The Pokémon stats screen, closed with a button. */ /** The Pokémon stats screen, closed with a button. */
| { kind: "stats"; mon: PartyMon } | { kind: "stats"; mon: PartyMon }
/** A Pokégear card with the textbox's pages, answered with the button
* pressed (PokegearButton); without a view the Pokégear closes. */
| { kind: "pokegear"; pages: string[]; view?: PokegearView }
/** BankOfMom's deposit or withdrawal box with the textbox's pages,
* answered with the button pressed (MomBankButton). */
| { kind: "mombank"; pages: string[]; view: MomBankView }
/** EvolutionAnimation: `from` flashes into `to`. Answered with 1 when /** EvolutionAnimation: `from` flashes into `to`. Answered with 1 when
* B stopped it (only if `cancelable`), otherwise 0. */ * B stopped it (only if `cancelable`), otherwise 0. */
| { kind: "evolve"; from: number; to: number; cancelable: boolean } | { kind: "evolve"; from: number; to: number; cancelable: boolean }
@@ -307,6 +331,8 @@ export interface ScriptHost {
/** wPlayerTileCollision and wEnvironment. */ /** wPlayerTileCollision and wEnvironment. */
playerCollision(): number; playerCollision(): number;
environment(): number; environment(): number;
/** GetMapPhoneService: nonzero where the phone has no signal. */
phoneService(): number;
} }
export class UnsupportedScript extends Error { export class UnsupportedScript extends Error {
@@ -329,7 +355,30 @@ const PHONE_CONTACT_SCRIPT2_BANK = 9;
/** PhoneContacts entries (data/phone/phone_contacts.asm) and PHONE_BILL. */ /** PhoneContacts entries (data/phone/phone_contacts.asm) and PHONE_BILL. */
const PHONE_CONTACT_SIZE = 12; const PHONE_CONTACT_SIZE = 12;
const PHONE_BILL = 3; const PHONE_BILL = 3;
/** A momitem: trigger and cost (bigdt), then MOM_ITEM or MOM_DOLL and the
* item or decoration. */
const MOMITEM_SIZE = 8,
MOM_ITEM = 1;
const BATTLERESULT_BOX_FULL = 0x80; const BATTLERESULT_BOX_FULL = 0x80;
/** HangUp (engine/phone/phone.asm): "Click!" for HangUp_Beep's 20
* frames, then Phone_CallEnd's three boops, "……" shown and cleared over six
* more 20-frame waits. */
export const HANG_UP = { clickTicks: 10, endTicks: 60 } as const;
/** GetLandmarkName (engine/overworld/landmarks.asm): the ROM's name of a
* landmark, as text prints it (<BSP> is a space). */
export function landmarkName(
reader: RomReader,
landmark: number,
context: TextContext,
) {
const entry = SYMBOLS.landmarks + landmark * 4 + 2;
return readString(
reader,
reader.near(entry, reader.bank(SYMBOLS.landmarks)),
context,
);
}
/** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm): /** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm):
* a one-tick sleep, then normal steps to the player's tile, dropping the * a one-tick sleep, then normal steps to the player's tile, dropping the
@@ -725,10 +774,16 @@ export class ScriptRun {
receiveItem(data, this.state.bag, ball.item, ball.quantity), receiveItem(data, this.state.bag, ball.item, ball.quantity),
); );
} else if (routine === NATIVE_ROUTINES.hangUp) { } else if (routine === NATIVE_ROUTINES.hangUp) {
// HangUp_Beep prints "Click!", then seven 20-frame waits for the // HangUp_Beep prints "Click!", then the hang-up tones pass
// hang-up tones pass before closetext; no button is needed. // before closetext; no button is needed.
write(SYMBOLS.phoneClickText); write(SYMBOLS.phoneClickText);
yield { kind: "text", pages: take(), holdTicks: 140 }; yield {
kind: "text",
pages: take(),
holdTicks: HANG_UP.clickTicks,
};
write(POKEGEAR.PhoneEllipseText);
yield { kind: "text", pages: take(), holdTicks: HANG_UP.endTicks };
} else if ( } else if (
routine === NATIVE_ROUTINES.ringTwiceStartCall || routine === NATIVE_ROUTINES.ringTwiceStartCall ||
routine === NATIVE_ROUTINES.initCallReceiveDelay routine === NATIVE_ROUTINES.initCallReceiveDelay
@@ -948,11 +1003,13 @@ export class ScriptRun {
case 0x60: case 0x60:
// Script_reloadmapafterbattle: a loss continues with the whiteout. // Script_reloadmapafterbattle: a loss continues with the whiteout.
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript; if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
// BATTLESCRIPT_WILD_F is set by trainer battles, which run
// MomTriesToBuySomething. Without a purchase it changes nothing.
else if (trainer) yield* this.momTriesToBuySomething();
// A wild catch that filled the box queues Bill's call // A wild catch that filled the box queues Bill's call
// (Script_SpecialBillCall: LoadCallerScript for PHONE_BILL, then // (Script_SpecialBillCall: LoadCallerScript for PHONE_BILL, then
// Script_ReceivePhoneCall). BATTLESCRIPT_WILD_F is set by trainer // Script_ReceivePhoneCall).
// battles, which instead run the unported MomTriesToBuySomething. else if (battleResult & BATTLERESULT_BOX_FULL)
else if (!trainer && battleResult & BATTLERESULT_BOX_FULL)
yield synchronous({ yield synchronous({
kind: "memscript", kind: "memscript",
script: PC.Script_ReceivePhoneCall, script: PC.Script_ReceivePhoneCall,
@@ -1078,6 +1135,49 @@ export class ScriptRun {
case 0x9c: case 0x9c:
this.state.specialPhoneCall = word(); this.state.specialPhoneCall = word();
break; break;
case 0x17: {
// random: wScriptVar = a byte below the operand, rejecting the
// bytes past the largest multiple of it (Script_random).
const range = byte();
value = range && this.randomRange(range);
break;
}
case 0x22:
case 0x23:
case 0x24: {
// givemoney, takemoney and checkmoney on YOUR_MONEY or MOMS_MONEY.
const account = byte() ? "momsMoney" : "money",
amount = (byte() << 16) | (byte() << 8) | byte();
const balance = this.state[account];
if (opcode === 0x22)
this.state[account] = Math.min(C.MAX_MONEY, balance + amount);
else if (opcode === 0x23) {
// TakeMoney leaves the account unchanged when it is short.
if (balance >= amount) this.state[account] = balance - amount;
} else
value =
balance > amount
? OW.HAVE_MORE
: balance === amount
? OW.HAVE_AMOUNT
: OW.HAVE_LESS;
break;
}
case 0x3d: {
// getmoney: the account's balance, left-aligned without the ¥.
const balance = byte() ? this.state.momsMoney : this.state.money;
this.strings[buffer(byte())] = String(balance);
break;
}
case 0x42:
case 0xa5: {
// getcurlandmarkname and getlandmarkname (whose landmark operand
// comes first): GetLandmarkName copies the name, whose <BSP>
// prints as a space.
const landmark = opcode === 0x42 ? this.host.landmark() : byte();
this.strings[buffer(byte())] = landmarkName(r, landmark, this.text());
break;
}
case 0x9d: case 0x9d:
value = Number(this.state.specialPhoneCall !== 0); value = Number(this.state.specialPhoneCall !== 0);
break; break;
@@ -1847,6 +1947,75 @@ export class ScriptRun {
}); });
} }
/** MomTriesToBuySomething (engine/events/mom_phone.asm) after a trainer
* battle, where the phone has service. CheckBalance_MomItem2: Mom buys
* the next MomItems_2 entry once her savings reach its trigger.
* Otherwise wMomItemTriggerBalance climbs in MOM_MONEY steps while it is
* below her savings, and landing exactly on them buys a random MomItems_1
* entry. Mom_GiveItemOrDoll puts an item in the PC (nothing happens when
* it is full) or sets a doll's decoration flag, and her call is queued.
* The call's .ASMFunction (the cost and the next wWhichMomItem) is
* applied here rather than when the call starts; nothing runs between.
* wMapReentryScriptQueueFlag's check is the world's: a script already
* queued is kept. */
private *momTriesToBuySomething(): Generator<ScriptEffect, void, Input> {
if (this.host.phoneService()) return;
const r = this.reader,
state = this.state;
const money = (p: number) =>
(r.byte(p) << 16) | (r.byte(p + 1) << 8) | r.byte(p + 2);
const items1 =
(POKEGEAR["MomItems_1.End"] - POKEGEAR.MomItems_1) / MOMITEM_SIZE,
items2 =
(POKEGEAR["MomItems_2.End"] - POKEGEAR.MomItems_2) / MOMITEM_SIZE;
// wWhichMomItemSet: 0 for MomItems_2, or a MomItems_1 entry plus one.
let set = 0;
if (
state.momItem >= items2 ||
state.momsMoney <
money(POKEGEAR.MomItems_2 + state.momItem * MOMITEM_SIZE)
) {
while (state.momItemTriggerBalance < state.momsMoney)
state.momItemTriggerBalance += OW.MOM_MONEY;
if (state.momItemTriggerBalance !== state.momsMoney) return;
state.momItemTriggerBalance += OW.MOM_MONEY;
set = this.randomRange(items1) + 1;
}
// GetItemFromMom
const entry = set
? POKEGEAR.MomItems_1 + (set - 1) * MOMITEM_SIZE
: POKEGEAR.MomItems_2 + state.momItem * MOMITEM_SIZE;
const doll = r.byte(entry + 6) !== MOM_ITEM,
item = r.byte(entry + 7);
if (doll)
state.events.add(
new DecorationData(r, this.host.data).attribute(item).event,
);
else if (!receivePCItem(state.pcItems, item, 1)) return;
// .ASMFunction: MomBuysItem_DeductFunds, then the next MomItems_2 entry.
state.momsMoney = Math.max(0, state.momsMoney - money(entry + 3));
if (!set) state.momItem++;
yield {
kind: "memscript",
script: PC.Script_ReceivePhoneCall,
callerScript: doll
? POKEGEAR["Mom_GetScriptPointer.DollScript"]
: POKEGEAR["Mom_GetScriptPointer.ItemScript"],
};
}
/** RandomRange: a byte below `range`, rejecting the draws past its
* largest multiple, as Script_random also does. */
private randomRange(range: number) {
const limit = 256 - (256 % range);
let draw = this.host.random();
for (let tries = 0; limit < 256 && draw >= limit; tries++) {
if (tries > 4096) throw new Error("Random draw limit exceeded.");
draw = this.host.random();
}
return draw % range;
}
/** readvar: the script variables that native state can answer. */ /** readvar: the script variables that native state can answer. */
private readVar(id: number, offset: number): number { private readVar(id: number, offset: number): number {
const clock = this.host.clock(); const clock = this.host.clock();
@@ -2031,6 +2200,17 @@ export class ScriptRun {
case OVERWORLD.OverworldTownMap: case OVERWORLD.OverworldTownMap:
yield { kind: "townmap" }; yield { kind: "townmap" };
return value; return value;
case MOM.BankOfMom:
yield* bankOfMom(
{
state: this.state,
reader: r,
text: () => this.text(),
hours: () => this.host.clock().hours,
},
pages,
);
return value;
case PC.PokemonCenterPC: case PC.PokemonCenterPC:
if (pages.length) yield { kind: "text", pages: take() }; if (pages.length) yield { kind: "text", pages: take() };
yield* this.pc().pokemonCenterPC(); yield* this.pc().pokemonCenterPC();
@@ -2125,6 +2305,7 @@ export function runScript(
objectPosition: () => undefined, objectPosition: () => undefined,
playerCollision: () => 0, playerCollision: () => 0,
environment: () => 0, environment: () => 0,
phoneService: () => 0,
}); });
const pages: string[] = []; const pages: string[] = [];
for (let effect = run.resume(); effect; effect = run.resume()) { for (let effect = run.resume(); effect; effect = run.resume()) {
+294
View File
@@ -0,0 +1,294 @@
import type { RomReader } from "../rom/reader";
import { MOM } from "../rom/profile";
import { C } from "../battle/constants";
import { OW } from "../world/constants";
import { readString, readText, type TextContext } from "./text";
import type { ClockSettings, ScriptEffect, ScriptState } from "./interpreter";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** The buttons Mom_WithdrawDepositMenuJoypad reads: A and B from
* hJoyPressed, the directions from hJoyLast. */
export type MomBankButton = "a" | "b" | "up" | "down" | "left" | "right";
export const MOM_BANK_BUTTONS: readonly string[] = [
"a",
"b",
"up",
"down",
"left",
"right",
];
/** Mom_ContinueMenuSetup's box: what Mom has saved, what the player holds,
* and the amount being chosen, whose digit under the cursor blinks. */
export interface MomBankView {
/** Mom_SavedString, Mom_HeldString, then Mom_DepositString or
* Mon_WithdrawString. */
labels: [string, string, string];
saved: number;
held: number;
amount: number;
/** wMomBankDigitCursorPosition: 0 is the leftmost of the six digits. */
cursor: number;
}
/** What BankOfMom needs from the running script. */
export interface MomHost {
state: ScriptState;
reader: RomReader;
text(): TextContext;
/** hHours: the game clock's hour. */
hours(): number;
}
/** BankOfMom's jumptable indices. CheckIfBankInitialized (0) runs first;
* AskDST (8) only sets JUMPTABLE_EXIT_F. */
const INITIALIZE_BANK = 1,
IS_THIS_ABOUT_YOUR_MONEY = 2,
ACCESS_BANK_OF_MOM = 3,
STORE_MONEY = 4,
TAKE_MONEY = 5,
STOP_OR_START_SAVING_MONEY = 6,
JUST_DO_WHAT_YOU_CAN = 7;
/** The six digits' cursor positions, as .movecursorright stops at 5. */
const LAST_DIGIT = 5;
/** DSTChecks' SetClockForward and SetClockBack move wStartHour by an hour,
* carrying into wStartDay. The native clock keeps its minute offset within
* half a day, so a wrap moves the weekday offset the other way instead. */
export function shiftClockHour(clock: ClockSettings, hours: 1 | -1) {
let offset = clock.minuteOffset + hours * 60;
if (offset >= 720) {
offset -= 1440;
clock.weekdayOffset = (clock.weekdayOffset + 1) % 7;
} else if (offset < -720) {
offset += 1440;
clock.weekdayOffset = (clock.weekdayOffset + 6) % 7;
}
clock.minuteOffset = offset;
}
/** BankOfMom (engine/events/mom.asm), the special Mom's script calls once
* the Mystery Egg is delivered. The first time she offers to save money;
* afterwards GET, SAVE and CHANGE withdraw, deposit or toggle her savings,
* and declining runs DSTChecks. wMomSavingMoney is kept as its two engine
* flags: BankOfMom only ever writes MOM_ACTIVE_F and
* MOM_SAVING_SOME_MONEY_F. The last text stays in `pages` for the
* script's waitbutton. */
export function* bankOfMom(host: MomHost, pages: string[]): Gen {
const { reader: r, state } = host;
const write = (address: number) => {
const content = readText(r, address, host.text());
if (content) pages.push(...content.split("\n\n"));
};
const take = () => pages.splice(0, pages.length);
const yesNo = function* (): Gen<boolean> {
return Boolean(yield { kind: "yesno", pages: take() });
};
/** wMomSavingMoney: MOM_ACTIVE_F, with or without MOM_SAVING_SOME_MONEY_F. */
const setSaving = (saving: boolean) => {
state.flags.add(OW.ENGINE_MOM_ACTIVE);
if (saving) state.flags.add(OW.ENGINE_MOM_SAVING_MONEY);
else state.flags.delete(OW.ENGINE_MOM_SAVING_MONEY);
};
// .CheckIfBankInitialized
let index = IS_THIS_ABOUT_YOUR_MONEY;
if (!state.flags.has(OW.ENGINE_MOM_ACTIVE)) {
state.flags.add(OW.ENGINE_MOM_ACTIVE);
index = INITIALIZE_BANK;
}
for (;;) {
switch (index) {
case INITIALIZE_BANK:
write(MOM.MomLeavingText1);
if (yield* yesNo()) {
write(MOM.MomLeavingText2);
setSaving(true);
} else setSaving(false);
write(MOM.MomLeavingText3);
return;
case IS_THIS_ABOUT_YOUR_MONEY:
write(MOM.MomIsThisAboutYourMoneyText);
if (yield* yesNo()) index = ACCESS_BANK_OF_MOM;
else {
yield* dstChecks(host, pages);
index = JUST_DO_WHAT_YOU_CAN;
}
break;
case ACCESS_BANK_OF_MOM: {
write(MOM.MomBankWhatDoYouWantToDoText);
// BankOfMom_MenuHeader's flags and item count precede the strings.
const items = menuStrings(
host,
MOM["BankOfMom_MenuHeader.MenuData"] + 2,
4,
);
const choice = yield {
kind: "menu",
pages: take(),
items,
selected: 0,
};
index =
choice === 0
? TAKE_MONEY
: choice === 1
? STORE_MONEY
: choice === 2
? STOP_OR_START_SAVING_MONEY
: JUST_DO_WHAT_YOU_CAN;
break;
}
case STORE_MONEY: {
write(MOM.MomStoreMoneyText);
const amount = yield* digitMenu(host, MOM.Mom_DepositString, take());
if (!amount) {
index = JUST_DO_WHAT_YOU_CAN;
break;
}
// CompareMoney, then GiveMoney's MAX_MONEY carry on Mom's side; a
// refusal returns to .StoreMoney with a fresh amount.
if (state.money < amount) write(MOM.MomInsufficientFundsInWalletText);
else if (state.momsMoney + amount > C.MAX_MONEY)
write(MOM.MomNotEnoughRoomInBankText);
else {
state.money -= amount;
state.momsMoney += amount;
write(MOM.MomStoredMoneyText);
return;
}
break;
}
case TAKE_MONEY: {
write(MOM.MomTakeMoneyText);
const amount = yield* digitMenu(host, MOM.Mon_WithdrawString, take());
if (!amount) {
index = JUST_DO_WHAT_YOU_CAN;
break;
}
if (state.momsMoney < amount) write(MOM.MomHaventSavedThatMuchText);
else if (state.money + amount > C.MAX_MONEY)
write(MOM.MomNotEnoughRoomInWalletText);
else {
state.momsMoney -= amount;
state.money += amount;
write(MOM.MomTakenMoneyText);
return;
}
break;
}
case STOP_OR_START_SAVING_MONEY:
write(MOM.MomSaveMoneyText);
if (yield* yesNo()) {
setSaving(true);
write(MOM.MomStartSavingMoneyText);
return;
}
setSaving(false);
index = JUST_DO_WHAT_YOU_CAN;
break;
default:
write(MOM.MomJustDoWhatYouCanText);
return;
}
}
}
/** Consecutive "@"-terminated strings, as static menus store them. */
function menuStrings(host: MomHost, address: number, count: number) {
const r = host.reader,
result: string[] = [];
let p = address;
for (let i = 0; i < count; i++) {
result.push(readString(r, p, host.text()));
while (r.byte(p++) !== 0x50);
}
return result;
}
/** Mom_SetUpDepositMenu or Mom_SetUpWithdrawMenu, then
* Mom_WithdrawDepositMenuJoypad: the amount starts at zero with the cursor
* on the last digit. Up and Down add or take the digit's DigitQuantities
* step through GiveMoney and TakeMoney, which stop at MAX_MONEY and zero.
* Returns the amount on A, or 0 on B. */
function* digitMenu(
host: MomHost,
label: number,
prompt: string[],
): Gen<number> {
const r = host.reader,
state = host.state,
table = MOM["Mom_WithdrawDepositMenuJoypad.DigitQuantities"];
const step = (digit: number) =>
(r.byte(table + digit * 3) << 16) |
(r.byte(table + digit * 3 + 1) << 8) |
r.byte(table + digit * 3 + 2);
const labels: MomBankView["labels"] = [
readString(r, MOM.Mom_SavedString, host.text()),
readString(r, MOM.Mom_HeldString, host.text()),
readString(r, label, host.text()),
];
let amount = 0,
cursor = LAST_DIGIT,
// Earlier pages (a refusal) show once; the prompt stays with the box.
pages = prompt;
for (;;) {
const button = yield {
kind: "mombank",
pages,
view: {
labels,
saved: state.momsMoney,
held: state.money,
amount,
cursor,
},
};
pages = prompt.slice(-1);
// hJoyPressed's B, then A, before the directions.
if (button === "b") return 0;
if (button === "a") return amount;
if (button === "up") amount = Math.min(C.MAX_MONEY, amount + step(cursor));
else if (button === "down") amount = Math.max(0, amount - step(cursor));
else if (button === "left") cursor = Math.max(0, cursor - 1);
else if (button === "right") cursor = Math.min(LAST_DIGIT, cursor + 1);
}
}
/** DSTChecks: offer to switch Daylight Saving Time on or off, unless doing
* so would change the day (within an hour of midnight), when Mom says she
* lost the Pokégear's booklet instead. */
function* dstChecks(host: MomHost, pages: string[]): Gen {
const r = host.reader,
clock = host.state.clock;
const write = (address: number) => {
const content = readText(r, address, host.text());
if (content) pages.push(...content.split("\n\n"));
};
const yesNo = function* (): Gen<boolean> {
return Boolean(
yield { kind: "yesno", pages: pages.splice(0, pages.length) },
);
};
const hours = host.hours();
if (clock.dst ? hours === 0 : hours === 23) {
write(MOM["DSTChecks.TimesetAskAdjustDSTText"]);
if (yield* yesNo()) write(MOM["DSTChecks.MomLostGearBookletText"]);
return;
}
if (clock.dst) {
write(MOM["DSTChecks.TimesetAskNotDSTText"]);
if (!(yield* yesNo())) return;
clock.dst = false;
shiftClockHour(clock, -1);
write(MOM["DSTChecks.TimesetNotDSTText"]);
} else {
write(MOM["DSTChecks.TimesetAskDSTText"]);
if (!(yield* yesNo())) return;
clock.dst = true;
shiftClockHour(clock, 1);
write(MOM["DSTChecks.TimesetDSTText"]);
}
}
+432
View File
@@ -0,0 +1,432 @@
import type { RomReader } from "../rom/reader";
import type { PokemonData } from "../rom/pokemon";
import { PC, POKEGEAR, SYMBOLS } from "../rom/profile";
import { TrainerData } from "../rom/trainers";
import { OW } from "../world/constants";
import {
HANG_UP,
ScriptRun,
landmarkName,
type ScriptEffect,
type ScriptHost,
type ScriptState,
} from "./interpreter";
import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** POKEGEARCARD_* in order. */
export type PokegearCard = "clock" | "map" | "phone" | "radio";
/** The buttons the Pokégear reads (hJoyLast and hJoyPressed). */
export type PokegearButton =
| "a"
| "b"
| "start"
| "select"
| "up"
| "down"
| "left"
| "right";
export const POKEGEAR_BUTTONS: readonly string[] = [
"a",
"b",
"start",
"select",
"up",
"down",
"left",
"right",
];
/** What a Pokégear card shows. The original draws its own tilemaps; here
* the card's contents are presented natively. */
export interface PokegearView {
card: PokegearCard;
/** The cards whose icons Pokegear_FinishTilemap places. */
cards: PokegearCard[];
/** The clock card: GearTodayText's weekday and PrintHoursMins. */
clock?: { day: string; time: string };
/** The map card: the region shown, the landmark under the cursor and
* the one where the player's icon stands. */
map?: { kanto: boolean; cursor: number; player: number; name: string };
/** The phone card's four rows (PHONE_DISPLAY_HEIGHT), the ▶ cursor, the
* ▷ on a chosen contact, whether the map has service, and the
* CALL/DELETE/CANCEL submenu while it is open. */
phone?: {
rows: string[];
cursor: number;
chosen?: number;
signal: boolean;
submenu?: { items: string[]; selected: number };
};
}
/** What the Pokégear needs from the world. */
export interface PokegearHost {
reader: RomReader;
data: PokemonData;
text(): TextContext;
/** The script host a phone call's callee script runs with. */
scriptHost(): ScriptHost;
/** GetWorldMapLocation for any map. */
landmarkOf(mapId: string): number;
}
const PHONE_DISPLAY_HEIGHT = 4;
const CONTACT_LIST_SIZE = 10;
const PHONE_CONTACT_SIZE = 12;
/** Ticks Sfx_Call plays while "……" shows (six four-frame notes). */
const CALL_TICKS = 12;
/** Pokegear_UpdateClock: the weekday (text_today) and PrintHoursMins's
* 12-hour time with the ROM's AM and PM strings. */
export function pokegearClock(
reader: RomReader,
context: TextContext,
hours: number,
minutes: number,
) {
const day = readText(
reader,
POKEGEAR["Pokegear_UpdateClock.GearTodayText"],
context,
);
const hour = hours % 12 || 12;
const suffix = readString(
reader,
hours < 12 ? POKEGEAR.String_AM : POKEGEAR.String_PM,
context,
);
return {
day,
time: `${String(hour).padStart(2, " ")}:${String(minutes).padStart(2, "0")} ${suffix}`,
};
}
/** PokeGear (engine/pokegear/pokegear.asm): the clock, map, phone and
* radio cards, with the original's joypad handling for each card. Each
* card is yielded as a `pokegear` effect answered with a button. Phone
* calls run the contact's callee script from PhoneContacts on this run's
* draft, as MakePhoneCallFromPokegear does. */
export class Pokegear {
private card: PokegearCard = "clock";
/** wPokegearMapCursorLandmark and wPokegearMapPlayerIconLandmark. */
private cursorLandmark: number;
private playerLandmark: number;
/** wPokegearPhoneScrollPosition and wPokegearPhoneCursorPosition. */
private scroll = 0;
private cursor = 0;
constructor(
private state: ScriptState,
private host: PokegearHost,
) {
// TownMap_InitCursorAndPlayerIconPositions.
const scriptHost = host.scriptHost();
let landmark = host.landmarkOf(scriptHost.mapId);
if (landmark === OW.LANDMARK_SPECIAL && state.backupWarp)
landmark = host.landmarkOf(state.backupWarp.map);
this.playerLandmark = landmark;
this.cursorLandmark =
landmark === OW.LANDMARK_FAST_SHIP ? OW.LANDMARK_NEW_BARK_TOWN : landmark;
}
private get reader() {
return this.host.reader;
}
private has(flag: number) {
return this.state.flags.has(flag);
}
private pages(address: number) {
return readText(this.reader, address, this.host.text())
.split("\n\n")
.filter(Boolean);
}
/** The cards Pokegear_FinishTilemap shows icons for. */
private cards(): PokegearCard[] {
const cards: PokegearCard[] = ["clock"];
if (this.has(OW.ENGINE_MAP_CARD)) cards.push("map");
if (this.has(OW.ENGINE_PHONE_CARD)) cards.push("phone");
if (this.has(OW.ENGINE_RADIO_CARD)) cards.push("radio");
return cards;
}
/** Show the card and wait for a button. */
private *show(
message: number | string[],
extra: Partial<PokegearView> = {},
): Gen<PokegearButton> {
const view: PokegearView = {
card: this.card,
cards: this.cards(),
...extra,
};
const pages = typeof message === "number" ? this.pages(message) : message;
const button = yield { kind: "pokegear", pages, view };
if (typeof button !== "string" || !POKEGEAR_BUTTONS.includes(button))
throw new Error("Invalid Pokégear button.");
return button as PokegearButton;
}
/** Close the Pokégear's screen. */
private *close(): Gen {
yield { kind: "pokegear", pages: [] };
}
private *hold(address: number, ticks: number): Gen {
yield { kind: "text", pages: this.pages(address), holdTicks: ticks };
}
/** PokeGear: the clock card first; returns when the player exits. */
*run(): Gen {
for (;;) {
const next =
this.card === "clock"
? yield* this.clock()
: this.card === "map"
? yield* this.map()
: this.card === "phone"
? yield* this.phone()
: yield* this.radio();
if (!next) return yield* this.close();
// Pokegear_SwitchPage.
this.card = next;
}
}
// ----------------------------------------------------------------- clock
/** PokegearClock_Joypad: any of A, B, Select or Start exits; right goes
* to the first card the player has. */
private *clock(): Gen<PokegearCard | undefined> {
for (;;) {
const time = this.host.scriptHost().clock();
const button = yield* this.show(POKEGEAR.PokegearPressButtonText, {
clock: pokegearClock(
this.reader,
this.host.text(),
time.hours,
time.minutes,
),
});
if (["a", "b", "start", "select"].includes(button)) return undefined;
if (button !== "right") continue;
if (this.has(OW.ENGINE_MAP_CARD)) return "map";
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
}
}
// ------------------------------------------------------------------- map
/** PokegearMap_ContinueMap: B exits, left and right change cards, up and
* down step the cursor through the region's landmarks with wrapping. */
private *map(): Gen<PokegearCard | undefined> {
const kanto =
this.playerLandmark !== OW.LANDMARK_FAST_SHIP &&
this.playerLandmark >= OW.KANTO_LANDMARK;
// TownMap_GetKantoLandmarkLimits: Kanto's routes need the Hall of Fame.
const [first, last] = !kanto
? [OW.JOHTO_LANDMARK, OW.JOHTO_LANDMARK_LAST]
: this.has(OW.ENGINE_CREDITS_SKIP)
? [OW.KANTO_LANDMARK, OW.KANTO_LANDMARK_LAST]
: [OW.LANDMARK_VICTORY_ROAD, OW.LANDMARK_ROUTE_28];
for (;;) {
const button = yield* this.show([], {
map: {
kanto,
cursor: this.cursorLandmark,
player: this.playerLandmark,
name: landmarkName(this.reader, this.cursorLandmark, this.host.text()),
},
});
if (button === "b") return undefined;
if (button === "right") {
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
} else if (button === "left") return "clock";
else if (button === "up")
this.cursorLandmark =
this.cursorLandmark < last ? this.cursorLandmark + 1 : first;
else if (button === "down")
this.cursorLandmark =
this.cursorLandmark !== first ? this.cursorLandmark - 1 : last;
}
}
// ----------------------------------------------------------------- phone
/** wPhoneList: CONTACT_LIST_SIZE slots, empty ones zero. */
private contact(slot: number) {
return this.state.phone[slot] ?? 0;
}
/** PhoneContacts' trainer class and number for a contact. */
private callerClass(contact: number) {
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
return { classId: this.reader.byte(entry), id: this.reader.byte(entry + 1) };
}
/** GetCallerClassAndName: "NAME:" over the trainer class, or the
* NonTrainerCallerNames entry (dashes for an empty slot). */
private callerName(contact: number) {
const r = this.reader,
{ classId, id } = this.callerClass(contact);
if (classId) {
const trainers = new TrainerData(r);
return `${trainers.trainer(classId, id).name}:\n ${trainers.className(classId)}`;
}
const pointer = POKEGEAR.NonTrainerCallerNames + id * 2;
return readString(r, r.near(pointer, r.bank(pointer)), this.host.text());
}
private rows() {
return Array.from({ length: PHONE_DISPLAY_HEIGHT }, (_, row) =>
this.callerName(this.contact(this.scroll + row)),
);
}
private signal() {
return !this.host.scriptHost().phoneService();
}
private *phoneCard(
message: number | string[],
extra: Partial<NonNullable<PokegearView["phone"]>> = {},
) {
return yield* this.show(message, {
phone: {
rows: this.rows(),
cursor: this.cursor,
signal: this.signal(),
...extra,
},
});
}
/** PokegearPhone_Init and PokegearPhone_Joypad. */
private *phone(): Gen<PokegearCard | undefined> {
this.scroll = this.cursor = 0;
for (;;) {
const button = yield* this.phoneCard(POKEGEAR.PokegearAskWhoCallText);
if (button === "b") return undefined;
if (button === "a") {
const contact = this.contact(this.scroll + this.cursor);
if (contact) yield* this.contactSubmenu(contact);
} else if (button === "left")
return this.has(OW.ENGINE_MAP_CARD) ? "map" : "clock";
else if (button === "right") {
if (this.has(OW.ENGINE_RADIO_CARD)) return "radio";
} else if (button === "up") {
if (this.cursor) this.cursor--;
else if (this.scroll) this.scroll--;
} else if (button === "down") {
if (this.cursor < PHONE_DISPLAY_HEIGHT - 1) this.cursor++;
else if (this.scroll < CONTACT_LIST_SIZE - PHONE_DISPLAY_HEIGHT)
this.scroll++;
}
}
}
/** CheckCanDeletePhoneNumber: Mom and Elm stay. */
private canDelete(contact: number) {
const { classId, id } = this.callerClass(contact);
return (
classId !== 0 || (id !== OW.PHONECONTACT_MOM && id !== OW.PHONECONTACT_ELM)
);
}
/** PokegearPhoneContactSubmenu: CALL (DELETE) CANCEL; up and down stop
* at the ends; B cancels. */
private *contactSubmenu(contact: number): Gen {
const deletable = this.canDelete(contact);
const strings =
POKEGEAR[
deletable
? "PokegearPhoneContactSubmenu.CallDeleteCancelStrings"
: "PokegearPhoneContactSubmenu.CallCancelStrings"
];
const r = this.reader,
count = r.byte(strings + 2);
const items = readString(r, strings + 3, this.host.text()).split("\n");
if (items.length !== count) throw new Error("Unexpected phone submenu.");
const actions = deletable
? (["call", "delete", "cancel"] as const)
: (["call", "cancel"] as const);
let selected = 0;
for (;;) {
const button = yield* this.phoneCard(POKEGEAR.PokegearAskWhoCallText, {
chosen: this.cursor,
submenu: { items, selected },
});
if (button === "up") selected = Math.max(0, selected - 1);
else if (button === "down") selected = Math.min(count - 1, selected + 1);
else if (button === "a" || button === "b") {
const action = button === "b" ? "cancel" : actions[selected];
if (action === "call") return yield* this.call(contact);
if (action === "delete") {
const answer = yield {
kind: "yesno",
pages: this.pages(POKEGEAR.PokegearAskDeleteText),
};
if (answer) this.deleteContact();
}
return;
}
}
}
/** PokegearPhone_DeletePhoneNumber: the slot empties. Its compaction
* loop only moves an entry up past the emptied slot, which the native
* list (kept without gaps) already does. */
private deleteContact() {
this.state.phone.splice(this.scroll + this.cursor, 1);
}
/** PokegearPhone_MakePhoneCall, MakePhoneCallFromPokegear and
* PokegearPhone_FinishPhoneCall. */
private *call(contact: number): Gen {
const r = this.reader,
scriptHost = this.host.scriptHost();
if (scriptHost.phoneService()) {
// Phone_NoSignal: the three boops of Phone_CallEnd.
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
const pages = this.pages(
POKEGEAR["PokegearPhone_MakePhoneCall.GearOutOfServiceText"],
);
yield { kind: "text", pages };
return;
}
for (let ring = 0; ring < 2; ring++)
yield* this.hold(
POKEGEAR["PokegearPhone_MakePhoneCall.GearEllipseText"],
CALL_TICKS,
);
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
// CheckPhoneContactTimeOfDay: the callee's time mask against wTimeOfDay.
const time = r.byte(entry + 4) & (OW.MORN | OW.DAY | OW.NITE);
const script = !(time & (1 << scriptHost.timeOfDay()))
? POKEGEAR.PhoneOutOfAreaScript
: `${r.byte(entry + 2)}:${r.byte(entry + 3)}` === scriptHost.mapId
? POKEGEAR.PhoneScript_JustTalkToThem
: r.far(entry + 5);
const run = new ScriptRun(r, script, this.state, scriptHost);
let input: Input;
for (let effect = run.resume(); effect; effect = run.resume(input))
input = yield effect;
if (run.deferred !== undefined)
throw new Error("A phone script deferred another script.");
Object.assign(this.state, run.state);
// HangUp after A or B; the callee's last text waited for that button.
yield* this.hold(SYMBOLS.phoneClickText, HANG_UP.clickTicks);
yield* this.hold(POKEGEAR.PhoneEllipseText, HANG_UP.endTicks);
}
// ----------------------------------------------------------------- radio
/** PokegearRadio_Joypad: B exits and left changes cards. The stations
* are not ported. */
private *radio(): Gen<PokegearCard | undefined> {
yield { kind: "notice", text: "The radio is not ported yet." };
for (;;) {
const button = yield* this.show([]);
if (button === "b") return undefined;
if (button === "left") {
if (this.has(OW.ENGINE_PHONE_CARD)) return "phone";
if (this.has(OW.ENGINE_MAP_CARD)) return "map";
return "clock";
}
}
}
}
+16
View File
@@ -14,8 +14,10 @@ import {
copyScriptState, copyScriptState,
type MartEntry, type MartEntry,
type ScriptEffect, type ScriptEffect,
type ScriptHost,
type ScriptState, type ScriptState,
} from "./interpreter"; } from "./interpreter";
import { Pokegear } from "./pokegear";
import { readString, readText, type TextContext } from "./text"; import { readString, readText, type TextContext } from "./text";
type Input = number | string | undefined; type Input = number | string | undefined;
@@ -109,6 +111,10 @@ export interface StartMenuHost {
landmark(): number; landmark(): number;
/** A field move's checks for party slot `mon`, on the menu's draft. */ /** A field move's checks for party slot `mon`, on the menu's draft. */
fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove; fieldMove(state: ScriptState, mon: number, move: number): MenuFieldMove;
/** The script host a Pokégear phone call runs with. */
scriptHost(): ScriptHost;
/** GetWorldMapLocation for any map. */
landmarkOf(mapId: string): number;
} }
/** The overworld start menu (engine/menus/start_menu.asm) with its /** The overworld start menu (engine/menus/start_menu.asm) with its
@@ -282,6 +288,16 @@ export class StartMenuRun {
case STARTMENUITEM_PACK: case STARTMENUITEM_PACK:
if (yield* this.pack()) return; if (yield* this.pack()) return;
break; break;
case STARTMENUITEM_POKEGEAR:
// StartMenu_Pokegear returns to the start menu.
yield* new Pokegear(this.state, {
reader: this.reader,
data: this.data,
text: () => this.text(),
scriptHost: () => this.host.scriptHost(),
landmarkOf: (mapId) => this.host.landmarkOf(mapId),
}).run();
break;
case STARTMENUITEM_EXIT: case STARTMENUITEM_EXIT:
return; return;
case STARTMENUITEM_SAVE: case STARTMENUITEM_SAVE:
+32
View File
@@ -87,6 +87,38 @@ export const OW = {
LANDMARK_SPECIAL: 0, LANDMARK_SPECIAL: 0,
LANDMARK_FAST_SHIP: 95, LANDMARK_FAST_SHIP: 95,
KANTO_LANDMARK: 47, KANTO_LANDMARK: 47,
JOHTO_LANDMARK: 1,
JOHTO_LANDMARK_LAST: 46,
KANTO_LANDMARK_LAST: 94,
LANDMARK_NEW_BARK_TOWN: 1,
LANDMARK_VICTORY_ROAD: 88,
LANDMARK_ROUTE_28: 94,
// The Pokégear's cards and Mom's savings (constants/engine_flags.asm).
ENGINE_RADIO_CARD: 0,
ENGINE_MAP_CARD: 1,
ENGINE_PHONE_CARD: 2,
ENGINE_MOM_SAVING_MONEY: 8,
ENGINE_MOM_ACTIVE: 9,
ENGINE_CREDITS_SKIP: 15,
// Phone contacts (constants/trainer_constants.asm).
PHONECONTACT_MOM: 1,
PHONECONTACT_ELM: 4,
// wTimeOfDay bits (constants/ram_constants.asm).
MORN: 1,
DAY: 2,
NITE: 4,
// Money accounts and checkmoney results (constants/script_constants.asm).
YOUR_MONEY: 0,
MOMS_MONEY: 1,
HAVE_MORE: 0,
HAVE_AMOUNT: 1,
HAVE_LESS: 2,
// wMomSavingMoney (constants/ram_constants.asm).
MOM_SAVING_SOME_MONEY_F: 0,
MOM_SAVING_HALF_MONEY_F: 1,
MOM_SAVING_ALL_MONEY_F: 2,
MOM_ACTIVE_F: 7,
MOM_MONEY: 2300,
COLL_WATER: 0x29, COLL_WATER: 0x29,
// Sprite ranges (constants/sprite_constants.asm). // Sprite ranges (constants/sprite_constants.asm).
NUM_OVERWORLD_SPRITES: 102, NUM_OVERWORLD_SPRITES: 102,
+3
View File
@@ -57,6 +57,9 @@ export interface GameMap {
tileset: number; tileset: number;
environment: number; environment: number;
paletteMode: number; paletteMode: number;
/** The palette byte's high nybble: nonzero where the phone has no
* signal (GetMapPhoneService). */
phoneService: number;
/** The map attributes' ROM offset, which some routines compare. */ /** The map attributes' ROM offset, which some routines compare. */
attributes?: number; attributes?: number;
blocks: Uint8Array; blocks: Uint8Array;
+102 -5
View File
@@ -15,6 +15,12 @@ import {
SYMBOLS, SYMBOLS,
} from "../rom/profile"; } from "../rom/profile";
import { OW } from "./constants"; import { OW } from "./constants";
import {
POKEGEAR_BUTTONS,
pokegearClock,
type PokegearView,
} from "../scripts/pokegear";
import { MOM_BANK_BUTTONS, type MomBankView } from "../scripts/mom";
import { import {
PLAYER, PLAYER,
ScriptRun, ScriptRun,
@@ -99,6 +105,11 @@ export interface NativeSave {
pcItems: ItemSlot[]; pcItems: ItemSlot[];
pokedex: { seen: number[]; caught: number[] }; pokedex: { seen: number[]; caught: number[] };
money: number; money: number;
/** wMomsMoney: Mom's savings. */
momsMoney: number;
/** wWhichMomItem and wMomItemTriggerBalance: Mom's purchases. */
momItem: number;
momItemTriggerBalance: number;
fruitTrees: number[]; fruitTrees: number[];
dailyReset: number | null; dailyReset: number | null;
/** wLastSpawnMapGroup/Number: the outdoor map last entered through a /** wLastSpawnMapGroup/Number: the outdoor map last entered through a
@@ -229,6 +240,10 @@ export type Choice =
pocket: number; pocket: number;
selected: number; selected: number;
} }
/** A Pokégear card, answered with the button pressed. */
| { kind: "pokegear"; view: PokegearView; selected: 0 }
/** BankOfMom's amount box, answered with the button pressed. */
| { kind: "mombank"; view: MomBankView; selected: 0 }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */ /** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| { | {
kind: "martquantity"; kind: "martquantity";
@@ -240,6 +255,10 @@ export type Choice =
prompt: string; prompt: string;
selected: number; selected: number;
}; };
/** Choices that read the buttons themselves and are answered with the
* button's name. */
const buttonChoice = (choice: Choice) =>
choice.kind === "pokegear" || choice.kind === "mombank";
export interface Dialogue { export interface Dialogue {
pages: string[]; pages: string[];
index: number; index: number;
@@ -1745,6 +1764,7 @@ export class GameWorld {
playerCollision: () => playerCollision: () =>
this.assets.collision(this.map, this.player.x, this.player.y), this.assets.collision(this.map, this.player.x, this.player.y),
environment: () => this.assets.map(mapId).environment, environment: () => this.assets.map(mapId).environment,
phoneService: () => this.assets.map(mapId).phoneService,
}; };
} }
private weekdayName(day: number) { private weekdayName(day: number) {
@@ -1839,11 +1859,19 @@ export class GameWorld {
} }
/** Select in PlayerEvents (SelectMenuScript): the registered item. */ /** Select in PlayerEvents (SelectMenuScript): the registered item. */
useSelect(): boolean { useSelect(): boolean {
if (this.dialogue?.choice?.kind === "pokegear") {
this.choose("select");
return true;
}
return this.openStartMenu(true); return this.openStartMenu(true);
} }
/** Start in PlayerEvents (StartMenuScript): the start menu opens while /** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */ * the player stands still. Its changes commit when it closes. */
openStartMenu(select = false): boolean { openStartMenu(select = false): boolean {
if (!select && this.dialogue?.choice?.kind === "pokegear") {
this.choose("start");
return true;
}
if (this.busy || this.introPending) return false; if (this.busy || this.introPending) return false;
this.clearNotice(); this.clearNotice();
this.script = { this.script = {
@@ -1858,6 +1886,8 @@ export class GameWorld {
landmark: () => this.assets.map(this.mapId).landmark, landmark: () => this.assets.map(this.mapId).landmark,
fieldMove: (state, mon, move) => fieldMove: (state, mon, move) =>
this.menuFieldMove(state, mon, move), this.menuFieldMove(state, mon, move),
scriptHost: () => this.host(PLAYER),
landmarkOf: (mapId) => this.assets.map(mapId).landmark,
}, },
this.menuCursors, this.menuCursors,
select, select,
@@ -2119,6 +2149,12 @@ export class GameWorld {
tutorial: effect.tutorial, tutorial: effect.tutorial,
landmark: this.assets.map(this.mapId).landmark, landmark: this.assets.map(this.mapId).landmark,
partyMenuCursor: this.menuCursors.party, partyMenuCursor: this.menuCursors.party,
// Only MOM_SAVING_SOME_MONEY_F has an engine flag; nothing in the
// ROM sets the half and all bits.
mom: {
saving: Number(draft.flags.has(OW.ENGINE_MOM_SAVING_MONEY)),
money: draft.momsMoney,
},
}); });
this.battle = { engine, selected: 1 }; this.battle = { engine, selected: 1 };
this.battleRun = engine.run(); this.battleRun = engine.run();
@@ -2154,6 +2190,22 @@ export class GameWorld {
this.statsScreen = effect.mon; this.statsScreen = effect.mon;
this.dialogue = { pages: ["Stats"], index: 0 }; this.dialogue = { pages: ["Stats"], index: 0 };
return true; return true;
case "pokegear":
this.pokegear = effect.view;
if (!effect.view) return false;
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
index: 0,
choice: { kind: "pokegear", view: effect.view, selected: 0 },
};
return true;
case "mombank":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
index: 0,
choice: { kind: "mombank", view: effect.view, selected: 0 },
};
return true;
case "menu": case "menu":
this.dialogue = { this.dialogue = {
pages: effect.pages.length ? effect.pages : [""], pages: effect.pages.length ? effect.pages : [""],
@@ -2572,6 +2624,7 @@ export class GameWorld {
this.memScript ??= script.memScript; this.memScript ??= script.memScript;
this.townMap = false; this.townMap = false;
this.statsScreen = undefined; this.statsScreen = undefined;
this.pokegear = undefined;
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
@@ -2611,6 +2664,7 @@ export class GameWorld {
this.script = undefined; this.script = undefined;
this.townMap = false; this.townMap = false;
this.statsScreen = undefined; this.statsScreen = undefined;
this.pokegear = undefined;
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
@@ -2639,11 +2693,12 @@ export class GameWorld {
input = undefined; input = undefined;
view.prompt = undefined; view.prompt = undefined;
if (next.done) { if (next.done) {
if (this.script && next.value.prizeMoney) if (this.script) {
this.script.run.state.money = Math.min( const draft = this.script.run.state,
C.MAX_MONEY, { prizeMoney = 0, momMoney = 0 } = next.value;
this.script.run.state.money + next.value.prizeMoney, draft.money = Math.min(C.MAX_MONEY, draft.money + prizeMoney);
); draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
}
this.battle = undefined; this.battle = undefined;
this.battleRun = undefined; this.battleRun = undefined;
// ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM. // ExitBattle: EvolveAfterBattle after a win, then CleanUpBattleRAM.
@@ -3049,6 +3104,9 @@ export class GameWorld {
townMap = false; townMap = false;
/** The Pokémon a script's stats screen shows (Bill's PC). */ /** The Pokémon a script's stats screen shows (Bill's PC). */
statsScreen: PartyMon | undefined; statsScreen: PartyMon | undefined;
/** The Pokégear card on screen while the Pokégear is open, including
* during the phone calls it makes. */
pokegear: PokegearView | undefined;
ticks = 0; ticks = 0;
tick(held?: Direction) { tick(held?: Direction) {
@@ -3069,6 +3127,17 @@ export class GameWorld {
} }
const script = this.script; const script = this.script;
if (script) { if (script) {
// Pokegear_UpdateClock runs every frame while the clock card shows.
const gear = this.pokegear;
if (gear?.clock) {
const { hours, minutes } = this.clock();
gear.clock = pokegearClock(
this.assets.reader,
this.host(PLAYER).text(),
hours,
minutes,
);
}
const evolution = this.evolution; const evolution = this.evolution;
if (evolution) { if (evolution) {
const frame = evolution.schedule[++evolution.step]; const frame = evolution.schedule[++evolution.step];
@@ -3187,6 +3256,7 @@ export class GameWorld {
if (choice && this.dialogue.index === this.dialogue.pages.length - 1) { if (choice && this.dialogue.index === this.dialogue.pages.length - 1) {
// The naming screen is answered with text through choose(). // The naming screen is answered with text through choose().
if (choice.kind === "pack") this.choosePackEntry(); if (choice.kind === "pack") this.choosePackEntry();
else if (buttonChoice(choice)) this.choose("a");
else if (choice.kind !== "name") this.choose(choice.selected); else if (choice.kind !== "name") this.choose(choice.selected);
} else this.advanceDialogue(); } else this.advanceDialogue();
return; return;
@@ -3516,6 +3586,7 @@ export class GameWorld {
const choice = dialogue.choice; const choice = dialogue.choice;
if (choice.kind === "yesno") this.choose(0); if (choice.kind === "yesno") this.choose(0);
else if (choice.kind === "martmenu") this.choose(2); else if (choice.kind === "martmenu") this.choose(2);
else if (buttonChoice(choice)) this.choose("b");
else if ( else if (
choice.kind === "martlist" || choice.kind === "martlist" ||
choice.kind === "martquantity" || choice.kind === "martquantity" ||
@@ -3545,6 +3616,7 @@ export class GameWorld {
if (!choice || this.dialogue!.index !== this.dialogue!.pages.length - 1) if (!choice || this.dialogue!.index !== this.dialogue!.pages.length - 1)
return; return;
if (choice.kind === "name") return; if (choice.kind === "name") return;
if (buttonChoice(choice)) return this.choose(direction);
if (choice.kind === "yesno") { if (choice.kind === "yesno") {
if (direction === "up") choice.selected = 1; if (direction === "up") choice.selected = 1;
if (direction === "down") choice.selected = 0; if (direction === "down") choice.selected = 0;
@@ -3623,6 +3695,13 @@ export class GameWorld {
(answer !== "" && !NICKNAME_PATTERN.test(answer))) (answer !== "" && !NICKNAME_PATTERN.test(answer)))
) )
return; return;
if (
(dialogue.choice.kind === "pokegear" &&
!POKEGEAR_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "mombank" &&
!MOM_BANK_BUTTONS.includes(answer as never))
)
return;
this.townMap = false; this.townMap = false;
this.statsScreen = undefined; this.statsScreen = undefined;
this.dialogue = undefined; this.dialogue = undefined;
@@ -3675,6 +3754,9 @@ export class GameWorld {
caught: [...state.pokedex.caught].sort((a, b) => a - b), caught: [...state.pokedex.caught].sort((a, b) => a - b),
}, },
money: state.money, money: state.money,
momsMoney: state.momsMoney,
momItem: state.momItem,
momItemTriggerBalance: state.momItemTriggerBalance,
decorations: { ...state.decorations }, decorations: { ...state.decorations },
fruitTrees: [...state.fruitTrees].sort((a, b) => a - b), fruitTrees: [...state.fruitTrees].sort((a, b) => a - b),
dailyReset: state.dailyReset, dailyReset: state.dailyReset,
@@ -3759,6 +3841,9 @@ export class GameWorld {
!species(save.pokedex.caught) || !species(save.pokedex.caught) ||
!save.pokedex.caught.every((id) => save.pokedex.seen.includes(id)) || !save.pokedex.caught.every((id) => save.pokedex.seen.includes(id)) ||
!integer(save.money, 0, C.MAX_MONEY) || !integer(save.money, 0, C.MAX_MONEY) ||
!integer(save.momsMoney, 0, C.MAX_MONEY) ||
!integer(save.momItem, 0, 10) ||
!integer(save.momItemTriggerBalance, 0, 0xffffff) ||
!Array.isArray(save.fruitTrees) || !Array.isArray(save.fruitTrees) ||
!save.fruitTrees.every((id) => integer(id, 1, NUM_FRUIT_TREES)) || !save.fruitTrees.every((id) => integer(id, 1, NUM_FRUIT_TREES)) ||
new Set(save.fruitTrees).size !== save.fruitTrees.length || new Set(save.fruitTrees).size !== save.fruitTrees.length ||
@@ -3837,6 +3922,9 @@ export class GameWorld {
caught: new Set(save.pokedex.caught), caught: new Set(save.pokedex.caught),
}, },
money: save.money, money: save.money,
momsMoney: save.momsMoney,
momItem: save.momItem,
momItemTriggerBalance: save.momItemTriggerBalance,
fruitTrees: new Set(save.fruitTrees), fruitTrees: new Set(save.fruitTrees),
dailyReset: save.dailyReset, dailyReset: save.dailyReset,
lastSpawnMap: save.lastSpawnMap, lastSpawnMap: save.lastSpawnMap,
@@ -3892,6 +3980,7 @@ export class GameWorld {
this.playerId = save.playerId; this.playerId = save.playerId;
this.townMap = false; this.townMap = false;
this.statsScreen = undefined; this.statsScreen = undefined;
this.pokegear = undefined;
this.dialogue = undefined; this.dialogue = undefined;
this.emote = undefined; this.emote = undefined;
this.picture = undefined; this.picture = undefined;
@@ -3929,6 +4018,11 @@ export function migrateSave(
decorations: initialDecorations(), decorations: initialDecorations(),
}), }),
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }), ...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
...(!Object.hasOwn(save, "momsMoney") && { momsMoney: 0 }),
...(!Object.hasOwn(save, "momItem") && {
momItem: 0,
momItemTriggerBalance: OW.MOM_MONEY,
}),
...(!Object.hasOwn(save, "storage") && { storage: emptyStorage() }), ...(!Object.hasOwn(save, "storage") && { storage: emptyStorage() }),
...(!Object.hasOwn(save, "pcItems") && { pcItems: [] }), ...(!Object.hasOwn(save, "pcItems") && { pcItems: [] }),
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }), ...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
@@ -3980,6 +4074,9 @@ export function migrateSave(
specialPhoneCall: 0, specialPhoneCall: 0,
rivalName: "???", rivalName: "???",
money: C.START_MONEY, money: C.START_MONEY,
momsMoney: 0,
momItem: 0,
momItemTriggerBalance: OW.MOM_MONEY,
fruitTrees: [], fruitTrees: [],
dailyReset: null, dailyReset: null,
playerState: 0, playerState: 0,
+140 -1
View File
@@ -5,6 +5,7 @@ import { bindTabs } from "./ui/tabs";
import { NativeRuntime } from "./game/runtime"; import { NativeRuntime } from "./game/runtime";
import { LocalMapView } from "./game/rendering/minimap"; import { LocalMapView } from "./game/rendering/minimap";
import type { Choice, GameWorld } from "./game/world/world"; import type { Choice, GameWorld } from "./game/world/world";
import type { PokegearCard } from "./game/scripts/pokegear";
import { drawTile } from "./game/rendering/pixels"; import { drawTile } from "./game/rendering/pixels";
import { PokemonData } from "./game/rom/pokemon"; import { PokemonData } from "./game/rom/pokemon";
import type { Direction, Palette } from "./game/world/types"; import type { Direction, Palette } from "./game/world/types";
@@ -47,6 +48,7 @@ function showWorld(world: GameWorld) {
get("game-notice").textContent = world.notice; get("game-notice").textContent = world.notice;
showBattle(world); showBattle(world);
showStatsScreen(world); showStatsScreen(world);
showPokegear(world);
get("town-map-view").hidden = !world.townMap; get("town-map-view").hidden = !world.townMap;
if (world.townMap) if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world); drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -60,6 +62,8 @@ function showWorld(world: GameWorld) {
get("native-dialogue").hidden = get("native-dialogue").hidden =
!dialogue || !dialogue ||
pictureWait || pictureWait ||
// The Pokégear's map and radio cards have no textbox.
(dialogue.choice?.kind === "pokegear" && !dialogue.pages[dialogue.index]) ||
Boolean(mart) || Boolean(mart) ||
world.townMap || world.townMap ||
Boolean(world.statsScreen); Boolean(world.statsScreen);
@@ -113,7 +117,11 @@ function showChoice(choice: Choice | undefined) {
return element; return element;
}; };
if (!choice) container.replaceChildren(); if (!choice) container.replaceChildren();
else if (choice.kind === "yesno") else if (choice.kind === "pokegear") {
// The Pokégear reads the controls directly.
container.replaceChildren();
container.hidden = true;
} else if (choice.kind === "yesno")
container.replaceChildren( container.replaceChildren(
button("YES", choice.selected === 1, () => runtime.choose(1)), button("YES", choice.selected === 1, () => runtime.choose(1)),
button("NO", choice.selected === 0, () => runtime.choose(0)), button("NO", choice.selected === 0, () => runtime.choose(0)),
@@ -156,6 +164,51 @@ function showChoice(choice: Choice | undefined) {
); );
container.firstElementChild?.setAttribute("aria-label", "Earlier"); container.firstElementChild?.setAttribute("aria-label", "Earlier");
container.children[2]?.setAttribute("aria-label", "Later"); container.children[2]?.setAttribute("aria-label", "Later");
} else if (choice.kind === "mombank") {
// Mom_ContinueMenuSetup's box: SAVED, HELD and the amount, whose digit
// under the cursor blinks. The buttons send the joypad's own presses.
const { view } = choice;
const box = document.createElement("div");
box.className = "mom-bank";
const row = (label: string, value: HTMLElement | string) => {
const line = document.createElement("p"),
name = document.createElement("span"),
amount = document.createElement("output");
name.textContent = label;
amount.append(value);
line.append(name, amount);
return line;
};
const digits = document.createElement("span");
digits.setAttribute("aria-label", `¥${view.amount}`);
digits.append(
"¥",
...[...String(view.amount).padStart(6, "0")].map((digit, index) => {
const cell = document.createElement("span");
cell.textContent = digit;
if (index === view.cursor) cell.className = "mom-bank-cursor";
return cell;
}),
);
box.append(
row(view.labels[0], `¥${view.saved}`),
row(view.labels[1], `¥${view.held}`),
row(view.labels[2], digits),
);
const press = (label: string, aria: string, name: string) => {
const element = button(label, undefined, () => runtime.choose(name));
element.setAttribute("aria-label", aria);
return element;
};
container.replaceChildren(
box,
press("◀", "Previous digit", "left"),
press("▼", "Decrease digit", "down"),
press("▲", "Increase digit", "up"),
press("▶", "Next digit", "right"),
press("OK", "OK", "a"),
press("CANCEL", "Cancel", "b"),
);
} else if (choice.kind === "name") } else if (choice.kind === "name")
container.replaceChildren( container.replaceChildren(
...nameField(choice.current, choice.max, (name) => runtime.choose(name)), ...nameField(choice.current, choice.max, (name) => runtime.choose(name)),
@@ -807,6 +860,92 @@ function showBattle(world: GameWorld) {
); );
highlightBattleChoice(); highlightBattleChoice();
} }
let pokegearKey = "";
const POKEGEAR_CARDS: Record<PokegearCard, string> = {
clock: "CLOCK",
map: "MAP",
phone: "PHONE",
radio: "RADIO",
};
/** The Pokégear's cards (engine/pokegear/pokegear.asm): the card icons,
* then the clock, the Town Map with its cursor's landmark, the phone list
* or the radio. Its textbox is the native dialogue. */
function showPokegear(world: GameWorld) {
const view = world.pokegear;
get("pokegear").hidden = !view;
if (!view) {
pokegearKey = "";
return;
}
const key = JSON.stringify(view);
if (key === pokegearKey) return;
pokegearKey = key;
const element = <K extends keyof HTMLElementTagNameMap>(
tag: K,
text = "",
className = "",
) => {
const node = document.createElement(tag);
node.textContent = text;
if (className) node.className = className;
return node;
};
get("pokegear-cards").replaceChildren(
...view.cards.map((card) => {
const icon = element("span", POKEGEAR_CARDS[card]);
icon.setAttribute("aria-current", String(card === view.card));
return icon;
}),
);
const body = get("pokegear-card");
if (view.clock) {
const clock = element("div", "", "pokegear-clock");
clock.append(element("span", view.clock.day), element("strong", view.clock.time));
body.replaceChildren(clock);
} else if (view.map) {
const map = element("div", "", "pokegear-map"),
canvas = element("canvas");
canvas.setAttribute("role", "img");
drawTownMap(canvas, world, view.map);
map.append(element("p", `▶ ${view.map.name}`), canvas);
body.replaceChildren(map);
} else if (view.phone) {
const phone = view.phone,
card = element("div", "", "pokegear-phone"),
list = element("ol");
list.setAttribute("aria-label", "Phone numbers");
list.replaceChildren(
...phone.rows.map((row, index) => {
const item = element("li", row);
item.dataset.mark =
phone.chosen === index
? "▷"
: phone.chosen === undefined && phone.cursor === index
? "▶"
: " ";
if (item.dataset.mark !== " ") item.setAttribute("aria-current", "true");
return item;
}),
);
card.append(
element("span", phone.signal ? "Signal ▮▮▮" : "Out of service", "pokegear-signal"),
list,
);
if (phone.submenu) {
const submenu = element("ol", "", "pokegear-submenu");
submenu.replaceChildren(
...phone.submenu.items.map((item, index) => {
const entry = element("li", item);
entry.dataset.mark = index === phone.submenu!.selected ? "▶" : " ";
return entry;
}),
);
card.append(submenu);
}
body.replaceChildren(card);
} else body.replaceChildren();
}
/** The stats screen's pages (engine/pokemon/stats_screen.asm), with its /** The stats screen's pages (engine/pokemon/stats_screen.asm), with its
* strings, type names and stat labels read from the ROM. */ * strings, type names and stat labels read from the ROM. */
function showStatsScreen(world: GameWorld) { function showStatsScreen(world: GameWorld) {
+1
View File
@@ -32,6 +32,7 @@ export function renderShell(root: HTMLElement) {
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div> <div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
</div> </div>
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div> <div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
<div id="pokegear" class="pokegear" hidden aria-label="Pokégear"><div id="pokegear-cards" class="pokegear-cards" aria-label="Cards"></div><div id="pokegear-card" class="pokegear-card" aria-live="polite"></div></div>
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div> <div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
<div id="mart" class="mart" hidden aria-label="Poké Mart"> <div id="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p> <p id="mart-money" class="mart-money"></p>
+126 -1
View File
@@ -538,7 +538,8 @@ dialog h2 {
.intro-screen[hidden] { .intro-screen[hidden] {
display: none; display: none;
} }
.intro-screen:not([hidden]) ~ .minimap-overlay { .intro-screen:not([hidden]) ~ .minimap-overlay,
.pokegear:not([hidden]) ~ .minimap-overlay {
display: none; display: none;
} }
.pokepic { .pokepic {
@@ -667,6 +668,38 @@ dialog h2 {
.dialogue-choice button { .dialogue-choice button {
min-width: 64px; min-width: 64px;
} }
/* BankOfMom's amount box: one row per amount, the cursor digit blinking
16 frames in 32 as hVBlankCounter's bit 4 does. */
.mom-bank {
flex: 1 0 100%;
margin: 0;
}
.mom-bank p {
display: flex;
justify-content: space-between;
max-width: 22ch;
margin: 0 0 2px;
font-weight: 800;
}
.dialogue-choice .mom-bank output {
min-width: 0;
text-align: right;
font-variant-numeric: tabular-nums;
}
.mom-bank-cursor {
animation: mom-bank-blink 0.53s steps(1) infinite;
}
@keyframes mom-bank-blink {
50% {
visibility: hidden;
}
}
@media (prefers-reduced-motion: reduce) {
.mom-bank-cursor {
animation: none;
text-decoration: underline;
}
}
.dialogue-choice button[aria-pressed="true"]::before { .dialogue-choice button[aria-pressed="true"]::before {
content: "▶ "; content: "▶ ";
} }
@@ -1136,3 +1169,95 @@ dialog dd {
image-rendering: pixelated; image-rendering: pixelated;
object-fit: contain; object-fit: contain;
} }
/* The Pokégear: card icons along the top, the card below and the
textbox (the native dialogue) over its lower part. */
.pokegear {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
grid-template-rows: auto 1fr;
gap: 8px;
padding: 10px 10px 118px;
color: var(--ink);
background: var(--blue-dark);
}
.pokegear-cards {
display: flex;
gap: 4px;
}
.pokegear-cards span {
padding: 2px 8px;
font-size: 0.75rem;
color: var(--cream);
border: 3px double var(--cream);
}
.pokegear-cards span[aria-current="true"] {
color: var(--ink);
background: var(--cream);
}
.pokegear-card {
min-height: 0;
display: grid;
place-items: center;
padding: 8px;
overflow: hidden;
background: var(--cream);
border: 5px double var(--ink);
}
.pokegear-clock {
display: grid;
gap: 4px;
justify-items: center;
}
.pokegear-clock strong {
font-size: 2rem;
letter-spacing: 0.05em;
white-space: pre;
}
.pokegear-map {
width: 100%;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
gap: 4px;
}
.pokegear-map canvas {
width: auto;
height: 100%;
max-width: 100%;
min-height: 0;
image-rendering: pixelated;
object-fit: contain;
}
.pokegear-phone {
width: 100%;
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 8px;
}
.pokegear-phone ol {
margin: 0;
padding: 0;
list-style: none;
}
.pokegear-phone li {
min-height: 2.9em;
white-space: pre;
}
.pokegear-phone li::before {
content: attr(data-mark) " ";
}
.pokegear-signal {
grid-column: 1 / -1;
justify-self: end;
font-size: 0.75rem;
}
.pokegear-submenu {
padding: 4px 8px;
border: 3px double var(--ink);
}
+93 -1
View File
@@ -11,7 +11,7 @@ test.beforeEach(() =>
async function start( async function start(
page: Page, page: Page,
room: boolean | "poster" | "center" | "candy" = false, room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom" = false,
) { ) {
await page.route("**/src/game/runtime.ts", async (route) => { await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(), const response = await route.fetch(),
@@ -295,3 +295,95 @@ test("a Rare Candy levels up and evolves a Pokémon through the pack", async ({
expect([state.party[0].species, state.party[0].level]).toEqual([156, 14]); expect([state.party[0].species, state.party[0].level]).toEqual([156, 14]);
expect(state.bag.items).toEqual([{ item: 18, quantity: 5 }]); expect(state.bag.items).toEqual([{ item: 18, quantity: 5 }]);
}); });
test("the Pokégear shows its cards and calls Elm", async ({ page }) => {
await start(page, "pokegear");
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
gear = page.locator("#pokegear"),
card = page.locator("#pokegear-card");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^POKéGEAR/ }).click();
await expect(gear).toBeVisible();
await expect(gear.locator('[aria-current="true"]')).toHaveText("CLOCK");
await expect(card).toContainText("TUESDAY");
await expect(card).toContainText("12:00 PM");
await expect(text).toHaveText("Press any button to exit.");
await page.keyboard.press("ArrowRight");
await expect(card).toContainText("▶ NEW BARK TOWN");
await expect(card.locator("canvas")).toHaveAttribute(
"aria-label",
"Town Map: NEW BARK TOWN, you are here",
);
await expect(page.locator("#native-dialogue")).toBeHidden();
await page.keyboard.press("ArrowUp");
await expect(card).toContainText("▶ ROUTE 29");
// The on-screen controls drive the Pokégear too.
const right = page.locator('[data-game-button="right"]');
await right.scrollIntoViewIfNeeded();
const bounds = (await right.boundingBox())!;
await page.mouse.move(
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2,
);
await page.mouse.down();
await page.mouse.up();
await page.locator("#game-screen").focus();
await expect(card.getByRole("list", { name: "Phone numbers" })).toContainText(
"PROF.ELM:",
);
await expect(text).toHaveText(/Whom do you want\s+to call\?/);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("KeyX");
await expect(card).toContainText("CALL");
await expect(card).toContainText("CANCEL");
await page.keyboard.press("KeyX");
await expect(text).toHaveText("……");
await page.clock.runFor(1000);
await expect(text).toHaveText("Hello, CHRIS?");
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyX");
await expect(text).toHaveText("Click!");
await page.clock.runFor(3000);
await expect(text).toHaveText(/Whom do you want\s+to call\?/);
await page.keyboard.press("KeyZ");
await expect(gear).toBeHidden();
await choices.getByRole("option", { name: /^EXIT/ }).click();
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
});
test("Mom keeps the player's money with the digit box", async ({ page }) => {
await start(page, "mom");
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
box = choices.locator(".mom-bank");
await page.keyboard.press("KeyX");
await expect(text).toHaveText(/Hi! Welcome home!/);
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyX");
await expect(text).toHaveText(/Or is this about\s+your money\?/);
await choices.getByRole("button", { name: "YES" }).click();
await expect(text).toHaveText(/What do you want\s+to do\?/);
await choices.getByRole("option", { name: "SAVE" }).click();
await expect(text).toHaveText(/How much do you\s+want to save\?/);
await expect(box).toContainText("SAVED¥2300");
await expect(box).toContainText("HELD¥3000");
await expect(box).toContainText("DEPOSIT¥000000");
await expect(box.locator(".mom-bank-cursor")).toHaveCount(1);
// The keyboard moves to the thousands and raises it; the on-screen
// buttons raise it again.
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowUp");
await expect(box).toContainText("DEPOSIT¥001000");
await choices.getByRole("button", { name: "Increase digit" }).click();
await expect(box).toContainText("DEPOSIT¥002000");
await page.locator("#game-screen").focus();
await page.keyboard.press("KeyX");
await expect(text).toHaveText(/Your money's safe\s+here! Get going!/);
await page.keyboard.press("KeyX");
await expect(page.locator("#native-dialogue")).toBeHidden();
const { state } = await backup(page);
expect([state.money, state.momsMoney]).toEqual([1000, 4300]);
});
+36 -4
View File
@@ -5,10 +5,13 @@ import { generatePartyMon } from "../src/game/party/pokemon";
/** `room` places the player at the bedroom PC or poster, "center" at /** `room` places the player at the bedroom PC or poster, "center" at
* Cherrygrove's Pokémon Center PC with two Pokémon ("candy": the first at * Cherrygrove's Pokémon Center PC with two Pokémon ("candy": the first at
* level 13, with a Rare Candy); otherwise outdoors. */ * level 13, with a Rare Candy); otherwise outdoors ("pokegear": with the
* Pokégear, its map and phone cards, and Mom's and Elm's numbers). "mom"
* faces Mom in the living room after the Mystery Egg, with her bank open
* and ¥2300 saved. */
export function foundationFixture( export function foundationFixture(
assets: CrystalAssets, assets: CrystalAssets,
room: boolean | "poster" | "center" | "candy", room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom",
) { ) {
const world = new GameWorld( const world = new GameWorld(
assets, assets,
@@ -32,13 +35,42 @@ export function foundationFixture(
world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION
if (room === "candy") world.state.bag.items.push({ item: 0x20, quantity: 1 }); if (room === "candy") world.state.bag.items.push({ item: 0x20, quantity: 1 });
} }
if (room === "pokegear" || room === "mom") {
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155,
level: 8,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
}),
];
for (const flag of [OW.ENGINE_MAP_CARD, OW.ENGINE_PHONE_CARD, 4])
world.state.flags.add(flag); // 4: ENGINE_POKEGEAR
world.state.phone = [OW.PHONECONTACT_MOM, OW.PHONECONTACT_ELM];
world.state.events.add(26); // EVENT_GOT_A_POKEMON_FROM_ELM
// Walking outside clears the bedroom's temporary events.
for (let event = 0; event < 8; event++) world.state.events.delete(event);
}
if (room === "mom") {
// EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON, EVENT_GAVE_MYSTERY_EGG_TO_ELM
// and EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST.
for (const event of [30, 31, 64]) world.state.events.add(event);
world.state.flags.add(OW.ENGINE_MOM_ACTIVE);
world.state.scenes["24:6"] = 1;
world.state.momsMoney = 2300;
}
const save = world.save(); const save = world.save();
const [map, x, y] = const [map, x, y] =
room === "center" || room === "candy" room === "center" || room === "candy"
? ["26:5", 9, 2] ? ["26:5", 9, 2]
: room === "poster" : room === "poster"
? ["24:7", 6, 1] ? ["24:7", 6, 1]
: room : room === "mom"
? ["24:6", 7, 3]
: room && room !== "pokegear"
? ["24:7", 2, 2] ? ["24:7", 2, 2]
: ["24:4", 9, 6]; : ["24:4", 9, 6];
world.restore({ world.restore({
@@ -46,7 +78,7 @@ export function foundationFixture(
map, map,
x, x,
y, y,
direction: "up", direction: room === "mom" ? "down" : "up",
objects: [], objects: [],
}); });
return world; return world;
+399
View File
@@ -0,0 +1,399 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { OW } from "../src/game/world/constants";
import {
shiftClockHour,
type MomBankButton,
type MomBankView,
} from "../src/game/scripts/mom";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const PLAYERS_HOUSE_1F = "24:6",
CYNDAQUIL = 155;
const EVENT_GOT_A_POKEMON_FROM_ELM = 26,
EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30,
EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64,
EVENT_FIRST_TIME_BANKING_WITH_MOM = 118;
const GET = 0,
SAVE = 1,
CHANGE = 2;
describe("DSTChecks' clock shift", () => {
it("moves the clock an hour, carrying across the half-day offset limit", () => {
const clock = { minuteOffset: 30, weekdayOffset: 2, dst: false };
shiftClockHour(clock, 1);
expect(clock).toEqual({ minuteOffset: 90, weekdayOffset: 2, dst: false });
clock.minuteOffset = 700;
shiftClockHour(clock, 1);
// 760 minutes ahead is a day ahead less 680 minutes.
expect(clock).toMatchObject({ minuteOffset: -680, weekdayOffset: 3 });
shiftClockHour(clock, -1);
expect(clock).toMatchObject({
minuteOffset: -740 + 1440,
weekdayOffset: 2,
});
clock.minuteOffset = -700;
clock.weekdayOffset = 0;
shiftClockHour(clock, -1);
expect(clock).toMatchObject({ minuteOffset: 680, weekdayOffset: 6 });
});
});
describe.skipIf(!existsSync(romPath))("BankOfMom", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** The player faces Mom in the living room on a Friday, after the
* Mystery Egg was delivered. */
function fixture(time = "2026-09-25T12:00:00") {
const world = new GameWorld(
assets(),
() => new Date(time),
() => 0x42,
);
world.state.party = [
generatePartyMon(world.assets.pokemon, {
species: CYNDAQUIL,
level: 8,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
}),
];
world.state.flags.add(ENGINE_FLAGS.pokegear);
for (const event of [
EVENT_GOT_A_POKEMON_FROM_ELM,
EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON,
EVENT_GAVE_MYSTERY_EGG_TO_ELM,
])
world.state.events.add(event);
world.state.scenes[PLAYERS_HOUSE_1F] = 1;
world.state.money = 3000;
world.restore({
...world.save(),
map: PLAYERS_HOUSE_1F,
x: 7,
y: 3,
direction: "down",
objects: [],
});
return world;
}
/** A world whose bank is already open, as after the first visit. */
function banking(time?: string) {
const world = fixture(time);
world.state.flags.add(OW.ENGINE_MOM_ACTIVE);
world.state.events.add(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST);
world.state.momsMoney = 2300;
return world;
}
/** Acknowledge pages up to the next choice; returns the pages seen. */
function until(world: GameWorld) {
const pages: string[] = [];
for (let tick = 0; tick < 2000; tick++) {
const dialogue = world.dialogue;
if (!dialogue) {
if (!world.busy) return pages;
world.tick();
continue;
}
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
if (page && pages.at(-1) !== page) pages.push(page);
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) return pages;
if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
throw new Error("No choice appeared.");
}
function talk(world: GameWorld) {
world.interact();
return until(world);
}
function answer(world: GameWorld, value: number) {
world.choose(value);
return until(world);
}
function view(world: GameWorld): MomBankView {
const choice = world.dialogue?.choice;
if (choice?.kind !== "mombank")
throw new Error(`Expected Mom's amount box, got ${choice?.kind}`);
return choice.view;
}
function press(world: GameWorld, ...buttons: MomBankButton[]) {
let pages: string[] = [];
for (const button of buttons) {
if (button === "a") world.interact();
else if (button === "b") world.advanceDialogue();
else world.moveCursor(button);
pages = until(world);
}
return pages;
}
/** Open the menu and choose GET, SAVE or CHANGE. */
function menu(world: GameWorld, item: number) {
expect(talk(world)).toEqual([
"Hi! Welcome home! You're trying very hard, I see.",
"I've kept your room tidy.",
"Or is this about your money?",
]);
const pages = answer(world, 1);
expect(pages).toEqual(["What do you want to do?"]);
const choice = world.dialogue?.choice;
expect(choice).toMatchObject({
kind: "menu",
items: ["GET", "SAVE", "CHANGE", "CANCEL"],
selected: 0,
});
return answer(world, item);
}
/** Close the last text and check the interaction ended cleanly. */
function finish(world: GameWorld) {
expect(until(world)).toEqual([]);
settle(world);
expect(world.busy).toBe(false);
expect(world.diagnostic).toBe("");
}
/** Key in an amount digit by digit, from the leftmost of six digits. */
function enter(world: GameWorld, amount: number) {
const digits = String(amount).padStart(6, "0");
press(world, "left", "left", "left", "left", "left");
for (const digit of digits) {
for (let i = 0; i < Number(digit); i++) press(world, "up");
press(world, "right");
}
expect(view(world).amount).toBe(amount);
}
it("offers to save money the first time, then only cheers the player on", () => {
const world = fixture();
const offer = talk(world);
expect(offer[0]).toBe("Wow, that's a cute POKéMON.");
expect(offer.at(-1)).toBe("Do you want me to save your money?");
expect(world.dialogue?.choice?.kind).toBe("yesno");
// Saves made during the visit keep the state from before it.
expect(world.save().events).not.toContain(
EVENT_FIRST_TIME_BANKING_WITH_MOM,
);
world.choose(1);
const pages = [...until(world)];
expect(pages.slice(0, 3)).toEqual([
"OK, I'll take care of your money.",
"………………",
"Be careful.",
]);
expect(pages.at(-1)).toBe("Now, go on!");
finish(world);
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
expect(world.state.events.has(EVENT_FIRST_TIME_BANKING_WITH_MOM)).toBe(
true,
);
expect(
world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST),
).toBe(true);
// Until New Bark Town's callback clears it, Mom only cheers.
const cheer = talk(world);
expect(cheer.join(" ")).toMatch(/behind you/i);
finish(world);
});
it("does not start saving when the player declines", () => {
const world = fixture();
talk(world);
const pages = answer(world, 0);
expect(pages[0]).toBe("Be careful.");
finish(world);
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
});
it("deposits money, refusing more than the player holds", () => {
const world = banking();
expect(menu(world, SAVE)).toEqual(["How much do you want to save?"]);
expect(view(world)).toEqual({
labels: ["SAVED", "HELD", "DEPOSIT"],
saved: 2300,
held: 3000,
amount: 0,
cursor: 5,
});
enter(world, 5000);
expect(press(world, "a")).toEqual([
"You don't have that much.",
"How much do you want to save?",
]);
// .StoreMoney starts again from zero on the last digit.
expect(view(world)).toMatchObject({ amount: 0, cursor: 5 });
enter(world, 1000);
expect(press(world, "a")).toEqual(["Your money's safe here! Get going!"]);
finish(world);
expect(world.state.money).toBe(2000);
expect(world.state.momsMoney).toBe(3300);
});
it("withdraws money, refusing more than Mom saved", () => {
const world = banking();
expect(menu(world, GET)).toEqual(["How much do you want to take?"]);
expect(view(world).labels[2]).toBe("WITHDRAW");
enter(world, 2301);
expect(press(world, "a")).toEqual([
"You haven't saved that much.",
"How much do you want to take?",
]);
enter(world, 2300);
expect(press(world, "a")).toEqual(["CHRIS, don't give up!"]);
finish(world);
expect(world.state.money).toBe(5300);
expect(world.state.momsMoney).toBe(0);
});
it("refuses amounts that would pass MAX_MONEY on either side", () => {
const world = banking();
world.state.money = 999_000;
menu(world, GET);
enter(world, 1000);
expect(press(world, "a")).toEqual([
"You can't take that much.",
"How much do you want to take?",
]);
enter(world, 999);
press(world, "a");
finish(world);
expect(world.state.money).toBe(999_999);
expect(world.state.momsMoney).toBe(1301);
world.state.momsMoney = 999_000;
menu(world, SAVE);
enter(world, 1000);
expect(press(world, "a")).toEqual([
"You can't save that much.",
"How much do you want to save?",
]);
press(world, "b");
finish(world);
expect(world.state.momsMoney).toBe(999_000);
});
it("steps digits through GiveMoney and TakeMoney's limits", () => {
const world = banking();
menu(world, SAVE);
// The cursor stops at both ends.
press(world, "right");
expect(view(world).cursor).toBe(5);
press(world, "down");
expect(view(world).amount).toBe(0);
press(world, "up", "up", "left", "up");
expect(view(world)).toMatchObject({ amount: 12, cursor: 4 });
for (let i = 0; i < 6; i++) press(world, "left");
expect(view(world).cursor).toBe(0);
for (let i = 0; i < 10; i++) press(world, "up");
expect(view(world).amount).toBe(999_999);
press(world, "down");
expect(view(world).amount).toBe(899_999);
for (let i = 0; i < 9; i++) press(world, "down");
expect(view(world).amount).toBe(0);
// Start and Select are not read.
world.choose("start");
expect(view(world).amount).toBe(0);
// A with nothing entered cancels.
expect(press(world, "a")).toEqual(["Just do what you can."]);
finish(world);
expect(world.state.momsMoney).toBe(2300);
});
it("cancels with B, from the box and from the menu", () => {
const world = banking();
menu(world, SAVE);
press(world, "up");
expect(press(world, "b")).toEqual(["Just do what you can."]);
finish(world);
expect(world.state.money).toBe(3000);
menu(world, -1);
finish(world);
});
it("starts or stops her savings with CHANGE", () => {
const world = banking();
expect(menu(world, CHANGE)).toEqual(["Do you want to save some money?"]);
expect(answer(world, 1)).toEqual([
"OK, I'll save your money. Trust me!",
"CHRIS, stick with it!",
]);
finish(world);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
menu(world, CHANGE);
expect(answer(world, 0)).toEqual(["Just do what you can."]);
finish(world);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
});
it("asks about Daylight Saving Time when it is not about money", () => {
const world = banking();
talk(world);
expect(answer(world, 0)).toEqual([
"Do you want to switch to Daylight Saving Time?",
]);
expect(answer(world, 1)).toEqual([
"I set the clock forward by one hour.",
"Just do what you can.",
]);
finish(world);
expect(world.state.clock.dst).toBe(true);
expect(world.clock().hours).toBe(13);
talk(world);
expect(answer(world, 0)).toEqual(["Is Daylight Saving Time over?"]);
expect(answer(world, 1)).toEqual([
"I put the clock back one hour.",
"Just do what you can.",
]);
finish(world);
expect(world.state.clock.dst).toBe(false);
expect(world.clock().hours).toBe(12);
// Declining leaves the clock alone.
talk(world);
answer(world, 0);
expect(answer(world, 0)).toEqual(["Just do what you can."]);
finish(world);
expect(world.state.clock).toMatchObject({ dst: false, minuteOffset: 0 });
});
it("will not change the clock within an hour of midnight", () => {
const world = banking("2026-09-25T23:30:00");
talk(world);
expect(answer(world, 0)).toEqual([
"Do you want to adjust your clock",
"for Daylight Saving Time?",
]);
expect(answer(world, 1)).toEqual([
"I lost the in- struction booklet for the POKéGEAR.",
"Come back again in a while.",
"Just do what you can.",
]);
finish(world);
expect(world.state.clock).toMatchObject({ dst: false, minuteOffset: 0 });
const dst = banking("2026-09-26T00:30:00");
dst.state.clock.dst = true;
talk(dst);
expect(answer(dst, 0)).toEqual([
"Do you want to adjust your clock",
"for Daylight Saving Time?",
]);
expect(answer(dst, 0)).toEqual(["Just do what you can."]);
finish(dst);
expect(dst.state.clock.dst).toBe(true);
});
});
+1
View File
@@ -27,6 +27,7 @@ export class MovementAssets extends CrystalAssets {
tileset: 1, tileset: 1,
environment: 1, environment: 1,
paletteMode: 1, paletteMode: 1,
phoneService: 0,
blocks: new Uint8Array(16), blocks: new Uint8Array(16),
warps: [], warps: [],
backgroundEvents: [], backgroundEvents: [],
+3 -2
View File
@@ -205,7 +205,8 @@ describe.skipIf(!existsSync(romPath))("Elm's robbed call with the local ROM", ()
const pages = settle(world); const pages = settle(world);
expect(world.notice, world.diagnostic).toBe(""); expect(world.notice, world.diagnostic).toBe("");
expect(pages[0]).toContain("It's a disaster!"); expect(pages[0]).toContain("It's a disaster!");
expect(pages.at(-1)).toBe("Click!"); // HangUp: "Click!", then Phone_CallEnd's boops.
expect(pages.slice(-2)).toEqual(["Click!", "……"]);
expect(world.state.specialPhoneCall).toBe(0); expect(world.state.specialPhoneCall).toBe(0);
expect(world.state.events.has(ELM_CALLED)).toBe(true); expect(world.state.events.has(ELM_CALLED)).toBe(true);
world.restore(world.save()); world.restore(world.save());
@@ -225,7 +226,7 @@ describe.skipIf(!existsSync(romPath))("Elm's robbed call with the local ROM", ()
expect(saved).toMatchObject({ x: 17, y: 8 }); expect(saved).toMatchObject({ x: 17, y: 8 });
world.restore(saved); world.restore(saved);
expect(world.move("down")).toBe(true); expect(world.move("down")).toBe(true);
expect(settle(world).at(-1)).toBe("Click!"); expect(settle(world).slice(-2)).toEqual(["Click!", "……"]);
expect(world.state.specialPhoneCall).toBe(0); expect(world.state.specialPhoneCall).toBe(0);
}); });
}); });
+340
View File
@@ -0,0 +1,340 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { OW } from "../src/game/world/constants";
import { Pokegear, type PokegearView } from "../src/game/scripts/pokegear";
import {
emptyScriptState,
type ScriptHost,
} from "../src/game/scripts/interpreter";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK_TOWN = "24:4",
PLAYERS_HOUSE_1F = "24:6",
CYNDAQUIL = 155;
const EVENT_GOT_A_POKEMON_FROM_ELM = 26,
EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30,
EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64,
EVENT_DUDE_TALKED_TO_YOU = 65;
const LANDMARK_ROUTE_29 = 2;
describe.skipIf(!existsSync(romPath))("the Pokégear", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** Outdoors in New Bark Town on a Friday at noon, with Mom's and Elm's
* numbers, the Pokégear and its map and phone cards. */
function fixture(map = NEW_BARK_TOWN, x = 13, y = 7) {
const world = new GameWorld(
assets(),
() => new Date("2026-09-25T12:00:00"),
() => 0x42,
);
world.state.party = [
generatePartyMon(world.assets.pokemon, {
species: CYNDAQUIL,
level: 8,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
}),
];
world.state.flags.add(ENGINE_FLAGS.pokegear);
world.state.flags.add(OW.ENGINE_PHONE_CARD);
world.state.flags.add(OW.ENGINE_MAP_CARD);
world.state.phone = [OW.PHONECONTACT_MOM, OW.PHONECONTACT_ELM];
world.state.events.add(EVENT_GOT_A_POKEMON_FROM_ELM);
world.state.scenes[NEW_BARK_TOWN] = 1;
// Arriving through a warp clears the map's temporary events, such as
// the bedroom's EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8 that Mom checks.
for (let event = 0; event < 8; event++) world.state.events.delete(event);
world.restore({
...world.save(),
map,
x,
y,
direction: "down",
objects: [],
});
return world;
}
/** Acknowledge pages up to the next choice; returns the pages seen. */
function until(world: GameWorld) {
const pages: string[] = [];
for (let tick = 0; tick < 2000; tick++) {
const dialogue = world.dialogue;
if (!dialogue) {
if (!world.busy) return pages;
world.tick();
continue;
}
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
if (page && pages.at(-1) !== page) pages.push(page);
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) return pages;
if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
throw new Error("No choice appeared.");
}
/** Open the Pokégear from the start menu. */
function openGear(world: GameWorld) {
expect(world.openStartMenu()).toBe(true);
until(world);
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error("Expected the start menu.");
world.choose(choice.items.indexOf("POKéGEAR"));
return until(world);
}
function view(world: GameWorld): PokegearView {
const choice = world.dialogue?.choice;
if (choice?.kind !== "pokegear") throw new Error(`Expected the Pokégear, got ${choice?.kind}`);
return choice.view;
}
function press(world: GameWorld, button: "a" | "b" | "up" | "down" | "left" | "right") {
if (button === "a") world.interact();
else if (button === "b") world.advanceDialogue();
else world.moveCursor(button);
return until(world);
}
/** B out of the Pokégear and the start menu. */
function close(world: GameWorld) {
for (let i = 0; i < 10 && world.busy; i++) {
if (world.dialogue?.choice) world.advanceDialogue();
until(world);
}
settle(world);
expect(world.diagnostic).toBe("");
expect(world.pokegear).toBeUndefined();
}
it("opens on the clock card and switches cards with left and right", () => {
const world = fixture();
expect(openGear(world)).toEqual(["Press any button to exit."]);
expect(view(world)).toMatchObject({
card: "clock",
cards: ["clock", "map", "phone"],
clock: { day: "FRIDAY", time: "12:00 PM" },
});
expect(world.pokegear).toBe(view(world));
// Left does nothing on the clock card.
press(world, "left");
expect(view(world).card).toBe("clock");
press(world, "right");
expect(view(world)).toMatchObject({
card: "map",
map: { kanto: false, cursor: 1, player: 1, name: "NEW BARK TOWN" },
});
press(world, "up");
expect(view(world).map).toMatchObject({ cursor: LANDMARK_ROUTE_29, name: "ROUTE 29" });
press(world, "down");
press(world, "down");
// The cursor wraps to the last Johto landmark.
expect(view(world).map?.cursor).toBe(OW.JOHTO_LANDMARK_LAST);
press(world, "right");
expect(view(world).phone).toEqual({
rows: ["MOM:", "PROF.ELM:", "----------", "----------"],
cursor: 0,
signal: true,
});
expect(world.dialogue?.pages).toEqual(["Whom do you want\nto call?"]);
// There is no radio card; right stays on the phone.
press(world, "right");
expect(view(world).card).toBe("phone");
press(world, "left");
// The map card keeps its cursor while the Pokégear is open.
expect(view(world).map?.cursor).toBe(OW.JOHTO_LANDMARK_LAST);
press(world, "left");
expect(view(world).card).toBe("clock");
// Any button leaves the clock card for the start menu.
press(world, "a");
expect(world.dialogue?.choice?.kind).toBe("menu");
expect(world.pokegear).toBeUndefined();
close(world);
});
it("follows the game clock while the clock card is open", () => {
let now = new Date("2026-09-25T00:05:00");
const world = new GameWorld(assets(), () => now, () => 0x42);
world.state.flags.add(ENGINE_FLAGS.pokegear);
openGear(world);
expect(view(world).clock).toEqual({ day: "FRIDAY", time: "12:05 AM" });
now = new Date("2026-09-25T09:41:00");
world.tick();
expect(world.pokegear?.clock).toEqual({ day: "FRIDAY", time: " 9:41 AM" });
// Without the map or phone card, right does nothing.
press(world, "right");
expect(view(world)).toMatchObject({ card: "clock", cards: ["clock"] });
close(world);
});
it("scrolls the phone list through all ten slots", () => {
const world = fixture();
openGear(world);
press(world, "right");
press(world, "right");
for (let i = 0; i < 3; i++) press(world, "down");
expect(view(world).phone).toMatchObject({ cursor: 3 });
for (let i = 0; i < 10; i++) press(world, "down");
// The list scrolls until its last four slots show.
expect(view(world).phone).toMatchObject({
cursor: 3,
rows: ["----------", "----------", "----------", "----------"],
});
// A on an empty slot does nothing.
press(world, "a");
expect(view(world).phone?.submenu).toBeUndefined();
for (let i = 0; i < 9; i++) press(world, "up");
expect(view(world).phone).toMatchObject({ cursor: 0, rows: ["MOM:", "PROF.ELM:", "----------", "----------"] });
close(world);
});
it("calls Elm with his callee script", () => {
const world = fixture();
openGear(world);
press(world, "right");
press(world, "right");
press(world, "down");
press(world, "a");
// Mom and Elm cannot be deleted.
expect(view(world).phone).toMatchObject({
chosen: 1,
submenu: { items: ["CALL", "CANCEL"], selected: 0 },
});
// The submenu stops at its ends.
press(world, "down");
press(world, "down");
expect(view(world).phone?.submenu?.selected).toBe(1);
press(world, "a");
expect(view(world).phone?.submenu).toBeUndefined();
press(world, "a");
press(world, "up");
const pages = press(world, "a");
expect(pages[0]).toBe("……");
expect(pages.slice(1, -3)).toEqual([
"Hello, CHRIS?",
"Try not to overdo it.",
"Be sure to heal your POKéMON if they are hurt.",
]);
expect(pages.slice(-3)).toEqual(["Click!", "……", "Whom do you want to call?"]);
expect(view(world).phone?.chosen).toBeUndefined();
close(world);
});
it("tells the player to talk to a contact on the same map", () => {
const world = fixture(PLAYERS_HOUSE_1F, 7, 3);
openGear(world);
press(world, "right");
press(world, "right");
press(world, "a");
const pages = press(world, "a");
expect(pages).toContain("Just go talk to that person!");
close(world);
});
it("calls Mom, whose lecture after the catching lesson starts her savings", () => {
const world = fixture();
world.state.events.add(EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON);
world.state.events.add(EVENT_GAVE_MYSTERY_EGG_TO_ELM);
world.state.events.add(EVENT_DUDE_TALKED_TO_YOU);
world.state.specialPhoneCall = 1;
openGear(world);
press(world, "right");
press(world, "right");
press(world, "a");
const lecture = press(world, "a");
expect(lecture).toContain("Hello?");
expect(world.dialogue?.choice?.kind).toBe("yesno");
// Saves made during the call keep the state from before the menu.
expect(world.save().events).not.toContain(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST);
world.choose(1);
const answer = until(world);
expect(answer.slice(0, 2)).toEqual([
"OK. I'll save your money.",
"CHRIS, keep it up! I'm rooting for you, baby!",
]);
expect(answer.slice(-3)).toEqual(["Click!", "……", "Whom do you want to call?"]);
close(world);
expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST)).toBe(true);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
expect(world.state.specialPhoneCall).toBe(0);
// The next call asks about her savings from where the player is.
world.state.momsMoney = 1234;
openGear(world);
press(world, "right");
press(world, "right");
press(world, "a");
const greeting = press(world, "a");
expect(greeting).toEqual(
expect.arrayContaining([
"Hello?",
"Oh, hi, CHRIS! Working hard?",
"What? You're in NEW BARK TOWN?",
"Come see your MOM sometime!",
]),
);
expect(greeting.slice(-2).join(" ")).toMatch(
/¥1234.*Do you want to keep on saving\?$/,
);
world.choose(0);
expect(until(world)).toContain("OK. I won't save your money.");
close(world);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
});
it("reports no service where the map has none", () => {
const world = fixture();
const state = emptyScriptState();
state.flags.add(OW.ENGINE_PHONE_CARD);
state.phone = [OW.PHONECONTACT_ELM];
const scriptHost: ScriptHost = {
...(world as unknown as { host(id: number): ScriptHost }).host(0),
phoneService: () => 1,
};
const gear = new Pokegear(state, {
reader: world.assets.reader,
data: world.assets.pokemon,
text: () => scriptHost.text(),
scriptHost: () => scriptHost,
landmarkOf: () => 1,
}).run();
const effects: unknown[] = [];
let effect = gear.next();
const answers = ["right", "a", "a", undefined, undefined, "b", "b"];
while (!effect.done) {
effects.push(effect.value);
effect = gear.next(answers.shift());
}
const phone = effects.find(
(e) => (e as { view?: PokegearView }).view?.card === "phone",
) as { view: PokegearView };
expect(phone.view.phone?.signal).toBe(false);
expect(effects).toContainEqual({ kind: "text", pages: ["……"], holdTicks: 60 });
expect(effects).toContainEqual({
kind: "text",
pages: ["You're out of the\nservice area."],
});
});
it("keeps Mom's savings in saves", () => {
const world = fixture();
world.state.momsMoney = 4321;
const save = world.save();
expect(save.momsMoney).toBe(4321);
const { momsMoney: _, ...old } = save;
const migrated = migrateSave(old, () => 0)!;
expect(migrated.momsMoney).toBe(0);
world.restore(migrated);
expect(world.state.momsMoney).toBe(0);
expect(() => world.restore({ ...save, momsMoney: -1 })).toThrow();
});
});
+6 -3
View File
@@ -168,9 +168,12 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
"OPTION", "OPTION",
"EXIT", "EXIT",
]); ]);
// The cursor stays on the last choice. // The cursor stays on the last choice; B leaves the Pokégear's clock.
pick(world, "POKéGEAR"); expect(pick(world, "POKéGEAR")).toEqual(["Press any button to exit."]);
expect(world.notice).toBe("POKéGEAR is not ported yet."); expect(world.dialogue?.choice?.kind).toBe("pokegear");
world.advanceDialogue();
until(world);
expect(world.dialogue?.choice?.kind).toBe("menu");
close(world); close(world);
open(world); open(world);
const reopened = world.dialogue?.choice; const reopened = world.dialogue?.choice;
+17
View File
@@ -105,6 +105,23 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
expect(result.prizeMoney).toBe(128); expect(result.prizeMoney).toBe(128);
expect(result.texts).toContain("CHRIS got ¥128"); expect(result.texts).toContain("CHRIS got ¥128");
}); });
it("sends a quarter of the prize to Mom while she saves some", () => {
const { mon, options } = setup();
mon.item = 0x5b;
const result = finish(new Battle({ ...options, mom: { saving: 1, money: 10 } }));
expect(result.prizeMoney).toBe(96);
expect(result.momMoney).toBe(32);
expect(result.texts).toContain("CHRIS got ¥128 for winning! Sent some to MOM!");
// A full account keeps the whole prize in the wallet.
const full = setup();
const kept = finish(
new Battle({ ...full.options, mom: { saving: 1, money: 999999 } }),
);
expect(kept.prizeMoney).toBe(64);
expect(kept.momMoney).toBeUndefined();
expect(kept.texts).toContain("CHRIS got ¥64 for winning!");
expect(kept.texts).not.toContain("MOM");
});
it("scores exhausted trainer moves before the menu, then uses Struggle", () => { it("scores exhausted trainer moves before the menu, then uses Struggle", () => {
const { options } = setup(); const { options } = setup();
options.trainer!.party[0].pp.fill(0); options.trainer!.party[0].pp.fill(0);
+138 -1
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets"; import { CrystalAssets } from "../src/game/rom/assets";
import { C } from "../src/game/battle/constants"; import { C } from "../src/game/battle/constants";
import { OW } from "../src/game/world/constants";
import { trainerFixtureWorld } from "./trainer-fixture"; import { trainerFixtureWorld } from "./trainer-fixture";
import type { GameWorld } from "../src/game/world/world"; import { migrateSave, type GameWorld } from "../src/game/world/world";
import { DecorationData } from "../src/game/world/decorations";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
@@ -65,6 +67,141 @@ describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", ()
expect(world.state.money).toBe(C.MAX_MONEY); expect(world.state.money).toBe(C.MAX_MONEY);
}); });
it("sends some of the reward to Mom", () => {
const world = fixture();
world.state.flags.add(OW.ENGINE_MOM_SAVING_MONEY);
world.state.momsMoney = 100;
const before = world.save();
world.interact();
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("Sent some to MOM!");
expect(world.state.money).toBe(before.money + 48);
expect(world.state.momsMoney).toBe(116);
expect(world.state.momItemTriggerBalance).toBe(OW.MOM_MONEY);
});
describe("Mom's purchases (MomTriesToBuySomething)", () => {
const SUPER_POTION = 0x11,
MOM_ITEMS_1 = [
[SUPER_POTION, 600],
[0x09, 90], // ANTIDOTE
[0x05, 180], // POKE_BALL
[0x13, 450], // ESCAPE_ROPE
[0x04, 500], // GREAT_BALL
];
/** Beat Joey while Mom saves a quarter (¥16) of his ¥64; returns the
* pages of the call that follows, if any. */
function win(world: GameWorld, momsMoney: number) {
world.state.flags.add(OW.ENGINE_MOM_SAVING_MONEY);
world.state.momsMoney = momsMoney - 16;
world.interact();
const pages: string[] = [];
// The queued call starts at the next PlayerEvents.
let idle = 0;
for (let tick = 0; tick < 10000; tick++) {
const view = world.battle;
if (view) {
if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
else world.tick();
continue;
}
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
if (pages.at(-1) !== page) pages.push(page);
if (dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
} else if (world.busy || idle++ < 2) world.tick();
else {
expect(world.notice, world.diagnostic).toBe("");
return pages;
}
}
throw new Error(`Trainer battle did not settle: ${world.diagnostic}`);
}
it("buys MomItems_2's next item once her savings reach its trigger", () => {
const world = fixture();
const pcItems = world.state.pcItems.map((slot) => ({ ...slot }));
const pages = win(world, 900);
expect(pages).toEqual([
"Hi, CHRIS! How are you?",
"I found a useful item shopping, so",
"I bought it with your money. Sorry!",
"It's in your PC. You'll like it!",
"Click!",
"……",
]);
expect(world.state.momsMoney).toBe(300);
expect(world.state.momItem).toBe(1);
expect(world.state.pcItems).toEqual([...pcItems, { item: SUPER_POTION, quantity: 1 }]);
// MomItems_2's next trigger is ¥4000.
expect(win(world, 3999)).toEqual([]);
expect(world.state.momItem).toBe(1);
const saved = world.save();
expect([saved.momItem, saved.momItemTriggerBalance]).toEqual([1, 4600]);
});
it("buys a random MomItems_1 item when her savings land on a ¥2300 step", () => {
const world = fixture();
world.state.momItem = 1;
const pages = win(world, 2300);
expect(pages[1]).toBe("I found a useful item shopping, so");
const bought = world.state.pcItems.at(-1)!;
const entry = MOM_ITEMS_1.find(([item]) => item === bought.item);
expect(entry).toBeDefined();
expect(world.state.momsMoney).toBe(2300 - entry![1]);
expect(world.state.momItem).toBe(1);
expect(world.state.momItemTriggerBalance).toBe(4600);
});
it("raises the ¥2300 step past her savings without buying anything", () => {
const world = fixture();
world.state.momItem = 1;
expect(win(world, 2400)).toEqual([]);
expect(world.state.momsMoney).toBe(2400);
expect(world.state.momItemTriggerBalance).toBe(4600);
});
it("sends dolls to the player's room", () => {
const world = fixture();
world.state.momItem = 3; // ¥10000: the Charmander doll for ¥1800.
const pages = win(world, 10000);
expect(pages.slice(0, 4)).toEqual([
"Hi, CHRIS! How are you?",
"While shopping today, I saw this adorable doll, so",
"I bought it with your money. Sorry!",
"It's in your room. You'll love it!",
]);
expect(world.state.momsMoney).toBe(8200);
expect(world.state.momItem).toBe(4);
const doll = new DecorationData(world.assets.reader, world.assets.pokemon);
const charmander = Array.from({ length: 52 }, (_, i) => i + 1).find(
(id) => doll.name(id) === "CHARMANDER DOLL",
)!;
expect(world.state.events.has(doll.attribute(charmander).event)).toBe(true);
});
it("buys nothing while the PC is full", () => {
const world = fixture();
world.state.pcItems = Array.from({ length: 50 }, (_, i) => ({ item: 0x20 + i, quantity: 1 }));
expect(win(world, 900)).toEqual([]);
expect(world.state.momsMoney).toBe(900);
expect(world.state.momItem).toBe(0);
});
it("migrates saves made before her purchases", () => {
const world = fixture();
const { momItem: _, momItemTriggerBalance: __, ...old } = world.save();
const migrated = migrateSave(old, () => 0)!;
expect([migrated.momItem, migrated.momItemTriggerBalance]).toEqual([0, OW.MOM_MONEY]);
world.restore(migrated);
expect(() => world.restore({ ...migrated, momItem: 11 })).toThrow();
});
});
it("rolls back reward, experience and sightings if the following script is unsupported", () => { it("rolls back reward, experience and sightings if the following script is unsupported", () => {
const world = fixture([0xff]); const world = fixture([0xff]);
const before = world.save(); const before = world.save();