Port Headbutt and Rock Smash encounters

Facing a Headbutt tree asks to HEADBUTT it, and HEADBUTT from the party
menu skips the question. ShakeHeadbuttTree shakes the tree as a sprite over
grass, then TreeMonEncounter scores the facing tile against the trainer ID
and draws from the map's common or rare tree table. A Pokémon that falls
fights as BATTLETYPE_TREE: "fell out of the tree!", and the time of day's
AsleepTreeMons species start asleep.

Smashable rocks run SmashRockScript, and ROCK SMASH from the party menu
acts on the facing rock; after the rock breaks, RockMonEncounter gives the
map's rock set a 40% chance of a wild battle.

Closes #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-02 12:20:13 +02:00
1 parent ee55d344af
commit f259163da9
18 files changed
+789 -36

No files matched your search

+10 -2
View File
@@ -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.
+50 -1
View File
@@ -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).
+1
View File
@@ -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");
+26 -2
View File
@@ -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 =
+2
View File
@@ -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,
+40 -1
View File
@@ -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(
+4
View File
@@ -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 {
+16
View File
@@ -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,
+48 -1
View File
@@ -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: {
+2
View File
@@ -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 }
+97
View File
@@ -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) };
}
+2
View File
@@ -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,
+51 -12
View File
@@ -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);
+28 -1
View File
@@ -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"),
+53 -11
View File
@@ -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.");
}
+338
View File
@@ -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("");
});
});
+7
View File
@@ -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);
});
},
);
+14 -5
View File
@@ -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", () => {