diff --git a/README.md b/README.md index dff6ce6..ec0eb33 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ 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: New Bark Town exploration.** Walk around your room, downstairs, town and its buildings. Read signs, use the bedroom radio, try supported conversations, and save your progress. Opening scenes, story progression, battles, NPC movement, decorations, audio and the rest of the world are still being ported. +**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. + +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. ## Run @@ -15,9 +19,9 @@ npm run dev Open the printed local URL, choose a name/character and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. -Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data. +Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data. -Native saves are separate from emulator snapshots and cartridge `.sav` files. Time-of-day colors follow the device clock. The tile counter measures in-game movement, not real-world steps. +Native saves are separate from emulator snapshots and cartridge `.sav` files. Saving during a scene keeps the progress from before it, so the scene replays after loading. Time of day follows the device clock; the weekday you give Mom is kept as an offset from the device's weekday. The tile counter measures in-game movement, not real-world steps. ## Reference emulator @@ -42,7 +46,7 @@ npm run check:vendor Local-ROM tests use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root, or `CRYSTAL_ROM_PATH`. They skip when absent; no test downloads a ROM. Game files, private reference builds and gameplay screenshots must remain ignored. -The [full port plan](docs/PLAN.md) covers remaining game systems, mobile UI, internet Link and walking. See the [architecture](docs/ARCHITECTURE.md) and [routine map and verification](docs/NATIVE_PORT.md) for module boundaries and reference checks. +The [full port plan](docs/PLAN.md) covers remaining game systems, save slots, the world map, Pokédex, internet Link, friends and optional mobile step support for egg hatching. See the [architecture](docs/ARCHITECTURE.md) and [routine map and verification](docs/NATIVE_PORT.md) for module boundaries and reference checks. References: PRET [documentation](https://pret.github.io/pokecrystal/), [wiki](https://github.com/pret/pokecrystal/wiki) and [source](https://github.com/pret/pokecrystal). The development reference uses [binjgb](https://github.com/binji/binjgb) under its [MIT license](reference/public/vendor/binjgb/LICENSE); see [provenance](reference/public/vendor/binjgb/PROVENANCE.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3bc422..4a2941b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ flowchart LR ROM[User ROM] --> Gate[Exact revision validation] Gate --> Assets[ROM reader and asset decoders] Assets --> World[Native world rules] - Assets --> Scripts[Native event and text interpreters] + Assets --> Scripts[Resumable event, movement and text interpreters] Scripts --> World Input[Touch and keyboard] --> Runtime[Native runtime] Runtime --> World @@ -17,28 +17,35 @@ flowchart LR Reference[Separate reference emulator] -. local comparisons only .-> World ``` -| Module | Responsibility | -| ------------------------------- | ------------------------------------------------------------------------------------------- | -| `src/rom/validate.ts` | Exact cartridge identity, no networking | -| `src/game/rom` | Banked reads, LZ, maps/tilesets/sprites/palettes and pinned addresses | -| `src/game/world` | Position, movement, collision, warps, visibility, time selection and native save validation | -| `src/game/scripts` | The game's event/text formats, no GB instructions | -| `src/game/rendering` | Planar pixels, metatiles, camera and sprites | -| `src/game/runtime.ts` | Frame scheduling, input, interpolation and presentation updates | -| `src/input` | Abstract buttons, multiple sources and browser bindings | -| `src/app` | Runtime-independent load/play/pause/backup lifecycle | -| `src/storage` | Versioned envelope, IndexedDB transactions and ordered writes | -| `src/ui`, `src/main.ts` | UI composition and DOM dialogue, no ROM offsets | -| `src/platform` | Browser locks and future native platform adapters | -| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build | -| `reference/public` | Licensed emulator files for the reference build only | +| Module | Responsibility | +| ------------------------------- | ----------------------------------------------------------------------------------------------- | +| `src/rom/validate.ts` | Exact cartridge identity, no networking | +| `src/game/rom` | Banked reads, LZ, maps/tilesets/sprites/palettes and pinned addresses | +| `src/game/world` | Overworld ticks, movement, collision, warps, connections, scenes, callbacks and save validation | +| `src/game/scripts` | The game's event, movement and text formats and translated specials, no GB instructions | +| `src/game/rendering` | Planar pixels, metatiles, connection strips, adaptive camera, sprites, emotes and minimap | +| `src/game/party` | Party Pokémon generation, stat formulas, bag pockets and their validation | +| `src/game/runtime.ts` | 30 Hz tick scheduling from real time, input routing and presentation updates | +| `src/input` | Abstract buttons, multiple sources and browser bindings | +| `src/app` | Runtime-independent load/play/pause/backup lifecycle | +| `src/storage` | Versioned envelope, IndexedDB transactions and ordered writes | +| `src/ui`, `src/main.ts` | Accessible game tabs, UI composition and DOM dialogue, no ROM offsets | +| `src/platform` | Browser locks and future native platform adapters | +| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build | +| `reference/public` | Licensed emulator files for the reference build only | -`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON: map, position, facing, name/avatar, in-game tile count, flags and sprite variables. It contains no graphics, music, dialogue, GB memory or ROM bytes. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID. +`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON, schema version 3. It holds the map, position, facing, name/avatar, player ID and in-game tile count; event and engine flags and sprite variables; scene values, phone contacts, the weekday offset and DST flag; positions of objects that scripts moved; and the party, bag, Pokédex sets and money. Early version-3 backups without a money field migrate to the starting balance. It contains no graphics, music, dialogue, GB memory or ROM bytes. Versions 1 and 2 migrate with new-game defaults. Party Pokémon are validated by recomputing their stats from the ROM. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID. + +Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival. Native and reference sessions use different IndexedDB databases and Web Locks. Imports reject incompatible schemas and invalid positions before changing the world. A failed import restores the prior state. Original cartridge saves and emulator snapshots cannot be used as native saves. -Scripts evaluate against a private draft. Unknown commands abort it. Dialogue commits only after the last page is acknowledged. A mid-dialogue save contains the last committed state, allowing the interaction to be replayed without partial rewards/flags. Automatic story scenes and callbacks are explicitly disabled in the exploration milestone. +`GameWorld` advances in overworld ticks of two video frames, as the original loop does. The runtime converts elapsed time to ticks. Tile events follow `CheckTileEvent`: a completed step checks connections, then warps, then coordinate events for the current scene. Idle ticks run the current scene script. Map entry runs the NEWMAP, TILES and OBJECTS callbacks on a copy first, so an unsupported callback refuses the step instead of entering a half-initialized map. Objects spawn from event flags at map load, and later flag changes leave them in place until `appear`/`disappear` or the next load. + +Scripts are generators that evaluate against a private draft and yield presentation effects: pages, yes/no or weekday choices, movement, emotes and pauses. The world presents each effect and resumes the script with the answer, or after the animation's ticks. Unknown commands, movement commands and specials abort. Aborting discards the draft and restores the positions the scene changed. The draft commits only when the script ends. A save made during a scene contains the committed state, and coordinate events rewind to the tile before the triggering step, so the scene replays without partial rewards or flags. Specials are translated natively and selected by the routine that the ROM's special table points to. Asset caches and the ROM reader remain in memory for lazy extraction. Neither ROMs nor extracted assets enter IndexedDB, the build or a backend. Vite blocks serving the root ROM and private reference checkout during development. -Introduce future modules with working features: `game/battle`, `game/party`, `game/inventory`, `game/audio`, then `features/link` and `features/walking`. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state. +The native viewport follows its available CSS dimensions and draws additional world tiles at a bounded pixel resolution. Beyond a map's edge it draws the connected map with the current tileset, as the original connection strips do, and the border block elsewhere. Resizing also redraws paused sessions. The local minimap caches terrain from the loaded map and updates its marker from the same world position used by the player; the Map tab shares that view. It is a current-area map, not a full region atlas. Leaving Game pauses the session and releases held input; returning requires explicit play. The reference player retains its own fixed-screen styling. + +`game/battle` now holds the native battle core and ROM-backed battle tables. Its unit and damage/type reference tests do not yet establish the full overworld-to-battle browser flow. Introduce future modules with working features: `game/audio`, then `features/link` and `features/walking`. Party and bag models live in `game/party`; scripts change them only on their draft state. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state. diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index fcdc23b..f4645bb 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -2,7 +2,7 @@ ## Reproducible reference -The private `.reference/pokecrystal` checkout is pinned to `7a7881d0d62e0ddbd82dcf10e7116807487ac651`. With RGBDS 1.0.3, its `crystal11` build is byte-identical to the supplied English US/Europe revision 1.1 ROM. `npm run check:reference` verifies the files, commit and every address in `src/game/rom/profile.ts` against generated symbols. +The private `.reference/pokecrystal` checkout is pinned to `7a7881d0d62e0ddbd82dcf10e7116807487ac651`. With RGBDS 1.0.3, its `crystal11` build is byte-identical to the supplied English US/Europe revision 1.1 ROM. `npm run check:reference` verifies the files and commit. It checks every ROM and RAM address in `src/game/rom/profile.ts` against generated symbols, event constants against `constants/event_flags.asm`, and map-group bounds against `data/maps/maps.asm`. To recreate the private reference: @@ -21,23 +21,31 @@ The reference checkout, rebuilt ROM, extracted graphics, saves and gameplay scre These paths refer to the pinned [PRET source](https://github.com/pret/pokecrystal/tree/7a7881d0d62e0ddbd82dcf10e7116807487ac651). -| Native module | PRET reference | Evidence and limits | -| -------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `game/rom/lz.ts` | `home/decompress.asm` | All LZ command types have unit vectors; six tilesets match original ASM execution byte-for-byte. | -| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `macros/scripts/maps.asm` | Six-map dimensions, events, destinations, navigation and return warps checked with the actual ROM. Connections are decoded but traversal is disabled. | -| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Day/night palettes and roofs supported; terrain animation/dynamic tiles and other special palettes pending. | -| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm` | Chris/Kris and standard NPC graphics/facings decoded. Dynamic Pokémon icons and room decorations pending. | -| `game/world/world.ts` | `engine/overworld/player_movement.asm`, `data/collision/collision_permissions.asm` | Walking, walls, visible objects and ordinary local warps. Not complete frame-by-frame parity; forced movement, ledges and surfing are refused. | -| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Bounded calls/jumps/conditions, flags, initial sprite variables and basic text interactions. Unknown commands abort; no assembly fallback. | -| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm` | Literal/far text, line/paragraph controls and player substitution. Dynamic RAM/ASM text is unsupported. | +| Native module | PRET reference | Evidence and limits | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `game/rom/lz.ts` | `home/decompress.asm` | All LZ command types have unit vectors; six tilesets match original ASM execution byte-for-byte. | +| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `data/maps/scenes.asm`, `data/maps/landmarks.asm`, `macros/scripts/maps.asm` | Any map header in the ROM's 26 groups decodes; only the six ported maps can be entered. Location names, scene-variable owners and connection offsets come from the ROM. | +| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Connected maps draw beyond the edges with the current tileset, like the original strips. Terrain animation and other special palettes pending. | +| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm`, `data/sprites/emotes.asm` | Chris/Kris, standard NPCs, emote bubbles and jump shadows decoded. Walking frames follow `SetFacingStepAction`. Dynamic Pokémon icons and room decorations pending. | +| `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. | +| `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. | -Supported event opcodes are explicit in the interpreter switch. Audio commands and delays consume their operands, but sound sequencing and presentation timing are pending; those opcodes do not imply complete audiovisual behavior. +Supported event opcodes are explicit in the interpreter switch. Audio commands consume their operands, but sound sequencing is pending; those opcodes do not imply complete audiovisual behavior. `pause` holds the current text or waits the original number of overworld ticks. + +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. ## Actual emulator comparison `tests/reference-parity.spec.ts` loads the user's ROM into the reference emulator and places a small original test trampoline in emulated WRAM. It calls the ROM's `FarDecompress` routine at `$0b40`, then sets a return sentinel. Every output byte is compared with the native decoder for six supported map tilesets. This executes the original routine rather than comparing two copies of our code. -That establishes decompression parity for those inputs. It does not prove complete gameplay, rendering, battles, audio, timing or physical-device parity. Extend controlled comparisons as each subsystem is translated. +The same suite compares 400 damage cases and 400 type-effectiveness cases per browser against the original battle routines. Battle unit tests cover win, escape, loss, experience, level-up and move-learning flows. This establishes parity only for the compared routines and inputs. It does not prove complete gameplay, rendering, battles, audio, timing or physical-device parity. Extend controlled comparisons as each subsystem is translated. ## Validation @@ -55,6 +63,10 @@ User-ROM tests skip when the file is absent, including public CI. Synthetic test Native production is `dist/`; the optional reference output is `dist-reference/`. Release checks reject emulator dependencies, binary game files and embedded binary assets in the native output. Source review is still required when adding assets or data. +## Save schema + +Native saves are version 3. Version 2 added scene values by owning map, phone contacts, the weekday offset and DST flag, and positions of objects that scripts moved on the current map. Version 3 adds the player ID, party, bag, Pokédex seen/caught sets and money. Early version-3 backups without money receive the original starting balance; explicitly invalid balances are rejected. Restores recompute each Pokémon's stats from its DVs and reject any mismatch, and check every item against its ROM pocket. Older saves migrate with new-game defaults; a missing player ID is drawn as a new game draws it. Objects are restored from the save on continue, and only the TILES callback runs, as in `MapSetupScript_Continue`. + ## Next slice -Expand automatic scenes/callbacks and native party/inventory models to implement the opening through starter selection. Add the first battle loop with Route 29 and the Mystery Egg quest. Keep this story milestone separate from exploration saves until initialization and migration are defined. +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. diff --git a/docs/PLAN.md b/docs/PLAN.md index 1d96485..91dfd29 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -17,17 +17,47 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati - A bounded interpreter for a subset of the game's own event bytecode, plus text decoding. This does not execute GB instructions. - Original initial event flags, readable signs, bedroom radio, bookshelves and supported conversations. Unknown interactions abort without committing partial changes. - Native DOM dialogue, name/avatar choice, touch/keyboard controls and pause/resume. +- A minimal Crystal-style shell with Game, Savegames, Map, Pokédex, Link and Friends tabs across the top. The game uses the available native canvas, and leaving Game releases input and pauses play. +- Existing save, backup export and backup import actions in Savegames. Map shares a live local minimap with the game and shows the current supported area, player position and coordinates. Pokédex, Link and Friends are clearly marked as planned rather than populated with placeholder data. - Native JSON state inside the backup envelope, separate IndexedDB storage and a separate tab lock. No emulator snapshot or cartridge save migration is implied. - Synthetic tests, local-ROM tests, browser checks, and comparisons against the original decompression routine running in the reference emulator. - Native production output excludes the emulator and original game assets. -This is **exploration**, not a complete new game or story-compatible save. Opening scenes, automatic map callbacks/coordinate events, story progression, NPC movement, decorations, animated terrain, full collision behavior, connected routes, party/inventory, battles and audio remain unported. Online Link and real-world steps come later. +## Implemented: opening scenes + +- Resumable event interpreter: text, yes/no and weekday choices, scripted movement, `follow`, emotes, pauses, scene variables, phone contacts, string buffers and translated specials. Unknown commands still abort without committing. +- Movement data decoder for turns, steps, slides, jumps, sleeps, facing locks, visibility and emotes, animated at the original 30 Hz overworld rate. Player walking now takes the original 8 ticks per tile, not half that. +- Coordinate events, scene scripts with `sdefer`, and NEWMAP/TILES/OBJECTS map callbacks, including the bedroom's own initialization callback. Objects spawn at map load as in the original. +- Mom's Pokégear, weekday and DST scene; the teacher at the town's west edge; the rival at the lab window; and Elm's introduction with the lab's exit guard, all from the ROM's scripts. +- Map headers for all 26 groups, ROM location names and connected maps drawn beyond the edges. Entering an unported connection is refused with its location name. +- Version 2 native saves with scenes, phone, game-clock settings and moved objects; version 1 saves migrate. + +## Implemented: starter and first items + +- ROM decoders for species names, base data, learnsets, moves, items, pockets, growth rates and front pictures. +- Native party generation for script gifts, following `GeneratePartyMonStats`: random DVs, level-up moves, PP, exp, stats, happiness and caught data. The Pokédex records the species as seen and caught. +- Bag pockets with the original capacities and stacking rules. `verbosegiveitem` runs the ROM's own `GiveItemScript`. +- Elm's Poké Balls, with a picture box, the nickname prompt with a native naming field, Elm's directions and phone number, and the aide's Potion. +- Version 3 saves with the player ID, party, bag and Pokédex; restores recompute stats and check item pockets. + +## Implemented: ledges and warp movement + +- 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. + +## 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. Full browser battle flows, capture, Route 29 scripts and the opening quest remain unverified or 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. ## 1. Complete the overworld foundation -Extend extraction to every map. Read the ROM's map index and location names rather than shipping a copied world database. Implement connected borders, seamless transitions, dynamic warps, elevators, ledges, ice, currents, forced movement, surfing, cycling, cut trees and boulders. +Map headers, location names, connection offsets, connected-border drawing, coordinate events, scene scripts, map callbacks and scripted movement are in place (see above). Next: enable connected transitions as their maps are ported; add dynamic warps, elevators, ice, currents, remaining forced movement, surfing, cycling, cut trees and boulders. Player turn timing still needs a reference comparison. -Port callbacks, decorations, tile changes, scheduled NPC visibility, NPC movement, trainer sight lines and scripted movement. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them. +Port decorations, tile changes (`changeblock`), NPC roaming movement types, trainer sight lines and the remaining movement commands. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them. Acceptance: representative outdoor/interior/cave/connected-map traces produce matching positions, collisions, transitions and event changes in native and reference engines. Do not enable an area merely because its image can be decoded. @@ -37,9 +67,9 @@ 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. -Implement the introduction, naming/clock setup, meeting Mom, Elm's starter selection, Route 29, Cherrygrove and the Mystery Egg round trip. Keep exploration saves separate until migration is meaningful. +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. -Build the required native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. +Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. Acceptance: finish the opening quest, including encounters and the rival battle, party/inventory changes and save/reload, without emulation or manually setting story flags. @@ -55,13 +85,31 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba ## 4. Modern mobile presentation and delivery +The first presentation increment replaces the landing page and handheld frame with a restrained Crystal-style interface. Game, Savegames, Map, Pokédex, Link and Friends stay above a viewport-filling native canvas. The renderer draws more world tiles as space grows instead of stretching a 160 × 144 image. The game and Map tab share a live local minimap; the atlas and unsupported tabs remain honest planned states. The linked UI reference could not be inspected through automated access because the site returned a Cloudflare challenge, so exact screenshot matching is not claimed. + Build touch interfaces directly over native models: party, pack, battle commands, PC and Pokégear. Keep UI separate from gameplay calculations. Add scalable text, configurable controls and accessible focus/announcements. +Replace the single logical native save with real slots only after party, inventory and story state have stable schemas. Each slot needs a durable ID, display metadata, independent autosave and explicit create, load, rename, duplicate, export, import and delete flows. Version every persisted model. Migrate the current singleton save into a default slot exactly once, preserve unknown or newer backups without rewriting them, and use atomic writes with recovery from the last valid record. Import must preview identity, version and conflicts before replacement. + +Dependencies: stable serialization for every system stored in a slot, migration fixtures for every released schema, quota/error handling and a transaction boundary that also covers future Link and walking credits. + +Acceptance: migrate existing browser data without loss, keep several independent playthroughs, recover from interrupted writes and reject incompatible or corrupt imports without changing a valid slot. Test create/load/delete/rename/duplicate, autosave isolation, export/import conflicts and upgrades from every released schema. + Add PWA installation/offline caching of the app shell only. ROM/data retention remains a separate explicit local preference; otherwise reselect the ROM after reopening. Add safe application updates, schema migrations, storage persistence/quota handling and portable backups. Investigate original `.sav` import only with verified structure, checksums and RTC conversion. Acceptance: installation, offline startup, portrait/landscape, touch/audio, background/resume and updates on physical iPhone/Safari and Android/Chrome. Browser automation does not establish physical-device compatibility. -## 5. Internet Link +## 5. World map and Pokédex + +Promote the current-area minimap into a Johto/Kanto atlas after all map groups, connections and location names can be decoded from the selected ROM. Derive region geometry, visited areas, player location and Fly destinations from native map and story state. Do not ship copied map artwork or claim navigation through maps whose collisions and events are still unsupported. + +Build the Pokédex from typed ROM decoders for species names, base data, evolutions, moves and graphics. Seen, caught, forms and habitat or area views must come from native game state and encounter tables. Keep ROM-derived records unavailable until a verified ROM is selected. + +Dependencies: complete map indexing and connections, stable player/story flags, party and capture flow, encounter decoding, species graphics and save migrations. + +Acceptance: atlas positions and Pokédex records match pinned PRET fixtures and local-ROM comparisons; visited, seen and caught state survives slot switching, backup round trips and schema upgrades. The UI must distinguish decoded data from progress the player has actually unlocked. + +## 6. Internet Link and friends Implement a game-level protocol over native state. This architecture does not need to tunnel serial timing or synchronize GB CPU cycles. @@ -69,17 +117,31 @@ Separate `LinkTransport`, `TradeSession` and `BattleSession`. Start with local p Trades need durable preparation, a transaction ID, agreed completion, idempotent recovery and pre-trade backups for both players. Do not retry ambiguous completion automatically. Battles need deterministic state, agreed randomness, turn commands, state checks and a disconnect policy. Define validation, room expiry and persistence before public service access. -Acceptance: cross-network trades/battles and induced duplicate messages, latency, disconnects and reconnects at every phase, without duplication or lost Pokémon. +Add a separate account and relationship layer before Friends becomes active. Define a stable public identity that does not expose save-slot or ROM data. Support private invite creation, acceptance and expiry; friend removal; user blocking; and abuse reporting if user-generated text is ever added. Presence is opt-in, coarse and independently disableable. Privacy controls must cover discoverability, invite permissions and online status. Blocking must prevent new invites, presence disclosure and Link sessions in both directions. -## 6. Walking companion +Dependencies: native party, PC, battle and save-slot transactions; a versioned wire protocol; authenticated identity; signaling/relay operations; account recovery and deletion; rate limits, moderation operations and a published privacy policy. Link and Friends remain unavailable until these dependencies have production ownership and failure handling. -Separate a platform `StepProvider`, local activity ledger and reward rules. Keep real-world steps distinct from the in-game tile counter. Deduplicate history, record credited intervals, bound invalid data and handle denied/revoked permission. +Acceptance: cross-network trades/battles and induced duplicate messages, latency, disconnects and reconnects at every phase, without duplication or lost Pokémon. Verify invite expiry, simultaneous relationship changes, remove/re-add, block/unblock, hidden presence, account deletion and attempts to join with incompatible game or schema versions. -Foreground browser motion can help prototype the experience; reliable pocket/background tracking needs platform support. Evaluate a native wrapper with iOS Core Motion and Android Health Connect/step sensors, checking current permissions and hardware availability when implementing it. +## 7. Optional Pokéwalker-style egg assistance -Choose the initial reward explicitly, such as companion progress or an item goal. Apply it through native game commands at safe points with a durable, idempotent ledger transaction. Sensor callbacks must not write directly into game state. Keep activity data on device by default. +This optional mobile feature uses real-world walking to assist egg hatching after native party, breeding, eggs and faithful in-game hatching are complete. The pinned Crystal engine decrements party egg counters from overworld walking in [`CountStep`](https://github.com/pret/pokecrystal/blob/7a7881d0d62e0ddbd82dcf10e7116807487ac651/engine/overworld/events.asm#L866-L900) and [`DoEggStep`](https://github.com/pret/pokecrystal/blob/7a7881d0d62e0ddbd82dcf10e7116807487ac651/engine/pokemon/breeding.asm#L174-L196); battles do not advance them. A battle-based bonus would therefore be an explicit, separately enabled extension rather than Crystal behavior. -Acceptance: a real walk with a locked phone is recorded, credited exactly once after reopening, and safely reflected in the game. Verify interaction with trades and save restoration. +Separate a platform `StepProvider`, local activity ledger and reward rules. Request read access to steps only, after an explicit explanation and opt-in. Keep real-world steps distinct from the in-game tile counter, show what interval will be credited and provide a disconnect/delete control. Do not request distance, heart rate, workouts or permission to write health records. Keep raw activity data on device by default. + +An ordinary PWA cannot directly query Apple HealthKit, Core Motion pedometer history, Android Health Connect or Android step sensors. Reliable pocket and locked-screen behavior therefore needs a native companion or wrapper and a narrow bridge into the web game. On Apple platforms, evaluate read-only HealthKit step counts and Core Motion `CMPedometer`; HealthKit requires the capability, a purpose string and per-type authorization, while Core Motion exposes historical pedometer queries. On Android, prefer Health Connect `READ_STEPS` where available and evaluate `TYPE_STEP_COUNTER` with `ACTIVITY_RECOGNITION` as a device-local fallback. Handle absent hardware, unavailable services, denied or revoked access and partial history without blocking the game. + +Normalize provider records into immutable local intervals with provider/source IDs, start/end times and step totals. Deduplicate overlapping samples before reward calculation. Commit each credit as one transaction containing a unique credit ID, the source interval set, the target slot and egg identity, the applied hatch-counter delta and resulting state version. Persist the ledger and game mutation atomically. Restoring an older backup must not make an already consumed interval eligible again; trading, moving or hatching an egg must close or retarget pending credits explicitly; wall-clock or timezone changes must not create new credit. Sensor callbacks only append provider observations, never mutate game state. + +The first reward is bounded egg-hatch assistance for an egg currently in the party. Define and test the conversion rate and daily cap before release, never reduce an egg below the normal hatch trigger outside a safe game checkpoint, and show the pending effect before applying it. No party or no eligible egg means steps remain unspent or expire according to a documented rule; do not silently redirect them to another reward. + +Store distribution is a release dependency, not an assumption. Apple says HealthKit must be used for health and fitness purposes and its review rules restrict health and Motion data use, so a game-only reward may not qualify for HealthKit/App Store approval. Google Play lists health-integrated games as an approved health-permission use case, but still requires a Health apps declaration and review of the requested permissions. Recheck both policies before implementation and submission. Core Motion or a device step sensor may reduce health-store scope, but still requires native permissions and review. + +Dependencies: native party, breeding, eggs and hatching; stable egg identity; save-slot transactions and migrations; a native iOS and Android host; permission/privacy UI; and confirmed store eligibility for the chosen provider. + +Acceptance: on supported physical iPhone and Android devices, a real walk with the phone locked is read with steps-only consent, credited to an eligible egg exactly once after reopening, and safely reaches the normal hatch flow. Exercise duplicate and overlapping samples, provider corrections, permission revocation, device reboot, offline use, timezone and clock changes, backup restore, slot switching, trade, egg movement and hatch between observation and credit. Denial, unsupported hardware and failed review eligibility leave the base game fully usable. + +Official platform references: Apple [HealthKit authorization and capability setup](https://developer.apple.com/documentation/xcode/configuring-healthkit-access), [Core Motion pedometer history]() and [App Review Guidelines 2.5.1 and 5.1.3](https://developer.apple.com/app-store/review/guidelines/); Android [Health Connect step access](https://developer.android.com/health-and-fitness/health-connect/features/steps), [step counter sensor guidance](https://developer.android.com/develop/sensors-and-location/sensors/sensors_motion) and Google Play [Android health permissions guidance](https://support.google.com/googleplay/android-developer/answer/12991134?hl=en). ## Working method diff --git a/docs/PRESENTATION_PLAN.md b/docs/PRESENTATION_PLAN.md new file mode 100644 index 0000000..c2b3cdb --- /dev/null +++ b/docs/PRESENTATION_PLAN.md @@ -0,0 +1,25 @@ +# Game-first presentation plan + +## Scope and sequence + +1. Replace the landing page and handheld frame with a minimal, viewport-first interface inspired by Crystal's menus: cream panels, dark double borders, compact monospace text, a selection arrow and restrained blue accents. The supplied [UI reference](https://www.gameuidatabase.com/gameData.php?id=1813) blocks automated access, so exact screenshot matching is not claimed. +2. Put Game, Savegames, Map, Pokédex, Link and Friends tabs above the viewport. Implement keyboard-accessible navigation and pause/release game input when leaving Game. Savegames exposes the existing save, export and import actions. Map shows the current supported map and player. Unimplemented game/network systems get explicit planned states, without pretend data. +3. Expand the native renderer to the available canvas dimensions, drawing additional world tiles instead of stretching a 160 × 144 image. Preserve pixel proportions, camera centering, collision and movement. Resize while playing or paused; release observers when replacing a session. Keep the separate emulator reference untouched. +4. Add a live local minimap with a player marker, map name and coordinates. Update movement, warps and restored saves. Share the map view with the Map tab; distinguish the current area from the future Johto/Kanto atlas. +5. Extend the full roadmap with save slots, a ROM-backed Pokédex, online Link, friends and an optional walking companion for egg progress. Research HealthKit/Health Connect and native pedometer alternatives; ordinary browsers must not pretend to have access to health stores. Party, eggs and faithful in-game hatching precede real-step rewards. Treat any battle-based hatch bonus as an explicit extension, not original Crystal behavior. +6. Validate production build, unit tests and Chromium/WebKit flows. Cover responsive widths, portrait/landscape, tabs/focus, pause safety, movement/minimap agreement, save restore and renderer resize. Keep ROM-derived screenshots and assets ignored. + +## Work allocation + +- Sol / medium: shell styling and accessible tabs (`src/ui/shell.ts`, `styles.css`, `tabs.ts`). +- Sol / medium: adaptive native renderer and resize lifecycle (`src/game/rendering`, `src/game/runtime.ts`), with focused tests. +- Sol / medium: roadmap, platform feasibility and concise documentation (`docs/PLAN.md`, `README.md`). +- Integrator: live minimap, main application wiring, integration tests, visual inspection and final verification. + +## Delivery boundaries + +This increment delivers the presentation, existing save actions, adaptive world canvas and local minimap. Full save-slot management, Pokédex records, networking, friends, health permissions and egg rewards depend on systems outside the existing exploration milestone and remain explicitly sequenced roadmap work. + +## Verification + +Delivered with three Sol agents at medium effort and an integration pass. Native and reference builds, release isolation, vendor integrity and pinned-reference checks pass. All 26 unit tests and 16 Chromium/WebKit browser tests pass, including local-ROM movement, stairs, minimap restore, menu pause behavior and responsive layouts down to 320 × 568 and 844 × 390 landscape. Browser checks do not establish physical-phone or health-provider compatibility. Local visual captures remain in ignored `artifacts/`. diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md new file mode 100644 index 0000000..a37603d --- /dev/null +++ b/docs/SESSION_HANDOFF.md @@ -0,0 +1,102 @@ +# Recovered Claude session + +Recovered on 2026-09-23 from the local Claude Code transcript +`~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`. +The session stopped at its usage limit at 09:54 UTC, during research, without a final handoff. + +## Resumed increment: completed 2026-09-23 + +The movement increment below is complete. The remaining sections preserve the +original recovery context. + +- Added directional two-tile ledge jumps with the existing arc/shadow animation. + Unsupported terrain, invalid destinations and occupied landings remain refused. +- Corrected directional mats to wait for exit input. Warp arrivals keep their + actual coordinates and appropriate facing; doors, stairs and caves animate a + downward exit. Removing the old inward teleport also corrected Elm's scripted + introduction position. +- Mid-walk, mid-jump and mid-connection saves retain the source position. Landing + scenes rewind to the actual source of a jump, rather than the intervening wall. +- Restored compatibility with early version-3 saves lacking the money field. +- Replaced the scratch diagnostic with synthetic movement regressions and + optional-ROM tests covering five Route 29 ledge types. Route 29 is enabled only + inside that test fixture, not in production. +- Fixed the battle test suite's eager ROM read so missing-ROM runs really skip + private tests. Updated browser stair inputs and added an exit-mat save/reload + flow. Updated the roadmap and architecture to distinguish the existing battle + code from its unfinished integration. + +Final checks: 75 unit tests passed with the local ROM; without it, 55 passed and +20 private tests skipped. All 24 Chromium/WebKit browser tests passed. Native and +reference builds, release isolation, pinned-reference checks, vendor checks and +`git diff --check` passed. No commit or push was made. + +Next: validate the overworld-to-battle browser flow and complete Route 29's +remaining script, capture/tutorial and item interactions before enabling the +route. Player turn timing also remains a documented comparison gap. + +## Scope and current state + +The original request was to read ARCHITECTURE.md, PLAN.md and PRESENTATION_PLAN.md +and continue the native port. The working tree contains substantial uncommitted +work from multiple increments; preserve it when continuing. + +- Opening scenes, scripted movement, starter selection, naming, the aide's Potion + and version 3 saves are implemented and documented. +- Native battle modules, ROM tables, damage/type parity tests, encounter handling, + battle UI and whiteout handling have been added. These later changes are not + fully reflected in the architecture and roadmap documents. +- Only the original six maps are enabled in `NATIVE_MAPS`. Route 29 (`24:3`) + remains disabled. Passing battle unit tests does not establish a playable + Route 29 encounter or the full opening quest. + +## Exact stopping point + +Claude was preparing to revise player movement in `src/game/world/world.ts`. +Its last investigation concerned spawn facing after warps. It had not implemented +the planned ledge/warp revision. + +The pinned local PRET checkout contains the relevant routines: + +- `engine/overworld/player_movement.asm`: `.TryStep`, `.TryJump`, + `.CheckLandPerms`, `.CheckWarp` and the direction tables. +- `engine/overworld/tile_events.asm`: `CheckWarpCollision` and + `CheckDirectionalWarp`. +- `engine/overworld/player_object.asm`: `SpawnPlayer`. +- `engine/overworld/map_objects.asm`: movement/turn timing. + +Research found that ledge tops have land permission; the jump check uses the +current tile and facing after an ordinary step fails. Doors use collision `$71`, +downward warp mats `$70`, and stairs `$7a`. Directional warp mats require their +designated input rather than an immediate warp simply upon entry. Verify these +rules against the local routines before changing the existing movement flow. + +`tests/zz-scratch.test.ts` is Claude's unfinished diagnostic: it logs local-ROM +warp and ledge collisions, contains no assertions and unconditionally requires +the private ROM. Replace it with meaningful optional-ROM or synthetic tests +before treating the suite as ready for public CI. + +## Validation after recovery + +Run against the current working tree with the local ROM and reference checkout: + +- `npm test`: 45 passed, including the assertion-free scratch diagnostic. +- `npm run build`: passed. +- `npm run test:e2e`: 22 passed across Chromium and WebKit, including native + damage/type comparisons against original routines in the reference emulator. +- `npm run check:reference`: ROM, pinned rebuild and profile addresses match. +- `npm run check:release`: native output excludes emulator and game assets. +- `npm run check:vendor`: passed. + +These checks do not establish complete battle behavior, browser battle UI flows, +Route 29 progression or physical-device compatibility. No gameplay source was +changed during session recovery. + +## Original next-increment notes (movement now completed) + +Finish ledge and directional-warp movement with focused regression tests, +including interrupted movement/save behavior and preservation of the existing +opening scenes. Audit Route 29 callbacks, scripts and unsupported destinations +before enabling it. Then test wild encounters and battle controls through the +browser, including win, escape, loss and save/reload, and reconcile the roadmap +with the verified behavior. diff --git a/index.html b/index.html index 9e2388e..34ead91 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> - + { + const match = /^0[01]:([c-d][0-9a-f]{3}) (\S+)$/.exec(line); + return match ? [[match[2], parseInt(match[1], 16)]] : []; + }), +); +const ramNames = { + stringBuffer1: "wStringBuffer1", + stringBuffer2: "wStringBuffer2", + stringBuffer3: "wStringBuffer3", + stringBuffer4: "wStringBuffer4", + stringBuffer5: "wStringBuffer5", + playerName: "wPlayerName", + momsName: "wMomsName", + rivalName: "wRivalName", + battleMonNickname: "wBattleMonNickname", + enemyMonNickname: "wEnemyMonNickname", + curPartyLevel: "wCurPartyLevel", + monOrItemNameBuffer: "wMonOrItemNameBuffer", }; const profile = readFileSync("src/game/rom/profile.ts", "utf8"); for (const [key, symbol] of Object.entries(names)) { @@ -58,6 +126,133 @@ for (const [key, symbol] of Object.entries(names)) { if (!match || Number(match[1]) !== symbols.get(symbol)) throw new Error(`Profile address mismatch: ${key} / ${symbol}`); } +for (const [key, symbol] of Object.entries(ramNames)) { + const match = new RegExp(`${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile); + if (!match || Number(match[1]) !== ram.get(symbol)) + throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`); +} +// BATTLE entries are keyed by their PRET labels. +{ + const block = /export const BATTLE = \{([\s\S]*?)\} as const;/.exec(profile)?.[1]; + if (!block) throw new Error("Battle symbol table is missing."); + for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) { + const label = quoted ?? bare; + if (symbols.get(label) !== Number(literal)) + throw new Error(`Battle symbol mismatch: ${label}`); + } +} +const eventIds = new Map(); +{ + let next = 0; + for (const raw of readFileSync( + `${root}/constants/event_flags.asm`, + "utf8", + ).split("\n")) { + const line = raw.split(";")[0].trim(); + let match; + if (line.startsWith("const_def")) next = 0; + else if ((match = /^const_next\s+(\d+)$/.exec(line))) + next = Number(match[1]); + else if ((match = /^const_skip(?:\s+(\d+))?$/.exec(line))) + next += Number(match[1] ?? 1); + else if ((match = /^const (\w+)$/.exec(line))) + eventIds.set(match[1], next++); + } +} +const eventNames = { + playersRoomPoster: "EVENT_PLAYERS_ROOM_POSTER", + playersHouseConsole: "EVENT_PLAYERS_HOUSE_2F_CONSOLE", + playersHouseDoll1: "EVENT_PLAYERS_HOUSE_2F_DOLL_1", + playersHouseDoll2: "EVENT_PLAYERS_HOUSE_2F_DOLL_2", + playersHouseBigDoll: "EVENT_PLAYERS_HOUSE_2F_BIG_DOLL", +}; +for (const [key, name] of Object.entries(eventNames)) { + const match = new RegExp(`${key}:\\s*(\\d+)`).exec(profile); + if (!match || Number(match[1]) !== eventIds.get(name)) + throw new Error(`Profile event mismatch: ${key} / ${name}`); +} +// Count constants up to a DEF, the way const_def/const_value do. +const constantCount = (file, name) => { + let value = 0; + for (const raw of readFileSync(`${root}/${file}`, "utf8").split("\n")) { + const line = raw.split(";")[0].trim(); + if (line.startsWith("const_def")) + value = Number(/const_def\s+(\d+)/.exec(line)?.[1] ?? 0); + else if (/^const \w+$/.test(line)) value++; + else if (line.startsWith(`DEF ${name} EQU const_value - 1`)) + return value - 1; + } + throw new Error(`${name} not found`); +}; +for (const [name, file] of [ + ["NUM_POKEMON", "constants/pokemon_constants.asm"], + ["NUM_ITEMS", "constants/item_constants.asm"], +]) + if (!profile.includes(`${name} = ${constantCount(file, name)};`)) + throw new Error(`${name} does not match the reference.`); +// PRET constants: const_def/const/const_skip/const_next/shift_const and +// numeric or aliased DEF ... EQU, plus the battle command macro list. +const pretConstants = new Map(); +{ + const number = (text) => + text.startsWith("$") + ? parseInt(text.slice(1), 16) + : text.startsWith("%") + ? parseInt(text.slice(1), 2) + : Number(text); + const files = [ + ...execFileSync("ls", [`${root}/constants`], { encoding: "utf8" }) + .split("\n") + .filter((name) => name.endsWith(".asm")) + .map((name) => `${root}/constants/${name}`), + `${root}/macros/scripts/battle_commands.asm`, + ]; + for (const file of files) { + let value = 0, + step = 1; + for (const raw of readFileSync(file, "utf8").split("\n")) { + const line = raw.split(";")[0].trim(); + let match; + if ((match = /^const_def(?:\s+(-?[$%]?[0-9a-fA-F]+))?(?:\s*,\s*(-?\d+))?$/.exec(line))) { + value = match[1] ? number(match[1].replace(/^-/, "")) * (match[1].startsWith("-") ? -1 : 1) : 0; + step = match[2] ? Number(match[2]) : 1; + } else if ((match = /^const_next\s+([$%]?[0-9a-fA-F]+)$/.exec(line))) value = number(match[1]); + else if ((match = /^const_skip(?:\s+(\d+))?$/.exec(line))) value += step * Number(match[1] ?? 1); + else if ((match = /^(const|shift_const|command)\s+(\w+)$/.exec(line))) { + const stored = match[1] === "shift_const" ? 1 << value : value; + pretConstants.set(match[2], stored); + if (match[1] === "shift_const") pretConstants.set(`${match[2]}_F`, value); + value += step; + } else if ((match = /^DEF\s+(\w+)\s+EQU\s+([$%]?[0-9a-fA-F]+|\w+)$/.exec(line))) { + const target = match[2]; + const resolved = + target === "const_value" + ? value + : /^[$%]?[0-9]/.test(target) || /^[$%]/.test(target) + ? number(target) + : pretConstants.get(target); + if (resolved !== undefined) pretConstants.set(match[1], resolved); + } + } + } +} +const battleConstants = readFileSync("src/game/battle/constants.ts", "utf8"); +for (const [, block] of battleConstants.matchAll(/export const \w+ = \{([\s\S]*?)\} as const;/g)) { + for (const [, name, literal] of block.matchAll(/^\s+(\w+):\s*(0x[0-9a-f]+|\d+),/gim)) { + const expected = pretConstants.get(name); + if (expected === undefined || ((expected + 256) % 256 !== Number(literal) % 256 && expected !== Number(literal))) + throw new Error(`Battle constant mismatch: ${name} is ${literal}, PRET has ${expected}`); + } +} +const groups = readFileSync(`${root}/data/maps/maps.asm`, "utf8") + .split(/^MapGroup_/m) + .slice(1) + .map((group) => (group.match(/^\tmap /gm) ?? []).length); +if ( + !profile.includes(`MAP_GROUP_COUNT = ${groups.length};`) || + !profile.includes(`LAST_MAP_GROUP_SIZE = ${groups.at(-1)};`) +) + throw new Error("Map group bounds do not match the reference."); console.log( "User ROM, pinned PRET rebuild and all native profile addresses match.", ); diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts new file mode 100644 index 0000000..033f08d --- /dev/null +++ b/src/game/battle/battle.ts @@ -0,0 +1,1965 @@ +import { RomReader } from "../rom/reader"; +import { BATTLE, RAM } from "../rom/profile"; +import type { PokemonData } from "../rom/pokemon"; +import { calcStats, type PartyMon } from "../party/pokemon"; +import { readString, readTextEnd, type TextContext } from "../scripts/text"; +import { BattleData } from "./data"; +import { C, COMMAND } from "./constants"; + +/** Raised for battle rules that have not been translated. The battle is + * discarded, as if it never started. */ +export class UnsupportedBattle extends Error {} + +/** A battle_struct: the Pokémon on the field, with stage-modified stats. */ +export interface BattleMon { + species: number; + item: number; + moves: number[]; + dvs: number; + pp: number[]; + happiness: number; + level: number; + status: number; + hp: number; + maxHp: number; + /** Attack, Defense, Speed, Sp. Atk, Sp. Def after stages and status. */ + stats: number[]; + types: [number, number]; + nickname: string; +} +interface MoveStruct { + anim: number; + effect: number; + power: number; + type: number; + accuracy: number; + pp: number; + chance: number; +} + +/** One side's battle variables (wPlayer* or wEnemy*). */ +class Side { + mon!: BattleMon; + /** Unmodified stats (wPlayerStats), used for stages and criticals. */ + stats = [0, 0, 0, 0, 0]; + levels = [7, 7, 7, 7, 7, 7, 7]; + /** SubStatus1-5; index 0 is unused so indices match the names. */ + sub = [0, 0, 0, 0, 0, 0]; + move: MoveStruct = { anim: 0, effect: 0, power: 0, type: 0, accuracy: 0, pp: 0, chance: 0 }; + curMove = 0; + moveNum = 0; + confuseCount = 0; + toxicCount = 0; + disableCount = 0; + disabledMove = 0; + encoreCount = 0; + rolloutCount = 0; + furyCutterCount = 0; + protectCount = 0; + rageCounter = 0; + wrapCount = 0; + damageTaken = 0; + screens = 0; + turnsTaken = 0; + charging = 0; + lastMove = 0; + lastCounterMove = 0; + minimized = 0; + justGotFrozen = 0; + has(byte: number, bit: number) { + return Boolean(this.sub[byte] & (1 << bit)); + } + set(byte: number, bit: number) { + this.sub[byte] |= 1 << bit; + } + reset(byte: number, bit: number) { + this.sub[byte] &= ~(1 << bit); + } +} + +export type BattleEffect = + /** A battle message; prompt texts wait for a button, others continue. */ + | { kind: "text"; text: string; prompt: boolean } + /** HP, status or level changed; renderers re-read the battle state. */ + | { kind: "hud" } + /** The battle menu: answered with an action. */ + | { kind: "action" } + | { kind: "yesno"; text: string } + /** Choose a move to forget (0-3); 4 cancels, like B on the list. */ + | { kind: "forget"; moves: number[]; learning: number } + /** The level-up stat box (PrintTempMonStats), acknowledged with a button. */ + | { kind: "stats"; labels: string[]; values: number[] }; +export type BattleAction = { action: "fight"; move: number } | { action: "run" }; +export type BattleInput = number | BattleAction | undefined; +export type BattleOutcome = { result: number }; + +type Gen = Generator; + +export interface BattleOptions { + data: PokemonData; + party: PartyMon[]; + pokedex: { seen: Set; caught: Set }; + playerId: number; + playerName: string; + /** wTimeOfDay: morning 0, day 1, night 2. */ + timeOfDay: number; + random: () => number; + wild: { species: number; level: number }; + text: () => TextContext; +} + +const SUB1 = 1, + SUB2 = 2, + SUB3 = 3, + SUB4 = 4, + SUB5 = 5; +/** "n percent" in the original: n * $ff / 100, rounded down. */ +const percent = (n: number) => Math.floor((n * 0xff) / 100); + +export interface TypeMatchup { + attacker: number; + defender: number; + multiplier: number; +} + +/** BattleCommand_DamageCalc's arithmetic. `boost` is a type-boosting held + * item's percentage; `current` is the damage already in wCurDamage. + * Returns undefined when the original leaves wCurDamage unchanged. */ +export function calculateDamage(input: { + power: number; + level: number; + attack: number; + defense: number; + effect: number; + critical: boolean; + boost?: number; + current: number; +}): number | undefined { + let { defense } = input; + const { power, effect } = input; + // Selfdestruct and Explosion halve defense. + if (effect === C.EFFECT_SELFDESTRUCT) defense = defense >> 1 || 1; + if (effect !== C.EFFECT_MULTI_HIT && effect !== C.EFFECT_CONVERSION && !power) return undefined; + if (!defense) defense = 1; + let value = Math.floor(((input.level * 2) & 0x1ff) / 5) + 2; + value = Math.floor((value * power * input.attack) / defense); + value = Math.floor(value / 50); + if (input.boost !== undefined) value = Math.floor((value * (input.boost + 100)) / 100); + if (input.critical) { + // Only the low 16 bits are doubled, capped at $ffff. + const high = value - (value & 0xffff); + const doubled = (value & 0xffff) * 2; + value = high + (doubled > 0xffff ? 0xffff : doubled); + } + // Add to wCurDamage, capped at 997, then add 2. The original first adds + // the high byte of wCurDamage to the result's low byte. + const current = input.current; + let low = (value & 0xff) + (current >> 8); + let high = (value >> 8) & 0xff; + if (low > 0xff) { + low &= 0xff; + high = (high + 1) & 0xff; + if (!high) return 999; + } + if ((value >> 16) & 0xff) return 999; // only hQuotient + 1 is checked + const result = (high << 8) | low; + if (result >= 998) return 999; + const total = result + current; + if (total > 0xffff || total >= 998) return 999; + return total + 2; +} + +/** BattleCommand_Stab and BattleCheckTypeMatchup. */ +export function applyTypeMatchups( + matchups: readonly TypeMatchup[], + input: { + damage: number; + modifier: number; + moveType: number; + userTypes: readonly number[]; + targetTypes: readonly number[]; + identified: boolean; + }, +): { damage: number; modifier: number; matchup: number; missed: boolean } { + let { damage, modifier } = input; + let missed = false; + const type = input.moveType; + if (type === input.userTypes[0] || type === input.userTypes[1]) { + damage = (damage + (damage >> 1)) & 0xffff; + modifier |= C.STAB_DAMAGE; + } + const hits = (row: TypeMatchup) => + row.attacker === type && + (row.defender === input.targetTypes[0] || row.defender === input.targetTypes[1]); + for (const row of matchups) { + // Rows after the -2 marker (Ghost immunities) stop under Foresight. + if (row.attacker === 0xfe) { + if (input.identified) break; + continue; + } + if (!hits(row)) continue; + if (!row.multiplier) missed = true; + modifier = (modifier & C.STAB_DAMAGE) + row.multiplier; + const product = damage * row.multiplier; + damage = product ? Math.floor(product / 10) & 0xffff || 1 : 0; + } + let matchup: number = C.EFFECTIVE; + for (const row of matchups) { + if (row.attacker === 0xfe) { + if (input.identified) break; + continue; + } + if (hits(row)) matchup = Math.floor((row.multiplier * matchup) / 10) & 0xff; + } + return { damage, modifier: (modifier & C.STAB_DAMAGE) | matchup, matchup, missed }; +} + +/** A native translation of Crystal's battle engine for wild battles. + * The move effect scripts are the ROM's own; each battle command is a + * native translation. Link, trainer, contest and item paths are refused. */ +export class Battle { + readonly data: BattleData; + readonly player = new Side(); + readonly enemy = new Side(); + /** hBattleTurn: 0 for the player's turn, 1 for the enemy's. */ + turn = 0; + curBattleMon = 0; + battleMode: number = C.WILD_BATTLE; + battleType: number = C.BATTLETYPE_NORMAL; + battleResult: number = C.WIN; + battleEnded = false; + battlePlayerAction = 0; + enemyGoesFirst = 0; + numFleeAttempts = 0; + participants = 0; + /** wEnemyMonBaseStats: five base stats, catch rate and base exp. */ + enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 }; + weather: number = C.WEATHER_NONE; + private curDamage = 0; + private criticalHit = 0; + private attackMissed = 0; + private effectFailed = 0; + private typeModifier: number = C.EFFECTIVE; + private alreadyDisobeyed = 0; + private alreadyFailed = 0; + private turnEnded = 0; + private loweredStat = 0; + private failedMessage = 0; + private script: number[] = []; + private scriptIndex = 0; + private strings: Record = {}; + private numbers: Record = {}; + private matchups; + private moveGrammarCache = new Map(); + + constructor(private options: BattleOptions) { + this.data = new BattleData(options.data.reader); + this.matchups = this.data.typeMatchups(); + } + private get reader(): RomReader { + return this.options.data.reader; + } + private random() { + return this.options.random() & 0xff; + } + private get user() { + return this.turn === 0 ? this.player : this.enemy; + } + private get target() { + return this.turn === 0 ? this.enemy : this.player; + } + get partyMon(): PartyMon { + return this.options.party[this.curBattleMon]; + } + + // ------------------------------------------------------------- messages + + private context(): TextContext { + const base = this.options.text(); + return { + ...base, + strings: { + ...base.strings, + [RAM.battleMonNickname]: this.player.mon?.nickname ?? "", + [RAM.enemyMonNickname]: this.enemy.mon?.nickname ?? "", + ...this.strings, + }, + numbers: this.numbers, + battle: { + turn: this.turn, + player: this.player.mon?.nickname ?? "", + enemy: this.enemy.mon?.nickname ?? "", + enemyPrefix: readString(this.reader, BATTLE.EnemyText, base), + }, + asm: (offset) => this.textAsm(offset), + }; + } + /** StdBattleTextbox / BattleTextbox with a ROM text. */ + private *say(address: number): Gen { + const { text, prompt } = readTextEnd(this.reader, address, this.context()); + for (const page of text.split("\n\n").filter(Boolean)) + yield { kind: "text", text: page, prompt }; + } + /** text_asm routines that battle texts run between fragments. */ + private textAsm(offset: number): number { + const at = (label: string, fragment = 4) => BATTLE[label as keyof typeof BATTLE] + fragment; + switch (offset) { + case at("UsedMoveText"): { + // Updates the last move, then picks the grammar for "used". + const user = this.user; + if (!user.charging) { + user.lastCounterMove = user.move.anim; + user.lastMove = user.move.anim; + } + const grammar = this.grammar(user.move.anim); + return this.alreadyDisobeyed || grammar < 3 ? BATTLE.UsedMove2Text : BATTLE.UsedMove1Text; + } + case at("UsedMove1Text"): + case at("UsedMove2Text"): + return this.alreadyDisobeyed ? BATTLE["UsedMoveText_CheckObedience.UsedInsteadText"] : BATTLE.MoveNameText; + case at("UsedMoveText_CheckObedience.UsedInsteadText"): + return BATTLE.MoveNameText; + case at("MoveNameText"): { + const ends = BATTLE["MoveNameText.endusedmovetexts"]; + const grammar = this.grammar(this.user.move.anim); + return this.reader.near(ends + grammar * 2, this.reader.bank(ends)); + } + case at("BattleCommand_StatUpMessage.stat"): + return this.loweredStat & 0xf0 + ? BATTLE["BattleCommand_StatUpMessage.BattleStatWentWayUpText"] + : BATTLE["BattleCommand_StatUpMessage.BattleStatWentUpText"]; + case at("BattleCommand_StatDownMessage.stat"): + return this.loweredStat & 0xf0 + ? BATTLE["BattleCommand_StatDownMessage.BattleStatSharplyFellText"] + : BATTLE["BattleCommand_StatDownMessage.BattleStatFellText"]; + case at("Text_MonGainedExpPoint"): + return this.numbers[RAM.stringBuffer2 + 2] ? BATTLE.BoostedExpPointsText : BATTLE.ExpPointsText; + case at("GoMonText"): + return BATTLE.BattleMonNicknameText; + case at("Text_1_2_and_Poof"): + return BATTLE["Text_1_2_and_Poof.MoveForgotText"]; + default: + throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`); + } + } + private grammar(move: number) { + let value = this.moveGrammarCache.get(move); + if (value === undefined) { + value = this.data.moveGrammar(move); + this.moveGrammarCache.set(move, value); + } + return value; + } + private hud(): BattleEffect { + return { kind: "hud" }; + } + + // --------------------------------------------------------------- setup + + /** LoadEnemyMon for a wild Pokémon from a grass or water table. */ + private loadEnemyMon() { + const { species, level } = this.options.wild; + if (species === C.UNOWN || species === C.MAGIKARP) + throw new UnsupportedBattle("Unown and Magikarp encounters are not ported yet."); + const data = this.options.data, + base = data.baseData(species); + // 25% chance of an item: 23% the first, 2% the second. + let item = 0; + if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0]; + const dvs = (this.random() << 8) | this.random(); + const stats = calcStats(data, species, dvs, level); + const moves: number[] = []; + for (const learned of data.learnset(species)) { + if (learned.level > level) break; + if (moves.includes(learned.move)) continue; + if (moves.length === 4) moves.shift(); + moves.push(learned.move); + } + while (moves.length < 4) moves.push(0); + this.enemy.mon = { + species, + item, + moves, + dvs, + pp: moves.map((move) => (move ? data.move(move).pp : 0)), + happiness: C.BASE_HAPPINESS, + level, + status: 0, + hp: stats[0], + maxHp: stats[0], + stats: stats.slice(1), + types: [base.types[0], base.types[1]], + nickname: data.speciesName(species), + }; + this.enemyBase = { + stats: base.stats.slice(0, 5), + catchRate: base.catchRate, + exp: base.baseExp, + }; + this.options.pokedex.seen.add(species); + this.enemy.stats = [...this.enemy.mon.stats]; + } + /** InitBattleMon from the party, then status penalties. */ + private initBattleMon() { + const mon = this.partyMon, + base = this.options.data.baseData(mon.species); + this.player.mon = { + species: mon.species, + item: mon.item, + moves: [...mon.moves], + dvs: mon.dvs, + pp: [...mon.pp], + happiness: mon.happiness, + level: mon.level, + status: mon.status, + hp: mon.hp, + maxHp: mon.stats[0], + stats: mon.stats.slice(1), + types: [base.types[0], base.types[1]], + nickname: mon.nickname, + }; + this.player.stats = mon.stats.slice(1); + this.applyStatusEffects(this.player); + // No badges exist yet, so BadgeStatBoosts changes nothing. + } + /** ApplyPrzEffectOnSpeed and ApplyBrnEffectOnAttack for one side. */ + private applyStatusEffects(side: Side) { + const mon = side.mon; + if (mon.status & (1 << C.PAR)) mon.stats[C.SPEED] = Math.max(1, mon.stats[C.SPEED] >> 2); + if (mon.status & (1 << C.BRN)) mon.stats[C.ATTACK] = Math.max(1, mon.stats[C.ATTACK] >> 1); + } + private newStatus(side: Side) { + side.sub = [0, 0, 0, 0, 0, 0]; + side.levels = [7, 7, 7, 7, 7, 7, 7]; + side.lastMove = 0; + side.lastCounterMove = 0; + side.disableCount = 0; + side.disabledMove = 0; + side.furyCutterCount = 0; + side.protectCount = 0; + side.rageCounter = 0; + side.minimized = 0; + side.wrapCount = 0; + side.turnsTaken = 0; + } + /** UpdateBattleMonInParty: level, status and HP go back to the party. */ + private updateBattleMonInParty() { + const mon = this.partyMon; + mon.level = this.player.mon.level; + mon.status = this.player.mon.status; + mon.hp = this.player.mon.hp; + } + + // ---------------------------------------------------------------- flow + + /** StartBattle through ExitBattle for a wild encounter. */ + *run(): Gen { + this.loadEnemyMon(); + this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0); + if (this.curBattleMon < 0) throw new UnsupportedBattle("No Pokémon can fight."); + yield* this.say(BATTLE.WildPokemonAppearedText); + this.participants = 1 << this.curBattleMon; + this.initBattleMon(); + this.newStatus(this.player); + this.newStatus(this.enemy); + yield* this.say(BATTLE.GoMonText); + yield this.hud(); + yield* this.battleTurn(); + return { result: this.battleResult }; + } + + private *battleTurn(): Gen { + for (let turns = 0; turns < 1000; turns++) { + this.curDamage = 0; + this.player.justGotFrozen = 0; + this.enemy.justGotFrozen = 0; + this.updateBattleMonInParty(); + if (!this.checkPlayerLockedIn()) { + for (;;) { + const escaped = yield* this.battleMenu(); + if (escaped || this.battleEnded) return; + if (yield* this.parsePlayerAction()) break; + } + } else if (!(yield* this.parsePlayerAction())) continue; + if (this.determineMoveOrder()) yield* this.battlePlayerFirst(); + else yield* this.battleEnemyFirst(); + if (this.battleEnded) return; + yield* this.handleBetweenTurnEffects(); + if (this.battleEnded) return; + } + throw new UnsupportedBattle("The battle did not end."); + } + + /** BattleMenu: FIGHT chooses a move; RUN tries to flee. Returns true if + * the battle ended from the menu. */ + private *battleMenu(): Gen { + for (;;) { + const input = yield { kind: "action" }; + if (!input || typeof input !== "object") continue; + if (input.action === "run") { + const escaped = yield* this.tryToRunAway(); + if (escaped) { + this.battleEnded = true; + return true; + } + // A failed escape uses the turn (BATTLEPLAYERACTION_USEITEM). + if (this.battlePlayerAction) return false; + continue; + } + this.battlePlayerAction = 0; + if (!(yield* this.moveSelection(input.move))) continue; + return false; + } + } + /** MoveSelectionScreen's checks for a chosen move. */ + private *moveSelection(index: number): Gen { + const mon = this.player.mon; + if (!this.checkPlayerHasUsableMoves()) { + yield* this.say(BATTLE.BattleText_MonHasNoMovesLeft); + this.player.curMove = C.STRUGGLE; + return true; + } + if (!Number.isInteger(index) || index < 0 || index > 3 || !mon.moves[index]) return false; + if (this.player.disableCount && index === ((this.player.disableCount >> 4) & 0xf) - 1) { + this.strings[RAM.stringBuffer1] = this.options.data.moveName(mon.moves[index]); + yield* this.say(BATTLE.BattleText_TheMoveIsDisabled); + return false; + } + if (!(mon.pp[index] & C.PP_MASK)) { + yield* this.say(BATTLE.BattleText_TheresNoPPLeftForThisMove); + return false; + } + this.player.moveNum = index; + this.player.curMove = mon.moves[index]; + return true; + } + private checkPlayerHasUsableMoves() { + const side = this.player; + if (side.disableCount) { + const disabled = ((side.disableCount >> 4) & 0xf) - 1; + return side.mon.pp.some((pp, index) => index !== disabled && pp & C.PP_MASK); + } + return Boolean(side.mon.pp.reduce((a, b) => a | b, 0) & C.PP_MASK); + } + private checkPlayerLockedIn(): boolean { + const player = this.player; + if (player.has(SUB4, C.SUBSTATUS_RECHARGE)) return true; + this.enemy.reset(SUB3, C.SUBSTATUS_FLINCHED); + player.reset(SUB3, C.SUBSTATUS_FLINCHED); + if (player.sub[SUB3] & ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_RAMPAGE))) return true; + return player.has(SUB1, C.SUBSTATUS_ROLLOUT); + } + /** ParsePlayerAction then ParseEnemyAction. Returns false to re-open the menu. */ + private *parsePlayerAction(): Gen { + const player = this.player; + if (this.checkPlayerLockedIn() || player.has(SUB5, C.SUBSTATUS_ENCORED) || player.has(SUB3, C.SUBSTATUS_BIDE)) + throw new UnsupportedBattle("Locked-in, encored and Bide moves are not ported yet."); + if (this.battlePlayerAction === 0) { + this.turn = 0; + this.updateMoveData(); + player.charging = 0; + if (player.move.effect !== C.EFFECT_FURY_CUTTER) player.furyCutterCount = 0; + if (player.move.effect !== C.EFFECT_RAGE) { + player.reset(SUB4, C.SUBSTATUS_RAGE); + player.rageCounter = 0; + } + if (player.move.effect !== C.EFFECT_PROTECT && player.move.effect !== C.EFFECT_ENDURE) + player.protectCount = 0; + } else { + player.furyCutterCount = 0; + player.protectCount = 0; + player.rageCounter = 0; + player.reset(SUB4, C.SUBSTATUS_RAGE); + } + this.parseEnemyAction(); + return true; + } + /** ParseEnemyAction for a wild Pokémon: a random usable move. */ + private parseEnemyAction() { + const enemy = this.enemy, + mon = enemy.mon; + if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn()) + throw new UnsupportedBattle("Locked-in and encored wild moves are not ported yet."); + let move: number = C.STRUGGLE; + for (let slot = 0; slot < 4; slot++) { + const candidate = mon.moves[slot]; + if (!candidate) break; + if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK)) continue; + for (let guard = 0; guard < 4096; guard++) { + const choice = this.random() & 3; + if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue; + if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue; + enemy.moveNum = choice; + move = mon.moves[choice]; + break; + } + break; + } + enemy.curMove = move; + this.turn = 1; + this.updateMoveData(); + if (!this.checkEnemyLockedIn()) enemy.charging = 0; + if (enemy.move.effect !== C.EFFECT_FURY_CUTTER) enemy.furyCutterCount = 0; + if (enemy.move.effect !== C.EFFECT_RAGE) { + enemy.reset(SUB4, C.SUBSTATUS_RAGE); + enemy.rageCounter = 0; + } + if (enemy.move.effect !== C.EFFECT_PROTECT && enemy.move.effect !== C.EFFECT_ENDURE) + enemy.protectCount = 0; + } + private checkEnemyLockedIn() { + const enemy = this.enemy; + return ( + enemy.has(SUB4, C.SUBSTATUS_RECHARGE) || + Boolean( + enemy.sub[SUB3] & + ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_BIDE)), + ) || + enemy.has(SUB1, C.SUBSTATUS_ROLLOUT) + ); + } + /** UpdateMoveData: the move struct for the current turn's move. */ + private updateMoveData() { + const side = this.user; + const move = this.options.data.move(side.curMove); + side.move = { + anim: move.id, + effect: move.effect, + power: move.power, + type: move.type, + accuracy: move.accuracy, + pp: move.pp, + chance: move.chance, + }; + // GetMoveName fills wStringBuffer1; CopyName1 copies it to wStringBuffer2. + const name = this.options.data.moveName(side.curMove); + this.strings[RAM.stringBuffer1] = name; + this.strings[RAM.stringBuffer2] = name; + } + + /** DetermineMoveOrder: true if the player moves first. */ + private determineMoveOrder(): boolean { + if (this.battlePlayerAction) return true; + const player = this.data.priority(this.options.data.move(this.player.curMove).effect, C.BASE_PRIORITY); + const enemy = this.data.priority(this.options.data.move(this.enemy.curMove).effect, C.BASE_PRIORITY); + const priority = (move: number, value: number) => (move === C.VITAL_THROW ? 0 : value); + const playerPriority = priority(this.player.curMove, player), + enemyPriority = priority(this.enemy.curMove, enemy); + if (playerPriority !== enemyPriority) return playerPriority > enemyPriority; + const playerItem = this.data.heldEffect(this.player.mon.item), + enemyItem = this.data.heldEffect(this.enemy.mon.item); + if (playerItem.effect === C.HELD_QUICK_CLAW && enemyItem.effect === C.HELD_QUICK_CLAW) { + if (this.random() < enemyItem.parameter) return false; + if (this.random() < playerItem.parameter) return true; + } else if (playerItem.effect === C.HELD_QUICK_CLAW) { + if (this.random() < playerItem.parameter) return true; + } else if (enemyItem.effect === C.HELD_QUICK_CLAW) { + if (this.random() < enemyItem.parameter) return false; + } + const playerSpeed = this.player.mon.stats[C.SPEED], + enemySpeed = this.enemy.mon.stats[C.SPEED]; + if (playerSpeed !== enemySpeed) return playerSpeed > enemySpeed; + return this.random() < percent(50) + 1; + } + + private *battlePlayerFirst(): Gen { + this.enemyGoesFirst = 0; + yield* this.doTurnFor(0); + if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); + if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); + this.turn = 0; + if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint(); + yield this.hud(); + if (this.tryEnemyFlee()) return yield* this.wildFled(); + yield* this.doTurnFor(1); + if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); + if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); + this.turn = 1; + if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint(); + yield this.hud(); + this.battlePlayerAction = 0; + } + private *battleEnemyFirst(): Gen { + if (this.tryEnemyFlee()) return yield* this.wildFled(); + this.enemyGoesFirst = 1; + yield* this.doTurnFor(1); + if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); + if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); + this.turn = 1; + if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint(); + yield this.hud(); + yield* this.doTurnFor(0); + if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); + if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); + this.turn = 0; + if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint(); + yield this.hud(); + this.battlePlayerAction = 0; + } + /** PlayerTurn/EnemyTurn_EndOpponentProtectEndureDestinyBond. */ + private *doTurnFor(turn: number): Gen { + this.turn = turn; + this.user.reset(SUB5, C.SUBSTATUS_DESTINY_BOND); + if (turn === 0 && this.battlePlayerAction) { + // DoPlayerTurn does nothing after a failed escape or an item. + } else yield* this.doTurn(); + const opponent = this.target; + opponent.reset(SUB1, C.SUBSTATUS_PROTECT); + opponent.reset(SUB1, C.SUBSTATUS_ENDURE); + opponent.reset(SUB5, C.SUBSTATUS_DESTINY_BOND); + } + private hasFainted(side: Side) { + return side.mon.hp === 0; + } + /** TryEnemyFlee: only some species flee, and never while asleep. */ + private tryEnemyFlee(): boolean { + if (this.battleMode !== C.WILD_BATTLE) return false; + if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false; + if (this.enemy.mon.status & ((1 << C.FRZ) | C.SLP_MASK)) return false; + const species = this.enemy.mon.species; + if (this.data.byteList(BATTLE.AlwaysFleeMons).includes(species)) return true; + const roll = this.random(); + if (roll >= percent(50) + 1) return false; + if (this.data.byteList(BATTLE.OftenFleeMons).includes(species)) return true; + if (roll >= percent(10) + 1) return false; + return this.data.byteList(BATTLE.SometimesFleeMons).includes(species); + } + private *wildFled(): Gen { + this.battleResult = (this.battleResult & 0xc0) + C.DRAW; + yield* this.say(BATTLE.BattleText_WildFled); + this.battleEnded = true; + } + + /** TryToRunAwayFromBattle for a wild battle. Returns true on escape. */ + private *tryToRunAway(): Gen { + if (this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.player.wrapCount) { + yield* this.say(BATTLE.BattleText_CantEscape); + return false; + } + if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_ESCAPE) + throw new UnsupportedBattle("Escape items are not ported yet."); + this.numFleeAttempts = (this.numFleeAttempts + 1) & 0xff; + const playerSpeed = this.player.mon.stats[C.SPEED], + enemySpeed = this.enemy.mon.stats[C.SPEED]; + let escaped = playerSpeed >= enemySpeed; + if (!escaped) { + const divisor = (enemySpeed >> 2) & 0xff; + if (!divisor) escaped = true; + else { + const quotient = Math.floor(((playerSpeed * 32) & 0xffff) / divisor); + if (quotient > 0xff) escaped = true; + else { + let odds = quotient; + for (let attempt = this.numFleeAttempts - 1; attempt > 0; attempt--) { + odds += 30; + if (odds > 0xff) { + escaped = true; + break; + } + } + if (!escaped) escaped = odds >= this.random(); + } + } + } + if (!escaped) { + this.battlePlayerAction = 1; // BATTLEPLAYERACTION_USEITEM + yield* this.say(BATTLE.BattleText_CantEscape2); + return false; + } + this.battleResult = (this.battleResult & 0xc0) + C.DRAW; + yield* this.say(BATTLE.BattleText_GotAwaySafely); + return true; + } + + // ------------------------------------------------------ move execution + + /** DoTurn: CheckTurn, then the move's effect script. */ + private *doTurn(): Gen { + this.turnEnded = 0; + yield* this.checkTurn(); + if (this.turnEnded) return; + this.updateMoveData(); + this.script = this.data.effectScript(this.user.move.effect); + this.scriptIndex = 0; + for (let steps = 0; steps < 256; steps++) { + const command = this.script[this.scriptIndex++]; + if (command >= COMMAND.endturn) return; + yield* this.command(command); + } + throw new UnsupportedBattle("Move effect script did not end."); + } + /** EndMoveEffect: stop reading the effect script. */ + private endMoveEffect() { + this.script.splice(this.scriptIndex, this.script.length, 0xff, 0xff, 0xff); + } + private *endTurn(): Gen { + this.turnEnded = 1; + this.curDamage = 0; + } + + private *checkTurn(): Gen { + const user = this.user, + mon = user.mon; + if (user.curMove === 0xff) return yield* this.endTurn(); // CANNOT_MOVE + this.attackMissed = 0; + this.effectFailed = 0; + this.alreadyDisobeyed = 0; + this.alreadyFailed = 0; + this.typeModifier = C.EFFECTIVE; + if (user.has(SUB4, C.SUBSTATUS_RECHARGE)) { + user.reset(SUB4, C.SUBSTATUS_RECHARGE); + yield* this.say(BATTLE.MustRechargeText); + this.cantMove(); + return yield* this.endTurn(); + } + if (mon.status & C.SLP_MASK) { + mon.status = (mon.status & ~C.SLP_MASK) | (((mon.status & C.SLP_MASK) - 1) & C.SLP_MASK); + // The player checks the sleep counter; the enemy checks the whole status. + const awake = this.turn === 0 ? !(mon.status & C.SLP_MASK) : mon.status === 0; + if (awake) { + yield* this.say(BATTLE.WokeUpText); + this.cantMove(); + if (this.turn === 0) this.updateBattleMonInParty(); + yield this.hud(); + user.reset(SUB1, C.SUBSTATUS_NIGHTMARE); + } else { + yield* this.say(BATTLE.FastAsleepText); + if (user.curMove !== C.SNORE && user.curMove !== C.SLEEP_TALK) { + this.cantMove(); + return yield* this.endTurn(); + } + } + } + if (mon.status & (1 << C.FRZ) && user.curMove !== C.FLAME_WHEEL && user.curMove !== C.SACRED_FIRE) { + yield* this.say(BATTLE.FrozenSolidText); + this.cantMove(); + return yield* this.endTurn(); + } + if (user.has(SUB3, C.SUBSTATUS_FLINCHED)) { + user.reset(SUB3, C.SUBSTATUS_FLINCHED); + yield* this.say(BATTLE.FlinchedText); + this.cantMove(); + return yield* this.endTurn(); + } + if (user.disableCount) { + user.disableCount = (user.disableCount - 1) & 0xff; + if (!(user.disableCount & 0xf)) { + user.disableCount = 0; + user.disabledMove = 0; + yield* this.say(BATTLE.DisabledNoMoreText); + } + } + if (user.has(SUB3, C.SUBSTATUS_CONFUSED)) { + user.confuseCount = (user.confuseCount - 1) & 0xff; + if (!user.confuseCount) { + user.reset(SUB3, C.SUBSTATUS_CONFUSED); + yield* this.say(BATTLE.ConfusedNoMoreText); + } else { + yield* this.say(BATTLE.IsConfusedText); + if (this.random() < percent(50) + 1) { + user.sub[SUB3] &= 1 << C.SUBSTATUS_CONFUSED; + yield* this.hitConfusion(); + this.cantMove(); + return yield* this.endTurn(); + } + } + } + if (user.has(SUB1, C.SUBSTATUS_IN_LOVE)) + throw new UnsupportedBattle("Attraction is not ported yet."); + if (user.disabledMove && user.disabledMove === user.curMove) { + user.reset(SUB3, C.SUBSTATUS_CHARGED); + this.strings[RAM.stringBuffer1] = this.options.data.moveName(user.curMove); + yield* this.say(BATTLE.DisabledMoveText); + this.cantMove(); + return yield* this.endTurn(); + } + if (mon.status & (1 << C.PAR) && this.random() < percent(25)) { + yield* this.say(BATTLE.FullyParalyzedText); + this.cantMove(); + return yield* this.endTurn(); + } + } + /** CantMove: moves that span turns are interrupted. */ + private cantMove() { + const user = this.user; + user.reset(SUB1, C.SUBSTATUS_ROLLOUT); + user.sub[SUB3] &= ~((1 << C.SUBSTATUS_BIDE) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_CHARGED)); + user.furyCutterCount = 0; + if (user.move.anim === C.FLY || user.move.anim === C.DIG) { + user.reset(SUB3, C.SUBSTATUS_UNDERGROUND); + user.reset(SUB3, C.SUBSTATUS_FLYING); + } + } + /** HitConfusion / HitSelfInConfusion: a typeless 40-power hit on itself. */ + private *hitConfusion(): Gen { + yield* this.say(BATTLE.HurtItselfText); + this.criticalHit = 0; + const user = this.user, + mon = user.mon; + this.curDamage = 0; + let defense = mon.stats[C.DEFENSE]; + if (user.screens & (1 << C.SCREENS_REFLECT)) defense *= 2; + const [attack, def] = this.truncate(mon.stats[C.ATTACK], defense); + this.damageCalc(40, mon.level, attack, def); + this.doDamage(this.user, true); + yield this.hud(); + } + + private *command(command: number): Gen { + switch (command) { + case COMMAND.checkobedience: + return this.checkObedience(); + case COMMAND.usedmovetext: + return yield* this.say(BATTLE.UsedMoveText); + case COMMAND.doturn: + return yield* this.doTurnCommand(); + case COMMAND.critical: + return this.critical(); + case COMMAND.damagestats: + return this.damageStats(); + case COMMAND.damagecalc: + return this.damageCalcCommand(); + case COMMAND.stab: + return this.stab(); + case COMMAND.damagevariation: + return this.damageVariation(); + case COMMAND.checkhit: + return yield* this.checkHit(); + case COMMAND.effectchance: + return this.effectChance(); + case COMMAND.lowersub: + case COMMAND.raisesub: + case COMMAND.moveanimnosub: + case COMMAND.moveanim: + case COMMAND.statupanim: + case COMMAND.statdownanim: + case COMMAND.movedelay: + return this.requireNoSubstitute(); + case COMMAND.failuretext: + return yield* this.failureText(); + case COMMAND.applydamage: + return yield* this.applyDamage(); + case COMMAND.criticaltext: + return yield* this.criticalText(); + case COMMAND.supereffectivetext: + return yield* this.superEffectiveText(); + case COMMAND.checkfaint: + return yield* this.checkFaint(); + case COMMAND.buildopponentrage: + return yield* this.buildOpponentRage(); + case COMMAND.kingsrock: + return this.kingsRock(); + case COMMAND.poisontarget: + return yield* this.poisonTarget(); + case COMMAND.burntarget: + return yield* this.burnTarget(); + case COMMAND.paralyzetarget: + return yield* this.paralyzeTarget(); + case COMMAND.flinchtarget: + return this.flinchTarget(); + case COMMAND.sleeptarget: + return yield* this.sleepTarget(); + case COMMAND.poison: + return yield* this.poisonCommand(); + case COMMAND.paralyze: + return yield* this.paralyzeCommand(); + case COMMAND.confuse: + return yield* this.confuseCommand(); + case COMMAND.draintarget: + return yield* this.drainTarget(); + case COMMAND.checksafeguard: + return yield* this.checkSafeguard(); + case COMMAND.splash: + return yield* this.say(BATTLE.NothingHappenedText); + case COMMAND.foresight: + return yield* this.foresight(); + case COMMAND.rage: + return this.user.set(SUB4, C.SUBSTATUS_RAGE); + case COMMAND.ragedamage: + return this.rageDamage(); + case COMMAND.curl: + return this.user.set(SUB2, C.SUBSTATUS_CURLED); + case COMMAND.doubleflyingdamage: + if (this.target.has(SUB3, C.SUBSTATUS_FLYING)) this.curDamage = Math.min(0xffff, this.curDamage * 2); + return; + case COMMAND.clearmissdamage: + if (this.attackMissed) this.curDamage = 0; + return; + case COMMAND.switchturn: + this.turn ^= 1; + return; + case COMMAND.healmorn: + case COMMAND.healday: + case COMMAND.healnite: + return yield* this.timeBasedHeal(command - COMMAND.healmorn); + case COMMAND.statupmessage: + return yield* this.statUpMessage(); + case COMMAND.statdownmessage: + return yield* this.statDownMessage(); + case COMMAND.statupfailtext: + return yield* this.statUpFailText(); + case COMMAND.statdownfailtext: + return yield* this.statDownFailText(); + default: + if (command >= COMMAND.attackup && command <= COMMAND.evasionup2) { + const stat = (command - COMMAND.attackup) % 7; + return this.raiseStat(stat | (command >= COMMAND.attackup2 ? 0x10 : 0)); + } + if (command >= COMMAND.attackdown && command <= COMMAND.evasiondown2) { + const stat = (command - COMMAND.attackdown) % 7; + return this.statDown(stat | (command >= COMMAND.attackdown2 ? 0x10 : 0)); + } + throw new UnsupportedBattle(`Battle command 0x${command.toString(16)} is not ported.`); + } + } + /** Substitute presentation is only safe once substitutes exist. */ + private requireNoSubstitute() { + if (this.user.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) + throw new UnsupportedBattle("Substitutes are not ported yet."); + } + + /** BattleCommand_CheckObedience: only foreign Pokémon may disobey. */ + private checkObedience() { + if (this.turn !== 0 || this.user.charging || this.alreadyDisobeyed) return; + if (this.partyMon.otId !== this.options.playerId) + throw new UnsupportedBattle("Traded Pokémon obedience is not ported yet."); + } + /** BattleCommand_DoTurn: count the turn and use one PP. */ + private *doTurnCommand(): Gen { + const user = this.user; + if (user.charging) return; + user.turnsTaken = (user.turnsTaken + 1) & 0xff; + if (user.curMove === C.STRUGGLE) return; + if (user.sub[SUB3] & ((1 << C.SUBSTATUS_IN_LOOP) | (1 << C.SUBSTATUS_RAMPAGE) | (1 << C.SUBSTATUS_BIDE))) + return; + const slot = user.moveNum; + if (!(user.mon.pp[slot] & C.PP_MASK)) { + const continuous = [ + C.EFFECT_RAZOR_WIND, + C.EFFECT_SKY_ATTACK, + C.EFFECT_SKULL_BASH, + C.EFFECT_SOLARBEAM, + C.EFFECT_FLY, + C.EFFECT_ROLLOUT, + C.EFFECT_BIDE, + C.EFFECT_RAMPAGE, + ] as number[]; + yield* this.say(continuous.includes(user.move.effect) ? BATTLE.HasNoPPLeftText : BATTLE.NoPPLeftText); + return this.endMoveEffect(); + } + user.mon.pp[slot]--; + if (user.has(SUB5, C.SUBSTATUS_TRANSFORMED)) return; + if (this.turn === 0) { + // The party copy loses PP too, unless the move came from Mimic. + if (user.curMove === C.MIMIC || this.partyMon.moves[slot] !== C.MIMIC) this.partyMon.pp[slot]--; + } + } + + private critical() { + this.criticalHit = 0; + const user = this.user, + mon = user.mon; + if (!user.move.power) return; + let stage = 0; + if (mon.species === C.CHANSEY && mon.item === C.LUCKY_PUNCH) stage = 2; + else if (mon.species === C.FARFETCH_D && mon.item === C.STICK) stage = 2; + else { + if (user.has(SUB4, C.SUBSTATUS_FOCUS_ENERGY)) stage++; + if (this.data.byteList(BATTLE.CriticalHitMoves).includes(user.move.anim)) stage += 2; + if (this.data.heldEffect(mon.item).effect === C.HELD_CRITICAL_UP) stage++; + } + if (this.random() < this.data.criticalChance(stage)) this.criticalHit = 1; + } + + private damageArgs = { power: 0, level: 0, attack: 0, defense: 0 }; + /** PlayerAttackDamage / EnemyAttackDamage. */ + private damageStats() { + this.curDamage = 0; + const user = this.user, + target = this.target, + power = user.move.power; + this.damageArgs.power = power; + if (!power) return; + const special = user.move.type >= C.SPECIAL; + const stat = special ? C.SP_ATTACK : C.ATTACK, + defenseStat = special ? C.SP_DEFENSE : C.DEFENSE; + const screen = special ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT; + // Criticals ignore stages when the target's are at least as high. + const unboosted = + this.criticalHit && target.levels[defenseStat] >= user.levels[stat]; + let attack: number, defense: number; + if (unboosted) { + attack = user.stats[stat]; + defense = target.stats[defenseStat]; + } else { + attack = user.mon.stats[stat]; + defense = target.mon.stats[defenseStat]; + if (target.screens & (1 << screen)) defense = (defense * 2) & 0xffff; + } + attack = this.speciesItemBoost(attack, special); + [attack, defense] = this.truncate(attack, defense); + // DittoMetalPowder boosts the defender's Defense. + if (target.mon.species === C.DITTO && target.mon.item === C.METAL_POWDER) + throw new UnsupportedBattle("Metal Powder is not ported yet."); + this.damageArgs = { power, level: user.mon.level, attack, defense }; + } + /** ThickClubBoost / LightBallBoost. */ + private speciesItemBoost(attack: number, special: boolean) { + const mon = this.user.mon; + const boosted = special + ? mon.species === C.PIKACHU && mon.item === C.LIGHT_BALL + : (mon.species === C.CUBONE || mon.species === C.MAROWAK) && mon.item === C.THICK_CLUB; + return boosted ? (attack * 2) & 0xffff : attack; + } + /** TruncateHL_BC: scale both stats down by 4 until they fit a byte. */ + private truncate(attack: number, defense: number): [number, number] { + while (attack > 0xff || defense > 0xff) { + defense = defense >> 2 || 1; + attack = attack >> 2 || 1; + } + return [attack, defense]; + } + private damageCalcCommand() { + const { power, level, attack, defense } = this.damageArgs; + this.damageCalc(power, level, attack, defense); + } + /** BattleCommand_DamageCalc. */ + private damageCalc(power: number, level: number, attack: number, defense: number) { + const held = this.data.heldEffect(this.user.mon.item); + const boost = + held.effect && this.data.typeBoost(held.effect) === this.user.move.type ? held.parameter : undefined; + const damage = calculateDamage({ + power, + level, + attack, + defense, + effect: this.user.move.effect, + critical: Boolean(this.criticalHit), + boost, + current: this.curDamage, + }); + if (damage !== undefined) this.curDamage = damage; + } + + /** BattleCommand_Stab, including type effectiveness. */ + private stab() { + const user = this.user, + target = this.target; + if (user.move.anim === C.STRUGGLE) return; + if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); + const result = applyTypeMatchups(this.matchups, { + damage: this.curDamage, + modifier: this.typeModifier, + moveType: user.move.type, + userTypes: user.mon.types, + targetTypes: target.mon.types, + identified: target.has(SUB1, C.SUBSTATUS_IDENTIFIED), + }); + this.curDamage = result.damage; + this.typeModifier = result.modifier; + if (result.missed) this.attackMissed = 1; + } + /** BattleCommand_DamageVariation: 217-255 / 255. */ + private damageVariation() { + if (this.curDamage < 2) return; + let multiplier: number; + do { + const r = this.random(); + multiplier = ((r >> 1) | ((r & 1) << 7)) & 0xff; + } while (multiplier < percent(85) + 1); + this.curDamage = Math.floor((this.curDamage * multiplier) / 255) & 0xffff; + } + + /** BattleCommand_CheckHit. */ + private *checkHit(): Gen { + const user = this.user, + target = this.target, + effect = user.move.effect; + const miss = () => { + if (effect !== C.EFFECT_JUMP_KICK) this.curDamage = 0; + this.attackMissed = 1; + }; + if (effect === C.EFFECT_DREAM_EATER && !(target.mon.status & C.SLP_MASK)) return miss(); + if (target.has(SUB1, C.SUBSTATUS_PROTECT)) { + yield* this.say(BATTLE.ProtectingItselfText); + return miss(); + } + if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) && (effect === C.EFFECT_LEECH_HIT || effect === C.EFFECT_DREAM_EATER)) + return miss(); + if (target.has(SUB5, C.SUBSTATUS_LOCK_ON)) + throw new UnsupportedBattle("Lock-On is not ported yet."); + if (target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND))) + throw new UnsupportedBattle("Fly and Dig are not ported yet."); + if (effect === C.EFFECT_THUNDER && this.weather === C.WEATHER_RAIN) return; + if (user.has(SUB4, C.SUBSTATUS_X_ACCURACY)) return; + if (effect === C.EFFECT_ALWAYS_HIT) return; + this.statModifiers(); + let accuracy = user.move.accuracy; + const opponentItem = this.data.heldEffect(target.mon.item); + if (opponentItem.effect === C.HELD_BRIGHTPOWDER) accuracy = Math.max(0, accuracy - opponentItem.parameter); + if (accuracy === 0xff) return; + if (this.random() >= accuracy) miss(); + } + /** CheckHit.StatModifiers: accuracy and evasion stages on the move. */ + private statModifiers() { + const user = this.user, + target = this.target; + const accuracyLevel = user.levels[C.ACCURACY], + evasionLevel = target.levels[C.EVASION]; + if (evasionLevel >= accuracyLevel && target.has(SUB1, C.SUBSTATUS_IDENTIFIED)) return; + let value = user.move.accuracy; + for (const level of [accuracyLevel, C.MAX_STAT_LEVEL + 1 - evasionLevel]) { + const [numerator, denominator] = this.data.accuracyMultiplier(level); + value = Math.floor((value * numerator) / denominator) || 1; + } + user.move.accuracy = value > 0xff ? 0xff : value; + } + private effectChance() { + this.effectFailed = 0; + if (this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || !(this.random() < this.user.move.chance)) + this.effectFailed = 1; + } + + /** BattleCommand_FailureText. */ + private *failureText(): Gen { + if (!this.attackMissed) return; + yield* this.getFailureResultText(); + const user = this.user; + if (user.move.anim === C.FLY || user.move.anim === C.DIG) + throw new UnsupportedBattle("Fly and Dig are not ported yet."); + this.endMoveEffect(); + } + private *getFailureResultText(): Gen { + let text: number = BATTLE.DoesntAffectText, + protectedText: number = BATTLE.DoesntAffectText; + if (this.typeModifier & C.EFFECTIVENESS_MASK) { + if (this.user.move.effect === C.EFFECT_FUTURE_SIGHT) { + text = BATTLE.ButItFailedText; + protectedText = BATTLE.ItFailedText; + } else { + text = BATTLE.AttackMissedText; + protectedText = BATTLE.AttackMissed2Text; + if (this.criticalHit === 0xff) text = BATTLE.UnaffectedText; + } + } + yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? protectedText : text); + this.criticalHit = 0; + if (this.user.move.effect === C.EFFECT_JUMP_KICK && this.typeModifier & C.EFFECTIVENESS_MASK) + throw new UnsupportedBattle("Jump Kick crash damage is not ported yet."); + } + + /** BattleCommand_ApplyDamage. */ + private *applyDamage(): Gen { + const target = this.target; + let hungOn = 0; + if (target.has(SUB1, C.SUBSTATUS_ENDURE)) throw new UnsupportedBattle("Endure is not ported yet."); + const item = this.data.heldEffect(target.mon.item); + if (item.effect === C.HELD_FOCUS_BAND && this.random() < item.parameter) + hungOn = this.falseSwipe() ? 2 : 0; + if (!target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) + target.damageTaken = Math.min(0xffff, target.damageTaken + this.curDamage); + this.doDamage(target, false); + yield this.hud(); + if (hungOn === 2) { + this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); + yield* this.say(BATTLE.HungOnText); + } + } + /** BattleCommand_FalseSwipe's rule: leave 1 HP if the hit would faint. */ + private falseSwipe(): boolean { + const hp = this.target.mon.hp; + if (this.curDamage < hp) return false; + this.curDamage = hp - 1; + if (this.curDamage <= 0) { + this.curDamage = 0; + return false; + } + return true; + } + /** DoEnemyDamage / DoPlayerDamage. */ + private doDamage(side: Side, ignoreSubstitute: boolean) { + if (!this.curDamage) return; + if (!ignoreSubstitute && side.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) + throw new UnsupportedBattle("Substitutes are not ported yet."); + const hp = side.mon.hp; + if (this.curDamage > hp) this.curDamage = hp; + side.mon.hp = hp - this.curDamage; + if (side === this.player) this.updateBattleMonInParty(); + } + private *criticalText(): Gen { + if (!this.criticalHit) return; + yield* this.say(this.criticalHit === 1 ? BATTLE.CriticalHitText : BATTLE.OneHitKOText); + this.criticalHit = 0; + } + private *superEffectiveText(): Gen { + const effectiveness = this.typeModifier & C.EFFECTIVENESS_MASK; + if (effectiveness === C.EFFECTIVE) return; + yield* this.say(effectiveness > C.EFFECTIVE ? BATTLE.SuperEffectiveText : BATTLE.NotVeryEffectiveText); + } + /** BattleCommand_CheckFaint: end the move if the target fainted. */ + private *checkFaint(): Gen { + if (this.target.mon.hp) return; + if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND)) + throw new UnsupportedBattle("Destiny Bond is not ported yet."); + const effect = this.user.move.effect; + if ([C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP].includes(effect as never)) + throw new UnsupportedBattle("Multi-hit moves are not ported yet."); + this.endMoveEffect(); + } + private *buildOpponentRage(): Gen { + if (this.attackMissed) return; + const target = this.target; + if (!target.has(SUB4, C.SUBSTATUS_RAGE)) return; + if (target.rageCounter === 0xff) return; + target.rageCounter++; + this.turn ^= 1; + yield* this.say(BATTLE.RageBuildingText); + this.turn ^= 1; + } + private rageDamage() { + let damage = this.curDamage; + for (let count = this.user.rageCounter; count > 0; count--) { + damage += this.curDamage; + if (damage > 0xffff) { + damage = 0xffff; + break; + } + } + this.curDamage = damage; + } + /** BattleCommand_HeldFlinch (kingsrock). */ + private kingsRock() { + if (this.attackMissed) return; + const item = this.data.heldEffect(this.user.mon.item); + if (item.effect !== C.HELD_FLINCH || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return; + if (this.random() >= item.parameter) return; + this.target.reset(SUB4, C.SUBSTATUS_RECHARGE); + this.target.set(SUB3, C.SUBSTATUS_FLINCHED); + } + + // ------------------------------------------------------------- statuses + + private safeguarded() { + return Boolean(this.target.screens & (1 << C.SCREENS_SAFEGUARD)); + } + private isType(side: Side, type: number) { + return side.mon.types[0] === type || side.mon.types[1] === type; + } + /** UseHeldStatusHealingItem for the opponent. Returns true if it cured. */ + private *useHeldStatusHealingItem(): Gen { + const side = this.target, + held = this.data.heldEffect(side.mon.item); + const mask = this.data.heldStatusHealing(held.effect); + if (mask === undefined || !(side.mon.status & mask)) return false; + side.mon.status = 0; + this.updateOpponentInParty(); + side.reset(SUB5, C.SUBSTATUS_TOXIC); + side.reset(SUB1, C.SUBSTATUS_NIGHTMARE); + // ALL_STATUS (a Miracle Berry) also cures confusion. + if (mask === this.data.heldStatusHealing(C.HELD_HEAL_STATUS)) side.reset(SUB3, C.SUBSTATUS_CONFUSED); + this.calcStats(side); + yield this.hud(); + yield* this.useOpponentItem(); + return true; + } + /** UseOpponentItem: name the item, consume it and report the recovery. */ + private *useOpponentItem(): Gen { + const side = this.target; + this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item); + this.consumeHeldItem(side); + yield* this.say(BATTLE.RecoveredUsingText); + } + /** ConsumeHeldItem: consumable held items are used up for good. */ + private consumeHeldItem(side: Side) { + const effect = this.data.heldEffect(side.mon.item).effect; + if (!this.data.byteList(BATTLE.ConsumableEffects).includes(effect)) return; + side.mon.item = 0; + if (side === this.player) this.partyMon.item = 0; + } + /** HandleHPHealingItem for the opponent: a Berry below half HP. */ + private *handleHPHealingItem(): Gen { + const side = this.target, + mon = side.mon, + held = this.data.heldEffect(mon.item); + if (held.effect !== C.HELD_BERRY) return; + if (((mon.hp * 2) & 0xffff) >= mon.maxHp) return; + mon.hp = Math.min(mon.maxHp, mon.hp + held.parameter); + if (side === this.player) this.updateBattleMonInParty(); + yield this.hud(); + yield* this.useOpponentItem(); + } + /** UseConfusionHealingItem for the opponent. */ + private *useConfusionHealingItem(): Gen { + const side = this.target; + if (!side.has(SUB3, C.SUBSTATUS_CONFUSED)) return; + const effect = this.data.heldEffect(side.mon.item).effect; + if (effect !== C.HELD_HEAL_CONFUSION && effect !== C.HELD_HEAL_STATUS) return; + side.reset(SUB3, C.SUBSTATUS_CONFUSED); + this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item); + yield* this.say(BATTLE.BattleText_ItemHealedConfusion); + side.mon.item = 0; + if (side === this.player) this.partyMon.item = 0; + } + private *poisonTarget(): Gen { + const target = this.target; + if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return; + if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || this.isType(target, C.POISON)) return; + if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_POISON) return; + if (this.effectFailed || this.safeguarded()) return; + target.mon.status |= 1 << C.PSN; + this.updateOpponentInParty(); + yield this.hud(); + yield* this.say(BATTLE.WasPoisonedText); + yield* this.useHeldStatusHealingItem(); + } + private *burnTarget(): Gen { + const target = this.target; + if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return; + if (target.mon.status) { + if (target.mon.status & (1 << C.FRZ)) throw new UnsupportedBattle("Defrosting is not ported yet."); + return; + } + if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return; + if (this.user.move.type !== C.NORMAL && this.isType(target, this.user.move.type)) return; + if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_BURN) return; + if (this.effectFailed || this.safeguarded()) return; + target.mon.status |= 1 << C.BRN; + this.updateOpponentInParty(); + this.applyStatusEffects(target); + yield this.hud(); + yield* this.say(BATTLE.WasBurnedText); + yield* this.useHeldStatusHealingItem(); + } + private *paralyzeTarget(): Gen { + const target = this.target; + if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return; + if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return; + if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_PARALYZE) return; + if (this.effectFailed || this.safeguarded()) return; + target.mon.status |= 1 << C.PAR; + this.updateOpponentInParty(); + this.applyStatusEffects(target); + yield this.hud(); + yield* this.say(BATTLE.ParalyzedText); + yield* this.useHeldStatusHealingItem(); + } + private flinchTarget() { + const target = this.target; + if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return; + if (target.mon.status & ((1 << C.FRZ) | C.SLP_MASK)) return; + if (this.enemyGoesFirst ^ this.turn) return; + if (this.effectFailed) return; + target.set(SUB3, C.SUBSTATUS_FLINCHED); + target.reset(SUB4, C.SUBSTATUS_RECHARGE); + } + /** The AI's 25% failure for status moves (not for the player). */ + private aiRandomFail() { + if (this.turn === 0 || this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) return false; + return this.random() < percent(25) + 1; + } + private *didntAffect2(): Gen { + yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.DidntAffect2Text : BATTLE.DidntAffect1Text); + } + private *sleepTarget(): Gen { + const target = this.target; + const item = this.data.heldEffect(target.mon.item); + if (item.effect === C.HELD_PREVENT_SLEEP) { + this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); + return yield* this.say(BATTLE.ProtectedByText); + } + if (target.mon.status & C.SLP_MASK) return yield* this.say(BATTLE.AlreadyAsleepText); + if (this.attackMissed) return yield* this.didntAffect2(); + if (this.aiRandomFail() || target.mon.status || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) + return yield* this.say(BATTLE.DidntAffect1Text); + let turns: number; + do turns = this.random() & C.SLP_MASK; + while (!turns || turns === C.SLP_MASK); + target.mon.status = turns + 1; + this.updateOpponentInParty(); + yield this.hud(); + yield* this.say(BATTLE.FellAsleepText); + // A sleeping opponent cannot finish a move that spans turns. + if (!(yield* this.useHeldStatusHealingItem())) { + this.turn ^= 1; + this.cantMove(); + this.turn ^= 1; + } + } + private *poisonCommand(): Gen { + const target = this.target; + if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || this.isType(target, C.POISON)) + return yield* this.say(BATTLE.DoesntAffectText); + if (target.mon.status & (1 << C.PSN)) return yield* this.say(BATTLE.AlreadyPoisonedText); + if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_POISON) { + this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); + return yield* this.say(BATTLE.ProtectedByText); + } + if (target.mon.status || this.aiRandomFail() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed) + return yield* this.say(BATTLE.DidntAffect1Text); + if (this.user.move.effect === C.EFFECT_TOXIC) throw new UnsupportedBattle("Toxic is not ported yet."); + target.mon.status |= 1 << C.PSN; + this.updateOpponentInParty(); + yield this.hud(); + yield* this.say(BATTLE.WasPoisonedText); + yield* this.useHeldStatusHealingItem(); + } + private *paralyzeCommand(): Gen { + const target = this.target; + if (target.mon.status & (1 << C.PAR)) return yield* this.say(BATTLE.AlreadyParalyzedText); + if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return yield* this.say(BATTLE.DoesntAffectText); + if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_PARALYZE) { + this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); + return yield* this.say(BATTLE.ProtectedByText); + } + if (this.aiRandomFail() || target.mon.status || this.attackMissed || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) + return yield* this.didntAffect2(); + target.mon.status |= 1 << C.PAR; + this.updateOpponentInParty(); + this.applyStatusEffects(target); + yield this.hud(); + yield* this.say(BATTLE.ParalyzedText); + yield* this.useHeldStatusHealingItem(); + } + private *confuseCommand(): Gen { + const target = this.target; + if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_CONFUSE) { + this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); + return yield* this.say(BATTLE.ProtectedByText); + } + if (target.has(SUB3, C.SUBSTATUS_CONFUSED)) return yield* this.say(BATTLE.AlreadyConfusedText); + if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed) { + const effect = this.user.move.effect; + if (effect === C.EFFECT_CONFUSE_HIT || effect === C.EFFECT_SNORE || effect === C.EFFECT_SWAGGER) return; + return yield* this.didntAffect2(); + } + target.set(SUB3, C.SUBSTATUS_CONFUSED); + target.confuseCount = (this.random() & 3) + 2; + yield* this.say(BATTLE.BecameConfusedText); + yield* this.useConfusionHealingItem(); + } + /** BattleCommand_DrainTarget: SapHealth restores half the damage. */ + private *drainTarget(): Gen { + const mon = this.user.mon; + const amount = this.curDamage >> 1 || 1; + mon.hp = Math.min(mon.maxHp, mon.hp + amount); + if (this.turn === 0) this.updateBattleMonInParty(); + yield this.hud(); + yield* this.say(BATTLE.SuckedHealthText); + } + private *checkSafeguard(): Gen { + if (!this.safeguarded()) return; + this.attackMissed = 1; + yield* this.say(BATTLE.SafeguardProtectText); + this.endMoveEffect(); + } + private *foresight(): Gen { + const target = this.target; + if (this.attackMissed || target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)) || target.has(SUB1, C.SUBSTATUS_IDENTIFIED)) + return yield* this.say(target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.ItFailedText : BATTLE.ButItFailedText); + target.set(SUB1, C.SUBSTATUS_IDENTIFIED); + yield* this.say(BATTLE.IdentifiedText); + } + /** BattleCommand_TimeBasedHealContinue (Morning Sun, Synthesis, Moonlight). */ + private *timeBasedHeal(time: number): Gen { + const mon = this.user.mon; + if (mon.hp === mon.maxHp) return yield* this.say(BATTLE.HPIsFullText); + let index = 2; + if (this.options.timeOfDay !== time) index--; + if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); + const quarter = mon.maxHp >> 2 || 1; + const amount = [quarter >> 1 || 1, quarter, mon.maxHp >> 1 || 1, mon.maxHp][index]; + mon.hp = Math.min(mon.maxHp, mon.hp + amount); + if (this.turn === 0) this.updateBattleMonInParty(); + yield this.hud(); + yield* this.say(BATTLE.RegainedHealthText); + } + private updateOpponentInParty() { + if (this.turn === 1) this.updateBattleMonInParty(); + } + + // ------------------------------------------------------ stat changes + + /** RaiseStat (BattleCommand_StatUp). */ + private raiseStat(stat: number) { + this.loweredStat = stat; + const user = this.user, + index = stat & 0xf; + if (this.attackMissed || this.effectFailed) { + this.failedMessage = 1; + return; + } + let level = user.levels[index] + 1; + if (level > C.MAX_STAT_LEVEL) { + this.failedMessage = 2; + this.attackMissed = 1; + return; + } + if (stat & 0xf0) level = Math.min(C.MAX_STAT_LEVEL, level + 1); + user.levels[index] = level; + if (index < C.ACCURACY) { + if (user.mon.stats[index] === 999) { + user.levels[index]--; + this.failedMessage = 2; + this.attackMissed = 1; + return; + } + this.calcStats(user); + } + this.failedMessage = 0; + if (user.move.anim === C.MINIMIZE) throw new UnsupportedBattle("Minimize is not ported yet."); + } + /** BattleCommand_StatDown. */ + private statDown(stat: number) { + this.loweredStat = stat; + const effect = this.user.move.effect; + const mistEffect = + (effect >= C.EFFECT_ATTACK_DOWN && effect <= C.EFFECT_EVASION_DOWN) || + (effect >= C.EFFECT_ATTACK_DOWN_2 && effect <= C.EFFECT_EVASION_DOWN_2) || + (effect >= C.EFFECT_ATTACK_DOWN_HIT && effect <= C.EFFECT_EVASION_DOWN_HIT); + const target = this.target; + if (mistEffect && target.has(SUB4, C.SUBSTATUS_MIST)) { + this.failedMessage = 2; + this.attackMissed = 1; + return; + } + const index = stat & 0xf; + let level = target.levels[index] - 1; + const cantLower = () => { + this.failedMessage = 3; + this.attackMissed = 1; + }; + if (!level) return cantLower(); + if (stat & 0xf0) { + level--; + if (!level) level = 1; + } + const failed = () => { + this.failedMessage = 1; + this.attackMissed = 1; + }; + if (this.turn === 1 && effect !== C.EFFECT_ACCURACY_DOWN_HIT && !this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) + if (this.random() < percent(25) + 1) return failed(); + if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed || this.effectFailed) return failed(); + if (target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND))) return failed(); + target.levels[index] = level; + if (index < C.ACCURACY) { + // TryLowerStat: a stat already at 1 cannot drop further. The level is + // raised back by one, even after a two-stage drop. + if (target.mon.stats[index] === 1) { + target.levels[index] = level + 1; + return cantLower(); + } + this.calcStats(target); + } + this.failedMessage = 0; + } + /** CalcPlayerStats / CalcEnemyStats: stages, then status penalties. */ + private calcStats(side: Side) { + for (let stat = 0; stat < 5; stat++) { + const [numerator, denominator] = this.data.statMultiplier(side.levels[stat]); + const value = Math.floor((side.stats[stat] * numerator) / denominator); + side.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value); + } + this.applyStatusEffects(side); + } + private statName() { + this.strings[RAM.stringBuffer2] = readString( + this.reader, + this.data.statNameAddress(this.loweredStat & 0xf), + this.context(), + ); + } + private *statUpMessage(): Gen { + if (this.failedMessage) return; + this.statName(); + yield* this.say(BATTLE["BattleCommand_StatUpMessage.stat"]); + } + private *statDownMessage(): Gen { + if (this.failedMessage) return; + this.statName(); + yield* this.say(BATTLE["BattleCommand_StatDownMessage.stat"]); + } + private *statUpFailText(): Gen { + if (!this.failedMessage) return; + if (this.failedMessage === 1) { + if (!this.alreadyFailed) yield* this.say(BATTLE.ButItFailedText); + return; + } + this.statName(); + yield* this.say(BATTLE.WontRiseAnymoreText); + } + private *statDownFailText(): Gen { + if (!this.failedMessage) return; + if (this.failedMessage === 1) { + if (!this.alreadyFailed) yield* this.say(BATTLE.ButItFailedText); + return; + } + if (this.failedMessage === 2) return yield* this.say(BATTLE.ProtectedByMistText); + this.statName(); + yield* this.say(BATTLE.WontDropAnymoreText); + } + + // ------------------------------------------------------- turn ending + + /** ResidualDamage for the side whose turn it is. Returns false if it fainted. */ + private *residualDamage(): Gen { + const user = this.user, + mon = user.mon; + if (!mon.hp) return false; + if (mon.status & ((1 << C.PSN) | (1 << C.BRN))) { + yield* this.say(mon.status & (1 << C.BRN) ? BATTLE.HurtByBurnText : BATTLE.HurtByPoisonText); + if (user.has(SUB5, C.SUBSTATUS_TOXIC)) throw new UnsupportedBattle("Toxic is not ported yet."); + const quarter = mon.maxHp >> 2 || 1; + this.subtractHP(user, quarter >> 1 || 1); + yield this.hud(); + } + if (!mon.hp) return false; + if (user.sub[SUB4] & (1 << C.SUBSTATUS_LEECH_SEED) || user.sub[SUB1] & ((1 << C.SUBSTATUS_NIGHTMARE) | (1 << C.SUBSTATUS_CURSE))) + throw new UnsupportedBattle("Leech Seed, Nightmare and Curse are not ported yet."); + return mon.hp > 0; + } + private subtractHP(side: Side, amount: number) { + side.mon.hp = Math.max(0, side.mon.hp - amount); + if (side === this.player) this.updateBattleMonInParty(); + } + /** HandleBetweenTurnEffects. Effects no early move can cause are refused. */ + private *handleBetweenTurnEffects(): Gen { + if (yield* this.checkFaintPlayerThenEnemy()) return; + if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); + for (const side of [this.player, this.enemy]) { + if (side.wrapCount || side.has(SUB1, C.SUBSTATUS_PERISH) || side.has(SUB5, C.SUBSTATUS_ENCORED)) + throw new UnsupportedBattle("Wrap, Perish Song and Encore are not ported yet."); + if (side.screens) throw new UnsupportedBattle("Screens and Safeguard are not ported yet."); + const effect = this.data.heldEffect(side.mon.item).effect; + if (effect === C.HELD_RESTORE_PP || (effect >= C.HELD_ATTACK_UP && effect <= C.HELD_EVASION_UP)) + throw new UnsupportedBattle("Mystery Berry and stat-raising held items are not ported yet."); + } + // HandleLeftovers: each side heals itself; the text names it as target. + for (const side of [this.player, this.enemy]) { + const mon = side.mon; + if (this.data.heldEffect(mon.item).effect !== C.HELD_LEFTOVERS || mon.hp === mon.maxHp) continue; + this.strings[RAM.stringBuffer1] = this.options.data.itemName(mon.item); + const quarter = mon.maxHp >> 2 || 1; + mon.hp = Math.min(mon.maxHp, mon.hp + (quarter >> 2 || 1)); + if (side === this.player) this.updateBattleMonInParty(); + this.turn = side === this.player ? 1 : 0; + yield this.hud(); + yield* this.say(BATTLE.BattleText_TargetRecoveredWithItem); + } + // HandleDefrost: a 10% thaw for a side that did not just freeze. + for (const side of [this.player, this.enemy]) { + if (!(side.mon.status & (1 << C.FRZ)) || side.justGotFrozen) continue; + if (this.random() >= percent(10)) continue; + side.mon.status = 0; + if (side === this.player) this.updateBattleMonInParty(); + this.turn = side === this.player ? 1 : 0; + yield this.hud(); + yield* this.say(BATTLE.DefrostedOpponentText); + } + // HandleHealingItems: turn 0 handles the enemy's items, then turn 1. + for (const turn of [0, 1]) { + this.turn = turn; + yield* this.handleHPHealingItem(); + yield* this.useHeldStatusHealingItem(); + yield* this.useConfusionHealingItem(); + } + this.updateBattleMonInParty(); + } + private *checkFaintPlayerThenEnemy(): Gen { + if (this.hasFainted(this.player)) { + yield* this.handlePlayerMonFaint(); + if (this.battleEnded) return true; + } + if (this.hasFainted(this.enemy)) { + yield* this.handleEnemyMonFaint(); + if (this.battleEnded) return true; + } + return false; + } + + private *handleEnemyMonFaint(): Gen { + yield* this.say(BATTLE.BattleText_EnemyMonFainted); + if (!this.player.mon.hp) yield* this.faintYourPokemon(); + yield* this.updateStateAndExperienceAfterEnemyFaint(false); + if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle(); + yield this.hud(); + this.battleEnded = true; + } + private *faintYourPokemon(): Gen { + yield* this.say(BATTLE.BattleText_MonFainted); + } + private *handlePlayerMonFaint(): Gen { + yield* this.faintYourPokemon(); + if (!this.enemy.mon.hp) yield* this.say(BATTLE.BattleText_EnemyMonFainted); + this.updateFaintedPlayerMon(); + if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle(); + if (!this.enemy.mon.hp) { + yield* this.updateStateAndExperienceAfterEnemyFaint(true); + this.battleEnded = true; + return; + } + throw new UnsupportedBattle("Switching Pokémon is not ported yet."); + } + private updateFaintedPlayerMon() { + this.participants &= ~(1 << this.curBattleMon); + this.enemy.reset(SUB3, C.SUBSTATUS_IN_LOOP); + this.player.damageTaken = 0; + this.player.mon.status = 0; + this.updateBattleMonInParty(); + // ChangeHappiness: fainting, or fainting to a much stronger foe. + const mon = this.partyMon; + const strong = this.enemy.mon.level >= this.player.mon.level + 30; + const change = this.happinessChange(strong ? "beatenByStrongFoe" : "fainted", mon.happiness); + mon.happiness = Math.max(0, Math.min(255, mon.happiness + change)); + this.battleResult = (this.battleResult & 0xc0) + C.LOSE; + } + /** HappinessChanges: changes by current happiness (<100, <200, else). */ + private happinessChange(kind: "fainted" | "beatenByStrongFoe" | "levelUp", happiness: number) { + const tier = happiness < 100 ? 0 : happiness < 200 ? 1 : 2; + const rows = { levelUp: [5, 3, 2], fainted: [-1, -1, -1], beatenByStrongFoe: [-5, -5, -10] }; + return rows[kind][tier]; + } + private *lostBattle(): Gen { + this.battleEnded = true; + this.battleResult = (this.battleResult & 0xc0) + C.LOSE; + } + + /** UpdateBattleStateAndExperienceAfterEnemyFaint and GiveExperiencePoints. */ + private *updateStateAndExperienceAfterEnemyFaint(playerFaintedFirst: boolean): Gen { + this.updateBattleMonInParty(); + this.player.reset(SUB3, C.SUBSTATUS_IN_LOOP); + this.enemy.damageTaken = 0; + if (!this.player.mon.hp && !playerFaintedFirst) this.updateFaintedPlayerMon(); + if (!this.options.party.some((mon) => mon.hp > 0)) return; + this.battleResult &= 0xc0; // WIN + if (this.options.party.some((mon) => mon.hp > 0 && mon.item === C.EXP_SHARE)) + throw new UnsupportedBattle("Exp. Share is not ported yet."); + yield* this.giveExperience(); + } + private *giveExperience(): Gen { + const party = this.options.party, + data = this.options.data; + const count = [0, 1, 2, 3, 4, 5].filter((index) => this.participants & (1 << index)).length; + const base = { ...this.enemyBase, stats: [...this.enemyBase.stats] }; + if (count >= 2) { + base.stats = base.stats.map((value) => Math.floor(value / count)); + base.catchRate = Math.floor(base.catchRate / count); + base.exp = Math.floor(base.exp / count); + } + for (let index = 0; index < party.length; index++) { + const mon = party[index]; + if (!mon.hp || !(this.participants & (1 << index))) continue; + // Stat experience: the defeated Pokémon's base stats, twice with Pokérus. + for (let stat = 0; stat < 5; stat++) { + let value = mon.statExp[stat] + base.stats[stat]; + if (mon.pokerus) value += base.stats[stat]; + mon.statExp[stat] = Math.min(0xffff, value); + } + let exp = Math.floor((base.exp * this.enemy.mon.level) / 7); + const traded = mon.otId !== this.options.playerId; + const boost = (value: number) => value + (value >> 1); + if (traded) exp = boost(exp); + if (this.battleMode !== C.WILD_BATTLE) exp = boost(exp); + if (mon.item === C.LUCKY_EGG) exp = boost(exp); + exp &= 0xffff; + this.numbers[RAM.stringBuffer2] = exp; + this.numbers[RAM.stringBuffer2 + 2] = traded ? 1 : 0; + this.strings[RAM.stringBuffer1] = mon.nickname; + yield* this.say(BATTLE.Text_MonGainedExpPoint); + const growth = data.baseData(mon.species).growthRate; + mon.exp = Math.min(data.expAtLevel(growth, C.MAX_LEVEL), Math.min(0xffffff, mon.exp + exp)); + yield this.hud(); + const newLevel = this.levelForExp(mon.species, mon.exp); + if (mon.level >= C.MAX_LEVEL || newLevel === mon.level) continue; + const oldLevel = mon.level; + mon.level = newLevel; + const oldMax = mon.stats[0]; + mon.stats = calcStats(data, mon.species, mon.dvs, newLevel, mon.statExp); + mon.hp = Math.min(mon.stats[0], mon.hp + (mon.stats[0] - oldMax)); + if (index === this.curBattleMon) { + const side = this.player; + side.mon.hp = mon.hp; + side.mon.maxHp = mon.stats[0]; + side.mon.level = newLevel; + side.stats = mon.stats.slice(1); + this.calcStats(side); + } + mon.happiness = Math.min(255, mon.happiness + this.happinessChange("levelUp", mon.happiness)); + this.strings[RAM.stringBuffer1] = mon.nickname; + this.numbers[RAM.curPartyLevel] = newLevel; + yield* this.say(BATTLE.BattleText_StringBuffer1GrewToLevel); + yield this.hud(); + // PrintTempMonStats: Attack, Defense, Sp. Atk, Sp. Def, Speed. + yield { + kind: "stats", + labels: readString(this.reader, BATTLE["PrintTempMonStats.StatNames"], this.context(), 64).split("\n").filter(Boolean), + values: [mon.stats[1], mon.stats[2], mon.stats[4], mon.stats[5], mon.stats[3]], + }; + for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level); + } + this.participants = 1 << this.curBattleMon; + } + /** CalcLevel: the highest level whose exp does not exceed the total. */ + private levelForExp(species: number, exp: number) { + const growth = this.options.data.baseData(species).growthRate; + let level = 1; + while (level < C.MAX_LEVEL && this.options.data.expAtLevel(growth, level + 1) <= exp) level++; + return level; + } + /** LearnLevelMoves: moves the species learns at exactly this level. */ + private *learnLevelMoves(mon: PartyMon, index: number, level: number): Gen { + for (const learned of this.options.data.learnset(mon.species)) { + if (learned.level !== level || mon.moves.includes(learned.move)) continue; + yield* this.learnMove(mon, index, learned.move); + } + } + /** LearnMove, including the choice of a move to forget. */ + private *learnMove(mon: PartyMon, index: number, move: number): Gen { + const data = this.options.data; + this.strings[RAM.stringBuffer1] = mon.nickname; + this.strings[RAM.monOrItemNameBuffer] = mon.nickname; + this.strings[RAM.stringBuffer2] = data.moveName(move); + for (;;) { + let slot = mon.moves.indexOf(0); + if (slot < 0) { + slot = yield* this.forgetMove(mon); + if (slot < 0) { + yield* this.say(BATTLE.StopLearningMoveText); + if (!(yield { kind: "yesno", text: "" })) continue; + yield* this.say(BATTLE.DidNotLearnMoveText); + return; + } + const forgotten = mon.moves[slot]; + if (this.player.disabledMove === forgotten) { + this.player.disabledMove = 0; + this.player.disableCount = 0; + } + this.strings[RAM.stringBuffer1] = data.moveName(forgotten); + yield* this.say(BATTLE.Text_1_2_and_Poof); + } + mon.moves[slot] = move; + mon.pp[slot] = data.move(move).pp; + if (index === this.curBattleMon && !this.player.has(SUB5, C.SUBSTATUS_TRANSFORMED)) { + this.player.mon.moves = [...mon.moves]; + this.player.mon.pp = [...mon.pp]; + } + yield* this.say(BATTLE.LearnedMoveText); + return; + } + } + /** ForgetMove: returns the slot to replace, or -1 to stop. */ + private *forgetMove(mon: PartyMon): Gen { + yield* this.say(BATTLE.AskForgetMoveText); + if (!(yield { kind: "yesno", text: "" })) return -1; + for (;;) { + yield* this.say(BATTLE.MoveAskForgetText); + const choice = yield { kind: "forget", moves: [...mon.moves], learning: 0 }; + if (typeof choice !== "number" || choice < 0 || choice > 3) return -1; + if (this.data.byteList(BATTLE["IsHMMove.HMMoves"]).includes(mon.moves[choice])) { + yield* this.say(BATTLE.MoveCantForgetHMText); + continue; + } + return choice; + } + } +} diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts new file mode 100644 index 0000000..f9197db --- /dev/null +++ b/src/game/battle/constants.ts @@ -0,0 +1,294 @@ +/** Battle constants under their PRET names (constants/*.asm and + * macros/scripts/battle_commands.asm). `npm run check:reference` compares + * every entry with the pinned source, so a wrong value cannot ship. */ +export const C = { + // Move effects (constants/move_effect_constants.asm) + EFFECT_NORMAL_HIT: 0, + EFFECT_LEECH_HIT: 3, + EFFECT_SELFDESTRUCT: 7, + EFFECT_DREAM_EATER: 8, + EFFECT_ALWAYS_HIT: 17, + EFFECT_ATTACK_DOWN: 18, + EFFECT_EVASION_DOWN: 24, + EFFECT_BIDE: 26, + EFFECT_RAMPAGE: 27, + EFFECT_MULTI_HIT: 29, + EFFECT_CONVERSION: 30, + EFFECT_TOXIC: 33, + EFFECT_RAZOR_WIND: 39, + EFFECT_DOUBLE_HIT: 44, + EFFECT_JUMP_KICK: 45, + EFFECT_ATTACK_DOWN_2: 58, + EFFECT_EVASION_DOWN_2: 64, + EFFECT_ATTACK_DOWN_HIT: 68, + EFFECT_ACCURACY_DOWN_HIT: 73, + EFFECT_EVASION_DOWN_HIT: 74, + EFFECT_SKY_ATTACK: 75, + EFFECT_CONFUSE_HIT: 76, + EFFECT_POISON_MULTI_HIT: 77, + EFFECT_RAGE: 81, + EFFECT_SPLASH: 85, + EFFECT_SNORE: 92, + EFFECT_PRIORITY_HIT: 103, + EFFECT_TRIPLE_KICK: 104, + EFFECT_PROTECT: 111, + EFFECT_ENDURE: 116, + EFFECT_ROLLOUT: 117, + EFFECT_SWAGGER: 118, + EFFECT_FURY_CUTTER: 119, + EFFECT_SKULL_BASH: 145, + EFFECT_FUTURE_SIGHT: 148, + EFFECT_SOLARBEAM: 151, + EFFECT_THUNDER: 152, + EFFECT_BEAT_UP: 154, + EFFECT_FLY: 155, + + // Moves (constants/move_constants.asm) + GUST: 16, + WHIRLWIND: 18, + FLY: 19, + THUNDER: 87, + EARTHQUAKE: 89, + FISSURE: 90, + DIG: 91, + MIMIC: 102, + MINIMIZE: 107, + SUBSTITUTE: 164, + STRUGGLE: 165, + FLAME_WHEEL: 172, + SNORE: 173, + SLEEP_TALK: 214, + SACRED_FIRE: 221, + MAGNITUDE: 222, + VITAL_THROW: 233, + TWISTER: 239, + + // Types (constants/type_constants.asm) + NORMAL: 0, + FLYING: 2, + POISON: 3, + FIRE: 20, + SPECIAL: 20, + ICE: 25, + + // Species and items + PIKACHU: 25, + FARFETCH_D: 83, + CUBONE: 104, + MAROWAK: 105, + CHANSEY: 113, + MAGIKARP: 129, + DITTO: 132, + UNOWN: 201, + NO_ITEM: 0, + LUCKY_PUNCH: 30, + METAL_POWDER: 35, + STICK: 105, + THICK_CLUB: 118, + EXP_SHARE: 57, + CLEANSE_TAG: 94, + LUCKY_EGG: 126, + LIGHT_BALL: 163, + + // Held item effects (constants/item_data_constants.asm) + HELD_BERRY: 1, + HELD_LEFTOVERS: 3, + HELD_RESTORE_PP: 6, + HELD_HEAL_POISON: 10, + HELD_HEAL_PARALYZE: 14, + HELD_HEAL_STATUS: 15, + HELD_HEAL_CONFUSION: 16, + HELD_PREVENT_POISON: 20, + HELD_PREVENT_BURN: 21, + HELD_PREVENT_FREEZE: 22, + HELD_PREVENT_SLEEP: 23, + HELD_PREVENT_PARALYZE: 24, + HELD_PREVENT_CONFUSE: 25, + HELD_ESCAPE: 72, + HELD_CRITICAL_UP: 73, + HELD_QUICK_CLAW: 74, + HELD_FLINCH: 75, + HELD_ATTACK_UP: 31, + HELD_EVASION_UP: 37, + HELD_BRIGHTPOWDER: 77, + HELD_FOCUS_BAND: 79, + + // Stats and levels + ATTACK: 0, + DEFENSE: 1, + SPEED: 2, + SP_ATTACK: 3, + SP_DEFENSE: 4, + ACCURACY: 5, + EVASION: 6, + BASE_STAT_LEVEL: 7, + MAX_STAT_LEVEL: 13, + MAX_LEVEL: 100, + PP_MASK: 63, + NUM_MOVES: 4, + BASE_HAPPINESS: 70, + START_MONEY: 3000, + MAX_MONEY: 999999, + + // Status bits and masks + SLP_MASK: 7, + PSN: 3, + BRN: 4, + FRZ: 5, + PAR: 6, + + // Substatus bits, by byte: 1 (Nightmare..In Love), 2 (Curled), + // 3 (Bide..Confused), 4 (X Accuracy..Leech Seed), 5 (Toxic..Can't Run) + SUBSTATUS_NIGHTMARE: 0, + SUBSTATUS_CURSE: 1, + SUBSTATUS_PROTECT: 2, + SUBSTATUS_IDENTIFIED: 3, + SUBSTATUS_PERISH: 4, + SUBSTATUS_ENDURE: 5, + SUBSTATUS_ROLLOUT: 6, + SUBSTATUS_IN_LOVE: 7, + SUBSTATUS_CURLED: 0, + SUBSTATUS_BIDE: 0, + SUBSTATUS_RAMPAGE: 1, + SUBSTATUS_IN_LOOP: 2, + SUBSTATUS_FLINCHED: 3, + SUBSTATUS_CHARGED: 4, + SUBSTATUS_UNDERGROUND: 5, + SUBSTATUS_FLYING: 6, + SUBSTATUS_CONFUSED: 7, + SUBSTATUS_X_ACCURACY: 0, + SUBSTATUS_MIST: 1, + SUBSTATUS_FOCUS_ENERGY: 2, + SUBSTATUS_SUBSTITUTE: 4, + SUBSTATUS_RECHARGE: 5, + SUBSTATUS_RAGE: 6, + SUBSTATUS_LEECH_SEED: 7, + SUBSTATUS_TOXIC: 0, + SUBSTATUS_TRANSFORMED: 3, + SUBSTATUS_ENCORED: 4, + SUBSTATUS_LOCK_ON: 5, + SUBSTATUS_DESTINY_BOND: 6, + SUBSTATUS_CANT_RUN: 7, + + // Screens, weather and effectiveness + SCREENS_SPIKES: 0, + SCREENS_SAFEGUARD: 2, + SCREENS_LIGHT_SCREEN: 3, + SCREENS_REFLECT: 4, + WEATHER_NONE: 0, + WEATHER_RAIN: 1, + WEATHER_SUN: 2, + NO_EFFECT: 0, + NOT_VERY_EFFECTIVE: 5, + EFFECTIVE: 10, + SUPER_EFFECTIVE: 20, + STAB_DAMAGE: 128, + EFFECTIVENESS_MASK: 127, + BASE_PRIORITY: 1, + + // Battle modes, types and results + WILD_BATTLE: 1, + TRAINER_BATTLE: 2, + BATTLETYPE_NORMAL: 0, + BATTLETYPE_DEBUG: 2, + BATTLETYPE_TUTORIAL: 3, + BATTLETYPE_ROAMING: 5, + BATTLETYPE_CONTEST: 6, + BATTLETYPE_FORCESHINY: 7, + BATTLETYPE_TRAP: 9, + BATTLETYPE_FORCEITEM: 10, + BATTLETYPE_CELEBI: 11, + BATTLETYPE_SUICUNE: 12, + WIN: 0, + LOSE: 1, + DRAW: 2, + MORN_F: 0, + DAY_F: 1, + NITE_F: 2, +} as const; + +/** Battle command IDs (macros/scripts/battle_commands.asm). */ +export const COMMAND = { + checkturn: 1, + checkobedience: 2, + usedmovetext: 3, + doturn: 4, + critical: 5, + damagestats: 6, + stab: 7, + damagevariation: 8, + checkhit: 9, + lowersub: 10, + moveanimnosub: 11, + raisesub: 12, + failuretext: 13, + applydamage: 14, + criticaltext: 15, + supereffectivetext: 16, + checkfaint: 17, + buildopponentrage: 18, + poisontarget: 19, + sleeptarget: 20, + draintarget: 21, + burntarget: 23, + freezetarget: 24, + paralyzetarget: 25, + flinchtarget: 37, + confuse: 42, + confusetarget: 43, + poison: 47, + paralyze: 48, + splash: 54, + kingsrock: 77, + foresight: 87, + damagecalc: 98, + checksafeguard: 101, + healmorn: 106, + healday: 107, + healnite: 108, + attackup: 112, + defenseup: 113, + speedup: 114, + specialattackup: 115, + specialdefenseup: 116, + accuracyup: 117, + evasionup: 118, + attackup2: 119, + defenseup2: 120, + speedup2: 121, + specialattackup2: 122, + specialdefenseup2: 123, + accuracyup2: 124, + evasionup2: 125, + attackdown: 126, + defensedown: 127, + speeddown: 128, + specialattackdown: 129, + specialdefensedown: 130, + accuracydown: 131, + evasiondown: 132, + attackdown2: 133, + defensedown2: 134, + speeddown2: 135, + specialattackdown2: 136, + specialdefensedown2: 137, + accuracydown2: 138, + evasiondown2: 139, + statupmessage: 140, + statdownmessage: 141, + statupfailtext: 142, + statdownfailtext: 143, + effectchance: 144, + statdownanim: 145, + statupanim: 146, + switchturn: 147, + rage: 151, + doubleflyingdamage: 152, + ragedamage: 162, + clearmissdamage: 169, + movedelay: 170, + moveanim: 171, + curl: 175, + endmove: 0xff, + endturn: 0xfe, +} as const; diff --git a/src/game/battle/data.ts b/src/game/battle/data.ts new file mode 100644 index 0000000..7bff562 --- /dev/null +++ b/src/game/battle/data.ts @@ -0,0 +1,116 @@ +import { RomReader } from "../rom/reader"; +import { BATTLE } from "../rom/profile"; + +/** Readers for the battle engine's ROM tables. */ +export class BattleData { + constructor(readonly reader: RomReader) {} + + /** The effect script bytes for a move effect, up to endmove. */ + effectScript(effect: number): number[] { + const r = this.reader; + let p = r.near( + BATTLE.MoveEffectsPointers + effect * 2, + r.bank(BATTLE.MoveEffectsPointers), + ); + const script: number[] = []; + for (let i = 0; i < 64; i++) { + const command = r.byte(p++); + script.push(command); + if (command === 0xff) return script; + } + throw new Error("Move effect script is unterminated."); + } + + /** TypeMatchups rows: attacking type, defending type, multiplier. The + * row with attacker 0xfe marks where Foresight stops the lookup. */ + typeMatchups(): { attacker: number; defender: number; multiplier: number }[] { + const r = this.reader, + rows = []; + for (let p = BATTLE.TypeMatchups; rows.length < 256; ) { + const attacker = r.byte(p++); + if (attacker === 0xff) return rows; + if (attacker === 0xfe) { + rows.push({ attacker, defender: 0, multiplier: 0 }); + continue; + } + rows.push({ attacker, defender: r.byte(p), multiplier: r.byte(p + 1) }); + p += 2; + } + throw new Error("Type matchups are unterminated."); + } + + /** Numerator and denominator pairs for stat levels 1-13. */ + statMultiplier(level: number): [number, number] { + const p = BATTLE.StatLevelMultipliers + (level - 1) * 2; + return [this.reader.byte(p), this.reader.byte(p + 1)]; + } + accuracyMultiplier(level: number): [number, number] { + const p = BATTLE.AccuracyLevelMultipliers + (level - 1) * 2; + return [this.reader.byte(p), this.reader.byte(p + 1)]; + } + criticalChance(stage: number): number { + return this.reader.byte(BATTLE.CriticalHitChances + Math.min(stage, 6)); + } + /** A -1 terminated byte list, such as CriticalHitMoves. */ + byteList(address: number): number[] { + const values: number[] = []; + for (let p = address; values.length < 256; p++) { + const value = this.reader.byte(p); + if (value === 0xff) return values; + values.push(value); + } + throw new Error("List is unterminated."); + } + /** MoveEffectPriorities: effect, priority pairs. */ + priority(effect: number, basePriority: number): number { + const r = this.reader; + for (let p = BATTLE.MoveEffectPriorities; ; p += 2) { + const entry = r.byte(p); + if (entry === effect) return r.byte(p + 1); + if (entry === 0xff) return basePriority; + } + } + /** TypeBoostItems: held effect, type pairs. */ + typeBoost(heldEffect: number): number | undefined { + const r = this.reader; + for (let p = BATTLE.TypeBoostItems; ; p += 2) { + const entry = r.byte(p); + if (entry === 0xff) return undefined; + if (entry === heldEffect) return r.byte(p + 1); + } + } + /** GetItemHeldEffect: the held effect and its parameter. */ + heldEffect(item: number): { effect: number; parameter: number } { + if (!item) return { effect: 0, parameter: 0 }; + const p = BATTLE.ItemAttributes + (item - 1) * 7 + 2; + return { effect: this.reader.byte(p), parameter: this.reader.byte(p + 1) }; + } + /** HeldStatusHealingEffects: the statuses a held item cures. */ + heldStatusHealing(effect: number): number | undefined { + const r = this.reader; + for (let p = BATTLE.HeldStatusHealingEffects; ; p += 2) { + const entry = r.byte(p); + if (entry === 0xff) return undefined; + if (entry === effect) return r.byte(p + 1); + } + } + /** GetMoveGrammar: the grammar set a move's "used" text follows. */ + moveGrammar(move: number): number { + const r = this.reader; + // Moves missing from every set use the set after the last one. + let set = 0; + for (let p = BATTLE.MoveGrammar; set < 8; ) { + const entry = r.byte(p++); + if (entry === 0xff || entry === move) return set; + if (entry === 0) set++; + } + throw new Error("Move grammar table is unterminated."); + } + /** GetStatName: the n-th "@"-terminated name in StatNames. */ + statNameAddress(stat: number): number { + const r = this.reader; + let p = BATTLE.StatNames; + for (let skipped = 0; skipped < stat; skipped++) while (r.byte(p++) !== 0x50); + return p; + } +} diff --git a/src/game/party/bag.ts b/src/game/party/bag.ts new file mode 100644 index 0000000..c135c30 --- /dev/null +++ b/src/game/party/bag.ts @@ -0,0 +1,133 @@ +import type { PokemonData, Pocket } from "../rom/pokemon"; + +/** The player's pack, with the original pocket capacities. */ +export interface Bag { + items: { item: number; quantity: number }[]; + balls: { item: number; quantity: number }[]; + keyItems: number[]; +} +export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] }); +export const copyBag = (bag: Bag): Bag => ({ + items: bag.items.map((slot) => ({ ...slot })), + balls: bag.balls.map((slot) => ({ ...slot })), + keyItems: [...bag.keyItems], +}); + +const MAX_ITEMS = 20, + MAX_BALLS = 12, + MAX_KEY_ITEMS = 25, + MAX_ITEM_STACK = 99; + +/** PutItemInPocket: room exists if matching stacks can absorb the quantity + * or a new slot is free; stacks then fill to 99 before a new one starts. */ +function putInPocket( + pocket: { item: number; quantity: number }[], + capacity: number, + item: number, + quantity: number, +): boolean { + let room = 0; + const fits = pocket.some((slot) => { + if (slot.item !== item) return false; + room += MAX_ITEM_STACK - slot.quantity; + return quantity <= room; + }); + if (!fits && pocket.length >= capacity) return false; + let remaining = quantity; + for (const slot of pocket) { + if (slot.item !== item) continue; + const total = slot.quantity + remaining; + if (total <= MAX_ITEM_STACK) { + slot.quantity = total; + return true; + } + slot.quantity = MAX_ITEM_STACK; + remaining = total - MAX_ITEM_STACK; + } + pocket.push({ item, quantity: remaining }); + return true; +} + +/** ReceiveItem into the pocket that ItemAttributes names. */ +export function receiveItem( + data: PokemonData, + bag: Bag, + item: number, + quantity: number, +): boolean { + if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_ITEM_STACK) + throw new Error("Invalid item quantity."); + const pocket: Pocket = data.itemPocket(item); + if (pocket === "item") + return putInPocket(bag.items, MAX_ITEMS, item, quantity); + if (pocket === "ball") + return putInPocket(bag.balls, MAX_BALLS, item, quantity); + if (pocket === "key") { + // ReceiveKeyItem appends one entry regardless of the quantity. + if (bag.keyItems.length >= MAX_KEY_ITEMS) return false; + bag.keyItems.push(item); + return true; + } + throw new Error("TM and HM items are not ported yet."); +} + +/** CheckItem across the pockets. */ +export function hasItem(bag: Bag, item: number): boolean { + return ( + bag.items.some((slot) => slot.item === item) || + bag.balls.some((slot) => slot.item === item) || + bag.keyItems.includes(item) + ); +} + +export function validBag(data: PokemonData, value: unknown): value is Bag { + const bag = value as Bag; + const slots = (list: unknown, capacity: number, pocket: Pocket) => + Array.isArray(list) && + list.length <= capacity && + list.every( + (slot) => + slot && + Number.isInteger(slot.item) && + Number.isInteger(slot.quantity) && + slot.quantity >= 1 && + slot.quantity <= MAX_ITEM_STACK && + itemIn(data, slot.item, pocket), + ); + return ( + Boolean(bag) && + typeof bag === "object" && + slots(bag.items, MAX_ITEMS, "item") && + slots(bag.balls, MAX_BALLS, "ball") && + Array.isArray(bag.keyItems) && + bag.keyItems.length <= MAX_KEY_ITEMS && + bag.keyItems.every((item) => itemIn(data, item, "key")) + ); +} +function itemIn(data: PokemonData, item: unknown, pocket: Pocket) { + try { + return Number.isInteger(item) && data.itemPocket(Number(item)) === pocket; + } catch { + return false; + } +} + +/** TossItem as `takeitem` uses it: only the first matching stack is used, + * and taking more than it holds fails. */ +export function tossItem(bag: Bag, item: number, quantity: number): boolean { + const key = bag.keyItems.indexOf(item); + if (key >= 0) { + bag.keyItems.splice(key, 1); + return true; + } + for (const pocket of [bag.items, bag.balls]) { + const index = pocket.findIndex((slot) => slot.item === item); + if (index < 0) continue; + const slot = pocket[index]; + if (quantity > slot.quantity) return false; + slot.quantity -= quantity; + if (!slot.quantity) pocket.splice(index, 1); + return true; + } + return false; +} diff --git a/src/game/party/pokemon.ts b/src/game/party/pokemon.ts new file mode 100644 index 0000000..7f7f62b --- /dev/null +++ b/src/game/party/pokemon.ts @@ -0,0 +1,190 @@ +import type { PokemonData } from "../rom/pokemon"; + +/** A party Pokémon with the fields of the original party_struct. */ +export interface PartyMon { + species: number; + item: number; + /** Move IDs; 0 marks an empty slot. */ + moves: [number, number, number, number]; + otId: number; + ot: string; + nickname: string; + exp: number; + /** HP, Attack, Defense, Speed, Special stat experience. */ + statExp: [number, number, number, number, number]; + /** Attack, Defense, Speed, Special DVs as four nibbles. */ + dvs: number; + pp: [number, number, number, number]; + happiness: number; + pokerus: number; + /** Time of day and level, then gender and landmark (SetCaughtData). */ + caughtData: number; + level: number; + status: number; + hp: number; + /** Max HP, Attack, Defense, Speed, Sp. Atk, Sp. Def. */ + stats: [number, number, number, number, number, number]; +} + +export const PARTY_LENGTH = 6; +/** Characters a nickname can hold: the naming screen's letters and + * symbols, plus the digits and apostrophes of default species names. */ +export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u; +/** BASE_HAPPINESS */ +const BASE_HAPPINESS = 70; +const MAX_STAT_VALUE = 999; + +/** GetSquareRoot: the first of 1..255 whose square is not below `value`. */ +function squareRoot(value: number) { + for (let root = 1; root < 255; root++) if (root * root >= value) return root; + return 255; +} + +/** DVs per stat; the HP DV is built from the low bits of the others. */ +export function statDv(dvs: number, stat: number): number { + const attack = (dvs >> 12) & 15, + defense = (dvs >> 8) & 15, + speed = (dvs >> 4) & 15, + special = dvs & 15; + if (stat === 0) + return ( + ((attack & 1) << 3) | + ((defense & 1) << 2) | + ((speed & 1) << 1) | + (special & 1) + ); + return [attack, defense, speed, special, special][stat - 1]; +} + +/** CalcMonStatC for stat 0-5 (HP, Atk, Def, Spd, SpAtk, SpDef). */ +export function calcStat( + base: number, + dvs: number, + stat: number, + level: number, + statExp: readonly number[] | undefined, +): number { + // Special attack and defense share the Special stat experience. + const experience = statExp ? statExp[Math.min(stat, 4)] : 0; + const bonus = statExp ? Math.floor(squareRoot(experience) / 4) : 0; + const value = Math.floor( + (((base + statDv(dvs, stat)) * 2 + bonus) * level) / 100, + ); + return Math.min(MAX_STAT_VALUE, value + (stat === 0 ? level + 10 : 5)); +} + +export function calcStats( + data: PokemonData, + species: number, + dvs: number, + level: number, + statExp?: readonly number[], +): PartyMon["stats"] { + const base = data.baseData(species).stats; + return base.map((value, stat) => + calcStat(value, dvs, stat, level, statExp), + ) as PartyMon["stats"]; +} + +/** FillMoves for a new Pokémon: learn every move up to its level, skipping + * repeats and shifting out the oldest move when all slots are full. */ +export function initialMoves( + data: PokemonData, + species: number, + level: number, +): PartyMon["moves"] { + const moves: number[] = []; + for (const learned of data.learnset(species)) { + if (learned.level > level) break; + if (moves.includes(learned.move)) continue; + if (moves.length === 4) moves.shift(); + moves.push(learned.move); + } + while (moves.length < 4) moves.push(0); + return moves as PartyMon["moves"]; +} + +/** GeneratePartyMonStats for a Pokémon given by a script (not a wild or + * trainer battle): random DVs, level-up moves, full PP and HP. */ +export function generatePartyMon( + data: PokemonData, + options: { + species: number; + level: number; + item: number; + otId: number; + ot: string; + dvs: number; + caughtData: number; + }, +): PartyMon { + const { species, level } = options; + if (!Number.isInteger(level) || level < 1 || level > 100) + throw new Error("Invalid level."); + const moves = initialMoves(data, species, level); + const stats = calcStats(data, species, options.dvs, level); + return { + species, + item: options.item, + moves, + otId: options.otId, + ot: options.ot, + nickname: data.speciesName(species), + exp: data.expAtLevel(data.baseData(species).growthRate, level), + statExp: [0, 0, 0, 0, 0], + dvs: options.dvs, + pp: moves.map((move) => (move ? data.move(move).pp : 0)) as PartyMon["pp"], + happiness: BASE_HAPPINESS, + pokerus: 0, + caughtData: options.caughtData, + level, + status: 0, + hp: stats[0], + stats, + }; +} + +/** Validate a stored Pokémon against the ROM's rules before restoring it. */ +export function validPartyMon( + data: PokemonData, + value: unknown, +): value is PartyMon { + const mon = value as PartyMon; + const integer = (value: unknown, min: number, max: number): value is number => + Number.isInteger(value) && Number(value) >= min && Number(value) <= max; + const tuple = (value: unknown, length: number, max: number) => + Array.isArray(value) && + value.length === length && + value.every((item) => integer(item, 0, max)); + try { + if ( + !mon || + typeof mon !== "object" || + !integer(mon.species, 1, 251) || + !integer(mon.item, 0, 255) || + !tuple(mon.moves, 4, 251) || + !integer(mon.otId, 0, 0xffff) || + typeof mon.ot !== "string" || + !/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) || + typeof mon.nickname !== "string" || + !NICKNAME_PATTERN.test(mon.nickname) || + !integer(mon.exp, 0, 0xffffff) || + !tuple(mon.statExp, 5, 0xffff) || + !integer(mon.dvs, 0, 0xffff) || + !tuple(mon.pp, 4, 255) || + !integer(mon.happiness, 0, 255) || + !integer(mon.pokerus, 0, 255) || + !integer(mon.caughtData, 0, 0xffff) || + !integer(mon.level, 1, 100) || + !integer(mon.status, 0, 255) || + !tuple(mon.stats, 6, MAX_STAT_VALUE) || + !integer(mon.hp, 0, mon.stats[0]) + ) + return false; + // Stored stats must be the ones the game would calculate. + const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp); + return stats.every((stat, index) => stat === mon.stats[index]); + } catch { + return false; + } +} diff --git a/src/game/rendering/minimap.ts b/src/game/rendering/minimap.ts new file mode 100644 index 0000000..6160917 --- /dev/null +++ b/src/game/rendering/minimap.ts @@ -0,0 +1,108 @@ +import type { CrystalAssets } from "../rom/assets"; +import type { GameWorld } from "../world/world"; +import { drawTile } from "./pixels"; + +/** A local-area atlas decoded in memory from the same tiles as the world. */ +export class LocalMapView { + private terrain = document.createElement("canvas"); + private assets?: CrystalAssets; + private mapKey = ""; + + constructor( + private mini: HTMLCanvasElement, + private full: HTMLCanvasElement, + ) {} + + update(world: GameWorld) { + const map = world.map; + const key = `${map.id}:${world.timeOfDay}`; + if (this.assets !== world.assets || this.mapKey !== key) { + this.buildTerrain(world); + this.assets = world.assets; + this.mapKey = key; + } + this.draw(this.mini, world, 160, 128); + this.draw( + this.full, + world, + this.terrain.width + 16, + this.terrain.height + 16, + ); + } + + private buildTerrain(world: GameWorld) { + const { map, assets } = world; + // The supported maps are small; reject unbounded buffers if profiles grow. + const width = map.width * 16; + const height = map.height * 16; + if (width < 1 || height < 1 || width > 2048 || height > 2048) + throw new Error("This area is too large for the local map."); + this.terrain.width = width; + this.terrain.height = height; + const context = this.terrain.getContext("2d", { alpha: false }); + if (!context) throw new Error("Map rendering is unavailable."); + const image = context.createImageData(width, height); + const tileset = assets.tileset(map); + const palettes = assets.palettes(map, world.timeOfDay); + for (let y = 0; y < height / 8; y++) { + for (let x = 0; x < width / 8; x++) { + const block = assets.block(map, Math.floor(x / 2), Math.floor(y / 2)); + const encoded = tileset.metatiles[block * 16 + (y % 4) * 4 + (x % 4)]; + const packed = tileset.paletteMap[encoded >> 1]; + const attribute = encoded & 1 ? packed >> 4 : packed & 15; + drawTile( + image.data, + width, + height, + tileset.graphics, + (encoded & 0x7f) + (attribute & 8 ? 0x60 : 0), + x * 8, + y * 8, + palettes[attribute & 7], + ); + } + } + context.putImageData(image, 0, 0); + } + + private draw( + canvas: HTMLCanvasElement, + world: GameWorld, + width: number, + height: number, + ) { + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Map rendering is unavailable."); + context.imageSmoothingEnabled = false; + context.fillStyle = "#f8f4df"; + context.fillRect(0, 0, width, height); + const scale = Math.min( + (width - 16) / this.terrain.width, + (height - 16) / this.terrain.height, + ); + const mapWidth = this.terrain.width * scale; + const mapHeight = this.terrain.height * scale; + const left = (width - mapWidth) / 2; + const top = (height - mapHeight) / 2; + context.drawImage(this.terrain, left, top, mapWidth, mapHeight); + const x = Math.round(left + (world.x * 16 + 8) * scale); + const y = Math.round(top + (world.y * 16 + 8) * scale); + // An outlined marker remains readable over both bright and dark terrain. + context.fillStyle = "#fffdf4"; + context.fillRect(x - 4, y - 4, 9, 9); + context.fillStyle = "#24283d"; + context.fillRect(x - 3, y - 3, 7, 7); + context.fillStyle = "#d33b49"; + context.fillRect(x - 2, y - 2, 5, 5); + canvas.dataset.map = world.mapId; + canvas.dataset.x = String(world.x); + canvas.dataset.y = String(world.y); + canvas.setAttribute("role", "img"); + canvas.setAttribute( + "aria-label", + `${world.map.name}. You are at ${world.x}, ${world.y}, shown in red.`, + ); + } +} diff --git a/src/game/rendering/renderer.ts b/src/game/rendering/renderer.ts index 6ef787d..6fe256b 100644 --- a/src/game/rendering/renderer.ts +++ b/src/game/rendering/renderer.ts @@ -2,16 +2,26 @@ import { CrystalAssets } from "../rom/assets"; import { GameWorld } from "../world/world"; import type { Direction } from "../world/types"; import { drawTile } from "./pixels"; +import { + BASE_VIEWPORT, + renderViewportForBox, + type RenderViewport, +} from "./viewport"; export class WorldRenderer { - readonly width = 160; - readonly height = 144; + width: number = BASE_VIEWPORT.width; + height: number = BASE_VIEWPORT.height; private context: CanvasRenderingContext2D; private image: ImageData; + private canvas: HTMLCanvasElement; constructor( private assets: CrystalAssets, canvas: HTMLCanvasElement, ) { + this.canvas = canvas; + const viewport = this.viewportFromCanvas(); + this.width = viewport.width; + this.height = viewport.height; canvas.width = this.width; canvas.height = this.height; const context = canvas.getContext("2d", { alpha: false }); @@ -19,19 +29,48 @@ export class WorldRenderer { this.context = context; this.image = context.createImageData(this.width, this.height); } - render( - world: GameWorld, - playerX = world.x * 16, - playerY = world.y * 16, - frame = 0, - ) { + resize(cssWidth: number, cssHeight: number) { + // ResizeObserver reports zero-sized boxes while an ancestor is hidden. + // Retaining the last usable viewport avoids destroying the current frame. + if ( + !Number.isFinite(cssWidth) || + !Number.isFinite(cssHeight) || + cssWidth <= 0 || + cssHeight <= 0 + ) + return false; + const viewport = renderViewportForBox(cssWidth, cssHeight); + if (viewport.width === this.width && viewport.height === this.height) + return false; + this.width = viewport.width; + this.height = viewport.height; + this.canvas.width = this.width; + this.canvas.height = this.height; + this.image = this.context.createImageData(this.width, this.height); + return true; + } + private viewportFromCanvas(): RenderViewport { + try { + const box = this.canvas.getBoundingClientRect(); + return renderViewportForBox(box.width, box.height); + } catch { + return { ...BASE_VIEWPORT, pixelScale: 3 }; + } + } + render(world: GameWorld) { const map = world.map, tileset = this.assets.tileset(map), palettes = this.assets.palettes(map, world.timeOfDay); - // Keep the camera player-centered as the original engine does. Out-of-map - // pixels use the map's own border block, including small room padding. - const cameraX = Math.round(playerX - 72), - cameraY = Math.round(playerY - 64); + const poses = world.poses(); + const player = poses.find((pose) => pose.id === 0) ?? { + x: world.x * 16, + y: world.y * 16, + }; + // Keep the camera player-centered as the original engine does. Beyond the + // edges, connected maps are drawn like the original connection strips; + // elsewhere the map's own border block fills the view. + const cameraX = Math.round(player.x - (this.width / 2 - 8)), + cameraY = Math.round(player.y - (this.height / 2 - 8)); this.image.data.fill(255); for ( let tileY = Math.floor(cameraY / 8); @@ -43,7 +82,7 @@ export class WorldRenderer { tileX < Math.ceil((cameraX + this.width) / 8); tileX++ ) { - const block = this.assets.block( + const block = this.assets.visibleBlock( map, Math.floor(tileX / 2), Math.floor(tileY / 2), @@ -66,35 +105,56 @@ export class WorldRenderer { ); } } - const actors = world - .objects() - .map((object) => ({ - x: object.x * 16, - y: object.y * 16, - sprite: object.sprite, - direction: world.objectDirection(object), - palette: object.palette ? object.palette - 1 : undefined, - frame: 0, - })); - actors.push({ - x: playerX, - y: playerY, - sprite: world.avatar === "kris" ? 0x60 : 1, - direction: world.direction, - palette: undefined, - frame, - }); - actors.sort((a, b) => a.y - b.y); - for (const actor of actors) + const time = world.timeOfDay; + // Temporary effect objects use PAL_OW_EMOTE. + const effectPalette = this.assets.objectPalette(5, time); + for (const pose of poses) { + if (pose.shadow === undefined) continue; + const shadow = this.assets.emote(8); + for (const [x, flipX] of [ + [0, false], + [8, true], + ] as const) + drawTile( + this.image.data, + this.width, + this.height, + shadow, + 0, + pose.x - cameraX + x, + pose.y - cameraY - 4 + pose.shadow, + effectPalette, + { transparent: true, flipX }, + ); + } + for (const pose of [...poses].sort((a, b) => a.y - b.y)) this.actor( - actor.sprite, - Math.round(actor.x - cameraX), - Math.round(actor.y - cameraY - 4), - actor.direction, - actor.frame, - world.timeOfDay, - actor.palette, + pose.sprite, + pose.x - cameraX, + pose.y - cameraY - 4 + (pose.lift ?? 0), + pose.direction, + pose.frame, + time, + pose.palette, ); + const emote = + world.emote && poses.find((pose) => pose.id === world.emote!.object); + if (emote) { + // FacingEmote: a 2x2 bubble 16 pixels above the object. + const graphics = this.assets.emote(world.emote!.emote); + for (const piece of this.assets.facingTiles(0x14)) + drawTile( + this.image.data, + this.width, + this.height, + graphics, + piece.tile - 0xf8, + emote.x - cameraX + piece.x, + emote.y - cameraY - 4 - 16 + piece.y, + effectPalette, + { transparent: true }, + ); + } this.context.putImageData(this.image, 0, 0); } private actor( diff --git a/src/game/rendering/viewport.ts b/src/game/rendering/viewport.ts new file mode 100644 index 0000000..68e353a --- /dev/null +++ b/src/game/rendering/viewport.ts @@ -0,0 +1,36 @@ +export const BASE_VIEWPORT = { width: 160, height: 144 } as const; +export const MAX_VIEWPORT = { width: 480, height: 432 } as const; + +export type RenderViewport = { + width: number; + height: number; + pixelScale: number; +}; + +const finitePositive = (value: number) => Number.isFinite(value) && value > 0; + +/** + * Selects a bounded low-resolution drawing buffer for a measured CSS box. + * The same integer divisor is used on both axes so world pixels retain their + * proportions. Detached and test canvases keep the original Game Boy view. + */ +export function renderViewportForBox( + cssWidth: number, + cssHeight: number, +): RenderViewport { + if (!finitePositive(cssWidth) || !finitePositive(cssHeight)) { + return { ...BASE_VIEWPORT, pixelScale: 3 }; + } + + const nominalScale = cssWidth < 600 ? 2 : 3; + const pixelScale = Math.max( + nominalScale, + Math.ceil(cssWidth / MAX_VIEWPORT.width), + Math.ceil(cssHeight / MAX_VIEWPORT.height), + ); + return { + width: Math.max(1, Math.round(cssWidth / pixelScale)), + height: Math.max(1, Math.round(cssHeight / pixelScale)), + pixelScale, + }; +} diff --git a/src/game/rom/assets.ts b/src/game/rom/assets.ts index bb3ea58..b9a6b2b 100644 --- a/src/game/rom/assets.ts +++ b/src/game/rom/assets.ts @@ -1,6 +1,13 @@ import { RomReader } from "./reader"; import { decompress } from "./lz"; -import { NATIVE_MAPS, SYMBOLS } from "./profile"; +import { + LAST_MAP_GROUP_SIZE, + MAP_GROUP_COUNT, + NATIVE_MAPS, + SYMBOLS, +} from "./profile"; +import { defaultTextContext, readString } from "../scripts/text"; +import { PokemonData } from "./pokemon"; import type { Direction, GameMap, @@ -14,23 +21,83 @@ import type { * decoded from the supplied ROM, on demand, and cached only in memory. */ export class CrystalAssets { readonly reader: RomReader; + readonly pokemon: PokemonData; private maps = new Map(); private tilesets = new Map(); private sprites = new Map(); + private sceneOwners?: Map; constructor(bytes: Uint8Array) { this.reader = new RomReader(bytes); + this.pokemon = new PokemonData(this.reader); + } + + /** Areas whose rules and scripts are ported. Other maps can be decoded, + * for example to draw a connected border, but cannot be entered. */ + isPorted(id: string): boolean { + return Object.hasOwn(NATIVE_MAPS, id); + } + + /** Header offset for a map ID, bounded by the ROM's own group table. */ + private header(id: string): number { + const match = /^(\d{1,2}):(\d{1,3})$/.exec(id); + const group = Number(match?.[1]), + number = Number(match?.[2]); + if (!match || group < 1 || group > MAP_GROUP_COUNT || number < 1) + throw new Error("Unknown map."); + const r = this.reader, + bank = r.bank(SYMBOLS.mapGroups); + const first = r.near(SYMBOLS.mapGroups + (group - 1) * 2, bank); + const count = + group === MAP_GROUP_COUNT + ? LAST_MAP_GROUP_SIZE + : (r.near(SYMBOLS.mapGroups + group * 2, bank) - first) / 9; + if (number > count) throw new Error("Unknown map."); + return first + (number - 1) * 9; + } + + /** The location name the game shows for a landmark, from the ROM. */ + landmark(index: number): string { + const r = this.reader, + entry = SYMBOLS.landmarks + index * 4; + const name = readString( + r, + r.near(entry + 2, r.bank(SYMBOLS.landmarks)), + defaultTextContext(""), + ); + return name + .toLowerCase() + .replace( + /(^|[\s.-])(\p{L})/gu, + (_, gap, letter) => gap + letter.toUpperCase(), + ); + } + + /** The map that owns a map's scene variable (data/maps/scenes.asm). */ + sceneOwner(id: string): string | undefined { + if (!this.sceneOwners) { + const owners = new Map(), + byAddress = new Map(); + const r = this.reader; + for (let p = SYMBOLS.mapScenes, i = 0; i < 256; i++, p += 4) { + const group = r.byte(p); + if (group === 0xff) break; + const map = `${group}:${r.byte(p + 1)}`, + address = r.word(p + 2); + const owner = byAddress.get(address) ?? map; + byAddress.set(address, owner); + owners.set(map, owner); + } + this.sceneOwners = owners; + } + return this.sceneOwners.get(id); } map(id: string): GameMap { const cached = this.maps.get(id); if (cached) return cached; - if (!Object.hasOwn(NATIVE_MAPS, id)) - throw new Error("This area has not been ported yet."); + const header = this.header(id); const [group, number] = id.split(":").map(Number); const r = this.reader; - const header = - r.near(SYMBOLS.mapGroups + (group - 1) * 2, r.bank(SYMBOLS.mapGroups)) + - (number - 1) * 9; const attributes = r.near(header + 3, r.byte(header)); const blockWidth = r.byte(attributes + 2), blockHeight = r.byte(attributes + 1); @@ -38,11 +105,14 @@ export class CrystalAssets { throw new Error("Invalid map dimensions."); const scriptBank = r.byte(attributes + 6); let p = r.near(attributes + 9, scriptBank) + 2; + const location = this.landmark(r.byte(header + 5)); const map: GameMap = { id, group, number, - name: NATIVE_MAPS[id], + name: NATIVE_MAPS[id] ?? location, + location, + landmark: r.byte(header + 5), width: blockWidth * 2, height: blockHeight * 2, border: r.byte(attributes), @@ -165,6 +235,54 @@ export class CrystalAssets { if (x < 0 || y < 0 || x >= map.width || y >= map.height) return map.border; return map.blocks[Math.floor(y / 2) * (map.width / 2) + Math.floor(x / 2)]; } + /** Where a step coordinate outside `map` lands in a connected map. */ + connected( + map: GameMap, + x: number, + y: number, + ): { map: GameMap; x: number; y: number } | undefined { + const direction = + y < 0 + ? "up" + : y >= map.height + ? "down" + : x < 0 + ? "left" + : x >= map.width + ? "right" + : undefined; + // Corners are outside every connection strip and show border blocks. + if (!direction || ((x < 0 || x >= map.width) && (y < 0 || y >= map.height))) + return undefined; + const connection = map.connections.find((c) => c.direction === direction); + if (!connection) return undefined; + const target = this.map(connection.targetMap); + // Offsets are the coordinates EnterMapConnection assigns on crossing. + const position = + direction === "up" + ? { x: x + connection.x, y: y + 1 + connection.y } + : direction === "down" + ? { x: x + connection.x, y: y - map.height + connection.y } + : direction === "left" + ? { x: x + 1 + connection.x, y: y + connection.y } + : { x: x - map.width + connection.x, y: y + connection.y }; + if ( + position.x < 0 || + position.y < 0 || + position.x >= target.width || + position.y >= target.height + ) + return undefined; + return { map: target, ...position }; + } + /** A visible block, drawing connected maps beyond the edges as the + * original connection strips do, with the current map's tileset. */ + visibleBlock(map: GameMap, x: number, y: number): number { + if (x >= 0 && y >= 0 && x < map.width && y < map.height) + return this.block(map, x, y); + const target = this.connected(map, x, y); + return target ? this.block(target.map, target.x, target.y) : map.border; + } collision(map: GameMap, x: number, y: number): number { if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7; const block = this.block(map, x, y); @@ -210,6 +328,48 @@ export class CrystalAssets { objectPalette(index: number, time: number): Palette { return this.palette(SYMBOLS.objectPalettes + time * 64 + (index & 7) * 8); } + /** Collisions that allow wild encounters (CheckGrassCollision). */ + grassCollisions(): number[] { + const r = this.reader, + values: number[] = []; + for (let p = SYMBOLS.grassCollisions; values.length < 32; p++) { + const value = r.byte(p); + if (value === 0xff) return values; + values.push(value); + } + throw new Error("Grass collision list is unterminated."); + } + /** JohtoGrassWildMons for a map: encounter rates and seven slots per + * time of day (data/wild/johto_grass.asm). */ + grassWildMons( + id: string, + ): { rates: number[]; slots: { level: number; species: number }[][] } | undefined { + const r = this.reader; + for (let p = SYMBOLS.johtoGrassWildMons, i = 0; i < 256; i++, p += 47) { + const group = r.byte(p); + if (group === 0xff) return undefined; + if (`${group}:${r.byte(p + 1)}` !== id) continue; + const rates = [r.byte(p + 2), r.byte(p + 3), r.byte(p + 4)]; + const slots = [0, 1, 2].map((time) => + Array.from({ length: 7 }, (_, slot) => { + const entry = p + 5 + time * 14 + slot * 2; + return { level: r.byte(entry), species: r.byte(entry + 1) }; + }), + ); + return { rates, slots }; + } + throw new Error("Wild encounter data is unterminated."); + } + /** Emote bubble graphics (data/sprites/emotes.asm). */ + emote(index: number): Uint8Array { + const r = this.reader, + entry = SYMBOLS.emotes + index * 6; + if (index < 0 || index > 11) throw new Error("Unknown emote."); + return r.slice( + r.address(r.byte(entry + 3), r.word(entry)), + r.byte(entry + 2), + ); + } sprite(id: number): Sprite | undefined { // Variable decorations and Pokémon icons need separate decoders. if (id < 1 || id >= 0xa0) return undefined; @@ -228,8 +388,12 @@ export class CrystalAssets { return sprite; } facing(direction: Direction, frame: number): FacingTile[] { - const facing = - ["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4); + return this.facingTiles( + ["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4), + ); + } + /** FACING_* layouts; 0x14 is the emote bubble. */ + facingTiles(facing: number): FacingTile[] { const r = this.reader, pointer = r.near(SYMBOLS.facings + facing * 2, r.bank(SYMBOLS.facings)); return Array.from({ length: r.byte(pointer) }, (_, i) => { diff --git a/src/game/rom/pokemon.ts b/src/game/rom/pokemon.ts new file mode 100644 index 0000000..55401c2 --- /dev/null +++ b/src/game/rom/pokemon.ts @@ -0,0 +1,253 @@ +import { RomReader } from "./reader"; +import { decompress } from "./lz"; +import { NUM_ITEMS, NUM_POKEMON, SYMBOLS } from "./profile"; +import { defaultTextContext, readString } from "../scripts/text"; +import type { Palette } from "../world/types"; + +/** data/pokemon/base_stats: one 32-byte record per species. */ +export interface BaseData { + species: number; + /** HP, Attack, Defense, Speed, Sp. Atk, Sp. Def. */ + stats: readonly [number, number, number, number, number, number]; + types: readonly [number, number]; + catchRate: number; + baseExp: number; + items: readonly [number, number]; + genderRatio: number; + eggCycles: number; + /** Front picture width and height in tiles (5, 6 or 7). */ + picSize: number; + growthRate: number; + eggGroups: number; +} +export interface MoveData { + id: number; + effect: number; + power: number; + type: number; + accuracy: number; + pp: number; + chance: number; +} +/** Item types, which select a bag pocket (constants/item_data_constants.asm). */ +export type Pocket = "item" | "key" | "ball" | "tm"; +const POCKETS: readonly Pocket[] = ["item", "key", "ball", "tm"]; + +/** Decoders for species, moves and items. All data is read from the + * user's ROM on demand and cached in memory only. */ +export class PokemonData { + private base = new Map(); + private names = new Map(); + constructor(readonly reader: RomReader) {} + + private checkSpecies(species: number) { + if (!Number.isInteger(species) || species < 1 || species > NUM_POKEMON) + throw new Error("Unknown Pokémon species."); + } + baseData(species: number): BaseData { + this.checkSpecies(species); + const cached = this.base.get(species); + if (cached) return cached; + const r = this.reader, + p = SYMBOLS.baseData + (species - 1) * 32; + const data: BaseData = { + species: r.byte(p), + stats: [1, 2, 3, 4, 5, 6].map((i) => + r.byte(p + i), + ) as unknown as BaseData["stats"], + types: [r.byte(p + 7), r.byte(p + 8)], + catchRate: r.byte(p + 9), + baseExp: r.byte(p + 10), + items: [r.byte(p + 11), r.byte(p + 12)], + genderRatio: r.byte(p + 13), + eggCycles: r.byte(p + 15), + picSize: r.byte(p + 17) & 15, + growthRate: r.byte(p + 22), + eggGroups: r.byte(p + 23), + }; + this.base.set(species, data); + return data; + } + + /** Fixed-length names are padded with "@"; a full-length name has none. */ + speciesName(species: number): string { + this.checkSpecies(species); + return this.cachedName(`mon:${species}`, () => { + const r = this.reader, + p = SYMBOLS.pokemonNames + (species - 1) * 10; + let bytes = 0; + while (bytes < 10 && r.byte(p + bytes) !== 0x50) bytes++; + const copy = new Uint8Array(bytes + 1); + copy.set(r.slice(p, bytes)); + copy[bytes] = 0x50; + return readString(new RomReader(copy), 0, defaultTextContext("")); + }); + } + /** Variable-length name lists are read like GetName: skip n - 1 names. */ + private listName(table: number, index: number): string { + const r = this.reader; + let p = table; + for (let skipped = 1; skipped < index; skipped++) { + let length = 0; + while (r.byte(p++) !== 0x50) + if (++length > 32) throw new Error("Invalid name list."); + } + return readString(r, p, defaultTextContext("")); + } + moveName(move: number): string { + if (!Number.isInteger(move) || move < 1 || move > 251) + throw new Error("Unknown move."); + return this.cachedName(`move:${move}`, () => + this.listName(SYMBOLS.moveNames, move), + ); + } + itemName(item: number): string { + this.checkItem(item); + return this.cachedName(`item:${item}`, () => + this.listName(SYMBOLS.itemNames, item), + ); + } + private cachedName(key: string, read: () => string) { + let name = this.names.get(key); + if (name === undefined) { + name = read(); + this.names.set(key, name); + } + return name; + } + private checkItem(item: number) { + if (!Number.isInteger(item) || item < 1 || item > NUM_ITEMS) + throw new Error("Unknown item."); + } + /** ItemAttributes: price, held effect, parameter, property, pocket, menus. */ + itemPocket(item: number): Pocket { + this.checkItem(item); + const pocket = + POCKETS[ + this.reader.byte(SYMBOLS.itemAttributes + (item - 1) * 7 + 5) - 1 + ]; + if (!pocket) throw new Error("Item has no pocket."); + return pocket; + } + pocketName(pocket: Pocket): string { + const r = this.reader, + bank = r.bank(SYMBOLS.itemPocketNames); + return readString( + r, + r.near(SYMBOLS.itemPocketNames + POCKETS.indexOf(pocket) * 2, bank), + defaultTextContext(""), + ); + } + + move(move: number): MoveData { + if (!Number.isInteger(move) || move < 1 || move > 251) + throw new Error("Unknown move."); + const r = this.reader, + p = SYMBOLS.moves + (move - 1) * 7; + return { + id: r.byte(p), + effect: r.byte(p + 1), + power: r.byte(p + 2), + type: r.byte(p + 3), + accuracy: r.byte(p + 4), + pp: r.byte(p + 5), + chance: r.byte(p + 6), + }; + } + + /** Level-up moves (data/pokemon/evos_attacks.asm). Like FillMoves, the + * evolution list is skipped up to its terminating zero byte. */ + learnset(species: number): { level: number; move: number }[] { + this.checkSpecies(species); + const r = this.reader; + let p = r.near( + SYMBOLS.evosAttacks + (species - 1) * 2, + r.bank(SYMBOLS.evosAttacks), + ); + for (let guard = 0; r.byte(p++) !== 0; guard++) + if (guard > 64) throw new Error("Invalid evolution data."); + const moves: { level: number; move: number }[] = []; + for (let level = r.byte(p); level !== 0; level = r.byte(p)) { + moves.push({ level, move: r.byte(p + 1) }); + p += 2; + if (moves.length > 64) throw new Error("Invalid learnset."); + } + return moves; + } + + /** CalcExpAtLevel: (a/b)n^3 + c n^2 + d n - e in 24-bit arithmetic, + * including the original underflow for Medium Slow at level 1. */ + expAtLevel(growthRate: number, level: number): number { + if (!Number.isInteger(growthRate) || growthRate < 0 || growthRate > 5) + throw new Error("Unknown growth rate."); + const r = this.reader, + p = SYMBOLS.growthRates + growthRate * 4; + const ab = r.byte(p), + c = r.byte(p + 1), + d = r.byte(p + 2), + e = r.byte(p + 3); + const cubic = Math.floor(((ab >> 4) * level ** 3) / (ab & 15)) & 0xffffff; + const square = ((c & 0x7f) * level ** 2) & 0xffffff; + const linear = (d * level - e) & 0xffffff; + const quadratic = c & 0x80 ? linear - square : linear + square; + return ((quadratic & 0xffffff) + cubic) & 0xffffff; + } + + /** The decompressed front picture: base frame, then animation tiles. */ + frontPicData(species: number): Uint8Array { + this.checkSpecies(species); + const r = this.reader, + entry = SYMBOLS.pokemonPics + (species - 1) * 6; + // FixPicBank: stored banks index a table whose first entry is "Pics 1". + const pics1 = r.byte(SYMBOLS.picsBanks); + const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36)); + return decompress(r, r.address(bank, r.word(entry + 1)), 0x2000); + } + /** Front picture tiles, padded into the 7x7 box as PadFrontpic does: + * column-major, bottom-aligned, with a blank left column for 5 and 6. */ + frontPic(species: number): Uint8Array { + const size = this.baseData(species).picSize; + if (size < 5 || size > 7) throw new Error("Invalid picture size."); + const tiles = this.frontPicData(species); + if (tiles.length < size * size * 16) + throw new Error("Picture data is truncated."); + const padded = new Uint8Array(49 * 16); + const left = size === 7 ? 0 : 1; + for (let column = 0; column < size; column++) + padded.set( + tiles.slice(column * size * 16, (column + 1) * size * 16), + ((column + left) * 7 + (7 - size)) * 16, + ); + return padded; + } + /** The back picture: 6x6 tiles, column by column (GetMonBackpic). */ + backPic(species: number): Uint8Array { + this.checkSpecies(species); + const r = this.reader, + entry = SYMBOLS.pokemonPics + (species - 1) * 6 + 3; + const pics1 = r.byte(SYMBOLS.picsBanks); + const bank = r.byte(SYMBOLS.picsBanks + r.byte(entry) - (pics1 - 0x36)); + const tiles = decompress(r, r.address(bank, r.word(entry + 1)), 0x1000); + if (tiles.length < 36 * 16) throw new Error("Picture data is truncated."); + return tiles.slice(0, 36 * 16); + } + /** Shininess from DVs (CheckShininess): Defense, Speed and Special 10, + * and an Attack DV with bit 1 set. */ + static shiny(dvs: number): boolean { + const attack = (dvs >> 12) & 15; + return (dvs & 0x0fff) === 0x0aaa && Boolean(attack & 2); + } + /** Normal colors from PokemonPalettes, between white and black. */ + palette(species: number, shiny = false): Palette { + this.checkSpecies(species); + const r = this.reader, + p = SYMBOLS.pokemonPalettes + species * 8 + (shiny ? 4 : 0); + const color = (value: number) => + [ + Math.round(((value & 31) * 255) / 31), + Math.round((((value >> 5) & 31) * 255) / 31), + Math.round((((value >> 10) & 31) * 255) / 31), + ] as const; + return [color(0x7fff), color(r.word(p)), color(r.word(p + 2)), color(0)]; + } +} diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index 97f4119..3b1e460 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -16,9 +16,213 @@ export const SYMBOLS = { sprites: 0x14736, facings: 0x4049, standardScripts: 0xbc000, + mapScenes: 0x4d01e, + specials: 0xc029, + emotes: 0x1444d, + landmarks: 0x1ca8c3, + weekdayNames: 0x909f2, + whatDayText: 0x90a3f, + isItDayText: 0x90a4f, + dstConfirmText: 0x90a83, + timeConfirmText: 0x90ab7, + baseData: 0x51424, + pokemonNames: 0x53384, + pokemonPics: 0x120000, + picsBanks: 0x511d4, + pokemonPalettes: 0xa8ce, + growthRates: 0x50efa, + evosAttacks: 0x425b1, + moves: 0x41afb, + moveNames: 0x1c9f29, + itemNames: 0x1c8000, + itemAttributes: 0x67c1, + itemPocketNames: 0x9701c, + giveItemScript: 0x96f77, + putItemInPocketText: 0x9705b, + pocketIsFullText: 0x97060, + caughtAskNicknameText: 0x4db44, + nicknameStrings: 0x11780, + wildBattleScript: 0x97cf9, + battleWhiteoutScript: 0x124c1, + spawnPoints: 0x152ab, + johtoGrassWildMons: 0x2a5e9, + grassMonProbTable: 0x2a1cb, + grassCollisions: 0x149ea, } as const; -/** The currently ported area. Story callbacks are not executed yet. */ +/** Assembly routines that scripts `callasm` but that do nothing. */ +export const EMPTY_ROUTINES = { + giveItemDummy: 0x96f76, + battleBGMap: 0x1250a, +} as const; + +/** Assembly routines that scripts `callasm`, translated natively. */ +export const NATIVE_ROUTINES = { + halveMoney: 0x12513, + getWhiteoutSpawn: 0x12527, +} as const; + +/** Native handlers are selected by the routine a special index points to, + * so the ROM's own special table remains the source of truth. */ +export const SPECIAL_ROUTINES = { + restartMapMusic: 0x3d47, + setDayOfWeek: 0x90913, + initialSetDst: 0x90a54, + initialClearDst: 0x90a88, + toggleDecorationsVisibility: 0x27043, + toggleMaptileDecorations: 0x26feb, + fadeOutToWhite: 0x8c084, + healParty: 0xc658, + warpToSpawnPoint: 0x97c28, +} as const; + +/** Work RAM addresses that the event and text formats name as operands. */ +export const RAM = { + stringBuffer1: 0xd073, + stringBuffer2: 0xd086, + stringBuffer3: 0xd099, + stringBuffer4: 0xd0ac, + stringBuffer5: 0xd0bf, + playerName: 0xd47d, + momsName: 0xd488, + rivalName: 0xd493, + battleMonNickname: 0xc621, + enemyMonNickname: 0xc616, + curPartyLevel: 0xd143, + monOrItemNameBuffer: 0xd050, +} as const; + +/** Event constants named directly by translated special routines. */ +export const EVENTS = { + playersRoomPoster: 716, + playersHouseConsole: 1857, + playersHouseDoll1: 1858, + playersHouseDoll2: 1859, + playersHouseBigDoll: 1860, +} 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 = { + AccuracyLevelMultipliers: 0x34eb2, + AlreadyAsleepText: 0x811c1, + AlreadyConfusedText: 0x80dcc, + AlreadyParalyzedText: 0x81592, + AlreadyPoisonedText: 0x811fa, + AlwaysFleeMons: 0x3c5b1, + AskForgetMoveText: 0x667f, + AttackMissed2Text: 0x81033, + AttackMissedText: 0x81020, + "BattleCommand_StatDownMessage.BattleStatFellText": 0x36415, + "BattleCommand_StatDownMessage.BattleStatSharplyFellText": 0x36410, + "BattleCommand_StatDownMessage.stat": 0x363fe, + "BattleCommand_StatUpMessage.BattleStatWentUpText": 0x363e4, + "BattleCommand_StatUpMessage.BattleStatWentWayUpText": 0x363df, + "BattleCommand_StatUpMessage.stat": 0x363cd, + BattleMonNicknameText: 0x3f2ef, + BattleText_CantEscape: 0x80ba0, + BattleText_CantEscape2: 0x80b3b, + BattleText_EnemyMonFainted: 0x809a8, + BattleText_GotAwaySafely: 0x80b77, + BattleText_ItemHealedConfusion: 0x80dab, + BattleText_MonFainted: 0x80a75, + BattleText_MonHasNoMovesLeft: 0x80c72, + BattleText_StringBuffer1GrewToLevel: 0x80c9c, + BattleText_TargetRecoveredWithItem: 0x80880, + BattleText_TheMoveIsDisabled: 0x80c5b, + BattleText_TheresNoPPLeftForThisMove: 0x80c39, + BattleText_WildFled: 0x807bd, + BecameConfusedText: 0x80d97, + BoostedExpPointsText: 0x3f12c, + ButItFailedText: 0x814f0, + ConfusedNoMoreText: 0x80d81, + ConsumableEffects: 0x271de, + CriticalHitChances: 0x346ab, + CriticalHitMoves: 0x346a3, + CriticalHitText: 0x81086, + DefrostedOpponentText: 0x8124b, + DidNotLearnMoveText: 0x667a, + DidntAffect1Text: 0x8150c, + DidntAffect2Text: 0x81520, + DisabledMoveText: 0x80f02, + DisabledNoMoreText: 0x80d39, + DoesntAffectText: 0x81071, + EnemyText: 0x1295, + ExpPointsText: 0x3f131, + FastAsleepText: 0x80ce7, + FellAsleepText: 0x811b1, + FlinchedText: 0x80d1a, + FrozenSolidText: 0x80d06, + FullyParalyzedText: 0x8157d, + GoMonText: 0x3f2d1, + HasNoPPLeftText: 0x80fba, + HeldStatusHealingEffects: 0x3de44, + HPIsFullText: 0x81534, + HungOnText: 0x80e99, + HurtByBurnText: 0x807f8, + HurtByPoisonText: 0x807e2, + HurtItselfText: 0x80d5f, + IdentifiedText: 0x816b1, + IsConfusedText: 0x80d4f, + "IsHMMove.HMMoves": 0x34f0, + ItemAttributes: 0x67c1, + ItFailedText: 0x81500, + LearnedMoveText: 0x666b, + MoveAskForgetText: 0x6670, + MoveCantForgetHMText: 0x669a, + MoveEffectPriorities: 0x3c5df, + MoveEffectsPointers: 0x271f4, + MoveGrammar: 0x105e7a, + MoveNameText: 0x105e23, + "MoveNameText.endusedmovetexts": 0x105e39, + MustRechargeText: 0x80d27, + NoPPLeftText: 0x80f99, + NothingHappenedText: 0x814d9, + NotVeryEffectiveText: 0x810c1, + OftenFleeMons: 0x3c5a8, + OneHitKOText: 0x81097, + ParalyzedText: 0x81558, + "PrintTempMonStats.StatNames": 0x50bb5, + ProtectedByMistText: 0x81321, + ProtectedByText: 0x815a9, + ProtectingItselfText: 0x8167a, + RageBuildingText: 0x810f3, + RecoveredUsingText: 0x80bc2, + RegainedHealthText: 0x8100c, + SafeguardProtectText: 0x81733, + SometimesFleeMons: 0x3c59a, + StatLevelMultipliers: 0x364e6, + StatNames: 0x364a6, + StopLearningMoveText: 0x6675, + SuckedHealthText: 0x81210, + SuperEffectiveText: 0x810aa, + Text_1_2_and_Poof: 0x6684, + "Text_1_2_and_Poof.MoveForgotText": 0x6695, + Text_MonGainedExpPoint: 0x3f11b, + TypeBoostItems: 0x35703, + TypeMatchups: 0x34bb1, + UnaffectedText: 0x81061, + UsedMove1Text: 0x105e04, + UsedMove2Text: 0x105e0b, + UsedMoveText: 0x105db9, + "UsedMoveText_CheckObedience.UsedInsteadText": 0x105e1a, + WasBurnedText: 0x8123c, + WasPoisonedText: 0x811d5, + WildPokemonAppearedText: 0x80746, + WokeUpText: 0x80cfa, + WontDropAnymoreText: 0x8128f, + WontRiseAnymoreText: 0x81272, +} as const; + +export const MAP_GROUP_COUNT = 26; +export const NUM_POKEMON = 251; +export const NUM_ITEMS = 190; +/** The last group has no following pointer to bound it. */ +export const LAST_MAP_GROUP_SIZE = 11; + +/** Areas whose rules, scripts and presentation are ported. Other decoded + * maps remain unavailable; the label only distinguishes interiors that share + * a ROM landmark name. */ export const NATIVE_MAPS: Record = { "24:4": "New Bark Town", "24:5": "Elm’s lab", diff --git a/src/game/runtime.ts b/src/game/runtime.ts index 72aa08b..1eba056 100644 --- a/src/game/runtime.ts +++ b/src/game/runtime.ts @@ -1,7 +1,8 @@ import type { GameRuntime } from "../app/runtime"; import type { Button } from "../input/types"; import { CrystalAssets } from "./rom/assets"; -import { GameWorld } from "./world/world"; +import { GameWorld, TICKS_PER_SECOND } from "./world/world"; +import type { BattleAction } from "./battle/battle"; import type { Direction } from "./world/types"; import { WorldRenderer } from "./rendering/renderer"; @@ -12,9 +13,10 @@ export class NativeRuntime implements GameRuntime { private pressed = new Set`; +const key = (name: string, label: string) => + ``; + +const tab = (id: string, label: string, selected = false) => + ``; export function renderShell(root: HTMLElement) { root.innerHTML = ` -
-
-

✧ YOUR CARTRIDGE. A NEW HOME.

-

A little adventure.
Anywhere.

-

Crystal, rebuilt for the web.
Start exploring New Bark Town in the first native milestone.

-
-
01

Bring your cartridge

-

Pokémon Crystal
English US/Europe · Revision 1.1

-
Used for your first session. Saved sessions keep their character.
- - -

Opened locally. Never uploaded.

-
-

An exploration build, not the full game yet.
Move, enter buildings, read signs and try interactions. Story progression, battles and audio are next.

-
-
+
+ +
-
NATIVE / EXPLORATIONSTANDBY
- -

Your next chapter
starts here.

Load your Crystal ROM to begin.

- +
+
+

Load your Crystal ROM

English US/Europe · Revision 1.1

+
+ + +
+ +

Opened locally. Never uploaded.

+
+
+ + + + +
-
YOUR WORLD, REBUILT160 × 144
+
YOUR WORLD0 tiles walked
-
+
${key("up", "▲")}${key("left", "◀")}${key("right", "▶")}${key("down", "▼")}
${key("b", "B")}${key("a", "A")}
${key("select", "SELECT")}${key("start", "START")}
-
-
MADE FOR THE WAY YOU PLAY✧
-
-

0 tiles walkedExploration build · Audio pending

-

-

Ready when you are. Choose your ROM to get started.

-

Session backups stay in this browser.

-
Save files & compatibility

Native saves keep your character, position and completed interactions. They are separate from emulator snapshots and cartridge .sav files, which cannot be imported yet.

Time-of-day colors follow your device clock. The tile counter measures in-game movement, not real-world steps. Export a backup before clearing browser data.

+ + + + +
-
A personal cartridge, a pocket-sized world.Independent project · No game assets included
-

NATIVE PORT · FIRST MILESTONE

Make yourself at home.

Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM. Select it again after a reload to resume.

Explore your bedroom, downstairs, New Bark Town and its buildings. Use A to read signs, the radio and supported conversations. An unfinished interaction shows a message and leaves progress unchanged.

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

Sessions save every 10 seconds and when paused. Download a backup before clearing browser data. Switching apps pauses play.

This is an exploration milestone. Opening scenes, story progression, battles, NPC movement, room decorations, audio and routes beyond town are not ported yet. Online Link and real-world steps come after the original game systems.

`; +
+ +

+

Ready when you are. Choose your ROM to get started.

+

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.

`; } diff --git a/src/ui/styles.css b/src/ui/styles.css index ec385dd..ed00b9f 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -1,32 +1,269 @@ :root { font-family: - Inter, - ui-sans-serif, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; - color: #183d36; - background: #f4f4ec; - font-synthesis: none; - font-weight: 400; - font-size: 15px; - line-height: 1.5; + "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; + color: #282831; + background: #d9e2dd; font-synthesis: none; + font-size: 14px; + line-height: 1.45; + --cream: #fffbea; + --cream-dark: #eee8ce; + --ink: #282831; + --blue: #2366a5; + --blue-dark: #164875; } + * { box-sizing: border-box; } +[hidden] { + display: none !important; +} +html, +body, +#app { + min-width: 280px; + min-height: 100%; +} +body { + margin: 0; + min-height: 100dvh; + padding: env(safe-area-inset-top) env(safe-area-inset-right) + env(safe-area-inset-bottom) env(safe-area-inset-left); + overflow-x: hidden; +} +button, +input, +select { + font: inherit; + color: inherit; +} +button, +label { + -webkit-tap-highlight-color: transparent; +} +button { + min-height: 44px; + cursor: pointer; + border: 0; +} +button:disabled { + cursor: default; + opacity: 0.45; + pointer-events: none; +} +button:focus-visible, +input:focus-visible, +select:focus-visible, +canvas:focus-visible, +label:focus-within { + outline: 3px solid #5aa4de; + outline-offset: 2px; +} +#app:has(#rom-file:focus-visible) label[for="rom-file"] { + outline: 3px solid #5aa4de; + outline-offset: 2px; +} + +#app { + height: 100dvh; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.site-header { + min-height: 48px; + padding: 6px clamp(10px, 2vw, 24px); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: var(--cream); + background: var(--blue-dark); + border-bottom: 4px double #101d2b; +} +.brand { + color: inherit; + text-decoration: none; + font-weight: 800; + letter-spacing: 0.18em; +} +.header-state, +.power { + display: flex; + align-items: center; + gap: 12px; +} +.power { + font-size: 10px; + letter-spacing: 0.08em; +} +.power i { + display: block; + width: 8px; + height: 8px; + border: 1px solid #0b1d2c; + border-radius: 50%; + background: #a0a692; +} +.power i.on { + background: #8ee36f; + box-shadow: 0 0 6px #b9ff9e; +} +.text-button { + padding: 4px 12px; + color: var(--cream); + background: transparent; + border: 2px solid #b4d6ed; +} + +.app-shell { + width: min(1500px, 100%); + min-height: 0; + margin: 0 auto; + padding: 10px clamp(8px, 2vw, 24px) 6px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} +.tabs { + display: flex; + align-items: end; + gap: 2px; + min-width: 0; + overflow-x: auto; + scrollbar-width: thin; +} +.tabs button { + flex: 1 0 auto; + min-width: 92px; + padding: 8px 13px; + white-space: nowrap; + background: #c8d8df; + border: 2px solid #566675; + border-bottom: 0; + color: #27394a; + font-size: 12px; +} +.tabs button[aria-selected="true"] { + position: relative; + z-index: 1; + color: #fff; + background: var(--blue); + font-weight: 700; +} +.tabs button span { + margin-right: 5px; + font-size: 9px; +} +.tab-panel { + min-height: 0; + background: var(--cream); + border: 4px double var(--ink); +} + +.game-panel { + overflow: hidden; +} +.console { + height: 100%; + min-height: 0; + padding: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) 198px; + gap: 10px; + background: var(--cream); +} +.screen-bezel { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + padding: 5px; + background: #202634; + border: 4px double #080b10; +} +.screen { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #dce9ce; + border: 2px solid #0a0c10; +} +#game-screen { + display: block; + width: 100%; + height: 100%; + image-rendering: pixelated; +} +.screen-caption { + min-height: 26px; + padding: 6px 5px 2px; + display: flex; + justify-content: space-between; + gap: 12px; + color: #c5dced; + font-size: 9px; + letter-spacing: 0.06em; +} + +.screen-empty, +.pause-overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: clamp(8px, 2.5vw, 28px); + background: color-mix(in srgb, var(--cream) 96%, transparent); +} +.screen-empty { + display: flex; + align-items: safe center; + justify-content: safe center; + overflow: auto; +} +.load-card { + flex: 0 0 auto; + width: min(430px, 100%); + margin: auto; + padding: clamp(12px, 2vw, 22px); + background: var(--cream); + border: 4px double var(--ink); + box-shadow: 6px 6px 0 #2d6685; +} +.menu-title { + margin: 0 0 7px; + color: var(--blue); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.15em; +} +h1, +h2, +h3, +p { + overflow-wrap: anywhere; +} +.load-card h1, +.menu-panel h2, +dialog h2 { + margin: 0 0 10px; + font-size: clamp(17px, 2.5vw, 25px); + line-height: 1.15; +} +.load-card > p:not(.menu-title) { + margin: 7px 0 12px; + font-size: 11px; +} .player-setup { display: grid; grid-template-columns: 1fr 1fr; - gap: 10px; + gap: 8px; margin: 12px 0; - grid-column: 1 / -1; } .player-setup label { font-size: 10px; - color: #647962; + font-weight: 700; } .player-setup input, .player-setup select { @@ -34,243 +271,36 @@ width: 100%; min-width: 0; min-height: 44px; - border: 1px solid #d5ddd1; - border-radius: 6px; - background: #fff; - padding: 8px; - color: #234e40; - font: inherit; - font-size: 12px; + margin-top: 3px; + padding: 7px; + background: #fffef5; + border: 3px double var(--ink); + border-radius: 0; } -.player-setup small { - grid-column: 1 / -1; - font-size: 9px; - color: #7a8b7b; -} -.world-status { - display: flex; - justify-content: space-between; - font-size: 9px; - color: #73816f; - gap: 8px; -} -.game-notice { - color: #765b38; - font-size: 11px; - text-align: center; - margin: 0; -} -.game-notice:empty { - display: none; -} -.native-dialogue { - position: absolute; - bottom: 8px; - left: 8px; - right: 8px; - z-index: 1; - padding: 12px; - color: #17382b; - background: #fffdf0; - border: 2px solid #284c3c; - border-radius: 8px; - box-shadow: 0 3px 15px #102b2a55; -} -.native-dialogue p { - font-size: clamp(11px, 2.8vw, 15px); - line-height: 1.45; - white-space: pre-line; - margin: 0; - max-height: 125px; - overflow: auto; -} -.native-dialogue > div { +.primary-button, +.menu-button { + min-height: 44px; display: flex; align-items: center; - justify-content: space-between; - font-size: 9px; -} -.native-dialogue button { - background: transparent; - padding: 8px 0 0 12px; - font-size: 11px; -} -.pause-overlay { - z-index: 2; -} -body { - margin: 0; - padding: env(safe-area-inset-top) env(safe-area-inset-right) - env(safe-area-inset-bottom) env(safe-area-inset-left); -} -button, -input { - font: inherit; -} -button, -a, -label { - -webkit-tap-highlight-color: transparent; -} -button { + gap: 8px; + padding: 9px 13px; + color: #fff; + background: var(--blue); + border: 3px double #102d4b; cursor: pointer; + font-weight: 700; } -button:disabled { - cursor: default; - opacity: 0.42; +.primary-button:hover, +.menu-button:hover { + background: var(--blue-dark); } -button:focus-visible, -a:focus-visible, -summary:focus-visible, -canvas:focus-visible { - outline: 3px solid #ca713a; - outline-offset: 4px; -} -button { - color: inherit; -} -button:disabled { +.disabled { + opacity: 0.45; pointer-events: none; } -a { - color: inherit; -} -button, -.file-button { - min-height: 44px; -} -button { - border: 0; -} -button:hover:not(:disabled) { - filter: brightness(0.94); -} -[hidden] { - display: none !important; -} -.site-header { - max-width: 1280px; - margin: auto; - padding: 30px 54px; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid #dce1d6; -} -.brand { - display: flex; - align-items: center; - gap: 10px; - font-size: 28px; - letter-spacing: -1.5px; - font-weight: 750; - text-decoration: none; -} -.brand span { - font-size: 9px; - letter-spacing: 1.7px; - font-weight: 600; - margin-left: 13px; - padding-left: 20px; - border-left: 1px solid #c8d0c6; -} -.text-button { - background: none; - font-size: 12px; -} -.text-button span { - margin-left: 16px; -} -main { - max-width: 1180px; - margin: 0 auto; - display: grid; - grid-template-columns: 1fr 490px; - gap: 85px; - padding: 62px 40px 45px; -} -.intro { - padding-top: 24px; -} -.eyebrow { - font-size: 10px; - letter-spacing: 2px; - font-weight: 700; - display: flex; - align-items: center; - gap: 10px; -} -.small-star { - font-size: 23px; - color: #6d8d72; -} -h1 { - font-size: clamp(38px, 4.8vw, 62px); - line-height: 1.08; - letter-spacing: -3px; - font-weight: 500; - margin: 26px 0; -} -h1 em { - font-family: Georgia, serif; - font-weight: 400; - color: #759378; -} -.intro-copy { - font-size: 15px; - line-height: 1.85; - color: #6a7b6e; - margin-bottom: 30px; -} -.load-card { - background: #fbfcf7; - border: 1px solid #dce2d6; - border-radius: 15px; - padding: 24px; - max-width: 350px; - box-shadow: 0 5px 18px #20352a03; -} -.card-heading { - display: flex; - gap: 12px; - align-items: center; -} -.step-number { - font-size: 10px; - color: #738876; - border: 1px solid #d9e1d5; - border-radius: 50%; - height: 26px; - width: 26px; - display: grid; - place-items: center; -} -.load-card h2 { - font-size: 14px; - margin: 0; - font-weight: 600; -} -.load-card > p { - color: #73816f; - font-size: 12px; - margin: 18px 0; -} -.load-card strong { - font-weight: 500; - color: #2e5246; -} -.primary-button { - background: #234e40; - color: #fff; - border: 1px solid #234e40; - border-radius: 7px; - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 15px; - font-size: 13px; - font-weight: 500; - cursor: pointer; +.privacy-note { + color: #5e6464; + text-align: center; } .file-input { position: absolute; @@ -279,176 +309,273 @@ h1 em { overflow: hidden; clip-path: inset(50%); } -.load-card:focus-within .file-button { - outline: 3px solid #ca713a; - outline-offset: 3px; + +.native-dialogue { + position: absolute; + z-index: 3; + left: 12px; + right: 12px; + bottom: 12px; + padding: 12px; + color: var(--ink); + background: var(--cream); + border: 5px double var(--ink); + box-shadow: 4px 4px 0 #0007; } -.disabled { - opacity: 0.5; - pointer-events: none; +.native-dialogue p { + max-height: 120px; + overflow: auto; + margin: 0; + white-space: pre-line; } -.load-card .privacy-note { - font-size: 10px; - margin: 13px 0 0; - text-align: center; - color: #7a8b7b; -} -.privacy-note span { - margin-right: 4px; -} -.project-note { - display: flex; - gap: 12px; - margin-top: 30px; - align-items: center; - max-width: 350px; -} -.note-line { - width: 3px; - align-self: stretch; - background: #d4ddce; - margin: 12px 0; -} -.project-note p { - font-size: 11px; - color: #576d5b; - line-height: 1.8; -} -.project-note p span { - color: #8b9789; -} -.console { - background: #133532; - border-radius: 25px 25px 60px 25px; - padding: 22px 25px 18px; - box-shadow: - 0 23px 45px #1335321c, - inset 0 1px 1px #ffffff28; - color: #a7c6b8; - border: 1px solid #244b42; -} -.console-top { +.native-dialogue > div { display: flex; justify-content: space-between; align-items: center; - font-size: 9px; - letter-spacing: 2px; - padding: 0 3px 17px; + margin-top: 4px; } -.power { +.native-dialogue > .dialogue-choice { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + margin-top: 6px; +} +.dialogue-choice input { + flex: 1 1 12ch; + min-width: 0; + max-width: 20ch; + padding: 6px 8px; + text-transform: none; + background: #fff; + border: 3px double var(--ink); +} +.battle { + position: absolute; + inset: 0; + z-index: 3; + display: grid; + grid-template-rows: 1fr 1fr auto; + gap: 4px; + padding: 10px; + color: var(--ink); + background: #f8f8f0; +} +.battle-foe, +.battle-player { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 0; +} +.battle canvas { + width: auto; + height: min(100%, 168px); + aspect-ratio: 1; + image-rendering: pixelated; +} +.battle-hud { + flex: 0 1 260px; + padding: 6px 10px; + border: 4px double var(--ink); + border-top: 0; + border-right: 0; +} +.battle-player .battle-hud { + border-left: 0; + border-right: 4px double var(--ink); +} +.battle-hud strong { + margin-right: 8px; +} +.hp-row { display: flex; align-items: center; gap: 6px; - font-size: 8px; - letter-spacing: 1px; + font-size: 0.8rem; + font-weight: 800; } -.power i { - height: 5px; - width: 5px; - border-radius: 50%; - background: #6f887a; +.hp-bar { + flex: 1; + height: 8px; + background: #fff; + border: 2px solid var(--ink); } -.power i.on { - background: #b2e6a5; - box-shadow: 0 0 7px #b2e6a5; -} -.screen-bezel { - padding: 15px 16px 9px; - background: #0a2423; - border-radius: 9px 9px 25px 9px; - border: 1px solid #315249; -} -.screen { - position: relative; - aspect-ratio: 160/144; - background: #dae6c7; - overflow: hidden; - border-radius: 3px; -} -canvas { +.hp-bar i { display: block; - width: 100%; height: 100%; - image-rendering: pixelated; + background: #3aa54f; + transition: width 0.4s linear; } -.screen-empty { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - color: #526c49; - background: radial-gradient(ellipse at center, #e6ecd6, #ccdcbc); +.hp-bar i.low { + background: #d9a91c; } -.crystal-mark { - font-size: 84px; - line-height: 1.05; - font-family: Georgia, serif; - font-weight: 400; - color: #64885d; - text-shadow: 6px 0 #b6cbaa; - margin-top: -8px; +.hp-bar i.critical { + background: #d33b49; } -.screen-empty h2 { - font-family: Georgia, serif; - font-size: 25px; - line-height: 1.2; - font-weight: 400; - letter-spacing: -0.5px; - margin: 16px 0 10px; -} -.screen-empty p { - font-size: 10px; - margin: 0; - color: #718565; -} -.screen-dots { - margin-top: 12px; - font-size: 20px; - letter-spacing: 5px; - color: #8da17b; -} -.screen-caption { +.battle-numbers { display: flex; justify-content: space-between; - padding: 9px 0 0; - letter-spacing: 2px; - font-size: 7px; - color: #6f8e7f; + font-variant-numeric: tabular-nums; } -.controls { +.battle-status { + font-size: 0.8rem; + font-weight: 800; +} +.battle-box { + min-height: 96px; + padding: 10px 12px; + background: var(--cream); + border: 5px double var(--ink); +} +.battle-box p { + margin: 0 0 6px; + white-space: pre-line; +} +.battle-menu { display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} +.battle-menu button { + padding: 6px 10px; + text-align: left; + color: var(--ink); + background: var(--cream-dark); + border: 3px double var(--ink); +} +.battle-menu button:disabled { + opacity: 0.5; +} +.battle-menu button small { + display: block; + font-size: 0.75rem; +} +.battle-menu .wide { + grid-column: 1 / -1; +} +.pokepic { + position: absolute; + z-index: 3; + top: 18%; + left: 50%; + display: grid; + justify-items: center; + gap: 8px; + padding: 10px; + background: var(--cream); + border: 5px double var(--ink); + box-shadow: 4px 4px 0 #0007; + transform: translateX(-50%); +} +.pokepic canvas { + width: 168px; + height: 168px; + image-rendering: pixelated; +} +.pokepic button { + padding: 7px 12px; + color: #fff; + background: var(--blue); + border: 3px double #102d4b; +} +.dialogue-choice button { + min-width: 64px; +} +.dialogue-choice button[aria-pressed="true"]::before { + content: "▶ "; +} +.dialogue-choice button[aria-pressed="false"] { + color: var(--ink); + background: var(--cream-dark); + border-color: var(--ink); +} +.dialogue-choice output { + align-self: center; + min-width: 11ch; + text-align: center; + font-weight: 800; +} +.native-dialogue button, +.pause-overlay button { + padding: 7px 12px; + color: #fff; + background: var(--blue); + border: 3px double #102d4b; +} +.pause-overlay { + z-index: 4; + align-content: center; + gap: 12px; + color: #fff; + background: #162032d9; + font-weight: 800; + letter-spacing: 0.18em; +} + +.minimap-overlay { + display: none; + position: absolute; + z-index: 2; + top: 8px; + right: 8px; + padding: 3px; + background: var(--cream); + border: 4px double var(--ink); + box-shadow: 3px 3px 0 #0005; +} +.has-game .minimap-overlay { + display: block; +} +#minimap { + display: block; + width: 128px; + height: 102px; + image-rendering: pixelated; +} +#minimap-position { + max-width: 128px; + margin: 3px 0 0; + text-align: center; + font-size: 8px; +} + +.controls { + min-height: 0; + display: grid; + grid-template-rows: 1fr auto; grid-template-columns: 1fr 1fr; align-items: center; - padding: 22px 8px 4px; - gap: 15px; + align-content: center; + justify-items: center; + gap: 10px 5px; + padding: 4px; + border-left: 2px solid #9b998c; +} +.game-key { + min-width: 44px; + min-height: 44px; + touch-action: none; + user-select: none; + border: 2px solid #20242d; + background: #53677a; + color: #fff; + font-weight: 800; +} +.game-key.pressed { + translate: 0 2px; + filter: brightness(1.35); } .dpad { display: grid; grid-template-columns: repeat(3, 44px); grid-template-rows: repeat(3, 44px); - filter: drop-shadow(0 5px 1px #061918); -} -.game-key { - min-width: 44px; - flex-shrink: 0; - touch-action: none; - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; + grid-column: 1 / 3; } .dpad .game-key, .dpad-center { - background: #254d43; - box-shadow: inset 0 1px #527363; - color: #8fb09a; - min-height: 44px; - font-size: 14px; -} -.dpad .game-key { - border-radius: 4px; + background: #394754; } .dpad [data-game-button="up"] { grid-column: 2; @@ -461,16 +588,7 @@ canvas { .dpad-center { grid-column: 2; grid-row: 2; - box-shadow: none; -} -.dpad-center:after { - content: ""; - display: block; - width: 14px; - height: 14px; - border: 1px solid #365e50; - border-radius: 50%; - margin: 15px; + border: 2px solid #20242d; } .dpad [data-game-button="right"] { grid-column: 3; @@ -482,450 +600,340 @@ canvas { } .action-keys { display: flex; - gap: 17px; - justify-content: flex-end; - transform: rotate(-20deg); + gap: 7px; } -.action-keys button { - width: 59px; - height: 59px; +.action-keys .game-key { border-radius: 50%; - background: #d8e7c0; - color: #35573e; - font-size: 20px; - font-weight: 650; - border: 3px solid #b3caa0; - box-shadow: 0 5px #081f1c; - transform: rotate(20deg); -} -.game-key:disabled { - opacity: 0.7; -} -.game-key.pressed { - filter: brightness(1.3); - translate: 0 3px; - box-shadow: none; + background: var(--blue); } .system-keys { display: flex; - gap: 15px; - grid-column: 1/3; - justify-content: center; - margin-top: -4px; + flex-direction: column; + gap: 4px; } -.system-keys button { - background: none; - position: relative; +.system-keys .game-key { + padding: 4px 7px; font-size: 8px; - letter-spacing: 1px; - padding-top: 22px; - color: #86a696; + background: #6d7478; } -.system-keys button:before { - content: ""; - position: absolute; - top: 6px; - left: 50%; - transform: translateX(-50%) rotate(-10deg); - width: 33px; - height: 9px; - background: #416356; - border: 1px solid #517463; - border-radius: 7px; - box-shadow: 0 3px #071f1c; + +.menu-panel { + overflow: auto; + padding: clamp(18px, 4vw, 48px); } -.speaker { - display: none; +.menu-panel > header { + padding-bottom: 15px; + border-bottom: 3px double #747269; } -.console-bottom { +.save-actions { display: flex; - justify-content: space-between; - align-items: center; - font-size: 7px; - letter-spacing: 1.7px; - color: #54776a; - margin: 13px 5px 0; -} -.console-bottom > span:last-child { - font-size: 18px; -} -.player-toolbar { - display: flex; - justify-content: center; - gap: 5px; - padding-top: 20px; flex-wrap: wrap; + gap: 8px; + margin: 22px 0; } -.player-toolbar button, -.save-tools button { - background: transparent; - font-size: 11px; - padding: 8px 11px; - border: 1px solid #d5ddd1; - border-radius: 7px; +.save-actions button, +.play-button { + padding: 8px 14px; + color: #fff; + background: var(--blue); + border: 3px double #102d4b; +} +.save-copy { + max-width: 720px; + color: #4e5157; +} +.replace-rom { + max-width: 720px; + margin-top: 24px; + padding-top: 20px; + border-top: 3px double #747269; +} +.replace-rom h3 { + margin: 0; +} +.replace-rom .menu-button { + width: fit-content; +} +.world-map-frame { + min-height: 180px; + height: calc(100% - 100px); + display: grid; + place-items: center; + margin-top: 16px; + padding: 10px; + overflow: auto; + background: #dce8d0; + border: 4px double var(--ink); +} +#world-map { + display: block; + width: min(100%, 640px); + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + image-rendering: pixelated; +} +#map-empty { + max-width: 460px; + text-align: center; + color: #5f6763; +} +.planned-panel { + max-width: none; +} +.planned-panel > p:last-child { + max-width: 680px; + font-size: 15px; + line-height: 1.8; +} + +.global-bar { + min-height: 68px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: "play status save" "play notice save"; + align-items: center; + gap: 0 14px; + padding: 7px clamp(10px, 2vw, 24px); + color: #eaf2f5; + background: #263744; + border-top: 4px double #0e161d; +} +.play-button { + grid-area: play; + min-width: 88px; } .status { + grid-area: status; + margin: 0; font-size: 11px; - text-align: center; - color: #647962; - min-height: 17px; - margin: 14px 0 0; - overflow-wrap: anywhere; +} +.game-notice { + grid-area: notice; + margin: 0; + color: #b8d4e6; + font-size: 10px; +} +.game-notice:empty { + display: none; } .save-status { - text-align: center; - font-size: 10px; - color: #8b9788; - margin: 6px 0; + grid-area: save; + margin: 0; + max-width: 280px; + text-align: right; + color: #b8c7ce; + font-size: 9px; } .error { - color: #a03e29 !important; -} -.save-tools { - font-size: 11px; - color: #71816c; - text-align: center; - margin-top: 16px; -} -.save-tools summary { - cursor: pointer; - min-height: 44px; - padding: 12px; -} -.save-tools > div { - padding: 12px; - border: 1px solid #d5ddd1; - border-radius: 8px; - text-align: left; -} -.save-tools button { - margin: 0 5px 5px 0; -} -.save-tools p { - font-size: 11px; - line-height: 1.7; -} -.pause-overlay { - position: absolute; - inset: 0; - background: #0a2423b8; - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - justify-content: center; - color: #e7efdb; - backdrop-filter: blur(2px); -} -.pause-overlay span { - font-size: 10px; - letter-spacing: 4px; -} -.pause-overlay button { - background: #d8e7c0; - color: #21483b; - border-radius: 7px; - padding: 10px 18px; - font-size: 12px; -} -footer { - max-width: 1172px; - margin: auto; - border-top: 1px solid #dce1d6; - padding: 22px 0 28px; - display: flex; - justify-content: space-between; - gap: 15px; - color: #8a9886; - font-size: 10px; + color: #ffb0a1 !important; } + dialog { - border: 1px solid #d2ddca; - background: #f4f4ec; - color: #244c3e; - border-radius: 18px; - padding: 32px; - max-width: 460px; - width: calc(100% - 32px); + width: min(520px, calc(100% - 24px)); + padding: 24px; + color: var(--ink); + background: var(--cream); + border: 5px double var(--ink); + border-radius: 0; } dialog::backdrop { - background: #0a242399; - backdrop-filter: blur(3px); -} -dialog h2 { - font-family: Georgia, serif; - font-weight: 400; - font-size: 30px; + background: #111827b8; } dialog p { - font-size: 13px; - line-height: 1.7; + line-height: 1.65; } dialog dl { display: grid; grid-template-columns: 1fr 1fr; - font-size: 13px; - gap: 10px; + gap: 8px; } dialog dd { margin: 0; } .dialog-close { float: right; - background: none; - font-size: 25px; + background: transparent; + font-size: 22px; } -@media (min-width: 1300px) { - main { - gap: 100px; - padding-top: 75px; - padding-bottom: 60px; - } - .intro { - padding-top: 37px; - } -} -@media (max-width: 1000px) { - main { - gap: 35px; - grid-template-columns: minmax(0, 1fr) minmax(340px, 440px); - padding: 40px 30px; - } + +@media (max-width: 720px) { .site-header { - padding: 22px 30px; + min-height: 42px; } - h1 { - font-size: 45px; + .app-shell { + padding: 5px 5px 3px; } - .console { - padding: 20px; - } - .screen-empty h2 { - font-size: 23px; - } - footer { - margin: 0 30px; - } -} -@media (max-width: 740px) { - .site-header { - padding: 17px 22px; - } - .brand { - font-size: 25px; - } - .brand img { - width: 30px; - height: 30px; - } - .brand span { - display: none; - } - main { - display: flex; - flex-direction: column; - gap: 25px; - padding: 20px 18px 26px; - max-width: 480px; - } - .intro { - padding: 0; - } - .eyebrow { - font-size: 8px; - letter-spacing: 1.6px; - margin: 10px 0; - } - h1 { - font-size: 36px; - letter-spacing: -1.6px; - margin: 15px 0 13px; - } - h1 br { - display: block; - } - h1 em { - white-space: nowrap; - } - .intro-copy { - font-size: 12px; - line-height: 1.7; - margin: 0 0 18px; - } - .intro-copy br { - display: none; - } - .intro-copy br:after { - content: " "; - } - #app.has-game .intro { - display: none; - } - #app.has-game main { - padding-top: 12px; - } - .load-card { - max-width: none; - padding: 15px 18px; - display: grid; - grid-template-columns: 1fr 140px; - column-gap: 10px; - align-items: center; - } - .card-heading h2 { - font-size: 12px; - } - .step-number { - display: none; - } - .load-card > p { - margin: 5px 0; + .tabs button { + min-width: 83px; + padding: 6px 9px; font-size: 10px; - grid-column: 1; - } - .load-card .primary-button { - grid-column: 2; - grid-row: 1/3; - font-size: 11px; - padding: 10px; - gap: 5px; - } - .load-card .privacy-note { - grid-column: 1/3; - margin: 10px 0 0; - font-size: 9px; - } - .project-note { - display: none; } .console { - border-radius: 22px 22px 50px 22px; - padding: 20px; - } - .console-top { - font-size: 8px; - } - .screen-bezel { - padding: 12px 12px 9px; + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 1fr) 132px; + padding: 5px; + gap: 4px; } .controls { - padding: 22px 4px 0; - } - .action-keys button { - width: 55px; - height: 55px; - } - .action-keys { - gap: 14px; - } - .player-toolbar { - padding-top: 17px; - } - .player-toolbar button { - font-size: 10px; - padding: 8px 10px; - } - .status { - font-size: 10px; - } - footer { - padding: 18px 0 25px; - margin: 0 22px; - flex-direction: column; - font-size: 9px; - gap: 5px; - } -} -@media (max-width: 360px) { - .load-card { - grid-template-columns: 1fr 114px; - padding: 13px; - } - .load-card .primary-button { - font-size: 10px; - } - .console { - padding: 15px; - } - .screen-bezel { - padding: 9px; - } - .screen-empty h2 { - font-size: 21px; - } - .screen-empty .crystal-mark { - font-size: 62px; - } - .action-keys { - gap: 8px; - } - .action-keys button { - width: 48px; - height: 48px; + grid-template-columns: 132px minmax(95px, 1fr) 48px; + grid-template-rows: 1fr; + border-left: 0; + border-top: 2px solid #9b998c; + padding: 2px; } .dpad { - grid-template-columns: repeat(3, 44px); - grid-template-rows: repeat(3, 44px); + grid-column: 1; } - .controls { - padding-left: 0; - padding-right: 0; - gap: 8px; + .action-keys { + grid-column: 2; } .system-keys { - margin-top: 0; + grid-column: 3; + } + #minimap { + width: 96px; + height: 77px; + } + #minimap-position { + max-width: 96px; + } + .screen-caption { + min-height: 22px; + padding-top: 4px; + } + .global-bar { + min-height: 76px; + grid-template-columns: auto 1fr; + grid-template-areas: "play status" "notice save"; + gap: 3px 9px; + } + .save-status { + max-width: none; } } -@media (max-height: 540px) and (orientation: landscape) { - #app.has-game - :is( - .site-header, - .intro, - footer, - .console-top, - .console-bottom, - .screen-caption, - .save-tools, - .save-status - ) { + +@media (max-width: 390px) { + .header-state { + gap: 6px; + } + .power { display: none; } - #app.has-game main { - display: block; - max-width: 850px; - padding: 12px 20px; + .load-card { + padding: 10px; + box-shadow: 3px 3px 0 #2d6685; } - #app.has-game .console { - position: relative; - padding: 12px; - border-radius: 20px; - min-height: 270px; + .load-card h1 { + font-size: 16px; } - #app.has-game .screen-bezel { - width: min(43%, 340px); - margin: auto; - padding: 8px; + .player-setup { + margin: 7px 0; } - #app.has-game .controls { - position: absolute; - inset: 0; - display: flex; - justify-content: space-between; - pointer-events: none; - padding: 25px; + .minimap-overlay { + top: 4px; + right: 4px; + padding: 2px; } - #app.has-game .controls > * { - pointer-events: auto; + #minimap { + width: 80px; + height: 64px; } - #app.has-game .system-keys { - position: absolute; - bottom: 18px; - left: 30px; + #minimap-position { + display: none !important; } - #app.has-game .player-toolbar { - padding-top: 8px; + .global-bar { + padding-block: 4px; } - #app.has-game .status { - margin-top: 6px; + .status { + font-size: 9px; + } + .save-status { + font-size: 8px; + } +} + +@media (max-height: 540px) and (orientation: landscape) { + #app { + grid-template-rows: minmax(0, 1fr) auto; + } + .site-header { + display: none; + } + .app-shell { + padding-top: 2px; + } + .tabs button { + min-height: 38px; + padding-block: 4px; + } + .screen-empty { + overflow: auto; + } + .screen-empty .load-card { + width: min(600px, 100%); + margin: auto; + padding: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(150px, 0.8fr); + gap: 4px 10px; + align-items: center; + } + .screen-empty .menu-title { + display: none; + } + .screen-empty .load-card h1, + .screen-empty .load-card > p:not(.privacy-note) { + grid-column: 1; + margin: 0; + } + .screen-empty .player-setup { + grid-column: 1; + margin: 0; + } + .screen-empty .primary-button { + grid-column: 2; + grid-row: 1 / 3; + } + .screen-empty .privacy-note { + grid-column: 2; + margin: 0; + } + .console { + grid-template-columns: minmax(0, 1fr) 188px; + grid-template-rows: minmax(0, 1fr); + padding: 3px; + } + .controls { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr auto; + border-top: 0; + border-left: 2px solid #9b998c; + } + .dpad { + grid-column: 1 / 3; + } + .action-keys { + grid-column: 1; + } + .system-keys { + grid-column: 2; + } + .global-bar { + min-height: 48px; + grid-template-columns: auto 1fr auto; + grid-template-areas: "play status save"; + } + .game-notice { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; } } diff --git a/src/ui/tabs.ts b/src/ui/tabs.ts new file mode 100644 index 0000000..2ba38ad --- /dev/null +++ b/src/ui/tabs.ts @@ -0,0 +1,49 @@ +const tabIds = ["game", "saves", "map", "pokedex", "link", "friends"] as const; +type TabId = (typeof tabIds)[number]; + +export function bindTabs(root: HTMLElement, onLeaveGame: () => void) { + const tabs = tabIds.map((id) => + root.querySelector(`#tab-${id}`), + ); + const panels = tabIds.map((id) => + root.querySelector(`#panel-${id}`), + ); + if (tabs.some((item) => !item) || panels.some((item) => !item)) + throw new Error("Crystal tabs require every tab and panel."); + + let current: TabId = "game"; + const select = (candidate: string) => { + if (!tabIds.includes(candidate as TabId)) return; + const next = candidate as TabId; + if (current === "game" && next !== "game") onLeaveGame(); + current = next; + tabIds.forEach((id, index) => { + const selected = id === current; + const button = tabs[index]!; + button.setAttribute("aria-selected", String(selected)); + button.tabIndex = selected ? 0 : -1; + button.innerHTML = `${selected ? '' : ""}${button.textContent?.replace(/^▶/, "").trim() ?? ""}`; + panels[index]!.hidden = !selected; + }); + }; + + tabs.forEach((tab, index) => { + tab!.addEventListener("click", () => select(tabIds[index])); + tab!.addEventListener("keydown", (event) => { + let next = index; + if (event.key === "ArrowRight" || event.key === "ArrowDown") + next = (index + 1) % tabs.length; + else if (event.key === "ArrowLeft" || event.key === "ArrowUp") + next = (index - 1 + tabs.length) % tabs.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = tabs.length - 1; + else return; + event.preventDefault(); + tabs[next]!.focus(); + select(tabIds[next]); + }); + }); + + select(current); + return { select, active: () => current }; +} diff --git a/tests/battle.test.ts b/tests/battle.test.ts new file mode 100644 index 0000000..a9a8fba --- /dev/null +++ b/tests/battle.test.ts @@ -0,0 +1,186 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { CrystalAssets } from "../src/game/rom/assets"; +import { + Battle, + UnsupportedBattle, + type BattleEffect, + type BattleInput, +} from "../src/game/battle/battle"; +import { C } from "../src/game/battle/constants"; +import { + calcStats, + generatePartyMon, + type PartyMon, +} from "../src/game/party/pokemon"; +import { defaultTextContext } from "../src/game/scripts/text"; + +const romPath = + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; + +describe.skipIf(!existsSync(romPath))( + "native battle engine with the local ROM", + () => { + let data: CrystalAssets["pokemon"]; + beforeAll(() => { + data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon; + }); + const PIDGEY = 16, + CYNDAQUIL = 155, + TACKLE = 33; + const starter = (level = 5, exp?: number): PartyMon => { + const mon = generatePartyMon(data, { + species: CYNDAQUIL, + level, + item: 0, + otId: 1234, + ot: "CHRIS", + dvs: 0x823b, + caughtData: 0, + }); + if (exp !== undefined) mon.exp = exp; + return mon; + }; + /** A reproducible stand-in for the hardware Random routine. */ + const seeded = (seed: number) => () => { + seed = (seed * 1103515245 + 12345) >>> 0; + return (seed >>> 16) & 255; + }; + const battle = ( + party: PartyMon[], + wild: { species: number; level: number }, + seed = 1, + ) => { + const pokedex = { seen: new Set(), caught: new Set() }; + return { + pokedex, + battle: new Battle({ + data, + party, + pokedex, + playerId: 1234, + playerName: "CHRIS", + timeOfDay: 1, + random: seeded(seed), + wild, + text: () => defaultTextContext("CHRIS"), + }), + }; + }; + /** Play a battle, answering the menu and prompts; returns texts and result. */ + const play = ( + battle: Battle, + answer: (effect: BattleEffect) => BattleInput = (effect) => + effect.kind === "action" + ? { action: "fight", move: 0 } + : effect.kind === "yesno" + ? 1 + : undefined, + ) => { + const texts: string[] = []; + const effects: BattleEffect[] = []; + const run = battle.run(); + let input: BattleInput; + for (let step = 0; step < 2000; step++) { + const next = run.next(input); + if (next.done) return { texts, effects, result: next.value.result }; + effects.push(next.value); + if (next.value.kind === "text") + texts.push(next.value.text.replace(/\n/g, " ")); + input = answer(next.value); + } + throw new Error("Battle did not end."); + }; + + it("wins a wild battle with the original messages, experience and stat experience", () => { + const mon = starter(); + const { battle: game, pokedex } = battle([mon], { + species: PIDGEY, + level: 3, + }); + const { texts, result } = play(game); + expect(texts.slice(0, 3)).toEqual([ + "Wild PIDGEY appeared!", + "Go! CYNDAQUIL!", + "CYNDAQUIL used TACKLE!", + ]); + expect(texts).toContain("Enemy PIDGEY fainted!"); + // Base experience 55 × level 3 ÷ 7. + expect(texts.at(-1)).toBe("CYNDAQUIL gained 23 EXP. Points!"); + expect(result).toBe(C.WIN); + expect(mon.exp).toBe(135 + 23); + expect(mon.statExp).toEqual([40, 45, 40, 56, 35]); + expect(mon.pp[0]).toBeLessThan(35); + expect(pokedex.seen.has(PIDGEY)).toBe(true); + expect(mon.hp).toBeLessThan(mon.stats[0]); + }); + + it("escapes when the player's Pokémon is faster", () => { + const mon = starter(20); + const { battle: game } = battle([mon], { species: PIDGEY, level: 2 }); + const { texts, result } = play(game, (effect) => + effect.kind === "action" ? { action: "run" } : undefined, + ); + expect(texts.slice(-1)).toEqual(["Got away safely!"]); + expect(result).toBe(C.DRAW); + expect(mon.exp).toBe(data.expAtLevel(3, 20)); + }); + + it("loses when the only Pokémon faints", () => { + const mon = starter(); + mon.hp = 1; + const { battle: game } = battle([mon], { species: 19, level: 10 }, 5); + const { texts, result } = play(game, (effect) => + effect.kind === "action" ? { action: "fight", move: 1 } : undefined, + ); + expect(texts).toContain("CYNDAQUIL fainted!"); + expect(result).toBe(C.LOSE); + expect(mon.hp).toBe(0); + expect(mon.status).toBe(0); + }); + + it("levels up, shows the stat box and learns the level's move", () => { + const mon = starter(5, 178); + const { battle: game } = battle([mon], { species: PIDGEY, level: 3 }); + const { texts, effects } = play(game); + expect(texts).toContain("CYNDAQUIL grew to level 6!"); + const box = effects.find((effect) => effect.kind === "stats"); + expect(box).toMatchObject({ + labels: ["ATTACK", "DEFENSE", "SPCL.ATK", "SPCL.DEF", "SPEED"], + }); + expect(texts).toContain("CYNDAQUIL learned SMOKESCREEN!"); + expect(mon.level).toBe(6); + expect(mon.moves).toEqual([TACKLE, 43, 108, 0]); + // Level-up recalculates stats with the new stat experience. + expect(mon.stats).toEqual( + calcStats(data, CYNDAQUIL, mon.dvs, 6, mon.statExp), + ); + }); + + it("asks which move to forget when four moves are known", () => { + const mon = starter(5, 178); + mon.moves = [TACKLE, 43, 52, 98]; + mon.pp = [35, 30, 25, 30]; + const { battle: game } = battle([mon], { species: PIDGEY, level: 3 }); + const { texts } = play(game, (effect) => + effect.kind === "action" + ? { action: "fight", move: 0 } + : effect.kind === "yesno" + ? 1 + : effect.kind === "forget" + ? 1 + : undefined, + ); + expect(texts).toContain("Which move should be forgotten?"); + expect(texts.some((text) => text.startsWith("1, 2 and…"))).toBe(true); + expect(mon.moves).toEqual([TACKLE, 108, 52, 98]); + expect(mon.pp[1]).toBe(data.move(108).pp); + }); + + it("refuses battles that need untranslated rules", () => { + const { battle: game } = battle([starter()], { species: 129, level: 5 }); + expect(() => play(game)).toThrow(UnsupportedBattle); + }); + }, +); diff --git a/tests/browser.spec.ts b/tests/browser.spec.ts index 3522fe2..cb1f867 100644 --- a/tests/browser.spec.ts +++ b/tests/browser.spec.ts @@ -6,8 +6,8 @@ test("ROM gate, help and responsive layouts", async ({ page }) => { page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/"); await expect( - page.getByRole("heading", { name: "A little adventure. Anywhere." }), - ).toBeVisible(); + page.getByRole("tab", { name: "Game", exact: true }), + ).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#play")).toBeDisabled(); await page.locator("#rom-file").setInputFiles({ name: "wrong.gbc", @@ -24,6 +24,7 @@ test("ROM gate, help and responsive layouts", async ({ page }) => { [768, 1024], [390, 844], [320, 740], + [320, 568], [844, 390], ]) { await page.setViewportSize({ width, height }); @@ -44,10 +45,40 @@ test("ROM gate, help and responsive layouts", async ({ page }) => { expect(button.width).toBeGreaterThanOrEqual(44); expect(button.height).toBeGreaterThanOrEqual(44); } + await page.locator("#player-name").scrollIntoViewIfNeeded(); + await expect(page.locator("#player-name")).toBeInViewport({ ratio: 0.9 }); + await page.locator("#rom-label").scrollIntoViewIfNeeded(); + await expect(page.locator("#rom-label")).toBeInViewport({ ratio: 0.9 }); } expect(errors).toEqual([]); }); +test("game menu tabs expose real tools and honest planned states with keyboard navigation", async ({ + page, +}) => { + await page.goto("/"); + const game = page.getByRole("tab", { name: "Game", exact: true }); + await game.focus(); + await page.keyboard.press("ArrowRight"); + await expect(page.locator("#tab-saves")).toBeFocused(); + await expect(page.locator("#panel-saves")).toBeVisible(); + await expect(page.locator("#save")).toBeDisabled(); + await expect(page.locator("#backup")).toBeDisabled(); + await page.keyboard.press("ArrowRight"); + await expect(page.locator("#panel-map")).toBeVisible(); + await expect(page.locator("#map-empty")).toBeVisible(); + await page.keyboard.press("End"); + await expect(page.locator("#tab-friends")).toBeFocused(); + await expect(page.locator("#panel-friends")).toContainText(/planned/i); + await page.keyboard.press("Home"); + await expect(game).toBeFocused(); + await expect(page.locator("#panel-game")).toBeVisible(); + for (const id of ["pokedex", "link"]) { + await page.locator(`#tab-${id}`).click(); + await expect(page.locator(`#panel-${id}`)).toContainText(/planned/i); + } +}); + test("actual WASM executes an original cartridge, reads input and round-trips state", async ({ page, }) => { diff --git a/tests/movement.test.ts b/tests/movement.test.ts new file mode 100644 index 0000000..e619d09 --- /dev/null +++ b/tests/movement.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from "vitest"; +import { CrystalAssets } from "../src/game/rom/assets"; +import { GameWorld, WALK_TICKS } from "../src/game/world/world"; +import { VECTORS, type Direction, type GameMap } from "../src/game/world/types"; +import { settle } from "./world-driver"; + +/** Synthetic maps and script bytes: these tests run without a private ROM. */ +class MovementAssets extends CrystalAssets { + readonly fixtures = new Map(); + readonly tiles = new Map(); + readonly bytes: Uint8Array; + constructor() { + const bytes = new Uint8Array(0xa0000); + bytes[0] = 0x50; // Empty weekday name for synthetic script hosts. + super(bytes); + this.bytes = bytes; + for (const id of ["24:7", "24:6"]) { + this.fixtures.set(id, { + id, + group: 24, + number: Number(id.split(":")[1]), + name: id, + location: id, + landmark: 1, + width: 8, + height: 8, + border: 0, + tileset: 1, + environment: 1, + paletteMode: 1, + blocks: new Uint8Array(16), + warps: [], + backgroundEvents: [], + objects: [], + connections: [], + sceneScripts: [], + callbacks: [], + coordinateEvents: [], + }); + } + } + override map(id: string) { + return this.fixtures.get(id)!; + } + override isPorted(id: string) { + return this.fixtures.has(id); + } + override sceneOwner() { + return undefined; + } + override collision(map: GameMap, x: number, y: number) { + if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7; + return this.tiles.get(`${map.id}:${x},${y}`) ?? 0; + } + override permission(collision: number) { + return collision === 7 ? 15 : 0; + } + override grassCollisions() { + return []; + } + override sprite() { + return { graphics: new Uint8Array(0), palette: 0, walking: false }; + } + tile(x: number, y: number, collision: number, map = "24:7") { + this.tiles.set(`${map}:${x},${y}`, collision); + } +} + +function fixture() { + const assets = new MovementAssets(); + const world = new GameWorld( + assets, + () => new Date("2026-09-22T12:00:00"), + () => 0, + ); + return { assets, world }; +} + +function connectWarp(assets: MovementAssets, x = 3, y = 4) { + assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 }); + assets + .map("24:6") + .warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 }); + assets.tile(2, 2, 0x70, "24:6"); +} + +describe("native ledge movement", () => { + it.each<[number, Direction]>([ + [0xa0, "right"], + [0xa1, "left"], + [0xa2, "up"], + [0xa3, "down"], + [0xa4, "right"], + [0xa4, "down"], + [0xa5, "down"], + [0xa5, "left"], + [0xa6, "up"], + [0xa6, "right"], + [0xa7, "up"], + [0xa7, "left"], + ])( + "jumps from ledge %i toward %s over two tiles in sixteen ticks", + (tile, direction) => { + const { assets, world } = fixture(); + const [dx, dy] = VECTORS[direction]; + assets.tile(3, 3, tile); + assets.tile(3 + dx, 3 + dy, 7); + world.tick(direction); + expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]); + expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ + x: 48 + dx * 2, + y: 48 + dy * 2, + lift: -6, + shadow: dx ? 12 : 14, + }); + expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 }); + for (let tick = 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick(); + expect(world.busy).toBe(true); + world.tick(); + expect(world.busy).toBe(false); + expect(world.tilesWalked).toBe(1); + expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined(); + }, + ); + + it("walks onto ledges and prefers a normal step when the next tile is clear", () => { + const { assets, world } = fixture(); + assets.tile(3, 4, 0xa3); + expect(world.move("down")).toBe(true); + world.tick("down"); + expect([world.x, world.y]).toEqual([3, 5]); + expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined(); + settle(world); + expect(world.tilesWalked).toBe(2); + }); + + it("refuses the wrong direction, blocked or unsupported landings and map-edge jumps", () => { + const { assets, world } = fixture(); + assets.tile(3, 3, 0xa3); + assets.tile(3, 2, 7); + expect(world.move("up")).toBe(false); + assets.tile(3, 4, 7); + for (const landing of [7, 0x30, 0x40, 0x60]) { + assets.tile(3, 5, landing); + expect(world.move("down")).toBe(false); + expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]); + } + assets.tile(3, 6, 0xa3); + assets.tile(3, 7, 7); + world.restore({ ...world.save(), y: 6 }); + expect(world.move("down")).toBe(false); + }); + + it("does not land on an occupied tile", () => { + const { assets, world } = fixture(); + assets.tile(3, 3, 0xa3); + assets.tile(3, 4, 7); + assets.map("24:7").objects.push({ + id: 2, + x: 3, + y: 5, + sprite: 1, + movement: 6, + palette: 0, + kind: 0, + script: 0, + eventFlag: 0xffff, + hour1: 0xff, + hour2: 0xff, + }); + world.restore(world.save()); + expect(world.move("down")).toBe(false); + expect(world.y).toBe(3); + }); + + it("replays an interrupted jump and its landing scene without skipping event writes", () => { + const { assets, world } = fixture(); + assets.tile(3, 3, 0xa3); + assets.tile(3, 4, 7); + // pause 4; setevent 8; end + assets.bytes.set([0x8b, 4, 0x33, 8, 0, 0x91], 0x100); + assets + .map("24:7") + .coordinateEvents.push({ x: 3, y: 5, scene: 0xff, script: 0x100 }); + world.tick("down"); + const beforeLanding = world.save(); + const restored = new GameWorld(assets); + restored.restore(beforeLanding); + expect(restored.move("down")).toBe(true); + expect(restored.activity).toBe("scene"); + expect(restored.save()).toEqual(beforeLanding); + expect(restored.state.events.has(8)).toBe(false); + settle(restored); + expect(restored.state.events.has(8)).toBe(true); + expect(restored.save()).toMatchObject({ x: 3, y: 5, tilesWalked: 1 }); + }); +}); + +describe("native warp movement", () => { + it.each<[number, Direction]>([ + [0x70, "down"], + [0x76, "left"], + [0x78, "up"], + [0x7e, "right"], + ])("carpet %i waits for a blocked step toward %s", (tile, direction) => { + const { assets, world } = fixture(); + connectWarp(assets); + assets.tile(3, 4, tile); + expect(world.move("down")).toBe(true); + expect(world.mapId).toBe("24:7"); + settle(world); + expect(world.mapId).toBe("24:7"); + const [dx, dy] = VECTORS[direction]; + assets.tile(3 + dx, 4 + dy, 7); + expect(world.move(direction)).toBe(true); + expect(world.mapId).toBe("24:6"); + expect([world.x, world.y, world.direction, world.tilesWalked]).toEqual([ + 2, + 2, + direction, + 1, + ]); + settle(world); + // Arrival on a mat neither teleports inward nor warps back. + expect([world.x, world.y]).toEqual([2, 2]); + }); + + it("lets the player walk away from a mat and refuses the wrong exit direction", () => { + const { assets, world } = fixture(); + connectWarp(assets); + assets.tile(3, 4, 0x70); + world.move("down"); + assets.tile(2, 4, 7); + expect(world.move("left")).toBe(false); + expect(world.move("up")).toBe(true); + expect(world.mapId).toBe("24:7"); + expect(world.y).toBe(3); + }); + + it("uses carpet warps on the boundary without moving outside the map", () => { + const { assets, world } = fixture(); + connectWarp(assets, 3, 7); + assets.tile(3, 7, 0x70); + world.restore({ ...world.save(), y: 7 }); + world.tick("down"); + expect(world.mapId).toBe("24:6"); + expect(world.tilesWalked).toBe(0); + expect(() => world.restore(world.save())).not.toThrow(); + }); + + it.each([0x71, 0x79, 0x7a, 0x7b])( + "walks down off arrival tile %i over eight ticks without input", + (tile) => { + const { assets, world } = fixture(); + connectWarp(assets); + assets.tile(3, 4, 0x72); + assets.tile(2, 2, tile, "24:6"); + world.move("down"); + expect([world.mapId, world.x, world.y, world.direction]).toEqual([ + "24:6", + 2, + 2, + "down", + ]); + world.tick("up"); // Forced exit overrides held input. + expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34); + const save = world.save(); + expect(save).toMatchObject({ x: 2, y: 2, tilesWalked: 1 }); + world.restore(save); + settle(world); + expect(world.save()).toMatchObject({ x: 2, y: 3, tilesWalked: 2 }); + }, + ); + + it("saves before a doorway until the walking animation and warp complete", () => { + const { assets, world } = fixture(); + connectWarp(assets); + assets.tile(3, 4, 0x71); + world.tick("down"); + const save = world.save(); + expect(save).toMatchObject({ map: "24:7", x: 3, y: 3, tilesWalked: 0 }); + world.restore(save); + expect(world.move("down")).toBe(true); + expect(world.save()).toMatchObject({ + map: "24:6", + x: 2, + y: 2, + tilesWalked: 1, + }); + }); + + it("saves on the source map during a connection crossing", () => { + const { assets, world } = fixture(); + assets + .map("24:7") + .connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 }); + world.restore({ ...world.save(), x: 7 }); + world.tick("right"); + const save = world.save(); + expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 }); + expect(() => world.restore(save)).not.toThrow(); + expect(world.move("right")).toBe(true); + expect(world.save()).toMatchObject({ + map: "24:6", + x: 0, + y: 3, + tilesWalked: 1, + }); + }); + + it("restores early version-3 backups without money but rejects corrupt balances", () => { + const { world } = fixture(); + const { money, ...earlySave } = world.save(); + world.state.money = 42; + world.restore(earlySave); + expect(world.state.money).toBe(money); + for (const invalid of [null, -1, 1.5, 1_000_000, "3000"]) { + expect(() => world.restore({ ...earlySave, money: invalid })).toThrow( + "Invalid native save", + ); + expect(world.state.money).toBe(money); + } + }); + + it("refuses unavailable destinations and bad callbacks before entering a doorway", () => { + const { assets, world } = fixture(); + connectWarp(assets); + assets.tile(3, 4, 0x71); + const warp = world.map.warps[0]; + for (const targetWarp of [0, 255, 2]) { + warp.targetWarp = targetWarp; + expect(world.move("down")).toBe(false); + expect(world.save()).toMatchObject({ + map: "24:7", + x: 3, + y: 3, + tilesWalked: 0, + }); + } + warp.targetWarp = 1; + warp.targetMap = "1:1"; + expect(world.move("down")).toBe(false); + warp.targetMap = "24:6"; + assets.bytes[0x100] = 0xff; + assets.map("24:6").callbacks.push({ kind: 5, script: 0x100 }); + expect(world.move("down")).toBe(false); + expect(world.notice).toContain("progress is unchanged"); + expect(world.save()).toMatchObject({ + map: "24:7", + x: 3, + y: 3, + tilesWalked: 0, + }); + // A directional carpet can be entered, but its failed exit is also atomic. + assets.tile(3, 4, 0x70); + assets.tile(3, 5, 7); + expect(world.move("down")).toBe(true); + expect(world.move("down")).toBe(false); + expect(world.save()).toMatchObject({ + map: "24:7", + x: 3, + y: 4, + tilesWalked: 1, + }); + }); +}); diff --git a/tests/native-browser.spec.ts b/tests/native-browser.spec.ts index 03ed4bb..5c558ea 100644 --- a/tests/native-browser.spec.ts +++ b/tests/native-browser.spec.ts @@ -1,12 +1,367 @@ import { test, expect } from "@playwright/test"; -import { existsSync } from "node:fs"; +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 { GameWorld } from "../src/game/world/world"; +import type { Direction } from "../src/game/world/types"; +import { VECTORS } from "../src/game/world/types"; +import { settle } from "./world-driver"; const romPath = resolve( process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", ); +test("local maps follow a stairs transition and importing a previous position", async ({ + page, +}) => { + test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); + // Find a walkable route in the local fixture, then drive the actual UI. + const world = new GameWorld( + new CrystalAssets(new Uint8Array(readFileSync(romPath))), + ); + const target = world.map.warps[0]; + const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }]; + const seen = new Set(); + let path: Direction[] | undefined; + const directions = [ + ["up", 0, -1], + ["down", 0, 1], + ["left", -1, 0], + ["right", 1, 0], + ] as const; + for (let i = 0; i < queue.length; i++) { + const node = queue[i]; + const key = `${node.x},${node.y}`; + if (seen.has(key)) continue; + seen.add(key); + if (node.x === target.x && node.y === target.y) { + path = node.path; + break; + } + for (const [direction, dx, dy] of directions) { + const x = node.x + dx, + y = node.y + dy; + if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) + continue; + if ( + world.assets.permission(world.assets.collision(world.map, x, y)) !== 0 + ) + continue; + if (world.objects().some((object) => object.x === x && object.y === y)) + continue; + queue.push({ x, y, path: [...node.path, direction] }); + } + } + expect(path).toBeDefined(); + await page.goto("/"); + await page.locator("#rom-file").setInputFiles(romPath); + await expect(page.locator("#status")).toContainText("verified"); + await page.locator("#tab-saves").click(); + const downloaded = page.waitForEvent("download"); + await page.locator("#backup").click(); + const backup = await downloaded; + await page.locator("#play").click(); + for (const direction of path!) { + const [dx, dy] = VECTORS[direction]; + const stepX = world.x + dx, + stepY = world.y + dy; + expect(world.move(direction)).toBe(true); + settle(world); + await page.keyboard.down( + `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`, + ); + // Release as the step starts, before a warp and its forced exit complete. + await expect(page.locator("#game-screen")).toHaveAttribute( + "data-x", + String(stepX), + ); + await expect(page.locator("#game-screen")).toHaveAttribute( + "data-y", + String(stepY), + ); + await page.keyboard.up( + `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`, + ); + await expect(page.locator("#game-screen")).toHaveAttribute( + "data-map", + world.mapId, + ); + await expect(page.locator("#game-screen")).toHaveAttribute( + "data-x", + String(world.x), + ); + await expect(page.locator("#game-screen")).toHaveAttribute( + "data-y", + String(world.y), + ); + await expect(page.locator("#game-screen")).toHaveAttribute( + "data-activity", + "idle", + ); + } + await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:6"); + await page.locator("#tab-map").click(); + await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:6"); + await expect(page.locator("#map-location")).toHaveText( + `${world.map.name} · ${world.map.location}`, + ); + await page.locator("#tab-saves").click(); + page.once("dialog", (dialog) => dialog.accept()); + await page.locator("#save-file").setInputFiles((await backup.path())!); + await expect(page.locator("#status")).toContainText("Backup restored"); + await page.locator("#tab-map").click(); + await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:7"); + await expect(page.locator("#world-map")).toHaveAttribute("data-x", "3"); + await expect(page.locator("#world-map")).toHaveAttribute("data-y", "3"); +}); + +test("Mom's opening scene runs with native keyboard and pointer choices", async ({ + page, +}) => { + test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + await page.locator("#rom-file").setInputFiles(romPath); + await expect(page.locator("#status")).toContainText("verified"); + await page.locator("#play").click(); + const screen = page.locator("#game-screen"); + const step = async (key: string, map: string, x: number, y: number) => { + await page.keyboard.down(key); + await expect(screen).toHaveAttribute("data-map", map); + await expect(screen).toHaveAttribute("data-x", String(x)); + await expect(screen).toHaveAttribute("data-y", String(y)); + await page.keyboard.up(key); + }; + // Bedroom (3,3) to the stairs, then down toward Mom. + await step("ArrowUp", "24:7", 3, 2); + for (const x of [4, 5, 6, 7]) await step("ArrowRight", "24:7", x, 2); + await step("ArrowUp", "24:7", 7, 1); + await step("ArrowUp", "24:7", 7, 0); + await expect(screen).toHaveAttribute("data-map", "24:6"); + await expect(screen).toHaveAttribute("data-y", "1"); + await expect(screen).toHaveAttribute("data-activity", "idle"); + for (const y of [2, 3, 4]) await step("ArrowDown", "24:6", 9, y); + const dialogue = page.locator("#native-dialogue"); + await expect(dialogue).toBeVisible(); + const seen: string[] = []; + for (let guard = 0; guard < 60 && (await dialogue.isVisible()); guard++) { + const text = (await page.locator("#dialogue-text").textContent()) ?? ""; + seen.push(text); + const choice = page.locator("#dialogue-choice"); + if (await choice.isVisible()) { + if (await choice.locator("output").count()) { + // Weekday box: Up selects the next day, X confirms. + await expect(choice.locator("output")).toHaveText("SUNDAY"); + await page.keyboard.press("ArrowUp"); + await expect(choice.locator("output")).toHaveText("MONDAY"); + await page.keyboard.press("KeyX"); + } else { + await choice.getByRole("button", { name: "YES" }).click(); + } + } else await page.keyboard.press("KeyX"); + await expect + .poll(async () => + (await dialogue.isVisible()) + ? await page.locator("#dialogue-text").textContent() + : "closed", + ) + .not.toBe(text); + } + expect(seen).toContain("MONDAY, is it?"); + expect(seen.some((text) => text.includes("received"))).toBe(true); + await expect(dialogue).toBeHidden(); + // Mom walks back before the scene commits. + await expect(screen).toHaveAttribute("data-activity", "idle"); + await expect(page.locator("#game-notice")).toHaveText(""); + // The completed scene persists in the exported native save. + await page.locator("#tab-saves").click(); + const download = page.waitForEvent("download"); + await page.locator("#backup").click(); + const record = JSON.parse( + readFileSync((await (await download).path())!, "utf8"), + ); + const save = JSON.parse(Buffer.from(record.state, "base64").toString("utf8")); + expect(save).toMatchObject({ + version: 3, + map: "24:6", + scenes: { "24:6": 1 }, + phone: [1], + }); + expect(save.clock.dst).toBe(true); + expect(errors).toEqual([]); +}); + +test("choosing a starter shows its picture, asks for a nickname and saves the party", async ({ + page, +}, testInfo) => { + test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + // Start in Elm's lab after his introduction, facing Cyndaquil's ball. + const bytes = readFileSync(romPath); + const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes))); + const save = { + ...world.save(), + map: "24:5", + x: 6, + y: 4, + direction: "up", + scenes: { "24:6": 1, "24:5": 1 }, + }; + const backup = testInfo.outputPath("lab.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(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(); + await page.keyboard.press("KeyX"); + const picture = page.locator("#pokepic"); + await expect(picture).toBeVisible(); + await expect(page.locator("#pokepic-canvas")).toHaveAttribute( + "aria-label", + "CYNDAQUIL", + ); + // The picture is drawn from ROM tiles, not left blank. + expect( + await page.locator("#pokepic-canvas").evaluate((element) => { + const canvas = element as HTMLCanvasElement; + const pixels = canvas.getContext("2d")!.getImageData(0, 0, 56, 56).data; + const colors = new Set(); + for (let i = 0; i < pixels.length; i += 4) + colors.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]); + return colors.size; + }), + ).toBeGreaterThanOrEqual(3); + await page.locator("#pokepic-next").click(); + await expect(picture).toBeHidden(); + await expect(page.locator("#dialogue-text")).toContainText("CYNDAQUIL"); + await page.getByRole("button", { name: "YES" }).click(); + const nickname = page.locator("#dialogue-choice input"); + for (let guard = 0; guard < 12 && !(await nickname.count()); guard++) { + const choice = page.getByRole("button", { name: "YES" }); + if (await choice.count()) await choice.click(); + else await page.keyboard.press("KeyX"); + await page.waitForTimeout(100); + } + await expect(page.locator("#dialogue-text")).toHaveText( + "CYNDAQUIL'S\nNICKNAME?", + ); + await expect(nickname).toBeFocused(); + await nickname.fill("BLAZE"); + await nickname.press("Enter"); + const screen = page.locator("#game-screen"); + for (let guard = 0; guard < 30; guard++) { + if ((await screen.getAttribute("data-activity")) === "idle") break; + if (await page.locator("#native-dialogue").isVisible()) + await page.keyboard.press("KeyX"); + await page.waitForTimeout(120); + } + await expect(screen).toHaveAttribute("data-activity", "idle"); + await page.locator("#tab-saves").click(); + const download = page.waitForEvent("download"); + await page.locator("#backup").click(); + const record = JSON.parse( + readFileSync((await (await download).path())!, "utf8"), + ); + const saved = JSON.parse( + Buffer.from(record.state, "base64").toString("utf8"), + ); + expect(saved.party).toHaveLength(1); + expect(saved.party[0]).toMatchObject({ + species: 155, + nickname: "BLAZE", + level: 5, + }); + expect(saved.scenes["24:5"]).toBe(5); + expect(errors).toEqual([]); +}); + +test("a home exit mat survives save/reload and waits for the exit direction", async ({ + page, +}, testInfo) => { + test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); + const bytes = readFileSync(romPath); + const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes))); + world.restore({ + ...world.save(), + map: "24:6", + x: 6, + y: 6, + scenes: { "24:6": 1 }, + }); + const backup = testInfo.outputPath("home.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("ArrowDown"); + await expect(screen).toHaveAttribute("data-y", "7"); + await page.keyboard.up("ArrowDown"); + await expect(screen).toHaveAttribute("data-activity", "idle"); + await expect(screen).toHaveAttribute("data-map", "24:6"); + await page.locator("#tab-saves").click(); + await page.locator("#save").click(); + await expect(page.locator("#save-status")).toContainText("Session saved"); + await page.reload(); + await page.locator("#rom-file").setInputFiles(romPath); + await expect(page.locator("#status")).toContainText("saved session"); + await expect(screen).toHaveAttribute("data-map", "24:6"); + await expect(screen).toHaveAttribute("data-y", "7"); + await page.locator("#play").click(); + // Walking back off the carpet is allowed and does not leave the house. + await page.keyboard.down("ArrowUp"); + await expect(screen).toHaveAttribute("data-y", "6"); + await page.keyboard.up("ArrowUp"); + await expect(screen).toHaveAttribute("data-activity", "idle"); + await expect(screen).toHaveAttribute("data-map", "24:6"); + await page.keyboard.down("ArrowDown"); + await expect(screen).toHaveAttribute("data-y", "7"); + await page.keyboard.up("ArrowDown"); + await expect(screen).toHaveAttribute("data-activity", "idle"); + await page.keyboard.down("ArrowDown"); + await page.waitForFunction( + () => + document.querySelector("#game-screen")?.getAttribute("data-map") === + "24:4", + ); + await page.keyboard.up("ArrowDown"); + await expect(screen).toHaveAttribute("data-x", "13"); + await expect(screen).toHaveAttribute("data-y", "6"); + await expect(screen).toHaveAttribute("data-activity", "idle"); + await expect(page.locator("#game-notice")).toHaveText(""); +}); + test("development server does not serve ROMs or the private reference checkout", async ({ request, }) => { @@ -40,19 +395,30 @@ test("native player loads the actual ROM without loading an emulator and persist "data-map", "24:7", ); + await expect(page.locator("#minimap")).toBeVisible(); + await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:7"); await page.locator("#play").click(); await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3"); await page.keyboard.down("ArrowUp"); await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2"); await page.keyboard.up("ArrowUp"); - await page.waitForTimeout(150); + await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2"); + // A is ignored mid-step, as in the original; wait for the step to end. + await expect(page.locator("#game-screen")).toHaveAttribute( + "data-activity", + "idle", + ); await page.keyboard.press("KeyX"); + // The radio's lines hold for `pause 45` each and advance by themselves. await expect(page.locator("#native-dialogue")).toBeVisible(); - await expect(page.locator("#dialogue-page")).toHaveText("1 / 4"); - for (let i = 0; i < 4; i++) await page.locator("#dialogue-next").click(); - await expect(page.locator("#native-dialogue")).toBeHidden(); + await expect(page.locator("#dialogue-text")).toContainText("TALK!"); + await expect(page.locator("#dialogue-next")).toBeHidden(); + await expect(page.locator("#native-dialogue")).toBeHidden({ + timeout: 10_000, + }); await page.locator("#play").click(); await expect(page.locator("#save-status")).toContainText("Session saved"); + await page.locator("#tab-saves").click(); const exported = page.waitForEvent("download"); await page.locator("#backup").click(); const backup = await exported; @@ -63,15 +429,30 @@ test("native player loads the actual ROM without loading an emulator and persist await page.locator("#rom-file").setInputFiles(romPath); await expect(page.locator("#status")).toContainText("saved session"); await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2"); + await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2"); await page.locator("#play").click(); await page.keyboard.press("KeyX"); - await expect(page.locator("#dialogue-page")).toHaveText("1 / 1"); - await page.locator("#dialogue-next").click(); + await expect(page.locator("#dialogue-text")).toContainText("POKéMON"); + await expect(page.locator("#native-dialogue")).toBeHidden({ timeout: 5_000 }); + // Opening any menu pauses and releases game input, even while a key is held. + await page.keyboard.down("ArrowUp"); + await page.locator("#tab-map").click(); + await expect(page.locator("#power-label")).toHaveText("PAUSED"); + await expect(page.locator("#world-map")).toHaveAttribute("data-y", "2"); + await expect(page.locator("#map-empty")).toBeHidden(); + await expect(page.locator('[data-game-button="up"]')).toHaveAttribute( + "aria-pressed", + "false", + ); + await page.keyboard.up("ArrowUp"); + await page.locator("#tab-game").click(); + await expect(page.locator("#pause-overlay")).toBeVisible(); for (const [width, height] of [ [1440, 1000], [1024, 900], [390, 844], [320, 740], + [320, 568], [844, 390], ]) { await page.setViewportSize({ width, height }); @@ -84,6 +465,27 @@ test("native player loads the actual ROM without loading an emulator and persist if (width <= 390 || height <= 390) { const box = (await page.locator(".console").boundingBox())!; expect(box.y + box.height).toBeLessThanOrEqual(height); + const toolbar = (await page.locator(".global-bar").boundingBox())!; + expect(toolbar.y + toolbar.height).toBeLessThanOrEqual(height); + } + // Responsive native rendering draws a different area, not a stretched GB frame. + await expect + .poll(async () => + page.locator("#game-screen").evaluate((el) => { + const canvas = el as HTMLCanvasElement; + const bounds = canvas.getBoundingClientRect(); + return Math.abs( + canvas.width / canvas.height - bounds.width / bounds.height, + ); + }), + ) + .toBeLessThan(0.03); + if (width >= 1024) { + expect( + await page + .locator("#game-screen") + .evaluate((el) => (el as HTMLCanvasElement).width), + ).toBeGreaterThan(160); } } await page.setViewportSize({ width: 390, height: 844 }); diff --git a/tests/native.test.ts b/tests/native.test.ts index dbbaa3e..f50157c 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -6,9 +6,17 @@ 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 { runScript } from "../src/game/scripts/interpreter"; +import { + ScriptRun, + emptyScriptState, + runScript, +} from "../src/game/scripts/interpreter"; +import { readMovement } from "../src/game/scripts/movement"; +import { defaultTextContext, readText } from "../src/game/scripts/text"; +import { settle, walkTo } from "./world-driver"; import { drawTile } from "../src/game/rendering/pixels"; import { CRYSTAL_ROM } from "../src/rom/validate"; +import { VECTORS, type Direction } from "../src/game/world/types"; describe("native ROM formats", () => { it("resolves fixed and banked pointers and rejects out-of-bounds reads", () => { @@ -57,24 +65,73 @@ describe("native ROM formats", () => { }); it("aborts unknown script commands without committing earlier event writes", () => { const reader = new RomReader(Uint8Array.from([0x33, 5, 0, 0x0e, 0, 0, 0])); - const state = { - events: new Set(), - flags: new Set(), - variables: {}, - }; + const state = emptyScriptState(); expect(() => runScript(reader, 0, state, "TEST")).toThrow("not ported"); expect(state.events.size).toBe(0); }); it("bounds looping scripts", () => { const reader = new RomReader(Uint8Array.from([0x03, 0, 0])); + expect(() => runScript(reader, 0, emptyScriptState(), "TEST")).toThrow( + "budget", + ); + }); + it("resumes scripts after choices and writes only to a private draft", () => { + const bytes = new Uint8Array(0x100); + // writetext $40; yesorno; iftrue $10; end; $10: setevent 7; end + bytes.set([0x4c, 0x40, 0x00, 0x4e, 0x09, 0x10, 0x00, 0x91], 0); + bytes.set([0x33, 7, 0, 0x91], 0x10); + // "HI" then the player's name through text_ram, then done. + bytes.set([0x00, 0x87, 0x88, 0x7f, 0x52, 0x57], 0x40); + const state = emptyScriptState(); + const host = { + mapId: "1:1", + lastTalked: 0, + sceneOwner: () => undefined, + timeMask: () => 2, + clock: () => ({ weekday: 0, hours: 12, minutes: 0 }), + deviceWeekday: () => 0, + text: () => defaultTextContext("KAWA"), + }; + const yes = new ScriptRun(new RomReader(bytes), 0, state, host); + expect(yes.resume()).toEqual({ kind: "yesno", pages: ["HI KAWA"] }); + expect(yes.resume(1)).toBeUndefined(); + expect(yes.state.events.has(7)).toBe(true); + expect(state.events.size).toBe(0); + const no = new ScriptRun(new RomReader(bytes), 0, state, host); + no.resume(); + expect(no.resume(0)).toBeUndefined(); + expect(no.state.events.size).toBe(0); + }); + it("decodes movement data and refuses unported movement commands", () => { + const reader = new RomReader( + Uint8Array.from([ + 0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x4c, 0x47, + ]), + ); + expect(readMovement(reader, 0)).toEqual([ + { kind: "turn", direction: "left", ticks: 1 }, + { kind: "step", direction: "right", ticks: 8, animate: true }, + { kind: "step", direction: "down", ticks: 16, animate: true }, + { kind: "jump", direction: "up", ticks: 8 }, + { kind: "fix-facing", fixed: true }, + { kind: "sleep", ticks: 3 }, + { kind: "sleep", ticks: 12 }, + ]); + expect(() => readMovement(reader, 9)).toThrow("not ported"); + }); + it("reads RAM-backed names and string buffers only from native values", () => { + // text_ram wStringBuffer4, " ", text_buffer 0, done + const bytes = Uint8Array.from([ + 0x01, 0xac, 0xd0, 0x00, 0x7f, 0x50, 0x14, 0x00, 0x50, 0x50, + ]); + const context = { + ...defaultTextContext("KAWA"), + strings: { 0xd0ac: "POKéGEAR", 0xd099: "ELM" }, + }; + expect(readText(new RomReader(bytes), 0, context)).toBe("POKéGEAR ELM"); expect(() => - runScript( - reader, - 0, - { events: new Set(), flags: new Set(), variables: {} }, - "TEST", - ), - ).toThrow("budget"); + readText(new RomReader(bytes), 0, defaultTextContext("KAWA")), + ).toThrow("no native value"); }); }); @@ -129,84 +186,438 @@ describe.skipIf(!existsSync(romPath))( ); }, ); - it("walks from the bedroom to downstairs and the town, with collision and return warps", () => { + it("walks with the original timing: eight overworld ticks per tile", () => { const world = new GameWorld( load(), () => new Date("2026-09-22T12:00:00"), ); - expect(world.x).toBe(3); - expect(world.y).toBe(3); - expect(world.move("up")).toBe(true); - const steps = world.tilesWalked; - expect(world.move("up")).toBe(false); // Radio/furniture is solid. - expect([world.x, world.y, world.tilesWalked]).toEqual([3, 2, steps]); - expect(world.move("down")).toBe(true); - const routeTo = (targetX: number, targetY: number) => { - const directions = [ - ["up", 0, -1], - ["down", 0, 1], - ["left", -1, 0], - ["right", 1, 0], - ] as const; - const queue = [ - { - x: world.x, - y: world.y, - path: [] as ("up" | "down" | "left" | "right")[], - }, - ]; - const visited = new Set(); - for (let i = 0; i < queue.length; i++) { - const node = queue[i], - key = `${node.x},${node.y}`; - if (visited.has(key)) continue; - visited.add(key); - if (node.x === targetX && node.y === targetY) { - for (const direction of node.path) - expect(world.move(direction)).toBe(true); - return; - } - for (const [direction, dx, dy] of directions) { - const x = node.x + dx, - y = node.y + dy; - if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) - continue; - if ( - world.assets.permission( - world.assets.collision(world.map, x, y), - ) !== 0 - ) - continue; - if ( - world.objects().some((object) => object.x === x && object.y === y) - ) - continue; - if ( - world.map.warps.some((warp) => warp.x === x && warp.y === y) && - (x !== targetX || y !== targetY) - ) - continue; - queue.push({ x, y, path: [...node.path, direction] }); - } - } - throw new Error(`No route to ${targetX},${targetY}`); - }; - routeTo(7, 0); - expect(world.mapId).toBe("24:6"); - routeTo(6, 7); - expect(world.mapId).toBe("24:4"); - expect(world.tilesWalked).toBeGreaterThan(10); - const arrival = world.map.warps.find( - (warp) => warp.targetMap === "24:6", - )!; - routeTo(arrival.x, arrival.y); + world.restore({ ...world.save(), x: 3, y: 2, direction: "down" }); + const ticks: number[] = []; + for (let tick = 1; tick <= 40 && world.x < 7; tick++) { + const x = world.x; + world.tick("right"); + if (world.x !== x) ticks.push(tick); + } + // Steps start on ticks 1, 9, 17... and continue without a gap. + expect(ticks).toEqual([1, 9, 17, 25]); + // Two pixels per tick: the step started on tick 25, now two ticks in. + world.tick(); + expect(world.poses().find((pose) => pose.id === 0)?.x).toBe(6 * 16 + 4); + }); + it("decodes ROM location names and draws connected maps beyond the edges", () => { + const assets = load(); + const town = assets.map("24:4"); + expect(town.location).toBe("New Bark Town"); + expect(assets.map("24:5").name).toBe("Elm’s lab"); + expect(assets.map("24:5").location).toBe("New Bark Town"); + 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); + for (let y = 0; y < town.height; y++) + expect(assets.visibleBlock(town, -1, y)).toBe( + assets.block(route, west.x, y + west.y), + ); + expect(assets.visibleBlock(town, -1, -1)).toBe(town.border); + expect(() => assets.map("24:99")).toThrow("Unknown map"); + expect(() => assets.map("27:1")).toThrow("Unknown map"); + }); + it("plays Mom's opening scene with native choices, then walks outside", () => { + const world = new GameWorld( + load(), + () => new Date("2026-09-22T12:00:00"), + ); + // The bedroom's NEWMAP callback ran the original initialization script. + expect(world.state.events.size).toBeGreaterThan(100); + walkTo(world, 7, 0); expect(world.mapId).toBe("24:6"); + expect([world.x, world.y]).toEqual([9, 1]); + const flags = world.state.flags.size; + // Monday, confirm, DST yes, confirm time, "know how to use the phone". + const pages = walkTo(world, 9, 4, [1, 1, 1, 1, 1]); + expect(pages[0]).toBe("Oh, CHRIS…! Our\nneighbor, PROF."); + expect(pages).toContain("CHRIS received\nPOKéGEAR."); + expect(pages).toContain("What day is it?"); + expect(pages).toContain("MONDAY, is it?"); + expect(pages.some((page) => /^12:00 PM DST,/.test(page))).toBe(true); + expect(pages).toContain("Don't you just\nturn the POKéGEAR"); const save = world.save(); - expect(() => world.restore({ ...save, x: 127 })).toThrow("position"); - expect(world.save()).toEqual(save); - const restored = new GameWorld(world.assets); - restored.restore(save); - expect(restored.save()).toEqual(save); + expect(save.scenes).toEqual({ "24:6": 1 }); + expect(save.phone).toEqual([1]); + // 2026-09-22 is a Tuesday; the chosen Monday is six days ahead. + expect(save.clock).toEqual({ weekdayOffset: 6, dst: true }); + expect(world.clock().weekday).toBe(1); + expect(world.state.flags.size).toBe(flags + 2); + expect(world.state.events.has(1735)).toBe(true); + // Stepping on the tile again does not replay the scene. + expect(world.move("up")).toBe(true); + expect(settle(world)).toEqual([]); + // Objects spawn when the map loads: the scene's Mom stays until the + // player leaves, then the daytime Mom replaces her. + expect(world.objects().find((object) => object.id === 2)).toMatchObject({ + x: 7, + y: 4, + direction: "left", + }); + walkTo(world, 6, 7); + expect(world.mapId).toBe("24:6"); + expect(world.move("down")).toBe(true); + settle(world); + expect(world.mapId).toBe("24:4"); + const home = world.map.warps.find((warp) => warp.targetMap === "24:6")!; + walkTo(world, home.x, home.y); + expect(world.objects().map((object) => object.id)).toEqual([4, 6]); + walkTo(world, 6, 7); + const restored = new GameWorld( + world.assets, + () => new Date("2026-09-22T12:00:00"), + ); + restored.restore(world.save()); + expect(restored.save()).toEqual(world.save()); + expect(() => world.restore({ ...world.save(), x: 127 })).toThrow( + "position", + ); + expect(() => + world.restore({ ...world.save(), scenes: { "24:4": 3 } }), + ).not.toThrow(); + expect(() => + world.restore({ ...world.save(), scenes: { "1:1": 3 } }), + ).toThrow("Invalid"); + }); + it("saves the committed state during a scene and replays it after restore", () => { + const world = new GameWorld( + load(), + () => new Date("2026-09-22T12:00:00"), + ); + walkTo(world, 7, 0); + walkTo(world, 9, 3); + const before = world.save(); + expect(world.move("down")).toBe(true); + expect(world.busy).toBe(true); + // A mid-scene save keeps the position before the triggering step. + expect(world.save()).toEqual(before); + const restored = new GameWorld( + world.assets, + () => new Date("2026-09-22T12:00:00"), + ); + restored.restore(world.save()); + expect(restored.move("down")).toBe(true); + expect(settle(restored, [1, 1, 1, 1, 1]).length).toBeGreaterThan(10); + expect(restored.save().scenes).toEqual({ "24:6": 1 }); + }); + it("runs New Bark Town's teacher and rival scenes with scripted movement", () => { + const world = new GameWorld( + load(), + () => new Date("2026-09-22T12:00:00"), + ); + world.restore({ ...world.save(), map: "24:4", x: 5, y: 8 }); + walkTo(world, 2, 8); + const events = world.save().events; + expect(world.move("left")).toBe(true); + const pages = settle(world); + expect(pages[0]).toBe("Wait, CHRIS!"); + expect(pages).toContain("It's dangerous to\ngo out without a\nPOKéMON!"); + // The teacher runs over and leads the player back east. + expect([world.x, world.y, world.direction]).toEqual([5, 8, "right"]); + const teacher = world.objects().find((object) => object.id === 2)!; + expect([teacher.x, teacher.y, teacher.direction]).toEqual([6, 8, "left"]); + expect(world.save().events).toEqual(events); + walkTo(world, 2, 2); + expect(world.move("right")).toBe(false); + world.interact(); + const rival = settle(world); + expect(rival).toContain("…What are you\nstaring at?"); + // Pushed one step and shoved two more with a jump. + expect([world.x, world.y, world.direction]).toEqual([2, 5, "up"]); + const silver = world.objects().find((object) => object.id === 4)!; + expect([silver.x, silver.y, silver.direction]).toEqual([3, 2, "right"]); + }); + it("runs Elm's introduction, the lab's exit guard and a declined Poké Ball", () => { + const world = new GameWorld( + load(), + () => new Date("2026-09-22T12:00:00"), + ); + world.restore({ + ...world.save(), + map: "24:4", + x: 6, + y: 4, + scenes: { "24:6": 1 }, + }); + const door = world.map.warps.find((warp) => warp.targetMap === "24:5")!; + walkTo(world, door.x, door.y + 1); + expect(world.move("up")).toBe(true); + expect(world.mapId).toBe("24:5"); + // The OBJECTS callback moved Elm for the introduction. + expect(world.objects().find((object) => object.id === 2)).toMatchObject({ + x: 3, + y: 4, + }); + const pages = settle(world, [0, 1]); + expect(pages[0]).toBe("ELM: CHRIS!\nThere you are!"); + expect(pages).toContain("But… Please, I\nneed your help!"); + expect(pages.at(-1)).toBe("Go on. Pick one!"); + expect(world.save().scenes).toMatchObject({ "24:5": 1 }); + // The warp places the player on the mat at y=11; the ROM walks up seven tiles. + expect([world.x, world.y, world.direction]).toEqual([4, 4, "right"]); + // Elm stops the player from leaving without a Pokémon. + walkTo(world, 4, 5); + expect(world.move("down")).toBe(true); + expect(settle(world).length).toBeGreaterThan(0); + expect([world.x, world.y]).toEqual([4, 5]); + walkTo(world, 6, 4); + world.move("up"); + const before = world.save(); + world.interact(); + // Declining a Poké Ball commits nothing. + expect(settle(world, [0])).toEqual([ + "", + "ELM: You'll take\nCYNDAQUIL, the\nfire POKéMON?", + "ELM: Think it over\ncarefully.", + "Your partner is\nimportant.", + ]); + expect(world.save()).toEqual(before); + }); + 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 = { + 0xa0: "right", + 0xa1: "left", + 0xa3: "down", + 0xa4: "down", + 0xa5: "down", + }; + for (let y = 1; y < route.height - 2; y++) { + for (let x = 2; x < route.width - 2; x++) { + const collision = assets.collision(route, x, y), + direction = directions[collision]; + if (!direction || checked.has(collision)) continue; + const [dx, dy] = VECTORS[direction]; + if ( + assets.permission(assets.collision(route, x + dx, y + dy)) === 0 || + assets.permission( + assets.collision(route, x + dx * 2, y + dy * 2), + ) !== 0 || + route.objects.some((o) => o.x === x + dx * 2 && o.y === y + dy * 2) + ) + continue; + world.restore({ + ...world.save(), + map: route.id, + x, + y, + direction, + objects: [], + }); + world.tick(direction); + expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]); + expect( + world.poses().find((pose) => pose.id === 0)?.lift, + ).toBeLessThan(0); + settle(world); + expect(world.notice).toBe(""); + checked.add(collision); + } + } + expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]); + }); + 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, + )!; + 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."); + const { + scenes, + phone, + clock, + objects, + playerId, + party, + bag, + pokedex, + ...legacy + } = world.save(); + void [scenes, phone, objects, playerId, party, bag, pokedex]; + world.restore({ ...legacy, version: 1 }); + expect(world.save()).toMatchObject({ + version: 3, + scenes: {}, + phone: [], + objects: [], + party: [], + pokedex: { seen: [], caught: [] }, + }); + world.restore({ + ...legacy, + version: 2, + scenes: { "24:6": 1 }, + phone: [1], + clock, + objects: [], + }); + expect(world.save()).toMatchObject({ + version: 3, + scenes: { "24:6": 1 }, + phone: [1], + }); + expect(world.save().playerId).toBeLessThan(0x10000); + expect(() => world.restore({ ...legacy, version: 4 })).toThrow("Invalid"); + }); + it("decodes species, moves, items and growth from the ROM", () => { + const data = load().pokemon; + expect([152, 155, 158].map((id) => data.speciesName(id))).toEqual([ + "CHIKORITA", + "CYNDAQUIL", + "TOTODILE", + ]); + expect(data.speciesName(122)).toBe("MR.MIME"); + expect(data.baseData(155)).toMatchObject({ + species: 155, + stats: [39, 52, 43, 65, 60, 50], + growthRate: 3, + picSize: 5, + }); + // Medium Slow: 135 at level 5, and the original level-1 underflow. + expect(data.expAtLevel(3, 5)).toBe(135); + expect(data.expAtLevel(3, 1)).toBe(16777162); + expect(data.expAtLevel(0, 100)).toBe(1_000_000); + expect(data.learnset(155).slice(0, 2)).toEqual([ + { level: 1, move: 33 }, + { level: 1, move: 43 }, + ]); + expect(data.moveName(33)).toBe("TACKLE"); + expect(data.move(33)).toMatchObject({ power: 35, accuracy: 242, pp: 35 }); + expect(data.itemName(18)).toBe("POTION"); + expect(data.itemPocket(18)).toBe("item"); + expect(data.pocketName("item")).toBe("ITEM POCKET"); + expect(data.frontPic(155)).toHaveLength(49 * 16); + expect(() => data.speciesName(252)).toThrow("Unknown"); + }); + it.skipIf( + !existsSync( + ".reference/pokecrystal/gfx/pokemon/cyndaquil/front.animated.2bpp", + ), + )( + "matches front pictures against the locally rebuilt PRET reference", + () => { + const data = load().pokemon; + for (const [species, name] of [ + [152, "chikorita"], + [155, "cyndaquil"], + [158, "totodile"], + ] as const) + expect(data.frontPicData(species)).toEqual( + new Uint8Array( + readFileSync( + `.reference/pokecrystal/gfx/pokemon/${name}/front.animated.2bpp`, + ), + ), + ); + }, + ); + it("gives the chosen starter and the aide's Potion, then saves them", () => { + let seed = 7; + const random = () => (seed = (seed * 73 + 41) & 255); + const world = new GameWorld( + load(), + () => new Date("2026-09-22T12:00:00"), + random, + ); + world.restore({ + ...world.save(), + map: "24:5", + x: 4, + y: 3, + scenes: { "24:6": 1, "24:5": 1 }, + }); + walkTo(world, 6, 4); + expect(world.move("up")).toBe(false); + world.interact(); + // pokepic shows the ROM's front picture and waits for a button. + expect(world.picture).toBe(155); + expect(world.dialogue?.pages).toEqual([""]); + world.advanceDialogue(); + expect(world.picture).toBeUndefined(); + expect(world.dialogue?.choice?.kind).toBe("yesno"); + // Take it, then give it a nickname through the naming screen. + const pages = settle(world, [1, 1]); + expect(pages).toContain("CHRIS received\nCYNDAQUIL!"); + expect(world.dialogue?.choice).toMatchObject({ + kind: "name", + current: "CYNDAQUIL", + }); + expect(world.dialogue?.pages).toEqual(["CYNDAQUIL'S\nNICKNAME?"]); + // Characters the naming screen cannot enter keep the prompt open. + world.choose("BAD~NAME"); + expect(world.dialogue?.choice?.kind).toBe("name"); + world.choose("BLAZE"); + expect(settle(world)).toContain("CHRIS got ELM's\nphone number."); + const save = world.save(); + expect(save.party).toHaveLength(1); + expect(save.party[0]).toMatchObject({ + species: 155, + nickname: "BLAZE", + ot: "CHRIS", + otId: world.playerId, + level: 5, + exp: 135, + item: 173, + moves: [33, 43, 0, 0], + pp: [35, 30, 0, 0], + happiness: 70, + // Day, level 5; male player in New Bark Town. + caughtData: 0x8501, + }); + expect(save.party[0].hp).toBe(save.party[0].stats[0]); + expect(save.pokedex).toEqual({ seen: [155], caught: [155] }); + expect(save.scenes).toEqual({ "24:6": 1, "24:5": 5, "24:4": 1 }); + expect(save.events).toContain(26); // EVENT_GOT_A_POKEMON_FROM_ELM + // The other balls now only describe themselves. + walkTo(world, 7, 4); + world.move("up"); + world.interact(); + expect(settle(world)).toHaveLength(1); + expect(world.save().party).toHaveLength(1); + walkTo(world, 4, 7); + expect(world.move("down")).toBe(true); + expect(settle(world)).toEqual([ + "CHRIS, I want\nyou to have this\nfor your errand.", + "CHRIS received\nPOTION.", + "CHRIS put the\nPOTION in\nthe ITEM POCKET.", + "There are only two\nof us, so we're\nalways busy.", + ]); + const final = world.save(); + expect(final.bag.items).toEqual([{ item: 18, quantity: 1 }]); + expect(final.scenes["24:5"]).toBe(2); + const restored = new GameWorld( + world.assets, + () => new Date("2026-09-22T12:00:00"), + ); + restored.restore(final); + expect(restored.save()).toEqual(final); + // Stored stats must match the ROM's formula for the stored DVs. + const tampered = structuredClone(final); + tampered.party[0].stats[1] += 1; + expect(() => restored.restore(tampered)).toThrow("Invalid"); + const potion = structuredClone(final); + potion.bag.keyItems = [18]; + expect(() => restored.restore(potion)).toThrow("Invalid"); + expect(restored.save()).toEqual(final); }); it("reads the bedroom radio through native event and text interpreters", () => { const world = new GameWorld(load()); @@ -214,12 +625,15 @@ describe.skipIf(!existsSync(romPath))( const previousEvents = world.state.events.size; world.interact(); expect(world.diagnostic).toBe(""); - expect(world.dialogue?.pages.length).toBe(4); + // Each radio line holds for 45 ticks (pause 45), ignoring buttons. + expect(world.dialogue?.holdTicks).toBe(45); + world.advanceDialogue(); + expect(world.dialogue?.holdTicks).toBe(45); expect(world.state.events.size).toBe(previousEvents); - while (world.dialogue) world.advanceDialogue(); + expect(settle(world)).toHaveLength(4); expect(world.state.events.size).toBe(previousEvents + 1); world.interact(); - expect(world.dialogue?.pages.length).toBe(1); + expect(settle(world)).toHaveLength(1); }); }, ); diff --git a/tests/reference-parity.spec.ts b/tests/reference-parity.spec.ts index 647157d..9cccc1d 100644 --- a/tests/reference-parity.spec.ts +++ b/tests/reference-parity.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "@playwright/test"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +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 { resolve } from "node:path"; const romPath = resolve( @@ -104,3 +108,188 @@ test("native decompression matches original GB assembly executing in the referen ).toBe(true); } }); + +test("native damage and type effectiveness match the original battle routines", async ({ + page, +}) => { + const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym"; + test.skip( + !existsSync(romPath) || !existsSync(symbolsPath), + "Requires the local user ROM and the private PRET rebuild.", + ); + const symbols = new Map( + readFileSync(symbolsPath, "utf8") + .split("\n") + .flatMap((line) => { + const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line); + return match + ? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const] + : []; + }), + ); + const at = (name: string) => symbols.get(name)!.address; + const routine = (name: string) => symbols.get(name)!; + const bytes = new Uint8Array(readFileSync(romPath)); + const assets = new CrystalAssets(bytes); + const data = new BattleData(assets.reader); + const matchups = data.typeMatchups(); + // A deterministic generator keeps failures reproducible. + let seed = 0x1234; + const next = (limit: number) => { + seed = (seed * 1103515245 + 12345) >>> 0; + return (seed >>> 8) % limit; + }; + const types = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27]; + const effects = [0, 0, 0, C.EFFECT_SELFDESTRUCT, C.EFFECT_MULTI_HIT, C.EFFECT_CONVERSION]; + const damageCases = Array.from({ length: 400 }, () => { + const item = next(4) === 0 ? next(191) : 0; + return { + power: next(8) === 0 ? 0 : next(256), + level: 1 + next(100), + attack: next(256), + defense: next(256), + effect: effects[next(effects.length)], + critical: next(2), + current: next(5) === 0 ? next(1200) : 0, + type: types[next(types.length)], + item, + }; + }); + const stabCases = Array.from({ length: 400 }, () => ({ + move: 1 + next(164), + type: types[next(types.length)], + user: [types[next(types.length)], types[next(types.length)]], + target: [types[next(types.length)], types[next(types.length)]], + identified: next(4) === 0, + damage: next(1000), + })); + await page.goto("/reference.html"); + await page.evaluate(() => { + const input = document.createElement("input"); + input.type = "file"; + input.id = "reference-fixture"; + document.body.append(input); + }); + await page.locator("#reference-fixture").setInputFiles(romPath); + const addresses = { + curDamage: at("wCurDamage"), + critical: at("wCriticalHit"), + turn: at("hBattleTurn"), + playerMove: at("wPlayerMoveStruct"), + battleItem: at("wBattleMonItem"), + battleTypes: at("wBattleMonType1"), + enemyTypes: at("wEnemyMonType1"), + typeModifier: at("wTypeModifier"), + attackMissed: at("wAttackMissed"), + weather: at("wBattleWeather"), + johto: at("wJohtoBadges"), + kanto: at("wKantoBadges"), + link: at("wLinkMode"), + tower: at("wInBattleTowerBattle"), + enemySub1: at("wEnemySubStatus1"), + damageCalc: routine("BattleCommand_DamageCalc"), + stab: routine("BattleCommand_Stab"), + }; + const results = await page.evaluate( + async ({ addresses, damageCases, stabCases }) => { + const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); + const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0]; + const reference = new BinjgbEmulator(); + await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas")); + const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff); + const word = (address: number, value: number) => { + write(address, value >> 8); + write(address + 1, value); + }; + // Trampoline: DI; SP=$c7fe; switch to the routine's bank; set B, C, D, E; + // CALL the original routine; write a sentinel; loop. + const call = (target: { bank: number; address: number }, registers: number[]) => { + const program = [ + 0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20, + 0x06, registers[0], 0x0e, registers[1], 0x16, registers[2], 0x1e, registers[3], + 0xcd, target.address & 0xff, target.address >> 8, + 0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe, + ]; + write(0xc6ff, 0); + program.forEach((value, index) => write(0xc000 + index, value)); + reference.setProgramCounter(0xc000); + reference.advanceFrames(2); + return reference.readMemory(0xc6ff) === 0x42; + }; + write(0xff70, 1); // WRAM bank 1 holds wCurDamage and wEnemyMon + for (const address of [addresses.weather, addresses.johto, addresses.kanto, addresses.link, addresses.tower]) + write(address, 0); + write(addresses.turn, 0); + const damage = damageCases.map((input) => { + write(addresses.playerMove + 1, input.effect); + write(addresses.playerMove + 3, input.type); + write(addresses.battleItem, input.item); + write(addresses.critical, input.critical); + word(addresses.curDamage, input.current); + // B = attack, C = defense, D = power, E = level + const returned = call(addresses.damageCalc, [input.attack, input.defense, input.power, input.level]); + return { + returned, + damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1), + }; + }); + const stab = stabCases.map((input) => { + write(addresses.playerMove, input.move); + write(addresses.playerMove + 3, input.type); + write(addresses.battleTypes, input.user[0]); + write(addresses.battleTypes + 1, input.user[1]); + write(addresses.enemyTypes, input.target[0]); + write(addresses.enemyTypes + 1, input.target[1]); + write(addresses.enemySub1, input.identified ? 1 << 3 : 0); + write(addresses.typeModifier, 10); + write(addresses.attackMissed, 0); + word(addresses.curDamage, input.damage); + const returned = call(addresses.stab, [0, 0, 0, 0]); + return { + returned, + damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1), + modifier: reference.readMemory(addresses.typeModifier), + missed: reference.readMemory(addresses.attackMissed), + }; + }); + reference.dispose(); + return { damage, stab }; + }, + { addresses, damageCases, stabCases }, + ); + damageCases.forEach((input, index) => { + const original = results.damage[index]; + expect(original.returned, `damage case ${index} returned`).toBe(true); + const held = data.heldEffect(input.item); + const boost = + held.effect && data.typeBoost(held.effect) === input.type ? held.parameter : undefined; + const native = + calculateDamage({ + power: input.power, + level: input.level, + attack: input.attack, + defense: input.defense, + effect: input.effect, + critical: Boolean(input.critical), + boost, + current: input.current, + }) ?? input.current; + expect(native, `damage case ${index}: ${JSON.stringify(input)}`).toBe(original.damage); + }); + stabCases.forEach((input, index) => { + const original = results.stab[index]; + expect(original.returned, `type case ${index} returned`).toBe(true); + const native = applyTypeMatchups(matchups, { + damage: input.damage, + modifier: 10, + moveType: input.type, + userTypes: input.user, + targetTypes: input.target, + identified: input.identified, + }); + expect( + [native.damage, native.modifier, Number(native.missed)], + `type case ${index}: ${JSON.stringify(input)}`, + ).toEqual([original.damage, original.modifier, original.missed]); + }); +}); diff --git a/tests/viewport.test.ts b/tests/viewport.test.ts new file mode 100644 index 0000000..d6b7b1a --- /dev/null +++ b/tests/viewport.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + BASE_VIEWPORT, + MAX_VIEWPORT, + renderViewportForBox, +} from "../src/game/rendering/viewport"; + +describe("adaptive native viewport", () => { + it("keeps detached fixture canvases at the original resolution", () => { + expect(renderViewportForBox(0, 0)).toEqual({ + ...BASE_VIEWPORT, + pixelScale: 3, + }); + expect(renderViewportForBox(Number.NaN, 500)).toEqual({ + ...BASE_VIEWPORT, + pixelScale: 3, + }); + }); + + it("uses one integer scale on both axes and reveals additional world", () => { + const phone = renderViewportForBox(390, 720); + expect(phone).toEqual({ width: 195, height: 360, pixelScale: 2 }); + + const desktop = renderViewportForBox(960, 648); + expect(desktop).toEqual({ width: 320, height: 216, pixelScale: 3 }); + expect(desktop.width).toBeGreaterThan(BASE_VIEWPORT.width); + expect(desktop.height).toBeGreaterThan(BASE_VIEWPORT.height); + expect(desktop.width * desktop.pixelScale).toBe(960); + expect(desktop.height * desktop.pixelScale).toBe(648); + }); + + it("rounds fractional boxes without materially changing their aspect", () => { + const css = { width: 713.5, height: 481.25 }; + const viewport = renderViewportForBox(css.width, css.height); + expect( + Math.abs(viewport.width / viewport.height - css.width / css.height), + ).toBeLessThan(0.005); + }); + + it.each([ + [304, 400], + [844, 220], + [1920, 600], + [100_000, 100_000], + ])( + "preserves the aspect of a %d x %d box while bounding buffers", + (width, height) => { + const viewport = renderViewportForBox(width, height); + expect(viewport.width).toBeLessThanOrEqual(MAX_VIEWPORT.width); + expect(viewport.height).toBeLessThanOrEqual(MAX_VIEWPORT.height); + expect( + Math.abs(viewport.width / viewport.height - width / height), + ).toBeLessThan(0.03); + }, + ); +}); diff --git a/tests/world-driver.ts b/tests/world-driver.ts new file mode 100644 index 0000000..2f2bcb2 --- /dev/null +++ b/tests/world-driver.ts @@ -0,0 +1,115 @@ +import type { GameWorld } from "../src/game/world/world"; +import type { Direction } from "../src/game/world/types"; + +/** Drive a world through scenes the way a player would: acknowledge pages, + * answer choices in order (default YES / first weekday) and wait out timed + * effects. Returns every page that was shown. */ +export function settle( + world: GameWorld, + answers: (number | string)[] = [], +): string[] { + const shown: string[] = []; + let lastHeld = ""; + for (let tick = 0; tick < 20_000; tick++) { + const dialogue = world.dialogue; + if (dialogue) { + const page = dialogue.pages[dialogue.index]; + const last = dialogue.index === dialogue.pages.length - 1; + if (last && dialogue.choice) { + shown.push(page); + // The naming screen waits for the caller unless a name is queued. + if (dialogue.choice.kind === "name") { + if (typeof answers[0] !== "string") return shown; + world.choose(answers.shift()!); + continue; + } + const answer = answers.shift(); + world.choose( + typeof answer === "number" + ? answer + : dialogue.choice.kind === "yesno" + ? 1 + : 0, + ); + } else if (last && dialogue.holdTicks !== undefined) { + if (page !== lastHeld) shown.push(page); + lastHeld = page; + world.tick(); + } else { + shown.push(page); + world.advanceDialogue(); + } + continue; + } + lastHeld = ""; + if (!world.busy) { + // Scene scripts start on an idle tick. + world.tick(); + if (!world.busy) return shown; + continue; + } + world.tick(); + } + throw new Error("The world did not settle."); +} + +const DIRECTIONS = [ + ["up", 0, -1], + ["down", 0, 1], + ["left", -1, 0], + ["right", 1, 0], +] as const; + +/** Shortest walkable path on the current map, avoiding other warps. */ +export function findPath( + world: GameWorld, + targetX: number, + targetY: number, +): Direction[] { + const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }]; + const visited = new Set(); + for (let i = 0; i < queue.length; i++) { + const node = queue[i], + key = `${node.x},${node.y}`; + if (visited.has(key)) continue; + visited.add(key); + if (node.x === targetX && node.y === targetY) return node.path; + for (const [direction, dx, dy] of DIRECTIONS) { + const x = node.x + dx, + y = node.y + dy; + if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) + continue; + if ( + world.assets.permission(world.assets.collision(world.map, x, y)) !== 0 + ) + continue; + if (world.objects().some((object) => object.x === x && object.y === y)) + continue; + if ( + world.map.warps.some((warp) => warp.x === x && warp.y === y) && + (x !== targetX || y !== targetY) + ) + continue; + queue.push({ x, y, path: [...node.path, direction] }); + } + } + throw new Error(`No route to ${targetX},${targetY}`); +} + +/** Walk to a tile, settling any scene that a step triggers. */ +export function walkTo( + world: GameWorld, + targetX: number, + targetY: number, + answers: (number | string)[] = [], +): string[] { + const shown: string[] = []; + for (const direction of findPath(world, targetX, targetY)) { + if (!world.move(direction)) + throw new Error( + `Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`, + ); + shown.push(...settle(world, answers)); + } + return shown; +}