Files
Crystal/tests/tin-tower.test.ts
T
kawaandClaude Opus 5.5 e49bc6de9a Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)
- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:52:30 +02:00

153 lines
5.9 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 { 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]);
// Preserve CheckOwnMonAnywhere's Day-Care omission: our own Entei
// in either keeper's care still does not satisfy MonCheck.
world.state.party.pop();
for (const [slot, flag] of [["mon1", 6], ["mon2", 7]] as const) {
world.state.dayCare[slot] = toBoxMon(mon(world, ENTEI, { ot: name }));
world.state.flags.add(flag);
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");
});
});