Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)
Enable Route 35, Route 36, Route 37, the National Park and its gates, Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's room and both floors of the Burned Tower. The story runs from Floria, the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the rival in the Burned Tower and the beasts' release to Ecruteak Gym, Morty, the FOGBADGE and TM30. Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons, CheckEncounterRoamMon) with their state in the save; only Continue moves them on restore, so a failed import's rollback keeps them in place. Port the Bug-Catching Contest: registration on contest days, Park Balls, contestants, the timer, QUIT, the catch-and-swap prompt, judging from the ROM's contestant table, prizes and the saved contest state. Port the Game Corner's SlotMachine and CardFlip on a new realtime tile screen that advances two frames per tick with buffered buttons: reel strips and scrolling, bias tables, the seven streak, Golem and Chansey, payouts, the Card Flip board, cursor and navigation. Their tilemaps are checked against the ROM's own data. Also port getnum, a plain warp that continues its script on the new map, and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the Burned Tower rival) is no longer sighted once its own flag is set, as the original's read of bit 1 of wNumKeyItems gives with the usual key items. The playthrough walks through the National Park and continues to Morty; its driver chooses moves by type matchup, buys at Marts and uses Super Potions and Awakenings in trainer battles. New tests cover roaming, the contest, the Game Corner and Ecruteak's side content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0930b780fc
commit
2640fa2725
31 files changed
+4743
-91
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
||||
|
||||
**Current milestone: from the opening to Goldenrod City.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||
**Current milestone: from the opening to Ecruteak City and the Fog Badge.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||
|
||||
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
||||
|
||||
@@ -18,9 +18,11 @@ The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Poké
|
||||
|
||||
Route 31 leads to Violet City, Sprout Tower and Falkner's gym. South of Violet, Route 32, Union Cave and Route 33 lead to Azalea Town: follow Kurt into the Slowpoke Well to stop Team Rocket, beat Bugsy, then herd the Farfetch'd in Ilex Forest for HM01 Cut and reach Route 34. Kurt makes balls from apricorns overnight. In the Ruins of Alph, solve the chamber puzzles to unlock wild Unown, which appear in their letters and fill the Unown Dex.
|
||||
|
||||
In Goldenrod, get the Bicycle, challenge Whitney for the Plain Badge and answer the Radio Tower quiz for the radio card. Visit the department store and Underground shops, the Name Rater and haircut brothers, check the Lucky Number show, or exchange Game Corner coins for prizes. The Route 35 gate gives you Kenya with Randy's MAIL.
|
||||
In Goldenrod, get the Bicycle, challenge Whitney for the Plain Badge and answer the Radio Tower quiz for the radio card. Visit the department store and Underground shops, the Name Rater and haircut brothers, check the Lucky Number show, or exchange Game Corner coins for prizes. The Route 35 gate gives you Kenya with Randy's MAIL. In the Game Corner, play the slot machines and Card Flip for coins.
|
||||
|
||||
Game Corner minigames, the routes beyond Goldenrod, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||
North of Goldenrod, Route 35 leads through the National Park to Route 36. On Tuesdays, Thursdays and Saturdays, enter the Bug-Catching Contest: catch the best bug you can with the Park Balls before time runs out, then wait for the judging and the prizes. Water the odd tree on Route 36 with the SquirtBottle from Goldenrod's flower shop, then follow Route 37 to Ecruteak City. Beat the five Kimono Girls in the Dance Theater for HM03 Surf. In the Burned Tower, your rival challenges you before the floor gives way and three legendary Pokémon flee; Raikou and Entei then roam Johto's routes, where you may meet them in the grass. Morty is then back in his gym for the Fog Badge.
|
||||
|
||||
The routes beyond Ecruteak, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
||||
| `game/scripts/ruins.ts` | `engine/events/unown_walls.asm`, `data/events/unown_walls.asm` | `DisplayUnownWords` as an overlay screen with the chamber tileset's Unown letters, `HoOhChamber` and `OmanyteChamber`. |
|
||||
| `game/scripts/goldenrod.ts`, `game/world/day-timers.ts` | `engine/events/name_rater.asm`, `haircut.asm`, `lucky_number.asm`, `engine/overworld/time.asm`, `engine/menus/intro_menu.asm` | Name Rater ownership checks, ROM haircut happiness tables, Lucky Number party and box matching with first-winner ties, saved number and Friday countdown with 140-day rollover. |
|
||||
| Goldenrod shops, gifts and prizes | `engine/items/mart.asm`, `engine/pokemon/move_mon.asm` (`GivePoke`), `engine/events/specials.asm` (`GameCornerPrizeMonCheckDex`), `engine/pokedex/new_pokedex_entry.asm` | All five mart types, gift OT/nickname and MAIL, full-party gifts to the box, coin commands and balance boxes, and first-owned prize registration with two A/B pages. Boxed gifts approximate an unspecified stale enemy held item as zero. SlotMachine and CardFlip remain refused. |
|
||||
| Route 35, Route 36, Route 37, Ecruteak and Burned Tower | `home/trainers.asm` (`_CheckTrainerBattle`), `mobile/mobile_41.asm` (`CheckMobileAdapterStatusSpecial`), `engine/overworld/overworld.asm` (`LoadUsedSpritesGFX`, `RefreshSprites`) | These maps' scripts (Floria and the Sudowoodo Squirtbottle battle, Arthur, Sunny, the Itemfinder house, the Kimono Girls and HM03 Surf, Eusine and the rival's Burned Tower cutscene) run on already-ported primitives. `checkTrainers` skips a map trainer whose script is `ObjectEvent` (the Burned Tower rival) once its own flag is set: the original reads `ObjectEvent`'s bytes as flag $f251, bit 1 of `wNumKeyItems` through echo RAM, which is set with the usual two or three key items; the other case is unverified. `CheckMobileAdapterStatusSpecial` is unused outside the Japanese release and always clears its result; `LoadUsedSpritesGFX`/`RefreshSprites` are VRAM sprite tile loading, presentation only. |
|
||||
| `game/world/roam-mons.ts` | `engine/overworld/wildmons.asm` (`InitRoamMons`, `CheckEncounterRoamMon`, `UpdateRoamMons`, `JumpRoamMons`), `data/wild/roammon_maps.asm` | Raikou and Entei roam from `InitRoamMons`; Suicune's slot is kept but not started. `UpdateRoamMons` runs on connections, door and pit warps and after one wild battle in sixteen; `JumpRoamMons` on Fly, Teleport and Continue. A roaming beast can take a grass encounter, and the battle writes its HP back or clears the slot. |
|
||||
| Bug-Catching Contest | `engine/events/bug_contest/*.asm`, `maps/Route35NationalParkGate.asm`, `maps/Route36NationalParkGate.asm`, `engine/menus/start_menu.asm` | Registration, Park Balls, contestant selection, the timer on the game clock, out of balls, QUIT, judging from `BugContestantPointers` with ties to the earlier entry, prizes and `CheckPartyFullAfterContest`. The contest state saves. The status box is a text page above the start menu. No sound. |
|
||||
| `game/scripts/slot-machine.ts`, `game/scripts/card-flip.ts` | `engine/games/slot_machine.asm`, `engine/games/card_flip.asm`, `engine/gfx/cgb_layouts.asm` | Both on a realtime tile screen (two frames per tick, buffered buttons). Slots: reel strips, spin rates and scrolling, bias tables with the lucky machine and seven streak, reel 3's Golem and Chansey replay with their sprites, payouts. Card Flip: deck, 12×11 board, cursor sprites and `ChooseCard_HandleJoypad`, paytable. Tilemaps are checked against the ROM. Golem's walk uses two ROM frames with flips; no sound. |
|
||||
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
||||
|
||||
Supported event opcodes are explicit in the interpreter switch. Audio commands consume their operands, but sound sequencing is pending; those opcodes do not imply complete audiovisual behavior. `pause` holds the current text or waits the original number of overworld ticks.
|
||||
|
||||
+14
-2
@@ -16,7 +16,8 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
||||
| Opening story: New Bark Town to Mr. Pokémon, the rival and the return to Elm | Done |
|
||||
| Route 29, Cherrygrove City, Route 30 and their buildings | Done |
|
||||
| Route 31, Violet City, Sprout Tower and Falkner's gym | Done (Dark Cave: Violet entrance only) |
|
||||
| Goldenrod City, Whitney and local shops and gifts | Done (Game Corner minigames pending) |
|
||||
| Goldenrod City, Whitney and local shops and gifts | Done |
|
||||
| Routes 35–37, the National Park, Ecruteak and Morty | Done (Tin Tower closed) |
|
||||
| Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open |
|
||||
| 1. Complete the overworld foundation | Done |
|
||||
| 2. Opening story and first battle slice | Mostly done |
|
||||
@@ -388,11 +389,22 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- Local validation (2026-10-06): 676 unit tests passed, 3 skipped (scratch tests excluded); without a ROM, 163 passed and 516 skipped. Both builds, reference/profile checks, vendor integrity and release isolation passed. Browser checks: 157 passed, 21 skipped, 12 known WebKit menu failures (#70). CI and physical-device acceptance were not run.
|
||||
- Remaining: SlotMachine and CardFlip, radio broadcasts and rematches (#30), breeding (#29), the later Rocket takeover, routes beyond the Route 35 gate, mobile/link features and audio. This increment does not complete #32 or start #33. Boxed gifts still approximate the original stale enemy held-item byte as zero when no item is specified.
|
||||
|
||||
## Implemented: Routes 35–37, the National Park and Ecruteak to the Fog Badge (board #32, increment C)
|
||||
|
||||
- Route 35, Route 36, Route 37, the National Park and both of its gates, Ecruteak City with its Pokémon Center, Mart, gym, Dance Theater and houses, the Tin Tower entrance, the Wise Trio's room and the Burned Tower's two floors are open. Tin Tower itself and the Route 38 and Route 42 gates stay closed.
|
||||
- The story runs: Floria and the SquirtBottle, Sudowoodo, the Kimono Girls and HM03 Surf, Eusine and the rival in the Burned Tower, the beasts' release (`ReleaseTheBeasts`, `InitRoamMons`), Ecruteak Gym opening, its trainers and Morty, the FOGBADGE and TM30.
|
||||
- Roaming Pokémon (`game/world/roam-mons.ts`): `InitRoamMons`, `UpdateRoamMons` on connections, door and pit warps and one wild battle in sixteen, `JumpRoamMons` on Fly, Teleport and Continue, and `CheckEncounterRoamMon` in the grass. Suicune's slot is kept but not started, as the ROM leaves it. Saves gain `roamMons` and `roamMonsMap`. A rollback restore (a failed import) keeps the beasts in place; only Continue moves them.
|
||||
- The Bug-Catching Contest: the gates on contest days, `GiveParkBalls`, `SelectRandomBugContestContestants`, `ContestDropOffMons`/`ContestReturnMons`, the timer on the game clock, running out of balls, QUIT in the start menu, the catch-and-swap prompt, `BugContestJudging` with the ROM's contestant table and prizes, and `CheckPartyFullAfterContest`. The contest state saves. The start menu's balls and catch status shows as a text page above the menu.
|
||||
- The Game Corner: `SlotMachine` (reels with their spin rates and scrolling, the normal and lucky bias tables, the seven streak, Golem and Chansey with the egg, payouts, the 9999-coin cap) and `CardFlip` (deck, board, cursor and group navigation, paytable). They run on a realtime tile screen, which advances two frames per world tick with the buffered button. The Card Flip board and slot frame are checked tile by tile against the ROM's own tilemaps.
|
||||
- Engine fixes found on the way: `getnum`, a plain `warp` that continues its script on the new map, `PokemonNames`' EGG entry, and a map trainer whose script is `ObjectEvent` (the Burned Tower rival) is no longer sighted once its own flag is set. In the original, its "beaten" check reads `ObjectEvent`'s bytes as flag $f251, bit 1 of `wNumKeyItems` through echo RAM, which is set with the usual two or three key items; the other case has not been checked against the original.
|
||||
- Tests: `tests/roaming.test.ts`, `tests/bug-contest.test.ts`, `tests/game-corner.test.ts` and `tests/ecruteak.test.ts`. The playthrough continues through the National Park to Floria, Sudowoodo, the Kimono Girls, the Burned Tower and Morty; its driver now chooses moves by type matchup, buys at Marts, and uses Super Potions and Awakenings in trainer battles.
|
||||
- Remaining: Tin Tower and the Clear Bell, Suicune's appearances, the Game Corner's Golem walk cycle (two ROM frames with flips), the contest status box layout, radio and rematches (#30), breeding (#29), routes beyond Ecruteak and the gyms after Morty, mobile and link features, and audio.
|
||||
|
||||
## Native battles: current state
|
||||
|
||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
||||
|
||||
This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, story progression beyond Goldenrod City and Whitney's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, online Link, friends and real-world steps come later.
|
||||
This is still not a complete new game. The Pokégear's radio, the Pokédex's area view, search and other modes, story progression beyond Ecruteak City and Morty's gym, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, online Link, friends and real-world steps come later.
|
||||
|
||||
## 1. Complete the overworld foundation
|
||||
|
||||
|
||||
@@ -266,6 +266,16 @@ const eventNames = {
|
||||
wallOpenedInKabutoChamber: "EVENT_WALL_OPENED_IN_KABUTO_CHAMBER",
|
||||
wallOpenedInHoOhChamber: "EVENT_WALL_OPENED_IN_HO_OH_CHAMBER",
|
||||
wallOpenedInOmanyteChamber: "EVENT_WALL_OPENED_IN_OMANYTE_CHAMBER",
|
||||
bugCatchingContestant1A: "EVENT_BUG_CATCHING_CONTESTANT_1A",
|
||||
bugCatchingContestant2A: "EVENT_BUG_CATCHING_CONTESTANT_2A",
|
||||
bugCatchingContestant3A: "EVENT_BUG_CATCHING_CONTESTANT_3A",
|
||||
bugCatchingContestant4A: "EVENT_BUG_CATCHING_CONTESTANT_4A",
|
||||
bugCatchingContestant5A: "EVENT_BUG_CATCHING_CONTESTANT_5A",
|
||||
bugCatchingContestant6A: "EVENT_BUG_CATCHING_CONTESTANT_6A",
|
||||
bugCatchingContestant7A: "EVENT_BUG_CATCHING_CONTESTANT_7A",
|
||||
bugCatchingContestant8A: "EVENT_BUG_CATCHING_CONTESTANT_8A",
|
||||
bugCatchingContestant9A: "EVENT_BUG_CATCHING_CONTESTANT_9A",
|
||||
bugCatchingContestant10A: "EVENT_BUG_CATCHING_CONTESTANT_10A",
|
||||
};
|
||||
for (const [key, name] of Object.entries(eventNames)) {
|
||||
const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile);
|
||||
@@ -365,6 +375,8 @@ if (!profile.includes(`pokedex: ${pretConstants.get("ENGINE_POKEDEX")},`))
|
||||
throw new Error("ENGINE_POKEDEX does not match the reference.");
|
||||
if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREES")},`))
|
||||
throw new Error("ENGINE_ALL_FRUIT_TREES does not match the reference.");
|
||||
if (!profile.includes(`bugContestTimer: ${pretConstants.get("ENGINE_BUG_CONTEST_TIMER")},`))
|
||||
throw new Error("ENGINE_BUG_CONTEST_TIMER does not match the reference.");
|
||||
if (!profile.includes(`NUM_SPRITEMOVEDATA = ${pretConstants.get("NUM_SPRITEMOVEDATA")};`))
|
||||
throw new Error("NUM_SPRITEMOVEDATA does not match the reference.");
|
||||
if (!profile.includes(`NUM_ENGINE_FLAGS = ${pretConstants.get("NUM_ENGINE_FLAGS")};`))
|
||||
|
||||
+3
-1
@@ -11,6 +11,8 @@ export interface GameRuntime {
|
||||
pause(): void;
|
||||
setButton(button: Button, pressed: boolean): void;
|
||||
captureState(): Uint8Array;
|
||||
restoreState(state: Uint8Array): void;
|
||||
/** `continued` false restores a captured state exactly (a rollback)
|
||||
* rather than continuing the game from it. */
|
||||
restoreState(state: Uint8Array, continued?: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
+2
-2
@@ -78,13 +78,13 @@ export class GameSession {
|
||||
try {
|
||||
this.runtime.restoreState(record.state);
|
||||
} catch (error) {
|
||||
this.runtime.restoreState(previous);
|
||||
this.runtime.restoreState(previous, false);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.save();
|
||||
} catch (error) {
|
||||
this.runtime.restoreState(previous);
|
||||
this.runtime.restoreState(previous, false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,8 @@ export const C = {
|
||||
ULTRA_BALL: 2,
|
||||
GREAT_BALL: 4,
|
||||
POKE_BALL: 5,
|
||||
SUPER_POTION: 0x11,
|
||||
AWAKENING: 0x0c,
|
||||
HEAVY_BALL: 0x9d,
|
||||
LEVEL_BALL: 0x9f,
|
||||
LURE_BALL: 0xa0,
|
||||
@@ -253,6 +255,7 @@ export const C = {
|
||||
START_MONEY: 3000,
|
||||
MAX_MONEY: 999999,
|
||||
MAX_COINS: 9999,
|
||||
COIN_CASE: 0x36,
|
||||
|
||||
// Status bits and masks
|
||||
SLP_MASK: 7,
|
||||
|
||||
@@ -18,6 +18,10 @@ export function drawTileScreen(
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const image = context.createImageData(width, height);
|
||||
// Recorded only for OAM_PRIO sprites (`behind`), which need the
|
||||
// background's own color index to clip against; a screen with none
|
||||
// pays nothing beyond the allocation.
|
||||
const indices = new Uint8Array(width * height);
|
||||
for (let row = 0; row < SCREEN_HEIGHT; row++)
|
||||
for (let col = 0; col < SCREEN_WIDTH; col++) {
|
||||
const cell = row * SCREEN_WIDTH + col;
|
||||
@@ -31,6 +35,7 @@ export function drawTileScreen(
|
||||
col * 8,
|
||||
row * 8,
|
||||
screen.palettes[screen.attrs[cell]] ?? screen.palettes[0],
|
||||
{ indices },
|
||||
);
|
||||
}
|
||||
const blinkOff = !(frame & 0x10);
|
||||
@@ -45,7 +50,12 @@ export function drawTileScreen(
|
||||
sprite.x,
|
||||
sprite.y,
|
||||
screen.objPalettes[sprite.palette] ?? screen.objPalettes[0],
|
||||
{ transparent: true, flipX: sprite.flipX, flipY: sprite.flipY },
|
||||
{
|
||||
transparent: true,
|
||||
flipX: sprite.flipX,
|
||||
flipY: sprite.flipY,
|
||||
...(sprite.behind && { indices, behind: true }),
|
||||
},
|
||||
);
|
||||
}
|
||||
context.putImageData(image, 0, 0);
|
||||
|
||||
@@ -9,6 +9,11 @@ const TEXTBOX_FRAME_TILES = 6,
|
||||
export const FRAME_TILE = 0x79,
|
||||
SPACE_TILE = 0x7f,
|
||||
FONT_TILE = 0x80;
|
||||
/** "…" ($75): the one _LoadFontsExtra1 glyph a ported screen needs
|
||||
* (_CardFlipDarnText's "Darn…"). _LoadFontsExtra1 loads 22 raw 2bpp tiles
|
||||
* from `FontExtra + 3 tiles` into vTiles2 tile $63 ('<BOLD_D>'); $75 is
|
||||
* the 18th of those, so tile 21 of FontExtra itself. */
|
||||
export const ELLIPSIS_TILE = 0x75;
|
||||
|
||||
/** Get1bpp: both bit planes copy the 1bpp row, so set pixels are color 3. */
|
||||
function oneBpp(target: Uint8Array, tile: number, source: Uint8Array) {
|
||||
@@ -29,6 +34,8 @@ export function fontTiles(reader: RomReader, frame: number) {
|
||||
oneBpp(tiles, SPACE_TILE, reader.slice(JOHTO.TextboxSpaceGFX, 8));
|
||||
for (let i = 0; i < 128; i++)
|
||||
oneBpp(tiles, FONT_TILE + i, reader.slice(JOHTO.Font + i * 8, 8));
|
||||
// _LoadFontsExtra1's ellipsis: raw 2bpp, unlike the standard font's 1bpp.
|
||||
tiles.set(reader.slice(JOHTO.FontExtra + 21 * 16, 16), ELLIPSIS_TILE * 16);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
@@ -59,6 +66,7 @@ const CODES: Record<string, number> = {
|
||||
"/": 0xf3,
|
||||
",": 0xf4,
|
||||
"♀": 0xf5,
|
||||
"…": ELLIPSIS_TILE,
|
||||
};
|
||||
/** Text as tile numbers, one character per tile. */
|
||||
export function textTiles(text: string): number[] {
|
||||
|
||||
@@ -92,6 +92,8 @@ const STAT_TRIGGERS = ["gt", "lt", "eq"] as const;
|
||||
* user's ROM on demand and cached in memory only. */
|
||||
/** UNOWN and NUM_UNOWN (constants/pokemon_constants.asm). */
|
||||
const UNOWN = 201;
|
||||
/** EGG (constants/pokemon_constants.asm). */
|
||||
export const EGG = 0xfd;
|
||||
export const NUM_UNOWN = 26;
|
||||
|
||||
export class PokemonData {
|
||||
@@ -128,9 +130,10 @@ export class PokemonData {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Fixed-length names are padded with "@"; a full-length name has none. */
|
||||
/** Fixed-length names are padded with "@"; a full-length name has none.
|
||||
* PokemonNames also names EGG, which GetPokemonName reads the same way. */
|
||||
speciesName(species: number): string {
|
||||
this.checkSpecies(species);
|
||||
if (species !== EGG) this.checkSpecies(species);
|
||||
return this.cachedName(`mon:${species}`, () => {
|
||||
const r = this.reader,
|
||||
p = SYMBOLS.pokemonNames + (species - 1) * 10;
|
||||
|
||||
@@ -188,6 +188,18 @@ export const EVENTS = {
|
||||
wallOpenedInKabutoChamber: 807,
|
||||
wallOpenedInHoOhChamber: 806,
|
||||
wallOpenedInOmanyteChamber: 808,
|
||||
/** BugCatchingContestantEventFlagTable: SelectRandomBugContestContestants
|
||||
* sets one of these for each of the five contestants a round excludes. */
|
||||
bugCatchingContestant1A: 1814,
|
||||
bugCatchingContestant2A: 1815,
|
||||
bugCatchingContestant3A: 1816,
|
||||
bugCatchingContestant4A: 1817,
|
||||
bugCatchingContestant5A: 1818,
|
||||
bugCatchingContestant6A: 1819,
|
||||
bugCatchingContestant7A: 1820,
|
||||
bugCatchingContestant8A: 1821,
|
||||
bugCatchingContestant9A: 1822,
|
||||
bugCatchingContestant10A: 1823,
|
||||
} as const;
|
||||
|
||||
/** Engine flag indices named directly by translated routines. */
|
||||
@@ -195,11 +207,14 @@ export const ENGINE_FLAGS = {
|
||||
pokegear: 4,
|
||||
pokedex: 11,
|
||||
allFruitTrees: 84,
|
||||
/** STATUSFLAGS2_BUG_CONTEST_TIMER_F is the same WRAM bit. */
|
||||
bugContestTimer: 17,
|
||||
} as const;
|
||||
|
||||
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
||||
* that `npm run check:reference` verifies each address by name. */
|
||||
export const OVERWORLD = {
|
||||
ObjectEvent: 0x26ef,
|
||||
NPCTrades: 0xfce58,
|
||||
TradeTexts: 0xfcf53,
|
||||
NPCTradeCableText: 0xfcf7b,
|
||||
@@ -207,6 +222,8 @@ export const OVERWORLD = {
|
||||
"TradedForText.done": 0xfcf92,
|
||||
BugCatchingContestBattleScript: 0x135eb,
|
||||
ContestMons: 0x97d87,
|
||||
InitRoamMons: 0x2a2a0,
|
||||
RoamMaps: 0x2a40f,
|
||||
"OverworldTownMap": 0xc2c0,
|
||||
"TownMapGFX": 0xf8ba0,
|
||||
"JohtoMap": 0x91fff,
|
||||
@@ -335,6 +352,8 @@ export const OVERWORLD = {
|
||||
PlayMapMusic: 0x3cdf,
|
||||
SurfStartStep: 0x8379,
|
||||
UpdateTimePals: 0x485,
|
||||
LoadUsedSpritesGFX: 0x14209,
|
||||
RefreshSprites: 0x14168,
|
||||
WaitTileAnimation: 0xfc2fe,
|
||||
DoneTileAnimation: 0xfc2fb,
|
||||
StandingTileFrame8: 0xfc2ff,
|
||||
@@ -1133,6 +1152,83 @@ export const JOHTO = {
|
||||
BattleTowerAction: 0x170687,
|
||||
GameboyCheck: 0xc478,
|
||||
UnlockMysteryGift: 0x1050b9,
|
||||
CheckMobileAdapterStatusSpecial: 0x10630f,
|
||||
ClearBGPalettes: 0x31f3,
|
||||
UpdateSprites: 0x1ad2,
|
||||
GiveParkBalls: 0x135db,
|
||||
SelectRandomBugContestContestants: 0x139a8,
|
||||
BugContestantPointers: 0x13783,
|
||||
ContestDropOffMons: 0x13a12,
|
||||
ContestReturnMons: 0x13a31,
|
||||
CheckPartyFullAfterContest: 0x4d9e5,
|
||||
BugContestJudging: 0xc34a,
|
||||
BugCatchingContestOverScript: 0x135f8,
|
||||
BugCatchingContestReturnToGateScript: 0x1360b,
|
||||
ContestJudging_FirstPlaceText: 0x136eb,
|
||||
ContestJudging_FirstPlaceScoreText: 0x136fd,
|
||||
ContestJudging_SecondPlaceText: 0x13702,
|
||||
ContestJudging_SecondPlaceScoreText: 0x13714,
|
||||
ContestJudging_ThirdPlaceText: 0x13719,
|
||||
ContestJudging_ThirdPlaceScoreText: 0x1372b,
|
||||
"StartMenu_Quit.StartMenuContestEndText": 0x12906,
|
||||
"StartMenu_PrintBugContestStatus.CaughtString": 0x24c4b,
|
||||
"StartMenu_PrintBugContestStatus.BallsString": 0x24c52,
|
||||
"StartMenu_PrintBugContestStatus.NoneString": 0x24c59,
|
||||
"StartMenu_PrintBugContestStatus.LevelString": 0x24c5e,
|
||||
|
||||
// Game Corner: the slot machine and Card Flip (engine/games/*, both
|
||||
// reached through StartGameCornerGame's farcall; CheckCoinsAndCoinCase's
|
||||
// refusal texts gate both games before either special even runs).
|
||||
SlotMachine: 0xc373,
|
||||
CardFlip: 0xc380,
|
||||
_NoCoinsText: 0x1bd3d7,
|
||||
_NoCoinCaseText: 0x1bd3eb,
|
||||
_SlotsBetHowManyCoinsText: 0x1c5049,
|
||||
_SlotsStartText: 0x1c505e,
|
||||
_SlotsNotEnoughCoinsText: 0x1c5066,
|
||||
_SlotsRanOutOfCoinsText: 0x1c5079,
|
||||
_SlotsPlayAgainText: 0x1c5092,
|
||||
_SlotsLinedUpText: 0x1c509f,
|
||||
_SlotsDarnText: 0x1c50bb,
|
||||
"Slots_InitBias.Normal": 0x93023,
|
||||
"Slots_InitBias.Lucky": 0x93031,
|
||||
"Slots_GetPayout.PayoutTable": 0x93145,
|
||||
Reel1Tilemap: 0x93327,
|
||||
Reel2Tilemap: 0x93339,
|
||||
Reel3Tilemap: 0x9334b,
|
||||
SlotsTilemap: 0x9335d,
|
||||
Slots1LZ: 0x9344d,
|
||||
Slots2LZ: 0x935cd,
|
||||
Slots3LZ: 0x9382d,
|
||||
SlotMachinePals: 0xb7a9,
|
||||
"SpriteAnimOAMData.OAMData_SlotsGolem": 0x8df29,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey1": 0x8df42,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey2": 0x8df5b,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey3": 0x8df74,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey4": 0x8df8d,
|
||||
"SpriteAnimOAMData.OAMData_SlotsChansey5": 0x8dfa6,
|
||||
"SpriteAnimOAMData.OAMData_1x1_Palette0": 0x8daf1,
|
||||
_CardFlipPlayWithThreeCoinsText: 0x1c5793,
|
||||
_CardFlipNotEnoughCoinsText: 0x1c57ab,
|
||||
_CardFlipChooseACardText: 0x1c57be,
|
||||
_CardFlipPlaceYourBetText: 0x1c57ce,
|
||||
_CardFlipPlayAgainText: 0x1c57df,
|
||||
_CardFlipShuffledText: 0x1c57f4,
|
||||
_CardFlipYeahText: 0x1c5813,
|
||||
_CardFlipDarnText: 0x1c581a,
|
||||
CardFlipOffButtonGFX: 0xe0cf6,
|
||||
CardFlipOnButtonGFX: 0xe0d06,
|
||||
CardFlipLZ01: 0xe0d16,
|
||||
CardFlipLZ02: 0xe0ea8,
|
||||
CardFlipLZ03: 0xe0cdb,
|
||||
CardFlipTilemap: 0xe110c,
|
||||
"CardFlip_DisplayCardFaceUp.Deck": 0xe0459,
|
||||
"CardFlip_DisplayCardFaceUp.FaceUpCardTilemap": 0xe043b,
|
||||
"PlaceCardFaceDown.FaceDownCardTilemap": 0xe03ce,
|
||||
"PlaceOAMCardBorder.SpriteData": 0xe0853,
|
||||
"CardFlip_UpdateCursorOAM.OAMData": 0xe0981,
|
||||
"CardFlip_InitAttrPals.palettes": 0xe0c93,
|
||||
FontExtra: 0xf8000,
|
||||
} as const;
|
||||
|
||||
/** Saved WRAM bytes that map scripts read and write directly (readmem,
|
||||
@@ -1148,6 +1244,10 @@ export type SavedByte = keyof typeof SAVED_WRAM;
|
||||
* PRET labels. */
|
||||
export const WRAM = {
|
||||
wTilemap: 0xc4a0,
|
||||
wBugContestWinnerName: 0xd016,
|
||||
wBugContestFirstPlaceScore: 0xd004,
|
||||
wBugContestSecondPlaceScore: 0xd008,
|
||||
wBugContestThirdPlaceScore: 0xd00c,
|
||||
} as const;
|
||||
|
||||
export const MAP_GROUP_COUNT = 26;
|
||||
@@ -1259,5 +1359,23 @@ export const NATIVE_MAPS: Record<string, string> = {
|
||||
"3:20": "Radio Tower 4F",
|
||||
"3:21": "Radio Tower 5F",
|
||||
"10:14": "Route 35 gate",
|
||||
"10:2": "Route 35",
|
||||
"10:3": "Route 36",
|
||||
"10:4": "Route 37",
|
||||
"4:1": "Tin Tower entrance",
|
||||
"4:2": "Wise Trio’s room",
|
||||
"4:3": "Pokémon Center",
|
||||
"4:4": "Lugia speech house",
|
||||
"4:5": "Dance Theater",
|
||||
"4:6": "Ecruteak Mart",
|
||||
"4:7": "Ecruteak Gym",
|
||||
"4:8": "Itemfinder house",
|
||||
"4:9": "Ecruteak City",
|
||||
"3:13": "Burned Tower 1F",
|
||||
"3:14": "Burned Tower B1F",
|
||||
"10:15": "Route 35 National Park gate",
|
||||
"10:17": "Route 36 National Park gate",
|
||||
"3:15": "National Park",
|
||||
"3:16": "National Park (Bug-Catching Contest)",
|
||||
};
|
||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||
+10
-1
@@ -62,6 +62,14 @@ export class NativeRuntime implements GameRuntime {
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
// A realtime screen (the Game Corner) reads every button itself, once
|
||||
// per its own frame, rather than through the usual per-button actions.
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind === "screen" && choice.realtime) {
|
||||
world.bufferScreenButton(button);
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
if (button === "a") world.interact();
|
||||
if (button === "b") world.advanceDialogue();
|
||||
if (
|
||||
@@ -241,11 +249,12 @@ export class NativeRuntime implements GameRuntime {
|
||||
if (!this.world) throw new Error("Load your ROM first.");
|
||||
return new TextEncoder().encode(JSON.stringify(this.world.save()));
|
||||
}
|
||||
restoreState(bytes: Uint8Array) {
|
||||
restoreState(bytes: Uint8Array, continued = true) {
|
||||
if (!this.world || bytes.length > 64_000)
|
||||
throw new Error("Invalid native save size.");
|
||||
this.world.restore(
|
||||
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
||||
continued,
|
||||
);
|
||||
this.pressed.clear();
|
||||
this.publish();
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { JOHTO } from "../rom/profile";
|
||||
import { C } from "../battle/constants";
|
||||
import { decompress } from "../rom/lz";
|
||||
import { fontTiles, textTiles, SPACE_TILE } from "../rom/font";
|
||||
import { readText } from "./text";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
import {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
blankScreen,
|
||||
fillBox,
|
||||
fillAttrs,
|
||||
romPalette,
|
||||
type ScreenSprite,
|
||||
type TileScreen,
|
||||
} from "./screen";
|
||||
import { checkCoinsAndCoinCase, signed8 } from "./slot-machine";
|
||||
|
||||
/** CARDFLIP_DECK_SIZE (constants/misc_constants.asm): 4 species x 6 levels. */
|
||||
const DECK_SIZE = 24;
|
||||
/** This port's own tile layout for the three graphics blocks. BG1/BG2 sit
|
||||
* at the same offsets the original uses so every raw tilemap byte (the
|
||||
* face-down/up card layouts and the Deck's picture anchors) can be copied
|
||||
* in unchanged; tiles $72-$78 of that same range stay free for
|
||||
* _LoadFontsExtra1's ellipsis ($75, see rom/font.ts), so CardFlipLZ03 (an
|
||||
* OBJ-bank asset in the original, vTiles0 not vTiles2) moves to its own
|
||||
* space beyond the merged standard font instead of also living there. */
|
||||
const BG1_BASE = 0x00,
|
||||
BG2_BASE = 0x3e,
|
||||
OBJ_BASE = 0x100;
|
||||
|
||||
/** wDeck (shuffled card values by position), wDiscardPile, the current
|
||||
* round's chosen card and the bet cursor (wCardFlipCursorX/Y). */
|
||||
interface CardFlipSession {
|
||||
deck: number[];
|
||||
discardPile: boolean[];
|
||||
numCardsPlayed: number;
|
||||
whichCard: 0 | 1;
|
||||
faceUpCard: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
/** CardFlip_ShuffleDeck: card values 23 down to 0 dropped into random
|
||||
* unfilled slots (Random & $1f, rejecting 24-31 and already-filled slots).
|
||||
* wDiscardPile is cleared with it by the caller. */
|
||||
export function shuffleDeck(host: { random(): number }): number[] {
|
||||
const deck = new Array<number>(DECK_SIZE).fill(0);
|
||||
for (let value = DECK_SIZE - 1; value >= 0; value--) {
|
||||
let slot: number;
|
||||
do {
|
||||
slot = host.random() & 0x1f;
|
||||
} while (slot >= DECK_SIZE || deck[slot] !== 0);
|
||||
deck[slot] = value;
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
/** ChooseCard_HandleJoypad: the board is an 8-row by 6-column grid
|
||||
* (CollapseCursorPosition: Y*6+X). Rows 0-1 (the Pokémon pair/group icons)
|
||||
* and columns 0-1 (the level pair/group numbers) snap to the nearest even
|
||||
* cell of a pair as they move, exactly as the original's own `and $e`
|
||||
* masking does (even where that leaves the cursor on an unchanged, already
|
||||
* even position), and moving past either snaps instead to the fixed
|
||||
* mon-group/number-group junction cell. */
|
||||
export function moveCardFlipCursor(
|
||||
session: { cursorX: number; cursorY: number },
|
||||
direction: string,
|
||||
) {
|
||||
const toMonGroup = () => {
|
||||
session.cursorY = 2;
|
||||
session.cursorX = 1;
|
||||
};
|
||||
const toNumberGroup = () => {
|
||||
session.cursorY = 1;
|
||||
session.cursorX = 2;
|
||||
};
|
||||
if (direction === "left") {
|
||||
if (session.cursorY === 0) {
|
||||
session.cursorX &= 0xe;
|
||||
if (session.cursorX < 3) return toMonGroup();
|
||||
session.cursorX -= 2;
|
||||
} else if (session.cursorY === 1) {
|
||||
if (session.cursorX < 3) return toMonGroup();
|
||||
session.cursorX -= 1;
|
||||
} else if (session.cursorX > 0) session.cursorX -= 1;
|
||||
return;
|
||||
}
|
||||
if (direction === "right") {
|
||||
if (session.cursorY === 0) {
|
||||
session.cursorX &= 0xe;
|
||||
if (session.cursorX >= 4) return;
|
||||
session.cursorX += 2;
|
||||
} else if (session.cursorX < 5) session.cursorX += 1;
|
||||
return;
|
||||
}
|
||||
if (direction === "up") {
|
||||
if (session.cursorX === 0) {
|
||||
session.cursorY &= 0xe;
|
||||
if (session.cursorY < 3) return toNumberGroup();
|
||||
session.cursorY -= 2;
|
||||
} else if (session.cursorX === 1) {
|
||||
if (session.cursorY < 3) return toNumberGroup();
|
||||
session.cursorY -= 1;
|
||||
} else if (session.cursorY > 0) session.cursorY -= 1;
|
||||
return;
|
||||
}
|
||||
if (direction === "down") {
|
||||
if (session.cursorX === 0) {
|
||||
session.cursorY &= 0xe;
|
||||
if (session.cursorY >= 6) return;
|
||||
session.cursorY += 2;
|
||||
} else if (session.cursorY < 7) session.cursorY += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** CardFlip_CheckWinCondition's 8x6 paytable, read off the card's species
|
||||
* (bits 0-1) and level (bits 2-4): the top two rows' outer cells are
|
||||
* always a loss (CardFlip_UpdateCursorOAM's ".Impossible" cells); the
|
||||
* species-pair/exact and level-pair/exact rows pay 6/12 and 9/18; the
|
||||
* bottom four rows' right four columns pay 72 for the exact card. */
|
||||
export function cardFlipPayout(cell: number, card: number): number {
|
||||
const row = Math.floor(cell / 6),
|
||||
col = cell % 6;
|
||||
const species = card & 3,
|
||||
level = card >> 2;
|
||||
if (row === 0) {
|
||||
if (col <= 1) return 0;
|
||||
if (col <= 3) return species < 2 ? 6 : 0; // Pikachu/Jigglypuff pair
|
||||
return species >= 2 ? 6 : 0; // Poliwag/Oddish pair
|
||||
}
|
||||
if (row === 1) {
|
||||
if (col <= 1) return 0;
|
||||
return species === col - 2 ? 12 : 0; // exact species
|
||||
}
|
||||
const pairIndex = Math.floor((row - 2) / 2),
|
||||
isPairRow = (row - 2) % 2 === 0;
|
||||
if (col === 0) return Math.floor(level / 2) === pairIndex ? 9 : 0; // level pair
|
||||
const rowLevel = pairIndex * 2 + (isPairRow ? 0 : 1);
|
||||
if (col === 1) return level === rowLevel ? 18 : 0; // exact level
|
||||
const wantCard = rowLevel * 4 + (col - 2);
|
||||
return card === wantCard ? 72 : 0; // exact card
|
||||
}
|
||||
|
||||
function cardFlipTiles(reader: RomReader, textboxFrame: number): Uint8Array {
|
||||
// fontTiles' own 256-tile buffer ends exactly at OBJ_BASE; this port's
|
||||
// own layout needs more room beyond it for CardFlipLZ03.
|
||||
const tiles = new Uint8Array((OBJ_BASE + 8) * 16);
|
||||
tiles.set(fontTiles(reader, textboxFrame));
|
||||
tiles.set(decompress(reader, JOHTO.CardFlipLZ01, 0x1000), BG1_BASE * 16);
|
||||
tiles.set(decompress(reader, JOHTO.CardFlipLZ02, 0x1000), BG2_BASE * 16);
|
||||
tiles.set(decompress(reader, JOHTO.CardFlipLZ03, 0x1000), OBJ_BASE * 16);
|
||||
// The two CopyBytes calls right after the three Decompress calls:
|
||||
// CARDFLIP_LIGHT_OFF/_ON ($ef/$f5) overwrite the standard font's own
|
||||
// (unused here) "♂"/"♀" glyphs at those same tile numbers with the
|
||||
// light-off/on bulb graphic, used by the board's own left-edge divider
|
||||
// column (CardFlipTilemap's column 0 is $ef on every row) and the two
|
||||
// bet-light indicators.
|
||||
tiles.set(reader.slice(JOHTO.CardFlipOffButtonGFX, 16), 0xef * 16);
|
||||
tiles.set(reader.slice(JOHTO.CardFlipOnButtonGFX, 16), 0xf5 * 16);
|
||||
return tiles;
|
||||
}
|
||||
/** PlaceCardFaceDown/CardFlip_DisplayCardFaceUp's 5x6 tile boxes; every
|
||||
* byte in them already indexes this port's merged tiles (see BG1_BASE/
|
||||
* BG2_BASE above), including the $7f space tile for the still-blank level
|
||||
* and picture cells on a face-down card. */
|
||||
function drawCardBox(screen: TileScreen, reader: RomReader, x: number, y: number, address: number) {
|
||||
const data = reader.slice(address, 30);
|
||||
for (let row = 0; row < 6; row++)
|
||||
for (let col = 0; col < 5; col++)
|
||||
screen.map[(y + row) * SCREEN_WIDTH + x + col] = data[row * 5 + col];
|
||||
}
|
||||
/** CardFlip_DisplayCardFaceUp: the face-up frame, then the level digit
|
||||
* (charmap "1".."6", already within the merged standard font) and the 3x3
|
||||
* picture anchor from the Deck table, by the card's species. */
|
||||
function drawFaceUpCard(screen: TileScreen, reader: RomReader, x: number, y: number, card: number) {
|
||||
drawCardBox(screen, reader, x, y, JOHTO["CardFlip_DisplayCardFaceUp.FaceUpCardTilemap"]);
|
||||
const level = card >> 2,
|
||||
species = card & 3;
|
||||
screen.map[(y + 1) * SCREEN_WIDTH + x + 3] = 0xf7 + level;
|
||||
const anchor = reader.byte(JOHTO["CardFlip_DisplayCardFaceUp.Deck"] + species * 2 + 1);
|
||||
for (let row = 0; row < 3; row++)
|
||||
for (let col = 0; col < 3; col++)
|
||||
screen.map[(y + 2 + row) * SCREEN_WIDTH + x + 1 + col] = anchor + row * 3 + col;
|
||||
}
|
||||
function drawNumber(screen: TileScreen, x: number, y: number, value: number, digits: number) {
|
||||
const text = String(Math.max(0, value)).padStart(digits, "0").slice(-digits);
|
||||
const tiles = textTiles(text);
|
||||
for (let i = 0; i < digits; i++) screen.map[y * SCREEN_WIDTH + x + i] = tiles[i];
|
||||
}
|
||||
function drawMessage(screen: TileScreen, pages: string[]) {
|
||||
// readText joins a page's own `line` continuations with "\n"; each one
|
||||
// is a separate row of this port's own message box.
|
||||
pages
|
||||
.flatMap((page) => page.split("\n"))
|
||||
.slice(0, 4)
|
||||
.forEach((line, row) => {
|
||||
const tiles = textTiles(line);
|
||||
for (let i = 0; i < tiles.length && i < SCREEN_WIDTH; i++)
|
||||
screen.map[(13 + row) * SCREEN_WIDTH + i] = tiles[i];
|
||||
});
|
||||
}
|
||||
/** CardFlip_UpdateCursorOAM.OAMData: 48 entries (CollapseCursorPosition's
|
||||
* Y*6+X order), each a dbpixel anchor (x, y) and a pointer to one of the
|
||||
* SingleTile/NumGroup/NumGroupPair/PokeGroup/PokeGroupPair/Impossible
|
||||
* style tables below it, in the same count-prefixed dbsprite format as
|
||||
* the slot machine's own sprite frames. The pointer is resolved and read
|
||||
* directly, rather than this port naming which named table each of the 48
|
||||
* cells uses. CGB draws these every tick regardless of hVBlankCounter
|
||||
* (DMG's own "and $4" blink only applies without hCGB set). */
|
||||
function cursorSprites(reader: RomReader, cursorX: number, cursorY: number): ScreenSprite[] {
|
||||
const table = JOHTO["CardFlip_UpdateCursorOAM.OAMData"];
|
||||
const cell = cursorY * 6 + cursorX;
|
||||
const entry = table + cell * 4;
|
||||
const anchorX = reader.byte(entry),
|
||||
anchorY = reader.byte(entry + 1);
|
||||
const style = reader.near(entry + 2, reader.bank(table));
|
||||
const count = reader.byte(style);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const p = style + 1 + i * 4;
|
||||
const attr = reader.byte(p + 3);
|
||||
return {
|
||||
x: anchorX + signed8(reader.byte(p + 1)),
|
||||
y: anchorY + signed8(reader.byte(p)),
|
||||
tile: OBJ_BASE + reader.byte(p + 2),
|
||||
palette: attr & 7, // OAM_PALETTE (CGB)
|
||||
flipX: Boolean(attr & 0x20), // OAM_XFLIP
|
||||
flipY: Boolean(attr & 0x40), // OAM_YFLIP
|
||||
behind: Boolean(attr & 0x80), // OAM_PRIO
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The scene CardFlip's own tile screen shows at a given moment; used by
|
||||
* `cardFlip`'s realtime loop for every phase from ChooseACard on. */
|
||||
interface CardFlipView {
|
||||
cards: "none" | "both" | "chosen";
|
||||
bet: boolean;
|
||||
message?: string[];
|
||||
}
|
||||
/** CardFlip_InitAttrPals: WipeAttrmap (left at 0 by blankScreen), then
|
||||
* each FillBox region in its own call order; the palette each uses comes
|
||||
* straight from the same `.palettes` block copied into wBGPals1. */
|
||||
function applyCardFlipAttrs(screen: TileScreen) {
|
||||
fillAttrs(screen, 12, 1, 2, 2, 1);
|
||||
fillAttrs(screen, 14, 1, 2, 2, 2);
|
||||
fillAttrs(screen, 16, 1, 2, 2, 3);
|
||||
fillAttrs(screen, 18, 1, 2, 2, 4);
|
||||
// `lb bc, 12, 1` into CardFlip_FillBox is 12 rows of 1 column (the same
|
||||
// b=height/c=width convention as CardFlip_CopyToBox): the board's own
|
||||
// left-edge divider column (x=9, every row $ef), not a 1-tall top row.
|
||||
fillAttrs(screen, 9, 0, 1, 12, 1);
|
||||
}
|
||||
function renderCardFlip(
|
||||
context: SpecialContext,
|
||||
session: CardFlipSession,
|
||||
tiles: Uint8Array,
|
||||
view: CardFlipView,
|
||||
): TileScreen {
|
||||
const r = context.reader;
|
||||
const palettes = Array.from({ length: 9 }, (_, i) =>
|
||||
romPalette(r, JOHTO["CardFlip_InitAttrPals.palettes"] + i * 8),
|
||||
);
|
||||
const screen = blankScreen(tiles, "card-flip", palettes, "Card Flip");
|
||||
fillBox(screen, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, 0x29);
|
||||
// CardFlip_InitTilemap: `lb bc, 12, 11` into CardFlip_CopyToBox, whose
|
||||
// outer loop (`dec b`) is the row count and inner loop (`dec c`) the
|
||||
// column count: 12 rows of 11 columns, not the other way round.
|
||||
const board = r.slice(JOHTO.CardFlipTilemap, 12 * 11);
|
||||
for (let row = 0; row < 12; row++)
|
||||
for (let col = 0; col < 11; col++) screen.map[row * SCREEN_WIDTH + 9 + col] = board[row * 11 + col];
|
||||
fillBox(screen, 0, 12, SCREEN_WIDTH, SCREEN_HEIGHT - 12, SPACE_TILE);
|
||||
applyCardFlipAttrs(screen);
|
||||
if (view.cards !== "none") {
|
||||
const top = session.whichCard === 0 || view.cards === "both",
|
||||
bottom = session.whichCard === 1 || view.cards === "both";
|
||||
if (top)
|
||||
if (view.cards === "chosen" && session.whichCard === 0)
|
||||
drawFaceUpCard(screen, r, 2, 0, session.faceUpCard);
|
||||
else drawCardBox(screen, r, 2, 0, JOHTO["PlaceCardFaceDown.FaceDownCardTilemap"]);
|
||||
if (bottom)
|
||||
if (view.cards === "chosen" && session.whichCard === 1)
|
||||
drawFaceUpCard(screen, r, 2, 6, session.faceUpCard);
|
||||
else drawCardBox(screen, r, 2, 6, JOHTO["PlaceCardFaceDown.FaceDownCardTilemap"]);
|
||||
}
|
||||
if (view.bet) screen.sprites = cursorSprites(r, session.cursorX, session.cursorY);
|
||||
// The coin balance is drawn last so each payout tick's increment shows
|
||||
// at once, like CardFlip_PrintCoinBalance redrawing it every loop.
|
||||
drawNumber(screen, 15, 16, context.state.coins, 4);
|
||||
if (view.message) drawMessage(screen, view.message);
|
||||
return screen;
|
||||
}
|
||||
|
||||
/** CardFlip.ChooseACard: toggle which face-down card is picked (any button
|
||||
* but A) and confirm with A. The three-blink reveal-prep and the
|
||||
* placement delay are not presented; see `renderCardFlip`'s doc comment. */
|
||||
function* chooseACard(context: SpecialContext, session: CardFlipSession, tiles: Uint8Array): Gen<void> {
|
||||
session.whichCard = 0;
|
||||
for (;;) {
|
||||
const button = yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, { cards: "both", bet: false }),
|
||||
realtime: true,
|
||||
};
|
||||
if (button === "a") break;
|
||||
if (button) session.whichCard = session.whichCard === 0 ? 1 : 0;
|
||||
}
|
||||
session.discardPile[session.numCardsPlayed * 2 + (1 - session.whichCard)] = true;
|
||||
}
|
||||
/** CardFlip.PlaceYourBet: move the cursor over the paytable and confirm. */
|
||||
function* placeYourBet(context: SpecialContext, session: CardFlipSession, tiles: Uint8Array): Gen<void> {
|
||||
for (;;) {
|
||||
const button = yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, { cards: "chosen", bet: true }),
|
||||
realtime: true,
|
||||
};
|
||||
if (button === "a") return;
|
||||
if (typeof button === "string") moveCardFlipCursor(session, button);
|
||||
}
|
||||
}
|
||||
/** CardFlip.CheckTheCard: reveal the chosen card. */
|
||||
function* checkTheCard(context: SpecialContext, session: CardFlipSession, tiles: Uint8Array): Gen<void> {
|
||||
const index = session.numCardsPlayed * 2 + session.whichCard;
|
||||
session.faceUpCard = session.deck[index];
|
||||
session.discardPile[index] = true;
|
||||
yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, { cards: "chosen", bet: true }),
|
||||
realtime: true,
|
||||
};
|
||||
}
|
||||
/** CardFlip.TabulateTheResult: CardFlip_CheckWinCondition's payout, paid
|
||||
* out one coin per tick like the slot machine's own payout animation
|
||||
* (Slots_CheckCoinCaseFull's cap applies the same way), then
|
||||
* WaitPressAorB_BlinkCursor. */
|
||||
function* tabulateTheResult(
|
||||
context: SpecialContext,
|
||||
session: CardFlipSession,
|
||||
tiles: Uint8Array,
|
||||
): Gen<void> {
|
||||
const cell = session.cursorY * 6 + session.cursorX;
|
||||
const payout = cardFlipPayout(cell, session.faceUpCard);
|
||||
const lines = readText(
|
||||
context.reader,
|
||||
payout > 0 ? JOHTO._CardFlipYeahText : JOHTO._CardFlipDarnText,
|
||||
context.text(),
|
||||
).split("\n\n");
|
||||
let remaining = payout;
|
||||
for (;;) {
|
||||
yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, {
|
||||
cards: "chosen",
|
||||
bet: true,
|
||||
message: lines,
|
||||
}),
|
||||
realtime: true,
|
||||
};
|
||||
if (remaining === 0) break;
|
||||
remaining--;
|
||||
if (context.state.coins < C.MAX_COINS) context.state.coins++;
|
||||
}
|
||||
for (;;) {
|
||||
const button = yield {
|
||||
kind: "screen",
|
||||
screen: renderCardFlip(context, session, tiles, { cards: "chosen", bet: true, message: lines }),
|
||||
realtime: true,
|
||||
};
|
||||
if (button === "a" || button === "b") break;
|
||||
}
|
||||
}
|
||||
|
||||
/** _CardFlip (engine/games/card_flip.asm), as CardFlip calls it. Each
|
||||
* round costs 3 coins: choose one of two face-down cards (the other is
|
||||
* discarded), bet on a paytable cell, reveal the card and pay out by
|
||||
* CardFlip_CheckWinCondition. The 24-card deck reshuffles after 12 rounds.
|
||||
* Sounds (SFX_KINESIS, SFX_CHOOSE_A_CARD, the payout clinks) are not
|
||||
* presented. */
|
||||
export function* cardFlip(context: SpecialContext): Gen<void> {
|
||||
if (!(yield* checkCoinsAndCoinCase(context))) return;
|
||||
const tiles = cardFlipTiles(context.reader, context.state.options.textboxFrame);
|
||||
let deck = shuffleDeck(context);
|
||||
let discardPile = new Array<boolean>(DECK_SIZE).fill(false);
|
||||
let numCardsPlayed = 0;
|
||||
let cursorX = 2,
|
||||
cursorY = 2;
|
||||
|
||||
context.write(JOHTO._CardFlipPlayWithThreeCoinsText);
|
||||
const wantsToPlay = yield { kind: "yesno", pages: context.take() };
|
||||
if (!wantsToPlay) return;
|
||||
|
||||
for (;;) {
|
||||
if (context.state.coins < 3) {
|
||||
context.write(JOHTO._CardFlipNotEnoughCoinsText);
|
||||
yield* context.flush();
|
||||
return;
|
||||
}
|
||||
context.state.coins -= 3;
|
||||
|
||||
const session: CardFlipSession = {
|
||||
deck,
|
||||
discardPile,
|
||||
numCardsPlayed,
|
||||
whichCard: 0,
|
||||
faceUpCard: 0,
|
||||
cursorX,
|
||||
cursorY,
|
||||
};
|
||||
yield* chooseACard(context, session, tiles);
|
||||
yield* placeYourBet(context, session, tiles);
|
||||
yield* checkTheCard(context, session, tiles);
|
||||
yield* tabulateTheResult(context, session, tiles);
|
||||
cursorX = session.cursorX;
|
||||
cursorY = session.cursorY;
|
||||
|
||||
context.write(JOHTO._CardFlipPlayAgainText);
|
||||
const again = yield { kind: "yesno", pages: context.take() };
|
||||
if (!again) return;
|
||||
|
||||
numCardsPlayed = session.numCardsPlayed + 1;
|
||||
if (numCardsPlayed >= 12) {
|
||||
deck = shuffleDeck(context);
|
||||
discardPile = new Array<boolean>(DECK_SIZE).fill(false);
|
||||
numCardsPlayed = 0;
|
||||
context.write(JOHTO._CardFlipShuffledText);
|
||||
yield* context.flush();
|
||||
} else {
|
||||
discardPile = session.discardPile;
|
||||
deck = session.deck;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BATTLE, JOHTO, RAM, SYMBOLS } from "../rom/profile";
|
||||
import { changedHappiness } from "../battle/battle";
|
||||
import { readString } from "./text";
|
||||
import { NICKNAME_PATTERN } from "../party/pokemon";
|
||||
import { EGG } from "../rom/pokemon";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
import {
|
||||
checkCountdown,
|
||||
@@ -59,8 +60,6 @@ export function checkFirstMonIsEgg(context: SpecialContext): number {
|
||||
name;
|
||||
return Number(Boolean(first.egg));
|
||||
}
|
||||
/** EGG (constants/pokemon_constants.asm). */
|
||||
const EGG = 0xfd;
|
||||
|
||||
/** HaircutOrGrooming (engine/events/haircut.asm): a Pokémon from the
|
||||
* party; an Egg answers 1. Otherwise one Random byte picks a row of the
|
||||
|
||||
+391
-14
@@ -18,6 +18,7 @@ import {
|
||||
POKEGEAR,
|
||||
RAM,
|
||||
SAVED_WRAM,
|
||||
WRAM,
|
||||
SPECIAL_ROUTINES,
|
||||
SYMBOLS,
|
||||
type SavedByte,
|
||||
@@ -25,7 +26,8 @@ import {
|
||||
import { PokemonData } from "../rom/pokemon";
|
||||
import { PokedexData } from "../rom/pokedex";
|
||||
import { C } from "../battle/constants";
|
||||
import type { BugContest } from "../battle/battle";
|
||||
import type { BugContest, RoamMon } from "../battle/battle";
|
||||
import { emptyRoamMons, initRoamMons } from "../world/roam-mons";
|
||||
import {
|
||||
copyBag,
|
||||
emptyBag,
|
||||
@@ -67,6 +69,8 @@ import type { FlyMapView } from "./fly-map";
|
||||
import type { TileScreen } from "./screen";
|
||||
import { unownPuzzle } from "./unown-puzzle";
|
||||
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
||||
import { slotMachine } from "./slot-machine";
|
||||
import { cardFlip } from "./card-flip";
|
||||
import {
|
||||
checkFirstMonIsEgg,
|
||||
checkForLuckyNumberWinners,
|
||||
@@ -182,12 +186,34 @@ export interface ScriptState {
|
||||
timers: Partial<Record<DayTimerName, DayTimer>>;
|
||||
/** sLuckyIDNumber and sLuckyNumberDay (wCurDay + 1 when drawn). */
|
||||
luckyNumber: { id: number; day: number };
|
||||
/** wRoamMon1/2/3 (Raikou, Entei, Suicune): InitRoamMons starts the first
|
||||
* two roaming; CheckEncounterRoamMon, UpdateRoamMons and JumpRoamMons
|
||||
* move or encounter them (see ../world/roam-mons). */
|
||||
roamMons: RoamMon[];
|
||||
/** wRoamMons_CurMapGroup/Number, kept from the end of the last
|
||||
* UpdateRoamMons or JumpRoamMons call: at the next call it is
|
||||
* wRoamMons_LastMapGroup/Number, the map .Update avoids repeating
|
||||
* (_BackUpMapIndices runs after every call, whether or not any roaming
|
||||
* Pokémon is active yet). Null before the first call. */
|
||||
roamMonsMap: string | null;
|
||||
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
|
||||
options: GameOptions;
|
||||
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
|
||||
* runs (STATUSFLAGS2_BUG_CONTEST_TIMER_F). Nothing starts a contest yet,
|
||||
* so saves do not keep it. */
|
||||
* runs (ENGINE_BUG_CONTEST_TIMER/STATUSFLAGS2_BUG_CONTEST_TIMER_F). */
|
||||
bugContest?: BugContest;
|
||||
/** StartBugContestTimer's wBugContestStartTime, kept here as the real
|
||||
* deadline (the game clock's time plus BUG_CONTEST_MINUTES) rather than
|
||||
* the ROM's day/hour/min/sec struct that CheckBugContestTimer
|
||||
* decrements every check: both converge on the same remaining time for
|
||||
* any elapsed duration, since checks run on (effectively) every idle
|
||||
* tick. Set only while `bugContest` is set. */
|
||||
bugContestEnd?: number;
|
||||
/** ContestDropOffMons/ContestReturnMons: the party slots 2-6 a Bug-
|
||||
* Catching Contest hides while it runs. The ROM instead truncates
|
||||
* wPartyCount and leaves the other slots' structs untouched in WRAM;
|
||||
* here they are moved aside and spliced back, which is observably the
|
||||
* same (nothing else can read or change them meanwhile). */
|
||||
contestLeftParty?: PartyMon[];
|
||||
}
|
||||
export const emptyScriptState = (): ScriptState => ({
|
||||
decorations: emptyDecorations(),
|
||||
@@ -228,6 +254,8 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
firstUnownSeen: 0,
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
options: defaultOptions(),
|
||||
});
|
||||
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
@@ -274,12 +302,18 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
Object.entries(state.timers).map(([name, timer]) => [name, { ...timer }]),
|
||||
),
|
||||
luckyNumber: { ...state.luckyNumber },
|
||||
roamMons: state.roamMons.map((mon) => ({ ...mon })),
|
||||
roamMonsMap: state.roamMonsMap,
|
||||
options: { ...state.options },
|
||||
...(state.bugContest && {
|
||||
bugContest: {
|
||||
parkBalls: state.bugContest.parkBalls,
|
||||
mon: structuredClone(state.bugContest.mon),
|
||||
},
|
||||
bugContestEnd: state.bugContestEnd,
|
||||
}),
|
||||
...(state.contestLeftParty && {
|
||||
contestLeftParty: structuredClone(state.contestLeftParty),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -330,6 +364,27 @@ const ROUTE_36 = "10:3";
|
||||
const CONTACT_LIST_SIZE = 10;
|
||||
/** VAR_CALLERID: wCurCaller, the phone contact a trainer script names. */
|
||||
const VAR_CALLERID = 0x17;
|
||||
/** BUG_CONTEST_BALLS, BUG_CONTEST_MINUTES and BUG_CONTEST_SECONDS
|
||||
* (constants/script_constants.asm): GiveParkBalls and StartBugContestTimer. */
|
||||
const BUG_CONTEST_BALLS = 20;
|
||||
const BUG_CONTEST_MINUTES = 20;
|
||||
/** The ten BugCatchingContestantEventFlagTable entries, in order. */
|
||||
const BUG_CONTESTANT_A_FLAGS: readonly number[] = [
|
||||
EVENTS.bugCatchingContestant1A,
|
||||
EVENTS.bugCatchingContestant2A,
|
||||
EVENTS.bugCatchingContestant3A,
|
||||
EVENTS.bugCatchingContestant4A,
|
||||
EVENTS.bugCatchingContestant5A,
|
||||
EVENTS.bugCatchingContestant6A,
|
||||
EVENTS.bugCatchingContestant7A,
|
||||
EVENTS.bugCatchingContestant8A,
|
||||
EVENTS.bugCatchingContestant9A,
|
||||
EVENTS.bugCatchingContestant10A,
|
||||
];
|
||||
/** LANDMARK_NATIONAL_PARK (constants/landmark_constants.asm), which
|
||||
* CheckPartyFullAfterContest writes over whatever landmark the gate the
|
||||
* player is standing at would otherwise give the catch. */
|
||||
const LANDMARK_NATIONAL_PARK = 0x13;
|
||||
/** PermanentNumbers: PHONECONTACT_MOM and PHONECONTACT_ELM, which share
|
||||
* PHONE_MOM's and PHONE_ELM's values. */
|
||||
const PERMANENT_NUMBERS = [1, 4];
|
||||
@@ -511,8 +566,9 @@ export type ScriptEffect =
|
||||
tree?: boolean;
|
||||
/** BATTLETYPE_FISH: a Pokémon hooked on a fishing rod. */
|
||||
fish?: boolean;
|
||||
/** The other wBattleType values scripts load: FORCESHINY, TRAP,
|
||||
* FORCEITEM, SUICUNE and CONTEST. */
|
||||
/** The other wBattleType values: FORCESHINY, TRAP, FORCEITEM,
|
||||
* SUICUNE and CONTEST (scripts load these) or ROAMING
|
||||
* (CheckEncounterRoamMon sets it before the script runs). */
|
||||
battleType?: number;
|
||||
}
|
||||
/** loademote: the emote graphics (EMOTE_*) that show_emote displays
|
||||
@@ -525,7 +581,11 @@ export type ScriptEffect =
|
||||
* MAPSETUP_DOOR (`door`) keeps the facing unless the arrival tile turns
|
||||
* the player down (CheckWarpFacingDown). newloadmap stops the script as
|
||||
* StopScript does; when more than an end follows, it resumes at `resume`
|
||||
* on the new map (ScriptEvents restarts it after EnterMap). */
|
||||
* on the new map (ScriptEvents restarts it after EnterMap). `roam`
|
||||
* reports UpdateRoamMons (MAPSETUP_DOOR, which Dig and the Escape Rope
|
||||
* use) or JumpRoamMons (MAPSETUP_TELEPORT/MAPSETUP_FLY): the shared byte
|
||||
* table in data/maps/setup_scripts.asm falls through into these from
|
||||
* those three mapsetup entries alone, never from plain MAPSETUP_WARP. */
|
||||
| {
|
||||
kind: "warp";
|
||||
map: string;
|
||||
@@ -533,6 +593,7 @@ export type ScriptEffect =
|
||||
y: number;
|
||||
facing?: Direction;
|
||||
door?: boolean;
|
||||
roam?: "update" | "jump";
|
||||
resume?: number;
|
||||
}
|
||||
/** FlyFromAnim and FlyToAnim: the Fly user's party icon flies off from
|
||||
@@ -604,8 +665,11 @@ export type ScriptEffect =
|
||||
* (warp, object, script) that boulders falling into pits trigger. */
|
||||
| { kind: "stonetable"; address: number }
|
||||
/** A full-screen minigame or special drawn in tiles, answered with the
|
||||
* button pressed (SCREEN_BUTTONS). */
|
||||
| { kind: "screen"; screen: TileScreen };
|
||||
* button pressed (SCREEN_BUTTONS). `realtime` marks a screen that keeps
|
||||
* running on its own, like the Game Corner (SlotsLoop, CardFlip's
|
||||
* MasterLoop): the world resumes it every tick (twice, as elsewhere)
|
||||
* with the latest buffered button rather than waiting for one. */
|
||||
| { kind: "screen"; screen: TileScreen; realtime?: boolean };
|
||||
|
||||
/** What the world supplies about the present moment. */
|
||||
export interface ScriptHost {
|
||||
@@ -618,6 +682,9 @@ export interface ScriptHost {
|
||||
clock(): { weekday: number; hours: number; minutes: number };
|
||||
/** The game clock's weekday before SetDayOfWeek's offset. */
|
||||
clockDay(): number;
|
||||
/** The game clock's real time (UpdateTime), in epoch milliseconds, for
|
||||
* StartBugContestTimer's deadline. */
|
||||
gameNow(): number;
|
||||
text(): TextContext;
|
||||
data: PokemonData;
|
||||
/** wPlayerID and wPlayerGender (1 for Kris). */
|
||||
@@ -807,6 +874,10 @@ export class ScriptRun {
|
||||
/** wBattleType BATTLETYPE_FISH, which FishFunction sets with the
|
||||
* hooked Pokémon (`wild`). */
|
||||
fish?: boolean;
|
||||
/** wBattleType BATTLETYPE_ROAMING, set directly by
|
||||
* CheckEncounterRoamMon before WildBattleScript ever runs (`wild`
|
||||
* names the roaming beast's own species and level). */
|
||||
roaming?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
this.state = copyScriptState(state);
|
||||
@@ -848,8 +919,15 @@ export class ScriptRun {
|
||||
? OVERWORLD["UseFlashTextScript.BlankText"]
|
||||
: // TradedForText: the fanfare after Text_NPCTraded.
|
||||
offset === OVERWORLD.TradedForText + 4
|
||||
? OVERWORLD["TradedForText.done"]
|
||||
: undefined,
|
||||
? OVERWORLD["TradedForText.done"]
|
||||
: // ContestJudging_*Text: a placing fanfare, then its score text.
|
||||
offset === JOHTO.ContestJudging_FirstPlaceText + 4
|
||||
? JOHTO.ContestJudging_FirstPlaceScoreText
|
||||
: offset === JOHTO.ContestJudging_SecondPlaceText + 4
|
||||
? JOHTO.ContestJudging_SecondPlaceScoreText
|
||||
: offset === JOHTO.ContestJudging_ThirdPlaceText + 4
|
||||
? JOHTO.ContestJudging_ThirdPlaceScoreText
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -873,7 +951,11 @@ export class ScriptRun {
|
||||
let winText = this.tempTrainer?.beatenText || undefined,
|
||||
lossText = this.tempTrainer?.lostScript || undefined;
|
||||
let canLose = false;
|
||||
let battleType: number | undefined;
|
||||
// CheckEncounterRoamMon sets wBattleType before WildBattleScript (just
|
||||
// randomwildmon/startbattle) ever runs, so no opcode here sets it.
|
||||
let battleType: number | undefined = this.options.roaming
|
||||
? C.BATTLETYPE_ROAMING
|
||||
: undefined;
|
||||
let menuHeader: number | undefined;
|
||||
const temp = (offset: number, opcode: number) => {
|
||||
if (!this.tempTrainer)
|
||||
@@ -1482,11 +1564,23 @@ export class ScriptRun {
|
||||
// carried over to the new map here.
|
||||
if (stack.length)
|
||||
throw new UnsupportedScript(offset, opcode, " (inside a call)");
|
||||
// MAPSETUP_DOOR's bytes fall through into MAPSETUP_TRAIN's
|
||||
// UpdateRoamMons; MAPSETUP_TELEPORT and MAPSETUP_FLY fall
|
||||
// through into MAPSETUP_WARP's bytes, but only after their own
|
||||
// JumpRoamMons. Plain MAPSETUP_WARP (used only by the whiteout
|
||||
// script) starts past both and calls neither.
|
||||
const roam =
|
||||
method === OW.MAPSETUP_DOOR
|
||||
? "update"
|
||||
: method === OW.MAPSETUP_TELEPORT || method === OW.MAPSETUP_FLY
|
||||
? "jump"
|
||||
: undefined;
|
||||
yield* flush();
|
||||
yield synchronous({
|
||||
kind: "warp",
|
||||
...destination,
|
||||
door: method === OW.MAPSETUP_DOOR || undefined,
|
||||
roam,
|
||||
resume:
|
||||
r.byte(p) === 0x91 || r.byte(p) === 0x93 ? undefined : p,
|
||||
});
|
||||
@@ -1513,7 +1607,10 @@ export class ScriptRun {
|
||||
case 0x3c:
|
||||
case 0xa3: {
|
||||
// warp and warpfacing: MAPSETUP_WARP with SPAWN_N_A. A zero map
|
||||
// group is the BADWARP fallback, which is not ported.
|
||||
// group is the BADWARP fallback, which is not ported. Most warps
|
||||
// end the script right after; BugContestResultsWarpScript (and
|
||||
// anything else that falls through into more commands) resumes
|
||||
// them on the new map, like newloadmap's resume.
|
||||
const facing =
|
||||
opcode === 0xa3 ? DIRECTION_ORDER[byte() & 3] : undefined;
|
||||
const group = byte(),
|
||||
@@ -1522,13 +1619,18 @@ export class ScriptRun {
|
||||
y = byte();
|
||||
if (!group)
|
||||
throw new UnsupportedScript(offset, opcode, " (bad warp)");
|
||||
const continues = r.byte(p) !== 0x91 && r.byte(p) !== 0x93;
|
||||
if (continues && stack.length)
|
||||
throw new UnsupportedScript(offset, opcode, " (inside a call)");
|
||||
yield synchronous({
|
||||
kind: "warp",
|
||||
map: `${group}:${number}`,
|
||||
x,
|
||||
y,
|
||||
facing,
|
||||
resume: continues ? p : undefined,
|
||||
});
|
||||
if (continues) return;
|
||||
break;
|
||||
}
|
||||
case 0x8c: {
|
||||
@@ -1621,6 +1723,12 @@ export class ScriptRun {
|
||||
this.strings[buffer(byte())] = String(balance);
|
||||
break;
|
||||
}
|
||||
case 0x3f:
|
||||
// getnum: wScriptVar as a left-aligned decimal string, such as
|
||||
// ContestResults_PlayerWonAPrizeText's placement number. value
|
||||
// itself is unchanged.
|
||||
this.strings[buffer(byte())] = String(value);
|
||||
break;
|
||||
case 0x42:
|
||||
case 0xa5: {
|
||||
// getcurlandmarkname and getlandmarkname (whose landmark operand
|
||||
@@ -3050,6 +3158,14 @@ export class ScriptRun {
|
||||
case 0x10:
|
||||
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount.
|
||||
return MONS_PER_BOX - currentBox(this.state.storage).length;
|
||||
case 0x11: {
|
||||
// VAR_CONTESTMINUTES: CheckBugContestTimer's remaining whole
|
||||
// minutes, from the real deadline rather than the ROM's
|
||||
// decremented wBugContestMinsRemaining/SecsRemaining pair.
|
||||
const end = this.state.bugContestEnd;
|
||||
if (end === undefined) return 0;
|
||||
return Math.floor(Math.max(0, end - this.host.gameNow()) / 60_000);
|
||||
}
|
||||
case 0x12:
|
||||
return this.host.position().x;
|
||||
case 0x13:
|
||||
@@ -3114,8 +3230,11 @@ export class ScriptRun {
|
||||
case OVERWORLD.UpdateTimePals:
|
||||
case OVERWORLD.WaitSFX:
|
||||
case OVERWORLD.FadeInFromWhite:
|
||||
// The native renderer draws the player from wPlayerState each frame;
|
||||
// music and palette fades are presentation.
|
||||
case OVERWORLD.LoadUsedSpritesGFX:
|
||||
case OVERWORLD.RefreshSprites:
|
||||
// The native renderer draws the player and objects from state each
|
||||
// frame; music, palette fades and VRAM sprite tile loading are
|
||||
// presentation.
|
||||
return value;
|
||||
case OVERWORLD.SurfStartStep:
|
||||
// slow_step in the facing direction, then step_end.
|
||||
@@ -3125,6 +3244,8 @@ export class ScriptRun {
|
||||
case SPECIAL_ROUTINES.fadeOutToBlack:
|
||||
case SPECIAL_ROUTINES.fadeInFromBlack:
|
||||
case SPECIAL_ROUTINES.reloadSpritesNoPalettes:
|
||||
case JOHTO.ClearBGPalettes:
|
||||
case JOHTO.UpdateSprites:
|
||||
// Graphics, palettes and sound only: wPartyCount/wScriptVar select
|
||||
// an animation sequence but nothing persists past it.
|
||||
return value;
|
||||
@@ -3216,6 +3337,9 @@ export class ScriptRun {
|
||||
case OVERWORLD.OverworldTownMap:
|
||||
yield { kind: "townmap" };
|
||||
return value;
|
||||
case OVERWORLD.InitRoamMons:
|
||||
this.state.roamMons = initRoamMons(this.state.roamMons);
|
||||
return value;
|
||||
case JOHTO.UnownPuzzle: {
|
||||
// FadeToMenu: the text box closes first.
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
@@ -3225,6 +3349,12 @@ export class ScriptRun {
|
||||
);
|
||||
return Number(solved);
|
||||
}
|
||||
case JOHTO.SlotMachine:
|
||||
yield* slotMachine(this.specialContext(pages, take), Boolean(value));
|
||||
return value;
|
||||
case JOHTO.CardFlip:
|
||||
yield* cardFlip(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.DisplayUnownWords: {
|
||||
const tileset = this.host.tileset;
|
||||
if (!tileset) return undefined;
|
||||
@@ -3295,6 +3425,10 @@ export class ScriptRun {
|
||||
case JOHTO.GameboyCheck:
|
||||
// The port is a Game Boy Color.
|
||||
return GBCHECK_CGB;
|
||||
case JOHTO.CheckMobileAdapterStatusSpecial:
|
||||
// Unused in this revision (CheckMobileAdapterStatus is the
|
||||
// Japanese version's routine): always clears wScriptVar.
|
||||
return 0;
|
||||
case JOHTO.UnlockMysteryGift:
|
||||
// sMysteryGiftUnlocked only adds MYSTERY GIFT to the main menu,
|
||||
// which belongs to the link features.
|
||||
@@ -3393,10 +3527,252 @@ export class ScriptRun {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case JOHTO.GiveParkBalls:
|
||||
this.state.bugContest = { parkBalls: BUG_CONTEST_BALLS, mon: null };
|
||||
// StartBugContestTimer: a real deadline rather than the ROM's
|
||||
// wBugContestStartTime day/hour/min/sec struct (see ScriptState).
|
||||
this.state.bugContestEnd =
|
||||
this.host.gameNow() + BUG_CONTEST_MINUTES * 60_000;
|
||||
return value;
|
||||
case JOHTO.SelectRandomBugContestContestants: {
|
||||
for (const flag of BUG_CONTESTANT_A_FLAGS) this.state.events.delete(flag);
|
||||
// $ff / NUM_BUG_CONTESTANTS, rejecting draws past its last
|
||||
// multiple, then SimpleDivide for a uniform index 0-9.
|
||||
const span = Math.floor(255 / BUG_CONTESTANT_A_FLAGS.length);
|
||||
let chosen = 0,
|
||||
tries = 0;
|
||||
while (chosen < 5) {
|
||||
let byte: number;
|
||||
do {
|
||||
if (++tries > 4096) throw new Error("Random draw limit exceeded.");
|
||||
byte = this.host.random();
|
||||
} while (byte >= span * BUG_CONTESTANT_A_FLAGS.length);
|
||||
const flag = BUG_CONTESTANT_A_FLAGS[Math.floor(byte / span)];
|
||||
// "If we've already set it, it doesn't count."
|
||||
if (this.state.events.has(flag)) continue;
|
||||
this.state.events.add(flag);
|
||||
chosen++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case JOHTO.ContestDropOffMons: {
|
||||
const party = this.state.party,
|
||||
lead = party[0];
|
||||
if (!lead.hp) return 1; // .fainted
|
||||
// The ROM truncates wPartyCount and leaves the other structs
|
||||
// alone in WRAM; here they are moved aside (see ScriptState).
|
||||
this.state.contestLeftParty = party.slice(1);
|
||||
this.state.party = [lead];
|
||||
return 0;
|
||||
}
|
||||
case JOHTO.ContestReturnMons: {
|
||||
const left = this.state.contestLeftParty ?? [];
|
||||
this.state.party = [this.state.party[0], ...left];
|
||||
this.state.contestLeftParty = undefined;
|
||||
return value;
|
||||
}
|
||||
case JOHTO.CheckPartyFullAfterContest:
|
||||
return yield* this.checkPartyFullAfterContest(pages, take);
|
||||
case JOHTO.BugContestJudging:
|
||||
return yield* this.bugContestJudging();
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** CheckPartyFullAfterContest: the caught contest Pokémon (or none)
|
||||
* joins the party, or the current box first (SetBoxMonCaughtData),
|
||||
* with the landmark overridden to the National Park regardless of
|
||||
* where the player is standing when this runs (back at the gate). */
|
||||
private *checkPartyFullAfterContest(
|
||||
pages: string[],
|
||||
take: () => string[],
|
||||
): Generator<ScriptEffect, number, Input> {
|
||||
const mon = this.state.bugContest?.mon ?? null;
|
||||
// BugContestResultsScript always reaches this last, whether judging
|
||||
// ran or not: the contest itself is over (ENGINE_BUG_CONTEST_TIMER
|
||||
// was already cleared at the top of that script).
|
||||
this.state.bugContest = undefined;
|
||||
this.state.bugContestEnd = undefined;
|
||||
if (!mon) return 2; // BUGCONTEST_NO_CATCH
|
||||
mon.caughtData = caughtData(
|
||||
this.host.timeOfDay(),
|
||||
mon.level,
|
||||
this.host.player().gender,
|
||||
LANDMARK_NATIONAL_PARK,
|
||||
);
|
||||
const speciesName = this.host.data.speciesName(mon.species);
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
// GiveANickname_YesNo, then NamingScreen (NAME_MON), as the regular
|
||||
// capture flow's askNickname does; declining or a blank answer keeps
|
||||
// the species name GeneratePartyMonStats already gave it.
|
||||
this.strings[RAM.stringBuffer1] = speciesName;
|
||||
const wants = yield {
|
||||
kind: "yesno" as const,
|
||||
pages: readText(this.reader, SYMBOLS.caughtAskNicknameText, this.text())
|
||||
.split("\n\n")
|
||||
.filter(Boolean),
|
||||
};
|
||||
if (wants) {
|
||||
const r = this.reader;
|
||||
let second = SYMBOLS.nicknameStrings;
|
||||
while (r.byte(second++) !== 0x50);
|
||||
const context = this.text(),
|
||||
suffix = readString(r, SYMBOLS.nicknameStrings, context),
|
||||
title = readString(r, second, context);
|
||||
const answer = yield {
|
||||
kind: "name" as const,
|
||||
pages: [`${speciesName}${suffix}\n${title}`],
|
||||
current: speciesName,
|
||||
max: 10,
|
||||
};
|
||||
if (
|
||||
typeof answer !== "string" ||
|
||||
answer.length > 10 ||
|
||||
(answer && !NICKNAME_PATTERN.test(answer))
|
||||
)
|
||||
throw new Error("Invalid nickname.");
|
||||
mon.nickname = /^ *$/.test(answer) ? speciesName : answer;
|
||||
}
|
||||
if (this.state.party.length < PARTY_LENGTH) {
|
||||
this.state.party.push(mon);
|
||||
return 0; // BUGCONTEST_CAUGHT_MON
|
||||
}
|
||||
const box = currentBox(this.state.storage);
|
||||
if (box.length >= MONS_PER_BOX) {
|
||||
// .BoxFull falls through to this even when nothing was inserted
|
||||
// (the box was already full): the kept quirk still overwrites the
|
||||
// box's first Pokémon's caught-location byte.
|
||||
const first = box[0];
|
||||
if (first)
|
||||
first.caughtData =
|
||||
(first.caughtData & 0xff00) |
|
||||
(first.caughtData & 0x80) |
|
||||
LANDMARK_NATIONAL_PARK;
|
||||
return 1; // BUGCONTEST_BOXED_MON
|
||||
}
|
||||
const stored = toBoxMon(mon);
|
||||
restorePP(this.host.data, stored);
|
||||
box.unshift(stored);
|
||||
return 1; // BUGCONTEST_BOXED_MON
|
||||
}
|
||||
|
||||
/** ContestScore: max HP (×4), the other five stats, DV bit 1s weighted
|
||||
* 1/4/8/16 for Speed/Special/Attack/Defense, an eighth of current HP
|
||||
* and 1 for a held item, each read as a single byte (the original's
|
||||
* kept 8-bit truncation for any stat at or above 256). */
|
||||
private contestScore(mon: PartyMon): number {
|
||||
const byte = (n: number) => n & 0xff;
|
||||
const bit1 = (dv: number) => (dv >> 1) & 1;
|
||||
const attackDv = (mon.dvs >> 12) & 15,
|
||||
defenseDv = (mon.dvs >> 8) & 15,
|
||||
speedDv = (mon.dvs >> 4) & 15,
|
||||
specialDv = mon.dvs & 15;
|
||||
const statSum = mon.stats
|
||||
.slice(1)
|
||||
.reduce((sum, stat) => sum + byte(stat), 0);
|
||||
return (
|
||||
byte(mon.stats[0]) * 4 +
|
||||
statSum +
|
||||
bit1(speedDv) +
|
||||
4 * bit1(specialDv) +
|
||||
8 * bit1(attackDv) +
|
||||
16 * bit1(defenseDv) +
|
||||
(byte(mon.hp) >> 3) +
|
||||
(mon.item ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** _BugContestJudging: ComputeAIContestantScores for the round's five
|
||||
* active contestants (SelectRandomBugContestContestants left their A
|
||||
* flag clear), read from BugContestantPointers, then the player's own
|
||||
* ContestScore, through DetermineContestWinners' ">=" placement rule
|
||||
* (a tie favors whichever entry is compared last). Announces third,
|
||||
* then second, then first place and returns the player's own placement
|
||||
* (1-3), or 0 (BugContest_GetPlayersResult). StubbedTrainerRankings_-
|
||||
* BugContestScore is the cut Trainer Rankings feature's dead stub. */
|
||||
private *bugContestJudging(): Generator<ScriptEffect, number, Input> {
|
||||
const r = this.reader,
|
||||
data = this.host.data;
|
||||
interface Entry {
|
||||
player: boolean;
|
||||
classId?: number;
|
||||
id?: number;
|
||||
mon: number;
|
||||
score: number;
|
||||
}
|
||||
// ClearContestResults: the three slots start zeroed. A real contest
|
||||
// always has five active AI contestants (SelectRandomBugContestContestants)
|
||||
// plus the player, so every slot always ends up with a genuine entry;
|
||||
// this placeholder only guards a synthetic state that skipped that.
|
||||
const blank: Entry = { player: false, mon: 0, score: 0 };
|
||||
let first: Entry = blank,
|
||||
second: Entry = blank,
|
||||
third: Entry = blank;
|
||||
const place = (entry: Entry) => {
|
||||
if (entry.score >= first.score) {
|
||||
third = second;
|
||||
second = first;
|
||||
first = entry;
|
||||
} else if (entry.score >= second.score) {
|
||||
third = second;
|
||||
second = entry;
|
||||
} else if (entry.score >= third.score) {
|
||||
third = entry;
|
||||
}
|
||||
};
|
||||
const bank = r.bank(JOHTO.BugContestantPointers);
|
||||
for (let i = 0; i < BUG_CONTESTANT_A_FLAGS.length; i++) {
|
||||
if (this.state.events.has(BUG_CONTESTANT_A_FLAGS[i])) continue;
|
||||
// +1: BugContestantPointers[0] is a dummy entry ("reverts to the
|
||||
// player") that index 0 never points to.
|
||||
const record = r.near(JOHTO.BugContestantPointers + (i + 1) * 2, bank);
|
||||
const classId = r.byte(record),
|
||||
id = r.byte(record + 1);
|
||||
let pick: number,
|
||||
tries = 0;
|
||||
do {
|
||||
if (++tries > 4096) throw new Error("Random draw limit exceeded.");
|
||||
pick = this.host.random() & 3;
|
||||
} while (pick === 3);
|
||||
const slot = record + 2 + pick * 3;
|
||||
const score = (r.word(slot + 1) + (this.host.random() & 7)) & 0xffff;
|
||||
place({ player: false, classId, id, mon: r.byte(slot), score });
|
||||
}
|
||||
const contestMon = this.state.bugContest?.mon;
|
||||
place({
|
||||
player: true,
|
||||
mon: contestMon?.species ?? 0,
|
||||
score: contestMon ? this.contestScore(contestMon) : 0,
|
||||
});
|
||||
const trainers = new TrainerData(r);
|
||||
for (const [e, textAddr, scoreRam] of [
|
||||
[third, JOHTO.ContestJudging_ThirdPlaceText, WRAM.wBugContestThirdPlaceScore],
|
||||
[second, JOHTO.ContestJudging_SecondPlaceText, WRAM.wBugContestSecondPlaceScore],
|
||||
[first, JOHTO.ContestJudging_FirstPlaceText, WRAM.wBugContestFirstPlaceScore],
|
||||
] as const) {
|
||||
// LoadContestantName; a slot still at ClearContestResults' zeroed
|
||||
// placeholder (unreachable with a real contest's five AI entries)
|
||||
// gets a blank name rather than the ROM's own out-of-bounds read.
|
||||
this.strings[WRAM.wBugContestWinnerName] = e.player
|
||||
? this.text().player
|
||||
: e.classId !== undefined
|
||||
? (() => {
|
||||
const def = trainers.trainer(e.classId!, e.id!);
|
||||
return `${def.className} ${def.name}`;
|
||||
})()
|
||||
: "";
|
||||
this.strings[RAM.stringBuffer1] = e.mon ? data.speciesName(e.mon) : "";
|
||||
this.numbers[scoreRam] = e.score;
|
||||
const content = readText(r, textAddr, this.text());
|
||||
const announcePages = content ? content.split("\n\n").filter(Boolean) : [];
|
||||
if (announcePages.length) yield { kind: "text", pages: announcePages };
|
||||
}
|
||||
if (first.player) return 1;
|
||||
if (second.player) return 2;
|
||||
if (third.player) return 3;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a script that must finish without presentation, such as in tests
|
||||
@@ -3414,6 +3790,7 @@ export function runScript(
|
||||
timeOfDay: () => 1,
|
||||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||||
clockDay: () => 0,
|
||||
gameNow: () => 0,
|
||||
text: () => defaultTextContext(name),
|
||||
data: new PokemonData(reader),
|
||||
player: () => ({ id: 0, gender: 0 }),
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface ScreenSprite {
|
||||
/** Hidden while bit 4 of the frame counter is clear, as screens that
|
||||
* blink their cursor with hVBlankCounter do. */
|
||||
blink?: boolean;
|
||||
/** OAM_PRIO (B_OAM_PRIO): drawn behind any non-zero background color,
|
||||
* rather than over it, as the slot machine's reel window clips its
|
||||
* sprites against the surrounding frame tiles. */
|
||||
behind?: boolean;
|
||||
}
|
||||
|
||||
/** A full-screen Game Boy picture that a minigame or special draws in
|
||||
@@ -84,6 +88,26 @@ export function blankScreen(
|
||||
};
|
||||
}
|
||||
|
||||
/** Fill a box's background palette only (FillBoxCGB/ByteFill into
|
||||
* wAttrmap), leaving the tiles a tilemap copy already placed there alone.
|
||||
* A region partly or wholly off the visible 20x18 screen (as some of
|
||||
* _CGB_SlotMachine's own regions are) is simply clipped, like `fillBox`. */
|
||||
export function fillAttrs(
|
||||
screen: TileScreen,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
palette: number,
|
||||
) {
|
||||
for (let row = y; row < y + height; row++)
|
||||
for (let col = x; col < x + width; col++) {
|
||||
if (col < 0 || row < 0 || col >= SCREEN_WIDTH || row >= SCREEN_HEIGHT)
|
||||
continue;
|
||||
screen.attrs[row * SCREEN_WIDTH + col] = palette;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill a box of the tilemap with one tile (and optionally a palette). */
|
||||
export function fillBox(
|
||||
screen: TileScreen,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { PokedexData } from "../rom/pokedex";
|
||||
import { BATTLE, ENGINE_FLAGS, MENU, OVERWORLD, RAM } from "../rom/profile";
|
||||
import { BATTLE, ENGINE_FLAGS, JOHTO, MENU, OVERWORLD, RAM } from "../rom/profile";
|
||||
import { C } from "../battle/constants";
|
||||
import {
|
||||
changedHappiness,
|
||||
@@ -47,7 +47,8 @@ const STARTMENUITEM_POKEDEX = 0,
|
||||
STARTMENUITEM_SAVE = 4,
|
||||
STARTMENUITEM_OPTION = 5,
|
||||
STARTMENUITEM_EXIT = 6,
|
||||
STARTMENUITEM_POKEGEAR = 7;
|
||||
STARTMENUITEM_POKEGEAR = 7,
|
||||
STARTMENUITEM_QUIT = 8;
|
||||
/** The pack's pockets in Pack_InterpretJoypad order (*_POCKET). */
|
||||
const POCKETS = ["item", "ball", "key", "tm"] as const;
|
||||
type PocketIndex = 0 | 1 | 2 | 3;
|
||||
@@ -288,35 +289,59 @@ export class StartMenuRun {
|
||||
|
||||
// --------------------------------------------------------- start menu
|
||||
|
||||
/** StartMenu.SetUpMenuItems: no Link mode or Bug-Catching Contest yet. */
|
||||
/** StartMenu.SetUpMenuItems: no Link mode. During a Bug-Catching
|
||||
* Contest (STATUSFLAGS2_BUG_CONTEST_TIMER_F) PACK is left out and QUIT
|
||||
* (StartMenu_Quit) replaces SAVE. */
|
||||
private menuItems() {
|
||||
const items: number[] = [];
|
||||
if (this.state.flags.has(ENGINE_FLAGS.pokedex))
|
||||
items.push(STARTMENUITEM_POKEDEX);
|
||||
if (this.state.party.length) items.push(STARTMENUITEM_POKEMON);
|
||||
items.push(STARTMENUITEM_PACK);
|
||||
if (!this.state.bugContest) items.push(STARTMENUITEM_PACK);
|
||||
if (this.state.flags.has(ENGINE_FLAGS.pokegear))
|
||||
items.push(STARTMENUITEM_POKEGEAR);
|
||||
items.push(
|
||||
STARTMENUITEM_STATUS,
|
||||
STARTMENUITEM_SAVE,
|
||||
STARTMENUITEM_OPTION,
|
||||
STARTMENUITEM_EXIT,
|
||||
);
|
||||
items.push(STARTMENUITEM_STATUS);
|
||||
items.push(this.state.bugContest ? STARTMENUITEM_QUIT : STARTMENUITEM_SAVE);
|
||||
items.push(STARTMENUITEM_OPTION, STARTMENUITEM_EXIT);
|
||||
return items;
|
||||
}
|
||||
/** StartMenu.Items: (routine, string, description) pointers. */
|
||||
/** StartMenu.Items: (routine, string, description) pointers. QUIT's
|
||||
* own entry lives at the same index 8 in the ROM's table. */
|
||||
private itemString(item: number, field: 1 | 2) {
|
||||
const r = this.reader,
|
||||
entry = MENU["StartMenu.Items"] + item * 6 + field * 2;
|
||||
return this.string(r.near(entry, r.bank(entry)));
|
||||
}
|
||||
/** StartMenu_DrawBugContestStatusBox/_PrintBugContestStatus's BALLS,
|
||||
* CAUGHT and LEVEL lines, shown above the menu list throughout a
|
||||
* contest instead of as a separately positioned box. */
|
||||
private contestStatusPages(): string[] {
|
||||
const contest = this.state.bugContest;
|
||||
if (!contest) return [];
|
||||
const label = (key: keyof typeof JOHTO) => this.string(JOHTO[key]);
|
||||
const mon = contest.mon;
|
||||
const caught = mon
|
||||
? this.data.speciesName(mon.species)
|
||||
: label("StartMenu_PrintBugContestStatus.NoneString");
|
||||
const level = mon
|
||||
? `${label("StartMenu_PrintBugContestStatus.LevelString")} ${mon.level}`
|
||||
: "";
|
||||
return [
|
||||
[
|
||||
`${label("StartMenu_PrintBugContestStatus.CaughtString")} ${caught}`,
|
||||
level,
|
||||
`${label("StartMenu_PrintBugContestStatus.BallsString")} ${contest.parkBalls}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
];
|
||||
}
|
||||
|
||||
private *run(): Gen {
|
||||
for (;;) {
|
||||
const items = this.menuItems();
|
||||
const choice = yield* this.menu(
|
||||
[],
|
||||
this.contestStatusPages(),
|
||||
items.map((item) => this.itemString(item, 1)),
|
||||
// The menu account (wOptions2) describes the highlighted item.
|
||||
menuAccount(this.state.options)
|
||||
@@ -384,6 +409,18 @@ export class StartMenuRun {
|
||||
"The game saves itself as you play. Use the Savegames tab for backups.",
|
||||
);
|
||||
break;
|
||||
case STARTMENUITEM_QUIT:
|
||||
// StartMenu_Quit: retiring early ends the contest the same way
|
||||
// running out of balls or time does.
|
||||
if (
|
||||
yield* this.yesNo(
|
||||
JOHTO["StartMenu_Quit.StartMenuContestEndText"],
|
||||
)
|
||||
) {
|
||||
this.deferred = JOHTO.BugCatchingContestReturnToGateScript;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
yield* this.notice(`${this.itemString(item, 1)} ${NOT_PORTED}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { OVERWORLD } from "../rom/profile";
|
||||
import type { RoamMon } from "../battle/battle";
|
||||
|
||||
/** RAIKOU and ENTEI (constants/pokemon_constants.asm) and the level and
|
||||
* starting maps InitRoamMons gives them: Route 42 (GROUP_ROUTE_42 2,
|
||||
* MAP_ROUTE_42 5) and Route 37 (GROUP_ROUTE_37 10, MAP_ROUTE_37 4). */
|
||||
const RAIKOU = 243,
|
||||
ENTEI = 244,
|
||||
BEAST_LEVEL = 40,
|
||||
RAIKOU_START = "2:5",
|
||||
ENTEI_START = "10:4";
|
||||
|
||||
/** NUM_ROAMMON_MAPS (data/wild/roammon_maps.asm): RoamMaps holds exactly
|
||||
* this many entries before its -1 terminator, one per listed Johto route
|
||||
* (Routes 40 and 41 are water routes and are not listed). */
|
||||
const NUM_ROAMMON_MAPS = 16;
|
||||
/** A random-draw loop that should always resolve quickly; this only
|
||||
* guards against a corrupted RoamMaps table. */
|
||||
const DRAW_LIMIT = 4096;
|
||||
|
||||
/** special InitRoamMons: Raikou and Entei (wRoamMon1/2) start at level 40
|
||||
* on their own route with HP 0, which LoadEnemyMon's `!roamer.hp` branch
|
||||
* reads as "draw fresh DVs and HP at the next encounter". Suicune
|
||||
* (wRoamMon3, index 2) is left exactly as it was: the routine never
|
||||
* writes to it, so it stays at a new game's species 0/map GROUP_N_A
|
||||
* until something else starts it roaming (nothing in the ROM does). */
|
||||
export function initRoamMons(current: readonly RoamMon[]): RoamMon[] {
|
||||
return [
|
||||
{ species: RAIKOU, level: BEAST_LEVEL, mapId: RAIKOU_START, hp: 0, dvs: 0 },
|
||||
{ species: ENTEI, level: BEAST_LEVEL, mapId: ENTEI_START, hp: 0, dvs: 0 },
|
||||
{ ...current[2] },
|
||||
];
|
||||
}
|
||||
|
||||
/** CheckEncounterRoamMon: a single Random draw both gates a roaming
|
||||
* encounter (cp 100, "25/64 chance") and, when it passes, picks which of
|
||||
* the three beasts might appear (`and %00000011`, a further 3/4 of those
|
||||
* draws split evenly over Raikou/Entei/Suicune — "1/3 chance" each in the
|
||||
* source comment). The beast only actually appears when its own current
|
||||
* map is the one the player is standing on; otherwise the draw is wasted,
|
||||
* exactly as the original leaves wTempWildMonSpecies untouched. */
|
||||
export function checkEncounterRoamMon(
|
||||
roamMons: readonly RoamMon[],
|
||||
mapId: string,
|
||||
random: () => number,
|
||||
): RoamMon | undefined {
|
||||
const draw = random();
|
||||
if (draw >= 100) return undefined;
|
||||
const pick = draw & 3;
|
||||
if (!pick) return undefined;
|
||||
const roamer = roamMons[pick - 1];
|
||||
return roamer.mapId === mapId ? roamer : undefined;
|
||||
}
|
||||
|
||||
/** RoamMaps' entries (the roam_map macro): [group][number][connection
|
||||
* count][neighbor group][neighbor number]... for each connection, ended by
|
||||
* a single 0 byte. Returns the entry's address, or undefined when `mapId`
|
||||
* is not one of the table's NUM_ROAMMON_MAPS listed routes. */
|
||||
function roamMapEntry(r: RomReader, mapId: string): number | undefined {
|
||||
const [group, number] = mapId.split(":").map(Number);
|
||||
let p = OVERWORLD.RoamMaps;
|
||||
for (let i = 0; i < NUM_ROAMMON_MAPS; i++) {
|
||||
if (r.byte(p) === group && r.byte(p + 1) === number) return p;
|
||||
p += 4 + 2 * r.byte(p + 2);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** JumpRoamMon: a uniform pick among RoamMaps' 16 entries. `maskbits
|
||||
* NUM_ROAMMON_MAPS` already restricts the draw to 0-15, so the ROM's own
|
||||
* `cp NUM_ROAMMON_MAPS` retry never fires (noted in its source comment);
|
||||
* the draw is redrawn only when it names the map given in `avoidMapId`
|
||||
* (JumpRoamMons passes the player's current map). */
|
||||
function jumpRoamMon(
|
||||
r: RomReader,
|
||||
avoidMapId: string,
|
||||
random: () => number,
|
||||
): string {
|
||||
for (let tries = 0; ; tries++) {
|
||||
if (tries > DRAW_LIMIT) throw new Error("Random draw limit exceeded.");
|
||||
const index = random() & (NUM_ROAMMON_MAPS - 1);
|
||||
let p = OVERWORLD.RoamMaps;
|
||||
for (let skip = 0; skip < index; skip++) p += 4 + 2 * r.byte(p + 2);
|
||||
const mapId = `${r.byte(p)}:${r.byte(p + 1)}`;
|
||||
if (mapId !== avoidMapId) return mapId;
|
||||
}
|
||||
}
|
||||
|
||||
/** .Update: a roaming beast's own RoamMaps entry picks a random
|
||||
* connection, redrawn whenever the index lands past the entry's
|
||||
* connection count or names `lastMapId` (wRoamMons_LastMapGroup/Number,
|
||||
* which UpdateRoamMons avoids repeating). A 1-in-32 draw jumps to any
|
||||
* listed map instead (JumpRoamMon), regardless of the current entry. A
|
||||
* beast whose map is not listed in RoamMaps at all is left in place, as
|
||||
* the original's unmatched `.Update` call (an early `ret`) does; this
|
||||
* should not happen since InitRoamMons only ever starts one on a listed
|
||||
* route. */
|
||||
function updateRoamMon(
|
||||
r: RomReader,
|
||||
mapId: string,
|
||||
lastMapId: string | null,
|
||||
playerMapId: string,
|
||||
random: () => number,
|
||||
): string {
|
||||
const entry = roamMapEntry(r, mapId);
|
||||
if (entry === undefined) return mapId;
|
||||
const count = r.byte(entry + 2),
|
||||
neighbors = entry + 3;
|
||||
for (let tries = 0; ; tries++) {
|
||||
if (tries > DRAW_LIMIT) throw new Error("Random draw limit exceeded.");
|
||||
const draw = random();
|
||||
// JumpRoamMon always reads wMapGroup/wMapNumber (the player's own
|
||||
// position) for its avoid-check, even called from here: it is not
|
||||
// parameterized by which roaming beast asked for it.
|
||||
if (!(draw & 0x1f)) return jumpRoamMon(r, playerMapId, random);
|
||||
const index = draw & 3;
|
||||
if (index >= count) continue;
|
||||
const candidate = `${r.byte(neighbors + index * 2)}:${r.byte(neighbors + index * 2 + 1)}`;
|
||||
if (candidate !== lastMapId) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/** UpdateRoamMons: every roaming beast (map not null, i.e. not
|
||||
* GROUP_N_A/MAP_N_A) moves one step along the RoamMaps graph. Mapsetup
|
||||
* MAPSETUP_CONNECTION, MAPSETUP_DOOR, MAPSETUP_FALL and MAPSETUP_TRAIN all
|
||||
* reach this through the shared byte table `data/maps/setup_scripts.asm`
|
||||
* lets them fall through into, and `BattleEnd_HandleRoamMons` calls it
|
||||
* directly after a non-roaming battle, one time in sixteen. Callers still
|
||||
* owe `_BackUpMapIndices`: the caller's map becomes `lastMapId` for the
|
||||
* next call. */
|
||||
export function updateRoamMons(
|
||||
r: RomReader,
|
||||
roamMons: readonly RoamMon[],
|
||||
lastMapId: string | null,
|
||||
playerMapId: string,
|
||||
random: () => number,
|
||||
): RoamMon[] {
|
||||
return roamMons.map((mon) =>
|
||||
mon.mapId === null
|
||||
? mon
|
||||
: { ...mon, mapId: updateRoamMon(r, mon.mapId, lastMapId, playerMapId, random) },
|
||||
);
|
||||
}
|
||||
|
||||
/** JumpRoamMons: every roaming beast (map not null) jumps to a uniformly
|
||||
* random RoamMaps entry other than the map named by `currentMapId`
|
||||
* (the player's own map at the time of the call). Mapsetup MAPSETUP_FLY
|
||||
* and MAPSETUP_TELEPORT both reach this before falling through into the
|
||||
* same MAPSETUP_WARP bytes that resolve the spawn point itself; the title
|
||||
* screen's Continue also calls it directly, once, before MAPSETUP_CONTINUE
|
||||
* even runs — the well-known trick of saving and reloading to relocate the
|
||||
* beasts. Like UpdateRoamMons, the caller still owes `_BackUpMapIndices`. */
|
||||
export function jumpRoamMons(
|
||||
r: RomReader,
|
||||
roamMons: readonly RoamMon[],
|
||||
currentMapId: string,
|
||||
random: () => number,
|
||||
): RoamMon[] {
|
||||
return roamMons.map((mon) =>
|
||||
mon.mapId === null
|
||||
? mon
|
||||
: { ...mon, mapId: jumpRoamMon(r, currentMapId, random) },
|
||||
);
|
||||
}
|
||||
|
||||
export function validRoamMon(value: unknown): value is RoamMon {
|
||||
const mon = value as RoamMon;
|
||||
return (
|
||||
Boolean(mon) &&
|
||||
typeof mon === "object" &&
|
||||
Number.isInteger(mon.species) &&
|
||||
mon.species >= 0 &&
|
||||
mon.species <= 251 &&
|
||||
Number.isInteger(mon.level) &&
|
||||
mon.level >= 0 &&
|
||||
mon.level <= 100 &&
|
||||
(mon.mapId === null ||
|
||||
(typeof mon.mapId === "string" && /^\d{1,2}:\d{1,3}$/.test(mon.mapId))) &&
|
||||
Number.isInteger(mon.hp) &&
|
||||
mon.hp >= 0 &&
|
||||
mon.hp <= 255 &&
|
||||
Number.isInteger(mon.dvs) &&
|
||||
mon.dvs >= 0 &&
|
||||
mon.dvs <= 0xffff
|
||||
);
|
||||
}
|
||||
|
||||
/** A new game's three wRoamMon structs: no species, GROUP_N_A/MAP_N_A
|
||||
* (null), as `engine/menus/intro_menu.asm`'s new-game reset leaves them
|
||||
* until InitRoamMons starts Raikou and Entei roaming. */
|
||||
export function emptyRoamMons(): RoamMon[] {
|
||||
return [
|
||||
{ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 },
|
||||
{ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 },
|
||||
{ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 },
|
||||
];
|
||||
}
|
||||
+220
-18
@@ -9,6 +9,7 @@ import {
|
||||
ENGINE_FLAGS,
|
||||
EVENTS,
|
||||
INITIAL_POSITION,
|
||||
JOHTO,
|
||||
NUM_FRUIT_TREES,
|
||||
OVERWORLD,
|
||||
PC,
|
||||
@@ -61,6 +62,13 @@ import { IntroRun } from "../scripts/intro";
|
||||
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
|
||||
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
|
||||
import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers";
|
||||
import {
|
||||
checkEncounterRoamMon,
|
||||
emptyRoamMons,
|
||||
jumpRoamMons,
|
||||
updateRoamMons,
|
||||
validRoamMon,
|
||||
} from "./roam-mons";
|
||||
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
|
||||
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
|
||||
import {
|
||||
@@ -89,6 +97,7 @@ import {
|
||||
} from "../party/storage";
|
||||
import {
|
||||
NICKNAME_PATTERN,
|
||||
PARTY_LENGTH,
|
||||
caughtData,
|
||||
validPartyMon,
|
||||
type PartyMon,
|
||||
@@ -100,6 +109,7 @@ import {
|
||||
type BattleEffect,
|
||||
type BattleInput,
|
||||
type BattleOutcome,
|
||||
type RoamMon,
|
||||
} from "../battle/battle";
|
||||
import { C } from "../battle/constants";
|
||||
import {
|
||||
@@ -188,8 +198,19 @@ export interface NativeSave {
|
||||
/** RTC day countdowns by name, and the Lucky Number show's number. */
|
||||
timers: Partial<Record<DayTimerName, DayTimer>>;
|
||||
luckyNumber: { id: number; day: number };
|
||||
/** wRoamMon1/2/3 (Raikou, Entei, Suicune). */
|
||||
roamMons: RoamMon[];
|
||||
/** wRoamMons_CurMapGroup/Number, kept from the last UpdateRoamMons or
|
||||
* JumpRoamMons call. Null before the first one (see ScriptState). */
|
||||
roamMonsMap: string | null;
|
||||
/** The option bytes, saved with the game as sOptions is. */
|
||||
options: GameOptions;
|
||||
/** wParkBallsRemaining/wContestMon and wBugContestStartTime's deadline,
|
||||
* while a Bug-Catching Contest runs; absent otherwise. */
|
||||
bugContest?: { parkBalls: number; mon: PartyMon | null };
|
||||
bugContestEnd?: number;
|
||||
/** The party slots a running contest hid (see ScriptState). */
|
||||
contestLeftParty?: PartyMon[];
|
||||
}
|
||||
|
||||
/** A random byte from the platform's secure generator. */
|
||||
@@ -339,8 +360,10 @@ export type Choice =
|
||||
| { kind: "options"; view: OptionsView; selected: 0 }
|
||||
/** The Fly map, answered with the button pressed. */
|
||||
| { kind: "flymap"; view: FlyMapView; selected: 0 }
|
||||
/** A tile screen (a minigame), answered with the button pressed. */
|
||||
| { kind: "screen"; screen: TileScreen; selected: 0 }
|
||||
/** A tile screen (a minigame), answered with the button pressed. A
|
||||
* `realtime` screen (the Game Corner) advances on its own every tick
|
||||
* instead of waiting for an answer; see `bufferScreenButton`. */
|
||||
| { kind: "screen"; screen: TileScreen; selected: 0; realtime?: boolean }
|
||||
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
|
||||
| {
|
||||
kind: "martquantity";
|
||||
@@ -730,6 +753,11 @@ export class GameWorld {
|
||||
/** The EMOTE_* whose graphics are in the emote bubble's tiles, which
|
||||
* show_emote displays. */
|
||||
private bubbleEmote = 0;
|
||||
/** Buttons pressed while a realtime screen (the Game Corner) is open,
|
||||
* consumed one per screen frame instead of answering it immediately, as
|
||||
* SlotsLoop and CardFlip's MasterLoop poll the joypad once per their own
|
||||
* video frame. */
|
||||
private screenInput: string[] = [];
|
||||
|
||||
constructor(
|
||||
readonly assets: CrystalAssets,
|
||||
@@ -1455,6 +1483,10 @@ export class GameWorld {
|
||||
if (FACE_DOWN_ON_WARP.has(destination.collision))
|
||||
this.player.direction = "down";
|
||||
this.applyMap(destination.entry);
|
||||
// WarpToNewMapScript's MAPSETUP_DOOR and FallIntoMapScript's
|
||||
// MAPSETUP_FALL both fall through into MAPSETUP_TRAIN's own
|
||||
// mapsetup UpdateRoamMons.
|
||||
this.applyRoamMons(this.state, destination.entry.id, false);
|
||||
// RefreshMapSprites: a warp leaves the player standing, with the sprite
|
||||
// CheckUpdatePlayerSprite chooses.
|
||||
this.turning = 0;
|
||||
@@ -1540,6 +1572,8 @@ export class GameWorld {
|
||||
player.x = target.x;
|
||||
player.y = target.y;
|
||||
this.applyMap(entry);
|
||||
// MapSetupScript_Connection's own mapsetup UpdateRoamMons.
|
||||
this.applyRoamMons(this.state, target.map.id, false);
|
||||
return;
|
||||
}
|
||||
// PlayerEvents: CheckTrainerEvent precedes CheckTileEvent, and a seen
|
||||
@@ -1638,7 +1672,8 @@ export class GameWorld {
|
||||
if (state.repel && chosen.level < lead.level) return;
|
||||
this.startScript(SYMBOLS.wildBattleScript, PLAYER, {
|
||||
committedPosition: previous,
|
||||
wild: chosen,
|
||||
wild: { species: chosen.species, level: chosen.level },
|
||||
roaming: Boolean(chosen.roamer),
|
||||
});
|
||||
// The step replays after a reload, so it keeps its Repel step.
|
||||
if (state.repel && this.script) this.script.committed.repel++;
|
||||
@@ -1696,13 +1731,21 @@ export class GameWorld {
|
||||
}
|
||||
/** ChooseWildEncounter: 1-100 against GrassMonProbTable or
|
||||
* WaterMonProbTable, whose entries are (percent, slot offset); surfing
|
||||
* varies the level. Undefined when no valid Pokémon is chosen. */
|
||||
* varies the level. Undefined when no valid Pokémon is chosen.
|
||||
* CheckEncounterRoamMon runs first (never on water) and, when a roaming
|
||||
* beast is standing on this map, can hijack the whole pick. */
|
||||
private chooseWildEncounter({
|
||||
water,
|
||||
grass,
|
||||
sea,
|
||||
}: ReturnType<GameWorld["encounterTable"]>) {
|
||||
}: ReturnType<GameWorld["encounterTable"]>): { species: number; level: number; roamer?: RoamMon } | undefined {
|
||||
if (!grass && !sea) return undefined;
|
||||
if (!water) {
|
||||
const roamer = checkEncounterRoamMon(this.state.roamMons, this.mapId, () =>
|
||||
this.random(),
|
||||
);
|
||||
if (roamer) return { species: roamer.species, level: roamer.level, roamer };
|
||||
}
|
||||
let roll: number;
|
||||
do roll = this.random();
|
||||
while (roll >= 100);
|
||||
@@ -1731,6 +1774,21 @@ export class GameWorld {
|
||||
return undefined;
|
||||
return chosen;
|
||||
}
|
||||
/** UpdateRoamMons (`jump` false) or JumpRoamMons (`jump` true), then
|
||||
* _BackUpMapIndices: `currentMap` becomes `roamMonsMap` for the next
|
||||
* call, regardless of whether any roaming Pokémon actually moved. */
|
||||
private applyRoamMons(
|
||||
state: ScriptState,
|
||||
currentMap: string,
|
||||
jump: boolean,
|
||||
) {
|
||||
const r = this.assets.reader,
|
||||
random = () => this.random();
|
||||
state.roamMons = jump
|
||||
? jumpRoamMons(r, state.roamMons, currentMap, random)
|
||||
: updateRoamMons(r, state.roamMons, state.roamMonsMap, currentMap, random);
|
||||
state.roamMonsMap = currentMap;
|
||||
}
|
||||
/** CanEncounterWildMon: wildoff; caves and dungeons need no grass; never
|
||||
* on ice. */
|
||||
private canEncounterWildMon(state: ScriptState) {
|
||||
@@ -2087,6 +2145,7 @@ export class GameWorld {
|
||||
timeOfDay: () => this.clockTimeOfDay,
|
||||
clock: () => this.clock(),
|
||||
clockDay: () => this.gameNow().getDay(),
|
||||
gameNow: () => this.gameNow().getTime(),
|
||||
text: () => ({
|
||||
...defaultTextContext(this.name),
|
||||
rival: this.state.rivalName,
|
||||
@@ -2195,10 +2254,13 @@ export class GameWorld {
|
||||
fieldMove,
|
||||
usedItem,
|
||||
fish,
|
||||
roaming,
|
||||
}: {
|
||||
usedItem?: number;
|
||||
/** BATTLETYPE_FISH, set by FishFunction for a hooked Pokémon. */
|
||||
fish?: boolean;
|
||||
/** BATTLETYPE_ROAMING, set by CheckEncounterRoamMon. */
|
||||
roaming?: boolean;
|
||||
fieldMove?: FieldMoveContext;
|
||||
committedPosition?: { x: number; y: number };
|
||||
scene?: boolean;
|
||||
@@ -2232,6 +2294,7 @@ export class GameWorld {
|
||||
fieldMove,
|
||||
usedItem,
|
||||
fish,
|
||||
roaming,
|
||||
},
|
||||
),
|
||||
committed,
|
||||
@@ -2251,6 +2314,8 @@ export class GameWorld {
|
||||
sighted,
|
||||
fieldMove,
|
||||
};
|
||||
// Any button buffered for an earlier realtime screen cannot still apply.
|
||||
this.screenInput = [];
|
||||
this.pump();
|
||||
}
|
||||
|
||||
@@ -2264,10 +2329,12 @@ export class GameWorld {
|
||||
}
|
||||
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
|
||||
useSelect(): boolean {
|
||||
if (
|
||||
this.dialogue?.choice?.kind === "pokegear" ||
|
||||
this.dialogue?.choice?.kind === "screen"
|
||||
) {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (choice?.kind === "screen" && choice.realtime) {
|
||||
this.bufferScreenButton("select");
|
||||
return true;
|
||||
}
|
||||
if (choice?.kind === "pokegear" || choice?.kind === "screen") {
|
||||
this.choose("select");
|
||||
return true;
|
||||
}
|
||||
@@ -2280,10 +2347,14 @@ export class GameWorld {
|
||||
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
|
||||
* the player stands still. Its changes commit when it closes. */
|
||||
openStartMenu(select = false): boolean {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (!select && choice?.kind === "screen" && choice.realtime) {
|
||||
this.bufferScreenButton("start");
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!select &&
|
||||
(this.dialogue?.choice?.kind === "pokegear" ||
|
||||
this.dialogue?.choice?.kind === "screen")
|
||||
(choice?.kind === "pokegear" || choice?.kind === "screen")
|
||||
) {
|
||||
this.choose("start");
|
||||
return true;
|
||||
@@ -2577,6 +2648,12 @@ export class GameWorld {
|
||||
fish: effect.fish,
|
||||
battleType: effect.battleType,
|
||||
contest: draft.bugContest,
|
||||
// GetRoamMonSpecies and the like identify a roaming battle's own
|
||||
// struct by species, exactly as the original's helpers do.
|
||||
roamer:
|
||||
effect.battleType === C.BATTLETYPE_ROAMING
|
||||
? draft.roamMons.find((mon) => mon.species === effect.wild?.species)
|
||||
: undefined,
|
||||
mapId: this.mapId,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bag: draft.bag,
|
||||
@@ -2625,7 +2702,12 @@ export class GameWorld {
|
||||
this.dialogue = {
|
||||
pages: [""],
|
||||
index: 0,
|
||||
choice: { kind: "screen", screen: effect.screen, selected: 0 },
|
||||
choice: {
|
||||
kind: "screen",
|
||||
screen: effect.screen,
|
||||
selected: 0,
|
||||
...(effect.realtime && { realtime: true }),
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "fly":
|
||||
@@ -3125,7 +3207,7 @@ export class GameWorld {
|
||||
private finishScript() {
|
||||
const script = this.script!;
|
||||
if (script.warp) {
|
||||
const { map, x, y, facing, door } = script.warp;
|
||||
const { map, x, y, facing, door, roam } = script.warp;
|
||||
const target = this.assets.isPorted(map)
|
||||
? this.assets.map(map)
|
||||
: undefined;
|
||||
@@ -3146,6 +3228,7 @@ export class GameWorld {
|
||||
motion: undefined,
|
||||
};
|
||||
this.applyMap(entry);
|
||||
if (roam) this.applyRoamMons(this.state, map, roam === "jump");
|
||||
this.turning = 0;
|
||||
this.checkUpdatePlayerSprite(this.state);
|
||||
} else {
|
||||
@@ -3249,12 +3332,22 @@ export class GameWorld {
|
||||
if (next.done) {
|
||||
if (this.script) {
|
||||
const draft = this.script.run.state,
|
||||
{ prizeMoney = 0, momMoney = 0, payDayMoney = 0 } = next.value;
|
||||
{
|
||||
prizeMoney = 0,
|
||||
momMoney = 0,
|
||||
payDayMoney = 0,
|
||||
updateRoamMons,
|
||||
} = next.value;
|
||||
draft.money = Math.min(
|
||||
C.MAX_MONEY,
|
||||
draft.money + prizeMoney + payDayMoney,
|
||||
);
|
||||
draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
|
||||
// CleanUpBattleRAM's callfar UpdateRoamMons, which the original
|
||||
// runs after EvolveAfterBattle below; the two touch unrelated
|
||||
// state, so committing it here first changes nothing observable.
|
||||
if (updateRoamMons)
|
||||
this.applyRoamMons(draft, this.mapId, false);
|
||||
}
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
@@ -3367,6 +3460,17 @@ export class GameWorld {
|
||||
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
||||
this.state.dailyReset = day;
|
||||
}
|
||||
/** CheckBugContestTimer, from CheckTimeEvents while
|
||||
* STATUSFLAGS2_BUG_CONTEST_TIMER_F is set: GiveParkBalls' deadline
|
||||
* replaces the ROM's wBugContestStartTime day/hour/min/sec struct (see
|
||||
* ScriptState.bugContestEnd); past it, BugCatchingContestOverScript
|
||||
* runs in place of the player's own input this tick. */
|
||||
private checkBugContestTimer() {
|
||||
const end = this.state.bugContestEnd;
|
||||
if (end === undefined || this.gameNow(this.state.clock).getTime() < end)
|
||||
return;
|
||||
this.startScript(JOHTO.BugCatchingContestOverScript, PLAYER);
|
||||
}
|
||||
// ---------------------------------------------------------------- NPCs
|
||||
|
||||
/** HandleNPCStep: every spawned object runs its step type once per tick
|
||||
@@ -3636,6 +3740,19 @@ export class GameWorld {
|
||||
if (object.kind !== OBJECTTYPE_TRAINER || !this.spawned.has(object.id))
|
||||
continue;
|
||||
if (this.refusedSighting?.id === object.id) continue;
|
||||
// BurnedTower1F's rival is a trainer whose script is ObjectEvent, not
|
||||
// a TrainerHeader. Once its battle has set its own flag, it still
|
||||
// faces the player beside the hole, and the original's "beaten" check
|
||||
// reads ObjectEvent's bytes as flag $f251: through echo RAM, bit 1 of
|
||||
// wNumKeyItems, which is set with the two or three key items usual
|
||||
// here (BICYCLE, SQUIRTBOTTLE, COIN CASE). The port skips it as that
|
||||
// case does; what the original does otherwise has not been checked.
|
||||
if (
|
||||
object.script === OVERWORLD.ObjectEvent &&
|
||||
object.eventFlag !== 0xffff &&
|
||||
this.state.events.has(object.eventFlag)
|
||||
)
|
||||
continue;
|
||||
const actor = this.actor(object);
|
||||
// FacingPlayerDistance: same row or column, facing the player.
|
||||
let distance: number, facing: Direction;
|
||||
@@ -3733,6 +3850,13 @@ export class GameWorld {
|
||||
}
|
||||
const dialogue = this.dialogue;
|
||||
if (dialogue) {
|
||||
if (dialogue.choice?.kind === "screen" && dialogue.choice.realtime) {
|
||||
// SlotsLoop/CardFlip's MasterLoop poll the joypad and redraw once
|
||||
// per video frame; two frames run per overworld tick, as elsewhere.
|
||||
this.advanceScreenFrame();
|
||||
this.advanceScreenFrame();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
dialogue.holdTicks !== undefined &&
|
||||
dialogue.index === dialogue.pages.length - 1
|
||||
@@ -3827,7 +3951,10 @@ export class GameWorld {
|
||||
}
|
||||
this.checkScene();
|
||||
if (this.busy) return;
|
||||
this.checkDailyReset();
|
||||
// CheckTimeEvents: a running contest skips the daily path entirely.
|
||||
if (this.state.bugContest) this.checkBugContestTimer();
|
||||
else this.checkDailyReset();
|
||||
if (this.busy) return;
|
||||
// PlayerEvents precede HandleMapObjects: a step starts and moves at once.
|
||||
const stepped = this.beginStep(held);
|
||||
if (this.script || this.battle || this.dialogue) return;
|
||||
@@ -4312,7 +4439,10 @@ export class GameWorld {
|
||||
(dialogue.choice.kind === "flymap" &&
|
||||
!FLY_MAP_BUTTONS.includes(answer as never)) ||
|
||||
(dialogue.choice.kind === "screen" &&
|
||||
!SCREEN_BUTTONS.includes(answer as never))
|
||||
!SCREEN_BUTTONS.includes(answer as never) &&
|
||||
// A realtime screen's own frame-advance passes "" for a frame with
|
||||
// no button pressed, which SCREEN_BUTTONS does not list.
|
||||
!(dialogue.choice.realtime && answer === ""))
|
||||
)
|
||||
return;
|
||||
this.townMap = false;
|
||||
@@ -4321,6 +4451,20 @@ export class GameWorld {
|
||||
this.dialogue = undefined;
|
||||
this.pump(answer);
|
||||
}
|
||||
/** A button pressed while a realtime screen is open: buffered for its
|
||||
* next frame rather than answered at once. Bounded so a screen left open
|
||||
* while busy elsewhere cannot grow this without limit. */
|
||||
bufferScreenButton(button: string) {
|
||||
if (this.screenInput.length < 8) this.screenInput.push(button);
|
||||
}
|
||||
/** One video frame of a realtime screen (SlotsLoop, CardFlip's
|
||||
* MasterLoop): resume it with the next buffered button, or none. */
|
||||
private advanceScreenFrame() {
|
||||
const choice = this.dialogue?.choice;
|
||||
if (!this.script || !choice || choice.kind !== "screen" || !choice.realtime)
|
||||
return;
|
||||
this.choose(this.screenInput.shift() ?? "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- saves
|
||||
|
||||
@@ -4398,10 +4542,25 @@ export class GameWorld {
|
||||
firstUnownSeen: state.firstUnownSeen,
|
||||
timers: structuredClone(state.timers),
|
||||
luckyNumber: { ...state.luckyNumber },
|
||||
roamMons: structuredClone(state.roamMons),
|
||||
roamMonsMap: state.roamMonsMap,
|
||||
options: { ...state.options },
|
||||
...(state.bugContest && {
|
||||
bugContest: {
|
||||
parkBalls: state.bugContest.parkBalls,
|
||||
mon: structuredClone(state.bugContest.mon),
|
||||
},
|
||||
bugContestEnd: state.bugContestEnd,
|
||||
}),
|
||||
...(state.contestLeftParty && {
|
||||
contestLeftParty: structuredClone(state.contestLeftParty),
|
||||
}),
|
||||
};
|
||||
}
|
||||
restore(value: unknown) {
|
||||
/** Load a save. `continued` is Continue from the title menu, which
|
||||
* relocates the roaming beasts; a rollback to a captured state passes
|
||||
* false so the state comes back exactly. */
|
||||
restore(value: unknown, continued = true) {
|
||||
const save = migrateSave(value, () => (this.random() << 8) | this.random());
|
||||
const integer = (
|
||||
value: unknown,
|
||||
@@ -4539,7 +4698,28 @@ export class GameWorld {
|
||||
!save.luckyNumber ||
|
||||
!integer(save.luckyNumber.id, 0, 0xffff) ||
|
||||
!integer(save.luckyNumber.day, 0, RTC_DAYS) ||
|
||||
!validOptions(save.options)
|
||||
!Array.isArray(save.roamMons) ||
|
||||
save.roamMons.length !== 3 ||
|
||||
!save.roamMons.every(validRoamMon) ||
|
||||
(save.roamMonsMap !== null &&
|
||||
(typeof save.roamMonsMap !== "string" ||
|
||||
!/^\d{1,2}:\d{1,3}$/.test(save.roamMonsMap))) ||
|
||||
!validOptions(save.options) ||
|
||||
(save.bugContest === undefined) !== (save.bugContestEnd === undefined) ||
|
||||
(save.bugContest !== undefined &&
|
||||
(typeof save.bugContest !== "object" ||
|
||||
save.bugContest === null ||
|
||||
!integer(save.bugContest.parkBalls, 0, 20) ||
|
||||
(save.bugContest.mon !== null &&
|
||||
!validPartyMon(this.assets.pokemon, save.bugContest.mon)))) ||
|
||||
(save.bugContestEnd !== undefined &&
|
||||
!integer(save.bugContestEnd, 0, Number.MAX_SAFE_INTEGER)) ||
|
||||
(save.contestLeftParty !== undefined &&
|
||||
(!Array.isArray(save.contestLeftParty) ||
|
||||
save.contestLeftParty.length > PARTY_LENGTH - 1 ||
|
||||
!save.contestLeftParty.every((mon) =>
|
||||
validPartyMon(this.assets.pokemon, mon),
|
||||
)))
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid native save. Emulator snapshots cannot be imported into this port.",
|
||||
@@ -4617,10 +4797,26 @@ export class GameWorld {
|
||||
firstUnownSeen: save.firstUnownSeen,
|
||||
timers: structuredClone(save.timers),
|
||||
luckyNumber: { ...save.luckyNumber },
|
||||
roamMons: structuredClone(save.roamMons),
|
||||
roamMonsMap: save.roamMonsMap,
|
||||
options: { ...save.options },
|
||||
specialPhoneCall: save.specialPhoneCall,
|
||||
rivalName: save.rivalName,
|
||||
...(save.bugContest && {
|
||||
bugContest: {
|
||||
parkBalls: save.bugContest.parkBalls,
|
||||
mon: structuredClone(save.bugContest.mon),
|
||||
},
|
||||
bugContestEnd: save.bugContestEnd,
|
||||
}),
|
||||
...(save.contestLeftParty && {
|
||||
contestLeftParty: structuredClone(save.contestLeftParty),
|
||||
}),
|
||||
};
|
||||
// Continue's own farcall JumpRoamMons, run directly before
|
||||
// MAPSETUP_CONTINUE itself: the well-known trick of saving and
|
||||
// reloading the game to relocate the roaming beasts.
|
||||
if (continued) this.applyRoamMons(this.state, save.map, true);
|
||||
let entry;
|
||||
try {
|
||||
entry = this.prepareMap(save.map, "continue");
|
||||
@@ -4756,6 +4952,10 @@ export function migrateSave(
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
}),
|
||||
...(!Object.hasOwn(save, "roamMons") && {
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
}),
|
||||
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
||||
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
|
||||
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
|
||||
@@ -4815,6 +5015,8 @@ export function migrateSave(
|
||||
firstUnownSeen: 0,
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
roamMons: emptyRoamMons(),
|
||||
roamMonsMap: null,
|
||||
intro: false,
|
||||
gameTime: emptyGameTime(),
|
||||
options: defaultOptions(),
|
||||
|
||||
@@ -165,7 +165,10 @@ test("pausing battle freezes messages and importing its backup restores committe
|
||||
await page.locator("#play").click();
|
||||
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "6");
|
||||
const restored = await exportState(page);
|
||||
expect(restored.state).toEqual(state);
|
||||
// Importing continues the game: _BackUpMapIndices records the loaded
|
||||
// map for the roaming Pokémon, and everything else is as committed.
|
||||
expect(restored.state.roamMonsMap).toBe("24:7");
|
||||
expect({ ...restored.state, roamMonsMap: state.roamMonsMap }).toEqual(state);
|
||||
});
|
||||
|
||||
test("an unsupported battle effect closes the UI without persisting partial changes", async ({ page }) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture";
|
||||
import type { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon, calcStats } from "../src/game/party/pokemon";
|
||||
import { withoutPlayTime } from "./world-driver";
|
||||
import { afterRestore, withoutPlayTime } from "./world-driver";
|
||||
|
||||
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const fixture = (scenario: BattleScenario = "win") =>
|
||||
@@ -95,7 +95,8 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
|
||||
expect(world.state.pokedex.caught.has(16)).toBe(false);
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
const reloaded = world.save();
|
||||
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
||||
});
|
||||
|
||||
it("evolves a Pokémon that levelled up once the battle is won", () => {
|
||||
@@ -118,7 +119,8 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
|
||||
expect(world.state.pokedex.caught.has(156)).toBe(true);
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
const reloaded = world.save();
|
||||
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
||||
});
|
||||
|
||||
it("considers only the Pokémon that levelled up (wEvolvableFlags)", () => {
|
||||
@@ -174,7 +176,8 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
|
||||
expect(saved.party[0].statExp.some((value) => value > 0)).toBe(true);
|
||||
expect(saved.party[0].stats).not.toEqual(calcStats(world.assets.pokemon, 155, mon.dvs, 100, saved.party[0].statExp));
|
||||
expect(() => world.restore(saved)).not.toThrow();
|
||||
expect(world.save()).toEqual(saved);
|
||||
const reloaded = world.save();
|
||||
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
||||
const corrupt = structuredClone(saved);
|
||||
corrupt.party[0].stats[1] = 999;
|
||||
expect(() => world.restore(corrupt)).toThrow("Invalid native save");
|
||||
@@ -208,7 +211,8 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
|
||||
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
|
||||
world.restore(committed);
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(world.save()).toEqual(committed);
|
||||
const reloaded = world.save();
|
||||
expect(reloaded).toEqual(afterRestore(committed, reloaded));
|
||||
expect(world.move("right")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
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 { emptyScriptState } from "../src/game/scripts/interpreter";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import type { Direction } from "../src/game/world/types";
|
||||
import { settle, findPath } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
// Item IDs read from constants/item_constants.asm, not reference-checked
|
||||
// here (only ROM/RAM addresses in profile.ts are).
|
||||
const SUN_STONE = 169,
|
||||
EVERSTONE = 112,
|
||||
BERRY = 173,
|
||||
GOLD_BERRY = 174;
|
||||
// CATERPIE and WEEDLE (constants/pokemon_constants.asm): ContestMons'
|
||||
// first two entries, each 20%.
|
||||
const CATERPIE = 10,
|
||||
WEEDLE = 13;
|
||||
|
||||
const ROUTE35_GATE = "10:15",
|
||||
ROUTE36_GATE = "10:17",
|
||||
NATIONAL_PARK = "3:15",
|
||||
CONTEST_PARK = "3:16";
|
||||
// Date#getDay() matches the ROM's SUNDAY..SATURDAY (0-6) directly, as the
|
||||
// rest of the native clock already assumes (world.ts's weekday math).
|
||||
const CONTEST_DAY = "2026-09-22T12:00:00"; // Tuesday
|
||||
const OFF_DAY = "2026-09-25T12:00:00"; // Friday
|
||||
// A COLL_LONG_GRASS patch reachable from the Route 36 gate's warp-in
|
||||
// point (33, 18); the Route 35 gate's own (10, 47) lands in a separate,
|
||||
// grass-less walkable region of this map.
|
||||
const GRASS = { x: 31, y: 17 };
|
||||
const GRASS2 = { x: 30, y: 17 };
|
||||
|
||||
function fixture(options: { day?: string; party?: number[] } = {}) {
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
let now = new Date(options.day ?? CONTEST_DAY);
|
||||
let seed = 7;
|
||||
const forced: number[] = [];
|
||||
const random = () => {
|
||||
if (forced.length) return forced.shift()!;
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
};
|
||||
const world = new GameWorld(assets, () => now, random);
|
||||
const species = options.party ?? [155]; // Cyndaquil: strong enough that nothing faints.
|
||||
world.state.party = species.map((sp) =>
|
||||
generatePartyMon(assets.pokemon, {
|
||||
species: sp,
|
||||
level: 60,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0xffff,
|
||||
caughtData: 0,
|
||||
}),
|
||||
);
|
||||
world.state.money = 3000;
|
||||
return {
|
||||
world,
|
||||
forced,
|
||||
advance: (ms: number) => {
|
||||
now = new Date(now.getTime() + ms);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Teleport straight into a gate or the park, bypassing Route 35/36/37
|
||||
* (opened separately; see the task brief). */
|
||||
function enterAt(
|
||||
world: GameWorld,
|
||||
map: string,
|
||||
x: number,
|
||||
y: number,
|
||||
direction: Direction = "down",
|
||||
) {
|
||||
world.restore({ ...world.save(), map, x, y, direction, objects: [] });
|
||||
}
|
||||
|
||||
/** Walk to a tile, stopping early if a step starts a battle (findPath's
|
||||
* BFS keeps walking around the contest's roaming NPCs). */
|
||||
function walkToOrBattle(world: GameWorld, x: number, y: number) {
|
||||
for (const direction of findPath(world, x, y)) {
|
||||
if (world.battle) return;
|
||||
if (!world.move(direction)) throw new Error(`Blocked moving ${direction}.`);
|
||||
for (let tick = 0; tick < 40 && world.busy && !world.battle; tick++)
|
||||
world.tick();
|
||||
if (world.battle) return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drive a Bug-Catching Contest battle: PARKBALL× on every action prompt,
|
||||
* as the contest's own battle menu requires. */
|
||||
function driveContestBattle(world: GameWorld, switchIn = true) {
|
||||
for (let tick = 0; tick < 20_000; tick++) {
|
||||
const view = world.battle;
|
||||
if (!view) return;
|
||||
const prompt = view.prompt;
|
||||
if (prompt?.kind === "action")
|
||||
world.battleAction({ action: "item", item: 0, slot: 0 });
|
||||
else if (prompt?.kind === "yesno") world.battleChoose(1);
|
||||
else if (prompt?.kind === "contestswitch")
|
||||
world.battleChoose(switchIn ? 1 : 0);
|
||||
else if (prompt?.kind === "name") world.battleName("");
|
||||
else if (prompt?.kind === "stats" || view.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
}
|
||||
throw new Error("The contest battle did not settle.");
|
||||
}
|
||||
|
||||
/** Registers at the Route 35 gate (a one-Pokémon party skips the "leave
|
||||
* the rest behind" question) and lands in the contest map. */
|
||||
function register(world: GameWorld) {
|
||||
enterAt(world, ROUTE35_GATE, 2, 2, "up");
|
||||
world.interact();
|
||||
settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.mapId).toBe(CONTEST_PARK);
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the Bug-Catching Contest", () => {
|
||||
it("the gate's officer offers the contest on Tuesday/Thursday/Saturday", () => {
|
||||
const { world } = fixture({ day: CONTEST_DAY });
|
||||
register(world);
|
||||
expect(world.state.bugContest).toEqual({ parkBalls: 20, mon: null });
|
||||
expect(world.state.bugContestEnd).toBeGreaterThan(Date.parse(CONTEST_DAY));
|
||||
expect(world.state.flags.has(17)).toBe(true); // ENGINE_BUG_CONTEST_TIMER
|
||||
expect(world.x).toBe(10);
|
||||
expect(world.y).toBe(47);
|
||||
});
|
||||
|
||||
it("an off day only explains the contest, without starting one", () => {
|
||||
const { world } = fixture({ day: OFF_DAY });
|
||||
enterAt(world, ROUTE35_GATE, 0, 2, "down");
|
||||
world.interact();
|
||||
const shown = settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(shown.join("\n")).toMatch(/PARK/);
|
||||
expect(world.state.bugContest).toBeUndefined();
|
||||
expect(world.mapId).toBe(ROUTE35_GATE);
|
||||
});
|
||||
|
||||
it("leaves the rest of the party with the contest helper when it has more than one Pokémon", () => {
|
||||
const { world } = fixture({ party: [155, 16] }); // Cyndaquil, Pidgey
|
||||
enterAt(world, ROUTE35_GATE, 2, 2, "up");
|
||||
world.interact();
|
||||
settle(world); // "Would you like to try?" / "use the first Pokémon?" / promptbuttons
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.party).toHaveLength(1);
|
||||
expect(world.state.party[0].species).toBe(155);
|
||||
expect(world.mapId).toBe(CONTEST_PARK);
|
||||
});
|
||||
|
||||
it("refuses to register with a fainted or an Egg first Pokémon", () => {
|
||||
{
|
||||
const { world } = fixture({ party: [155, 16] });
|
||||
world.state.party[0].hp = 0;
|
||||
enterAt(world, ROUTE35_GATE, 2, 2, "up");
|
||||
world.interact();
|
||||
const shown = settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(shown.join("\n")).toMatch(/can't battle/);
|
||||
expect(world.state.bugContest).toBeUndefined();
|
||||
}
|
||||
{
|
||||
const { world } = fixture({ party: [155, 16] });
|
||||
world.state.party[0].egg = true;
|
||||
enterAt(world, ROUTE35_GATE, 2, 2, "up");
|
||||
world.interact();
|
||||
const shown = settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(shown.join("\n")).toMatch(/EGG/);
|
||||
expect(world.state.bugContest).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("catches a bug, catches a second and swaps, then quits early for judging and a prize", () => {
|
||||
const { world, forced } = fixture({ day: CONTEST_DAY });
|
||||
// The Route 36 gate's own registration lands at (33, 18), which (unlike
|
||||
// the Route 35 gate's (10, 47)) can reach this map's grass.
|
||||
enterAt(world, ROUTE36_GATE, 0, 4, "up");
|
||||
world.interact();
|
||||
settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.mapId).toBe(CONTEST_PARK);
|
||||
// SetUpFiveStepWildEncounterCooldown: walk off the five-step cooldown
|
||||
// from entering the map on safe (non-grass) ground before approaching it.
|
||||
walkToOrBattle(world, 28, 18);
|
||||
expect(world.battle).toBeUndefined();
|
||||
// Force the first encounter (long grass: 40% of 255) onto Caterpie
|
||||
// (ContestMons' first 20% entry) at its minimum level.
|
||||
forced.push(0, 0, 0);
|
||||
walkToOrBattle(world, GRASS.x, GRASS.y);
|
||||
expect(world.battle).toBeDefined();
|
||||
driveContestBattle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.bugContest?.mon?.species).toBe(CATERPIE);
|
||||
expect(world.state.bugContest?.parkBalls).toBeLessThan(20);
|
||||
const ballsAfterFirst = world.state.bugContest!.parkBalls;
|
||||
|
||||
// A second catch (the next ContestMons entry, Weedle) offers a swap.
|
||||
forced.push(0, 50, 0);
|
||||
walkToOrBattle(world, GRASS2.x, GRASS2.y);
|
||||
if (!world.battle) walkToOrBattle(world, GRASS.x, GRASS.y);
|
||||
expect(world.battle).toBeDefined();
|
||||
driveContestBattle(world, true);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.bugContest?.mon?.species).toBe(WEEDLE);
|
||||
expect(world.state.bugContest!.parkBalls).toBeLessThan(ballsAfterFirst);
|
||||
|
||||
// QUIT replaces SAVE in the start menu, and PACK is not offered.
|
||||
world.openStartMenu();
|
||||
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
|
||||
expect(menu.kind).toBe("menu");
|
||||
expect(menu.items.some((label) => /PACK/.test(label))).toBe(false);
|
||||
const quitIndex = menu.items.findIndex((label) => /QUIT/.test(label));
|
||||
expect(quitIndex).toBeGreaterThanOrEqual(0);
|
||||
world.choose(quitIndex);
|
||||
expect(world.dialogue?.choice?.kind).toBe("yesno");
|
||||
world.choose(1); // "Do you want to finish now?"
|
||||
// BugContestResultsScript: the ready-to-judge text, third/second/first
|
||||
// place announcements, the player's own prize (or consolation Berry),
|
||||
// the returned party (none left behind here) and the nickname offer.
|
||||
const shown = settle(world, [1, ""]);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(shown.join("\n")).toMatch(/Weedle|WEEDLE/i);
|
||||
expect(world.state.bugContest).toBeUndefined();
|
||||
expect(world.state.bugContestEnd).toBeUndefined();
|
||||
expect(world.state.flags.has(17)).toBe(false);
|
||||
expect(world.mapId).toBe(ROUTE36_GATE);
|
||||
// The judged Weedle joined the party (which had room) with the
|
||||
// National Park as its caught location.
|
||||
const caught = world.state.party.at(-1)!;
|
||||
expect(caught.species).toBe(WEEDLE);
|
||||
expect(caught.caughtData & 0x7f).toBe(0x13); // LANDMARK_NATIONAL_PARK
|
||||
});
|
||||
|
||||
it("ends the contest and prints the judging when the twenty minutes run out", () => {
|
||||
const { world, advance } = fixture({ day: CONTEST_DAY });
|
||||
register(world);
|
||||
advance(21 * 60_000);
|
||||
const shown = settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(shown.join("\n")).toMatch(/time|Time/);
|
||||
expect(world.state.bugContest).toBeUndefined();
|
||||
expect(world.mapId).toBe(ROUTE36_GATE);
|
||||
});
|
||||
|
||||
it("awards first place and the Sun Stone to an outstanding catch", () => {
|
||||
const { world } = fixture({ day: CONTEST_DAY });
|
||||
register(world);
|
||||
// A maximum-level, maximum-stat catch outscores every AI contestant.
|
||||
world.state.bugContest = {
|
||||
parkBalls: 15,
|
||||
mon: generatePartyMon(world.assets.pokemon, {
|
||||
species: 127, // Pinsir: a real ContestMons rare entry, high stats.
|
||||
level: 100,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0xffff,
|
||||
caughtData: 0,
|
||||
}),
|
||||
};
|
||||
const before = world.state.bag.items.find((slot) => slot.item === SUN_STONE)?.quantity ?? 0;
|
||||
world.openStartMenu();
|
||||
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
|
||||
world.choose(menu.items.findIndex((label) => /QUIT/.test(label)));
|
||||
world.choose(1);
|
||||
const shown = settle(world, [1, ""]);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(shown.join("\n")).toMatch(/winner/i);
|
||||
const after = world.state.bag.items.find((slot) => slot.item === SUN_STONE)?.quantity ?? 0;
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("boxes the catch when the party is full, with the National Park landmark", () => {
|
||||
const { world } = fixture({ day: CONTEST_DAY, party: [155, 16, 19, 1, 4, 7] });
|
||||
// Registering with more than one Pokémon leaves the other five with
|
||||
// the helper (ContestDropOffMons); ContestReturnMons restores them
|
||||
// during judging, which is where the party is actually full again.
|
||||
register(world);
|
||||
expect(world.state.party).toHaveLength(1);
|
||||
expect(world.state.contestLeftParty).toHaveLength(5);
|
||||
world.state.bugContest = {
|
||||
parkBalls: 10,
|
||||
mon: generatePartyMon(world.assets.pokemon, {
|
||||
species: CATERPIE,
|
||||
level: 7,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0,
|
||||
caughtData: 0,
|
||||
}),
|
||||
};
|
||||
world.openStartMenu();
|
||||
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
|
||||
world.choose(menu.items.findIndex((label) => /QUIT/.test(label)));
|
||||
world.choose(1);
|
||||
const shown = settle(world, [1, ""]);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.party).toHaveLength(6); // unchanged; went to the box
|
||||
const box = world.state.storage.boxes[world.state.storage.current];
|
||||
expect(box[0]?.species).toBe(CATERPIE);
|
||||
expect(box[0]?.caughtData & 0x7f).toBe(0x13);
|
||||
expect(shown.join("\n")).toMatch(/PC|BOX/i);
|
||||
});
|
||||
|
||||
it("saves and restores mid-contest, keeping the Park Balls, caught mon and deadline", () => {
|
||||
const { world } = fixture({ day: CONTEST_DAY });
|
||||
register(world);
|
||||
world.state.bugContest = {
|
||||
parkBalls: 12,
|
||||
mon: generatePartyMon(world.assets.pokemon, {
|
||||
species: WEEDLE,
|
||||
level: 9,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x1234,
|
||||
caughtData: 0,
|
||||
}),
|
||||
};
|
||||
const saved = world.save();
|
||||
expect(saved.bugContest).toEqual({ parkBalls: 12, mon: world.state.bugContest.mon });
|
||||
expect(saved.bugContestEnd).toBe(world.state.bugContestEnd);
|
||||
|
||||
const assets2 = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const reloaded = new GameWorld(assets2, () => new Date(CONTEST_DAY), () => 0);
|
||||
reloaded.restore(saved);
|
||||
expect(reloaded.notice, reloaded.diagnostic).toBe("");
|
||||
expect(reloaded.state.bugContest).toEqual(world.state.bugContest);
|
||||
expect(reloaded.state.bugContestEnd).toBe(world.state.bugContestEnd);
|
||||
// The deadline survives the reload and still ends the contest on time.
|
||||
const laterSave = { ...reloaded.save() };
|
||||
const assets3 = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
let later = new Date(Date.parse(CONTEST_DAY) + 21 * 60_000);
|
||||
const reloaded2 = new GameWorld(assets3, () => later, () => 0);
|
||||
reloaded2.restore(laterSave);
|
||||
settle(reloaded2, [1, ""]); // accept the judged Weedle's nickname offer
|
||||
expect(reloaded2.notice, reloaded2.diagnostic).toBe("");
|
||||
expect(reloaded2.state.bugContest).toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates an older save with no contest fields into no contest running", () => {
|
||||
const { world } = fixture();
|
||||
const save = world.save() as Record<string, unknown>;
|
||||
delete save.bugContest;
|
||||
delete save.bugContestEnd;
|
||||
const migrated = migrateSave(save, () => 1);
|
||||
expect(migrated).toBeDefined();
|
||||
expect(migrated!.bugContest).toBeUndefined();
|
||||
expect(migrated!.bugContestEnd).toBeUndefined();
|
||||
world.restore(migrated);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.bugContest).toBeUndefined();
|
||||
});
|
||||
|
||||
it("the National Park's own NPCs and items work outside the contest", () => {
|
||||
const { world } = fixture({ day: OFF_DAY });
|
||||
enterAt(world, NATIONAL_PARK, 27, 41, "up"); // facing Teacher1 at (27,40)
|
||||
world.interact();
|
||||
const shown = settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(shown.join("\n")).toMatch(/QUICK CLAW/);
|
||||
const item = world.assets.pokemon.itemName(73); // QUICK_CLAW
|
||||
expect(world.state.bag.items.some((slot) => world.assets.pokemon.itemName(slot.item) === item)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon, validPartyMon } from "../src/game/party/pokemon";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture";
|
||||
import { settle } from "./world-driver";
|
||||
import { afterRestore, settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
@@ -159,7 +159,8 @@ describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the
|
||||
expect(world.state.bag.balls[0].quantity).toBeLessThan(10);
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
const reloaded = world.save();
|
||||
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
||||
expect(validPartyMon(world.assets.pokemon, world.state.party[1])).toBe(true);
|
||||
});
|
||||
|
||||
@@ -219,7 +220,8 @@ describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the
|
||||
expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true);
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
const reloaded = world.save();
|
||||
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
||||
});
|
||||
|
||||
it("keeps the ball when the party and the box are full", () => {
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
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 { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Ecruteak side content with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
|
||||
const ROUTE_36 = "10:3",
|
||||
ROUTE_37 = "10:4",
|
||||
ECRUTEAK = "4:9",
|
||||
ECRUTEAK_ITEMFINDER_HOUSE = "4:8",
|
||||
ECRUTEAK_CENTER = "4:3",
|
||||
ECRUTEAK_GYM = "4:7",
|
||||
BURNED_TOWER_1F = "3:13",
|
||||
BURNED_TOWER_B1F = "3:14";
|
||||
const HARD_STONE = 0x7d,
|
||||
MAGNET = 0x6c,
|
||||
ITEMFINDER = 0x37,
|
||||
ETHER = 0x3f,
|
||||
ULTRA_BALL = 2,
|
||||
HP_UP = 0x1a;
|
||||
const EVENT_GOT_HARD_STONE_FROM_ARTHUR = 0x67,
|
||||
EVENT_GOT_MAGNET_FROM_SUNNY = 0x69,
|
||||
EVENT_GOT_ITEMFINDER = 0x5a;
|
||||
// def_scene_scripts numbers each map's own scenes from 0 in file order:
|
||||
// EcruteakGymForcedToLeaveScene is 0 (the default), EcruteakGymNoopScene
|
||||
// (opened by BurnedTowerB1F's ReleaseTheBeasts) is 1.
|
||||
const SCENE_ECRUTEAKGYM_NOOP = 1;
|
||||
const EVENT_RELEASED_THE_BEASTS = 0x7b,
|
||||
EVENT_BEAT_MORTY = 0x4c0,
|
||||
ENGINE_FOGBADGE = 30;
|
||||
|
||||
/** A world on `date`. */
|
||||
const world = (date: string) => {
|
||||
let seed = 13;
|
||||
return new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => new Date(date),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
};
|
||||
/** Stand beside the object at (x, y) on `map` (across a counter if one
|
||||
* is in between), face it and press A. The first warpTo runs the map's
|
||||
* OBJECTS callback (which the weekday gifts depend on) and settles any
|
||||
* entry scene; repositioning after that with restore(), rather than a
|
||||
* second settled move, reaches the approach tile in the same tick, so a
|
||||
* wandering object (radius > 0) has no window to step away first.
|
||||
* restore() also resets object positions to home, since wandering is
|
||||
* not saved. */
|
||||
const talk = (game: GameWorld, map: string, x: number, y: number) => {
|
||||
const target = game.assets.map(map);
|
||||
const STEP = {
|
||||
up: [0, -1],
|
||||
down: [0, 1],
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
} as const;
|
||||
for (const [face, [dx, dy]] of Object.entries(STEP) as [
|
||||
keyof typeof STEP,
|
||||
readonly [number, number],
|
||||
][]) {
|
||||
const across =
|
||||
game.assets.collision(target, x - dx, y - dy) === 0x90 ? 2 : 1;
|
||||
const px = x - dx * across,
|
||||
py = y - dy * across;
|
||||
if (
|
||||
px < 0 ||
|
||||
py < 0 ||
|
||||
px >= target.width ||
|
||||
py >= target.height ||
|
||||
game.assets.permission(game.assets.collision(target, px, py)) !== 0
|
||||
)
|
||||
continue;
|
||||
try {
|
||||
game.warpTo(map, px, py);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: px, y: py, direction: face });
|
||||
game.interact();
|
||||
return;
|
||||
}
|
||||
throw new Error(`No place to talk to ${x},${y} on ${map}`);
|
||||
};
|
||||
/** Advance to the next choice, keeping the pages shown. */
|
||||
const next = (game: GameWorld, pages: string[] = []) => {
|
||||
for (let tick = 0; tick < 4000; tick++) {
|
||||
const dialogue = game.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||
if (page && pages.at(-1) !== page) pages.push(page);
|
||||
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
|
||||
return dialogue.choice;
|
||||
if (dialogue.index === dialogue.pages.length - 1 && dialogue.holdTicks !== undefined)
|
||||
game.tick();
|
||||
else game.advanceDialogue();
|
||||
} else if (game.busy) game.tick();
|
||||
else return undefined;
|
||||
}
|
||||
throw new Error(`No choice: ${game.diagnostic}`);
|
||||
};
|
||||
/** Accept yes/no prompts and advance text until the interaction ends. */
|
||||
const finish = (game: GameWorld, pages: string[] = []) => {
|
||||
for (let step = 0; step < 200; step++) {
|
||||
const choice = next(game, pages);
|
||||
if (!choice) {
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
return pages.join(" ");
|
||||
}
|
||||
if (choice.kind === "name") game.choose("");
|
||||
else game.choose(choice.kind === "yesno" ? 1 : 0);
|
||||
}
|
||||
throw new Error("Interaction did not finish.");
|
||||
};
|
||||
|
||||
it("gives Arthur's Hard Stone only on Thursday, once met", () => {
|
||||
const notThursday = world("2026-09-22T12:00:00"); // Tuesday
|
||||
talk(notThursday, ROUTE_36, 46, 6); // Arthur is hidden on a non-Thursday.
|
||||
expect(notThursday.notice).toBe("Nothing to interact with here.");
|
||||
|
||||
const game = world("2026-09-24T12:00:00"); // Thursday
|
||||
talk(game, ROUTE_36, 46, 6);
|
||||
let pages: string[] = [];
|
||||
let text = finish(game, pages);
|
||||
expect(text).toMatch(/Who are\s+you/);
|
||||
expect(game.state.events.has(EVENT_GOT_HARD_STONE_FROM_ARTHUR)).toBe(true);
|
||||
expect(game.state.bag.items).toContainEqual({ item: HARD_STONE, quantity: 1 });
|
||||
|
||||
// Talking again on the same day shows the "already have it" text.
|
||||
talk(game, ROUTE_36, 46, 6);
|
||||
pages = [];
|
||||
text = finish(game, pages);
|
||||
expect(text).toMatch(/second son/i);
|
||||
});
|
||||
|
||||
it("gives Sunny's Magnet only on Sunday, once met", () => {
|
||||
const game = world("2026-09-20T12:00:00"); // Sunday
|
||||
talk(game, ROUTE_37, 16, 8);
|
||||
const pages: string[] = [];
|
||||
const text = finish(game, pages);
|
||||
expect(text).toMatch(/SUNNY: Hi!/);
|
||||
expect(game.state.events.has(EVENT_GOT_MAGNET_FROM_SUNNY)).toBe(true);
|
||||
expect(game.state.bag.items).toContainEqual({ item: MAGNET, quantity: 1 });
|
||||
});
|
||||
|
||||
it("gives the Itemfinder from the house south of Tin Tower", () => {
|
||||
const game = world("2026-09-22T12:00:00");
|
||||
talk(game, ECRUTEAK_ITEMFINDER_HOUSE, 2, 3);
|
||||
const pages: string[] = [];
|
||||
const text = finish(game, pages);
|
||||
expect(text).toMatch(/true spirit/i);
|
||||
expect(game.state.events.has(EVENT_GOT_ITEMFINDER)).toBe(true);
|
||||
// ITEMFINDER is a key item (ItemAttributes' pocket byte).
|
||||
expect(game.state.bag.keyItems).toContain(ITEMFINDER);
|
||||
});
|
||||
|
||||
it("answers CheckMobileAdapterStatusSpecial's non-mobile branch at the Pokémon Center", () => {
|
||||
const game = world("2026-09-22T12:00:00");
|
||||
// The default scene (EcruteakPokecenter1FMeetBillScene) plays out and
|
||||
// moves the player on the first visit; let it finish before walking
|
||||
// over to the PokéFan.
|
||||
game.warpTo(ECRUTEAK_CENTER, 3, 7);
|
||||
finish(game);
|
||||
talk(game, ECRUTEAK_CENTER, 7, 6);
|
||||
const text = finish(game);
|
||||
// CheckMobileAdapterStatusSpecial always clears wScriptVar in this
|
||||
// revision (CheckMobileAdapterStatus is the Japanese version's
|
||||
// routine), so the non-mobile KIMONO GIRLS text always shows.
|
||||
expect(text).toMatch(/KIMONO GIRLS/);
|
||||
expect(text).not.toMatch(/OLIVINE CITY/);
|
||||
});
|
||||
|
||||
it("keeps Morty's gym closed until the Burned Tower beasts are released", () => {
|
||||
// EcruteakGymForcedToLeaveScene is the default scene (0); only
|
||||
// BurnedTowerB1F's ReleaseTheBeasts (board #32's roaming increment,
|
||||
// out of scope here) sets SCENE_ECRUTEAKGYM_NOOP to open it. Morty's
|
||||
// own dialogue in Burned Tower 1F explains why: he is investigating
|
||||
// the tower with Eusine instead of running the gym.
|
||||
const game = world("2026-09-22T12:00:00");
|
||||
game.warpTo(ECRUTEAK_GYM, 5, 17);
|
||||
const pages = settle(game);
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
expect(pages.join(" ")).toMatch(/is absent/i);
|
||||
expect(game.mapId).toBe(ECRUTEAK);
|
||||
expect(game.battle).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds Burned Tower 1F's hidden items and HP Up, and cannot smash its rock yet", () => {
|
||||
const game = world("2026-09-22T12:00:00");
|
||||
game.warpTo(BURNED_TOWER_1F, 9, 15);
|
||||
settle(game); // the Eusine scene (sdefer) runs on the first idle tick
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
|
||||
game.restore({ ...game.save(), x: 7, y: 7, direction: "right" });
|
||||
game.interact();
|
||||
finish(game);
|
||||
expect(game.state.bag.items).toContainEqual({ item: ETHER, quantity: 1 });
|
||||
|
||||
game.restore({ ...game.save(), x: 13, y: 12, direction: "up" });
|
||||
game.interact();
|
||||
finish(game);
|
||||
expect(game.state.bag.balls).toContainEqual({ item: ULTRA_BALL, quantity: 1 });
|
||||
|
||||
game.restore({ ...game.save(), x: 14, y: 3, direction: "up" });
|
||||
game.interact();
|
||||
finish(game);
|
||||
expect(game.state.bag.items).toContainEqual({ item: HP_UP, quantity: 1 });
|
||||
|
||||
game.restore({ ...game.save(), x: 15, y: 5, direction: "up" });
|
||||
game.interact();
|
||||
expect(finish(game)).toMatch(/Maybe a.*POKéMON.*break/i);
|
||||
});
|
||||
|
||||
it("asks for Strength at Burned Tower B1F's boulder, which is not obtainable yet", () => {
|
||||
// The TM Endure behind it stays unreachable until a much later card
|
||||
// gives Strength; AskStrengthScript (jumpstd StrengthBoulderScript)
|
||||
// should still answer sensibly rather than refusing outright.
|
||||
const game = world("2026-09-22T12:00:00");
|
||||
game.warpTo(BURNED_TOWER_B1F, 17, 9);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 17, y: 9, direction: "up" });
|
||||
game.interact();
|
||||
expect(finish(game)).toMatch(/may be able to move this/i);
|
||||
});
|
||||
|
||||
it("opens Ecruteak Gym's guide and statue text once Morty is reachable, then again once he is beaten", () => {
|
||||
// BurnedTowerB1F's ReleaseTheBeasts sets SCENE_ECRUTEAKGYM_NOOP; drive
|
||||
// that directly, as the playthrough's own roaming-beast stage (out of
|
||||
// scope for this focused file) otherwise would.
|
||||
const game = world("2026-09-22T12:00:00");
|
||||
game.warpTo(ECRUTEAK_GYM, 5, 17);
|
||||
game.state.scenes[ECRUTEAK_GYM] = SCENE_ECRUTEAKGYM_NOOP;
|
||||
settle(game);
|
||||
expect(game.mapId).toBe(ECRUTEAK_GYM);
|
||||
|
||||
game.restore({ ...game.save(), x: 7, y: 16, direction: "up" }); // the Gym Guide
|
||||
game.interact();
|
||||
let text = finish(game);
|
||||
expect(text).toMatch(/secret mo-?\s*tives/i);
|
||||
|
||||
game.restore({ ...game.save(), x: 3, y: 16, direction: "up" }); // the left statue
|
||||
game.interact();
|
||||
text = finish(game);
|
||||
expect(text).toMatch(/#MON GYM|POKéMON GYM/);
|
||||
expect(text).not.toMatch(/WINNING TRAINERS/);
|
||||
|
||||
// EcruteakGymGuideScript checks EVENT_BEAT_MORTY; EcruteakGymStatue
|
||||
// checks ENGINE_FOGBADGE instead. Beating Morty sets both together,
|
||||
// but this isolated test drives each script without a real battle.
|
||||
game.state.events.add(EVENT_BEAT_MORTY);
|
||||
game.state.flags.add(ENGINE_FOGBADGE);
|
||||
|
||||
game.restore({ ...game.save(), x: 7, y: 16, direction: "up" });
|
||||
game.interact();
|
||||
text = finish(game);
|
||||
expect(text).toMatch(/cowering in/i);
|
||||
|
||||
game.restore({ ...game.save(), x: 6, y: 16, direction: "up" }); // the right statue
|
||||
game.interact();
|
||||
text = finish(game);
|
||||
expect(text).toMatch(/LEADER:\s*MORTY/i);
|
||||
expect(text).toMatch(/WINNING TRAINERS/);
|
||||
});
|
||||
|
||||
it("shows Ecruteak City's beasts-released text once they are released", () => {
|
||||
const before = world("2026-09-22T12:00:00");
|
||||
talk(before, ECRUTEAK, 3, 9); // EcruteakCityLass2 (the old tower)
|
||||
expect(finish(before)).toMatch(/tower that.*used to be here/i);
|
||||
|
||||
const after = world("2026-09-22T12:00:00");
|
||||
after.state.events.add(EVENT_RELEASED_THE_BEASTS);
|
||||
talk(after, ECRUTEAK, 3, 9);
|
||||
expect(finish(after)).toMatch(/ran off in differ-?\s*ent directions/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,588 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { RomReader } from "../src/game/rom/reader";
|
||||
import { PokemonData } from "../src/game/rom/pokemon";
|
||||
import { emptyScriptState, type ScriptEffect } from "../src/game/scripts/interpreter";
|
||||
import { defaultTextContext, readText } from "../src/game/scripts/text";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import {
|
||||
slotMachine,
|
||||
initBias,
|
||||
SLOTS_NO_BIAS,
|
||||
SLOTS_SEVEN,
|
||||
makeReel,
|
||||
spinAllReels,
|
||||
ReelAction,
|
||||
type SlotsSession,
|
||||
} from "../src/game/scripts/slot-machine";
|
||||
import {
|
||||
cardFlip,
|
||||
shuffleDeck,
|
||||
cardFlipPayout,
|
||||
moveCardFlipCursor,
|
||||
} from "../src/game/scripts/card-flip";
|
||||
import type { SpecialContext } from "../src/game/scripts/special-context";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { settle } from "./world-driver";
|
||||
import { JOHTO } from "../src/game/rom/profile";
|
||||
import { SCREEN_WIDTH } from "../src/game/scripts/screen";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
/** A simple deterministic byte stream, like the other seeded-RNG tests. */
|
||||
function seededRandom(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1103515245 + 12345) >>> 0;
|
||||
return (s >>> 16) & 0xff;
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal SpecialContext over the real ROM: enough state for the Game
|
||||
* Corner's coin checks and text, built the same way interpreter.ts's own
|
||||
* `specialContext` is. */
|
||||
function makeContext(
|
||||
reader: RomReader,
|
||||
data: PokemonData,
|
||||
random: () => number,
|
||||
coins: number,
|
||||
): SpecialContext {
|
||||
const state = emptyScriptState();
|
||||
state.coins = coins;
|
||||
state.bag.keyItems.push(C.COIN_CASE);
|
||||
const pages: string[] = [];
|
||||
const strings: Record<number, string> = {},
|
||||
numbers: Record<number, number> = {};
|
||||
// Merge the mutable string/number buffers into the text context, as
|
||||
// interpreter.ts's own `text()` does with `this.strings`/`this.numbers`.
|
||||
const text = () => ({ ...defaultTextContext("CHRIS"), strings, numbers });
|
||||
return {
|
||||
reader,
|
||||
data,
|
||||
state,
|
||||
strings,
|
||||
numbers,
|
||||
text,
|
||||
random,
|
||||
player: () => ({ id: 1, name: "CHRIS" }),
|
||||
today: () => 0,
|
||||
weekday: () => 0,
|
||||
write: (address: number) => {
|
||||
const content = readText(reader, address, text());
|
||||
if (content) pages.push(...content.split("\n\n"));
|
||||
},
|
||||
take: () => pages.splice(0, pages.length),
|
||||
flush: function* () {
|
||||
if (pages.length) yield { kind: "text", pages: pages.splice(0, pages.length) } as ScriptEffect;
|
||||
},
|
||||
selectMon: function* () {
|
||||
return -1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Answer = number | string | undefined;
|
||||
/** Drive a Game Corner generator to completion: `screens` answers "screen"
|
||||
* effects in order (defaulting to A once exhausted, which both confirms a
|
||||
* choice and passes every non-interactive realtime frame); `plays` answers
|
||||
* "yesno" effects in order (defaulting to YES); the bet menu always picks
|
||||
* `bet` coins. */
|
||||
function play<T>(
|
||||
generator: Generator<ScriptEffect, T, Answer>,
|
||||
options: { bet?: number; screens?: string[]; plays?: boolean[] } = {},
|
||||
): T {
|
||||
const screens = [...(options.screens ?? [])];
|
||||
const plays = [...(options.plays ?? [])];
|
||||
let result = generator.next();
|
||||
let steps = 0;
|
||||
while (!result.done) {
|
||||
if (++steps > 300_000) throw new Error("The Game Corner session did not finish.");
|
||||
const effect = result.value;
|
||||
let answer: Answer;
|
||||
if (effect.kind === "menu") answer = options.bet !== undefined ? 3 - options.bet : 1;
|
||||
else if (effect.kind === "yesno") answer = (plays.length ? plays.shift() : true) ? 1 : 0;
|
||||
else if (effect.kind === "screen") answer = screens.length ? screens.shift() : "a";
|
||||
result = generator.next(answer);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
/** ChooseCard_HandleJoypad's board (CollapseCursorPosition: Y*6+X), moved
|
||||
* one cell per press as this port's `moveCardFlipCursor` does. */
|
||||
function moveButtons(fromX: number, fromY: number, toX: number, toY: number): string[] {
|
||||
const buttons: string[] = [];
|
||||
for (let i = 0; i < Math.abs(toY - fromY); i++) buttons.push(toY > fromY ? "down" : "up");
|
||||
for (let i = 0; i < Math.abs(toX - fromX); i++) buttons.push(toX > fromX ? "right" : "left");
|
||||
return buttons;
|
||||
}
|
||||
|
||||
describe("CardFlip_CheckWinCondition's paytable", () => {
|
||||
it("pays the species pair, exact species, level pair, exact level and exact card cells", () => {
|
||||
// Card 0: species 0 (Pikachu), level 0 ("1").
|
||||
expect(cardFlipPayout(0, 0)).toBe(0); // row 0's outer cells always lose
|
||||
expect(cardFlipPayout(1, 0)).toBe(0);
|
||||
expect(cardFlipPayout(2, 0)).toBe(6); // Pikachu/Jigglypuff pair
|
||||
expect(cardFlipPayout(4, 0)).toBe(0); // Poliwag/Oddish pair does not match
|
||||
expect(cardFlipPayout(8, 0)).toBe(12); // row 1, exact species (Pikachu)
|
||||
expect(cardFlipPayout(12, 0)).toBe(9); // row 2, level pair ("1"-"2")
|
||||
expect(cardFlipPayout(13, 0)).toBe(18); // row 2, exact level ("1")
|
||||
expect(cardFlipPayout(14, 0)).toBe(72); // row 2, exact card (Pikachu "1")
|
||||
expect(cardFlipPayout(14, 1)).toBe(0); // the same cell loses for a different card
|
||||
// Card 23: species 3 (Oddish), level 5 ("6").
|
||||
expect(cardFlipPayout(5, 23)).toBe(6); // Poliwag/Oddish pair
|
||||
expect(cardFlipPayout(11, 23)).toBe(12); // row 1, exact species (Oddish)
|
||||
expect(cardFlipPayout(42, 23)).toBe(9); // row 6/7's shared level pair ("5"-"6")
|
||||
expect(cardFlipPayout(43, 23)).toBe(18); // row 7, exact level ("6")
|
||||
expect(cardFlipPayout(47, 23)).toBe(72); // row 7, exact card (Oddish "6")
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChooseCard_HandleJoypad's exact navigation", () => {
|
||||
const at = (cursorX: number, cursorY: number) => ({ cursorX, cursorY });
|
||||
it("snaps left/right to the nearest even cell in the Pokémon-pair row (Y=0)", () => {
|
||||
const session = at(5, 0);
|
||||
moveCardFlipCursor(session, "left");
|
||||
// 5 & 0xe = 4, which is not below 3, so it becomes 4 - 2.
|
||||
expect(session).toEqual({ cursorX: 2, cursorY: 0 });
|
||||
const session2 = at(3, 0);
|
||||
moveCardFlipCursor(session2, "left");
|
||||
// 3 & 0xe = 2, which is below 3, so the cursor jumps to the mon-group
|
||||
// junction cell instead of stopping on the pair row.
|
||||
expect(session2).toEqual({ cursorX: 1, cursorY: 2 });
|
||||
const session3 = at(4, 0);
|
||||
moveCardFlipCursor(session3, "right");
|
||||
// 4 & 0xe = 4, which is not below 4, so right is refused (but the
|
||||
// mask is still applied, a kept quirk of the original).
|
||||
expect(session3).toEqual({ cursorX: 4, cursorY: 0 });
|
||||
});
|
||||
it("snaps to the mon-group junction from the Pokémon-group row (Y=1) too, without masking", () => {
|
||||
const session = at(2, 1);
|
||||
moveCardFlipCursor(session, "left");
|
||||
expect(session).toEqual({ cursorX: 1, cursorY: 2 });
|
||||
const session2 = at(4, 1);
|
||||
moveCardFlipCursor(session2, "left");
|
||||
expect(session2).toEqual({ cursorX: 3, cursorY: 1 });
|
||||
});
|
||||
it("snaps up/down to the nearest even cell in the number-pair column (X=0), or to the number-group junction", () => {
|
||||
const session = at(0, 5);
|
||||
moveCardFlipCursor(session, "up");
|
||||
expect(session).toEqual({ cursorX: 0, cursorY: 2 });
|
||||
const session2 = at(0, 3);
|
||||
moveCardFlipCursor(session2, "up");
|
||||
expect(session2).toEqual({ cursorX: 2, cursorY: 1 });
|
||||
const session3 = at(0, 4);
|
||||
moveCardFlipCursor(session3, "down");
|
||||
expect(session3).toEqual({ cursorX: 0, cursorY: 6 });
|
||||
const session4 = at(0, 6);
|
||||
moveCardFlipCursor(session4, "down");
|
||||
expect(session4).toEqual({ cursorX: 0, cursorY: 6 });
|
||||
});
|
||||
it("moves one cell at a time everywhere else, refusing past either edge", () => {
|
||||
const session = at(3, 5);
|
||||
moveCardFlipCursor(session, "left");
|
||||
expect(session).toEqual({ cursorX: 2, cursorY: 5 });
|
||||
moveCardFlipCursor(session, "right");
|
||||
moveCardFlipCursor(session, "right");
|
||||
expect(session).toEqual({ cursorX: 4, cursorY: 5 });
|
||||
moveCardFlipCursor(session, "right");
|
||||
expect(session).toEqual({ cursorX: 5, cursorY: 5 }); // the last column
|
||||
moveCardFlipCursor(session, "right");
|
||||
expect(session).toEqual({ cursorX: 5, cursorY: 5 }); // refused past it
|
||||
const bottom = at(2, 7);
|
||||
moveCardFlipCursor(bottom, "down");
|
||||
expect(bottom).toEqual({ cursorX: 2, cursorY: 7 }); // the last row, refused
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the slot machine with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const reader = new RomReader(bytes);
|
||||
const data = new PokemonData(reader);
|
||||
|
||||
describe("Slots_InitBias", () => {
|
||||
it("re-rolls a fresh bias every round, except when it is already SLOTS_SEVEN", () => {
|
||||
// Slots_InitBias's own "and a; ret z" guard: a seven bias is never
|
||||
// re-rolled, keeping last round's streak untouched.
|
||||
expect(initBias({ reader, random: () => 0 }, SLOTS_SEVEN, false)).toBe(SLOTS_SEVEN);
|
||||
// 255 falls past every real entry, into the table's own 100% "no
|
||||
// bias" slice for both tables.
|
||||
expect(initBias({ reader, random: () => 255 }, SLOTS_NO_BIAS, false)).toBe(SLOTS_NO_BIAS);
|
||||
expect(initBias({ reader, random: () => 255 }, SLOTS_NO_BIAS, true)).toBe(SLOTS_NO_BIAS);
|
||||
});
|
||||
it("the lucky machine (wScriptVar true) widens the cherry slice well past the normal table's", () => {
|
||||
// Normal's cumulative cherry threshold is 19 percent (48 of 255);
|
||||
// Lucky's is 31 percent + 1 (80 of 255). A byte of 60 falls past
|
||||
// Normal's cherry slice (into "no bias") but still inside Lucky's.
|
||||
const byte = 60;
|
||||
const normal = initBias({ reader, random: () => byte }, SLOTS_NO_BIAS, false);
|
||||
const lucky = initBias({ reader, random: () => byte }, SLOTS_NO_BIAS, true);
|
||||
expect(normal).toBe(SLOTS_NO_BIAS);
|
||||
expect(lucky).not.toBe(SLOTS_NO_BIAS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Golem and Chansey/egg replay (reel 3's real frame-by-frame hops)", () => {
|
||||
const sevenEverywhere = Array<number>(18).fill(SLOTS_SEVEN);
|
||||
// Positions 0 and 15 (the wraparound repeat of 0) are the seven; every
|
||||
// other position (including the reel's own starting position, 14) is
|
||||
// not, so InitGolem's own first check does not already count as a win.
|
||||
const oneSevenReel3 = [0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 0, 4, 4];
|
||||
// Every position but the reel's own starting one (14) is the seven, so
|
||||
// InitChansey's own first check does not already count as a win, but
|
||||
// wherever DropReel's 16-rate spin leaves it afterward still does.
|
||||
const allButStartingSeven = Array<number>(18).fill(SLOTS_SEVEN).map((value, i) => (i === 14 ? 4 : value));
|
||||
function newSession(reel3Tilemap: readonly number[], bias: number): SlotsSession {
|
||||
return {
|
||||
reels: [makeReel(sevenEverywhere), makeReel(sevenEverywhere), makeReel(reel3Tilemap)],
|
||||
bias,
|
||||
bet: 1, // only the middle line, so reel1/reel2's own middle entry decides the match
|
||||
matched: -1,
|
||||
firstTwoReelsMatching: true,
|
||||
firstTwoReelsMatchingSevens: true,
|
||||
buildingMatch: 0,
|
||||
payout: 0,
|
||||
delay: 0,
|
||||
phase: 0,
|
||||
joypadSumA: false,
|
||||
flashFrame: 0,
|
||||
resultLines: [],
|
||||
};
|
||||
}
|
||||
function runUntilStopped(session: SlotsSession) {
|
||||
const host = { reader, random: () => 0 };
|
||||
for (let tick = 0; tick < 5000 && session.reels[2].action !== ReelAction.DoNothing; tick++)
|
||||
spinAllReels(session, host);
|
||||
if (session.reels[2].action !== ReelAction.DoNothing)
|
||||
throw new Error("Reel 3 never stopped.");
|
||||
}
|
||||
|
||||
it("Golem's replay advances reel 3 by exactly the probe's real hop count, not the probe's own discarded position", () => {
|
||||
const session = newSession(oneSevenReel3, SLOTS_SEVEN);
|
||||
const reel3 = session.reels[2];
|
||||
reel3.action = ReelAction.InitGolem;
|
||||
const startPosition = reel3.position;
|
||||
runUntilStopped(session);
|
||||
// Slots_GetNumberOfGolems' seven-bias probe (reel.position starts at
|
||||
// 14): the very first incremented position (raw 15, the tilemap's
|
||||
// own wraparound repeat of index 0) already matches, so the probe
|
||||
// reports one hop, and reel 3's real position should have advanced
|
||||
// by exactly that one hop for real.
|
||||
expect(reel3.position).toBe((startPosition + 1) % 15);
|
||||
expect(session.matched).toBe(SLOTS_SEVEN);
|
||||
expect(session.golem).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Chansey's replay walks in, lays the egg and lets DropReel stop reel 3 on seven", () => {
|
||||
const session = newSession(allButStartingSeven, SLOTS_NO_BIAS); // Chansey always targets seven, regardless of bias
|
||||
const reel3 = session.reels[2];
|
||||
reel3.action = ReelAction.InitChansey;
|
||||
runUntilStopped(session);
|
||||
expect(session.matched).toBe(SLOTS_SEVEN);
|
||||
// The egg always finishes its bounce (and clears itself) before
|
||||
// DropReel's own match check runs, whether that check succeeds or
|
||||
// (EggAgain) loops back through Chansey for another egg.
|
||||
expect(session.egg).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("spins through a seeded random source, over several rounds, to a payout", () => {
|
||||
const context = makeContext(reader, data, seededRandom(7), 150);
|
||||
const rounds = 30;
|
||||
const plays = Array<boolean>(rounds - 1).fill(true).concat([false]);
|
||||
play(slotMachine(context, true), { bet: 3, plays });
|
||||
// 30 rounds at 3 coins each (90 total) must have paid out at least
|
||||
// once for the balance to end this high; a run that only ever lost
|
||||
// could not exceed 150 - 90.
|
||||
expect(context.state.coins).toBeGreaterThan(150 - rounds * 3);
|
||||
});
|
||||
|
||||
it("the lucky machine plays a full round without refusing", () => {
|
||||
const context = makeContext(reader, data, seededRandom(21), 50);
|
||||
play(slotMachine(context, true), { bet: 1, plays: [false] });
|
||||
expect(context.state.coins).toBeGreaterThanOrEqual(0);
|
||||
expect(context.state.coins).toBeLessThanOrEqual(C.MAX_COINS);
|
||||
});
|
||||
|
||||
it("refuses to start with no coins, and takes nothing", () => {
|
||||
const context = makeContext(reader, data, seededRandom(1), 0);
|
||||
play(slotMachine(context, false), {});
|
||||
expect(context.state.coins).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses to start without a Coin Case, even with coins", () => {
|
||||
const context = makeContext(reader, data, seededRandom(1), 50);
|
||||
context.state.bag.keyItems = [];
|
||||
play(slotMachine(context, false), {});
|
||||
expect(context.state.coins).toBe(50);
|
||||
});
|
||||
|
||||
it("the coin case never exceeds 9999 while paying out", () => {
|
||||
const context = makeContext(reader, data, seededRandom(13), 9990);
|
||||
const rounds = 40;
|
||||
const plays = Array<boolean>(rounds - 1).fill(true).concat([false]);
|
||||
play(slotMachine(context, true), { bet: 3, plays });
|
||||
expect(context.state.coins).toBeLessThanOrEqual(C.MAX_COINS);
|
||||
});
|
||||
|
||||
it("quitting the bet menu (B) ends the game without spending coins", () => {
|
||||
const context = makeContext(reader, data, seededRandom(9), 20);
|
||||
const generator = slotMachine(context, false);
|
||||
const first = generator.next();
|
||||
expect(first.done).toBe(false);
|
||||
const second = generator.next(-1); // B cancels Slots_AskBet's menu
|
||||
expect(second.done).toBe(true);
|
||||
expect(context.state.coins).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Card Flip with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const reader = new RomReader(bytes);
|
||||
const data = new PokemonData(reader);
|
||||
|
||||
it("a round the player bets on the dealt card's exact species wins", () => {
|
||||
const deck = shuffleDeck({ random: seededRandom(5) });
|
||||
const species = deck[0] & 3;
|
||||
const context = makeContext(reader, data, seededRandom(5), 50);
|
||||
const buttons = ["a", ...moveButtons(2, 2, 2 + species, 1), "a"];
|
||||
play(cardFlip(context), { screens: buttons, plays: [true, false] });
|
||||
expect(context.state.coins).toBe(50 - 3 + 12);
|
||||
});
|
||||
|
||||
it("a round the player bets on the wrong exact species and loses", () => {
|
||||
// ChooseCard_HandleJoypad's own snapping keeps the cursor off the
|
||||
// board's four truly "impossible" cells (row 0-1, col 0-1) from any
|
||||
// reachable approach, so this port's exact translation of it cannot
|
||||
// reach one either; betting the wrong exact species is an equally
|
||||
// sure loss that stays reachable.
|
||||
const deck = shuffleDeck({ random: seededRandom(6) });
|
||||
const wrongSpecies = ((deck[0] & 3) + 1) % 4;
|
||||
const context = makeContext(reader, data, seededRandom(6), 50);
|
||||
const buttons = ["a", ...moveButtons(2, 2, 2 + wrongSpecies, 1), "a"];
|
||||
play(cardFlip(context), { screens: buttons, plays: [true, false] });
|
||||
expect(context.state.coins).toBe(50 - 3);
|
||||
});
|
||||
|
||||
it("refuses to start with no coins, and takes nothing", () => {
|
||||
const context = makeContext(reader, data, seededRandom(1), 0);
|
||||
play(cardFlip(context), {});
|
||||
expect(context.state.coins).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses to start without a Coin Case, even with coins", () => {
|
||||
const context = makeContext(reader, data, seededRandom(1), 50);
|
||||
context.state.bag.keyItems = [];
|
||||
play(cardFlip(context), {});
|
||||
expect(context.state.coins).toBe(50);
|
||||
});
|
||||
|
||||
it("the coin case never exceeds 9999, even betting the dealt card's exact match (72 coins)", () => {
|
||||
const deck = shuffleDeck({ random: seededRandom(5) });
|
||||
const card = deck[0];
|
||||
const level = card >> 2,
|
||||
species = card & 3;
|
||||
const pairIndex = Math.floor(level / 2),
|
||||
isPairRow = level % 2 === 0;
|
||||
const row = 2 + pairIndex * 2 + (isPairRow ? 0 : 1);
|
||||
const col = 2 + species;
|
||||
const context = makeContext(reader, data, seededRandom(5), 9995);
|
||||
const buttons = ["a", ...moveButtons(2, 2, col, row), "a"];
|
||||
play(cardFlip(context), { screens: buttons, plays: [true, false] });
|
||||
expect(context.state.coins).toBe(C.MAX_COINS);
|
||||
});
|
||||
|
||||
it("declining to play with three coins ends the game without spending any", () => {
|
||||
const context = makeContext(reader, data, seededRandom(2), 30);
|
||||
play(cardFlip(context), { plays: [false] });
|
||||
expect(context.state.coins).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))(
|
||||
"the Game Corner's realtime screen through the world, with the local ROM",
|
||||
() => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const GAME_CORNER = "11:19";
|
||||
|
||||
function enterGameCorner(coins: number) {
|
||||
let seed = 17;
|
||||
const game = new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 0xff;
|
||||
},
|
||||
);
|
||||
game.state.coins = coins;
|
||||
game.state.bag.keyItems.push(C.COIN_CASE);
|
||||
return game;
|
||||
}
|
||||
/** Stand beside the object at (x, y), face it and press A, as
|
||||
* tests/goldenrod.test.ts's own `talk` helper does. No settle() runs
|
||||
* after `interact()`: the special's first effect may be a realtime
|
||||
* screen, which the generic turn-based driver cannot answer. */
|
||||
function talk(game: GameWorld, map: string, x: number, y: number) {
|
||||
const STEP = {
|
||||
up: [0, -1],
|
||||
down: [0, 1],
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
} as const;
|
||||
for (const [face, [dx, dy]] of Object.entries(STEP) as [
|
||||
keyof typeof STEP,
|
||||
readonly [number, number],
|
||||
][]) {
|
||||
try {
|
||||
game.warpTo(map, x - dx, y - dy);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
settle(game);
|
||||
game.move(face);
|
||||
settle(game);
|
||||
game.interact();
|
||||
return;
|
||||
}
|
||||
throw new Error(`No place to talk to ${x},${y} on ${map}`);
|
||||
}
|
||||
/** Turn-based effects (Slots_AskBet's menu, a refusal's text) up to
|
||||
* the first realtime screen, picking the first menu item and YES. */
|
||||
function driveToScreen(game: GameWorld) {
|
||||
for (let tick = 0; tick < 2000; tick++) {
|
||||
const dialogue = game.dialogue;
|
||||
if (dialogue?.choice?.kind === "screen") return;
|
||||
if (dialogue) {
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice) game.choose(dialogue.choice.kind === "yesno" ? 1 : 0);
|
||||
else if (last && dialogue.holdTicks !== undefined) game.tick();
|
||||
else game.advanceDialogue();
|
||||
} else if (game.busy) game.tick();
|
||||
else return;
|
||||
}
|
||||
throw new Error("Did not reach a screen choice.");
|
||||
}
|
||||
/** The realtime screen itself, holding A every fourth tick (as a
|
||||
* player holding the button would, since setButton only reports
|
||||
* presses) until Slots_AskPlayAgain's yes/no replaces it. */
|
||||
function driveRealtimeToPlayAgain(game: GameWorld) {
|
||||
for (let tick = 0; tick < 20_000; tick++) {
|
||||
const choice = game.dialogue?.choice;
|
||||
if (choice?.kind === "yesno") return;
|
||||
if (choice?.kind === "screen" && (choice as { realtime?: boolean }).realtime) {
|
||||
if (tick % 4 === 0) game.bufferScreenButton("a");
|
||||
game.tick();
|
||||
continue;
|
||||
}
|
||||
if (game.dialogue) {
|
||||
const dialogue = game.dialogue;
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice) game.choose(dialogue.choice.kind === "menu" ? 0 : 1);
|
||||
else if (last && dialogue.holdTicks !== undefined) game.tick();
|
||||
else game.advanceDialogue();
|
||||
} else game.tick();
|
||||
}
|
||||
throw new Error("Did not reach the play-again question.");
|
||||
}
|
||||
|
||||
it("opens the lucky slot machine as a realtime screen, driven through world.tick", () => {
|
||||
const game = enterGameCorner(50);
|
||||
// GoldenrodGameCornerLuckySlotsMachineScript's fixed tile.
|
||||
talk(game, GAME_CORNER, 7, 7);
|
||||
driveToScreen(game);
|
||||
const choice = game.dialogue?.choice;
|
||||
expect(choice?.kind).toBe("screen");
|
||||
expect((choice as { realtime?: boolean }).realtime).toBe(true);
|
||||
driveRealtimeToPlayAgain(game);
|
||||
expect(game.dialogue?.choice?.kind).toBe("yesno");
|
||||
// A bet was taken (3 coins) before any payout; a win would only add
|
||||
// to the balance, never make it exceed the starting amount after
|
||||
// the bet.
|
||||
expect(game.state.coins).toBeLessThanOrEqual(50);
|
||||
expect(game.state.coins).toBeLessThanOrEqual(50 - 3 + 300);
|
||||
game.choose(0); // Slots_AskPlayAgain: No.
|
||||
settle(game);
|
||||
expect(game.busy).toBe(false);
|
||||
expect(game.dialogue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens Card Flip as a realtime screen, driven through world.tick", () => {
|
||||
const game = enterGameCorner(50);
|
||||
talk(game, GAME_CORNER, 13, 7); // GoldenrodGameCornerCardFlipMachineScript
|
||||
// CardFlip.AskPlayWithThree is a plain yes/no before any screen.
|
||||
driveToScreen(game);
|
||||
expect(game.dialogue?.choice?.kind).toBe("screen");
|
||||
driveRealtimeToPlayAgain(game);
|
||||
expect(game.dialogue?.choice?.kind).toBe("yesno");
|
||||
expect(game.state.coins).toBeLessThanOrEqual(50 - 3 + 72);
|
||||
game.choose(0); // CardFlip.PlayAgain: No.
|
||||
settle(game);
|
||||
expect(game.busy).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(!existsSync(romPath))(
|
||||
"the port's own static tilemaps match the ROM's data, copied the asm's way",
|
||||
() => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const reader = new RomReader(bytes);
|
||||
const data = new PokemonData(reader);
|
||||
|
||||
/** Drive a Game Corner generator to its first "screen" effect that
|
||||
* carries sprites (PlaceYourBet for Card Flip, any spinning frame for
|
||||
* the slot machine; ChooseACard's own screen has none), answering
|
||||
* every other effect the same way `play()` does. A regression here
|
||||
* would mean the board/frame these screens draw moved or changed
|
||||
* shape without anyone noticing a pixel. */
|
||||
function firstScreenWithSprites<T>(generator: Generator<ScriptEffect, T, Answer>) {
|
||||
let result = generator.next();
|
||||
let steps = 0;
|
||||
while (!result.done) {
|
||||
if (++steps > 2000) throw new Error("Did not reach a screen with sprites.");
|
||||
const effect = result.value;
|
||||
let answer: Answer;
|
||||
if (effect.kind === "menu") answer = 1; // bet 2
|
||||
else if (effect.kind === "yesno") answer = 1; // yes
|
||||
else if (effect.kind === "screen") {
|
||||
if (effect.screen.sprites && effect.screen.sprites.length > 0) return effect.screen;
|
||||
answer = "a";
|
||||
}
|
||||
result = generator.next(answer);
|
||||
}
|
||||
throw new Error("The generator finished before a screen with sprites appeared.");
|
||||
}
|
||||
|
||||
it("the slot machine's static frame matches SlotsTilemap's own flat 20x12 copy (InitGFX's CopyBytes)", () => {
|
||||
const context = makeContext(reader, data, seededRandom(11), 50);
|
||||
const screen = firstScreenWithSprites(slotMachine(context, false));
|
||||
const ground = Array.from(reader.slice(JOHTO.SlotsTilemap, SCREEN_WIDTH * 12));
|
||||
const port = Array.from(screen.map.slice(0, SCREEN_WIDTH * 12));
|
||||
// Mask out the two cells PrintNum/illuminateBetLights redraw every
|
||||
// tick (the CREDIT/PAYOUT digits at row 1, and the bet-light column
|
||||
// at x=3/16 rows 2-7): everywhere else must still match the ROM's
|
||||
// own static SlotsTilemap bytes exactly.
|
||||
for (let col = 5; col < 9; col++) port[1 * SCREEN_WIDTH + col] = ground[1 * SCREEN_WIDTH + col];
|
||||
for (let col = 11; col < 15; col++) port[1 * SCREEN_WIDTH + col] = ground[1 * SCREEN_WIDTH + col];
|
||||
for (let row = 2; row < 8; row++)
|
||||
for (const col of [3, 16]) port[row * SCREEN_WIDTH + col] = ground[row * SCREEN_WIDTH + col];
|
||||
expect(port).toEqual(ground);
|
||||
});
|
||||
|
||||
it("Card Flip's paytable board matches CardFlipTilemap's own 12-row by 11-column copy (CardFlip_InitTilemap's `lb bc, 12, 11`)", () => {
|
||||
const context = makeContext(reader, data, seededRandom(11), 50);
|
||||
const screen = firstScreenWithSprites(cardFlip(context));
|
||||
const ground = reader.slice(JOHTO.CardFlipTilemap, 12 * 11);
|
||||
const board: number[] = [];
|
||||
for (let row = 0; row < 12; row++)
|
||||
for (let col = 0; col < 11; col++) board.push(screen.map[row * SCREEN_WIDTH + 9 + col]);
|
||||
expect(board).toEqual(Array.from(ground));
|
||||
// Columns 0-1 (left of the dealt-card box, which PlaceYourBet's own
|
||||
// `cards: "chosen"` view draws at x=2..6) stay untouched background,
|
||||
// never leaked-into by a row/column dimension mismatch.
|
||||
for (let row = 0; row < 12; row++)
|
||||
for (let col = 0; col < 2; col++) expect(screen.map[row * SCREEN_WIDTH + col]).toBe(0x29);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
|
||||
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
|
||||
import { settle } from "./world-driver";
|
||||
import { afterRestore, settle } from "./world-driver";
|
||||
import { MovementAssets, connectWarp, fixture } from "./movement-fixture";
|
||||
|
||||
describe("native ledge movement", () => {
|
||||
@@ -118,7 +118,8 @@ describe("native ledge movement", () => {
|
||||
restored.restore(beforeLanding);
|
||||
expect(restored.move("down")).toBe(true);
|
||||
expect(restored.activity).toBe("scene");
|
||||
expect(restored.save()).toEqual(beforeLanding);
|
||||
const reloaded = restored.save();
|
||||
expect(reloaded).toEqual(afterRestore(beforeLanding, reloaded));
|
||||
expect(restored.state.events.has(8)).toBe(false);
|
||||
settle(restored);
|
||||
expect(restored.state.events.has(8)).toBe(true);
|
||||
|
||||
+96
-22
@@ -6,7 +6,9 @@ import type { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld, type NativeSave } from "../src/game/world/world";
|
||||
import type { Direction } from "../src/game/world/types";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { findPath } from "./world-driver";
|
||||
import { applyTypeMatchups } from "../src/game/battle/battle";
|
||||
import { BattleData } from "../src/game/battle/data";
|
||||
import { afterRestore, findPath } from "./world-driver";
|
||||
|
||||
const STEP: Record<Direction, [number, number]> = {
|
||||
up: [0, -1],
|
||||
@@ -51,29 +53,46 @@ export class Playthrough {
|
||||
this.world.restore(dump.save);
|
||||
}
|
||||
|
||||
/** The usable move with the most power, or the first with PP; a move
|
||||
* Disable blocks (its slot in the side's disable count) is skipped. */
|
||||
/** The usable move that hits hardest for its power, STAB and type
|
||||
* matchups against `targetTypes`, or the first with PP; a move Disable
|
||||
* blocks (its slot in the side's disable count) is skipped. */
|
||||
strongest = (
|
||||
mon: { moves: readonly number[]; pp: readonly number[] },
|
||||
mon: {
|
||||
moves: readonly number[];
|
||||
pp: readonly number[];
|
||||
types: readonly number[];
|
||||
},
|
||||
targetTypes: readonly number[],
|
||||
disableCount = 0,
|
||||
) => {
|
||||
const disabled = disableCount ? ((disableCount >> 4) & 0xf) - 1 : -1;
|
||||
this.matchups ??= new BattleData(this.assets.reader).typeMatchups();
|
||||
const score = (move: number) =>
|
||||
applyTypeMatchups(this.matchups!, {
|
||||
damage: this.assets.pokemon.move(move).power * 10,
|
||||
modifier: 0,
|
||||
moveType: this.assets.pokemon.move(move).type,
|
||||
userTypes: mon.types,
|
||||
targetTypes,
|
||||
identified: false,
|
||||
}).damage;
|
||||
let best = -1;
|
||||
mon.moves.forEach((move, i) => {
|
||||
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
|
||||
if (
|
||||
best < 0 ||
|
||||
this.assets.pokemon.move(move).power >
|
||||
this.assets.pokemon.move(mon.moves[best]).power
|
||||
)
|
||||
best = i;
|
||||
if (best < 0 || score(move) > score(mon.moves[best])) best = i;
|
||||
});
|
||||
return Math.max(0, best);
|
||||
};
|
||||
private matchups?: ReturnType<BattleData["typeMatchups"]>;
|
||||
/** Run scripts and battles until the player is free again. Battles use
|
||||
* 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,
|
||||
* and other questions take the queued answers or their defaults. */
|
||||
* the strongest move with PP; a wild battle below half HP is run from,
|
||||
* and a trainer battle (which cannot be run from) instead reaches for
|
||||
* the bag through the battle's own item action (BattleMenu_Pack), as a
|
||||
* cautious player who restocked before a hard fight would: a carried
|
||||
* Awakening wakes the active Pokémon before Sleep/Dream Eater can lock
|
||||
* it out of a whole fight, and a carried Super Potion heals it below
|
||||
* half HP. New moves are learned when there is room, and other
|
||||
* questions take the queued answers or their defaults. */
|
||||
play = (answers: (number | string)[] = []) => {
|
||||
const world = this.world,
|
||||
log = this.log;
|
||||
@@ -90,6 +109,12 @@ export class Playthrough {
|
||||
const ball = world.liveState.bag.balls.findIndex(
|
||||
(slot) => slot.item === C.POKE_BALL,
|
||||
);
|
||||
const potion = world.liveState.bag.items.findIndex(
|
||||
(slot) => slot.item === C.SUPER_POTION,
|
||||
);
|
||||
const awakening = world.liveState.bag.items.findIndex(
|
||||
(slot) => slot.item === C.AWAKENING,
|
||||
);
|
||||
if (
|
||||
engine.battleMode === C.WILD_BATTLE &&
|
||||
engine.enemy.mon.species === this.wanted &&
|
||||
@@ -99,22 +124,43 @@ export class Playthrough {
|
||||
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
|
||||
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||
world.battleAction({ action: "run" });
|
||||
else if (
|
||||
engine.battleMode !== C.WILD_BATTLE &&
|
||||
(mon.status & C.SLP_MASK) !== 0 &&
|
||||
awakening >= 0
|
||||
)
|
||||
world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening });
|
||||
else if (
|
||||
engine.battleMode !== C.WILD_BATTLE &&
|
||||
mon.hp * 2 < mon.maxHp &&
|
||||
potion >= 0
|
||||
)
|
||||
world.battleAction({ action: "item", item: C.SUPER_POTION, slot: potion });
|
||||
else
|
||||
world.battleAction({
|
||||
action: "fight",
|
||||
move: this.strongest(mon, engine.player.disableCount),
|
||||
move: this.strongest(
|
||||
mon,
|
||||
engine.enemy.mon.types,
|
||||
engine.player.disableCount,
|
||||
),
|
||||
});
|
||||
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
||||
// SHIFT's party menu: keep the Pokémon out (or send the first
|
||||
// that can fight when one must go).
|
||||
// that can fight when one must go). UseItem_SelectMon shows the
|
||||
// same prompt kind for the item action just above, distinguished
|
||||
// by its own text (BattlePack's RestoreHPEffect heals whichever
|
||||
// slot is chosen; the active Pokémon is the one that asked for it).
|
||||
else if (prompt?.kind === "party")
|
||||
world.battleChoose(
|
||||
prompt.cancel
|
||||
? -1
|
||||
: world.liveState.party.findIndex(
|
||||
(mon, i) =>
|
||||
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
|
||||
),
|
||||
prompt.text === "Use on which POKéMON?"
|
||||
? battle.engine.curBattleMon
|
||||
: prompt.cancel
|
||||
? -1
|
||||
: world.liveState.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 === "name") world.battleName("");
|
||||
@@ -254,7 +300,8 @@ export class Playthrough {
|
||||
}
|
||||
this.world = new GameWorld(this.assets, this.now, this.random);
|
||||
this.world.restore(saved);
|
||||
expect(this.world.save()).toEqual(saved);
|
||||
const reloaded = this.world.save();
|
||||
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
||||
};
|
||||
lead = () => this.world.state.party[0];
|
||||
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
|
||||
@@ -445,6 +492,33 @@ export class Playthrough {
|
||||
}
|
||||
throw new Error(`No way to talk to object ${id}`);
|
||||
};
|
||||
/** Talk to the clerk at `clerk` and buy `quantity` of `item` through
|
||||
* the world's own BuyMenu (BuyMenuLoop, SelectQuantityToBuy): BUY, the
|
||||
* item in the stock list, the quantity, YES, back out of the list,
|
||||
* then QUIT, as tests/mart.test.ts and tests/goldenrod.test.ts drive the
|
||||
* martmenu/martlist/martquantity choices directly. */
|
||||
buy = (clerk: number, item: number, quantity: number) => {
|
||||
const world = this.world;
|
||||
this.talkTo(clerk);
|
||||
expect(this.toChoice().kind).toBe("martmenu");
|
||||
world.choose(0); // BUY
|
||||
const list = this.toChoice();
|
||||
if (list.kind !== "martlist")
|
||||
throw new Error(`Expected a mart list, got ${list.kind}`);
|
||||
const index = list.entries.findIndex((entry) => entry.item === item);
|
||||
expect(index, `item ${item} in the mart's stock`).toBeGreaterThanOrEqual(0);
|
||||
world.choose(index);
|
||||
expect(this.toChoice().kind).toBe("martquantity");
|
||||
world.choose(quantity);
|
||||
expect(this.toChoice().kind).toBe("yesno");
|
||||
world.choose(1);
|
||||
// "Here you are!", then the stock list again (BuyMenuLoop repeats).
|
||||
expect(this.toChoice().kind).toBe("martlist");
|
||||
world.choose(-1); // back to the mart's own menu
|
||||
expect(this.toChoice().kind).toBe("martmenu");
|
||||
world.choose(2); // QUIT
|
||||
this.play();
|
||||
};
|
||||
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
|
||||
* a full move list forgets the first move. */
|
||||
teach = (item: number, slot: number) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { Playthrough } from "./playthrough-driver";
|
||||
import { findPath } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
@@ -42,7 +43,22 @@ const ROUTE_32 = "10:1",
|
||||
GOLDENROD_CENTER = "11:20",
|
||||
GOLDENROD_GYM = "11:3",
|
||||
BIKE_SHOP = "11:4",
|
||||
FLOWER_SHOP = "11:8",
|
||||
RADIO_TOWER_1F = "3:17";
|
||||
const ROUTE_35 = "10:2",
|
||||
ROUTE_35_NATIONAL_PARK_GATE = "10:15",
|
||||
NATIONAL_PARK = "3:15",
|
||||
ROUTE_36_NATIONAL_PARK_GATE = "10:17",
|
||||
ROUTE_35_GATE = "10:14",
|
||||
ROUTE_36 = "10:3",
|
||||
ROUTE_37 = "10:4",
|
||||
ECRUTEAK = "4:9",
|
||||
ECRUTEAK_CENTER = "4:3",
|
||||
ECRUTEAK_MART = "4:6",
|
||||
ECRUTEAK_GYM = "4:7",
|
||||
DANCE_THEATER = "4:5",
|
||||
BURNED_TOWER_1F = "3:13",
|
||||
BURNED_TOWER_B1F = "3:14";
|
||||
const MIRACLE_SEED = 0x75,
|
||||
LURE_BALL = 0xa0,
|
||||
HM01 = 0xf3,
|
||||
@@ -68,6 +84,27 @@ const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
|
||||
EVENT_GOT_TM31_MUD_SLAP = 0x8,
|
||||
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
|
||||
ENGINE_ZEPHYRBADGE = 0x1b;
|
||||
const SQUIRTBOTTLE = 0xaf;
|
||||
const EVENT_MET_FLORIA = 0xb9,
|
||||
EVENT_GOT_SQUIRTBOTTLE = 0x5c,
|
||||
EVENT_FOUGHT_SUDOWOODO = 0x2a,
|
||||
EVENT_GOT_HM03_SURF = 0x12,
|
||||
EVENT_RIVAL_BURNED_TOWER = 0x6c5,
|
||||
EVENT_HOLE_IN_BURNED_TOWER = 0x332;
|
||||
const EVENT_RELEASED_THE_BEASTS = 0x7b,
|
||||
EVENT_BURNED_TOWER_MORTY = 0x764,
|
||||
EVENT_ECRUTEAK_GYM_GRAMPS = 0x7a8,
|
||||
EVENT_ECRUTEAK_CITY_GRAMPS = 0x7a9,
|
||||
EVENT_BEAT_SAGE_JEFFREY = 0x415,
|
||||
EVENT_BEAT_SAGE_PING = 0x416,
|
||||
EVENT_BEAT_MEDIUM_MARTHA = 0x58b,
|
||||
EVENT_BEAT_MEDIUM_GRACE = 0x58c,
|
||||
EVENT_BEAT_MORTY = 0x4c0,
|
||||
EVENT_GOT_TM30_SHADOW_BALL = 0xc,
|
||||
ENGINE_FOGBADGE = 30,
|
||||
RAIKOU = 0xf3,
|
||||
ENTEI = 0xf4,
|
||||
ROUTE_42 = "2:5";
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -98,6 +135,7 @@ describe.skipIf(!existsSync(romPath))(
|
||||
choose,
|
||||
choosePack,
|
||||
talkTo,
|
||||
buy,
|
||||
} = p;
|
||||
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
|
||||
const restInViolet = () => {
|
||||
@@ -108,6 +146,25 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(ROUTE_31, "right");
|
||||
};
|
||||
|
||||
/** Route 35 and Route 36 meet only through the National Park and
|
||||
* its two gates (Route 36's Sudowoodo still blocks the way from
|
||||
* Violet City). */
|
||||
const toRoute36 = () => {
|
||||
stairs(ROUTE_35_GATE);
|
||||
stairs(ROUTE_35);
|
||||
stairs(ROUTE_35_NATIONAL_PARK_GATE);
|
||||
stairs(NATIONAL_PARK);
|
||||
stairs(ROUTE_36_NATIONAL_PARK_GATE);
|
||||
stairs(ROUTE_36);
|
||||
};
|
||||
const toGoldenrod = () => {
|
||||
stairs(ROUTE_36_NATIONAL_PARK_GATE);
|
||||
stairs(NATIONAL_PARK);
|
||||
stairs(ROUTE_35_NATIONAL_PARK_GATE);
|
||||
stairs(ROUTE_35);
|
||||
stairs(ROUTE_35_GATE);
|
||||
stairs(GOLDENROD);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
p.log.length = 0;
|
||||
@@ -559,5 +616,184 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(GOLDENROD);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("meets Floria on Route 36 and gets the SquirtBottle from the flower shop", () => {
|
||||
traceFailures();
|
||||
toRoute36();
|
||||
// ROUTE36_FLORIA is the eighth object in Route36_MapEvents.
|
||||
talkTo(p.world.map.objects[7].id);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_MET_FLORIA)).toBe(true);
|
||||
toGoldenrod();
|
||||
enter(FLOWER_SHOP, "up");
|
||||
// Floria walked here from Route 36; talk to her first
|
||||
// (EVENT_TALKED_TO_FLORIA_AT_FLOWER_SHOP), then the Teacher gives
|
||||
// the SquirtBottle since the PLAINBADGE is already held.
|
||||
talkTo(p.world.map.objects[1].id);
|
||||
play();
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
// The Squirtbottle is a key item (ItemAttributes' pocket byte).
|
||||
expect(p.world.state.bag.keyItems).toContain(SQUIRTBOTTLE);
|
||||
expect(p.world.state.events.has(EVENT_GOT_SQUIRTBOTTLE)).toBe(true);
|
||||
exit(GOLDENROD);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("waters the weird tree on Route 36 and battles Sudowoodo, then crosses Route 37 to Ecruteak City", () => {
|
||||
traceFailures();
|
||||
toRoute36();
|
||||
const tree = p.world.map.objects[2].id; // ROUTE36_WEIRD_TREE
|
||||
talkTo(tree);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_FOUGHT_SUDOWOODO)).toBe(true);
|
||||
cross("up", ROUTE_37);
|
||||
cross("up", ECRUTEAK);
|
||||
expect(p.world.mapId).toBe(ECRUTEAK);
|
||||
heal(ECRUTEAK_CENTER, ECRUTEAK);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("beats all five Kimono Girls for HM03 Surf from the Dance Theater's gentleman", () => {
|
||||
traceFailures();
|
||||
enter(DANCE_THEATER, "up");
|
||||
// The five Kimono Girls are objects 2 through 6 (file order); the
|
||||
// gentleman who gives Surf is object 7. An already-beaten Kimono
|
||||
// Girl's trainer header just shows her after-text, so healing
|
||||
// between fights (five single-Pokémon trainers in a row otherwise
|
||||
// wear the lead down) is safe to repeat.
|
||||
for (let id = 2; id <= 6; id++) {
|
||||
if (lead().hp < lead().stats[0]) {
|
||||
exit(ECRUTEAK);
|
||||
heal(ECRUTEAK_CENTER, ECRUTEAK);
|
||||
enter(DANCE_THEATER, "up");
|
||||
}
|
||||
talkTo(id);
|
||||
play();
|
||||
}
|
||||
talkTo(7);
|
||||
play([1]);
|
||||
expect(p.world.state.bag.tmsHms[52]).toBe(1); // HM03 (50 TMs + HM03 - 1)
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM03_SURF)).toBe(true);
|
||||
exit(ECRUTEAK);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("meets Eusine and the rival at the Burned Tower, falling to the basement", () => {
|
||||
traceFailures();
|
||||
// Stock up on Super Potions first: the rival's Croconaw hits the
|
||||
// Fire-type lead for super-effective damage on top of Haunter's
|
||||
// Curse, so a careful player restocks before a hard fight rather
|
||||
// than going in on whatever is left from Route 36.
|
||||
enter(ECRUTEAK_MART, "up");
|
||||
const money = p.world.state.money;
|
||||
buy(p.world.map.objects[0].id, C.SUPER_POTION, 10);
|
||||
expect(p.world.state.money).toBe(money - 700 * 10);
|
||||
expect(p.world.state.bag.items).toContainEqual({ item: C.SUPER_POTION, quantity: 10 });
|
||||
exit(ECRUTEAK);
|
||||
enter(BURNED_TOWER_1F, "up");
|
||||
// BurnedTower1FMeetEusineScene runs automatically on the first idle
|
||||
// tick (sdefer), as Ecruteak's Pokémon Center's Bill scene does.
|
||||
expect(p.log.join(" ")).toContain("EUSINE: My name's");
|
||||
// The only way to any tile by the rival crosses the coordinate event
|
||||
// at (11, 9); walking there triggers BurnedTowerRivalBattleScript,
|
||||
// which ends with the player falling to the basement, so the usual
|
||||
// walk() helper (which expects to stay on this map unless stepping
|
||||
// onto a listed warp) does not fit here.
|
||||
// A loss whites out (healed) to Ecruteak's spawn point outside the
|
||||
// Pokémon Center with the scene still set, so the player tries again.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
for (const direction of findPath(p.world, 11, 9)) {
|
||||
if (!p.world.move(direction)) break;
|
||||
play();
|
||||
if (p.world.mapId !== BURNED_TOWER_1F) break;
|
||||
}
|
||||
if (p.world.mapId === BURNED_TOWER_B1F) break;
|
||||
expect(p.world.mapId, "whited out to Ecruteak").toBe(ECRUTEAK);
|
||||
enter(BURNED_TOWER_1F, "up");
|
||||
}
|
||||
expect(p.world.state.events.has(EVENT_RIVAL_BURNED_TOWER)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_HOLE_IN_BURNED_TOWER)).toBe(true);
|
||||
expect(p.world.mapId).toBe(BURNED_TOWER_B1F);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("releases the beasts, says goodbye to Eusine and climbs back to Ecruteak", () => {
|
||||
traceFailures();
|
||||
// The coordinate event at (10, 6) runs ReleaseTheBeasts: the three
|
||||
// beasts flee one by one through InitRoamMons, which starts Raikou
|
||||
// and Entei roaming (Suicune stays put until Cianwood).
|
||||
walk(10, 6);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_RELEASED_THE_BEASTS)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BURNED_TOWER_MORTY)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_ECRUTEAK_GYM_GRAMPS)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_ECRUTEAK_CITY_GRAMPS)).toBe(false);
|
||||
expect(p.world.state.roamMons[0]).toMatchObject({ species: RAIKOU, mapId: ROUTE_42 });
|
||||
expect(p.world.state.roamMons[1]).toMatchObject({ species: ENTEI, mapId: ROUTE_37 });
|
||||
// Eusine appears once the beasts are gone (BurnedTowerB1F_EUSINE's
|
||||
// own flag, set by `appear` rather than a scene variable) and says
|
||||
// goodbye before leaving through the tower's own exit sound.
|
||||
talkTo(p.world.map.objects[8].id);
|
||||
play();
|
||||
expect(p.log.join(" ")).toContain("I'm all choked up!");
|
||||
// Multiple small stairs lead back to 1F now that the ladder at (6,
|
||||
// 14) is in place (BurnedTowerB1FLadderCallback); any of them works.
|
||||
stairs(BURNED_TOWER_1F);
|
||||
exit(ECRUTEAK);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("beats Ecruteak Gym's trainers and Morty for the FOGBADGE and TM30", () => {
|
||||
traceFailures();
|
||||
// Morty's own Gengar leans on Hypnosis and Dream Eater; a careful
|
||||
// player also stocks Awakenings, not just Super Potions, before a
|
||||
// Ghost-type gym.
|
||||
enter(ECRUTEAK_MART, "up");
|
||||
buy(p.world.map.objects[0].id, C.AWAKENING, 5);
|
||||
expect(p.world.state.bag.items).toContainEqual({ item: C.AWAKENING, quantity: 5 });
|
||||
exit(ECRUTEAK);
|
||||
// EcruteakGymForcedToLeaveScene (the default scene) no longer plays:
|
||||
// ReleaseTheBeasts already set SCENE_ECRUTEAKGYM_NOOP for this map.
|
||||
enter(ECRUTEAK_GYM, "up");
|
||||
// The trainers stand past Morty's own invisible-floor maze; findPath
|
||||
// already treats every unlisted pit tile as impassable (the same
|
||||
// warps the player would fall through), so it finds the real floor
|
||||
// on its own, the way a player who knows the safe path would.
|
||||
const [morty, jeffrey, ping, martha, grace] = p.world.map.objects.map(
|
||||
(object) => object.id,
|
||||
);
|
||||
// Ghost types shrug off Normal moves entirely ("No normal-type
|
||||
// attack can harm them!", straight from Sage Ping's own text), so
|
||||
// EMBER is the lead's only way to damage any of them; a cautious
|
||||
// player heals between fights (restoring its PP, not just HP) so
|
||||
// a long multi-Pokémon trainer never finds EMBER run dry.
|
||||
for (const trainer of [jeffrey, ping, martha, grace]) {
|
||||
exit(ECRUTEAK);
|
||||
heal(ECRUTEAK_CENTER, ECRUTEAK);
|
||||
enter(ECRUTEAK_GYM, "up");
|
||||
talkTo(trainer);
|
||||
play();
|
||||
}
|
||||
expect(p.world.state.events.has(EVENT_BEAT_SAGE_JEFFREY)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_SAGE_PING)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_MEDIUM_MARTHA)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_MEDIUM_GRACE)).toBe(true);
|
||||
exit(ECRUTEAK);
|
||||
heal(ECRUTEAK_CENTER, ECRUTEAK);
|
||||
enter(ECRUTEAK_GYM, "up");
|
||||
talkTo(morty);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_MORTY), p.log.join("|")).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_FOGBADGE)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("FOGBADGE");
|
||||
// MortyText_FogBadgeSpeech and the TM30 Shadow Ball gift follow in
|
||||
// the same conversation (EVENT_GOT_TM30_SHADOW_BALL was not yet set).
|
||||
expect(p.world.state.events.has(EVENT_GOT_TM30_SHADOW_BALL)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[29]).toBe(1); // TM30
|
||||
expect(p.log.join(" ")).toContain("SHADOW BALL");
|
||||
exit(ECRUTEAK);
|
||||
reload();
|
||||
}, 240_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,420 @@
|
||||
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 { OVERWORLD } from "../src/game/rom/profile";
|
||||
import { GameWorld, migrateSave } from "../src/game/world/world";
|
||||
import {
|
||||
ScriptRun,
|
||||
emptyScriptState,
|
||||
type ScriptHost,
|
||||
} from "../src/game/scripts/interpreter";
|
||||
import { PokemonData } from "../src/game/rom/pokemon";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import type { RoamMon } from "../src/game/battle/battle";
|
||||
import {
|
||||
initRoamMons,
|
||||
checkEncounterRoamMon,
|
||||
updateRoamMons,
|
||||
jumpRoamMons,
|
||||
} from "../src/game/world/roam-mons";
|
||||
import { afterRestore } from "./world-driver";
|
||||
|
||||
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
// RAIKOU, ENTEI (constants/pokemon_constants.asm).
|
||||
const RAIKOU = 243,
|
||||
ENTEI = 244,
|
||||
SUICUNE = 245;
|
||||
// Open Johto routes that are also RoamMaps entries (confirmed against the
|
||||
// ROM below): Route 29, Route 32 and Route 33, which directly connect.
|
||||
const ROUTE_29 = "24:3",
|
||||
ROUTE_32 = "10:1",
|
||||
ROUTE_33 = "8:6";
|
||||
// InitRoamMons' own starting maps: Route 42 and Route 37 (not open, but
|
||||
// decodable like any of the ROM's 26 map groups).
|
||||
const ROUTE_42 = "2:5",
|
||||
ROUTE_37 = "10:4";
|
||||
/** special InitRoamMons' index in SpecialsPointers (data/events/special_pointers.asm). */
|
||||
const INIT_ROAM_MONS_SPECIAL = 105;
|
||||
|
||||
function assets() {
|
||||
return new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
}
|
||||
|
||||
/** Every map RoamMaps lists, read straight from the ROM: a roaming beast's
|
||||
* new position must always be one of these sixteen. */
|
||||
function roamMapIds(r: RomReader): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
let p = OVERWORLD.RoamMaps;
|
||||
while (r.byte(p) !== 0xff) {
|
||||
ids.add(`${r.byte(p)}:${r.byte(p + 1)}`);
|
||||
p += 4 + 2 * r.byte(p + 2);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
const emptyRoamer = (): RoamMon => ({ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 });
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("roaming Pokémon (Raikou, Entei, Suicune)", () => {
|
||||
describe("the RoamMaps graph (direct ROM reads)", () => {
|
||||
it("lists Route 29, Route 32 and Route 33 with Route 32 between the other two", () => {
|
||||
const r = assets().reader;
|
||||
const ids = roamMapIds(r);
|
||||
expect(ids).toContain(ROUTE_29);
|
||||
expect(ids).toContain(ROUTE_32);
|
||||
expect(ids).toContain(ROUTE_33);
|
||||
expect(ids.size).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initRoamMons", () => {
|
||||
it("starts Raikou on Route 42 and Entei on Route 37 at level 40 with HP 0, leaving Suicune untouched", () => {
|
||||
const suicune: RoamMon = { species: SUICUNE, level: 12, mapId: "1:1", hp: 7, dvs: 0x55aa };
|
||||
const result = initRoamMons([emptyRoamer(), emptyRoamer(), suicune]);
|
||||
expect(result[0]).toEqual({ species: RAIKOU, level: 40, mapId: ROUTE_42, hp: 0, dvs: 0 });
|
||||
expect(result[1]).toEqual({ species: ENTEI, level: 40, mapId: ROUTE_37, hp: 0, dvs: 0 });
|
||||
expect(result[2]).toEqual(suicune);
|
||||
});
|
||||
|
||||
it("runs through the real special dispatch (special 105), as the ported interpreter resolves it", () => {
|
||||
// special InitRoamMons; end, placed in an unused bank-0 spot, exactly
|
||||
// as tests/zz-specials.local.test.ts probes every special routine.
|
||||
const rom = new Uint8Array(new Uint8Array(readFileSync(romPath)));
|
||||
rom.set(
|
||||
[0x0f, INIT_ROAM_MONS_SPECIAL & 255, INIT_ROAM_MONS_SPECIAL >> 8, 0x91],
|
||||
0x3ff0,
|
||||
);
|
||||
const reader = new RomReader(rom);
|
||||
const host: ScriptHost = {
|
||||
mapId: ROUTE_29,
|
||||
lastTalked: 0,
|
||||
sceneOwner: () => undefined,
|
||||
timeOfDay: () => 1,
|
||||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||||
clockDay: () => 0,
|
||||
text: () => defaultTextContext("A"),
|
||||
data: new PokemonData(reader),
|
||||
player: () => ({ id: 1, gender: 0 }),
|
||||
random: () => 0,
|
||||
landmark: () => 0,
|
||||
facing: () => 0,
|
||||
position: () => ({ x: 0, y: 0 }),
|
||||
objectPosition: () => undefined,
|
||||
playerCollision: () => 0,
|
||||
environment: () => 0,
|
||||
phoneService: () => 0,
|
||||
facingObject: () => undefined,
|
||||
};
|
||||
const state = emptyScriptState();
|
||||
state.roamMons[2] = { species: SUICUNE, level: 5, mapId: "1:1", hp: 9, dvs: 0x1234 };
|
||||
const run = new ScriptRun(reader, 0x3ff0, state, host);
|
||||
for (let i = 0; i < 10 && run.resume(0); i++);
|
||||
expect(run.state.roamMons[0]).toEqual({ species: RAIKOU, level: 40, mapId: ROUTE_42, hp: 0, dvs: 0 });
|
||||
expect(run.state.roamMons[1]).toEqual({ species: ENTEI, level: 40, mapId: ROUTE_37, hp: 0, dvs: 0 });
|
||||
expect(run.state.roamMons[2]).toEqual({ species: SUICUNE, level: 5, mapId: "1:1", hp: 9, dvs: 0x1234 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkEncounterRoamMon", () => {
|
||||
it("draws once: the top 100 values never encounter, a draw & 3 == 0 never encounters, and the rest pick Raikou/Entei/Suicune by index", () => {
|
||||
const roamMons: RoamMon[] = [
|
||||
{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 },
|
||||
{ species: ENTEI, level: 40, mapId: "9:9", hp: 0, dvs: 0 },
|
||||
{ species: SUICUNE, level: 40, mapId: "9:9", hp: 0, dvs: 0 },
|
||||
];
|
||||
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 100)).toBeUndefined();
|
||||
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 0)).toBeUndefined();
|
||||
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 1)).toBe(roamMons[0]);
|
||||
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 2)).toBeUndefined();
|
||||
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 3)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateRoamMons and JumpRoamMons (direct ROM reads)", () => {
|
||||
it("picks a random connection of the beast's own RoamMaps entry, redrawing an out-of-range index", () => {
|
||||
const r = assets().reader;
|
||||
// Route 32 (10:1) lists three neighbors in this order: Route 36
|
||||
// (10:3), Route 31 (26:2), Route 33 (8:6). The first draw's index (3)
|
||||
// is past the connection count and must be redrawn.
|
||||
const draws = [3, 4];
|
||||
const moved = updateRoamMons(
|
||||
r,
|
||||
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
|
||||
null,
|
||||
"9:9",
|
||||
() => draws.shift()!,
|
||||
);
|
||||
expect(moved[0].mapId).toBe("10:3");
|
||||
});
|
||||
|
||||
it("redraws when the chosen connection repeats the last map it was on", () => {
|
||||
const r = assets().reader;
|
||||
const draws = [2, 1]; // index 2 (Route 33) repeats lastMapId, then index 1 (Route 31).
|
||||
const moved = updateRoamMons(
|
||||
r,
|
||||
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
|
||||
ROUTE_33,
|
||||
"9:9",
|
||||
() => draws.shift()!,
|
||||
);
|
||||
expect(moved[0].mapId).toBe("26:2");
|
||||
});
|
||||
|
||||
it("a 1-in-32 draw jumps instead, avoiding the player's own map rather than the beast's", () => {
|
||||
const r = assets().reader;
|
||||
// draw & 0x1f == 0 triggers JumpRoamMon; its own index draw (5) lands
|
||||
// on RoamMaps entry 5 (Route 34, 11:1), which is not the player's map.
|
||||
const draws = [0, 5];
|
||||
const moved = updateRoamMons(
|
||||
r,
|
||||
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
|
||||
null,
|
||||
"9:9",
|
||||
() => draws.shift()!,
|
||||
);
|
||||
expect(moved[0].mapId).toBe("11:1");
|
||||
});
|
||||
|
||||
it("leaves a beast on an unlisted map untouched and draws nothing", () => {
|
||||
const r = assets().reader;
|
||||
const moved = updateRoamMons(
|
||||
r,
|
||||
[{ species: RAIKOU, level: 40, mapId: "9:9", hp: 0, dvs: 0 }],
|
||||
null,
|
||||
"9:9",
|
||||
() => {
|
||||
throw new Error("should not draw");
|
||||
},
|
||||
);
|
||||
expect(moved[0].mapId).toBe("9:9");
|
||||
});
|
||||
|
||||
it("skips a beast whose map is null (GROUP_N_A), drawing nothing", () => {
|
||||
const r = assets().reader;
|
||||
const moved = updateRoamMons(r, [emptyRoamer()], null, "9:9", () => {
|
||||
throw new Error("should not draw");
|
||||
});
|
||||
expect(moved[0].mapId).toBeNull();
|
||||
});
|
||||
|
||||
it("JumpRoamMons picks uniformly among the 16 listed maps, redrawing only the player's own map", () => {
|
||||
const r = assets().reader;
|
||||
// Entry 0 is Route 29 itself, the player's map: redraw. Entry 1 is
|
||||
// Route 30 (26:1).
|
||||
const draws = [0, 1];
|
||||
const jumped = jumpRoamMons(
|
||||
r,
|
||||
[{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 }],
|
||||
ROUTE_29,
|
||||
() => draws.shift()!,
|
||||
);
|
||||
expect(jumped[0].mapId).toBe("26:1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("map transitions hook UpdateRoamMons/JumpRoamMons", () => {
|
||||
it("a map connection crossing moves an active beast and records the map", () => {
|
||||
const world = new GameWorld(assets());
|
||||
// Column x=7 runs clear from y=86 to the map's southern edge (y=90),
|
||||
// which connects straight into Route 33.
|
||||
world.warpTo(ROUTE_32, 7, 86);
|
||||
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 };
|
||||
let crossed = false;
|
||||
for (let tries = 0; tries < 6 && !crossed; tries++)
|
||||
crossed = world.move("down") && world.mapId === ROUTE_33;
|
||||
expect(crossed).toBe(true);
|
||||
expect(world.state.roamMonsMap).toBe(ROUTE_33);
|
||||
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
|
||||
});
|
||||
|
||||
it("a door warp moves an active beast too (MAPSETUP_DOOR falls through into MAPSETUP_TRAIN's UpdateRoamMons)", () => {
|
||||
const world = new GameWorld(assets());
|
||||
const warp = world.assets.map(ROUTE_32).warps.find((w) => w.targetMap === "10:13")!;
|
||||
world.warpTo(ROUTE_32, warp.x, warp.y - 1);
|
||||
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 };
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(world.mapId).toBe("10:13");
|
||||
expect(world.state.roamMonsMap).toBe("10:13");
|
||||
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
|
||||
});
|
||||
|
||||
it("continuing a save (JumpRoamMons) relocates an active beast off the loaded map", () => {
|
||||
const world = new GameWorld(assets());
|
||||
const saved = world.save();
|
||||
saved.map = ROUTE_32;
|
||||
saved.x = 10;
|
||||
saved.y = 80;
|
||||
saved.roamMons = [
|
||||
{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 },
|
||||
emptyRoamer(),
|
||||
emptyRoamer(),
|
||||
];
|
||||
world.restore(saved);
|
||||
expect(world.state.roamMonsMap).toBe(ROUTE_32);
|
||||
expect(world.state.roamMons[0].mapId).not.toBe(ROUTE_32);
|
||||
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
|
||||
});
|
||||
|
||||
it("a rollback restore (not a Continue) leaves the beasts where they were", () => {
|
||||
const world = new GameWorld(assets());
|
||||
const saved = world.save();
|
||||
saved.map = ROUTE_32;
|
||||
saved.x = 10;
|
||||
saved.y = 80;
|
||||
saved.roamMons = [
|
||||
{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 },
|
||||
emptyRoamer(),
|
||||
emptyRoamer(),
|
||||
];
|
||||
world.restore(saved, false);
|
||||
expect(world.save()).toEqual(saved);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a roaming encounter", () => {
|
||||
/** An open Johto route (Route 29) with a forced, deterministic
|
||||
* encounter rate: the roam check itself still runs for real. */
|
||||
function roamEncounterWorld() {
|
||||
const a = assets();
|
||||
a.grassCollisions = () => Array.from({ length: 256 }, (_, i) => i);
|
||||
a.grassWildMons = (id) =>
|
||||
id === ROUTE_29
|
||||
? {
|
||||
rates: [256, 256, 256],
|
||||
slots: [0, 1, 2].map(() => Array.from({ length: 7 }, () => ({ species: 16, level: 3 }))),
|
||||
}
|
||||
: undefined;
|
||||
let mode: "fixed" | "lcg" = "fixed";
|
||||
let seed = 7;
|
||||
const world = new GameWorld(
|
||||
a,
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
() => {
|
||||
if (mode === "fixed") return 1;
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
const mon = generatePartyMon(a.pokemon, {
|
||||
species: 155, level: 30, item: 0, otId: world.playerId,
|
||||
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
||||
});
|
||||
world.state.party = [mon];
|
||||
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 };
|
||||
world.warpTo(ROUTE_29, 30, 9);
|
||||
return { world, setMode: (next: "fixed" | "lcg") => (mode = next) };
|
||||
}
|
||||
/** Walk several steps in whichever direction is open, without depending
|
||||
* on the route's exact terrain: SetUpFiveStepWildEncounterCooldown
|
||||
* skips the encounter check on the first five steps after any map
|
||||
* entry, so a single step is never enough to reach it. */
|
||||
function stepSomewhere(world: GameWorld, steps = 6): boolean {
|
||||
for (let i = 0; i < steps; i++) {
|
||||
if (world.battle) return true;
|
||||
let moved = false;
|
||||
for (const direction of ["down", "right", "up", "left"] as const)
|
||||
if (world.move(direction)) {
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
if (!moved) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
it("CheckEncounterRoamMon starts a BATTLETYPE_ROAMING battle naming the beast on this map", () => {
|
||||
const { world } = roamEncounterWorld();
|
||||
expect(stepSomewhere(world)).toBe(true);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.battle).toBeDefined();
|
||||
const engine = world.battle!.engine;
|
||||
expect(engine.battleType).toBe(C.BATTLETYPE_ROAMING);
|
||||
expect(engine.enemy.mon.species).toBe(RAIKOU);
|
||||
});
|
||||
|
||||
it("catching it with a Master Ball clears the roaming slot and adds it to the party", () => {
|
||||
const { world, setMode } = roamEncounterWorld();
|
||||
world.state.bag.balls = [{ item: C.MASTER_BALL, quantity: 1 }];
|
||||
expect(stepSomewhere(world)).toBe(true);
|
||||
expect(world.battle).toBeDefined();
|
||||
setMode("lcg");
|
||||
for (let tick = 0; tick < 20_000 && world.busy; tick++) {
|
||||
const view = world.battle;
|
||||
if (!view) {
|
||||
if (world.dialogue) world.advanceDialogue();
|
||||
else world.tick();
|
||||
continue;
|
||||
}
|
||||
const prompt = view.prompt;
|
||||
if (prompt?.kind === "action")
|
||||
world.battleAction({ action: "item", item: C.MASTER_BALL, slot: 0 });
|
||||
else if (prompt?.kind === "yesno") world.battleChoose(0);
|
||||
else if (view.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
}
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.party.some((p) => p.species === RAIKOU)).toBe(true);
|
||||
expect(world.state.roamMons[0]).toMatchObject({ species: 0, mapId: null, hp: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("save format", () => {
|
||||
it("round-trips a beast's species, level, HP and DVs (the map itself may jump on continue)", () => {
|
||||
const world = new GameWorld(assets());
|
||||
world.state.roamMons = [
|
||||
{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 123, dvs: 0xabcd },
|
||||
emptyRoamer(),
|
||||
emptyRoamer(),
|
||||
];
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
const [raikou, entei, suicune] = world.state.roamMons;
|
||||
expect({ species: raikou.species, level: raikou.level, hp: raikou.hp, dvs: raikou.dvs }).toEqual(
|
||||
{ species: RAIKOU, level: 40, hp: 123, dvs: 0xabcd },
|
||||
);
|
||||
expect(raikou.mapId).not.toBeNull();
|
||||
expect(entei.mapId).toBeNull();
|
||||
expect(suicune.mapId).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips through save()/restore() with afterRestore's allowance", () => {
|
||||
const world = new GameWorld(assets());
|
||||
world.state.roamMons[1] = { species: ENTEI, level: 40, mapId: ROUTE_37, hp: 200, dvs: 0x4242 };
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
const reloaded = world.save();
|
||||
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
||||
});
|
||||
|
||||
it("rejects a save with an invalid roamMons entry", () => {
|
||||
const world = new GameWorld(assets());
|
||||
const saved = world.save();
|
||||
const bad = {
|
||||
...saved,
|
||||
roamMons: [{ species: 999, level: 0, mapId: null, hp: 0, dvs: 0 }, emptyRoamer(), emptyRoamer()],
|
||||
};
|
||||
expect(() => world.restore(bad)).toThrow("Invalid native save");
|
||||
});
|
||||
|
||||
it("rejects a save missing a roam slot", () => {
|
||||
const world = new GameWorld(assets());
|
||||
const saved = world.save();
|
||||
const bad = { ...saved, roamMons: [emptyRoamer(), emptyRoamer()] };
|
||||
expect(() => world.restore(bad)).toThrow("Invalid native save");
|
||||
});
|
||||
|
||||
it("migrates an older save to three empty, GROUP_N_A roam slots", () => {
|
||||
const world = new GameWorld(assets());
|
||||
const saved = world.save() as Record<string, unknown>;
|
||||
delete saved.roamMons;
|
||||
delete saved.roamMonsMap;
|
||||
const migrated = migrateSave(saved, () => 1);
|
||||
expect(migrated?.roamMons).toEqual([emptyRoamer(), emptyRoamer(), emptyRoamer()]);
|
||||
expect(migrated?.roamMonsMap).toBeNull();
|
||||
expect(() => world.restore(migrated)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,15 @@ const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" } as con
|
||||
const edges = (tile: number): readonly Direction[] =>
|
||||
(tile & 0xf0) === 0xb0 || (tile & 0xf0) === 0xc0 ? EDGES[tile & 7] : [];
|
||||
|
||||
/** CheckWarpCollision's own two hazard nybbles ($6x pits, $7x doors/
|
||||
* stairs/carpets): a tile only needs avoiding here if stepping onto it
|
||||
* could actually trigger a warp. Burned Tower B1F's fall/ladder warps
|
||||
* (e.g. the coordinates `warpcheck` and a `changeblock`-revealed ladder
|
||||
* read) sit on ordinary $00 floor and never fire from an ordinary step,
|
||||
* so they are left walkable like any other floor tile. */
|
||||
const isWarpHazard = (tile: number) =>
|
||||
(tile & 0xf0) === 0x60 || (tile & 0xf0) === 0x70;
|
||||
|
||||
/** Shortest walkable path on the current map, avoiding other warps. */
|
||||
export function findPath(
|
||||
world: GameWorld,
|
||||
@@ -108,6 +117,7 @@ export function findPath(
|
||||
if (world.objects().some((object) => object.x === x && object.y === y))
|
||||
continue;
|
||||
if (
|
||||
isWarpHazard(tile) &&
|
||||
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
|
||||
(x !== targetX || y !== targetY)
|
||||
)
|
||||
@@ -158,3 +168,14 @@ export function withoutPlayTime<T extends { gameTime?: unknown }>(save: T) {
|
||||
const { gameTime: _, ...rest } = save;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** Restoring a save always moves the roam-mon bookkeeping to the map just
|
||||
* loaded (Continue's own farcall JumpRoamMons, then _BackUpMapIndices),
|
||||
* even before any roaming Pokémon has been released; a round-trip
|
||||
* comparison needs `reloaded`'s own `roamMons`/`roamMonsMap` in place of
|
||||
* `saved`'s to still match everything else exactly. */
|
||||
export function afterRestore<
|
||||
T extends { roamMons?: unknown; roamMonsMap?: unknown },
|
||||
>(saved: T, reloaded: T) {
|
||||
return { ...saved, roamMons: reloaded.roamMons, roamMonsMap: reloaded.roamMonsMap };
|
||||
}
|
||||
Reference in new issue
Block a user