diff --git a/README.md b/README.md index 831e360..0b192f9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ East of Ecruteak, Route 42 runs past Mt. Mortar to Mahogany Town. North of town, Team Rocket then takes over Goldenrod's Radio Tower. Fight up to the fake director for the Basement Key, open the Underground's locked door, work the switches past your rival to free the real director in the warehouse, and use his Card Key to reach the executive on the top floor. The director gives you the Clear Bell. East of Mahogany, Route 44 leads into Ice Path: push a boulder down a hole so it stops your slide on the ice below, and come out in Blackthorn City. Clair's gym hides its way forward behind boulders that fall from the floor above. Beat her, pass the elder's test in the Dragon Shrine for the Rising Badge, and receive a Dratini that knows Extremespeed. The Move Deleter makes a Pokémon forget a move. -Tin Tower, Whirl Islands, the routes beyond Blackthorn, the Battle Tower and Whirl Islands, 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. +Routes 45 and 46 lead south from Blackthorn back to Route 29. In Ecruteak, the Clear Bell convinces the sages to let you into Tin Tower, where Suicune puts you to the test. After the League, Tin Tower's upper floors lead to Ho-Oh, and the Whirl Islands hide Lugia for a trainer with the Silver Wing. + +The Battle Tower, Kanto, the League, the Battle Tower and Whirl Islands, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. ## Run diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index 768b3e2..74c4b50 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -53,6 +53,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta | `game/scripts/cianwood.ts`, Routes 38–41, Olivine and Cianwood | `engine/events/shuckle.asm` (`GiveShuckle`, `ReturnShuckie`), `engine/pokemon/bills_pc_top.asm` (`CheckCurPartyMonFainted`), `engine/pokemon/caught_data.asm` (`SetGiftPartyMonCaughtData`), `engine/events/poke_seer.asm`, `engine/events/print_photo.asm`, `engine/printer/printer.asm` (`PrintPartymon`, `CheckPrinterStatus`), `engine/printer/printer_serial.asm`, `engine/events/specials.asm` (`PlaySlowCry`) | Shuckie is a level 15 party-only gift with MANIA's OT, ID 518, nickname, a Berry and `LANDMARK_GIFT` caught data; it sets `ENGINE_GOT_SHUCKIE_TODAY`. `ReturnShuckie` returns all five codes; `CheckCurPartyMonFainted` looks at the rest of the party, and Mania answers `SHUCKIE_FAINTED` with his happiness speech, as the original does. `PokeSeer` follows `ReadCaughtData`, including the missing `cp [hl]` that compares only the OT ID's high byte, `GetCaughtLocation`'s event and gift landmarks, `GetCaughtLevel`'s ??? that leaves `wSeerCaughtLevel` unchanged, and `SeerAdvice`'s inclusive thresholds. `wSeerCaughtLevel` lives in the "Miscellaneous WRAM 1" union: the port keeps the Seer's own last value until a load, while in the original other users of those bytes may change it. `PhotoStudio` has no printer to talk to: the handshake reads $ff, so `CheckPrinterStatus` reports `PRINTER_ERROR_2`, and `SendScreenToPrinter` retries until B sets `hPrinter`. The port shows "Printer Error 2" with "Press B to Cancel" on one page, then the no-photo text; the print page behind the box and its alternation with "CHECKING LINK" are not drawn. `PlaySlowCry` is audio. The story maps run on ported script commands; `tests/olivine-cianwood.test.ts` and six playthrough stages cover them. | | `game/scripts/lake-of-rage.ts`, Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | `engine/events/magikarp.asm` (`CheckMagikarpLength`, `CalcMagikarpLength`, `PrintMagikarpLength`, `MagikarpHouseSign`), `engine/menus/intro_menu.asm` (`InitializeMagikarpHouse`), `engine/pokemon/search_party.asm` (`FindThatSpecies`), `engine/overworld/scripting.asm` (`DoScene`), `engine/events/money.asm` (`TakeMoney`) | The Magikarp is measured from its own DVs and OT ID with the battle code's `CalcMagikarpLength` (whose `.BCLessThanDE` compares only the high byte). A longer one, compared feet then inches, becomes the record with the Magikarp's OT name as holder; the record starts at 3′6″ by RALPH and is saved as `magikarpRecord`. `FindThatSpecies` reads `wPartySpecies`, where an Egg is EGG, so an Egg is never found. `setmapscene`/`setscene` on a map without a scene variable (Route 43, the Lake of Rage): `GetMapSceneID` leaves `de` alone and `DoScene` still writes the byte through it when it is nonzero. At the Route 43 gate `de` is `EventFlagAction`'s byte offset of `EVENT_CLEARED_ROCKET_HIDEOUT` (4), the MBC3 RAM-enable register, so nothing changes. After the Red Gyarados, `de` is whatever `closetext` left; the port consumes the byte and changes nothing, and that write was not checked in an emulator. `TakeMoney` leaves a short account at zero, so the Route 43 Rockets take all of a smaller balance. The Rocket Hideout's cameras, traps, passwords, executives and Electrodes and Pryce's ice floor run on ported primitives; `PlayCurMonCry` is audio. `tests/mahogany.test.ts` and five playthrough stages cover them. | | `game/scripts/blackthorn.ts`, the Radio Tower takeover, Ice Path, Blackthorn and Dragon's Den | `engine/events/dratini.asm` (`GiveDratini`), `engine/events/move_deleter.asm` (`MoveDeletion`), `engine/pokemon/mon_menu.asm` (`ChooseMoveToDelete`), `engine/events/whiteout.asm` (`Script_Whiteout`), `data/maps/setup_scripts.asm` (`MapSetupScript_Continue`) | `GiveDratini` searches the party backwards by species and writes `.Movesets` 0 (WRAP, THUNDER WAVE, TWISTER, EXTREMESPEED) or 1 with each move's base PP; a value of 2 or more changes nothing. `MoveDeletion` follows the routine's order of questions; `ChooseMoveToDelete` is shown as a list of the moves, and `.DeleteMove` shifts the later moves and PP bytes, PP Ups included. A save records objects whose visibility no longer matches their flags: `MapSetupScript_Continue` skips `LoadMapObjects`, so a flagless object a script made disappear (the Radio Tower director) stays gone after Continue and one that appeared with its flag set stays visible; a later visit rebuilds them from the map. A battle lost inside an `scall` jumps to `Script_Whiteout` without unwinding; its `newloadmap` is followed by `endall`, which drops the caller. The switch room's doors, the warehouse's reset, the Ice Path and Blackthorn Gym stone tables, whirlpools and the Dragon Shrine quiz run on ported primitives. `tests/blackthorn.test.ts`, `tests/radio-tower.test.ts` and six playthrough stages cover them. | +| `game/scripts/tin-tower.ts`, Tin Tower and Whirl Islands | `engine/pokemon/search_owned.asm` (`BeastsCheck`, `MonCheck`, `CheckOwnMonAnywhere`, `CheckOwnMon`) | `CheckOwnMonAnywhere` returns nothing with an empty party, then reads the party structs' species (an Egg counts as its species), `sBox` and the fourteen `sBoxN` copies, matching the player's ID and the OT's first five letters (`NAME_LENGTH_JAPANESE - 2` compares, stopping early at a shared terminator); the Day-Care is not searched. The original's `sBox` is the current box, and its own `sBoxN` slot may still hold an older copy from before the last box change; the port keeps the boxes without stale copies, so a Pokémon only in such a copy is not found. The sages, Suicune, Ho-Oh and Lugia run on ported primitives (`BATTLETYPE_SUICUNE`, `BATTLETYPE_FORCEITEM`). `tests/tin-tower.test.ts` and two playthrough stages cover them. | | `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, all 48 cursor positions, selected reel states and whole Golem/Chansey/Egg sequences are checked against the ROM (`tests/reference-game-corner.spec.ts`, which runs `CardFlip_UpdateCursorOAM`, `Slots_UpdateReelPositionAndOAM`/`.SpinReel` and the sprite-animation engine's own Golem/Chansey/Egg routines): the cursor, the reel symbols and the three creatures each anchor an OAM-relative pixel pair 8 (x) and 16 (y) beyond the screen's, as `cursorSprites` reads; `depixel`'s own two arguments end up swapped by `_InitSpriteAnimStruct` (confirmed against the reference emulator), so Golem/Chansey/Egg's X and Y bases were swapped to match, and Golem's own OAM table X-flips its right half. `PlaySpriteAnimations` is compared frame by frame over a three-Golem run and a Chansey round with two eggs (shadow OAM, `wSlotsDelay` and `hSCY`): frames last 8 ticks (the first draw plus 7 decrements); Golem's frames 3 and 4 flip the whole OAM set (offsets become −8−offset); a sprite removed by `DeinitializeSprite` is still drawn that tick, because `UpdateAnimFrame` runs after the animation function; the Golem's roll toggles `hSCY` by ±2, which the tile screen shows with the space tiles `_SlotMachine` filled the BG map with. The rows scrolled in use palette 0, since the attribute map beyond row 18 is not reproduced. 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. | diff --git a/docs/PLAN.md b/docs/PLAN.md index d5cdaf9..b40c79d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -21,6 +21,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati | Routes 38–41, Olivine, Cianwood, Chuck and Jasmine | Done (port, Battle Tower closed) | | Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | Done | | The Radio Tower takeover, Ice Path, Blackthorn, Clair and Dragon's Den | Done | +| Tin Tower, Suicune, Ho-Oh, Whirl Islands and Lugia | Done | | 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 | @@ -440,6 +441,13 @@ The numbered sections below keep their full scope. Each one opens with a checkli - The playthrough driver gained `push` and `boulders` (pushes planned breadth first over the boulders and the player's reachable area, each into its own stone-table hole), clears whirlpools in `sail`, can choose the move `teach` forgets, and heals with the strongest potion carried. Tests: `tests/blackthorn.test.ts` (Move Deleter, Dratini) and `tests/radio-tower.test.ts` (switches through a reload and the warehouse reset, a lost rival battle, the director after a save). The playthrough continues for six more stages: the Radio Tower, the Underground, the Clear Bell, Ice Path, training and Clair, and the Dragon Shrine. - Remaining: Tin Tower and Suicune, Whirl Islands and Lugia, the Battle Tower, radio and rematches (#30, including Buena's password show), breeding (#29), mobile and link features, and audio. +## Implemented: Tin Tower and Whirl Islands (board #32, increment G) + +- Tin Tower's nine floors and roof and the Whirl Islands' eight maps are open. The Clear Bell lets the player past the entrance's sages and through the Wise Trio's room; in Tin Tower 1F Raikou and Entei flee unless caught, and Suicune battles (`BATTLETYPE_SUICUNE`), followed by Eusine. +- New specials (`game/scripts/tin-tower.ts`): `MonCheck` and `BeastsCheck` over `CheckOwnMonAnywhere`: the party and every box, the player's ID and the first five letters of the OT, an empty party finding nothing, the Day-Care left out. After the League with all three beasts, the second Wise Trio appears and gives the Rainbow Wing; its stairs lead up to Ho-Oh on the roof. Lugia waits at the bottom of the Whirl Islands for a player with the Silver Wing. Both battles use `BATTLETYPE_FORCEITEM`. +- The playthrough driver plans ledge jumps (for the later stages only, so the earlier seeded stages keep their recorded paths), waterfalls carrying a surfer down, warp panels landing elsewhere on a map, and Surf and Whirlpool between warps in `travel`. Tests: `tests/tin-tower.test.ts` (MonCheck's rules, the Rainbow Wing and the climb to Ho-Oh, the Whirl Islands to Lugia). The playthrough continues for two more stages: Routes 45 and 46 back to Ecruteak, and Tin Tower's Suicune. +- Remaining: the Battle Tower, Olivine's port and the S.S. Aqua (board #33), radio and rematches (#30), breeding (#29), mobile and link features, and audio. `CheckOwnMonAnywhere` in the original also reads the current box's own SRAM copy, which can be stale; the port keeps no stale copy. + ## 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. diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index 8239a64..b39fc95 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -1069,6 +1069,8 @@ export const JOHTO = { FindPartyMonThatSpecies: 0xc276, PlayCurMonCry: 0xc472, GiveDratini: 0x8b170, + BeastsCheck: 0x4a6e8, + MonCheck: 0x4a711, "GiveDratini.Movesets": 0x8b1c4, MoveDeletion: 0x2c547, "MoveDeletion.DeleterIntroText": 0x2c5ef, @@ -1328,6 +1330,24 @@ export const NATIVE_MAPS: Record = { "3:58": "Mt. Mortar", "3:59": "Mt. Mortar 2F", "3:60": "Mt. Mortar B1F", + "3:4": "Tin Tower 1F", + "3:5": "Tin Tower 2F", + "3:6": "Tin Tower 3F", + "3:7": "Tin Tower 4F", + "3:8": "Tin Tower 5F", + "3:9": "Tin Tower 6F", + "3:10": "Tin Tower 7F", + "3:11": "Tin Tower 8F", + "3:12": "Tin Tower 9F", + "15:12": "Tin Tower roof", + "3:66": "Whirl Islands", + "3:67": "Whirl Islands", + "3:68": "Whirl Islands", + "3:69": "Whirl Islands", + "3:70": "Whirl Islands", + "3:71": "Whirl Islands B1F", + "3:72": "Whirl Islands B2F", + "3:73": "Lugia’s chamber", "3:61": "Ice Path 1F", "3:62": "Ice Path B1F", "3:63": "Ice Path B2F", diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index 28cf4c1..6448579 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -73,6 +73,7 @@ import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins"; import { slotMachine } from "./slot-machine"; import { cardFlip } from "./card-flip"; import { giveDratini, moveDeletion } from "./blackthorn"; +import { beastsCheck, monCheck } from "./tin-tower"; import { checkMagikarpLength, findPartyMonThatSpecies, @@ -3426,6 +3427,10 @@ export class ScriptRun { this.specialContext(pages, take), this.state.magikarpRecord, ); + case JOHTO.MonCheck: + return monCheck(this.specialContext(pages, take), value); + case JOHTO.BeastsCheck: + return beastsCheck(this.specialContext(pages, take)); case JOHTO.GiveDratini: giveDratini(this.specialContext(pages, take), value); return value; diff --git a/src/game/scripts/tin-tower.ts b/src/game/scripts/tin-tower.ts new file mode 100644 index 0000000..cf53015 --- /dev/null +++ b/src/game/scripts/tin-tower.ts @@ -0,0 +1,39 @@ +import type { SpecialContext } from "./special-context"; + +const RAIKOU = 243, + ENTEI = 244, + SUICUNE = 245; + +/** CheckOwnMon's OT comparison: the first five characters, stopping at a + * terminator both names share (NAME_LENGTH_JAPANESE - 2 compares, then + * one more). */ +const sameOT = (ot: string, player: string) => { + const a = `${ot}@`, + b = `${player}@`; + for (let i = 0; i < 4; i++) { + if (a[i] !== b[i]) return false; + if (a[i] === "@") return true; + } + return a[4] === b[4]; +}; + +/** CheckOwnMonAnywhere (engine/pokemon/search_owned.asm): a Pokémon of + * `species` with the player's ID and OT in the party or a box. An empty + * party finds nothing; the Day-Care is not searched. The party struct's + * species is read, so an Egg counts as its species. */ +export function ownsMon(c: SpecialContext, species: number) { + const { party, storage } = c.state; + if (!party.length) return false; + const player = c.player(); + const owns = (mon: { species: number; otId: number; ot: string }) => + mon.species === species && mon.otId === player.id && sameOT(mon.ot, player.name); + return party.some(owns) || storage.boxes.some((box) => box.some(owns)); +} + +/** MonCheck: 1 when the player owns a Pokémon of the species. */ +export const monCheck = (c: SpecialContext, species: number) => + ownsMon(c, species) ? 1 : 0; + +/** BeastsCheck: 1 when the player owns Raikou, Entei and Suicune. */ +export const beastsCheck = (c: SpecialContext) => + [RAIKOU, ENTEI, SUICUNE].every((species) => ownsMon(c, species)) ? 1 : 0; diff --git a/tests/playthrough-driver.ts b/tests/playthrough-driver.ts index cf6fc83..35a5396 100644 --- a/tests/playthrough-driver.ts +++ b/tests/playthrough-driver.ts @@ -36,6 +36,9 @@ export class Playthrough { readonly log: string[] = []; /** A wild species to throw Poké Balls at instead of fighting. */ wanted = 0; + /** Plan ledge jumps (Tin Tower, Route 45). Off for the earlier stages, + * whose seeded runs were recorded walking around ledges. */ + ledges = false; seed = 0x2a; private stage = 0; readonly now = () => new Date("2026-09-22T12:00:00"); @@ -227,10 +230,11 @@ export class Playthrough { if (world.x === x && world.y === y) return; // A surfer crosses water until it steps ashore. const water = world.state.playerState === PLAYER_SURF; - for (const direction of findPath(world, x, y, { water })) { + for (const direction of findPath(world, x, y, { water, ledges: this.ledges })) { const end = landing(world, world.x, world.y, direction, { water, target: { x, y }, + ledges: this.ledges, }); if (!world.move(direction)) { for (let tick = 0; tick < 64; tick++) world.tick(); @@ -255,7 +259,11 @@ export class Playthrough { this.play(); expect(world.mapId, `sailing to ${x},${y}`).toBe(map); if (world.x === x && world.y === y) return; - for (const direction of findPath(world, x, y, { water: true, whirlpools })) { + for (const direction of findPath(world, x, y, { + water: true, + whirlpools, + ledges: this.ledges, + })) { const [dx, dy] = STEP[direction]; const from = [world.x, world.y]; const ahead = world.assets.collision(world.map, from[0] + dx, from[1] + dy); @@ -298,8 +306,10 @@ export class Playthrough { ? [dx < 0 ? 0 : world.map.width - 1, i] : [i, dy < 0 ? 0 : world.map.height - 1], ).flatMap(([x, y]) => { + // A warp on the edge (a gate's doorway) is not the connection. + if (world.map.warps.some((warp) => warp.x === x && warp.y === y)) return []; try { - return [{ x, y, length: findPath(world, x, y, { water }).length }]; + return [{ x, y, length: findPath(world, x, y, { water, ledges: this.ledges }).length }]; } catch { return []; } @@ -426,7 +436,7 @@ export class Playthrough { const world = this.world; for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) { try { - findPath(world, warp.x, warp.y); + findPath(world, warp.x, warp.y, { ledges: this.ledges }); } catch { continue; } @@ -452,7 +462,7 @@ export class Playthrough { const reachable = () => { if (!goal) return true; try { - findPath(world, goal.x, goal.y); + findPath(world, goal.x, goal.y, { ledges: this.ledges }); return true; } catch { return false; @@ -464,7 +474,7 @@ export class Playthrough { for (const warp of world.map.warps) { if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue; try { - findPath(world, warp.x, warp.y); + findPath(world, warp.x, warp.y, { ledges: this.ledges }); } catch { continue; } @@ -480,23 +490,37 @@ export class Playthrough { }; /** The fewest warps (stairs, holes, doors) among `maps` to `target`: * breadth first over the places each warp lands, so a maze of floors - * whose holes drop into cut-off corners is solved without looping. */ - travel = (target: string, maps: string[]) => { + * whose holes drop into cut-off corners is solved without looping. + * `water` lets a player with Surf and Whirlpool sail to the warps. */ + travel = (target: string, maps: string[], { water = false } = {}) => { const world = this.world; const queue = [world.save()]; const seen = new Set([`${world.mapId} ${world.x},${world.y}`]); - for (let i = 0; i < queue.length && i < 200; i++) { + for (let i = 0; i < queue.length && i < 1000; i++) { world.restore(queue[i]); const from = world.mapId; for (const warp of world.map.warps) { if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue; world.restore(queue[i]); try { - findPath(world, warp.x, warp.y); + findPath(world, warp.x, warp.y, { water, whirlpools: water, ledges: this.ledges }); } catch { continue; } - this.walk(warp.x, warp.y); + // A surfer reaches a cave's stairs over water, then steps on. + if (water) { + try { + this.sail(warp.x, warp.y, { whirlpools: true }); + } catch { + if (world.mapId === from) continue; + } + } else + try { + this.walk(warp.x, warp.y); + } catch { + // A warp panel on the way can land elsewhere on the same map. + if (world.mapId === from) continue; + } const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)]; if (world.mapId === from && exit && world.move(exit)) this.play(); if (world.mapId === from) continue; diff --git a/tests/playthrough.test.ts b/tests/playthrough.test.ts index 90f5534..49a77c7 100644 --- a/tests/playthrough.test.ts +++ b/tests/playthrough.test.ts @@ -207,6 +207,13 @@ const BASEMENT_KEY = 0x85, EVENT_GOT_DRATINI = 0xbd, EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 0xc1, ENGINE_RISINGBADGE = 0x22; +const ROUTE_46 = "5:9", + ROUTE_29_ROUTE_46_GATE = "24:13", + TIN_TOWER_ENTRANCE = "4:1", + WISE_TRIOS_ROOM = "4:2", + TIN_TOWER_1F = "3:4", + EVENT_RANG_CLEAR_BELL_2 = 0x767, + EVENT_FOUGHT_SUICUNE = 0x335; /** The acceptance run for PLAN section 2: a new game played from the * introduction to the catching tutorial through the world's own inputs, @@ -1431,5 +1438,53 @@ describe.skipIf(!existsSync(romPath))( reload(); writeTestSave("blackthorn-city"); }, 240_000); + it("takes Routes 45 and 46 down to Route 29 and walks back to Ecruteak", () => { + traceFailures(); + // From here on the driver plans ledge jumps (Route 45, Tin Tower). + p.ledges = true; + heal(BLACKTHORN_CENTER, BLACKTHORN); + cross("down", ROUTE_45); + // Route 45's ledges only lead down; Route 46 joins it on the west. + cross("left", ROUTE_46); + stairs(ROUTE_29_ROUTE_46_GATE); + stairs(ROUTE_29); + cross("left", CHERRYGROVE); + cross("up", ROUTE_30); + cross("up", ROUTE_31); + stairs(ROUTE_31_GATE); + stairs(VIOLET); + cross("left", ROUTE_36); + cross("up", ROUTE_37); + cross("up", ECRUTEAK); + reload(); + }, 240_000); + + it("rings the Clear Bell for the sages and faces Suicune in Tin Tower", () => { + traceFailures(); + heal(ECRUTEAK_CENTER, ECRUTEAK); + enter(TIN_TOWER_ENTRANCE, "up"); + // EcruteakTinTowerEntranceSageBlocks: a sage steps in the way; told + // about the Clear Bell, he lets the player through. + walk(5, 8); + p.world.move("up"); + play(); + if (p.world.save().direction !== "up") p.world.move("up"); + p.world.interact(); + play(); + expect(p.log.join(" ")).toContain("The sound of that\nCLEAR BELL!"); + expect(p.world.state.events.has(EVENT_RANG_CLEAR_BELL_2)).toBe(true); + walk(5, 4); // the stairs at (5, 3) lead to the building's far side + p.world.move("up"); + play(); + stairs(WISE_TRIOS_ROOM); + stairs(ECRUTEAK); + // TinTower1FSuicuneBattleScript: Raikou and Entei run; Suicune stays. + stairs(TIN_TOWER_1F); + expect(p.world.state.events.has(EVENT_FOUGHT_SUICUNE)).toBe(true); + expect(p.log.join(" ")).toContain("SUICUNE"); + expect(p.log.join(" ")).toContain("rainbow-"); + reload(); + writeTestSave("tin-tower"); + }, 240_000); }, ); diff --git a/tests/tin-tower.test.ts b/tests/tin-tower.test.ts new file mode 100644 index 0000000..ec83321 --- /dev/null +++ b/tests/tin-tower.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { CrystalAssets } from "../src/game/rom/assets"; +import { GameWorld } from "../src/game/world/world"; +import { generatePartyMon } from "../src/game/party/pokemon"; +import { toBoxMon } from "../src/game/party/storage"; +import { Playthrough } from "./playthrough-driver"; + +const romPath = + process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; + +describe.skipIf(!existsSync(romPath))("Tin Tower and Whirl Islands with the local ROM", () => { + const assets = existsSync(romPath) + ? new CrystalAssets(new Uint8Array(readFileSync(romPath))) + : undefined; + + const TIN_TOWER = ["3:4", "3:5", "3:6", "3:7", "3:8", "3:9", "3:10", "3:11", "3:12"], + TIN_TOWER_ROOF = "15:12", + ROUTE_41 = "22:2", + WHIRL_ISLANDS = ["3:66", "3:67", "3:68", "3:69", "3:70", "3:71", "3:72"], + LUGIA_CHAMBER = "3:73"; + const TYPHLOSION = 157, + FERALIGATR = 160, + RAIKOU = 243, + ENTEI = 244, + SUICUNE = 245, + SURF = 57, + WHIRLPOOL = 250, + WATERFALL = 127, + RAINBOW_WING = 0xb2, + SILVER_WING = 0x47; + const EVENT_FOUGHT_SUICUNE = 0x335, + EVENT_BEAT_ELITE_FOUR = 0x44, + EVENT_GOT_RAINBOW_WING = 0x336, + EVENT_FOUGHT_HO_OH = 0x317, + EVENT_FOUGHT_LUGIA = 0x318, + SCENE_TINTOWER1F_NOOP = 1; + /** TINTOWER1F_RAIKOU and TINTOWER1F_ENTEI (objects 2 and 3). */ + const RAIKOU_OBJECT = 3, + ENTEI_OBJECT = 4; + + const mon = (world: GameWorld, species: number, { level = 100, ot = "KAWA", otId = world.playerId } = {}) => + generatePartyMon(assets!.pokemon, { + species, + level, + item: 0, + otId, + ot, + dvs: 0xffff, + caughtData: 0, + }); + /** A playthrough driver on a fresh world named KAWA with all badges. */ + const driver = () => { + const p = new Playthrough(assets!); + p.world = new GameWorld(assets!, p.now, p.random); + // Tin Tower's floors are joined by ledges. + p.ledges = true; + for (let badge = 0x1b; badge <= 0x22; badge++) p.world.state.flags.add(badge); + return p; + }; + + it("hides the beasts the player owns, by ID and the OT's first five letters (MonCheck)", () => { + const p = driver(); + const { world } = p; + world.name = "KRISTAL"; + const name = world.name; + world.state.party = [mon(world, TYPHLOSION, { ot: name })]; + const beasts = () => { + world.warpTo(TIN_TOWER[0], 9, 14); + world.state.scenes[TIN_TOWER[0]] = SCENE_TINTOWER1F_NOOP; + p.play(); + return [RAIKOU_OBJECT, ENTEI_OBJECT].map((id) => + world.objects().some((object) => object.id === id), + ); + }; + expect(beasts()).toEqual([true, true]); + // A Raikou in a box with the player's ID and OT counts. + world.state.storage.boxes[3].push(toBoxMon(mon(world, RAIKOU, { ot: name }))); + expect(beasts()).toEqual([false, true]); + // CheckOwnMon compares only five letters of the OT. + world.state.party.push(mon(world, ENTEI, { ot: "KRISTOF" })); + expect(beasts()).toEqual([false, false]); + // Another trainer's ID does not. + world.state.party.pop(); + world.state.party.push(mon(world, ENTEI, { ot: name, otId: world.playerId ^ 1 })); + expect(beasts()).toEqual([false, true]); + // Without any party Pokémon, CheckOwnMonAnywhere finds nothing. + world.state.party = []; + expect(beasts()).toEqual([true, true]); + }); + + it("gives the Rainbow Wing after the League with all three beasts, and Ho-Oh waits on the roof", () => { + const p = driver(); + const { world } = p; + const name = world.name; + world.state.party = [ + mon(world, TYPHLOSION, { ot: name }), + mon(world, RAIKOU, { ot: name }), + mon(world, ENTEI, { ot: name }), + mon(world, SUICUNE, { ot: name }), + ]; + world.state.events.add(EVENT_FOUGHT_SUICUNE); + world.state.events.add(EVENT_BEAT_ELITE_FOUR); + world.state.scenes[TIN_TOWER[0]] = SCENE_TINTOWER1F_NOOP; + world.warpTo(TIN_TOWER[0], 9, 14); + p.play(); + // BeastsCheck: the second trio appears; the one at (9, 1) has the wing. + p.talkTo(world.map.objects[8].id); + p.play(); + expect(world.state.events.has(EVENT_GOT_RAINBOW_WING)).toBe(true); + expect(world.state.bag.keyItems).toContain(RAINBOW_WING); + // The stairs it reveals lead up through the floors to the roof. + p.travel(TIN_TOWER_ROOF, TIN_TOWER.slice(1)); + expect(p.world.mapId).toBe(TIN_TOWER_ROOF); + p.talkTo(p.world.map.objects[0].id); + p.play(); + expect(p.world.notice, p.world.diagnostic).toBe(""); + expect(p.world.state.events.has(EVENT_FOUGHT_HO_OH)).toBe(true); + expect(p.log.join(" ")).toContain("HO-OH"); + }); + + it("leads through Whirl Islands to Lugia with the Silver Wing", () => { + const p = driver(); + const { world } = p; + const feraligatr = mon(world, FERALIGATR); + feraligatr.moves = [SURF, WHIRLPOOL, WATERFALL, 0]; + feraligatr.pp = [15, 15, 15, 0]; + world.state.party = [mon(world, TYPHLOSION), feraligatr]; + world.state.bag.keyItems.push(SILVER_WING); + // On the island in front of the north-west entrance. + world.warpTo(ROUTE_41, 12, 18); + p.play(); + p.travel(LUGIA_CHAMBER, [ROUTE_41, ...WHIRL_ISLANDS], { water: true }); + expect(p.world.mapId).toBe(LUGIA_CHAMBER); + // Lugia waits in the pool at (9, 5). + p.sail(9, 6); + if (p.world.save().direction !== "up") p.world.move("up"); + p.world.interact(); + p.play(); + expect(p.world.notice, p.world.diagnostic).toBe(""); + expect(p.world.state.events.has(EVENT_FOUGHT_LUGIA)).toBe(true); + expect(p.log.join(" ")).toContain("LUGIA"); + }); +}); diff --git a/tests/world-driver.ts b/tests/world-driver.ts index 3720ffd..edfd477 100644 --- a/tests/world-driver.ts +++ b/tests/world-driver.ts @@ -134,9 +134,22 @@ function open( return true; } -/** Where a step in `direction` from (x, y) ends: the next tile, or, on - * ice, the last tile of the slide before something blocks it. Undefined - * when the step is blocked. */ +/** .ledge_table: the directions each COLL_HOP_* tile jumps toward. */ +const LEDGES: readonly (readonly Direction[])[] = [ + ["right"], + ["left"], + ["up"], + ["down"], + ["down", "right"], + ["down", "left"], + ["up", "right"], + ["up", "left"], +]; + +/** Where a step in `direction` from (x, y) ends: the next tile, two tiles + * on when standing on a ledge that jumps that way (.TryJump), or, on ice, + * the last tile of the slide before something blocks it. Undefined when + * the step is blocked. */ export function landing( world: GameWorld, x: number, @@ -146,10 +159,47 @@ export function landing( water = false, target, whirlpools = false, - }: { water?: boolean; target?: { x: number; y: number }; whirlpools?: boolean } = {}, + ledges = false, + }: { + water?: boolean; + target?: { x: number; y: number }; + whirlpools?: boolean; + ledges?: boolean; + } = {}, ): { x: number; y: number } | undefined { - if (!open(world, x, y, direction, water, target, whirlpools)) return undefined; const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!; + const standing = world.assets.collision(world.map, x, y); + if (ledges && (standing & 0xf0) === 0xa0 && LEDGES[standing & 7].includes(direction)) { + const lx = x + dx * 2, + ly = y + dy * 2; + const occupied = (ox: number, oy: number) => + world.objects().some((object) => object.x === ox && object.y === oy); + if ( + lx >= 0 && + ly >= 0 && + lx < world.map.width && + ly < world.map.height && + world.assets.permission(world.assets.collision(world.map, lx, ly)) === 0 && + !occupied(x + dx, y + dy) && + !occupied(lx, ly) && + !( + isWarpHazard(world.assets.collision(world.map, lx, ly)) && + world.map.warps.some((warp) => warp.x === lx && warp.y === ly) && + (lx !== target?.x || ly !== target?.y) + ) + ) + return { x: lx, y: ly }; + } + // COLL_WATERFALL: a surfer entering from above is carried down it. + const ahead = world.assets.collision(world.map, x + dx, y + dy); + if (water && ahead === 0x33) { + if (direction !== "down") return undefined; + let fy = y + 1; + while (world.assets.collision(world.map, x, fy) === 0x33) fy++; + if (!open(world, x, fy - 1, "down", water, target, whirlpools)) return undefined; + return { x, y: fy }; + } + if (!open(world, x, y, direction, water, target, whirlpools)) return undefined; x += dx; y += dy; while ( @@ -164,13 +214,13 @@ export function landing( /** Shortest walkable path on the current map, avoiding other warps. * `water` also crosses open water, for a player who can Surf, and - * `whirlpools` the whirlpools a player with Whirlpool can clear; a step - * onto ice slides on until blocked. */ + * `whirlpools` the whirlpools a player with Whirlpool can clear; `ledges` + * takes ledge jumps; a step onto ice slides on until blocked. */ export function findPath( world: GameWorld, targetX: number, targetY: number, - { water = false, whirlpools = false } = {}, + { water = false, whirlpools = false, ledges = false } = {}, ): Direction[] { const target = { x: targetX, y: targetY }; const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }]; @@ -182,7 +232,7 @@ export function findPath( visited.add(key); if (node.x === targetX && node.y === targetY) return node.path; for (const [direction] of DIRECTIONS) { - const end = landing(world, node.x, node.y, direction, { water, target, whirlpools }); + const end = landing(world, node.x, node.y, direction, { water, target, whirlpools, ledges }); if (end) queue.push({ ...end, path: [...node.path, direction] }); } }