Open Route 30 with map trainers and multi-Pokémon trainer battles

Map trainers run the ROM's TalkToTrainerScript and SeenByTrainerScript.
_CheckTrainerBattle runs before tile events at the end of each step and
on idle ticks; TrainerWalkToPlayer builds wMovementBuffer natively, and
writeobjectxy keeps the trainer where it stopped. The interpreter gains
the trainer header commands, wRunningTrainerBattleScript and hidden
items (HiddenItemScript).

Trainers with several Pokémon send out the next one through
EnemyPartyMonEntrance and FindMonInOTPartyToSwitchIntoBattle, including
its BattleRandom fallback. AI_SwitchOrTryItem, CheckAbleToSwitch and
AI_Switch are translated with their original bugs, and wPlayerUsedMoves
is recorded. The Shift-mode switch offer, trainer items, Pursuit on a
switch, Perish Song and Spikes remain refused.

Route 30 and its Berry house are enabled; Mr. Pokémon's house and
Route 31 stay closed. A refused trainer sighting does not restart until
the player moves, so a rollback cannot trap the player in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-24 14:36:54 +02:00
1 parent 0d1bedb3b1
commit e00dfab42e
18 files changed
+1145 -51

No files matched your search

+4 -2
View File
@@ -2,13 +2,15 @@
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
**Current milestone: from the opening to Cherrygrove City.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. Save, export and import work now. Pokédex, Link and Friends show their planned scope without placeholder data.
**Current milestone: from the opening to Route 30.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. Save, export and import work now. Pokédex, Link and Friends show their planned scope without placeholder data.
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
With a Pokémon, head west along Route 29 to Cherrygrove City. Wild Pokémon appear in the grass and battle you with their original moves. The route has ledges, signs, an item ball and a Berry tree that regrows daily. Townspeople wander and look around as in the original. In Cherrygrove, the Guide Gent gives a tour, the Poké Mart buys and sells items, the Pokémon Center nurse heals your party, and a whiteout returns you to the last Pokémon Center you entered.
Catching, trainer battles, Route 30 and beyond, party and bag menus, decorations and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you from a distance and walks over; Youngster Mikey battles when you talk to him. Each sends out two Pokémon, and beaten trainers remember you. The Berry house gives a Berry, and a Potion is hidden on the route.
Catching, Mr. Pokémon's house and the Mystery Egg errand, Route 31 and beyond, party and bag menus, decorations and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run
+1 -1
View File
@@ -48,4 +48,4 @@ Asset caches and the ROM reader remain in memory for lazy extraction. Neither RO
The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling.
`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Scripted single-Pokémon trainer battles share that core, with ROM trainer decoders and explicit AI support checks; test fixtures exercise these without opening new production maps. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
`game/battle` now holds the native battle core and ROM-backed battle tables. Browser tests cover the overworld-to-battle flow using a test-only encounter fixture; Route 29 uses ROM encounter tables. Trainer battles share that core, with ROM trainer decoders, the trainer's send-out and switch AI, and explicit AI support checks. Map trainers (sight and talk) are started by `game/world` and run the ROM's own trainer scripts. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
+12 -2
View File
@@ -73,8 +73,18 @@ Native saves are version 3. Version 2 added scene values by owning map, phone co
`game/battle/ai.ts` translates `AIChooseMove`, `AI_Redundant` and the Basic, Setup, Types, Offensive, Opportunist, Aggressive, Cautious, Status and Risky layers. It preserves the original random tie selection, known redundancy quirks, HP thresholds, exclusions and the deterministic pre-variation damage estimate used by Aggressive and Risky. Smart remains refused, including the rival's AI. Synthetic scoring vectors establish the tested branches; original-engine AI execution parity is still pending.
`loadtrainer`, `winlosstext` and `startbattle` now run single-Pokémon trainer battles through the existing battle UI. Tests cover Joey's ROM party/text/reward, RUN refusal, trainer experience, Amulet Coin, CANLOSE behavior, money limits, transactional rollback and midbattle restore. Chromium and WebKit exercise the UI with a test-only script. Production map gates are unchanged. Trainer items, party switching, sight lines, trainer-object interaction and the rival round trip remain unported.
`loadtrainer`, `winlosstext` and `startbattle` run scripted trainer battles through the existing battle UI. Tests cover Joey's ROM party/text/reward, RUN refusal, trainer experience, Amulet Coin, CANLOSE behavior, money limits, transactional rollback and midbattle restore. Chromium and WebKit exercise the UI with a test-only script.
## Map trainers
`home/trainers.asm` and `engine/events/trainer_scripts.asm`: talking to an `OBJECTTYPE_TRAINER` object runs `TalkToTrainerScript` with the object's header copied as `wTempTrainer`; `_CheckTrainerBattle` runs `SeenByTrainerScript` with `wSeenTrainerDistance`. The world checks sight in `PlayerEvents` order: before warps, coordinate events and encounters at the end of a step (a seen player's tile events are then skipped, as `DisableEvents` does), and on idle ticks before scene scripts and input. Walls and objects do not block sight. `TrainerWalkToPlayer`/`ComputePathToWalkToPlayer` build the raw `wMovementBuffer` bytes, which `applymovementlasttalked` decodes with the normal movement reader. `wRunningTrainerBattleScript` lives in the script run: zero at the start, set by `loadmem` after the battle so `endifjustbattled` ends the after-script. Trainer commands without a map trainer are refused. `MomTriesToBuySomething` after scripted battles does nothing while Mom's savings (not ported) are zero.
`engine/battle/core.asm` and `engine/battle/ai/items.asm`, `switch.asm`: when a trainer's Pokémon faints and others remain, `UpdateBattleStateAndExperienceAfterEnemyFaint` zeroes its party HP and runs `NewEnemyMonStatus`; `EnemyPartyMonEntrance` then sends out the Pokémon chosen by `FindMonInOTPartyToSwitchIntoBattle`. When every candidate is threatened it draws `BattleRandom & 7`, which tests pin. `AI_SwitchOrTryItem` runs before either side moves: `CheckAbleToSwitch`, the Often/Rarely/Sometimes thresholds, `AI_TrySwitch` and `AI_Switch` (withdraw text, HP and status back to the party). `CheckTypeMatchup` ignores the type its switch-AI callers load and uses `BATTLE_VARS_MOVE_TYPE`, so every such matchup uses the enemy's chosen move type; `FindEnemyMonsWithASuperEffectiveMove` never resets its result between Pokémon; `FindEnemyMonsWithAtLeastQuarterMaxHP` rotates HP bits instead of multiplying. All three bugs are kept. `UsedMoveText` records `wPlayerUsedMoves`. Refused: the Shift-mode offer to switch (`OfferSwitch`), `AskUseNextPokemon` with other Pokémon left, Pursuit on a switching Pokémon, Perish Song, Spikes, and trainer items. Side effects of the type scans on the move structs (overwritten types and animation) are not modelled; the next `ParseEnemyAction`/`ParsePlayerAction` reloads them.
`BGEVENT_ITEM` runs `HiddenItemScript` with `wHiddenItemData`; `SetMemEvent` sets the item's flag.
A trainer sighting whose script is refused is not restarted until the player moves. This is a native safeguard, not original behavior.
## Next slice
Route 29 and Cherrygrove are enabled with item balls, fruit trees and the daily reset, the Pokémon Center nurse and the recorded whiteout spawn. NPCs roam. Next: trainer sight lines and trainer battles, capture with the catching tutorial, 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, Reflect/Light Screen, and party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
+15 -5
View File
@@ -49,7 +49,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
## Done: Route 29 and Cherrygrove City
- Route 29, its Route 46 gate, Cherrygrove City and its five buildings are enabled. Route 30 and Route 46 remain closed.
- Route 29, its Route 46 gate, Cherrygrove City and its five buildings are enabled. Route 46 remains closed.
- Item balls run the ROM's `FindItemInBallScript`. Fruit trees run `FruitTreeScript`, with picked trees saved and regrown by a native `CheckDailyResetTimer` that clears the daily engine flags once per game day.
- The Pokémon Center nurse runs `PokecenterNurseScript`: `HealParty` (with `ComputeMaxPP`'s PP Up clamp), `CheckPokerus` and the phone-call checks. Entering a Center from a town or route records the whiteout spawn, as `EnterMapWarp` does.
- Standard Poké Marts run `pokemart` natively from `StandardMart`, `BuyMenu` and `SellMenu`: stock from the ROM's `Marts` table, `ItemAttributes` prices, half-price selling and the original texts, with keyboard, D-pad and pointer menus. Special mart types remain refused.
@@ -65,11 +65,21 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
- Native move scoring for Basic, Setup, Types, Offensive, Opportunist, Aggressive, Cautious, Status and Risky, with deterministic tie selection and the original pre-variation damage estimate. Smart remains refused.
- Scripted single-Pokémon trainer battles support the challenge, sending out, RUN refusal, experience, win/loss text and prize money, including Amulet Coin. Script results, party changes and rewards commit together; interrupted saves replay from committed state.
- Local-ROM tests and Chromium/WebKit tests exercise Youngster Joey through a test-only script. No new maps or trainer encounters are enabled in normal play.
- Next: Smart AI, trainer party switching/items, trainer sight lines and normal trainer-object scripts, then the rival and Route 30 story. The rival remains refused because Smart AI is not yet translated.
- Next: Smart AI, trainer items and player switching, then the rival and the rest of the Route 30 story. The rival remains refused because Smart AI is not yet translated.
## Implemented: Route 30 trainers
- Map trainers run the ROM's `TalkToTrainerScript` and `SeenByTrainerScript`. `_CheckTrainerBattle` runs before tile events after each step and on every idle tick: the first unbeaten trainer facing the player in line within its sight range spots them. The native `TrainerWalkToPlayer` path is played from `wMovementBuffer`, and `writeobjectxy` keeps the trainer where it stopped (saved with the map's moved objects).
- New script commands: `loadtemptrainer`, `trainertext`, `trainerflagaction`, `scripttalkafter`, `endifjustbattled`, `checkjustbattled`, `writeobjectxy`, `encountermusic` (silent) and `loadmem` for `wRunningTrainerBattleScript`.
- Trainers with several Pokémon: `HandleEnemyMonFaint` sends out the next one through `EnemySwitch_SetMode`, `FindMonInOTPartyToSwitchIntoBattle` (including its `BattleRandom` fallback) and `NewEnemyMonStatus`. `AI_SwitchOrTryItem` translates `CheckAbleToSwitch` with its original bugs (every matchup uses the enemy's move type; the quarter-HP test rotates bits) and `AI_Switch`. The player's used moves are recorded for it.
- 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).
## In progress: native battles
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience and move learning; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Capture, switching, items in battle, trainer battles and the rest of the opening quest remain unfinished.
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience and move learning; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Capture, player switching, items in battle and the rest of the opening quest remain unfinished.
This is still not a complete new game. Party/bag/Pokégear menus, story progression beyond Cherrygrove, decorations, animated terrain, surfing and audio remain unfinished. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
@@ -77,7 +87,7 @@ This is still not a complete new game. Party/bag/Pokégear menus, story progress
Map headers, location names, connection offsets, connected-border drawing, coordinate events, scene scripts, map callbacks and scripted movement are in place (see above). Next: enable connected transitions as their maps are ported; add dynamic warps, elevators, ice, currents, remaining forced movement, surfing, cycling, cut trees and boulders. Player turn timing still needs a reference comparison.
Port decorations, tile changes (`changeblock`), the remaining NPC movement types (followers, bouncing, Strength boulders), trainer sight lines and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them.
Port decorations, tile changes (`changeblock`), the remaining NPC movement types (followers, bouncing, Strength boulders) and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them.
Acceptance: representative outdoor/interior/cave/connected-map traces produce matching positions, collisions, transitions and event changes in native and reference engines. Do not enable an area merely because its image can be decoded.
@@ -87,7 +97,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29 and Cherrygrove run now. Next: trainer sight lines and trainer battles, Route 30 and Mr. Pokémon, the Mystery Egg round trip with the catching tutorial and the rival battle; then the introduction's naming and clock screens.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove and Route 30's trainers run now. Next: Mr. Pokémon's house, the Mystery Egg round trip with the catching tutorial and the rival battle; then the introduction's naming and clock screens.
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
+31
View File
@@ -195,3 +195,34 @@ tests; native and reference builds; reference, release and vendor checks;
Next (unchanged): Smart AI before the rival, or trainer sight lines and
normal trainer-object scripts for Route 30's Youngster and Bug Catcher.
## Route 30 trainers: 2026-09-24
Chose option 2 from the review above: trainer sight lines and trainer-object
scripts. Both Route 30 trainers have two Pokémon, so the increment also ports
the trainer's send-out and switch AI. Route 30 and its Berry house are now
enabled in production.
- World: `_CheckTrainerBattle` at step end (before warps, coordinate events and
encounters) and on idle ticks; `TalkToTrainer` for `OBJECTTYPE_TRAINER`;
`BGEVENT_ITEM` hidden items. A refused sighting is not restarted until the
player moves (native safeguard against a rollback loop).
- Interpreter: `loadtemptrainer`, `trainertext`, `trainerflagaction`,
`scripttalkafter`, `endifjustbattled`, `checkjustbattled`, `writeobjectxy`,
`encountermusic`, `loadmem wRunningTrainerBattleScript`, `readmem
wHiddenItemID`, and callasm `TrainerWalkToPlayer`/`SetMemEvent`.
`applymovementlasttalked wMovementBuffer` decodes the native buffer.
- Battle: multi-Pokémon trainer parties (`HandleEnemyMonFaint`,
`EnemyPartyMonEntrance`, `FindMonInOTPartyToSwitchIntoBattle` with its
`BattleRandom` fallback), `AI_SwitchOrTryItem`/`CheckAbleToSwitch`/`AI_Switch`
with the original type-matchup, `e`-carry and quarter-HP bugs, and
`wPlayerUsedMoves`. `BattleTrainer` now requires `switchFlags`.
- Stress (local, not committed): 1,080 Route 30 battles; the only refusal was
Chikorita's Reflect at level 12 (`screen`), a pre-existing gap.
Final checks: 160 unit tests with the local ROM; 44 Chromium/WebKit browser
tests including the Route 30 sighting through the UI; native and reference
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.
+8
View File
@@ -87,6 +87,9 @@ const names = {
findItemInBallScript: "FindItemInBallScript",
caughtAskNicknameText: "CaughtAskNicknameText",
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
hiddenItemScript: "HiddenItemScript",
talkToTrainerScript: "TalkToTrainerScript",
seenByTrainerScript: "SeenByTrainerScript",
wildBattleScript: "WildBattleScript",
battleWhiteoutScript: "Script_BattleWhiteout",
spawnPoints: "SpawnPoints",
@@ -101,6 +104,8 @@ const names = {
tryResetFruitTrees: "TryResetFruitTrees",
checkFruitTree: "CheckFruitTree",
pickedFruitTree: "PickedFruitTree",
trainerWalkToPlayer: "TrainerWalkToPlayer",
setMemEvent: "SetMemEvent",
marts: "Marts",
martWelcomeText: "MartWelcomeText",
martAskMoreText: "MartAskMoreText",
@@ -162,6 +167,9 @@ const ramNames = {
dailyFlags1: "wDailyFlags1",
unusedDailyFlag: "wUnusedDailyFlag",
itemQuantityChange: "wItemQuantityChange",
movementBuffer: "wMovementBuffer",
hiddenItemID: "wHiddenItemID",
runningTrainerBattleScript: "wRunningTrainerBattleScript",
moneyTemp: "hMoneyTemp",
};
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
+394 -6
View File
@@ -97,8 +97,12 @@ export type BattleOutcome = { result: number; prizeMoney?: number };
export interface BattleTrainer {
name: string;
className: string;
/** wOTPartyMons: HP and status change only when a Pokémon faints or is
* withdrawn, as in the original. */
party: PartyMon[];
aiFlags: number;
/** TrainerClassAttributes' AI item/switch word (SWITCH_* in the low byte). */
switchFlags: number;
reward: number;
winText?: number;
lossText?: number;
@@ -128,6 +132,18 @@ const SUB1 = 1,
SUB5 = 5;
/** "n percent" in the original: n * $ff / 100, rounded down. */
const percent = (n: number) => Math.floor((n * 0xff) / 100);
/** TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). */
const SWITCH_OFTEN = 1 << 0,
SWITCH_RARELY = 1 << 1,
SWITCH_SOMETIMES = 1 << 2;
/** SwitchOften/SwitchRarely/SwitchSometimes: for each wEnemySwitchMonParam
* tier ($10, $20, $30), a Random threshold and whether a roll below it
* switches (true) or keeps the Pokémon in (false). */
const SWITCH_CHANCES: readonly (readonly [number, boolean])[][] = [
[[percent(50) + 1, true], [percent(79) - 1, true], [percent(4), false]],
[[percent(8), true], [percent(12), true], [percent(79) - 1, false]],
[[percent(20) - 1, true], [percent(50) + 1, true], [percent(20) - 1, false]],
];
export interface TypeMatchup {
attacker: number;
@@ -248,6 +264,13 @@ export class Battle {
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 };
weather: number = C.WEATHER_NONE;
/** wCurOTMon: the trainer's party index in battle. */
curOTMon = 0;
/** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */
private enemySwitchMonIndex = 0;
private battleHasJustStarted = 0;
/** wPlayerUsedMoves: the player's distinct moves since sending out. */
private playerUsedMoves = [0, 0, 0, 0];
private curDamage = 0;
private criticalHit = 0;
private attackMissed = 0;
@@ -323,6 +346,7 @@ export class Battle {
switch (offset) {
case at("UsedMoveText"): {
// Updates the last move, then picks the grammar for "used".
if (this.turn === 0) this.updateUsedMoves(this.player.move.anim);
const user = this.user;
if (!user.charging) {
user.lastCounterMove = user.move.anim;
@@ -373,11 +397,12 @@ export class Battle {
// --------------------------------------------------------------- setup
/** LoadEnemyMon for a wild Pokémon from a grass or water table. */
/** LoadEnemyMon: the trainer's wCurOTMon, or a wild Pokémon from a grass
* or water table. */
private loadEnemyMon() {
const trainer = this.options.trainer;
if (trainer) {
const mon = trainer.party[0],
const mon = trainer.party[this.curOTMon],
base = this.options.data.baseData(mon.species);
this.enemy.mon = {
species: mon.species,
@@ -476,6 +501,7 @@ export class Battle {
if (mon.status & (1 << C.BRN)) mon.stats[C.ATTACK] = Math.max(1, mon.stats[C.ATTACK] >> 1);
}
private newStatus(side: Side) {
if (side === this.player) this.playerUsedMoves = [0, 0, 0, 0];
side.sub = [0, 0, 0, 0, 0, 0];
side.levels = [7, 7, 7, 7, 7, 7, 7];
side.lastMove = 0;
@@ -504,10 +530,8 @@ export class Battle {
const trainer = this.options.trainer;
if (trainer) {
validateTrainerAI(trainer.aiFlags);
if (trainer.party.length !== 1)
throw new UnsupportedBattle(
"Trainer party switching is not ported yet.",
);
if (trainer.party.length < 1 || trainer.party.length > 6)
throw new UnsupportedBattle("Invalid trainer party.");
if (
!Number.isInteger(trainer.reward) ||
trainer.reward < 0 ||
@@ -858,16 +882,20 @@ export class Battle {
private *battlePlayerFirst(): Gen {
this.enemyGoesFirst = 0;
this.turn = 1;
const switched = yield* this.aiSwitchOrTryItem();
yield* this.doTurnFor(0);
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
this.turn = 0;
if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint();
yield this.hud();
if (!switched) {
if (this.tryEnemyFlee()) return yield* this.wildFled();
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
}
this.turn = 1;
if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint();
yield this.hud();
@@ -875,10 +903,13 @@ export class Battle {
}
private *battleEnemyFirst(): Gen {
if (this.tryEnemyFlee()) return yield* this.wildFled();
this.turn = 1;
this.enemyGoesFirst = 1;
if (!(yield* this.aiSwitchOrTryItem())) {
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
}
this.turn = 1;
if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint();
yield this.hud();
@@ -1991,7 +2022,358 @@ export class Battle {
yield* this.updateStateAndExperienceAfterEnemyFaint(false);
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
yield this.hud();
// A wild battle ends; so does a trainer's with no Pokémon left (WinTrainerBattle).
if (!this.options.trainer || this.enemyTrainerDefeated()) {
this.battleEnded = true;
return;
}
// AskUseNextPokemon and ForcePlayerMonChoice.
if (!this.player.mon.hp) throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
// HandleEnemySwitch: the player's Pokémon stays in.
yield* this.enemyPartyMonEntrance();
this.battlePlayerAction = 0;
}
/** CheckEnemyTrainerDefeated: every party HP is zero. */
private enemyTrainerDefeated() {
return this.options.trainer!.party.every((mon) => mon.hp === 0);
}
/** EnemyPartyMonEntrance after a faint, while the player's Pokémon stands. */
private *enemyPartyMonEntrance(): Gen {
this.enemySwitchMonIndex = 0;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; // ResetEnemyStatLevels
this.breakAttraction();
yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon; // ResetBattleParticipants
this.turn = 1;
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
this.enemy.move.anim = 0;
}
/** EnemySwitch and EnemySwitch_SetMode. The Shift-mode offer to switch
* too (CheckWhetherToAskSwitch) needs player switching and is refused. */
private *enemySwitch(): Gen {
if (!this.battleHasJustStarted && this.options.party.length > 1 && this.partyMon.hp)
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
this.resetEnemyBattleVars();
// CheckWhetherSwitchmonIsPredetermined, else the type-matchup choice.
const index = this.enemySwitchMonIndex
? this.enemySwitchMonIndex - 1
: this.battleHasJustStarted
? 0
: this.findMonInOTPartyToSwitchIntoBattle();
if (!this.options.trainer!.party[index]?.hp)
throw new UnsupportedBattle("Invalid trainer switch.");
// LoadEnemyMonToSwitchTo: LoadEnemyMon records wCurOTMon.
this.curOTMon = index;
this.loadEnemyMon();
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
}
/** ResetEnemyBattleVars. wEnemyItemState is unused while trainer items are refused. */
private resetEnemyBattleVars() {
this.player.lastCounterMove = 0;
this.enemy.lastCounterMove = 0;
this.enemy.lastMove = 0;
this.enemy.curMove = 0;
this.player.wrapCount = 0;
}
/** NewEnemyMonStatus: also clears the player's counter move, both wrap
* counts and the player's inability to run. */
private newEnemyMonStatus() {
const enemy = this.enemy;
this.player.lastCounterMove = 0;
enemy.lastCounterMove = 0;
enemy.lastMove = 0;
enemy.sub = [0, 0, 0, 0, 0, 0];
enemy.disableCount = 0;
enemy.furyCutterCount = 0;
enemy.protectCount = 0;
enemy.rageCounter = 0;
enemy.disabledMove = 0;
enemy.minimized = 0;
this.player.wrapCount = 0;
enemy.wrapCount = 0;
enemy.turnsTaken = 0;
this.player.reset(SUB5, C.SUBSTATUS_CANT_RUN);
}
private breakAttraction() {
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
this.enemy.reset(SUB1, C.SUBSTATUS_IN_LOVE);
}
/** UpdateUsedMoves: append a new move, dropping the oldest when full. */
private updateUsedMoves(move: number) {
const used = this.playerUsedMoves;
if (used.includes(move) && move) return;
const empty = used.indexOf(0);
if (empty >= 0) used[empty] = move;
else this.playerUsedMoves = [used[1], used[2], used[3], move];
}
/** CheckTypeMatchup's wTypeMatchup (EFFECTIVE is 10). */
private typeMatchup(moveType: number, types: readonly number[], identified: boolean) {
return applyTypeMatchups(this.matchups, {
damage: 0,
modifier: 0,
moveType,
userTypes: [],
targetTypes: types,
identified,
}).matchup;
}
/** FindMonInOTPartyToSwitchIntoBattle and ScoreMonTypeMatchups: prefer
* the first Pokémon with a super-effective move that the player's types
* don't also threaten, then the first unthreatened one, else a random
* standing one (drawn with BattleRandom). */
private findMonInOTPartyToSwitchIntoBattle(): number {
const party = this.options.trainer!.party,
data = this.options.data,
count = party.length;
// wEnemyEffectivenessVsPlayerMons / wPlayerEffectivenessVsEnemyMons,
// each shifted left once per Pokémon from a sentinel bit.
let enemyBits = 1,
playerBits = 1;
for (let index = 0; index < count; index++) {
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
const mon = party[index];
if (index === this.curOTMon || !mon.hp) {
playerBits |= 1;
continue;
}
// LookUpTheEffectivenessOfEveryMove, on the enemy's turn.
for (const move of mon.moves) {
if (!move) break;
const identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED);
if (this.typeMatchup(data.move(move).type, this.player.mon.types, identified) > C.EFFECTIVE) {
enemyBits |= 1;
break;
}
}
// IsThePlayerMonTypesEffectiveAgainstOTMon, on the player's turn.
const types = data.baseData(mon.species).types,
identified = this.enemy.has(SUB1, C.SUBSTATUS_IDENTIFIED);
if (this.player.mon.types.some((type) => this.typeMatchup(type, types, identified) > C.EFFECTIVE)) {
if (enemyBits & 1) enemyBits &= ~1;
else playerBits |= 1;
}
}
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
// Align the first Pokémon to bit 7 by shifting the sentinel out.
for (;;) {
const carry = playerBits & 0x80;
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
if (carry) break;
}
let discouraged = 0;
while (discouraged < count && playerBits & (0x80 >> discouraged)) discouraged++;
if (discouraged < count) {
const bits = enemyBits || (~playerBits & 0xff);
for (let index = 0; index < 8; index++) if (bits & (0x80 >> index)) return index;
}
for (let guard = 0; guard < 4096; guard++) {
const index = this.random() & 7;
if (index < count && index !== this.curOTMon && party[index].hp) return index;
}
throw new UnsupportedBattle("The trainer has no Pokémon to send out.");
}
/** AI_SwitchOrTryItem (engine/battle/ai/items.asm). Returns true when the
* enemy spent its turn. Trainers holding items are refused before battle,
* so AI_TryItem finds none. */
private *aiSwitchOrTryItem(): Gen<boolean> {
const trainer = this.options.trainer;
if (!trainer || this.checkEnemyLockedIn()) return false;
if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false;
const style = [SWITCH_OFTEN, SWITCH_RARELY, SWITCH_SOMETIMES].findIndex(
(bit) => trainer.switchFlags & bit,
);
if (style < 0) return false;
const param = this.checkAbleToSwitch(),
tier = param & 0xf0;
if (!tier) return false;
const [threshold, below] = SWITCH_CHANCES[style][Math.min(tier >> 4, 3) - 1];
if (this.random() < threshold !== below) return false;
this.enemySwitchMonIndex = (param & 0x0f) + 1;
// AI_TrySwitch: the withdrawn Pokémon's stale party HP still counts.
if (trainer.party.filter((mon) => mon.hp).length < 2) return false;
yield* this.aiSwitch();
return true;
}
/** AI_Switch: withdraw the enemy Pokémon and send out the chosen one. */
private *aiSwitch(): Gen {
const trainer = this.options.trainer!;
this.enemyGoesFirst = 1;
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
this.turn = 0;
// PursuitSwitch reads wCurPlayerMove's effect.
if (this.player.curMove && this.options.data.move(this.player.curMove).effect === C.EFFECT_PURSUIT)
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
// Status and HP go back to the party (MON_STATUS through MON_HP).
const outgoing = trainer.party[this.curOTMon];
outgoing.status = this.enemy.mon.status;
outgoing.hp = this.enemy.mon.hp;
yield* this.say(BATTLE.EnemyWithdrewText);
this.battleHasJustStarted = 1;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon;
this.battleHasJustStarted = 0;
}
/** CheckAbleToSwitch (engine/battle/ai/switch.asm): wEnemySwitchMonParam,
* a chance tier in the high nibble and a party index in the low one.
* CheckTypeMatchup reads BATTLE_VARS_MOVE_TYPE rather than the type its
* callers load, so with hBattleTurn on the enemy every matchup here uses
* the enemy's chosen move type (docs/bugs_and_glitches.md). */
private checkAbleToSwitch(): number {
const party = this.options.trainer!.party,
data = this.options.data;
const matchup = (types: readonly number[]) =>
this.typeMatchup(this.enemy.move.type, types, this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED));
const bit = (index: number) => 0x20 >> index;
const damaging = (move: number) => data.move(move).power !== 0;
const baseTypes = (index: number) => data.baseData(party[index].species).types;
// FindAliveEnemyMons: the others still standing.
const aliveOthers = () => {
let mask = 0;
if (party.length >= 2)
party.forEach((mon, index) => {
if (index !== this.curOTMon && mon.hp) mask |= bit(index);
});
return mask;
};
// FindEnemyMonsWithAtLeastQuarterMaxHP. BUG: srl c / rl b rotate the low
// HP byte into the high byte instead of multiplying the HP by four.
const quarterHP = (mask: number) => {
let chosen = 0;
party.forEach((mon, index) => {
const high = mon.hp >> 8,
low = mon.hp & 0xff;
const rotated = ((((high << 2) | ((low & 1) << 1) | ((low >> 1) & 1)) & 0xff) << 8) | (low >> 2);
if (rotated > mon.stats[0]) chosen |= bit(index);
});
return mask & chosen;
};
// FindEnemyMonsThatResistPlayer: every branch reduces to one matchup.
const resisting = (mask: number) => {
let chosen = 0;
party.forEach((_, index) => {
if (matchup(baseTypes(index)) <= C.EFFECTIVE) chosen |= bit(index);
});
return mask & chosen;
};
// FindEnemyMonsWithASuperEffectiveMove. `e` is not reset between
// Pokémon, so one neutral move encourages every later candidate.
const superEffective = (mask: number) => {
let e = 0,
encouraged = 0;
for (let index = 0; index < 6; index++) {
if (!(mask & bit(index))) continue;
for (const move of party[index].moves) {
if (!move) break;
if (!damaging(move)) continue;
const value = matchup(this.player.mon.types);
if (value < C.EFFECTIVE) continue;
e = 1;
if (value === C.EFFECTIVE) continue;
e = 2;
break;
}
if (e === 2) return { e, score: index };
if (e === 1) encouraged |= bit(index);
}
for (let index = 0; index < 6; index++) if (encouraged & bit(index)) return { e, score: index };
return { e, score: 0xff };
};
// FindAliveEnemyMonsWithASuperEffectiveMove: standing, including the
// current Pokémon.
const standingSuperEffective = (mask: number) => {
let standing = 0;
party.forEach((mon, index) => {
if (mon.hp) standing |= bit(index);
});
return superEffective(mask & standing);
};
if (!aliveOthers()) return 0;
if (this.enemy.has(SUB1, C.SUBSTATUS_PERISH))
throw new UnsupportedBattle("Perish Song is not ported yet.");
if (this.checkPlayerMoveTypeMatchups() > C.EFFECTIVE) return 0;
const lastCounter = this.player.lastCounterMove;
if (lastCounter) {
// FindEnemyMonsImmuneToLastCounterMove.
let immune = 0;
party.forEach((mon, index) => {
if (index === this.curOTMon || !mon.hp || !damaging(lastCounter)) return;
if (matchup(baseTypes(index)) === 0) immune |= bit(index);
});
if (immune) {
const { e, score } = superEffective(immune);
if (score === 0xff) return 0;
if (e !== 2) {
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
return score + 0x10;
}
return score + (this.checkPlayerMoveTypeMatchups() < C.EFFECTIVE ? 0x20 : 0x10);
}
}
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers())));
return e === 2 ? score + 0x10 : 0;
}
/** CheckPlayerMoveTypeMatchups: wEnemyAISwitchScore from BASE_AI_SWITCH_SCORE
* (10); lower means the enemy is worse off. Matchups use the enemy's move
* type (see checkAbleToSwitch). */
private checkPlayerMoveTypeMatchups(): number {
const data = this.options.data,
identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
type = this.enemy.move.type;
const vsEnemy = () => this.typeMatchup(type, this.enemy.mon.types, identified),
vsPlayer = () => this.typeMatchup(type, this.player.mon.types, identified);
let score = 10;
const used = this.playerUsedMoves;
if (!used[0]) {
if (vsEnemy() > C.EFFECTIVE) score--;
if (this.player.mon.types[1] !== this.player.mon.types[0] && vsEnemy() > C.EFFECTIVE) score--;
} else {
let e = 0,
superEffective = false;
for (const move of used) {
if (!move) break;
if (!data.move(move).power) continue;
const value = vsEnemy();
if (value > C.EFFECTIVE) {
score--;
superEffective = true;
break;
}
if (!value) continue;
if (value === C.EFFECTIVE) e = 2;
else if (e < 1) e = 1;
}
if (!superEffective && e !== 2) score += e ? 1 : 2;
}
// .CheckEnemyMoveMatchups
let c = 0;
for (const move of this.enemy.mon.moves) {
if (!move) break;
if (!data.move(move).power) continue;
const value = vsPlayer();
if (!value) continue;
c++;
if (value < C.EFFECTIVE) continue;
c += 5;
if (value > C.EFFECTIVE) c = 100;
}
if (!c) score -= 2;
else if (c < 5) score--;
else if (c >= 100) score++;
return score & 0xff;
}
private *faintYourPokemon(): Gen {
yield* this.say(BATTLE.BattleText_MonFainted);
@@ -2003,9 +2385,11 @@ export class Battle {
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
if (!this.enemy.mon.hp) {
yield* this.updateStateAndExperienceAfterEnemyFaint(true);
if (!this.options.trainer || this.enemyTrainerDefeated()) {
this.battleEnded = true;
return;
}
}
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
}
private updateFaintedPlayerMon() {
@@ -2035,8 +2419,12 @@ export class Battle {
/** UpdateBattleStateAndExperienceAfterEnemyFaint and GiveExperiencePoints. */
private *updateStateAndExperienceAfterEnemyFaint(playerFaintedFirst: boolean): Gen {
this.updateBattleMonInParty();
const trainer = this.options.trainer;
if (trainer) trainer.party[this.curOTMon].hp = 0;
this.player.reset(SUB3, C.SUBSTATUS_IN_LOOP);
this.enemy.damageTaken = 0;
this.newEnemyMonStatus();
this.breakAttraction();
if (!this.player.mon.hp && !playerFaintedFirst) this.updateFaintedPlayerMon();
if (!this.options.party.some((mon) => mon.hp > 0)) return;
this.battleResult &= 0xc0; // WIN
+1
View File
@@ -81,6 +81,7 @@ export const C = {
EFFECT_EVASION_UP: 16,
EFFECT_ATTACK_UP_2: 50,
EFFECT_EVASION_UP_2: 56,
EFFECT_PURSUIT: 128,
// Moves (constants/move_constants.asm)
GUST: 16,
+11
View File
@@ -52,6 +52,9 @@ export const SYMBOLS = {
findItemInBallScript: 0x122ce,
caughtAskNicknameText: 0x4db44,
nicknameStrings: 0x11780,
hiddenItemScript: 0x13625,
talkToTrainerScript: 0xbe66a,
seenByTrainerScript: 0xbe675,
wildBattleScript: 0x97cf9,
battleWhiteoutScript: 0x124c1,
spawnPoints: 0x152ab,
@@ -92,6 +95,8 @@ export const NATIVE_ROUTINES = {
tryResetFruitTrees: 0x4404c,
checkFruitTree: 0x44055,
pickedFruitTree: 0x4405f,
trainerWalkToPlayer: 0x831e,
setMemEvent: 0x1364f,
} as const;
/** Native handlers are selected by the routine a special index points to,
@@ -130,6 +135,9 @@ export const RAM = {
dailyFlags1: 0xdc1e,
unusedDailyFlag: 0xdc21,
itemQuantityChange: 0xd10c,
movementBuffer: 0xd007,
hiddenItemID: 0xd040,
runningTrainerBattleScript: 0xd04d,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
} as const;
@@ -200,6 +208,7 @@ export const BATTLE = {
DisabledNoMoreText: 0x80d39,
DoesntAffectText: 0x81071,
EnemyText: 0x1295,
EnemyWithdrewText: 0x384d0,
ExpPointsText: 0x3f131,
FastAsleepText: 0x80ce7,
FellAsleepText: 0x811b1,
@@ -289,11 +298,13 @@ export const NATIVE_MAPS: Record<string, string> = {
"24:7": "Your room",
"24:8": "Neighbor’s house",
"24:9": "Elm’s house",
"26:1": "Route 30",
"26:3": "Cherrygrove City",
"26:4": "Poké Mart",
"26:5": "Pokémon Center",
"26:6": "Gym speech house",
"26:7": "Guide Gent’s house",
"26:8": "Evolution speech house",
"26:9": "Berry house",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+155 -5
View File
@@ -25,6 +25,7 @@ import {
generatePartyMon,
type PartyMon,
} from "../party/pokemon";
import { TrainerData, type TrainerHeader } from "../rom/trainers";
import { DIRECTION_ORDER, type Direction } from "../world/types";
import { readMovement, type MovementStep } from "./movement";
import {
@@ -184,6 +185,8 @@ export interface ScriptHost {
/** The player's facing and position, for readvar. */
facing(): number;
position(): { x: number; y: number };
/** OBJECT_MAP_X/Y of a visible object (script numbering), if any. */
objectPosition(id: number): { x: number; y: number } | undefined;
}
export class UnsupportedScript extends Error {
@@ -202,6 +205,43 @@ type Input = number | string | undefined;
/** ITEM_FROM_MEM: take the item from wScriptVar. */
const ITEM_FROM_MEM = 0xff;
/** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm):
* a one-tick sleep, then normal steps to the player's tile, dropping the
* last step so the trainer stops beside the player. Returns the raw
* wMovementBuffer bytes. */
export function trainerWalkBuffer(
trainer: { x: number; y: number },
player: { x: number; y: number },
distance: number,
): number[] {
const SLEEP_1 = 0x3e,
STEP = 0x0c,
END = 0x47;
const [DOWN, UP, LEFT, RIGHT] = [0, 1, 2, 3];
const buffer = [SLEEP_1];
if ((distance - 1) & 0xff) {
let x = (trainer.x - player.x) & 0xff,
h = LEFT;
if (trainer.x < player.x) {
x = (player.x - trainer.x) & 0xff;
h = RIGHT;
}
let y = (trainer.y - player.y) & 0xff,
v = UP;
if (trainer.y < player.y) {
y = (player.y - trainer.y) & 0xff;
v = DOWN;
}
// Swapped only when the y distance is the smaller one.
const path: [number, number][] = y < x ? [[v, y], [h, x]] : [[h, x], [v, y]];
for (const [direction, count] of path)
for (let i = 0; i < count; i++) buffer.push(STEP | direction);
buffer.pop(); // DecrementMovementBufferCount
}
buffer.push(END);
return buffer;
}
/** A native interpreter for the game's own event format, NOT GB assembly.
* Execution is resumable: presentation effects are yielded and the caller
* resumes with button, choice or animation completion. All state changes are
@@ -222,6 +262,13 @@ export class ScriptRun {
/** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */
private fruitTree = 0;
private fruit = 0;
/** wTempTrainer, copied from a map trainer's header by LoadTrainer_continue. */
private tempTrainer?: TrainerHeader;
/** wRunningTrainerBattleScript: zeroed by LoadTrainer_continue, set to -1
* after the battle so the trainer's after-script ends at once. */
private runningTrainerBattleScript = 0;
/** wMovementBuffer, filled by TrainerWalkToPlayer. */
private movementBuffer?: number[];
private iterator: Generator<ScriptEffect, void, Input>;
private instructions = 0;
@@ -237,10 +284,17 @@ export class ScriptRun {
/** wItemBallItemID/wItemBallQuantity, copied from the itemball data the
* facing object's script pointer names. */
itemBall?: { item: number; quantity: number };
/** A map trainer (TalkToTrainer or _CheckTrainerBattle): its header
* address and wSeenTrainerDistance. */
trainer?: { header: number; distance: number };
/** wHiddenItemData, copied from a BGEVENT_ITEM's hiddenitem data. */
hiddenItem?: { event: number; item: number };
} = {},
) {
this.state = copyScriptState(state);
this.lastTalked = host.lastTalked;
if (options.trainer)
this.tempTrainer = new TrainerData(reader).header(options.trainer.header);
this.iterator = this.execute(start);
}
@@ -273,8 +327,15 @@ export class ScriptRun {
spawn = 0;
let wild = this.options.wild;
let trainer: { classId: number; id: number } | undefined;
let winText: number | undefined, lossText: number | undefined;
// LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too.
let winText = this.tempTrainer?.beatenText || undefined,
lossText = this.tempTrainer?.lostScript || undefined;
let canLose = false;
const temp = (offset: number, opcode: number) => {
if (!this.tempTrainer)
throw new UnsupportedScript(offset, opcode, " (no map trainer)");
return this.tempTrainer;
};
// One array for the whole run: specials append to and clear it in place.
const pages: string[] = [];
const byte = () => r.byte(p++);
@@ -441,6 +502,18 @@ export class ScriptRun {
this.state.fruitTrees.add(this.fruitTree);
else if (routine === NATIVE_ROUTINES.getWhiteoutSpawn)
spawn = findSpawn(this.state.lastSpawnMap);
else if (routine === NATIVE_ROUTINES.setMemEvent) {
// SetMemEvent: the hidden item's wHiddenItemEvent flag.
const hidden = this.options.hiddenItem;
if (!hidden) throw new UnsupportedScript(offset, opcode, " (no hidden item)");
this.state.events.add(hidden.event);
} else if (routine === NATIVE_ROUTINES.trainerWalkToPlayer) {
const distance = this.options.trainer?.distance,
from = this.host.objectPosition(this.lastTalked);
if (distance === undefined || !from || this.lastTalked === PLAYER)
throw new UnsupportedScript(offset, opcode, " (no seen trainer)");
this.movementBuffer = trainerWalkBuffer(from, this.host.position(), distance);
}
else if (routine === NATIVE_ROUTINES.tryReceiveItem) {
// FindItemInBallScript.TryReceiveItem: give the ball's item and
// name it for FoundItemText (wStringBuffer3), matching GetItemName
@@ -459,9 +532,22 @@ export class ScriptRun {
break;
}
case 0x19: {
// readmem: only the fruit a tree bears is readable so far.
if (word() !== RAM.curFruit) throw new UnsupportedScript(offset, opcode);
value = this.fruit;
// readmem: the fruit a tree bears, or a hidden item's ID.
const address = word();
if (address === RAM.curFruit) value = this.fruit;
else if (address === RAM.hiddenItemID && this.options.hiddenItem)
value = this.options.hiddenItem.item;
else throw new UnsupportedScript(offset, opcode);
break;
}
case 0x1b: {
// loadmem: only the trainer script's wRunningTrainerBattleScript.
const address = word(),
stored = byte();
if (address !== RAM.runningTrainerBattleScript)
throw new UnsupportedScript(offset, opcode);
temp(offset, opcode);
this.runningTrainerBattleScript = stored;
break;
}
case 0x9b: {
@@ -492,6 +578,53 @@ export class ScriptRun {
trainer = { classId: byte(), id: byte() };
wild = undefined;
break;
case 0x5c: {
// loadtemptrainer: wOtherTrainerClass/ID from wTempTrainer.
const header = temp(offset, opcode);
trainer = { classId: header.classId, id: header.id };
wild = undefined;
break;
}
case 0x62: {
// trainertext: seen, beaten or loss text from wTempTrainer.
const header = temp(offset, opcode),
text = [header.seenText, header.beatenText, header.lostScript][byte()];
if (!text) throw new UnsupportedScript(offset, opcode, " (text)");
write(text);
break;
}
case 0x63: {
// trainerflagaction on wTempTrainerEventFlag. FlagAction leaves a
// nonzero mask after setting or clearing, so wScriptVar is TRUE.
const flag = temp(offset, opcode).eventFlag,
action = byte();
if (action === 0) this.state.events.delete(flag);
else if (action === 1) this.state.events.add(flag);
else if (action !== 2)
throw new UnsupportedScript(offset, opcode, " (flag action)");
value = action === 2 ? Number(this.state.events.has(flag)) : 1;
break;
}
case 0x65: {
const after = temp(offset, opcode).afterScript;
if (!after) throw new UnsupportedScript(offset, opcode, " (no script)");
p = after;
break;
}
case 0x66:
// endifjustbattled: Script_end, which also returns from a call.
temp(offset, opcode);
if (!this.runningTrainerBattleScript) break;
if (stack.length) {
p = stack.pop()!;
break;
}
yield* flush();
return;
case 0x67:
temp(offset, opcode);
value = Number(this.runningTrainerBattleScript !== 0);
break;
case 0x64: {
// Null pointers request the battle engine's default end text.
const bank = r.bank(p), won = word(), lost = word();
@@ -767,7 +900,13 @@ export class ScriptRun {
case 0x69:
case 0x6a: {
const target = opcode === 0x69 ? object(byte()) : this.lastTalked;
const steps = readMovement(r, near());
let steps: MovementStep[];
if (r.word(p) === RAM.movementBuffer) {
word();
const buffer = this.movementBuffer;
if (!buffer) throw new UnsupportedScript(offset, opcode, " (movement buffer)");
steps = readMovement({ byte: (index) => buffer[index] }, 0);
} else steps = readMovement(r, near());
yield* flush();
yield synchronous({ kind: "move", object: target, steps });
break;
@@ -814,6 +953,15 @@ export class ScriptRun {
yield synchronous({ kind: "moveobject", object: target, x, y });
break;
}
case 0x73: {
// writeobjectxy: the object's current tile becomes its map position.
const target = object(byte()),
position = this.host.objectPosition(target);
if (target === PLAYER || !position)
throw new UnsupportedScript(offset, opcode, " (object)");
yield synchronous({ kind: "moveobject", object: target, ...position });
break;
}
case 0x75: {
const emote = byte(),
target = byte(),
@@ -844,6 +992,7 @@ export class ScriptRun {
word();
byte();
break;
case 0x80: // encountermusic
case 0x82:
case 0x83:
case 0x86:
@@ -1226,6 +1375,7 @@ export function runScript(
landmark: () => 0,
facing: () => 0,
position: () => ({ x: 0, y: 0 }),
objectPosition: () => undefined,
});
const pages: string[] = [];
for (let effect = run.resume(); effect; effect = run.resume()) {
+6 -2
View File
@@ -28,8 +28,12 @@ export class UnsupportedMovement extends Error {
const STEP_TICKS = [16, 8, 4] as const; // slow, walk, big (bike speed)
/** Decode movement data until step_end. Unknown commands abort the script
* rather than approximating a jump, slide or special effect. */
export function readMovement(reader: RomReader, start: number): MovementStep[] {
* rather than approximating a jump, slide or special effect. The source is
* usually the ROM; wMovementBuffer data built natively decodes the same way. */
export function readMovement(
reader: Pick<RomReader, "byte">,
start: number,
): MovementStep[] {
const steps: MovementStep[] = [];
let p = start;
for (let count = 0; count < 256; count++) {
+102 -15
View File
@@ -188,6 +188,8 @@ interface ActiveScript {
scene: boolean;
/** newloadmap: entered after the script commits. */
warp?: { map: string; x: number; y: number };
/** The trainer whose sight started this script, if any. */
sighted?: number;
}
const OPPOSITE: Record<Direction, Direction> = {
@@ -250,6 +252,7 @@ const WONT_DELETE = 1 << 1,
MOVE_ANYWHERE = 1 << 5,
NOCLIP_OBJS = 1 << 6,
SWIMMING = 1 << 5;
const OBJECTTYPE_TRAINER = 2;
/** STEP_SLOW: random walkers take 16 ticks per tile. */
const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) =>
@@ -296,6 +299,10 @@ export class GameWorld {
/** Scene scripts are re-evaluated only after the map or state changes. */
private sceneCheck = true;
private dailyFlags?: number[];
/** A trainer sighting whose script was refused. Rollback leaves the player
* in view; without this the trainer would restart the same refused
* script every tick until the player could never move away. */
private refusedSighting?: { map: string; id: number; x: number; y: number };
constructor(
readonly assets: CrystalAssets,
@@ -663,6 +670,9 @@ export class GameWorld {
this.applyMap(entry);
return;
}
// PlayerEvents: CheckTrainerEvent precedes CheckTileEvent, and a seen
// player's warp, coordinate event and step count are then skipped.
if (this.checkTrainers()) return;
const warp = map.warps.find(
(warp) => warp.x === player.x && warp.y === player.y,
);
@@ -678,7 +688,7 @@ export class GameWorld {
event.x === player.x &&
event.y === player.y,
);
if (event) this.startScript(event.script, PLAYER, previous);
if (event) this.startScript(event.script, PLAYER, { committedPosition: previous });
else this.randomEncounter(previous);
}
@@ -712,7 +722,7 @@ export class GameWorld {
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
return;
if (!this.state.party.some((mon) => mon.hp > 0)) return;
this.startScript(SYMBOLS.wildBattleScript, PLAYER, previous, false, chosen);
this.startScript(SYMBOLS.wildBattleScript, PLAYER, { committedPosition: previous, wild: chosen });
}
// ------------------------------------------------------------ map entry
@@ -823,6 +833,13 @@ export class GameWorld {
landmark: () => this.assets.map(mapId).landmark,
facing: () => DIRECTION_ORDER.indexOf(this.player.direction),
position: () => ({ x: this.player.x, y: this.player.y }),
objectPosition: (id) => {
if (id === PLAYER) return { x: this.player.x, y: this.player.y };
const object = this.map.objects.find((candidate) => candidate.id === id);
if (!object || !this.spawned.has(id)) return undefined;
const actor = this.actor(object);
return { x: actor.x, y: actor.y };
},
};
}
private weekdayName(day: number) {
@@ -837,10 +854,23 @@ export class GameWorld {
private startScript(
start: number,
lastTalked: number,
committedPosition?: { x: number; y: number },
{
committedPosition,
scene = false,
wild?: { species: number; level: number },
itemBall?: { item: number; quantity: number },
wild,
itemBall,
trainer,
hiddenItem,
sighted,
}: {
committedPosition?: { x: number; y: number };
scene?: boolean;
wild?: { species: number; level: number };
itemBall?: { item: number; quantity: number };
trainer?: { header: number; distance: number };
hiddenItem?: { event: number; item: number };
sighted?: number;
} = {},
) {
const committed = this.save();
if (committedPosition) {
@@ -858,6 +888,8 @@ export class GameWorld {
{
wild,
itemBall,
trainer,
hiddenItem,
},
),
committed,
@@ -873,6 +905,7 @@ export class GameWorld {
waitTicks: 0,
moving: new Map(),
scene,
sighted,
};
this.pump();
}
@@ -1016,6 +1049,7 @@ export class GameWorld {
className: opponent.className,
party: trainers.createParty(opponent, { otId: this.playerId, ot: this.name }),
aiFlags: opponent.attributes.aiMoveFlags,
switchFlags: opponent.attributes.aiItemSwitchFlags,
reward: opponent.reward,
winText: effect.trainer?.winText,
lossText: effect.trainer?.lossText,
@@ -1226,6 +1260,13 @@ export class GameWorld {
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
if (script.sighted !== undefined)
this.refusedSighting = {
map: this.mapId,
id: script.sighted,
x: this.player.x,
y: this.player.y,
};
this.refuse(error);
this.notice =
"This interaction needs game logic that has not been ported yet. Your progress is unchanged.";
@@ -1461,6 +1502,41 @@ export class GameWorld {
);
}
/** _CheckTrainerBattle (home/trainers.asm): the first map trainer, in
* object order, that faces the player in a straight line within its sight
* range and whose event flag is clear starts SeenByTrainerScript. Walls and
* objects do not block sight. Sight ranges (at most 5) never reach beyond
* the object window, so an in-range trainer always has an object struct. */
private checkTrainers(): boolean {
const player = this.player,
r = this.assets.reader,
refused = this.refusedSighting;
if (refused && (refused.map !== this.mapId || refused.x !== player.x || refused.y !== player.y))
this.refusedSighting = undefined;
for (const object of this.map.objects) {
if (object.kind !== OBJECTTYPE_TRAINER || !this.spawned.has(object.id)) continue;
if (this.refusedSighting?.id === object.id) continue;
const actor = this.actor(object);
// FacingPlayerDistance: same row or column, facing the player.
let distance: number, facing: Direction;
if (actor.x === player.x && actor.y !== player.y) {
distance = Math.abs(player.y - actor.y);
facing = player.y > actor.y ? "down" : "up";
} else if (actor.y === player.y && actor.x !== player.x) {
distance = Math.abs(player.x - actor.x);
facing = player.x > actor.x ? "right" : "left";
} else continue;
if (actor.direction !== facing || distance > (object.sightRange ?? 0)) continue;
if (this.state.events.has(r.word(object.script))) continue;
this.startScript(SYMBOLS.seenByTrainerScript, object.id, {
trainer: { header: object.script, distance },
sighted: object.id,
});
return true;
}
return false;
}
/** Scene scripts run while the player is idle (RunSceneScript). */
private checkScene() {
if (!this.sceneCheck || this.busy) return;
@@ -1468,7 +1544,7 @@ export class GameWorld {
const owner = this.assets.sceneOwner(this.mapId);
if (!owner) return;
const script = this.map.sceneScripts[this.state.scenes[owner] ?? 0];
if (script !== undefined) this.startScript(script, PLAYER, undefined, true);
if (script !== undefined) this.startScript(script, PLAYER, { scene: true });
}
// ---------------------------------------------------------------- input
@@ -1531,6 +1607,7 @@ export class GameWorld {
return;
}
if (this.dialogue) return;
if (this.checkTrainers()) return;
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
@@ -1563,6 +1640,8 @@ export class GameWorld {
}
if (this.busy) return;
this.clearNotice();
// CheckTrainerEvent precedes OWPlayerInput in PlayerEvents.
if (this.checkTrainers()) return;
const [dx, dy] = VECTORS[this.player.direction];
let x = this.player.x + dx,
y = this.player.y + dy;
@@ -1582,14 +1661,16 @@ export class GameWorld {
// macro's item, quantity), not a script; FindItemInBallScript always
// runs, with hLastTalked left on this object as TryObjectEvent does.
const r = this.assets.reader;
this.startScript(
SYMBOLS.findItemInBallScript,
object.id,
undefined,
false,
undefined,
{ item: r.byte(object.script), quantity: r.byte(object.script + 1) },
);
this.startScript(SYMBOLS.findItemInBallScript, object.id, {
itemBall: { item: r.byte(object.script), quantity: r.byte(object.script + 1) },
});
return;
}
if (object.kind === OBJECTTYPE_TRAINER) {
// TalkToTrainer: wSeenTrainerDistance 1, then TalkToTrainerScript.
this.startScript(SYMBOLS.talkToTrainerScript, object.id, {
trainer: { header: object.script, distance: 1 },
});
return;
}
if (object.kind !== 0) {
@@ -1614,7 +1695,13 @@ export class GameWorld {
if (set !== (event.kind === 5)) return;
script = r.near(event.script + 2, r.bank(event.script));
} else if (event.kind === 0) script = event.script;
else {
else if (event.kind === 7) {
// BGEVENT_ITEM: hiddenitem data (event flag, item) for HiddenItemScript.
const hidden = { event: r.word(event.script), item: r.byte(event.script + 2) };
if (this.state.events.has(hidden.event)) return;
this.startScript(SYMBOLS.hiddenItemScript, PLAYER, { hiddenItem: hidden });
return;
} else {
this.notice = "This interaction is not ported yet.";
return;
}
+6 -5
View File
@@ -500,14 +500,14 @@ describe.skipIf(!existsSync(romPath))(
load(),
() => new Date("2026-09-22T12:00:00"),
);
const city = world.assets.map("26:3");
const edge = Array.from({ length: city.width }, (_, x) => x).find(
const route = world.assets.map("26:1");
const edge = Array.from({ length: route.width }, (_, x) => x).find(
(x) =>
world.assets.permission(world.assets.collision(city, x, 0)) === 0,
world.assets.permission(world.assets.collision(route, x, 0)) === 0,
)!;
world.restore({ ...world.save(), map: "26:3", x: edge, y: 0 });
world.restore({ ...world.save(), map: "26:1", x: edge, y: 0 });
expect(world.move("up")).toBe(false);
expect(world.notice).toBe("Route 30 is not ported yet.");
expect(world.notice).toBe("Route 31 is not ported yet.");
const {
scenes,
phone,
@@ -831,6 +831,7 @@ describe.skipIf(!existsSync(romPath))(
landmark: () => 0,
facing: () => 0,
position: () => ({ x: 0, y: 0 }),
objectPosition: () => undefined,
};
// Runs the ROM's own battleWhiteoutScript end to end: HealParty,
// GetWhiteoutSpawn and WarpToSpawnPoint all through the interpreter.
+197
View File
@@ -0,0 +1,197 @@
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 { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const ROUTE_30 = "26:1";
const MIKEY = 4,
DON = 5;
const EVENT_BEAT_YOUNGSTER_MIKEY = 1450,
EVENT_BEAT_BUG_CATCHER_DON = 1336;
const POTION = 0x12;
describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", () => {
const fixture = (position: { x: number; y: number; direction: "up" | "down" | "left" | "right" }) => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
let seed = 7;
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
});
const mon = generatePartyMon(assets.pokemon, {
species: 158, level: 20, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
});
mon.moves = [10, 0, 0, 0]; // Scratch
mon.pp = [35, 0, 0, 0];
world.state.party = [mon];
world.restore({ ...world.save(), map: ROUTE_30, ...position });
return world;
};
/** Play through scripts and battles, fighting with the first move. */
const drive = (world: GameWorld) => {
const shown: string[] = [];
let emote = false;
for (let tick = 0; tick < 50_000; tick++) {
if (world.emote) emote = true;
const view = world.battle;
if (view) {
if (view.message && shown.at(-1) !== view.message.text) shown.push(view.message.text);
if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
else if (view.prompt?.kind === "yesno") world.battleChoose(0);
else if (view.prompt?.kind === "forget") world.battleChoose(4);
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
else world.tick();
continue;
}
const dialogue = world.dialogue;
if (dialogue) {
shown.push(dialogue.pages[dialogue.index]);
if (dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
continue;
}
if (!world.busy) return { text: shown.join("\n"), emote };
world.tick();
}
throw new Error(`The world did not settle: ${world.diagnostic}`);
};
const object = (world: GameWorld, id: number) =>
world.objects().find((candidate) => candidate.id === id)!;
it("Bug Catcher Don sees the player, walks over, sends out both Caterpie and stays beaten", () => {
const world = fixture({ x: 1, y: 12, direction: "up" });
const money = world.state.money;
expect(world.move("up")).toBe(true); // (1, 11): four tiles away
expect(world.busy).toBe(false);
expect(world.move("up")).toBe(true); // (1, 10): within his range of three
expect(world.activity).toBe("scene");
const { text, emote } = drive(world);
expect(world.notice, world.diagnostic).toBe("");
expect(emote).toBe(true);
// TrainerWalkToPlayer stops beside the player; writeobjectxy keeps it there.
expect(object(world, DON)).toMatchObject({ x: 1, y: 9, direction: "down" });
expect(world.direction).toBe("up");
expect(text.match(/sent out/g)?.length).toBe(2);
expect(text).toContain("CATERPIE");
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(true);
expect(world.state.money).toBe(money + 48);
expect(world.state.pokedex.seen.has(10)).toBe(true);
// endifjustbattled: no after-battle text straight after the battle.
const after = text.slice(text.lastIndexOf("48"));
expect(after.split("\n").length).toBeLessThanOrEqual(2);
// The position survives a save, and a beaten trainer no longer sees.
const saved = world.save();
expect(saved.objects).toContainEqual({ id: DON, x: 1, y: 9, direction: "down" });
world.restore(saved);
expect(world.save()).toEqual(saved);
world.move("down");
world.move("up");
world.tick();
expect(world.busy).toBe(false);
// TalkToTrainer: already beaten, so only the after-battle text.
world.interact();
const talk = drive(world);
expect(talk.text.length).toBeGreaterThan(0);
expect(talk.text).not.toMatch(/sent out/);
expect(world.state.money).toBe(money + 48);
});
it("replays the whole sighting from committed state after a mid-battle reload", () => {
const world = fixture({ x: 1, y: 11, direction: "up" });
const before = world.save();
world.move("up");
for (let tick = 0; tick < 5000 && !world.battle?.prompt; tick++) {
if (world.dialogue) world.advanceDialogue();
else if (world.battle?.message?.prompt) world.interact();
else world.tick();
}
expect(world.battle?.prompt?.kind).toBe("action");
const midBattle = world.save();
expect(midBattle.events).toEqual(before.events);
expect(midBattle.objects).toEqual(before.objects);
expect(midBattle).toMatchObject({ x: 1, y: 10 });
world.restore(midBattle);
expect(world.battle).toBeUndefined();
expect(object(world, DON)).toMatchObject({ x: 1, y: 7 });
// The first idle check sees the player again.
world.tick();
expect(world.activity).toBe("scene");
drive(world);
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(true);
});
it("does not re-trigger a refused sighting until the player moves", () => {
const world = fixture({ x: 1, y: 11, direction: "up" });
world.state.party[0].moves = [144, 0, 0, 0]; // Transform is refused
world.state.party[0].pp = [10, 0, 0, 0];
const before = world.save();
world.move("up");
drive(world);
expect(world.notice).toContain("not been ported");
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(false);
expect(world.save().objects).toEqual(before.objects);
for (let tick = 0; tick < 10; tick++) world.tick();
expect(world.activity).toBe("idle");
expect(world.move("down")).toBe(true);
expect(world.move("up")).toBe(true);
expect(world.activity).toBe("scene");
});
it("battles Youngster Mikey when spoken to, then gives his after-battle text", () => {
const world = fixture({ x: 5, y: 22, direction: "down" });
expect(world.busy).toBe(false); // Mikey faces his Rattata, not the player.
world.interact();
const { text } = drive(world);
expect(world.notice, world.diagnostic).toBe("");
expect(object(world, MIKEY).direction).toBe("up"); // faceplayer
expect(text).toContain("PIDGEY");
expect(text).toContain("RATTATA");
expect(world.state.events.has(EVENT_BEAT_YOUNGSTER_MIKEY)).toBe(true);
world.interact();
expect(drive(world).text).not.toMatch(/sent out/);
});
it("finds the hidden Potion once", () => {
const world = fixture({ x: 14, y: 10, direction: "up" });
world.interact();
expect(settle(world).join("\n")).toContain("POTION");
expect(world.notice, world.diagnostic).toBe("");
const potions = () =>
world.state.bag.items.find((slot) => slot.item === POTION)?.quantity ?? 0;
expect(potions()).toBe(1);
world.interact();
expect(settle(world)).toEqual([]);
expect(potions()).toBe(1);
});
it("runs the Route 30 Pokémon battle scene and the Berry house, but keeps Mr. Pokémon's house closed", () => {
const world = fixture({ x: 5, y: 27, direction: "up" });
world.interact();
expect(settle(world).length).toBeGreaterThan(1);
expect(world.notice, world.diagnostic).toBe("");
world.restore({ ...world.save(), x: 7, y: 40, direction: "up" });
expect(world.move("up")).toBe(true);
settle(world);
expect(world.mapId).toBe("26:9");
const berry = world.state.bag.items.length;
// Facing the Pokéfan M, who gives a Berry once.
const pokefan = world.objects()[0];
world.restore({ ...world.save(), x: pokefan.x, y: pokefan.y + 1, direction: "up" });
world.interact();
expect(settle(world).join("\n")).toMatch(/BERRY/);
expect(world.state.bag.items.length).toBe(berry + 1);
world.restore({ ...world.save(), map: ROUTE_30, x: 17, y: 6, direction: "up" });
expect(world.move("up")).toBe(false);
expect(world.notice).toMatch(/not ported/);
});
});
+91 -8
View File
@@ -15,16 +15,17 @@ 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))("single-Pokémon trainer battles", () => {
describe.skipIf(!existsSync(romPath))("trainer battles", () => {
let assets: CrystalAssets, trainers: TrainerData;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
trainers = new TrainerData(assets.reader);
});
const setup = (level = 15) => {
const trainer = trainers.trainer(22, 1); // Youngster Joey, level 4 Rattata.
const setup = (level = 15, [classId, id] = [22, 1], species = 155) => {
// Youngster Joey, level 4 Rattata, unless another trainer is named.
const trainer = trainers.trainer(classId, id);
const mon = generatePartyMon(assets.pokemon, {
species: 155,
species,
level,
item: 0,
otId: 1234,
@@ -50,6 +51,7 @@ describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
className: trainer.className,
party: trainers.createParty(trainer),
aiFlags: trainer.attributes.aiMoveFlags,
switchFlags: trainer.attributes.aiItemSwitchFlags,
reward: trainer.reward,
},
};
@@ -171,11 +173,92 @@ describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
expect(mon.status).toBe(0);
expect(mon.pp[0]).toBe(assets.pokemon.move(mon.moves[0]).pp);
});
it("refuses unported party switching and AI before mutating the party or Pokédex", () => {
for (const kind of ["party", "ai"] as const) {
/** Run to the first battle menu; the battle's internals for AI checks. */
const atMenu = (battle: Battle) => {
const run = battle.run();
for (let guard = 0; guard < 20; guard++) {
const next = run.next();
if (!next.done && next.value.kind === "action") break;
}
return {
run,
internals: battle as unknown as {
curOTMon: number;
playerUsedMoves: number[];
findMonInOTPartyToSwitchIntoBattle(): number;
checkAbleToSwitch(): number;
aiSwitchOrTryItem(): Generator<{ kind: string; text?: string }, boolean>;
},
};
};
it("sends out a trainer's next Pokémon with no random draw when a candidate is unthreatened", () => {
// Totodile's Water type is neutral against Bug Catcher Don's Caterpie.
const { options } = setup(15, [36, 1], 158);
const battle = new Battle(options),
{ internals } = atMenu(battle);
options.trainer!.party[0].hp = 0;
options.random = () => {
throw new Error("Unexpected random draw");
};
expect(internals.findMonInOTPartyToSwitchIntoBattle()).toBe(1);
});
it("draws BattleRandom & 7 when the player's types threaten every candidate", () => {
// Cyndaquil's Fire type is super effective against the other Caterpie.
const { options } = setup(15, [36, 1], 155);
const battle = new Battle(options),
{ internals } = atMenu(battle);
options.trainer!.party[0].hp = 0;
const draws = [0x0d, 0x08, 0x02, 0x09];
options.random = () => draws.shift()!;
// 5 and 0 (the fainted current Pokémon) and 2 (past the party) retry.
expect(internals.findMonInOTPartyToSwitchIntoBattle()).toBe(1);
expect(draws).toEqual([]);
});
it("switches out under SWITCH_SOMETIMES using the enemy move type for every matchup", () => {
const { options } = setup(15, [22, 2]); // Youngster Mikey: Pidgey, Rattata
const battle = new Battle(options),
{ internals } = atMenu(battle);
const WATER = 0x15, GROUND = 0x04, GRASS = 0x16;
// CheckTypeMatchup reads the enemy's move type (here Grass), so it is
// super effective against the enemy's Water type and the player's Water/Ground.
battle.player.mon.types = [WATER, GROUND];
battle.enemy.mon.types = [WATER, WATER];
battle.enemy.move = { ...battle.enemy.move, type: GRASS };
const rattata = options.trainer!.party[1];
// FindEnemyMonsWithAtLeastQuarterMaxHP's rotation: 16 HP gives 4, never
// above the maximum, while 1 HP gives $200.
rattata.hp = 16;
expect(internals.checkAbleToSwitch()).toBe(0);
rattata.hp = 1;
expect(internals.checkAbleToSwitch()).toBe(0x11);
battle.enemy.mon.hp = 3;
battle.enemy.mon.status = 1 << 3; // poisoned
const draws = [49];
options.random = () => draws.shift()!;
const texts: string[] = [];
const run = internals.aiSwitchOrTryItem();
let next = run.next();
for (; !next.done; next = run.next()) if (next.value.text) texts.push(next.value.text);
expect(next.value).toBe(true);
expect(texts.join(" ").replace(/\n/g, " ")).toMatch(/withdrew PIDGEY.*sent out RATTATA/);
expect(internals.curOTMon).toBe(1);
expect(battle.enemy.mon.hp).toBe(1);
// AI_Switch keeps the withdrawn Pokémon's HP and status in the party.
expect(options.trainer!.party[0]).toMatchObject({ hp: 3, status: 1 << 3 });
});
it("records the player's used moves for the switch AI", () => {
const { options } = setup(15, [22, 2]);
const battle = new Battle(options),
{ run, internals } = atMenu(battle);
expect(internals.playerUsedMoves).toEqual([0, 0, 0, 0]);
run.next({ action: "fight", move: 0 });
expect(internals.playerUsedMoves).toEqual([options.party[0].moves[0], 0, 0, 0]);
});
it("refuses the untranslated Smart AI and empty parties before mutating the party or Pokédex", () => {
for (const kind of ["empty", "ai"] as const) {
const { options } = setup();
if (kind === "party")
options.trainer!.party.push(structuredClone(options.trainer!.party[0]));
if (kind === "empty") options.trainer!.party = [];
else options.trainer!.aiFlags |= 1 << 4;
const before = structuredClone(options.party);
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);
+65
View File
@@ -61,3 +61,68 @@ test("trainer UI refuses RUN, wins against Joey and saves the reward", async ({
expect(state.pokedex.seen).toContain(19);
expect(state.events).toContain(256);
});
test("Route 30: Bug Catcher Don spots the player, walks over and sends out both Caterpie", async ({ page }) => {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch();
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle,
`const { route30FixtureWorld } = await import('/tests/trainer-fixture.ts');
const world = route30FixtureWorld(assets);`) });
});
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
await page.locator("#play").click();
await page.keyboard.down("ArrowUp");
await page.clock.runFor(70);
await page.keyboard.up("ArrowUp");
// Both send-outs print the same text, so only repeats in a row are dropped.
const messages: string[] = [];
const add = (text: string) => {
if (messages.at(-1) !== text) messages.push(text);
};
let dialogues = 0;
for (let guard = 0; guard < 120; guard++) {
if (await page.locator("#battle").isVisible()) {
add(await page.locator("#battle-text").innerText());
const menu = page.locator("#battle-menu");
const fight = menu.getByRole("button", { name: "FIGHT", exact: true });
if (await fight.count()) {
await fight.click();
await menu.getByRole("button", { name: /SCRATCH/ }).click();
} else if (await menu.getByRole("button", { name: /Continue/ }).count()) {
await menu.getByRole("button", { name: /Continue/ }).click();
} else await page.clock.runFor(1200);
} else if (await page.locator("#native-dialogue").isVisible()) {
dialogues++;
add(await page.locator("#dialogue-text").innerText());
await page.keyboard.press("KeyX");
await page.clock.runFor(500);
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
await page.clock.runFor(500);
} else break;
}
expect(dialogues).toBeGreaterThan(0);
await expect(page.locator("#battle")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "10");
const shown = messages.join("\n");
expect(shown).toContain("I found");
expect(shown.match(/sent out/g)?.length).toBe(2);
expect(shown).toContain("48");
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const path = (await (await download).path())!;
const record = JSON.parse(readFileSync(path, "utf8"));
const state = JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
expect(state).toMatchObject({ map: "26:1", x: 1, y: 10 });
expect(state.events).toContain(1336);
expect(state.objects).toContainEqual({ id: 5, x: 1, y: 9, direction: "down" });
expect(state.pokedex.seen).toContain(10);
});
+22
View File
@@ -1,5 +1,6 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world";
import { martFixtureWorld } from "./mart-fixture";
// Test-only script on an already accessible clerk. The actual trainer party,
@@ -28,3 +29,24 @@ export function trainerFixtureWorld(assets: CrystalAssets, tail = [0x60, 0x33, 0
return world;
}
/** Route 30 is enabled in production; only the start (below Bug Catcher
* Don's sight line, with a level 20 Totodile) is a test fixture. */
export function route30FixtureWorld(assets: CrystalAssets) {
let seed = 7;
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
});
const mon = generatePartyMon(assets.pokemon, {
species: 158, level: 20, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
});
mon.moves = [10, 0, 0, 0]; // Scratch
mon.pp = [35, 0, 0, 0];
world.state.party = [mon];
world.state.pokedex.seen.add(158);
world.state.pokedex.caught.add(158);
world.restore({ ...world.save(), map: "26:1", x: 1, y: 11, direction: "up" });
return world;
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { trainerWalkBuffer } from "../src/game/scripts/interpreter";
import { readMovement } from "../src/game/scripts/movement";
describe("TrainerWalkToPlayer", () => {
const decode = (buffer: number[]) => readMovement({ byte: (index) => buffer[index] }, 0);
it("only pauses when the player is adjacent", () => {
expect(trainerWalkBuffer({ x: 5, y: 5 }, { x: 5, y: 6 }, 1)).toEqual([0x3e, 0x47]);
expect(decode([0x3e, 0x47])).toEqual([{ kind: "sleep", ticks: 1 }]);
});
it("walks all but the last tile toward the player at normal speed", () => {
// step DOWN is movement_step | DOWN.
expect(trainerWalkBuffer({ x: 1, y: 7 }, { x: 1, y: 10 }, 3)).toEqual([0x3e, 0x0c, 0x0c, 0x47]);
expect(trainerWalkBuffer({ x: 9, y: 4 }, { x: 4, y: 4 }, 5)).toEqual([0x3e, 0x0e, 0x0e, 0x0e, 0x0e, 0x47]);
expect(trainerWalkBuffer({ x: 2, y: 4 }, { x: 4, y: 4 }, 2)).toEqual([0x3e, 0x0f, 0x47]);
expect(decode([0x3e, 0x0d, 0x47])).toEqual([
{ kind: "sleep", ticks: 1 },
{ kind: "step", direction: "up", ticks: 8, animate: true },
]);
});
it("follows ComputePathToWalkToPlayer off a straight line: the shorter axis first", () => {
expect(trainerWalkBuffer({ x: 0, y: 0 }, { x: 3, y: 1 }, 4)).toEqual([0x3e, 0x0c, 0x0f, 0x0f, 0x47]);
});
});