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