Add the TM/HM pocket, Egg screens and Violet's remaining scripts
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
The pack's TM/HM pocket lists TMs and HMs with their moves, gives TMs and teaches either through the ABLE/NOT ABLE party menu, the compatibility and "already knows" checks and LearnMove; a TM is used up and makes the Pokémon happier. Eggs show no level or HP in the party menus, get the Egg stats page with its hatching hint, and are skipped by HealParty. Bug Catcher Wade's number registers (VAR_CALLERID and RandomPhoneMon). The playthrough now runs through Falkner's ZEPHYRBADGE, the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. With MAKE_TEST_SAVES it writes a Violet City backup for the manual checks. Validated with 610 unit tests, 157 browser tests (including all 256 badge bytes against the original BadgeStatBoosts), native/reference builds and reference/vendor/release checks. Egg hatching (#29) and trainer calls (#30) remain on their cards. Closes #31 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7909302db1
commit
73022b1505
13 files changed
+803
-31
No files matched your search
+1
-1
@@ -114,7 +114,7 @@ A trainer sighting whose script is refused is not restarted until the player mov
|
|||||||
|
|
||||||
## Next slice
|
## Next slice
|
||||||
|
|
||||||
Route 29, Cherrygrove, Route 30 and Mr. Pokémon's house are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle, Elm's special call, capture and the catching tutorial. NPCs roam. The PC and boxes are ported. Next: party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
|
Route 29, Cherrygrove, Route 30, Mr. Pokémon's house, Route 31, Violet City, Sprout Tower and Dark Cave's Violet entrance are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle, Elm's special call, capture and the catching tutorial. NPCs roam. The PC and boxes are ported. Next: party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
|
||||||
|
|
||||||
## Reflect and Light Screen
|
## Reflect and Light Screen
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -15,6 +15,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
|||||||
| Exploration foundation, rendering, saves and shell | Done |
|
| Exploration foundation, rendering, saves and shell | Done |
|
||||||
| Opening story: New Bark Town to Mr. Pokémon, the rival and the return to Elm | Done |
|
| Opening story: New Bark Town to Mr. Pokémon, the rival and the return to Elm | Done |
|
||||||
| Route 29, Cherrygrove City, Route 30 and their buildings | Done |
|
| Route 29, Cherrygrove City, Route 30 and their buildings | Done |
|
||||||
|
| Route 31, Violet City, Sprout Tower and Falkner's gym | Done (Dark Cave: Violet entrance only) |
|
||||||
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
||||||
| 1. Complete the overworld foundation | Done |
|
| 1. Complete the overworld foundation | Done |
|
||||||
| 2. Opening story and first battle slice | Mostly done |
|
| 2. Opening story and first battle slice | Mostly done |
|
||||||
@@ -320,11 +321,21 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- Scripts can load the forced-shiny, trap, forced-item, Suicune and contest types with `loadvar VAR_BATTLETYPE`.
|
- Scripts can load the forced-shiny, trap, forced-item, Suicune and contest types with `loadvar VAR_BATTLETYPE`.
|
||||||
- Still to come with their maps: National Park's contest event (registration, timer, judging; the contest state is not saved), Raikou's, Entei's and Suicune's roaming, and the Ilex Forest Celebi event with `CheckCaughtCelebi`.
|
- Still to come with their maps: National Park's contest event (registration, timer, judging; the contest state is not saved), Raikou's, Entei's and Suicune's roaming, and the Ilex Forest Celebi event with `CheckCaughtCelebi`.
|
||||||
|
|
||||||
|
## Implemented: Route 31 to Violet City and the first gym
|
||||||
|
|
||||||
|
- Route 31 and its Violet gate, Dark Cave's Violet entrance, Violet City with its Mart, gym, Earl's Pokémon Academy, the nickname speech house, the Pokémon Center and Kyle's house, and Sprout Tower's three floors are open, with their scripts, trainers, items, fruit trees and encounters. Route 32, Route 36, Dark Cave's other entrances and the Pokémon Center's second floor stay closed.
|
||||||
|
- New script commands: `gettrainername`, `askforphonenumber` (AddPhoneNumber keeps room for Mom and Elm; `addcellnum` now reports failure as the original does), `checkpokemail`, `loadmenu`/`_2dmenu`/`closewindow` (Earl's blackboard, shown as a list), `giveegg`, `trade` (NPCTrade with saved `wTradeFlags`), `loadvar VAR_CALLERID` and the `RandomPhoneMon` special. The trade animation is not presented.
|
||||||
|
- Eggs keep their species with an `egg` flag, as `wPartySpecies` holds EGG: the party menu shows no level or HP, the stats screen shows the Egg page with its hatching hint, healing skips them, and `checkpoke` does not count them. Eggs do not hatch yet (board #29).
|
||||||
|
- The pack's TM/HM pocket holds counts by number: TMs and HMs are received, listed with their moves, given (TMs) and taught with ABLE/NOT ABLE, the compatibility and "already knows" checks and LearnMove. A TM is used up and makes the Pokémon happier; an HM stays.
|
||||||
|
- The Johto badges raise the battle Pokémon's stats (BadgeStatBoosts, with the PlainBadge/MineralBadge swap and the Glacier Badge register bug), compared against the unmodified routine for all 256 badge bytes in `tests/reference-badges.spec.ts`.
|
||||||
|
- `tests/playthrough.test.ts` now continues to Route 31 (Mom's call), Sprout Tower (Elder Li's Flash), Falkner (the ZEPHYRBADGE, TM31 and the aide's call), the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. `tests/violet.test.ts` covers the other answers.
|
||||||
|
- Kenya's MAIL is checked as the original does, but it is given at the Route 35 gate, which is closed; its text (a 15-letter first line) is one the port's MAIL model cannot yet store. Wade's calls, rematches and gifts come with the phone (board #30).
|
||||||
|
|
||||||
## Native battles: current state
|
## Native battles: current state
|
||||||
|
|
||||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
||||||
|
|
||||||
This is still not a complete new game. The Pokégear's radio, the Pokédex screen, 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 screen, story progression beyond Violet City and Falkner's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, 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
|
||||||
|
|
||||||
@@ -402,7 +413,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, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Kurt's balls are thrown too, and the battle engine handles every special battle type (board #28 is complete). Next: Route 31 to Violet City (board #31).
|
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, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Kurt's balls are thrown too, and the battle engine handles every special battle type (board #28 is complete). Route 31, Violet City, Sprout Tower and Falkner's gym are open (board #31). Next: the CI run of the synthetic suite (board #45), then the remaining Johto cities (board #32).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -411,7 +422,7 @@ Acceptance: finish the opening quest, including encounters and the rival battle,
|
|||||||
## 3. Complete Crystal
|
## 3. Complete Crystal
|
||||||
|
|
||||||
- [x] Pokémon Center healing, standard Poké Marts and Reflect/Light Screen
|
- [x] Pokémon Center healing, standard Poké Marts and Reflect/Light Screen
|
||||||
- [ ] Remaining battle commands, move and item effects, AI layers and special battles
|
- [x] Remaining battle commands, move and item effects, AI layers and special battles (board #28)
|
||||||
- [x] Evolution by level, happiness, Tyrogue's stats and stones, with Rare Candy and vitamins (trade evolutions need Link)
|
- [x] Evolution by level, happiness, Tyrogue's stats and stones, with Rare Candy and vitamins (trade evolutions need Link)
|
||||||
- [ ] Breeding, eggs and the rest of PC storage
|
- [ ] Breeding, eggs and the rest of PC storage
|
||||||
- [ ] Field moves, gyms, Rocket events, the phone and Pokégear, puzzles, minigames, daily events, roaming Pokémon and the postgame
|
- [ ] Field moves, gyms, Rocket events, the phone and Pokégear, puzzles, minigames, daily events, roaming Pokémon and the postgame
|
||||||
|
|||||||
@@ -1307,3 +1307,38 @@ Left for the map cards: National Park's contest event (GiveParkBalls,
|
|||||||
timer, out-of-balls/time-up scripts, judging, saving the contest state),
|
timer, out-of-balls/time-up scripts, judging, saving the contest state),
|
||||||
Burned Tower's roaming release with UpdateRoamMons/JumpRoamMons and
|
Burned Tower's roaming release with UpdateRoamMons/JumpRoamMons and
|
||||||
roaming encounters, and Ilex Forest's CelebiShrineEvent/CheckCaughtCelebi.
|
roaming encounters, and Ilex Forest's CelebiShrineEvent/CheckCaughtCelebi.
|
||||||
|
|
||||||
|
## Route 31 to Violet City: 2026-10-03
|
||||||
|
|
||||||
|
Card #31 in the same session.
|
||||||
|
|
||||||
|
- Maps: 26:2, 26:11, 3:78, 10:5–10:11, 3:1–3:3 in `NATIVE_MAPS`. The
|
||||||
|
closed-connection test now uses Violet City's south edge (Route 32).
|
||||||
|
- Script commands added: 0x43 gettrainername, 0x97 askforphonenumber,
|
||||||
|
0x30 checkpokemail, 0x4f/0x58/0x50 loadmenu/_2dmenu/closewindow,
|
||||||
|
0x2e giveegg, 0x96 trade; `loadvar VAR_CALLERID` (0x17, wCurCaller)
|
||||||
|
and the RandomPhoneMon special. `addcellnum` returned success where
|
||||||
|
the original returns TRUE on failure; fixed.
|
||||||
|
- Eggs: `PartyMon.egg` with the real species (was species 0xfd). Every
|
||||||
|
former `species === EGG` check uses the flag. HealParty skips Eggs.
|
||||||
|
The stats screen has the Egg page (EggPic, EggPalette). Hatching is
|
||||||
|
not ported (#29).
|
||||||
|
- TM/HM: `Bag.tmsHms` (57 counts, migrated as zeros for old saves),
|
||||||
|
item names TM01–HM07, `tmhmMove`, `canLearnTMHM`; the pack's pocket,
|
||||||
|
USE/GIVE/QUIT submenu and AskTeachTMHM/ChooseMonToLearnTMHM/TeachTMHM.
|
||||||
|
- `ScriptState.tradeFlags` (saved) for NPCTrade.
|
||||||
|
- BadgeStatBoosts at InitBattleMon, the in-battle level-up and
|
||||||
|
CalcPlayerStats; `tests/reference-badges.spec.ts` compares it.
|
||||||
|
- Event flag numbers for tests: read them from ROM scripts. Counting
|
||||||
|
`constants/event_flags.asm` by hand drifted by 0x200 for later flags.
|
||||||
|
- `MAKE_TEST_SAVES=1 npx vitest run tests/playthrough.test.ts` writes
|
||||||
|
`test-saves/violet-city.crystal-save.json` (Cyndaquil L11 at Violet's
|
||||||
|
east gate) for the manual check cards.
|
||||||
|
|
||||||
|
Validation: 610 unit tests passed (3 skipped); without the ROM 143
|
||||||
|
passed, 470 skipped. Browser suite: 157 passed, 19 skipped. Both builds,
|
||||||
|
reference/release/vendor checks and `git diff --check` passed.
|
||||||
|
|
||||||
|
Open boundaries: Egg hatching (#29), trainer calls/rematches (#30), the
|
||||||
|
trade animation, Earl's blackboard as a list rather than a 2×3 grid,
|
||||||
|
Kenya's MAIL text in the MAIL model, Route 32/36 and Dark Cave's far side.
|
||||||
@@ -88,6 +88,7 @@ const names = {
|
|||||||
caughtAskNicknameText: "CaughtAskNicknameText",
|
caughtAskNicknameText: "CaughtAskNicknameText",
|
||||||
stringEgg: "String_Egg",
|
stringEgg: "String_Egg",
|
||||||
tmhmMoves: "TMHMMoves",
|
tmhmMoves: "TMHMMoves",
|
||||||
|
eggPic: "EggPic",
|
||||||
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
|
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
|
||||||
rivalNameString: "NamingScreenJumptable.RivalNameString",
|
rivalNameString: "NamingScreenJumptable.RivalNameString",
|
||||||
rivalDefaultName: "NameRival.DefaultName",
|
rivalDefaultName: "NameRival.DefaultName",
|
||||||
@@ -126,6 +127,7 @@ const names = {
|
|||||||
fadeOutToWhite: "FadeOutToWhite",
|
fadeOutToWhite: "FadeOutToWhite",
|
||||||
healParty: "HealParty",
|
healParty: "HealParty",
|
||||||
healMachineAnim: "HealMachineAnim",
|
healMachineAnim: "HealMachineAnim",
|
||||||
|
randomPhoneMon: "RandomPhoneMon",
|
||||||
checkPokerus: "CheckPokerus",
|
checkPokerus: "CheckPokerus",
|
||||||
stubbedTrainerRankingsHealings: "StubbedTrainerRankings_Healings",
|
stubbedTrainerRankingsHealings: "StubbedTrainerRankings_Healings",
|
||||||
warpToSpawnPoint: "WarpToSpawnPoint",
|
warpToSpawnPoint: "WarpToSpawnPoint",
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export const C = {
|
|||||||
NUM_MONMENU_ITEMS: 8,
|
NUM_MONMENU_ITEMS: 8,
|
||||||
PARTYMENUACTION_CHOOSE_POKEMON: 0,
|
PARTYMENUACTION_CHOOSE_POKEMON: 0,
|
||||||
PARTYMENUACTION_HEALING_ITEM: 1,
|
PARTYMENUACTION_HEALING_ITEM: 1,
|
||||||
|
PARTYMENUACTION_TEACH_TMHM: 3,
|
||||||
PARTYMENUACTION_MOVE: 4,
|
PARTYMENUACTION_MOVE: 4,
|
||||||
PARTYMENUACTION_GIVE_ITEM: 8,
|
PARTYMENUACTION_GIVE_ITEM: 8,
|
||||||
KEY_ITEM_POCKET: 2,
|
KEY_ITEM_POCKET: 2,
|
||||||
@@ -349,6 +350,7 @@ export const C = {
|
|||||||
PARTYMENUACTION_EVO_STONE: 5,
|
PARTYMENUACTION_EVO_STONE: 5,
|
||||||
PARTYMENUTEXT_LEVEL_UP: 0xf8,
|
PARTYMENUTEXT_LEVEL_UP: 0xf8,
|
||||||
HAPPINESS_USEDITEM: 0x02,
|
HAPPINESS_USEDITEM: 0x02,
|
||||||
|
HAPPINESS_LEARNMOVE: 0x05,
|
||||||
// Evolution (constants/pokemon_data_constants.asm)
|
// Evolution (constants/pokemon_data_constants.asm)
|
||||||
EVOLVE_LEVEL: 1,
|
EVOLVE_LEVEL: 1,
|
||||||
EVOLVE_ITEM: 2,
|
EVOLVE_ITEM: 2,
|
||||||
|
|||||||
@@ -399,6 +399,19 @@ export class PokemonData {
|
|||||||
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
|
const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36));
|
||||||
return decompress(r, r.address(bank, r.word(entry + 1)), 0x2000);
|
return decompress(r, r.address(bank, r.word(entry + 1)), 0x2000);
|
||||||
}
|
}
|
||||||
|
/** EggPic (5x5 tiles) in the 7x7 box, as GetFrontpic loads it for EGG,
|
||||||
|
* with PokemonPalettes' EGG entry. */
|
||||||
|
eggPic(): Uint8Array {
|
||||||
|
const tiles = decompress(this.reader, SYMBOLS.eggPic, 0x2000);
|
||||||
|
if (tiles.length < 25 * 16) throw new Error("Picture data is truncated.");
|
||||||
|
const padded = new Uint8Array(49 * 16);
|
||||||
|
for (let column = 0; column < 5; column++)
|
||||||
|
padded.set(tiles.slice(column * 80, (column + 1) * 80), ((column + 1) * 7 + 2) * 16);
|
||||||
|
return padded;
|
||||||
|
}
|
||||||
|
eggPalette(): Palette {
|
||||||
|
return this.middleColors(SYMBOLS.pokemonPalettes + 0xfd * 8);
|
||||||
|
}
|
||||||
/** Front picture tiles, padded into the 7x7 box as PadFrontpic does:
|
/** Front picture tiles, padded into the 7x7 box as PadFrontpic does:
|
||||||
* column-major, bottom-aligned, with a blank left column for 5 and 6. */
|
* column-major, bottom-aligned, with a blank left column for 5 and 6. */
|
||||||
frontPic(species: number): Uint8Array {
|
frontPic(species: number): Uint8Array {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const SYMBOLS = {
|
|||||||
caughtAskNicknameText: 0x4db44,
|
caughtAskNicknameText: 0x4db44,
|
||||||
stringEgg: 0xe035,
|
stringEgg: 0xe035,
|
||||||
tmhmMoves: 0x1167a,
|
tmhmMoves: 0x1167a,
|
||||||
|
eggPic: 0x151caf,
|
||||||
nicknameStrings: 0x11780,
|
nicknameStrings: 0x11780,
|
||||||
rivalNameString: 0x117c3,
|
rivalNameString: 0x117c3,
|
||||||
rivalDefaultName: 0xc2b2,
|
rivalDefaultName: 0xc2b2,
|
||||||
@@ -129,6 +130,7 @@ export const SPECIAL_ROUTINES = {
|
|||||||
reloadSpritesNoPalettes: 0xd91,
|
reloadSpritesNoPalettes: 0xd91,
|
||||||
healParty: 0xc658,
|
healParty: 0xc658,
|
||||||
healMachineAnim: 0x12324,
|
healMachineAnim: 0x12324,
|
||||||
|
randomPhoneMon: 0x2a567,
|
||||||
checkPokerus: 0xc419,
|
checkPokerus: 0xc419,
|
||||||
stubbedTrainerRankingsHealings: 0x1060a2,
|
stubbedTrainerRankingsHealings: 0x1060a2,
|
||||||
warpToSpawnPoint: 0x97c28,
|
warpToSpawnPoint: 0x97c28,
|
||||||
@@ -423,6 +425,12 @@ export const STATS = {
|
|||||||
"LoadGreenPage.Move": 0x4e1a9,
|
"LoadGreenPage.Move": 0x4e1a9,
|
||||||
IDNoString: 0x4e21e,
|
IDNoString: 0x4e21e,
|
||||||
OTString: 0x4e222,
|
OTString: 0x4e222,
|
||||||
|
EggString: 0x4e3c0,
|
||||||
|
FiveQMarkString: 0x4e3c4,
|
||||||
|
EggSoonString: 0x4e3ca,
|
||||||
|
EggCloseString: 0x4e3fd,
|
||||||
|
EggMoreTimeString: 0x4e43d,
|
||||||
|
EggALotMoreTimeString: 0x4e46e,
|
||||||
TypeNames: 0x5097b,
|
TypeNames: 0x5097b,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -512,6 +520,15 @@ export const PC = {
|
|||||||
* outside battle: menu data, strings and texts, keyed by their PRET labels
|
* outside battle: menu data, strings and texts, keyed by their PRET labels
|
||||||
* so that \`npm run check:reference\` can verify each address by name. */
|
* so that \`npm run check:reference\` can verify each address by name. */
|
||||||
export const MENU = {
|
export const MENU = {
|
||||||
|
"Pack.MenuData_1": 0x1012c,
|
||||||
|
"Pack.MenuData_2": 0x10143,
|
||||||
|
BootedTMText: 0x2c8bf,
|
||||||
|
BootedHMText: 0x2c8c4,
|
||||||
|
ContainedMoveText: 0x2c8c9,
|
||||||
|
TMHMNotCompatibleText: 0x2c8ce,
|
||||||
|
"KnowsMove.KnowsMoveText": 0xfa06,
|
||||||
|
"PlacePartyMonTMHMCompatibility.string_able": 0x50221,
|
||||||
|
"PlacePartyMonTMHMCompatibility.string_not_able": 0x50226,
|
||||||
"StartMenu.Items": 0x126eb,
|
"StartMenu.Items": 0x126eb,
|
||||||
PartyMenuStrings: 0x504d2,
|
PartyMenuStrings: 0x504d2,
|
||||||
MonMenuOptionStrings: 0x24caf,
|
MonMenuOptionStrings: 0x24caf,
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ const UP = 1;
|
|||||||
const ROUTE_36 = "10:3";
|
const ROUTE_36 = "10:3";
|
||||||
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
|
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
|
||||||
const CONTACT_LIST_SIZE = 10;
|
const CONTACT_LIST_SIZE = 10;
|
||||||
|
/** VAR_CALLERID: wCurCaller, the phone contact a trainer script names. */
|
||||||
|
const VAR_CALLERID = 0x17;
|
||||||
/** PermanentNumbers: PHONECONTACT_MOM and PHONECONTACT_ELM, which share
|
/** PermanentNumbers: PHONECONTACT_MOM and PHONECONTACT_ELM, which share
|
||||||
* PHONE_MOM's and PHONE_ELM's values. */
|
* PHONE_MOM's and PHONE_ELM's values. */
|
||||||
const PERMANENT_NUMBERS = [1, 4];
|
const PERMANENT_NUMBERS = [1, 4];
|
||||||
@@ -607,6 +609,8 @@ export function trainerWalkBuffer(
|
|||||||
* made to a private draft that the caller commits only after the script ends.
|
* made to a private draft that the caller commits only after the script ends.
|
||||||
* Unknown commands abort the whole interaction. */
|
* Unknown commands abort the whole interaction. */
|
||||||
export class ScriptRun {
|
export class ScriptRun {
|
||||||
|
/** wCurCaller, which loadvar VAR_CALLERID sets for phone scripts. */
|
||||||
|
private curCaller = 0;
|
||||||
readonly state: ScriptState;
|
readonly state: ScriptState;
|
||||||
lastTalked: number;
|
lastTalked: number;
|
||||||
/** Set by sdefer: a script to start after this one returns. */
|
/** Set by sdefer: a script to start after this one returns. */
|
||||||
@@ -1134,6 +1138,10 @@ export class ScriptRun {
|
|||||||
this.setPlayerState(setting, offset, opcode);
|
this.setPlayerState(setting, offset, opcode);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (variable === VAR_CALLERID) {
|
||||||
|
this.curCaller = setting;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (variable !== 3 || !SCRIPT_BATTLE_TYPES.includes(setting))
|
if (variable !== 3 || !SCRIPT_BATTLE_TYPES.includes(setting))
|
||||||
throw new UnsupportedScript(
|
throw new UnsupportedScript(
|
||||||
offset,
|
offset,
|
||||||
@@ -2555,6 +2563,8 @@ export class ScriptRun {
|
|||||||
}
|
}
|
||||||
case 0x08:
|
case 0x08:
|
||||||
return this.state.playerState;
|
return this.state.playerState;
|
||||||
|
case VAR_CALLERID:
|
||||||
|
return this.curCaller;
|
||||||
case 0x0f:
|
case 0x0f:
|
||||||
return this.host.environment();
|
return this.host.environment();
|
||||||
case 0x05:
|
case 0x05:
|
||||||
@@ -2647,9 +2657,25 @@ export class ScriptRun {
|
|||||||
// Graphics, palettes and sound only: wPartyCount/wScriptVar select
|
// Graphics, palettes and sound only: wPartyCount/wScriptVar select
|
||||||
// an animation sequence but nothing persists past it.
|
// an animation sequence but nothing persists past it.
|
||||||
return value;
|
return value;
|
||||||
|
case SPECIAL_ROUTINES.randomPhoneMon: {
|
||||||
|
// RandomPhoneMon: GetCallerLocation's trainer, then a party slot
|
||||||
|
// drawn as Random & 7 until it exists, named into wStringBuffer4.
|
||||||
|
const entry = PC.PhoneContacts + this.curCaller * PHONE_CONTACT_SIZE;
|
||||||
|
const party = new TrainerData(r).trainer(r.byte(entry), r.byte(entry + 1)).party;
|
||||||
|
let slot: number;
|
||||||
|
let tries = 0;
|
||||||
|
do {
|
||||||
|
if (++tries > 4096) throw new Error("Random draw limit exceeded.");
|
||||||
|
slot = this.host.random() & 7;
|
||||||
|
} while (slot >= party.length);
|
||||||
|
this.strings[RAM.stringBuffer4] = this.host.data.speciesName(party[slot].species);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
case SPECIAL_ROUTINES.healParty:
|
case SPECIAL_ROUTINES.healParty:
|
||||||
// HealPartyMon: clear status, restore HP and every move's PP.
|
// HealPartyMon for each party member but Eggs: clear status,
|
||||||
|
// restore HP and every move's PP.
|
||||||
for (const mon of this.state.party) {
|
for (const mon of this.state.party) {
|
||||||
|
if (mon.egg) continue;
|
||||||
mon.status = 0;
|
mon.status = 0;
|
||||||
mon.hp = mon.stats[0];
|
mon.hp = mon.stats[0];
|
||||||
mon.pp = mon.moves.map((move, slot) =>
|
mon.pp = mon.moves.map((move, slot) =>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
restoreHealth,
|
restoreHealth,
|
||||||
restoredPP,
|
restoredPP,
|
||||||
} from "../battle/battle";
|
} from "../battle/battle";
|
||||||
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
import { receiveItem, removeFromSlot, tossItem, type ItemSlot } from "../party/bag";
|
||||||
|
import { HM01, NUM_TMS, tmhmItem, tmhmNumber } from "../rom/pokemon";
|
||||||
import { calcStats, maxPP, partyMonDetails, type PartyMon } from "../party/pokemon";
|
import { calcStats, maxPP, partyMonDetails, type PartyMon } from "../party/pokemon";
|
||||||
import type { GameTime } from "../world/game-time";
|
import type { GameTime } from "../world/game-time";
|
||||||
import { PartyEvolution, evoStoneCompatible } from "./evolution";
|
import { PartyEvolution, evoStoneCompatible } from "./evolution";
|
||||||
@@ -75,6 +76,7 @@ const {
|
|||||||
PARTYMENUACTION_HEALING_ITEM,
|
PARTYMENUACTION_HEALING_ITEM,
|
||||||
PARTYMENUACTION_MOVE,
|
PARTYMENUACTION_MOVE,
|
||||||
PARTYMENUACTION_GIVE_ITEM,
|
PARTYMENUACTION_GIVE_ITEM,
|
||||||
|
PARTYMENUACTION_TEACH_TMHM,
|
||||||
KEY_ITEM_POCKET,
|
KEY_ITEM_POCKET,
|
||||||
TM_HM_POCKET,
|
TM_HM_POCKET,
|
||||||
PP_UP,
|
PP_UP,
|
||||||
@@ -729,6 +731,10 @@ export class StartMenuRun {
|
|||||||
yield { kind: "mail", mail: { ...mail } };
|
yield { kind: "mail", mail: { ...mail } };
|
||||||
}
|
}
|
||||||
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
|
private giveItemToPokemon(pocket: PocketIndex, index: number, item: number) {
|
||||||
|
if (pocket === TM_HM_POCKET) {
|
||||||
|
if (!tossItem(this.state.bag, item, 1)) throw new Error("The item is not in the pack.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const list = this.pocketSlots(pocket);
|
const list = this.pocketSlots(pocket);
|
||||||
if (!list || !removeFromSlot(list, index, item, 1))
|
if (!list || !removeFromSlot(list, index, item, 1))
|
||||||
throw new Error("The item is not in the pack.");
|
throw new Error("The item is not in the pack.");
|
||||||
@@ -830,10 +836,25 @@ export class StartMenuRun {
|
|||||||
const bag = this.state.bag;
|
const bag = this.state.bag;
|
||||||
return pocket === 0 ? bag.items : pocket === 1 ? bag.balls : undefined;
|
return pocket === 0 ? bag.items : pocket === 1 ? bag.balls : undefined;
|
||||||
}
|
}
|
||||||
/** A pocket's list: quantities for items and balls, none for key items,
|
/** A pocket's list: quantities for items and balls, none for key items;
|
||||||
* and no TMs or HMs yet. */
|
* TMs (with a count) and HMs by number, with the move they teach
|
||||||
|
* (TMHM_DisplayPocketItems). */
|
||||||
private pocketEntries(pocket: PocketIndex): MartEntry[] {
|
private pocketEntries(pocket: PocketIndex): MartEntry[] {
|
||||||
const bag = this.state.bag;
|
const bag = this.state.bag;
|
||||||
|
if (pocket === TM_HM_POCKET)
|
||||||
|
return bag.tmsHms.flatMap((count, index) => {
|
||||||
|
if (!count) return [];
|
||||||
|
const number = index + 1,
|
||||||
|
item = tmhmItem(number);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
item,
|
||||||
|
name: `${this.data.itemName(item)} ${this.data.moveName(this.data.tmhmMove(number))}`,
|
||||||
|
price: 0,
|
||||||
|
...(number <= NUM_TMS && { owned: count }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
const slots: ItemSlot[] =
|
const slots: ItemSlot[] =
|
||||||
pocket === KEY_ITEM_POCKET
|
pocket === KEY_ITEM_POCKET
|
||||||
? bag.keyItems.map((item) => ({ item, quantity: 1 }))
|
? bag.keyItems.map((item) => ({ item, quantity: 1 }))
|
||||||
@@ -934,11 +955,75 @@ export class StartMenuRun {
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
const chosen = yield* this.choosePackItem(true);
|
const chosen = yield* this.choosePackItem(true);
|
||||||
if (!chosen) return false;
|
if (!chosen) return false;
|
||||||
if (yield* this.itemSubmenu(chosen.pocket, chosen.index, chosen.item))
|
if (chosen.pocket === TM_HM_POCKET) yield* this.tmhmSubmenu(chosen.item);
|
||||||
|
else if (yield* this.itemSubmenu(chosen.pocket, chosen.index, chosen.item))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pack's .TMHMPocketMenu: USE, GIVE and QUIT for a TM that can be
|
||||||
|
* tossed; USE and QUIT for an HM. */
|
||||||
|
private *tmhmSubmenu(item: number): Gen {
|
||||||
|
const tossable = !this.data.itemUnsellable(item);
|
||||||
|
const actions = tossable ? ["use", "give", "quit"] : ["use", "quit"];
|
||||||
|
const choice = yield* this.menu(
|
||||||
|
[this.data.itemName(item)],
|
||||||
|
this.stringList(tossable ? MENU["Pack.MenuData_2"] + 2 : MENU["Pack.MenuData_1"] + 2, actions.length),
|
||||||
|
);
|
||||||
|
const action = choice < 0 ? "quit" : actions[choice];
|
||||||
|
if (action === "give") return yield* this.giveItem(TM_HM_POCKET, 0, item);
|
||||||
|
if (action === "use") yield* this.teachTMHM(item);
|
||||||
|
}
|
||||||
|
/** AskTeachTMHM, ChooseMonToLearnTMHM and TeachTMHM: the move goes to a
|
||||||
|
* compatible Pokémon that does not know it, through LearnMove. A TM is
|
||||||
|
* used up and makes the Pokémon happier; an HM stays. */
|
||||||
|
private *teachTMHM(item: number): Gen {
|
||||||
|
const data = this.data,
|
||||||
|
number = tmhmNumber(item),
|
||||||
|
move = data.tmhmMove(number),
|
||||||
|
name = data.moveName(move);
|
||||||
|
// GetMoveName and CopyName1.
|
||||||
|
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = name;
|
||||||
|
yield* this.print(item >= HM01 ? MENU.BootedHMText : MENU.BootedTMText);
|
||||||
|
if (!(yield* this.yesNo(MENU.ContainedMoveText))) return;
|
||||||
|
const party = this.state.party;
|
||||||
|
let slot: number;
|
||||||
|
for (;;) {
|
||||||
|
// PlacePartyMonTMHMCompatibility: ABLE or NOT ABLE; nothing for Eggs.
|
||||||
|
const strings = MENU.PartyMenuStrings + PARTYMENUACTION_TEACH_TMHM * 2;
|
||||||
|
const prompt = this.string(this.reader.near(strings, this.reader.bank(strings)));
|
||||||
|
slot = yield* this.menu(
|
||||||
|
[prompt],
|
||||||
|
party.map((mon) => mon.nickname),
|
||||||
|
party.map((mon) =>
|
||||||
|
mon.egg
|
||||||
|
? ""
|
||||||
|
: this.string(
|
||||||
|
data.canLearnTMHM(mon.species, move)
|
||||||
|
? MENU["PlacePartyMonTMHMCompatibility.string_able"]
|
||||||
|
: MENU["PlacePartyMonTMHMCompatibility.string_not_able"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (slot < 0) return;
|
||||||
|
// An Egg plays SFX_WRONG and the menu stays.
|
||||||
|
if (!party[slot].egg) break;
|
||||||
|
}
|
||||||
|
const mon = party[slot];
|
||||||
|
this.strings[RAM.stringBuffer1] = mon.nickname;
|
||||||
|
this.strings[RAM.stringBuffer2] = name;
|
||||||
|
if (!data.canLearnTMHM(mon.species, move))
|
||||||
|
return yield* this.print(MENU.TMHMNotCompatibleText);
|
||||||
|
if (mon.moves.includes(move)) return yield* this.print(MENU["KnowsMove.KnowsMoveText"]);
|
||||||
|
const evolution = this.evolution();
|
||||||
|
evolution.strings[RAM.stringBuffer1] = evolution.strings[RAM.stringBuffer2] = name;
|
||||||
|
if (!(yield* evolution.learnMove(slot, move))) return;
|
||||||
|
if (item >= HM01) return;
|
||||||
|
this.changeHappiness(slot, C.HAPPINESS_LEARNMOVE);
|
||||||
|
// ConsumeTM
|
||||||
|
tossItem(this.state.bag, item, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/** Pack.ItemBallsKey_LoadSubmenu: the submenu the item's permissions and
|
/** Pack.ItemBallsKey_LoadSubmenu: the submenu the item's permissions and
|
||||||
* field menu select. */
|
* field menu select. */
|
||||||
private *itemSubmenu(
|
private *itemSubmenu(
|
||||||
|
|||||||
+41
-3
@@ -928,9 +928,12 @@ function showBattle(world: GameWorld) {
|
|||||||
showWorld(world);
|
showWorld(world);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${
|
// An Egg's row has no level or HP.
|
||||||
mon.status ? ` ${statusName(mon.status)}` : ""
|
detail: mon.egg
|
||||||
}`,
|
? ""
|
||||||
|
: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${
|
||||||
|
mon.status ? ` ${statusName(mon.status)}` : ""
|
||||||
|
}`,
|
||||||
wide: true,
|
wide: true,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1225,6 +1228,41 @@ function showStatsScreen(world: GameWorld) {
|
|||||||
const shiny = PokemonData.shiny(mon.dvs);
|
const shiny = PokemonData.shiny(mon.dvs);
|
||||||
const text = (label: Parameters<PokemonData["statsString"]>[0]) =>
|
const text = (label: Parameters<PokemonData["statsString"]>[0]) =>
|
||||||
data.statsString(label);
|
data.statsString(label);
|
||||||
|
if (mon.egg) {
|
||||||
|
// EggStatsScreen: the Egg's picture, "EGG", unknown ID and OT, and a
|
||||||
|
// hint picked by the egg cycles left.
|
||||||
|
drawPic(get("stats-pic"), data.eggPic(), 7, data.eggPalette());
|
||||||
|
get("stats-pic").setAttribute("aria-label", text("EggString"));
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = text("EggString");
|
||||||
|
const unknown = text("FiveQMarkString");
|
||||||
|
const row = (...parts: (string | Node)[]) => {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.append(...parts);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
get("stats-header").replaceChildren(
|
||||||
|
row(strong),
|
||||||
|
row(`${text("IDNoString")}${unknown}`),
|
||||||
|
row(`${text("OTString")}${unknown}`),
|
||||||
|
);
|
||||||
|
const cycles = mon.happiness;
|
||||||
|
get("stats-page").replaceChildren(
|
||||||
|
row(
|
||||||
|
text(
|
||||||
|
cycles < 6
|
||||||
|
? "EggSoonString"
|
||||||
|
: cycles < 0xb
|
||||||
|
? "EggCloseString"
|
||||||
|
: cycles < 0x29
|
||||||
|
? "EggMoreTimeString"
|
||||||
|
: "EggALotMoreTimeString",
|
||||||
|
).replace(/\n/g, " "),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
panel.dataset.page = "egg";
|
||||||
|
return;
|
||||||
|
}
|
||||||
drawPic(
|
drawPic(
|
||||||
get("stats-pic"),
|
get("stats-pic"),
|
||||||
data.frontPic(mon.species),
|
data.frontPic(mon.species),
|
||||||
|
|||||||
+292
-17
@@ -1,5 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
|
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { encodeSave } from "../src/storage/saves";
|
||||||
|
import { CRYSTAL_ROM } from "../src/rom/validate";
|
||||||
import { CrystalAssets } from "../src/game/rom/assets";
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
import { GameWorld } from "../src/game/world/world";
|
import { GameWorld } from "../src/game/world/world";
|
||||||
import type { Direction } from "../src/game/world/types";
|
import type { Direction } from "../src/game/world/types";
|
||||||
@@ -23,13 +25,21 @@ const NEW_BARK = "24:4",
|
|||||||
VIOLET_GYM = "10:7",
|
VIOLET_GYM = "10:7",
|
||||||
SPROUT_1F = "3:1",
|
SPROUT_1F = "3:1",
|
||||||
SPROUT_2F = "3:2",
|
SPROUT_2F = "3:2",
|
||||||
SPROUT_3F = "3:3";
|
SPROUT_3F = "3:3",
|
||||||
|
KYLES_HOUSE = "10:11";
|
||||||
|
const DARK_CAVE = "3:78",
|
||||||
|
EARLS_ACADEMY = "10:8";
|
||||||
|
const PHONE_BUG_CATCHER_WADE = 16;
|
||||||
|
const FLASH = 148,
|
||||||
|
HM05 = 0xf7;
|
||||||
|
const TOGEPI = 175,
|
||||||
|
BELLSPROUT = 69,
|
||||||
|
ONIX = 95;
|
||||||
const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
|
const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
|
||||||
EVENT_GOT_HM05_FLASH = 0x14,
|
EVENT_GOT_HM05_FLASH = 0x14,
|
||||||
EVENT_BEAT_FALKNER = 0x2bd,
|
EVENT_BEAT_FALKNER = 0x4bd,
|
||||||
EVENT_GOT_TM31_MUD_SLAP = 0x8,
|
EVENT_GOT_TM31_MUD_SLAP = 0x8,
|
||||||
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
|
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
|
||||||
EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 0x48c,
|
|
||||||
ENGINE_ZEPHYRBADGE = 0x1b;
|
ENGINE_ZEPHYRBADGE = 0x1b;
|
||||||
|
|
||||||
/** The acceptance run for PLAN section 2: a new game played from the
|
/** The acceptance run for PLAN section 2: a new game played from the
|
||||||
@@ -53,8 +63,20 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
let world: GameWorld;
|
let world: GameWorld;
|
||||||
const log: string[] = [];
|
const log: string[] = [];
|
||||||
|
|
||||||
|
/** A wild species to throw Poké Balls at instead of fighting. */
|
||||||
|
let wanted = 0;
|
||||||
|
/** The usable move with the most power, or the first with PP. */
|
||||||
|
const strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => {
|
||||||
|
let best = -1;
|
||||||
|
mon.moves.forEach((move, i) => {
|
||||||
|
if (!move || !(mon.pp[i] & 63)) return;
|
||||||
|
if (best < 0 || assets!.pokemon.move(move).power > assets!.pokemon.move(mon.moves[best]).power)
|
||||||
|
best = i;
|
||||||
|
});
|
||||||
|
return Math.max(0, best);
|
||||||
|
};
|
||||||
/** Run scripts and battles until the player is free again. Battles use
|
/** Run scripts and battles until the player is free again. Battles use
|
||||||
* the first move with PP; a wild battle below half HP is run from, as
|
* the strongest move with PP; a wild battle below half HP is run from, as
|
||||||
* a cautious player would. New moves are learned when there is room,
|
* a cautious player would. New moves are learned when there is room,
|
||||||
* and other questions take the queued answers or their defaults. */
|
* and other questions take the queued answers or their defaults. */
|
||||||
const play = (answers: (number | string)[] = []) => {
|
const play = (answers: (number | string)[] = []) => {
|
||||||
@@ -68,17 +90,27 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
if (prompt?.kind === "action") {
|
if (prompt?.kind === "action") {
|
||||||
const { engine } = battle,
|
const { engine } = battle,
|
||||||
mon = engine.player.mon;
|
mon = engine.player.mon;
|
||||||
if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
const ball = world.state.bag.balls.findIndex((slot) => slot.item === C.POKE_BALL);
|
||||||
|
if (
|
||||||
|
engine.battleMode === C.WILD_BATTLE &&
|
||||||
|
engine.enemy.mon.species === wanted &&
|
||||||
|
!world.state.party.some((member) => member.species === wanted) &&
|
||||||
|
ball >= 0
|
||||||
|
)
|
||||||
|
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
|
||||||
|
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||||
world.battleAction({ action: "run" });
|
world.battleAction({ action: "run" });
|
||||||
else
|
else
|
||||||
world.battleAction({
|
world.battleAction({ action: "fight", move: strongest(mon) });
|
||||||
action: "fight",
|
|
||||||
move: Math.max(
|
|
||||||
0,
|
|
||||||
mon.pp.findIndex((pp, i) => mon.moves[i] && pp & 63),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
||||||
|
// SHIFT's party menu: keep the Pokémon out (or send the first
|
||||||
|
// that can fight when one must go).
|
||||||
|
else if (prompt?.kind === "party")
|
||||||
|
world.battleChoose(
|
||||||
|
prompt.cancel
|
||||||
|
? -1
|
||||||
|
: world.state.party.findIndex((mon, i) => i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0),
|
||||||
|
);
|
||||||
else if (prompt?.kind === "forget") world.battleChoose(4);
|
else if (prompt?.kind === "forget") world.battleChoose(4);
|
||||||
else if (prompt?.kind === "name") world.battleName("");
|
else if (prompt?.kind === "name") world.battleName("");
|
||||||
else if (prompt?.kind === "stats" || battle.message?.prompt)
|
else if (prompt?.kind === "stats" || battle.message?.prompt)
|
||||||
@@ -213,6 +245,22 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const lead = () => world.state.party[0];
|
const lead = () => world.state.party[0];
|
||||||
|
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
|
||||||
|
* tests/human-test-saves.test.ts writes them (kept out of git). */
|
||||||
|
const writeTestSave = (name: string) => {
|
||||||
|
if (!process.env.MAKE_TEST_SAVES) return;
|
||||||
|
mkdirSync("test-saves", { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
`test-saves/${name}.crystal-save.json`,
|
||||||
|
encodeSave({
|
||||||
|
version: 1,
|
||||||
|
romHash: CRYSTAL_ROM.sha1,
|
||||||
|
coreId: "crystal-native-world-v1",
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
state: new TextEncoder().encode(JSON.stringify(world.save())),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
/** PokecenterNurseScript from in front of the counter, then back out. */
|
/** PokecenterNurseScript from in front of the counter, then back out. */
|
||||||
const heal = (center: string, city: string) => {
|
const heal = (center: string, city: string) => {
|
||||||
enter(center, "up");
|
enter(center, "up");
|
||||||
@@ -268,8 +316,22 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
};
|
};
|
||||||
/** Find a way through warps among `maps` to `target`, as a player
|
/** Find a way through warps among `maps` to `target`, as a player
|
||||||
* trying staircases would: depth first, backing out of dead ends. */
|
* trying staircases would: depth first, backing out of dead ends. */
|
||||||
const route = (target: string, maps: string[], depth = 6): boolean => {
|
const route = (
|
||||||
if (world.mapId === target) return true;
|
target: string,
|
||||||
|
maps: string[],
|
||||||
|
depth = 6,
|
||||||
|
goal?: { x: number; y: number },
|
||||||
|
): boolean => {
|
||||||
|
const reachable = () => {
|
||||||
|
if (!goal) return true;
|
||||||
|
try {
|
||||||
|
findPath(world, goal.x, goal.y);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (world.mapId === target && reachable()) return true;
|
||||||
if (!depth) return false;
|
if (!depth) return false;
|
||||||
const here = world.save();
|
const here = world.save();
|
||||||
for (const warp of world.map.warps) {
|
for (const warp of world.map.warps) {
|
||||||
@@ -281,11 +343,43 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
}
|
}
|
||||||
const from = world.mapId;
|
const from = world.mapId;
|
||||||
walk(warp.x, warp.y);
|
walk(warp.x, warp.y);
|
||||||
if (world.mapId !== from && route(target, maps, depth - 1)) return true;
|
if (world.mapId !== from && route(target, maps, depth - 1, goal)) return true;
|
||||||
world.restore(here);
|
world.restore(here);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
/** Advance to the next menu choice, keeping its pages in the log. */
|
||||||
|
const toChoice = () => {
|
||||||
|
for (let tick = 0; tick < 2000; tick++) {
|
||||||
|
const dialogue = world.dialogue;
|
||||||
|
if (dialogue) {
|
||||||
|
const page = dialogue.pages[dialogue.index];
|
||||||
|
if (log.at(-1) !== page) log.push(page);
|
||||||
|
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) return dialogue.choice;
|
||||||
|
if (dialogue.holdTicks !== undefined) world.tick();
|
||||||
|
else world.advanceDialogue();
|
||||||
|
} else world.tick();
|
||||||
|
}
|
||||||
|
throw new Error(`No choice appeared: ${world.diagnostic}`);
|
||||||
|
};
|
||||||
|
/** Choose a menu item by its label (or a pattern). */
|
||||||
|
const choose = (label: string | RegExp) => {
|
||||||
|
const choice = toChoice();
|
||||||
|
if (choice.kind !== "menu") throw new Error(`Expected a menu, got ${choice.kind}`);
|
||||||
|
const index = choice.items.findIndex((item) => (typeof label === "string" ? item === label : label.test(item)));
|
||||||
|
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
||||||
|
world.choose(index);
|
||||||
|
};
|
||||||
|
/** Choose an item in the pack. */
|
||||||
|
const choosePack = (item: number) => {
|
||||||
|
const choice = toChoice();
|
||||||
|
if (choice.kind !== "pack") throw new Error(`Expected the pack, got ${choice.kind}`);
|
||||||
|
for (const [pocket, { entries }] of choice.pockets.entries()) {
|
||||||
|
const index = entries.findIndex((entry) => entry.item === item);
|
||||||
|
if (index >= 0) return world.choosePackEntry(pocket, index);
|
||||||
|
}
|
||||||
|
throw new Error(`Item ${item} is not in the pack.`);
|
||||||
|
};
|
||||||
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
|
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
|
||||||
const restInViolet = () => {
|
const restInViolet = () => {
|
||||||
exit(ROUTE_31_GATE, "left");
|
exit(ROUTE_31_GATE, "left");
|
||||||
@@ -438,7 +532,8 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
exit(VIOLET, "left");
|
exit(VIOLET, "left");
|
||||||
expect(world.state.flags.size).toBeGreaterThan(0);
|
expect(world.state.flags.size).toBeGreaterThan(0);
|
||||||
reload();
|
reload();
|
||||||
});
|
writeTestSave("violet-city");
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
it("trains on Route 31 and earns Flash from Elder Li in Sprout Tower", () => {
|
it("trains on Route 31 and earns Flash from Elder Li in Sprout Tower", () => {
|
||||||
traceFailures();
|
traceFailures();
|
||||||
@@ -458,6 +553,186 @@ describe.skipIf(!existsSync(romPath))(
|
|||||||
expect(pages).toContain("FLASH");
|
expect(pages).toContain("FLASH");
|
||||||
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
|
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
|
||||||
reload();
|
reload();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it("beats Falkner for the ZEPHYRBADGE and TM31, then hears from Elm's aide", () => {
|
||||||
|
traceFailures();
|
||||||
|
expect(route(SPROUT_1F, [SPROUT_1F, SPROUT_2F, SPROUT_3F], 6, { x: 9, y: 15 })).toBe(true);
|
||||||
|
exit(VIOLET);
|
||||||
|
heal(VIOLET_CENTER, VIOLET);
|
||||||
|
enter(VIOLET_GYM, "up");
|
||||||
|
walk(5, 2);
|
||||||
|
world.move("up"); // Falkner: this only turns the player
|
||||||
|
world.interact();
|
||||||
|
play();
|
||||||
|
expect(world.state.events.has(EVENT_BEAT_FALKNER), log.join("|")).toBe(true);
|
||||||
|
expect(world.state.flags.has(ENGINE_ZEPHYRBADGE)).toBe(true);
|
||||||
|
expect(world.state.events.has(EVENT_GOT_TM31_MUD_SLAP)).toBe(true);
|
||||||
|
expect(world.state.bag.tmsHms[30]).toBe(1); // TM31
|
||||||
|
reload();
|
||||||
|
exit(VIOLET);
|
||||||
|
// SPECIALCALL_ASSISTANT rings on a step: the aide waits in the
|
||||||
|
// Pokémon Center with the Egg.
|
||||||
|
enter(VIOLET_CENTER, "up");
|
||||||
|
expect(log.join("\n").replace(/\n/g, " ")).toContain("POKéMON CENTER");
|
||||||
|
// The aide's object flag (EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER).
|
||||||
|
const aide = world.assets.map(VIOLET_CENTER).objects[4];
|
||||||
|
expect(world.state.events.has(aide.eventFlag)).toBe(false);
|
||||||
|
reload();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it("takes the Egg from Elm's aide", () => {
|
||||||
|
traceFailures();
|
||||||
|
walk(4, 4);
|
||||||
|
world.move("up"); // the aide: this only turns the player
|
||||||
|
world.interact();
|
||||||
|
play([1]);
|
||||||
|
expect(world.state.events.has(EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE)).toBe(true);
|
||||||
|
const egg = world.state.party.at(-1)!;
|
||||||
|
expect(egg).toMatchObject({
|
||||||
|
species: TOGEPI,
|
||||||
|
egg: true,
|
||||||
|
nickname: "EGG",
|
||||||
|
hp: 0,
|
||||||
|
happiness: world.assets.pokemon.baseData(TOGEPI).eggCycles,
|
||||||
|
});
|
||||||
|
// An Egg is neither seen nor caught.
|
||||||
|
expect(world.state.pokedex.caught.has(TOGEPI)).toBe(false);
|
||||||
|
reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("catches a Bellsprout on Route 31 and trades it to Kyle for Onix", () => {
|
||||||
|
traceFailures();
|
||||||
|
exit(VIOLET);
|
||||||
|
exit(ROUTE_31_GATE, "right");
|
||||||
|
exit(ROUTE_31, "right");
|
||||||
|
wanted = BELLSPROUT;
|
||||||
|
try {
|
||||||
|
for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++) {
|
||||||
|
if (!world.state.bag.balls.length) break;
|
||||||
|
train(lead().level + 1, restInViolet);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wanted = 0;
|
||||||
|
}
|
||||||
|
const bellsprout = world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||||
|
expect(bellsprout, log.slice(-20).join("|")).toBeGreaterThan(0);
|
||||||
|
exit(ROUTE_31_GATE, "left");
|
||||||
|
exit(VIOLET, "left");
|
||||||
|
enter(KYLES_HOUSE, "up");
|
||||||
|
const kyle = world.objects().find((object) => object.sprite === world.assets.map(KYLES_HOUSE).objects[1].sprite && object.id === world.assets.map(KYLES_HOUSE).objects[1].id)!;
|
||||||
|
walk(kyle.x - 1, kyle.y);
|
||||||
|
world.move("right");
|
||||||
|
world.interact();
|
||||||
|
play([1, bellsprout]);
|
||||||
|
const onix = world.state.party.at(-1)!;
|
||||||
|
expect(onix).toMatchObject({ species: ONIX, nickname: "ROCKY", ot: "KYLE", otId: 48926, dvs: 0x9666 });
|
||||||
|
expect(world.state.party.some((mon) => mon.species === BELLSPROUT && !mon.egg)).toBe(false);
|
||||||
|
expect(world.state.tradeFlags).toBe(1 << 1);
|
||||||
|
expect(world.state.pokedex.caught.has(ONIX)).toBe(true);
|
||||||
|
reload();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it("teaches HM05 to a new Bellsprout and lights up Dark Cave with FLASH", () => {
|
||||||
|
traceFailures();
|
||||||
|
exit(VIOLET);
|
||||||
|
exit(ROUTE_31_GATE, "right");
|
||||||
|
exit(ROUTE_31, "right");
|
||||||
|
wanted = BELLSPROUT;
|
||||||
|
try {
|
||||||
|
for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++)
|
||||||
|
train(lead().level + 1, restInViolet);
|
||||||
|
} finally {
|
||||||
|
wanted = 0;
|
||||||
|
}
|
||||||
|
const slot = world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||||
|
expect(slot).toBeGreaterThan(0);
|
||||||
|
const nickname = world.state.party[slot].nickname;
|
||||||
|
// PACK, the TM/HM pocket's HM05, USE, YES, then the Bellsprout.
|
||||||
|
expect(world.openStartMenu()).toBe(true);
|
||||||
|
choose("PACK");
|
||||||
|
choosePack(HM05);
|
||||||
|
choose("USE");
|
||||||
|
expect(toChoice().kind).toBe("yesno");
|
||||||
|
world.choose(1);
|
||||||
|
const party = toChoice();
|
||||||
|
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
|
||||||
|
world.choose(slot);
|
||||||
|
if (world.state.party[slot].moves.every(Boolean)) {
|
||||||
|
// A full move list asks which move to forget: the first one.
|
||||||
|
expect(toChoice().kind).toBe("yesno");
|
||||||
|
world.choose(1);
|
||||||
|
toChoice();
|
||||||
|
world.choose(0);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++) toChoice();
|
||||||
|
expect(log.join("\n").replace(/\n/g, " ")).toContain("learned FLASH!");
|
||||||
|
// The menus work on a draft that is kept once they close.
|
||||||
|
for (let i = 0; i < 10 && world.busy; i++) {
|
||||||
|
if (world.dialogue?.choice) world.advanceDialogue();
|
||||||
|
else world.tick();
|
||||||
|
}
|
||||||
|
play();
|
||||||
|
expect(world.state.party[slot].moves).toContain(FLASH);
|
||||||
|
expect(world.state.bag.tmsHms[50 + 4]).toBe(1); // an HM is kept
|
||||||
|
// Dark Cave is dark until FLASH, which the ZEPHYRBADGE allows.
|
||||||
|
enter(DARK_CAVE, "up");
|
||||||
|
expect(world.state.flash).toBe(false);
|
||||||
|
expect(world.openStartMenu()).toBe(true);
|
||||||
|
choose("POKéMON");
|
||||||
|
choose(nickname);
|
||||||
|
choose("FLASH");
|
||||||
|
play();
|
||||||
|
expect(world.state.flash, log.slice(-6).join("|")).toBe(true);
|
||||||
|
reload();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it("registers Bug Catcher Wade's number and reads Earl's blackboard", () => {
|
||||||
|
traceFailures();
|
||||||
|
exit(ROUTE_31);
|
||||||
|
// Talk to Wade wherever he stands (he walks up to battle on sight).
|
||||||
|
// A trainer who spots the player on the way battles and moves, so
|
||||||
|
// the approach is planned again.
|
||||||
|
const talkTo = (id: number) => {
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++)
|
||||||
|
for (const [dx, dy, face] of [[-1, 0, "right"], [1, 0, "left"], [0, 1, "up"], [0, -1, "down"]] as const) {
|
||||||
|
const target = world.objects().find((object) => object.id === id)!;
|
||||||
|
const x = target.x + dx,
|
||||||
|
y = target.y + dy;
|
||||||
|
try {
|
||||||
|
if (!(world.x === x && world.y === y)) walk(x, y);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const now = world.objects().find((object) => object.id === id)!;
|
||||||
|
if (now.x !== target.x || now.y !== target.y) break;
|
||||||
|
world.move(face);
|
||||||
|
world.interact();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`No way to talk to object ${id}`);
|
||||||
|
};
|
||||||
|
talkTo(world.map.objects[2].id);
|
||||||
|
play([1]);
|
||||||
|
const pages = log.join("\n").replace(/\n/g, " ");
|
||||||
|
expect(pages).toContain("BUG CATCHER WADE");
|
||||||
|
expect(world.state.phone).toContain(PHONE_BUG_CATCHER_WADE);
|
||||||
|
reload();
|
||||||
|
restInViolet();
|
||||||
|
exit(ROUTE_31_GATE, "left");
|
||||||
|
exit(VIOLET, "left");
|
||||||
|
enter(EARLS_ACADEMY, "up");
|
||||||
|
walk(3, 1);
|
||||||
|
world.move("up");
|
||||||
|
world.interact();
|
||||||
|
choose("PSN");
|
||||||
|
toChoice();
|
||||||
|
expect(log.join("\n").replace(/\n/g, " ")).toContain("steadily loses HP");
|
||||||
|
// B leaves the blackboard.
|
||||||
|
world.choose(-1);
|
||||||
|
play();
|
||||||
|
expect(world.busy).toBe(false);
|
||||||
|
reload();
|
||||||
|
}, 120_000);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -607,15 +607,17 @@ test("native switch texts, healing, PP, happiness and CalcPlayerStats match the
|
|||||||
text: () => ({}) as never,
|
text: () => ({}) as never,
|
||||||
});
|
});
|
||||||
const internals = battle as unknown as {
|
const internals = battle as unknown as {
|
||||||
options: { party: { species: number; happiness: number }[] };
|
options: { party: { species: number; happiness: number; egg?: true }[] };
|
||||||
partyMenuCursor: number;
|
partyMenuCursor: number;
|
||||||
turn: number;
|
turn: number;
|
||||||
changeHappiness(index: number, action: number): void;
|
changeHappiness(index: number, action: number): void;
|
||||||
calcPlayerStats(): void;
|
calcPlayerStats(): void;
|
||||||
};
|
};
|
||||||
happinessCases.forEach((input, index) => {
|
happinessCases.forEach((input, index) => {
|
||||||
|
// An Egg is EGG in wPartySpecies and an egg flag here.
|
||||||
internals.options.party = Array.from({ length: 6 }, (_, slot) => ({
|
internals.options.party = Array.from({ length: 6 }, (_, slot) => ({
|
||||||
species: input.egg && slot === input.mon ? C.EGG : 155,
|
species: 155,
|
||||||
|
...(input.egg && slot === input.mon && { egg: true }),
|
||||||
happiness: input.happiness,
|
happiness: input.happiness,
|
||||||
}));
|
}));
|
||||||
internals.partyMenuCursor = input.cursor;
|
internals.partyMenuCursor = input.cursor;
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { RomReader } from "../src/game/rom/reader";
|
||||||
|
import { ScriptRun, emptyScriptState, type ScriptState } from "../src/game/scripts/interpreter";
|
||||||
|
import { defaultTextContext } from "../src/game/scripts/text";
|
||||||
|
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
|
||||||
|
import { tmhmItem, tmhmNumber } from "../src/game/rom/pokemon";
|
||||||
|
import type { GameWorld } from "../src/game/world/world";
|
||||||
|
import { battleFixtureWorld } from "./battle-fixture";
|
||||||
|
import { settle, walkTo } from "./world-driver";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
|
||||||
|
describe("askforphonenumber and addcellnum", () => {
|
||||||
|
const host = {
|
||||||
|
mapId: "1:1",
|
||||||
|
lastTalked: 0,
|
||||||
|
sceneOwner: () => undefined,
|
||||||
|
timeMask: () => 2,
|
||||||
|
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||||||
|
clockDay: () => 0,
|
||||||
|
text: () => defaultTextContext("KAWA"),
|
||||||
|
};
|
||||||
|
/** Run `command contact`, then setevent 1, 2 or 3 for a result of 0, 1
|
||||||
|
* or 2; returns the event set and the phone list. */
|
||||||
|
const run = (command: number, phone: number[], answer?: number) => {
|
||||||
|
const bytes = Uint8Array.from([
|
||||||
|
command, 16,
|
||||||
|
0x06, 0, 15, 0, // ifequal 0, $0f
|
||||||
|
0x06, 1, 19, 0, // ifequal 1, $13
|
||||||
|
0x06, 2, 23, 0, // ifequal 2, $17
|
||||||
|
0x91,
|
||||||
|
0x33, 1, 0, 0x91,
|
||||||
|
0x33, 2, 0, 0x91,
|
||||||
|
0x33, 3, 0, 0x91,
|
||||||
|
]);
|
||||||
|
const state: ScriptState = { ...emptyScriptState(), phone: [...phone] };
|
||||||
|
const script = new ScriptRun(new RomReader(bytes), 0, state, host);
|
||||||
|
let effect = script.resume();
|
||||||
|
if (effect?.kind === "yesno") effect = script.resume(answer);
|
||||||
|
expect(effect).toBeUndefined();
|
||||||
|
return { result: [...script.state.events][0] - 1, phone: script.state.phone };
|
||||||
|
};
|
||||||
|
const others = [5, 6, 7, 11, 12, 13, 14, 15, 17];
|
||||||
|
it("registers a contact, keeping room for Mom and Elm until they are listed", () => {
|
||||||
|
expect(run(0x97, [], 1)).toEqual({ result: 0, phone: [16] });
|
||||||
|
// Eight others leave no room: two slots wait for Mom (1) and Elm (4).
|
||||||
|
expect(run(0x97, others.slice(0, 8), 1).result).toBe(1);
|
||||||
|
// With Mom and Elm listed, the tenth slot is free.
|
||||||
|
expect(run(0x97, [1, 4, ...others.slice(0, 7)], 1)).toMatchObject({ result: 0 });
|
||||||
|
// An already listed contact cannot be added again: also "full".
|
||||||
|
expect(run(0x97, [16], 1).result).toBe(1);
|
||||||
|
expect(run(0x97, [], 0)).toEqual({ result: 2, phone: [] });
|
||||||
|
});
|
||||||
|
it("addcellnum answers TRUE when the contact could not be added", () => {
|
||||||
|
expect(run(0x28, [])).toEqual({ result: 0, phone: [16] });
|
||||||
|
expect(run(0x28, [16])).toMatchObject({ result: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("Violet City scripts with the local ROM", () => {
|
||||||
|
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||||
|
const ROUTE_31 = "26:2",
|
||||||
|
KYLES_HOUSE = "10:11",
|
||||||
|
SPEAROW = 21,
|
||||||
|
BELLSPROUT = 69,
|
||||||
|
CYNDAQUIL = 155,
|
||||||
|
FLOWER_MAIL = 0x9e;
|
||||||
|
const mon = (world: GameWorld, species: number, level = 10): PartyMon =>
|
||||||
|
generatePartyMon(world.assets.pokemon, {
|
||||||
|
species, level, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0,
|
||||||
|
});
|
||||||
|
/** Advance to the next choice (or until the world is idle). */
|
||||||
|
const toChoice = (world: GameWorld) => {
|
||||||
|
const pages: string[] = [];
|
||||||
|
for (let tick = 0; tick < 3000; 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: ${JSON.stringify(world.dialogue)} ${world.diagnostic}`);
|
||||||
|
};
|
||||||
|
const place = (world: GameWorld, map: string, x: number, y: number) => {
|
||||||
|
world.restore({ ...world.save(), map, x, y });
|
||||||
|
settle(world);
|
||||||
|
};
|
||||||
|
/** From where the player stands, talk to the object facing `direction`,
|
||||||
|
* answering in order. */
|
||||||
|
const talk = (world: GameWorld, direction: "up" | "right", answers: number[]) => {
|
||||||
|
world.move(direction);
|
||||||
|
world.interact();
|
||||||
|
const pages = toChoice(world);
|
||||||
|
for (const answer of answers) {
|
||||||
|
world.choose(answer);
|
||||||
|
pages.push(...toChoice(world));
|
||||||
|
}
|
||||||
|
settle(world);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
return pages.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Route 31's sleepy man and Kenya's MAIL", () => {
|
||||||
|
const setup = (options: { message?: string; party?: (world: GameWorld) => PartyMon[] } = {}) => {
|
||||||
|
const world = battleFixtureWorld(assets());
|
||||||
|
// Kenya's MAIL text is not one the mail entry could write, so it is
|
||||||
|
// set after the save is restored.
|
||||||
|
place(world, ROUTE_31, 17, 8);
|
||||||
|
// Route31MailRecipientScript: faceplayer, opentext, checkevent
|
||||||
|
// EVENT_GOT_TM50_NIGHTMARE, iftrue, checkevent EVENT_GOT_KENYA.
|
||||||
|
const script = world.assets.map(ROUTE_31).objects[0].script,
|
||||||
|
r = world.assets.reader;
|
||||||
|
const gotKenya = r.word(script + 9);
|
||||||
|
world.state.events.add(gotKenya);
|
||||||
|
const spearow = mon(world, SPEAROW);
|
||||||
|
spearow.item = FLOWER_MAIL;
|
||||||
|
spearow.mail = {
|
||||||
|
message: options.message ?? "DARK CAVE leads\nto another road",
|
||||||
|
author: "RANDY",
|
||||||
|
authorId: 1,
|
||||||
|
species: SPEAROW,
|
||||||
|
type: FLOWER_MAIL,
|
||||||
|
};
|
||||||
|
world.state.party.splice(0, world.state.party.length, ...(options.party?.(world) ?? [mon(world, CYNDAQUIL), spearow]));
|
||||||
|
if (!options.party) world.state.party[1] = spearow;
|
||||||
|
return { world, spearow };
|
||||||
|
};
|
||||||
|
it("takes Kenya and gives TM50", () => {
|
||||||
|
const { world } = setup();
|
||||||
|
const pages = talk(world, "up", [1]);
|
||||||
|
expect(pages).toContain("NIGHTMARE");
|
||||||
|
expect(world.state.party.map((each) => each.species)).toEqual([CYNDAQUIL]);
|
||||||
|
expect(world.state.bag.tmsHms[tmhmNumber(tmhmItem(50)) - 1]).toBe(1);
|
||||||
|
});
|
||||||
|
it("keeps the Pokémon for the wrong MAIL, no MAIL, B or the last that can fight", () => {
|
||||||
|
const wrong = setup({ message: "HELLO" });
|
||||||
|
expect(talk(wrong.world, "up", [1])).toContain("This MAIL isn't for me.");
|
||||||
|
expect(wrong.world.state.party).toHaveLength(2);
|
||||||
|
const none = setup();
|
||||||
|
talk(none.world, "up", [0]);
|
||||||
|
expect(none.world.state.party).toHaveLength(2);
|
||||||
|
expect(none.world.state.bag.tmsHms.every((count) => count === 0)).toBe(true);
|
||||||
|
const refused = setup();
|
||||||
|
talk(refused.world, "up", [-1]);
|
||||||
|
expect(refused.world.state.party).toHaveLength(2);
|
||||||
|
const last = setup({
|
||||||
|
party: (world) => {
|
||||||
|
const fainted = mon(world, CYNDAQUIL);
|
||||||
|
fainted.hp = 0;
|
||||||
|
return [fainted];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
last.world.state.party.push(last.spearow);
|
||||||
|
talk(last.world, "up", [1]);
|
||||||
|
expect(last.world.state.party).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kyle's trade", () => {
|
||||||
|
/** Walk up beside Kyle, who walks up and down, and talk to him. */
|
||||||
|
const kyle = (world: GameWorld, answers: number[]) => {
|
||||||
|
const id = world.assets.map(KYLES_HOUSE).objects[1].id;
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
const object = world.objects().find((each) => each.id === id)!;
|
||||||
|
try {
|
||||||
|
walkTo(world, object.x - 1, object.y);
|
||||||
|
} catch {
|
||||||
|
for (let tick = 0; tick < 16; tick++) world.tick();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const now = world.objects().find((each) => each.id === id)!;
|
||||||
|
if (now.x === world.x + 1 && now.y === world.y) return talk(world, "right", answers);
|
||||||
|
}
|
||||||
|
throw new Error("Kyle kept walking away.");
|
||||||
|
};
|
||||||
|
it("turns down the wrong Pokémon and a refusal, then trades once", () => {
|
||||||
|
const world = battleFixtureWorld(assets());
|
||||||
|
world.state.party.push(mon(world, BELLSPROUT, 7));
|
||||||
|
place(world, KYLES_HOUSE, 3, 6);
|
||||||
|
const wrong = kyle(world, [1, 0]);
|
||||||
|
expect(wrong).toContain("BELLSPROUT");
|
||||||
|
expect(world.state.tradeFlags).toBe(0);
|
||||||
|
kyle(world, [0]);
|
||||||
|
expect(world.state.tradeFlags).toBe(0);
|
||||||
|
kyle(world, [1, 1]);
|
||||||
|
expect(world.state.tradeFlags).toBe(1 << 1);
|
||||||
|
expect(world.state.party[1]).toMatchObject({ species: 95, level: 7, nickname: "ROCKY", ot: "KYLE" });
|
||||||
|
// Afterwards Kyle only remembers the trade.
|
||||||
|
const save = JSON.stringify(world.save().party);
|
||||||
|
kyle(world, []);
|
||||||
|
expect(JSON.stringify(world.save().party)).toBe(save);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an Egg unhealed at the Pokémon Center", () => {
|
||||||
|
const world = battleFixtureWorld(assets());
|
||||||
|
const egg = { ...mon(world, 175, 5), egg: true as const, nickname: "EGG", hp: 0 };
|
||||||
|
world.state.party.push(egg);
|
||||||
|
world.state.party[0].hp = 1;
|
||||||
|
// Violet's nurse stands behind the counter at 3,1.
|
||||||
|
place(world, "10:10", 3, 3);
|
||||||
|
talk(world, "up", [1]);
|
||||||
|
expect(world.state.party[0].hp).toBe(world.state.party[0].stats[0]);
|
||||||
|
expect(world.state.party[1]).toMatchObject({ egg: true, hp: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teaches a TM from the pack, refusing incompatible Pokémon and known moves", () => {
|
||||||
|
const world = battleFixtureWorld(assets());
|
||||||
|
const data = world.assets.pokemon,
|
||||||
|
move = data.tmhmMove(31),
|
||||||
|
CATERPIE = 10;
|
||||||
|
world.state.bag.tmsHms[30] = 2; // TM31
|
||||||
|
const lead = world.state.party[0];
|
||||||
|
expect(data.canLearnTMHM(lead.species, move)).toBe(true);
|
||||||
|
expect(data.canLearnTMHM(CATERPIE, move)).toBe(false);
|
||||||
|
world.state.party.push(mon(world, CATERPIE));
|
||||||
|
const choose = (label: string) => {
|
||||||
|
toChoice(world);
|
||||||
|
const choice = world.dialogue!.choice!;
|
||||||
|
if (choice.kind !== "menu") throw new Error(`Expected a menu, got ${choice.kind}`);
|
||||||
|
const index = choice.items.indexOf(label);
|
||||||
|
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
||||||
|
world.choose(index);
|
||||||
|
};
|
||||||
|
/** PACK, TM31, USE, YES, then the party slot; back to the pack. */
|
||||||
|
const teach = (slot: number) => {
|
||||||
|
const pages = toChoice(world);
|
||||||
|
const choice = world.dialogue!.choice!;
|
||||||
|
if (choice.kind !== "pack") throw new Error(`Expected the pack, got ${choice.kind}`);
|
||||||
|
world.choosePackEntry(3, 0);
|
||||||
|
choose("USE");
|
||||||
|
pages.push(...toChoice(world));
|
||||||
|
world.choose(1);
|
||||||
|
pages.push(...toChoice(world));
|
||||||
|
const party = world.dialogue!.choice!;
|
||||||
|
expect(party.kind === "menu" && party.details).toEqual(["ABLE", "NOT ABLE"]);
|
||||||
|
world.choose(slot);
|
||||||
|
pages.push(...toChoice(world));
|
||||||
|
return pages.join(" ");
|
||||||
|
};
|
||||||
|
const happiness = lead.happiness;
|
||||||
|
expect(world.openStartMenu()).toBe(true);
|
||||||
|
choose("PACK");
|
||||||
|
expect(teach(1)).toContain("is not compatible with CATERPIE");
|
||||||
|
expect(teach(0)).toContain("learned MUD-SLAP!");
|
||||||
|
expect(teach(0)).toContain("knows MUD-SLAP.");
|
||||||
|
for (let i = 0; i < 10 && world.busy; i++) {
|
||||||
|
if (world.dialogue?.choice) world.advanceDialogue();
|
||||||
|
toChoice(world);
|
||||||
|
}
|
||||||
|
settle(world);
|
||||||
|
expect(world.state.party[0].moves).toContain(move);
|
||||||
|
expect(world.state.party[0].happiness).toBeGreaterThan(happiness);
|
||||||
|
expect(world.state.bag.tmsHms[30]).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user