Open Mr. Pokémon's house, the first rival battle and the return to Elm
Checks / web (push) Canceled after 0s

Mr. Pokémon's ROM scene gives the Mystery Egg and Pokédex, heals the
party, sets the Cherrygrove spawn with blackoutmod and queues Elm's
call. Cherrygrove's rival scene runs both triggers and all three
RIVAL1_1 parties as a CANLOSE battle; reloadmap returns to the script.
Smart AI is accepted for effects with no AI_Smart_EffectHandlers entry
(Normal Hit, Attack Down, Defense Down) and still refuses others before
any random draw, including for reserve Pokémon.

Special calls read SpecialPhoneCallList: the entry's condition decides,
and the ROM's CheckSpecialPhoneCall.script and Script_ReceivePhoneCall
run its caller script through memcall, ending with HangUp's timed
"Click!". Ringing and the caller box are not presented yet.

In Elm's lab, NameRival shows the ROM's "RIVAL'S NAME?" title (the
prompt previously had no page and soft-locked) and applies InitName's
default to blank names. Handing over the Egg and the aide's Poké Balls
run from the ROM. Saves gain rivalName and specialPhoneCall.

The quest fixtures use a seeded RNG (a constant byte never ends the
damage-variation loop) and no longer carry the house's moved objects
into Cherrygrove.

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

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ flowchart LR
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build | | `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
| `reference/public` | Licensed emulator files for the reference build only | | `reference/public` | Licensed emulator files for the reference build only |
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, the weekday offset and DST flag; positions of objects that scripts moved; and the party, bag, Pokédex sets and money. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID. `GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, a queued special call, the rival's name, the weekday offset and DST flag; positions of objects that scripts moved; and the party, bag, Pokédex sets and money. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival. Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
+21 -2
View File
@@ -71,7 +71,7 @@ Native saves are version 3. Version 2 added scene values by owning map, phone co
`game/rom/trainers.ts` decodes `TrainerGroups`, `TrainerClassAttributes`, `TrainerClassNames`, `TrainerClassDVs` and map trainer headers. Tests cover all 542 ROM records across 67 classes; CAL2 construction refuses its SRAM-dependent Mystery Gift party. `game/rom/trainers.ts` decodes `TrainerGroups`, `TrainerClassAttributes`, `TrainerClassNames`, `TrainerClassDVs` and map trainer headers. Tests cover all 542 ROM records across 67 classes; CAL2 construction refuses its SRAM-dependent Mystery Gift party.
`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. `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 runs only for move effects that have no `AI_Smart_EffectHandlers` entry (Normal Hit, Attack Down, Defense Down; enough for the first rival). Other effects are refused before any random draw. Synthetic scoring vectors establish the tested branches; original-engine AI execution parity is still pending.
`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. `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.
@@ -87,7 +87,7 @@ A trainer sighting whose script is refused is not restarted until the player mov
## Next slice ## 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, and party, bag and Pokégear menus over the native models. Other specials stay refused until translated. Route 29, Cherrygrove, Route 30 and Mr. Pokémon's house are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle and Elm's special call. NPCs roam. Next: capture with the catching tutorial, then party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
## Reflect and Light Screen ## Reflect and Light Screen
@@ -101,3 +101,22 @@ and retains the original critical/stage rule. Safeguard and other screen
flags remain refused. Seven local-ROM tests cover move execution, counters, flags remain refused. Seven local-ROM tests cover move execution, counters,
PP, damage stats and send-out persistence; no screen-routine emulator parity PP, damage stats and send-out persistence; no screen-routine emulator parity
or physical-device validation is claimed. or physical-device validation is claimed.
## Mr. Pokémon, the first rival and special calls
`MrPokemonsHouse.asm`, `CherrygroveRivalScene*` and the `ElmsLab.asm` officer
and Egg scripts run from the ROM. Supporting translations: `blackoutmod`,
`reloadmap` (returns to the script; native presentation keeps the map),
`readvar VAR_SPECIALPHONECALL` and `memcall` (only through
`wCallerContact + PHONE_CONTACT_SCRIPT2_BANK`). Specials: `NameRival` (native
naming field, ROM title and `InitName` default), `FadeOutMusic`, and the
presentation-only fades. `CountStep`'s `CheckSpecialPhoneCall` reads the
queued `SpecialPhoneCallList` entry. It checks `SpecialCallOnlyWhenOutside`
or `SpecialCallWhereverYouAre`, then runs `CheckSpecialPhoneCall.script` with
that entry's caller script. `RingTwice_StartCall` and `InitCallReceiveDelay`
are accepted without effect, because ringing presentation and random calls are
not ported. `HangUp` shows "Click!" for its 140 frames. Local-ROM tests cover
the house, both rival triggers with all three parties, a rival loss, the call
indoors, outdoors and on reload, the officer's naming (including blank names
and reload at the naming screen), and the Egg and Poké Ball handovers. Browser
tests cover the house, the rival battle and the naming field.
+14 -5
View File
@@ -57,15 +57,15 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
- The battle engine gained `recoil` (Struggle). A local stress run of 360 Route 29 battles across all starters and times of day finishes without refusals. - The battle engine gained `recoil` (Struggle). A local stress run of 360 Route 29 battles across all starters and times of day finishes without refusals.
- Save format 3 gains `fruitTrees`, `dailyReset` and `lastSpawnMap`; older backups migrate by absence. - Save format 3 gains `fruitTrees`, `dailyReset` and `lastSpawnMap`; older backups migrate by absence.
- NPCs roam as `HandleNPCStep` does: random walks (Y, X, XY) and slow/fast spins from `SpriteMovementData`, `CanObjectMoveInDirection` (side walls, land, objects' current and previous tiles, radius stored plus one, screen edge), random sleeps, freezing during text and `applymovement`, and deletion/respawn at home beyond the object window. Wandering positions are not saved; objects start from home on reload. - NPCs roam as `HandleNPCStep` does: random walks (Y, X, XY) and slow/fast spins from `SpriteMovementData`, `CanObjectMoveInDirection` (side walls, land, objects' current and previous tiles, radius stored plus one, screen edge), random sleeps, freezing during text and `applymovement`, and deletion/respawn at home beyond the object window. Wandering positions are not saved; objects start from home on reload.
- Still refused: the catching tutorial (`catchtutorial`) and Cherrygrove's rival scene, both reachable only after the Mystery Egg errand. - Still refused: the catching tutorial (`catchtutorial`), reachable after the Mystery Egg errand.
## Implemented: first scripted trainer battle slice ## Implemented: first scripted trainer battle slice
- ROM decoder for 542 trainer records across 67 classes, all four party formats, class names, DVs, rewards and map trainer headers. SRAM-dependent Mystery Gift parties remain refused. - ROM decoder for 542 trainer records across 67 classes, all four party formats, class names, DVs, rewards and map trainer headers. SRAM-dependent Mystery Gift parties remain refused.
- 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. - 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 runs only for effects without a Smart handler (see the Mr. Pokémon section).
- 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. - 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. - 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 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. - Next: Smart AI handlers, trainer items and player switching.
## Implemented: Route 30 trainers ## Implemented: Route 30 trainers
@@ -73,7 +73,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
- New script commands: `loadtemptrainer`, `trainertext`, `trainerflagaction`, `scripttalkafter`, `endifjustbattled`, `checkjustbattled`, `writeobjectxy`, `encountermusic` (silent) and `loadmem` for `wRunningTrainerBattleScript`. - 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. - 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`. - 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. - Route 30 and its Berry house are enabled, and Mr. Pokémon's house since the section below. Route 31 stays 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 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`), now addressed by the screen increment below. - 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.
@@ -83,6 +83,15 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
- 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. - 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. - 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.
## Implemented: Mr. Pokémon's house and the return to Elm
- Mr. Pokémon's house is enabled. Its ROM scene gives the Mystery Egg and Pokédex, heals the party (`FadeOutToBlack`, `ReloadSpritesNoPalettes` and `FadeInFromBlack` are presentation-only), sets the Cherrygrove whiteout spawn with `blackoutmod` and queues Elm's call with `specialphonecall`. A refusal late in the scene rolls back the rewards, healing and Oak's exit.
- Special calls: after trainer, warp and coordinate events, a queued call runs if its `SpecialPhoneCallList` condition allows (Elm's only outdoors). It runs the ROM's `CheckSpecialPhoneCall.script` and `Script_ReceivePhoneCall`, with `memcall` into the entry's caller script and `HangUp`'s timed "Click!". Ringing tones and the caller name box are not presented yet. Queued calls are saved.
- Cherrygrove's rival scene runs both triggers and all three `RIVAL1_1` parties as a `BATTLETYPE_CANLOSE` battle. `reloadmap` returns to the script, and a loss continues the scene without whiteout or money loss. The name shows as "RIVAL ?", as in the original. Smart AI accepts the rival's Normal Hit, Attack Down and Defense Down moves, because `AI_Smart_EffectHandlers` has no entries for them. Other Smart effects are still refused before any random draw, including for reserve Pokémon.
- Elm's lab: the officer's `NameRival` opens the naming field with the ROM title "RIVAL'S NAME?". A blank name takes the ROM default, as `InitName` does. The rival name is saved and used by `<RIVAL>` text. Handing over the Egg and the aide's five Poké Balls run from the ROM.
- Save format 3 gained `rivalName` and `specialPhoneCall` (migrated by absence to "???" and 0).
- Next: the catching tutorial on Route 29 (`catchtutorial`) with capture; then player switching and items in battle.
## In progress: native battles ## 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, player switching, items in battle 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.
@@ -103,7 +112,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. 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, 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. Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle and the return to Elm run now. Next: capture and the catching tutorial; 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. 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.
+40
View File
@@ -249,3 +249,43 @@ Changes are uncommitted.
Next: Mr. Pokémon's house and the Mystery Egg; Smart AI before enabling the 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 rival; capture and the catching tutorial. Joey's post-Egg phone scripts remain
unsupported. unsupported.
## Mr. Pokémon and the return to Elm: 2026-09-24 (resumed from Codex)
Codex had enabled Mr. Pokémon's house and added `NameRival`, `blackoutmod`,
`reloadmap`, the queued Elm call and the no-handler Smart AI subset. It
stopped with all four quest browser tests failing and without docs. Claude
Code finished the increment:
- The rival scene needed `special FadeOutMusic` (audio only, now accepted).
- The quest fixture's constant `() => 0` RNG hung `BattleCommand_DamageVariation`,
which redraws until the byte is at least 217. The fixture now uses a seeded LCG.
Relocating to Cherrygrove had also carried the house's moved Mr. Pokémon and
Oak onto the Guide Gent and rival (ids 2 and 3), producing an unrestorable
save. The fixture now clears objects and starts off the Guide Gent's tile.
- The browser test expected `specialPhoneCall` 1; `SPECIALCALL_ROBBED` is 2.
- `NameRival` yielded a naming choice with zero pages, because `promptbutton`
had already flushed them. The dialogue never reached its last page, so the
officer scene soft-locked. It now shows the ROM's "RIVAL'S NAME?" and reads
the "SILVER" default from ROM. As in `InitName`, blank names of any number of
spaces take the default. The naming input's accessible label no longer says
"Nickname".
- Elm's call now runs the ROM's `CheckSpecialPhoneCall.script` →
`Script_ReceivePhoneCall` wrapper. It reads the condition and caller script
from `SpecialPhoneCallList` instead of hard-coding id 2, adds `memcall`, and
shows `HangUp`'s timed "Click!". Ringing and the caller name box are still
not presented.
- Added tests: the rival scene for all three starters and both triggers, a
`CANLOSE` loss, reload after each scene, the Elm call (indoors, outdoors,
mid-call reload), the officer naming (named, blank, all-space, reload at
the naming screen), the Egg handover and the aide's Poké Balls, plus a
browser naming-field test.
Checks: 191 unit tests with the local ROM (80 passed, 111 skipped without it);
50 Chromium/WebKit browser tests; native and reference builds; reference,
release and vendor checks; `git diff --check`. Not committed.
Next: capture and the Route 29 catching tutorial (`catchtutorial`), the
first remaining refusal in the opening quest. Then the ringing presentation,
player switching and items in battle. Joey's post-Egg phone scripts remain
unsupported.
+16
View File
@@ -87,6 +87,8 @@ const names = {
findItemInBallScript: "FindItemInBallScript", findItemInBallScript: "FindItemInBallScript",
caughtAskNicknameText: "CaughtAskNicknameText", caughtAskNicknameText: "CaughtAskNicknameText",
nicknameStrings: "NamingScreenJumptable.NicknameStrings", nicknameStrings: "NamingScreenJumptable.NicknameStrings",
rivalNameString: "NamingScreenJumptable.RivalNameString",
rivalDefaultName: "NameRival.DefaultName",
hiddenItemScript: "HiddenItemScript", hiddenItemScript: "HiddenItemScript",
talkToTrainerScript: "TalkToTrainerScript", talkToTrainerScript: "TalkToTrainerScript",
seenByTrainerScript: "SeenByTrainerScript", seenByTrainerScript: "SeenByTrainerScript",
@@ -131,6 +133,19 @@ const names = {
tryReceiveItem: "FindItemInBallScript.TryReceiveItem", tryReceiveItem: "FindItemInBallScript.TryReceiveItem",
giveItemDummy: "GiveItemScript_DummyFunction", giveItemDummy: "GiveItemScript_DummyFunction",
restartMapMusic: "RestartMapMusic", restartMapMusic: "RestartMapMusic",
fadeOutMusic: "FadeOutMusic",
fadeOutToBlack: "FadeOutToBlack",
fadeInFromBlack: "FadeInFromBlack",
reloadSpritesNoPalettes: "ReloadSpritesNoPalettes",
receiveSpecialPhoneCall: "CheckSpecialPhoneCall.script",
specialPhoneCallList: "SpecialPhoneCallList",
specialCallOnlyWhenOutside: "SpecialCallOnlyWhenOutside",
specialCallWhereverYouAre: "SpecialCallWhereverYouAre",
phoneClickText: "PhoneClickText",
ringTwiceStartCall: "RingTwice_StartCall",
hangUp: "HangUp",
initCallReceiveDelay: "InitCallReceiveDelay",
nameRival: "NameRival",
setDayOfWeek: "SetDayOfWeek", setDayOfWeek: "SetDayOfWeek",
initialSetDst: "InitialSetDSTFlag", initialSetDst: "InitialSetDSTFlag",
initialClearDst: "InitialClearDSTFlag", initialClearDst: "InitialClearDSTFlag",
@@ -170,6 +185,7 @@ const ramNames = {
movementBuffer: "wMovementBuffer", movementBuffer: "wMovementBuffer",
hiddenItemID: "wHiddenItemID", hiddenItemID: "wHiddenItemID",
runningTrainerBattleScript: "wRunningTrainerBattleScript", runningTrainerBattleScript: "wRunningTrainerBattleScript",
callerContact: "wCallerContact",
moneyTemp: "hMoneyTemp", moneyTemp: "hMoneyTemp",
}; };
const profile = readFileSync("src/game/rom/profile.ts", "utf8"); const profile = readFileSync("src/game/rom/profile.ts", "utf8");
+27 -10
View File
@@ -31,19 +31,32 @@ export interface TrainerAIInput {
// AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist, // AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist,
// Aggressive, Cautious, Status, Risky, then six actual AI_None entries. // Aggressive, Cautious, Status, Risky, then six actual AI_None entries.
// Smart (bit 4) is the only translated pointer still refused. Bits 10-15 // Bits 10-15 point to AI_None in the original and are valid no-ops.
// point to AI_None in the original and are valid no-ops. // AI_Smart_EffectHandlers has no entries for these effects: Tackle/Scratch,
const SUPPORTED = 0xffef; // Growl and Leer therefore leave scores and the random stream unchanged.
export function validateTrainerAI(weights: number): void { // Other Smart effects remain refused until their dispatch is translated.
if ( const SMART_NOOP_EFFECTS: ReadonlySet<number> = new Set([
!Number.isInteger(weights) || C.EFFECT_NORMAL_HIT,
weights < 0 || C.EFFECT_ATTACK_DOWN,
weights > 0xffff || C.EFFECT_DEFENSE_DOWN,
weights & ~SUPPORTED ]);
) export function validateTrainerAI(
weights: number,
moveEffects: readonly number[] = [],
): void {
if (!Number.isInteger(weights) || weights < 0 || weights > 0xffff)
throw new UnsupportedBattle( throw new UnsupportedBattle(
`Trainer AI layers 0x${weights.toString(16)} are not translated.`, `Trainer AI layers 0x${weights.toString(16)} are not translated.`,
); );
if (weights & 16) {
const unsupported = moveEffects.find(
(effect) => !SMART_NOOP_EFFECTS.has(effect),
);
if (unsupported !== undefined)
throw new UnsupportedBattle(
`Trainer Smart AI for move effect ${unsupported} is not translated.`,
);
}
} }
const has = (side: TrainerAISide, byte: number, bit: number) => const has = (side: TrainerAISide, byte: number, bit: number) =>
Boolean(side.sub[byte] & (1 << bit)); Boolean(side.sub[byte] & (1 << bit));
@@ -154,6 +167,10 @@ export function chooseTrainerMove(input: TrainerAIInput): {
"Trainer move AI requires at least one nonzero move.", "Trainer move AI requires at least one nonzero move.",
); );
const attributes = moves.map((move) => data.move(move)); const attributes = moves.map((move) => data.move(move));
validateTrainerAI(
weights,
attributes.map((move) => move.effect),
);
const scores: number[] = Array.from({ length: 4 }, (_, i) => const scores: number[] = Array.from({ length: 4 }, (_, i) =>
moves[i] === enemy.disabledMove || !(enemy.mon.pp[i] & 0x3f) ? 80 : 20, moves[i] === enemy.disabledMove || !(enemy.mon.pp[i] & 0x3f) ? 80 : 20,
); );
+3 -1
View File
@@ -531,7 +531,9 @@ export class Battle {
*run(): Gen<BattleOutcome> { *run(): Gen<BattleOutcome> {
const trainer = this.options.trainer; const trainer = this.options.trainer;
if (trainer) { if (trainer) {
validateTrainerAI(trainer.aiFlags); validateTrainerAI(trainer.aiFlags, trainer.party.flatMap((mon) =>
mon.moves.filter(Boolean).map((move) => this.options.data.move(move).effect),
));
if (trainer.party.length < 1 || trainer.party.length > 6) if (trainer.party.length < 1 || trainer.party.length > 6)
throw new UnsupportedBattle("Invalid trainer party."); throw new UnsupportedBattle("Invalid trainer party.");
if ( if (
+1
View File
@@ -9,6 +9,7 @@ export const C = {
EFFECT_DREAM_EATER: 8, EFFECT_DREAM_EATER: 8,
EFFECT_ALWAYS_HIT: 17, EFFECT_ALWAYS_HIT: 17,
EFFECT_ATTACK_DOWN: 18, EFFECT_ATTACK_DOWN: 18,
EFFECT_DEFENSE_DOWN: 19,
EFFECT_EVASION_DOWN: 24, EFFECT_EVASION_DOWN: 24,
EFFECT_BIDE: 26, EFFECT_BIDE: 26,
EFFECT_RAMPAGE: 27, EFFECT_RAMPAGE: 27,
+18
View File
@@ -27,6 +27,12 @@ export const SYMBOLS = {
standardScripts: 0xbc000, standardScripts: 0xbc000,
mapScenes: 0x4d01e, mapScenes: 0x4d01e,
specials: 0xc029, specials: 0xc029,
/** CheckSpecialPhoneCall.script: pause, then Script_ReceivePhoneCall. */
receiveSpecialPhoneCall: 0x90173,
specialPhoneCallList: 0x90627,
specialCallOnlyWhenOutside: 0x90188,
specialCallWhereverYouAre: 0x90197,
phoneClickText: 0x9032a,
emotes: 0x1444d, emotes: 0x1444d,
landmarks: 0x1ca8c3, landmarks: 0x1ca8c3,
weekdayNames: 0x909f2, weekdayNames: 0x909f2,
@@ -52,6 +58,8 @@ export const SYMBOLS = {
findItemInBallScript: 0x122ce, findItemInBallScript: 0x122ce,
caughtAskNicknameText: 0x4db44, caughtAskNicknameText: 0x4db44,
nicknameStrings: 0x11780, nicknameStrings: 0x11780,
rivalNameString: 0x117c3,
rivalDefaultName: 0xc2b2,
hiddenItemScript: 0x13625, hiddenItemScript: 0x13625,
talkToTrainerScript: 0xbe66a, talkToTrainerScript: 0xbe66a,
seenByTrainerScript: 0xbe675, seenByTrainerScript: 0xbe675,
@@ -97,18 +105,26 @@ export const NATIVE_ROUTINES = {
pickedFruitTree: 0x4405f, pickedFruitTree: 0x4405f,
trainerWalkToPlayer: 0x831e, trainerWalkToPlayer: 0x831e,
setMemEvent: 0x1364f, setMemEvent: 0x1364f,
ringTwiceStartCall: 0x9026f,
hangUp: 0x902eb,
initCallReceiveDelay: 0x113e5,
} as const; } as const;
/** Native handlers are selected by the routine a special index points to, /** Native handlers are selected by the routine a special index points to,
* so the ROM's own special table remains the source of truth. */ * so the ROM's own special table remains the source of truth. */
export const SPECIAL_ROUTINES = { export const SPECIAL_ROUTINES = {
nameRival: 0xc29d,
restartMapMusic: 0x3d47, restartMapMusic: 0x3d47,
fadeOutMusic: 0xc48f,
setDayOfWeek: 0x90913, setDayOfWeek: 0x90913,
initialSetDst: 0x90a54, initialSetDst: 0x90a54,
initialClearDst: 0x90a88, initialClearDst: 0x90a88,
toggleDecorationsVisibility: 0x27043, toggleDecorationsVisibility: 0x27043,
toggleMaptileDecorations: 0x26feb, toggleMaptileDecorations: 0x26feb,
fadeOutToWhite: 0x8c084, fadeOutToWhite: 0x8c084,
fadeOutToBlack: 0x8c0b6,
fadeInFromBlack: 0x8c0ab,
reloadSpritesNoPalettes: 0xd91,
healParty: 0xc658, healParty: 0xc658,
healMachineAnim: 0x12324, healMachineAnim: 0x12324,
checkPokerus: 0xc419, checkPokerus: 0xc419,
@@ -138,6 +154,7 @@ export const RAM = {
movementBuffer: 0xd007, movementBuffer: 0xd007,
hiddenItemID: 0xd040, hiddenItemID: 0xd040,
runningTrainerBattleScript: 0xd04d, runningTrainerBattleScript: 0xd04d,
callerContact: 0xd03f,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */ /** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3, moneyTemp: 0xffc3,
} as const; } as const;
@@ -310,5 +327,6 @@ export const NATIVE_MAPS: Record<string, string> = {
"26:7": "Guide Gent’s house", "26:7": "Guide Gent’s house",
"26:8": "Evolution speech house", "26:8": "Evolution speech house",
"26:9": "Berry house", "26:9": "Berry house",
"26:10": "Mr. Pokémon’s house",
}; };
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const; export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+65 -2
View File
@@ -62,9 +62,9 @@ export interface ScriptState {
* through a Pokémon Center door (EnterMapWarp's SetSpawn), used by * through a Pokémon Center door (EnterMapWarp's SetSpawn), used by
* GetWhiteoutSpawn. Null before any Center has been entered. */ * GetWhiteoutSpawn. Null before any Center has been entered. */
lastSpawnMap: string | null; lastSpawnMap: string | null;
/** wSpecialPhoneCallID's low byte (checkphonecall): nonzero while a call /** wSpecialPhoneCallID: nonzero while a saved story call is pending. */
* is pending. No ported content ever sets it, so it is not saved. */
specialPhoneCall: number; specialPhoneCall: number;
rivalName: string;
} }
export const emptyScriptState = (): ScriptState => ({ export const emptyScriptState = (): ScriptState => ({
events: new Set(), events: new Set(),
@@ -81,6 +81,7 @@ export const emptyScriptState = (): ScriptState => ({
dailyReset: null, dailyReset: null,
lastSpawnMap: null, lastSpawnMap: null,
specialPhoneCall: 0, specialPhoneCall: 0,
rivalName: "???",
}); });
export const copyScriptState = (state: ScriptState): ScriptState => ({ export const copyScriptState = (state: ScriptState): ScriptState => ({
events: new Set(state.events), events: new Set(state.events),
@@ -100,6 +101,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
dailyReset: state.dailyReset, dailyReset: state.dailyReset,
lastSpawnMap: state.lastSpawnMap, lastSpawnMap: state.lastSpawnMap,
specialPhoneCall: state.specialPhoneCall, specialPhoneCall: state.specialPhoneCall,
rivalName: state.rivalName,
}); });
/** Object operands keep the script's numbering: 0 is the player, 2+ are map /** Object operands keep the script's numbering: 0 is the player, 2+ are map
@@ -204,6 +206,8 @@ export class UnsupportedScript extends Error {
type Input = number | string | undefined; type Input = number | string | undefined;
/** ITEM_FROM_MEM: take the item from wScriptVar. */ /** ITEM_FROM_MEM: take the item from wScriptVar. */
const ITEM_FROM_MEM = 0xff; const ITEM_FROM_MEM = 0xff;
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
const PHONE_CONTACT_SCRIPT2_BANK = 9;
/** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm): /** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm):
* a one-tick sleep, then normal steps to the player's tile, dropping the * a one-tick sleep, then normal steps to the player's tile, dropping the
@@ -289,6 +293,9 @@ export class ScriptRun {
trainer?: { header: number; distance: number }; trainer?: { header: number; distance: number };
/** wHiddenItemData, copied from a BGEVENT_ITEM's hiddenitem data. */ /** wHiddenItemData, copied from a BGEVENT_ITEM's hiddenitem data. */
hiddenItem?: { event: number; item: number }; hiddenItem?: { event: number; item: number };
/** wCallerContact's second script, copied by CheckSpecialPhoneCall
* from the queued SpecialPhoneCallList entry. */
callerScript?: number;
} = {}, } = {},
) { ) {
this.state = copyScriptState(state); this.state = copyScriptState(state);
@@ -313,6 +320,7 @@ export class ScriptRun {
private text(): TextContext { private text(): TextContext {
return { return {
...this.host.text(), ...this.host.text(),
rival: this.state.rivalName,
strings: { ...this.host.text().strings, ...this.strings }, strings: { ...this.host.text().strings, ...this.strings },
numbers: { ...this.host.text().numbers, ...this.numbers }, numbers: { ...this.host.text().numbers, ...this.numbers },
}; };
@@ -406,6 +414,16 @@ export class ScriptRun {
p = address; p = address;
break; break;
} }
case 0x02: {
// memcall: only Script_ReceivePhoneCall's caller script is known.
const address = word();
const script = this.options.callerScript;
if (address !== RAM.callerContact + PHONE_CONTACT_SCRIPT2_BANK || script === undefined)
throw new UnsupportedScript(offset, opcode);
stack.push(p);
p = script;
break;
}
case 0x03: case 0x03:
p = near(); p = near();
break; break;
@@ -527,6 +545,18 @@ export class ScriptRun {
value = Number( value = Number(
receiveItem(data, this.state.bag, ball.item, ball.quantity), receiveItem(data, this.state.bag, ball.item, ball.quantity),
); );
} else if (routine === NATIVE_ROUTINES.hangUp) {
// HangUp_Beep prints "Click!", then seven 20-frame waits for the
// hang-up tones pass before closetext; no button is needed.
write(SYMBOLS.phoneClickText);
yield { kind: "text", pages: take(), holdTicks: 140 };
} else if (
routine === NATIVE_ROUTINES.ringTwiceStartCall ||
routine === NATIVE_ROUTINES.initCallReceiveDelay
) {
// The ringing tones and caller name box are not presented yet.
// InitCallReceiveDelay restarts the random-call timers, and
// random calls are not ported.
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never)) } else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
throw new UnsupportedScript(offset, opcode); throw new UnsupportedScript(offset, opcode);
break; break;
@@ -649,6 +679,14 @@ export class ScriptRun {
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript; if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
canLose = false; canLose = false;
break; break;
case 0x7b:
// MAPSETUP_RELOADMAP restores graphics/audio after battle without
// re-running callbacks or rebuilding objects. Native presentation
// already retains the map; clear wBattleScriptFlags equivalents.
wild = undefined;
trainer = undefined;
canLose = false;
break;
case 0x8a: { case 0x8a: {
byte(); // newloadmap MAPSETUP_WARP byte(); // newloadmap MAPSETUP_WARP
const entry = SYMBOLS.spawnPoints + spawn * 4; const entry = SYMBOLS.spawnPoints + spawn * 4;
@@ -663,6 +701,10 @@ export class ScriptRun {
case 0x1c: case 0x1c:
value = this.readVar(byte(), offset); value = this.readVar(byte(), offset);
break; break;
case 0x3b:
// blackoutmod writes wLastSpawnMapGroup/wLastSpawnMapNumber.
this.state.lastSpawnMap = `${byte()}:${byte()}`;
break;
case 0x9c: case 0x9c:
this.state.specialPhoneCall = word(); this.state.specialPhoneCall = word();
break; break;
@@ -1227,6 +1269,8 @@ export class ScriptRun {
return this.host.position().x; return this.host.position().x;
case 0x13: case 0x13:
return this.host.position().y; return this.host.position().y;
case 0x14:
return this.state.specialPhoneCall;
default: default:
throw new UnsupportedScript(offset, 0x1c, ` (variable ${id})`); throw new UnsupportedScript(offset, 0x1c, ` (variable ${id})`);
} }
@@ -1244,7 +1288,23 @@ export class ScriptRun {
): Generator<ScriptEffect, number | undefined, Input> { ): Generator<ScriptEffect, number | undefined, Input> {
const r = this.reader; const r = this.reader;
switch (routine) { switch (routine) {
case SPECIAL_ROUTINES.nameRival: {
// The naming screen's own title; any pending text was already shown.
const fallback = readString(r, SYMBOLS.rivalDefaultName, this.text());
const name = yield {
kind: "name",
pages: [...take(), readString(r, SYMBOLS.rivalNameString, this.text())],
current: fallback,
max: 7,
};
if (typeof name !== "string" || name.length > 7 || (name && !NICKNAME_PATTERN.test(name)))
throw new Error("Invalid rival name.");
// InitName: a name of zero or more spaces takes the default.
this.state.rivalName = /^ *$/.test(name) ? fallback : name;
return value;
}
case SPECIAL_ROUTINES.restartMapMusic: case SPECIAL_ROUTINES.restartMapMusic:
case SPECIAL_ROUTINES.fadeOutMusic:
case SPECIAL_ROUTINES.fadeOutToWhite: case SPECIAL_ROUTINES.fadeOutToWhite:
case SPECIAL_ROUTINES.warpToSpawnPoint: case SPECIAL_ROUTINES.warpToSpawnPoint:
case SPECIAL_ROUTINES.stubbedTrainerRankingsHealings: case SPECIAL_ROUTINES.stubbedTrainerRankingsHealings:
@@ -1253,6 +1313,9 @@ export class ScriptRun {
// Rankings feature (Pokémon News) was cut, leaving a dead stub. // Rankings feature (Pokémon News) was cut, leaving a dead stub.
return value; return value;
case SPECIAL_ROUTINES.healMachineAnim: case SPECIAL_ROUTINES.healMachineAnim:
case SPECIAL_ROUTINES.fadeOutToBlack:
case SPECIAL_ROUTINES.fadeInFromBlack:
case SPECIAL_ROUTINES.reloadSpritesNoPalettes:
// Graphics, palettes and sound only: wPartyCount/wScriptVar select // Graphics, palettes and sound only: wPartyCount/wScriptVar select
// an animation sequence but nothing persists past it. // an animation sequence but nothing persists past it.
return value; return value;
+46 -3
View File
@@ -50,6 +50,9 @@ export interface NativeSave {
variables: Record<number, number>; variables: Record<number, number>;
scenes: Record<string, number>; scenes: Record<string, number>;
phone: number[]; phone: number[];
/** Queued special call, persisted until its script completes. */
specialPhoneCall: number;
rivalName: string;
clock: { weekdayOffset: number; dst: boolean }; clock: { weekdayOffset: number; dst: boolean };
/** Current-map objects moved or turned by scripts, as saved object structs. */ /** Current-map objects moved or turned by scripts, as saved object structs. */
objects: { id: number; x: number; y: number; direction: Direction }[]; objects: { id: number; x: number; y: number; direction: Direction }[];
@@ -253,6 +256,10 @@ const WONT_DELETE = 1 << 1,
NOCLIP_OBJS = 1 << 6, NOCLIP_OBJS = 1 << 6,
SWIMMING = 1 << 5; SWIMMING = 1 << 5;
const OBJECTTYPE_TRAINER = 2; const OBJECTTYPE_TRAINER = 2;
/** Map environments (map_data_constants.asm) and the special call count. */
const TOWN = 1,
ROUTE = 2;
const NUM_SPECIALCALLS = 8;
/** STEP_SLOW: random walkers take 16 ticks per tile. */ /** STEP_SLOW: random walkers take 16 ticks per tile. */
const NPC_STEP_TICKS = 16; const NPC_STEP_TICKS = 16;
const isTimed = (effect: ScriptEffect | undefined) => const isTimed = (effect: ScriptEffect | undefined) =>
@@ -594,7 +601,7 @@ export class GameWorld {
let sourceState = this.state; let sourceState = this.state;
if ( if (
(target.tileset === 7 || target.tileset === 21) && // TILESET_POKECENTER, TILESET_POKECOM_CENTER (target.tileset === 7 || target.tileset === 21) && // TILESET_POKECENTER, TILESET_POKECOM_CENTER
(this.map.environment === 1 || this.map.environment === 2) // TOWN, ROUTE (this.map.environment === TOWN || this.map.environment === ROUTE)
) )
sourceState = { sourceState = {
...copyScriptState(sourceState), ...copyScriptState(sourceState),
@@ -689,7 +696,28 @@ export class GameWorld {
event.y === player.y, event.y === player.y,
); );
if (event) this.startScript(event.script, PLAYER, { committedPosition: previous }); if (event) this.startScript(event.script, PLAYER, { committedPosition: previous });
else this.randomEncounter(previous); else if (!this.checkSpecialPhoneCall(previous)) this.randomEncounter(previous);
}
/** CountStep / CheckSpecialPhoneCall: a queued call waits for a step
* after trainer, warp and coordinate events, before encounters. Its
* SpecialPhoneCallList entry supplies the condition and caller script,
* which runs inside the ROM's receive-call script; the caller script
* commits the cleared queue and any story flag together. */
private checkSpecialPhoneCall(previous: { x: number; y: number }): boolean {
const id = this.state.specialPhoneCall;
if (id < 1 || id > NUM_SPECIALCALLS) return false;
const r = this.assets.reader,
entry = SYMBOLS.specialPhoneCallList + (id - 1) * 6;
const condition = r.near(entry, r.bank(entry));
if (condition === SYMBOLS.specialCallOnlyWhenOutside) {
if (this.map.environment !== TOWN && this.map.environment !== ROUTE) return false;
} else if (condition !== SYMBOLS.specialCallWhereverYouAre) return false;
this.startScript(SYMBOLS.receiveSpecialPhoneCall, PLAYER, {
committedPosition: previous,
callerScript: r.far(entry + 3),
});
return true;
} }
/** RandomEncounter: cooldown, grass, the map's rate, then a slot. */ /** RandomEncounter: cooldown, grass, the map's rate, then a slot. */
@@ -822,6 +850,7 @@ export class GameWorld {
deviceWeekday: () => this.now().getDay(), deviceWeekday: () => this.now().getDay(),
text: () => ({ text: () => ({
...defaultTextContext(this.name), ...defaultTextContext(this.name),
rival: this.state.rivalName,
weekday: this.weekdayName(this.clock().weekday), weekday: this.weekdayName(this.clock().weekday),
}), }),
data: this.assets.pokemon, data: this.assets.pokemon,
@@ -862,6 +891,7 @@ export class GameWorld {
trainer, trainer,
hiddenItem, hiddenItem,
sighted, sighted,
callerScript,
}: { }: {
committedPosition?: { x: number; y: number }; committedPosition?: { x: number; y: number };
scene?: boolean; scene?: boolean;
@@ -870,6 +900,7 @@ export class GameWorld {
trainer?: { header: number; distance: number }; trainer?: { header: number; distance: number };
hiddenItem?: { event: number; item: number }; hiddenItem?: { event: number; item: number };
sighted?: number; sighted?: number;
callerScript?: number;
} = {}, } = {},
) { ) {
const committed = this.save(); const committed = this.save();
@@ -890,6 +921,7 @@ export class GameWorld {
itemBall, itemBall,
trainer, trainer,
hiddenItem, hiddenItem,
callerScript,
}, },
), ),
committed, committed,
@@ -1828,6 +1860,8 @@ export class GameWorld {
variables: { ...state.variables }, variables: { ...state.variables },
scenes: { ...state.scenes }, scenes: { ...state.scenes },
phone: [...state.phone], phone: [...state.phone],
specialPhoneCall: state.specialPhoneCall,
rivalName: state.rivalName,
clock: { ...state.clock }, clock: { ...state.clock },
objects: [...this.actors] objects: [...this.actors]
.filter(([, actor]) => actor.moved) .filter(([, actor]) => actor.moved)
@@ -1893,6 +1927,10 @@ export class GameWorld {
Object.keys(save.scenes).some( Object.keys(save.scenes).some(
(id) => this.assets.sceneOwner(id) !== id, (id) => this.assets.sceneOwner(id) !== id,
) || ) ||
!integer(save.specialPhoneCall, 0, 0xffff) ||
typeof save.rivalName !== "string" ||
save.rivalName.length > 7 ||
!NICKNAME_PATTERN.test(save.rivalName) ||
!Array.isArray(save.phone) || !Array.isArray(save.phone) ||
save.phone.length > 10 || save.phone.length > 10 ||
!save.phone.every((id) => integer(id, 0, 255)) || !save.phone.every((id) => integer(id, 0, 255)) ||
@@ -1968,7 +2006,8 @@ export class GameWorld {
fruitTrees: new Set(save.fruitTrees), fruitTrees: new Set(save.fruitTrees),
dailyReset: save.dailyReset, dailyReset: save.dailyReset,
lastSpawnMap: save.lastSpawnMap, lastSpawnMap: save.lastSpawnMap,
specialPhoneCall: 0, specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
}; };
let entry; let entry;
try { try {
@@ -2034,6 +2073,8 @@ export function migrateSave(
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }), ...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }), ...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }), ...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
...(!Object.hasOwn(save, "specialPhoneCall") && { specialPhoneCall: 0 }),
...(!Object.hasOwn(save, "rivalName") && { rivalName: "???" }),
} as unknown as NativeSave; } as unknown as NativeSave;
} }
if (save.version !== 1 && save.version !== 2) return undefined; if (save.version !== 1 && save.version !== 2) return undefined;
@@ -2055,6 +2096,8 @@ export function migrateSave(
bag: emptyBag(), bag: emptyBag(),
pokedex: { seen: [], caught: [] }, pokedex: { seen: [], caught: [] },
lastSpawnMap: null, lastSpawnMap: null,
specialPhoneCall: 0,
rivalName: "???",
money: C.START_MONEY, money: C.START_MONEY,
fruitTrees: [], fruitTrees: [],
dailyReset: null, dailyReset: null,
+1 -1
View File
@@ -132,7 +132,7 @@ function showChoice(choice: Choice | undefined) {
const input = document.createElement("input"); const input = document.createElement("input");
input.maxLength = choice.max; input.maxLength = choice.max;
input.placeholder = choice.current; input.placeholder = choice.current;
input.setAttribute("aria-label", `Nickname for ${choice.current}`); input.setAttribute("aria-label", `Name (empty keeps ${choice.current})`);
input.autocomplete = "off"; input.autocomplete = "off";
input.spellcheck = false; input.spellcheck = false;
const submit = () => { const submit = () => {
+37 -3
View File
@@ -79,10 +79,44 @@ function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput {
} }
describe("trainer AI native scoring", () => { describe("trainer AI native scoring", () => {
it("rejects the untranslated Smart layer, including Rival1, before consuming randomness", () => { it("accepts all defined layer bits and rejects invalid layer words", () => {
for (const weights of [16, 0x3d3, -1, 65536]) for (const weights of [-1, 65536, 1.5, NaN])
expect(() => validateTrainerAI(weights)).toThrow(/not translated/); expect(() => validateTrainerAI(weights)).toThrow(/not translated/);
expect(() => validateTrainerAI(0xffef)).not.toThrow(); for (const weights of [16, 0x3d3, 0xffff])
expect(() => validateTrainerAI(weights)).not.toThrow();
});
it.each([C.EFFECT_ATTACK_DOWN, C.EFFECT_DEFENSE_DOWN])(
"Smart leaves the first rival's attack and stat-lowering effect %i unchanged",
(effect) => {
const input = fixture([move(1), move(2, effect, 0)], [3, 1]);
input.weights = 16;
expect(chooseTrainerMove(input)).toEqual({
move: 2,
index: 1,
scores: [20, 20, 80, 80],
});
},
);
it("refuses untranslated Smart effects before any earlier layer consumes randomness", () => {
const input = fixture(
[move(1, C.EFFECT_ATTACK_DOWN, 0), move(2, C.EFFECT_REFLECT, 0)],
[],
);
input.weights = 0x3d3;
input.enemy.mon.pp[1] = 0; // Exhaustion does not bypass scoring or validation.
expect(() => chooseTrainerMove(input)).toThrow(/Smart AI for move effect/);
});
it("validates Smart effects across a supplied full trainer party", () => {
expect(() =>
validateTrainerAI(0x3d3, [
C.EFFECT_NORMAL_HIT,
C.EFFECT_ATTACK_DOWN,
C.EFFECT_DEFENSE_DOWN,
]),
).not.toThrow();
expect(() =>
validateTrainerAI(0x3d3, [C.EFFECT_NORMAL_HIT, C.EFFECT_SLEEP]),
).toThrow(/Smart AI/);
}); });
it("uses masked random slots and retries blanks without first-slot tie bias", () => { it("uses masked random slots and retries blanks without first-slot tie bias", () => {
const input = fixture(undefined, [3, 6, 253]); const input = fixture(undefined, [3, 6, 253]);
+303
View File
@@ -0,0 +1,303 @@
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";
import { generatePartyMon } from "../src/game/party/pokemon";
import { settle, walkTo } from "./world-driver";
import { questFixtureWorld, questLabFixtureWorld, questRivalFixtureWorld } from "./quest-fixture";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const HOUSE = "26:10", EGG = 0x45;
describe.skipIf(!existsSync(romPath))("Mr. Pokémon's house with the local ROM", () => {
function fixture(species = 155, refuseHealing = false) {
const bytes = new Uint8Array(readFileSync(romPath));
// FadeInFromBlackSpecial's table entry: refuse the routine after rewards
// and healing to check the complete interaction's rollback boundary.
if (refuseHealing) bytes.set([0, 0, 0x40], 0xc0bf);
const assets = new CrystalAssets(bytes);
const world = new GameWorld(assets, () => new Date("2026-09-24T12:00:00"));
const mon = generatePartyMon(assets.pokemon, {
species, level: 5, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
});
mon.hp = 1;
mon.status = 8;
mon.pp[0] = 0;
world.state.party = [mon];
world.state.events.add(species === 155 ? 27 : species === 158 ? 28 : 29);
world.state.events.add(1794);
world.state.events.add(1939);
world.restore({ ...world.save(), map: "26:1", x: 17, y: 6, direction: "up" });
expect(world.move("up")).toBe(true);
expect(world.mapId).toBe(HOUSE);
return world;
}
it.each([[155, 1601], [158, 1602], [152, 1600]])("gives the Egg and Pokédex, heals starter %i, and sets the return quest", (species, stolenBall) => {
const world = fixture(species);
const pages = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(pages).toContain("MYSTERY EGG");
expect(pages).toContain("DEX");
expect(world.state.bag.keyItems).toEqual([EGG]);
expect(world.state.flags.has(11)).toBe(true);
for (const event of [30, 1737, 1725, 1938, stolenBall])
expect(world.state.events.has(event), String(event)).toBe(true);
for (const event of [1794, 1939]) expect(world.state.events.has(event)).toBe(false);
expect(world.state.lastSpawnMap).toBe("26:3");
expect(world.state.scenes).toMatchObject({ [HOUSE]: 1, "26:3": 1, "24:5": 3 });
expect(world.state.specialPhoneCall).toBe(2);
expect(world.objects().some((object) => object.id === 3)).toBe(false);
const mon = world.state.party[0];
expect(mon.hp).toBe(mon.stats[0]);
expect(mon.status).toBe(0);
expect(mon.pp[0]).toBe(world.assets.pokemon.move(mon.moves[0]).pp);
// The completed scene and Oak's removal survive reload without replay.
world.restore(world.save());
expect(settle(world)).toEqual([]);
expect(world.objects().some((object) => object.id === 3)).toBe(false);
world.interact();
expect(settle(world).join("\n")).toContain("depending on");
expect(world.state.bag.keyItems).toEqual([EGG]);
});
it("reloads during Oak's speech from committed state and replays rewards once", () => {
const world = fixture();
for (let tick = 0; tick < 3000; tick++) {
if (world.dialogue?.pages[world.dialogue.index].includes("OAK: Aha")) break;
if (world.dialogue) world.advanceDialogue();
else world.tick();
}
expect(world.dialogue?.pages[world.dialogue.index]).toContain("OAK: Aha");
const saved = world.save();
expect(saved.bag.keyItems).toEqual([]);
expect(saved.events).not.toContain(30);
world.restore(saved);
expect(settle(world).join("\n")).toContain("MYSTERY EGG");
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bag.keyItems).toEqual([EGG]);
});
it("rolls back rewards, healing and Oak's departure when a late command is refused", () => {
const world = fixture(155, true);
const before = world.save();
for (let tick = 0; tick < 5000 && !world.notice; tick++) {
if (world.dialogue) world.advanceDialogue();
else world.tick();
}
expect(world.notice).toContain("not been ported");
expect(world.state.bag.keyItems).toEqual([]);
expect(world.state.party[0]).toEqual(before.party[0]);
expect(world.state.events.has(30)).toBe(false);
expect(world.state.events.has(1737)).toBe(false);
expect(world.state.lastSpawnMap).toBe(before.lastSpawnMap);
expect(world.state.flags.has(11)).toBe(false);
expect(world.objects().find((object) => object.id === 3)).toMatchObject({ x: 6, y: 5 });
expect(world.save()).toMatchObject({ x: before.x, y: before.y });
});
});
describe.skipIf(!existsSync(romPath))("Cherrygrove's first rival battle with the local ROM", () => {
const RIVAL = 3; // CHERRYGROVECITY_RIVAL, after the Guide Gent
let assets: CrystalAssets;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
});
/** Play through scripts and battles, fighting with the first move. */
const drive = (world: GameWorld) => {
const shown: string[] = [];
for (let tick = 0; tick < 50_000; tick++) {
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 === "stats" || view.message?.prompt) world.interact();
else world.tick();
continue;
}
if (world.dialogue) {
shown.push(world.dialogue.pages[world.dialogue.index]);
if (world.dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
continue;
}
if (!world.busy) return shown.join("\n");
world.tick();
}
throw new Error(`The world did not settle: ${world.diagnostic}`);
};
// loadtrainer RIVAL1, RIVAL1_1_<the starter Elm kept>.
it.each([[158, 152], [152, 155], [155, 158]])("with starter %i, battles the rival's %i and commits the exit", (species, rivalSpecies) => {
const world = questRivalFixtureWorld(assets, species);
const money = world.state.money;
expect(world.move("right")).toBe(true);
const shown = drive(world);
expect(world.notice, world.diagnostic).toBe("");
// TrainerClassNames' "RIVAL" and Rival1Group's placeholder name "?".
expect(shown).toContain("RIVAL ?\nwas defeated!");
expect(shown).toContain("My name's ???.");
expect(world.state.pokedex.seen.has(rivalSpecies)).toBe(true);
expect(world.state.money).toBe(money + 300);
expect(world.state.scenes["26:3"]).toBe(0); // SCENE_CHERRYGROVECITY_NOOP
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
const mon = world.state.party[0];
expect(mon.hp).toBe(mon.stats[0]);
// The finished scene does not replay after reload.
world.restore(world.save());
expect(drive(world)).toBe("");
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
});
it("runs CherrygroveRivalSceneNorth from the east as well", () => {
const world = questRivalFixtureWorld(assets);
world.restore({ ...world.save(), x: 34, y: 6, direction: "left" });
expect(world.move("left")).toBe(true);
expect(drive(world)).toContain("RIVAL ?\nwas defeated!");
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.scenes["26:3"]).toBe(0);
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
world.restore(world.save());
});
it("continues the scene after a loss without whiting out (BATTLETYPE_CANLOSE)", () => {
const world = questRivalFixtureWorld(assets);
const mon = world.state.party[0];
mon.hp = 1;
mon.moves = [43, 0, 0, 0]; // Leer cannot win.
mon.pp = [30, 0, 0, 0];
const money = world.state.money;
expect(world.move("right")).toBe(true);
const shown = drive(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("fainted!");
expect(shown).toContain("waste of time");
expect(world.mapId).toBe("26:3");
expect(world.state.money).toBe(money);
expect(world.state.scenes["26:3"]).toBe(0);
expect(world.state.party[0].hp).toBe(world.state.party[0].stats[0]);
});
});
describe.skipIf(!existsSync(romPath))("Elm's robbed call with the local ROM", () => {
const ELM_CALLED = 67; // EVENT_ELM_CALLED_ABOUT_STOLEN_POKEMON
let assets: CrystalAssets;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
});
const afterHouse = () => {
const world = questFixtureWorld(assets);
expect(world.move("up")).toBe(true);
settle(world);
expect(world.state.specialPhoneCall).toBe(2); // SPECIALCALL_ROBBED
return world;
};
it("waits indoors, then rings on the next outdoor step and ends with the hang-up", () => {
const world = afterHouse();
// SpecialCallOnlyWhenOutside: a step inside the house does not ring.
expect(world.move("left") || world.move("right")).toBe(true);
expect(settle(world)).toEqual([]);
expect(world.state.specialPhoneCall).toBe(2);
world.restore({ ...world.save(), map: "26:1", x: 17, y: 8, direction: "down", objects: [] });
expect(world.move("down")).toBe(true);
const pages = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(pages[0]).toContain("It's a disaster!");
expect(pages.at(-1)).toBe("Click!");
expect(world.state.specialPhoneCall).toBe(0);
expect(world.state.events.has(ELM_CALLED)).toBe(true);
world.restore(world.save());
expect(world.move("down")).toBe(true);
expect(settle(world)).toEqual([]);
});
it("replays the whole call after a reload mid-call, from the committed step", () => {
const world = afterHouse();
world.restore({ ...world.save(), map: "26:1", x: 17, y: 8, direction: "down", objects: [] });
expect(world.move("down")).toBe(true);
for (let tick = 0; tick < 500 && !world.dialogue; tick++) world.tick();
expect(world.dialogue?.pages[0]).toContain("disaster");
const saved = world.save();
expect(saved.specialPhoneCall).toBe(2);
expect(saved.events).not.toContain(ELM_CALLED);
expect(saved).toMatchObject({ x: 17, y: 8 });
world.restore(saved);
expect(world.move("down")).toBe(true);
expect(settle(world).at(-1)).toBe("Click!");
expect(world.state.specialPhoneCall).toBe(0);
});
});
describe.skipIf(!existsSync(romPath))("the return to Elm's lab with the local ROM", () => {
const LAB = "24:5", OFFICER = 7, ELM = 2; // ELMSLAB_OFFICER, ELMSLAB_ELM
let assets: CrystalAssets;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
});
/** Enter the lab and step onto MeetCopScript's tile, answering the naming screen. */
const meetOfficer = (name: string) => {
const world = questLabFixtureWorld(assets);
expect(world.move("up")).toBe(true);
expect(world.mapId).toBe(LAB);
walkTo(world, 4, 6);
expect(world.move("up")).toBe(true);
return { world, pages: settle(world, [name]) };
};
it("names the rival on the naming screen and remembers it across reload", () => {
const { world, pages } = meetOfficer("BLUE");
expect(world.notice, world.diagnostic).toBe("");
expect(pages).toContain("RIVAL'S NAME?");
expect(pages).toContain("OK! So BLUE\nwas his name.");
expect(world.state.rivalName).toBe("BLUE");
expect(world.state.scenes[LAB]).toBe(2); // SCENE_ELMSLAB_NOOP
expect(world.objects().some((object) => object.id === OFFICER)).toBe(false);
world.restore(world.save());
expect(world.state.rivalName).toBe("BLUE");
expect(world.objects().some((object) => object.id === OFFICER)).toBe(false);
});
it.each(["", " "])("InitName gives the blank name %j the ROM default", (name) => {
const { world, pages } = meetOfficer(name);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.rivalName).toBe("SILVER");
expect(pages).toContain("OK! So SILVER\nwas his name.");
});
it("replays the officer scene from the committed step after a reload at the naming screen", () => {
const world = questLabFixtureWorld(assets);
world.move("up");
walkTo(world, 4, 6);
world.move("up");
expect(settle(world)).toContain("RIVAL'S NAME?"); // waits: no name queued
const saved = world.save();
expect(saved.rivalName).toBe("???");
expect(saved).toMatchObject({ x: 4, y: 6 });
world.restore(saved);
expect(world.move("up")).toBe(true);
settle(world, ["GARY"]);
expect(world.state.rivalName).toBe("GARY");
});
it("hands the Egg to Elm, then the aide gives five Poké Balls", () => {
const { world } = meetOfficer("BLUE");
walkTo(world, 5, 3);
world.move("up"); // Elm stands at 5,2: this only turns the player.
world.interact();
const elm = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(elm).toContain("handed\nthe MYSTERY EGG");
expect(world.state.bag.keyItems).toEqual([]);
expect(world.state.scenes[LAB]).toBe(6); // SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS
expect(world.state.scenes["24:3"]).toBe(1); // SCENE_ROUTE29_CATCH_TUTORIAL
const aide = walkTo(world, 4, 8).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(aide).toContain("received\nPOKé BALL.");
expect(world.state.bag.balls).toEqual([{ item: 5, quantity: 5 }]); // POKE_BALL
world.restore(world.save());
expect(world.state.bag.balls).toEqual([{ item: 5, quantity: 5 }]);
expect(world.objects().some((object) => object.id === ELM)).toBe(true);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc");
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."));
async function start(
page: Page,
fixture: "questFixtureWorld" | "questRivalFixtureWorld" | "questLabFixtureWorld",
) {
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 { ${fixture} } = await import('/tests/quest-fixture.ts');
const world = ${fixture}(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();
}
async function step(page: Page, direction: string) {
await page.keyboard.down(`Arrow${direction}`);
await page.clock.runFor(70);
await page.keyboard.up(`Arrow${direction}`);
await page.clock.runFor(300);
}
async function drive(page: Page, name = "") {
const messages: string[] = [];
for (let guard = 0; guard < 180; guard++) {
if (await page.locator("#battle").isVisible()) {
messages.push(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("#dialogue-choice input").isVisible()) {
messages.push(await page.locator("#dialogue-text").innerText());
const input = page.locator("#dialogue-choice input");
await expect(input).toBeFocused();
await input.fill(name);
await input.press("Enter");
await page.clock.runFor(500);
} else if (await page.locator("#native-dialogue").isVisible()) {
messages.push(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 {
await expect(page.locator("#game-notice")).toHaveText("");
return messages.join("\n");
}
}
throw new Error("Quest scene did not finish.");
}
async function backup(page: Page) {
await page.locator("#tab-saves").click();
const downloaded = page.waitForEvent("download");
await page.locator("#backup").click();
const path = (await (await downloaded).path())!;
const record = JSON.parse(readFileSync(path, "utf8"));
return { path, state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")) };
}
test("Mr. Pokémon's ROM scene grants the Egg and Pokédex, heals and survives reload", async ({ page }) => {
await start(page, "questFixtureWorld");
await step(page, "Up");
await expect(page.locator("#game-screen")).toHaveAttribute("data-map", "26:10");
const shown = await drive(page);
expect(shown).toContain("MYSTERY EGG");
expect(shown).toContain("POKéDEX");
const { path, state } = await backup(page);
expect(state.events).toContain(30);
expect(state.bag.keyItems).toContain(0x45);
expect(state.scenes).toMatchObject({ "26:10": 1, "26:3": 1, "24:5": 3 });
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
expect(state.specialPhoneCall).toBe(2); // SPECIALCALL_ROBBED
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(path);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
const restored = await backup(page);
expect(restored.state).toEqual(state);
});
test("the real Cherrygrove return scene battles the rival and commits its exit", async ({ page }) => {
await start(page, "questRivalFixtureWorld");
await drive(page); // a pending outdoor call may run before the encounter
await step(page, "Right");
const shown = await drive(page);
expect(shown).toContain("CHIKORITA");
expect(shown).toContain("???");
await expect(page.locator("#battle")).toBeHidden();
const { state } = await backup(page);
expect(state.scenes["26:3"]).toBe(0);
expect(state.pokedex.seen).toContain(152);
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
expect(state.events).toContain(30);
});
test("the officer in Elm's lab names the rival through the naming field", async ({ page }) => {
await start(page, "questLabFixtureWorld");
await step(page, "Up");
await expect(page.locator("#game-screen")).toHaveAttribute("data-map", "24:5");
await drive(page);
for (let y = 11; y > 5; y--) await step(page, "Up");
const shown = await drive(page, "BLUE");
expect(shown).toContain("RIVAL'S NAME?");
expect(shown).toContain("BLUE");
const { state } = await backup(page);
expect(state.rivalName).toBe("BLUE");
expect(state.scenes["24:5"]).toBe(2);
});
+60
View File
@@ -0,0 +1,60 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world";
import { settle } from "./world-driver";
// EVENT_GOT_CYNDAQUIL/TOTODILE/CHIKORITA_FROM_ELM.
const STARTER_EVENTS: Record<number, number> = { 155: 27, 158: 28, 152: 29 };
/** Only the starting position and earlier starter choice are fixtures.
* The house rewards and return scenes come from the untouched ROM scripts. */
export function questFixtureWorld(assets: CrystalAssets, species = 158) {
// A seeded Random: a constant byte never ends BattleRandom retry loops
// such as BattleCommand_DamageVariation's.
let seed = 1;
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, level: 20, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
});
mon.moves = [10, 0, 0, 0];
mon.pp = [35, 0, 0, 0];
mon.hp = 1; // Mr. Pokémon must actually heal the party.
world.state.party = [mon];
world.state.pokedex.seen.add(species);
world.state.pokedex.caught.add(species);
world.state.events.add(26); // GOT_A_POKEMON_FROM_ELM
world.state.events.add(STARTER_EVENTS[species]);
world.restore({ ...world.save(), map: "26:1", x: 17, y: 6, direction: "up" });
return world;
}
/** Independent rival UI fixture, derived from completing the actual house
* scene, then relocated to the city's return trigger. This is not a full walk. */
export function questRivalFixtureWorld(assets: CrystalAssets, species = 158) {
const world = questFixtureWorld(assets, species);
if (!world.move("up")) throw new Error("Could not enter Mr. Pokémon's house.");
settle(world);
if (world.notice || !world.state.events.has(30))
throw new Error(`House fixture did not complete: ${world.diagnostic}`);
// The house's moved objects (Mr. Pokémon, Oak) must not carry over. The
// Guide Gent still stands at 32,6; one step right reaches 33,7's trigger.
world.restore({ ...world.save(), map: "26:3", x: 32, y: 7, direction: "right", objects: [] });
return world;
}
/** The house scene completed for real, then relocated below Elm's lab door
* in New Bark Town. Elm's robbed call stays queued: the door step warps,
* and calls do not ring indoors. */
export function questLabFixtureWorld(assets: CrystalAssets) {
const world = questFixtureWorld(assets);
if (!world.move("up")) throw new Error("Could not enter Mr. Pokémon's house.");
settle(world);
if (world.notice || !world.state.events.has(30))
throw new Error(`House fixture did not complete: ${world.diagnostic}`);
world.restore({ ...world.save(), map: "24:4", x: 6, y: 4, direction: "up", objects: [] });
return world;
}
+5 -3
View File
@@ -172,7 +172,7 @@ describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", ()
expect(potions()).toBe(1); 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", () => { it("runs the Route 30 Pokémon battle scene and the Berry house, and enters Mr. Pokémon's house", () => {
const world = fixture({ x: 5, y: 27, direction: "up" }); const world = fixture({ x: 5, y: 27, direction: "up" });
world.interact(); world.interact();
expect(settle(world).length).toBeGreaterThan(1); expect(settle(world).length).toBeGreaterThan(1);
@@ -191,7 +191,9 @@ describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", ()
expect(world.state.bag.items.length).toBe(berry + 1); expect(world.state.bag.items.length).toBe(berry + 1);
world.restore({ ...world.save(), map: ROUTE_30, x: 17, y: 6, direction: "up" }); world.restore({ ...world.save(), map: ROUTE_30, x: 17, y: 6, direction: "up" });
expect(world.move("up")).toBe(false); expect(world.move("up")).toBe(true);
expect(world.notice).toMatch(/not ported/); settle(world);
expect(world.mapId).toBe("26:10");
expect(world.notice, world.diagnostic).toBe("");
}); });
}); });
+25 -3
View File
@@ -255,15 +255,37 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
run.next({ action: "fight", move: 0 }); run.next({ action: "fight", move: 0 });
expect(internals.playerUsedMoves).toEqual([options.party[0].moves[0], 0, 0, 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", () => { it.each([1, 2, 3])(
for (const kind of ["empty", "ai"] as const) { "completes first rival starter variant %i with its ROM AI flags",
(id) => {
const { options } = setup(15, [9, id]);
expect(options.trainer!.aiFlags).toBe(0x3d3);
const result = finish(new Battle(options));
expect(result.result).toBe(0);
expect(result.prizeMoney).toBe(300);
expect(result.texts).toContain("was defeated!");
},
);
it("refuses untranslated Smart effects, including reserves, and empty parties before mutation", () => {
for (const kind of ["empty", "ai", "reserve"] as const) {
const { options } = setup(); const { options } = setup();
if (kind === "empty") options.trainer!.party = []; if (kind === "empty") options.trainer!.party = [];
else options.trainer!.aiFlags |= 1 << 4; else {
options.trainer!.aiFlags |= 1 << 4;
const unsupported = structuredClone(options.trainer!.party[0]);
unsupported.moves = [115, 0, 0, 0]; // Reflect has an untranslated Smart handler.
if (kind === "reserve") options.trainer!.party.push(unsupported);
else options.trainer!.party[0] = unsupported;
}
const before = structuredClone(options.party); const before = structuredClone(options.party);
const enemyBefore = structuredClone(options.trainer!.party);
let draws = 0;
options.random = () => { draws++; return 0; };
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle); expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);
expect(options.party).toEqual(before); expect(options.party).toEqual(before);
expect(options.trainer!.party).toEqual(enemyBefore);
expect(options.pokedex.seen.size).toBe(0); expect(options.pokedex.seen.size).toBe(0);
expect(draws).toBe(0);
} }
}); });
}); });