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(); 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); }); });