The Route 40 gate, the Battle Tower and its rooms are open with the offline challenge: the rules, the save before entering, the level menu with its level and uber checks, seven trainers drawn from BattleTowerTrainers with Pokémon from BattleTowerMons, BattleTowerText, save and quit with the receptionist resuming it, cancelling, losing and the vitamin reward. After each battle the party and Pokédex return to the quick save's, as LoadPokemonData does. Battle Tower battles turn off obedience, badge boosts, experience, items, AI items and prize money, use Falkner's AI attributes, keep Sleep to one to three turns and never fail the AI's status moves at random. New script command battletowertext; new specials BattleTowerAction (offline actions), BattleTowerRoomMenu, CheckForBattleTowerRules, BattleTowerBattle, LoadOpponentTrainerAndPokemonWithOTSprite, Menu_ChallengeExplanationCancel, TryQuickSave and Reset. DoBadgeTypeBoosts was missing from every battle; it now adds an eighth of the damage for the first badge matching the move's type on the player's turn, checked against the original routine. This completes Johto from Azalea to Blackthorn: Kanto, the League and the S.S. Aqua continue on #33; radio, rematches and Buena's password on #30. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
10 KiB
TypeScript
257 lines
10 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 } from "../src/game/scripts/battle-tower";
|
|
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("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);
|
|
});
|
|
});
|