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>
180 lines
7.3 KiB
TypeScript
180 lines
7.3 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 { 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);
|
||
});
|
||
});
|