Port the remaining battle items, the stats screen and hBattleTurn at the menu
- Run every non-ball item effect usable in battle from the ROM's ItemEffects: Full Restore, revives, bitter medicine, Bitter Berry, Ethers and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll. - Read happiness changes from HappinessChanges, including the larger level-up gain where a Pokémon was caught. - Model hBattleTurn at the battle menu, including the trainer AI's writes, and drop the status-heal refusal. - Port the stats screen for battles and Bill's PC. - Compare the send-out and withdraw texts, RestoreHealth, RestorePP, ChangeHappiness and CalcPlayerStats with the original routines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cfd083c16d
commit
b13e09ec20
22 files changed
+1544
-168
No files matched your search
@@ -10,13 +10,13 @@ With a Pokémon, head west along Route 29 to Cherrygrove City. Wild Pokémon app
|
|||||||
|
|
||||||
North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you from a distance and walks over; Youngster Mikey battles when you talk to him. Each sends out two Pokémon, and beaten trainers remember you. The Berry house gives a Berry, and a Potion is hidden on the route.
|
North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you from a distance and walks over; Youngster Mikey battles when you talk to him. Each sends out two Pokémon, and beaten trainers remember you. The Berry house gives a Berry, and a Potion is hidden on the route.
|
||||||
|
|
||||||
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon, and when one faints you choose the next. PACK's Items pocket uses Potions, Berries and status healers such as Antidote.
|
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname. In battle, PKMN switches Pokémon or opens a Pokémon's stats, and when one faints you choose the next. PACK's Items pocket uses healing items, revives, Ethers, X items and Poké Dolls.
|
||||||
|
|
||||||
Use Start or Select for Key Items, including the Bicycle when owned. The poster opens the Town Map.
|
Use Start or Select for Key Items, including the Bicycle when owned. The poster opens the Town Map.
|
||||||
|
|
||||||
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
|
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
|
||||||
|
|
||||||
Route 31 and beyond, full party and bag menus, moving Pokémon between boxes, the stats screen, battle items such as X Attack or Poké Doll, and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
Route 31 and beyond, full party and bag menus, moving Pokémon between boxes, Kurt's special balls and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
+52
-18
@@ -156,7 +156,7 @@ triggers, declining and the repeat offer, and mid-battle reloads. Browser
|
|||||||
tests cover PACK, catching and naming. No emulator comparison of the
|
tests cover PACK, catching and naming. No emulator comparison of the
|
||||||
capture routines has been run.
|
capture routines has been run.
|
||||||
|
|
||||||
## Switching and healing items in battle
|
## Switching, battle items and the stats screen
|
||||||
|
|
||||||
`engine/battle/core.asm`: `BattleMenu_PKMN` and `TryPlayerSwitch` ("is
|
`engine/battle/core.asm`: `BattleMenu_PKMN` and `TryPlayerSwitch` ("is
|
||||||
already out", "can't be recalled" while wrapped or blocked by Mean Look,
|
already out", "can't be recalled" while wrapped or blocked by Mean Look,
|
||||||
@@ -181,26 +181,60 @@ switching clears both participant sets as the original does.
|
|||||||
|
|
||||||
`engine/items/item_effects.asm` and `pack.asm`: `BattlePack` shows the Items
|
`engine/items/item_effects.asm` and `pack.asm`: `BattlePack` shows the Items
|
||||||
and Balls pockets. `CheckItemContext` gives Oak's "This isn't the time"
|
and Balls pockets. `CheckItemContext` gives Oak's "This isn't the time"
|
||||||
text for items with no battle use. `ItemEffects` is read from the ROM:
|
text for items with no battle use. `ItemEffects` is read from the ROM and
|
||||||
`RestoreHPEffect` (`ItemRestoreHP`, `HealingHPAmounts`, `RestoreHealth`) and
|
dispatches every non-ball effect usable in battle:
|
||||||
`StatusHealingEffect` (`UseStatusHealer`, `StatusHealingActions`,
|
|
||||||
`HealStatus`, `IsItemUsedOnConfusedMon`) run with `UseItem_SelectMon`, the
|
- Party items (`.BattleField`): `RestoreHPEffect` (`ItemRestoreHP`,
|
||||||
party menu action texts and `UseDisposableItem`. "Won't have any effect"
|
`HealingHPAmounts`, `RestoreHealth`), `StatusHealingEffect`
|
||||||
and a cancelled menu keep the item and the turn. `wItemEffectSucceeded`
|
(`UseStatusHealer`, `StatusHealingActions`, `HealStatus`,
|
||||||
shares `wBattlePlayerAction`'s byte, so a used item takes the turn.
|
`IsItemUsedOnConfusedMon`), `FullRestoreEffect`, `ReviveEffect` (half or
|
||||||
|
full HP; a revived participant shares the experience again through
|
||||||
|
`wBattleParticipantsIncludingFainted`), the bitter Energypowder, Energy
|
||||||
|
Root, Heal Powder and Revival Herb (`ChangeHappiness` and "It looks
|
||||||
|
bitter…"), `BitterBerryEffect` and `RestorePPEffect` (Ether, Max Ether,
|
||||||
|
Elixer, Max Elixer, MysteryBerry; `GetMaxPPOfMove`, `BattleRestorePP`, a
|
||||||
|
`move` prompt for `MoveSelectionScreen` whose B reopens the party menu).
|
||||||
|
- Battle-only items (`.BattleOnly`): `XItemEffect` (`XItemStats`,
|
||||||
|
`RaiseStat` with `hBattleTurn` on the player, used even when the stat
|
||||||
|
cannot rise, and the X-item happiness gain), X Accuracy, Guard Spec and
|
||||||
|
Dire Hit (set once; a second use keeps the item), and `PokeDollEffect`
|
||||||
|
(ends a wild battle as a draw; Oak's warning in a trainer battle).
|
||||||
|
|
||||||
|
"Won't have any effect" and a cancelled menu keep the item and the turn.
|
||||||
|
`wItemEffectSucceeded` shares `wBattlePlayerAction`'s byte, so a used item
|
||||||
|
takes the turn. `UseItemText` waits for a button. `ChangeHappiness` reads
|
||||||
|
`HappinessChanges` and updates the battle Pokémon's copy only when
|
||||||
|
`wCurPartyMon` equals the 1-based `wPartyMenuCursor`, as in the original;
|
||||||
|
fainting and levelling up use it too, and `LevelUpHappinessMod` gives the
|
||||||
|
larger gain on the map where the Pokémon was caught.
|
||||||
|
|
||||||
`CalcPlayerStats` reapplies the paralysis and burn penalties to the side
|
`CalcPlayerStats` reapplies the paralysis and burn penalties to the side
|
||||||
named by `hBattleTurn` after a switch of turns, which can be the enemy.
|
named by `hBattleTurn` after a switch of turns, which can be the enemy.
|
||||||
Because `hBattleTurn` at the battle menu is not modelled everywhere, a
|
`hBattleTurn` at the battle menu is modelled: `DoBattle` leaves the
|
||||||
status heal that would reapply a penalty to a paralysed or burned enemy is
|
player's turn, `HandleHealingItems` ends a full turn on the enemy's, and
|
||||||
refused. Other item effects (X items, Poké Doll, Full Restore, revives,
|
the trainer AI reports when `AI_Types`, `AI_Status` or `AIDamageCalc`
|
||||||
Ether) are refused.
|
set it. A status heal after a full turn therefore penalises a paralysed
|
||||||
|
or burned enemy again, as the original does. Remaining gaps: the special
|
||||||
|
Kurt and Park balls.
|
||||||
|
|
||||||
|
`engine/pokemon/stats_screen.asm`: the stats screen's three pages (HP,
|
||||||
|
status, types, experience to the next level; item and moves with PP; ID,
|
||||||
|
OT and stats) with the ROM's strings, `TypeNames`, `GetGender` and the
|
||||||
|
front picture. It opens from BattleMonMenu (B returns to the party menu,
|
||||||
|
as `BattleMenuPKMN_ReturnFromStats` does) and from Bill's PC, where a
|
||||||
|
boxed Pokémon shows `CalcTempmonStats`' full HP and no status.
|
||||||
|
|
||||||
Tests: local-ROM battles cover switching, cancelling, the withdrawal texts,
|
Tests: local-ROM battles cover switching, cancelling, the withdrawal texts,
|
||||||
forced choices (including a fainted pick and a cancelled forced menu), run
|
forced choices (including a fainted pick and a cancelled forced menu), run
|
||||||
after NO, Shift mode's offer (accepted and declined), a trainer double
|
after NO, Shift mode's offer (accepted and declined), a trainer double
|
||||||
faint, Potion, Max Potion, a Berry on a benched Pokémon, no-effect cases,
|
faint, and every item effect above, including the reapplied enemy penalty
|
||||||
Antidote and PSNCUREBERRY, Parlyz Heal restoring Speed, Oak's warning and a
|
and the caught-location level-up bonus. Trainer AI tests pin its
|
||||||
refused X Attack. Browser tests switch through the party menu and its
|
`hBattleTurn` reporting. The reference suite runs the original
|
||||||
SWITCH/STATS/CANCEL submenu and use a Potion from the Items pocket. The
|
`SendOutMonText` (its final jump to `BattleTextbox` patched to `ret` in the
|
||||||
stats screen is not ported. No emulator comparison of these routines has
|
test's ROM copy), `WithdrawMonText`'s text routine, `RestoreHealth`,
|
||||||
been run.
|
`RestorePP`, `ChangeHappiness` and `CalcPlayerStats` with both values of
|
||||||
|
`hBattleTurn`: 300, 300, 200, 300 and 200 cases. Browser tests switch
|
||||||
|
through the party menu, use a Potion from the Items pocket and page
|
||||||
|
through the stats screen. `WithdrawMonText` still assumes the leftover
|
||||||
|
multiplicand high byte is zero; a whole battle has not been compared with
|
||||||
|
the original.
|
||||||
+11
-7
@@ -130,7 +130,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- Capture with a full party runs `SendMonIntoBox`: the Pokémon goes first in the current box, with restored PP and `SetBoxMonCaughtData`, can be nicknamed and shows `BallSentToPCText`. If the box is already full the ball is not used (`Ball_BoxIsFullMessage`). A catch that fills the box sets `BATTLERESULT_BOX_FULL`; `reloadmapafterbattle` then queues Bill's call (`Script_SpecialBillCall`) as a memory script that runs at the next `PlayerEvents` and is not saved, as in the original.
|
- Capture with a full party runs `SendMonIntoBox`: the Pokémon goes first in the current box, with restored PP and `SetBoxMonCaughtData`, can be nicknamed and shows `BallSentToPCText`. If the box is already full the ball is not used (`Ball_BoxIsFullMessage`). A catch that fills the box sets `BATTLERESULT_BOX_FULL`; `reloadmapafterbattle` then queues Bill's call (`Script_SpecialBillCall`) as a memory script that runs at the next `PlayerEvents` and is not saved, as in the original.
|
||||||
- `VAR_BOXSPACE` reads the current box.
|
- `VAR_BOXSPACE` reads the current box.
|
||||||
- Save format 3 gained `storage` (boxes, the current box and box names; a null name is the ROM default) and `pcItems`, migrated by absence to empty storage.
|
- Save format 3 gained `storage` (boxes, the current box and box names; a null name is the ROM default) and `pcItems`, migrated by absence to empty storage.
|
||||||
- Not ported: MOVE PKMN W/O MAIL and the stats screen (both show a notice and return to the menu), printing, a `givepoke` with a full party (refused; no open map reaches it) and the tutorial with a full party and box (the original repeats the unusable ball). Changing boxes does not show the save messages; the native session saves continuously.
|
- Not ported: MOVE PKMN W/O MAIL (a notice returns to the menu; the stats screen is now ported), printing, a `givepoke` with a full party (refused; no open map reaches it) and the tutorial with a full party and box (the original repeats the unusable ball). Changing boxes does not show the save messages; the native session saves continuously.
|
||||||
- Next: the introduction's naming and clock screens, player switching and other items in battle, then party and bag menus.
|
- Next: the introduction's naming and clock screens, player switching and other items in battle, then party and bag menus.
|
||||||
|
|
||||||
## Implemented: the introduction's naming and clock screens
|
## Implemented: the introduction's naming and clock screens
|
||||||
@@ -144,7 +144,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- Save format 3 gained `intro` and `clock.minuteOffset`, migrated by absence to a finished intro on the device clock.
|
- Save format 3 gained `intro` and `clock.minuteOffset`, migrated by absence to a finished intro on the device clock.
|
||||||
- Intentional change: the steppers start at the device's current time rather than 10:00, so confirming keeps the device clock.
|
- Intentional change: the steppers start at the device's current time rather than 10:00, so confirming keeps the device clock.
|
||||||
- Not presented: the palette fades, Wooper's cry, the picture slide during name selection, `ShrinkPlayer` and the Route 30 music (no audio yet).
|
- Not presented: the palette fades, Wooper's cry, the picture slide during name selection, `ShrinkPlayer` and the Route 30 music (no audio yet).
|
||||||
- Next: MOVE PKMN W/O MAIL and the stats screen with the party menu.
|
- Next: MOVE PKMN W/O MAIL with the party menu.
|
||||||
|
|
||||||
## Implemented: evolution data, reference vectors and the opening playthrough
|
## Implemented: evolution data, reference vectors and the opening playthrough
|
||||||
|
|
||||||
@@ -152,15 +152,19 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- The reference suite also runs the original `CalcMonStats` (300 cases, with and without stat experience, including levels above 100 that reach the 999 cap) and `CalcExpAtLevel` (every growth rate at levels 1–100).
|
- The reference suite also runs the original `CalcMonStats` (300 cases, with and without stat experience, including levels above 100 that reach the 999 cap) and `CalcExpAtLevel` (every growth rate at levels 1–100).
|
||||||
- `tests/playthrough.test.ts` plays a new game from the introduction to the catching tutorial with a seeded RNG: Mom, Elm, the starter and Potion, Route 29, Cherrygrove, Route 30, Mr. Pokémon, Elm's call, the rival battle, the officer, the Egg, the aide's Poké Balls and the Dude's demonstration. Wild battles are fought, or fled below half HP. Each stage ends with a JSON save loaded into a fresh world. It uses no fixtures or hand-set flags, but drives `GameWorld` directly rather than the browser UI.
|
- `tests/playthrough.test.ts` plays a new game from the introduction to the catching tutorial with a seeded RNG: Mom, Elm, the starter and Potion, Route 29, Cherrygrove, Route 30, Mr. Pokémon, Elm's call, the rival battle, the officer, the Egg, the aide's Poké Balls and the Dude's demonstration. Wild battles are fought, or fled below half HP. Each stage ends with a JSON save loaded into a fresh world. It uses no fixtures or hand-set flags, but drives `GameWorld` directly rather than the browser UI.
|
||||||
|
|
||||||
## Implemented: switching and healing items in battle
|
## Implemented: switching, battle items and the stats screen
|
||||||
|
|
||||||
- PKMN opens the party menu; SWITCH sends out the chosen Pokémon (STATS is not ported). The texts, the trapped and fainted checks, and the enemy's turn after the switch follow the original. Both Pokémon share the experience.
|
- PKMN opens the party menu; SWITCH sends out the chosen Pokémon and STATS opens the stats screen. The texts, the trapped and fainted checks, and the enemy's turn after the switch follow the original. Both Pokémon share the experience.
|
||||||
- After a faint, wild battles ask "Use next POKéMON?" (NO tries to run) and the party menu cannot be cancelled. Trainers' next Pokémon come with Shift mode's offer to switch; a double faint sends out both sides.
|
- After a faint, wild battles ask "Use next POKéMON?" (NO tries to run) and the party menu cannot be cancelled. Trainers' next Pokémon come with Shift mode's offer to switch; a double faint sends out both sides.
|
||||||
- PACK's Items pocket uses HP items (Potion, Berry and the rest of `HealingHPAmounts`) and status healers (Antidote, Parlyz Heal, Awakening, PSNCUREBERRY and the rest of `StatusHealingActions`) on any party Pokémon. Items with no battle use get Oak's warning. X items, Poké Doll, Full Restore and revives are refused.
|
- PACK's Items pocket uses HP items (Potion, Berry and the rest of `HealingHPAmounts`) and status healers (Antidote, Parlyz Heal, Awakening, PSNCUREBERRY and the rest of `StatusHealingActions`) on any party Pokémon. Items with no battle use get Oak's warning.
|
||||||
|
- Every other battle item effect runs too: Full Restore, revives, the bitter herbs, Bitter Berry, Ethers and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll. Happiness changes come from the ROM's `HappinessChanges`.
|
||||||
|
- `hBattleTurn` at the battle menu is modelled, so `CalcPlayerStats` after a status heal penalises the side the original does.
|
||||||
|
- The stats screen shows its three pages from BattleMonMenu and Bill's PC.
|
||||||
|
- The reference suite compares the send-out and withdraw texts, `RestoreHealth`, `RestorePP`, `ChangeHappiness` and `CalcPlayerStats` with the original routines.
|
||||||
|
|
||||||
## Native battles: current state
|
## 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. Other battle items (X items, Poké Doll, Full Restore, revives) remain unfinished.
|
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.
|
||||||
|
|
||||||
This is still not a complete new game. Full party/bag/Pokégear menus, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The Key Items menu supports the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
|
This is still not a complete new game. Full party/bag/Pokégear menus, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The Key Items menu supports the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
|
||||||
|
|
||||||
@@ -228,7 +232,7 @@ their scripts and story progression are supported.
|
|||||||
- [x] The Cherrygrove rival battle, Elm's call and the return to Elm
|
- [x] The Cherrygrove rival battle, Elm's call and the return to Elm
|
||||||
- [x] Capture and the catching tutorial
|
- [x] Capture and the catching tutorial
|
||||||
- [x] Battles: stats, damage, types, accuracy, criticals, status, turn order, fainting, experience, move learning, trainer AI, trainer send-outs and prize money
|
- [x] Battles: stats, damage, types, accuracy, criticals, status, turn order, fainting, experience, move learning, trainer AI, trainer send-outs and prize money
|
||||||
- [x] Player switching, and HP and status healing items, in battle (X items, Poké Doll, Full Restore and revives are refused)
|
- [x] Player switching and every non-ball battle item, with the stats screen
|
||||||
- [x] The PC and boxes (catching with a full party)
|
- [x] The PC and boxes (catching with a full party)
|
||||||
- [x] The introduction's naming and clock screens
|
- [x] The introduction's naming and clock screens
|
||||||
- [ ] Controlled-seed comparisons with the original for random-driven routines (accuracy, criticals, damage variation, capture). Damage, types, stats and experience curves are compared.
|
- [ ] Controlled-seed comparisons with the original for random-driven routines (accuracy, criticals, damage variation, capture). Damage, types, stats and experience curves are compared.
|
||||||
|
|||||||
+42
-2
@@ -1,7 +1,7 @@
|
|||||||
# Recovered Claude session
|
# Recovered Claude session
|
||||||
|
|
||||||
Latest continuation: see **Remaining opening-milestone items: 2026-09-30** at the end of
|
Latest continuation: see **Battle items, hBattleTurn and the stats screen: 2026-09-30**
|
||||||
this file. Earlier sections are historical snapshots.
|
at the end of this file. Earlier sections are historical snapshots.
|
||||||
|
|
||||||
Recovered on 2026-09-23 from the local Claude Code transcript
|
Recovered on 2026-09-23 from the local Claude Code transcript
|
||||||
`~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`.
|
`~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`.
|
||||||
@@ -566,3 +566,43 @@ Next (PLAN section 2): controlled-seed comparisons of random-driven
|
|||||||
battle routines. Then PLAN section 3/4 items: the party and bag menus
|
battle routines. Then PLAN section 3/4 items: the party and bag menus
|
||||||
outside battle (with the stats screen and MOVE PKMN W/O MAIL), evolution
|
outside battle (with the stats screen and MOVE PKMN W/O MAIL), evolution
|
||||||
itself, and the remaining battle items.
|
itself, and the remaining battle items.
|
||||||
|
|
||||||
|
## Battle items, hBattleTurn and the stats screen: 2026-09-30
|
||||||
|
|
||||||
|
After `f8e0f9c`/`cfd083c`, the user asked for the items the previous
|
||||||
|
summary listed as refused or not ported:
|
||||||
|
|
||||||
|
- Every non-ball item effect usable in battle, found by reading each
|
||||||
|
item's battle context and `ItemEffects` entry from the ROM: Full
|
||||||
|
Restore, Revive/Max Revive, Energypowder, Energy Root, Heal Powder,
|
||||||
|
Revival Herb, Bitter Berry, Ether/Max Ether/Elixer/Max Elixer/
|
||||||
|
MysteryBerry (new `move` prompt), X items, X Accuracy, Guard Spec, Dire
|
||||||
|
Hit and Poké Doll. `ChangeHappiness` now reads `HappinessChanges`
|
||||||
|
(including the `wPartyMenuCursor` quirk), which also fixed level-ups:
|
||||||
|
`LevelUpHappinessMod`'s larger gain where the Pokémon was caught was
|
||||||
|
missing (new `landmark` battle option). A second participant set
|
||||||
|
(`wBattleParticipantsIncludingFainted`) serves revives.
|
||||||
|
- `hBattleTurn` at the battle menu: `DoBattle` ends on the player's turn,
|
||||||
|
full turns end on the enemy's, and `chooseTrainerMove` now returns
|
||||||
|
`enemyTurn` when `AI_Types`, `AI_Status` or `AIDamageCalc` ran. The
|
||||||
|
status-heal refusal is gone; the enemy's penalty is reapplied as in the
|
||||||
|
original.
|
||||||
|
- Stats screen: three pages from ROM strings (`STATS` profile table,
|
||||||
|
verified by `check:reference`), `TypeNames` and `GetGender`. It opens from
|
||||||
|
BattleMonMenu and from Bill's PC (a `stats` script effect;
|
||||||
|
`world.statsScreen`).
|
||||||
|
- Emulator comparisons: a new reference test runs `SendOutMonText` (with
|
||||||
|
its final `jp BattleTextbox` patched to `ret` in the in-page ROM copy),
|
||||||
|
`WithdrawMonText`'s text routine, `RestoreHealth` (with `wHPBuffer1` set
|
||||||
|
as `ItemRestoreHP` leaves it), `RestorePP`, `ChangeHappiness` and
|
||||||
|
`CalcPlayerStats` with both turns. A mutation of `calcPlayerStats` was
|
||||||
|
confirmed to fail. The pure helpers are exported from `battle.ts`.
|
||||||
|
|
||||||
|
Remaining: Kurt's and the Park Ball; the leftover multiplicand byte in
|
||||||
|
`WithdrawMonText` is assumed zero; no whole battle has been compared with
|
||||||
|
the original.
|
||||||
|
|
||||||
|
Checks: 337 unit tests with the local ROM (131 passed, 206 skipped
|
||||||
|
without it); 97 Chromium/WebKit browser tests passed, 19 skipped
|
||||||
|
intentionally; native and reference builds; reference, release and
|
||||||
|
vendor checks; `git diff --check`. Not committed.
|
||||||
@@ -202,8 +202,8 @@ for (const [key, symbol] of Object.entries(ramNames)) {
|
|||||||
if (!match || Number(match[1]) !== ram.get(symbol))
|
if (!match || Number(match[1]) !== ram.get(symbol))
|
||||||
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
|
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
|
||||||
}
|
}
|
||||||
// BATTLE, OVERWORLD, PC and INTRO entries are keyed by their PRET labels.
|
// BATTLE, OVERWORLD, PC, INTRO and STATS entries are keyed by their PRET labels.
|
||||||
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO"]) {
|
for (const table of ["BATTLE", "OVERWORLD", "PC", "INTRO", "STATS"]) {
|
||||||
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
||||||
if (!block) throw new Error(`${table} symbol table is missing.`);
|
if (!block) throw new Error(`${table} symbol table is missing.`);
|
||||||
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
|
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
|
||||||
|
|||||||
+24
-8
@@ -156,9 +156,17 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
move: number;
|
move: number;
|
||||||
index: number;
|
index: number;
|
||||||
scores: number[];
|
scores: number[];
|
||||||
|
/** Whether AI_Types, AI_Status or AIDamageCalc set hBattleTurn to the
|
||||||
|
* enemy, which the battle menu then still holds. */
|
||||||
|
enemyTurn: boolean;
|
||||||
} {
|
} {
|
||||||
validateTrainerAI(input.weights);
|
validateTrainerAI(input.weights);
|
||||||
const { player, enemy, data, battleData, weights, random } = input;
|
const { player, enemy, data, battleData, weights, random } = input;
|
||||||
|
let enemyTurn = false;
|
||||||
|
const damage = (move: number) => {
|
||||||
|
enemyTurn = true;
|
||||||
|
return input.damage(move);
|
||||||
|
};
|
||||||
const moves = enemy.mon.moves.slice(0, 4);
|
const moves = enemy.mon.moves.slice(0, 4);
|
||||||
const end = moves.indexOf(0);
|
const end = moves.indexOf(0);
|
||||||
if (end >= 0) moves.length = end;
|
if (end >= 0) moves.length = end;
|
||||||
@@ -213,7 +221,9 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
if (random() >= 128) add(i, -2);
|
if (random() >= 128) add(i, -2);
|
||||||
} else if (random() >= 30) add(i, 2);
|
} else if (random() >= 30) add(i, 2);
|
||||||
});
|
});
|
||||||
if (weights & 4)
|
if (weights & 4) {
|
||||||
|
// AI_Types sets hBattleTurn before each move's matchup.
|
||||||
|
enemyTurn = true;
|
||||||
attributes.forEach((move, i) => {
|
attributes.forEach((move, i) => {
|
||||||
const value = matchup(i);
|
const value = matchup(i);
|
||||||
if (!value) add(i, 10);
|
if (!value) add(i, 10);
|
||||||
@@ -224,6 +234,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
)
|
)
|
||||||
add(i, 1);
|
add(i, 1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
if (weights & 8)
|
if (weights & 8)
|
||||||
attributes.forEach((move, i) => {
|
attributes.forEach((move, i) => {
|
||||||
if (!move.power) add(i, 2);
|
if (!move.power) add(i, 2);
|
||||||
@@ -250,9 +261,9 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
highest = 0;
|
highest = 0;
|
||||||
attributes.forEach((move, i) => {
|
attributes.forEach((move, i) => {
|
||||||
if (!move.power) return;
|
if (!move.power) return;
|
||||||
const damage = input.damage(moves[i]);
|
const value = damage(moves[i]);
|
||||||
if (damage >= highest) {
|
if (value >= highest) {
|
||||||
highest = damage;
|
highest = value;
|
||||||
strongest = i;
|
strongest = i;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -287,8 +298,13 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
move.effect === C.EFFECT_PARALYZE ||
|
move.effect === C.EFFECT_PARALYZE ||
|
||||||
move.power
|
move.power
|
||||||
) {
|
) {
|
||||||
if ((poison && player.mon.types.includes(C.POISON)) || !matchup(i))
|
// Every path but a poison move against a Poison type sets
|
||||||
add(i, 10);
|
// hBattleTurn before BattleCheckTypeMatchup.
|
||||||
|
if (poison && player.mon.types.includes(C.POISON)) add(i, 10);
|
||||||
|
else {
|
||||||
|
enemyTurn = true;
|
||||||
|
if (!matchup(i)) add(i, 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (weights & 512) {
|
if (weights & 512) {
|
||||||
@@ -302,7 +318,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
}
|
}
|
||||||
// The original subtracts damage from HP and requires a borrow, so exact
|
// The original subtracts damage from HP and requires a borrow, so exact
|
||||||
// damage equality is not treated as a knockout here.
|
// damage equality is not treated as a knockout here.
|
||||||
if (input.damage(moves[i]) > player.mon.hp) add(i, -5);
|
if (damage(moves[i]) > player.mon.hp) add(i, -5);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// A zero score would wrap to 255 on the first decrement in the ROM.
|
// A zero score would wrap to 255 on the first decrement in the ROM.
|
||||||
@@ -311,7 +327,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
for (let attempt = 0; attempt < 4096; attempt++) {
|
for (let attempt = 0; attempt < 4096; attempt++) {
|
||||||
const index = random() & 3;
|
const index = random() & 3;
|
||||||
if (index < moves.length && effective[index] === minimum)
|
if (index < moves.length && effective[index] === minimum)
|
||||||
return { move: moves[index], index, scores };
|
return { move: moves[index], index, scores, enemyTurn };
|
||||||
}
|
}
|
||||||
throw new UnsupportedBattle(
|
throw new UnsupportedBattle(
|
||||||
"Trainer move selection random source did not produce a candidate.",
|
"Trainer move selection random source did not produce a candidate.",
|
||||||
|
|||||||
+372
-104
@@ -14,6 +14,7 @@ import {
|
|||||||
PARTY_LENGTH,
|
PARTY_LENGTH,
|
||||||
calcStats,
|
calcStats,
|
||||||
levelForExp,
|
levelForExp,
|
||||||
|
maxPP,
|
||||||
type PartyMon,
|
type PartyMon,
|
||||||
} from "../party/pokemon";
|
} from "../party/pokemon";
|
||||||
import { readString, readTextEnd, type TextContext } from "../scripts/text";
|
import { readString, readTextEnd, type TextContext } from "../scripts/text";
|
||||||
@@ -112,7 +113,11 @@ export type BattleEffect =
|
|||||||
/** The battle party menu: answered with a party index, or -1 to go back
|
/** The battle party menu: answered with a party index, or -1 to go back
|
||||||
* when `cancel` allows it. With `menu`, the choice goes through the
|
* when `cancel` allows it. With `menu`, the choice goes through the
|
||||||
* SWITCH/STATS/CANCEL submenu (BattleMonMenu) first. */
|
* SWITCH/STATS/CANCEL submenu (BattleMonMenu) first. */
|
||||||
| { kind: "party"; text: string; menu: boolean; cancel: boolean };
|
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
|
||||||
|
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
|
||||||
|
| { kind: "move"; text: string; moves: number[]; pp: number[] };
|
||||||
|
/** An item from a pack slot (wCurItem and wCurItemQuantity). */
|
||||||
|
type ItemUse = { item: number; slot: number };
|
||||||
export type BattleAction =
|
export type BattleAction =
|
||||||
| { action: "fight"; move: number }
|
| { action: "fight"; move: number }
|
||||||
| { action: "run" }
|
| { action: "run" }
|
||||||
@@ -162,6 +167,8 @@ export interface BattleOptions {
|
|||||||
caughtData?: (level: number) => number;
|
caughtData?: (level: number) => number;
|
||||||
/** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */
|
/** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */
|
||||||
tutorial?: boolean;
|
tutorial?: boolean;
|
||||||
|
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
|
||||||
|
landmark?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
|
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
|
||||||
@@ -324,6 +331,63 @@ export function catchRate(input: {
|
|||||||
return chance;
|
return chance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HP × 25 / (max HP / 4) as a byte, as SendOutMonText and WithdrawMonText
|
||||||
|
* compute it. Undefined when max HP / 4 is zero: the original divides by
|
||||||
|
* zero and freezes. */
|
||||||
|
function cheerPercent(hp: number, maxHp: number): number | undefined {
|
||||||
|
const divisor = (maxHp >> 2) & 0xff;
|
||||||
|
return divisor ? Math.floor((hp * 25) / divisor) & 0xff : undefined;
|
||||||
|
}
|
||||||
|
/** SendOutMonText's cheer from the enemy's remaining HP ("Go!" when it
|
||||||
|
* has fainted). */
|
||||||
|
export function sendOutText(hp: number, maxHp: number) {
|
||||||
|
if (!hp) return "GoMonText" as const;
|
||||||
|
const percent = cheerPercent(hp, maxHp);
|
||||||
|
if (percent === undefined) return undefined;
|
||||||
|
return percent >= 70
|
||||||
|
? ("GoMonText" as const)
|
||||||
|
: percent >= 40
|
||||||
|
? ("DoItMonText" as const)
|
||||||
|
: percent >= 10
|
||||||
|
? ("GoForItMonText" as const)
|
||||||
|
: ("YourFoesWeakGetmMonText" as const);
|
||||||
|
}
|
||||||
|
/** WithdrawMonText's choice from the HP lost since the send-out, in 16
|
||||||
|
* bits. The multiplicand's high byte is left over from earlier arithmetic
|
||||||
|
* in the original; it is taken as zero here. */
|
||||||
|
export function withdrawText(hpAtSwitch: number, hp: number, maxHp: number) {
|
||||||
|
const percent = cheerPercent((hpAtSwitch - hp) & 0xffff, maxHp);
|
||||||
|
if (percent === undefined) return undefined;
|
||||||
|
return !percent
|
||||||
|
? ("ThatsEnoughComeBackText" as const)
|
||||||
|
: percent < 30
|
||||||
|
? ("ComeBackText" as const)
|
||||||
|
: percent < 70
|
||||||
|
? ("OKComeBackText" as const)
|
||||||
|
: ("GoodComeBackText" as const);
|
||||||
|
}
|
||||||
|
/** RestoreHealth: a 16-bit sum that overflows or passes the maximum is
|
||||||
|
* the maximum. */
|
||||||
|
export function restoreHealth(hp: number, maxHp: number, amount: number) {
|
||||||
|
const sum = hp + amount;
|
||||||
|
return sum > 0xffff || sum > maxHp ? maxHp : sum;
|
||||||
|
}
|
||||||
|
/** RestorePP's new PP byte (PP Ups kept), or undefined when the move's PP
|
||||||
|
* is already at `max` (GetMaxPPOfMove). MAX items fill the move; others add
|
||||||
|
* 10, or 5 for the MysteryBerry. */
|
||||||
|
export function restoredPP(pp: number, max: number, item: number) {
|
||||||
|
const current = pp & C.PP_MASK;
|
||||||
|
if (current >= max) return undefined;
|
||||||
|
const full = item === C.MAX_ELIXER || item === C.MAX_ETHER;
|
||||||
|
return (pp & ~C.PP_MASK) | (full ? max : Math.min(max, current + (item === C.MYSTERYBERRY ? 5 : 10)));
|
||||||
|
}
|
||||||
|
/** ChangeHappiness' arithmetic with a HappinessChanges byte: bytes from
|
||||||
|
* $64 up (`cp $64`) are decreases that stop at zero. */
|
||||||
|
export function changedHappiness(happiness: number, delta: number) {
|
||||||
|
const sum = happiness + delta;
|
||||||
|
return delta >= 0x64 ? (sum > 0xff ? sum & 0xff : 0) : Math.min(0xff, sum);
|
||||||
|
}
|
||||||
|
|
||||||
export class Battle {
|
export class Battle {
|
||||||
readonly data: BattleData;
|
readonly data: BattleData;
|
||||||
readonly player = new Side();
|
readonly player = new Side();
|
||||||
@@ -338,7 +402,10 @@ export class Battle {
|
|||||||
battlePlayerAction = 0;
|
battlePlayerAction = 0;
|
||||||
enemyGoesFirst = 0;
|
enemyGoesFirst = 0;
|
||||||
numFleeAttempts = 0;
|
numFleeAttempts = 0;
|
||||||
|
/** wBattleParticipantsNotFainted, which shares the experience. */
|
||||||
participants = 0;
|
participants = 0;
|
||||||
|
/** wBattleParticipantsIncludingFainted, which revives consult. */
|
||||||
|
private participantsAll = 0;
|
||||||
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
|
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
|
||||||
enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 };
|
enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 };
|
||||||
weather: number = C.WEATHER_NONE;
|
weather: number = C.WEATHER_NONE;
|
||||||
@@ -357,6 +424,9 @@ export class Battle {
|
|||||||
private battleHasJustStarted = 0;
|
private battleHasJustStarted = 0;
|
||||||
/** wCurPartyMon: the party index a menu picked. */
|
/** wCurPartyMon: the party index a menu picked. */
|
||||||
private curPartyMon = 0;
|
private curPartyMon = 0;
|
||||||
|
/** wPartyMenuCursor: the 1-based cursor of the last party menu, which
|
||||||
|
* no ported field menu sets before a battle. */
|
||||||
|
private partyMenuCursor = 0;
|
||||||
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
|
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
|
||||||
lastPlayerMon = 0;
|
lastPlayerMon = 0;
|
||||||
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
|
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
|
||||||
@@ -434,11 +504,12 @@ export class Battle {
|
|||||||
asm: (offset) => this.textAsm(offset),
|
asm: (offset) => this.textAsm(offset),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/** StdBattleTextbox / BattleTextbox with a ROM text. */
|
/** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for
|
||||||
private *say(address: number): Gen {
|
* a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */
|
||||||
|
private *say(address: number, button = false): Gen {
|
||||||
const { text, prompt } = readTextEnd(this.reader, address, this.context());
|
const { text, prompt } = readTextEnd(this.reader, address, this.context());
|
||||||
// The tutorial's auto input presses A at every prompt.
|
// The tutorial's auto input presses A at every prompt.
|
||||||
const waits = prompt && this.battleType !== C.BATTLETYPE_TUTORIAL;
|
const waits = (prompt || button) && this.battleType !== C.BATTLETYPE_TUTORIAL;
|
||||||
for (const page of text.split("\n\n").filter(Boolean))
|
for (const page of text.split("\n\n").filter(Boolean))
|
||||||
yield { kind: "text", text: page, prompt: waits };
|
yield { kind: "text", text: page, prompt: waits };
|
||||||
}
|
}
|
||||||
@@ -483,18 +554,9 @@ export class Battle {
|
|||||||
case at("YourFoesWeakGetmMonText"):
|
case at("YourFoesWeakGetmMonText"):
|
||||||
return BATTLE.BattleMonNicknameText;
|
return BATTLE.BattleMonNicknameText;
|
||||||
case at("WithdrawMonText.WithdrawMonText"): {
|
case at("WithdrawMonText.WithdrawMonText"): {
|
||||||
// The enemy's HP lost since the send-out, as a percentage. The
|
const text = withdrawText(this.enemyHPAtSwitch, this.enemy.mon.hp, this.enemy.mon.maxHp);
|
||||||
// multiplicand's high byte is left over from earlier arithmetic;
|
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
|
||||||
// it is taken as zero here.
|
return BATTLE[text];
|
||||||
const lost = (this.enemyHPAtSwitch - this.enemy.mon.hp) & 0xffff;
|
|
||||||
const percent = this.hpPercent(lost);
|
|
||||||
return !percent
|
|
||||||
? BATTLE.ThatsEnoughComeBackText
|
|
||||||
: percent < 30
|
|
||||||
? BATTLE.ComeBackText
|
|
||||||
: percent < 70
|
|
||||||
? BATTLE.OKComeBackText
|
|
||||||
: BATTLE.GoodComeBackText;
|
|
||||||
}
|
}
|
||||||
case at("Text_1_2_and_Poof"):
|
case at("Text_1_2_and_Poof"):
|
||||||
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
|
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
|
||||||
@@ -693,7 +755,7 @@ export class Battle {
|
|||||||
yield* this.say(BATTLE.WantsToBattleText);
|
yield* this.say(BATTLE.WantsToBattleText);
|
||||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||||
} else yield* this.say(BATTLE.WildPokemonAppearedText);
|
} else yield* this.say(BATTLE.WildPokemonAppearedText);
|
||||||
this.participants = 1 << this.curBattleMon;
|
this.resetBattleParticipants();
|
||||||
this.initBattleMon();
|
this.initBattleMon();
|
||||||
const amuletCoin =
|
const amuletCoin =
|
||||||
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
|
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
|
||||||
@@ -701,6 +763,7 @@ export class Battle {
|
|||||||
this.newStatus(this.enemy);
|
this.newStatus(this.enemy);
|
||||||
yield* this.sendOutMonText();
|
yield* this.sendOutMonText();
|
||||||
yield this.hud();
|
yield this.hud();
|
||||||
|
this.turn = 0; // SetPlayerTurn before SpikesDamage
|
||||||
yield* this.battleTurn();
|
yield* this.battleTurn();
|
||||||
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
|
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
|
||||||
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||||||
@@ -749,6 +812,7 @@ export class Battle {
|
|||||||
});
|
});
|
||||||
this.enemy.moveNum = choice.index;
|
this.enemy.moveNum = choice.index;
|
||||||
this.enemy.curMove = choice.move;
|
this.enemy.curMove = choice.move;
|
||||||
|
if (choice.enemyTurn) this.turn = 1;
|
||||||
}
|
}
|
||||||
if (!this.checkPlayerLockedIn()) {
|
if (!this.checkPlayerLockedIn()) {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -892,14 +956,65 @@ export class Battle {
|
|||||||
yield* this.say(BATTLE.OakThisIsntTheTimeText);
|
yield* this.say(BATTLE.OakThisIsntTheTimeText);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// ItemEffects: the routine this item runs.
|
// ItemEffects: the routine this item runs, chosen by its ROM address.
|
||||||
const effect = this.reader.near(BATTLE.ItemEffects + (item - 1) * 2, this.reader.bank(BATTLE.ItemEffects));
|
const effect = this.reader.near(BATTLE.ItemEffects + (item - 1) * 2, this.reader.bank(BATTLE.ItemEffects));
|
||||||
if (context === C.ITEMMENU_PARTY && effect === BATTLE.RestoreHPEffect)
|
const use = { item, slot };
|
||||||
return yield* this.restoreHPEffect(item, slot);
|
if (context === C.ITEMMENU_PARTY) {
|
||||||
if (context === C.ITEMMENU_PARTY && effect === BATTLE.StatusHealingEffect)
|
// BattlePack's .BattleField: anything but success stays in the pack.
|
||||||
return yield* this.statusHealingEffect(item, slot);
|
switch (effect) {
|
||||||
|
case BATTLE.RestoreHPEffect:
|
||||||
|
return yield* this.statusHealerResult(yield* this.itemRestoreHP(use));
|
||||||
|
case BATTLE.StatusHealingEffect:
|
||||||
|
return yield* this.statusHealingEffect(use);
|
||||||
|
case BATTLE.FullRestoreEffect:
|
||||||
|
return yield* this.fullRestoreEffect(use);
|
||||||
|
case BATTLE.ReviveEffect:
|
||||||
|
return yield* this.reviveEffect(use, 0);
|
||||||
|
case BATTLE.RevivalHerbEffect:
|
||||||
|
return yield* this.reviveEffect(use, C.HAPPINESS_REVIVALHERB);
|
||||||
|
case BATTLE.EnergypowderEffect:
|
||||||
|
case BATTLE.EnergyRootEffect:
|
||||||
|
return yield* this.bitterRestoreHP(use, effect === BATTLE.EnergyRootEffect ? C.HAPPINESS_ENERGYROOT : C.HAPPINESS_BITTERPOWDER);
|
||||||
|
case BATTLE.HealPowderEffect:
|
||||||
|
return yield* this.healPowderEffect(use);
|
||||||
|
case BATTLE.BitterBerryEffect:
|
||||||
|
return yield* this.bitterBerryEffect(use);
|
||||||
|
case BATTLE.RestorePPEffect:
|
||||||
|
return yield* this.restorePPEffect(use);
|
||||||
|
}
|
||||||
|
} else if (context !== C.ITEMMENU_CURRENT) {
|
||||||
|
// BattlePack's .BattleOnly: failure gives Oak's warning, and the
|
||||||
|
// "won't have any effect" value 2 returns to the pack.
|
||||||
|
let result: number | undefined;
|
||||||
|
switch (effect) {
|
||||||
|
case BATTLE.XItemEffect:
|
||||||
|
result = yield* this.xItemEffect(use);
|
||||||
|
break;
|
||||||
|
case BATTLE.XAccuracyEffect:
|
||||||
|
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_X_ACCURACY);
|
||||||
|
break;
|
||||||
|
case BATTLE.GuardSpecEffect:
|
||||||
|
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_MIST);
|
||||||
|
break;
|
||||||
|
case BATTLE.DireHitEffect:
|
||||||
|
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_FOCUS_ENERGY);
|
||||||
|
break;
|
||||||
|
case BATTLE.PokeDollEffect:
|
||||||
|
result = yield* this.pokeDollEffect(use);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (result !== undefined) {
|
||||||
|
if (!result) yield* this.say(BATTLE.OakThisIsntTheTimeText);
|
||||||
|
return result === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
|
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
|
||||||
}
|
}
|
||||||
|
/** StatusHealer_Jumptable: 0 used, 1 "won't have any effect", 2 cancelled. */
|
||||||
|
private *statusHealerResult(code: number): Gen<boolean> {
|
||||||
|
if (code === 1) yield* this.say(BATTLE.ItemWontHaveEffectText);
|
||||||
|
return code === 0;
|
||||||
|
}
|
||||||
/** UseItem_SelectMon: the party menu for an item. Returns the party
|
/** UseItem_SelectMon: the party menu for an item. Returns the party
|
||||||
* index, or -1 when cancelled or an Egg was chosen. */
|
* index, or -1 when cancelled or an Egg was chosen. */
|
||||||
private *useItemSelectMon(): Gen<number> {
|
private *useItemSelectMon(): Gen<number> {
|
||||||
@@ -911,77 +1026,231 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
/** ItemRestoreHP (RestoreHPEffect): HealingHPAmounts, up to the maximum.
|
/** UseItemText: "<PLAYER> used <ITEM>!", a button, then UseDisposableItem. */
|
||||||
* The Pokémon out takes the party HP, which UpdateBattleMonInParty
|
private *useItemText({ item, slot }: ItemUse): Gen {
|
||||||
* copied at the start of the turn. */
|
yield* this.say(BATTLE.ItemUsedText, true);
|
||||||
private *restoreHPEffect(item: number, slot: number): Gen<boolean> {
|
this.tossItem("items", item, slot);
|
||||||
const target = yield* this.useItemSelectMon();
|
|
||||||
if (target < 0) return false;
|
|
||||||
const mon = this.options.party[target];
|
|
||||||
if (!mon.hp || mon.hp >= mon.stats[0]) {
|
|
||||||
yield* this.say(BATTLE.ItemWontHaveEffectText);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
/** ItemRestoreHP: HealingHPAmounts, up to the maximum. The Pokémon out
|
||||||
|
* takes the party HP, which UpdateBattleMonInParty copied at the start of
|
||||||
|
* the turn. Returns the StatusHealer_Jumptable value. */
|
||||||
|
private *itemRestoreHP({ item, slot }: ItemUse): Gen<number> {
|
||||||
|
const target = yield* this.useItemSelectMon();
|
||||||
|
if (target < 0) return 2;
|
||||||
|
const mon = this.options.party[target];
|
||||||
|
if (!mon.hp || mon.hp >= mon.stats[0]) return 1;
|
||||||
const r = this.reader;
|
const r = this.reader;
|
||||||
let amount: number | undefined;
|
let amount: number | undefined;
|
||||||
for (let p = BATTLE.HealingHPAmounts; r.byte(p) !== 0xff; p += 3)
|
for (let p = BATTLE.HealingHPAmounts; r.byte(p) !== 0xff; p += 3)
|
||||||
if (r.byte(p) === item) amount = r.word(p + 1);
|
if (r.byte(p) === item) amount = r.word(p + 1);
|
||||||
if (amount === undefined) throw new UnsupportedBattle("Missing healing amount.");
|
if (amount === undefined) throw new UnsupportedBattle("Missing healing amount.");
|
||||||
// RestoreHealth: a 16-bit sum that overflows or passes the maximum is full.
|
yield* this.restoreHP(target, restoreHealth(mon.hp, mon.stats[0], amount), C.PARTYMENUTEXT_HEAL_HP);
|
||||||
const before = mon.hp,
|
|
||||||
sum = mon.hp + amount;
|
|
||||||
mon.hp = sum > 0xffff || sum > mon.stats[0] ? mon.stats[0] : sum;
|
|
||||||
if (target === this.curBattleMon) this.player.mon.hp = mon.hp;
|
|
||||||
yield this.hud();
|
|
||||||
this.numbers[RAM.curHPAnimDeltaHP] = mon.hp - before;
|
|
||||||
yield* this.partyMenuActionText(target, C.PARTYMENUTEXT_HEAL_HP);
|
|
||||||
this.tossItem("items", item, slot);
|
this.tossItem("items", item, slot);
|
||||||
return true;
|
return 0;
|
||||||
}
|
}
|
||||||
/** StatusHealingEffect and UseStatusHealer, with StatusHealingActions. */
|
/** The HP change, BattlemonRestoreHealth and the party menu action text
|
||||||
private *statusHealingEffect(item: number, slot: number): Gen<boolean> {
|
* with wCurHPAnimDeltaHP. */
|
||||||
|
private *restoreHP(target: number, hp: number, text: number): Gen {
|
||||||
|
const mon = this.options.party[target];
|
||||||
|
this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp;
|
||||||
|
mon.hp = hp;
|
||||||
|
if (target === this.curBattleMon) this.player.mon.hp = hp;
|
||||||
|
yield this.hud();
|
||||||
|
yield* this.partyMenuActionText(target, text);
|
||||||
|
}
|
||||||
|
/** EnergypowderEffect and EnergyRootEffect: ItemRestoreHP, then the
|
||||||
|
* bitter taste costs happiness. */
|
||||||
|
private *bitterRestoreHP(use: ItemUse, happiness: number): Gen<boolean> {
|
||||||
|
const code = yield* this.itemRestoreHP(use);
|
||||||
|
if (!code) {
|
||||||
|
this.changeHappiness(this.curPartyMon, happiness);
|
||||||
|
yield* this.say(BATTLE.ItemLooksBitterText);
|
||||||
|
}
|
||||||
|
return yield* this.statusHealerResult(code);
|
||||||
|
}
|
||||||
|
/** StatusHealingEffect. */
|
||||||
|
private *statusHealingEffect(use: ItemUse): Gen<boolean> {
|
||||||
const target = yield* this.useItemSelectMon();
|
const target = yield* this.useItemSelectMon();
|
||||||
if (target < 0) return false;
|
if (target < 0) return false;
|
||||||
const mon = this.options.party[target],
|
return yield* this.statusHealerResult(yield* this.useStatusHealer(use, target));
|
||||||
r = this.reader;
|
}
|
||||||
|
/** HealPowderEffect: a status heal that tastes bitter. */
|
||||||
|
private *healPowderEffect(use: ItemUse): Gen<boolean> {
|
||||||
|
const target = yield* this.useItemSelectMon();
|
||||||
|
if (target < 0) return false;
|
||||||
|
const code = yield* this.useStatusHealer(use, target);
|
||||||
|
if (!code) {
|
||||||
|
this.changeHappiness(target, C.HAPPINESS_BITTERPOWDER);
|
||||||
|
yield* this.say(BATTLE.ItemLooksBitterText);
|
||||||
|
}
|
||||||
|
return yield* this.statusHealerResult(code);
|
||||||
|
}
|
||||||
|
/** GetItemHealingAction: StatusHealingActions' text and status mask. */
|
||||||
|
private healingAction(item: number) {
|
||||||
|
const r = this.reader;
|
||||||
let p = BATTLE.StatusHealingActions;
|
let p = BATTLE.StatusHealingActions;
|
||||||
while (r.byte(p) !== item) {
|
while (r.byte(p) !== item) {
|
||||||
if (r.byte(p) === 0xff) throw new UnsupportedBattle("Missing status healing action.");
|
if (r.byte(p) === 0xff) throw new UnsupportedBattle("Missing status healing action.");
|
||||||
p += 3;
|
p += 3;
|
||||||
}
|
}
|
||||||
let text = r.byte(p + 1);
|
return { text: r.byte(p + 1), mask: r.byte(p + 2) };
|
||||||
const mask = r.byte(p + 2),
|
|
||||||
battleMon = target === this.curBattleMon;
|
|
||||||
if (!mon.hp) {
|
|
||||||
yield* this.say(BATTLE.ItemWontHaveEffectText);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
/** UseStatusHealer: 0 if used, 1 if it would have no effect. */
|
||||||
|
private *useStatusHealer({ item, slot }: ItemUse, target: number): Gen<number> {
|
||||||
|
const mon = this.options.party[target];
|
||||||
|
if (!mon.hp) return 1;
|
||||||
|
let { text, mask } = this.healingAction(item);
|
||||||
if (!(mon.status & mask)) {
|
if (!(mon.status & mask)) {
|
||||||
// IsItemUsedOnConfusedMon: a full heal still cures the confusion of
|
// IsItemUsedOnConfusedMon: a full heal still cures the confusion of
|
||||||
// the Pokémon out.
|
// the Pokémon out.
|
||||||
if (!(battleMon && mask === 0xff && this.player.has(SUB3, C.SUBSTATUS_CONFUSED))) {
|
if (!(target === this.curBattleMon && mask === 0xff && this.player.has(SUB3, C.SUBSTATUS_CONFUSED)))
|
||||||
yield* this.say(BATTLE.ItemWontHaveEffectText);
|
return 1;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
text = C.PARTYMENUTEXT_HEAL_CONFUSION;
|
text = C.PARTYMENUTEXT_HEAL_CONFUSION;
|
||||||
}
|
}
|
||||||
mon.status = 0;
|
mon.status = 0;
|
||||||
if (battleMon) {
|
this.healStatus(target, mask);
|
||||||
// HealStatus
|
yield this.hud();
|
||||||
|
yield* this.partyMenuActionText(target, text);
|
||||||
|
this.tossItem("items", item, slot);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
/** HealStatus: the Pokémon out loses its status, Toxic and Nightmare
|
||||||
|
* (and confusion with a full heal), then CalcPlayerStats. */
|
||||||
|
private healStatus(target: number, mask: number) {
|
||||||
|
if (target !== this.curBattleMon) return;
|
||||||
this.player.mon.status = 0;
|
this.player.mon.status = 0;
|
||||||
this.player.reset(SUB5, C.SUBSTATUS_TOXIC);
|
this.player.reset(SUB5, C.SUBSTATUS_TOXIC);
|
||||||
this.player.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
|
this.player.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
|
||||||
if (mask === 0xff) this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
|
if (mask === 0xff) this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
|
||||||
this.calcPlayerStats();
|
this.calcPlayerStats();
|
||||||
}
|
}
|
||||||
yield this.hud();
|
/** FullRestoreEffect: full HP and no status; at full HP only the status. */
|
||||||
yield* this.partyMenuActionText(target, text);
|
private *fullRestoreEffect(use: ItemUse): Gen<boolean> {
|
||||||
this.tossItem("items", item, slot);
|
const target = yield* this.useItemSelectMon();
|
||||||
|
if (target < 0) return false;
|
||||||
|
const mon = this.options.party[target];
|
||||||
|
if (!mon.hp) return yield* this.statusHealerResult(1);
|
||||||
|
if (mon.hp >= mon.stats[0]) return yield* this.statusHealerResult(yield* this.useStatusHealer(use, target));
|
||||||
|
mon.status = 0;
|
||||||
|
this.healStatus(target, this.healingAction(use.item).mask);
|
||||||
|
yield* this.restoreHP(target, mon.stats[0], C.PARTYMENUTEXT_HEAL_HP);
|
||||||
|
this.tossItem("items", use.item, use.slot);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
/** ReviveEffect and RevivalHerbEffect (RevivePokemon). A revived
|
||||||
|
* Pokémon that took part in the battle shares the experience again. */
|
||||||
|
private *reviveEffect(use: ItemUse, happiness: number): Gen<boolean> {
|
||||||
|
const target = yield* this.useItemSelectMon();
|
||||||
|
if (target < 0) return false;
|
||||||
|
const mon = this.options.party[target];
|
||||||
|
if (mon.hp) return yield* this.statusHealerResult(1);
|
||||||
|
if (this.participantsAll & (1 << target)) this.participants |= 1 << target;
|
||||||
|
// ReviveHalfHP halves the maximum HP; the others restore all of it.
|
||||||
|
yield* this.restoreHP(target, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0], C.PARTYMENUTEXT_REVIVE);
|
||||||
|
this.tossItem("items", use.item, use.slot);
|
||||||
|
if (happiness) {
|
||||||
|
this.changeHappiness(target, happiness);
|
||||||
|
yield* this.say(BATTLE.ItemLooksBitterText);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** BitterBerryEffect: cures the confusion of the Pokémon out. */
|
||||||
|
private *bitterBerryEffect(use: ItemUse): Gen<boolean> {
|
||||||
|
if (!this.player.has(SUB3, C.SUBSTATUS_CONFUSED)) return yield* this.statusHealerResult(1);
|
||||||
|
this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
|
||||||
|
this.turn = 0;
|
||||||
|
yield* this.useItemText(use);
|
||||||
|
yield* this.say(BATTLE.ConfusedNoMoreText);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** RestorePPEffect for Ethers, Elixers and the MysteryBerry. Choosing no
|
||||||
|
* move goes back to the party menu. */
|
||||||
|
private *restorePPEffect(use: ItemUse): Gen<boolean> {
|
||||||
|
const data = this.options.data,
|
||||||
|
item = use.item;
|
||||||
|
for (;;) {
|
||||||
|
const target = yield* this.useItemSelectMon();
|
||||||
|
if (target < 0) return false;
|
||||||
|
const mon = this.options.party[target];
|
||||||
|
let restored = false;
|
||||||
|
if (item === C.ELIXER || item === C.MAX_ELIXER) {
|
||||||
|
for (let slot = 0; slot < 4; slot++)
|
||||||
|
if (mon.moves[slot] && this.restorePP(mon, slot, item)) restored = true;
|
||||||
|
} else {
|
||||||
|
const choice = yield {
|
||||||
|
kind: "move",
|
||||||
|
text: readTextEnd(this.reader, BATTLE.RestoreThePPOfWhichMoveText, this.context()).text,
|
||||||
|
moves: [...mon.moves],
|
||||||
|
pp: [...mon.pp],
|
||||||
|
};
|
||||||
|
if (typeof choice !== "number" || choice < 0 || choice > 3 || !mon.moves[choice]) continue;
|
||||||
|
// GetMoveName and CopyName1: the move's name in wStringBuffer2.
|
||||||
|
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.moveName(mon.moves[choice]);
|
||||||
|
restored = this.restorePP(mon, choice, item);
|
||||||
|
}
|
||||||
|
if (!restored) return yield* this.statusHealerResult(1);
|
||||||
|
// BattleRestorePP: the Pokémon out takes the PP of matching moves,
|
||||||
|
// unless it is transformed.
|
||||||
|
if (target === this.curBattleMon && !this.player.has(SUB5, C.SUBSTATUS_TRANSFORMED))
|
||||||
|
this.player.mon.moves.forEach((move, slot) => {
|
||||||
|
if (move && move === mon.moves[slot]) this.player.mon.pp[slot] = mon.pp[slot];
|
||||||
|
});
|
||||||
|
yield* this.say(BATTLE.PPRestoredText);
|
||||||
|
this.tossItem("items", item, use.slot);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** RestorePP: MAX items fill the move; others add 10 (the MysteryBerry
|
||||||
|
* 5) up to GetMaxPPOfMove. Returns false if the PP was already full. */
|
||||||
|
private restorePP(mon: PartyMon, slot: number, item: number) {
|
||||||
|
const max = maxPP(this.options.data, mon.moves[slot], mon.pp[slot] >> 6) & C.PP_MASK;
|
||||||
|
const pp = restoredPP(mon.pp[slot], max, item);
|
||||||
|
if (pp === undefined) return false;
|
||||||
|
mon.pp[slot] = pp;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** XItemEffect: the item is used, then RaiseStat on the player's side,
|
||||||
|
* even if the stat cannot rise, and a small happiness gain. */
|
||||||
|
private *xItemEffect(use: ItemUse): Gen<number> {
|
||||||
|
yield* this.useItemText(use);
|
||||||
|
const r = this.reader;
|
||||||
|
let p = BATTLE.XItemStats;
|
||||||
|
while (r.byte(p) !== use.item) p += 2;
|
||||||
|
this.turn = 0;
|
||||||
|
this.attackMissed = 0;
|
||||||
|
this.effectFailed = 0;
|
||||||
|
this.raiseStat(r.byte(p + 1));
|
||||||
|
yield this.hud();
|
||||||
|
yield* this.statUpMessage();
|
||||||
|
yield* this.statUpFailText();
|
||||||
|
this.changeHappiness(this.curBattleMon, C.HAPPINESS_USEDXITEM);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
/** XAccuracyEffect, GuardSpecEffect and DireHitEffect: a SubStatus4 flag
|
||||||
|
* that can be set once (WontHaveAnyEffect_NotUsedMessage returns 2). */
|
||||||
|
private *setPlayerSubstatusItem(use: ItemUse, bit: number): Gen<number> {
|
||||||
|
if (this.player.has(SUB4, bit)) {
|
||||||
|
yield* this.say(BATTLE.ItemWontHaveEffectText);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
this.player.sub[SUB4] |= 1 << bit;
|
||||||
|
yield* this.useItemText(use);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
/** PokeDollEffect: ends a wild battle as a draw (wForcedSwitch); in a
|
||||||
|
* trainer battle it fails. */
|
||||||
|
private *pokeDollEffect(use: ItemUse): Gen<number> {
|
||||||
|
if (this.battleMode !== C.WILD_BATTLE) return 0;
|
||||||
|
this.battleResult = (this.battleResult & 0xc0) | C.DRAW;
|
||||||
|
yield* this.useItemText(use);
|
||||||
|
this.battleEnded = true;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
/** CalcPlayerStats: stat stages, then the paralysis and burn penalties
|
/** CalcPlayerStats: stat stages, then the paralysis and burn penalties
|
||||||
* for the side that hBattleTurn names after BattleCommand_SwitchTurn,
|
* for the side that hBattleTurn names after BattleCommand_SwitchTurn,
|
||||||
* which is the enemy if hBattleTurn held the enemy's turn. */
|
* which is the enemy if hBattleTurn held the enemy's turn. At the battle
|
||||||
|
* menu that happens after a full turn (HandleHealingItems ends on the
|
||||||
|
* enemy) and after a trainer AI layer set it. */
|
||||||
private calcPlayerStats() {
|
private calcPlayerStats() {
|
||||||
const player = this.player;
|
const player = this.player;
|
||||||
for (let stat = 0; stat < 5; stat++) {
|
for (let stat = 0; stat < 5; stat++) {
|
||||||
@@ -989,13 +1258,7 @@ export class Battle {
|
|||||||
const value = Math.floor((player.stats[stat] * numerator) / denominator);
|
const value = Math.floor((player.stats[stat] * numerator) / denominator);
|
||||||
player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
|
player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
|
||||||
}
|
}
|
||||||
const side = this.turn === 0 ? this.player : this.enemy;
|
this.applyStatusEffects(this.turn === 0 ? this.player : this.enemy);
|
||||||
// hBattleTurn at the battle menu is not modelled everywhere (AIDamageCalc
|
|
||||||
// leaves it on the enemy), so a penalty that would hit the enemy again
|
|
||||||
// is refused rather than guessed.
|
|
||||||
if (side === this.enemy && this.enemy.mon.status & ((1 << C.PAR) | (1 << C.BRN)))
|
|
||||||
throw new UnsupportedBattle("Healing status while the enemy is paralyzed or burned is not ported yet.");
|
|
||||||
this.applyStatusEffects(side);
|
|
||||||
}
|
}
|
||||||
/** PrintPartyMenuActionText with the Pokémon's nickname. */
|
/** PrintPartyMenuActionText with the Pokémon's nickname. */
|
||||||
private *partyMenuActionText(target: number, text: number): Gen {
|
private *partyMenuActionText(target: number, text: number): Gen {
|
||||||
@@ -2590,7 +2853,7 @@ export class Battle {
|
|||||||
this.breakAttraction();
|
this.breakAttraction();
|
||||||
if (setMode) yield* this.enemySwitchSetMode();
|
if (setMode) yield* this.enemySwitchSetMode();
|
||||||
else yield* this.enemySwitch();
|
else yield* this.enemySwitch();
|
||||||
this.participants = 1 << this.curBattleMon; // ResetBattleParticipants
|
this.resetBattleParticipants();
|
||||||
this.turn = 1;
|
this.turn = 1;
|
||||||
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
|
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
|
||||||
throw new UnsupportedBattle("Spikes are not ported yet.");
|
throw new UnsupportedBattle("Spikes are not ported yet.");
|
||||||
@@ -2605,7 +2868,7 @@ export class Battle {
|
|||||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||||
yield this.hud();
|
yield this.hud();
|
||||||
if (!switching) return;
|
if (!switching) return;
|
||||||
this.participants = 0;
|
this.participants = this.participantsAll = 0;
|
||||||
this.battlePlayerAction = 0;
|
this.battlePlayerAction = 0;
|
||||||
this.enemyIsSwitching = 1;
|
this.enemyIsSwitching = 1;
|
||||||
yield* this.playerSwitch();
|
yield* this.playerSwitch();
|
||||||
@@ -2662,7 +2925,10 @@ export class Battle {
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
const answer = yield { kind: "party", text: prompt, menu, cancel };
|
const answer = yield { kind: "party", text: prompt, menu, cancel };
|
||||||
if (typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < this.options.party.length)
|
if (typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < this.options.party.length)
|
||||||
|
{
|
||||||
|
this.partyMenuCursor = answer + 1; // PartyMenuSelect
|
||||||
return (this.curPartyMon = answer);
|
return (this.curPartyMon = answer);
|
||||||
|
}
|
||||||
if (cancel) return -1;
|
if (cancel) return -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2733,7 +2999,7 @@ export class Battle {
|
|||||||
/** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and
|
/** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and
|
||||||
* PlayerPartyMonEntrance. */
|
* PlayerPartyMonEntrance. */
|
||||||
private *sendOutPlayerMon(): Gen {
|
private *sendOutPlayerMon(): Gen {
|
||||||
this.participants |= 1 << this.curBattleMon; // AddBattleParticipant
|
this.addBattleParticipant();
|
||||||
this.initBattleMon();
|
this.initBattleMon();
|
||||||
yield* this.sendOutMonText();
|
yield* this.sendOutMonText();
|
||||||
this.newStatus(this.player); // with ResetPlayerStatLevels
|
this.newStatus(this.player); // with ResetPlayerStatLevels
|
||||||
@@ -2745,28 +3011,11 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
/** SendOutMonText: the cheer depends on the enemy's remaining HP. */
|
/** SendOutMonText: the cheer depends on the enemy's remaining HP. */
|
||||||
private *sendOutMonText(): Gen {
|
private *sendOutMonText(): Gen {
|
||||||
const hp = this.enemy.mon.hp;
|
const { hp, maxHp } = this.enemy.mon;
|
||||||
let text: number = BATTLE.GoMonText;
|
if (hp) this.enemyHPAtSwitch = hp;
|
||||||
if (hp) {
|
const text = sendOutText(hp, maxHp);
|
||||||
this.enemyHPAtSwitch = hp;
|
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
|
||||||
const percent = this.hpPercent(hp);
|
yield* this.say(BATTLE[text]);
|
||||||
if (percent < 70)
|
|
||||||
text =
|
|
||||||
percent >= 40
|
|
||||||
? BATTLE.DoItMonText
|
|
||||||
: percent >= 10
|
|
||||||
? BATTLE.GoForItMonText
|
|
||||||
: BATTLE.YourFoesWeakGetmMonText;
|
|
||||||
}
|
|
||||||
yield* this.say(text);
|
|
||||||
}
|
|
||||||
/** HP × 25 / (max HP / 4), low byte, as the send-out and withdraw texts
|
|
||||||
* compute it. A maximum HP below 4 divides by zero, which freezes the
|
|
||||||
* original. */
|
|
||||||
private hpPercent(hp: number) {
|
|
||||||
const divisor = (this.enemy.mon.maxHp >> 2) & 0xff;
|
|
||||||
if (!divisor) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
|
|
||||||
return Math.floor((hp * 25) / divisor) & 0xff;
|
|
||||||
}
|
}
|
||||||
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
|
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
|
||||||
* with the first party Pokémon's Speed. Returns true if the player fled. */
|
* with the first party Pokémon's Speed. Returns true if the player fled. */
|
||||||
@@ -2945,7 +3194,7 @@ export class Battle {
|
|||||||
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||||||
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
|
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
|
||||||
yield* this.enemySwitch();
|
yield* this.enemySwitch();
|
||||||
this.participants = 1 << this.curBattleMon;
|
this.resetBattleParticipants();
|
||||||
this.battleHasJustStarted = 0;
|
this.battleHasJustStarted = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3129,17 +3378,32 @@ export class Battle {
|
|||||||
this.player.mon.status = 0;
|
this.player.mon.status = 0;
|
||||||
this.updateBattleMonInParty();
|
this.updateBattleMonInParty();
|
||||||
// ChangeHappiness: fainting, or fainting to a much stronger foe.
|
// ChangeHappiness: fainting, or fainting to a much stronger foe.
|
||||||
const mon = this.partyMon;
|
|
||||||
const strong = this.enemy.mon.level >= this.player.mon.level + 30;
|
const strong = this.enemy.mon.level >= this.player.mon.level + 30;
|
||||||
const change = this.happinessChange(strong ? "beatenByStrongFoe" : "fainted", mon.happiness);
|
this.changeHappiness(this.curBattleMon, strong ? C.HAPPINESS_BEATENBYSTRONGFOE : C.HAPPINESS_FAINTED);
|
||||||
mon.happiness = Math.max(0, Math.min(255, mon.happiness + change));
|
|
||||||
this.battleResult = (this.battleResult & 0xc0) + C.LOSE;
|
this.battleResult = (this.battleResult & 0xc0) + C.LOSE;
|
||||||
}
|
}
|
||||||
/** HappinessChanges: changes by current happiness (<100, <200, else). */
|
/** ChangeHappiness: HappinessChanges by tier (below 100, below 200,
|
||||||
private happinessChange(kind: "fainted" | "beatenByStrongFoe" | "levelUp", happiness: number) {
|
* above). Eggs are skipped. In battle the battle Pokémon's copy follows
|
||||||
const tier = happiness < 100 ? 0 : happiness < 200 ? 1 : 2;
|
* only when wCurPartyMon equals wPartyMenuCursor, which is 1-based. */
|
||||||
const rows = { levelUp: [5, 3, 2], fainted: [-1, -1, -1], beatenByStrongFoe: [-5, -5, -10] };
|
private changeHappiness(index: number, action: number) {
|
||||||
return rows[kind][tier];
|
const mon = this.options.party[index];
|
||||||
|
if (mon.species === C.EGG) return;
|
||||||
|
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
|
||||||
|
mon.happiness = changedHappiness(
|
||||||
|
mon.happiness,
|
||||||
|
this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
|
||||||
|
);
|
||||||
|
if (index === this.partyMenuCursor) this.player.mon.happiness = mon.happiness;
|
||||||
|
}
|
||||||
|
/** ResetBattleParticipants: only the Pokémon out, in both sets. */
|
||||||
|
private resetBattleParticipants() {
|
||||||
|
this.participants = this.participantsAll = 0;
|
||||||
|
this.addBattleParticipant();
|
||||||
|
}
|
||||||
|
/** AddBattleParticipant. */
|
||||||
|
private addBattleParticipant() {
|
||||||
|
this.participants |= 1 << this.curBattleMon;
|
||||||
|
this.participantsAll |= 1 << this.curBattleMon;
|
||||||
}
|
}
|
||||||
private *lostBattle(): Gen {
|
private *lostBattle(): Gen {
|
||||||
this.battleEnded = true;
|
this.battleEnded = true;
|
||||||
@@ -3210,7 +3474,11 @@ export class Battle {
|
|||||||
side.stats = mon.stats.slice(1);
|
side.stats = mon.stats.slice(1);
|
||||||
this.calcStats(side);
|
this.calcStats(side);
|
||||||
}
|
}
|
||||||
mon.happiness = Math.min(255, mon.happiness + this.happinessChange("levelUp", mon.happiness));
|
// LevelUpHappinessMod: more where it was caught.
|
||||||
|
this.changeHappiness(
|
||||||
|
index,
|
||||||
|
(mon.caughtData & 0x7f) === this.options.landmark ? C.HAPPINESS_GAINLEVELATHOME : C.HAPPINESS_GAINLEVEL,
|
||||||
|
);
|
||||||
this.strings[RAM.stringBuffer1] = mon.nickname;
|
this.strings[RAM.stringBuffer1] = mon.nickname;
|
||||||
this.numbers[RAM.curPartyLevel] = newLevel;
|
this.numbers[RAM.curPartyLevel] = newLevel;
|
||||||
yield* this.say(BATTLE.BattleText_StringBuffer1GrewToLevel);
|
yield* this.say(BATTLE.BattleText_StringBuffer1GrewToLevel);
|
||||||
@@ -3223,7 +3491,7 @@ export class Battle {
|
|||||||
};
|
};
|
||||||
for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level);
|
for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level);
|
||||||
}
|
}
|
||||||
this.participants = 1 << this.curBattleMon;
|
this.resetBattleParticipants();
|
||||||
}
|
}
|
||||||
/** CalcLevel: the highest level whose exp does not exceed the total. */
|
/** CalcLevel: the highest level whose exp does not exceed the total. */
|
||||||
private levelForExp(species: number, exp: number) {
|
private levelForExp(species: number, exp: number) {
|
||||||
|
|||||||
@@ -241,6 +241,20 @@ export const C = {
|
|||||||
ITEMMENU_PARTY: 5,
|
ITEMMENU_PARTY: 5,
|
||||||
PARTYMENUTEXT_HEAL_HP: 0xf5,
|
PARTYMENUTEXT_HEAL_HP: 0xf5,
|
||||||
PARTYMENUTEXT_HEAL_CONFUSION: 0xf9,
|
PARTYMENUTEXT_HEAL_CONFUSION: 0xf9,
|
||||||
|
PARTYMENUTEXT_REVIVE: 0xf7,
|
||||||
|
HAPPINESS_GAINLEVEL: 0x01,
|
||||||
|
HAPPINESS_USEDXITEM: 0x03,
|
||||||
|
HAPPINESS_FAINTED: 0x06,
|
||||||
|
HAPPINESS_BEATENBYSTRONGFOE: 0x08,
|
||||||
|
HAPPINESS_BITTERPOWDER: 0x0f,
|
||||||
|
HAPPINESS_ENERGYROOT: 0x10,
|
||||||
|
HAPPINESS_REVIVALHERB: 0x11,
|
||||||
|
HAPPINESS_GAINLEVELATHOME: 0x13,
|
||||||
|
REVIVE: 0x27,
|
||||||
|
ELIXER: 0x41,
|
||||||
|
MAX_ELIXER: 0x15,
|
||||||
|
MAX_ETHER: 0x40,
|
||||||
|
MYSTERYBERRY: 0x96,
|
||||||
EGG: 0xfd,
|
EGG: 0xfd,
|
||||||
WILD_BATTLE: 1,
|
WILD_BATTLE: 1,
|
||||||
TRAINER_BATTLE: 2,
|
TRAINER_BATTLE: 2,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { RomReader } from "./reader";
|
import { RomReader } from "./reader";
|
||||||
import { decompress } from "./lz";
|
import { decompress } from "./lz";
|
||||||
import {
|
import {
|
||||||
|
BATTLE,
|
||||||
INTRO,
|
INTRO,
|
||||||
NUM_ITEMS,
|
NUM_ITEMS,
|
||||||
NUM_POKEMON,
|
NUM_POKEMON,
|
||||||
NUM_TRAINER_CLASSES,
|
NUM_TRAINER_CLASSES,
|
||||||
|
STATS,
|
||||||
SYMBOLS,
|
SYMBOLS,
|
||||||
} from "./profile";
|
} from "./profile";
|
||||||
import { defaultTextContext, readString } from "../scripts/text";
|
import { defaultTextContext, readString } from "../scripts/text";
|
||||||
@@ -178,6 +180,43 @@ export class PokemonData {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PrintType: the name of a type from TypeNames. */
|
||||||
|
typeName(type: number): string {
|
||||||
|
if (!Number.isInteger(type) || type < 0 || type > 27)
|
||||||
|
throw new Error("Unknown type.");
|
||||||
|
const r = this.reader;
|
||||||
|
return readString(
|
||||||
|
r,
|
||||||
|
r.near(STATS.TypeNames + type * 2, r.bank(STATS.TypeNames)),
|
||||||
|
defaultTextContext(""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** GetGender: the Attack and Speed DVs against the species' ratio;
|
||||||
|
* undefined for genderless species. */
|
||||||
|
gender(species: number, dvs: number): "male" | "female" | undefined {
|
||||||
|
const ratio = this.baseData(species).genderRatio;
|
||||||
|
if (ratio === 0xff) return undefined; // GENDER_UNKNOWN
|
||||||
|
if (!ratio) return "male"; // GENDER_F0
|
||||||
|
if (ratio === 0xfe) return "female"; // GENDER_F100
|
||||||
|
const value = ((dvs >> 8) & 0xf0) | ((dvs >> 4) & 0x0f);
|
||||||
|
return ratio < value ? "male" : "female";
|
||||||
|
}
|
||||||
|
/** PrintTempMonStats' labels: Attack, Defense, Sp. Atk, Sp. Def, Speed. */
|
||||||
|
statNames(): string[] {
|
||||||
|
return readString(
|
||||||
|
this.reader,
|
||||||
|
BATTLE["PrintTempMonStats.StatNames"],
|
||||||
|
defaultTextContext(""),
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
/** A string of the Pokémon stats screen. */
|
||||||
|
statsString(label: keyof typeof STATS): string {
|
||||||
|
return readString(this.reader, STATS[label], defaultTextContext(""));
|
||||||
|
}
|
||||||
|
|
||||||
move(move: number): MoveData {
|
move(move: number): MoveData {
|
||||||
if (!Number.isInteger(move) || move < 1 || move > 251)
|
if (!Number.isInteger(move) || move < 1 || move > 251)
|
||||||
throw new Error("Unknown move.");
|
throw new Error("Unknown move.");
|
||||||
|
|||||||
@@ -320,6 +320,23 @@ export const INTRO = {
|
|||||||
"GetTimeOfDayString.day_string": 0x90b7b,
|
"GetTimeOfDayString.day_string": 0x90b7b,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** The Pokémon stats screen's strings and the type names, keyed by their
|
||||||
|
* PRET labels so that \`npm run check:reference\` can verify each address. */
|
||||||
|
export const STATS = {
|
||||||
|
"LoadPinkPage.Status_Type": 0x4e119,
|
||||||
|
"LoadPinkPage.OK_str": 0x4e127,
|
||||||
|
"LoadPinkPage.ExpPointStr": 0x4e12b,
|
||||||
|
"LoadPinkPage.LevelUpStr": 0x4e136,
|
||||||
|
"LoadPinkPage.ToStr": 0x4e13f,
|
||||||
|
"LoadPinkPage.PkrsStr": 0x4e142,
|
||||||
|
"LoadGreenPage.Item": 0x4e1a0,
|
||||||
|
"LoadGreenPage.ThreeDashes": 0x4e1a5,
|
||||||
|
"LoadGreenPage.Move": 0x4e1a9,
|
||||||
|
IDNoString: 0x4e21e,
|
||||||
|
OTString: 0x4e222,
|
||||||
|
TypeNames: 0x5097b,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
|
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
|
||||||
* data, strings and texts, keyed by their PRET labels so that
|
* data, strings and texts, keyed by their PRET labels so that
|
||||||
* \`npm run check:reference\` can verify each address by name. */
|
* \`npm run check:reference\` can verify each address by name. */
|
||||||
@@ -495,6 +512,24 @@ export const BATTLE = {
|
|||||||
ItemCantUseOnEggText: 0xf810,
|
ItemCantUseOnEggText: 0xf810,
|
||||||
OakThisIsntTheTimeText: 0x10af3,
|
OakThisIsntTheTimeText: 0x10af3,
|
||||||
UseOnWhichPKMNString: 0x504f3,
|
UseOnWhichPKMNString: 0x504f3,
|
||||||
|
FullRestoreEffect: 0xf128,
|
||||||
|
ReviveEffect: 0xf0c8,
|
||||||
|
RevivalHerbEffect: 0xf0a9,
|
||||||
|
EnergypowderEffect: 0xf18c,
|
||||||
|
EnergyRootEffect: 0xf190,
|
||||||
|
HealPowderEffect: 0xefad,
|
||||||
|
BitterBerryEffect: 0xf16a,
|
||||||
|
RestorePPEffect: 0xf5bf,
|
||||||
|
XItemEffect: 0xf4c5,
|
||||||
|
XAccuracyEffect: 0xf482,
|
||||||
|
GuardSpecEffect: 0xf4ab,
|
||||||
|
DireHitEffect: 0xf4b8,
|
||||||
|
PokeDollEffect: 0xf48f,
|
||||||
|
XItemStats: 0xf504,
|
||||||
|
HappinessChanges: 0x7221,
|
||||||
|
ItemLooksBitterText: 0xf80b,
|
||||||
|
RestoreThePPOfWhichMoveText: 0xf72a,
|
||||||
|
PPRestoredText: 0xf739,
|
||||||
HungOnText: 0x80e99,
|
HungOnText: 0x80e99,
|
||||||
HurtByBurnText: 0x807f8,
|
HurtByBurnText: 0x807f8,
|
||||||
HurtByPoisonText: 0x807e2,
|
HurtByPoisonText: 0x807e2,
|
||||||
|
|||||||
@@ -248,6 +248,8 @@ export type ScriptEffect =
|
|||||||
/** A native message for presentation the port does not have yet; the
|
/** A native message for presentation the port does not have yet; the
|
||||||
* script continues. */
|
* script continues. */
|
||||||
| { kind: "notice"; text: string }
|
| { kind: "notice"; text: string }
|
||||||
|
/** The Pokémon stats screen, closed with a button. */
|
||||||
|
| { kind: "stats"; mon: PartyMon }
|
||||||
/** warpcheck: the player's tile events run again after the script. */
|
/** warpcheck: the player's tile events run again after the script. */
|
||||||
| { kind: "warpcheck" }
|
| { kind: "warpcheck" }
|
||||||
/** LoadMemScript: a script (with a phone caller's script) that runs at
|
/** LoadMemScript: a script (with a phone caller's script) that runs at
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ export class PcSession {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (action === SUBMENU_STATS) {
|
if (action === SUBMENU_STATS) {
|
||||||
yield { kind: "notice", text: "The stats screen is not ported yet." };
|
yield { kind: "stats", mon: structuredClone(mon) };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (action !== SUBMENU_RELEASE) break;
|
if (action !== SUBMENU_RELEASE) break;
|
||||||
@@ -547,7 +547,8 @@ export class PcSession {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (action === SUBMENU_STATS) {
|
if (action === SUBMENU_STATS) {
|
||||||
yield { kind: "notice", text: "The stats screen is not ported yet." };
|
// CalcTempmonStats: stats from stat experience, full HP, no status.
|
||||||
|
yield { kind: "stats", mon: withdrawnMon(this.host.data, mon) };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (action !== SUBMENU_RELEASE) break;
|
if (action !== SUBMENU_RELEASE) break;
|
||||||
|
|||||||
+20
-4
@@ -241,7 +241,7 @@ export interface BattleView {
|
|||||||
/** A choice the battle is waiting for, if any. */
|
/** A choice the battle is waiting for, if any. */
|
||||||
prompt?: Extract<
|
prompt?: Extract<
|
||||||
BattleEffect,
|
BattleEffect,
|
||||||
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" }
|
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" | "move" }
|
||||||
>;
|
>;
|
||||||
/** Yes/No cursor, as on the original boxes. */
|
/** Yes/No cursor, as on the original boxes. */
|
||||||
selected: 0 | 1;
|
selected: 0 | 1;
|
||||||
@@ -1822,6 +1822,7 @@ export class GameWorld {
|
|||||||
this.assets.map(this.mapId).landmark,
|
this.assets.map(this.mapId).landmark,
|
||||||
),
|
),
|
||||||
tutorial: effect.tutorial,
|
tutorial: effect.tutorial,
|
||||||
|
landmark: this.assets.map(this.mapId).landmark,
|
||||||
});
|
});
|
||||||
this.battle = { engine, selected: 1 };
|
this.battle = { engine, selected: 1 };
|
||||||
this.battleRun = engine.run();
|
this.battleRun = engine.run();
|
||||||
@@ -1853,6 +1854,10 @@ export class GameWorld {
|
|||||||
this.townMap = true;
|
this.townMap = true;
|
||||||
this.dialogue = { pages: ["Town Map"], index: 0 };
|
this.dialogue = { pages: ["Town Map"], index: 0 };
|
||||||
return true;
|
return true;
|
||||||
|
case "stats":
|
||||||
|
this.statsScreen = effect.mon;
|
||||||
|
this.dialogue = { pages: ["Stats"], index: 0 };
|
||||||
|
return true;
|
||||||
case "menu":
|
case "menu":
|
||||||
this.dialogue = {
|
this.dialogue = {
|
||||||
pages: effect.pages.length ? effect.pages : [""],
|
pages: effect.pages.length ? effect.pages : [""],
|
||||||
@@ -2248,6 +2253,7 @@ export class GameWorld {
|
|||||||
this.script = undefined;
|
this.script = undefined;
|
||||||
this.memScript ??= script.memScript;
|
this.memScript ??= script.memScript;
|
||||||
this.townMap = false;
|
this.townMap = false;
|
||||||
|
this.statsScreen = undefined;
|
||||||
this.dialogue = undefined;
|
this.dialogue = undefined;
|
||||||
this.emote = undefined;
|
this.emote = undefined;
|
||||||
this.picture = undefined;
|
this.picture = undefined;
|
||||||
@@ -2275,6 +2281,7 @@ export class GameWorld {
|
|||||||
this.spawned = script.rollback.spawned;
|
this.spawned = script.rollback.spawned;
|
||||||
this.script = undefined;
|
this.script = undefined;
|
||||||
this.townMap = false;
|
this.townMap = false;
|
||||||
|
this.statsScreen = undefined;
|
||||||
this.dialogue = undefined;
|
this.dialogue = undefined;
|
||||||
this.emote = undefined;
|
this.emote = undefined;
|
||||||
this.picture = undefined;
|
this.picture = undefined;
|
||||||
@@ -2346,14 +2353,16 @@ export class GameWorld {
|
|||||||
return;
|
return;
|
||||||
this.pumpBattle(name);
|
this.pumpBattle(name);
|
||||||
}
|
}
|
||||||
/** Answer a battle Yes/No (1/0), the move to forget (0-3, 4 cancels)
|
/** Answer a battle Yes/No (1/0), the move to forget (0-3, 4 cancels),
|
||||||
* or a party menu (a party index, or -1 to go back). */
|
* a party menu (a party index, or -1 to go back) or an item's move
|
||||||
|
* choice (0-3, or -1 to go back). */
|
||||||
battleChoose(answer: number) {
|
battleChoose(answer: number) {
|
||||||
const prompt = this.battle?.prompt;
|
const prompt = this.battle?.prompt;
|
||||||
if (
|
if (
|
||||||
prompt?.kind === "yesno" ||
|
prompt?.kind === "yesno" ||
|
||||||
prompt?.kind === "forget" ||
|
prompt?.kind === "forget" ||
|
||||||
prompt?.kind === "party"
|
prompt?.kind === "party" ||
|
||||||
|
prompt?.kind === "move"
|
||||||
)
|
)
|
||||||
this.pumpBattle(answer);
|
this.pumpBattle(answer);
|
||||||
}
|
}
|
||||||
@@ -2372,6 +2381,7 @@ export class GameWorld {
|
|||||||
if (prompt?.kind === "yesno") return this.pumpBattle(0);
|
if (prompt?.kind === "yesno") return this.pumpBattle(0);
|
||||||
if (prompt?.kind === "forget") return this.pumpBattle(4);
|
if (prompt?.kind === "forget") return this.pumpBattle(4);
|
||||||
if (prompt?.kind === "party" && prompt.cancel) return this.pumpBattle(-1);
|
if (prompt?.kind === "party" && prompt.cancel) return this.pumpBattle(-1);
|
||||||
|
if (prompt?.kind === "move") return this.pumpBattle(-1);
|
||||||
if (prompt?.kind === "stats") return this.pumpBattle();
|
if (prompt?.kind === "stats") return this.pumpBattle();
|
||||||
if (!prompt && view.message?.prompt) this.pumpBattle();
|
if (!prompt && view.message?.prompt) this.pumpBattle();
|
||||||
}
|
}
|
||||||
@@ -2695,6 +2705,8 @@ export class GameWorld {
|
|||||||
/** One overworld tick. `held` is the direction the player is holding. */
|
/** One overworld tick. `held` is the direction the player is holding. */
|
||||||
/** Overworld ticks run so far, for presentation such as tile animation. */
|
/** Overworld ticks run so far, for presentation such as tile animation. */
|
||||||
townMap = false;
|
townMap = false;
|
||||||
|
/** The Pokémon a script's stats screen shows (Bill's PC). */
|
||||||
|
statsScreen: PartyMon | undefined;
|
||||||
ticks = 0;
|
ticks = 0;
|
||||||
|
|
||||||
tick(held?: Direction) {
|
tick(held?: Direction) {
|
||||||
@@ -2723,6 +2735,7 @@ export class GameWorld {
|
|||||||
) {
|
) {
|
||||||
if (--dialogue.holdTicks <= 0) {
|
if (--dialogue.holdTicks <= 0) {
|
||||||
this.townMap = false;
|
this.townMap = false;
|
||||||
|
this.statsScreen = undefined;
|
||||||
this.dialogue = undefined;
|
this.dialogue = undefined;
|
||||||
this.pump();
|
this.pump();
|
||||||
}
|
}
|
||||||
@@ -3144,6 +3157,7 @@ export class GameWorld {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.townMap = false;
|
this.townMap = false;
|
||||||
|
this.statsScreen = undefined;
|
||||||
this.dialogue = undefined;
|
this.dialogue = undefined;
|
||||||
this.pump();
|
this.pump();
|
||||||
}
|
}
|
||||||
@@ -3214,6 +3228,7 @@ export class GameWorld {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
this.townMap = false;
|
this.townMap = false;
|
||||||
|
this.statsScreen = undefined;
|
||||||
this.dialogue = undefined;
|
this.dialogue = undefined;
|
||||||
this.pump(answer);
|
this.pump(answer);
|
||||||
}
|
}
|
||||||
@@ -3475,6 +3490,7 @@ export class GameWorld {
|
|||||||
this.tilesWalked = save.tilesWalked;
|
this.tilesWalked = save.tilesWalked;
|
||||||
this.playerId = save.playerId;
|
this.playerId = save.playerId;
|
||||||
this.townMap = false;
|
this.townMap = false;
|
||||||
|
this.statsScreen = undefined;
|
||||||
this.dialogue = undefined;
|
this.dialogue = undefined;
|
||||||
this.emote = undefined;
|
this.emote = undefined;
|
||||||
this.picture = undefined;
|
this.picture = undefined;
|
||||||
|
|||||||
+193
-6
@@ -10,7 +10,7 @@ import { drawTile } from "./game/rendering/pixels";
|
|||||||
import { PokemonData } from "./game/rom/pokemon";
|
import { PokemonData } from "./game/rom/pokemon";
|
||||||
import type { Direction, Palette } from "./game/world/types";
|
import type { Direction, Palette } from "./game/world/types";
|
||||||
import type { Button } from "./input/types";
|
import type { Button } from "./input/types";
|
||||||
import { NICKNAME_PATTERN } from "./game/party/pokemon";
|
import { NICKNAME_PATTERN, maxPP } from "./game/party/pokemon";
|
||||||
import { GameSession } from "./app/session";
|
import { GameSession } from "./app/session";
|
||||||
import { InputController } from "./input/controller";
|
import { InputController } from "./input/controller";
|
||||||
import { bindControls } from "./input/bindings";
|
import { bindControls } from "./input/bindings";
|
||||||
@@ -47,6 +47,7 @@ function showWorld(world: GameWorld) {
|
|||||||
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
|
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
|
||||||
get("game-notice").textContent = world.notice;
|
get("game-notice").textContent = world.notice;
|
||||||
showBattle(world);
|
showBattle(world);
|
||||||
|
showStatsScreen(world);
|
||||||
showFieldMenu(world);
|
showFieldMenu(world);
|
||||||
get("town-map-view").hidden = !world.townMap;
|
get("town-map-view").hidden = !world.townMap;
|
||||||
if (world.townMap)
|
if (world.townMap)
|
||||||
@@ -59,7 +60,11 @@ function showWorld(world: GameWorld) {
|
|||||||
);
|
);
|
||||||
const mart = dialogue?.choice && martChoice(dialogue.choice);
|
const mart = dialogue?.choice && martChoice(dialogue.choice);
|
||||||
get("native-dialogue").hidden =
|
get("native-dialogue").hidden =
|
||||||
!dialogue || pictureWait || Boolean(mart) || world.townMap;
|
!dialogue ||
|
||||||
|
pictureWait ||
|
||||||
|
Boolean(mart) ||
|
||||||
|
world.townMap ||
|
||||||
|
Boolean(world.statsScreen);
|
||||||
get("pokepic-next").hidden = !pictureWait;
|
get("pokepic-next").hidden = !pictureWait;
|
||||||
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
|
get("dialogue-text").textContent = dialogue?.pages[dialogue.index] ?? "";
|
||||||
get("dialogue-page").textContent =
|
get("dialogue-page").textContent =
|
||||||
@@ -384,6 +389,9 @@ let showPack = false;
|
|||||||
let packPocket: "item" | "ball" = "item";
|
let packPocket: "item" | "ball" = "item";
|
||||||
/** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */
|
/** The party slot whose BattleMonMenu (SWITCH/STATS/CANCEL) is open. */
|
||||||
let partyChoice: number | undefined;
|
let partyChoice: number | undefined;
|
||||||
|
/** The stats screen and its page (pink, green, blue): opened from
|
||||||
|
* BattleMonMenu for a party slot, or by a script (Bill's PC) without one. */
|
||||||
|
let statsView: { slot?: number; page: number } | undefined;
|
||||||
let battleCursor = 0;
|
let battleCursor = 0;
|
||||||
let battleSelectionKey = "";
|
let battleSelectionKey = "";
|
||||||
|
|
||||||
@@ -458,6 +466,18 @@ function highlightBattleChoice() {
|
|||||||
function handleBattleInput(button: Button): boolean {
|
function handleBattleInput(button: Button): boolean {
|
||||||
if (handleFieldInput(button)) return true;
|
if (handleFieldInput(button)) return true;
|
||||||
const world = runtime.world;
|
const world = runtime.world;
|
||||||
|
if (statsView && world) {
|
||||||
|
// StatsScreen_JoypadAction: left and right turn pages, B leaves.
|
||||||
|
// BattleMenuPKMN_ReturnFromStats reopens the party menu itself.
|
||||||
|
if (button === "b" && statsView.slot === undefined) world.advanceDialogue();
|
||||||
|
else if (button === "b") statsView = partyChoice = undefined;
|
||||||
|
else if (button === "left" || button === "up")
|
||||||
|
statsView.page = (statsView.page + 2) % 3;
|
||||||
|
else if (button === "right" || button === "down" || button === "a")
|
||||||
|
statsView.page = (statsView.page + 1) % 3;
|
||||||
|
showWorld(world);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!world?.battle) return false;
|
if (!world?.battle) return false;
|
||||||
if (button === "b" && (showMoves || showPack)) {
|
if (button === "b" && (showMoves || showPack)) {
|
||||||
showMoves = showPack = false;
|
showMoves = showPack = false;
|
||||||
@@ -540,11 +560,14 @@ function showBattle(world: GameWorld) {
|
|||||||
get("battle-text").textContent =
|
get("battle-text").textContent =
|
||||||
prompt?.kind === "name"
|
prompt?.kind === "name"
|
||||||
? prompt.title
|
? prompt.title
|
||||||
: prompt?.kind === "party"
|
: prompt?.kind === "party" || prompt?.kind === "move"
|
||||||
? prompt.text
|
? prompt.text
|
||||||
: (view.message?.text ?? "");
|
: (view.message?.text ?? "");
|
||||||
if (prompt?.kind !== "action") showMoves = showPack = false;
|
if (prompt?.kind !== "action") showMoves = showPack = false;
|
||||||
if (prompt?.kind !== "party") partyChoice = undefined;
|
if (prompt?.kind !== "party") {
|
||||||
|
partyChoice = undefined;
|
||||||
|
if (statsView?.slot !== undefined) statsView = undefined;
|
||||||
|
}
|
||||||
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`;
|
const selectionKey = `${prompt?.kind ?? "text"}:${showMoves}:${showPack}:${partyChoice}`;
|
||||||
if (selectionKey !== battleSelectionKey) {
|
if (selectionKey !== battleSelectionKey) {
|
||||||
battleCursor = 0;
|
battleCursor = 0;
|
||||||
@@ -706,8 +729,9 @@ function showBattle(world: GameWorld) {
|
|||||||
partyChoice = undefined;
|
partyChoice = undefined;
|
||||||
runtime.battleChoose(slot);
|
runtime.battleChoose(slot);
|
||||||
}),
|
}),
|
||||||
button("STATS", () => {}, {
|
button("STATS", () => {
|
||||||
disabled: "The stats screen is not ported yet.",
|
statsView = { slot, page: 0 };
|
||||||
|
showWorld(world);
|
||||||
}),
|
}),
|
||||||
button("CANCEL", () => {
|
button("CANCEL", () => {
|
||||||
partyChoice = undefined;
|
partyChoice = undefined;
|
||||||
@@ -737,6 +761,18 @@ function showBattle(world: GameWorld) {
|
|||||||
buttons.push(
|
buttons.push(
|
||||||
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
|
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
|
||||||
);
|
);
|
||||||
|
} else if (prompt?.kind === "move") {
|
||||||
|
prompt.moves.forEach((move, index) => {
|
||||||
|
if (!move) return;
|
||||||
|
buttons.push(
|
||||||
|
button(data.moveName(move), () => runtime.battleChoose(index), {
|
||||||
|
detail: `PP ${prompt.pp[index] & 63}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
buttons.push(
|
||||||
|
button("CANCEL", () => runtime.battleChoose(-1), { wide: true }),
|
||||||
|
);
|
||||||
} else if (prompt?.kind === "forget") {
|
} else if (prompt?.kind === "forget") {
|
||||||
prompt.moves.forEach((move, index) =>
|
prompt.moves.forEach((move, index) =>
|
||||||
buttons.push(
|
buttons.push(
|
||||||
@@ -774,6 +810,143 @@ function showBattle(world: GameWorld) {
|
|||||||
);
|
);
|
||||||
highlightBattleChoice();
|
highlightBattleChoice();
|
||||||
}
|
}
|
||||||
|
/** The stats screen's pages (engine/pokemon/stats_screen.asm), with its
|
||||||
|
* strings, type names and stat labels read from the ROM. */
|
||||||
|
function showStatsScreen(world: GameWorld) {
|
||||||
|
const panel = get("stats-screen");
|
||||||
|
const view = world.battle;
|
||||||
|
// A script's stats screen opens on the first page and closes with it.
|
||||||
|
if (world.statsScreen && !statsView) statsView = { page: 0 };
|
||||||
|
if (!world.statsScreen && statsView?.slot === undefined) statsView = undefined;
|
||||||
|
const slot = statsView?.slot;
|
||||||
|
const own = view?.engine.player.mon;
|
||||||
|
const stored =
|
||||||
|
slot === undefined ? world.statsScreen : world.liveState.party[slot];
|
||||||
|
panel.hidden = !statsView || !stored;
|
||||||
|
if (!statsView || !stored) return;
|
||||||
|
const data = world.assets.pokemon;
|
||||||
|
const mon =
|
||||||
|
view && slot === view.engine.curBattleMon && own
|
||||||
|
? { ...stored, hp: own.hp, status: own.status }
|
||||||
|
: stored;
|
||||||
|
const shiny = PokemonData.shiny(mon.dvs);
|
||||||
|
const text = (label: Parameters<PokemonData["statsString"]>[0]) =>
|
||||||
|
data.statsString(label);
|
||||||
|
drawPic(
|
||||||
|
get("stats-pic"),
|
||||||
|
data.frontPic(mon.species),
|
||||||
|
7,
|
||||||
|
data.palette(mon.species, shiny),
|
||||||
|
);
|
||||||
|
get("stats-pic").setAttribute("aria-label", data.speciesName(mon.species));
|
||||||
|
const gender = data.gender(mon.species, mon.dvs);
|
||||||
|
const line = (...parts: (string | Node)[]) => {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.append(...parts);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
name.textContent = `${mon.nickname}${gender === "male" ? "♂" : gender === "female" ? "♀" : ""}`;
|
||||||
|
get("stats-header").replaceChildren(
|
||||||
|
line(
|
||||||
|
`№.${String(mon.species).padStart(3, "0")} :L${mon.level}${shiny ? " ★" : ""}`,
|
||||||
|
),
|
||||||
|
line(name),
|
||||||
|
line(`/${data.speciesName(mon.species)}`),
|
||||||
|
);
|
||||||
|
const list = (rows: [string, string][], wide = false) => {
|
||||||
|
const dl = document.createElement("dl");
|
||||||
|
if (wide) dl.classList.add("wide");
|
||||||
|
for (const [term, value] of rows) {
|
||||||
|
const dt = document.createElement("dt"),
|
||||||
|
dd = document.createElement("dd");
|
||||||
|
dt.textContent = term;
|
||||||
|
dd.textContent = value;
|
||||||
|
dl.append(dt, dd);
|
||||||
|
}
|
||||||
|
return dl;
|
||||||
|
};
|
||||||
|
const growth = data.baseData(mon.species).growthRate;
|
||||||
|
const page: Node[] = [];
|
||||||
|
if (statsView.page === 0) {
|
||||||
|
const [statusLabel, typeLabel] = text("LoadPinkPage.Status_Type").split(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
const types = [...new Set(data.baseData(mon.species).types)].map((type) =>
|
||||||
|
data.typeName(type),
|
||||||
|
);
|
||||||
|
const next = Math.min(mon.level + 1, 100);
|
||||||
|
page.push(
|
||||||
|
list([
|
||||||
|
["HP", `${mon.hp}/ ${mon.stats[0]}`],
|
||||||
|
[
|
||||||
|
statusLabel,
|
||||||
|
mon.pokerus & 0x0f
|
||||||
|
? text("LoadPinkPage.PkrsStr")
|
||||||
|
: !mon.hp
|
||||||
|
? "FNT"
|
||||||
|
: statusName(mon.status) || text("LoadPinkPage.OK_str").trim(),
|
||||||
|
],
|
||||||
|
[typeLabel, types.join(" / ")],
|
||||||
|
]),
|
||||||
|
list([
|
||||||
|
[text("LoadPinkPage.ExpPointStr"), String(mon.exp)],
|
||||||
|
[
|
||||||
|
text("LoadPinkPage.LevelUpStr"),
|
||||||
|
String(
|
||||||
|
mon.level >= 100 ? 0 : data.expAtLevel(growth, next) - mon.exp,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[text("LoadPinkPage.ToStr"), `:L${next}`],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} else if (statsView.page === 1) {
|
||||||
|
page.push(
|
||||||
|
list(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
text("LoadGreenPage.Item"),
|
||||||
|
mon.item
|
||||||
|
? data.itemName(mon.item)
|
||||||
|
: text("LoadGreenPage.ThreeDashes"),
|
||||||
|
],
|
||||||
|
...mon.moves.flatMap((move, index): [string, string][] =>
|
||||||
|
move
|
||||||
|
? [
|
||||||
|
[
|
||||||
|
index ? "" : text("LoadGreenPage.Move"),
|
||||||
|
`${data.moveName(move)} PP ${mon.pp[index] & 63}/${maxPP(data, move, mon.pp[index] >> 6) & 63}`,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// The OT's gender comes from the caught data unless it is unset.
|
||||||
|
const caught = mon.caughtData & 0xff;
|
||||||
|
const otGender =
|
||||||
|
!caught || caught === 0x7f ? "" : caught & 0x80 ? "♀" : "♂";
|
||||||
|
const labels = data.statNames();
|
||||||
|
page.push(
|
||||||
|
list([
|
||||||
|
[text("IDNoString"), String(mon.otId).padStart(5, "0")],
|
||||||
|
[text("OTString"), `${mon.ot}${otGender}`],
|
||||||
|
]),
|
||||||
|
list(
|
||||||
|
labels.map((label, index): [string, string] => [
|
||||||
|
label,
|
||||||
|
String(mon.stats[[1, 2, 4, 5, 3][index]]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get("stats-page").replaceChildren(...page);
|
||||||
|
panel.dataset.page = String(statsView.page);
|
||||||
|
get("stats-page-number").textContent = `${statsView.page + 1} / 3`;
|
||||||
|
}
|
||||||
function setHp(bar: HTMLElement, hp: number, max: number) {
|
function setHp(bar: HTMLElement, hp: number, max: number) {
|
||||||
const fraction = max ? hp / max : 0;
|
const fraction = max ? hp / max : 0;
|
||||||
bar.style.width = `${Math.round(fraction * 100)}%`;
|
bar.style.width = `${Math.round(fraction * 100)}%`;
|
||||||
@@ -1067,3 +1240,17 @@ get("town-map-close").onclick = () => {
|
|||||||
runtime.advanceDialogue();
|
runtime.advanceDialogue();
|
||||||
canvas.focus({ preventScroll: true });
|
canvas.focus({ preventScroll: true });
|
||||||
};
|
};
|
||||||
|
for (const [id, turn] of [
|
||||||
|
["stats-prev", 2],
|
||||||
|
["stats-next", 1],
|
||||||
|
["stats-close", 0],
|
||||||
|
] as const)
|
||||||
|
get(id).onclick = () => {
|
||||||
|
const world = runtime.world;
|
||||||
|
if (session.state !== "playing" || !statsView || !world) return;
|
||||||
|
if (turn) statsView.page = (statsView.page + turn) % 3;
|
||||||
|
else if (statsView.slot === undefined) runtime.advanceDialogue();
|
||||||
|
else statsView = partyChoice = undefined;
|
||||||
|
showWorld(world);
|
||||||
|
canvas.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
@@ -31,6 +31,7 @@ export function renderShell(root: HTMLElement) {
|
|||||||
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
|
<div id="battle-player" class="battle-player"><canvas id="battle-player-pic" width="48" height="48" role="img"></canvas><div class="battle-hud"><div><strong id="battle-player-name"></strong><span id="battle-player-level"></span></div><div class="hp-row"><span>HP</span><span class="hp-bar"><i id="battle-player-hp"></i></span></div><div class="battle-numbers"><span id="battle-player-status" class="battle-status"></span><span id="battle-player-hp-text"></span></div></div></div>
|
||||||
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
|
<div class="battle-box"><p id="battle-text" aria-live="polite"></p><div id="battle-menu" class="battle-menu"></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="stats-screen" class="stats-screen" hidden aria-label="Pokémon stats"><div class="stats-top"><canvas id="stats-pic" width="56" height="56" role="img"></canvas><div id="stats-header" class="stats-header"></div></div><div id="stats-page" class="stats-page" aria-live="polite"></div><div class="stats-nav"><button id="stats-prev" aria-label="Previous page">◀</button><span id="stats-page-number"></span><button id="stats-next" aria-label="Next page">▶</button><button id="stats-close">Close</button></div></div>
|
||||||
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
|
<div id="town-map-view" class="town-map-view" hidden aria-label="Town Map"><canvas id="town-map-canvas" width="160" height="144" role="img" aria-label="Town Map"></canvas><button id="town-map-close">Close map</button></div>
|
||||||
<div id="field-menu" class="field-menu" hidden aria-label="Pack"><p>KEY ITEMS</p><div id="field-items"></div></div>
|
<div id="field-menu" class="field-menu" hidden aria-label="Pack"><p>KEY ITEMS</p><div id="field-items"></div></div>
|
||||||
<div id="mart" class="mart" hidden aria-label="Poké Mart">
|
<div id="mart" class="mart" hidden aria-label="Poké Mart">
|
||||||
|
|||||||
@@ -444,6 +444,89 @@ dialog h2 {
|
|||||||
.battle-menu .wide {
|
.battle-menu .wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
/* The Pokémon stats screen over the battle: three pages, as the pink,
|
||||||
|
green and blue pages of StatsScreenMain. */
|
||||||
|
.stats-screen {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #f8f8f0;
|
||||||
|
border-top: 8px solid var(--stats-page, #e8a0b8);
|
||||||
|
}
|
||||||
|
.stats-screen[data-page="1"] {
|
||||||
|
--stats-page: #88c888;
|
||||||
|
}
|
||||||
|
.stats-screen[data-page="2"] {
|
||||||
|
--stats-page: #88a8e0;
|
||||||
|
}
|
||||||
|
.stats-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.stats-top canvas {
|
||||||
|
width: min(28vw, 112px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.stats-header p,
|
||||||
|
.stats-page p {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.stats-header strong {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.stats-page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px 16px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--cream);
|
||||||
|
border: 5px double var(--ink);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stats-page dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 2px 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.stats-page dt {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.stats-page dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.stats-page .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.stats-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stats-nav button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--cream-dark);
|
||||||
|
border: 3px double var(--ink);
|
||||||
|
}
|
||||||
|
.stats-nav #stats-close {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.stats-page {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
/* NewGame's screens: a plain background over the world until the
|
/* NewGame's screens: a plain background over the world until the
|
||||||
introduction ends. */
|
introduction ends. */
|
||||||
.intro-screen {
|
.intro-screen {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ describe("trainer AI native scoring", () => {
|
|||||||
move: 2,
|
move: 2,
|
||||||
index: 1,
|
index: 1,
|
||||||
scores: [20, 20, 80, 80],
|
scores: [20, 20, 80, 80],
|
||||||
|
enemyTurn: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -124,8 +125,34 @@ describe("trainer AI native scoring", () => {
|
|||||||
move: 2,
|
move: 2,
|
||||||
index: 1,
|
index: 1,
|
||||||
scores: [20, 20, 80, 80],
|
scores: [20, 20, 80, 80],
|
||||||
|
enemyTurn: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it("reports when AI_Types, AI_Status or AIDamageCalc set hBattleTurn", () => {
|
||||||
|
const run = (
|
||||||
|
weights: number,
|
||||||
|
moves = [move(1), move(2)],
|
||||||
|
playerTypes = [0, 0],
|
||||||
|
) => {
|
||||||
|
const input = fixture(moves, [0, 1, 2, 3, 0, 1, 2, 3]);
|
||||||
|
input.weights = weights;
|
||||||
|
input.player.mon.types = playerTypes;
|
||||||
|
input.damage = () => 0;
|
||||||
|
return chooseTrainerMove(input).enemyTurn;
|
||||||
|
};
|
||||||
|
expect(run(0)).toBe(false);
|
||||||
|
expect(run(4)).toBe(true); // Types
|
||||||
|
expect(run(64)).toBe(true); // Aggressive's damage estimates
|
||||||
|
expect(run(256)).toBe(true); // Status, for damaging moves
|
||||||
|
// A poison move against a Poison type skips the matchup and the write;
|
||||||
|
// a status move with no power checks nothing.
|
||||||
|
const poison = [
|
||||||
|
move(1, C.EFFECT_POISON, 0),
|
||||||
|
move(2, C.EFFECT_ATTACK_UP, 0),
|
||||||
|
];
|
||||||
|
expect(run(256, poison, [C.POISON, C.POISON])).toBe(false);
|
||||||
|
expect(run(256, poison)).toBe(true);
|
||||||
|
});
|
||||||
it("refuses a random source that never produces a candidate", () => {
|
it("refuses a random source that never produces a candidate", () => {
|
||||||
const input = fixture();
|
const input = fixture();
|
||||||
input.random = () => 3;
|
input.random = () => 3;
|
||||||
@@ -140,6 +167,7 @@ describe("trainer AI native scoring", () => {
|
|||||||
move: 3,
|
move: 3,
|
||||||
index: 2,
|
index: 2,
|
||||||
scores: [80, 80, 20, 80],
|
scores: [80, 80, 20, 80],
|
||||||
|
enemyTurn: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("still scores exhausted moves and consumes randomness before Struggle", () => {
|
it("still scores exhausted moves and consumes randomness before Struggle", () => {
|
||||||
@@ -153,6 +181,7 @@ describe("trainer AI native scoring", () => {
|
|||||||
move: 1,
|
move: 1,
|
||||||
index: 0,
|
index: 0,
|
||||||
scores: [78, 80, 80, 80],
|
scores: [78, 80, 80, 80],
|
||||||
|
enemyTurn: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("still selects when the only move is disabled", () => {
|
it("still selects when the only move is disabled", () => {
|
||||||
@@ -163,6 +192,7 @@ describe("trainer AI native scoring", () => {
|
|||||||
move: 1,
|
move: 1,
|
||||||
index: 0,
|
index: 0,
|
||||||
scores: [80, 20, 80, 80],
|
scores: [80, 20, 80, 80],
|
||||||
|
enemyTurn: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("refuses empty move lists instead of the cartridge's infinite loop", () => {
|
it("refuses empty move lists instead of the cartridge's infinite loop", () => {
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ test("battle PKMN switches through the party menu and its submenu, then shares t
|
|||||||
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
|
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
|
||||||
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||||||
// BattleMonMenu: B returns to the party list.
|
// BattleMonMenu: B returns to the party list.
|
||||||
await expect(menu.getByRole("button", { name: "STATS", exact: true })).toBeDisabled();
|
await expect(menu.getByRole("button", { name: "STATS", exact: true })).toBeEnabled();
|
||||||
await page.keyboard.press("KeyZ");
|
await page.keyboard.press("KeyZ");
|
||||||
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||||||
await menu.getByRole("button", { name: "SWITCH", exact: true }).click();
|
await menu.getByRole("button", { name: "SWITCH", exact: true }).click();
|
||||||
@@ -273,3 +273,30 @@ test("battle PACK uses a Potion from the Items pocket on the Pokémon out", asyn
|
|||||||
const { state } = await exportState(page);
|
const { state } = await exportState(page);
|
||||||
expect(state.bag.items).toEqual([]);
|
expect(state.bag.items).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("battle PKMN STATS shows the stats screen's three pages and returns with B", async ({ page }) => {
|
||||||
|
await start(page, "switch");
|
||||||
|
const menu = page.locator("#battle-menu");
|
||||||
|
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PKMN", exact: true }).count()); tick++) {
|
||||||
|
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
|
||||||
|
await page.clock.runFor(1200);
|
||||||
|
}
|
||||||
|
await menu.getByRole("button", { name: "PKMN", exact: true }).click();
|
||||||
|
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||||||
|
await menu.getByRole("button", { name: "STATS", exact: true }).click();
|
||||||
|
const stats = page.locator("#stats-screen");
|
||||||
|
await expect(stats).toBeVisible();
|
||||||
|
await expect(page.locator("#stats-header")).toContainText("№.158");
|
||||||
|
await expect(page.locator("#stats-header")).toContainText("/TOTODILE");
|
||||||
|
await expect(page.locator("#stats-page")).toContainText("WATER");
|
||||||
|
await expect(page.locator("#stats-page")).toContainText("EXP POINTS");
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
await expect(page.locator("#stats-page")).toContainText("SCRATCH");
|
||||||
|
await page.locator("#stats-next").click();
|
||||||
|
await expect(page.locator("#stats-page")).toContainText("SPCL.ATK");
|
||||||
|
await expect(page.locator("#stats-page")).toContainText("OT/");
|
||||||
|
await page.keyboard.press("KeyZ");
|
||||||
|
await expect(stats).toBeHidden();
|
||||||
|
// BattleMenuPKMN_ReturnFromStats: back to the party menu.
|
||||||
|
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
|
||||||
|
});
|
||||||
+36
-3
@@ -66,8 +66,8 @@ export function battleOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Run a battle. Menus take queued answers per prompt kind, then the
|
/** Run a battle. Menus take queued answers per prompt kind, then the
|
||||||
* defaults: the first move, YES, cancelling a move to forget and the
|
* defaults: the first move, YES, cancelling a move to forget, and the
|
||||||
* first party slot. */
|
* first party slot or move slot. */
|
||||||
export function playBattle(
|
export function playBattle(
|
||||||
battle: Battle,
|
battle: Battle,
|
||||||
queued: Partial<Record<BattleEffect["kind"], BattleInput[]>> = {},
|
queued: Partial<Record<BattleEffect["kind"], BattleInput[]>> = {},
|
||||||
@@ -94,10 +94,43 @@ export function playBattle(
|
|||||||
? 1
|
? 1
|
||||||
: effect.kind === "forget"
|
: effect.kind === "forget"
|
||||||
? 4
|
? 4
|
||||||
: effect.kind === "party"
|
: effect.kind === "party" || effect.kind === "move"
|
||||||
? 0
|
? 0
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("Battle did not end.");
|
throw new Error("Battle did not end.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Step a battle by hand: texts are collected up to each prompt, and
|
||||||
|
* `answer` feeds the current prompt's input. */
|
||||||
|
export function battleSteps(battle: Battle) {
|
||||||
|
const run = battle.run();
|
||||||
|
const texts: string[] = [];
|
||||||
|
let next = run.next();
|
||||||
|
const collect = () => {
|
||||||
|
while (
|
||||||
|
!next.done &&
|
||||||
|
(next.value.kind === "text" || next.value.kind === "hud")
|
||||||
|
) {
|
||||||
|
if (next.value.kind === "text")
|
||||||
|
texts.push(next.value.text.replace(/\n/g, " "));
|
||||||
|
next = run.next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect();
|
||||||
|
return {
|
||||||
|
texts,
|
||||||
|
get prompt(): BattleEffect | undefined {
|
||||||
|
return next.done ? undefined : next.value;
|
||||||
|
},
|
||||||
|
get done() {
|
||||||
|
return Boolean(next.done);
|
||||||
|
},
|
||||||
|
answer(input: BattleInput) {
|
||||||
|
next = run.next(input);
|
||||||
|
collect();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+248
-7
@@ -2,11 +2,16 @@ import { beforeAll, describe, expect, it } from "vitest";
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { CrystalAssets } from "../src/game/rom/assets";
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
import { TrainerData } from "../src/game/rom/trainers";
|
import { TrainerData } from "../src/game/rom/trainers";
|
||||||
import { Battle, UnsupportedBattle } from "../src/game/battle/battle";
|
import { Battle } from "../src/game/battle/battle";
|
||||||
import { C } from "../src/game/battle/constants";
|
import { C } from "../src/game/battle/constants";
|
||||||
import type { Bag } from "../src/game/party/bag";
|
import type { Bag } from "../src/game/party/bag";
|
||||||
import type { PartyMon } from "../src/game/party/pokemon";
|
import type { PartyMon } from "../src/game/party/pokemon";
|
||||||
import { battleOptions, partyMon, playBattle } from "./battle-driver";
|
import {
|
||||||
|
battleOptions,
|
||||||
|
battleSteps,
|
||||||
|
partyMon,
|
||||||
|
playBattle,
|
||||||
|
} from "./battle-driver";
|
||||||
|
|
||||||
const romPath =
|
const romPath =
|
||||||
process.env.CRYSTAL_ROM_PATH ??
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
@@ -16,6 +21,18 @@ const PIDGEY = 16,
|
|||||||
TOTODILE = 158;
|
TOTODILE = 158;
|
||||||
const BRIGHTPOWDER = 0x03,
|
const BRIGHTPOWDER = 0x03,
|
||||||
ANTIDOTE = 0x09,
|
ANTIDOTE = 0x09,
|
||||||
|
FULL_RESTORE = 0x0e,
|
||||||
|
MAX_ELIXER = 0x15,
|
||||||
|
X_ACCURACY = 0x21,
|
||||||
|
POKE_DOLL = 0x25,
|
||||||
|
REVIVE = 0x27,
|
||||||
|
GUARD_SPEC = 0x29,
|
||||||
|
DIRE_HIT = 0x2c,
|
||||||
|
ETHER = 0x3f,
|
||||||
|
BITTER_BERRY = 0x53,
|
||||||
|
ENERGYPOWDER = 0x79,
|
||||||
|
REVIVAL_HERB = 0x7c,
|
||||||
|
MYSTERYBERRY = 0x96,
|
||||||
PARLYZ_HEAL = 0x0d,
|
PARLYZ_HEAL = 0x0d,
|
||||||
MAX_POTION = 0x0f,
|
MAX_POTION = 0x0f,
|
||||||
POTION = 0x12,
|
POTION = 0x12,
|
||||||
@@ -196,13 +213,237 @@ describe.skipIf(!existsSync(romPath))("healing items in battle", () => {
|
|||||||
expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]);
|
expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses items whose battle effects are not ported", () => {
|
it("raises a stat with an X item, even past the limit, and uses the turn", () => {
|
||||||
const { battle } = setup(
|
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||||
|
mon.happiness = 70;
|
||||||
|
const { battle, bag } = setup([mon], [{ item: X_ATTACK, quantity: 2 }]);
|
||||||
|
const steps = battleSteps(battle).answer(use(X_ATTACK));
|
||||||
|
expect(steps.texts.slice(2, 5)).toEqual([
|
||||||
|
"CHRIS used the X ATTACK.",
|
||||||
|
"CYNDAQUIL's ATTACK went up!",
|
||||||
|
"Enemy PIDGEY used TACKLE!",
|
||||||
|
]);
|
||||||
|
expect(battle.player.levels[C.ATTACK]).toBe(8);
|
||||||
|
// HappinessChanges' X item row: +1 below 100.
|
||||||
|
expect(mon.happiness).toBe(71);
|
||||||
|
battle.player.levels[C.ATTACK] = 13;
|
||||||
|
steps.answer(use(X_ATTACK));
|
||||||
|
expect(steps.texts).toContain("CYNDAQUIL's ATTACK won't rise anymore!");
|
||||||
|
expect(bag.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[X_ACCURACY, C.SUBSTATUS_X_ACCURACY],
|
||||||
|
[GUARD_SPEC, C.SUBSTATUS_MIST],
|
||||||
|
[DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY],
|
||||||
|
])(
|
||||||
|
"sets item %i's flag once, then keeps the item and the turn",
|
||||||
|
(item, bit) => {
|
||||||
|
const { battle, bag } = setup(
|
||||||
[partyMon(assets, CYNDAQUIL, 10)],
|
[partyMon(assets, CYNDAQUIL, 10)],
|
||||||
[{ item: X_ATTACK, quantity: 1 }],
|
[{ item, quantity: 2 }],
|
||||||
);
|
);
|
||||||
expect(() => playBattle(battle, { action: [use(X_ATTACK)] })).toThrow(
|
const steps = battleSteps(battle).answer(use(item));
|
||||||
UnsupportedBattle,
|
expect(battle.player.sub[4] & (1 << bit)).toBeTruthy();
|
||||||
|
const before = steps.texts.length;
|
||||||
|
steps.answer(use(item));
|
||||||
|
expect(steps.texts.slice(before)).toEqual(["It won't have any effect."]);
|
||||||
|
expect(steps.prompt?.kind).toBe("action");
|
||||||
|
expect(bag.items).toEqual([{ item, quantity: 1 }]);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("ends a wild battle with a Poké Doll, and warns in a trainer battle", () => {
|
||||||
|
const wild = setup(
|
||||||
|
[partyMon(assets, CYNDAQUIL, 10)],
|
||||||
|
[{ item: POKE_DOLL, quantity: 1 }],
|
||||||
|
);
|
||||||
|
const { texts, result } = playBattle(wild.battle, {
|
||||||
|
action: [use(POKE_DOLL)],
|
||||||
|
});
|
||||||
|
expect(texts.at(-1)).toBe("CHRIS used the POKé DOLL.");
|
||||||
|
expect(result).toBe(C.DRAW);
|
||||||
|
expect(wild.bag.items).toEqual([]);
|
||||||
|
|
||||||
|
const bag: Bag = {
|
||||||
|
items: [{ item: POKE_DOLL, quantity: 1 }],
|
||||||
|
balls: [],
|
||||||
|
keyItems: [],
|
||||||
|
};
|
||||||
|
const trainer = new Battle(
|
||||||
|
battleOptions(
|
||||||
|
assets,
|
||||||
|
trainers,
|
||||||
|
[partyMon(assets, CYNDAQUIL, 15)],
|
||||||
|
{ trainer: [22, 1] },
|
||||||
|
{ bag },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const steps = battleSteps(trainer).answer(use(POKE_DOLL));
|
||||||
|
expect(steps.texts.at(-1)).toBe(
|
||||||
|
"OAK: CHRIS! This isn't the time to use that!",
|
||||||
|
);
|
||||||
|
expect(bag.items).toEqual([{ item: POKE_DOLL, quantity: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores HP and status with a Full Restore, or only the status at full HP", () => {
|
||||||
|
const hurt = partyMon(assets, CYNDAQUIL, 10);
|
||||||
|
hurt.hp = 3;
|
||||||
|
hurt.status = PSN;
|
||||||
|
const first = setup([hurt], [{ item: FULL_RESTORE, quantity: 1 }]);
|
||||||
|
const steps = battleSteps(first.battle).answer(use(FULL_RESTORE)).answer(0);
|
||||||
|
expect(steps.texts).toContain(
|
||||||
|
`CYNDAQUIL recovered ${hurt.stats[0] - 3}HP!`,
|
||||||
|
);
|
||||||
|
expect(first.battle.player.mon.status).toBe(0);
|
||||||
|
|
||||||
|
const healthy = partyMon(assets, CYNDAQUIL, 10);
|
||||||
|
healthy.status = PSN;
|
||||||
|
const second = setup([healthy], [{ item: FULL_RESTORE, quantity: 1 }]);
|
||||||
|
expect(
|
||||||
|
playBattle(second.battle, { action: [use(FULL_RESTORE)] }).texts,
|
||||||
|
).toContain("CYNDAQUIL's health returned.");
|
||||||
|
expect(healthy.status).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revives a fainted Pokémon, which shares the experience again", () => {
|
||||||
|
// As in the forced switch test, the Rattata faints the first Pokémon.
|
||||||
|
const first = partyMon(assets, CYNDAQUIL, 5),
|
||||||
|
second = partyMon(assets, TOTODILE, 10);
|
||||||
|
first.hp = 1;
|
||||||
|
const bag: Bag = {
|
||||||
|
items: [{ item: REVIVE, quantity: 1 }],
|
||||||
|
balls: [],
|
||||||
|
keyItems: [],
|
||||||
|
};
|
||||||
|
const battle = new Battle(
|
||||||
|
battleOptions(
|
||||||
|
assets,
|
||||||
|
trainers,
|
||||||
|
[first, second],
|
||||||
|
{ wild: { species: 19, level: 8 } },
|
||||||
|
{ seed: 5, bag },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { texts } = playBattle(battle, {
|
||||||
|
party: [1, 0],
|
||||||
|
action: [{ action: "fight", move: 0 }, use(REVIVE)],
|
||||||
|
});
|
||||||
|
expect(texts).toContain("CYNDAQUIL fainted!");
|
||||||
|
expect(texts).toContain("CYNDAQUIL is revitalized.");
|
||||||
|
// ReviveHalfHP: half the maximum, rounded down.
|
||||||
|
expect(first.hp).toBeLessThanOrEqual(first.stats[0] >> 1);
|
||||||
|
expect(texts.some((text) => text.startsWith("CYNDAQUIL gained"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(bag.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes bitter medicine cost happiness", () => {
|
||||||
|
const fainted = partyMon(assets, TOTODILE, 10),
|
||||||
|
out = partyMon(assets, CYNDAQUIL, 10);
|
||||||
|
fainted.hp = 0;
|
||||||
|
fainted.happiness = 70;
|
||||||
|
const herb = setup([out, fainted], [{ item: REVIVAL_HERB, quantity: 1 }]);
|
||||||
|
const { texts } = playBattle(herb.battle, {
|
||||||
|
action: [use(REVIVAL_HERB)],
|
||||||
|
party: [1],
|
||||||
|
});
|
||||||
|
expect(texts).toContain("It looks bitter…");
|
||||||
|
expect(fainted.hp).toBe(fainted.stats[0]);
|
||||||
|
expect(fainted.happiness).toBe(55); // -15 below 100
|
||||||
|
|
||||||
|
const hurt = partyMon(assets, CYNDAQUIL, 10);
|
||||||
|
hurt.hp = 3;
|
||||||
|
hurt.happiness = 150;
|
||||||
|
const powder = setup([hurt], [{ item: ENERGYPOWDER, quantity: 1 }]);
|
||||||
|
expect(
|
||||||
|
playBattle(powder.battle, { action: [use(ENERGYPOWDER)] }).texts,
|
||||||
|
).toContain("It looks bitter…");
|
||||||
|
expect(hurt.happiness).toBe(145); // -5 below 200
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cures confusion with a Bitter Berry, and does nothing otherwise", () => {
|
||||||
|
const { battle, bag } = setup(
|
||||||
|
[partyMon(assets, CYNDAQUIL, 10)],
|
||||||
|
[{ item: BITTER_BERRY, quantity: 2 }],
|
||||||
|
);
|
||||||
|
const steps = battleSteps(battle);
|
||||||
|
steps.answer(use(BITTER_BERRY));
|
||||||
|
expect(steps.texts).toContain("It won't have any effect.");
|
||||||
|
battle.player.sub[3] |= 1 << C.SUBSTATUS_CONFUSED;
|
||||||
|
steps.answer(use(BITTER_BERRY));
|
||||||
|
expect(steps.texts).toContain("CHRIS used the BITTER BERRY.");
|
||||||
|
expect(steps.texts.some((text) => text.includes("confused"))).toBe(true);
|
||||||
|
expect(battle.player.sub[3] & (1 << C.SUBSTATUS_CONFUSED)).toBe(0);
|
||||||
|
expect(bag.items).toEqual([{ item: BITTER_BERRY, quantity: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores PP with Ethers, Elixers and the MysteryBerry", () => {
|
||||||
|
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||||
|
mon.pp[0] = (1 << 6) | 1; // One PP Up: TACKLE's maximum is 42.
|
||||||
|
mon.pp[1] = 2;
|
||||||
|
const { battle, bag } = setup(
|
||||||
|
[mon],
|
||||||
|
[
|
||||||
|
{ item: ETHER, quantity: 1 },
|
||||||
|
{ item: MYSTERYBERRY, quantity: 1 },
|
||||||
|
{ item: MAX_ELIXER, quantity: 1 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const steps = battleSteps(battle).answer(use(ETHER)).answer(0);
|
||||||
|
expect(steps.prompt).toMatchObject({
|
||||||
|
kind: "move",
|
||||||
|
text: "Restore the PP of\nwhich move?",
|
||||||
|
});
|
||||||
|
// Going back from the move list reopens the party menu.
|
||||||
|
steps.answer(-1);
|
||||||
|
expect(steps.prompt?.kind).toBe("party");
|
||||||
|
steps.answer(0).answer(0);
|
||||||
|
expect(steps.texts).toContain("PP was restored.");
|
||||||
|
expect(mon.pp[0]).toBe((1 << 6) | 11);
|
||||||
|
// BattleRestorePP: the Pokémon out takes the new PP.
|
||||||
|
expect(battle.player.mon.pp[0] & 63).toBe(11);
|
||||||
|
steps.answer(use(MYSTERYBERRY, 0)).answer(0).answer(1);
|
||||||
|
expect(mon.pp[1]).toBe(7);
|
||||||
|
steps.answer(use(MAX_ELIXER, 0)).answer(0);
|
||||||
|
expect(mon.pp[0]).toBe((1 << 6) | 42);
|
||||||
|
expect(mon.pp[1] & 63).toBe(assets.pokemon.move(mon.moves[1]).pp);
|
||||||
|
expect(bag.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reapplies the paralysed enemy's Speed penalty when a heal follows a full turn", () => {
|
||||||
|
// After HandleHealingItems, hBattleTurn holds the enemy, so
|
||||||
|
// CalcPlayerStats penalises the enemy again (the original's behaviour).
|
||||||
|
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||||
|
mon.status = PSN;
|
||||||
|
const { battle } = setup([mon], [{ item: ANTIDOTE, quantity: 1 }], 5);
|
||||||
|
const steps = battleSteps(battle).answer({ action: "fight", move: 1 });
|
||||||
|
battle.enemy.mon.status = PAR;
|
||||||
|
const speed = battle.enemy.mon.stats[C.SPEED];
|
||||||
|
steps.answer(use(ANTIDOTE)).answer(0);
|
||||||
|
expect(steps.texts).toContain("CYNDAQUIL's cured of poison.");
|
||||||
|
expect(battle.enemy.mon.stats[C.SPEED]).toBe(Math.max(1, speed >> 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises happiness more when leveling up where the Pokémon was caught", () => {
|
||||||
|
const level = (landmark: number) => {
|
||||||
|
const mon = partyMon(assets, CYNDAQUIL, 5);
|
||||||
|
mon.exp = 178; // One Pidgey from level 6
|
||||||
|
mon.happiness = 70;
|
||||||
|
mon.caughtData = (mon.caughtData & ~0x7f) | 3;
|
||||||
|
const bag: Bag = { items: [], balls: [], keyItems: [] };
|
||||||
|
const options = battleOptions(
|
||||||
|
assets,
|
||||||
|
trainers,
|
||||||
|
[mon],
|
||||||
|
{ wild: { species: PIDGEY, level: 3 } },
|
||||||
|
{ bag },
|
||||||
|
);
|
||||||
|
playBattle(new Battle({ ...options, landmark }));
|
||||||
|
return mon.happiness;
|
||||||
|
};
|
||||||
|
expect(level(3)).toBe(80); // HAPPINESS_GAINLEVELATHOME: +10
|
||||||
|
expect(level(4)).toBe(75); // HAPPINESS_GAINLEVEL: +5
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -226,6 +226,46 @@ describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
|
|||||||
expect(world.save()).toEqual(saved);
|
expect(world.save()).toEqual(saved);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the stats screen from Bill's PC for party and boxed Pokémon", () => {
|
||||||
|
const world = fixture();
|
||||||
|
const data = world.assets.pokemon;
|
||||||
|
const pidgey = world.state.party[1];
|
||||||
|
pidgey.hp = 1;
|
||||||
|
pidgey.status = 0x08;
|
||||||
|
open(world);
|
||||||
|
pick(world, "BILL's PC");
|
||||||
|
pick(world, /^DEPOSIT/);
|
||||||
|
pick(world, "PIDGEY");
|
||||||
|
const stats = () => {
|
||||||
|
const choice = world.dialogue?.choice;
|
||||||
|
if (choice?.kind !== "menu") throw new Error("Expected a menu");
|
||||||
|
world.choose(choice.items.indexOf("STATS"));
|
||||||
|
for (let tick = 0; tick < 10 && !world.statsScreen; tick++) world.tick();
|
||||||
|
const shown = world.statsScreen;
|
||||||
|
world.advanceDialogue();
|
||||||
|
until(world);
|
||||||
|
return shown;
|
||||||
|
};
|
||||||
|
// A party Pokémon shows its own HP and status.
|
||||||
|
expect(stats()).toMatchObject({ species: PIDGEY, hp: 1, status: 0x08 });
|
||||||
|
expect(world.statsScreen).toBeUndefined();
|
||||||
|
expect(menuItems(world)).toEqual(["DEPOSIT", "STATS", "RELEASE", "CANCEL"]);
|
||||||
|
pick(world, "DEPOSIT");
|
||||||
|
pick(world, "CANCEL");
|
||||||
|
pick(world, /^WITHDRAW/);
|
||||||
|
pick(world, "PIDGEY");
|
||||||
|
// CalcTempmonStats: a boxed Pokémon shows computed stats, full HP and
|
||||||
|
// no status.
|
||||||
|
const boxed = stats()!;
|
||||||
|
expect(boxed.status).toBe(0);
|
||||||
|
expect(boxed.hp).toBe(boxed.stats[0]);
|
||||||
|
expect(boxed.stats).toEqual(
|
||||||
|
calcStats(data, PIDGEY, pidgey.dvs, 4, pidgey.statExp),
|
||||||
|
);
|
||||||
|
expect(menuItems(world)).toEqual(["WITHDRAW", "STATS", "RELEASE", "CANCEL"]);
|
||||||
|
expect(world.notice, world.diagnostic).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses to withdraw into a full party or deposit into a full box", () => {
|
it("refuses to withdraw into a full party or deposit into a full box", () => {
|
||||||
const world = fixture();
|
const world = fixture();
|
||||||
const party = world.state.party;
|
const party = world.state.party;
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import { test, expect, type Page } from "@playwright/test";
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { CrystalAssets } from "../src/game/rom/assets";
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
import { BattleData } from "../src/game/battle/data";
|
import { BattleData } from "../src/game/battle/data";
|
||||||
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
|
import {
|
||||||
|
Battle,
|
||||||
|
applyTypeMatchups,
|
||||||
|
calculateDamage,
|
||||||
|
restoreHealth,
|
||||||
|
restoredPP,
|
||||||
|
sendOutText,
|
||||||
|
withdrawText,
|
||||||
|
} from "../src/game/battle/battle";
|
||||||
import { C } from "../src/game/battle/constants";
|
import { C } from "../src/game/battle/constants";
|
||||||
import { NATIVE_MAPS } from "../src/game/rom/profile";
|
import { NATIVE_MAPS } from "../src/game/rom/profile";
|
||||||
import { calcStat } from "../src/game/party/pokemon";
|
import { calcStat, maxPP } from "../src/game/party/pokemon";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const romPath = resolve(
|
const romPath = resolve(
|
||||||
@@ -410,3 +418,260 @@ test("native stats and experience curves match the original routines", async ({
|
|||||||
expect(pokemon.expAtLevel(growth, level), `experience ${growth}/${level}`).toBe(original.exp);
|
expect(pokemon.expAtLevel(growth, level), `experience ${growth}/${level}`).toBe(original.exp);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("native switch texts, healing, PP, happiness and CalcPlayerStats match the original routines", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.skip(
|
||||||
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
||||||
|
"Requires the local user ROM and the private PRET rebuild.",
|
||||||
|
);
|
||||||
|
const { at, routine } = loadSymbols();
|
||||||
|
const bytes = new Uint8Array(readFileSync(romPath));
|
||||||
|
const assets = new CrystalAssets(bytes);
|
||||||
|
const pokemon = assets.pokemon;
|
||||||
|
let seed = 0xbeef;
|
||||||
|
const next = (limit: number) => {
|
||||||
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||||
|
return (seed >>> 8) % limit;
|
||||||
|
};
|
||||||
|
const maxHp = () => (next(4) === 0 ? 4 + next(60) : 4 + next(996));
|
||||||
|
const cheerCases = Array.from({ length: 300 }, () => {
|
||||||
|
const max = maxHp();
|
||||||
|
return { max, hp: next(max + 1), atSwitch: next(max + 1) };
|
||||||
|
});
|
||||||
|
const healthCases = Array.from({ length: 300 }, () => {
|
||||||
|
const max = 1 + next(999);
|
||||||
|
const amounts = [10, 20, 50, 60, 80, 100, 200, 999, next(0x10000)];
|
||||||
|
return { max, hp: next(max + 1), amount: amounts[next(amounts.length)] };
|
||||||
|
});
|
||||||
|
const ppItems = [C.MAX_ELIXER, 0x3f, C.MAX_ETHER, C.ELIXER, C.MYSTERYBERRY];
|
||||||
|
const ppCases = Array.from({ length: 200 }, () => ({
|
||||||
|
move: 1 + next(251),
|
||||||
|
pp: (next(4) << 6) | next(64),
|
||||||
|
item: ppItems[next(ppItems.length)],
|
||||||
|
}));
|
||||||
|
const happinessCases = Array.from({ length: 300 }, () => ({
|
||||||
|
happiness: next(256),
|
||||||
|
action: 1 + next(19),
|
||||||
|
cursor: next(7),
|
||||||
|
mon: next(6),
|
||||||
|
egg: next(8) === 0,
|
||||||
|
}));
|
||||||
|
const statCases = Array.from({ length: 200 }, () => ({
|
||||||
|
levels: Array.from({ length: 5 }, () => 1 + next(13)),
|
||||||
|
stats: Array.from({ length: 5 }, () => 1 + next(999)),
|
||||||
|
status: [0, 1 << 6, 1 << 4, 1 << 3][next(4)],
|
||||||
|
enemyStatus: [0, 1 << 6, 1 << 4][next(3)],
|
||||||
|
enemyStats: Array.from({ length: 5 }, () => 1 + next(999)),
|
||||||
|
turn: next(2),
|
||||||
|
}));
|
||||||
|
await openReference(page);
|
||||||
|
const skipToTextbox = routine("SendOutMonText.skip_to_textbox");
|
||||||
|
const addresses = {
|
||||||
|
// The ROM copy loaded in the page gets `ret` instead of SendOutMonText's
|
||||||
|
// final `jp BattleTextbox`, so the routine returns its text in HL.
|
||||||
|
patch: skipToTextbox.bank * 0x4000 + skipToTextbox.address - 0x4000,
|
||||||
|
sendOut: routine("SendOutMonText"),
|
||||||
|
withdraw: { ...routine("WithdrawMonText.WithdrawMonText"), address: at("WithdrawMonText.WithdrawMonText") + 5 },
|
||||||
|
restoreHealth: routine("RestoreHealth"),
|
||||||
|
restorePP: routine("RestorePP"),
|
||||||
|
changeHappiness: routine("ChangeHappiness"),
|
||||||
|
calcPlayerStats: routine("CalcPlayerStats"),
|
||||||
|
multiplicand: at("hMultiplicand"),
|
||||||
|
enemyHP: at("wEnemyMonHP"),
|
||||||
|
enemyMaxHP: at("wEnemyMonMaxHP"),
|
||||||
|
hpAtSwitch: at("wEnemyHPAtTimeOfPlayerSwitch"),
|
||||||
|
link: at("wLinkMode"),
|
||||||
|
curPartyMon: at("wCurPartyMon"),
|
||||||
|
partyMon1: at("wPartyMon1"),
|
||||||
|
partySpecies: at("wPartySpecies"),
|
||||||
|
partyCount: at("wPartyCount"),
|
||||||
|
menuCursorY: at("wMenuCursorY"),
|
||||||
|
hpBuffer1: at("wHPBuffer1"),
|
||||||
|
ppItem: at("wTempRestorePPItem"),
|
||||||
|
monType: at("wMonType"),
|
||||||
|
battleMode: at("wBattleMode"),
|
||||||
|
partyMenuCursor: at("wPartyMenuCursor"),
|
||||||
|
battleMonHappiness: at("wBattleMonHappiness"),
|
||||||
|
playerLevels: at("wPlayerAtkLevel"),
|
||||||
|
playerStats: at("wPlayerStats"),
|
||||||
|
battleMonStats: at("wBattleMonAttack"),
|
||||||
|
battleMonStatus: at("wBattleMonStatus"),
|
||||||
|
enemyStatus: at("wEnemyMonStatus"),
|
||||||
|
enemyStats: at("wEnemyMonAttack"),
|
||||||
|
johto: at("wJohtoBadges"),
|
||||||
|
turn: at("hBattleTurn"),
|
||||||
|
text: {
|
||||||
|
GoMonText: at("GoMonText"),
|
||||||
|
DoItMonText: at("DoItMonText"),
|
||||||
|
GoForItMonText: at("GoForItMonText"),
|
||||||
|
YourFoesWeakGetmMonText: at("YourFoesWeakGetmMonText"),
|
||||||
|
ThatsEnoughComeBackText: at("ThatsEnoughComeBackText"),
|
||||||
|
ComeBackText: at("ComeBackText"),
|
||||||
|
OKComeBackText: at("OKComeBackText"),
|
||||||
|
GoodComeBackText: at("GoodComeBackText"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const results = await page.evaluate(
|
||||||
|
async ({ addresses, cheerCases, healthCases, ppCases, happinessCases, statCases }) => {
|
||||||
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||||
|
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
||||||
|
const rom = new Uint8Array(await file.arrayBuffer());
|
||||||
|
if (rom[addresses.patch] !== 0xc3) throw new Error("Unexpected SendOutMonText ending.");
|
||||||
|
rom[addresses.patch] = 0xc9;
|
||||||
|
const reference = new BinjgbEmulator();
|
||||||
|
await reference.load(rom, document.createElement("canvas"));
|
||||||
|
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
||||||
|
const word = (address: number, value: number) => {
|
||||||
|
write(address, value >> 8);
|
||||||
|
write(address + 1, value);
|
||||||
|
};
|
||||||
|
const readWord = (address: number) => (reference.readMemory(address) << 8) | reference.readMemory(address + 1);
|
||||||
|
// Trampoline: DI; SP=$c7fe; switch banks; set BC and DE; CALL; store
|
||||||
|
// HL at $c6fd; write a sentinel; loop.
|
||||||
|
const call = (target: { bank: number; address: number }, bc = 0, de = 0) => {
|
||||||
|
const program = [
|
||||||
|
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
||||||
|
0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8,
|
||||||
|
0xcd, target.address & 0xff, target.address >> 8,
|
||||||
|
0x7d, 0xea, 0xfd, 0xc6, 0x7c, 0xea, 0xfe, 0xc6,
|
||||||
|
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
||||||
|
];
|
||||||
|
write(0xc6ff, 0);
|
||||||
|
program.forEach((value, index) => write(0xc000 + index, value));
|
||||||
|
reference.setProgramCounter(0xc000);
|
||||||
|
reference.advanceFrames(2);
|
||||||
|
if (reference.readMemory(0xc6ff) !== 0x42) throw new Error(`Routine at ${target.address.toString(16)} did not return.`);
|
||||||
|
return reference.readMemory(0xc6fd) | (reference.readMemory(0xc6fe) << 8);
|
||||||
|
};
|
||||||
|
write(0xff70, 1);
|
||||||
|
write(addresses.link, 0);
|
||||||
|
write(addresses.johto, 0);
|
||||||
|
const textName = (hl: number) =>
|
||||||
|
Object.entries(addresses.text).find(([, address]) => address === hl)?.[0] ?? `0x${hl.toString(16)}`;
|
||||||
|
const cheers = cheerCases.map((input) => {
|
||||||
|
word(addresses.enemyHP, input.hp);
|
||||||
|
word(addresses.enemyMaxHP, input.max);
|
||||||
|
const sendOut = textName(call(addresses.sendOut));
|
||||||
|
word(addresses.hpAtSwitch, input.atSwitch);
|
||||||
|
write(addresses.multiplicand, 0);
|
||||||
|
const withdraw = textName(call(addresses.withdraw));
|
||||||
|
return { sendOut, withdraw };
|
||||||
|
});
|
||||||
|
const mon = addresses.partyMon1;
|
||||||
|
write(addresses.curPartyMon, 0);
|
||||||
|
const health = healthCases.map((input) => {
|
||||||
|
word(mon + 34, input.hp);
|
||||||
|
word(mon + 36, input.max);
|
||||||
|
// ItemRestoreHP's IsMonFainted loads the maximum into wHPBuffer1
|
||||||
|
// (low byte first), which a full restore copies.
|
||||||
|
write(addresses.hpBuffer1, input.max);
|
||||||
|
write(addresses.hpBuffer1 + 1, input.max >> 8);
|
||||||
|
call(addresses.restoreHealth, 0, input.amount);
|
||||||
|
return readWord(mon + 34);
|
||||||
|
});
|
||||||
|
const pp = ppCases.map((input) => {
|
||||||
|
write(mon + 2, input.move);
|
||||||
|
write(mon + 23, input.pp);
|
||||||
|
write(addresses.menuCursorY, 0);
|
||||||
|
write(addresses.ppItem, input.item);
|
||||||
|
call(addresses.restorePP);
|
||||||
|
return reference.readMemory(mon + 23);
|
||||||
|
});
|
||||||
|
write(addresses.partyCount, 6);
|
||||||
|
write(addresses.battleMode, 1);
|
||||||
|
const happiness = happinessCases.map((input) => {
|
||||||
|
for (let slot = 0; slot < 6; slot++) {
|
||||||
|
write(addresses.partySpecies + slot, input.egg && slot === input.mon ? 0xfd : 155);
|
||||||
|
write(mon + slot * 48 + 27, input.happiness);
|
||||||
|
}
|
||||||
|
write(addresses.curPartyMon, input.mon);
|
||||||
|
write(addresses.partyMenuCursor, input.cursor);
|
||||||
|
write(addresses.battleMonHappiness, 0x55);
|
||||||
|
call(addresses.changeHappiness, input.action);
|
||||||
|
return {
|
||||||
|
party: reference.readMemory(mon + input.mon * 48 + 27),
|
||||||
|
battle: reference.readMemory(addresses.battleMonHappiness),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const stats = statCases.map((input) => {
|
||||||
|
input.levels.forEach((level, i) => write(addresses.playerLevels + i, level));
|
||||||
|
input.stats.forEach((value, i) => word(addresses.playerStats + i * 2, value));
|
||||||
|
input.enemyStats.forEach((value, i) => word(addresses.enemyStats + i * 2, value));
|
||||||
|
write(addresses.battleMonStatus, input.status);
|
||||||
|
write(addresses.enemyStatus, input.enemyStatus);
|
||||||
|
write(addresses.turn, input.turn);
|
||||||
|
call(addresses.calcPlayerStats);
|
||||||
|
return {
|
||||||
|
player: Array.from({ length: 5 }, (_, i) => readWord(addresses.battleMonStats + i * 2)),
|
||||||
|
enemy: Array.from({ length: 5 }, (_, i) => readWord(addresses.enemyStats + i * 2)),
|
||||||
|
turn: reference.readMemory(addresses.turn),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
reference.dispose();
|
||||||
|
return { cheers, health, pp, happiness, stats };
|
||||||
|
},
|
||||||
|
{ addresses, cheerCases, healthCases, ppCases, happinessCases, statCases },
|
||||||
|
);
|
||||||
|
cheerCases.forEach((input, index) => {
|
||||||
|
expect(
|
||||||
|
{ sendOut: sendOutText(input.hp, input.max), withdraw: withdrawText(input.atSwitch, input.hp, input.max) },
|
||||||
|
`cheer case ${index}: ${JSON.stringify(input)}`,
|
||||||
|
).toEqual(results.cheers[index]);
|
||||||
|
});
|
||||||
|
healthCases.forEach((input, index) =>
|
||||||
|
expect(restoreHealth(input.hp, input.max, input.amount), `health case ${index}: ${JSON.stringify(input)}`).toBe(
|
||||||
|
results.health[index],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ppCases.forEach((input, index) => {
|
||||||
|
const max = maxPP(pokemon, input.move, input.pp >> 6) & 63;
|
||||||
|
expect(restoredPP(input.pp, max, input.item) ?? input.pp, `PP case ${index}: ${JSON.stringify(input)}`).toBe(
|
||||||
|
results.pp[index],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// ChangeHappiness and CalcPlayerStats run on a battle's own state.
|
||||||
|
const battle = new Battle({
|
||||||
|
data: pokemon,
|
||||||
|
party: [],
|
||||||
|
pokedex: { seen: new Set(), caught: new Set() },
|
||||||
|
playerId: 1,
|
||||||
|
playerName: "CHRIS",
|
||||||
|
timeOfDay: 1,
|
||||||
|
random: () => 0,
|
||||||
|
text: () => ({}) as never,
|
||||||
|
});
|
||||||
|
const internals = battle as unknown as {
|
||||||
|
options: { party: { species: number; happiness: number }[] };
|
||||||
|
partyMenuCursor: number;
|
||||||
|
turn: number;
|
||||||
|
changeHappiness(index: number, action: number): void;
|
||||||
|
calcPlayerStats(): void;
|
||||||
|
};
|
||||||
|
happinessCases.forEach((input, index) => {
|
||||||
|
internals.options.party = Array.from({ length: 6 }, (_, slot) => ({
|
||||||
|
species: input.egg && slot === input.mon ? C.EGG : 155,
|
||||||
|
happiness: input.happiness,
|
||||||
|
}));
|
||||||
|
internals.partyMenuCursor = input.cursor;
|
||||||
|
battle.player.mon = { happiness: 0x55 } as never;
|
||||||
|
internals.changeHappiness(input.mon, input.action);
|
||||||
|
expect(
|
||||||
|
{ party: internals.options.party[input.mon].happiness, battle: battle.player.mon.happiness },
|
||||||
|
`happiness case ${index}: ${JSON.stringify(input)}`,
|
||||||
|
).toEqual(results.happiness[index]);
|
||||||
|
});
|
||||||
|
statCases.forEach((input, index) => {
|
||||||
|
battle.player.levels = [...input.levels, 7, 7];
|
||||||
|
battle.player.stats = [...input.stats];
|
||||||
|
battle.player.mon = { stats: [0, 0, 0, 0, 0], status: input.status } as never;
|
||||||
|
battle.enemy.mon = { stats: [...input.enemyStats], status: input.enemyStatus } as never;
|
||||||
|
internals.turn = input.turn;
|
||||||
|
internals.calcPlayerStats();
|
||||||
|
expect(
|
||||||
|
{ player: battle.player.mon.stats, enemy: battle.enemy.mon.stats, turn: internals.turn },
|
||||||
|
`stat case ${index}: ${JSON.stringify(input)}`,
|
||||||
|
).toEqual(results.stats[index]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user