Open Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce (#32)

Route 42, Mt. Mortar, Mahogany Town, Route 43 and its gates, the Lake of
Rage and its houses, the Rocket Hideout and Route 44 are open. The story
plays through the toll, the red Gyarados, Lance, the hideout and Pryce's
GLACIERBADGE, which starts the Radio Tower takeover.

The fishing guru measures Magikarp (CheckMagikarpLength,
MagikarpHouseSign, FindPartyMonThatSpecies); the record is saved.
takemoney now leaves a short account at zero as TakeMoney does, and
setmapscene/setscene on a map without scenes consume the byte instead of
refusing. The playthrough's path search slides on ice and the run gains
five stages to Pryce.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-13 13:35:58 +02:00
1 parent 62fe4bf664
commit 061ca4d59d
11 files changed
+578 -43

No files matched your search

+4 -2
View File
@@ -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 Olivine City and the Mineral 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.
**Current milestone: from the opening to Mahogany Town and the Glacier 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.
@@ -24,7 +24,9 @@ North of Goldenrod, Route 35 leads through the National Park to Route 36. On Tue
West of Ecruteak, Routes 38 and 39 lead past the MooMoo farm, where seven Berries cure the sick Miltank, to Olivine City. Your rival is there ahead of you, and Jasmine is at the top of the Lighthouse with her sick Ampharos. The Café gives HM04 Strength and a fisherman gives the Good Rod. Surf Routes 40 and 41 to Cianwood City: Suicune flees across the water and Eusine challenges you, the pharmacy hands over the SecretPotion, and Chuck waits behind the boulders of his gym for the Storm Badge. His wife gives HM02 Fly. Mania lends you Shuckie, the Poké Seer reads where you met your Pokémon, and the photographer offers a photo, which needs a Game Boy Printer. Bring the medicine back to the Lighthouse, then challenge Jasmine for the Mineral Badge.
The routes beyond Olivine, 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.
East of Ecruteak, Route 42 runs past Mt. Mortar to Mahogany Town. North of town, Team Rocket charges a toll at the Route 43 gate, and the Lake of Rage is full of Gyarados forced to evolve. Stop the red one, then join Lance in the hideout under Mahogany's souvenir shop: get past the cameras and traps, learn the passwords, beat the executives and make the Electrodes faint for HM06 Whirlpool. Pryce's gym is an ice floor to slide across for the Glacier Badge. The fishing guru by the lake measures your Magikarp against the record.
The Radio Tower takeover and the routes beyond Mahogany, 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
+3 -2
View File
@@ -51,6 +51,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `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/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/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. |
@@ -597,8 +598,8 @@ frames) and the boops (120 frames). The two "……" rings last 12 ticks each,
an estimate from `Sfx_Call`'s notes; no sound is played.
Interpreter additions: `random` (`Script_random`'s rejection of draws past
the largest multiple), `givemoney`, `takemoney` (unchanged when short, like
`TakeMoney`), `checkmoney` (`HAVE_MORE`/`HAVE_AMOUNT`/`HAVE_LESS`) and
the largest multiple), `givemoney`, `takemoney` (an account that is short is left at
zero, like `TakeMoney`), `checkmoney` (`HAVE_MORE`/`HAVE_AMOUNT`/`HAVE_LESS`) and
`getmoney` on `YOUR_MONEY` or `MOMS_MONEY`, `getcurlandmarkname` and
`getlandmarkname` (`GetLandmarkName`; `<BSP>` prints as a space). The money
and landmark macros put the account or landmark before the string buffer.
+11
View File
@@ -18,6 +18,8 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
| 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 |
| Routes 35–37, the National Park, Ecruteak and Morty | Done (Tin Tower closed) |
| 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 |
| 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 |
@@ -419,6 +421,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Local validation (2026-10-13): 790 unit tests passed, 3 skipped (scratch tests excluded), including the 32-stage playthrough; without a ROM, 168 passed and 625 skipped. Both builds, reference/profile checks, vendor integrity and release isolation passed. Browser checks: 181 passed, 21 skipped, 12 known WebKit menu failures (#70). CI and physical-device acceptance were not run.
- Remaining: Olivine's port and the S.S. Aqua, the Battle Tower, Whirl Islands, the Game Boy Printer, routes beyond Olivine and Cianwood (Route 42 onward), radio and rematches (#30), breeding (#29), mobile and link features, and audio.
## Implemented: Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce (board #32, increment E)
- Route 42 with its Ecruteak gate, Mt. Mortar's four maps, Mahogany Town with its Pokémon Center, gym, souvenir shop and the red Gyarados speech house, Route 43 with both gates, the Lake of Rage with the Hidden Power and fishing guru houses, the Rocket Hideout's three floors and Route 44 are open. Ice Path and the Radio Tower takeover follow.
- The story runs on existing primitives: the Rockets' toll at the Route 43 gate (charged on every pass until the hideout is cleared), the red Gyarados (forced shiny) and the Red Scale, Lance, Dragonite's Hyper Beam on the souvenir shop's stairs, the hideout's cameras and exploding traps, Lance's healing, the two door passwords, the rival, both executives, the Murkrow's password, the six Electrodes and HM06, Pryce's ice floor, the GLACIERBADGE and TM16, and `RadioTowerRocketsScript` at seven badges.
- New specials: `CheckMagikarpLength`, `MagikarpHouseSign` and `FindPartyMonThatSpecies` (`game/scripts/lake-of-rage.ts`). The Magikarp record and holder are saved as `magikarpRecord` (3′6″ by RALPH in a new game, and in older saves).
- Engine fixes: `takemoney` leaves a short account at zero, as `TakeMoney` does (it used to leave it unchanged). `setmapscene` and `setscene` on a map without a scene variable consume the scene byte instead of refusing; see NATIVE_PORT.md for where the original writes it.
- The playthrough driver's path search slides on ice. Tests: `tests/mahogany.test.ts` covers the record, its holder and reload, every guru answer, an Egg, the Elixer on standby and the toll. The playthrough continues for five more stages, from Olivine to Pryce.
- Remaining: the Radio Tower takeover, Ice Path, Blackthorn, Clair, Dragon's Den, Routes 45 and 46, Tin Tower, Whirl Islands, radio and rematches (#30), breeding (#29), 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.
+28
View File
@@ -1064,6 +1064,12 @@ export const OPTIONS = {
/** Johto's scripts, routines and tables from Route 32 on, keyed by their
* PRET labels for `npm run check:reference`. */
export const JOHTO = {
CheckMagikarpLength: 0xfbb32,
MagikarpHouseSign: 0xfbcd2,
FindPartyMonThatSpecies: 0xc276,
PlayCurMonCry: 0xc472,
"CheckMagikarpLength.MagikarpGuruMeasureText": 0xfbba9,
"MagikarpHouseSign.KarpGuruRecordText": 0xfbce8,
"GiveShuckle": 0x7305,
"ReturnShuckie": 0x737e,
"PokeSeer": 0x4f0bc,
@@ -1279,6 +1285,7 @@ export const WRAM = {
wBugContestFirstPlaceScore: 0xd004,
wBugContestSecondPlaceScore: 0xd008,
wBugContestThirdPlaceScore: 0xd00c,
wMagikarpRecordHoldersName: 0xdfea,
} as const;
export const MAP_GROUP_COUNT = 26;
@@ -1295,6 +1302,27 @@ export const LAST_MAP_GROUP_SIZE = 11;
* maps remain unavailable; the label only distinguishes interiors that share
* a ROM landmark name. */
export const NATIVE_MAPS: Record<string, string> = {
"2:1": "Red Gyarados speech house",
"2:2": "Mahogany Gym",
"2:3": "Pokémon Center",
"2:4": "Route 42 Ecruteak gate",
"2:5": "Route 42",
"2:6": "Route 44",
"2:7": "Mahogany Town",
"3:48": "Mahogany souvenir shop",
"3:49": "Rocket Hideout B1F",
"3:50": "Rocket Hideout B2F",
"3:51": "Rocket Hideout B3F",
"3:57": "Mt. Mortar",
"3:58": "Mt. Mortar",
"3:59": "Mt. Mortar 2F",
"3:60": "Mt. Mortar B1F",
"9:1": "Hidden Power house",
"9:2": "Fishing guru’s house",
"9:3": "Route 43 Mahogany gate",
"9:4": "Route 43 gate",
"9:5": "Route 43",
"9:6": "Lake of Rage",
"1:1": "Olivine Pokecenter 1F",
"1:2": "Olivine Gym",
"1:3": "Olivine Tims House",
+39 -7
View File
@@ -72,6 +72,13 @@ import { unownPuzzle } from "./unown-puzzle";
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
import { slotMachine } from "./slot-machine";
import { cardFlip } from "./card-flip";
import {
checkMagikarpLength,
findPartyMonThatSpecies,
initialMagikarpRecord,
magikarpHouseSign,
type MagikarpRecord,
} from "./lake-of-rage";
import {
checkFirstMonIsEgg,
checkForLuckyNumberWinners,
@@ -187,6 +194,8 @@ export interface ScriptState {
timers: Partial<Record<DayTimerName, DayTimer>>;
/** sLuckyIDNumber and sLuckyNumberDay (wCurDay + 1 when drawn). */
luckyNumber: { id: number; day: number };
/** wBestMagikarpLengthFeet/Inches and wMagikarpRecordHoldersName. */
magikarpRecord: MagikarpRecord;
/** wRoamMon1/2/3 (Raikou, Entei, Suicune): InitRoamMons starts the first
* two roaming; CheckEncounterRoamMon, UpdateRoamMons and JumpRoamMons
* move or encounter them (see ../world/roam-mons). */
@@ -255,6 +264,7 @@ export const emptyScriptState = (): ScriptState => ({
firstUnownSeen: 0,
timers: {},
luckyNumber: { id: 0, day: 0 },
magikarpRecord: initialMagikarpRecord(),
roamMons: emptyRoamMons(),
roamMonsMap: null,
options: defaultOptions(),
@@ -303,6 +313,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
Object.entries(state.timers).map(([name, timer]) => [name, { ...timer }]),
),
luckyNumber: { ...state.luckyNumber },
magikarpRecord: { ...state.magikarpRecord },
roamMons: state.roamMons.map((mon) => ({ ...mon })),
roamMonsMap: state.roamMonsMap,
options: { ...state.options },
@@ -1114,9 +1125,11 @@ export class ScriptRun {
if (opcode === 0x11)
value = owner ? (this.state.scenes[owner] ?? 0) : 0xff;
else {
if (!owner)
throw new Error("setmapscene targets a map without scenes.");
this.state.scenes[owner] = byte();
// DoScene: GetMapSceneID leaves de as it was for a map without
// a scene variable, and the scene byte is still read and
// written there when de is nonzero (see the 0x14 case below).
const scene = byte();
if (owner) this.state.scenes[owner] = scene;
}
break;
}
@@ -1127,8 +1140,15 @@ export class ScriptRun {
}
case 0x14: {
const owner = sceneOwner(this.host.mapId);
if (!owner) throw new Error("setscene on a map without scenes.");
this.state.scenes[owner] = byte();
// A map without a scene variable (Route 43, the Lake of Rage):
// GetMapSceneID returns carry but leaves de alone, and DoScene
// writes the byte through de whenever it is nonzero. At the Route
// 43 gate de is EventFlagAction's byte offset (4), the MBC's
// RAM-enable register; at the Lake of Rage it is whatever
// closetext left. Either way the byte is consumed and no game
// state changes here.
const scene = byte();
if (owner) this.state.scenes[owner] = scene;
break;
}
case 0x15:
@@ -1709,8 +1729,9 @@ export class ScriptRun {
if (opcode === 0x22)
this.state[account] = Math.min(C.MAX_MONEY, balance + amount);
else if (opcode === 0x23) {
// TakeMoney leaves the account unchanged when it is short.
if (balance >= amount) this.state[account] = balance - amount;
// TakeMoney: SubtractFunds keeps de on the account, which is
// zeroed when the subtraction borrows ("leave with 0 money").
this.state[account] = Math.max(0, balance - amount);
} else
value =
balance > amount
@@ -3389,8 +3410,19 @@ export class ScriptRun {
yield* photoStudio(this.specialContext(pages, take));
return value;
case JOHTO.PlaySlowCry:
case JOHTO.PlayCurMonCry:
// Cry pitch/duration is audio, like the ordinary cry opcode.
return value;
case JOHTO.FindPartyMonThatSpecies:
return findPartyMonThatSpecies(this.specialContext(pages, take), value);
case JOHTO.CheckMagikarpLength:
return yield* checkMagikarpLength(
this.specialContext(pages, take),
this.state.magikarpRecord,
);
case JOHTO.MagikarpHouseSign:
magikarpHouseSign(this.specialContext(pages, take), this.state.magikarpRecord);
return value;
case JOHTO.GameCornerPrizeMonCheckDex:
if (this.state.pokedex.caught.has(value)) return value;
this.state.pokedex.seen.add(value);
+68
View File
@@ -0,0 +1,68 @@
import { JOHTO, RAM, WRAM } from "../rom/profile";
import { magikarpLength } from "../battle/battle";
import type { Gen, SpecialContext } from "./special-context";
const MAGIKARP = 129;
/** MAGIKARPLENGTH_* (constants/script_constants.asm). */
export const MAGIKARP_LENGTH = {
notMagikarp: 0,
refused: 1,
tooShort: 2,
beatRecord: 3,
} as const;
/** wBestMagikarpLengthFeet/Inches and wMagikarpRecordHoldersName;
* InitializeMagikarpHouse starts a new game at 3′6″ by RALPH. */
export interface MagikarpRecord {
feet: number;
inches: number;
holder: string;
}
export const initialMagikarpRecord = (): MagikarpRecord => ({
feet: 3,
inches: 6,
holder: "RALPH",
});
/** PrintMagikarpLength: feet and inches, each a left-aligned number, with
* the feet and inches marks it loads into tiles $6e and $6f. */
const lengthString = (feet: number, inches: number) => `${feet}′${inches}″`;
/** FindThatSpecies: wPartySpecies holds EGG for an Egg, so an Egg never
* matches. */
export const findPartyMonThatSpecies = (c: SpecialContext, species: number) =>
c.state.party.some((mon) => !mon.egg && mon.species === species) ? 1 : 0;
/** CheckMagikarpLength (engine/events/magikarp.asm): pick a party
* Pokémon; a Magikarp is measured from its DVs and its own OT ID, and a
* longer one (both bytes compared, feet first) becomes the record, with
* its OT's name as the holder. */
export function* checkMagikarpLength(
c: SpecialContext,
record: MagikarpRecord,
): Gen<number> {
const slot = yield* c.selectMon();
if (slot < 0) return MAGIKARP_LENGTH.refused;
const mon = c.state.party[slot];
// wCurPartySpecies comes from wPartySpecies, EGG for an Egg.
if (mon.egg || mon.species !== MAGIKARP) return MAGIKARP_LENGTH.notMagikarp;
const { feet, inches } = magikarpLength(c.reader, mon.dvs, mon.otId);
c.strings[RAM.stringBuffer1] = lengthString(feet, inches);
c.write(JOHTO["CheckMagikarpLength.MagikarpGuruMeasureText"]);
// CompareBytes over wMagikarpLength and the record: carry when the new
// length is greater.
if (feet * 256 + inches <= record.feet * 256 + record.inches)
return MAGIKARP_LENGTH.tooShort;
record.feet = feet;
record.inches = inches;
record.holder = mon.ot;
return MAGIKARP_LENGTH.beatRecord;
}
/** MagikarpHouseSign: the record and its holder. */
export function magikarpHouseSign(c: SpecialContext, record: MagikarpRecord) {
c.strings[RAM.stringBuffer1] = lengthString(record.feet, record.inches);
c.strings[WRAM.wMagikarpRecordHoldersName] = record.holder;
c.write(JOHTO["MagikarpHouseSign.KarpGuruRecordText"]);
}
+14
View File
@@ -58,6 +58,7 @@ import {
type ScriptState,
type DayTimerName,
} from "../scripts/interpreter";
import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage";
import { IntroRun } from "../scripts/intro";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
@@ -198,6 +199,8 @@ 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 };
/** The Magikarp length record and its holder. */
magikarpRecord: MagikarpRecord;
/** wRoamMon1/2/3 (Raikou, Entei, Suicune). */
roamMons: RoamMon[];
/** wRoamMons_CurMapGroup/Number, kept from the last UpdateRoamMons or
@@ -4547,6 +4550,7 @@ export class GameWorld {
firstUnownSeen: state.firstUnownSeen,
timers: structuredClone(state.timers),
luckyNumber: { ...state.luckyNumber },
magikarpRecord: { ...state.magikarpRecord },
roamMons: structuredClone(state.roamMons),
roamMonsMap: state.roamMonsMap,
options: { ...state.options },
@@ -4703,6 +4707,11 @@ export class GameWorld {
!save.luckyNumber ||
!integer(save.luckyNumber.id, 0, 0xffff) ||
!integer(save.luckyNumber.day, 0, RTC_DAYS) ||
!save.magikarpRecord ||
!integer(save.magikarpRecord.feet, 0, 0xff) ||
!integer(save.magikarpRecord.inches, 0, 0xff) ||
typeof save.magikarpRecord.holder !== "string" ||
save.magikarpRecord.holder.length > 10 ||
!Array.isArray(save.roamMons) ||
save.roamMons.length !== 3 ||
!save.roamMons.every(validRoamMon) ||
@@ -4802,6 +4811,7 @@ export class GameWorld {
firstUnownSeen: save.firstUnownSeen,
timers: structuredClone(save.timers),
luckyNumber: { ...save.luckyNumber },
magikarpRecord: { ...save.magikarpRecord },
roamMons: structuredClone(save.roamMons),
roamMonsMap: save.roamMonsMap,
options: { ...save.options },
@@ -4958,6 +4968,9 @@ export function migrateSave(
timers: {},
luckyNumber: { id: 0, day: 0 },
}),
...(!Object.hasOwn(save, "magikarpRecord") && {
magikarpRecord: initialMagikarpRecord(),
}),
...(!Object.hasOwn(save, "roamMons") && {
roamMons: emptyRoamMons(),
roamMonsMap: null,
@@ -5021,6 +5034,7 @@ export function migrateSave(
firstUnownSeen: 0,
timers: {},
luckyNumber: { id: 0, day: 0 },
magikarpRecord: initialMagikarpRecord(),
roamMons: emptyRoamMons(),
roamMonsMap: null,
intro: false,
+179
View File
@@ -0,0 +1,179 @@
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 { magikarpLength } from "../src/game/battle/battle";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Mahogany and the Lake of Rage with the local ROM", () => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const MAGIKARP_HOUSE = "9:2",
ROUTE_43 = "9:5",
ROUTE_43_GATE = "9:4",
LAKE_OF_RAGE = "9:6";
const MAGIKARP = 129,
TYPHLOSION = 157,
ELIXER = 0x41;
const EVENT_CLEARED_ROCKET_HIDEOUT = 0x22,
EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP = 0x38,
EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP = 0x39,
EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY = 0x3a,
EVENT_ROUTE_43_GATE_ROCKETS = 0x6dc;
const world = () => {
let seed = 13;
return new GameWorld(
new CrystalAssets(bytes),
() => new Date("2026-09-22T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
};
const mon = (
game: GameWorld,
species: number,
{ dvs = 0x8888, otId = game.playerId, ot = "KAWA", egg = false } = {},
) =>
Object.assign(
generatePartyMon(game.assets.pokemon, {
species,
level: 20,
item: 0,
otId,
ot,
dvs,
caughtData: 0,
}),
egg ? { egg } : {},
);
/** Enter `map`, stand at (x, y) facing `face`, press A and answer. */
const talk = (
game: GameWorld,
map: string,
x: number,
y: number,
face: "up" | "down" | "left" | "right",
answers: number[] = [],
) => {
game.warpTo(map, x, y);
settle(game);
// The guru spins in place; keep him where his script expects.
game.restore({ ...game.save(), x, y, direction: face, objects: [] });
game.interact();
const shown = settle(game, answers).join(" ");
expect(game.notice, game.diagnostic).toBe("");
return shown;
};
/** The fishing guru stands at (2, 3); talk to him from below. */
const guru = (game: GameWorld, answers: number[] = []) =>
talk(game, MAGIKARP_HOUSE, 2, 4, "up", answers);
/** The record sign outside his house: only its name until the Rocket
* Hideout is cleared. */
const sign = (game: GameWorld) => talk(game, LAKE_OF_RAGE, 25, 32, "up");
/** DVs whose Magikarp, for this OT ID, measures longer or shorter than
* the starting 3′6″ record. */
const dvsFor = (game: GameWorld, otId: number, longer: boolean) => {
for (let dvs = 0; dvs < 0x10000; dvs += 0x0101) {
const { feet, inches } = magikarpLength(game.assets.reader, dvs, otId);
if (longer ? feet * 12 + inches > 42 : feet * 12 + inches < 42)
return { dvs, length: `${feet}′${inches}″` };
}
throw new Error("No such Magikarp.");
};
it("measures a longer Magikarp as the new record, with its OT as the holder", () => {
const game = world();
expect(sign(game)).not.toContain("RECORD");
game.state.events.add(EVENT_CLEARED_ROCKET_HIDEOUT);
expect(sign(game)).toContain("3′6″ caught by\nRALPH");
game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP);
const otId = 4321;
const { dvs, length } = dvsFor(game, otId, true);
game.state.party = [
mon(game, TYPHLOSION),
mon(game, MAGIKARP, { dvs, otId, ot: "ZOE" }),
];
const shown = guru(game, [1]);
expect(shown).toContain(`it measures\n${length}.`);
expect(shown).toContain("Take this as a\nmemento!");
expect(game.state.bag.items).toContainEqual({ item: ELIXER, quantity: 1 });
// The holder is the Magikarp's own OT, not the player.
expect(sign(game)).toContain(`${length} caught by\nZOE`);
// The record is saved and survives a reload.
const saved = JSON.parse(JSON.stringify(game.save()));
expect(saved.magikarpRecord).toMatchObject({ holder: "ZOE" });
const reloaded = world();
reloaded.restore(saved);
expect(sign(reloaded)).toContain(`${length} caught by\nZOE`);
// The same length again is not longer.
expect(guru(game, [1])).toContain("seen a bigger one");
});
it("turns away a shorter Magikarp, another Pokémon, a refusal and an Egg", () => {
const game = world();
game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP);
const { dvs } = dvsFor(game, game.playerId, false);
game.state.party = [mon(game, TYPHLOSION), mon(game, MAGIKARP, { dvs })];
expect(guru(game, [1])).toContain("seen a bigger one");
expect(guru(game, [0])).toContain("That's not a\nMAGIKARP!");
expect(guru(game, [-1])).toContain("didn't\nget one good");
expect(game.save().magikarpRecord).toEqual({ feet: 3, inches: 6, holder: "RALPH" });
// FindPartyMonThatSpecies reads wPartySpecies, where an Egg is EGG:
// the guru only repeats his request.
game.state.party = [mon(game, TYPHLOSION), mon(game, MAGIKARP, { dvs, egg: true })];
expect(guru(game)).toContain("world's\nlargest MAGIKARP.");
});
it("keeps the Elixer on standby when the pack is full", () => {
const game = world();
game.state.events.add(EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP);
game.state.events.add(EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP);
const { dvs } = dvsFor(game, game.playerId, true);
game.state.party = [mon(game, MAGIKARP, { dvs })];
// Twenty different items fill the Items pocket.
game.state.bag.items = Array.from({ length: 20 }, (_, i) => ({ item: 0x10 + i, quantity: 1 }));
guru(game, [0]);
expect(game.state.events.has(EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY)).toBe(true);
game.state.bag.items = [];
expect(guru(game)).toContain("Take this as a\nmemento!");
expect(game.state.events.has(EVENT_LAKE_OF_RAGE_ELIXIR_ON_STANDBY)).toBe(false);
});
it("charges the Route 43 toll on every pass until the Rocket Hideout is cleared", () => {
const game = world();
game.state.party = [mon(game, TYPHLOSION)];
game.state.money = 2500;
/** Walk into the gate from Route 43's south door, facing up. */
const pass = () => {
game.warpTo(ROUTE_43, 17, 36);
settle(game);
game.restore({ ...game.save(), x: 17, y: 36, direction: "up", objects: [] });
expect(game.move("up")).toBe(true);
const shown = settle(game).join(" ");
expect(game.notice, game.diagnostic).toBe("");
expect(game.mapId).toBe(ROUTE_43_GATE);
return shown;
};
expect(pass()).toContain("Thank you very\nmuch!");
expect(game.state.money).toBe(1500);
// Route 43's callback sets the gate's scene again on each visit (its
// own setmapscene to scene-less Route 43 changes nothing).
expect(pass()).toContain("The toll is ¥1000");
expect(game.state.money).toBe(500);
// Short of the toll, the Rockets take what there is.
expect(pass()).toContain("pay what you\nhave");
expect(game.state.money).toBe(0);
game.state.events.add(EVENT_CLEARED_ROCKET_HIDEOUT);
game.state.events.add(EVENT_ROUTE_43_GATE_ROCKETS);
game.state.money = 1000;
expect(pass()).not.toContain("toll");
expect(game.state.money).toBe(1000);
});
});
+6 -9
View File
@@ -8,7 +8,7 @@ import type { Direction } from "../src/game/world/types";
import { C } from "../src/game/battle/constants";
import { applyTypeMatchups } from "../src/game/battle/battle";
import { BattleData } from "../src/game/battle/data";
import { afterRestore, findPath } from "./world-driver";
import { afterRestore, findPath, landing } from "./world-driver";
const STEP: Record<Direction, [number, number]> = {
up: [0, -1],
@@ -223,19 +223,16 @@ export class Playthrough {
// A surfer crosses water until it steps ashore.
const water = world.state.playerState === PLAYER_SURF;
for (const direction of findPath(world, x, y, { water })) {
const [dx, dy] = STEP[direction];
const from = [world.x, world.y];
const end = landing(world, world.x, world.y, direction, {
water,
target: { x, y },
});
if (!world.move(direction)) {
for (let tick = 0; tick < 64; tick++) world.tick();
break;
}
this.play();
if (
world.mapId !== map ||
world.x !== from[0] + dx ||
world.y !== from[1] + dy
)
break;
if (world.mapId !== map || world.x !== end?.x || world.y !== end.y) break;
}
}
throw new Error(
+161
View File
@@ -143,6 +143,35 @@ const GOOD_ROD = 0x3b,
EVENT_GOT_TM23_IRON_TAIL = 0xd,
ENGINE_MINERALBADGE = 31,
ENGINE_STORMBADGE = 32;
const ROUTE_42_GATE = "2:4",
MAHOGANY = "2:7",
MAHOGANY_CENTER = "2:3",
MAHOGANY_GYM = "2:2",
MAHOGANY_SHOP = "3:48",
ROCKET_HIDEOUT = ["3:49", "3:50", "3:51"],
ROUTE_43_MAHOGANY_GATE = "9:3",
ROUTE_43_GATE = "9:4",
ROUTE_43 = "9:5",
LAKE_OF_RAGE = "9:6";
const RED_SCALE = 0x42,
EVENT_BEAT_POKEMANIAC_SHANE = 0x4e7,
EVENT_LAKE_OF_RAGE_RED_GYARADOS = 0x751,
EVENT_DECIDED_TO_HELP_LANCE = 0x60,
EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART = 0x2e2,
EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE = 0x4c,
EVENT_LEARNED_SLOWPOKETAIL = 0x301,
EVENT_LEARNED_RATICATE_TAIL = 0x302,
EVENT_LEARNED_HAIL_GIOVANNI = 0x2ff,
EVENT_RIVAL_TEAM_ROCKET_BASE = 0x6c0,
EVENT_BEAT_ROCKET_EXECUTIVEM_4 = 0x574,
EVENT_BEAT_ROCKET_EXECUTIVEF_2 = 0x570,
EVENT_CLEARED_ROCKET_HIDEOUT = 0x22,
EVENT_GOT_HM06_WHIRLPOOL = 0x15,
EVENT_BEAT_PRYCE = 0x4c3,
EVENT_GOT_TM16_ICY_WIND = 0xe,
EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 0x6ce,
ENGINE_GLACIERBADGE = 0x21,
ENGINE_ROCKETS_IN_RADIO_TOWER = 0x13;
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
@@ -1024,5 +1053,137 @@ describe.skipIf(!existsSync(romPath))(
reload();
writeTestSave("olivine-city");
}, 240_000);
it("crosses Route 42 past Mt. Mortar to Mahogany Town", () => {
traceFailures();
cross("up", ROUTE_39);
cross("right", ROUTE_38);
stairs(ROUTE_38_GATE);
stairs(ECRUTEAK);
stairs(ROUTE_42_GATE);
stairs(ROUTE_42);
// Route 42's pond splits it around Mt. Mortar; Krabby surfs across.
cross("right", MAHOGANY, { water: true });
expect(p.world.state.events.has(EVENT_BEAT_POKEMANIAC_SHANE)).toBe(true);
reload();
}, 240_000);
it("pays the Rockets' toll on Route 43 and stops the Red Gyarados for Lance", () => {
traceFailures();
stairs(ROUTE_43_MAHOGANY_GATE);
stairs(ROUTE_43);
// Route43GateRocketShakedownScene: ¥1000 to go through.
const money = p.world.state.money;
stairs(ROUTE_43_GATE);
expect(p.log.join(" ")).toContain("The toll is ¥1000");
expect(p.world.state.money).toBe(money - 1000);
stairs(ROUTE_43);
cross("up", LAKE_OF_RAGE);
// The Gyarados waits in the middle of the lake (18, 22).
sail(18, 23);
p.world.move("up");
p.world.interact();
play();
expect(p.world.state.events.has(EVENT_LAKE_OF_RAGE_RED_GYARADOS)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(RED_SCALE);
// Lance appears on the shore (21, 28).
sail(21, 29);
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_DECIDED_TO_HELP_LANCE)).toBe(true);
expect(p.log.join(" ")).toContain("from MAHOGANY is\nthe cause.");
reload();
}, 240_000);
it("follows Lance into the Rocket Hideout and learns both passwords", () => {
traceFailures();
cross("down", ROUTE_43);
// Route 43 sets the gate's shakedown scene again on every visit.
stairs(ROUTE_43_GATE);
stairs(ROUTE_43);
stairs(ROUTE_43_MAHOGANY_GATE);
stairs(MAHOGANY);
heal(MAHOGANY_CENTER, MAHOGANY);
// MahoganyMart1FLanceUncoversStaircaseScript: Dragonite's Hyper Beam.
enter(MAHOGANY_SHOP, "up");
expect(p.world.state.events.has(EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART)).toBe(true);
// B1F's cameras and exploding traps lie on the way down.
stairs(ROCKET_HIDEOUT[0]);
stairs(ROCKET_HIDEOUT[1]);
walk(5, 14); // LanceHealsScript1
expect(p.world.state.events.has(EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE)).toBe(true);
// B3F: the grunts tell their passwords when talked to again.
stairs(ROCKET_HIDEOUT[2]);
expect(p.log.join(" ")).toContain("two passwords to");
const [, , , slowpokeTail, raticateTail] = p.world.map.objects.map((object) => object.id);
for (const grunt of [slowpokeTail, slowpokeTail, raticateTail, raticateTail]) {
talkTo(grunt);
play();
}
expect(p.world.state.events.has(EVENT_LEARNED_SLOWPOKETAIL)).toBe(true);
expect(p.world.state.events.has(EVENT_LEARNED_RATICATE_TAIL)).toBe(true);
// The boss's door is reached from B2F's other stairs; the rival
// waits on the way (RocketBaseRival).
expect(route(ROCKET_HIDEOUT[2], ROCKET_HIDEOUT, 6, { x: 10, y: 10 })).toBe(true);
walk(10, 10);
expect(p.world.state.events.has(EVENT_RIVAL_TEAM_ROCKET_BASE)).toBe(true);
p.world.move("up");
p.world.interact();
play();
walk(10, 8); // RocketBaseBossLeft
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEM_4)).toBe(true);
talkTo(p.world.map.objects[2].id); // the Murkrow
play();
expect(p.world.state.events.has(EVENT_LEARNED_HAIL_GIOVANNI)).toBe(true);
reload();
}, 240_000);
it("beats the executive with Lance and the Electrodes for HM06", () => {
traceFailures();
expect(route(ROCKET_HIDEOUT[1], ROCKET_HIDEOUT, 6, { x: 14, y: 13 })).toBe(true);
walk(14, 13);
p.world.move("up");
p.world.interact();
play();
// RocketBaseBossFRight's coordinate event is two steps in.
p.world.move("up");
play();
p.world.move("up");
play();
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEF_2)).toBe(true);
// Lance takes the right-hand Electrodes; the player the left three.
for (let round = 0; round < 6; round++) {
const left = p.world.map.objects
.slice(4, 7)
.map((object) => object.id)
.filter((id) => p.world.objects().some((object) => object.id === id));
if (!left.length) break;
talkTo(left[0]);
play();
}
expect(p.world.state.events.has(EVENT_CLEARED_ROCKET_HIDEOUT)).toBe(true);
expect(p.world.state.events.has(EVENT_GOT_HM06_WHIRLPOOL)).toBe(true);
expect(p.world.state.bag.tmsHms[55]).toBe(1); // HM06
travel(MAHOGANY_SHOP, ROCKET_HIDEOUT);
exit(MAHOGANY);
reload();
}, 240_000);
it("slides across Pryce's ice for the GLACIERBADGE, and the Rockets take the Radio Tower", () => {
traceFailures();
heal(MAHOGANY_CENTER, MAHOGANY);
enter(MAHOGANY_GYM, "up");
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_BEAT_PRYCE)).toBe(true);
expect(p.world.state.flags.has(ENGINE_GLACIERBADGE)).toBe(true);
expect(p.world.state.events.has(EVENT_GOT_TM16_ICY_WIND)).toBe(true);
expect(p.world.state.bag.tmsHms[15]).toBe(1); // TM16
// The seventh badge runs RadioTowerRocketsScript.
expect(p.world.state.flags.has(ENGINE_ROCKETS_IN_RADIO_TOWER)).toBe(true);
expect(p.world.state.events.has(EVENT_RADIO_TOWER_ROCKET_TAKEOVER)).toBe(false);
exit(MAHOGANY);
reload();
writeTestSave("mahogany-town");
}, 240_000);
},
);
+65 -23
View File
@@ -92,14 +92,75 @@ const isWarpHazard = (tile: number) =>
* freely, unlike currents, waterfalls and whirlpools. */
const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21;
/** COLL_ICE and COLL_ICE_2B: a step onto them keeps sliding (.CheckForced). */
const isIce = (tile: number) => tile === 0x23 || tile === 0x2b;
/** Whether a step from (fromX, fromY) onto the next tile in `direction`
* is open: walkable (or open water for a surfer), no side wall, no object,
* and no warp other than the target. */
function open(
world: GameWorld,
fromX: number,
fromY: number,
direction: Direction,
water: boolean,
target?: { x: number; y: number },
) {
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
const x = fromX + dx,
y = fromY + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false;
const tile = world.assets.collision(world.map, x, y);
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile))) return false;
if (
edges(world.assets.collision(world.map, fromX, fromY)).includes(direction) ||
edges(tile).includes(OPPOSITE[direction])
)
return false;
if (world.objects().some((object) => object.x === x && object.y === y)) return false;
if (
isWarpHazard(tile) &&
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== target?.x || y !== target?.y)
)
return false;
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. */
export function landing(
world: GameWorld,
x: number,
y: number,
direction: Direction,
{ water = false, target }: { water?: boolean; target?: { x: number; y: number } } = {},
): { x: number; y: number } | undefined {
if (!open(world, x, y, direction, water, target)) return undefined;
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
x += dx;
y += dy;
while (
isIce(world.assets.collision(world.map, x, y)) &&
open(world, x, y, direction, water, target)
) {
x += dx;
y += dy;
}
return { x, y };
}
/** Shortest walkable path on the current map, avoiding other warps.
* `water` also crosses open water, for a player who can Surf. */
* `water` also crosses open water, for a player who can Surf; a step onto
* ice slides on until blocked. */
export function findPath(
world: GameWorld,
targetX: number,
targetY: number,
{ water = false } = {},
): Direction[] {
const target = { x: targetX, y: targetY };
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
const visited = new Set<string>();
for (let i = 0; i < queue.length; i++) {
@@ -108,28 +169,9 @@ export function findPath(
if (visited.has(key)) continue;
visited.add(key);
if (node.x === targetX && node.y === targetY) return node.path;
for (const [direction, dx, dy] of DIRECTIONS) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
const tile = world.assets.collision(world.map, x, y);
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile)))
continue;
if (
edges(world.assets.collision(world.map, node.x, node.y)).includes(direction) ||
edges(tile).includes(OPPOSITE[direction])
)
continue;
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)
)
continue;
queue.push({ x, y, path: [...node.path, direction] });
for (const [direction] of DIRECTIONS) {
const end = landing(world, node.x, node.y, direction, { water, target });
if (end) queue.push({ ...end, path: [...node.path, direction] });
}
}
throw new Error(`No route to ${targetX},${targetY}`);