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 { SOUND } from "../src/game/audio/constants"; import { battleMusic, hpBarPixels, victoryMusic, MUSIC_TRAINERS as T } from "../src/game/audio/battle-music"; import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe("battle music choices (PlayBattleMusic, PlayVictoryMusic)", () => { it("picks the wild, trainer, leader, rival and Rocket songs", () => { expect(battleMusic({ night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_WILD_BATTLE); expect(battleMusic({ night: true, kanto: false })).toBe(SOUND.MUSIC_JOHTO_WILD_BATTLE_NIGHT); expect(battleMusic({ night: true, kanto: true })).toBe(SOUND.MUSIC_KANTO_WILD_BATTLE); expect(battleMusic({ battleType: T.BATTLETYPE_ROAMING, night: false, kanto: false })).toBe(SOUND.MUSIC_SUICUNE_BATTLE); expect(battleMusic({ trainerClass: T.FALKNER, night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_GYM_LEADER_BATTLE); expect(battleMusic({ trainerClass: T.BROCK, night: false, kanto: true })).toBe(SOUND.MUSIC_KANTO_GYM_LEADER_BATTLE); expect(battleMusic({ trainerClass: T.GRUNTM, night: false, kanto: false })).toBe(SOUND.MUSIC_ROCKET_BATTLE); expect(battleMusic({ trainerClass: T.RIVAL1, night: false, kanto: false })).toBe(SOUND.MUSIC_RIVAL_BATTLE); expect(battleMusic({ trainerClass: T.RIVAL2, trainerId: 4, night: false, kanto: false })).toBe(SOUND.MUSIC_CHAMPION_BATTLE); expect(battleMusic({ trainerClass: T.RED, night: false, kanto: true })).toBe(SOUND.MUSIC_CHAMPION_BATTLE); // Youngsters and the like: by region. expect(battleMusic({ trainerClass: 30, night: false, kanto: false })).toBe(SOUND.MUSIC_JOHTO_TRAINER_BATTLE); expect(battleMusic({ trainerClass: 30, night: false, kanto: true })).toBe(SOUND.MUSIC_KANTO_TRAINER_BATTLE); expect(victoryMusic(undefined)).toBe(SOUND.MUSIC_WILD_VICTORY); expect(victoryMusic(T.MORTY)).toBe(SOUND.MUSIC_GYM_VICTORY); // IsGymLeader's list runs on into the Kanto leaders. expect(victoryMusic(T.BLUE)).toBe(SOUND.MUSIC_GYM_VICTORY); expect(victoryMusic(30)).toBe(SOUND.MUSIC_TRAINER_VICTORY); }); it("measures the HP bar as ComputeHPBarPixels does", () => { expect(hpBarPixels(0, 20)).toBe(0); expect(hpBarPixels(1, 200)).toBe(1); expect(hpBarPixels(20, 20)).toBe(48); expect(hpBarPixels(4, 20)).toBe(9); expect(hpBarPixels(5, 20)).toBe(12); // Above 255 HP both sides are divided by four first. expect(hpBarPixels(100, 400)).toBe(12); }); }); describe.skipIf(!existsSync(romPath))("sound in the world with the local ROM", () => { const load = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); /** Every song and sound effect the engine is asked to start. */ const record = (world: GameWorld) => { const music: number[] = [], sfx: number[] = [], cries: number[] = []; const engine = world.sound.engine; const playMusic = engine.playMusic.bind(engine), playSFX = engine.playSFX.bind(engine), playCry = engine.playCryWith.bind(engine); engine.playMusic = (id) => { if (id) music.push(id); playMusic(id); }; engine.playSFX = (id) => { sfx.push(id); playSFX(id); }; engine.playCryWith = (id, pitch, length) => { cries.push(id); playCry(id, pitch, length); }; return { music, sfx, cries }; }; it("plays a wild battle's music, cries and victory, then the map's music again", () => { const world = battleFixtureWorld(load(), "win"); world.restore(world.save()); const heard = record(world); const mapMusic = world.sound.engine.mapMusic; expect(mapMusic).toBe(world.mapMusicId()); for (const direction of ENCOUNTER_WALK) world.move(direction); expect(world.battle).toBeDefined(); // PlayBattleMusic (noon: day music), then the wild Pokémon's cry. expect(heard.music).toContain(SOUND.MUSIC_JOHTO_WILD_BATTLE); const pidgey = world.sound.engine.cryEntry(16).id; expect(heard.cries[0]).toBe(pidgey); for (let tick = 0; tick < 10000 && (world.battle || world.busy); tick++) { const view = world.battle; if (view?.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 }); else if (view?.prompt?.kind === "yesno") world.battleChoose(1); else if (view?.prompt?.kind === "forget") world.battleChoose(3); else if (view && (view.prompt?.kind === "stats" || view.message?.prompt)) world.interact(); else if (world.dialogue) world.advanceDialogue(); else world.tick(); } expect(world.battle).toBeUndefined(); // The enemy fainted (SFX_KINESIS, SFX_FAINT), the wild victory played, // and reloadmapafterbattle restarted the map's music. expect(heard.music).toContain(SOUND.MUSIC_WILD_VICTORY); for (let tick = 0; tick < 200; tick++) world.tick(); expect(heard.sfx).toContain(SOUND.SFX_KINESIS); expect(heard.sfx).toContain(SOUND.SFX_FAINT); expect(heard.music.at(-1)).toBe(mapMusic); expect(world.sound.engine.channelMusicId(0)).toBe(mapMusic); expect(world.sound.engine.lowHealthAlarm).toBe(false); }); it("sounds the low HP tone while the player's HP bar is red", () => { const world = battleFixtureWorld(load(), "loss"); for (const direction of ENCOUNTER_WALK) world.move(direction); for (let tick = 0; tick < 400 && world.battle && world.battle.prompt?.kind !== "action"; tick++) if (world.battle.message?.prompt) world.interact(); else world.tick(); expect(world.battle?.prompt?.kind).toBe("action"); expect(world.sound.engine.lowHealthAlarm).toBe(true); }); it("heals with the Pokémon Center's jingle, then the map's music returns", () => { const assets = load(); const world = new GameWorld(assets, () => new Date("2026-09-22T13:00:00")); const mon = generatePartyMon(assets.pokemon, { species: 155, level: 10, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); mon.hp = 1; world.state.party.push(mon, structuredClone(mon)); world.restore({ ...world.save(), map: "26:5", x: 3, y: 3, direction: "up" }); const heard = record(world); world.interact(); // PlayTalkObject expect(heard.sfx).toEqual([SOUND.SFX_READ_TEXT_2]); const ticksBefore = world.ticks; const pages = settle(world, [1]); expect(pages).toContain("Your POKéMON are\nfully healed."); // A ball sound for each Pokémon, the heal jingle, and the map's music. expect(heard.sfx.filter((id) => id === SOUND.SFX_SECOND_PART_OF_ITEMFINDER)).toHaveLength(2); expect(heard.music).toContain(SOUND.MUSIC_HEAL); expect(heard.music.at(-1)).toBe(SOUND.MUSIC_POKEMON_CENTER); // HealMachineAnim's 2 × 30 + 80 frames, then pause 30. expect(world.ticks - ticksBefore).toBeGreaterThan(70 + 15); }); it("plays the warp's sound and fades to the next map's music", () => { const world = new GameWorld(load(), () => new Date("2026-09-22T13:00:00")); // New Bark Town, below Elm's lab door. world.restore({ ...world.save(), map: "24:4", x: 6, y: 4, direction: "up" }); const heard = record(world); expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_NEW_BARK_TOWN); world.move("up"); for (let tick = 0; tick < 40; tick++) world.tick(); expect(world.mapId).toBe("24:5"); expect(heard.sfx[0]).toBe(SOUND.SFX_ENTER_DOOR); // FadeToMapMusic: Elm's lab plays MUSIC_PROF_ELM once the fade ends. for (let tick = 0; tick < 200; tick++) world.tick(); expect(world.sound.engine.mapMusic).toBe(SOUND.MUSIC_PROF_ELM); expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_PROF_ELM); }); });