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:
kawaandClaude Opus 5.5 committed 2026-10-12 13:10:17 +02:00
1 parent 0930b780fc
commit 2640fa2725
31 files changed
+4743 -91

No files matched your search

+96 -22
View File
@@ -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) => {