Port Reflect and Light Screen

BattleCommand_Screen and HandleScreens run natively. Each side owns
independent five-turn counters that include the setup turn, do not
refresh on repeated use and survive enemy send-outs. Screens expire in
the original non-Link order with ROM messages; the existing damage path
already doubles the right defense and keeps the critical/stage rule.
This closes the only refusal found by the Route 30 stress run
(Chikorita's Reflect).

The NPC movement test now reads the ROM in beforeAll, so it skips
cleanly without one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-24 19:38:13 +02:00
1 parent e00dfab42e
commit 7c1c0cc45b
8 files changed
+282 -5

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
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<BattleEffect>;
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);
}
},
);
});