diff --git a/README.md b/README.md index ec0eb33..2001ba5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index f4645bb..8edd0a9 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -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. diff --git a/docs/PLAN.md b/docs/PLAN.md index bf28d99..a3ec247 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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. diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 3688783..4fd9ce5 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -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. diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs index 0198ac0..46118e2 100644 --- a/scripts/check-reference.mjs +++ b/scripts/check-reference.mjs @@ -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) diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index 033f08d..c37aa99 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -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; diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts index f9197db..89ebd10 100644 --- a/src/game/battle/constants.ts +++ b/src/game/battle/constants.ts @@ -234,6 +234,7 @@ export const COMMAND = { freezetarget: 24, paralyzetarget: 25, flinchtarget: 37, + recoil: 39, confuse: 42, confusetarget: 43, poison: 47, diff --git a/src/game/rom/assets.ts b/src/game/rom/assets.ts index b9a6b2b..1d30042 100644 --- a/src/game/rom/assets.ts +++ b/src/game/rom/assets.ts @@ -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, diff --git a/src/game/rom/pokemon.ts b/src/game/rom/pokemon.ts index 55401c2..73a2aab 100644 --- a/src/game/rom/pokemon.ts +++ b/src/game/rom/pokemon.ts @@ -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); diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index 3b1e460..2a22422 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -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 = { + "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; diff --git a/src/game/runtime.ts b/src/game/runtime.ts index 7223bf4..5c00ee1 100644 --- a/src/game/runtime.ts +++ b/src/game/runtime.ts @@ -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; diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index f535ede..f2d374c 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -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; caught: Set }; /** wMoney: a 24-bit amount. */ money: number; + /** wFruitTreeFlags: trees (FRUITTREE_* ids) picked since the last reset. */ + fruitTrees: Set; + /** 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 = {}; + /** Native values for text_decimal, keyed by RAM address (wItemQuantityChange, + * hMoneyTemp): the mart's quantity and price texts. */ + private numbers: Record = {}; /** 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; 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + ): Generator { 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; } diff --git a/src/game/world/world.ts b/src/game/world/world.ts index 9f6e4ff..c0b22f7 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -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; } diff --git a/src/main.ts b/src/main.ts index 90dbf51..ad3b67c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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"], diff --git a/src/ui/shell.ts b/src/ui/shell.ts index d5cf103..68237dc 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -35,6 +35,10 @@ export function renderShell(root: HTMLElement) {
HP

+ @@ -70,5 +74,5 @@ export function renderShell(root: HTMLElement) {

Session backups stay in this browser.

-

Explore New Bark Town

Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.

Move
Arrow keys
Interact / Continue
X / Z or A / B

Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.

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.

Choose
Up / Down, then X or A. Z or B answers No.

An optional walking companion may support egg progress after party, eggs and hatching are complete.

`; +

From New Bark Town to Cherrygrove

Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.

Move
Arrow keys
Interact / Continue
X / Z or A / B

Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.

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.

Choose
Up / Down, then X or A. Z or B answers No.

An optional walking companion may support egg progress after party, eggs and hatching are complete.

`; } diff --git a/src/ui/styles.css b/src/ui/styles.css index 31ac577..d9e90e5 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -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; } diff --git a/tests/battle-world.test.ts b/tests/battle-world.test.ts index f8132c4..20921ae 100644 --- a/tests/battle-world.test.ts +++ b/tests/battle-world.test.ts @@ -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); diff --git a/tests/mart-browser.spec.ts b/tests/mart-browser.spec.ts new file mode 100644 index 0000000..9b522be --- /dev/null +++ b/tests/mart-browser.spec.ts @@ -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 }]); +}); diff --git a/tests/mart-fixture.ts b/tests/mart-fixture.ts new file mode 100644 index 0000000..d0a7401 --- /dev/null +++ b/tests/mart-fixture.ts @@ -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(); +} diff --git a/tests/mart.test.ts b/tests/mart.test.ts new file mode 100644 index 0000000..93ad91a --- /dev/null +++ b/tests/mart.test.ts @@ -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"); + }); +}); diff --git a/tests/native-browser.spec.ts b/tests/native-browser.spec.ts index 5c558ea..97f8d33 100644 --- a/tests/native-browser.spec.ts +++ b/tests/native-browser.spec.ts @@ -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, }) => { diff --git a/tests/native.test.ts b/tests/native.test.ts index f50157c..539ec86 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -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(); const directions: Record = { @@ -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(); + }); }, ); diff --git a/tests/reference-parity.spec.ts b/tests/reference-parity.spec.ts index 9cccc1d..9cf6e2a 100644 --- a/tests/reference-parity.spec.ts +++ b/tests/reference-parity.spec.ts @@ -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, diff --git a/tests/route29.test.ts b/tests/route29.test.ts new file mode 100644 index 0000000..0adfd8a --- /dev/null +++ b/tests/route29.test.ts @@ -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); + }); +});