import { beforeAll, describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { Battle, type BattleEffect, type BattleInput, } from "../src/game/battle/battle"; import { C } from "../src/game/battle/constants"; import { generatePartyMon } from "../src/game/party/pokemon"; import { defaultTextContext } from "../src/game/scripts/text"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))("Reflect and Light Screen", () => { let data: CrystalAssets["pokemon"]; beforeAll(() => { data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon; }); const setup = () => { const mon = generatePartyMon(data, { species: 152, level: 12, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); mon.moves = [115, 113, 33, 0]; // Reflect, Light Screen, Tackle mon.pp = [20, 30, 35, 0]; let seed = 1; const game = new Battle({ data, party: [mon], playerId: 1234, playerName: "CHRIS", timeOfDay: 1, pokedex: { seen: new Set(), caught: new Set() }, random: () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, wild: { species: 16, level: 3 }, text: () => defaultTextContext("CHRIS"), }); const run = game.run(); const texts: string[] = []; const advance = (input?: BattleInput) => { for (let guard = 0; guard < 100; guard++) { const next = run.next(input); input = undefined; if (next.done) return next.value; if (next.value.kind === "text") texts.push(next.value.text.replace(/\n/g, " ")); if (next.value.kind === "action") return; } throw new Error("No battle menu reached"); }; advance(); return { game, mon, texts, advance }; }; it("plays Chikorita's Reflect, spends PP on failed repeats, and expires after five turns", () => { const { game, mon, texts, advance } = setup(); const stats = [...game.player.mon.stats]; for (let turn = 1; turn <= 5; turn++) { advance({ action: "fight", move: 0 }); expect(game.player.reflectCount).toBe(5 - turn); expect(Boolean(game.player.screens & (1 << C.SCREENS_REFLECT))).toBe( turn < 5, ); } expect(mon.pp[0]).toBe(15); expect(game.player.mon.stats).toEqual(stats); expect(texts.filter((text) => text === "But it failed!")).toHaveLength(4); expect(texts).toContain("CHIKORITA's DEFENSE rose!"); expect(texts).toContain("Your POKéMON's REFLECT faded!"); advance({ action: "fight", move: 0 }); expect(game.player.reflectCount).toBe(4); expect(advance({ action: "run" })?.result).toBe(C.DRAW); }); it("keeps separate durations when both screens are active", () => { const { game, texts, advance } = setup(); advance({ action: "fight", move: 0 }); advance({ action: "fight", move: 1 }); expect([game.player.reflectCount, game.player.lightScreenCount]).toEqual([ 3, 4, ]); for (let turn = 0; turn < 3; turn++) advance({ action: "fight", move: 1 }); expect(game.player.screens).toBe(1 << C.SCREENS_LIGHT_SCREEN); advance({ action: "fight", move: 1 }); expect(game.player.screens).toBe(0); expect(texts).toContain("CHIKORITA's SPCL.DEF rose!"); expect(texts).toContain("Your POKéMON's LIGHT SCREEN fell!"); }); it.each([113, 115])( "executes an enemy screen move through its ROM effect script (%i)", (move) => { const { game, texts, advance } = setup(); game.enemy.mon.moves = [move, 0, 0, 0]; game.enemy.mon.pp = [30, 0, 0, 0]; for (let turn = 0; turn < 5; turn++) advance({ action: "fight", move: 0 }); expect(game.enemy.screens).toBe(0); expect(texts).toContain( move === 113 ? "Enemy PIDGEY's SPCL.DEF rose!" : "Enemy PIDGEY's DEFENSE rose!", ); expect(texts).toContain( move === 113 ? "Enemy POKéMON's LIGHT SCREEN fell!" : "Enemy POKéMON's REFLECT faded!", ); }, ); it("expires player then enemy screens, with Light Screen before Reflect, and preserves them on send-out", () => { const { game } = setup(); const internal = game as unknown as { handleScreens(): Generator; newEnemyMonStatus(): void; }; for (const side of [game.player, game.enemy]) { side.screens = (1 << C.SCREENS_LIGHT_SCREEN) | (1 << C.SCREENS_REFLECT); side.lightScreenCount = side.reflectCount = 1; } internal.newEnemyMonStatus(); expect(game.enemy.reflectCount).toBe(1); expect(game.enemy.screens).toBe(24); const texts = [...internal.handleScreens()].map((effect) => effect.kind === "text" ? effect.text.replace(/\n/g, " ") : "", ); expect(texts).toEqual([ "Your POKéMON's LIGHT SCREEN fell!", "Your POKéMON's REFLECT faded!", "Enemy POKéMON's LIGHT SCREEN fell!", "Enemy POKéMON's REFLECT faded!", ]); expect(game.player.screens | game.enemy.screens).toBe(0); }); it.each([0, 1])( "doubles only the appropriate defense and respects critical stage rules (turn %i)", (turn) => { const { game } = setup(); const internal = game as unknown as { turn: number; criticalHit: number; damageStats(): void; damageArgs: { defense: number }; }; internal.turn = turn; const user = turn ? game.enemy : game.player; const target = turn ? game.player : game.enemy; user.move.power = 40; for (const [type, stat, screen] of [ [0, C.DEFENSE, C.SCREENS_REFLECT], [C.SPECIAL, C.SP_DEFENSE, C.SCREENS_LIGHT_SCREEN], ]) { user.move.type = type; target.screens = 0; internal.criticalHit = 0; internal.damageStats(); const plain = internal.damageArgs.defense; target.screens = 1 << screen; internal.damageStats(); expect(internal.damageArgs.defense).toBe(plain * 2); internal.criticalHit = 1; internal.damageStats(); expect(internal.damageArgs.defense).toBe(plain); target.levels[stat] = 6; internal.damageStats(); expect(internal.damageArgs.defense).toBe(plain * 2); target.levels[stat] = 7; internal.criticalHit = 0; target.screens = 1 << (screen === C.SCREENS_REFLECT ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT); internal.damageStats(); expect(internal.damageArgs.defense).toBe(plain); } }, ); });