Open Route 32 to Route 34, the Ruins of Alph and Azalea Town (#32)

Enable Route 32 and its Pokémon Center, the Ruins of Alph with the four
chambers, item and word rooms, inner chamber and research center, Union
Cave, Route 33, Azalea Town with Kurt's house, the kiln and Bugsy's gym,
the Slowpoke Well, Ilex Forest and Route 34.

Port what their scripts need: saved WRAM bytes by label (readmem,
writemem, loadmem: wFarfetchdPosition, wKurtApricornQuantity),
VAR_UNOWNCOUNT and VAR_KURT_APRICORNS, SelectApricornForKurt and
verbosegiveitemvar, Unown letters (GetUnownLetter, per-letter pictures,
CheckUnownLetter's redraw, UpdateUnownDex, wFirstUnownSeen), the chamber
puzzles (_UnownPuzzle), DisplayUnownWords, HoOhChamber and
OmanyteChamber.

Minigames and specials that draw a whole screen in tiles now share a
tile screen effect with ROM graphics, palettes and sprites, answered
with buttons; the standard font and textbox frame load for them.

The playthrough continues through Kurt's Slowpoke Well, Bugsy, the rival
and the Farfetch'd to the Route 34 gate; its helpers move to
tests/playthrough-driver.ts. New tests solve the Kabuto puzzle through
the world, catch Unown and make Kurt's balls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-06 15:35:27 +02:00
1 parent 15a31f95e4
commit f4ef244fc7
31 files changed
+2334 -528

No files matched your search

+4 -2
View File
@@ -2,7 +2,7 @@
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
**Current milestone: from the opening to Route 30.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
**Current milestone: from the opening to Route 34.** 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. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. 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.
@@ -16,7 +16,9 @@ Start opens the start menu. POKéMON shows your party: open a Pokémon's stats,
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
Route 31 and beyond, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
Route 31 leads to Violet City, Sprout Tower and Falkner's gym. South of Violet, Route 32, Union Cave and Route 33 lead to Azalea Town: follow Kurt into the Slowpoke Well to stop Team Rocket, beat Bugsy, then herd the Farfetch'd in Ilex Forest for HM01 Cut and reach Route 34. Kurt makes balls from apricorns overnight. In the Ruins of Alph, solve the chamber puzzles to unlock wild Unown, which appear in their letters and fill the Unown Dex.
Goldenrod City and beyond, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, Headbutt and Rock Smash, MAIL, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
## Run
+5
View File
@@ -42,6 +42,9 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
| `game/scripts/evolution.ts` | `engine/pokemon/evolve.asm`, `engine/pokemon/learn.asm`, `engine/movie/evolution_animation.asm`, `engine/pokemon/party_menu.asm` | `EvolveAfterBattle`/`EvolvePokemon` over the raw evolution bytes, the evolution texts and a timed, B-stoppable picture sequence, `UpdateSpeciesNameIfNotNicknamed`, `CalcMonStats` and the HP rise, `LearnLevelMoves`, `LearnMove`/`ForgetMove` outside battle and `PlacePartyMonEvoStoneCompatibility`. |
| `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, evolution lists, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes; all 251 evolution lists match the pinned source. |
| `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`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. |
| `game/scripts/screen.ts`, `game/rendering/tile-screen.ts`, `game/rom/font.ts` | `home/tilemap.asm`, `engine/gfx/load_font.asm` (`_LoadStandardFont`, `LoadFrame`) | Tile screens: a 20x18 tilemap with background palettes and sprites over it, built by a minigame or special from ROM graphics and answered with buttons. Blinking sprites follow bit 4 of the frame counter. Overlay screens (a menu box) leave the map visible around them. The standard font and the option's textbox frame load as 2bpp tiles. |
| `game/scripts/unown-puzzle.ts` | `engine/games/unown_puzzle.asm`, `engine/events/specials.asm` (`UnownPuzzle`), `engine/gfx/cgb_layouts.asm` (`_CGB_UnownPuzzle`) | `_UnownPuzzle`: `InitUnownPuzzlePiecePositions` with Random, the d-pad rules, picking up and placing pieces, `CheckSolvedUnownPuzzle`, and the graphics (`ConvertLoadedPuzzlePieces`' doubling, the piece borders, the cursor and START/CANCEL, the OAM layouts) and palettes from the ROM. Sounds are not played. |
| `game/scripts/ruins.ts` | `engine/events/unown_walls.asm`, `data/events/unown_walls.asm` | `DisplayUnownWords` as an overlay screen with the chamber tileset's Unown letters, `HoOhChamber` and `OmanyteChamber`. |
| 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 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.
@@ -96,6 +99,8 @@ Native production is `dist/`; the optional reference output is `dist-reference/`
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, and later Mom's savings (`momsMoney`, migrated by absence to 0). 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`.
Later fields keep, by absence-migrated defaults: `memory`, the saved WRAM bytes that scripts read and write directly, by PRET label (`SAVED_WRAM`); and `unownDex` with `firstUnownSeen` (`wUnownDex`, `wFirstUnownSeen`).
## Scripted trainer battle foundation
`game/rom/trainers.ts` decodes `TrainerGroups`, `TrainerClassAttributes`, `TrainerClassNames`, `TrainerClassDVs` and map trainer headers. Tests cover all 542 ROM records across 67 classes; CAL2 construction refuses its SRAM-dependent Mystery Gift party.
+13 -1
View File
@@ -364,6 +364,18 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- `tests/playthrough.test.ts` now continues to Route 31 (Mom's call), Sprout Tower (Elder Li's Flash), Falkner (the ZEPHYRBADGE, TM31 and the aide's call), the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. `tests/violet.test.ts` covers the other answers.
- Kenya's MAIL is checked as the original does, but it is given at the Route 35 gate, which is closed; its text (a 15-letter first line) is one the port's MAIL model cannot yet store. Wade's calls, rematches and gifts come with the phone (board #30).
## Implemented: Route 32 to Route 34, the Ruins of Alph and Azalea Town (board #32)
- Route 32 with its Pokémon Center, the Ruins of Alph (outside, the four chambers, their item and word rooms, the inner chamber and the research center) and both gates, Union Cave's three floors, Route 33, Azalea Town with its Pokémon Center, Mart, Kurt's house, the charcoal kiln and the gym, the Slowpoke Well, the Ilex Forest gates and forest, Route 34 and the Day-Care are open. Goldenrod City, Route 36 and the Pokémon Centers' second floor stay closed.
- The story runs: the Miracle Seed, Kurt's run to the Slowpoke Well, the Rocket grunts there, the Lure Ball, Bugsy and the HIVEBADGE, the rival at Azalea's west end, the Farfetch'd herding in Ilex Forest (`wFarfetchdPosition`), HM01 from the charcoal master and the Cut tree.
- Saved WRAM bytes that scripts read and write directly (`readmem`, `writemem`, `loadmem`) are kept by PRET label in the save's `memory` (`SAVED_WRAM`, verified by `check:reference`): so far `wFarfetchdPosition` and `wKurtApricornQuantity`.
- Kurt makes balls: `SelectApricornForKurt` lists the apricorns in `ApricornBalls` order with their counts, asks how many with the mart's 1-99 stepper and takes them; `verbosegiveitemvar` hands over the balls the next day (`VAR_KURT_APRICORNS`, `ENGINE_KURT_MAKING_BALLS` cleared by the daily reset).
- Unown: `GetUnownLetter` from the DVs, the per-letter front and back pictures (`UnownPicPointers`), `CheckUnownLetter`'s redraw against `UnlockedUnownLetterSets`, no wild Unown before a set is unlocked, `UpdateUnownDex` on a catch to the party or a box, `wFirstUnownSeen` (also the Pokédex's Unown picture) and `VAR_UNOWNCOUNT`.
- The chamber puzzles (`_UnownPuzzle`) run as a tile screen with the ROM's graphics: the random border layout, the cursor's moves around START/CANCEL, picking up and placing pieces, the solved check, then the chamber's floor opens and the player falls into the inner chamber. The wall patterns (`DisplayUnownWords`) draw their word in Unown letters from the chamber's tileset in a menu box over the map. `HoOhChamber` and `OmanyteChamber` open their walls.
- Tile screens are a general effect for minigames and specials that draw a whole Game Boy screen in tiles (`scripts/screen.ts`, `rendering/tile-screen.ts`): a tilemap with palettes and sprites, answered with buttons. `rom/font.ts` supplies the standard font and the chosen textbox frame for them.
- `tests/playthrough.test.ts` continues through Route 32, Union Cave, Azalea Town, the Slowpoke Well, Bugsy, the rival and Ilex Forest to the Route 34 gate; its helpers now live in `tests/playthrough-driver.ts`, and `PLAYTHROUGH_SAVES` keeps each stage's save. `tests/ruins.test.ts` solves the Kabuto puzzle through the world and catches Unown; `tests/azalea.test.ts` covers Kurt's apricorns.
- Still closed or refused here: the Day-Care's deposits and Eggs (board #29), the radios' `MapRadio` and trainers' rematch counts (board #30), the research center's `UnownPrinter` (it needs all 26 letters), the Pokédex's Unown mode, the GS Ball and Celebi shrine (not reachable without the mobile event), and sound.
## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
@@ -446,7 +458,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Kurt's balls are thrown too, and the battle engine handles every special battle type (board #28 is complete). Route 31, Violet City, Sprout Tower and Falkner's gym are open (board #31). The synthetic CI suite runs on the shared server runner (board #45): 143 unit tests and 10 Chromium/WebKit browser tests passed in [run 119](https://git.azuze.fr/kawa/Crystal/actions/runs/119), with ROM-dependent tests skipped. Both builds, vendor integrity and native release isolation passed too. Next roadmap milestone: the remaining Johto cities (board #32); check current board priorities before selecting work.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Kurt's balls are thrown too, and the battle engine handles every special battle type (board #28 is complete). Route 31, Violet City, Sprout Tower and Falkner's gym are open (board #31), and Route 32 to Route 34 with the Ruins of Alph, Azalea Town, Bugsy's gym and Ilex Forest (board #32, in progress). The synthetic CI suite runs on the shared server runner (board #45): 143 unit tests and 10 Chromium/WebKit browser tests passed in [run 119](https://git.azuze.fr/kawa/Crystal/actions/runs/119), with ROM-dependent tests skipped. Both builds, vendor integrity and native release isolation passed too. Next roadmap milestone: the remaining Johto cities (board #32); check current board priorities before selecting work.
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.
+15 -3
View File
@@ -216,10 +216,20 @@ for (const [key, symbol] of Object.entries(ramNames)) {
if (!match || Number(match[1]) !== ram.get(symbol))
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
}
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX and OPTIONS entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS"]) {
// SAVED_WRAM and WRAM entries are work RAM labels.
for (const table of ["SAVED_WRAM", "WRAM"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
if (!block) throw new Error(`${table} symbol table is missing.`);
if (block === undefined) throw new Error(`${table} 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 (ram.get(label) !== Number(literal))
throw new Error(`${table} RAM mismatch: ${label}`);
}
}
// BATTLE, OVERWORLD, PC, INTRO, STATS, MENU, POKEGEAR, MOM, TRAINER_CARD, POKEDEX, OPTIONS and JOHTO entries are keyed by their PRET labels.
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS", "MENU", "POKEGEAR", "MOM", "TRAINER_CARD", "POKEDEX", "OPTIONS", "JOHTO"]) {
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
if (block === undefined) throw new Error(`${table} 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))
@@ -254,6 +264,8 @@ const eventNames = {
playersHouseBigDoll: "EVENT_PLAYERS_HOUSE_2F_BIG_DOLL",
wallOpenedInAerodactylChamber: "EVENT_WALL_OPENED_IN_AERODACTYL_CHAMBER",
wallOpenedInKabutoChamber: "EVENT_WALL_OPENED_IN_KABUTO_CHAMBER",
wallOpenedInHoOhChamber: "EVENT_WALL_OPENED_IN_HO_OH_CHAMBER",
wallOpenedInOmanyteChamber: "EVENT_WALL_OPENED_IN_OMANYTE_CHAMBER",
};
for (const [key, name] of Object.entries(eventNames)) {
const match = new RegExp(`\\b${key}:\\s*(\\d+)`).exec(profile);
+44 -4
View File
@@ -1,6 +1,6 @@
import { RomReader } from "../rom/reader";
import { BATTLE, RAM, SYMBOLS } from "../rom/profile";
import type { PokemonData } from "../rom/pokemon";
import { BATTLE, JOHTO, RAM, SYMBOLS } from "../rom/profile";
import { PokemonData } from "../rom/pokemon";
import type { Bag } from "../party/bag";
import {
MONS_PER_BOX,
@@ -15,7 +15,9 @@ import {
calcStats,
levelForExp,
maxPP,
updateUnownDex,
type PartyMon,
type UnownRecord,
} from "../party/pokemon";
import { readString, readTextEnd, type TextContext } from "../scripts/text";
import { BattleData } from "./data";
@@ -256,8 +258,33 @@ export interface BattleOptions {
/** wOptions' BATTLE_SCENE clear: battle animations on (the default).
* Some texts only show with them off (_CheckBattleScene). */
battleScene?: boolean;
/** wUnownDex and wFirstUnownSeen, which meeting and catching Unown
* update, and wUnlockedUnowns, the letter sets wild Unown may have. */
unown?: UnownRecord;
unlockedUnowns?: number;
}
/** CheckUnownLetter: whether an Unown with these DVs has a letter in one
* of the sets wUnlockedUnowns has unlocked (UnlockedUnownLetterSets, each
* a list of UNOWN_* letters ending with -1). */
export function unownUnlocked(
data: PokemonData,
unlocked: number,
dvs: number,
): boolean {
const r = data.reader,
letter = PokemonData.unownLetter(dvs),
table = JOHTO.UnlockedUnownLetterSets;
for (let set = 0; set < NUM_UNLOCKED_UNOWN_SETS; set++) {
if (!(unlocked & (1 << set))) continue;
for (let p = r.near(table + set * 2, r.bank(table)); r.byte(p) !== 0xff; p++)
if (r.byte(p) === letter) return true;
}
return false;
}
/** NUM_UNLOCKED_UNOWN_SETS (constants/ram_constants.asm). */
const NUM_UNLOCKED_UNOWN_SETS = 4;
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
const BATTLERESULT_BOX_FULL = 0x80;
const SUB1 = 1,
@@ -780,12 +807,17 @@ export class Battle {
this.enemy.stats = [...this.enemy.mon.stats];
this.enemySpecies = mon.species;
this.options.pokedex.seen.add(mon.species);
// LoadEnemyMonToSwitchTo: a trainer's Unown can be the first seen.
const unown = this.options.unown;
if (mon.species === C.UNOWN && unown && !unown.firstUnownSeen)
unown.firstUnownSeen = PokemonData.unownLetter(mon.dvs);
return;
}
if (!this.options.wild) throw new UnsupportedBattle("Missing encounter.");
const { species, level } = this.options.wild;
if (species === C.UNOWN)
throw new UnsupportedBattle("Unown encounters are not ported yet.");
const unown = this.options.unown;
if (species === C.UNOWN && !unown)
throw new UnsupportedBattle("Missing the Unown Dex.");
const data = this.options.data,
base = data.baseData(species);
// 25% chance of an item: 23% the first, 2% the second. Ho-Oh, Lugia
@@ -805,7 +837,13 @@ export class Battle {
dvs = roamer.dvs;
} else if (this.battleType === C.BATTLETYPE_FORCESHINY) dvs = 0xeaaa;
else dvs = (this.random() << 8) | this.random();
// CheckUnownLetter: a letter outside the unlocked sets draws again.
while (species === C.UNOWN && !unownUnlocked(data, this.options.unlockedUnowns ?? 0, dvs))
dvs = (this.random() << 8) | this.random();
while (species === C.MAGIKARP && this.rerollMagikarp(dvs)) dvs = (this.random() << 8) | this.random();
// InitEnemyWildmon: the first Unown met names wFirstUnownSeen.
if (species === C.UNOWN && !unown!.firstUnownSeen)
unown!.firstUnownSeen = PokemonData.unownLetter(dvs);
const stats = calcStats(data, species, dvs, level);
const moves = this.levelMoves(species, level);
this.enemySpecies = species;
@@ -1782,6 +1820,7 @@ export class Battle {
stats: calcStats(data, species, enemy.dvs, level),
};
this.options.party.push(mon);
if (this.options.unown) updateUnownDex(this.options.unown, mon);
mon.nickname = yield* this.askNickname(name);
this.tossItem("balls", item, slot);
return true;
@@ -1978,6 +2017,7 @@ export class Battle {
};
restorePP(data, mon);
box.unshift(mon);
if (this.options.unown) updateUnownDex(this.options.unown, mon, false);
if (box.length === MONS_PER_BOX) this.battleResult |= BATTLERESULT_BOX_FULL;
mon.nickname = yield* this.askNickname(name);
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
+21 -1
View File
@@ -1,4 +1,4 @@
import type { PokemonData } from "../rom/pokemon";
import { PokemonData } from "../rom/pokemon";
import type { Mail } from "./mail";
/** A party Pokémon with the fields of the original party_struct. */
@@ -34,6 +34,26 @@ export interface PartyMon {
}
export const PARTY_LENGTH = 6;
/** wUnownDex and wFirstUnownSeen, kept with the game. */
export interface UnownRecord {
unownDex: number[];
firstUnownSeen: number;
}
/** UpdateUnownDex: an Unown's letter (GetUnownLetter) is added to the
* Unown Dex the first time it joins the party or a box. TryAddMonToParty
* also records the first letter seen, when none was. */
export function updateUnownDex(
record: UnownRecord,
mon: { species: number; dvs: number },
party = true,
) {
if (mon.species !== UNOWN_SPECIES) return;
const letter = PokemonData.unownLetter(mon.dvs);
if (!record.unownDex.includes(letter) && record.unownDex.length < 26)
record.unownDex.push(letter);
if (party && !record.firstUnownSeen) record.firstUnownSeen = letter;
}
const UNOWN_SPECIES = 201;
const STATUS_NAMES: [number, string][] = [
[0x08, " PSN"],
+53
View File
@@ -0,0 +1,53 @@
import {
SCREEN_HEIGHT,
SCREEN_WIDTH,
type TileScreen,
} from "../scripts/screen";
import { drawTile } from "./pixels";
/** Draw a tile screen: the background tilemap, then the sprites over it
* (color 0 transparent). `frame` counts video frames for blinking sprites. */
export function drawTileScreen(
canvas: HTMLCanvasElement,
screen: TileScreen,
frame: number,
) {
const width = SCREEN_WIDTH * 8,
height = SCREEN_HEIGHT * 8;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const context = canvas.getContext("2d")!;
const image = context.createImageData(width, height);
for (let row = 0; row < SCREEN_HEIGHT; row++)
for (let col = 0; col < SCREEN_WIDTH; col++) {
const cell = row * SCREEN_WIDTH + col;
if (screen.map[cell] < 0) continue;
drawTile(
image.data,
width,
height,
screen.tiles,
screen.map[cell],
col * 8,
row * 8,
screen.palettes[screen.attrs[cell]] ?? screen.palettes[0],
);
}
const blinkOff = !(frame & 0x10);
for (const sprite of screen.sprites) {
if (sprite.blink && blinkOff) continue;
drawTile(
image.data,
width,
height,
screen.tiles,
sprite.tile,
sprite.x,
sprite.y,
screen.objPalettes[sprite.palette] ?? screen.objPalettes[0],
{ transparent: true, flipX: sprite.flipX, flipY: sprite.flipY },
);
}
context.putImageData(image, 0, 0);
canvas.setAttribute("aria-label", screen.label);
}
+74
View File
@@ -0,0 +1,74 @@
import type { RomReader } from "./reader";
import { JOHTO } from "./profile";
/** TEXTBOX_FRAME_TILES and NUM_FRAMES (constants/text_constants.asm). */
const TEXTBOX_FRAME_TILES = 6,
NUM_FRAMES = 8;
/** The tiles LoadFrame and _LoadStandardFont fill: '┌' at $79, ' ' at
* $7f and the font from $80. */
export const FRAME_TILE = 0x79,
SPACE_TILE = 0x7f,
FONT_TILE = 0x80;
/** Get1bpp: both bit planes copy the 1bpp row, so set pixels are color 3. */
function oneBpp(target: Uint8Array, tile: number, source: Uint8Array) {
for (let row = 0; row < 8; row++) {
target[tile * 16 + row * 2] = source[row];
target[tile * 16 + row * 2 + 1] = source[row];
}
}
/** vTiles2 $79-$7f and vTiles1 as the text engine leaves them: the
* textbox frame chosen in the options (LoadFrame), the space, and the
* standard font (_LoadStandardFont). Indices are tile numbers. */
export function fontTiles(reader: RomReader, frame: number) {
const tiles = new Uint8Array(0x100 * 16);
const frames = JOHTO.Frames + (frame & (NUM_FRAMES - 1)) * TEXTBOX_FRAME_TILES * 8;
for (let i = 0; i < TEXTBOX_FRAME_TILES; i++)
oneBpp(tiles, FRAME_TILE + i, reader.slice(frames + i * 8, 8));
oneBpp(tiles, SPACE_TILE, reader.slice(JOHTO.TextboxSpaceGFX, 8));
for (let i = 0; i < 128; i++)
oneBpp(tiles, FONT_TILE + i, reader.slice(JOHTO.Font + i * 8, 8));
return tiles;
}
/** The characters these screens print, to their charmap codes
* (constants/charmap.asm). */
const CODES: Record<string, number> = {
" ": 0x7f,
"(": 0x9a,
")": 0x9b,
":": 0x9c,
";": 0x9d,
"[": 0x9e,
"]": 0x9f,
"'": 0xe0,
"-": 0xe3,
"?": 0xe6,
"!": 0xe7,
".": 0xe8,
"&": 0xe9,
"é": 0xea,
"→": 0xeb,
"▷": 0xec,
"▶": 0xed,
"▼": 0xee,
"♂": 0xef,
"¥": 0xf0,
"×": 0xf1,
"/": 0xf3,
",": 0xf4,
"♀": 0xf5,
};
/** Text as tile numbers, one character per tile. */
export function textTiles(text: string): number[] {
return [...text].map((character) => {
const code = character.charCodeAt(0);
if (character >= "A" && character <= "Z") return 0x80 + code - 65;
if (character >= "a" && character <= "z") return 0xa0 + code - 97;
if (character >= "0" && character <= "9") return 0xf6 + code - 48;
const mapped = CODES[character];
if (mapped === undefined) throw new Error(`No tile for "${character}".`);
return mapped;
});
}
+34 -8
View File
@@ -3,6 +3,7 @@ import { decompress } from "./lz";
import {
BATTLE,
INTRO,
JOHTO,
MENU,
NUM_ITEMS,
NUM_POKEMON,
@@ -89,6 +90,10 @@ const STAT_TRIGGERS = ["gt", "lt", "eq"] as const;
/** Decoders for species, moves and items. All data is read from the
* user's ROM on demand and cached in memory only. */
/** UNOWN and NUM_UNOWN (constants/pokemon_constants.asm). */
const UNOWN = 201;
export const NUM_UNOWN = 26;
export class PokemonData {
private base = new Map<number, BaseData>();
private names = new Map<string, string>();
@@ -389,11 +394,33 @@ export class PokemonData {
return ((quadratic & 0xffffff) + cubic) & 0xffffff;
}
/** The decompressed front picture: base frame, then animation tiles. */
frontPicData(species: number): Uint8Array {
/** GetFrontpicPointer's entry: PokemonPicPointers, or for Unown
* UnownPicPointers by letter (1-26), at the same address in the next
* bank. */
private picEntry(species: number, letter: number) {
this.checkSpecies(species);
if (species !== UNOWN) return SYMBOLS.pokemonPics + (species - 1) * 6;
if (!Number.isInteger(letter) || letter < 1 || letter > NUM_UNOWN)
throw new Error("Unknown Unown letter.");
return JOHTO.UnownPicPointers + (letter - 1) * 6;
}
/** GetUnownLetter: the middle two bits of each DV (Attack, Defense,
* Speed, Special) make a byte, divided by $ff / NUM_UNOWN + 1, plus one. */
static unownLetter(dvs: number): number {
const high = dvs >> 8,
low = dvs & 0xff;
const value =
((high & 0x60) << 1) |
((high & 0x06) << 3) |
((low & 0x60) >> 3) |
((low & 0x06) >> 1);
return Math.floor(value / (Math.floor(0xff / NUM_UNOWN) + 1)) + 1;
}
/** The decompressed front picture: base frame, then animation tiles.
* Unown's depends on its letter. */
frontPicData(species: number, letter = 1): Uint8Array {
const r = this.reader,
entry = SYMBOLS.pokemonPics + (species - 1) * 6;
entry = this.picEntry(species, letter);
// 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));
@@ -414,10 +441,10 @@ export class PokemonData {
}
/** 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 {
frontPic(species: number, letter = 1): Uint8Array {
const size = this.baseData(species).picSize;
if (size < 5 || size > 7) throw new Error("Invalid picture size.");
const tiles = this.frontPicData(species);
const tiles = this.frontPicData(species, letter);
if (tiles.length < size * size * 16)
throw new Error("Picture data is truncated.");
const padded = new Uint8Array(49 * 16);
@@ -430,10 +457,9 @@ export class PokemonData {
return padded;
}
/** The back picture: 6x6 tiles, column by column (GetMonBackpic). */
backPic(species: number): Uint8Array {
this.checkSpecies(species);
backPic(species: number, letter = 1): Uint8Array {
const r = this.reader,
entry = SYMBOLS.pokemonPics + (species - 1) * 6 + 3;
entry = this.picEntry(species, letter) + 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);
+85
View File
@@ -186,6 +186,8 @@ export const EVENTS = {
playersHouseBigDoll: 1860,
wallOpenedInAerodactylChamber: 809,
wallOpenedInKabutoChamber: 807,
wallOpenedInHoOhChamber: 806,
wallOpenedInOmanyteChamber: 808,
} as const;
/** Engine flag indices named directly by translated routines. */
@@ -1039,6 +1041,53 @@ export const OPTIONS = {
"Options_MenuAccount.On": 0xe44f6,
} as const;
/** Johto's scripts, routines and tables from Route 32 on, keyed by their
* PRET labels for `npm run check:reference`. */
export const JOHTO = {
UnlockedUnownLetterSets: 0x3eba1,
UnownPicPointers: 0x124000,
"InitUnownPuzzlePiecePositions.PuzzlePieceInitialPositions": 0xe126d,
"GetCurrentPuzzlePieceVTileCorner.Corners": 0xe148f,
"CheckSolvedUnownPuzzle.SolvedPuzzleConfiguration": 0xe14b5,
"RedrawUnownPuzzlePieces.OAM_HoldingPiece": 0xe150f,
"RedrawUnownPuzzlePieces.OAM_NotHoldingPiece": 0xe1534,
UnownPuzzleCoordData: 0xe1559,
"PuzzlePieceBorderData.TileBordersGFX": 0xe1723,
UnownPuzzleCursorGFX: 0xe17c5,
UnownPuzzleStartCancelLZ: 0xe1805,
HoOhPuzzleLZ: 0xe18ab,
AerodactylPuzzleLZ: 0xe19fb,
KabutoPuzzleLZ: 0xe1bab,
OmanytePuzzleLZ: 0xe1c9b,
Font: 0xf8200,
Frames: 0xf8800,
TextboxSpaceGFX: 0xf9204,
UnownWalls: 0x8aebc,
MenuHeaders_UnownWalls: 0x8aed5,
UnownPuzzle: 0xc360,
DisplayUnownWords: 0x8ae68,
HoOhChamber: 0x8addb,
OmanyteChamber: 0x8adef,
ApricornBalls: 0x24ca0,
SelectApricornForKurt: 0x88018,
"Kurt_PrintTextWhichApricorn.WhichApricornText": 0x88007,
"Kurt_PrintTextHowMany.HowManyShouldIMakeText": 0x88013,
} as const;
/** Saved WRAM bytes that map scripts read and write directly (readmem,
* writemem, loadmem and readvar), keyed by their PRET labels; the native
* save keeps them by label in `memory`. */
export const SAVED_WRAM = {
wFarfetchdPosition: 0xd964,
wKurtApricornQuantity: 0xdca4,
} as const;
export type SavedByte = keyof typeof SAVED_WRAM;
/** Work RAM the native port reads by address but does not save, keyed by
* PRET labels. */
export const WRAM = {
wTilemap: 0xc4a0,
} as const;
export const MAP_GROUP_COUNT = 26;
export const NUM_POKEMON = 251;
export const NUM_TRAINER_CLASSES = 67;
@@ -1083,5 +1132,41 @@ export const NATIVE_MAPS: Record<string, string> = {
"3:1": "Sprout Tower 1F",
"3:2": "Sprout Tower 2F",
"3:3": "Sprout Tower 3F",
"10:1": "Route 32",
"10:12": "Ruins of Alph gate",
"10:13": "Pokémon Center",
"10:16": "Ruins of Alph gate",
"3:22": "Ruins of Alph",
"3:23": "Ho-Oh chamber",
"3:24": "Kabuto chamber",
"3:25": "Omanyte chamber",
"3:26": "Aerodactyl chamber",
"3:27": "Inner chamber",
"3:28": "Research center",
"3:29": "Ho-Oh item room",
"3:30": "Kabuto item room",
"3:31": "Omanyte item room",
"3:32": "Aerodactyl item room",
"3:33": "Ho-Oh word room",
"3:34": "Kabuto word room",
"3:35": "Omanyte word room",
"3:36": "Aerodactyl word room",
"3:37": "Union Cave 1F",
"3:38": "Union Cave B1F",
"3:39": "Union Cave B2F",
"8:6": "Route 33",
"8:7": "Azalea Town",
"8:1": "Pokémon Center",
"8:2": "Charcoal kiln",
"8:3": "Azalea Mart",
"8:4": "Kurt’s house",
"8:5": "Azalea Gym",
"3:40": "Slowpoke Well B1F",
"3:41": "Slowpoke Well B2F",
"11:22": "Ilex Forest gate",
"3:52": "Ilex Forest",
"11:23": "Route 34 gate",
"11:1": "Route 34",
"11:24": "Day-Care",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+173 -5
View File
@@ -7,6 +7,7 @@ import { RomReader } from "../rom/reader";
import {
EMPTY_ROUTINES,
ENGINE_FLAGS,
JOHTO,
MENU,
NATIVE_ROUTINES,
NUM_FRUIT_TREES,
@@ -16,8 +17,10 @@ import {
PC,
POKEGEAR,
RAM,
SAVED_WRAM,
SPECIAL_ROUTINES,
SYMBOLS,
type SavedByte,
} from "../rom/profile";
import { PokemonData } from "../rom/pokemon";
import { C } from "../battle/constants";
@@ -46,6 +49,7 @@ import {
generatePartyMon,
maxPP,
partyMonDetails,
updateUnownDex,
type PartyMon,
} from "../party/pokemon";
import { copyMail, type Mail } from "../party/mail";
@@ -57,6 +61,10 @@ import { PcSession } from "./pc";
import { bankOfMom, type MomBankView } from "./mom";
import type { PokegearView } from "./pokegear";
import type { FlyMapView } from "./fly-map";
import type { TileScreen } from "./screen";
import { unownPuzzle } from "./unown-puzzle";
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
import type { Palette } from "../world/types";
import { rockMonEncounter, treeMonEncounter } from "./treemons";
import type { TrainerCardView } from "./trainer-card";
import type { PokedexView } from "./pokedex";
@@ -144,6 +152,13 @@ export interface ScriptState {
tradeFlags: number;
/** wBlueCardBalance: the Blue Card's points. */
blueCardBalance: number;
/** Saved WRAM bytes that scripts read and write directly, by label
* (SAVED_WRAM); an absent byte is 0, as in a new game. */
memory: Partial<Record<SavedByte, number>>;
/** wUnownDex: the Unown letters (1-26) in the order first caught, and
* wFirstUnownSeen, the letter of the first Unown met in battle. */
unownDex: number[];
firstUnownSeen: number;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
options: GameOptions;
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
@@ -184,6 +199,9 @@ export const emptyScriptState = (): ScriptState => ({
coins: 0,
tradeFlags: 0,
blueCardBalance: 0,
memory: {},
unownDex: [],
firstUnownSeen: 0,
options: defaultOptions(),
});
export const copyScriptState = (state: ScriptState): ScriptState => ({
@@ -222,6 +240,9 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance,
memory: { ...state.memory },
unownDex: [...state.unownDex],
firstUnownSeen: state.firstUnownSeen,
options: { ...state.options },
...(state.bugContest && {
bugContest: {
@@ -308,7 +329,14 @@ const POKEMAIL_WRONG_MAIL = 0,
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
/** The mart's lists and quantity box, which the PC's item storage reuses. */
export type MartMode = "buy" | "sell" | "withdraw" | "deposit" | "toss";
export type MartMode =
| "buy"
| "sell"
| "withdraw"
| "deposit"
| "toss"
/** A quantity for a script, such as Kurt's apricorns. */
| "quantity";
export interface MartEntry {
item: number;
name: string;
@@ -478,7 +506,10 @@ export type ScriptEffect =
| { kind: "mapblocks"; address: number }
/** writecmdqueue with CMDQUEUE_STONETABLE: the map's stone table
* (warp, object, script) that boulders falling into pits trigger. */
| { kind: "stonetable"; address: number };
| { kind: "stonetable"; address: number }
/** A full-screen minigame or special drawn in tiles, answered with the
* button pressed (SCREEN_BUTTONS). */
| { kind: "screen"; screen: TileScreen };
/** What the world supplies about the present moment. */
export interface ScriptHost {
@@ -512,6 +543,9 @@ export interface ScriptHost {
/** GetFacingObject: the visible object on the facing tile, with its
* MAPOBJECT_MOVEMENT. */
facingObject(): { id: number; movement: number } | undefined;
/** The map's tileset graphics and background palettes as shown now,
* for specials that draw over the map. */
tileset?(): { graphics: Uint8Array; palettes: Palette[] };
}
export class UnsupportedScript extends Error {
@@ -527,6 +561,11 @@ export class UnsupportedScript extends Error {
}
type Input = number | string | undefined;
/** The SAVED_WRAM label at a RAM address, if it is one. */
const savedByte = (address: number) =>
(Object.keys(SAVED_WRAM) as SavedByte[]).find(
(label) => SAVED_WRAM[label] === address,
);
/** ITEM_FROM_MEM: take the item from wScriptVar. */
const ITEM_FROM_MEM = 0xff;
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
@@ -1107,15 +1146,29 @@ export class ScriptRun {
value = tempWild?.species ?? 0;
else if (address === RAM.parkBallsRemaining && this.state.bugContest)
value = this.state.bugContest.parkBalls;
else if (savedByte(address))
value = this.state.memory[savedByte(address)!] ?? 0;
else throw new UnsupportedScript(offset, opcode);
break;
}
case 0x1a: {
// writemem: wScriptVar into a saved byte.
const label = savedByte(word());
if (!label) throw new UnsupportedScript(offset, opcode);
this.state.memory[label] = value;
break;
}
case 0x1b: {
// loadmem: the trainer script's wRunningTrainerBattleScript, and
// hBGMapMode, which only affects how the screen is redrawn.
const address = word(),
stored = byte();
if (address === RAM.bgMapMode) break;
const label = savedByte(address);
if (label) {
this.state.memory[label] = stored;
break;
}
if (address !== RAM.runningTrainerBattleScript)
throw new UnsupportedScript(offset, opcode);
temp(offset, opcode);
@@ -1480,14 +1533,16 @@ export class ScriptRun {
value = Number(this.state.specialPhoneCall !== 0);
break;
case 0x1f:
case 0x9e: {
case 0x9e:
case 0x9f: {
// verbosegiveitemvar takes the quantity from a script variable.
const item = byte(),
quantity = byte();
quantity = opcode === 0x9f ? this.readVar(byte(), offset) : byte();
this.currentItem = item === ITEM_FROM_MEM ? value : item;
value = Number(
receiveItem(data, this.state.bag, this.currentItem, quantity),
);
if (opcode === 0x9e) {
if (opcode !== 0x1f) {
// verbosegiveitem names the item, then calls the ROM's own
// GiveItemScript for its text and pocket message.
const name = data.itemName(this.currentItem);
@@ -1559,6 +1614,7 @@ export class ScriptRun {
),
});
this.state.party.push(mon);
updateUnownDex(this.state, mon);
this.state.pokedex.seen.add(species);
this.state.pokedex.caught.add(species);
// GiveANickname_YesNo reads the species name from wStringBuffer1.
@@ -2323,6 +2379,76 @@ export class ScriptRun {
}
}
/** SelectApricornForKurt (engine/events/kurt.asm): the apricorns in the
* Items pocket (FindApricornsInBag, in ApricornBalls' order) with their
* counts, then how many (BuySellToss_InterpretJoypad's 1 to the count,
* at most 99). B on the quantity returns to the list. The apricorns are
* taken and the count kept in wKurtApricornQuantity. Returns the
* apricorn, or 0 when none was chosen. */
private *selectApricornForKurt(
pages: string[],
take: () => string[],
): Generator<ScriptEffect, number, Input> {
const r = this.reader,
data = this.host.data,
bag = this.state.bag;
const write = (address: number) => {
const content = readText(r, address, this.text());
if (content) pages.push(...content.split("\n\n"));
};
this.state.memory.wKurtApricornQuantity = 0;
// Kurt_GetQuantityOfApricorn: every stack, at most 99.
const count = (item: number) =>
Math.min(
99,
bag.items
.filter((slot) => slot.item === item)
.reduce((sum, slot) => sum + slot.quantity, 0),
);
let selected = 0;
for (;;) {
write(JOHTO["Kurt_PrintTextWhichApricorn.WhichApricornText"]);
const apricorns: number[] = [];
for (let p = JOHTO.ApricornBalls; r.byte(p) !== 0xff; p += 2)
if (count(r.byte(p))) apricorns.push(r.byte(p));
if (!apricorns.length) {
take();
return 0;
}
const answer = yield {
kind: "menu",
pages: take(),
items: [...apricorns.map((item) => data.itemName(item)), "CANCEL"],
details: [
...apricorns.map((item) => `×${String(count(item)).padStart(2, " ")}`),
"",
],
selected,
};
if (typeof answer !== "number" || !Number.isInteger(answer))
throw new Error("Invalid apricorn.");
if (answer < 0 || answer >= apricorns.length) return 0;
selected = answer;
const item = apricorns[answer];
write(JOHTO["Kurt_PrintTextHowMany.HowManyShouldIMakeText"]);
const chosen = yield {
kind: "martquantity",
mode: "quantity",
item,
name: data.itemName(item),
price: 0,
max: count(item),
prompt: take().join("\n"),
selected: 1,
};
if (typeof chosen !== "number" || chosen < 1) continue;
if (chosen > count(item)) throw new Error("Invalid apricorn count.");
this.state.memory.wKurtApricornQuantity = chosen;
tossItem(bag, item, chosen);
return item;
}
}
/** VAR_MOVEMENT: only the states GetPlayerSprite knows. */
private setPlayerState(state: number, offset: number, opcode: number) {
if (![0, 1, 2, 4, 8].includes(state))
@@ -2592,6 +2718,11 @@ export class ScriptRun {
return this.host.position().y;
case 0x14:
return this.state.specialPhoneCall;
case 0x0e:
// .UnownCaught: the letters in wUnownDex.
return this.state.unownDex.length;
case 0x16:
return this.state.memory.wKurtApricornQuantity ?? 0;
default:
throw new UnsupportedScript(offset, 0x1c, ` (variable ${id})`);
}
@@ -2747,6 +2878,43 @@ export class ScriptRun {
case OVERWORLD.OverworldTownMap:
yield { kind: "townmap" };
return value;
case JOHTO.UnownPuzzle: {
// FadeToMenu: the text box closes first.
if (pages.length) yield { kind: "text", pages: take() };
const solved = yield* unownPuzzle(
{ reader: r, random: () => this.host.random() },
value,
);
return Number(solved);
}
case JOHTO.DisplayUnownWords: {
const tileset = this.host.tileset;
if (!tileset) return undefined;
// The wall's text stays on screen under the box.
if (pages.length) yield { kind: "text", pages: take() };
yield* displayUnownWords(
{
reader: r,
tileset: () => tileset(),
frame: this.state.options.textboxFrame,
},
value,
);
return value;
}
case JOHTO.SelectApricornForKurt:
return yield* this.selectApricornForKurt(pages, take);
case JOHTO.HoOhChamber:
if (hoOhChamber(this.state.party))
this.state.events.add(EVENTS.wallOpenedInHoOhChamber);
return value;
case JOHTO.OmanyteChamber:
if (
!this.state.events.has(EVENTS.wallOpenedInOmanyteChamber) &&
omanyteChamber(this.state.bag.items, this.state.party)
)
this.state.events.add(EVENTS.wallOpenedInOmanyteChamber);
return value;
case MOM.BankOfMom:
yield* bankOfMom(
{
+5
View File
@@ -53,6 +53,8 @@ export interface PokedexView {
entry?: PokedexEntryView;
/** String_SEEN and String_OWN from the ROM. */
labels: { seen: string; own: string };
/** Pokedex_LoadSelectedMonTiles: Unown shows wFirstUnownSeen's letter. */
unownLetter: number;
}
/** DisplayDexEntry: the number, name and category always; the height,
@@ -82,6 +84,8 @@ export interface PokedexHost {
dex: PokedexData;
seen: ReadonlySet<number>;
caught: ReadonlySet<number>;
/** wFirstUnownSeen, the letter Unown's picture shows (0 before any). */
firstUnownSeen?: number;
}
/** Pokedex (engine/pokedex/pokedex.asm) in New mode: the main list and
@@ -260,6 +264,7 @@ export class Pokedex {
return {
seen: this.host.seen.size,
own: this.host.caught.size,
unownLetter: this.host.firstUnownSeen || 1,
cursor: this.cursor,
scroll: this.scroll,
end: this.end,
+128
View File
@@ -0,0 +1,128 @@
import { FRAME_TILE, SPACE_TILE, fontTiles } from "../rom/font";
import type { RomReader } from "../rom/reader";
import { JOHTO } from "../rom/profile";
import type { PartyMon } from "../party/pokemon";
import type { ItemSlot } from "../party/bag";
import type { Palette } from "../world/types";
import type { ScriptEffect } from "./interpreter";
import { SCREEN_HEIGHT, SCREEN_WIDTH, type TileScreen } from "./screen";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** PAL_BG_BROWN and PAL_BG_TEXT (constants/tileset_constants.asm). */
const PAL_BG_BROWN = 5,
PAL_BG_TEXT = 7;
/** HO_OH, WATER_STONE, and the walls' events (constants). */
const HO_OH = 250,
WATER_STONE = 0x18;
/** The Ruins chambers' tilesets keep Unown letters in VRAM bank 1 (here
* tiles $100 on), with Y, Z and the dash in bank 0. */
const BANK_1 = 0x100;
/** What the Unown words need from the map on screen. */
export interface UnownWordsHost {
reader: RomReader;
/** The map's tileset graphics (bank 0 tiles, then bank 1 from $60) and
* its background palettes at this time of day. */
tileset(): { graphics: Uint8Array; palettes: Palette[] };
/** wTextboxFrame. */
frame: number;
}
/** DisplayUnownWords (engine/events/unown_walls.asm): MenuHeaders_UnownWalls'
* box for UNOWNWORDS_* `word`, with the word from UnownWalls in Unown
* letters (2 x 2 tiles each, brown), until A or B. */
export function* displayUnownWords(host: UnownWordsHost, word: number): Gen {
const r = host.reader,
{ graphics, palettes } = host.tileset();
// The word: -1-terminated strings in the unown charmap.
let p = JOHTO.UnownWalls;
for (let skip = word; skip > 0; skip--) while (r.byte(p++) !== 0xff);
const letters: number[] = [];
for (let c = r.byte(p); c !== 0xff; c = r.byte(++p)) letters.push(c);
// menu_coords: flags, top, left, bottom, right.
const header = JOHTO.MenuHeaders_UnownWalls + word * 5;
const top = r.byte(header + 1),
left = r.byte(header + 2),
bottom = r.byte(header + 3),
right = r.byte(header + 4);
const tiles = new Uint8Array(0x200 * 16);
tiles.set(fontTiles(r, host.frame));
tiles.set(graphics.slice(0, 0x60 * 16), 0);
tiles.set(graphics.slice(0x60 * 16, 0xc0 * 16), BANK_1 * 16);
const map = Array<number>(SCREEN_WIDTH * SCREEN_HEIGHT).fill(-1),
attrs = Array<number>(SCREEN_WIDTH * SCREEN_HEIGHT).fill(PAL_BG_TEXT);
const put = (x: number, y: number, tile: number, palette = PAL_BG_TEXT) => {
map[y * SCREEN_WIDTH + x] = tile;
attrs[y * SCREEN_WIDTH + x] = palette;
};
// MenuBox: TextboxBorder's frame ($79-$7e: ┌ ─ ┐ │ └ ┘) around spaces.
for (let y = top; y <= bottom; y++)
for (let x = left; x <= right; x++) {
const side = x === left ? 0 : x === right ? 2 : 1;
put(
x,
y,
y === top
? FRAME_TILE + [0, 1, 2][side]
: y === bottom
? FRAME_TILE + [4, 1, 5][side]
: side === 1
? SPACE_TILE
: FRAME_TILE + 3,
);
}
// _DisplayUnownWords_CopyWord two rows below the box's top: each letter
// c takes tiles c, c+1, c+$10, c+$11 (bank 1); Y, Z and the dash use
// fixed bank 0 tiles.
let x = left + 1;
const y = top + 2;
for (const c of letters) {
// The unown charmap puts letter i at $10 * (i / 8) + 2 * i: Y is
// $60, Z $62 and the dash $64.
const square =
c === 0x60
? [0x5b, 0x5c, 0x4d, 0x5d]
: c === 0x62
? [0x4e, 0x4f, 0x5e, 0x5f]
: c === 0x64
? [0x02, 0x03, 0x03, 0x02]
: [c, c + 1, c + 0x10, c + 0x11].map((tile) => BANK_1 + tile);
put(x, y, square[0], PAL_BG_BROWN);
put(x + 1, y, square[1], PAL_BG_BROWN);
put(x, y + 1, square[2], PAL_BG_BROWN);
put(x + 1, y + 1, square[3], PAL_BG_BROWN);
x += 2;
}
const screen: TileScreen = {
tiles,
map,
attrs,
palettes,
sprites: [],
objPalettes: palettes,
tileset: `unown-words-${word}`,
label: "UNOWN letters on the wall",
overlay: true,
};
// JoyWaitAorB, then CloseWindow.
for (;;) {
const button = yield { kind: "screen", screen };
if (button === "a" || button === "b") return;
}
}
/** HoOhChamber: Ho-Oh first in the party opens the chamber's wall. */
export function hoOhChamber(party: readonly PartyMon[]) {
return party[0] !== undefined && !party[0].egg && party[0].species === HO_OH;
}
/** OmanyteChamber: a WATER STONE in the Items pocket or held by a party
* Pokémon (checked from the last) opens the wall. */
export function omanyteChamber(items: readonly ItemSlot[], party: readonly PartyMon[]) {
return (
items.some((slot) => slot.item === WATER_STONE) ||
party.some((mon) => mon.item === WATER_STONE)
);
}
+104
View File
@@ -0,0 +1,104 @@
import type { RomReader } from "../rom/reader";
import type { Palette } from "../world/types";
/** The buttons a tile screen reads (hJoyPressed and hJoyLast). */
export const SCREEN_BUTTONS: readonly string[] = [
"a",
"b",
"start",
"select",
"up",
"down",
"left",
"right",
];
export const SCREEN_WIDTH = 20,
SCREEN_HEIGHT = 18;
/** One OAM entry in screen pixels (the top-left of its 8x8 tile). */
export interface ScreenSprite {
x: number;
y: number;
tile: number;
palette: number;
flipX?: boolean;
flipY?: boolean;
/** Hidden while bit 4 of the frame counter is clear, as screens that
* blink their cursor with hVBlankCounter do. */
blink?: boolean;
}
/** A full-screen Game Boy picture that a minigame or special draws in
* tiles: the background tilemap with its palettes and the sprites over it,
* all from ROM graphics. It is answered with the button pressed. */
export interface TileScreen {
/** 2bpp graphics; the tilemap and sprites index tiles in it. */
tiles: Uint8Array;
/** SCREEN_WIDTH x SCREEN_HEIGHT tile indices, row by row; -1 leaves
* the game view beneath visible (with `overlay`). */
map: number[];
/** The background palette (0-7) of each tile, row by row. */
attrs: number[];
palettes: Palette[];
sprites: ScreenSprite[];
objPalettes: Palette[];
/** Changes whenever `tiles` does, so presentation can cache them. */
tileset: string;
/** Read aloud with the picture (an accessible description). */
label: string;
/** Drawn over the overworld, like a menu box, rather than replacing it. */
overlay?: boolean;
}
/** A GBC color (RGB555, little-endian word) as RGB. */
export function rgb(value: number): [number, number, number] {
return [
Math.round(((value & 31) * 255) / 31),
Math.round((((value >> 5) & 31) * 255) / 31),
Math.round((((value >> 10) & 31) * 255) / 31),
];
}
/** Four GBC colors (8 bytes) from the ROM. */
export function romPalette(reader: RomReader, address: number): Palette {
return Array.from({ length: 4 }, (_, i) => rgb(reader.word(address + i * 2)));
}
/** An empty screen of tile 0 in palette 0. */
export function blankScreen(
tiles: Uint8Array,
tileset: string,
palettes: Palette[],
label: string,
): TileScreen {
return {
tiles,
map: Array<number>(SCREEN_WIDTH * SCREEN_HEIGHT).fill(0),
attrs: Array<number>(SCREEN_WIDTH * SCREEN_HEIGHT).fill(0),
palettes,
sprites: [],
objPalettes: palettes,
tileset,
label,
};
}
/** Fill a box of the tilemap with one tile (and optionally a palette). */
export function fillBox(
screen: TileScreen,
x: number,
y: number,
width: number,
height: number,
tile: number,
palette?: number,
) {
for (let row = y; row < y + height; row++)
for (let col = x; col < x + width; col++) {
if (col < 0 || row < 0 || col >= SCREEN_WIDTH || row >= SCREEN_HEIGHT)
continue;
screen.map[row * SCREEN_WIDTH + col] = tile;
if (palette !== undefined) screen.attrs[row * SCREEN_WIDTH + col] = palette;
}
}
+1
View File
@@ -337,6 +337,7 @@ export class StartMenuRun {
dex: this.host.dex,
seen: this.state.pokedex.seen,
caught: this.state.pokedex.caught,
firstUnownSeen: this.state.firstUnownSeen,
},
this.cursors.dex,
);
+267
View File
@@ -0,0 +1,267 @@
import { decompress } from "../rom/lz";
import type { RomReader } from "../rom/reader";
import { JOHTO, TRAINER_CARD, WRAM } from "../rom/profile";
import { OW } from "../world/constants";
import type { ScriptEffect } from "./interpreter";
import {
SCREEN_WIDTH,
blankScreen,
fillBox,
romPalette,
rgb,
type ScreenSprite,
type TileScreen,
} from "./screen";
type Input = number | string | undefined;
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
/** PUZZLE_BORDER and PUZZLE_VOID, and the tiles the puzzle loads into
* vTiles0: the cursor at $e0 and START/CANCEL's graphics at $ed. */
const PUZZLE_BORDER = 0xee,
PUZZLE_VOID = 0xef,
CURSOR_TILE = 0xe0,
START_CANCEL_TILE = 0xed;
/** The board is 6 x 6 places; pieces are 3 x 3 tiles. */
const PLACES = 6 * 6,
PIECES = 16;
/** UNOWNPUZZLE_* (constants/script_constants.asm), in .LZPointers order. */
const PUZZLE_GRAPHICS = [
JOHTO.KabutoPuzzleLZ,
JOHTO.OmanytePuzzleLZ,
JOHTO.AerodactylPuzzleLZ,
JOHTO.HoOhPuzzleLZ,
] as const;
/** .EnlargedTiles: each bit of a nybble doubled. */
const enlarge = (nybble: number) =>
(nybble & 8) * 0x18 + (nybble & 4) * 0x0c + (nybble & 2) * 0x06 + (nybble & 1) * 0x03;
/** LoadUnownPuzzlePiecesGFX and ConvertLoadedPuzzlePieces: the puzzle's
* 6 x 6 picture drawn at twice its size (12 x 12 tiles from $00), the
* piece borders ORed onto every piece (UnownPuzzle_AddPuzzlePieceBorders),
* then the cursor and START/CANCEL graphics. */
export function unownPuzzleTiles(reader: RomReader, puzzle: number) {
const tiles = new Uint8Array(0x100 * 16);
const picture = decompress(reader, PUZZLE_GRAPHICS[puzzle & 3], 0x1000);
let out = 0;
for (let row = 0; row < 6; row++)
for (let half = 0; half < 2; half++)
for (let column = 0; column < 6; column++) {
const source = (row * 6 + column) * 16 + half * 8;
for (const shift of [4, 0])
for (let line = 0; line < 4; line++) {
const low = enlarge((picture[source + line * 2] >> shift) & 15),
high = enlarge((picture[source + line * 2 + 1] >> shift) & 15);
tiles.set([low, high, low, high], out + line * 4);
if (line === 3) out += 16;
}
}
// PuzzlePieceBorderData: eight border tiles, each ORed onto its tile of
// every piece (four rows of four pieces, three tiles apart).
const borders = [0x00, 0x01, 0x02, 0x0c, 0x0e, 0x18, 0x19, 0x1a];
borders.forEach((offset, border) => {
for (let pieceRow = 0; pieceRow < 4; pieceRow++)
for (let pieceColumn = 0; pieceColumn < 4; pieceColumn++) {
const tile = offset + pieceColumn * 3 + pieceRow * 36;
for (let i = 0; i < 16; i++)
tiles[tile * 16 + i] |= reader.byte(
JOHTO["PuzzlePieceBorderData.TileBordersGFX"] + border * 16 + i,
);
}
});
tiles.set(reader.slice(JOHTO.UnownPuzzleCursorGFX, 4 * 16), CURSOR_TILE * 16);
const startCancel = decompress(reader, JOHTO.UnownPuzzleStartCancelLZ, 0x200);
tiles.set(
startCancel.slice(0, (0x100 - START_CANCEL_TILE) * 16),
START_CANCEL_TILE * 16,
);
return tiles;
}
/** What the puzzle needs from the game. */
export interface UnownPuzzleHost {
reader: RomReader;
/** The Random routine: one byte. */
random(): number;
}
/** The board as the screen shows it, and the button that ends a turn. */
export interface UnownPuzzleState {
/** wPuzzlePieces: the piece (1-16) at each of the 36 places, or 0. */
pieces: number[];
/** wUnownPuzzleCursorPosition. */
cursor: number;
/** wUnownPuzzleHeldPiece while wHoldingUnownPuzzlePiece is set. */
held: number;
}
/** InitUnownPuzzlePiecePositions: pieces 1-16 go to random places on the
* border, drawing again while the place drawn is taken. */
export function initialPieces(host: UnownPuzzleHost): number[] {
const r = host.reader,
pieces = Array<number>(PLACES).fill(0);
for (let piece = 1; piece <= PIECES; piece++)
for (let tries = 0; ; tries++) {
if (tries > 4096) throw new Error("Random draw limit exceeded.");
const place = r.byte(
JOHTO["InitUnownPuzzlePiecePositions.PuzzlePieceInitialPositions"] +
(host.random() & 15),
);
if (pieces[place]) continue;
pieces[place] = piece;
break;
}
return pieces;
}
/** UnownPuzzleJumptable's d-pad: the cursor stays on the board and steps
* over START/CANCEL along the bottom row. Undefined when it cannot move. */
export function moveCursor(cursor: number, direction: string): number | undefined {
const row = Math.floor(cursor / 6),
column = cursor % 6;
switch (direction) {
case "up":
return row ? cursor - 6 : undefined;
case "down":
return row === 5 || (row === 4 && column >= 1 && column <= 4)
? undefined
: cursor + 6;
case "left":
if (!column) return undefined;
return cursor === 35 ? 30 : cursor - 1;
case "right":
if (column === 5) return undefined;
return cursor === 30 ? 35 : cursor + 1;
}
return undefined;
}
/** CheckSolvedUnownPuzzle: every place matches the solved layout. */
export function solved(reader: RomReader, pieces: readonly number[]) {
return pieces.every(
(piece, place) =>
piece ===
reader.byte(JOHTO["CheckSolvedUnownPuzzle.SolvedPuzzleConfiguration"] + place),
);
}
/** _UnownPuzzle (engine/games/unown_puzzle.asm), as UnownPuzzle calls it
* with the UNOWNPUZZLE_* in wScriptVar. A picks up the piece under the
* cursor or puts the held one down on an empty place; START quits. Placing
* the last piece right shows the finished picture until A or B. Returns
* wSolvedUnownPuzzle. */
export function* unownPuzzle(host: UnownPuzzleHost, puzzle: number): Gen<boolean> {
const r = host.reader,
tiles = unownPuzzleTiles(r, puzzle);
const state: UnownPuzzleState = {
pieces: initialPieces(host),
cursor: 0,
held: 0,
};
for (;;) {
const button = yield { kind: "screen", screen: puzzleScreen(r, tiles, puzzle, state) };
if (button === "start") return false;
if (button === "a") {
const at = state.pieces[state.cursor];
if (!state.held) {
// Nothing to pick up is an invalid action (SFX_WRONG).
if (!at) continue;
state.held = at;
state.pieces[state.cursor] = 0;
continue;
}
if (at) continue;
state.pieces[state.cursor] = state.held;
state.held = 0;
if (!solved(r, state.pieces)) continue;
// PlaceStartCancelBoxBorder clears START/CANCEL; ClearSprites; then
// SimpleWaitPressAorB.
const done = puzzleScreen(r, tiles, puzzle, state, true);
for (;;) {
const press = yield { kind: "screen", screen: done };
if (press === "a" || press === "b") return true;
}
}
if (typeof button === "string") {
const next = moveCursor(state.cursor, button);
if (next !== undefined) state.cursor = next;
}
}
}
/** The puzzle's screen: the border, the 12 x 12 void, the pieces on their
* places (UnownPuzzle_UpdateTilemap), START/CANCEL, and the cursor sprite
* (blinking while no piece is held) or the held piece. */
export function puzzleScreen(
reader: RomReader,
tiles: Uint8Array,
puzzle: number,
state: UnownPuzzleState,
finished = false,
): TileScreen {
const r = reader;
// _CGB_UnownPuzzle: PREDEFPAL_UNOWN_PUZZLE for the background and
// sprites; DmgToCgbObjPal0 with $24 maps the cursor's color 3 to the red
// the layout put in color 0.
const palette = romPalette(r, TRAINER_CARD.PredefPals + OW.PREDEFPAL_UNOWN_PUZZLE * 8);
const red = rgb(31);
const screen = blankScreen(tiles, `unown-puzzle-${puzzle}`, [palette], "UNOWN puzzle");
screen.objPalettes = [[red, palette[1], palette[2], red]];
fillBox(screen, 0, 0, 20, 18, PUZZLE_BORDER);
fillBox(screen, 4, 3, 12, 12, PUZZLE_VOID);
const entry = (place: number) => JOHTO.UnownPuzzleCoordData + place * 6;
const corner = (piece: number) =>
r.byte(JOHTO["GetCurrentPuzzlePieceVTileCorner.Corners"] + piece);
for (let place = 0; place < PLACES; place++) {
const cell = r.word(entry(place) + 2) - WRAM.wTilemap,
x = cell % SCREEN_WIDTH,
y = Math.floor(cell / SCREEN_WIDTH);
const piece = state.pieces[place];
if (!piece) {
fillBox(screen, x, y, 3, 3, r.byte(entry(place) + 4));
continue;
}
for (let row = 0; row < 3; row++)
for (let column = 0; column < 3; column++)
screen.map[(y + row) * SCREEN_WIDTH + x + column] =
corner(piece) + row * 12 + column;
}
// PlaceStartCancelBox: the box border at (4, 15), then $f6-$ff inside;
// once solved only the border remains, filled with the void.
fillBox(screen, 4, 15, 1, 1, 0xf0);
fillBox(screen, 5, 15, 10, 1, 0xf1);
fillBox(screen, 15, 15, 1, 1, 0xf2);
fillBox(screen, 4, 16, 1, 1, 0xf3);
fillBox(screen, 15, 16, 1, 1, 0xf3);
fillBox(screen, 4, 17, 1, 1, 0xf4);
fillBox(screen, 5, 17, 10, 1, 0xf1);
fillBox(screen, 15, 17, 1, 1, 0xf5);
for (let i = 0; i < 10; i++)
screen.map[16 * SCREEN_WIDTH + 5 + i] = finished ? PUZZLE_VOID : 0xf6 + i;
if (finished) return screen;
// RedrawUnownPuzzlePieces: OAM_HoldingPiece or OAM_NotHoldingPiece at
// the cursor's OAM coordinates (dbpixel), from the held piece's corner
// tile or the cursor's ($e0).
const x = r.byte(entry(state.cursor)),
y = r.byte(entry(state.cursor) + 1);
const layout = state.held
? JOHTO["RedrawUnownPuzzlePieces.OAM_HoldingPiece"]
: JOHTO["RedrawUnownPuzzlePieces.OAM_NotHoldingPiece"];
const base = state.held ? corner(state.held) : CURSOR_TILE;
const sprites: ScreenSprite[] = [];
for (let p = layout; r.byte(p) !== 0xff; p += 4) {
const attributes = r.byte(p + 3);
sprites.push({
// OAM coordinates are 16 (y) and 8 (x) beyond the screen's.
y: ((y + r.byte(p)) & 0xff) - 16,
x: ((x + r.byte(p + 1)) & 0xff) - 8,
tile: (base + r.byte(p + 2)) & 0xff,
palette: 0,
...(attributes & 0x20 && { flipX: true }),
...(attributes & 0x40 && { flipY: true }),
...(!state.held && { blink: true }),
});
}
screen.sprites = sprites;
return screen;
}
+3
View File
@@ -48,6 +48,9 @@ export const OW = {
ENGINE_ALWAYS_ON_BIKE: 25,
ENGINE_DOWNHILL: 26,
ENGINE_ZEPHYRBADGE: 27,
ENGINE_UNLOCKED_UNOWNS_A_TO_K: 43,
ENGINE_UNOWN_DEX: 12,
PREDEFPAL_UNOWN_PUZZLE: 76,
ENGINE_HIVEBADGE: 28,
ENGINE_PLAINBADGE: 29,
ENGINE_FOGBADGE: 30,
+73 -6
View File
@@ -12,7 +12,9 @@ import {
NUM_FRUIT_TREES,
OVERWORLD,
PC,
SAVED_WRAM,
SYMBOLS,
type SavedByte,
} from "../rom/profile";
import { OW } from "./constants";
import {
@@ -56,6 +58,7 @@ import {
} from "../scripts/interpreter";
import { IntroRun } from "../scripts/intro";
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
import {
@@ -170,6 +173,11 @@ export interface NativeSave {
blueCardBalance: number;
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
tradeFlags: number;
/** Saved WRAM bytes scripts use directly, by PRET label (SAVED_WRAM). */
memory: Partial<Record<SavedByte, number>>;
/** wUnownDex and wFirstUnownSeen. */
unownDex: number[];
firstUnownSeen: number;
/** The option bytes, saved with the game as sOptions is. */
options: GameOptions;
}
@@ -319,6 +327,8 @@ export type Choice =
| { kind: "options"; view: OptionsView; selected: 0 }
/** The Fly map, answered with the button pressed. */
| { kind: "flymap"; view: FlyMapView; selected: 0 }
/** A tile screen (a minigame), answered with the button pressed. */
| { kind: "screen"; screen: TileScreen; selected: 0 }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
@@ -338,7 +348,8 @@ const buttonChoice = (choice: Choice) =>
choice.kind === "trainercard" ||
choice.kind === "pokedex" ||
choice.kind === "options" ||
choice.kind === "flymap";
choice.kind === "flymap" ||
choice.kind === "screen";
export interface Dialogue {
pages: string[];
index: number;
@@ -1697,8 +1708,14 @@ export class GameWorld {
for (const threshold of [35, 65, 85, 95])
if (variety >= Math.floor((threshold * 255) / 100)) chosen.level++;
}
// ValidateTempWildMonSpecies; Unown needs unlocked Unown (not ported).
if (!chosen.species || chosen.species > 251 || chosen.species === C.UNOWN)
// ValidateTempWildMonSpecies; no Unown before a letter set is unlocked.
if (!chosen.species || chosen.species > 251) return undefined;
if (
chosen.species === C.UNOWN &&
!Array.from({ length: 8 }, (_, bit) => OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K + bit).some(
(flag) => this.state.flags.has(flag),
)
)
return undefined;
return chosen;
}
@@ -2086,6 +2103,10 @@ export class GameWorld {
environment: () => this.assets.map(mapId).environment,
phoneService: () => this.assets.map(mapId).phoneService,
facingObject: () => this.facingObject(),
tileset: () => ({
graphics: this.assets.tileset(this.map).graphics,
palettes: this.assets.palettes(this.map, this.timeOfDay),
}),
};
}
/** FishFunction with rod 0-2 (Old, Good, Super): never while surfing,
@@ -2230,7 +2251,10 @@ export class GameWorld {
}
/** Select in PlayerEvents (SelectMenuScript): the registered item. */
useSelect(): boolean {
if (this.dialogue?.choice?.kind === "pokegear") {
if (
this.dialogue?.choice?.kind === "pokegear" ||
this.dialogue?.choice?.kind === "screen"
) {
this.choose("select");
return true;
}
@@ -2243,7 +2267,11 @@ export class GameWorld {
/** Start in PlayerEvents (StartMenuScript): the start menu opens while
* the player stands still. Its changes commit when it closes. */
openStartMenu(select = false): boolean {
if (!select && this.dialogue?.choice?.kind === "pokegear") {
if (
!select &&
(this.dialogue?.choice?.kind === "pokegear" ||
this.dialogue?.choice?.kind === "screen")
) {
this.choose("start");
return true;
}
@@ -2555,6 +2583,11 @@ export class GameWorld {
},
shift: shiftBattleStyle(draft.options),
battleScene: battleSceneOn(draft.options),
unown: draft,
// wUnlockedUnowns: the ENGINE_UNLOCKED_UNOWNS_* flags' bits.
unlockedUnowns: Array.from({ length: 8 }, (_, bit) =>
draft.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K + bit) ? 1 << bit : 0,
).reduce((bits, bit) => bits | bit, 0),
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -2571,6 +2604,13 @@ export class GameWorld {
choice: { kind: "flymap", view: effect.view, selected: 0 },
};
return true;
case "screen":
this.dialogue = {
pages: [""],
index: 0,
choice: { kind: "screen", screen: effect.screen, selected: 0 },
};
return true;
case "fly":
// FlyFromAnim's loop runs 129 frames after a DelayFrame, FlyToAnim's
// 65: 65 and 33 overworld ticks.
@@ -4246,7 +4286,9 @@ export class GameWorld {
(dialogue.choice.kind === "options" &&
!OPTIONS_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "flymap" &&
!FLY_MAP_BUTTONS.includes(answer as never))
!FLY_MAP_BUTTONS.includes(answer as never)) ||
(dialogue.choice.kind === "screen" &&
!SCREEN_BUTTONS.includes(answer as never))
)
return;
this.townMap = false;
@@ -4326,6 +4368,9 @@ export class GameWorld {
coins: state.coins,
tradeFlags: state.tradeFlags,
blueCardBalance: state.blueCardBalance,
memory: { ...state.memory },
unownDex: [...state.unownDex],
firstUnownSeen: state.firstUnownSeen,
options: { ...state.options },
};
}
@@ -4447,6 +4492,17 @@ export class GameWorld {
!integer(save.coins, 0, C.MAX_COINS) ||
!integer(save.tradeFlags, 0, 0x7f) ||
!integer(save.blueCardBalance, 0, 255) ||
!save.memory ||
typeof save.memory !== "object" ||
Object.entries(save.memory).some(
([label, byte]) =>
!Object.hasOwn(SAVED_WRAM, label) || !integer(byte, 0, 255),
) ||
!Array.isArray(save.unownDex) ||
save.unownDex.length > 26 ||
!save.unownDex.every((letter) => integer(letter, 1, 26)) ||
new Set(save.unownDex).size !== save.unownDex.length ||
!integer(save.firstUnownSeen, 0, 26) ||
!validOptions(save.options)
) {
throw new Error(
@@ -4519,6 +4575,9 @@ export class GameWorld {
coins: save.coins,
tradeFlags: save.tradeFlags,
blueCardBalance: save.blueCardBalance,
memory: { ...save.memory },
unownDex: [...save.unownDex],
firstUnownSeen: save.firstUnownSeen,
options: { ...save.options },
specialPhoneCall: save.specialPhoneCall,
rivalName: save.rivalName,
@@ -4648,6 +4707,11 @@ export function migrateSave(
}
: {}),
...(!Object.hasOwn(save, "tradeFlags") && { tradeFlags: 0 }),
...(!Object.hasOwn(save, "memory") && { memory: {} }),
...(!Object.hasOwn(save, "unownDex") && {
unownDex: [],
firstUnownSeen: 0,
}),
...(!Object.hasOwn(save, "intro") && { intro: false }),
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
@@ -4701,6 +4765,9 @@ export function migrateSave(
coins: 0,
blueCardBalance: 0,
tradeFlags: 0,
memory: {},
unownDex: [],
firstUnownSeen: 0,
intro: false,
gameTime: emptyGameTime(),
options: defaultOptions(),
+50 -11
View File
@@ -1,4 +1,5 @@
import { drawAtlas, drawTownMap } from "./game/rendering/town-map";
import { drawTileScreen } from "./game/rendering/tile-screen";
import type { AtlasRegion } from "./game/world/atlas";
import {
drawCardPic,
@@ -88,6 +89,7 @@ function showWorld(world: GameWorld) {
showPokedex(world);
showOptions(world);
showFlyMap(world);
showTileScreen(world);
get("town-map-view").hidden = !world.townMap;
if (world.townMap)
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
@@ -107,6 +109,7 @@ function showWorld(world: GameWorld) {
dialogue.choice?.kind === "pokedex" ||
dialogue.choice?.kind === "options" ||
dialogue.choice?.kind === "flymap" ||
dialogue.choice?.kind === "screen" ||
Boolean(mart) ||
world.townMap ||
Boolean(world.statsScreen) ||
@@ -167,10 +170,11 @@ function showChoice(choice: Choice | undefined) {
choice.kind === "trainercard" ||
choice.kind === "pokedex" ||
choice.kind === "options" ||
choice.kind === "flymap"
choice.kind === "flymap" ||
choice.kind === "screen"
) {
// The Pokégear, the trainer card, the Pokédex, the options and the
// Fly map read the controls directly.
// The Pokégear, the trainer card, the Pokédex, the options, the Fly
// map and tile screens read the controls directly.
container.replaceChildren();
container.hidden = true;
} else if (choice.kind === "yesno")
@@ -416,7 +420,13 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
(mart.kind !== "pack" && (mart.mode === "buy" || mart.mode === "sell"));
get("mart").setAttribute(
"aria-label",
shop ? "Poké Mart" : mart.kind === "pack" ? "Pack" : "Item storage",
shop
? "Poké Mart"
: mart.kind === "pack"
? "Pack"
: mart.kind === "martquantity" && mart.mode === "quantity"
? "How many"
: "Item storage",
);
get("mart-money").hidden = !shop;
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
@@ -555,9 +565,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
);
menu.replaceChildren(
stepper,
button(mart.mode.toUpperCase(), () => runtime.choose(mart.selected), {
wide: true,
}),
button(
mart.mode === "quantity" ? "OK" : mart.mode.toUpperCase(),
() => runtime.choose(mart.selected),
{ wide: true },
),
button("CANCEL", () => runtime.choose(-1), { wide: true }),
);
}
@@ -740,7 +752,7 @@ function showBattle(world: GameWorld) {
battlePics.foe = key;
drawPic(
get("battle-foe-pic"),
data.frontPic(foe.species),
data.frontPic(foe.species, PokemonData.unownLetter(foe.dvs)),
7,
data.palette(foe.species, PokemonData.shiny(foe.dvs)),
);
@@ -758,7 +770,7 @@ function showBattle(world: GameWorld) {
battlePics.player = key;
drawPic(
get("battle-player-pic"),
data.backPic(own.species),
data.backPic(own.species, PokemonData.unownLetter(own.dvs)),
6,
data.palette(own.species, PokemonData.shiny(own.dvs)),
);
@@ -1184,10 +1196,11 @@ function showPokedexTab() {
owned: state.flags.has(ENGINE_FLAGS.pokedex),
seen: state.pokedex.seen,
caught: state.pokedex.caught,
firstUnownSeen: state.firstUnownSeen,
};
const key = JSON.stringify([
Boolean(world),
progress && [progress.owned, [...progress.seen], [...progress.caught]],
progress && [progress.owned, [...progress.seen], [...progress.caught], progress.firstUnownSeen],
pokedexTabSelection,
]);
if (key === pokedexTabKey) return;
@@ -1286,6 +1299,32 @@ function showOptions(world: GameWorld) {
);
}
let tileScreenKey = "";
/** A minigame or special's tile screen; blinking sprites follow the
* world's frames. */
function showTileScreen(world: GameWorld) {
const choice = world.dialogue?.choice;
const screen = choice?.kind === "screen" ? choice.screen : undefined;
get("tile-screen").hidden = !screen;
get("tile-screen").classList.toggle("overlay", Boolean(screen?.overlay));
if (!screen) {
tileScreenKey = "";
return;
}
const blink = screen.sprites.some((sprite) => sprite.blink);
const frame = world.ticks * 2;
const key = JSON.stringify([
screen.tileset,
screen.map,
screen.attrs,
screen.sprites,
blink && frame & 0x10,
]);
if (key === tileScreenKey) return;
tileScreenKey = key;
drawTileScreen(get<HTMLCanvasElement>("tile-screen-canvas"), screen, frame);
}
let flyMapKey = "";
/** _FlyMap: TownMapBubble's "Where?" and flypoint name over the Town Map,
* with the Fly user's icon on the flypoint. */
@@ -1445,7 +1484,7 @@ function showStatsScreen(world: GameWorld) {
}
drawPic(
get("stats-pic"),
data.frontPic(mon.species),
data.frontPic(mon.species, PokemonData.unownLetter(mon.dvs)),
7,
data.palette(mon.species, shiny),
);
+9 -5
View File
@@ -27,6 +27,7 @@ function dexPicture(
assets: CrystalAssets,
species: number | undefined,
ownPalette: boolean,
unownLetter = 1,
) {
const canvas = element("canvas", "", "pokedex-pic");
canvas.width = canvas.height = 56;
@@ -34,7 +35,7 @@ function dexPicture(
const tiles =
species === undefined
? assets.pokedex.questionMarkPic()
: assets.pokemon.frontPic(species);
: assets.pokemon.frontPic(species, unownLetter);
const palette =
species !== undefined && ownPalette
? assets.pokemon.palette(species)
@@ -97,6 +98,7 @@ function ownedMark(owned: boolean) {
* category, the number and the measurements. */
function entryHeader(
assets: CrystalAssets,
unownLetter: number,
entry: {
species: number;
name: string;
@@ -116,7 +118,7 @@ function entryHeader(
element("span", entry.weight),
);
header.append(
dexPicture(assets, entry.species, true),
dexPicture(assets, entry.species, true, unownLetter),
details,
footprint(assets, entry.species),
);
@@ -144,7 +146,7 @@ export function renderPokedexScreen(
actions.append(item);
});
container.replaceChildren(
entryHeader(assets, entry),
entryHeader(assets, view.unownLetter, entry),
element("div", "", "pokedex-text-box"),
actions,
);
@@ -159,7 +161,7 @@ export function renderPokedexScreen(
element("dt", view.labels.own),
element("dd", String(view.own).padStart(3, " ")),
);
side.append(dexPicture(assets, view.picture, false), counts);
side.append(dexPicture(assets, view.picture, false, view.unownLetter), counts);
const list = element("ol", "", "pokedex-list");
view.rows.forEach((row, index) => {
const item = element("li");
@@ -185,6 +187,8 @@ export interface PokedexProgress {
owned: boolean;
seen: ReadonlySet<number>;
caught: ReadonlySet<number>;
/** wFirstUnownSeen: Unown's picture letter. */
firstUnownSeen: number;
}
/** The Pokédex tab: New mode's list up to the last seen Pokémon, and the
@@ -263,7 +267,7 @@ export function renderPokedexTab(
const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"),
weight = dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight");
detail.append(
entryHeader(assets, {
entryHeader(assets, progress.firstUnownSeen || 1, {
species,
name: assets.pokemon.speciesName(species),
category: entry.category,
+1 -1
View File
@@ -38,7 +38,7 @@ export function renderShell(root: HTMLElement) {
<div id="trainer-card" class="trainer-card" hidden aria-label="Trainer card"><div class="trainer-card-top"><dl id="trainer-card-info"></dl><canvas id="trainer-card-pic" width="40" height="56" role="img"></canvas></div><div id="trainer-card-page" class="trainer-card-page"></div></div>
<div id="pokedex-screen" class="pokedex-screen" hidden aria-label="Pokédex"></div>
<div id="options" class="options-screen" hidden aria-label="Options"><ol id="options-list"></ol></div>
<div id="fly-map" class="fly-map" hidden aria-label="Fly map"><p id="fly-map-bubble" class="fly-map-bubble" aria-live="polite"></p><canvas id="fly-map-canvas" width="160" height="144" role="img"></canvas></div>
<div id="tile-screen" class="tile-screen" hidden><canvas id="tile-screen-canvas" width="160" height="144" role="img"></canvas></div><div id="fly-map" class="fly-map" hidden aria-label="Fly map"><p id="fly-map-bubble" class="fly-map-bubble" aria-live="polite"></p><canvas id="fly-map-canvas" width="160" height="144" role="img"></canvas></div>
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
<div id="mart" class="mart" hidden aria-label="Poké Mart">
<p id="mart-money" class="mart-money"></p>
+25 -1
View File
@@ -644,7 +644,8 @@ dialog h2 {
.trainer-card:not([hidden]) ~ .minimap-overlay,
.pokedex-screen:not([hidden]) ~ .minimap-overlay,
.options-screen:not([hidden]) ~ .minimap-overlay,
.fly-map:not([hidden]) ~ .minimap-overlay {
.fly-map:not([hidden]) ~ .minimap-overlay,
.tile-screen:not([hidden]) ~ .minimap-overlay {
display: none;
}
.pokepic {
@@ -1890,3 +1891,26 @@ dialog dd {
font-weight: normal;
white-space: pre;
}
/* A minigame or special drawn in tiles (the Unown puzzle and others). */
.tile-screen {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
background: #000;
}
.tile-screen[hidden] {
display: none;
}
.tile-screen.overlay {
background: transparent;
}
.tile-screen canvas {
width: auto;
height: 100%;
max-width: 100%;
image-rendering: pixelated;
object-fit: contain;
}
+2 -1
View File
@@ -45,6 +45,7 @@ test("the Map tab shows the Johto atlas with the player and open areas", async (
const open = page.locator("#atlas-open li");
await expect(open.first()).toHaveText("NEW BARK TOWN");
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
await expect(page.locator("#atlas-open")).not.toContainText("AZALEA TOWN");
await expect(page.locator("#atlas-open")).toContainText("AZALEA TOWN");
await expect(page.locator("#atlas-open")).not.toContainText("BLACKTHORN CITY");
expect(errors).toEqual([]);
});
+3 -1
View File
@@ -17,6 +17,7 @@ const NEW_BARK_TOWN = "24:4",
LANDMARK_CHERRYGROVE_CITY = 3,
LANDMARK_VIOLET_CITY = 6,
LANDMARK_AZALEA_TOWN = 12,
LANDMARK_BLACKTHORN_CITY = 0x29,
LANDMARK_PALLET_TOWN = 47;
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/maps/landmarks.asm`))(
@@ -98,7 +99,8 @@ describe.skipIf(!existsSync(romPath))("the region atlas", () => {
expect(at(LANDMARK_VIOLET_CITY)).toMatchObject({ open: true, flypoint: { visited: false } });
expect(at(LANDMARK_ROUTE_29)).toMatchObject({ open: true });
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: false, flypoint: { visited: false } });
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: true, flypoint: { visited: false } });
expect(at(LANDMARK_BLACKTHORN_CITY)).toMatchObject({ open: false, flypoint: { visited: false } });
// Kanto stays closed, so asking for it shows Johto.
expect(world.atlas("kanto").region).toBe("johto");
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Azalea Town with the local ROM", () => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const KURTS_HOUSE = "8:4";
const EVENT_CLEARED_SLOWPOKE_WELL = 0x2b,
EVENT_KURT_GAVE_YOU_LURE_BALL = 0x35,
EVENT_GAVE_KURT_RED_APRICORN = 0x258,
ENGINE_KURT_MAKING_BALLS = 80;
const RED_APRICORN = 0x55,
BLU_APRICORN = 0x56,
LEVEL_BALL = 0x9f;
const world = (day: number) => {
let seed = 9;
const game = new GameWorld(
new CrystalAssets(bytes),
() => new Date(`2026-09-${day}T12:00:00`),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
game.state.party.push(
generatePartyMon(game.assets.pokemon, {
species: 155, level: 20, item: 0, otId: game.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0,
}),
);
return game;
};
/** Answer menus and quantities in order, collecting the pages shown. */
const answer = (game: GameWorld, answers: number[]) => {
const pages: string[] = [];
for (let tick = 0; tick < 5000; tick++) {
const dialogue = game.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
if (page && pages.at(-1) !== page) pages.push(page.replace(/\n/g, " "));
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) {
const choice = dialogue.choice;
if (choice.kind === "martquantity") {
pages.push(choice.prompt.replace(/\n/g, " "));
const wanted = answers.shift()!;
while (choice.selected < wanted) game.moveCursor("up");
game.choose(choice.selected);
} else game.choose(answers.shift() ?? 0);
} else game.advanceDialogue();
continue;
}
if (!game.busy) break;
game.tick();
}
expect(game.notice, game.diagnostic).toBe("");
return pages.join(" ");
};
it("makes Level Balls from Red Apricorns by the next day", () => {
const game = world(22);
game.state.events.add(EVENT_CLEARED_SLOWPOKE_WELL);
game.state.events.add(EVENT_KURT_GAVE_YOU_LURE_BALL);
game.state.bag.items = [
{ item: BLU_APRICORN, quantity: 1 },
{ item: RED_APRICORN, quantity: 4 },
];
game.restore({ ...game.save(), map: KURTS_HOUSE, x: 3, y: 3, direction: "up" });
settle(game);
game.interact();
// The list follows ApricornBalls: RED before BLU. Three of four.
let pages = answer(game, [0, 3]);
expect(pages).toContain("Which APRICORN");
expect(pages).toContain("How many");
expect(game.state.events.has(EVENT_GAVE_KURT_RED_APRICORN)).toBe(true);
expect(game.state.flags.has(ENGINE_KURT_MAKING_BALLS)).toBe(true);
expect(game.state.memory.wKurtApricornQuantity).toBe(3);
expect(game.state.bag.items).toEqual([
{ item: BLU_APRICORN, quantity: 1 },
{ item: RED_APRICORN, quantity: 1 },
]);
// The next day, ENGINE_KURT_MAKING_BALLS is cleared and the balls are
// ready.
const next = world(23);
next.restore(JSON.parse(JSON.stringify(game.save())));
settle(next);
expect(next.state.flags.has(ENGINE_KURT_MAKING_BALLS)).toBe(false);
// EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 clears when the house is entered
// again (ResetMapBufferEventFlags).
next.warpTo(KURTS_HOUSE, 3, 3);
settle(next);
next.move("up"); // Kurt: this only turns the player
next.interact();
pages = answer(next, [1]);
expect(next.state.bag.balls).toContainEqual({ item: LEVEL_BALL, quantity: 3 });
expect(next.state.events.has(EVENT_GAVE_KURT_RED_APRICORN)).toBe(false);
});
it("returns to the apricorn list when the quantity is cancelled", () => {
const game = world(22);
game.state.events.add(EVENT_CLEARED_SLOWPOKE_WELL);
game.state.events.add(EVENT_KURT_GAVE_YOU_LURE_BALL);
game.state.bag.items = [{ item: RED_APRICORN, quantity: 2 }];
game.restore({ ...game.save(), map: KURTS_HOUSE, x: 3, y: 3, direction: "up" });
settle(game);
game.interact();
// RED, cancel the quantity, then CANCEL the list.
for (let tick = 0; tick < 200 && game.dialogue?.choice?.kind !== "menu"; tick++)
if (game.dialogue && !game.dialogue.choice) game.advanceDialogue();
else game.tick();
game.choose(0);
expect(game.dialogue?.choice?.kind).toBe("martquantity");
game.choose(-1);
for (let tick = 0; tick < 200 && game.dialogue?.choice?.kind !== "menu"; tick++)
if (game.dialogue && !game.dialogue.choice) game.advanceDialogue();
else game.tick();
expect(game.dialogue?.choice?.kind).toBe("menu");
game.choose(1);
const pages = answer(game, []);
expect(pages).toContain("letdown");
expect(game.state.bag.items).toEqual([{ item: RED_APRICORN, quantity: 2 }]);
});
});
+4 -4
View File
@@ -18,7 +18,7 @@ const NEW_BARK_TOWN = "24:4",
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
const SPAWN_NEW_BARK = 14,
SPAWN_CHERRYGROVE = 15,
SPAWN_AZALEA = 18;
SPAWN_BLACKTHORN = 25;
const ENGINE_STORMBADGE = 32,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
const FLY = 0x13,
@@ -203,11 +203,11 @@ describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope",
it("refuses to fly to a flypoint that is not ported", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10);
world.state.flags.add(ENGINE_STORMBADGE);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_AZALEA);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_BLACKTHORN);
useMove(world, "FLY");
for (let press = 0; press < 4 && flyMap(world)?.name !== "AZALEA TOWN"; press++)
for (let press = 0; press < 4 && flyMap(world)?.name !== "BLACKTHORN CITY"; press++)
world.moveCursor("down");
expect(flyMap(world)?.name).toBe("AZALEA TOWN");
expect(flyMap(world)?.name).toBe("BLACKTHORN CITY");
world.choose("a");
until(world);
expect(world.notice).toBe("This destination is not ported yet.");
+18 -9
View File
@@ -517,16 +517,25 @@ describe.skipIf(!existsSync(romPath))(
load(),
() => new Date("2026-09-22T12:00:00"),
);
// Violet City's south edge leads to Route 32, which stays closed.
const city = world.assets.map("10:5"),
bottom = city.height - 1;
const edge = Array.from({ length: city.width }, (_, x) => x).find(
(x) =>
world.assets.permission(world.assets.collision(city, x, bottom)) === 0,
const start = world.save();
// New Bark Town's east edge is water leading to Route 27, which
// stays closed: a surfer is refused.
const town = world.assets.map("24:4"),
right = town.width - 1;
const edge = Array.from({ length: town.height }, (_, y) => y).find(
(y) =>
world.assets.permission(world.assets.collision(town, right, y)) === 1,
)!;
world.restore({ ...world.save(), map: "10:5", x: edge, y: bottom });
expect(world.move("down")).toBe(false);
expect(world.notice).toBe("Route 32 is not ported yet.");
world.restore({
...world.save(),
map: "24:4",
x: right,
y: edge,
playerState: 4, // PLAYER_SURF
});
expect(world.move("right")).toBe(false);
expect(world.notice).toBe("Route 27 is not ported yet.");
world.restore(start);
const {
scenes,
phone,
+488
View File
@@ -0,0 +1,488 @@
import { expect } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { encodeSave } from "../src/storage/saves";
import { CRYSTAL_ROM } from "../src/rom/validate";
import type { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, type NativeSave } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { C } from "../src/game/battle/constants";
import { findPath } from "./world-driver";
const STEP: Record<Direction, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
/** Warp mats and edges that need their direction pressed (.CheckWarp). */
const WARP_EXITS: Partial<Record<number, Direction>> = {
0x70: "down",
0x76: "left",
0x78: "up",
0x7e: "right",
};
/** Plays the game through the world's own inputs, as tests/playthrough
* does: scripts, battles and walking, with a seeded Random. A stage's save
* can be dumped (PLAYTHROUGH_SAVES) and resumed with `resume`. */
export class Playthrough {
world!: GameWorld;
readonly log: string[] = [];
/** A wild species to throw Poké Balls at instead of fighting. */
wanted = 0;
seed = 0x2a;
private stage = 0;
readonly now = () => new Date("2026-09-22T12:00:00");
readonly random = () => {
this.seed = (this.seed * 1103515245 + 12345) >>> 0;
return (this.seed >>> 16) & 255;
};
constructor(readonly assets: CrystalAssets) {}
/** A new world for the introduction. */
start() {
this.world = new GameWorld(this.assets, this.now, this.random);
}
/** Continue from a dumped stage. */
resume(dump: { save: NativeSave; seed: number }) {
this.seed = dump.seed;
this.world = new GameWorld(this.assets, this.now, this.random);
this.world.restore(dump.save);
}
/** The usable move with the most power, or the first with PP. */
strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => {
let best = -1;
mon.moves.forEach((move, i) => {
if (!move || !(mon.pp[i] & 63)) return;
if (
best < 0 ||
this.assets.pokemon.move(move).power >
this.assets.pokemon.move(mon.moves[best]).power
)
best = i;
});
return Math.max(0, best);
};
/** Run scripts and battles until the player is free again. Battles use
* the strongest move with PP; a wild battle below half HP is run from, as
* a cautious player would. New moves are learned when there is room,
* and other questions take the queued answers or their defaults. */
play = (answers: (number | string)[] = []) => {
const world = this.world,
log = this.log;
for (let tick = 0; tick < 100_000; tick++) {
expect(world.notice, world.diagnostic).toBe("");
const battle = world.battle;
if (battle) {
if (battle.message && log.at(-1) !== battle.message.text)
log.push(battle.message.text);
const prompt = battle.prompt;
if (prompt?.kind === "action") {
const { engine } = battle,
mon = engine.player.mon;
const ball = world.state.bag.balls.findIndex(
(slot) => slot.item === C.POKE_BALL,
);
if (
engine.battleMode === C.WILD_BATTLE &&
engine.enemy.mon.species === this.wanted &&
!world.state.party.some((member) => member.species === this.wanted) &&
ball >= 0
)
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
world.battleAction({ action: "run" });
else world.battleAction({ action: "fight", move: this.strongest(mon) });
} else if (prompt?.kind === "yesno") world.battleChoose(1);
// SHIFT's party menu: keep the Pokémon out (or send the first
// that can fight when one must go).
else if (prompt?.kind === "party")
world.battleChoose(
prompt.cancel
? -1
: world.state.party.findIndex(
(mon, i) =>
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
),
);
else if (prompt?.kind === "forget") world.battleChoose(4);
else if (prompt?.kind === "name") world.battleName("");
else if (prompt?.kind === "stats" || battle.message?.prompt)
world.interact();
else world.tick();
continue;
}
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1;
if (log.at(-1) !== page) log.push(page);
if (last && dialogue.choice) {
const answer = answers.shift();
if (dialogue.choice.kind === "name")
world.choose(typeof answer === "string" ? answer : "");
else
world.choose(
typeof answer === "number"
? answer
: dialogue.choice.kind === "yesno"
? 1
: dialogue.choice.kind === "clock"
? dialogue.choice.selected
: 0,
);
} else if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
continue;
}
if (!world.busy) {
world.tick(); // scene scripts and phone calls start on idle ticks
if (!world.busy) return;
continue;
}
world.tick();
}
throw new Error(`The world did not settle: ${world.diagnostic}`);
};
/** Walk to a tile on the current map, replanning after ledges, roaming
* NPCs and scenes that move the player. A warp target leaves the map. */
walk = (x: number, y: number) => {
const world = this.world,
map = world.mapId,
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
for (let attempt = 0; attempt < 40; attempt++) {
this.play();
if (warp && world.mapId !== map) return;
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
if (world.x === x && world.y === y) return;
for (const direction of findPath(world, x, y)) {
const [dx, dy] = STEP[direction];
const from = [world.x, world.y];
if (!world.move(direction)) {
for (let tick = 0; tick < 64; tick++) world.tick();
break;
}
this.play();
if (
world.mapId !== map ||
world.x !== from[0] + dx ||
world.y !== from[1] + dy
)
break;
}
}
throw new Error(
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}: ${world.notice}`,
);
};
/** Step across a map connection from the nearest usable edge tile. */
cross = (direction: Direction, target: string) => {
const world = this.world,
map = world.mapId;
const [dx, dy] = STEP[direction];
const edge = Array.from(
{ length: dx ? world.map.height : world.map.width },
(_, i) =>
dx
? [dx < 0 ? 0 : world.map.width - 1, i]
: [i, dy < 0 ? 0 : world.map.height - 1],
).flatMap(([x, y]) => {
try {
return [{ x, y, length: findPath(world, x, y).length }];
} catch {
return [];
}
});
edge.sort((a, b) => a.length - b.length);
for (const { x, y } of edge) {
this.walk(x, y);
if (world.move(direction)) {
this.play();
if (world.mapId === target) return;
}
if (world.mapId !== map) break;
}
throw new Error(
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
);
};
/** Stand in front of a warp leading to `target` and take it. */
enter = (target: string, direction: Direction) => {
const world = this.world;
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
expect(warp, `a warp to ${target}`).toBeDefined();
const [dx, dy] = STEP[direction];
this.walk(warp.x - dx, warp.y - dy);
expect(world.move(direction)).toBe(true);
this.play();
expect(world.mapId).toBe(target);
};
/** Leave through an exit mat, which needs the exit direction pressed. */
exit = (target: string, direction: Direction = "down") => {
const world = this.world;
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
this.walk(warp.x, warp.y);
if (world.mapId !== target) {
expect(world.move(direction)).toBe(true);
this.play();
}
expect(world.mapId).toBe(target);
};
/** Serialize the save to JSON and load it into a fresh world. */
reload = () => {
const saved = JSON.parse(JSON.stringify(this.world.save()));
// PLAYTHROUGH_SAVES=dir keeps every stage's save, for debugging a
// later stage from where the run reached.
if (process.env.PLAYTHROUGH_SAVES) {
mkdirSync(process.env.PLAYTHROUGH_SAVES, { recursive: true });
writeFileSync(
`${process.env.PLAYTHROUGH_SAVES}/${String(++this.stage).padStart(3, "0")}.json`,
JSON.stringify({ save: saved, seed: this.seed }),
);
}
this.world = new GameWorld(this.assets, this.now, this.random);
this.world.restore(saved);
expect(this.world.save()).toEqual(saved);
};
lead = () => this.world.state.party[0];
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
* tests/human-test-saves.test.ts writes them (kept out of git). */
writeTestSave = (name: string) => {
if (!process.env.MAKE_TEST_SAVES) return;
mkdirSync("test-saves", { recursive: true });
writeFileSync(
`test-saves/${name}.crystal-save.json`,
encodeSave({
version: 1,
romHash: CRYSTAL_ROM.sha1,
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: new TextEncoder().encode(JSON.stringify(this.world.save())),
}),
);
};
/** PokecenterNurseScript from in front of the counter, then back out. */
heal = (center: string, city: string) => {
this.enter(center, "up");
this.walk(3, 3);
this.world.move("up"); // the counter: this only turns the player
this.world.interact();
this.play();
expect(
this.world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0]),
).toBe(true);
this.exit(city);
};
/** Pace between two neighbouring grass tiles (the nearest pair) until
* the lead reaches `level`. `rest` heals and comes back here. */
train = (level: number, rest: () => void) => {
const world = this.world,
map = world.mapId,
grass = world.assets.grassCollisions();
const isGrass = (x: number, y: number) =>
x >= 0 &&
y >= 0 &&
x < world.map.width &&
y < world.map.height &&
grass.includes(world.assets.collision(world.map, x, y));
for (let round = 0; round < 2000 && this.lead().level < level; round++) {
if (this.lead().hp * 2 < this.lead().stats[0]) {
rest();
expect(this.world.mapId).toBe(map);
}
const pairs: { x: number; y: number; length: number }[] = [];
for (let y = 0; y < world.map.height; y++)
for (let x = 0; x < world.map.width; x++)
if (isGrass(x, y) && isGrass(x + 1, y))
try {
pairs.push({ x, y, length: findPath(world, x, y).length });
} catch {
/* unreachable */
}
pairs.sort((a, b) => a.length - b.length);
const { x, y } = pairs[0];
this.walk(x, y);
this.walk(x + 1, y);
}
expect(this.lead().level).toBeGreaterThanOrEqual(level);
};
/** Take the stairs (or another warp) to `target`, trying each. A warp
* mat or cave edge needs its exit direction pressed (.CheckWarp). */
stairs = (target: string) => {
const world = this.world;
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
this.walk(warp.x, warp.y);
if (world.mapId === target) return;
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
if (exit && world.move(exit)) {
this.play();
if (world.mapId === target) return;
}
}
throw new Error(`No way from ${world.mapId} to ${target}`);
};
/** Find a way through warps among `maps` to `target`, as a player
* trying staircases would: depth first, backing out of dead ends. */
route = (
target: string,
maps: string[],
depth = 6,
goal?: { x: number; y: number },
): boolean => {
const world = this.world;
const reachable = () => {
if (!goal) return true;
try {
findPath(world, goal.x, goal.y);
return true;
} catch {
return false;
}
};
if (world.mapId === target && reachable()) return true;
if (!depth) return false;
const here = world.save();
for (const warp of world.map.warps) {
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
const from = world.mapId;
this.walk(warp.x, warp.y);
if (world.mapId !== from && this.route(target, maps, depth - 1, goal))
return true;
world.restore(here);
}
return false;
};
/** Advance to the next menu choice, keeping its pages in the log. */
toChoice = () => {
const world = this.world,
log = this.log;
for (let tick = 0; tick < 2000; tick++) {
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
if (log.at(-1) !== page) log.push(page);
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
return dialogue.choice;
if (dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
} else world.tick();
}
throw new Error(`No choice appeared: ${world.diagnostic}`);
};
/** Choose a menu item by its label (or a pattern). */
choose = (label: string | RegExp) => {
const choice = this.toChoice();
if (choice.kind !== "menu")
throw new Error(`Expected a menu, got ${choice.kind}`);
const index = choice.items.findIndex((item) =>
typeof label === "string" ? item === label : label.test(item),
);
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
this.world.choose(index);
};
/** Choose an item in the pack. */
choosePack = (item: number) => {
const choice = this.toChoice();
if (choice.kind !== "pack")
throw new Error(`Expected the pack, got ${choice.kind}`);
for (const [pocket, { entries }] of choice.pockets.entries()) {
const index = entries.findIndex((entry) => entry.item === item);
if (index >= 0) return this.world.choosePackEntry(pocket, index);
}
throw new Error(`Item ${item} is not in the pack.`);
};
/** Talk to an object wherever it stands. A trainer who spots the player
* on the way battles and moves, so the approach is planned again. */
talkTo = (id: number) => {
const world = this.world;
for (let attempt = 0; attempt < 4; attempt++)
for (const [dx, dy, face] of [
[-1, 0, "right"],
[1, 0, "left"],
[0, 1, "up"],
[0, -1, "down"],
] as const) {
const target = world.objects().find((object) => object.id === id)!;
const x = target.x + dx,
y = target.y + dy;
try {
if (!(world.x === x && world.y === y)) this.walk(x, y);
} catch {
continue;
}
const now = world.objects().find((object) => object.id === id)!;
if (now.x !== target.x || now.y !== target.y) break;
world.move(face);
world.interact();
return;
}
throw new Error(`No way to talk to object ${id}`);
};
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
* a full move list forgets the first move. */
teach = (item: number, slot: number) => {
const world = this.world;
expect(world.openStartMenu()).toBe(true);
this.choose("PACK");
this.choosePack(item);
this.choose("USE");
expect(this.toChoice().kind).toBe("yesno");
world.choose(1);
const party = this.toChoice();
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
world.choose(slot);
if (world.liveState.party[slot].moves.every(Boolean)) {
expect(this.toChoice().kind).toBe("yesno");
world.choose(1);
this.toChoice();
world.choose(0);
}
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++)
this.toChoice();
// The menus work on a draft that is kept once they close.
for (let i = 0; i < 10 && world.busy; i++) {
if (world.dialogue?.choice) world.advanceDialogue();
else world.tick();
}
this.play();
};
/** Cut the tree at (x, y) from a walkable side (TryCutOW, then YES). */
cut = (x: number, y: number) => {
const world = this.world;
for (const [face, [dx, dy]] of Object.entries(STEP) as [Direction, [number, number]][]) {
try {
this.walk(x - dx, y - dy);
} catch {
continue;
}
world.move(face);
world.interact();
this.play([1]);
return;
}
throw new Error(`No side to cut ${x},${y} from`);
};
/** The current location and party, then the last pages, for a failure. */
trace = () =>
`${this.world.mapId} ${this.world.x},${this.world.y}; party ${JSON.stringify(
this.world.state.party.map(({ species, level, hp }) => ({
species,
level,
hp,
})),
)}\n${this.log.slice(-30).join("\n---\n")}`;
}
+228 -464
View File
@@ -1,12 +1,8 @@
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { encodeSave } from "../src/storage/saves";
import { CRYSTAL_ROM } from "../src/rom/validate";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { C } from "../src/game/battle/constants";
import { findPath } from "./world-driver";
import { Playthrough } from "./playthrough-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
@@ -29,6 +25,27 @@ const NEW_BARK = "24:4",
KYLES_HOUSE = "10:11";
const DARK_CAVE = "3:78",
EARLS_ACADEMY = "10:8";
const ROUTE_32 = "10:1",
ROUTE_32_CENTER = "10:13",
UNION_CAVE_1F = "3:37",
ROUTE_33 = "8:6",
AZALEA = "8:7",
AZALEA_CENTER = "8:1",
KURTS_HOUSE = "8:4",
AZALEA_GYM = "8:5",
SLOWPOKE_WELL_B1F = "3:40",
ILEX_GATE = "11:22",
ILEX_FOREST = "3:52",
ROUTE_34_GATE = "11:23";
const MIRACLE_SEED = 0x75,
LURE_BALL = 0xa0,
HM01 = 0xf3,
CUT = 15;
const EVENT_CLEARED_SLOWPOKE_WELL = 0x2b,
EVENT_BEAT_BUGSY = 0x4be,
EVENT_RIVAL_AZALEA_TOWN = 0x6bf,
EVENT_HERDED_FARFETCHD = 0x29,
ENGINE_HIVEBADGE = 0x1c;
const PHONE_BUG_CATCHER_WADE = 16;
const FLASH = 148,
HM05 = 0xf7;
@@ -50,336 +67,28 @@ const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
describe.skipIf(!existsSync(romPath))(
"a continuous opening playthrough",
() => {
const bytes = existsSync(romPath)
? new Uint8Array(readFileSync(romPath))
: new Uint8Array();
const now = () => new Date("2026-09-22T12:00:00");
let seed = 0x2a;
const random = () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
};
const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined;
let world: GameWorld;
const log: string[] = [];
/** A wild species to throw Poké Balls at instead of fighting. */
let wanted = 0;
/** The usable move with the most power, or the first with PP. */
const strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => {
let best = -1;
mon.moves.forEach((move, i) => {
if (!move || !(mon.pp[i] & 63)) return;
if (best < 0 || assets!.pokemon.move(move).power > assets!.pokemon.move(mon.moves[best]).power)
best = i;
});
return Math.max(0, best);
};
/** Run scripts and battles until the player is free again. Battles use
* the strongest move with PP; a wild battle below half HP is run from, as
* a cautious player would. New moves are learned when there is room,
* and other questions take the queued answers or their defaults. */
const play = (answers: (number | string)[] = []) => {
for (let tick = 0; tick < 100_000; tick++) {
expect(world.notice, world.diagnostic).toBe("");
const battle = world.battle;
if (battle) {
if (battle.message && log.at(-1) !== battle.message.text)
log.push(battle.message.text);
const prompt = battle.prompt;
if (prompt?.kind === "action") {
const { engine } = battle,
mon = engine.player.mon;
const ball = world.state.bag.balls.findIndex((slot) => slot.item === C.POKE_BALL);
if (
engine.battleMode === C.WILD_BATTLE &&
engine.enemy.mon.species === wanted &&
!world.state.party.some((member) => member.species === wanted) &&
ball >= 0
)
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
world.battleAction({ action: "run" });
else
world.battleAction({ action: "fight", move: strongest(mon) });
} else if (prompt?.kind === "yesno") world.battleChoose(1);
// SHIFT's party menu: keep the Pokémon out (or send the first
// that can fight when one must go).
else if (prompt?.kind === "party")
world.battleChoose(
prompt.cancel
? -1
: world.state.party.findIndex((mon, i) => i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0),
);
else if (prompt?.kind === "forget") world.battleChoose(4);
else if (prompt?.kind === "name") world.battleName("");
else if (prompt?.kind === "stats" || battle.message?.prompt)
world.interact();
else world.tick();
continue;
}
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1;
if (log.at(-1) !== page) log.push(page);
if (last && dialogue.choice) {
const answer = answers.shift();
if (dialogue.choice.kind === "name")
world.choose(typeof answer === "string" ? answer : "");
else
world.choose(
typeof answer === "number"
? answer
: dialogue.choice.kind === "yesno"
? 1
: dialogue.choice.kind === "clock"
? dialogue.choice.selected
: 0,
);
} else if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
continue;
}
if (!world.busy) {
world.tick(); // scene scripts and phone calls start on idle ticks
if (!world.busy) return;
continue;
}
world.tick();
}
throw new Error(`The world did not settle: ${world.diagnostic}`);
};
const STEP: Record<Direction, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
/** Walk to a tile on the current map, replanning after ledges, roaming
* NPCs and scenes that move the player. A warp target leaves the map. */
const walk = (x: number, y: number) => {
const map = world.mapId,
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
for (let attempt = 0; attempt < 40; attempt++) {
play();
if (warp && world.mapId !== map) return;
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
if (world.x === x && world.y === y) return;
for (const direction of findPath(world, x, y)) {
const [dx, dy] = STEP[direction];
const from = [world.x, world.y];
if (!world.move(direction)) {
for (let tick = 0; tick < 64; tick++) world.tick();
break;
}
play();
if (
world.mapId !== map ||
world.x !== from[0] + dx ||
world.y !== from[1] + dy
)
break;
}
}
throw new Error(
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}`,
);
};
/** Step across a map connection from the nearest usable edge tile. */
const cross = (direction: Direction, target: string) => {
const map = world.mapId;
const [dx, dy] = STEP[direction];
const edge = Array.from(
{ length: dx ? world.map.height : world.map.width },
(_, i) =>
dx
? [dx < 0 ? 0 : world.map.width - 1, i]
: [i, dy < 0 ? 0 : world.map.height - 1],
).flatMap(([x, y]) => {
try {
return [{ x, y, length: findPath(world, x, y).length }];
} catch {
return [];
}
});
edge.sort((a, b) => a.length - b.length);
for (const { x, y } of edge) {
walk(x, y);
if (world.move(direction)) {
play();
if (world.mapId === target) return;
}
if (world.mapId !== map) break;
}
throw new Error(
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
);
};
/** Stand in front of a warp leading to `target` and take it. */
const enter = (target: string, direction: Direction) => {
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
expect(warp, `a warp to ${target}`).toBeDefined();
const [dx, dy] = STEP[direction];
walk(warp.x - dx, warp.y - dy);
expect(world.move(direction)).toBe(true);
play();
expect(world.mapId).toBe(target);
};
/** Leave through an exit mat, which needs the exit direction pressed. */
const exit = (target: string, direction: Direction = "down") => {
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
walk(warp.x, warp.y);
if (world.mapId !== target) {
expect(world.move(direction)).toBe(true);
play();
}
expect(world.mapId).toBe(target);
};
const reload = () => {
const saved = JSON.parse(JSON.stringify(world.save()));
world = new GameWorld(assets!, now, random);
world.restore(saved);
expect(world.save()).toEqual(saved);
};
const lead = () => world.state.party[0];
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
* tests/human-test-saves.test.ts writes them (kept out of git). */
const writeTestSave = (name: string) => {
if (!process.env.MAKE_TEST_SAVES) return;
mkdirSync("test-saves", { recursive: true });
writeFileSync(
`test-saves/${name}.crystal-save.json`,
encodeSave({
version: 1,
romHash: CRYSTAL_ROM.sha1,
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: new TextEncoder().encode(JSON.stringify(world.save())),
}),
);
};
/** PokecenterNurseScript from in front of the counter, then back out. */
const heal = (center: string, city: string) => {
enter(center, "up");
walk(3, 3);
world.move("up"); // the counter: this only turns the player
world.interact();
play();
expect(world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0])).toBe(true);
exit(city);
};
/** Pace between two neighbouring grass tiles (the nearest pair) until
* the lead reaches `level`. `rest` heals and comes back here. */
const train = (level: number, rest: () => void) => {
const map = world.mapId,
grass = world.assets.grassCollisions();
const isGrass = (x: number, y: number) =>
x >= 0 && y >= 0 && x < world.map.width && y < world.map.height &&
grass.includes(world.assets.collision(world.map, x, y));
for (let round = 0; round < 2000 && lead().level < level; round++) {
if (lead().hp * 2 < lead().stats[0]) {
rest();
expect(world.mapId).toBe(map);
}
const pairs: { x: number; y: number; length: number }[] = [];
for (let y = 0; y < world.map.height; y++)
for (let x = 0; x < world.map.width; x++)
if (isGrass(x, y) && isGrass(x + 1, y))
try {
pairs.push({ x, y, length: findPath(world, x, y).length });
} catch {
/* unreachable */
}
pairs.sort((a, b) => a.length - b.length);
const { x, y } = pairs[0];
walk(x, y);
walk(x + 1, y);
}
expect(lead().level).toBeGreaterThanOrEqual(level);
};
/** Take the stairs (or another warp) to `target`, trying each. */
const stairs = (target: string) => {
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
walk(warp.x, warp.y);
if (world.mapId === target) return;
}
throw new Error(`No way from ${world.mapId} to ${target}`);
};
/** Find a way through warps among `maps` to `target`, as a player
* trying staircases would: depth first, backing out of dead ends. */
const route = (
target: string,
maps: string[],
depth = 6,
goal?: { x: number; y: number },
): boolean => {
const reachable = () => {
if (!goal) return true;
try {
findPath(world, goal.x, goal.y);
return true;
} catch {
return false;
}
};
if (world.mapId === target && reachable()) return true;
if (!depth) return false;
const here = world.save();
for (const warp of world.map.warps) {
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
const from = world.mapId;
walk(warp.x, warp.y);
if (world.mapId !== from && route(target, maps, depth - 1, goal)) return true;
world.restore(here);
}
return false;
};
/** Advance to the next menu choice, keeping its pages in the log. */
const toChoice = () => {
for (let tick = 0; tick < 2000; tick++) {
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
if (log.at(-1) !== page) log.push(page);
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) return dialogue.choice;
if (dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
} else world.tick();
}
throw new Error(`No choice appeared: ${world.diagnostic}`);
};
/** Choose a menu item by its label (or a pattern). */
const choose = (label: string | RegExp) => {
const choice = toChoice();
if (choice.kind !== "menu") throw new Error(`Expected a menu, got ${choice.kind}`);
const index = choice.items.findIndex((item) => (typeof label === "string" ? item === label : label.test(item)));
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
world.choose(index);
};
/** Choose an item in the pack. */
const choosePack = (item: number) => {
const choice = toChoice();
if (choice.kind !== "pack") throw new Error(`Expected the pack, got ${choice.kind}`);
for (const [pocket, { entries }] of choice.pockets.entries()) {
const index = entries.findIndex((entry) => entry.item === item);
if (index >= 0) return world.choosePackEntry(pocket, index);
}
throw new Error(`Item ${item} is not in the pack.`);
};
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const p = new Playthrough(assets!);
const {
play,
walk,
cross,
enter,
exit,
reload,
lead,
writeTestSave,
heal,
train,
stairs,
route,
toChoice,
choose,
choosePack,
talkTo,
} = p;
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
const restInViolet = () => {
exit(ROUTE_31_GATE, "left");
@@ -389,56 +98,46 @@ describe.skipIf(!existsSync(romPath))(
exit(ROUTE_31, "right");
};
beforeEach(() => {
log.length = 0;
p.log.length = 0;
});
const traceFailures = () =>
onTestFailed(() => {
console.log(
`${world.mapId} ${world.x},${world.y}; party ${JSON.stringify(
world.state.party.map(({ species, level, hp }) => ({
species,
level,
hp,
})),
)}\n${log.slice(-30).join("\n---\n")}`,
);
});
const traceFailures = () => onTestFailed(() => console.log(p.trace()));
it("plays the introduction and Mom's scene", () => {
world = new GameWorld(assets!, now, random);
p.start();
traceFailures();
world.beginIntro();
p.world.beginIntro();
play([0, "", 1, "", 1, 0, "KAWA"]);
expect(world.intro).toBe(false);
expect(world.name).toBe("KAWA");
expect(p.world.intro).toBe(false);
expect(p.world.name).toBe("KAWA");
reload();
walk(7, 0);
expect(world.mapId).toBe("24:6");
expect(p.world.mapId).toBe("24:6");
walk(9, 3);
expect(world.move("down")).toBe(true);
expect(p.world.move("down")).toBe(true);
play([1, 1, 1, 1, 1]);
expect(log).toContain("KAWA received\nPOKéGEAR.");
expect(p.log).toContain("KAWA received\nPOKéGEAR.");
reload();
});
it("meets Elm, takes Cyndaquil and the aide's Potion", () => {
traceFailures();
walk(6, 7);
expect(world.move("down")).toBe(true);
expect(p.world.move("down")).toBe(true);
play();
expect(world.mapId).toBe(NEW_BARK);
expect(p.world.mapId).toBe(NEW_BARK);
enter(ELMS_LAB, "up");
play([0, 1]);
walk(6, 4);
world.move("up");
world.interact();
p.world.move("up");
p.world.interact();
play([1, 1, ""]);
expect(world.state.party.map((mon) => mon.species)).toEqual([155]);
expect(p.world.state.party.map((mon) => mon.species)).toEqual([155]);
walk(4, 7);
expect(world.move("down")).toBe(true);
expect(p.world.move("down")).toBe(true);
play();
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
expect(p.world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
reload();
});
@@ -451,24 +150,24 @@ describe.skipIf(!existsSync(romPath))(
cross("up", ROUTE_30);
enter(MR_POKEMONS_HOUSE, "up");
play();
expect(world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG
expect(world.state.flags.has(11)).toBe(true); // the Pokédex
expect(p.world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG
expect(p.world.state.flags.has(11)).toBe(true); // the Pokédex
reload();
});
it("takes Elm's call outside and meets the rival in Cherrygrove", () => {
traceFailures();
exit(ROUTE_30);
expect(log.join("\n")).toContain("It's a disaster!");
expect(world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON
expect(p.log.join("\n")).toContain("It's a disaster!");
expect(p.world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON
reload();
cross("down", CHERRYGROVE);
// The rival's scene blocks the way east to Route 29.
cross("right", ROUTE_29);
const rival = log.join("\n");
const rival = p.log.join("\n");
expect(rival).toContain("RIVAL ?");
expect(rival).toContain("My name's ???.");
expect(world.state.scenes[CHERRYGROVE]).toBe(0);
expect(p.world.state.scenes[CHERRYGROVE]).toBe(0);
reload();
});
@@ -477,17 +176,17 @@ describe.skipIf(!existsSync(romPath))(
cross("right", NEW_BARK);
enter(ELMS_LAB, "up");
walk(4, 6);
expect(world.move("up")).toBe(true);
expect(p.world.move("up")).toBe(true);
play(["SILVER"]);
expect(world.state.rivalName).toBe("SILVER");
expect(p.world.state.rivalName).toBe("SILVER");
reload();
walk(5, 3);
world.move("up"); // Elm stands at 5,2: this only turns the player.
world.interact();
p.world.move("up"); // Elm stands at 5,2: this only turns the player.
p.world.interact();
play();
expect(world.state.bag.keyItems).toEqual([]);
expect(p.world.state.bag.keyItems).toEqual([]);
walk(4, 8);
expect(world.state.bag.balls).toEqual([
expect(p.world.state.bag.balls).toEqual([
{ item: C.POKE_BALL, quantity: 5 },
]);
reload();
@@ -498,13 +197,13 @@ describe.skipIf(!existsSync(romPath))(
exit(NEW_BARK);
cross("left", ROUTE_29);
cross("left", CHERRYGROVE);
const pages = log.join("\n").replace(/\n/g, " ");
const pages = p.log.join("\n").replace(/\n/g, " ");
expect(pages).toContain("show you how to catch POKéMON?");
expect(pages).toContain("That's how you do it");
expect(world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON
expect(world.state.scenes[ROUTE_29]).toBe(0);
expect(p.world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON
expect(p.world.state.scenes[ROUTE_29]).toBe(0);
reload();
const save = world.save();
const save = p.world.save();
expect(save.party[0].species).toBe(155);
expect(save.pokedex.caught).toContain(155);
expect(save.rivalName).toBe("SILVER");
@@ -528,9 +227,9 @@ describe.skipIf(!existsSync(romPath))(
exit(ROUTE_31_GATE, "left");
// Mom worries when the player has not come home since the Egg; the
// call rings on a step after Route 31's callback queued it.
expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), log.join("|")).toBe(true);
expect(p.world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), p.log.join("|")).toBe(true);
exit(VIOLET, "left");
expect(world.state.flags.size).toBeGreaterThan(0);
expect(p.world.state.flags.size).toBeGreaterThan(0);
reload();
writeTestSave("violet-city");
}, 120_000);
@@ -546,12 +245,12 @@ describe.skipIf(!existsSync(romPath))(
enter(SPROUT_1F, "up");
expect(route(SPROUT_3F, [SPROUT_1F, SPROUT_2F])).toBe(true);
walk(10, 3);
world.move("up"); // Elder Li: this only turns the player
world.interact();
p.world.move("up"); // Elder Li: this only turns the player
p.world.interact();
play();
const pages = log.join("\n").replace(/\n/g, " ");
const pages = p.log.join("\n").replace(/\n/g, " ");
expect(pages).toContain("FLASH");
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
expect(p.world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
reload();
}, 120_000);
@@ -562,42 +261,42 @@ describe.skipIf(!existsSync(romPath))(
heal(VIOLET_CENTER, VIOLET);
enter(VIOLET_GYM, "up");
walk(5, 2);
world.move("up"); // Falkner: this only turns the player
world.interact();
p.world.move("up"); // Falkner: this only turns the player
p.world.interact();
play();
expect(world.state.events.has(EVENT_BEAT_FALKNER), log.join("|")).toBe(true);
expect(world.state.flags.has(ENGINE_ZEPHYRBADGE)).toBe(true);
expect(world.state.events.has(EVENT_GOT_TM31_MUD_SLAP)).toBe(true);
expect(world.state.bag.tmsHms[30]).toBe(1); // TM31
expect(p.world.state.events.has(EVENT_BEAT_FALKNER), p.log.join("|")).toBe(true);
expect(p.world.state.flags.has(ENGINE_ZEPHYRBADGE)).toBe(true);
expect(p.world.state.events.has(EVENT_GOT_TM31_MUD_SLAP)).toBe(true);
expect(p.world.state.bag.tmsHms[30]).toBe(1); // TM31
reload();
exit(VIOLET);
// SPECIALCALL_ASSISTANT rings on a step: the aide waits in the
// Pokémon Center with the Egg.
enter(VIOLET_CENTER, "up");
expect(log.join("\n").replace(/\n/g, " ")).toContain("POKéMON CENTER");
expect(p.log.join("\n").replace(/\n/g, " ")).toContain("POKéMON CENTER");
// The aide's object flag (EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER).
const aide = world.assets.map(VIOLET_CENTER).objects[4];
expect(world.state.events.has(aide.eventFlag)).toBe(false);
const aide = p.world.assets.map(VIOLET_CENTER).objects[4];
expect(p.world.state.events.has(aide.eventFlag)).toBe(false);
reload();
}, 120_000);
it("takes the Egg from Elm's aide", () => {
traceFailures();
walk(4, 4);
world.move("up"); // the aide: this only turns the player
world.interact();
p.world.move("up"); // the aide: this only turns the player
p.world.interact();
play([1]);
expect(world.state.events.has(EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE)).toBe(true);
const egg = world.state.party.at(-1)!;
expect(p.world.state.events.has(EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE)).toBe(true);
const egg = p.world.state.party.at(-1)!;
expect(egg).toMatchObject({
species: TOGEPI,
egg: true,
nickname: "EGG",
hp: 0,
happiness: world.assets.pokemon.baseData(TOGEPI).eggCycles,
happiness: p.world.assets.pokemon.baseData(TOGEPI).eggCycles,
});
// An Egg is neither seen nor caught.
expect(world.state.pokedex.caught.has(TOGEPI)).toBe(false);
expect(p.world.state.pokedex.caught.has(TOGEPI)).toBe(false);
reload();
});
@@ -606,30 +305,30 @@ describe.skipIf(!existsSync(romPath))(
exit(VIOLET);
exit(ROUTE_31_GATE, "right");
exit(ROUTE_31, "right");
wanted = BELLSPROUT;
p.wanted = BELLSPROUT;
try {
for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++) {
if (!world.state.bag.balls.length) break;
for (let round = 0; round < 400 && !p.world.state.party.some((mon) => mon.species === BELLSPROUT); round++) {
if (!p.world.state.bag.balls.length) break;
train(lead().level + 1, restInViolet);
}
} finally {
wanted = 0;
p.wanted = 0;
}
const bellsprout = world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
expect(bellsprout, log.slice(-20).join("|")).toBeGreaterThan(0);
const bellsprout = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
expect(bellsprout, p.log.slice(-20).join("|")).toBeGreaterThan(0);
exit(ROUTE_31_GATE, "left");
exit(VIOLET, "left");
enter(KYLES_HOUSE, "up");
const kyle = world.objects().find((object) => object.sprite === world.assets.map(KYLES_HOUSE).objects[1].sprite && object.id === world.assets.map(KYLES_HOUSE).objects[1].id)!;
const kyle = p.world.objects().find((object) => object.sprite === p.world.assets.map(KYLES_HOUSE).objects[1].sprite && object.id === p.world.assets.map(KYLES_HOUSE).objects[1].id)!;
walk(kyle.x - 1, kyle.y);
world.move("right");
world.interact();
p.world.move("right");
p.world.interact();
play([1, bellsprout]);
const onix = world.state.party.at(-1)!;
const onix = p.world.state.party.at(-1)!;
expect(onix).toMatchObject({ species: ONIX, nickname: "ROCKY", ot: "KYLE", otId: 48926, dvs: 0x9666 });
expect(world.state.party.some((mon) => mon.species === BELLSPROUT && !mon.egg)).toBe(false);
expect(world.state.tradeFlags).toBe(1 << 1);
expect(world.state.pokedex.caught.has(ONIX)).toBe(true);
expect(p.world.state.party.some((mon) => mon.species === BELLSPROUT && !mon.egg)).toBe(false);
expect(p.world.state.tradeFlags).toBe(1 << 1);
expect(p.world.state.pokedex.caught.has(ONIX)).toBe(true);
reload();
}, 120_000);
@@ -638,100 +337,165 @@ describe.skipIf(!existsSync(romPath))(
exit(VIOLET);
exit(ROUTE_31_GATE, "right");
exit(ROUTE_31, "right");
wanted = BELLSPROUT;
p.wanted = BELLSPROUT;
try {
for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++)
for (let round = 0; round < 400 && !p.world.state.party.some((mon) => mon.species === BELLSPROUT); round++)
train(lead().level + 1, restInViolet);
} finally {
wanted = 0;
p.wanted = 0;
}
const slot = world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
const slot = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
expect(slot).toBeGreaterThan(0);
const nickname = world.state.party[slot].nickname;
const nickname = p.world.state.party[slot].nickname;
// PACK, the TM/HM pocket's HM05, USE, YES, then the Bellsprout.
expect(world.openStartMenu()).toBe(true);
expect(p.world.openStartMenu()).toBe(true);
choose("PACK");
choosePack(HM05);
choose("USE");
expect(toChoice().kind).toBe("yesno");
world.choose(1);
p.world.choose(1);
const party = toChoice();
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
world.choose(slot);
if (world.state.party[slot].moves.every(Boolean)) {
p.world.choose(slot);
if (p.world.state.party[slot].moves.every(Boolean)) {
// A full move list asks which move to forget: the first one.
expect(toChoice().kind).toBe("yesno");
world.choose(1);
p.world.choose(1);
toChoice();
world.choose(0);
p.world.choose(0);
}
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++) toChoice();
expect(log.join("\n").replace(/\n/g, " ")).toContain("learned FLASH!");
for (let i = 0; i < 10 && p.world.dialogue?.choice?.kind !== "pack"; i++) toChoice();
expect(p.log.join("\n").replace(/\n/g, " ")).toContain("learned FLASH!");
// The menus work on a draft that is kept once they close.
for (let i = 0; i < 10 && world.busy; i++) {
if (world.dialogue?.choice) world.advanceDialogue();
else world.tick();
for (let i = 0; i < 10 && p.world.busy; i++) {
if (p.world.dialogue?.choice) p.world.advanceDialogue();
else p.world.tick();
}
play();
expect(world.state.party[slot].moves).toContain(FLASH);
expect(world.state.bag.tmsHms[50 + 4]).toBe(1); // an HM is kept
expect(p.world.state.party[slot].moves).toContain(FLASH);
expect(p.world.state.bag.tmsHms[50 + 4]).toBe(1); // an HM is kept
// Dark Cave is dark until FLASH, which the ZEPHYRBADGE allows.
enter(DARK_CAVE, "up");
expect(world.state.flash).toBe(false);
expect(world.openStartMenu()).toBe(true);
expect(p.world.state.flash).toBe(false);
expect(p.world.openStartMenu()).toBe(true);
choose("POKéMON");
choose(nickname);
choose("FLASH");
play();
expect(world.state.flash, log.slice(-6).join("|")).toBe(true);
expect(p.world.state.flash, p.log.slice(-6).join("|")).toBe(true);
reload();
}, 120_000);
it("registers Bug Catcher Wade's number and reads Earl's blackboard", () => {
traceFailures();
exit(ROUTE_31);
// Talk to Wade wherever he stands (he walks up to battle on sight).
// A trainer who spots the player on the way battles and moves, so
// the approach is planned again.
const talkTo = (id: number) => {
for (let attempt = 0; attempt < 4; attempt++)
for (const [dx, dy, face] of [[-1, 0, "right"], [1, 0, "left"], [0, 1, "up"], [0, -1, "down"]] as const) {
const target = world.objects().find((object) => object.id === id)!;
const x = target.x + dx,
y = target.y + dy;
try {
if (!(world.x === x && world.y === y)) walk(x, y);
} catch {
continue;
}
const now = world.objects().find((object) => object.id === id)!;
if (now.x !== target.x || now.y !== target.y) break;
world.move(face);
world.interact();
return;
}
throw new Error(`No way to talk to object ${id}`);
};
talkTo(world.map.objects[2].id);
talkTo(p.world.map.objects[2].id);
play([1]);
const pages = log.join("\n").replace(/\n/g, " ");
const pages = p.log.join("\n").replace(/\n/g, " ");
expect(pages).toContain("BUG CATCHER WADE");
expect(world.state.phone).toContain(PHONE_BUG_CATCHER_WADE);
expect(p.world.state.phone).toContain(PHONE_BUG_CATCHER_WADE);
reload();
restInViolet();
exit(ROUTE_31_GATE, "left");
exit(VIOLET, "left");
enter(EARLS_ACADEMY, "up");
walk(3, 1);
world.move("up");
world.interact();
p.world.move("up");
p.world.interact();
choose("PSN");
toChoice();
expect(log.join("\n").replace(/\n/g, " ")).toContain("steadily loses HP");
expect(p.log.join("\n").replace(/\n/g, " ")).toContain("steadily loses HP");
// B leaves the blackboard.
world.choose(-1);
p.world.choose(-1);
play();
expect(world.busy).toBe(false);
expect(p.world.busy).toBe(false);
reload();
}, 120_000);
it("takes Route 32 and Union Cave to Azalea Town", () => {
traceFailures();
exit(VIOLET);
cross("down", ROUTE_32);
// The man at Route 32's north end gives the Miracle Seed after the
// ZEPHYRBADGE and the Egg.
expect(p.world.state.bag.items.some((slot) => slot.item === MIRACLE_SEED)).toBe(false);
p.talkTo(p.world.map.objects[7].id);
play();
expect(p.world.state.bag.items.some((slot) => slot.item === MIRACLE_SEED)).toBe(true);
stairs(UNION_CAVE_1F);
stairs(ROUTE_33);
cross("left", AZALEA);
reload();
}, 240_000);
it("follows Kurt into the Slowpoke Well and drives out Team Rocket", () => {
traceFailures();
enter(KURTS_HOUSE, "up");
talkTo(p.world.map.objects[0].id);
play();
expect(p.log.join(" ")).toContain("Old KURT is on his\nway!");
exit(AZALEA);
stairs(SLOWPOKE_WELL_B1F);
// The three grunts, then the one by the boulder, whose defeat ends
// the scene in Kurt's house.
for (const index of [0, 2, 3, 1]) {
const id = p.world.map.objects[index].id;
if (!p.world.objects().some((object) => object.id === id)) continue;
talkTo(id);
play();
}
expect(p.world.mapId).toBe(KURTS_HOUSE);
expect(p.world.state.events.has(EVENT_CLEARED_SLOWPOKE_WELL)).toBe(true);
talkTo(p.world.map.objects[0].id);
play();
expect(p.world.state.bag.balls.some((slot) => slot.item === LURE_BALL)).toBe(true);
reload();
}, 120_000);
it("beats Bugsy for the HIVEBADGE and the rival on the way to Ilex Forest", () => {
traceFailures();
exit(AZALEA);
heal(AZALEA_CENTER, AZALEA);
enter(AZALEA_GYM, "up");
talkTo(p.world.map.objects[0].id);
play();
expect(p.world.state.events.has(EVENT_BEAT_BUGSY)).toBe(true);
expect(p.world.state.flags.has(ENGINE_HIVEBADGE)).toBe(true);
expect(p.world.state.bag.tmsHms[48]).toBe(1); // TM49 FURY CUTTER
exit(AZALEA);
heal(AZALEA_CENTER, AZALEA);
// The rival waits at the town's west end (SCENE_AZALEATOWN_RIVAL_BATTLE).
stairs(ILEX_GATE);
expect(p.world.state.events.has(EVENT_RIVAL_AZALEA_TOWN)).toBe(true);
reload();
}, 120_000);
it("herds the Farfetch'd back in Ilex Forest and cuts through to Route 34", () => {
traceFailures();
stairs(ILEX_FOREST);
const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] } as const;
const bird = p.world.map.objects[0].id;
// wFarfetchdPosition 1 → 2 from any side, 2 → 8 and 8 → 9 facing
// down, then 9 → 10 facing left.
for (const face of ["up", "down", "down", "left"] as const) {
const at = p.world.objects().find((object) => object.id === bird)!;
walk(at.x - STEP[face][0], at.y - STEP[face][1]);
p.world.move(face);
p.world.interact();
play();
}
expect(p.world.state.memory.wFarfetchdPosition).toBe(10);
expect(p.world.state.events.has(EVENT_HERDED_FARFETCHD)).toBe(true);
talkTo(p.world.map.objects[2].id); // the charcoal master
play();
expect(p.world.state.bag.tmsHms[50]).toBe(1); // HM01
reload();
const bellsprout = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
p.teach(HM01, bellsprout);
expect(p.world.state.party[bellsprout].moves).toContain(CUT);
p.cut(8, 25);
expect(p.log).toContain("BELLSPROUT used\nCUT!");
stairs(ROUTE_34_GATE);
reload();
}, 120_000);
},
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { PokemonData } from "../src/game/rom/pokemon";
import { JOHTO } from "../src/game/rom/profile";
import { RomReader } from "../src/game/rom/reader";
import { generatePartyMon, updateUnownDex } from "../src/game/party/pokemon";
import { unownUnlocked } from "../src/game/battle/battle";
import {
initialPieces,
moveCursor,
solved,
} from "../src/game/scripts/unown-puzzle";
import { GameWorld } from "../src/game/world/world";
import { OW } from "../src/game/world/constants";
import { battleFixtureWorld } from "./battle-fixture";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const UNOWN = 201;
describe("GetUnownLetter", () => {
it("takes the middle two bits of each DV, divided by 10, plus one", () => {
expect(PokemonData.unownLetter(0x0000)).toBe(1);
// Every middle bit set: $ff / 10 = 25, letter Z.
expect(PokemonData.unownLetter(0x6666)).toBe(26);
// Attack %0110 0000 alone: $c0 / 10 = 19, letter T.
expect(PokemonData.unownLetter(0x6000)).toBe(20);
// Special's bits are the lowest: 3 / 10 = 0.
expect(PokemonData.unownLetter(0x0006)).toBe(1);
for (let dvs = 0; dvs < 0x10000; dvs += 37) {
const letter = PokemonData.unownLetter(dvs);
expect(letter).toBeGreaterThanOrEqual(1);
expect(letter).toBeLessThanOrEqual(26);
}
});
it("records each new letter once, and the first in the party", () => {
const record = { unownDex: [] as number[], firstUnownSeen: 0 };
updateUnownDex(record, { species: UNOWN, dvs: 0x6666 }, false);
expect(record).toEqual({ unownDex: [26], firstUnownSeen: 0 });
updateUnownDex(record, { species: UNOWN, dvs: 0x0000 });
updateUnownDex(record, { species: UNOWN, dvs: 0x6666 });
updateUnownDex(record, { species: 16, dvs: 0x1234 });
expect(record).toEqual({ unownDex: [26, 1], firstUnownSeen: 1 });
});
});
describe("the Unown puzzle's rules", () => {
it("moves the cursor around START and CANCEL", () => {
expect(moveCursor(0, "up")).toBeUndefined();
expect(moveCursor(0, "left")).toBeUndefined();
expect(moveCursor(5, "right")).toBeUndefined();
expect(moveCursor(7, "down")).toBe(13);
// Below the inner pieces' last row lies START/CANCEL.
expect(moveCursor(25, "down")).toBeUndefined();
expect(moveCursor(24, "down")).toBe(30);
expect(moveCursor(30, "right")).toBe(35);
expect(moveCursor(35, "left")).toBe(30);
expect(moveCursor(30, "down")).toBeUndefined();
});
});
describe.skipIf(!existsSync(romPath))("the Ruins of Alph with the local ROM", () => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const assets = () => new CrystalAssets(bytes);
const KABUTO_CHAMBER = "3:24",
INNER_CHAMBER = "3:27";
const EVENT_SOLVED_KABUTO_PUZZLE = 0x2a1;
it("places the sixteen pieces on the border, and knows the solution", () => {
const reader = new RomReader(bytes);
let seed = 3;
const pieces = initialPieces({
reader,
random: () => (seed = (seed * 75 + 74) % 65537) & 0xff,
});
expect(pieces.filter(Boolean).sort((a, b) => a - b)).toEqual(
Array.from({ length: 16 }, (_, i) => i + 1),
);
// Only the outer ring and the corners of the bottom row are used.
pieces.forEach((piece, place) => {
const row = Math.floor(place / 6),
column = place % 6;
if (piece) expect(row === 0 || row === 5 || column === 0 || column === 5).toBe(true);
});
const done = Array<number>(36).fill(0);
for (let piece = 1; piece <= 16; piece++)
done[7 + ((piece - 1) >> 2) * 6 + ((piece - 1) & 3)] = piece;
expect(solved(reader, done)).toBe(true);
expect(solved(reader, pieces)).toBe(false);
});
it("only lets wild Unown appear with letters from the unlocked sets", () => {
const data = new PokemonData(new RomReader(bytes));
for (let dvs = 0; dvs < 0x10000; dvs += 7) {
const letter = PokemonData.unownLetter(dvs);
expect(unownUnlocked(data, 0, dvs)).toBe(false);
expect(unownUnlocked(data, 1, dvs)).toBe(letter <= 11);
expect(unownUnlocked(data, 1 << 3, dvs)).toBe(letter >= 24);
expect(unownUnlocked(data, 0x0f, dvs)).toBe(true);
}
});
/** Solve the puzzle on screen as a player would: read the pieces from
* the tilemap, then carry each to its place. */
const solvePuzzle = (world: GameWorld) => {
const r = world.assets.reader;
const corners = Array.from({ length: 17 }, (_, piece) =>
r.byte(JOHTO["GetCurrentPuzzlePieceVTileCorner.Corners"] + piece),
);
const placeCell = (place: number) => {
const cell = r.word(JOHTO.UnownPuzzleCoordData + place * 6 + 2) - 0xc4a0;
return cell;
};
const screen = () => {
const choice = world.dialogue?.choice;
if (choice?.kind !== "screen") throw new Error(`No puzzle on screen: ${world.diagnostic}`);
return choice.screen;
};
const pieces = () =>
Array.from({ length: 36 }, (_, place) => {
const tile = screen().map[placeCell(place)];
const piece = corners.indexOf(tile, 1);
return piece > 0 ? piece : 0;
});
let cursor = 0;
const goTo = (target: number) => {
// Breadth first over the cursor's moves.
const previous = new Map<number, [number, string]>([[cursor, [-1, ""]]]);
const queue = [cursor];
while (queue.length) {
const at = queue.shift()!;
for (const direction of ["up", "down", "left", "right"]) {
const next = moveCursor(at, direction);
if (next === undefined || previous.has(next)) continue;
previous.set(next, [at, direction]);
queue.push(next);
}
}
const path: string[] = [];
for (let at = target; at !== cursor; at = previous.get(at)![0])
path.unshift(previous.get(at)![1]);
for (const direction of path) world.choose(direction);
cursor = target;
};
for (let piece = 1; piece <= 16; piece++) {
const from = pieces().indexOf(piece);
const to = 7 + ((piece - 1) >> 2) * 6 + ((piece - 1) & 3);
goTo(from);
world.choose("a");
goTo(to);
world.choose("a");
}
};
it("solves the Kabuto puzzle, unlocks A to K and drops into the inner chamber", () => {
const world = battleFixtureWorld(assets());
world.restore({ ...world.save(), map: KABUTO_CHAMBER, x: 3, y: 3, direction: "up" });
settle(world);
// The floor is closed until the puzzle is solved.
expect(world.assets.permission(world.assets.collision(world.map, 3, 3))).toBe(0);
world.interact();
for (let i = 0; i < 20 && world.dialogue?.choice?.kind !== "screen"; i++) world.tick();
// START quits without solving.
world.openStartMenu();
settle(world);
expect(world.state.events.has(EVENT_SOLVED_KABUTO_PUZZLE)).toBe(false);
world.interact();
for (let i = 0; i < 20 && world.dialogue?.choice?.kind !== "screen"; i++) world.tick();
solvePuzzle(world);
// The finished picture waits for A or B.
expect(world.dialogue?.choice?.kind).toBe("screen");
world.choose("a");
settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.mapId).toBe(INNER_CHAMBER);
expect(world.state.events.has(EVENT_SOLVED_KABUTO_PUZZLE)).toBe(true);
expect(world.state.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K)).toBe(true);
});
it("shows the wall's word in Unown letters", () => {
const world = battleFixtureWorld(assets());
world.restore({ ...world.save(), map: KABUTO_CHAMBER, x: 3, y: 1, direction: "up" });
settle(world);
world.interact();
for (let i = 0; i < 40 && world.dialogue?.choice?.kind !== "screen"; i++) {
if (world.dialogue && !world.dialogue.choice) world.advanceDialogue();
else world.tick();
}
const choice = world.dialogue?.choice;
expect(choice?.kind).toBe("screen");
if (choice?.kind !== "screen") return;
expect(choice.screen.overlay).toBe(true);
// UNOWNWORDS_ESCAPE: six letters, two tiles wide, in bank 1.
const drawn = choice.screen.map.filter((tile) => tile >= 0x100);
expect(drawn.length).toBe(6 * 4);
world.choose("b");
settle(world);
expect(world.busy).toBe(false);
});
it("meets Unown in the inner chamber once a set is unlocked, and records them", () => {
// The battle fixture's encounter tables would replace the chamber's.
let seed = 5;
const world = new GameWorld(assets(), () => new Date("2026-09-22T12:00:00"), () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
});
world.state.party.push(
generatePartyMon(world.assets.pokemon, {
species: 157, level: 50, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0,
}),
);
world.state.flags.add(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K);
world.state.bag.balls = [{ item: 1, quantity: 30 }]; // MASTER BALL
world.restore({ ...world.save(), map: INNER_CHAMBER, x: 10, y: 8 });
settle(world);
let caught = false;
for (let step = 0; step < 400 && !caught; step++) {
world.move(step & 1 ? "up" : "down");
for (let tick = 0; tick < 4000; tick++) {
const battle = world.battle;
if (battle) {
if (battle.prompt?.kind === "action") {
const ball = world.liveState.bag.balls.findIndex((slot) => slot.item === 1);
if (battle.engine.enemy.mon.species === UNOWN && ball >= 0)
world.battleAction({ action: "item", item: 1, slot: ball });
else world.battleAction({ action: "run" });
} else if (battle.prompt?.kind === "name") world.battleName("");
else if (battle.prompt?.kind === "yesno") world.battleChoose(0);
else if (battle.message?.prompt) world.interact();
else world.tick();
continue;
}
if (world.dialogue) {
if (world.dialogue.choice) world.choose(0);
else world.advanceDialogue();
continue;
}
if (!world.busy) break;
world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
caught = world.state.party.some((member) => member.species === UNOWN);
}
expect(caught).toBe(true);
const unown = world.state.party.find((member) => member.species === UNOWN)!;
const letter = PokemonData.unownLetter(unown.dvs);
expect(letter).toBeLessThanOrEqual(11);
expect(world.state.unownDex).toEqual([letter]);
expect(world.state.firstUnownSeen).toBeGreaterThanOrEqual(1);
// VAR_UNOWNCOUNT counts the letters.
expect(world.state.unownDex.length).toBe(1);
});
});
+20 -1
View File
@@ -63,6 +63,22 @@ const DIRECTIONS = [
["right", 1, 0],
] as const;
/** .ledge_table's side-wall directions (GetMovementPermissions): side
* walls ($b0) and buoys ($c0) block these directions. */
const EDGES: readonly (readonly Direction[])[] = [
["right"],
["left"],
["up"],
["down"],
["down", "right"],
["down", "left"],
["up", "right"],
["up", "left"],
];
const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" } as const;
const edges = (tile: number): readonly Direction[] =>
(tile & 0xf0) === 0xb0 || (tile & 0xf0) === 0xc0 ? EDGES[tile & 7] : [];
/** Shortest walkable path on the current map, avoiding other warps. */
export function findPath(
world: GameWorld,
@@ -82,8 +98,11 @@ export function findPath(
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
const tile = world.assets.collision(world.map, x, y);
if (world.assets.permission(tile) !== 0) continue;
if (
world.assets.permission(world.assets.collision(world.map, x, y)) !== 0
edges(world.assets.collision(world.map, node.x, node.y)).includes(direction) ||
edges(tile).includes(OPPOSITE[direction])
)
continue;
if (world.objects().some((object) => object.x === x && object.y === y))