Open Route 29 and Cherrygrove City
Checks / web (push) Canceled after 0s

- Enable Route 29, its Route 46 gate, Cherrygrove City and its buildings.
- Run item balls through FindItemInBallScript and fruit trees through
  FruitTreeScript, with a native daily reset of the daily engine flags.
- Port the Pokémon Center nurse (HealParty with the PP Up clamp,
  CheckPokerus, phone-call checks) and record the whiteout spawn on
  entering a Pokémon Center, as EnterMapWarp does.
- Port standard Poké Marts with keyboard, D-pad and pointer menus.
- Add the recoil battle command (Struggle) and VAR_BOXSPACE.
- Save fruit trees, the daily reset day and the last spawn map; older
  version-3 backups migrate by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-23 17:25:02 +02:00
1 parent 11d1153802
commit 796fe8f6d5
24 files changed
+1549 -59

No files matched your search

+4 -2
View File
@@ -2,11 +2,13 @@
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: the opening through Elm's starter.** 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 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.
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.
Battles, routes beyond New Bark Town, party and bag menus, random NPC movement, decorations and audio are still being ported. Neighboring routes appear at the town's edges but cannot be entered yet.
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. 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, random NPC movement, decorations and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run
+3 -3
View File
@@ -30,7 +30,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. Surfing and NPC roaming remain absent; turn timing still needs comparison. |
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. |
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` moves the follower into the leader's previous square. |
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet) and the empty-room decoration toggles. |
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), the empty-room decoration toggles, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
| `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes. |
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. PC boxes, TMs and HMs are refused. |
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
@@ -39,7 +39,7 @@ Supported event opcodes are explicit in the interpreter switch. Audio commands c
Scripts run on a private draft. The world presents each yielded effect, then resumes the script with the player's answer or when the animation ends. The draft is committed only when the script ends. A save made during a scene returns the committed state, and coordinate events rewind to the source of the triggering walk or two-tile jump. Saves during movement also retain the source tile until arrival events run, including across map connections. A failed scene restores the positions it had changed. As in `LoadMapObjects`, map objects spawn when the map loads. Later flag changes do not remove objects already shown; only `appear` and `disappear` do.
The opening scenes currently run end to end from the ROM: Mom's Pokégear, weekday and DST scene, the teacher at the west edge, the rival at the lab window, Elm's introduction with its yes/no loop, the lab's exit guard, the starter choice with `pokepic`, `givepoke` and the nickname prompt, Elm's directions and the aide's Potion through the ROM's own `GiveItemScript`. `callasm` accepts only explicitly empty routines or native translations; battle whiteout hooks are present but await full flow validation. The naming screen is a native text field limited to the characters the original offers.
The opening scenes currently run end to end from the ROM: Mom's Pokégear, weekday and DST scene, the teacher at the west edge, the rival at the lab window, Elm's introduction with its yes/no loop, the lab's exit guard, the starter choice with `pokepic`, `givepoke` and the nickname prompt, Elm's directions and the aide's Potion through the ROM's own `GiveItemScript`. `callasm` accepts only explicitly empty routines or native translations: `HalveMoney`, `GetWhiteoutSpawn`, the item ball's `TryReceiveItem` and the fruit tree routines. The naming screen is a native text field limited to the characters the original offers.
## Actual emulator comparison
@@ -69,4 +69,4 @@ Native saves are version 3. Version 2 added scene values by owning map, phone co
## Next slice
Validate the existing battle core and UI together, including win, escape, whiteout and save/reload through the browser. Encounter tables and ledge movement are present, but Route 29 stays disabled until its scripts, catching tutorial, fruit tree and item interactions are supported and tested. Then continue to Cherrygrove. Add party, bag and Pokégear menus over the native models. Heal-machine and other specials stay refused until translated.
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. Next: NPC roaming, 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.
+15 -4
View File
@@ -45,13 +45,24 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
- Walkable ledge tops, directional two-tile jumps over 16 ticks, and jump shadows.
- Exit mats wait for input in their exit direction. Arriving on a mat preserves the actual position; doors, stairs and caves animate the downward exit step.
- Mid-step saves replay from the source tile, including jumps and map connections, so reload cannot bypass landing scenes or warps.
- Synthetic movement tests run without a ROM. Local-ROM tests exercise five Route 29 ledge types without enabling that route in production; browser checks cover stairs, opening scenes and an exit-mat save/reload.
- Synthetic movement tests run without a ROM. Local-ROM tests exercise five Route 29 ledge types; browser checks cover stairs, opening scenes and an exit-mat save/reload.
## 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.
- 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.
- `VAR_BOXSPACE` reads an empty box: nothing can be caught or deposited yet.
- 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.
- Still refused: the catching tutorial (`catchtutorial`) and Cherrygrove's rival scene, both reachable only after the Mystery Egg errand.
## 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. Capture, switching, items in battle, Route 29 scripts and 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, switching, items in battle, trainer battles and the rest of the opening quest remain unfinished.
This is still not a complete new game. Playable battles, party/bag/Pokégear menus, story progression beyond the lab, NPC roaming, decorations, animated terrain, surfing, entering routes and audio remain unfinished. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
This is still not a complete new game. Party/bag/Pokégear menus, story progression beyond Cherrygrove, NPC roaming, 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.
## 1. Complete the overworld foundation
@@ -67,7 +78,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 and the aide's Potion run now. Next: validate and complete battle integration, Route 29 with wild encounters and the catching tutorial, Cherrygrove and the Mystery Egg round trip; then the introduction's naming and clock screens.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29 and Cherrygrove run now. Next: NPC roaming movement, 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.
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.
+27
View File
@@ -130,3 +130,30 @@ vendor and reference checks; `git diff --check`. Not committed.
Next: Route 29 (`24:3`) scripts, catching tutorial and items before enabling it;
then capture, switching and PACK in battle.
## Route 29 and Cherrygrove increment: 2026-09-23
Enabled Route 29, its Route 46 gate, Cherrygrove City and its five buildings.
Item balls, the Pokémon Center nurse and the Poké Mart were each ported by a
Sonnet sub-agent in an isolated worktree, reviewed against the disassembly and
merged; the rest was done directly.
- Fruit trees run `FruitTreeScript`; a native `CheckDailyResetTimer` clears
the daily engine flags once per game day. `VAR_BOXSPACE` reads an empty box.
- Item balls run `FindItemInBallScript` with a native `TryReceiveItem`.
- The nurse runs `PokecenterNurseScript`; `HealParty` now applies
`ComputeMaxPP`'s PP Up clamp. `EnterMapWarp`'s spawn recording makes
whiteouts return to the last Pokémon Center entered.
- Standard marts run natively (`StandardMart`/`BuyMenu`/`SellMenu`).
- Battle command `recoil` (Struggle) was the only refusal found by a local
stress run of 360 Route 29 battles across starters and times of day.
- Save format 3 gained `fruitTrees`, `dailyReset` and `lastSpawnMap`
(migrated by absence).
- The phone-width status bar layout and parity test map counts were fixed.
Still refused: `catchtutorial` and Cherrygrove's rival scene (both after the
Mystery Egg), Route 30/46. NPCs do not roam yet.
Next: NPC roaming movement (`map_objects.asm` random walk/spin, radius and
visibility rules, deterministic RNG in tests), trainer sight lines and trainer
battles, Route 30 and Mr. Pokémon, capture and the catching tutorial.
+40 -2
View File
@@ -75,6 +75,7 @@ const names = {
giveItemScript: "GiveItemScript",
putItemInPocketText: "PutItemInPocketText",
pocketIsFullText: "PocketIsFullText",
findItemInBallScript: "FindItemInBallScript",
caughtAskNicknameText: "CaughtAskNicknameText",
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
wildBattleScript: "WildBattleScript",
@@ -83,12 +84,36 @@ const names = {
johtoGrassWildMons: "JohtoGrassWildMons",
grassMonProbTable: "GrassMonProbTable",
grassCollisions: "CheckGrassCollision.blocks",
fruitTreeScript: "FruitTreeScript",
fruitTreeItems: "FruitTreeItems",
engineFlags: "EngineFlags",
getCurTreeFruit: "GetCurTreeFruit",
tryResetFruitTrees: "TryResetFruitTrees",
checkFruitTree: "CheckFruitTree",
pickedFruitTree: "PickedFruitTree",
marts: "Marts",
martWelcomeText: "MartWelcomeText",
martAskMoreText: "MartAskMoreText",
martComeAgainText: "MartComeAgainText",
martHowManyText: "MartHowManyText",
martFinalPriceText: "MartFinalPriceText",
martNoMoneyText: "MartNoMoneyText",
martPackFullText: "MartPackFullText",
martThanksText: "MartThanksText",
martCantBuyText: "MartCantBuyText",
martSellHowManyText: "MartSellHowManyText",
martSellPriceText: "MartSellPriceText",
martBoughtText: "MartBoughtText",
fadeOutToWhite: "FadeOutToWhite",
healParty: "HealParty",
healMachineAnim: "HealMachineAnim",
checkPokerus: "CheckPokerus",
stubbedTrainerRankingsHealings: "StubbedTrainerRankings_Healings",
warpToSpawnPoint: "WarpToSpawnPoint",
battleBGMap: "BattleBGMap",
halveMoney: "HalveMoney",
getWhiteoutSpawn: "GetWhiteoutSpawn",
tryReceiveItem: "FindItemInBallScript.TryReceiveItem",
giveItemDummy: "GiveItemScript_DummyFunction",
restartMapMusic: "RestartMapMusic",
setDayOfWeek: "SetDayOfWeek",
@@ -97,12 +122,15 @@ const names = {
toggleDecorationsVisibility: "ToggleDecorationsVisibility",
toggleMaptileDecorations: "ToggleMaptileDecorations",
};
// Work RAM symbols are compared by their CPU address, not a ROM offset.
// Work and hardware RAM symbols are compared by their CPU address, not a
// ROM offset. Hardware RAM (hMoneyTemp and similar) lives at $FF80-$FFFE.
const ram = new Map(
readFileSync(`${root}/pokecrystal11.sym`, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^0[01]:([c-d][0-9a-f]{3}) (\S+)$/.exec(line);
const match = /^0[01]:((?:[c-d][0-9a-f]{3}|ff[0-9a-f]{2})) (\S+)$/.exec(
line,
);
return match ? [[match[2], parseInt(match[1], 16)]] : [];
}),
);
@@ -119,6 +147,11 @@ const ramNames = {
enemyMonNickname: "wEnemyMonNickname",
curPartyLevel: "wCurPartyLevel",
monOrItemNameBuffer: "wMonOrItemNameBuffer",
curFruit: "wCurFruit",
dailyFlags1: "wDailyFlags1",
unusedDailyFlag: "wUnusedDailyFlag",
itemQuantityChange: "wItemQuantityChange",
moneyTemp: "hMoneyTemp",
};
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
for (const [key, symbol] of Object.entries(names)) {
@@ -187,6 +220,7 @@ const constantCount = (file, name) => {
for (const [name, file] of [
["NUM_POKEMON", "constants/pokemon_constants.asm"],
["NUM_ITEMS", "constants/item_constants.asm"],
["NUM_FRUIT_TREES", "constants/script_constants.asm"],
])
if (!profile.includes(`${name} = ${constantCount(file, name)};`))
throw new Error(`${name} does not match the reference.`);
@@ -244,6 +278,10 @@ for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?
throw new Error(`Battle constant mismatch: ${name} is ${literal}, PRET has ${expected}`);
}
}
if (!profile.includes(`allFruitTrees: ${pretConstants.get("ENGINE_ALL_FRUIT_TREES")},`))
throw new Error("ENGINE_ALL_FRUIT_TREES does not match the reference.");
if (!profile.includes(`NUM_ENGINE_FLAGS = ${pretConstants.get("NUM_ENGINE_FLAGS")};`))
throw new Error("NUM_ENGINE_FLAGS does not match the reference.");
const groups = readFileSync(`${root}/data/maps/maps.asm`, "utf8")
.split(/^MapGroup_/m)
.slice(1)
+10
View File
@@ -967,6 +967,8 @@ export class Battle {
return yield* this.confuseCommand();
case COMMAND.draintarget:
return yield* this.drainTarget();
case COMMAND.recoil:
return yield* this.recoil();
case COMMAND.checksafeguard:
return yield* this.checkSafeguard();
case COMMAND.splash:
@@ -1546,6 +1548,14 @@ export class Battle {
yield this.hud();
yield* this.say(BATTLE.SuckedHealthText);
}
/** BattleCommand_Recoil: a quarter of the damage dealt, at least 1 HP. */
private *recoil(): Gen {
const mon = this.user.mon;
mon.hp = Math.max(0, mon.hp - (this.curDamage >> 2 || 1));
if (this.turn === 0) this.updateBattleMonInParty();
yield this.hud();
yield* this.say(BATTLE.RecoilText);
}
private *checkSafeguard(): Gen {
if (!this.safeguarded()) return;
this.attackMissed = 1;
+1
View File
@@ -234,6 +234,7 @@ export const COMMAND = {
freezetarget: 24,
paralyzetarget: 25,
flinchtarget: 37,
recoil: 39,
confuse: 42,
confusetarget: 43,
poison: 47,
+14
View File
@@ -4,6 +4,8 @@ import {
LAST_MAP_GROUP_SIZE,
MAP_GROUP_COUNT,
NATIVE_MAPS,
NUM_ENGINE_FLAGS,
RAM,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
@@ -360,6 +362,18 @@ export class CrystalAssets {
}
throw new Error("Wild encounter data is unterminated.");
}
/** Engine flags kept in the four bytes CheckDailyResetTimer clears
* (wDailyFlags1 through wUnusedDailyFlag), from the ROM's EngineFlags. */
dailyEngineFlags(): number[] {
const r = this.reader,
flags: number[] = [];
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
const address = r.word(SYMBOLS.engineFlags + index * 3);
if (address >= RAM.dailyFlags1 && address <= RAM.unusedDailyFlag)
flags.push(index);
}
return flags;
}
/** Emote bubble graphics (data/sprites/emotes.asm). */
emote(index: number): Uint8Array {
const r = this.reader,
+14
View File
@@ -129,6 +129,20 @@ export class PokemonData {
if (!pocket) throw new Error("Item has no pocket.");
return pocket;
}
/** ItemAttributes: price, in Poké Dollars. */
itemPrice(item: number): number {
this.checkItem(item);
return this.reader.word(SYMBOLS.itemAttributes + (item - 1) * 7);
}
/** ItemAttributes permissions, bit 7 (CANT_TOSS_F): mostly key items and
* TMs/HMs, checked by _CheckTossableItem before a mart buys one back. */
itemUnsellable(item: number): boolean {
this.checkItem(item);
const permissions = this.reader.byte(
SYMBOLS.itemAttributes + (item - 1) * 7 + 4,
);
return Boolean(permissions & 0x80);
}
pocketName(pocket: Pocket): string {
const r = this.reader,
bank = r.bank(SYMBOLS.itemPocketNames);
+47
View File
@@ -40,6 +40,7 @@ export const SYMBOLS = {
giveItemScript: 0x96f77,
putItemInPocketText: 0x9705b,
pocketIsFullText: 0x97060,
findItemInBallScript: 0x122ce,
caughtAskNicknameText: 0x4db44,
nicknameStrings: 0x11780,
wildBattleScript: 0x97cf9,
@@ -48,6 +49,22 @@ export const SYMBOLS = {
johtoGrassWildMons: 0x2a5e9,
grassMonProbTable: 0x2a1cb,
grassCollisions: 0x149ea,
fruitTreeScript: 0x44000,
fruitTreeItems: 0x44097,
engineFlags: 0x80462,
marts: 0x160a9,
martWelcomeText: 0x15f83,
martAskMoreText: 0x15fb9,
martComeAgainText: 0x15fb4,
martHowManyText: 0x15e0e,
martFinalPriceText: 0x15e13,
martNoMoneyText: 0x15fa5,
martPackFullText: 0x15faa,
martThanksText: 0x15fa0,
martCantBuyText: 0x15faf,
martSellHowManyText: 0x15f73,
martSellPriceText: 0x15f78,
martBoughtText: 0x15fbe,
} as const;
/** Assembly routines that scripts `callasm` but that do nothing. */
@@ -60,6 +77,11 @@ export const EMPTY_ROUTINES = {
export const NATIVE_ROUTINES = {
halveMoney: 0x12513,
getWhiteoutSpawn: 0x12527,
tryReceiveItem: 0x122f8,
getCurTreeFruit: 0x44041,
tryResetFruitTrees: 0x4404c,
checkFruitTree: 0x44055,
pickedFruitTree: 0x4405f,
} as const;
/** Native handlers are selected by the routine a special index points to,
@@ -73,6 +95,9 @@ export const SPECIAL_ROUTINES = {
toggleMaptileDecorations: 0x26feb,
fadeOutToWhite: 0x8c084,
healParty: 0xc658,
healMachineAnim: 0x12324,
checkPokerus: 0xc419,
stubbedTrainerRankingsHealings: 0x1060a2,
warpToSpawnPoint: 0x97c28,
} as const;
@@ -90,6 +115,12 @@ export const RAM = {
enemyMonNickname: 0xc616,
curPartyLevel: 0xd143,
monOrItemNameBuffer: 0xd050,
curFruit: 0xd03f,
dailyFlags1: 0xdc1e,
unusedDailyFlag: 0xdc21,
itemQuantityChange: 0xd10c,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
} as const;
/** Event constants named directly by translated special routines. */
@@ -101,6 +132,11 @@ export const EVENTS = {
playersHouseBigDoll: 1860,
} as const;
/** Engine flag indices named directly by translated routines. */
export const ENGINE_FLAGS = {
allFruitTrees: 84,
} as const;
/** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const BATTLE = {
@@ -187,6 +223,7 @@ export const BATTLE = {
ProtectedByText: 0x815a9,
ProtectingItselfText: 0x8167a,
RageBuildingText: 0x810f3,
RecoilText: 0x8134d,
RecoveredUsingText: 0x80bc2,
RegainedHealthText: 0x8100c,
SafeguardProtectText: 0x81733,
@@ -217,6 +254,8 @@ export const BATTLE = {
export const MAP_GROUP_COUNT = 26;
export const NUM_POKEMON = 251;
export const NUM_ITEMS = 190;
export const NUM_ENGINE_FLAGS = 162;
export const NUM_FRUIT_TREES = 30;
/** The last group has no following pointer to bound it. */
export const LAST_MAP_GROUP_SIZE = 11;
@@ -224,11 +263,19 @@ export const LAST_MAP_GROUP_SIZE = 11;
* maps remain unavailable; the label only distinguishes interiors that share
* a ROM landmark name. */
export const NATIVE_MAPS: Record<string, string> = {
"24:3": "Route 29",
"24:13": "Route 29 gate",
"24:4": "New Bark Town",
"24:5": "Elm’s lab",
"24:6": "Your home",
"24:7": "Your room",
"24:8": "Neighbor’s house",
"24:9": "Elm’s house",
"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",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+8 -3
View File
@@ -63,9 +63,9 @@ export class NativeRuntime implements GameRuntime {
}
if (button === "a") world.interact();
if (button === "b") world.advanceDialogue();
if ((button === "up" || button === "down") &&
if (["up", "down", "left", "right"].includes(button) &&
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno"))
world.moveCursor(button);
world.moveCursor(button as Direction);
if ((button === "start" || button === "select") && !world.busy)
world.notice =
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
@@ -142,7 +142,12 @@ export class NativeRuntime implements GameRuntime {
],
dialogue?.index,
dialogue?.pages,
dialogue?.choice?.selected,
// The whole choice, not just `selected`: mart screens replace one
// choice kind with another (menu -> list -> quantity) while keeping
// the same page and index, and `world.liveState.money` the mart UI
// reads directly is otherwise absent from this signature.
dialogue?.choice,
world.liveState.money,
dialogue?.holdTicks === undefined,
]);
if (signature === this.published) return;
+315 -14
View File
@@ -1,7 +1,9 @@
import { RomReader } from "../rom/reader";
import {
EMPTY_ROUTINES,
ENGINE_FLAGS,
NATIVE_ROUTINES,
NUM_FRUIT_TREES,
EVENTS,
RAM,
SPECIAL_ROUTINES,
@@ -50,6 +52,18 @@ export interface ScriptState {
pokedex: { seen: Set<number>; caught: Set<number> };
/** wMoney: a 24-bit amount. */
money: number;
/** wFruitTreeFlags: trees (FRUITTREE_* ids) picked since the last reset. */
fruitTrees: Set<number>;
/** Local calendar day of the last CheckDailyResetTimer reset; null until
* the first check of a new game, which resets immediately. */
dailyReset: number | null;
/** wLastSpawnMapGroup/wLastSpawnMapNumber: the outdoor map last entered
* through a Pokémon Center door (EnterMapWarp's SetSpawn), used by
* GetWhiteoutSpawn. Null before any Center has been entered. */
lastSpawnMap: string | null;
/** wSpecialPhoneCallID's low byte (checkphonecall): nonzero while a call
* is pending. No ported content ever sets it, so it is not saved. */
specialPhoneCall: number;
}
export const emptyScriptState = (): ScriptState => ({
events: new Set(),
@@ -62,6 +76,10 @@ export const emptyScriptState = (): ScriptState => ({
bag: emptyBag(),
pokedex: { seen: new Set(), caught: new Set() },
money: C.START_MONEY,
fruitTrees: new Set(),
dailyReset: null,
lastSpawnMap: null,
specialPhoneCall: 0,
});
export const copyScriptState = (state: ScriptState): ScriptState => ({
events: new Set(state.events),
@@ -77,6 +95,10 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
caught: new Set(state.pokedex.caught),
},
money: state.money,
fruitTrees: new Set(state.fruitTrees),
dailyReset: state.dailyReset,
lastSpawnMap: state.lastSpawnMap,
specialPhoneCall: state.specialPhoneCall,
});
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
@@ -85,7 +107,15 @@ export const PLAYER = 0;
const LAST_TALKED = 0xfe;
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
const CONTACT_LIST_SIZE = 10;
const MONS_PER_BOX = 20;
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
export interface MartEntry {
item: number;
name: string;
price: number;
owned?: number;
}
export type ScriptEffect =
/** Pages the player acknowledges. With holdTicks the last page instead
* stays for a fixed time, as `writetext` followed by `pause` does. */
@@ -106,6 +136,23 @@ export type ScriptEffect =
| { kind: "picture"; species: number }
/** The naming screen; answered with the name, or "" to keep `current`. */
| { kind: "name"; pages: string[]; current: string; max: number }
/** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list (buy: full stock; sell: the bag's pockets);
* answered with the chosen entry's index, or a negative number to cancel. */
| { kind: "martlist"; mode: "buy" | "sell"; entries: MartEntry[]; selected: number }
/** The 1-99 quantity stepper; answered with the chosen amount, or a
* number below 1 to cancel back to the item list. */
| {
kind: "martquantity";
mode: "buy" | "sell";
item: number;
name: string;
price: number;
max: number;
prompt: string;
selected: number;
}
/** startbattle: answered with wBattleResult. */
| { kind: "battle"; wild: { species: number; level: number } }
/** newloadmap: the world enters this map after the script ends. */
@@ -162,8 +209,14 @@ export class ScriptRun {
deferred?: number;
done = false;
private strings: Record<number, string> = {};
/** Native values for text_decimal, keyed by RAM address (wItemQuantityChange,
* hMoneyTemp): the mart's quantity and price texts. */
private numbers: Record<number, number> = {};
/** wCurItem: the item named by itemnotify and pocketisfull. */
private currentItem = 0;
/** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */
private fruitTree = 0;
private fruit = 0;
private iterator: Generator<ScriptEffect, void, Input>;
private instructions = 0;
@@ -176,6 +229,9 @@ export class ScriptRun {
callback?: boolean;
/** The wild Pokémon chosen by the encounter check (wTempWildMonSpecies). */
wild?: { species: number; level: number };
/** wItemBallItemID/wItemBallQuantity, copied from the itemball data the
* facing object's script pointer names. */
itemBall?: { item: number; quantity: number };
} = {},
) {
this.state = copyScriptState(state);
@@ -199,6 +255,7 @@ export class ScriptRun {
return {
...this.host.text(),
strings: { ...this.host.text().strings, ...this.strings },
numbers: { ...this.host.text().numbers, ...this.numbers },
};
}
@@ -252,6 +309,14 @@ export class ScriptRun {
return effect;
};
const sceneOwner = (mapId: string) => this.host.sceneOwner(mapId);
// GetWhiteoutSpawn/IsSpawnPoint: the table index of the last recorded
// spawn map, or SPAWN_HOME when unset or no longer a listed spawn point.
const findSpawn = (mapId: string | null) => {
if (mapId)
for (let entry = SYMBOLS.spawnPoints, index = 0; r.byte(entry) !== 0xff; entry += 4, index++)
if (`${r.byte(entry)}:${r.byte(entry + 1)}` === mapId) return index;
return 0;
};
for (;;) {
if (++this.instructions > 4096)
@@ -312,9 +377,10 @@ export class ScriptRun {
const index = word();
const entry = SYMBOLS.specials + index * 3;
const routine = r.address(r.byte(entry), r.word(entry + 1));
const special = yield* this.special(routine, pages, take);
if (special === undefined)
const result = yield* this.special(routine, value, pages, take);
if (result === undefined)
throw new UnsupportedScript(offset, opcode, ` (special ${index})`);
value = result;
break;
}
case 0x11:
@@ -352,13 +418,51 @@ export class ScriptRun {
const routine = far();
if (routine === NATIVE_ROUTINES.halveMoney)
this.state.money = this.state.money >> 1;
else if (routine === NATIVE_ROUTINES.getCurTreeFruit)
this.fruit = r.byte(SYMBOLS.fruitTreeItems + this.fruitTree - 1);
else if (routine === NATIVE_ROUTINES.tryResetFruitTrees) {
// Trees regrow once per daily reset of ENGINE_ALL_FRUIT_TREES.
if (!this.state.flags.has(ENGINE_FLAGS.allFruitTrees)) {
this.state.fruitTrees.clear();
this.state.flags.add(ENGINE_FLAGS.allFruitTrees);
}
} else if (routine === NATIVE_ROUTINES.checkFruitTree)
value = Number(this.state.fruitTrees.has(this.fruitTree));
else if (routine === NATIVE_ROUTINES.pickedFruitTree)
this.state.fruitTrees.add(this.fruitTree);
else if (routine === NATIVE_ROUTINES.getWhiteoutSpawn)
// No Pokémon Center is ported, so the spawn is still SPAWN_HOME.
spawn = 0;
else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
spawn = findSpawn(this.state.lastSpawnMap);
else if (routine === NATIVE_ROUTINES.tryReceiveItem) {
// FindItemInBallScript.TryReceiveItem: give the ball's item and
// name it for FoundItemText (wStringBuffer3), matching GetItemName
// + CopyName2. wScriptVar (value) reports success for iffalse.
const ball = this.options.itemBall;
if (!ball) throw new UnsupportedScript(offset, opcode, " (no item ball)");
const name = data.itemName(ball.item);
this.strings[RAM.stringBuffer1] = name;
this.strings[RAM.stringBuffer3] = name;
this.currentItem = ball.item;
value = Number(
receiveItem(data, this.state.bag, ball.item, ball.quantity),
);
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
throw new UnsupportedScript(offset, opcode);
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;
break;
}
case 0x9b: {
// Script_fruittree jumps to FruitTreeScript for FRUITTREE_* id.
const tree = byte();
if (tree < 1 || tree > NUM_FRUIT_TREES) throw new Error("Invalid fruit tree.");
this.fruitTree = tree;
p = SYMBOLS.fruitTreeScript;
break;
}
case 0x5b:
break; // randomwildmon clears wBattleScriptFlags.
case 0x5f: {
@@ -386,6 +490,12 @@ export class ScriptRun {
case 0x1c:
value = this.readVar(byte(), offset);
break;
case 0x9c:
this.state.specialPhoneCall = word();
break;
case 0x9d:
value = Number(this.state.specialPhoneCall !== 0);
break;
case 0x1f:
case 0x9e: {
const item = byte(),
@@ -723,6 +833,15 @@ export class ScriptRun {
case 0x93:
yield* flush();
return;
case 0x94: {
const type = byte(),
martId = word();
if (type !== 0 /* MARTTYPE_STANDARD */)
throw new UnsupportedScript(offset, opcode, " (mart type)");
yield* flush();
yield* this.pokemart(martId, pages);
break;
}
default:
throw new UnsupportedScript(offset, opcode);
}
@@ -730,6 +849,164 @@ export class ScriptRun {
}
}
/** OpenMartDialog/MartDialog/StandardMart (engine/items/mart.asm), for
* MARTTYPE_STANDARD only. Welcome, then BUY/SELL/QUIT until the player
* quits; buying and selling loop over their own item lists until the
* player backs out, then the menu asks "anything else?" and repeats. */
private *pokemart(
martId: number,
pages: string[],
): Generator<ScriptEffect, void, Input> {
const r = this.reader;
const write = (address: number) => {
const content = readText(r, address, this.text());
if (content) pages.push(...content.split("\n\n"));
};
const take = () => pages.splice(0, pages.length);
const entry = r.near(SYMBOLS.marts + martId * 2, r.bank(SYMBOLS.marts));
let p = entry;
const count = r.byte(p++);
const stock = Array.from({ length: count }, () => r.byte(p++));
let first = true;
for (;;) {
const choice = yield {
kind: "martmenu",
prompt: readText(
r,
first ? SYMBOLS.martWelcomeText : SYMBOLS.martAskMoreText,
this.text(),
),
selected: 0,
};
first = false;
const option = typeof choice === "number" ? choice : 2;
if (option === 0) yield* this.martBuy(stock, pages);
else if (option === 1) yield* this.martSell(pages);
else {
write(SYMBOLS.martComeAgainText);
if (pages.length) yield { kind: "text", pages: take() };
return;
}
}
}
/** BuyMenu/BuyMenuLoop: pick a stocked item, a 1-99 quantity, confirm the
* price, then check funds and bag room before handing it over. */
private *martBuy(
stock: number[],
pages: string[],
): Generator<ScriptEffect, void, Input> {
const r = this.reader,
data = this.host.data;
const write = (address: number) => {
const content = readText(r, address, this.text());
if (content) pages.push(...content.split("\n\n"));
};
const take = () => pages.splice(0, pages.length);
const flush = function* (): Generator<ScriptEffect, void, Input> {
if (pages.length) yield { kind: "text", pages: take() };
};
for (;;) {
const entries: MartEntry[] = stock.map((item) => ({
item,
name: data.itemName(item),
price: data.itemPrice(item),
}));
const selection = yield { kind: "martlist", mode: "buy", entries, selected: 0 };
const item = typeof selection === "number" ? stock[selection] : undefined;
if (item === undefined) return;
const price = data.itemPrice(item);
const chosen = yield {
kind: "martquantity",
mode: "buy",
item,
name: data.itemName(item),
price,
max: 99,
prompt: readText(r, SYMBOLS.martHowManyText, this.text()),
selected: 1,
};
if (typeof chosen !== "number" || chosen < 1) continue;
const total = price * chosen;
this.strings[RAM.stringBuffer2] = data.itemName(item);
this.numbers[RAM.itemQuantityChange] = chosen;
this.numbers[RAM.moneyTemp] = total;
write(SYMBOLS.martFinalPriceText);
const confirmed = yield { kind: "yesno", pages: take() };
if (!confirmed) continue;
if (total > this.state.money) {
write(SYMBOLS.martNoMoneyText);
yield* flush();
continue;
}
if (!receiveItem(data, this.state.bag, item, chosen)) {
write(SYMBOLS.martPackFullText);
yield* flush();
continue;
}
this.state.money -= total;
write(SYMBOLS.martThanksText);
yield* flush();
}
}
/** SellMenu/DepositSellPack: pick a bag item and a quantity up to its
* stack, refuse unsellable items, then pay half price and toss it. */
private *martSell(pages: string[]): Generator<ScriptEffect, void, Input> {
const r = this.reader,
data = this.host.data;
const write = (address: number) => {
const content = readText(r, address, this.text());
if (content) pages.push(...content.split("\n\n"));
};
const take = () => pages.splice(0, pages.length);
const flush = function* (): Generator<ScriptEffect, void, Input> {
if (pages.length) yield { kind: "text", pages: take() };
};
for (;;) {
const slots = [...this.state.bag.items, ...this.state.bag.balls];
if (!slots.length) return;
const entries: MartEntry[] = slots.map((slot) => ({
item: slot.item,
name: data.itemName(slot.item),
price: data.itemPrice(slot.item),
owned: slot.quantity,
}));
const selection = yield { kind: "martlist", mode: "sell", entries, selected: 0 };
const slot = typeof selection === "number" ? slots[selection] : undefined;
if (!slot) return;
if (data.itemUnsellable(slot.item)) {
write(SYMBOLS.martCantBuyText);
yield* flush();
continue;
}
const price = data.itemPrice(slot.item);
const chosen = yield {
kind: "martquantity",
mode: "sell",
item: slot.item,
name: data.itemName(slot.item),
price,
max: slot.quantity,
prompt: readText(r, SYMBOLS.martSellHowManyText, this.text()),
selected: 1,
};
if (typeof chosen !== "number" || chosen < 1) continue;
const total = Math.floor((price * chosen) / 2);
this.strings[RAM.stringBuffer2] = data.itemName(slot.item);
this.numbers[RAM.moneyTemp] = total;
write(SYMBOLS.martSellPriceText);
const confirmed = yield { kind: "yesno", pages: take() };
if (!confirmed) continue;
tossItem(this.state.bag, slot.item, chosen);
this.state.money = Math.min(C.MAX_MONEY, this.state.money + total);
write(SYMBOLS.martBoughtText);
yield* flush();
}
}
/** readvar: the script variables that native state can answer. */
private readVar(id: number, offset: number): number {
const clock = this.host.clock();
@@ -753,6 +1030,10 @@ export class ScriptRun {
return group;
case 0x0d:
return number;
case 0x10:
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be
// deposited yet (no capture or PC), so the current box is empty.
return MONS_PER_BOX;
case 0x12:
return this.host.position().x;
case 0x13:
@@ -763,18 +1044,29 @@ export class ScriptRun {
}
/** Native translations of special routines, selected by routine address.
* Returns undefined for routines that have not been translated. */
* `value` is the script's current wScriptVar, in case a routine (like
* CheckPokerus) returns a new one; routines that leave it alone return it
* unchanged. Returns undefined for routines that have not been translated. */
private *special(
routine: number,
value: number,
pages: string[],
take: () => string[],
): Generator<ScriptEffect, true | undefined, Input> {
): Generator<ScriptEffect, number | undefined, Input> {
const r = this.reader;
switch (routine) {
case SPECIAL_ROUTINES.restartMapMusic:
case SPECIAL_ROUTINES.fadeOutToWhite:
case SPECIAL_ROUTINES.warpToSpawnPoint:
return true; // Music, fades and Safari/contest flags not in use.
case SPECIAL_ROUTINES.stubbedTrainerRankingsHealings:
// Music and fades are not modeled; Safari/contest flags are unused.
// StubbedTrainerRankings_Healings begins with `ret`: the Trainer
// Rankings feature (Pokémon News) was cut, leaving a dead stub.
return value;
case SPECIAL_ROUTINES.healMachineAnim:
// Graphics, palettes and sound only: wPartyCount/wScriptVar select
// an animation sequence but nothing persists past it.
return value;
case SPECIAL_ROUTINES.healParty:
// HealPartyMon: clear status, restore HP and every move's PP.
for (const mon of this.state.party) {
@@ -784,10 +1076,19 @@ export class ScriptRun {
if (!move) return 0;
const ups = mon.pp[slot] >> 6,
base = this.host.data.move(move).pp;
return (ups << 6) | (base + Math.floor(base / 5) * ups);
// ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move
// maxes at 61 instead of overflowing the packed byte at 64.
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
}) as PartyMon["pp"];
}
return true;
return value;
case SPECIAL_ROUTINES.checkPokerus:
// _CheckPokerus: carry (here, a truthy return) if any party member's
// status nybble is infected. No ported content ever sets pokerus,
// so this is always 0 until Pokérus itself is modeled.
return Number(
this.state.party.some((mon) => (mon.pokerus & 0x0f) !== 0),
);
case SPECIAL_ROUTINES.setDayOfWeek: {
const names = Array.from({ length: 7 }, (_, day) =>
readString(
@@ -821,7 +1122,7 @@ export class ScriptRun {
if (!confirmed) continue;
this.state.clock.weekdayOffset =
(day - this.host.deviceWeekday() + 7) % 7;
return true;
return value;
}
}
case SPECIAL_ROUTINES.initialSetDst:
@@ -839,7 +1140,7 @@ export class ScriptRun {
// The box is cleared first; the following yesorno shows this page.
take();
pages.push(`${time}${text.startsWith(",") ? "" : " "}${text}`);
return true;
return value;
}
case SPECIAL_ROUTINES.toggleDecorationsVisibility:
// No decorations are placed until the native decoration system exists,
@@ -851,12 +1152,12 @@ export class ScriptRun {
EVENTS.playersHouseBigDoll,
])
this.state.events.add(event);
return true;
return value;
case SPECIAL_ROUTINES.toggleMaptileDecorations:
// Empty decoration slots leave the room blocks unchanged and clear
// the poster's conditional event.
this.state.events.delete(EVENTS.playersRoomPoster);
return true;
return value;
default:
return undefined;
}
+166 -11
View File
@@ -1,10 +1,11 @@
import { CrystalAssets } from "../rom/assets";
import { INITIAL_POSITION, SYMBOLS } from "../rom/profile";
import { INITIAL_POSITION, NUM_FRUIT_TREES, SYMBOLS } from "../rom/profile";
import {
PLAYER,
ScriptRun,
copyScriptState,
emptyScriptState,
type MartEntry,
type ScriptEffect,
type ScriptHost,
type ScriptState,
@@ -57,6 +58,12 @@ export interface NativeSave {
bag: Bag;
pokedex: { seen: number[]; caught: number[] };
money: number;
fruitTrees: number[];
dailyReset: number | null;
/** wLastSpawnMapGroup/Number: the outdoor map last entered through a
* Pokémon Center door, for the whiteout/blackout spawn. Null on early
* version-3 backups and before any Center has been entered. */
lastSpawnMap: string | null;
}
/** A random byte from the platform's secure generator. */
@@ -112,7 +119,22 @@ export type Choice =
| { kind: "yesno"; selected: 0 | 1 }
| { kind: "weekday"; names: string[]; selected: number }
/** The naming screen: an empty answer keeps `current`. */
| { kind: "name"; current: string; max: number; selected?: undefined };
| { kind: "name"; current: string; max: number; selected?: undefined }
/** The mart's BUY/SELL/QUIT box. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list; `selected` answers with that entry's index. */
| { kind: "martlist"; mode: "buy" | "sell"; entries: MartEntry[]; selected: number }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
mode: "buy" | "sell";
item: number;
name: string;
price: number;
max: number;
prompt: string;
selected: number;
};
export interface Dialogue {
pages: string[];
index: number;
@@ -247,6 +269,7 @@ export class GameWorld {
private script?: ActiveScript;
/** Scene scripts are re-evaluated only after the map or state changes. */
private sceneCheck = true;
private dailyFlags?: number[];
constructor(
readonly assets: CrystalAssets,
@@ -524,7 +547,18 @@ export class GameWorld {
const arrival = target.warps[warp.targetWarp - 1];
if (!arrival || !this.walkable(target, arrival.x, arrival.y))
throw new Error("Warp destination is invalid.");
const entry = this.prepareMap(target.id, "warp");
// EnterMapWarp's SetSpawn: walking from an outdoor map into a Pokémon
// Center records that outdoor map as the whiteout/blackout spawn.
let sourceState = this.state;
if (
(target.tileset === 7 || target.tileset === 21) && // TILESET_POKECENTER, TILESET_POKECOM_CENTER
(this.map.environment === 1 || this.map.environment === 2) // TOWN, ROUTE
)
sourceState = {
...copyScriptState(sourceState),
lastSpawnMap: this.mapId,
};
const entry = this.prepareMap(target.id, "warp", sourceState);
return {
entry,
arrival,
@@ -769,6 +803,7 @@ export class GameWorld {
committedPosition?: { x: number; y: number },
scene = false,
wild?: { species: number; level: number },
itemBall?: { item: number; quantity: number },
) {
const committed = this.save();
if (committedPosition) {
@@ -785,6 +820,7 @@ export class GameWorld {
this.host(lastTalked),
{
wild,
itemBall,
},
),
committed,
@@ -957,6 +993,45 @@ export class GameWorld {
choice: { kind: "name", current: effect.current, max: effect.max },
};
return true;
case "martmenu":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martmenu",
prompt: effect.prompt,
selected: effect.selected,
},
};
return true;
case "martlist":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martlist",
mode: effect.mode,
entries: effect.entries,
selected: effect.selected,
},
};
return true;
case "martquantity":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martquantity",
mode: effect.mode,
item: effect.item,
name: effect.name,
price: effect.price,
max: effect.max,
prompt: effect.prompt,
selected: effect.selected,
},
};
return true;
}
}
@@ -1162,6 +1237,19 @@ export class GameWorld {
if (!prompt && view.message?.prompt) this.pumpBattle();
}
/** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the
* first idle check on a new game day clears the daily engine flags. */
private checkDailyReset() {
const now = this.now();
const day =
Math.floor(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
) + this.state.clock.weekdayOffset;
if (this.state.dailyReset === day) return;
this.dailyFlags ??= this.assets.dailyEngineFlags();
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
this.state.dailyReset = day;
}
/** Scene scripts run while the player is idle (RunSceneScript). */
private checkScene() {
if (!this.sceneCheck || this.busy) return;
@@ -1229,6 +1317,7 @@ export class GameWorld {
if (this.dialogue) return;
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
if (
FORCED_WARP_EXITS.has(this.assets.collision(this.map, player.x, player.y))
)
@@ -1270,6 +1359,21 @@ export class GameWorld {
);
let script: number | undefined;
if (object) {
if (object.kind === 1) {
// OBJECTTYPE_ITEMBALL: the script field names raw data (the itemball
// 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) },
);
return;
}
if (object.kind !== 0) {
this.notice = "This interaction is not ported yet.";
return;
@@ -1323,8 +1427,14 @@ export class GameWorld {
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.holdTicks !== undefined) return;
if (last && dialogue.choice) {
// B answers NO on a yes/no box; other choices need their own answer.
if (dialogue.choice.kind === "yesno") this.choose(0);
// B answers NO on a yes/no box, quits the mart menu (matching the
// ROM's VerticalMenu carry) or backs out of a mart list/quantity
// screen; other choices need their own answer.
const choice = dialogue.choice;
if (choice.kind === "yesno") this.choose(0);
else if (choice.kind === "martmenu") this.choose(2);
else if (choice.kind === "martlist" || choice.kind === "martquantity")
this.choose(-1);
return;
}
if (!last) {
@@ -1348,6 +1458,29 @@ export class GameWorld {
if (choice.kind === "yesno") {
if (direction === "up") choice.selected = 1;
if (direction === "down") choice.selected = 0;
} else if (choice.kind === "martmenu") {
if (direction === "up") choice.selected = (choice.selected + 2) % 3;
if (direction === "down") choice.selected = (choice.selected + 1) % 3;
} else if (choice.kind === "martlist") {
const length = choice.entries.length;
if (!length) return;
if (direction === "up")
choice.selected = (choice.selected + length - 1) % length;
if (direction === "down")
choice.selected = (choice.selected + 1) % length;
} else if (choice.kind === "martquantity") {
// BuySellToss_InterpretJoypad: Up/Down step by 1 and wrap; Left/Right
// step by 10 and clamp to the 1-max range.
if (direction === "down")
choice.selected =
choice.selected > 1 ? choice.selected - 1 : choice.max;
if (direction === "up")
choice.selected =
choice.selected < choice.max ? choice.selected + 1 : 1;
if (direction === "left")
choice.selected = Math.max(1, choice.selected - 10);
if (direction === "right")
choice.selected = Math.min(choice.max, choice.selected + 10);
} else if (direction === "up") choice.selected = (choice.selected + 1) % 7;
else if (direction === "down") choice.selected = (choice.selected + 6) % 7;
}
@@ -1408,6 +1541,9 @@ export class GameWorld {
caught: [...state.pokedex.caught].sort((a, b) => a - b),
},
money: state.money,
fruitTrees: [...state.fruitTrees].sort((a, b) => a - b),
dailyReset: state.dailyReset,
lastSpawnMap: state.lastSpawnMap,
};
}
restore(value: unknown) {
@@ -1470,7 +1606,14 @@ export class GameWorld {
!species(save.pokedex.seen) ||
!species(save.pokedex.caught) ||
!save.pokedex.caught.every((id) => save.pokedex.seen.includes(id)) ||
!integer(save.money, 0, C.MAX_MONEY)
!integer(save.money, 0, C.MAX_MONEY) ||
!Array.isArray(save.fruitTrees) ||
!save.fruitTrees.every((id) => integer(id, 1, NUM_FRUIT_TREES)) ||
new Set(save.fruitTrees).size !== save.fruitTrees.length ||
!(save.dailyReset === null || integer(save.dailyReset, 0, 0xffffff)) ||
(save.lastSpawnMap !== null &&
(typeof save.lastSpawnMap !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(save.lastSpawnMap)))
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
@@ -1517,6 +1660,10 @@ export class GameWorld {
caught: new Set(save.pokedex.caught),
},
money: save.money,
fruitTrees: new Set(save.fruitTrees),
dailyReset: save.dailyReset,
lastSpawnMap: save.lastSpawnMap,
specialPhoneCall: 0,
};
let entry;
try {
@@ -1571,11 +1718,16 @@ export function migrateSave(
if (!save || typeof save !== "object" || save.format !== "crystal-native")
return undefined;
if (save.version === 3) {
// Early version-3 backups predate battle rewards and the money field.
// Only absence migrates; an explicitly invalid value must still be rejected.
return (Object.hasOwn(save, "money")
? save
: { ...save, money: C.START_MONEY }) as unknown as NativeSave;
// Early version-3 backups predate battle rewards, the money field, daily
// state and the recorded whiteout spawn. Only absence migrates; an
// explicitly invalid value must still be rejected.
return {
...save,
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
} as unknown as NativeSave;
}
if (save.version !== 1 && save.version !== 2) return undefined;
const previous =
@@ -1595,6 +1747,9 @@ export function migrateSave(
party: [],
bag: emptyBag(),
pokedex: { seen: [], caught: [] },
lastSpawnMap: null,
money: C.START_MONEY,
fruitTrees: [],
dailyReset: null,
} as unknown as NativeSave;
}
+113 -5
View File
@@ -6,7 +6,7 @@ import { LocalMapView } from "./game/rendering/minimap";
import type { Choice, GameWorld } from "./game/world/world";
import { drawTile } from "./game/rendering/pixels";
import { PokemonData } from "./game/rom/pokemon";
import type { Palette } from "./game/world/types";
import type { Direction, Palette } from "./game/world/types";
import type { Button } from "./input/types";
import { NICKNAME_PATTERN } from "./game/party/pokemon";
import { GameSession } from "./app/session";
@@ -52,7 +52,8 @@ function showWorld(world: GameWorld) {
const pictureWait = Boolean(
dialogue && !dialogue.choice && dialogue.pages[dialogue.index] === "",
);
get("native-dialogue").hidden = !dialogue || pictureWait;
const mart = dialogue?.choice && martChoice(dialogue.choice);
get("native-dialogue").hidden = !dialogue || pictureWait || Boolean(mart);
get("pokepic-next").hidden = !pictureWait;
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
get("dialogue-page").textContent =
@@ -60,12 +61,13 @@ function showWorld(world: GameWorld) {
? `${dialogue.index + 1} / ${dialogue.pages.length}`
: "";
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
const choice = last ? dialogue.choice : undefined;
const choice = last && !mart ? dialogue.choice : undefined;
// Timed pages advance themselves; choices replace Continue.
get("dialogue-next").hidden = Boolean(
choice || (last && dialogue.holdTicks !== undefined),
choice || mart || (last && dialogue.holdTicks !== undefined),
);
showChoice(choice);
showMart(world, mart);
// Coordinates provide a stable accessible description and a test observation
// point, without exposing mutable game state or a production debug API.
canvas.dataset.map = world.mapId;
@@ -125,7 +127,7 @@ function showChoice(choice: Choice | undefined) {
);
container.firstElementChild?.setAttribute("aria-label", "Previous day");
container.children[2]?.setAttribute("aria-label", "Next day");
} else {
} else if (choice.kind === "name") {
// The naming screen: an empty name keeps the current one.
const input = document.createElement("input");
input.maxLength = choice.max;
@@ -159,6 +161,112 @@ function showChoice(choice: Choice | undefined) {
queueMicrotask(() => input.focus({ preventScroll: true }));
}
}
type MartChoice = Extract<
Choice,
{ kind: "martmenu" | "martlist" | "martquantity" }
>;
function martChoice(choice: Choice): MartChoice | undefined {
return choice.kind === "martmenu" ||
choice.kind === "martlist" ||
choice.kind === "martquantity"
? choice
: undefined;
}
let martMenuKey = "";
/** The mart's BUY/SELL/QUIT box, item list or quantity stepper, plus a
* money display. Follows the battle menu's button-grid style. */
function showMart(world: GameWorld, mart: MartChoice | undefined) {
get("mart").hidden = !mart;
if (!mart) {
martMenuKey = "";
return;
}
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
get("mart-prompt").textContent = mart.kind === "martlist" ? "" : mart.prompt;
const key = JSON.stringify(mart);
if (key === martMenuKey) return;
martMenuKey = key;
const menu = get("mart-menu");
const button = (
label: string,
action: () => void,
options: { detail?: string; selected?: boolean; wide?: boolean } = {},
) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
if (options.detail) {
const small = document.createElement("small");
small.textContent = options.detail;
element.append(small);
}
if (options.selected) element.classList.add("battle-selected");
if (options.wide) element.classList.add("wide");
element.addEventListener("click", () => {
if (session.state !== "playing") return;
action();
canvas.focus({ preventScroll: true });
});
return element;
};
if (mart.kind === "martmenu") {
menu.replaceChildren(
...(["BUY", "SELL", "QUIT"] as const).map((label, index) =>
button(label, () => runtime.choose(index), {
selected: mart.selected === index,
}),
),
);
return;
}
if (mart.kind === "martlist") {
const buttons = mart.entries.map((entry, index) =>
button(entry.name, () => runtime.choose(index), {
detail:
mart.mode === "sell" ? `have ${entry.owned}` : `¥${entry.price}`,
selected: mart.selected === index,
}),
);
buttons.push(button("BACK", () => runtime.choose(-1), { wide: true }));
menu.replaceChildren(...buttons);
return;
}
// martquantity: a 1-99 stepper (±1 Up/Down, ±10 Left/Right, matching
// BuySellToss_InterpretJoypad), then BUY/SELL and CANCEL.
const total =
mart.mode === "sell"
? Math.floor((mart.price * mart.selected) / 2)
: mart.price * mart.selected;
const stepper = document.createElement("div");
stepper.className = "mart-quantity";
const step = (label: string, aria: string, direction: Direction) => {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
element.setAttribute("aria-label", aria);
element.addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.world?.moveCursor(direction);
if (runtime.world) showWorld(runtime.world);
canvas.focus({ preventScroll: true });
});
return element;
};
const output = document.createElement("output");
output.textContent = `${mart.name} ×${mart.selected} ¥${total}`;
stepper.append(
step("◀◀", "Minus 10", "left"),
step("▼", "Minus 1", "down"),
output,
step("▲", "Plus 1", "up"),
step("▶▶", "Plus 10", "right"),
);
menu.replaceChildren(
stepper,
button(mart.mode === "sell" ? "SELL" : "BUY", () => runtime.choose(mart.selected), { wide: true }),
button("CANCEL", () => runtime.choose(-1), { wide: true }),
);
}
const STATUS_NAMES: [number, string][] = [
[0x08, "PSN"],
[0x10, "BRN"],
+5 -1
View File
@@ -35,6 +35,10 @@ export function renderShell(root: HTMLElement) {
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
</div>
<div id="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p>
<div class="mart-box"><p id="mart-prompt" aria-live="polite"></p><div id="mart-menu" class="mart-menu"></div></div>
</div>
<div id="pokepic" class="pokepic" hidden><canvas id="pokepic-canvas" width="56" height="56" role="img"></canvas><button id="pokepic-next" hidden>Continue <span aria-hidden="true">▶</span></button></div>
<div id="native-dialogue" class="native-dialogue" aria-label="Dialogue" hidden><p id="dialogue-text" aria-live="polite"></p><div id="dialogue-choice" class="dialogue-choice" hidden></div><div><span id="dialogue-page"></span><button id="dialogue-next">Continue <span aria-hidden="true">▶</span></button></div></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing</button></div>
@@ -70,5 +74,5 @@ export function renderShell(root: HTMLElement) {
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
</div>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>Explore New Bark Town</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone includes your home, New Bark Town and Professor Elm's lab, with the original scenes, choices and your first Pokémon. Battles, routes, menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Cherrygrove</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City: wild battles, items, the Poké Mart and the Pokémon Center. Catching, trainers, menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
}
+81
View File
@@ -484,6 +484,87 @@ dialog h2 {
background: var(--blue);
border: 3px double #102d4b;
}
.mart {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
grid-template-rows: auto 1fr;
align-content: start;
gap: 8px;
padding: 10px;
color: var(--ink);
background: #f8f8f0;
}
.mart-money {
justify-self: end;
margin: 0;
padding: 4px 10px;
font-weight: 800;
font-variant-numeric: tabular-nums;
background: var(--cream);
border: 3px double var(--ink);
}
.mart-box {
min-height: 96px;
padding: 10px 12px;
background: var(--cream);
border: 5px double var(--ink);
}
.mart-box p {
margin: 0 0 6px;
white-space: pre-line;
}
.mart-menu {
display: grid;
gap: 6px;
max-height: 220px;
overflow: auto;
}
.mart-menu button {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
text-align: left;
color: var(--ink);
background: var(--cream-dark);
border: 3px double var(--ink);
}
.mart-menu button:disabled {
opacity: 0.5;
}
.mart-menu button.battle-selected {
outline: 2px solid currentColor;
outline-offset: -4px;
}
.mart-menu button small {
display: block;
font-size: 0.75rem;
}
.mart-menu .wide {
grid-column: 1 / -1;
}
.mart-quantity {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.mart-quantity button {
min-width: 40px;
padding: 6px;
color: var(--ink);
background: var(--cream-dark);
border: 3px double var(--ink);
}
.mart-quantity output {
flex: 1;
min-width: 0;
text-align: center;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.dialogue-choice button {
min-width: 64px;
}
+10
View File
@@ -54,6 +54,16 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
expect(world.save()).toEqual(saved);
});
it("struggles with recoil once every move is out of PP", () => {
const world = fixture();
world.state.party[0].pp = [0, 0, 0, 0];
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toMatch(/STRUGGLE/);
expect(messages).toMatch(/recoil/i);
});
it("escapes without awarding experience and returns to walking", () => {
const world = fixture();
encounter(world);
+78
View File
@@ -0,0 +1,78 @@
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");
async function start(page: Page) {
// Replace only this test's runtime module response, the same way
// battle-browser.spec.ts substitutes an encounter fixture: no fixture
// import or map override exists in the application or production build.
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 { martFixtureWorld } = await import('/tests/mart-fixture.ts');
const world = martFixtureWorld(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 exportState(page: Page) {
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"));
await page.locator("#tab-game").click();
return JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
}
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM; only map-enabling is a test fixture."));
test("buys a Potion from the Cherrygrove clerk with the keyboard", async ({ page }) => {
await start(page);
// The fixture places the player at the counter, already facing the clerk.
await page.keyboard.press("KeyX");
await expect(page.locator("#mart")).toBeVisible();
await expect(page.locator("#mart-menu")).toContainText("BUY");
await page.keyboard.press("KeyX"); // BUY is already highlighted
await expect(page.locator("#mart-menu")).toContainText("POTION");
await page.keyboard.press("KeyX"); // the Potion is first and already highlighted
await expect(page.locator(".mart-quantity output")).toHaveText(/×1/);
await page.keyboard.press("ArrowUp"); // quantity 2, exercising the stepper
await expect(page.locator(".mart-quantity output")).toHaveText(/×2/);
await page.keyboard.press("KeyX"); // confirm the quantity
await expect(page.locator("#dialogue-text")).toContainText("will be");
await expect(page.locator("#dialogue-choice")).toContainText("YES");
await page.keyboard.press("KeyX"); // YES is already highlighted
await expect(page.locator("#dialogue-text")).toContainText("Here you are");
await page.keyboard.press("KeyX"); // continue
// Back at the item list (BuyMenu loops for another purchase); leave.
await expect(page.locator("#mart-menu")).toContainText("POTION");
await page.keyboard.press("KeyZ"); // back out of the list
await page.keyboard.press("KeyZ"); // quit the BUY/SELL/QUIT menu
await expect(page.locator("#dialogue-text")).toContainText("Please come again");
await page.keyboard.press("KeyX");
await expect(page.locator("#mart")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
const state = await exportState(page);
expect(state.money).toBe(3000 - 300 * 2);
expect(state.bag.items).toEqual([{ item: 18, quantity: 2 }]);
});
+31
View File
@@ -0,0 +1,31 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
/** Places the player at the Cherrygrove Mart counter (facing the clerk
* behind it at 1,3 through the counter tile at 2,3) so `interact()` talks
* to them without walking the overworld from New Bark Town. */
export function martFixtureWorld(assets: CrystalAssets) {
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 save = world.save();
save.map = "26:4";
save.x = 3;
save.y = 3;
save.direction = "left";
world.restore(save);
// The first idle tick runs the new game's daily reset.
world.tick();
return world;
}
/** Talk to the clerk and let the mart's welcome screen present. */
export function talkToClerk(world: GameWorld) {
world.interact();
}
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { C } from "../src/game/battle/constants";
import { martFixtureWorld, talkToClerk } from "./mart-fixture";
import { settle } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = () => martFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))));
// constants/item_constants.asm
const POKE_BALL = 5,
BICYCLE = 7,
POTION = 18;
describe.skipIf(!existsSync(romPath))("Cherrygrove Mart with the local ROM", () => {
it("buys Potions at the ROM price and adds them to the bag", () => {
const world = fixture();
const money = world.state.money;
talkToClerk(world);
// BUY -> Potion (list index 0) -> quantity 3 -> confirm YES -> back -> QUIT
const shown = settle(world, [0, 0, 3, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toContain("Here you are");
expect(world.state.money).toBe(money - 300 * 3);
expect(world.state.bag.items).toEqual([{ item: POTION, quantity: 3 }]);
});
it("refuses to buy without enough money, changing nothing", () => {
const world = fixture();
world.state.money = 100; // less than one Potion (300)
talkToClerk(world);
const committed = world.save();
const shown = settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/enough money/i);
expect(world.save()).toEqual(committed);
});
it("refuses to buy into a full pocket, changing nothing", () => {
const world = fixture();
const data = world.assets.pokemon;
const fillers: number[] = [];
for (let id = 1; fillers.length < 20; id++) {
if (id === POTION) continue;
try {
if (data.itemPocket(id) === "item") fillers.push(id);
} catch {
// Not every id names an item; skip gaps.
}
}
world.state.bag.items = fillers.map((item) => ({ item, quantity: 1 }));
talkToClerk(world);
const committed = world.save();
const shown = settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/can't carry/i);
expect(world.save()).toEqual(committed);
});
it("sells items at half the ROM price", () => {
const world = fixture();
world.state.bag.items = [{ item: POTION, quantity: 5 }];
talkToClerk(world);
const money = world.state.money;
// SELL -> Potion (index 0) -> quantity 2 -> confirm YES -> back -> QUIT
const shown = settle(world, [1, 0, 2, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toContain("Got ¥300");
expect(world.state.money).toBe(money + 300); // 300/2 * 2
expect(world.state.bag.items).toEqual([{ item: POTION, quantity: 3 }]);
});
it("caps money at C.MAX_MONEY when selling", () => {
const world = fixture();
world.state.bag.items = [{ item: POTION, quantity: 1 }];
world.state.money = C.MAX_MONEY - 10;
talkToClerk(world);
const shown = settle(world, [1, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).not.toBe("");
expect(world.state.money).toBe(C.MAX_MONEY);
expect(world.state.bag.items).toEqual([]);
});
it("excludes key items from the sell list", () => {
const world = fixture();
world.state.bag.items = [{ item: POTION, quantity: 1 }];
world.state.bag.keyItems = [BICYCLE];
talkToClerk(world);
world.choose(1); // SELL
const choice = world.dialogue?.choice;
expect(choice?.kind).toBe("martlist");
if (choice?.kind === "martlist") {
expect(choice.entries.map((entry) => entry.item)).toEqual([POTION]);
}
});
it("sells a Poké Ball but refuses to sell key items or unaffordable buys together", () => {
// MART_CHERRYGROVE_DEX stocks a Poké Ball once the mystery egg is given;
// exercising a ball-pocket item confirms pockets beyond "item" work too.
const world = fixture();
world.state.events.add(31); // EVENT_GAVE_MYSTERY_EGG_TO_ELM
talkToClerk(world);
const money = world.state.money;
// BUY -> Poké Ball (list index 0 in the dex mart) -> quantity 1 -> YES -> back -> QUIT
settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.money).toBe(money - 200);
expect(world.state.bag.balls).toEqual([{ item: POKE_BALL, quantity: 1 }]);
});
it("quits immediately without changing state", () => {
const world = fixture();
const before = world.save();
talkToClerk(world);
settle(world, [2]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.save()).toEqual(before);
});
it("a mid-interaction save restores the committed state and replays it", () => {
const world = fixture();
talkToClerk(world);
const committed = world.save();
world.choose(0); // BUY
world.choose(0); // Potion -> quantity picker
expect(world.dialogue?.choice?.kind).toBe("martquantity");
expect(world.save()).toEqual(committed);
world.restore(committed);
expect(world.dialogue).toBeUndefined();
expect(world.save()).toEqual(committed);
// The world is usable again: a fresh visit still works.
talkToClerk(world);
expect(world.dialogue?.choice?.kind).toBe("martmenu");
});
});
+50
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { VECTORS } from "../src/game/world/types";
@@ -362,6 +363,55 @@ test("a home exit mat survives save/reload and waits for the exit direction", as
await expect(page.locator("#game-notice")).toHaveText("");
});
test("walks from New Bark Town onto Route 29 after receiving a starter", async ({
page,
}, testInfo) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const bytes = readFileSync(romPath);
const assets = new CrystalAssets(new Uint8Array(bytes));
const world = new GameWorld(assets);
const town = assets.map("24:4");
const edge = Array.from({ length: town.height }, (_, y) => y).find(
(y) => assets.permission(assets.collision(town, 0, y)) === 0,
)!;
world.state.party.push(
generatePartyMon(assets.pokemon, {
species: 155, level: 5, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
}),
);
// SCENE_NEWBARKTOWN_NOOP: after the starter the teacher no longer stops you.
world.restore({ ...world.save(), map: "24:4", x: 0, y: edge, direction: "left", scenes: { "24:4": 1 } });
const backup = testInfo.outputPath("new-bark-edge.crystal-save.json");
writeFileSync(
backup,
JSON.stringify({
version: 1,
romHash: createHash("sha1").update(bytes).digest("hex"),
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: Buffer.from(JSON.stringify(world.save())).toString("base64"),
}),
);
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
const screen = page.locator("#game-screen");
await page.keyboard.down("ArrowLeft");
await page.waitForFunction(
() => document.querySelector("#game-screen")?.getAttribute("data-map") === "24:3",
);
await page.keyboard.up("ArrowLeft");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(page.locator("#location")).toHaveText("Route 29");
await expect(page.locator("#game-notice")).toHaveText("");
});
test("development server does not serve ROMs or the private reference checkout", async ({
request,
}) => {
+254 -13
View File
@@ -5,12 +5,13 @@ import { RomReader } from "../src/game/rom/reader";
import { decompress } from "../src/game/rom/lz";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { NATIVE_MAPS, SYMBOLS } from "../src/game/rom/profile";
import {
ScriptRun,
emptyScriptState,
runScript,
} from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo } from "./world-driver";
@@ -213,7 +214,7 @@ describe.skipIf(!existsSync(romPath))(
const west = town.connections.find((c) => c.direction === "left")!;
const route = assets.map(west.targetMap);
expect(route.location).toBe("Route 29");
expect(assets.isPorted(route.id)).toBe(false);
expect(assets.isPorted(route.id)).toBe(true);
for (let y = 0; y < town.height; y++)
expect(assets.visibleBlock(town, -1, y)).toBe(
assets.block(route, west.x, y + west.y),
@@ -381,10 +382,6 @@ describe.skipIf(!existsSync(romPath))(
it("jumps over representative Route 29 ledges from the actual collision data", () => {
const assets = load();
const route = assets.map("24:3");
expect(assets.isPorted(route.id)).toBe(false);
// Enable only in this fixture: production still refuses Route 29's unported scripts.
const isPorted = assets.isPorted.bind(assets);
assets.isPorted = (id) => id === route.id || isPorted(id);
const world = new GameWorld(assets);
const checked = new Set<number>();
const directions: Record<number, Direction> = {
@@ -428,19 +425,88 @@ describe.skipIf(!existsSync(romPath))(
}
expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]);
});
it("gives the Route 29 item ball's Potion through FindItemInBallScript, hides the ball and commits atomically", () => {
const assets = load();
const route = assets.map("24:3");
const world = new GameWorld(assets);
const ball = route.objects.find((object) => object.kind === 1)!; // OBJECTTYPE_ITEMBALL
// The tile west of the ball is the only walkable approach.
world.restore({
...world.save(),
map: route.id,
x: ball.x - 1,
y: ball.y,
direction: "right",
objects: [],
});
expect(world.objects().some((object) => object.id === ball.id)).toBe(
true,
);
world.interact();
expect(settle(world)).toEqual([
"CHRIS found\nPOTION!",
"CHRIS put the\nPOTION in\nthe ITEM POCKET.",
]);
expect(world.diagnostic).toBe("");
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
expect(world.state.events.has(ball.eventFlag)).toBe(true);
// The ball disappears for the rest of the session...
expect(world.objects().some((object) => object.id === ball.id)).toBe(
false,
);
// ...and the pickup survives save()/restore().
const saved = world.save();
const restored = new GameWorld(assets);
restored.restore(saved);
expect(restored.save()).toEqual(saved);
expect(restored.objects().some((object) => object.id === ball.id)).toBe(
false,
);
});
it("shows the original no-room text and changes nothing when the item pocket is full", () => {
const assets = load();
const route = assets.map("24:3");
const world = new GameWorld(assets);
const ball = route.objects.find((object) => object.kind === 1)!;
const full = world.save();
// Twenty full POTION stacks: PutItemInPocket finds no room for another.
full.bag.items = Array.from({ length: 20 }, () => ({
item: 18,
quantity: 99,
}));
world.restore({
...full,
map: route.id,
x: ball.x - 1,
y: ball.y,
direction: "right",
objects: [],
});
world.interact();
expect(settle(world)).toEqual([
"CHRIS found\nPOTION!",
"But CHRIS can't\ncarry any more\nitems.",
]);
expect(world.diagnostic).toBe("");
expect(world.state.bag).toEqual(full.bag);
expect(world.state.events.has(ball.eventFlag)).toBe(false);
expect(world.objects().some((object) => object.id === ball.id)).toBe(
true,
);
});
it("refuses connections into unported maps and imports version 1 saves", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
const town = world.assets.map("24:4");
const edge = Array.from({ length: town.height }, (_, y) => y).find(
(y) =>
world.assets.permission(world.assets.collision(town, 0, y)) === 0,
const city = world.assets.map("26:3");
const edge = Array.from({ length: city.width }, (_, x) => x).find(
(x) =>
world.assets.permission(world.assets.collision(city, x, 0)) === 0,
)!;
world.restore({ ...world.save(), map: "24:4", x: 0, y: edge });
expect(world.move("left")).toBe(false);
expect(world.notice).toBe("Route 29 is not ported yet.");
world.restore({ ...world.save(), map: "26:3", x: edge, y: 0 });
expect(world.move("up")).toBe(false);
expect(world.notice).toBe("Route 30 is not ported yet.");
const {
scenes,
phone,
@@ -635,5 +701,180 @@ describe.skipIf(!existsSync(romPath))(
world.interact();
expect(settle(world)).toHaveLength(1);
});
it("heals a damaged, statused party through the Cherrygrove nurse at every time of day", () => {
const greetings = {
morning: ["Good morning!\nWelcome to our\nPOKéMON CENTER."],
day: ["Hello!\nWelcome to our\nPOKéMON CENTER."],
night: [
"Good evening!\nYou're out late.",
"Welcome to our\nPOKéMON CENTER.",
],
};
for (const [label, hour] of [
["morning", 8],
["day", 13],
["night", 21],
] as const) {
const assets = load();
const world = new GameWorld(
assets,
() => new Date(`2026-09-22T${String(hour).padStart(2, "0")}:00:00`),
);
const mon = generatePartyMon(assets.pokemon, {
species: 155,
level: 10,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
mon.moves = [45, 0, 0, 0]; // GROWL: base PP 40
mon.pp = [(3 << 6) | 2, 0, 0, 0]; // three PP Ups, nearly spent
mon.hp = 1;
mon.status = 0x08; // paralysis
world.state.party.push(mon);
world.restore({
...world.save(),
map: "26:5",
x: 3,
y: 3,
direction: "up",
});
// The counter at (3,2) lets interact() reach the nurse behind it.
world.interact();
expect(world.dialogue?.pages).toEqual(greetings[label]);
const pages = settle(world, [1]);
expect(pages).toContain("Your POKéMON are\nfully healed.");
const healed = world.save().party[0];
expect(healed.hp).toBe(healed.stats[0]);
expect(healed.status).toBe(0);
// ComputeMaxPP clamps each PP Up's bonus to 7, not 8: 40 + 3*7 = 61.
expect(healed.pp).toEqual([(3 << 6) | 61, 0, 0, 0]);
expect(world.diagnostic).toBe("");
}
});
it("leaves the party unchanged when healing is declined", () => {
const assets = load();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T13:00:00"),
);
const mon = generatePartyMon(assets.pokemon, {
species: 155,
level: 10,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
mon.hp = 1;
mon.status = 0x08;
world.state.party.push(mon);
world.restore({
...world.save(),
map: "26:5",
x: 3,
y: 3,
direction: "up",
});
world.interact();
const before = structuredClone(world.save().party);
const pages = settle(world, [0]);
expect(pages.at(-1)).toBe("We hope to see you\nagain.");
expect(pages).not.toContain("Your POKéMON are\nfully healed.");
expect(world.save().party).toEqual(before);
expect(world.diagnostic).toBe("");
});
it("records the whiteout spawn by walking into the Cherrygrove Pokécenter, and keeps it through save/restore", () => {
const assets = load();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T13:00:00"),
);
expect(world.save().lastSpawnMap).toBeNull();
world.restore({
...world.save(),
map: "26:3",
x: 29,
y: 4,
direction: "up",
});
// The Center's door is an automatic warp; EnterMapWarp's SetSpawn
// records the outdoor map (Cherrygrove City) as the new blackout spot.
expect(world.move("up")).toBe(true);
expect(world.mapId).toBe("26:5");
expect([world.x, world.y]).toEqual([3, 7]);
expect(world.save().lastSpawnMap).toBe("26:3");
const restored = new GameWorld(
world.assets,
() => new Date("2026-09-22T13:00:00"),
);
restored.restore(world.save());
expect(restored.save()).toEqual(world.save());
});
it("honors the recorded spawn for the whiteout warp, and defaults to the player's house", () => {
const assets = load();
const host = {
mapId: "1:1",
lastTalked: 0,
sceneOwner: () => undefined,
timeOfDay: () => 1,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0,
text: () => defaultTextContext("CHRIS"),
data: assets.pokemon,
player: () => ({ id: 0, gender: 0 as const }),
random: () => 0,
landmark: () => 0,
facing: () => 0,
position: () => ({ x: 0, y: 0 }),
};
// Runs the ROM's own battleWhiteoutScript end to end: HealParty,
// GetWhiteoutSpawn and WarpToSpawnPoint all through the interpreter.
const whiteoutWarp = (lastSpawnMap: string | null) => {
const state = emptyScriptState();
state.lastSpawnMap = lastSpawnMap;
const run = new ScriptRun(
assets.reader,
SYMBOLS.battleWhiteoutScript,
state,
host,
);
let effect = run.resume();
while (effect && effect.kind !== "warp") effect = run.resume();
return effect;
};
expect(whiteoutWarp("26:3")).toEqual({
kind: "warp",
map: "26:3",
x: 29,
y: 4,
});
// No recorded Center, or one the spawn table no longer lists: SPAWN_HOME.
expect(whiteoutWarp(null)).toEqual({
kind: "warp",
map: "24:7",
x: 3,
y: 3,
});
expect(whiteoutWarp("1:1")).toEqual({
kind: "warp",
map: "24:7",
x: 3,
y: 3,
});
});
it("migrates a save without the spawn field to the player's house default", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T13:00:00"),
);
const { lastSpawnMap, ...withoutSpawn } = world.save();
void lastSpawnMap;
expect(() => world.restore(withoutSpawn)).not.toThrow();
expect(world.save().lastSpawnMap).toBeNull();
});
},
);
+2 -1
View File
@@ -4,6 +4,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
import { BattleData } from "../src/game/battle/data";
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { resolve } from "node:path";
const romPath = resolve(
@@ -96,7 +97,7 @@ test("native decompression matches original GB assembly executing in the referen
reference.dispose();
return comparisons;
});
expect(result.length).toBe(6);
expect(result.length).toBe(Object.keys(NATIVE_MAPS).length);
for (const comparison of result) {
expect(
comparison.returned,
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { settle, findPath, walkTo } from "./world-driver";
import { generatePartyMon } from "../src/game/party/pokemon";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const BERRY = 0xad;
const ROUTE_29 = "24:3";
describe.skipIf(!existsSync(romPath))("Route 29 with the local ROM", () => {
/** `now` is mutable per test. */
const fixture = (date = "2026-09-22T12:00:00") => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const clock = { now: new Date(date) };
const world = new GameWorld(assets, () => clock.now);
// Facing FRUITTREE_ROUTE_29 at (12, 2).
world.restore({ ...world.save(), map: ROUTE_29, x: 12, y: 3, direction: "up" });
world.tick();
return { world, clock };
};
const berries = (world: GameWorld) =>
world.state.bag.items.find((slot) => slot.item === BERRY)?.quantity ?? 0;
const pick = (world: GameWorld) => {
world.interact();
const pages = settle(world);
expect(world.notice, world.diagnostic).toBe("");
return pages.join("\n");
};
it("picks a Berry once per day and regrows after the daily reset", () => {
const { world, clock } = fixture();
expect(world.state.dailyReset).not.toBeNull();
expect(pick(world)).toMatch(/BERRY/);
expect(berries(world)).toBe(1);
expect(world.state.fruitTrees).toEqual(new Set([1]));
expect(world.state.flags.has(ENGINE_FLAGS.allFruitTrees)).toBe(true);
// Same day, even across a save and reload: nothing to pick.
const saved = world.save();
expect(saved).toMatchObject({ fruitTrees: [1] });
world.restore(saved);
expect(world.save()).toEqual(saved);
pick(world);
expect(berries(world)).toBe(1);
// The first idle tick of the next day clears the daily flag.
clock.now = new Date("2026-09-23T00:01:00");
world.tick();
expect(world.state.flags.has(ENGINE_FLAGS.allFruitTrees)).toBe(false);
expect(pick(world)).toMatch(/BERRY/);
expect(berries(world)).toBe(2);
});
it("leaves the tree unpicked when the item pocket is full", () => {
const { world } = fixture();
world.state.bag.items = Array.from({ length: 20 }, (_, index) => ({
item: index + 1,
quantity: 1,
}));
const before = world.state.bag.items.map((slot) => ({ ...slot }));
pick(world);
expect(world.state.bag.items).toEqual(before);
expect(world.state.fruitTrees.size).toBe(0);
});
it("migrates version-3 saves without daily state and rejects invalid trees", () => {
const { world } = fixture();
const { fruitTrees: _, dailyReset: __, ...legacy } = world.save();
expect(migrateSave(legacy, () => 0)).toMatchObject({
fruitTrees: [],
dailyReset: null,
});
expect(() => world.restore({ ...world.save(), fruitTrees: [0] })).toThrow(
"Invalid native save",
);
expect(() => world.restore({ ...world.save(), fruitTrees: [1, 1] })).toThrow(
"Invalid native save",
);
});
it("walks from New Bark Town across Route 29 into Cherrygrove and a house", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
// Random byte 255 never passes an encounter-rate check.
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 255);
world.state.party.push(
generatePartyMon(assets.pokemon, {
species: 155, level: 5, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
}),
);
// After the starter, Elm sets SCENE_NEWBARKTOWN_NOOP: the teacher lets you go.
world.restore({ ...world.save(), map: "24:4", x: 5, y: 8, scenes: { "24:4": 1 } });
const crossWest = (next: string) => {
const edge = Array.from({ length: world.map.height }, (_, y) => y).find((y) => {
try {
findPath(world, 0, y);
return true;
} catch {
return false;
}
})!;
walkTo(world, 0, edge);
expect(world.move("left"), world.notice).toBe(true);
settle(world);
expect(world.mapId).toBe(next);
expect(world.notice, world.diagnostic).toBe("");
};
crossWest(ROUTE_29);
crossWest("26:3");
walkTo(world, 17, 8);
expect(world.move("up")).toBe(true);
settle(world);
expect(world.mapId).toBe("26:6");
expect(world.notice, world.diagnostic).toBe("");
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
});