diff --git a/README.md b/README.md index 0b192f9..3157569 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ Team Rocket then takes over Goldenrod's Radio Tower. Fight up to the fake direct Routes 45 and 46 lead south from Blackthorn back to Route 29. In Ecruteak, the Clear Bell convinces the sages to let you into Tin Tower, where Suicune puts you to the test. After the League, Tin Tower's upper floors lead to Ho-Oh, and the Whirl Islands hide Lugia for a trainer with the Silver Wing. -The Battle Tower, Kanto, the League, the Battle Tower and Whirl Islands, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. +On Route 40, the Battle Tower takes a party of three different Pokémon holding different items into a Battle Room of the level you choose, against seven trainers in a row. Win them all for five of a random vitamin. Between battles you can save and quit, and the receptionist picks up where you left off. + +Kanto, the League, the Battle Tower and Whirl Islands, the Day-Care's Eggs, trade evolutions, the Pokégear's radio, the Pokédex's area view, search and other list modes, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet. ## Run diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md index 74c4b50..a8292ce 100644 --- a/docs/NATIVE_PORT.md +++ b/docs/NATIVE_PORT.md @@ -54,6 +54,7 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta | `game/scripts/lake-of-rage.ts`, Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | `engine/events/magikarp.asm` (`CheckMagikarpLength`, `CalcMagikarpLength`, `PrintMagikarpLength`, `MagikarpHouseSign`), `engine/menus/intro_menu.asm` (`InitializeMagikarpHouse`), `engine/pokemon/search_party.asm` (`FindThatSpecies`), `engine/overworld/scripting.asm` (`DoScene`), `engine/events/money.asm` (`TakeMoney`) | The Magikarp is measured from its own DVs and OT ID with the battle code's `CalcMagikarpLength` (whose `.BCLessThanDE` compares only the high byte). A longer one, compared feet then inches, becomes the record with the Magikarp's OT name as holder; the record starts at 3′6″ by RALPH and is saved as `magikarpRecord`. `FindThatSpecies` reads `wPartySpecies`, where an Egg is EGG, so an Egg is never found. `setmapscene`/`setscene` on a map without a scene variable (Route 43, the Lake of Rage): `GetMapSceneID` leaves `de` alone and `DoScene` still writes the byte through it when it is nonzero. At the Route 43 gate `de` is `EventFlagAction`'s byte offset of `EVENT_CLEARED_ROCKET_HIDEOUT` (4), the MBC3 RAM-enable register, so nothing changes. After the Red Gyarados, `de` is whatever `closetext` left; the port consumes the byte and changes nothing, and that write was not checked in an emulator. `TakeMoney` leaves a short account at zero, so the Route 43 Rockets take all of a smaller balance. The Rocket Hideout's cameras, traps, passwords, executives and Electrodes and Pryce's ice floor run on ported primitives; `PlayCurMonCry` is audio. `tests/mahogany.test.ts` and five playthrough stages cover them. | | `game/scripts/blackthorn.ts`, the Radio Tower takeover, Ice Path, Blackthorn and Dragon's Den | `engine/events/dratini.asm` (`GiveDratini`), `engine/events/move_deleter.asm` (`MoveDeletion`), `engine/pokemon/mon_menu.asm` (`ChooseMoveToDelete`), `engine/events/whiteout.asm` (`Script_Whiteout`), `data/maps/setup_scripts.asm` (`MapSetupScript_Continue`) | `GiveDratini` searches the party backwards by species and writes `.Movesets` 0 (WRAP, THUNDER WAVE, TWISTER, EXTREMESPEED) or 1 with each move's base PP; a value of 2 or more changes nothing. `MoveDeletion` follows the routine's order of questions; `ChooseMoveToDelete` is shown as a list of the moves, and `.DeleteMove` shifts the later moves and PP bytes, PP Ups included. A save records objects whose visibility no longer matches their flags: `MapSetupScript_Continue` skips `LoadMapObjects`, so a flagless object a script made disappear (the Radio Tower director) stays gone after Continue and one that appeared with its flag set stays visible; a later visit rebuilds them from the map. A battle lost inside an `scall` jumps to `Script_Whiteout` without unwinding; its `newloadmap` is followed by `endall`, which drops the caller. The switch room's doors, the warehouse's reset, the Ice Path and Blackthorn Gym stone tables, whirlpools and the Dragon Shrine quiz run on ported primitives. `tests/blackthorn.test.ts`, `tests/radio-tower.test.ts` and six playthrough stages cover them. | | `game/scripts/tin-tower.ts`, Tin Tower and Whirl Islands | `engine/pokemon/search_owned.asm` (`BeastsCheck`, `MonCheck`, `CheckOwnMonAnywhere`, `CheckOwnMon`) | `CheckOwnMonAnywhere` returns nothing with an empty party, then reads the party structs' species (an Egg counts as its species), `sBox` and the fourteen `sBoxN` copies, matching the player's ID and the OT's first five letters (`NAME_LENGTH_JAPANESE - 2` compares, stopping early at a shared terminator); the Day-Care is not searched. The original's `sBox` is the current box, and its own `sBoxN` slot may still hold an older copy from before the last box change; the port keeps the boxes without stale copies, so a Pokémon only in such a copy is not found. The sages, Suicune, Ho-Oh and Lugia run on ported primitives (`BATTLETYPE_SUICUNE`, `BATTLETYPE_FORCEITEM`). `tests/tin-tower.test.ts` and two playthrough stages cover them. | +| `game/scripts/battle-tower.ts`, the Battle Tower | `engine/events/battle_tower/battle_tower.asm` (`BattleTowerAction`, `RunBattleTowerTrainer`, `ReadBTTrainerParty`, `CopyBTTrainer_FromBT_OT_TowBT_OTTemp`, `BattleTower_GiveReward`, `BattleTower_RandomlyChooseReward`, `LoadOpponentTrainerAndPokemonWithOTSprite`, `CheckForBattleTowerRules`), `engine/events/battle_tower/load_trainer.asm`, `engine/events/battle_tower/rules.asm`, `engine/events/battle_tower/trainer_text.asm`, `mobile/mobile_46.asm` (`_BattleTowerRoomMenu`, `BattleTower_LevelCheck`, `BattleTower_UbersCheck`), `mobile/mobile_5f.asm` (`Menu_ChallengeExplanationCancel`), `engine/link/link.asm` (`TryQuickSave`), `engine/battle/core.asm`, `engine/battle/effect_commands.asm`, `engine/battle/misc.asm` (`DoBadgeTypeBoosts`) | The Battle Tower's SRAM and the work RAM its scripts share are saved as `battleTower`. The trainer sampler keeps its index as the running byte after a repeated trainer; the Pokémon sampler starts again from `hRandomAdd`; both fold `Random`'s `hRandomAdd` into a byte below a power-of-two mask, which the port draws from its random source. `BattleTowerText` reads the class's gender (1.1 behaviour) and picks the greeting from `hRandomAdd` (0-24 male, 0-14 female), keeping it for the end texts. Each battle start counts the battle and marks the challenge in progress, as `CopyBTTrainer_FromBT_OT_TowBT_OTTemp` does; `LoadPokemonData` then returns the party, Pokédex and Unown Dex to the quick save's. The level menu shows the levels as a list; its CANCEL/B question and the mobile code's YES/NO flow are modelled from their outcomes (YES leaves with $a, NO picks a level again). `TryQuickSave` asks the overwrite question for the player's own save and shows the saving texts; the port keeps the place and Pokémon data `LoadPokemonData` and `Reset` return to. The native game saves itself, so the original's "left without saving" state (a reset during a challenge) cannot arise: a reload replays the room's script. `Reset` returns the player to the quick save's place with the 1F scene set to check the challenge; the original's rollback of money gained by Pay Day is not reproduced. In battle, `wInBattleTowerBattle` turns off obedience, `DoBadgeTypeBoosts`, `BadgeStatBoosts`, experience, the pack (BattleMenu_Pack says items can't be used), AI items and prize money, uses Falkner's AI attributes, keeps Sleep to `%011` and skips the AI's random failure for Sleep, Poison, stat-lowering and Paralyze moves; a loss shows BattleTowerText and does not white out. `DoBadgeTypeBoosts` was added to every battle (it was missing). BATTLETOWERACTION_LEVEL_CHECK and _UBERS_CHECK and the mobile actions are reached only from unreferenced scripts and stay refused. `tests/battle-tower.test.ts`, `tests/battle-tower-engine.test.ts` and `tests/reference-badges.spec.ts` cover them. | | `game/scripts/slot-machine.ts`, `game/scripts/card-flip.ts` | `engine/games/slot_machine.asm`, `engine/games/card_flip.asm`, `engine/gfx/cgb_layouts.asm` | Both on a realtime tile screen (two frames per tick, buffered buttons). Slots: reel strips, spin rates and scrolling, bias tables with the lucky machine and seven streak, reel 3's Golem and Chansey replay with their sprites, payouts. Card Flip: deck, 12×11 board, cursor sprites and `ChooseCard_HandleJoypad`, paytable. Tilemaps, all 48 cursor positions, selected reel states and whole Golem/Chansey/Egg sequences are checked against the ROM (`tests/reference-game-corner.spec.ts`, which runs `CardFlip_UpdateCursorOAM`, `Slots_UpdateReelPositionAndOAM`/`.SpinReel` and the sprite-animation engine's own Golem/Chansey/Egg routines): the cursor, the reel symbols and the three creatures each anchor an OAM-relative pixel pair 8 (x) and 16 (y) beyond the screen's, as `cursorSprites` reads; `depixel`'s own two arguments end up swapped by `_InitSpriteAnimStruct` (confirmed against the reference emulator), so Golem/Chansey/Egg's X and Y bases were swapped to match, and Golem's own OAM table X-flips its right half. `PlaySpriteAnimations` is compared frame by frame over a three-Golem run and a Chansey round with two eggs (shadow OAM, `wSlotsDelay` and `hSCY`): frames last 8 ticks (the first draw plus 7 decrements); Golem's frames 3 and 4 flip the whole OAM set (offsets become −8−offset); a sprite removed by `DeinitializeSprite` is still drawn that tick, because `UpdateAnimFrame` runs after the animation function; the Golem's roll toggles `hSCY` by ±2, which the tile screen shows with the space tiles `_SlotMachine` filled the BG map with. The rows scrolled in use palette 0, since the attribute map beyond row 18 is not reproduced. No sound. | | Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. | diff --git a/docs/PLAN.md b/docs/PLAN.md index b40c79d..4b75da4 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -22,6 +22,7 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati | Route 42 to Mahogany, the Lake of Rage, the Rocket Hideout and Pryce | Done | | The Radio Tower takeover, Ice Path, Blackthorn, Clair and Dragon's Den | Done | | Tin Tower, Suicune, Ho-Oh, Whirl Islands and Lugia | Done | +| The Battle Tower | Done | | Wild and trainer battles, capture and the catching tutorial | Done for the areas that are open | | 1. Complete the overworld foundation | Done | | 2. Opening story and first battle slice | Mostly done | @@ -446,7 +447,16 @@ The numbered sections below keep their full scope. Each one opens with a checkli - Tin Tower's nine floors and roof and the Whirl Islands' eight maps are open. The Clear Bell lets the player past the entrance's sages and through the Wise Trio's room; in Tin Tower 1F Raikou and Entei flee unless caught, and Suicune battles (`BATTLETYPE_SUICUNE`), followed by Eusine. - New specials (`game/scripts/tin-tower.ts`): `MonCheck` and `BeastsCheck` over `CheckOwnMonAnywhere`: the party and every box, the player's ID and the first five letters of the OT, an empty party finding nothing, the Day-Care left out. After the League with all three beasts, the second Wise Trio appears and gives the Rainbow Wing; its stairs lead up to Ho-Oh on the roof. Lugia waits at the bottom of the Whirl Islands for a player with the Silver Wing. Both battles use `BATTLETYPE_FORCEITEM`. - The playthrough driver plans ledge jumps (for the later stages only, so the earlier seeded stages keep their recorded paths), waterfalls carrying a surfer down, warp panels landing elsewhere on a map, and Surf and Whirlpool between warps in `travel`. Tests: `tests/tin-tower.test.ts` (MonCheck's rules, the Rainbow Wing and the climb to Ho-Oh, the Whirl Islands to Lugia). The playthrough continues for two more stages: Routes 45 and 46 back to Ecruteak, and Tin Tower's Suicune. -- Remaining: the Battle Tower, Olivine's port and the S.S. Aqua (board #33), radio and rematches (#30), breeding (#29), mobile and link features, and audio. `CheckOwnMonAnywhere` in the original also reads the current box's own SRAM copy, which can be stale; the port keeps no stale copy. +- Remaining: the Battle Tower (increment H below), Olivine's port and the S.S. Aqua (board #33), radio and rematches (#30), breeding (#29), mobile and link features, and audio. `CheckOwnMonAnywhere` in the original also reads the current box's own SRAM copy, which can be stale; the port keeps no stale copy. + +## Implemented: the Battle Tower (board #32, increment H) + +- The Route 40 gate, the Battle Tower outside and its 1F, elevator, hallway and Battle Room are open. Offline challenges work as in the original: the explanation, the Challenge/Explanation/Cancel menu, the party rules (three Pokémon, different species and items, no Egg), the save before entering, the level menu (L:10-L:40, or to L:100 after the Hall of Fame) with its level and uber checks, the walk to the room, seven trainers with healing and "next opponent" questions, save and quit, cancelling, a loss, and the reward of five of a random vitamin (not Lucky Punch). +- New in `game/scripts/battle-tower.ts`: `BattleTowerAction`'s offline actions on a saved `battleTower` state (the challenge state, streak count and trainers, the last two trainers' species, the level group, the reward and the file flags); `LoadOpponentTrainerAndPokemon`'s trainer and Pokémon sampling (no repeated trainer, species or item, none of the last two trainers' species) from `BattleTowerTrainers` and `BattleTowerMons`; `BattleTowerText` (a random greeting, kept for the end texts); `_CheckForBattleTowerRules`; `Menu_ChallengeExplanationCancel`; the level menu of `_BattleTowerRoomMenu`; `TryQuickSave`; and `Reset`. New script command: `battletowertext`. The opponent's overworld sprite comes from `BTTrainerClassSprites`. +- Battle Tower battles (`battleTower` in the battle engine): the SET style, no obedience check, badge boosts, experience, items or prize, no AI item use, Falkner's AI attributes, Sleep of one to three turns, no random failure of the AI's status moves, and BattleTowerText instead of the prize or a whiteout. After each battle `LoadPokemonData` returns the party and Pokédex to the quick save's, so held items used in battle come back. +- `DoBadgeTypeBoosts` was missing from all battles: on the player's turn, the first badge whose type is the move's adds an eighth of the damage. `tests/reference-badges.spec.ts` now compares it with the original routine for every Johto badge byte, Kanto badges, types, damage values, both turns and the Battle Tower. +- The native game saves itself, so a reload during a challenge replays the room's script from its start instead of reaching the original's "left without saving" state, and `Reset` (save and quit) returns to where the quick save was made. The original also rolls back money gained by Pay Day there; the port keeps it. +- Tests: `tests/battle-tower.test.ts` (the gate, every rule's text, the level and uber checks, the L:40 menu, a full streak and its reward, save and quit and the resume, a saved-and-left state after a reload, cancelling and losing) and `tests/battle-tower-engine.test.ts` (no experience, prize or items; obedience; a loss without a whiteout). ## Native battles: current state diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index dcc7046..a59e2aa 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -115,7 +115,9 @@ export type BattleEffect = | { kind: "hud" } /** The battle menu: answered with an action. In the Bug-Catching * Contest, PACK is PARKBALL× with this wParkBallsRemaining. */ - | { kind: "action"; parkBalls?: number } + /** BattleMenu. `noItems`: PACK only says items can't be used (link and + * Battle Tower battles), without opening the pack. */ + | { kind: "action"; parkBalls?: number; noItems?: boolean } /** DisplayCaughtContestMonStats: the stocked contest Pokémon beside the * one just caught, with ContestAskSwitchText; answered Yes/No (1/0). */ | { kind: "contestswitch"; text: string; stock: ContestMonSummary; caught: ContestMonSummary } @@ -219,8 +221,14 @@ export interface BattleOptions { pokedex: { seen: Set; caught: Set }; playerId: number; playerName: string; - /** wJohtoBadges, for traded Pokémon obedience. */ + /** wJohtoBadges, for traded Pokémon obedience and the badges' boosts. */ johtoBadges?: number; + /** wKantoBadges, for DoBadgeTypeBoosts. */ + kantoBadges?: number; + /** wInBattleTowerBattle: a Battle Tower battle (no obedience, badge + * boosts, experience, items, prize money or whiteout; the AI's status + * moves never fail at random; Sleep lasts one to three turns). */ + battleTower?: boolean; /** wTimeOfDay: morning 0, day 1, night 2. */ timeOfDay: number; random: () => number; @@ -964,6 +972,7 @@ export class Battle { * Special Defense boost tests the register that BoostStat leaves after * Special Attack, so it depends on that stat (a kept bug). */ private badgeStatBoosts() { + if (this.options.battleTower) return; const badges = this.options.johtoBadges ?? 0; // ZEPHYR, HIVE, FOG, STORM, GLACIER and RISING stay; PLAIN (bit 2) // moves to bit 4 and MINERAL (bit 4) to bit 2. @@ -1128,6 +1137,15 @@ export class Battle { yield this.hud(); this.turn = 0; // SetPlayerTurn before SpikesDamage yield* this.battleTurn(); + if (trainer && this.options.battleTower) { + // WinTrainerBattle and LostBattle's .battle_tower: BattleTowerText + // instead of the prize money or the whiteout. + const won = (this.battleResult & 0x3f) === C.WIN; + if (won) yield* this.say(BATTLE.BattleText_EnemyWasDefeated); + const text = won ? trainer.winText : trainer.lossText; + if (text) yield* this.say(text); + return { result: this.battleResult, evolvable: this.evolvable }; + } if (trainer && (this.battleResult & 0x3f) === C.WIN) { yield* this.say(BATTLE.BattleText_EnemyWasDefeated); if (this.options.canLose) { @@ -1318,8 +1336,17 @@ export class Battle { private *battleMenu(): Gen { for (;;) { const contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined; - const input = yield contest ? { kind: "action", parkBalls: contest.parkBalls } : { kind: "action" }; + const input = yield contest + ? { kind: "action", parkBalls: contest.parkBalls } + : this.options.battleTower + ? { kind: "action", noItems: true } + : { kind: "action" }; if (!input || typeof input !== "object") continue; + if (input.action === "item" && this.options.battleTower) { + // BattleMenu_Pack.ItemsCantBeUsed. + yield* this.say(BATTLE.BattleText_ItemsCantBeUsedHere); + continue; + } if (input.action === "item") { // BattleMenu_Pack's .contest: PARKBALL× throws a Park Ball. const used = contest @@ -2903,6 +2930,7 @@ export class Battle { /** BattleCommand_CheckObedience: only foreign Pokémon may disobey. */ private *checkObedience(): Gen { if (this.turn !== 0 || this.user.charging || this.alreadyDisobeyed) return; + if (this.options.battleTower) return; if (this.partyMon.otId === this.options.playerId) return; const user = this.user, badges = this.options.johtoBadges ?? 0; const limit = badges & 0x80 ? 101 : badges & 0x20 ? 70 : badges & 8 ? 50 : badges & 2 ? 30 : 10; @@ -3157,7 +3185,10 @@ export class Battle { target = this.target; if (user.move.anim === C.STRUGGLE) return; const result = applyTypeMatchups(this.matchups, { - damage: this.weatherModifier(this.curDamage, user.move.type, user.move.effect), + damage: this.badgeTypeBoost( + this.weatherModifier(this.curDamage, user.move.type, user.move.effect), + user.move.type, + ), modifier: this.typeModifier, moveType: user.move.type, userTypes: user.mon.types, @@ -3183,6 +3214,24 @@ export class Battle { const value = Math.floor((damage * multiplier) / 10); return value > 0xffff ? 0xffff : value || 1; } + /** DoBadgeTypeBoosts, after the weather: on the player's turn, the first + * badge (Johto, then Kanto) whose BadgeTypeBoosts type is the move's adds + * an eighth of the damage (at least 1, to at most $ffff). */ + private badgeTypeBoost(damage: number, type: number) { + if (this.options.battleTower || this.turn !== 0) return damage; + const r = this.reader; + let johto = this.options.johtoBadges ?? 0, + kanto = this.options.kantoBadges ?? 0; + for (let p = BATTLE.BadgeTypeBoosts; r.byte(p) !== 0xff; p++) { + // srl b / rr c: the Johto byte's low bit, then Kanto's shifted in. + const carry = johto & 1; + johto = (johto >> 1) | ((kanto & 1) << 7); + kanto >>= 1; + if (carry && r.byte(p) === type) + return Math.min(0xffff, damage + ((damage >> 3) || 1)); + } + return damage; + } /** BattleCheckTypeMatchup: wTypeMatchup for the move against the target. */ private battleCheckTypeMatchup() { const target = this.target; @@ -4817,7 +4866,8 @@ export class Battle { } /** The AI's 25% failure for status moves (not for the player). */ private aiRandomFail() { - if (this.turn === 0 || this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) return false; + if (this.turn === 0 || this.options.battleTower) return false; + if (this.player.has(SUB5, C.SUBSTATUS_LOCK_ON)) return false; return this.random() < percent(25) + 1; } private *didntAffect2(): Gen { @@ -4834,8 +4884,10 @@ export class Battle { if (this.attackMissed) return yield* this.didntAffect2(); if (this.aiRandomFail() || target.mon.status || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.say(BATTLE.DidntAffect1Text); + // The Battle Tower keeps the sleep counter to %011. + const mask = this.options.battleTower ? 0b011 : C.SLP_MASK; let turns: number; - do turns = this.random() & C.SLP_MASK; + do turns = this.random() & mask; while (!turns || turns === C.SLP_MASK); target.mon.status = turns + 1; this.updateOpponentInParty(); @@ -6264,6 +6316,8 @@ export class Battle { } } private *giveExperience(shared = false): Gen { + // GiveExperiencePoints: none in the Battle Tower. + if (this.options.battleTower) return; const party = this.options.party, data = this.options.data; const count = [0, 1, 2, 3, 4, 5].filter((index) => this.participants & (1 << index)).length; diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index b39fc95..da94bd5 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -770,6 +770,8 @@ export const BATTLE = { BattleText_MonsReflectFaded: 0x80905, WantsToBattleText: 0x807a9, BattleText_EnemyWasDefeated: 0x809da, + BattleText_ItemsCantBeUsedHere: 0x80bf3, + BadgeTypeBoosts: 0xfbe80, GotMoneyForWinningText: 0x809be, SentSomeToMomText: 0x809fc, SentHalfToMomText: 0x80a2a, @@ -1070,6 +1072,38 @@ export const JOHTO = { PlayCurMonCry: 0xc472, GiveDratini: 0x8b170, BeastsCheck: 0x4a6e8, + BattleTowerRoomMenu: 0x1700b0, + CheckForBattleTowerRules: 0x170bd3, + BattleTowerBattle: 0x170215, + LoadOpponentTrainerAndPokemonWithOTSprite: 0x170b44, + BattleTowerFade: 0x8c092, + LoadMapPalettes: 0x1047eb, + Menu_ChallengeExplanationCancel: 0x17d224, + TryQuickSave: 0x29e66, + Reset: 0x150, + BattleTowerMons: 0x1f8450, + BattleTowerTrainers: 0x1f814e, + BTTrainerClassSprites: 0x170b90, + BTTrainerClassGenders: 0x11f2f0, + BTMaleTrainerTexts: 0x11f332, + BTFemaleTrainerTexts: 0x11f3ce, + Text_WhatLevelDoYouWantToChallenge: 0x11aba5, + Text_PartyMonTopsThisLevel: 0x11aaf0, + Text_UberRestriction: 0x11ab0f, + Text_CancelBattleRoomChallenge: 0x11ab4a, + Strings_L10ToL100: 0x119d0c, + Strings_Ll0ToL40: 0x119d64, + ExcuseMeYoureNotReadyText: 0x8b22c, + OnlyThreeMonMayBeEnteredText: 0x8b247, + TheMonMustAllBeDifferentKindsText: 0x8b24c, + TheMonMustNotHoldTheSameItemsText: 0x8b251, + YouCantTakeAnEggText: 0x8b256, + "BattleTower_PleaseReturnWhenReady.BattleTowerReturnWhenReadyText": 0x8b238, + MenuData_ChallengeExplanationCancel: 0x17d297, + "BattleTowerHallwayChooseBattleRoomScript.asm_load_battle_room": 0x9f5cb, + AlreadyASaveFileText: 0x15292, + SavingDontTurnOffThePowerText: 0x15288, + SavedTheGameText: 0x1528d, MonCheck: 0x4a711, "GiveDratini.Movesets": 0x8b1c4, MoveDeletion: 0x2c547, @@ -1299,6 +1333,8 @@ export const WRAM = { wBugContestSecondPlaceScore: 0xd008, wBugContestThirdPlaceScore: 0xd00c, wMagikarpRecordHoldersName: 0xdfea, + wcd49: 0xcd49, + wNrOfBeatenBattleTowerTrainers: 0xcf64, } as const; export const MAP_GROUP_COUNT = 26; @@ -1394,6 +1430,12 @@ export const NATIVE_MAPS: Record = { "3:46": "Olivine Lighthouse 5F", "3:47": "Olivine Lighthouse 6F", "22:1": "Route 40", + "22:11": "Battle Tower 1F", + "22:12": "Battle Room", + "22:13": "Battle Tower elevator", + "22:14": "Battle Tower hallway", + "22:15": "Route 40 Battle Tower gate", + "22:16": "Battle Tower", "22:2": "Route 41", "22:3": "Cianwood City", "22:4": "Manias House", diff --git a/src/game/scripts/battle-tower.ts b/src/game/scripts/battle-tower.ts new file mode 100644 index 0000000..fc47a7e --- /dev/null +++ b/src/game/scripts/battle-tower.ts @@ -0,0 +1,550 @@ +import { JOHTO, RAM, WRAM } from "../rom/profile"; +import { maxPP, validPartyMon, type PartyMon } from "../party/pokemon"; +import type { PokemonData } from "../rom/pokemon"; +import { DIRECTION_ORDER, type Direction } from "../world/types"; +import { readString } from "./text"; +import type { Gen, SpecialContext } from "./special-context"; + +/** BATTLETOWER_* (constants/battle_tower_constants.asm). */ +export const BATTLE_TOWER = { + partyLength: 3, + streakLength: 7, + uniqueMons: 21, + uniqueTrainers: 70, + rewardQuantity: 5, +} as const; +/** sBattleTowerChallengeState values. */ +export const CHALLENGE = { + none: 0, + savedAndLeft: 1, + inProgress: 2, + won: 3, + receivedReward: 4, +} as const; +/** BATTLETOWERACTION_* (wScriptVar for the BattleTowerAction special). */ +export const BATTLE_TOWER_ACTION = { + checkExplanationRead: 0x00, + setExplanationRead: 0x01, + getChallengeState: 0x02, + saveAndQuit: 0x03, + challengeCanceled: 0x04, + action05: 0x05, + action06: 0x06, + saveLevelGroup: 0x07, + loadLevelGroup: 0x08, + checkSaveFileIsYours: 0x09, + action0A: 0x0a, + gsBall: 0x0b, + action11: 0x11, + action12: 0x12, + action13: 0x13, + resetData: 0x1a, + giveReward: 0x1b, + action1C: 0x1c, + action1D: 0x1d, + chooseReward: 0x1e, + saveOptions: 0x1f, +} as const; + +/** party_struct plus a nickname (NICKNAMED_MON_STRUCT_LENGTH). */ +const MON_STRUCT = 48, + NICKNAMED_MON = MON_STRUCT + 11; +/** BattleTowerTrainers' entries: a 10-character name and the class. */ +const TRAINER_ENTRY = 11; +const HP_UP = 0x1a, + CALCIUM = 0x1f, + LUCKY_PUNCH = 0x1e, + POTION = 0x12; +const MEW = 151, + MEWTWO = 150, + LUGIA = 249, + NUM_POKEMON = 251; +/** MAX_ITEMS and MAX_ITEM_STACK (constants/item_data_constants.asm). */ +const MAX_ITEMS = 20, + MAX_ITEM_STACK = 99; + +/** The opponent LoadOpponentTrainerAndPokemon wrote to wBT_OTTrainer. */ +export interface BattleTowerOpponent { + /** BattleTowerTrainers' index, as sBTTrainers records it. */ + trainer: number; + name: string; + trainerClass: number; + party: PartyMon[]; +} + +/** What LoadPokemonData reads back from the save made before the Battle + * Room (TryQuickSave), and where that save put the player. */ +export interface BattleTowerQuickSave { + map: string; + x: number; + y: number; + direction: Direction; + party: PartyMon[]; + seen: number[]; + caught: number[]; + unownDex: number[]; + firstUnownSeen: number; +} + +/** The Battle Tower's SRAM (bank 5) and the work RAM its scripts keep from + * one special to the next. */ +export interface BattleTowerState { + /** sBattleTowerChallengeState. */ + challenge: number; + /** sNrOfBeatenBattleTowerTrainers: raised as each battle starts. */ + beaten: number; + /** sBTTrainers: the BattleTowerTrainers of this streak, $ff unused. */ + trainers: number[]; + /** sBTMonPrevTrainer1-3 and sBTMonPrevPrevTrainer1-3: species. */ + prevMons: number[]; + prevPrevMons: number[]; + /** sBTChoiceOfLevelGroup. */ + savedLevelGroup: number; + /** sBattleTowerReward. */ + reward: number; + /** sBattleTowerSaveFileFlags: bit 1 once the explanation was offered. */ + fileFlags: number; + /** s5_aa8d, which BATTLETOWERACTION_11 and _12 set. */ + aa8d: number; + /** wBTChoiceOfLvlGroup (1-10). */ + levelGroup: number; + /** wNrOfBeatenBattleTowerTrainers. */ + beatenNow: number; + /** wBT_TrainerTextIndex: the greeting's index, reused for the end texts. */ + textIndex: number; + opponent?: BattleTowerOpponent; + quickSave?: BattleTowerQuickSave; +} + +export const emptyBattleTowerState = (): BattleTowerState => ({ + challenge: CHALLENGE.none, + beaten: 0, + trainers: Array.from({ length: BATTLE_TOWER.streakLength }, () => 0xff), + prevMons: [0, 0, 0], + prevPrevMons: [0, 0, 0], + savedLevelGroup: 0, + reward: 0, + fileFlags: 0, + aa8d: 0, + levelGroup: 0, + beatenNow: 0, + textIndex: 0, +}); +export const copyBattleTowerState = (state: BattleTowerState): BattleTowerState => + structuredClone(state); + +/** HealPartyMon for each party member but Eggs. */ +export function healParty(c: SpecialContext) { + for (const mon of c.state.party) { + if (mon.egg) continue; + mon.status = 0; + mon.hp = mon.stats[0]; + mon.pp = mon.moves.map((move, slot) => + move ? maxPP(c.data, move, mon.pp[slot] >> 6) : 0, + ) as PartyMon["pp"]; + } +} + +/** A BattleTowerMons entry: a party_struct (big-endian words) and the + * nickname, which LoadRandomBattleTowerMon replaces with the species + * name. */ +export function battleTowerMon(c: SpecialContext, address: number): PartyMon { + const r = c.reader, + byte = (offset: number) => r.byte(address + offset), + word = (offset: number) => (byte(offset) << 8) | byte(offset + 1); + const species = byte(0); + return { + species, + item: byte(1), + moves: [byte(2), byte(3), byte(4), byte(5)], + otId: word(6), + ot: "", + nickname: c.data.speciesName(species), + exp: (byte(8) << 16) | word(9), + statExp: [word(11), word(13), word(15), word(17), word(19)], + dvs: word(21), + pp: [byte(23), byte(24), byte(25), byte(26)], + happiness: byte(27), + pokerus: byte(28), + caughtData: word(29), + level: byte(31), + status: byte(32), + hp: word(34), + stats: [word(36), word(38), word(40), word(42), word(44), word(46)], + }; +} + +/** Random's hRandomAdd folded into a running byte, as both of + * LoadOpponentTrainerAndPokemon's samplers do: `count` values below a + * power-of-two mask, drawn again while too large or `reject`ed. After a + * rejection the trainer sampler keeps the index as its running byte; the + * Pokémon sampler starts again from hRandomAdd (`restart`). */ +function sample( + c: SpecialContext, + count: number, + reject: (index: number) => boolean, + restart = false, +) { + const mask = (1 << Math.ceil(Math.log2(count))) - 1; + let b = c.random(); + for (let tries = 0; tries < 100_000; tries++) { + b = (b + c.random()) & 0xff; + const index = b & mask; + if (index >= count) continue; + if (reject(index)) { + b = restart ? c.random() : index; + continue; + } + return index; + } + throw new Error("Battle Tower sampling did not end."); +} + +/** LoadOpponentTrainerAndPokemon (engine/events/battle_tower/load_trainer.asm): + * a trainer this streak has not met, recorded in sBTTrainers, then three + * Pokémon of the level group, none sharing a species or item with the + * others and none of the last two trainers' species. */ +export function loadOpponent(c: SpecialContext, bt: BattleTowerState): BattleTowerOpponent { + const r = c.reader; + const trainer = sample(c, BATTLE_TOWER.uniqueTrainers, (index) => bt.trainers.includes(index)); + bt.trainers[bt.beaten] = trainer; + const entry = JOHTO.BattleTowerTrainers + trainer * TRAINER_ENTRY; + const name = readString(r, entry, c.text(), TRAINER_ENTRY - 1); + const trainerClass = r.byte(entry + TRAINER_ENTRY - 1); + // LoadRandomBattleTowerMon: wBT_OTMon1-3 start as species 0 and item $ff. + const species = [0, 0, 0], + items = [0xff, 0xff, 0xff]; + const party: PartyMon[] = []; + const group = + JOHTO.BattleTowerMons + + ((bt.levelGroup - 1) & 0xff) * BATTLE_TOWER.uniqueMons * NICKNAMED_MON; + for (let slot = 0; slot < BATTLE_TOWER.partyLength; slot++) { + const index = sample(c, BATTLE_TOWER.uniqueMons, (i) => { + const at = group + i * NICKNAMED_MON, + s = r.byte(at), + item = r.byte(at + 1); + return ( + species.includes(s) || + items.includes(item) || + bt.prevMons.includes(s) || + bt.prevPrevMons.includes(s) + ); + }, true); + const mon = battleTowerMon(c, group + index * NICKNAMED_MON); + species[slot] = mon.species; + items[slot] = mon.item; + party.push(mon); + } + bt.prevPrevMons = [...bt.prevMons]; + bt.prevMons = [...species]; + return { trainer, name, trainerClass, party }; +} + +/** BTTrainerClassSprites: the overworld sprite of a trainer class. */ +export const battleTowerSprite = (c: SpecialContext, trainerClass: number) => + c.reader.byte(JOHTO.BTTrainerClassSprites + trainerClass - 1); + +/** BattleTowerText: which = 1 picks a greeting at random (hRandomAdd, 0-24 + * for a male class and 0-14 for a female one) and keeps the index; 2 (the + * player lost) and 3 (the player won) reuse it. Returns the text. */ +export function battleTowerText(c: SpecialContext, bt: BattleTowerState, which: 1 | 2 | 3) { + const r = c.reader; + const trainerClass = bt.opponent?.trainerClass ?? 0; + const female = r.byte(JOHTO.BTTrainerClassGenders + ((trainerClass - 1) & 0xff)) !== 0; + const table = female ? JOHTO.BTFemaleTrainerTexts : JOHTO.BTMaleTrainerTexts; + if (which === 1) { + const roll = c.random(); + bt.textIndex = female + ? (roll & 0x0f) < 15 ? roll & 0x0f : (roll & 0x0f) - 15 + : (roll & 0x1f) < 25 ? roll & 0x1f : (roll & 0x1f) - 25; + } + const bank = r.bank(table); + const list = r.near(table + (which - 1) * 2, bank); + return r.near(list + bt.textIndex * 2, bank); +} + +/** _CheckForBattleTowerRules: three Pokémon, all of different species and + * holding different items (Eggs and empty item slots aside), and no Egg. + * Every broken rule prints its text after a first "Excuse me"; returns 1 + * when the party may not enter. */ +export function* checkBattleTowerRules(c: SpecialContext): Gen { + const party = c.state.party; + c.strings[RAM.stringBuffer2] = "3"; + /** CheckPartyValueIsUnique: each non-Egg value against the non-Egg + * values after it; a zero value is never compared. */ + const unique = (value: (mon: PartyMon) => number) => + !party.some( + (mon, i) => + !mon.egg && + value(mon) !== 0 && + party.slice(i + 1).some((other) => !other.egg && value(other) === value(mon)), + ); + const rules: [boolean, number][] = [ + [party.length !== BATTLE_TOWER.partyLength, JOHTO.OnlyThreeMonMayBeEnteredText], + [!unique((mon) => mon.species), JOHTO.TheMonMustAllBeDifferentKindsText], + [!unique((mon) => mon.item), JOHTO.TheMonMustNotHoldTheSameItemsText], + [party.some((mon) => mon.egg), JOHTO.YouCantTakeAnEggText], + ]; + const failures = rules.filter(([broken]) => broken).map(([, text]) => text); + if (!failures.length) return 0; + c.write(JOHTO.ExcuseMeYoureNotReadyText); + for (const text of failures) c.write(text); + c.write(JOHTO["BattleTower_PleaseReturnWhenReady.BattleTowerReturnWhenReadyText"]); + return 1; +} + +/** Menu_ChallengeExplanationCancel (English): the three-item menu; the + * item's number, or 4 for B. */ +export function* challengeMenu(c: SpecialContext): Gen { + const r = c.reader, + data = JOHTO.MenuData_ChallengeExplanationCancel; + const items: string[] = []; + let p = data + 2; + for (let i = 0; i < r.byte(data + 1); i++) { + items.push(readString(r, p, c.text())); + while (r.byte(p++) !== 0x50); + } + const choice = yield { kind: "menu", pages: c.take(), items, selected: 0 }; + return typeof choice === "number" && choice >= 0 && choice < items.length + ? choice + 1 + : 4; +} + +/** BattleTowerRoomMenu without a mobile adapter (_BattleTowerRoomMenu's + * states 0-12): the level picker, L:10 to L:40, or to L:100 after the + * Hall of Fame. A party Pokémon above the level, or Mewtwo, Mew, Lugia, + * Ho-Oh or Celebi below level 70 in a room under Lv.70, stops the choice + * with a message and the menu starts again. CANCEL or B asks whether to + * cancel. Returns 0 with wBTChoiceOfLvlGroup set, or $a when cancelled. */ +export function* battleTowerRoomMenu(c: SpecialContext, bt: BattleTowerState): Gen { + const r = c.reader; + // InitBattleTowerChallengeRAM. + bt.beatenNow = 0; + const levels = c.state.hallOfFame ? 10 : 4; + const strings = c.state.hallOfFame ? JOHTO.Strings_L10ToL100 : JOHTO.Strings_Ll0ToL40; + const items = Array.from({ length: levels + 1 }, (_, i) => + readString(r, strings + i * 8, c.text()).trim(), + ); + for (let round = 0; round < 1000; round++) { + c.write(JOHTO.Text_WhatLevelDoYouWantToChallenge); + const choice = yield { kind: "menu", pages: c.take(), items, selected: 0 }; + const group = typeof choice === "number" && choice >= 0 && choice < levels ? choice + 1 : 0; + if (group) { + // BattleTower_LevelCheck: every party slot, Eggs included. + if (c.state.party.some((mon) => mon.level > group * 10)) { + c.write(JOHTO.Text_PartyMonTopsThisLevel); + // BattleTowerRoomMenu_DelayRestartMenu: $80 frames. + yield { kind: "text", pages: c.take(), holdTicks: 64 }; + continue; + } + // BattleTower_UbersCheck reads wPartySpecies, where an Egg is EGG. + const uber = + group < 7 && + c.state.party.find( + (mon) => + !mon.egg && + (mon.species === MEWTWO || + mon.species === MEW || + (mon.species >= LUGIA && mon.species <= NUM_POKEMON)) && + mon.level < 70, + ); + if (uber) { + c.strings[WRAM.wcd49] = c.data.speciesName(uber.species); + c.write(JOHTO.Text_UberRestriction); + yield { kind: "text", pages: c.take(), holdTicks: 64 }; + continue; + } + bt.levelGroup = group; + return 0; + } + // BattleTowerRoomMenu_QuitMessage, then its YES/NO box. + c.write(JOHTO.Text_CancelBattleRoomChallenge); + if (yield { kind: "yesno", pages: c.take() }) return 0x0a; + } + throw new Error("The Battle Tower menu did not end."); +} + +/** TryQuickSave (Link_SaveGame): the overwrite question for the player's + * own save, then the saving texts. The native game saves itself; the + * quick save keeps what LoadPokemonData and Reset return to. */ +export function* tryQuickSave( + c: SpecialContext, + bt: BattleTowerState, + position: { map: string; x: number; y: number; direction: Direction }, +): Gen { + c.write(JOHTO.AlreadyASaveFileText); + if (!(yield { kind: "yesno", pages: c.take() })) return 0; + c.write(JOHTO.SavingDontTurnOffThePowerText); + c.write(JOHTO.SavedTheGameText); + yield* c.flush(); + bt.quickSave = { + ...position, + party: structuredClone(c.state.party), + seen: [...c.state.pokedex.seen], + caught: [...c.state.pokedex.caught], + unownDex: [...c.state.unownDex], + firstUnownSeen: c.state.firstUnownSeen, + }; + return 1; +} + +/** LoadPokemonData after each Battle Tower battle: the party and the + * Pokédex return to the quick save's. */ +export function loadPokemonData(c: SpecialContext, bt: BattleTowerState) { + const save = bt.quickSave; + if (!save) return; + c.state.party = structuredClone(save.party); + c.state.pokedex.seen = new Set(save.seen); + c.state.pokedex.caught = new Set(save.caught); + c.state.unownDex = [...save.unownDex]; + c.state.firstUnownSeen = save.firstUnownSeen; +} + +/** BattleTower_RandomlyChooseReward: HP Up to Calcium, not Lucky Punch. */ +function chooseReward(c: SpecialContext) { + for (let tries = 0; tries < 100_000; tries++) { + const span = CALCIUM - HP_UP + 1; + let item = c.random() & 7; + if (item >= span) item -= span; + item += HP_UP; + if (item !== LUCKY_PUNCH) return item; + } + throw new Error("The reward draw did not end."); +} + +/** BattleTowerAction for the actions the Battle Tower's maps use offline. + * Returns wScriptVar, or undefined for an action that is not ported. */ +export function battleTowerAction( + c: SpecialContext, + bt: BattleTowerState, + action: number, +): number | undefined { + const A = BATTLE_TOWER_ACTION; + switch (action) { + case A.checkExplanationRead: + // BattleTower_CheckSaveFileExistsAndIsYours: the native save is. + return bt.fileFlags & 2; + case A.setExplanationRead: + bt.fileFlags |= 2; + return action; + case A.getChallengeState: + return bt.challenge; + case A.saveAndQuit: + bt.challenge = CHALLENGE.savedAndLeft; + return action; + case A.challengeCanceled: + bt.challenge = CHALLENGE.none; + return action; + case A.action06: + // Clears s5_be46/s5_aa8b/s5_aa8c, which only the mobile record sets. + return action; + case A.saveLevelGroup: + bt.savedLevelGroup = bt.levelGroup; + return action; + case A.loadLevelGroup: + bt.levelGroup = bt.savedLevelGroup; + return action; + case A.checkSaveFileIsYours: + return 1; + case A.action0A: + // wMusicFade and MaxVolume: audio. + return action; + case A.action11: + case A.action12: + bt.aa8d = action === A.action12 ? 1 : 0; + return action; + case A.action13: + return bt.aa8d; + case A.resetData: + bt.trainers = bt.trainers.map(() => 0xff); + bt.beaten = 0; + return action; + case A.giveReward: { + // BattleTower_GiveReward: POTION stands for a pack with no room for + // five more of the reward. + const items = c.state.bag.items; + if (items.length < MAX_ITEMS) return bt.reward; + const room = items.some( + (slot) => + slot.item === bt.reward && + slot.quantity < MAX_ITEM_STACK - BATTLE_TOWER.rewardQuantity + 1, + ); + return room ? bt.reward : POTION; + } + case A.action1C: + bt.challenge = CHALLENGE.won; + return action; + case A.action1D: + bt.challenge = CHALLENGE.receivedReward; + return action; + case A.chooseReward: + bt.reward = chooseReward(c); + return action; + case A.saveOptions: + // SaveOptions: the options are saved with the native game. + return action; + } + return undefined; +} + +/** Whether a saved Battle Tower state is well formed. */ +export function validBattleTower(data: PokemonData, value: unknown): value is BattleTowerState { + const bt = value as BattleTowerState; + const byte = (n: unknown) => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 0xff; + const bytes = (list: unknown, length: number) => + Array.isArray(list) && list.length === length && list.every(byte); + const species = (list: unknown) => + Array.isArray(list) && + list.length <= 251 && + list.every((n) => Number.isInteger(n) && n >= 1 && n <= 251); + const opponent = bt?.opponent, + save = bt?.quickSave; + return ( + Boolean(bt) && + typeof bt === "object" && + [ + bt.challenge, + bt.beaten, + bt.savedLevelGroup, + bt.reward, + bt.fileFlags, + bt.aa8d, + bt.levelGroup, + bt.beatenNow, + bt.textIndex, + ].every(byte) && + bytes(bt.trainers, BATTLE_TOWER.streakLength) && + bytes(bt.prevMons, BATTLE_TOWER.partyLength) && + bytes(bt.prevPrevMons, BATTLE_TOWER.partyLength) && + (opponent === undefined || + (Boolean(opponent) && + Number.isInteger(opponent.trainer) && + opponent.trainer >= 0 && + opponent.trainer < BATTLE_TOWER.uniqueTrainers && + typeof opponent.name === "string" && + opponent.name.length <= 10 && + byte(opponent.trainerClass) && + Array.isArray(opponent.party) && + opponent.party.length === BATTLE_TOWER.partyLength && + opponent.party.every((mon) => validPartyMon(data, mon)))) && + (save === undefined || + (Boolean(save) && + typeof save.map === "string" && + /^\d{1,2}:\d{1,3}$/.test(save.map) && + byte(save.x) && + byte(save.y) && + DIRECTION_ORDER.includes(save.direction) && + Array.isArray(save.party) && + save.party.length >= 1 && + save.party.length <= 6 && + save.party.every((mon) => validPartyMon(data, mon)) && + species(save.seen) && + species(save.caught) && + Array.isArray(save.unownDex) && + save.unownDex.length <= 26 && + save.unownDex.every((n) => Number.isInteger(n) && n >= 1 && n <= 26) && + Number.isInteger(save.firstUnownSeen) && + save.firstUnownSeen >= 0 && + save.firstUnownSeen <= 26)) + ); +} diff --git a/src/game/scripts/interpreter.ts b/src/game/scripts/interpreter.ts index 6448579..440d9b5 100644 --- a/src/game/scripts/interpreter.ts +++ b/src/game/scripts/interpreter.ts @@ -74,6 +74,23 @@ import { slotMachine } from "./slot-machine"; import { cardFlip } from "./card-flip"; import { giveDratini, moveDeletion } from "./blackthorn"; import { beastsCheck, monCheck } from "./tin-tower"; +import { + battleTowerAction, + battleTowerRoomMenu, + battleTowerSprite, + battleTowerText, + CHALLENGE, + challengeMenu, + checkBattleTowerRules, + copyBattleTowerState, + emptyBattleTowerState, + healParty, + loadOpponent, + loadPokemonData, + tryQuickSave, + type BattleTowerOpponent, + type BattleTowerState, +} from "./battle-tower"; import { checkMagikarpLength, findPartyMonThatSpecies, @@ -198,6 +215,8 @@ export interface ScriptState { luckyNumber: { id: number; day: number }; /** wBestMagikarpLengthFeet/Inches and wMagikarpRecordHoldersName. */ magikarpRecord: MagikarpRecord; + /** The Battle Tower's SRAM and the work RAM its scripts share. */ + battleTower: BattleTowerState; /** wRoamMon1/2/3 (Raikou, Entei, Suicune): InitRoamMons starts the first * two roaming; CheckEncounterRoamMon, UpdateRoamMons and JumpRoamMons * move or encounter them (see ../world/roam-mons). */ @@ -267,6 +286,7 @@ export const emptyScriptState = (): ScriptState => ({ timers: {}, luckyNumber: { id: 0, day: 0 }, magikarpRecord: initialMagikarpRecord(), + battleTower: emptyBattleTowerState(), roamMons: emptyRoamMons(), roamMonsMap: null, options: defaultOptions(), @@ -316,6 +336,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({ ), luckyNumber: { ...state.luckyNumber }, magikarpRecord: { ...state.magikarpRecord }, + battleTower: copyBattleTowerState(state.battleTower), roamMons: state.roamMons.map((mon) => ({ ...mon })), roamMonsMap: state.roamMonsMap, options: { ...state.options }, @@ -584,7 +605,13 @@ export type ScriptEffect = * SUICUNE and CONTEST (scripts load these) or ROAMING * (CheckEncounterRoamMon sets it before the script runs). */ battleType?: number; + /** BattleTowerBattle: the opponent of wBT_OTTemp and the texts + * BattleTowerText shows when the player loses or wins. */ + tower?: BattleTowerOpponent & { winText: number; lossText: number }; } + /** LoadOpponentTrainerAndPokemonWithOTSprite: the map object's sprite + * becomes `sprite` until the map loads again. */ + | { kind: "objectsprite"; object: number; sprite: number } /** loademote: the emote graphics (EMOTE_*) that show_emote displays * next, when they go to the emote bubble's tiles. */ | { kind: "loademote"; emote: number } @@ -747,6 +774,8 @@ const savedByte = (address: number) => (Object.keys(SAVED_WRAM) as SavedByte[]).find( (label) => SAVED_WRAM[label] === address, ); +/** VAR_FACING's values (DOWN, UP, LEFT, RIGHT) as directions. */ +const FACINGS: Direction[] = ["down", "up", "left", "right"]; /** ITEM_FROM_MEM: take the item from wScriptVar. */ const ITEM_FROM_MEM = 0xff; /** wCallerContact's script-2 pointer (bank, address) within the contact. */ @@ -850,6 +879,8 @@ export class ScriptRun { /** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */ private fruitTree = 0; private fruit = 0; + /** Set by the Reset special: where the quick save left the player. */ + private resetTo?: BattleTowerState["quickSave"]; /** wTempTrainer, copied from a map trainer's header by LoadTrainer_continue. */ private tempTrainer?: TrainerHeader; /** wRunningTrainerBattleScript: zeroed by LoadTrainer_continue, set to -1 @@ -1118,6 +1149,14 @@ export class ScriptRun { if (result === undefined) throw new UnsupportedScript(offset, opcode, ` (special ${index})`); value = result; + if (this.resetTo) { + // Reset: the script stops and the quick save's place returns. + const save = this.resetTo; + this.resetTo = undefined; + yield* flush(); + yield synchronous({ kind: "warp", map: save.map, x: save.x, y: save.y, facing: save.direction }); + return; + } break; } case 0x11: @@ -1176,6 +1215,8 @@ export class ScriptRun { value = Number(this.state.fruitTrees.has(this.fruitTree)); else if (routine === NATIVE_ROUTINES.pickedFruitTree) this.state.fruitTrees.add(this.fruitTree); + else if (routine === JOHTO["BattleTowerHallwayChooseBattleRoomScript.asm_load_battle_room"]) + value = this.state.battleTower.levelGroup; else if (routine === NATIVE_ROUTINES.getWhiteoutSpawn) spawn = findSpawn(this.state.lastSpawnMap); else if (routine === NATIVE_ROUTINES.setMemEvent) { @@ -1354,6 +1395,8 @@ export class ScriptRun { value = tempWild?.species ?? 0; else if (address === RAM.parkBallsRemaining && this.state.bugContest) value = this.state.bugContest.parkBalls; + else if (address === WRAM.wNrOfBeatenBattleTowerTrainers) + value = this.state.battleTower.beatenNow; else if (savedByte(address)) value = this.state.memory[savedByte(address)!] ?? 0; else throw new UnsupportedScript(offset, opcode); @@ -1760,6 +1803,16 @@ export class ScriptRun { // itself is unchanged. this.strings[buffer(byte())] = String(value); break; + case 0xa4: { + // battletowertext: BattleTowerText for the loaded opponent. + const which = byte(); + if (which < 1 || which > 3) + throw new UnsupportedScript(offset, opcode, " (text)"); + write( + battleTowerText(this.specialContext(pages, take), this.state.battleTower, which as 1 | 2 | 3), + ); + break; + } case 0x42: case 0xa5: { // getcurlandmarkname and getlandmarkname (whose landmark operand @@ -3483,10 +3536,83 @@ export class ScriptRun { return yield* checkForLuckyNumberWinners(this.specialContext(pages, take)); case JOHTO.BattleTowerAction: // BATTLETOWERACTION_GSBALL reads sGSBallFlag, which only the - // mobile/Virtual Console event sets. The Battle Tower's other - // actions are not ported yet. + // mobile/Virtual Console event sets. if (value === BATTLETOWERACTION_GSBALL) return 0; - return undefined; + return battleTowerAction(this.specialContext(pages, take), this.state.battleTower, value); + case JOHTO.BattleTowerRoomMenu: + return yield* battleTowerRoomMenu(this.specialContext(pages, take), this.state.battleTower); + case JOHTO.CheckForBattleTowerRules: + return yield* checkBattleTowerRules(this.specialContext(pages, take)); + case JOHTO.Menu_ChallengeExplanationCancel: + // The English menu (wScriptVar TRUE); FALSE is the Japanese one + // with the mobile News items. + if (!value) return undefined; + return yield* challengeMenu(this.specialContext(pages, take)); + case JOHTO.TryQuickSave: { + const { x, y } = this.host.position(); + return yield* tryQuickSave(this.specialContext(pages, take), this.state.battleTower, { + map: this.host.mapId, + x, + y, + direction: FACINGS[this.host.facing() & 3], + }); + } + case JOHTO.LoadOpponentTrainerAndPokemonWithOTSprite: { + const c = this.specialContext(pages, take), + bt = this.state.battleTower; + bt.opponent = loadOpponent(c, bt); + yield { + kind: "objectsprite", + object: value, + sprite: battleTowerSprite(c, bt.opponent.trainerClass), + }; + return value; + } + case JOHTO.BattleTowerBattle: { + // RunBattleTowerTrainer: the party is healed, the opponent copied + // to wBT_OTTemp (marking the challenge in progress and counting + // the battle), and the battle runs in the SET style. LoadPokemonData + // then reads the party and Pokédex back from the quick save. + const c = this.specialContext(pages, take), + bt = this.state.battleTower, + opponent = bt.opponent; + if (!opponent) throw new Error("No Battle Tower opponent is loaded."); + healParty(c); + bt.challenge = CHALLENGE.inProgress; + bt.beaten = (bt.beaten + 1) & 0xff; + if (pages.length) yield { kind: "text", pages: take() }; + const result = yield { + kind: "battle", + tower: { + ...opponent, + winText: battleTowerText(c, bt, 2), + lossText: battleTowerText(c, bt, 3), + }, + }; + if (typeof result !== "number") throw new Error("Invalid battle result."); + loadPokemonData(c, bt); + healParty(c); + if (!(result & 0x3f)) { + bt.beatenNow = bt.beaten; + this.strings[RAM.stringBuffer3] = String(bt.beatenNow + 1); + } + return result; + } + case JOHTO.BattleTowerFade: + case JOHTO.LoadMapPalettes: + // Palette fades and reloads: presentation. + return value; + case JOHTO.Reset: { + // After BATTLETOWERACTION_SAVE_AND_QUIT the game resets without + // saving: Continue loads the quick save made before the Battle + // Room, whose scene checks the challenge state. + const save = this.state.battleTower.quickSave; + if (!save) return undefined; + const owner = this.host.sceneOwner(save.map); + if (owner) this.state.scenes[owner] = 0; + this.resetTo = save; + return value; + } case JOHTO.GameboyCheck: // The port is a Game Boy Color. return GBCHECK_CGB; diff --git a/src/game/world/world.ts b/src/game/world/world.ts index b47f943..97f21d3 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -59,6 +59,12 @@ import { type DayTimerName, } from "../scripts/interpreter"; import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage"; +import { + copyBattleTowerState, + emptyBattleTowerState, + validBattleTower, + type BattleTowerState, +} from "../scripts/battle-tower"; import { IntroRun } from "../scripts/intro"; import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map"; import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen"; @@ -211,6 +217,8 @@ export interface NativeSave { luckyNumber: { id: number; day: number }; /** The Magikarp length record and its holder. */ magikarpRecord: MagikarpRecord; + /** The Battle Tower's SRAM and its scripts' work RAM. */ + battleTower: BattleTowerState; /** wRoamMon1/2/3 (Raikou, Entei, Suicune). */ roamMons: RoamMon[]; /** wRoamMons_CurMapGroup/Number, kept from the last UpdateRoamMons or @@ -282,6 +290,9 @@ interface Actor { /** An appear or disappear this visit: its visibility, not its flag, * is what a save keeps. */ toggled?: boolean; + /** A map object sprite a script set (LoadOpponentTrainerAndPokemonWith- + * OTSprite) until the map loads again. */ + sprite?: number; /** OBJECT_ACTION_FISHING, from fish_cast_rod or fish_got_bite until * PutTheRodAway: the fishing frames replace the standing ones. */ fishing?: boolean; @@ -932,6 +943,8 @@ export class GameWorld { } /** GetSprite's sprite id: SPRITE_VARS ids read wVariableSprites. */ private spriteOf(object: MapObject, state = this.liveState): number { + const override = this.actors?.get(object.id)?.sprite; + if (override !== undefined) return override; if (object.sprite < OW.SPRITE_VARS) return object.sprite; return state.variables[object.sprite - OW.SPRITE_VARS] ?? 0; } @@ -2628,13 +2641,37 @@ export class GameWorld { }, }; return true; + case "objectsprite": { + const object = this.map.objects.find((candidate) => candidate.id === effect.object); + if (!object) throw new Error("Script names an unknown object."); + this.actor(object).sprite = effect.sprite; + return false; + } case "battle": { const draft = script.run.state; const trainers = new TrainerData(this.assets.reader); const opponent = effect.trainer && trainers.trainer(effect.trainer.classId, effect.trainer.id); - const trainer = opponent && { + // A Battle Tower trainer: its own name and Pokémon, and the first + // class's (Falkner's) AI attributes, which the AI layers and + // AI_SwitchOrTryItem read in the Battle Tower; no items or prize. + const tower = effect.tower; + const falkner = tower && trainers.attributes(1); + const towerTrainer = tower && + falkner && { + name: tower.name, + className: trainers.className(tower.trainerClass), + party: structuredClone(tower.party), + aiFlags: falkner.aiMoveFlags, + switchFlags: falkner.aiItemSwitchFlags, + items: [0, 0] as [number, number], + baseReward: 0, + reward: 0, + winText: tower.lossText, + lossText: tower.winText, + }; + const trainer = towerTrainer ?? (opponent && { name: opponent.name, className: opponent.className, party: trainers.createParty(opponent, { @@ -2648,7 +2685,7 @@ export class GameWorld { reward: opponent.reward, winText: effect.trainer?.winText, lossText: effect.trainer?.lossText, - }; + }); const engine = new Battle({ data: this.assets.pokemon, party: draft.party, @@ -2658,6 +2695,10 @@ export class GameWorld { johtoBadges: Array.from({ length: 8 }, (_, bit) => draft.flags.has(OW.ENGINE_ZEPHYRBADGE + bit) ? 1 << bit : 0, ).reduce((bits, badge) => bits | badge, 0), + // wKantoBadges: ENGINE_BOULDERBADGE follows the Johto badges. + kantoBadges: Array.from({ length: 8 }, (_, bit) => + draft.flags.has(OW.ENGINE_ZEPHYRBADGE + 8 + bit) ? 1 << bit : 0, + ).reduce((bits, badge) => bits | badge, 0), timeOfDay: this.clockTimeOfDay, random: () => this.random() & 0xff, wild: effect.wild, @@ -2694,7 +2735,9 @@ export class GameWorld { saving: Number(draft.flags.has(OW.ENGINE_MOM_SAVING_MONEY)), money: draft.momsMoney, }, - shift: shiftBattleStyle(draft.options), + // RunBattleTowerTrainer sets BATTLE_SHIFT: the SET style. + shift: tower ? false : shiftBattleStyle(draft.options), + battleTower: Boolean(tower), battleScene: battleSceneOn(draft.options), unown: draft, // wUnlockedUnowns: the ENGINE_UNLOCKED_UNOWNS_* flags' bits. @@ -4576,6 +4619,7 @@ export class GameWorld { timers: structuredClone(state.timers), luckyNumber: { ...state.luckyNumber }, magikarpRecord: { ...state.magikarpRecord }, + battleTower: copyBattleTowerState(state.battleTower), roamMons: structuredClone(state.roamMons), roamMonsMap: state.roamMonsMap, options: { ...state.options }, @@ -4737,6 +4781,7 @@ export class GameWorld { !integer(save.magikarpRecord.inches, 0, 0xff) || typeof save.magikarpRecord.holder !== "string" || save.magikarpRecord.holder.length > 10 || + !validBattleTower(this.assets.pokemon, save.battleTower) || !Array.isArray(save.roamMons) || save.roamMons.length !== 3 || !save.roamMons.every(validRoamMon) || @@ -4839,6 +4884,7 @@ export class GameWorld { timers: structuredClone(save.timers), luckyNumber: { ...save.luckyNumber }, magikarpRecord: { ...save.magikarpRecord }, + battleTower: copyBattleTowerState(save.battleTower), roamMons: structuredClone(save.roamMons), roamMonsMap: save.roamMonsMap, options: { ...save.options }, @@ -5001,6 +5047,9 @@ export function migrateSave( ...(!Object.hasOwn(save, "magikarpRecord") && { magikarpRecord: initialMagikarpRecord(), }), + ...(!Object.hasOwn(save, "battleTower") && { + battleTower: emptyBattleTowerState(), + }), ...(!Object.hasOwn(save, "roamMons") && { roamMons: emptyRoamMons(), roamMonsMap: null, @@ -5065,6 +5114,7 @@ export function migrateSave( timers: {}, luckyNumber: { id: 0, day: 0 }, magikarpRecord: initialMagikarpRecord(), + battleTower: emptyBattleTowerState(), roamMons: emptyRoamMons(), roamMonsMap: null, intro: false, diff --git a/src/main.ts b/src/main.ts index 4161d5b..8ab07d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -971,6 +971,9 @@ function showBattle(world: GameWorld) { }), ) : button("PACK", () => { + // BattleMenu_Pack.ItemsCantBeUsed: the engine says why. + if (prompt.noItems) + return runtime.battleAction({ action: "item", item: 0, slot: -1 }); showPack = true; showWorld(world); }), diff --git a/tests/battle-tower-engine.test.ts b/tests/battle-tower-engine.test.ts new file mode 100644 index 0000000..48ef7be --- /dev/null +++ b/tests/battle-tower-engine.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { CrystalAssets } from "../src/game/rom/assets"; +import { TrainerData } from "../src/game/rom/trainers"; +import { Battle } from "../src/game/battle/battle"; +import { C } from "../src/game/battle/constants"; +import { battleOptions, battleSteps, partyMon, playBattle } from "./battle-driver"; + +const romPath = + process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; + +describe.skipIf(!existsSync(romPath))("Battle Tower battles (wInBattleTowerBattle)", () => { + const assets = existsSync(romPath) + ? new CrystalAssets(new Uint8Array(readFileSync(romPath))) + : undefined; + const trainers = assets && new TrainerData(assets.reader); + const TYPHLOSION = 157, + RATTATA = 19; + /** A Battle Tower trainer as the world builds one: Falkner's AI, no + * items or prize, and the texts BattleTowerText chose. */ + const tower = (party = [partyMon(assets!, RATTATA, 5)]) => ({ + name: "HANSON", + className: "FISHER", + party, + aiFlags: trainers!.attributes(1).aiMoveFlags, + switchFlags: trainers!.attributes(1).aiItemSwitchFlags, + items: [0, 0] as [number, number], + baseReward: 0, + reward: 0, + }); + + it("gives no experience or prize, and refuses items", () => { + const party = [partyMon(assets!, TYPHLOSION, 50)]; + const exp = party[0].exp; + const options = battleOptions(assets!, trainers!, party, { wild: { species: RATTATA, level: 5 } }); + const battle = new Battle({ + ...options, + wild: undefined, + trainer: tower(), + battleTower: true, + bag: { items: [{ item: C.POTION, quantity: 1 }], balls: [], keyItems: [], tmsHms: [] } as never, + }); + const steps = battleSteps(battle); + expect(steps.prompt?.kind).toBe("action"); + steps.answer({ action: "item", item: C.POTION, slot: 0 }); + expect(steps.texts.join(" ")).toContain("Items can't be used here."); + expect(steps.prompt?.kind).toBe("action"); + for (let turn = 0; turn < 20 && !steps.done; turn++) + steps.answer(steps.prompt?.kind === "action" ? { action: "fight", move: 0 } : 1); + expect(steps.done).toBe(true); + expect(party[0].exp).toBe(exp); + expect(steps.texts.join(" ")).not.toContain("EXP. Points"); + expect(steps.texts.join(" ")).not.toContain("for winning"); + }); + + it("lets a traded Pokémon over the badge level obey", () => { + const traded = { ...partyMon(assets!, TYPHLOSION, 80), otId: 4321 }; + const options = battleOptions(assets!, trainers!, [traded], { wild: { species: RATTATA, level: 5 } }); + const run = (battleTower: boolean) => + playBattle(new Battle({ ...options, wild: undefined, trainer: tower(), battleTower, johtoBadges: 0 })); + // Without badges an 80th-level traded Pokémon disobeys sometimes; in + // the Battle Tower it never does. + const disobeys = /ignored orders|loafing|won't obey|turned away|began to nap/; + expect(run(false).texts.join(" ")).toMatch(disobeys); + expect(run(true).texts.join(" ")).not.toMatch(disobeys); + }); + + it("ends a loss with the trainer's text and no whiteout prompt", () => { + const party = [partyMon(assets!, RATTATA, 2)]; + const options = battleOptions(assets!, trainers!, party, { wild: { species: RATTATA, level: 5 } }); + const winText = assets!.reader.near( + assets!.reader.near(0x11f332 + 2, assets!.reader.bank(0x11f332)), + assets!.reader.bank(0x11f332), + ); + const result = playBattle( + new Battle({ + ...options, + wild: undefined, + trainer: { ...tower([partyMon(assets!, TYPHLOSION, 60)]), lossText: winText }, + battleTower: true, + }), + ); + expect(result.result & 0x3f).toBe(C.LOSE); + expect(result.texts.join(" ")).not.toContain("whited out"); + }); +}); diff --git a/tests/battle-tower.test.ts b/tests/battle-tower.test.ts new file mode 100644 index 0000000..409f535 --- /dev/null +++ b/tests/battle-tower.test.ts @@ -0,0 +1,256 @@ +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 { calcStats, generatePartyMon, maxPP, type PartyMon } from "../src/game/party/pokemon"; +import { CHALLENGE } from "../src/game/scripts/battle-tower"; +import { Playthrough } from "./playthrough-driver"; + +const romPath = + process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; + +describe.skipIf(!existsSync(romPath))("the Battle Tower with the local ROM", () => { + const assets = existsSync(romPath) + ? new CrystalAssets(new Uint8Array(readFileSync(romPath))) + : undefined; + + const BATTLE_TOWER_1F = "22:11", + BATTLE_ROOM = "22:12", + ROUTE_40 = "22:1", + BATTLE_TOWER_OUTSIDE = "22:16"; + const MEWTWO = 150, + LUGIA = 249, + HO_OH = 250, + RATTATA = 19, + PIDGEY = 16, + SENTRET = 161, + BERRY = 0xad, + HP_UP = 0x1a, + CALCIUM = 0x1f, + LUCKY_PUNCH = 0x1e; + /** The Challenge/Explanation/Cancel menu's items. */ + const CHALLENGE_ITEM = 0, + CANCEL_ITEM = 2; + + const mon = (world: GameWorld, species: number, level: number, item = 0): PartyMon => + generatePartyMon(assets!.pokemon, { + species, + level, + item, + otId: world.playerId, + ot: world.name, + dvs: 0xffff, + caughtData: 0, + }); + /** A playthrough driver in front of the receptionist (7, 7). */ + const driver = (party: (world: GameWorld) => PartyMon[], hallOfFame = true) => { + const p = new Playthrough(assets!); + p.world = new GameWorld(assets!, p.now, p.random); + p.world.state.party = party(p.world); + p.world.state.hallOfFame = hallOfFame; + p.world.warpTo(BATTLE_TOWER_1F, 7, 7); + p.play(); + p.world.restore({ ...p.world.save(), x: 7, y: 7, direction: "up" }); + return p; + }; + /** A trained Pokémon: full stat experience and chosen moves. */ + const trained = (world: GameWorld, species: number, moves: number[], item = 0): PartyMon => { + const base = mon(world, species, 100, item); + const statExp: PartyMon["statExp"] = [65535, 65535, 65535, 65535, 65535]; + const stats = calcStats(assets!.pokemon, species, base.dvs, 100, statExp); + return { + ...base, + statExp, + stats, + hp: stats[0], + moves: moves as PartyMon["moves"], + pp: moves.map((move) => maxPP(assets!.pokemon, move, 0)) as PartyMon["pp"], + }; + }; + const PSYCHIC = 94, + ICE_BEAM = 58, + THUNDERBOLT = 85, + FLAMETHROWER = 53, + AEROBLAST = 177, + SACRED_FIRE = 221, + EARTHQUAKE = 89, + SURF = 57; + const strong = (world: GameWorld) => [ + trained(world, MEWTWO, [PSYCHIC, ICE_BEAM, THUNDERBOLT, FLAMETHROWER], BERRY), + trained(world, LUGIA, [AEROBLAST, SURF, ICE_BEAM, EARTHQUAKE]), + trained(world, HO_OH, [SACRED_FIRE, EARTHQUAKE, THUNDERBOLT, PSYCHIC]), + ]; + const bt = (p: Playthrough) => p.world.state.battleTower; + /** Talk to the receptionist and answer her questions. */ + const receptionist = (p: Playthrough, answers: (number | string)[]) => { + p.world.interact(); + p.play(answers); + expect(p.world.notice, p.world.diagnostic).toBe(""); + }; + + it("is reached from Route 40 through its gate", () => { + const p = new Playthrough(assets!); + p.world = new GameWorld(assets!, p.now, p.random); + p.world.state.party = [mon(p.world, MEWTWO, 50)]; + p.world.warpTo(ROUTE_40, 9, 5); + p.play(); + p.travel(BATTLE_TOWER_1F, ["22:15", BATTLE_TOWER_OUTSIDE]); + expect(p.world.mapId).toBe(BATTLE_TOWER_1F); + }); + + it("turns away a party that breaks the rules, saying why", () => { + // Four Pokémon, two Pidgey holding the same Berry and an Egg. + const p = driver((world) => [ + mon(world, PIDGEY, 10, BERRY), + mon(world, PIDGEY, 10, BERRY), + { ...mon(world, RATTATA, 5), egg: true }, + mon(world, SENTRET, 10), + ]); + // The first visit offers the explanation (NO), then the menu. + receptionist(p, [0, CHALLENGE_ITEM]); + const shown = p.log.join(" "); + expect(shown).toContain("Excuse me.\nYou're not ready."); + expect(shown).toContain("Only three POKéMON\nmay be entered."); + expect(shown).toContain("The 3 POKéMON\nmust all be\ndifferent kinds."); + expect(shown).toContain("must not hold the\nsame items."); + expect(shown).toContain("You can't take an\nEGG!"); + expect(shown).toContain("Please return when\nyou're ready."); + expect(bt(p).challenge).toBe(CHALLENGE.none); + }); + + it("stops a choice that a party Pokémon's level tops, and an uber below Lv.70", () => { + const p = driver((world) => [ + mon(world, MEWTWO, 60), + mon(world, PIDGEY, 30), + mon(world, SENTRET, 30), + ]); + // Challenge, YES to the save, YES to overwrite, then L:30 (Mewtwo is + // level 60), L:60 (an uber under 70), and CANCEL with YES. + receptionist(p, [0, CHALLENGE_ITEM, 1, 1, 2, 5, 10, 1, CANCEL_ITEM]); + const shown = p.log.join(" "); + expect(shown).toContain("A party POKéMON\ntops this level."); + expect(shown).toContain("MEWTWO may go\nonly to BATTLE"); + expect(shown).toContain("Cancel your BATTLE\nROOM challenge?"); + expect(p.world.mapId).toBe(BATTLE_TOWER_1F); + }); + + it("offers L:10 to L:40 before the Hall of Fame", () => { + const p = driver((world) => [mon(world, PIDGEY, 10), mon(world, SENTRET, 10), mon(world, RATTATA, 10)], false); + p.world.interact(); + // NO to the explanation, Challenge, YES to save, YES to overwrite. + for (const answer of [0, CHALLENGE_ITEM, 1, 1]) { + p.toChoice(); + p.world.choose(answer); + } + const choice = p.toChoice(); + expect(choice).toMatchObject({ kind: "menu", items: ["L:10", "L:20", "L:30", "L:40", "CANCEL"] }); + }); + + it("battles seven trainers, then gives five of a random stat item", () => { + const p = driver(strong); + // NO to the explanation, Challenge, YES (save), YES (overwrite), L:100. + receptionist(p, [0, CHALLENGE_ITEM, 1, 1, 9]); + expect(p.world.state.battleTower.levelGroup).toBe(10); + expect(p.world.state.battleTower.quickSave).toMatchObject({ map: BATTLE_TOWER_1F, x: 7, y: 7 }); + // Each win heals the party and asks to go on (YES). + for (let round = 0; round < 7 && p.world.mapId === BATTLE_ROOM; round++) p.play([1]); + expect(p.world.notice, p.world.diagnostic).toBe(""); + const shown = p.log.join(" "); + expect(shown).toContain("Congratulations!"); + expect(p.world.mapId).toBe(BATTLE_TOWER_1F); + const state = bt(p); + expect(state.beaten).toBe(7); + expect(new Set(state.trainers).size).toBe(7); + expect(state.trainers.every((trainer) => trainer < 70)).toBe(true); + expect(state.challenge).toBe(CHALLENGE.receivedReward); + expect(state.reward).toBeGreaterThanOrEqual(HP_UP); + expect(state.reward).toBeLessThanOrEqual(CALCIUM); + expect(state.reward).not.toBe(LUCKY_PUNCH); + expect(p.world.state.bag.items).toContainEqual({ item: state.reward, quantity: 5 }); + // No experience, and LoadPokemonData gave the Berry back after each + // battle; the party is healed. + expect(p.world.state.party.map((m) => m.level)).toEqual([100, 100, 100]); + expect(p.world.state.party[0].item).toBe(BERRY); + expect(p.world.state.party.every((m) => m.hp === m.stats[0])).toBe(true); + // The streak's opponents come from the L:100 group of BattleTowerMons. + expect(state.opponent!.party.every((m) => m.level === 100)).toBe(true); + // A second talk only says goodbye after the reward. + p.play(); + }); + + it("saves and quits between battles, then resumes the streak", () => { + const p = driver(strong); + // After the first win: NO to the next opponent, YES to save and end. + // Reset continues from the quick save made at the counter, whose scene + // checks the challenge state and resumes it; the streak then goes on + // (YES each time) to its seventh trainer. + receptionist(p, [0, CHALLENGE_ITEM, 1, 1, 9, 0, 1]); + const shown = p.log.join(" "); + const saved = shown.indexOf("SAVE and end the\nsession?"), + waiting = shown.indexOf("We've been waiting"); + expect(saved).toBeGreaterThan(0); + expect(waiting).toBeGreaterThan(saved); + // One battle before the reset, six after. + expect(shown.slice(0, saved).match(/wants to battle!/g)).toHaveLength(1); + expect(shown.slice(waiting).match(/wants to battle!/g)).toHaveLength(6); + expect(shown).toContain("Congratulations!"); + expect(bt(p).beaten).toBe(7); + expect(new Set(bt(p).trainers).size).toBe(7); + expect(p.world.mapId).toBe(BATTLE_TOWER_1F); + }); + + it("keeps a saved-and-left challenge for the next visit", () => { + const p = driver(strong); + p.world.state.battleTower = { + ...p.world.state.battleTower, + challenge: CHALLENGE.savedAndLeft, + beaten: 3, + savedLevelGroup: 10, + reward: CALCIUM, + fileFlags: 2, + quickSave: { + map: BATTLE_TOWER_1F, + x: 7, + y: 7, + direction: "up", + party: structuredClone(p.world.state.party), + seen: [], + caught: [], + unownDex: [], + firstUnownSeen: 0, + }, + }; + // A save made in that state and loaded again: the check scene runs. + p.world.state.scenes[BATTLE_TOWER_1F] = 0; + p.reload(); + p.play([1, 1, 1, 1]); + const shown = p.log.join(" "); + expect(shown).toContain("We've been waiting"); + expect(shown.match(/wants to battle!/g)).toHaveLength(4); + expect(bt(p).levelGroup).toBe(10); + expect(p.world.state.bag.items).toContainEqual({ item: CALCIUM, quantity: 5 }); + }); + + it("cancels the challenge, or ends it with a loss without a whiteout", () => { + const p = driver(strong); + // After the first win: NO, NO (no save), YES (cancel): back to 1F + // with the challenge gone. + receptionist(p, [0, CHALLENGE_ITEM, 1, 1, 9, 0, 0, 1]); + expect(p.world.mapId).toBe(BATTLE_TOWER_1F); + expect(bt(p).challenge).toBe(CHALLENGE.none); + expect(p.log.join(" ")).toContain("Cancel your BATTLE\nROOM challenge?"); + + const weak = driver((world) => [ + mon(world, PIDGEY, 10), + mon(world, SENTRET, 10), + mon(world, RATTATA, 10), + ]); + receptionist(weak, [0, CHALLENGE_ITEM, 1, 1, 9]); + expect(weak.world.notice, weak.world.diagnostic).toBe(""); + expect(weak.world.mapId).toBe(BATTLE_TOWER_1F); + expect(weak.log.join(" ")).toContain("Thanks for\nvisiting!"); + expect(bt(weak).challenge).toBe(CHALLENGE.none); + // LoadPokemonData and HealParty: the party is back as it was saved. + expect(weak.world.state.party.every((m) => m.hp === m.stats[0])).toBe(true); + }); +}); diff --git a/tests/playthrough-driver.ts b/tests/playthrough-driver.ts index 35a5396..7e7615f 100644 --- a/tests/playthrough-driver.ts +++ b/tests/playthrough-driver.ts @@ -137,12 +137,14 @@ export class Playthrough { world.battleAction({ action: "run" }); else if ( engine.battleMode !== C.WILD_BATTLE && + !prompt.noItems && (mon.status & C.SLP_MASK) !== 0 && awakening >= 0 ) world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening }); else if ( engine.battleMode !== C.WILD_BATTLE && + !prompt.noItems && mon.hp * 2 < mon.maxHp && potion >= 0 ) diff --git a/tests/reference-badges.spec.ts b/tests/reference-badges.spec.ts index eeea40f..0153696 100644 --- a/tests/reference-badges.spec.ts +++ b/tests/reference-badges.spec.ts @@ -67,3 +67,74 @@ test("badge stat boosts match the original for every Johto badge byte", async ({ expect(result.compared).toBe(256 * 28); expect(result, JSON.stringify(result)).toMatchObject({ count: 0 }); }); + +test("badge type boosts match the original for badges, types, damage, turn and the Battle Tower", async ({ page }) => { + const { at, routine } = loadSymbols(); + const addresses = { + romBank: at("hROMBank"), + johto: at("wJohtoBadges"), + kanto: at("wKantoBadges"), + linkMode: at("wLinkMode"), + tower: at("wInBattleTowerBattle"), + turn: at("hBattleTurn"), + type: at("wCurType"), + damage: at("wCurDamage"), + }; + const boosts = routine("DoBadgeTypeBoosts"); + await openReference(page); + const result = await page.evaluate(async ({ a, boosts }) => { + const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); + const { CrystalAssets } = await import("/src/game/rom/assets.ts"); + const { Battle } = await import("/src/game/battle/battle.ts"); + const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); + const assets = new CrystalAssets(rom); + const reference = new BinjgbEmulator(); + await reference.load(rom, document.createElement("canvas")); + const write = (p: number, v: number) => reference.writeMemory(p, v & 255); + const read = (p: number) => reference.readMemory(p); + const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, boosts.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, + 0xcd, boosts.address & 255, boosts.address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe]; + const failures: unknown[] = []; + let compared = 0; + const cases: [number, number, number, number, number, number][] = []; + for (let johto = 0; johto < 256; johto++) + for (const type of [0, 2, 7, 8, 9, 22, 23, 24, 25, 26, 27]) + cases.push([johto, 0, type, johto % 3 === 0 ? 1 : 100 + johto * 37, 0, 0]); + for (const kanto of [0, 1, 2, 4, 8, 16, 32, 64, 128, 255]) + for (const type of [0, 3, 5, 20, 21, 22, 23, 24]) + for (const damage of [0, 1, 7, 8, 9, 997, 65000, 65535]) + cases.push([0x80, kanto, type, damage, 0, 0]); + for (const [turn, tower] of [[1, 0], [0, 1], [1, 1]]) + for (const type of [2, 7, 8, 26]) + cases.push([0xff, 0xff, type, 640, turn, tower]); + for (const [johto, kanto, type, damage, turn, tower] of cases) { + const battle = new Battle({ + data: assets.pokemon, party: [], pokedex: { seen: new Set(), caught: new Set() }, playerId: 1, + playerName: "CHRIS", timeOfDay: 1, random: () => 0, text: () => ({}) as never, + johtoBadges: johto, kantoBadges: kanto, battleTower: Boolean(tower), + }); + (battle as unknown as { turn: number }).turn = turn; + const native = (battle as unknown as { badgeTypeBoost(d: number, t: number): number }).badgeTypeBoost(damage, type); + write(a.johto, johto); + write(a.kanto, kanto); + write(a.linkMode, 0); + write(a.tower, tower); + write(a.turn, turn); + write(a.type, type); + write(a.damage, damage >> 8); + write(a.damage + 1, damage); + write(0xc0f0, 0); + program.forEach((value, i) => write(0xc000 + i, value)); + reference.setProgramCounter(0xc000); + reference.advanceFrames(1); + if (read(0xc0f0) !== 0x42) throw Error("DoBadgeTypeBoosts did not return"); + const original = (read(a.damage) << 8) | read(a.damage + 1); + if (original !== native) failures.push({ johto, kanto, type, damage, turn, tower, original, native }); + compared++; + } + reference.dispose(); + return { compared, failures: failures.slice(0, 5), count: failures.length }; + }, { a: addresses, boosts }); + expect(result.compared).toBeGreaterThan(3000); + expect(result, JSON.stringify(result)).toMatchObject({ count: 0 }); +});