diff --git a/docs/PLAN.md b/docs/PLAN.md index 5d0125d..44281db 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -185,7 +185,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli - Field moves in the Pokémon submenu run their menu functions: the world checks the badge (`CheckBadge`: "Sorry! A new BADGE is required."), the facing tile, water or object, then the menu closes and the ROM's `…FromMenu` script runs for the chosen Pokémon (no "Want to …?" question): Cut (`CheckMapForSomethingToCut`), Surf (including "already surfing" and forced cycling), Strength, Flash (a `PALETTE_DARK` map or the Aerodactyl chamber; `BlindingFlash` sets the Flash flag), Whirlpool and Waterfall. Failures print the ROM's messages and return to the party menu. - Sweet Scent runs `SweetScentFromMenu`: where wild Pokémon can appear and the map has a rate, `ChooseWildEncounter` picks one without the rate roll and a battle starts; elsewhere "Looks like there's nothing here…". - Softboiled and Milk Drink give a fifth of the user's maximum HP to another Pokémon that has not fainted and is not at full HP; the user needs more than that fifth. -- Headbutt (facing a tree) and Rock Smash (facing a rock) run their checks and messages, then give a notice: tree and rock encounters are not ported. Fly, Dig and Teleport run since the section below. +- Headbutt (facing a tree) and Rock Smash (facing a rock) run their checks and messages; their encounters run since a later section, as do Fly, Dig and Teleport. - SEL registers an item that `CANT_SELECT` allows (in practice key items) in `wWhichRegisteredItem` and `wRegisteredItem`, which saves keep. Select runs `SelectMenu`: `CheckRegisteredItem` (a slot that no longer holds the item clears the registration; a registered ball is never found, as in the original) and `UseRegisteredItem` by field menu. The Bicycle uses `Script_GetOnBike_Register` and `Script_GetOffBike_Register`, without a message; elsewhere Oak objects. Without a registration Select explains SEL. This replaces the earlier Key Items shortcut. - Bill's PC's MOVE PKMN W/O MAIL: refused while a party Pokémon holds MAIL, then "Each time you move a POKéMON, data will be saved. OK?". Choose the party or a box, a Pokémon (MOVE, STATS, CANCEL), then a box and the position to insert before. The party keeps its last Pokémon and its last healthy one, a different full box or party has "no room", a Pokémon moved down its own list lands one place earlier (`.CheckTrivialMove`), a boxed Pokémon joining the party gets its stats with full HP and no status (`CalcBufferMonStats`), and one entering a box gets its PP restored. The original's left/right box switching is presented as a list of boxes; after "no room" the box is chosen again rather than the position. - Save format 3 gained `registeredItem`, migrated by absence to none. @@ -232,6 +232,14 @@ The numbered sections below keep their full scope. Each one opens with a checkli - `newloadmap` now stops its script and resumes it on the new map, which these scripts need for their landing movements. Save format 3 gained `digWarp`, migrated by absence to none. - Unreachable in normal play so far: the Storm Badge, caves (Dark Cave is beyond Route 31) and flypoints beyond Cherrygrove. A destination that is not ported gives a notice. `JumpRoamMons` waits for roaming Pokémon. +## Implemented: Headbutt and Rock Smash + +- Facing a Headbutt tree with a Pokémon that knows Headbutt asks "Want to HEADBUTT it?" (`AskHeadbuttScript`); HEADBUTT from the party menu skips the question (`HeadbuttFromMenuScript`). "did a HEADBUTT!", then `ShakeHeadbuttTree`: the tree's four tiles become grass tile $05 while `SPRITE_ANIM_OBJ_HEADBUTT` shakes over them in `PAL_OW_TREE`, two frames alternating every three steps with the second mirrored on every other pass, for 40 frames. The sound is not presented. +- `TreeMonEncounter`: the map's set in `TreeMonMaps` (`TREEMON_SET_NONE` and unlisted maps have none), `GetTreeScore`'s facing-tile score ((x × y + x + y) / 5 modulo 10, in `wPlayerMapX/Y` coordinates) against the trainer ID's last digit, then 10%, 50% or 80% for a bad, good or rare score, the rare one drawing from the rare table, and `SelectTreeMon`'s percentages. Otherwise "Nope. Nothing…". +- A tree battle is `BATTLETYPE_TREE`: "fell out of the tree!" instead of "appeared!", and the time of day's `AsleepTreeMons` species start asleep for `TREEMON_SLEEP_TURNS`. +- Talking to a smashable rock runs `SmashRockScript`: "Maybe a POKéMON can break this." without Rock Smash (`HasRockSmash`), otherwise the question; ROCK SMASH from the party menu skips it and acts on the facing rock as `hLastTalked`. The earthquake, the rock's flicker and its disappearance run from the ROM's script, then `RockMonEncounter`: the map's `RockMonMaps` set and a 40% chance of a wild battle. +- Unreachable in normal play so far: no open map has rocks, and no Pokémon available yet learns Headbutt (the Ilex Forest tutor) or Rock Smash (a TM on Route 36). Route 29 and Route 30 have tree sets. + ## 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. Only the special Kurt and Park balls remain unported among battle items. @@ -314,7 +322,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, and Fly, Dig, Teleport and the Escape Rope. Next: Headbutt and Rock Smash encounters. +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. Next: the remaining field items, MAIL and pack reordering. Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index b7baae4..4dfed9b 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -1,6 +1,6 @@ # Recovered Claude session -Latest continuation: see **Mom's bank and her purchases: 2026-10-01** +Latest continuation: see **Headbutt and Rock Smash: 2026-10-02** at the end of this file. Earlier sections are historical snapshots. Recovered on 2026-09-23 from the local Claude Code transcript @@ -1039,3 +1039,52 @@ Teleport then Fly was checked once in Chromium and WebKit. Not made into cards, because they can be automated: emulator comparisons of the flight paths and of the Teleport/Dig warps through the reference harness. + +## Headbutt and Rock Smash: 2026-10-02 + +Resumed with "continue the plan" on a clean tree after `ee55d34`, taking +board card #26. + +- `src/game/scripts/treemons.ts`: `treeMonEncounter` (`TreeMonEncounter` + with `GetTreeMonSet`, `GetTreeMons`, `GetTreeScore` and `GetTreeMon`, + including the rare table after the common one), `rockMonEncounter` + (`RockMonEncounter`'s 40%) and `treeScore`, all read from the ROM's + `TreeMonMaps`, `RockMonMaps` and `TreeMons`. +- Interpreter: `callasm` runs `TreeMonEncounter` (facing tile in + `wPlayerMapX/Y` coordinates, +4), `RockMonEncounter`, `HasRockSmash` + (sets `wCurPartyMon`) and `ShakeHeadbuttTree` (a `headbutt` effect after + the text). A `tempWild` local stands for `wTempWildMonSpecies`/ + `wCurPartyLevel`: `randomwildmon` reads it, `readmem wTempWildMonSpecies` + answers from it, and the battle clears it with the tree battle type, as + `CleanUpBattleRAM` does. The `battle` effect gained `tree`. +- World: facing a tree runs `AskHeadbuttScript`; HEADBUTT and ROCK SMASH + from the party menu queue `HeadbuttFromMenuScript` and + `RockSmashFromMenuScript`, the latter with the rock as `hLastTalked` + (`MenuFieldMove.lastTalked`). Talking to a rock already ran + `SmashRockScript`. The `headbutt` effect lasts 20 ticks + (`HEADBUTT_TICKS`) and `world.headbuttTree` gives the renderer the square + and frames; objects do not move meanwhile. +- Battle: `BATTLETYPE_TREE` (`tree` option) prints + `PokemonFellFromTreeText` and puts the time of day's `AsleepTreeMons` + species to sleep for `TREEMON_SLEEP_TURNS` (`CheckSleepingTreeMon`). The + skipped cry is not presented anyway. +- Renderer: tile $05 over the tree's four tiles for 36 frames, and the + `HeadbuttTreeGFX` sprite in `PAL_OW_TREE` with the frameset's timing + (three video frames a step, frame 2 mirrored on every other pass). Checked + in Chromium with close-up screenshots across the shake. +- New profile entries (OVERWORLD, BATTLE, `RAM.tempWildMonSpecies`) and + constants (`BATTLETYPE_TREE`, `TREEMON_SLEEP_TURNS`, `NUM_TREEMON_SETS`), + all verified by `check:reference`. + +Checks: 435 unit tests with the local ROM (434 passed, 1 skipped; 135 +passed, 300 skipped without it; new `tests/headbutt-rock-smash.test.ts`, +and the Rock Smash test in `tests/overworld-rom.test.ts` now smashes the +rock and meets a Krabby); 123 Chromium/WebKit browser tests passed, 19 +skipped intentionally (a new Headbutt flow with a "headbutt" fixture room +below a Route 29 tree); native and reference builds; reference, vendor and +release checks; `git diff --check`. No emulator comparison of the tree +scores or the shake; no CI or physical-device run. Unreachable in normal +play so far: no open map has rocks, and Headbutt (the Ilex Forest tutor) +and Rock Smash (a TM on Route 36) are not obtainable yet. + +Next: the remaining field items, MAIL and pack reordering (board #27). diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs index 929fcbf..ffda62c 100644 --- a/scripts/check-reference.mjs +++ b/scripts/check-reference.mjs @@ -190,6 +190,7 @@ const ramNames = { strengthSpecies: "wStrengthSpecies", curHPAnimDeltaHP: "wCurHPAnimDeltaHP", visitedSpawns: "wVisitedSpawns", + tempWildMonSpecies: "wTempWildMonSpecies", moneyTemp: "hMoneyTemp", }; const profile = readFileSync("src/game/rom/profile.ts", "utf8"); diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index b575e52..2f18b8d 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -175,6 +175,8 @@ export interface BattleOptions { caughtData?: (level: number) => number; /** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */ tutorial?: boolean; + /** BATTLETYPE_TREE: a wild Pokémon that fell from a Headbutt tree. */ + tree?: boolean; /** GetWorldMapLocation for the current map (LevelUpHappinessMod). */ landmark?: number; /** wPartyMenuCursor as the last party menu outside battle left it. */ @@ -476,6 +478,7 @@ export class Battle { this.partyMenuCursor = options.partyMenuCursor ?? 0; if (options.trainer) this.battleMode = C.TRAINER_BATTLE; if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL; + else if (options.tree) this.battleType = C.BATTLETYPE_TREE; } private get reader(): RomReader { return this.options.data.reader; @@ -659,7 +662,8 @@ export class Battle { pp: moves.map((move) => (move ? data.move(move).pp : 0)), happiness: C.BASE_HAPPINESS, level, - status: 0, + // .TreeMon: some Pokémon fall from trees asleep. + status: this.sleepingTreeMon(species) ? C.TREEMON_SLEEP_TURNS : 0, hp: stats[0], maxHp: stats[0], stats: stats.slice(1), @@ -676,6 +680,21 @@ export class Battle { this.wildMoves = [...moves]; this.wildPP = [...this.enemy.mon.pp]; } + /** CheckSleepingTreeMon: in a tree battle, the time of day's + * AsleepTreeMons list names the species that sleep. */ + private sleepingTreeMon(species: number) { + if (this.battleType !== C.BATTLETYPE_TREE) return false; + const time = this.options.timeOfDay; + let p = + time < C.DAY_F + ? BATTLE.AsleepTreeMonsMorn + : time === C.DAY_F + ? BATTLE.AsleepTreeMonsDay + : BATTLE.AsleepTreeMonsNite; + for (const r = this.reader; r.byte(p) !== 0xff; p++) + if (r.byte(p) === species) return true; + return false; + } /** InitBattleMon from the party, then status penalties. */ private initBattleMon() { const mon = this.partyMon, @@ -773,7 +792,12 @@ export class Battle { if (trainer) { yield* this.say(BATTLE.WantsToBattleText); yield* this.say(BATTLE.BattleText_EnemySentOut); - } else yield* this.say(BATTLE.WildPokemonAppearedText); + } else + yield* this.say( + this.battleType === C.BATTLETYPE_TREE + ? BATTLE.PokemonFellFromTreeText + : BATTLE.WildPokemonAppearedText, + ); this.resetBattleParticipants(); this.initBattleMon(); const amuletCoin = diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts index c0587a0..94914be 100644 --- a/src/game/battle/constants.ts +++ b/src/game/battle/constants.ts @@ -313,6 +313,8 @@ export const C = { BATTLETYPE_TRAP: 9, BATTLETYPE_FORCEITEM: 10, BATTLETYPE_CELEBI: 11, + BATTLETYPE_TREE: 8, + TREEMON_SLEEP_TURNS: 7, BATTLETYPE_SUICUNE: 12, WIN: 0, LOSE: 1, diff --git a/src/game/rendering/renderer.ts b/src/game/rendering/renderer.ts index 3f74e28..328f004 100644 --- a/src/game/rendering/renderer.ts +++ b/src/game/rendering/renderer.ts @@ -10,6 +10,11 @@ import { type RenderViewport, } from "./viewport"; +/** Video frames before LoadOverworldTilemapAndAttrmapPals puts the + * Headbutt tree back: HideHeadbuttTree's WaitBGMap and the 32 shaking + * frames. */ +const HEADBUTT_HIDDEN_FRAMES = 36; + export class WorldRenderer { width: number = BASE_VIEWPORT.width; height: number = BASE_VIEWPORT.height; @@ -91,6 +96,10 @@ export class WorldRenderer { const cameraX = Math.round(player.x - (this.width / 2 - 8)), cameraY = Math.round(player.y - (this.height / 2 - 8)) + world.screenShake; this.image.data.fill(255); + // HideHeadbuttTree puts tile $05 (grass) in the tree's four tilemap + // places, keeping their attributes, until the shaking ends. + const tree = world.headbuttTree; + const hidden = tree && tree.frames < HEADBUTT_HIDDEN_FRAMES ? tree : undefined; for ( let tileY = Math.floor(cameraY / 8); tileY < Math.ceil((cameraY + this.height) / 8); @@ -111,7 +120,12 @@ export class WorldRenderer { const encoded = tileset.metatiles[block * 16 + localY * 4 + localX]; const packed = tileset.paletteMap[encoded >> 1], attribute = encoded & 1 ? packed >> 4 : packed & 15; - const tile = (encoded & 0x7f) + (attribute & 8 ? 0x60 : 0); + const covered = + hidden && + tileX >> 1 === hidden.x && + tileY >> 1 === hidden.y; + const tile = + (covered ? 0x05 : encoded & 0x7f) + (attribute & 8 ? 0x60 : 0); drawTile( this.image.data, this.width, @@ -200,6 +214,31 @@ export class WorldRenderer { ); } } + if (tree) { + // SPRITE_ANIM_OBJ_HEADBUTT over the tree's square, in PAL_OW_TREE: + // the two frames alternate every three animation steps, the second + // mirrored on every other pass. The first step lasts through + // HideHeadbuttTree's WaitBGMap, the last until the sprite is cleared. + const graphics = this.assets.headbuttTree(), + step = Math.min(32, Math.max(0, tree.frames - 4)), + phase = Math.floor(step / 3) % 4, + flip = phase === 3, + palette = this.assets.objectPalette(6, time); + for (let tile = 0; tile < 4; tile++) { + const column = tile & 1; + drawTile( + this.image.data, + this.width, + this.height, + graphics, + (phase & 1) * 4 + tile, + tree.x * 16 - cameraX + (flip ? 8 - column * 8 : column * 8), + tree.y * 16 - cameraY + (tile >> 1) * 8, + palette, + { transparent: true, flipX: flip }, + ); + } + } this.context.putImageData(this.image, 0, 0); } private actor( diff --git a/src/game/rom/assets.ts b/src/game/rom/assets.ts index 49b682f..3628ffd 100644 --- a/src/game/rom/assets.ts +++ b/src/game/rom/assets.ts @@ -500,6 +500,10 @@ export class CrystalAssets { r.byte(entry + 2), ); } + /** HeadbuttTreeGFX: the shaking tree's two 2x2 frames, eight tiles. */ + headbuttTree(): Uint8Array { + return this.reader.slice(OVERWORLD.HeadbuttTreeGFX, 8 * 16); + } /** GetIcon: a species' two-frame party menu icon (ReadMonMenuIcon and * IconPointers), eight tiles. */ monIcon(species: number): Uint8Array { diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index 4d061fc..124b356 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -159,6 +159,7 @@ export const RAM = { strengthSpecies: 0xd1ef, curHPAnimDeltaHP: 0xd1f3, visitedSpawns: 0xdca5, + tempWildMonSpecies: 0xd22e, /** hMoneyTemp: HRAM, not WRAM like the other entries above. */ moneyTemp: 0xffc3, } as const; @@ -233,6 +234,17 @@ export const OVERWORLD = { Script_WaterfallFromMenu: 0xcb1c, "SweetScentFromMenu.SweetScent": 0x506c8, SweetScentEncounter: 0x506ef, + HeadbuttFromMenuScript: 0xcea7, + AskHeadbuttScript: 0xcedc, + ShakeHeadbuttTree: 0x8c80a, + HeadbuttTreeGFX: 0x8c893, + TreeMonEncounter: 0xb81ea, + RockMonEncounter: 0xb8219, + TreeMonMaps: 0xb825e, + RockMonMaps: 0xb82c5, + TreeMons: 0xb82e8, + RockSmashFromMenuScript: 0xcf2e, + HasRockSmash: 0xcf7c, "CheckBadge.BadgeRequiredText": 0xc73d, "FieldMoveFailed.CantUseItemText": 0xc780, CutNothingText: 0xc7c9, @@ -685,6 +697,10 @@ export const BATTLE = { WasBurnedText: 0x8123c, WasPoisonedText: 0x811d5, WildPokemonAppearedText: 0x80746, + PokemonFellFromTreeText: 0x80778, + AsleepTreeMonsNite: 0x3eb5d, + AsleepTreeMonsDay: 0x3eb69, + AsleepTreeMonsMorn: 0x3eb6f, WokeUpText: 0x80cfa, WontDropAnymoreText: 0x8128f, WontRiseAnymoreText: 0x81272, diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index 3278c10..ad62c6e 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -53,6 +53,7 @@ import { PcSession } from "./pc"; import { bankOfMom, type MomBankView } from "./mom"; import type { PokegearView } from "./pokegear"; import type { FlyMapView } from "./fly-map"; +import { rockMonEncounter, treeMonEncounter } from "./treemons"; import type { TrainerCardView } from "./trainer-card"; import { defaultOptions, @@ -214,6 +215,13 @@ export interface FieldMoveContext { * objects. LAST_TALKED is resolved before an effect is emitted. */ export const PLAYER = 0; const LAST_TALKED = 0xfe; +/** Map steps for wPlayerDirection's DOWN, UP, LEFT and RIGHT. */ +const FACING_VECTORS = [ + [0, 1], + [0, -1], + [-1, 0], + [1, 0], +] as const; /** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */ const CONTACT_LIST_SIZE = 10; @@ -286,6 +294,8 @@ export type ScriptEffect = canLose?: boolean; /** catchtutorial: the Dude's demonstration (BATTLETYPE_TUTORIAL). */ tutorial?: boolean; + /** BATTLETYPE_TREE: a Pokémon that fell from a Headbutt tree. */ + tree?: boolean; } /** warp and newloadmap: the world enters this map after the script * ends, facing `facing` (warpfacing) or down (SpawnInFacingDown). @@ -306,6 +316,8 @@ export type ScriptEffect = * the player's place or lands on it, with the leaves; HideSprites and * the animation loops leave no other object on screen meanwhile. */ | { kind: "fly"; landing: boolean; species: number } + /** ShakeHeadbuttTree: the facing tree shakes as a sprite over grass. */ + | { kind: "headbutt" } /** A vertical menu (the elevator's floors); answered with the chosen * item's index, or -1 when cancelled with B. */ | { @@ -591,6 +603,10 @@ export class ScriptRun { // wDefaultSpawnpoint: set by Fly and Teleport before their scripts. spawn = this.options.fieldMove?.spawn ?? 0; let wild = this.options.wild; + // wTempWildMonSpecies/wCurPartyLevel, as the encounter routines leave + // them for randomwildmon, and wBattleType's BATTLETYPE_TREE. + let tempWild = this.options.wild, + treeBattle = false; let trainer: { classId: number; id: number } | undefined; // LoadTrainer_continue's copy fills wWinTextPointer/wLossTextPointer too. let winText = this.tempTrainer?.beatenText || undefined, @@ -880,6 +896,31 @@ export class ScriptRun { // Chosen by the world when the move was used (CanEncounterWildMon, // GetMapEncounterRate and ChooseWildEncounter). value = Number(this.options.wild !== undefined); + } else if (routine === OVERWORLD.ShakeHeadbuttTree) { + yield* flush(); + yield synchronous({ kind: "headbutt" }); + } else if (routine === OVERWORLD.TreeMonEncounter) { + const [dx, dy] = FACING_VECTORS[this.host.facing()], + at = this.host.position(); + tempWild = treeMonEncounter( + r, + this.host.mapId, + // GetFacingTileCoord: wPlayerMapX/Y count the map's border. + { x: at.x + dx + 4, y: at.y + dy + 4 }, + this.host.player().id, + (range) => this.randomRange(range), + ); + treeBattle = tempWild !== undefined; + value = Number(treeBattle); + } else if (routine === OVERWORLD.RockMonEncounter) { + tempWild = rockMonEncounter(r, this.host.mapId, (range) => + this.randomRange(range), + ); + } else if (routine === OVERWORLD.HasRockSmash) { + // CheckPartyMove leaves the Pokémon in wCurPartyMon. + const mon = this.partyMove(OW.ROCK_SMASH); + if (mon >= 0) this.curPartyMon = mon; + value = mon < 0 ? 1 : 0; } else if (routine === OVERWORLD["UsedSurfScript.stubbed_fn"]) { // StubbedTrainerRankings_Surf: the cut Trainer Rankings feature. } else if ( @@ -917,6 +958,8 @@ export class ScriptRun { value = this.options.fieldMove.surfState; else if (address === RAM.strengthSpecies) value = this.strengthSpecies; + else if (address === RAM.tempWildMonSpecies) + value = tempWild?.species ?? 0; else throw new UnsupportedScript(offset, opcode); break; } @@ -971,7 +1014,7 @@ export class ScriptRun { } case 0x5b: trainer = undefined; - wild = this.options.wild; + wild = tempWild; break; // randomwildmon clears wBattleScriptFlags. case 0x5d: wild = { species: byte(), level: byte() }; @@ -1049,11 +1092,15 @@ export class ScriptRun { wild, trainer: trainer && { ...trainer, winText, lossText }, canLose, + tree: treeBattle || undefined, }); if (typeof result !== "number") throw new Error("Invalid battle result."); battleResult = result; value = result & 0x3f; // Script_startbattle also updates wScriptVar. + // CleanUpBattleRAM clears wBattleType and wTempWildMonSpecies. + treeBattle = false; + tempWild = undefined; break; } case 0x61: { diff --git a/src/game/scripts/start-menu.ts b/src/game/scripts/start-menu.ts index 4a8fa69..bfcb4b0 100644 --- a/src/game/scripts/start-menu.ts +++ b/src/game/scripts/start-menu.ts @@ -96,6 +96,8 @@ export type MenuFieldMove = script: number; fieldMove: FieldMoveContext; wild?: { species: number; level: number }; + /** hLastTalked, when the move acts on a facing object. */ + lastTalked?: number; } | { texts: number[] } | { notice: string } diff --git a/src/game/scripts/treemons.ts b/src/game/scripts/treemons.ts new file mode 100644 index 0000000..af692f2 --- /dev/null +++ b/src/game/scripts/treemons.ts @@ -0,0 +1,97 @@ +import type { RomReader } from "../rom/reader"; +import { OVERWORLD } from "../rom/profile"; +import { OW } from "../world/constants"; + +/** Headbutt trees and Rock Smash rocks (engine/events/treemons.asm). */ +export interface TreeMon { + species: number; + level: number; +} + +/** TREEMON_SCORE_BAD, _GOOD and _RARE. */ +const SCORE_BAD = 0, + SCORE_GOOD = 1, + SCORE_RARE = 2; + +/** TreeMonEncounter: the map's TreeMonMaps set, scored by the facing tile + * (in wPlayerMapX/Y coordinates, four more than the map's) against the + * trainer ID, then an encounter roll and SelectTreeMon. Undefined when + * nothing falls; a Pokémon found fights as BATTLETYPE_TREE. */ +export function treeMonEncounter( + r: RomReader, + mapId: string, + facing: { x: number; y: number }, + playerId: number, + randomRange: (range: number) => number, +): TreeMon | undefined { + let table = treeMons(r, treeMonSet(r, OVERWORLD.TreeMonMaps, mapId)); + if (table === undefined) return undefined; + // GetTreeMon: 10% for a bad score, 50% for a good one, 80% for a rare + // one, which also skips past the common table to the rare one. + const score = treeScore(facing.x, facing.y, playerId); + const draw = randomRange(10); + if (score === SCORE_BAD && draw !== 0) return undefined; + if (score === SCORE_GOOD && draw >= 5) return undefined; + if (score === SCORE_RARE) { + if (draw >= 8) return undefined; + while (r.byte(table++) !== 0xff); + } + return selectTreeMon(r, table, randomRange); +} + +/** RockMonEncounter: the map's RockMonMaps set, a 40% chance, then + * SelectTreeMon. Undefined when the rock hides nothing. */ +export function rockMonEncounter( + r: RomReader, + mapId: string, + randomRange: (range: number) => number, +): TreeMon | undefined { + const table = treeMons(r, treeMonSet(r, OVERWORLD.RockMonMaps, mapId)); + if (table === undefined) return undefined; + if (randomRange(10) >= 4) return undefined; + return selectTreeMon(r, table, randomRange); +} + +/** GetTreeScore: .CoordScore is (x × y + x + y) / 5 modulo 10 and + * .OTIDScore the trainer ID modulo 10; equal scores are rare, and a + * coordinate score less than five above the ID's (wrapping by ten) good. */ +export function treeScore(x: number, y: number, playerId: number): number { + const coord = Math.floor(((x * y + x + y) & 0xffff) / 5) % 10; + let difference = coord - (playerId % 10); + if (difference === 0) return SCORE_RARE; + if (difference < 0) difference += 10; + return difference < 5 ? SCORE_GOOD : SCORE_BAD; +} + +/** GetTreeMonSet: the TREEMON_SET_* of map `mapId` in TreeMonMaps or + * RockMonMaps (group, number, set entries ending with -1). */ +function treeMonSet(r: RomReader, table: number, mapId: string) { + const [group, number] = mapId.split(":").map(Number); + for (let p = table; r.byte(p) !== 0xff; p += 3) + if (r.byte(p) === group && r.byte(p + 1) === number) return r.byte(p + 2); + return undefined; +} + +/** GetTreeMons: the set's common table in TreeMons; TREEMON_SET_NONE and + * sets past NUM_TREEMON_SETS have none. */ +function treeMons(r: RomReader, set: number | undefined) { + if (!set || set >= OW.NUM_TREEMON_SETS) return undefined; + return r.near(OVERWORLD.TreeMons + set * 2, r.bank(OVERWORLD.TreeMons)); +} + +/** SelectTreeMon: 0-99 against the table's percentages (percent, species, + * level entries ending with -1). */ +function selectTreeMon( + r: RomReader, + table: number, + randomRange: (range: number) => number, +): TreeMon | undefined { + let roll = randomRange(100), + p = table; + while (roll >= r.byte(p)) { + roll -= r.byte(p); + p += 3; + } + if (r.byte(p) === 0xff) return undefined; + return { species: r.byte(p + 1), level: r.byte(p + 2) }; +} diff --git a/src/game/world/constants.ts b/src/game/world/constants.ts index fc28d9e..8912a4e 100644 --- a/src/game/world/constants.ts +++ b/src/game/world/constants.ts @@ -178,6 +178,8 @@ export const OW = { NUM_FLYPOINTS: 24, // SpriteMovementData (constants/map_object_constants.asm). SPRITEMOVEDATA_SMASHABLE_ROCK: 0x18, + // Headbutt and Rock Smash sets (constants/pokemon_data_constants.asm). + NUM_TREEMON_SETS: 8, BICYCLE: 7, PIKACHU: 25, EGG: 0xfd, diff --git a/src/game/world/world.ts b/src/game/world/world.ts index c4bb637..06c435b 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -551,7 +551,12 @@ const isTimed = (effect: ScriptEffect | undefined) => effect?.kind === "move" || effect?.kind === "emote" || effect?.kind === "pause" || - effect?.kind === "fly"; + effect?.kind === "fly" || + effect?.kind === "headbutt"; +/** ShakeHeadbuttTree after its graphics load: HideHeadbuttTree's WaitBGMap + * (4 frames), 32 frames of shaking, then the WaitBGMap that restores the + * tree, 40 frames in all. */ +const HEADBUTT_TICKS = 20; /** Deterministic native world rules. Rendering, browser APIs and emulator * registers are deliberately absent; callers provide time and input. */ @@ -639,6 +644,9 @@ export class GameWorld { private warpSettle?: { script?: number }; /** FlyFromAnim or FlyToAnim while it plays, with the frames shown. */ private flight?: { landing: boolean; species: number; frames: number }; + /** ShakeHeadbuttTree while it plays: the facing square and the frames + * shown. */ + private shakingTree?: { x: number; y: number; frames: number }; constructor( readonly assets: CrystalAssets, @@ -828,6 +836,12 @@ export class GameWorld { }; } + /** The tree ShakeHeadbuttTree shakes: its map square and the video + * frames since the shaking sprite appeared. */ + get headbuttTree(): { x: number; y: number; frames: number } | undefined { + return this.shakingTree && { ...this.shakingTree }; + } + /** Pixel positions for drawing, including partial steps. */ poses(): Pose[] { // HideSprites and the Fly animations' loops leave only their icon. @@ -1697,12 +1711,19 @@ export class GameWorld { case OW.HEADBUTT: return tile === OW.COLL_HEADBUTT_TREE || tile === OW.COLL_HEADBUTT_TREE_1D - ? { notice: "Headbutt trees are not ported yet." } + ? { script: OVERWORLD.HeadbuttFromMenuScript, fieldMove: { mon } } : failed; - case OW.ROCK_SMASH: - return facingObject()?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK - ? { notice: "Smashing rocks is not ported yet." } + case OW.ROCK_SMASH: { + // GetFacingObject leaves the rock in hLastTalked. + const rock = facingObject(); + return rock?.movement === OW.SPRITEMOVEDATA_SMASHABLE_ROCK + ? { + script: OVERWORLD.RockSmashFromMenuScript, + fieldMove: { mon }, + lastTalked: rock.id, + } : failed; + } case OW.SWEET_SCENT: { // SweetScentEncounter, drawn now: no rate roll, and no battle // without a rate or a valid Pokémon. @@ -2307,6 +2328,7 @@ export class GameWorld { wild: effect.wild, trainer, canLose: effect.canLose, + tree: effect.tree, text: () => this.host(PLAYER).text(), bag: draft.bag, storage: draft.storage, @@ -2350,6 +2372,16 @@ export class GameWorld { this.flight = { ...effect, frames: 0 }; script.waitTicks = effect.landing ? 33 : 65; return true; + case "headbutt": { + const [dx, dy] = VECTORS[this.player.direction]; + this.shakingTree = { + x: this.player.x + dx, + y: this.player.y + dy, + frames: 0, + }; + script.waitTicks = HEADBUTT_TICKS; + return true; + } case "warpcheck": script.warpCheck = true; return false; @@ -2858,7 +2890,9 @@ export class GameWorld { if (script.run.deferred !== undefined) this.startScript( script.run.deferred, - PLAYER, + (script.run instanceof StartMenuRun && + script.run.deferredMove?.lastTalked) || + PLAYER, script.run instanceof StartMenuRun ? { usedItem: script.run.deferredItem, @@ -2890,6 +2924,7 @@ export class GameWorld { this.portrait = undefined; this.evolution = undefined; this.flight = undefined; + this.shakingTree = undefined; if (script.sighted !== undefined) this.refusedSighting = { map: this.mapId, @@ -3402,13 +3437,15 @@ export class GameWorld { else this.advanceMovement(1); } else if (isTimed(script.effect)) { // Pauses and emotes keep the map loop running; applymovement and - // open text freeze other objects, and the Fly animations run their - // own loops. + // open text freeze other objects, and the Fly animations and the + // Headbutt shake run their own loops. if (this.flight) this.flight.frames += 2; + else if (this.shakingTree) this.shakingTree.frames += 2; else this.stepObjects(); if (--script.waitTicks <= 0) { if (script.effect?.kind === "emote") this.emote = undefined; this.flight = undefined; + this.shakingTree = undefined; this.pump(); } } @@ -3649,10 +3686,12 @@ export class GameWorld { return true; } if (tile === OW.COLL_HEADBUTT_TREE || tile === OW.COLL_HEADBUTT_TREE_1D) { - // TryHeadbuttOW: without Headbutt nothing happens; tree encounters - // (TreeMonEncounter) are not ported. - if (this.partyMove(OW.HEADBUTT) < 0) return false; - this.notice = "Headbutt trees are not ported yet."; + // TryHeadbuttOW: without Headbutt nothing happens. + const mon = this.partyMove(OW.HEADBUTT); + if (mon < 0) return false; + this.startScript(OVERWORLD.AskHeadbuttScript, PLAYER, { + fieldMove: { mon }, + }); return true; } return this.trySurfOW(tile); diff --git a/tests/foundation-browser.spec.ts b/tests/foundation-browser.spec.ts index 267652a..83a4fe7 100644 --- a/tests/foundation-browser.spec.ts +++ b/tests/foundation-browser.spec.ts @@ -18,7 +18,8 @@ async function start( | "candy" | "pokegear" | "mom" - | "fly" = false, + | "fly" + | "headbutt" = false, ) { await page.route("**/src/game/runtime.ts", async (route) => { const response = await route.fetch(), @@ -480,6 +481,32 @@ test("Fly picks a visited town on the Fly map and lands there", async ({ expect([state.map, state.x, state.y]).toEqual(["26:3", 29, 4]); }); +test("Headbutt shakes a tree and a sleeping Hoothoot falls out", async ({ + page, +}) => { + await start(page, "headbutt"); + const text = page.locator("#dialogue-text"), + choices = page.locator("#dialogue-choice"), + screen = page.locator("#game-screen"); + await page.keyboard.press("KeyX"); + await expect(text).toHaveText(/A POKéMON could be\s+in this tree\./); + await page.keyboard.press("KeyX"); + await expect(text).toHaveText(/Want to HEADBUTT\s+it\?/); + await choices.getByRole("button", { name: "YES" }).click(); + await expect(text).toHaveText(/TOTODILE did a\s+HEADBUTT!/); + await page.keyboard.press("KeyX"); + await expect(page.locator("#native-dialogue")).toBeHidden(); + await page.clock.runFor(5_000); + await expect(page.locator("#battle")).toBeVisible(); + await expect(page.locator("#battle-text")).toHaveText( + /HOOTHOOT fell\s+out of the tree!/, + ); + await expect(page.locator("#battle-foe-name")).toHaveText("HOOTHOOT"); + await expect(page.locator("#battle-foe-status")).toHaveText("SLP"); + await expect(page.locator("#game-notice")).toHaveText(""); + await expect(screen).toHaveAttribute("data-map", "24:3"); +}); + test("Mom keeps the player's money with the digit box", async ({ page }) => { await start(page, "mom"); const text = page.locator("#dialogue-text"), diff --git a/tests/foundation-fixture.ts b/tests/foundation-fixture.ts index 2633860..e0e3e49 100644 --- a/tests/foundation-fixture.ts +++ b/tests/foundation-fixture.ts @@ -9,10 +9,21 @@ import { generatePartyMon } from "../src/game/party/pokemon"; * Pokégear, its map and phone cards, and Mom's and Elm's numbers). "mom" * faces Mom in the living room after the Mystery Egg, with her bank open * and ¥2300 saved. "fly" stands outdoors with a Pidgeot that knows Fly, - * the Storm Badge, and New Bark Town and Cherrygrove City visited. */ + * the Storm Badge, and New Bark Town and Cherrygrove City visited. + * "headbutt" faces Route 29's first reachable Headbutt tree from below + * with a Pokémon that knows Headbutt; Random always returning 0 drops a + * sleeping Hoothoot. */ export function foundationFixture( assets: CrystalAssets, - room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom" | "fly", + room: + | boolean + | "poster" + | "center" + | "candy" + | "pokegear" + | "mom" + | "fly" + | "headbutt", ) { const world = new GameWorld( assets, @@ -73,6 +84,21 @@ export function foundationFixture( world.state.flags.add(assets.spawnEngineFlag(spawn)!); for (let event = 0; event < 8; event++) world.state.events.delete(event); } + if (room === "headbutt") { + const mon = generatePartyMon(assets.pokemon, { + species: 158, // TOTODILE + level: 30, + item: 0, + otId: world.playerId, + ot: "CHRIS", + dvs: 0x9abc, + caughtData: 0, + }); + mon.moves = [OW.HEADBUTT, 33, 0, 0]; // TACKLE + mon.pp = [15, 35, 0, 0]; + world.state.party = [mon]; + for (let event = 0; event < 8; event++) world.state.events.delete(event); + } if (room === "mom") { // EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON, EVENT_GAVE_MYSTERY_EGG_TO_ELM // and EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST. @@ -83,15 +109,17 @@ export function foundationFixture( } const save = world.save(); const [map, x, y] = - room === "center" || room === "candy" - ? ["26:5", 9, 2] - : room === "poster" - ? ["24:7", 6, 1] - : room === "mom" - ? ["24:6", 7, 3] - : room && room !== "pokegear" && room !== "fly" - ? ["24:7", 2, 2] - : ["24:4", 9, 6]; + room === "headbutt" + ? headbuttSpot(assets) + : room === "center" || room === "candy" + ? ["26:5", 9, 2] + : room === "poster" + ? ["24:7", 6, 1] + : room === "mom" + ? ["24:6", 7, 3] + : room && room !== "pokegear" && room !== "fly" + ? ["24:7", 2, 2] + : ["24:4", 9, 6]; world.restore({ ...save, map, @@ -102,3 +130,17 @@ export function foundationFixture( }); return world; } + +/** Below Route 29's first Headbutt tree with walkable ground under it. */ +function headbuttSpot(assets: CrystalAssets): [string, number, number] { + const map = assets.map("24:3"); + for (let y = 1; y < map.height - 1; y++) + for (let x = 0; x < map.width; x++) + if ( + assets.collision(map, x, y) === OW.COLL_HEADBUTT_TREE && + assets.permission(assets.collision(map, x, y + 1)) === 0 && + !map.objects.some((o) => o.x === x && o.y <= y + 1 && o.y >= y) + ) + return ["24:3", x, y + 1]; + throw new Error("No reachable Headbutt tree."); +} diff --git a/tests/headbutt-rock-smash.test.ts b/tests/headbutt-rock-smash.test.ts new file mode 100644 index 0000000..f4542b0 --- /dev/null +++ b/tests/headbutt-rock-smash.test.ts @@ -0,0 +1,338 @@ +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 { emptyScriptState } from "../src/game/scripts/interpreter"; +import { generatePartyMon } from "../src/game/party/pokemon"; +import { + rockMonEncounter, + treeMonEncounter, + treeScore, +} from "../src/game/scripts/treemons"; +import type { Direction } from "../src/game/world/types"; +import { settle } from "./world-driver"; + +const romPath = + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; +const NEW_BARK_TOWN = "24:4", + ROUTE_29 = "24:3", + DARK_CAVE_VIOLET_ENTRANCE = "3:78"; +const HEADBUTT = 29, + ROCK_SMASH = 0xf9, + TACKLE = 33; +const EXEGGCUTE = 102, + KRABBY = 98, + HOOTHOOT = 163, + SPINARAK = 167, + PINECO = 204, + SHUCKLE = 213, + TOTODILE = 158; +const COLL_HEADBUTT_TREE = 0x15; +/** A rock (SPRITEMOVEDATA_SMASHABLE_ROCK) in Dark Cave at 27, 6. */ +const ROCK = { x: 27, y: 6 }; + +describe("GetTreeScore", () => { + it("compares the facing tile's score with the trainer ID's last digit", () => { + // (5 × 5 + 5 + 5) / 5 = 7. + expect(treeScore(5, 5, 7)).toBe(2); // equal: rare + expect(treeScore(5, 5, 12343)).toBe(1); // 7 - 3 = 4: good + expect(treeScore(5, 5, 2)).toBe(0); // 5: bad + // (2 × 4 + 2 + 4) / 5 = 2, below the ID's digit: ten is added. + expect(treeScore(2, 4, 9)).toBe(1); // 2 - 9 + 10 = 3 + expect(treeScore(2, 4, 6)).toBe(0); // 2 - 6 + 10 = 6 + // The largest coordinates still fit the routine's 16 bits. + expect(treeScore(255, 255, 3)).toBe(1); // 65535 / 5 = 13107: 7 - 3 = 4 + }); +}); + +/** RandomRange answers in order, each checked against its range. */ +function draws(...values: number[]) { + const ranges: number[] = []; + const next = (range: number) => { + ranges.push(range); + const value = values.shift(); + if (value === undefined) throw new Error("No draw left."); + expect(value).toBeLessThan(range); + return value; + }; + return { next, ranges, left: () => values.length }; +} + +describe.skipIf(!existsSync(romPath))("Headbutt and Rock Smash", () => { + const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); + + it("draws tree Pokémon from the map's set by score", () => { + const r = assets().reader; + // (5, 5) scores 7: trainer IDs ending in 2, 3 and 7 are bad, good and rare. + const tree = (id: number, ...values: number[]) => { + const random = draws(...values); + const mon = treeMonEncounter(r, ROUTE_29, { x: 5, y: 5 }, id, random.next); + expect(random.left()).toBe(0); + return { mon, ranges: random.ranges }; + }; + // Bad: only a zero out of ten finds one; the roll then picks the slot. + expect(tree(2, 0, 0)).toEqual({ + mon: { species: HOOTHOOT, level: 10 }, + ranges: [10, 100], + }); + expect(tree(2, 1).mon).toBeUndefined(); + // Good: five out of ten; 64 passes Hoothoot's 50 into Spinarak's 15. + expect(tree(3, 4, 64).mon).toEqual({ species: SPINARAK, level: 10 }); + expect(tree(3, 5).mon).toBeUndefined(); + // Rare: eight out of ten, from the rare table after the common one. + expect(tree(7, 7, 64).mon).toEqual({ species: PINECO, level: 10 }); + expect(tree(7, 7, 99).mon).toEqual({ species: EXEGGCUTE, level: 10 }); + expect(tree(7, 8).mon).toBeUndefined(); + // New Bark Town's set is TREEMON_SET_NONE; Dark Cave is not listed. + for (const map of [NEW_BARK_TOWN, DARK_CAVE_VIOLET_ENTRANCE]) { + const random = draws(); + expect(treeMonEncounter(r, map, { x: 5, y: 5 }, 2, random.next)).toBeUndefined(); + expect(random.ranges).toEqual([]); + } + }); + + it("finds a rock Pokémon four times out of ten where the map has a set", () => { + const r = assets().reader; + const rock = (map: string, ...values: number[]) => { + const random = draws(...values); + const mon = rockMonEncounter(r, map, random.next); + expect(random.left()).toBe(0); + return mon; + }; + expect(rock(DARK_CAVE_VIOLET_ENTRANCE, 3, 89)).toEqual({ species: KRABBY, level: 15 }); + expect(rock(DARK_CAVE_VIOLET_ENTRANCE, 3, 90)).toEqual({ species: SHUCKLE, level: 15 }); + expect(rock(DARK_CAVE_VIOLET_ENTRANCE, 4)).toBeUndefined(); + expect(rock(ROUTE_29)).toBeUndefined(); + }); + + /** A world whose Random always returns `random`, at `hour`, with one + * Pokémon that knows `moves`. */ + function fixture(moves: number[], random = 0, hour = 12) { + const crystal = assets(); + const ported = crystal.isPorted.bind(crystal); + crystal.isPorted = (id) => ported(id) || id === DARK_CAVE_VIOLET_ENTRANCE; + const time = `2026-09-22T${String(hour).padStart(2, "0")}:00:00`; + const world = new GameWorld(crystal, () => new Date(time), () => random); + world.state = emptyScriptState(); + const mon = generatePartyMon(crystal.pokemon, { + species: TOTODILE, + level: 30, + item: 0, + otId: 1, + ot: "CHRIS", + dvs: 0, + caughtData: 0, + }); + mon.moves = [...moves, TACKLE, 0, 0, 0].slice(0, 4) as typeof mon.moves; + mon.pp = [10, 10, 10, 10]; + mon.nickname = "BITEY"; + world.state.party = [mon]; + world.state.scenes[NEW_BARK_TOWN] = 1; + return { assets: crystal, world }; + } + /** Stand below a Route 29 Headbutt tree, facing it. */ + function underTree(world: GameWorld) { + const map = world.assets.map(ROUTE_29); + for (let y = 1; y < map.height - 1; y++) + for (let x = 0; x < map.width; x++) + if ( + world.assets.collision(map, x, y) === COLL_HEADBUTT_TREE && + world.assets.permission(world.assets.collision(map, x, y + 1)) === 0 && + !map.objects.some((o) => o.x === x && o.y <= y + 1 && o.y >= y) + ) { + world.restore({ ...world.save(), map: ROUTE_29, x, y: y + 1, direction: "up", objects: [] }); + return { x, y }; + } + throw new Error("No reachable Headbutt tree."); + } + function atRock(world: GameWorld, direction: Direction = "up") { + world.restore({ + ...world.save(), + map: DARK_CAVE_VIOLET_ENTRANCE, + x: ROCK.x, + y: ROCK.y + 1, + direction, + }); + } + const rockVisible = (world: GameWorld) => + world.objects().some((o) => o.x === ROCK.x && o.y === ROCK.y); + /** Drive pages and Yes/No boxes (YES) until a battle starts or the + * world settles; returns the pages and the shake's ticks and square. */ + function play(world: GameWorld) { + const pages: string[] = []; + let shakeTicks = 0, + shaken: { x: number; y: number } | undefined; + for (let tick = 0; tick < 2000; tick++) { + if (world.battle) break; + const dialogue = world.dialogue; + const tree = world.headbuttTree; + if (tree) { + shakeTicks++; + shaken = { x: tree.x, y: tree.y }; + expect(dialogue).toBeUndefined(); + } + if (dialogue) { + const page = dialogue.pages[dialogue.index]; + if (pages.at(-1) !== page) pages.push(page); + const last = dialogue.index === dialogue.pages.length - 1; + if (last && dialogue.choice && dialogue.choice.kind !== "yesno") break; + if (last && dialogue.choice?.kind === "yesno") world.choose(1); + else if (last && dialogue.holdTicks !== undefined) world.tick(); + else world.advanceDialogue(); + continue; + } + if (!world.busy) break; + world.tick(); + } + return { text: pages.join("\n"), shakeTicks, shaken }; + } + /** Run from the battle and let the script finish. */ + function runAway(world: GameWorld) { + const messages: string[] = []; + for (let tick = 0; tick < 5000; tick++) { + const view = world.battle; + if (view) { + if (view.message && messages.at(-1) !== view.message.text) messages.push(view.message.text); + if (view.prompt?.kind === "action") world.battleAction({ action: "run", move: 0 }); + else if (view.message?.prompt) world.interact(); + else world.tick(); + } else if (world.dialogue) world.advanceDialogue(); + else if (world.busy) world.tick(); + else return messages.join("\n"); + } + throw new Error(`The battle did not end: ${world.diagnostic}`); + } + + it("asks to Headbutt a tree, shakes it and finds nothing", () => { + // Nine out of ten fails at any score. + const { world } = fixture([HEADBUTT], 9); + const tree = underTree(world); + world.interact(); + const { text, shakeTicks, shaken } = play(world); + expect(world.notice, world.diagnostic).toBe(""); + expect(text).toContain("A POKéMON could be\nin this tree."); + expect(text).toContain("BITEY did a\nHEADBUTT!"); + expect(text).toContain("Nope. Nothing…"); + expect(text.indexOf("HEADBUTT!")).toBeLessThan(text.indexOf("Nope")); + expect(shaken).toEqual(tree); + expect(shakeTicks).toBe(20); + expect(world.battle).toBeUndefined(); + expect(world.busy).toBe(false); + }); + + it("does nothing at a tree without a Pokémon that knows Headbutt", () => { + const { world } = fixture([]); + underTree(world); + world.interact(); + expect(world.dialogue).toBeUndefined(); + expect(world.busy).toBe(false); + }); + + it("drops a Pokémon from the tree into a tree battle, asleep in the day", () => { + const { world } = fixture([HEADBUTT]); + underTree(world); + world.interact(); + play(world); + expect(world.battle, world.diagnostic).toBeDefined(); + const enemy = world.battle!.engine.enemy.mon; + expect(enemy.species).toBe(HOOTHOOT); + expect(enemy.level).toBe(10); + expect(enemy.status).toBe(7); // TREEMON_SLEEP_TURNS + expect(world.battle!.engine.battleType).toBe(8); // BATTLETYPE_TREE + const messages = runAway(world); + expect(messages).toContain("HOOTHOOT fell\nout of the tree!"); + expect(world.diagnostic).toBe(""); + expect(world.state.pokedex.seen.has(HOOTHOOT)).toBe(true); + }); + + it("leaves Hoothoot awake at night, when it is not in the asleep list", () => { + const { world } = fixture([HEADBUTT], 0, 22); + underTree(world); + world.interact(); + play(world); + expect(world.battle, world.diagnostic).toBeDefined(); + expect(world.battle!.engine.enemy.mon.species).toBe(HOOTHOOT); + expect(world.battle!.engine.enemy.mon.status).toBe(0); + runAway(world); + }); + + /** Start → POKéMON → BITEY → `move`, then the queued script. */ + function useFromMenu(world: GameWorld, move: string) { + expect(world.openStartMenu()).toBe(true); + const pick = (label: string) => { + for (let tick = 0; tick < 200 && world.dialogue?.choice?.kind !== "menu"; tick++) { + if (world.dialogue) world.advanceDialogue(); + else world.tick(); + } + const choice = world.dialogue?.choice; + if (choice?.kind !== "menu") throw new Error(`No menu for ${label}`); + const index = choice.items.findIndex((item) => item.startsWith(label)); + expect(index, `${label} in ${choice.items}`).toBeGreaterThanOrEqual(0); + world.choose(index); + }; + pick("POKéMON"); + pick("BITEY"); + pick(move); + const { text } = play(world); + // A failure prints its message and returns to the party menu. + if (world.dialogue?.choice) { + for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue(); + settle(world); + } + return [text]; + } + + it("uses Headbutt from the party menu only when facing a tree", () => { + const { world } = fixture([HEADBUTT], 9); + underTree(world); + world.restore({ ...world.save(), direction: "down" }); + expect(useFromMenu(world, "HEADBUTT")[0]).toMatch(/^Can't use that\nhere.\n/); + world.restore({ ...world.save(), direction: "up" }); + const [text] = useFromMenu(world, "HEADBUTT"); + expect(world.notice, world.diagnostic).toBe(""); + // HeadbuttFromMenuScript skips the question. + expect(text).not.toContain("in this tree"); + expect(text).toContain("BITEY did a\nHEADBUTT!"); + expect(text).toContain("Nope. Nothing…"); + }); + + it("explains a rock to a party without Rock Smash", () => { + const { world } = fixture([]); + atRock(world); + world.interact(); + expect(settle(world)).toEqual(["Maybe a POKéMON\ncan break this."]); + expect(rockVisible(world)).toBe(true); + }); + + it("smashes a rock that hides nothing", () => { + const { world } = fixture([ROCK_SMASH], 9); + atRock(world); + expect(rockVisible(world)).toBe(true); + world.interact(); + const { text } = play(world); + expect(world.notice, world.diagnostic).toBe(""); + expect(text).toContain("This rock looks\nbreakable."); + expect(text).toContain("BITEY used\nROCK SMASH!"); + expect(world.battle).toBeUndefined(); + expect(rockVisible(world)).toBe(false); + }); + + it("smashes a rock from the party menu and meets the Krabby under it", () => { + const { world } = fixture([ROCK_SMASH]); + atRock(world, "down"); + expect(useFromMenu(world, "ROCK SMASH")[0]).toMatch(/^Can't use that\nhere.\n/); + world.restore({ ...world.save(), direction: "up" }); + const [text] = useFromMenu(world, "ROCK SMASH"); + expect(world.notice, world.diagnostic).toBe(""); + expect(text).not.toContain("breakable"); + expect(text).toContain("BITEY used\nROCK SMASH!"); + expect(rockVisible(world)).toBe(false); + expect(world.battle).toBeDefined(); + const enemy = world.battle!.engine.enemy.mon; + expect([enemy.species, enemy.level, enemy.status]).toEqual([KRABBY, 15, 0]); + expect(world.battle!.engine.battleType).toBe(0); + expect(runAway(world)).toContain("Wild KRABBY\nappeared!"); + expect(world.diagnostic).toBe(""); + }); +}); diff --git a/tests/human-test-saves.test.ts b/tests/human-test-saves.test.ts index a49814d..59ba7bb 100644 --- a/tests/human-test-saves.test.ts +++ b/tests/human-test-saves.test.ts @@ -65,5 +65,12 @@ describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)( expect(world.mapId).toBe("24:4"); write("fly-teleport", world); }); + + it("writes headbutt: Route 29 facing a Headbutt tree", () => { + // Totodile with Headbutt and Tackle, below the first reachable tree. + const world = foundationFixture(assets(), "headbutt"); + expect(world.mapId).toBe("24:3"); + write("headbutt", world); + }); }, ); diff --git a/tests/overworld-rom.test.ts b/tests/overworld-rom.test.ts index 0de7e69..67436fb 100644 --- a/tests/overworld-rom.test.ts +++ b/tests/overworld-rom.test.ts @@ -537,7 +537,7 @@ describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => { closeMenus(world); }); - it("recognises a smashable rock for Rock Smash, which is not ported further", () => { + it("recognises a smashable rock for Rock Smash and smashes it", () => { // A rock (SPRITEMOVEDATA_SMASHABLE_ROCK) in Dark Cave at 27, 6. const spot = { map: "3:78", x: 27, y: 6 }; const { assets, world } = fixture([spot.map]); @@ -552,10 +552,19 @@ describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => { expect(useFromMenu(world, "ROCK SMASH")).toEqual(["Can't use that\nhere."]); closeMenus(world); world.restore({ ...world.save(), direction: "up" }); - useFromMenu(world, "ROCK SMASH"); - expect(world.notice).toBe("Smashing rocks is not ported yet."); - closeMenus(world); - expect(world.diagnostic).toBe(""); + const shown = useFromMenu(world, "ROCK SMASH"); + for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue(); + // Random always returns 0 here, so the rock hides a Krabby. + for (let tick = 0; tick < 500 && !world.battle; tick++) { + if (world.dialogue) { + shown.push(world.dialogue.pages[world.dialogue.index]); + world.advanceDialogue(); + } else world.tick(); + } + expect(world.notice, world.diagnostic).toBe(""); + expect(shown).toContain("SWIMMY used\nROCK SMASH!"); + expect(world.objects().some((o) => o.x === spot.x && o.y === spot.y)).toBe(false); + expect(world.battle?.engine.enemy.mon.species).toBe(98); // KRABBY }); it("draws a wild Pokémon with Sweet Scent in grass, and nothing elsewhere", () => {