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:
1 parent
e00dfab42e
commit
7c1c0cc45b
8 files changed
+282
-5
No files matched your search
+14
-1
@@ -87,4 +87,17 @@ A trainer sighting whose script is refused is not restarted until the player mov
|
||||
|
||||
## Next slice
|
||||
|
||||
Route 29, Cherrygrove and Route 30 are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn and map trainers. NPCs roam. Next: Mr. Pokémon's house and the Mystery Egg, capture with the catching tutorial, Reflect/Light Screen, and party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
|
||||
Route 29, Cherrygrove and Route 30 are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn and map trainers. NPCs roam. Next: Mr. Pokémon's house and the Mystery Egg, capture with the catching tutorial, and party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
|
||||
|
||||
## Reflect and Light Screen
|
||||
|
||||
`BattleCommand_Screen` in `engine/battle/effect_commands.asm` and `HandleScreens`
|
||||
in `engine/battle/core.asm` now run natively. Each screen has a side-owned
|
||||
five-turn counter, including its setup turn. Repeated use fails without
|
||||
refreshing it; send-outs preserve it. Expiration runs after defrost, before
|
||||
healing items, player before enemy and Light Screen before Reflect. Messages
|
||||
come from the ROM. The existing damage path doubles the appropriate defense
|
||||
and retains the original critical/stage rule. Safeguard and other screen
|
||||
flags remain refused. Seven local-ROM tests cover move execution, counters,
|
||||
PP, damage stats and send-out persistence; no screen-routine emulator parity
|
||||
or physical-device validation is claimed.
|
||||
+7
-1
@@ -75,7 +75,13 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
||||
- Hidden items (`BGEVENT_ITEM`) run `HiddenItemScript`.
|
||||
- Route 30 and its Berry house are enabled. Mr. Pokémon's house and Route 31 stay closed; Youngster Joey (after the Egg) needs phone registration and stays hidden until then, as in the original.
|
||||
- A refused trainer script does not restart until the player moves, so a rollback cannot trap the player in a trainer's sight.
|
||||
- A local stress run of 1,080 Route 30 battles (three starters, four levels, three times of day, both trainers and wild encounters) found one refusal: Chikorita's Reflect at level 12 (`screen`, not yet ported).
|
||||
- A local stress run of 1,080 Route 30 battles (three starters, four levels, three times of day, both trainers and wild encounters) found one refusal: Chikorita's Reflect at level 12 (`screen`), now addressed by the screen increment below.
|
||||
|
||||
## Implemented: Reflect and Light Screen
|
||||
|
||||
- Both moves execute their ROM effect scripts, set independent five-turn counters and fail without refreshing an active screen. The setup turn counts toward expiration.
|
||||
- Screens persist across enemy send-outs and expire in the original non-Link order, with ROM messages. Physical/special defenses and critical-hit stage rules use the existing damage path.
|
||||
- Local-ROM regressions cover both sides, repeated use and PP, concurrent durations, send-out persistence, expiration order and damage stats. These checks are not an emulator comparison of the screen routines.
|
||||
|
||||
## In progress: native battles
|
||||
|
||||
|
||||
@@ -226,3 +226,26 @@ builds; reference, release and vendor checks; `git diff --check`. Not committed.
|
||||
Next: Mr. Pokémon's house (Oak, Pokédex, Mystery Egg) and the return to Elm;
|
||||
Reflect/Light Screen; then Smart AI before the rival. Joey (after the Egg)
|
||||
needs `askforphonenumber` and the phone standard scripts.
|
||||
|
||||
## Reflect and Light Screen: 2026-09-24
|
||||
|
||||
Completed the battle gap found in the Route 30 stress run. `screen` now runs
|
||||
both moves from ROM effect scripts. Independent five-turn counters include
|
||||
the setup turn, do not refresh on repeated use, survive enemy send-outs and
|
||||
expire with ROM text in `HandleScreens` order. The existing damage path handles
|
||||
physical/special defense and critical stage rules.
|
||||
|
||||
Seven new local-ROM regressions cover player and enemy use, PP on failure,
|
||||
concurrent durations, expiration order, send-out persistence and damage stats.
|
||||
Also moved the NPC test's eager ROM read into `beforeAll`, restoring its
|
||||
intended skip behavior when no ROM is available.
|
||||
|
||||
Checks: 167 unit tests with the local ROM; 76 passed and 91 skipped without it;
|
||||
44 Chromium/WebKit browser tests; native and reference builds; reference,
|
||||
release and vendor checks; `git diff --check`. Screen routines were reviewed
|
||||
against pinned PRET source, not compared by executing the original routines.
|
||||
Changes are uncommitted.
|
||||
|
||||
Next: Mr. Pokémon's house and the Mystery Egg; Smart AI before enabling the
|
||||
rival; capture and the catching tutorial. Joey's post-Egg phone scripts remain
|
||||
unsupported.
|
||||
@@ -61,6 +61,8 @@ class Side {
|
||||
wrapCount = 0;
|
||||
damageTaken = 0;
|
||||
screens = 0;
|
||||
lightScreenCount = 0;
|
||||
reflectCount = 0;
|
||||
turnsTaken = 0;
|
||||
charging = 0;
|
||||
lastMove = 0;
|
||||
@@ -1208,6 +1210,8 @@ export class Battle {
|
||||
return this.user.set(SUB4, C.SUBSTATUS_RAGE);
|
||||
case COMMAND.ragedamage:
|
||||
return this.rageDamage();
|
||||
case COMMAND.screen:
|
||||
return yield* this.screen();
|
||||
case COMMAND.curl:
|
||||
return this.user.set(SUB2, C.SUBSTATUS_CURLED);
|
||||
case COMMAND.doubleflyingdamage:
|
||||
@@ -1302,6 +1306,41 @@ export class Battle {
|
||||
if (this.random() < this.data.criticalChance(stage)) this.criticalHit = 1;
|
||||
}
|
||||
|
||||
/** BattleCommand_Screen: a repeated screen fails without renewing it. */
|
||||
private *screen(): Gen {
|
||||
const side = this.user;
|
||||
const light = side.move.effect === C.EFFECT_LIGHT_SCREEN;
|
||||
const bit = 1 << (light ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT);
|
||||
if (side.screens & bit) return yield* this.say(BATTLE.ButItFailedText);
|
||||
side.screens |= bit;
|
||||
if (light) side.lightScreenCount = 5;
|
||||
else side.reflectCount = 5;
|
||||
yield* this.say(light ? BATTLE.LightScreenEffectText : BATTLE.ReflectEffectText);
|
||||
}
|
||||
|
||||
/** HandleScreens, in non-Link order. Screens belong to the side and
|
||||
* survive a send-out; their setup turn is the first of five ticks. */
|
||||
private *handleScreens(): Gen {
|
||||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||||
this.turn = turn;
|
||||
this.strings[RAM.stringBuffer1] = turn === 0 ? "Your" : "Enemy";
|
||||
if (side.screens & (1 << C.SCREENS_LIGHT_SCREEN)) {
|
||||
side.lightScreenCount = (side.lightScreenCount - 1) & 0xff;
|
||||
if (!side.lightScreenCount) {
|
||||
side.screens &= ~(1 << C.SCREENS_LIGHT_SCREEN);
|
||||
yield* this.say(BATTLE.BattleText_MonsLightScreenFell);
|
||||
}
|
||||
}
|
||||
if (side.screens & (1 << C.SCREENS_REFLECT)) {
|
||||
side.reflectCount = (side.reflectCount - 1) & 0xff;
|
||||
if (!side.reflectCount) {
|
||||
side.screens &= ~(1 << C.SCREENS_REFLECT);
|
||||
yield* this.say(BATTLE.BattleText_MonsReflectFaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private damageArgs = { power: 0, level: 0, attack: 0, defense: 0 };
|
||||
/** PlayerAttackDamage / EnemyAttackDamage. */
|
||||
private damageStats() {
|
||||
@@ -1968,7 +2007,8 @@ export class Battle {
|
||||
for (const side of [this.player, this.enemy]) {
|
||||
if (side.wrapCount || side.has(SUB1, C.SUBSTATUS_PERISH) || side.has(SUB5, C.SUBSTATUS_ENCORED))
|
||||
throw new UnsupportedBattle("Wrap, Perish Song and Encore are not ported yet.");
|
||||
if (side.screens) throw new UnsupportedBattle("Screens and Safeguard are not ported yet.");
|
||||
if (side.screens & ~((1 << C.SCREENS_LIGHT_SCREEN) | (1 << C.SCREENS_REFLECT)))
|
||||
throw new UnsupportedBattle("Other screen effects are not ported yet.");
|
||||
const effect = this.data.heldEffect(side.mon.item).effect;
|
||||
if (effect === C.HELD_RESTORE_PP || (effect >= C.HELD_ATTACK_UP && effect <= C.HELD_EVASION_UP))
|
||||
throw new UnsupportedBattle("Mystery Berry and stat-raising held items are not ported yet.");
|
||||
@@ -1995,6 +2035,7 @@ export class Battle {
|
||||
yield this.hud();
|
||||
yield* this.say(BATTLE.DefrostedOpponentText);
|
||||
}
|
||||
yield* this.handleScreens();
|
||||
// HandleHealingItems: turn 0 handles the enemy's items, then turn 1.
|
||||
for (const turn of [0, 1]) {
|
||||
this.turn = turn;
|
||||
|
||||
@@ -280,6 +280,7 @@ export const COMMAND = {
|
||||
recoil: 39,
|
||||
confuse: 42,
|
||||
confusetarget: 43,
|
||||
screen: 46,
|
||||
poison: 47,
|
||||
paralyze: 48,
|
||||
splash: 54,
|
||||
|
||||
@@ -159,6 +159,10 @@ export const ENGINE_FLAGS = {
|
||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||
* \`npm run check:reference\` can verify each address by name. */
|
||||
export const BATTLE = {
|
||||
LightScreenEffectText: 0x814b4,
|
||||
ReflectEffectText: 0x814c7,
|
||||
BattleText_MonsLightScreenFell: 0x808e7,
|
||||
BattleText_MonsReflectFaded: 0x80905,
|
||||
WantsToBattleText: 0x807a9,
|
||||
BattleText_EnemyWasDefeated: 0x809da,
|
||||
GotMoneyForWinningText: 0x809be,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, 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";
|
||||
@@ -15,7 +15,10 @@ const TEACHER = 4; // 15,11 SPRITEMOVEDATA_WALK_LEFT_RIGHT, radius 1, 0
|
||||
const DUDE = 2; // 50,12 SPRITEMOVEDATA_SPINRANDOM_SLOW
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("NPC movement with the local ROM", () => {
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
let assets: CrystalAssets;
|
||||
beforeAll(() => {
|
||||
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
});
|
||||
/** A seeded Random so every run takes the same steps. */
|
||||
const at = (x: number, y: number, direction: Direction = "down") => {
|
||||
let seed = 7;
|
||||
|
||||
Reference in new issue
Block a user