Files
Crystal/tests/battle-tower.test.ts
kawaandClaude Opus 5.5 acdaf62eaa Name the Battle Tower's ZABOROWSKI instead of refusing the battle
BattleTowerTrainers' names are ten bytes padded with "@" only when
shorter, and the cartridge copies the ten bytes to wOTPlayerName before
adding the terminator. The port read the name as an "@"-terminated
string, so drawing ZABOROWSKI (trainer 5) threw "Unterminated string."
and the challenge was refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:06:28 +02:00

278 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 { calcStats, generatePartyMon, maxPP, type PartyMon } from "../src/game/party/pokemon";
import { CHALLENGE, emptyBattleTowerState, loadOpponent } from "../src/game/scripts/battle-tower";
import type { SpecialContext } from "../src/game/scripts/special-context";
import { Playthrough } from "./playthrough-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("the Battle Tower with the local ROM", () => {
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const BATTLE_TOWER_1F = "22:11",
BATTLE_ROOM = "22:12",
ROUTE_40 = "22:1",
BATTLE_TOWER_OUTSIDE = "22:16";
const MEWTWO = 150,
LUGIA = 249,
HO_OH = 250,
RATTATA = 19,
PIDGEY = 16,
SENTRET = 161,
BERRY = 0xad,
HP_UP = 0x1a,
CALCIUM = 0x1f,
LUCKY_PUNCH = 0x1e;
/** The Challenge/Explanation/Cancel menu's items. */
const CHALLENGE_ITEM = 0,
CANCEL_ITEM = 2;
const mon = (world: GameWorld, species: number, level: number, item = 0): PartyMon =>
generatePartyMon(assets!.pokemon, {
species,
level,
item,
otId: world.playerId,
ot: world.name,
dvs: 0xffff,
caughtData: 0,
});
/** A playthrough driver in front of the receptionist (7, 7). */
const driver = (party: (world: GameWorld) => PartyMon[], hallOfFame = true) => {
const p = new Playthrough(assets!);
p.world = new GameWorld(assets!, p.now, p.random);
p.world.state.party = party(p.world);
p.world.state.hallOfFame = hallOfFame;
p.world.warpTo(BATTLE_TOWER_1F, 7, 7);
p.play();
p.world.restore({ ...p.world.save(), x: 7, y: 7, direction: "up" });
return p;
};
/** A trained Pokémon: full stat experience and chosen moves. */
const trained = (world: GameWorld, species: number, moves: number[], item = 0): PartyMon => {
const base = mon(world, species, 100, item);
const statExp: PartyMon["statExp"] = [65535, 65535, 65535, 65535, 65535];
const stats = calcStats(assets!.pokemon, species, base.dvs, 100, statExp);
return {
...base,
statExp,
stats,
hp: stats[0],
moves: moves as PartyMon["moves"],
pp: moves.map((move) => maxPP(assets!.pokemon, move, 0)) as PartyMon["pp"],
};
};
const PSYCHIC = 94,
ICE_BEAM = 58,
THUNDERBOLT = 85,
FLAMETHROWER = 53,
AEROBLAST = 177,
SACRED_FIRE = 221,
EARTHQUAKE = 89,
SURF = 57;
const strong = (world: GameWorld) => [
trained(world, MEWTWO, [PSYCHIC, ICE_BEAM, THUNDERBOLT, FLAMETHROWER], BERRY),
trained(world, LUGIA, [AEROBLAST, SURF, ICE_BEAM, EARTHQUAKE]),
trained(world, HO_OH, [SACRED_FIRE, EARTHQUAKE, THUNDERBOLT, PSYCHIC]),
];
const bt = (p: Playthrough) => p.world.state.battleTower;
/** Talk to the receptionist and answer her questions. */
const receptionist = (p: Playthrough, answers: (number | string)[]) => {
p.world.interact();
p.play(answers);
expect(p.world.notice, p.world.diagnostic).toBe("");
};
it("names a trainer whose name fills all ten characters", () => {
// BattleTowerTrainers' sixth entry, ZABOROWSKI, has no "@" of its own.
let seed = 5;
const context = {
reader: assets!.reader,
data: assets!.pokemon,
random: () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
text: () => ({ player: "KAWA", rival: "SILVER", mom: "MOM", strings: {}, weekday: "SUNDAY" }),
} as unknown as SpecialContext;
const names = new Map<number, string>();
for (let draw = 0; draw < 300 && !names.has(5); draw++) {
const opponent = loadOpponent(context, { ...emptyBattleTowerState(), levelGroup: 1 });
names.set(opponent.trainer, opponent.name);
}
expect(names.get(5)).toBe("ZABOROWSKI");
});
it("is reached from Route 40 through its gate", () => {
const p = new Playthrough(assets!);
p.world = new GameWorld(assets!, p.now, p.random);
p.world.state.party = [mon(p.world, MEWTWO, 50)];
p.world.warpTo(ROUTE_40, 9, 5);
p.play();
p.travel(BATTLE_TOWER_1F, ["22:15", BATTLE_TOWER_OUTSIDE]);
expect(p.world.mapId).toBe(BATTLE_TOWER_1F);
});
it("turns away a party that breaks the rules, saying why", () => {
// Four Pokémon, two Pidgey holding the same Berry and an Egg.
const p = driver((world) => [
mon(world, PIDGEY, 10, BERRY),
mon(world, PIDGEY, 10, BERRY),
{ ...mon(world, RATTATA, 5), egg: true },
mon(world, SENTRET, 10),
]);
// The first visit offers the explanation (NO), then the menu.
receptionist(p, [0, CHALLENGE_ITEM]);
const shown = p.log.join(" ");
expect(shown).toContain("Excuse me.\nYou're not ready.");
expect(shown).toContain("Only three POKéMON\nmay be entered.");
expect(shown).toContain("The 3 POKéMON\nmust all be\ndifferent kinds.");
expect(shown).toContain("must not hold the\nsame items.");
expect(shown).toContain("You can't take an\nEGG!");
expect(shown).toContain("Please return when\nyou're ready.");
expect(bt(p).challenge).toBe(CHALLENGE.none);
});
it("stops a choice that a party Pokémon's level tops, and an uber below Lv.70", () => {
const p = driver((world) => [
mon(world, MEWTWO, 60),
mon(world, PIDGEY, 30),
mon(world, SENTRET, 30),
]);
// Challenge, YES to the save, YES to overwrite, then L:30 (Mewtwo is
// level 60), L:60 (an uber under 70), and CANCEL with YES.
receptionist(p, [0, CHALLENGE_ITEM, 1, 1, 2, 5, 10, 1, CANCEL_ITEM]);
const shown = p.log.join(" ");
expect(shown).toContain("A party POKéMON\ntops this level.");
expect(shown).toContain("MEWTWO may go\nonly to BATTLE");
expect(shown).toContain("Cancel your BATTLE\nROOM challenge?");
expect(p.world.mapId).toBe(BATTLE_TOWER_1F);
});
it("offers L:10 to L:40 before the Hall of Fame", () => {
const p = driver((world) => [mon(world, PIDGEY, 10), mon(world, SENTRET, 10), mon(world, RATTATA, 10)], false);
p.world.interact();
// NO to the explanation, Challenge, YES to save, YES to overwrite.
for (const answer of [0, CHALLENGE_ITEM, 1, 1]) {
p.toChoice();
p.world.choose(answer);
}
const choice = p.toChoice();
expect(choice).toMatchObject({ kind: "menu", items: ["L:10", "L:20", "L:30", "L:40", "CANCEL"] });
});
it("battles seven trainers, then gives five of a random stat item", () => {
const p = driver(strong);
// NO to the explanation, Challenge, YES (save), YES (overwrite), L:100.
receptionist(p, [0, CHALLENGE_ITEM, 1, 1, 9]);
expect(p.world.state.battleTower.levelGroup).toBe(10);
expect(p.world.state.battleTower.quickSave).toMatchObject({ map: BATTLE_TOWER_1F, x: 7, y: 7 });
// Each win heals the party and asks to go on (YES).
for (let round = 0; round < 7 && p.world.mapId === BATTLE_ROOM; round++) p.play([1]);
expect(p.world.notice, p.world.diagnostic).toBe("");
const shown = p.log.join(" ");
expect(shown).toContain("Congratulations!");
expect(p.world.mapId).toBe(BATTLE_TOWER_1F);
const state = bt(p);
expect(state.beaten).toBe(7);
expect(new Set(state.trainers).size).toBe(7);
expect(state.trainers.every((trainer) => trainer < 70)).toBe(true);
expect(state.challenge).toBe(CHALLENGE.receivedReward);
expect(state.reward).toBeGreaterThanOrEqual(HP_UP);
expect(state.reward).toBeLessThanOrEqual(CALCIUM);
expect(state.reward).not.toBe(LUCKY_PUNCH);
expect(p.world.state.bag.items).toContainEqual({ item: state.reward, quantity: 5 });
// No experience, and LoadPokemonData gave the Berry back after each
// battle; the party is healed.
expect(p.world.state.party.map((m) => m.level)).toEqual([100, 100, 100]);
expect(p.world.state.party[0].item).toBe(BERRY);
expect(p.world.state.party.every((m) => m.hp === m.stats[0])).toBe(true);
// The streak's opponents come from the L:100 group of BattleTowerMons.
expect(state.opponent!.party.every((m) => m.level === 100)).toBe(true);
// A second talk only says goodbye after the reward.
p.play();
});
it("saves and quits between battles, then resumes the streak", () => {
const p = driver(strong);
// After the first win: NO to the next opponent, YES to save and end.
// Reset continues from the quick save made at the counter, whose scene
// checks the challenge state and resumes it; the streak then goes on
// (YES each time) to its seventh trainer.
receptionist(p, [0, CHALLENGE_ITEM, 1, 1, 9, 0, 1]);
const shown = p.log.join(" ");
const saved = shown.indexOf("SAVE and end the\nsession?"),
waiting = shown.indexOf("We've been waiting");
expect(saved).toBeGreaterThan(0);
expect(waiting).toBeGreaterThan(saved);
// One battle before the reset, six after.
expect(shown.slice(0, saved).match(/wants to battle!/g)).toHaveLength(1);
expect(shown.slice(waiting).match(/wants to battle!/g)).toHaveLength(6);
expect(shown).toContain("Congratulations!");
expect(bt(p).beaten).toBe(7);
expect(new Set(bt(p).trainers).size).toBe(7);
expect(p.world.mapId).toBe(BATTLE_TOWER_1F);
});
it("keeps a saved-and-left challenge for the next visit", () => {
const p = driver(strong);
p.world.state.battleTower = {
...p.world.state.battleTower,
challenge: CHALLENGE.savedAndLeft,
beaten: 3,
savedLevelGroup: 10,
reward: CALCIUM,
fileFlags: 2,
quickSave: {
map: BATTLE_TOWER_1F,
x: 7,
y: 7,
direction: "up",
party: structuredClone(p.world.state.party),
seen: [],
caught: [],
unownDex: [],
firstUnownSeen: 0,
},
};
// A save made in that state and loaded again: the check scene runs.
p.world.state.scenes[BATTLE_TOWER_1F] = 0;
p.reload();
p.play([1, 1, 1, 1]);
const shown = p.log.join(" ");
expect(shown).toContain("We've been waiting");
expect(shown.match(/wants to battle!/g)).toHaveLength(4);
expect(bt(p).levelGroup).toBe(10);
expect(p.world.state.bag.items).toContainEqual({ item: CALCIUM, quantity: 5 });
});
it("cancels the challenge, or ends it with a loss without a whiteout", () => {
const p = driver(strong);
// After the first win: NO, NO (no save), YES (cancel): back to 1F
// with the challenge gone.
receptionist(p, [0, CHALLENGE_ITEM, 1, 1, 9, 0, 0, 1]);
expect(p.world.mapId).toBe(BATTLE_TOWER_1F);
expect(bt(p).challenge).toBe(CHALLENGE.none);
expect(p.log.join(" ")).toContain("Cancel your BATTLE\nROOM challenge?");
const weak = driver((world) => [
mon(world, PIDGEY, 10),
mon(world, SENTRET, 10),
mon(world, RATTATA, 10),
]);
receptionist(weak, [0, CHALLENGE_ITEM, 1, 1, 9]);
expect(weak.world.notice, weak.world.diagnostic).toBe("");
expect(weak.world.mapId).toBe(BATTLE_TOWER_1F);
expect(weak.log.join(" ")).toContain("Thanks for\nvisiting!");
expect(bt(weak).challenge).toBe(CHALLENGE.none);
// LoadPokemonData and HealParty: the party is back as it was saved.
expect(weak.world.state.party.every((m) => m.hp === m.stats[0])).toBe(true);
});
});