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>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|