Compare commits

...

24 Commits

Author SHA1 Message Date
Bertie690
1dbb7ac158
Merge b57788b908 into f42237d415 2025-08-14 13:28:35 -04:00
Bertie690
f42237d415
[Refactor] Removed map(x => x) (#6256)
* Enforced a few usages of `toCamelCase`

* Removed `map(x => x)`

* Removed more maps and sufff

* Update test/mystery-encounter/encounters/weird-dream-encounter.test.ts

* Update game-data.ts types to work
2025-08-14 10:25:44 -07:00
fabske0
b44f0a4176
[Refactor] Remove bgm param from arena constructor (#6254) 2025-08-14 16:52:56 +00:00
Sirz Benjie
076ef81691
[Bug] [UI/UX] [Beta] Fix icons not showing in save slot selection (#6262)
Fix icons not showing in save slot selection
2025-08-13 20:49:46 -05:00
fabske0
23271901cf
[Docs] Add locale key naming info to localization.md (#6260) 2025-08-14 01:12:00 +00:00
Inês Simões
1517e0512e
[UI/UX] [Feature] Save Management Tool (Rename/Delete Saves) (#5978)
* Implement Name Run Feat
Modified load session ui component, adding a submenu when selecting a 3
slot. This menu has 4 options:
Load Game -> Behaves as before, allowing the player to continue
progress from the last saved state in the slot.

Rename Run -> Overlays a rename form, allowing the player to type a
name for the run, checking for string validity, with the option to
cancel or confirm (Rename).

Delete Run -> Prompts user confirmation to delete save data, removing
the current save slot from the users save data.

Cancel -> Hides menu overlay.

Modified game data to implement a function to accept and store
runNameText to the users data.

Modified run info ui component, to display the chosen name when
viewing run information.

Example: When loading the game, the user can choose the Load Game
menu option, then select a save slot, prompting the menu, then choose
"Rename Run" and type the name "Monotype Water Run" then confirm,
thus being able to better organize their save files.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Implement Rename Input Design and Tests for Name Run Feat
Created a test to verify Name Run Feature behaviour in the
backend (rename_run.test.ts), checking possible errors and
 expected behaviours.

Created a UiHandler RenameRunFormUiHandler
(rename-run-ui-handler.ts), creating a frontend input
overlay for the Name Run Feature.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Fixed formating and best practices issues:
Rewrote renameSession to be more inline with other
API call funtions, removed debugging comments and
whitespaces.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Minor Sanitization for aesthetics
Deleting the input when closing the overlay for
aesthetics purpose

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Fixed minor rebase alterations.

Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Inês Simões ines.p.simoes@tecnico.ulisboa.pt

* Implemented Default Name Logic
Altered logic in save-slot-select-ui-handler.ts to
support default naming of runs based on the run
game mode with decideFallback function.

In game-data.ts, to prevent inconsistent naming,
added check for unfilled input, ignoring empty
rename requests.

Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Inês Simões ines.p.simoes@tecnico.ulisboa.pt

* Replace fallback name logic: use first active challenge instead
of game mode

Previously used game mode as the fallback name, updated to use the
first active challenge instead (e.g. Monogen or Mono Type), which
better reflects the run's theme.
Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Rebasing and conflict resolution

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Lint fix

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Minor compile fix

* Dependency resolved

* Format name respected

* Add all active challenges to default challenge session name if possible

If more than 3 challenges are active, only the first 3 are added
to the name (to prevent the text going off-screen)
and then "..." is appended to the end to indicate
there were more challenges active than the ones listed

* Allow deleting malformed sessions

---------

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Wlowscha <54003515+Wlowscha@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-08-13 20:08:12 -05:00
Bertie690
b57788b908 Merge remote-tracking branch 'upstream/beta' into todo-test-enable 2025-08-11 10:47:11 -04:00
Bertie690
412feb07ca Moved function out of file to avoid circ imports 2025-08-06 21:05:39 -04:00
Bertie690
db718f683b fixed buggg 2025-08-06 20:59:41 -04:00
Bertie690
56ff84aa9d Ran Biome 2025-08-06 20:35:17 -04:00
Bertie690
cfbce175db Fixed instruct interaction with Encore 2025-08-06 20:33:31 -04:00
Bertie690
7d8f53e64e added more test 2025-08-06 18:57:47 -04:00
Bertie690
6cfb26c528 Moved methods around to be in sequential order 2025-08-06 18:43:24 -04:00
Bertie690
b2d10b7006 Moved reflection check out of MEP and into ability side logic 2025-08-06 18:29:12 -04:00
Bertie690
cbc6f6b89e jjjjjjhuoui 2025-08-05 23:59:31 -04:00
Bertie690
510d683cc1 fixed test setting incorrect pp values 2025-08-05 23:05:56 -04:00
Bertie690
502d6d9e12 xxxxxx 2025-08-05 22:33:19 -04:00
Bertie690
8b4951ed63 fixed tpoy 2025-08-05 22:05:41 -04:00
Bertie690
ba6885d289 ddd 2025-08-05 22:02:30 -04:00
Bertie690
ce8491f4a5 rrr 2025-08-05 21:41:35 -04:00
Bertie690
5653ec83be Fixed minor bugs in the files
encore was basing targets off of the PRIOR move, not the encored one. mb
2025-08-05 21:19:22 -04:00
Bertie690
93c3422f91 Fixed syntax errors 2025-08-05 20:56:13 -04:00
Bertie690
664bf555bd Fixed Encore interactions with Magic Bounce, Magic Coat, etc etc 2025-08-05 20:52:18 -04:00
Bertie690
69157f07bc Condensed magic bounce tests 2025-08-05 19:32:53 -04:00
33 changed files with 1661 additions and 1348 deletions

View File

@ -90,9 +90,13 @@ If this feature requires new text, the text should be integrated into the code w
- For any feature pulled from the mainline Pokémon games (e.g. a Move or Ability implementation), it's best practice to include a source link for any added text.
[Poké Corpus](https://abcboy101.github.io/poke-corpus/) is a great resource for finding text from the mainline games; otherwise, a video/picture showing the text being displayed should suffice.
- You should also [notify the current Head of Translation](#notifying-translation) to ensure a fast response.
3. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes).
4. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`.
5. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment.
3. Your locales should use the following format:
- File names should be in `kebab-case`. Example: `trainer-names.json`
- Key names should be in `camelCase`. Example: `aceTrainer`
- If you make use of i18next's inbuilt [context support](https://www.i18next.com/translation-function/context), you need to use `snake_case` for the context key. Example: `aceTrainer_male`
4. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes).
5. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`.
6. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment.
[^2]: For those wondering, the reason for choosing English specifically is due to it being the master language set in Pontoon (the program used by the Translation Team to perform locale updates).
If a key is present in any language _except_ the master language, it won't appear anywhere else in the translation tool, rendering missing English keys quite a hassle.

View File

@ -104,6 +104,7 @@ import {
getLuckString,
getLuckTextTint,
getPartyLuckValue,
type ModifierType,
PokemonHeldItemModifierType,
} from "#modifiers/modifier-type";
import { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
@ -1203,7 +1204,9 @@ export class BattleScene extends SceneBase {
this.updateScoreText();
this.scoreText.setVisible(false);
[this.luckLabelText, this.luckText].map(t => t.setVisible(false));
[this.luckLabelText, this.luckText].forEach(t => {
t.setVisible(false);
});
this.newArena(Overrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN);
@ -1237,8 +1240,7 @@ export class BattleScene extends SceneBase {
Object.values(mp)
.flat()
.map(mt => mt.modifierType)
.filter(mt => "localize" in mt)
.map(lpb => lpb as unknown as Localizable),
.filter((mt): mt is ModifierType & Localizable => "localize" in mt && typeof mt.localize === "function"),
),
];
for (const item of localizable) {
@ -1513,8 +1515,8 @@ export class BattleScene extends SceneBase {
return this.currentBattle;
}
newArena(biome: BiomeId, playerFaints?: number): Arena {
this.arena = new Arena(biome, BiomeId[biome].toLowerCase(), playerFaints);
newArena(biome: BiomeId, playerFaints = 0): Arena {
this.arena = new Arena(biome, playerFaints);
this.eventTarget.dispatchEvent(new NewArenaEvent());
this.arenaBg.pipelineData = {
@ -2711,7 +2713,9 @@ export class BattleScene extends SceneBase {
}
}
this.party.map(p => p.updateInfo(instant));
this.party.forEach(p => {
p.updateInfo(instant);
});
} else {
const args = [this];
if (modifier.shouldApply(...args)) {

View File

@ -1,6 +1,8 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { BattleScene } from "#app/battle-scene";
import type { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import type { MoveEndPhase } from "#phases/move-end-phase";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
@ -51,7 +53,8 @@ import { BerryModifierType } from "#modifiers/modifier-type";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves";
import type { Move } from "#moves/move";
import type { PokemonMove } from "#moves/pokemon-move";
import { getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import type { StatStageChangePhase } from "#phases/stat-stage-change-phase";
import type {
AbAttrCondition,
@ -74,6 +77,7 @@ import {
randSeedItem,
toDmgValue,
} from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
export class Ability implements Localizable {
@ -109,13 +113,9 @@ export class Ability implements Localizable {
}
localize(): void {
const i18nKey = AbilityId[this.id]
.split("_")
.filter(f => f)
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("") as string;
const i18nKey = toCamelCase(AbilityId[this.id]);
this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`) as string}${this.nameAppend}` : "";
this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`)}${this.nameAppend}` : "";
this.description = this.id ? (i18next.t(`ability:${i18nKey}.description`) as string) : "";
}
@ -5789,12 +5789,21 @@ export class InfiltratorAbAttr extends AbAttr {
/**
* Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Magic_Bounce_(ability) | Magic Bounce}.
* Allows the source to bounce back {@linkcode MoveFlags.REFLECTABLE | Reflectable}
* moves as if the user had used {@linkcode MoveId.MAGIC_COAT | Magic Coat}.
* @sealed
* @todo Make reflection a part of this ability's effects
* moves as if the user had used {@linkcode MoveId.MAGIC_COAT | Magic Coat}.
* The calling {@linkcode MoveEffectPhase} will "skip" targets with a reflection effect active,
* showing the flyout and queueing the reaction during the move's {@linkcode MoveEndPhase}.
*/
export class ReflectStatusMoveAbAttr extends AbAttr {
private declare readonly _: never;
export class ReflectStatusMoveAbAttr extends PreDefendAbAttr {
override apply({ pokemon, opponent, move }: AugmentMoveInteractionAbAttrParams): void {
const newTargets = move.isMultiTarget() ? getMoveTargets(pokemon, move.id).targets : [opponent.getBattlerIndex()];
globalScene.phaseManager.unshiftNew(
"MovePhase",
pokemon,
newTargets,
new PokemonMove(move.id),
MoveUseMode.REFLECTED,
);
}
}
// TODO: Make these ability attributes be flags instead of dummy attributes
@ -7251,10 +7260,7 @@ export function initAbilities() {
.attr(PostIntimidateStatStageChangeAbAttr, [ Stat.SPD ], 1),
new Ability(AbilityId.MAGIC_BOUNCE, 5)
.attr(ReflectStatusMoveAbAttr)
.ignorable()
// Interactions with stomping tantrum, instruct, encore, and probably other moves that
// rely on move history
.edgeCase(),
.ignorable(),
new Ability(AbilityId.SAP_SIPPER, 5)
.attr(TypeImmunityStatStageChangeAbAttr, PokemonType.GRASS, Stat.ATK, 1)
.ignorable(),
@ -7288,7 +7294,7 @@ export function initAbilities() {
.attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonTeravolt", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }))
.attr(MoveAbilityBypassAbAttr),
new Ability(AbilityId.AROMA_VEIL, 6)
.attr(UserFieldBattlerTagImmunityAbAttr, [ BattlerTagType.INFATUATED, BattlerTagType.TAUNT, BattlerTagType.DISABLED, BattlerTagType.TORMENT, BattlerTagType.HEAL_BLOCK ])
.attr(UserFieldBattlerTagImmunityAbAttr, [ BattlerTagType.INFATUATED, BattlerTagType.TAUNT, BattlerTagType.DISABLED, BattlerTagType.TORMENT, BattlerTagType.HEAL_BLOCK, BattlerTagType.ENCORE ])
.ignorable(),
new Ability(AbilityId.FLOWER_VEIL, 6)
.attr(ConditionalUserFieldStatusEffectImmunityAbAttr, (target: Pokemon, source: Pokemon | null) => {
@ -7352,7 +7358,7 @@ export function initAbilities() {
new Ability(AbilityId.GOOEY, 6)
.attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.hasFlag(MoveFlags.MAKES_CONTACT), Stat.SPD, -1, false),
new Ability(AbilityId.AERILATE, 6)
.attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL),
.attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL),
new Ability(AbilityId.PARENTAL_BOND, 6)
.attr(AddSecondStrikeAbAttr, 0.25),
new Ability(AbilityId.DARK_AURA, 6)

View File

@ -1866,17 +1866,16 @@ interface PokemonPrevolutions {
export const pokemonPrevolutions: PokemonPrevolutions = {};
export function initPokemonPrevolutions(): void {
const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ].map(sfk => sfk as string);
const prevolutionKeys = Object.keys(pokemonEvolutions);
prevolutionKeys.forEach(pk => {
const evolutions = pokemonEvolutions[pk];
// TODO: Why do we have empty strings in our array?
const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ];
for (const [pk, evolutions] of Object.entries(pokemonEvolutions)) {
for (const ev of evolutions) {
if (ev.evoFormKey && megaFormKeys.indexOf(ev.evoFormKey) > -1) {
continue;
}
pokemonPrevolutions[ev.speciesId] = Number.parseInt(pk) as SpeciesId;
}
});
}
}

View File

@ -28,6 +28,8 @@ import type { Pokemon } from "#field/pokemon";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { invalidEncoreMoves } from "#moves/invalid-moves";
import type { Move } from "#moves/move";
import { getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import type { MovePhase } from "#phases/move-phase";
import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase";
@ -174,6 +176,7 @@ export class BattlerTag implements BaseBattlerTag {
return "";
}
// TODO: Make this a getter
isSourceLinked(): boolean {
return false;
}
@ -1238,13 +1241,16 @@ export class FrenzyTag extends SerializableBattlerTag {
*/
export class EncoreTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.ENCORE;
/** The ID of the move the user is locked into using */
/** The {@linkcode MoveID} the tag holder is locked into */
public moveId: MoveId;
constructor(sourceId: number) {
// Encore ends at the end of the 3rd turn it procs.
// If used on turn X when faster, it ends at the end of turn X+2.
// If used on turn X when slower, it ends at the end of turn X+3.
super(
BattlerTagType.ENCORE,
[BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE],
[BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.TURN_END],
3,
MoveId.ENCORE,
sourceId,
@ -1266,6 +1272,14 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
return false;
}
if (!pokemon.getMoveset().some(m => m.moveId === lastMove.move && !m.isOutOfPp())) {
return false;
}
if (pokemon.getTag(BattlerTagType.SHELL_TRAP)) {
return false;
}
this.moveId = lastMove.move;
return true;
@ -1278,35 +1292,57 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
}),
);
const movePhase = globalScene.phaseManager.findPhase(m => m.is("MovePhase") && m.pokemon === pokemon);
if (movePhase) {
const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId);
if (movesetMove) {
const lastMove = pokemon.getLastXMoves(1)[0];
globalScene.phaseManager.tryReplacePhase(
m => m.is("MovePhase") && m.pokemon === pokemon,
globalScene.phaseManager.create(
"MovePhase",
pokemon,
lastMove.targets ?? [],
movesetMove,
MoveUseMode.NORMAL,
),
);
}
// If the target has not moved yet,
// replace their upcoming move with the encored move against randomized targets
const movePhase = globalScene.phaseManager.findPhase(
(m): m is MovePhase => m.is("MovePhase") && m.pokemon === pokemon,
);
if (!movePhase) {
return;
}
// Use the prior move in the moveset.
// Bang is justified as `canAdd` returns false if not found
const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId)!;
const moveTargets = getMoveTargets(pokemon, this.moveId);
// Spread moves and ones with only 1 valid target will use their normal targeting.
// If not, target a random enemy in our target list
const targets =
moveTargets.multiple || moveTargets.targets.length === 1
? moveTargets.targets
: [moveTargets.targets[pokemon.randBattleSeedInt(moveTargets.targets.length)]];
globalScene.phaseManager.tryReplacePhase(
m => m.is("MovePhase") && m.pokemon === pokemon,
globalScene.phaseManager.create(
"MovePhase",
pokemon,
targets,
movesetMove,
movePhase.useMode,
movePhase.isForcedLast(),
),
);
}
/**
* If the encored move has run out of PP, Encore ends early. Otherwise, Encore lapses based on the AFTER_MOVE battler tag lapse type.
* @returns `true` to persist | `false` to end and be removed
* If the encored move has run out of PP or the tag's turn count has elapsed,
* Encore ends at the END of the turn.
* Otherwise, Encore's duration reduces when the target attempts to use a move.
* @returns Whether the tag should remain active.
*/
override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (lapseType === BattlerTagLapseType.CUSTOM) {
const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId);
return !isNullOrUndefined(encoredMove) && encoredMove.getPpRatio() > 0;
if (lapseType === BattlerTagLapseType.AFTER_MOVE) {
this.turnCount--;
return true;
}
return super.lapse(pokemon, lapseType);
const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId);
if (isNullOrUndefined(encoredMove) || encoredMove.isOutOfPp()) {
return false;
}
return this.turnCount > 0;
}
/**
@ -1489,12 +1525,8 @@ export class MinimizeTag extends SerializableBattlerTag {
export class DrowsyTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.DROWSY;
constructor() {
super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN);
}
canAdd(pokemon: Pokemon): boolean {
return globalScene.arena.terrain?.terrainType !== TerrainType.ELECTRIC || !pokemon.isGrounded();
constructor(sourceId: number) {
super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN, sourceId);
}
onAdd(pokemon: Pokemon): void {
@ -1509,6 +1541,7 @@ export class DrowsyTag extends SerializableBattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (!super.lapse(pokemon, lapseType)) {
// TODO: Safeguard should not prevent yawn from setting sleep after tag use
pokemon.trySetStatus(StatusEffect.SLEEP, true);
return false;
}
@ -3632,6 +3665,23 @@ export class MagicCoatTag extends BattlerTag {
}),
);
}
/**
* Apply the tag to reflect a move.
* @param pokemon - The {@linkcode Pokemon} to whom this tag belongs
* @param opponent - The {@linkcode Pokemon} having originally used the move
* @param move - The {@linkcode Move} being used
*/
public apply(pokemon: Pokemon, opponent: Pokemon, move: Move): void {
const newTargets = move.isMultiTarget() ? getMoveTargets(pokemon, move.id).targets : [opponent.getBattlerIndex()];
globalScene.phaseManager.unshiftNew(
"MovePhase",
pokemon,
newTargets,
new PokemonMove(move.id),
MoveUseMode.REFLECTED,
);
}
}
/**
@ -3679,7 +3729,7 @@ export function getBattlerTag(
case BattlerTagType.AQUA_RING:
return new AquaRingTag();
case BattlerTagType.DROWSY:
return new DrowsyTag();
return new DrowsyTag(sourceId);
case BattlerTagType.TRAPPED:
return new TrappedTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);
case BattlerTagType.NO_RETREAT:

View File

@ -280,3 +280,68 @@ export const invalidEncoreMoves: ReadonlySet<MoveId> = new Set([
MoveId.SLEEP_TALK,
MoveId.ENCORE,
]);
export const invalidInstructMoves: ReadonlySet<MoveId> = new Set([
// Locking/Continually Executed moves
MoveId.OUTRAGE,
MoveId.RAGING_FURY,
MoveId.ROLLOUT,
MoveId.PETAL_DANCE,
MoveId.THRASH,
MoveId.ICE_BALL,
MoveId.UPROAR,
// Multi-turn Moves
MoveId.BIDE,
MoveId.SHELL_TRAP,
MoveId.BEAK_BLAST,
MoveId.FOCUS_PUNCH,
// "First Turn Only" moves
MoveId.FAKE_OUT,
MoveId.FIRST_IMPRESSION,
MoveId.MAT_BLOCK,
// Moves with a recharge turn
MoveId.HYPER_BEAM,
MoveId.ETERNABEAM,
MoveId.FRENZY_PLANT,
MoveId.BLAST_BURN,
MoveId.HYDRO_CANNON,
MoveId.GIGA_IMPACT,
MoveId.PRISMATIC_LASER,
MoveId.ROAR_OF_TIME,
MoveId.ROCK_WRECKER,
MoveId.METEOR_ASSAULT,
// Charging & 2-turn moves
MoveId.DIG,
MoveId.FLY,
MoveId.BOUNCE,
MoveId.SHADOW_FORCE,
MoveId.PHANTOM_FORCE,
MoveId.DIVE,
MoveId.ELECTRO_SHOT,
MoveId.ICE_BURN,
MoveId.GEOMANCY,
MoveId.FREEZE_SHOCK,
MoveId.SKY_DROP,
MoveId.SKY_ATTACK,
MoveId.SKULL_BASH,
MoveId.SOLAR_BEAM,
MoveId.SOLAR_BLADE,
MoveId.METEOR_BEAM,
// Copying/Move-Calling moves
MoveId.ASSIST,
MoveId.COPYCAT,
MoveId.ME_FIRST,
MoveId.METRONOME,
MoveId.MIRROR_MOVE,
MoveId.NATURE_POWER,
MoveId.SLEEP_TALK,
MoveId.SNATCH,
MoveId.INSTRUCT,
// Misc moves
MoveId.KINGS_SHIELD,
MoveId.SKETCH,
MoveId.TRANSFORM,
MoveId.MIMIC,
MoveId.STRUGGLE,
// TODO: Add Max/G-Max/Z-Move blockage if or when they are implemented
]);

View File

@ -11,6 +11,7 @@ import { WeakenMoveTypeTag } from "#data/arena-tag";
import { MoveChargeAnim } from "#data/battle-anims";
import {
CommandedTag,
DrowsyTag,
EncoreTag,
GulpMissileTag,
HelpingHandTag,
@ -77,7 +78,7 @@ import {
PreserveBerryModifier,
} from "#modifiers/modifier";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves";
import { invalidAssistMoves, invalidCopycatMoves, invalidInstructMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves";
import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import { MoveEndPhase } from "#phases/move-end-phase";
@ -90,7 +91,7 @@ import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindS
import type { TurnMove } from "#types/turn-move";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import { toCamelCase, toTitleCase } from "#utils/strings";
import i18next from "i18next";
import { applyChallenges } from "#utils/challenge-utils";
@ -162,10 +163,16 @@ export abstract class Move implements Localizable {
}
localize(): void {
const i18nKey = MoveId[this.id].split("_").filter(f => f).map((f, i) => i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()).join("") as unknown as string;
const i18nKey = toCamelCase(MoveId[this.id])
this.name = this.id ? `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}` : "";
this.effect = this.id ? `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}` : "";
if (this.id === MoveId.NONE) {
this.name = "";
this.effect = ""
return;
}
this.name = `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}`;
this.effect = `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}`;
}
/**
@ -673,20 +680,9 @@ export abstract class Move implements Localizable {
return true;
}
break;
case MoveFlags.REFLECTABLE:
// If the target is not semi-invulnerable and either has magic coat active or an unignored magic bounce ability
if (
target?.getTag(SemiInvulnerableTag) ||
!(target?.getTag(BattlerTagType.MAGIC_COAT) ||
(!this.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user, target }) &&
target?.hasAbilityWithAttr("ReflectStatusMoveAbAttr")))
) {
return false;
}
break;
}
return !!(this.flags & flag);
return this.hasFlag(flag)
}
/**
@ -5699,6 +5695,34 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
}
}
/**
* Attribute to implement {@linkcode MoveId.YAWN}.
* Yawn adds a BattlerTag to its target that puts them to sleep at the end
* of the next turn, retaining many of the same checks as normal status setting moves.
*/
export class YawnAttr extends AddBattlerTagAttr {
constructor() {
super(BattlerTagType.DROWSY, false, true)
}
getCondition(): MoveConditionFunc {
return (user, target, move) => {
if (!super.getCondition()!(user, target, move)) {
return false;
}
// Statused opponents or ones with safeguard active use a generic failure message
if (target.status || target.isSafeguarded(user)) {
return false;
}
// TODO: This does not display the cause of the "but it failed" message,
// but fixing it would require a rework of the move failure system
return target.canSetStatus(StatusEffect.SLEEP, true, false, user)
}
}
}
/**
* Adds a {@link https://bulbapedia.bulbagarden.net/wiki/Seeding | Seeding} effect to the target
* as seen with Leech Seed and Sappy Seed.
@ -7146,7 +7170,6 @@ export class RepeatMoveAttr extends MoveEffectAttr {
// bangs are justified as Instruct fails if no prior move or moveset move exists
// TODO: How does instruct work when copying a move called via Copycat that the user itself knows?
const lastMove = target.getLastNonVirtualMove()!;
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move)!
// If the last move used can hit more than one target or has variable targets,
// re-compute the targets for the attack (mainly for alternating double/single battles)
@ -7170,12 +7193,18 @@ export class RepeatMoveAttr extends MoveEffectAttr {
}
}
// If the target is currently affected by Encore, increase its duration by 1 (to offset decrease during move use)
const targetEncore = target.getTag(BattlerTagType.ENCORE) as EncoreTag | undefined;
if (targetEncore) {
targetEncore.turnCount++
}
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:instructingMove", {
userPokemonName: getPokemonNameWithAffix(user),
targetPokemonName: getPokemonNameWithAffix(target)
}));
target.turnData.extraTurns++;
globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL);
globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, this.movesetMove, MoveUseMode.NORMAL);
return true;
}
@ -7184,77 +7213,13 @@ export class RepeatMoveAttr extends MoveEffectAttr {
// TODO: Check instruct behavior with struggle - ignore, fail or success
const lastMove = target.getLastNonVirtualMove();
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move);
const uninstructableMoves = [
// Locking/Continually Executed moves
MoveId.OUTRAGE,
MoveId.RAGING_FURY,
MoveId.ROLLOUT,
MoveId.PETAL_DANCE,
MoveId.THRASH,
MoveId.ICE_BALL,
MoveId.UPROAR,
// Multi-turn Moves
MoveId.BIDE,
MoveId.SHELL_TRAP,
MoveId.BEAK_BLAST,
MoveId.FOCUS_PUNCH,
// "First Turn Only" moves
MoveId.FAKE_OUT,
MoveId.FIRST_IMPRESSION,
MoveId.MAT_BLOCK,
// Moves with a recharge turn
MoveId.HYPER_BEAM,
MoveId.ETERNABEAM,
MoveId.FRENZY_PLANT,
MoveId.BLAST_BURN,
MoveId.HYDRO_CANNON,
MoveId.GIGA_IMPACT,
MoveId.PRISMATIC_LASER,
MoveId.ROAR_OF_TIME,
MoveId.ROCK_WRECKER,
MoveId.METEOR_ASSAULT,
// Charging & 2-turn moves
MoveId.DIG,
MoveId.FLY,
MoveId.BOUNCE,
MoveId.SHADOW_FORCE,
MoveId.PHANTOM_FORCE,
MoveId.DIVE,
MoveId.ELECTRO_SHOT,
MoveId.ICE_BURN,
MoveId.GEOMANCY,
MoveId.FREEZE_SHOCK,
MoveId.SKY_DROP,
MoveId.SKY_ATTACK,
MoveId.SKULL_BASH,
MoveId.SOLAR_BEAM,
MoveId.SOLAR_BLADE,
MoveId.METEOR_BEAM,
// Copying/Move-Calling moves
MoveId.ASSIST,
MoveId.COPYCAT,
MoveId.ME_FIRST,
MoveId.METRONOME,
MoveId.MIRROR_MOVE,
MoveId.NATURE_POWER,
MoveId.SLEEP_TALK,
MoveId.SNATCH,
MoveId.INSTRUCT,
// Misc moves
MoveId.KINGS_SHIELD,
MoveId.SKETCH,
MoveId.TRANSFORM,
MoveId.MIMIC,
MoveId.STRUGGLE,
// TODO: Add Max/G-Max/Z-Move blockage if or when they are implemented
];
if (!lastMove?.move // no move to instruct
if (
!lastMove?.move // no move to instruct
|| !movesetMove // called move not in target's moveset (forgetting the move, etc.)
|| movesetMove.ppUsed === movesetMove.getMovePp() // move out of pp
// TODO: This next line is likely redundant as all charging moves are in the above list
|| allMoves[lastMove.move].isChargingMove() // called move is a charging/recharging move
|| uninstructableMoves.includes(lastMove.move)) { // called move is in the banlist
|| movesetMove.isOutOfPp() // move out of pp
|| invalidInstructMoves.has(lastMove.move) // called move is in the banlist
) {
return false;
}
this.movesetMove = movesetMove;
@ -7968,7 +7933,7 @@ export class AfterYouAttr extends MoveEffectAttr {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:afterYou", { targetName: getPokemonNameWithAffix(target) }));
// Will find next acting phase of the targeted pokémon, delete it and queue it right after us.
const targetNextPhase = globalScene.phaseManager.findPhase<MovePhase>(phase => phase.pokemon === target);
const targetNextPhase = globalScene.phaseManager.findPhase((phase): phase is MovePhase => phase.is("MovePhase") && phase.pokemon === target);
if (targetNextPhase && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
globalScene.phaseManager.prependToPhase(targetNextPhase, "MovePhase");
}
@ -7996,7 +7961,7 @@ export class ForceLastAttr extends MoveEffectAttr {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) }));
// TODO: Refactor this to be more readable and less janky
const targetMovePhase = globalScene.phaseManager.findPhase<MovePhase>((phase) => phase.pokemon === target);
const targetMovePhase = globalScene.phaseManager.findPhase((phase): phase is MovePhase => phase.is("MovePhase") && phase.pokemon === target);
if (targetMovePhase && !targetMovePhase.isForcedLast() && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
// Finding the phase to insert the move in front of -
// Either the end of the turn or in front of another, slower move which has also been forced last
@ -9180,11 +9145,11 @@ export function initMoves() {
.hidesUser(),
new StatusMove(MoveId.ENCORE, PokemonType.NORMAL, 100, 5, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true)
.ignoresSubstitute()
.condition((user, target, move) => new EncoreTag(user.id).canAdd(target))
.ignoresSubstitute()
.reflectable()
// Can lock infinitely into struggle; has incorrect interactions with Blood Moon/Gigaton Hammer
// Also may or may not incorrectly select targets for replacement move (needs verification)
// has incorrect interactions with Blood Moon/Gigaton Hammer
// TODO: Verify if Encore's duration decreases during status based move failures
.edgeCase(),
new AttackMove(MoveId.PURSUIT, PokemonType.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2)
.partial(), // No effect implemented
@ -9357,9 +9322,7 @@ export function initMoves() {
new SelfStatusMove(MoveId.MAGIC_COAT, PokemonType.PSYCHIC, -1, 15, -1, 4, 3)
.attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, true, 0)
.condition(failIfLastCondition)
// Interactions with stomping tantrum, instruct, and other moves that
// rely on move history
// Also will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr
// Will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr
.edgeCase(),
new SelfStatusMove(MoveId.RECYCLE, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.unimplemented(),
@ -9368,11 +9331,11 @@ export function initMoves() {
new AttackMove(MoveId.BRICK_BREAK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 75, 100, 15, -1, 0, 3)
.attr(RemoveScreensAttr),
new StatusMove(MoveId.YAWN, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.attr(AddBattlerTagAttr, BattlerTagType.DROWSY, false, true)
.condition((user, target, move) => !target.status && !target.isSafeguarded(user))
.reflectable(),
.attr(YawnAttr)
.reflectable()
.edgeCase(), // Should not be blocked by safeguard once tag is applied
new AttackMove(MoveId.KNOCK_OFF, PokemonType.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1)
.attr(MovePowerMultiplierAttr, (_user, target, _move) => target.getHeldItems().some(i => i.isTransferable) ? 1.5 : 1)
.attr(RemoveHeldItemAttr, false)
.edgeCase(),
// Should not be able to remove held item if user faints due to Rough Skin, Iron Barbs, etc.
@ -10656,11 +10619,10 @@ export function initMoves() {
new AttackMove(MoveId.TROP_KICK, PokemonType.GRASS, MoveCategory.PHYSICAL, 70, 100, 15, 100, 0, 7)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1),
new StatusMove(MoveId.INSTRUCT, PokemonType.PSYCHIC, -1, 15, -1, 0, 7)
.ignoresSubstitute()
.attr(RepeatMoveAttr)
.ignoresSubstitute()
/*
* Incorrect interactions with Gigaton Hammer, Blood Moon & Torment due to them _failing on use_, not merely being unselectable.
* Incorrectly ticks down Encore's fail counter
* TODO: Verify whether Instruct can repeat Struggle
* TODO: Verify whether Instruct can fail when using a copied move also in one's own moveset
*/

View File

@ -73,6 +73,7 @@ export class PokemonMove {
this.ppUsed = Math.min(this.ppUsed + count, this.getMovePp());
}
// TODO: Rename to `getMaxPP`
getMovePp(): number {
return this.maxPpOverride || this.getMove().pp + this.ppUp * toDmgValue(this.getMove().pp / 5);
}
@ -81,6 +82,10 @@ export class PokemonMove {
return 1 - this.ppUsed / this.getMovePp();
}
public isOutOfPp(): boolean {
return this.ppUsed >= this.getMovePp();
}
getName(): string {
return this.getMove().name;
}

View File

@ -12,6 +12,7 @@ import { WeatherType } from "#enums/weather-type";
import type { Pokemon } from "#field/pokemon";
import type { PokemonFormChangeItemModifier } from "#modifiers/modifier";
import { type Constructor, coerceArray } from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
export abstract class SpeciesFormChangeTrigger {
@ -143,11 +144,7 @@ export class SpeciesFormChangeMoveLearnedTrigger extends SpeciesFormChangeTrigge
super();
this.move = move;
this.known = known;
const moveKey = MoveId[this.move]
.split("_")
.filter(f => f)
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("") as unknown as string;
const moveKey = toCamelCase(MoveId[this.move]);
this.description = known
? i18next.t("pokemonEvolutions:Forms.moveLearned", {
move: i18next.t(`move:${moveKey}.name`),

View File

@ -38,6 +38,7 @@ export enum UiMode {
UNAVAILABLE,
CHALLENGE_SELECT,
RENAME_POKEMON,
RENAME_RUN,
RUN_HISTORY,
RUN_INFO,
TEST_DIALOGUE,

View File

@ -54,7 +54,7 @@ export class Arena {
public bgm: string;
public ignoreAbilities: boolean;
public ignoringEffectSource: BattlerIndex | null;
public playerTerasUsed: number;
public playerTerasUsed = 0;
/**
* Saves the number of times a party pokemon faints during a arena encounter.
* {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave).
@ -68,12 +68,11 @@ export class Arena {
public readonly eventTarget: EventTarget = new EventTarget();
constructor(biome: BiomeId, bgm: string, playerFaints = 0) {
constructor(biome: BiomeId, playerFaints = 0) {
this.biomeType = biome;
this.bgm = bgm;
this.bgm = BiomeId[biome].toLowerCase();
this.trainerPool = biomeTrainerPools[biome];
this.updatePoolsForTimeOfDay();
this.playerTerasUsed = 0;
this.playerFaints = playerFaints;
}

View File

@ -4430,14 +4430,19 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Return this Pokemon's move history.
* Entries are sorted in order of OLDEST to NEWEST
* @returns An array of {@linkcode TurnMove}, as described above.
* Entries are sorted in order of OLDEST to NEWEST.
* @returns An array of {@linkcode TurnMove}s, as described above.
* @see {@linkcode getLastXMoves}
*/
public getMoveHistory(): TurnMove[] {
return this.summonData.moveHistory;
}
/**
* Add a move to the end of this {@linkcode Pokemon}'s move history,
* used to record its most recently executed actions.
* @param turnMove - The {@linkcode TurnMove} to add
*/
public pushMoveHistory(turnMove: TurnMove): void {
if (!this.isOnField()) {
return;

View File

@ -447,7 +447,9 @@ export class LoadingScene extends SceneBase {
);
if (!mobile) {
loadingGraphics.map(g => g.setVisible(false));
loadingGraphics.forEach(g => {
g.setVisible(false);
});
}
const intro = this.add.video(0, 0);

View File

@ -121,8 +121,8 @@ export class ModifierBar extends Phaser.GameObjects.Container {
}
updateModifierOverflowVisibility(ignoreLimit: boolean) {
const modifierIcons = this.getAll().reverse();
for (const modifier of modifierIcons.map(m => m as Phaser.GameObjects.Container).slice(iconOverflowIndex)) {
const modifierIcons = this.getAll().reverse() as Phaser.GameObjects.Container[];
for (const modifier of modifierIcons.slice(iconOverflowIndex)) {
modifier.setVisible(ignoreLimit);
}
}

View File

@ -44,6 +44,7 @@ import { MoveEffectPhase } from "#phases/move-effect-phase";
import { MoveEndPhase } from "#phases/move-end-phase";
import { MoveHeaderPhase } from "#phases/move-header-phase";
import { MovePhase } from "#phases/move-phase";
import { MoveReflectPhase } from "#phases/move-reflect-phase";
import {
MysteryEncounterBattlePhase,
MysteryEncounterBattleStartCleanupPhase,
@ -157,6 +158,7 @@ const PHASES = Object.freeze({
MoveEffectPhase,
MoveEndPhase,
MoveHeaderPhase,
MoveReflectPhase,
MovePhase,
MysteryEncounterPhase,
MysteryEncounterOptionSelectedPhase,
@ -414,6 +416,8 @@ export class PhaseManager {
* @param phaseFilter filter function to use to find the wanted phase
* @returns the found phase or undefined if none found
*/
findPhase<P extends Phase = Phase>(phaseFilter: (phase: Phase) => phase is P): P | undefined;
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined;
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined {
return this.phaseQueue.find(phaseFilter) as P | undefined;
}

View File

@ -178,11 +178,6 @@ export class CommandPhase extends FieldPhase {
this.checkCommander();
const playerPokemon = this.getPokemon();
// Note: It is OK to call this if the target is not under the effect of encore; it will simply do nothing.
playerPokemon.lapseTag(BattlerTagType.ENCORE);
if (globalScene.currentBattle.turnCommands[this.fieldIndex]?.skip) {
this.end();
return;

View File

@ -1,7 +1,6 @@
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import type { Phase } from "#app/phase";
import { ConditionalProtectTag } from "#data/arena-tag";
import { MoveAnim } from "#data/battle-anims";
import { DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag, TypeBoostTag } from "#data/battler-tags";
@ -33,8 +32,7 @@ import {
} from "#modifiers/modifier";
import { applyFilteredMoveAttrs, applyMoveAttrs } from "#moves/apply-attrs";
import type { Move, MoveAttr } from "#moves/move";
import { getMoveTargets, isFieldTargeted } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import { isFieldTargeted } from "#moves/move-utils";
import { PokemonPhase } from "#phases/pokemon-phase";
import { DamageAchv } from "#system/achv";
import type { DamageResult } from "#types/damage-result";
@ -67,12 +65,6 @@ export class MoveEffectPhase extends PokemonPhase {
/** Is this the last strike of a move? */
private lastHit: boolean;
/**
* Phases queued during moves; used to add a new MovePhase for reflected moves after triggering.
* TODO: Remove this and move the reflection logic to ability-side
*/
private queuedPhases: Phase[] = [];
/**
* @param useMode - The {@linkcode MoveUseMode} corresponding to how this move was used.
*/
@ -95,143 +87,11 @@ export class MoveEffectPhase extends PokemonPhase {
this.hitChecks = Array(this.targets.length).fill([HitCheckResult.PENDING, 0]);
}
/**
* Compute targets and the results of hit checks of the invoked move against all targets,
* organized by battler index.
*
* **This is *not* a pure function**; it has the following side effects
* - `this.hitChecks` - The results of the hit checks against each target
* - `this.moveHistoryEntry` - Sets success or failure based on the hit check results
* - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the
* move was unsuccessful against all targets
*
* @returns The targets of the invoked move
* @see {@linkcode hitCheck}
*/
private conductHitChecks(user: Pokemon, fieldMove: boolean): Pokemon[] {
/** All Pokemon targeted by this phase's invoked move */
/** Whether any hit check ended in a success */
let anySuccess = false;
/** Whether the attack missed all of its targets */
let allMiss = true;
let targets = this.getTargets();
// For field targeted moves, we only look for the first target that may magic bounce
for (const [i, target] of targets.entries()) {
const hitCheck = this.hitCheck(target);
// If the move bounced and was a field targeted move,
// then immediately stop processing other targets
if (fieldMove && hitCheck[0] === HitCheckResult.REFLECTED) {
targets = [target];
this.hitChecks = [hitCheck];
break;
}
if (hitCheck[0] === HitCheckResult.HIT) {
anySuccess = true;
} else {
allMiss ||= hitCheck[0] === HitCheckResult.MISS;
}
this.hitChecks[i] = hitCheck;
}
if (anySuccess) {
this.moveHistoryEntry.result = MoveResult.SUCCESS;
} else {
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
this.moveHistoryEntry.result = allMiss ? MoveResult.MISS : MoveResult.FAIL;
}
return targets;
}
/**
* Queue the phaes that should occur when the target reflects the move back to the user
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} that is reflecting the move
* TODO: Rework this to use `onApply` of Magic Coat
*/
private queueReflectedMove(user: Pokemon, target: Pokemon): void {
const newTargets = this.move.isMultiTarget()
? getMoveTargets(target, this.move.id).targets
: [user.getBattlerIndex()];
// TODO: ability displays should be handled by the ability
if (!target.getTag(BattlerTagType.MAGIC_COAT)) {
this.queuedPhases.push(
globalScene.phaseManager.create(
"ShowAbilityPhase",
target.getBattlerIndex(),
target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"),
),
);
this.queuedPhases.push(globalScene.phaseManager.create("HideAbilityPhase"));
}
this.queuedPhases.push(
globalScene.phaseManager.create(
"MovePhase",
target,
newTargets,
new PokemonMove(this.move.id),
MoveUseMode.REFLECTED,
),
);
}
/**
* Apply the move to each of the resolved targets.
* @param targets - The resolved set of targets of the move
* @throws Error if there was an unexpected hit check result
*/
private applyToTargets(user: Pokemon, targets: Pokemon[]): void {
let firstHit = true;
for (const [i, target] of targets.entries()) {
const [hitCheckResult, effectiveness] = this.hitChecks[i];
switch (hitCheckResult) {
case HitCheckResult.HIT:
this.applyMoveEffects(target, effectiveness, firstHit);
firstHit = false;
if (isFieldTargeted(this.move)) {
// Stop processing other targets if the move is a field move
return;
}
break;
// biome-ignore lint/suspicious/noFallthroughSwitchClause: The fallthrough is intentional
case HitCheckResult.NO_EFFECT:
globalScene.phaseManager.queueMessage(
i18next.t(this.move.id === MoveId.SHEER_COLD ? "battle:hitResultImmune" : "battle:hitResultNoEffect", {
pokemonName: getPokemonNameWithAffix(target),
}),
);
case HitCheckResult.NO_EFFECT_NO_MESSAGE:
case HitCheckResult.PROTECTED:
case HitCheckResult.TARGET_NOT_ON_FIELD:
applyMoveAttrs("NoEffectAttr", user, target, this.move);
break;
case HitCheckResult.MISS:
globalScene.phaseManager.queueMessage(
i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }),
);
applyMoveAttrs("MissEffectAttr", user, target, this.move);
break;
case HitCheckResult.REFLECTED:
this.queueReflectedMove(user, target);
break;
case HitCheckResult.PENDING:
case HitCheckResult.ERROR:
throw new Error("Unexpected hit check result");
}
}
}
public override start(): void {
super.start();
/** The Pokemon using this phase's invoked move */
const user = this.getUserPokemon();
if (!user) {
super.end();
return;
@ -326,6 +186,58 @@ export class MoveEffectPhase extends PokemonPhase {
this.postAnimCallback(user, targets);
}
/**
* Compute targets and the results of hit checks of the invoked move against all targets,
* organized by battler index.
*
* **This is *not* a pure function**; it has the following side effects
* - `this.hitChecks` - The results of the hit checks against each target
* - `this.moveHistoryEntry` - Sets success or failure based on the hit check results
* - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the
* move was unsuccessful against all targets
*
* @returns The targets of the invoked move
* @see {@linkcode hitCheck}
*/
private conductHitChecks(user: Pokemon, fieldMove: boolean): Pokemon[] {
/** All Pokemon targeted by this phase's invoked move */
/** Whether any hit check ended in a success */
let anySuccess = false;
/** Whether the attack missed all of its targets */
let allMiss = true;
let targets = this.getTargets();
// For field targeted moves, we only look for the first target that may magic bounce
for (const [i, target] of targets.entries()) {
const hitCheck = this.hitCheck(target);
// If the move bounced and was a field targeted move,
// then immediately stop processing other targets
if (fieldMove && hitCheck[0] === HitCheckResult.REFLECTED) {
targets = [target];
this.hitChecks = [hitCheck];
break;
}
if (hitCheck[0] === HitCheckResult.HIT) {
anySuccess = true;
} else {
allMiss ||= hitCheck[0] === HitCheckResult.MISS;
}
this.hitChecks[i] = hitCheck;
}
if (anySuccess) {
this.moveHistoryEntry.result = MoveResult.SUCCESS;
} else {
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
this.moveHistoryEntry.result = allMiss ? MoveResult.MISS : MoveResult.FAIL;
}
return targets;
}
/**
* Callback to be called after the move animation is played
*/
@ -344,9 +256,6 @@ export class MoveEffectPhase extends PokemonPhase {
return;
}
if (this.queuedPhases.length) {
globalScene.phaseManager.appendToPhase(this.queuedPhases, "MoveEndPhase");
}
const moveType = user.getMoveType(this.move, true);
if (this.move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) {
user.stellarTypesBoosted.push(moveType);
@ -360,121 +269,52 @@ export class MoveEffectPhase extends PokemonPhase {
this.end();
}
public override end(): void {
const user = this.getUserPokemon();
if (!user) {
super.end();
return;
}
/**
* If this phase isn't for the invoked move's last strike (and we still have something to hit),
* unshift another MoveEffectPhase for the next strike before ending this phase.
*/
if (--user.turnData.hitsLeft >= 1 && this.getFirstTarget()) {
this.addNextHitPhase();
super.end();
return;
}
/**
* All hits of the move have resolved by now.
* Queue message for multi-strike moves before applying Shell Bell heals & proccing Dancer-like effects.
*/
const hitsTotal = user.turnData.hitCount - Math.max(user.turnData.hitsLeft, 0);
if (hitsTotal > 1 || user.turnData.hitsLeft > 0) {
// Queue message if multiple hits occurred or were slated to occur (such as a Triple Axel miss)
globalScene.phaseManager.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal }));
}
globalScene.applyModifiers(HitHealModifier, this.player, user);
this.getTargets().forEach(target => {
target.turnData.moveEffectiveness = null;
});
super.end();
}
/**
* Applies reactive effects that occur when a Pokémon is hit.
* (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast)
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param hitResult - The {@linkcode HitResult} of the attempted move
* @param wasCritical - `true` if the move was a critical hit
* Apply the move to each of the resolved targets.
* @param targets - The resolved set of targets of the move
* @throws Error if there was an unexpected hit check result
*/
protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void {
const params = { pokemon: target, opponent: user, move: this.move, hitResult };
applyAbAttrs("PostDefendAbAttr", params);
if (wasCritical) {
applyAbAttrs("PostReceiveCritStatStageChangeAbAttr", params);
}
target.lapseTags(BattlerTagLapseType.AFTER_HIT);
}
/**
* Handles checking for and applying Flinches
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param dealsDamage - `true` if the attempted move successfully dealt damage
*/
protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean): void {
if (this.move.hasAttr("FlinchAttr")) {
return;
}
if (
dealsDamage &&
!target.hasAbilityWithAttr("IgnoreMoveEffectsAbAttr") &&
!this.move.hitsSubstitute(user, target)
) {
const flinched = new BooleanHolder(false);
globalScene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched);
if (flinched.value) {
target.addTag(BattlerTagType.FLINCHED, undefined, this.move.id, user.id);
private applyToTargets(user: Pokemon, targets: Pokemon[]): void {
let firstHit = true;
for (const [i, target] of targets.entries()) {
const [hitCheckResult, effectiveness] = this.hitChecks[i];
switch (hitCheckResult) {
case HitCheckResult.HIT:
this.applyMoveEffects(target, effectiveness, firstHit);
firstHit = false;
if (isFieldTargeted(this.move)) {
// Stop processing other targets if the move is a field move
return;
}
break;
// biome-ignore lint/suspicious/noFallthroughSwitchClause: The fallthrough is intentional
case HitCheckResult.NO_EFFECT:
globalScene.phaseManager.queueMessage(
i18next.t(this.move.id === MoveId.SHEER_COLD ? "battle:hitResultImmune" : "battle:hitResultNoEffect", {
pokemonName: getPokemonNameWithAffix(target),
}),
);
case HitCheckResult.NO_EFFECT_NO_MESSAGE:
case HitCheckResult.PROTECTED:
case HitCheckResult.TARGET_NOT_ON_FIELD:
applyMoveAttrs("NoEffectAttr", user, target, this.move);
break;
case HitCheckResult.MISS:
globalScene.phaseManager.queueMessage(
i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }),
);
applyMoveAttrs("MissEffectAttr", user, target, this.move);
break;
case HitCheckResult.REFLECTED:
globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MoveReflectPhase", target, user, this.move);
break;
case HitCheckResult.PENDING:
case HitCheckResult.ERROR:
throw new Error("Unexpected hit check result");
}
}
}
/** Return whether the target is protected by protect or a relevant conditional protection
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the target to check for protection
* @param move - The {@linkcode Move} being used
* @returns Whether the pokemon was protected
*/
private protectedCheck(user: Pokemon, target: Pokemon): boolean {
/** The {@linkcode ArenaTagSide} to which the target belongs */
const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
/** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */
const hasConditionalProtectApplied = new BooleanHolder(false);
/** Does the applied conditional protection bypass Protect-ignoring effects? */
const bypassIgnoreProtect = new BooleanHolder(false);
/** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */
if (!this.move.isAllyTarget()) {
globalScene.arena.applyTagsForSide(
ConditionalProtectTag,
targetSide,
false,
hasConditionalProtectApplied,
user,
target,
this.move.id,
bypassIgnoreProtect,
);
}
// TODO: Break up this chunky boolean to make it more palatable
return (
![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.moveTarget) &&
(bypassIgnoreProtect.value || !this.move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) &&
(hasConditionalProtectApplied.value ||
(!target.findTags(t => t instanceof DamageProtectedTag).length &&
target.findTags(t => t instanceof ProtectedTag).some(t => target.lapseTag(t.tagType))) ||
(this.move.category !== MoveCategory.STATUS &&
target.findTags(t => t instanceof DamageProtectedTag).some(t => target.lapseTag(t.tagType))))
);
}
/**
* Conduct the hit check and type effectiveness for this move against the target
*
@ -495,10 +335,6 @@ export class MoveEffectPhase extends PokemonPhase {
const user = this.getUserPokemon();
const move = this.move;
if (!user) {
return [HitCheckResult.ERROR, 0];
}
// Moves targeting the user bypass all checks
if (move.moveTarget === MoveTarget.USER) {
return [HitCheckResult.HIT, 1];
@ -532,7 +368,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
// Reflected moves cannot be reflected again
if (!isReflected(this.useMode) && move.doesFlagEffectApply({ flag: MoveFlags.REFLECTABLE, user, target })) {
if (isMoveReflectableBy(this.move, target, this.useMode)) {
return [HitCheckResult.REFLECTED, 0];
}
@ -603,9 +439,6 @@ export class MoveEffectPhase extends PokemonPhase {
*/
public checkBypassAccAndInvuln(target: Pokemon) {
const user = this.getUserPokemon();
if (!user) {
return false;
}
if (user.hasAbilityWithAttr("AlwaysHitAbAttr") || target.hasAbilityWithAttr("AlwaysHitAbAttr")) {
return true;
}
@ -637,79 +470,43 @@ export class MoveEffectPhase extends PokemonPhase {
return move.getAttrs("HitsTagAttr").some(hta => hta.tagType === semiInvulnerableTag.tagType);
}
/** @returns The {@linkcode Pokemon} using this phase's invoked move */
public getUserPokemon(): Pokemon | null {
// TODO: Make this purely a battler index
if (this.battlerIndex > BattlerIndex.ENEMY_2) {
return globalScene.getPokemonById(this.battlerIndex);
}
return (this.player ? globalScene.getPlayerField() : globalScene.getEnemyField())[this.fieldIndex];
}
/**
* @returns An array of {@linkcode Pokemon} that are:
* - On-field and active
* - Non-fainted
* - Targeted by this phase's invoked move
* Check whether the target is protected by protect or a relevant conditional protection.
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The target {@linkcode Pokemon} to check for protection
* @returns Whether the target was protected
*/
public getTargets(): Pokemon[] {
return globalScene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1);
}
/** @returns The first active, non-fainted target of this phase's invoked move. */
public getFirstTarget(): Pokemon | undefined {
return this.getTargets()[0];
}
/**
* Removes the given {@linkcode Pokemon} from this phase's target list
* @param target - The {@linkcode Pokemon} to be removed
*/
protected removeTarget(target: Pokemon): void {
const targetIndex = this.targets.indexOf(target.getBattlerIndex());
if (targetIndex !== -1) {
this.targets.splice(this.targets.indexOf(target.getBattlerIndex()), 1);
private protectedCheck(user: Pokemon, target: Pokemon): boolean {
/** The {@linkcode ArenaTagSide} to which the target belongs */
const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
/** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */
const hasConditionalProtectApplied = new BooleanHolder(false);
/** Does the applied conditional protection bypass Protect-ignoring effects? */
const bypassIgnoreProtect = new BooleanHolder(false);
/** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */
if (!this.move.isAllyTarget()) {
globalScene.arena.applyTagsForSide(
ConditionalProtectTag,
targetSide,
false,
hasConditionalProtectApplied,
user,
target,
this.move.id,
bypassIgnoreProtect,
);
}
}
/**
* Prevents subsequent strikes of this phase's invoked move from occurring
* @param target - If defined, only stop subsequent strikes against this {@linkcode Pokemon}
*/
public stopMultiHit(target?: Pokemon): void {
// If given a specific target, remove the target from subsequent strikes
if (target) {
this.removeTarget(target);
}
const user = this.getUserPokemon();
if (!user) {
return;
}
// If no target specified, or the specified target was the last of this move's
// targets, completely cancel all subsequent strikes.
if (!target || this.targets.length === 0) {
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
}
}
/**
* Unshifts a new `MoveEffectPhase` with the same properties as this phase.
* Used to queue the next hit of multi-strike moves.
*/
protected addNextHitPhase(): void {
globalScene.phaseManager.unshiftNew("MoveEffectPhase", this.battlerIndex, this.targets, this.move, this.useMode);
}
/** Removes all substitutes that were broken by this phase's invoked move */
protected updateSubstitutes(): void {
const targets = this.getTargets();
for (const target of targets) {
const substitute = target.getTag(SubstituteTag);
if (substitute && substitute.hp <= 0) {
target.lapseTag(BattlerTagType.SUBSTITUTE);
}
}
// TODO: Break up this chunky boolean to make it more palatable
return (
![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.moveTarget) &&
(bypassIgnoreProtect.value || !this.move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) &&
(hasConditionalProtectApplied.value ||
(!target.findTags(t => t instanceof DamageProtectedTag).length &&
target.findTags(t => t instanceof ProtectedTag).some(t => target.lapseTag(t.tagType))) ||
(this.move.category !== MoveCategory.STATUS &&
target.findTags(t => t instanceof DamageProtectedTag).some(t => target.lapseTag(t.tagType))))
);
}
/**
@ -757,9 +554,6 @@ export class MoveEffectPhase extends PokemonPhase {
*/
protected applyMoveEffects(target: Pokemon, effectiveness: TypeDamageMultiplier, firstTarget: boolean): void {
const user = this.getUserPokemon();
if (isNullOrUndefined(user)) {
return;
}
this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target);
@ -783,7 +577,33 @@ export class MoveEffectPhase extends PokemonPhase {
}
/**
* Sub-method of for {@linkcode applyMoveEffects} that applies damage to the target.
* Apply the result of this phase's move to the given target
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} struck by the move
* @param effectiveness - The effectiveness of the move against the target
*/
protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] {
const moveCategory = user.getMoveCategory(target, this.move);
if (moveCategory === MoveCategory.STATUS) {
return [HitResult.STATUS, false];
}
const result = this.applyMoveDamage(user, target, effectiveness);
if (user.turnData.hitsLeft === 1 || target.isFainted()) {
this.queueHitResultMessage(result[0]);
}
if (target.isFainted()) {
this.onFaintTarget(user, target);
}
return result;
}
/**
* Sub-method of {@linkcode applyMove} that applies damage to the target.
*
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} targeted by the move
@ -882,6 +702,29 @@ export class MoveEffectPhase extends PokemonPhase {
return [result, isCritical];
}
/**
* Sub-method of {@linkcode applyMove} that queues the hit-result message
* on the final strike of the move against a target
* @param result - The {@linkcode HitResult} of the move
*/
protected queueHitResultMessage(result: HitResult) {
let msg: string | undefined;
switch (result) {
case HitResult.SUPER_EFFECTIVE:
msg = i18next.t("battle:hitResultSuperEffective");
break;
case HitResult.NOT_VERY_EFFECTIVE:
msg = i18next.t("battle:hitResultNotVeryEffective");
break;
case HitResult.ONE_HIT_KO:
msg = i18next.t("battle:hitResultOneHitKO");
break;
}
if (msg) {
globalScene.phaseManager.queueMessage(msg);
}
}
/**
* Sub-method of {@linkcode applyMove} that handles the event of a target fainting.
* @param user - The {@linkcode Pokemon} using this phase's invoked move
@ -906,55 +749,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
/**
* Sub-method of {@linkcode applyMove} that queues the hit-result message
* on the final strike of the move against a target
* @param result - The {@linkcode HitResult} of the move
*/
protected queueHitResultMessage(result: HitResult) {
let msg: string | undefined;
switch (result) {
case HitResult.SUPER_EFFECTIVE:
msg = i18next.t("battle:hitResultSuperEffective");
break;
case HitResult.NOT_VERY_EFFECTIVE:
msg = i18next.t("battle:hitResultNotVeryEffective");
break;
case HitResult.ONE_HIT_KO:
msg = i18next.t("battle:hitResultOneHitKO");
break;
}
if (msg) {
globalScene.phaseManager.queueMessage(msg);
}
}
/** Apply the result of this phase's move to the given target
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} struck by the move
* @param effectiveness - The effectiveness of the move against the target
*/
protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] {
const moveCategory = user.getMoveCategory(target, this.move);
if (moveCategory === MoveCategory.STATUS) {
return [HitResult.STATUS, false];
}
const result = this.applyMoveDamage(user, target, effectiveness);
if (user.turnData.hitsLeft === 1 || target.isFainted()) {
this.queueHitResultMessage(result[0]);
}
if (target.isFainted()) {
this.onFaintTarget(user, target);
}
return result;
}
/**
* Applies all effects aimed at the move's target.
* Sub-method of {@linkcode applyMovetEffects} that applies all effects aimed at the move's target.
* To be used when the target is successfully and directly hit by the move.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
@ -992,4 +787,173 @@ export class MoveEffectPhase extends PokemonPhase {
globalScene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target);
}
}
/**
* Sub-method of {@linkcode applyOnTargetEffects} that applies reactive effects that occur when a Pokémon is hit.
* (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast)
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param hitResult - The {@linkcode HitResult} of the attempted move
* @param wasCritical - `true` if the move was a critical hit
*/
protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void {
const params = { pokemon: target, opponent: user, move: this.move, hitResult };
applyAbAttrs("PostDefendAbAttr", params);
if (wasCritical) {
applyAbAttrs("PostReceiveCritStatStageChangeAbAttr", params);
}
target.lapseTags(BattlerTagLapseType.AFTER_HIT);
}
/**
* Sub-method of {@linkcode applyOnTargetEffects} that handles checking for and applying flinches.
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param dealsDamage - `true` if the attempted move successfully dealt damage
*/
protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean): void {
if (this.move.hasAttr("FlinchAttr")) {
return;
}
if (
dealsDamage &&
!target.hasAbilityWithAttr("IgnoreMoveEffectsAbAttr") &&
!this.move.hitsSubstitute(user, target)
) {
const flinched = new BooleanHolder(false);
globalScene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched);
if (flinched.value) {
target.addTag(BattlerTagType.FLINCHED, undefined, this.move.id, user.id);
}
}
}
public override end(): void {
const user = this.getUserPokemon();
/**
* If this phase isn't for the invoked move's last strike (and we still have something to hit),
* unshift another MoveEffectPhase for the next strike before ending this phase.
*/
if (--user.turnData.hitsLeft >= 1 && this.getFirstTarget()) {
this.addNextHitPhase();
super.end();
return;
}
/**
* All hits of the move have resolved by now.
* Queue message for multi-strike moves before applying Shell Bell heals & proccing Dancer-like effects.
*/
const hitsTotal = user.turnData.hitCount - Math.max(user.turnData.hitsLeft, 0);
if (hitsTotal > 1 || user.turnData.hitsLeft > 0) {
// Queue message if multiple hits occurred or were slated to occur (such as a Triple Axel miss)
globalScene.phaseManager.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal }));
}
globalScene.applyModifiers(HitHealModifier, this.player, user);
this.getTargets().forEach(target => {
target.turnData.moveEffectiveness = null;
});
super.end();
}
// #region Helpers
/**
* @returns The {@linkcode Pokemon} using this phase's invoked move.
* Is never null during the move execution itself, as {@linkcode start} ends the phase immediately if a source is missing.
* @todo Delete in favor of {@linkcode PokemonPhase.getPokemon}
*/
public getUserPokemon(): Pokemon {
return super.getPokemon()!;
}
/**
* @returns An array of {@linkcode Pokemon} that are:
* - On-field and active
* - Non-fainted
* - Targeted by this phase's invoked move
*/
public getTargets(): Pokemon[] {
return globalScene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1);
}
/** @returns The first active, non-fainted target of this phase's invoked move. */
public getFirstTarget(): Pokemon | undefined {
return this.getTargets()[0];
}
/**
* Removes the given {@linkcode Pokemon} from this phase's target list
* @param target - The {@linkcode Pokemon} to be removed
*/
protected removeTarget(target: Pokemon): void {
const targetIndex = this.targets.indexOf(target.getBattlerIndex());
if (targetIndex !== -1) {
this.targets.splice(this.targets.indexOf(target.getBattlerIndex()), 1);
}
}
/**
* Prevents subsequent strikes of this phase's invoked move from occurring
* @param target - If defined, only stop subsequent strikes against this {@linkcode Pokemon}
*/
public stopMultiHit(target?: Pokemon): void {
// If given a specific target, remove the target from subsequent strikes
if (target) {
this.removeTarget(target);
}
const user = this.getUserPokemon();
// If no target specified, or the specified target was the last of this move's
// targets, completely cancel all subsequent strikes.
if (!target || this.targets.length === 0) {
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
}
}
/**
* Unshifts a new `MoveEffectPhase` with the same properties as this phase.
* Used to queue the next hit of multi-strike moves.
*/
protected addNextHitPhase(): void {
globalScene.phaseManager.unshiftNew("MoveEffectPhase", this.battlerIndex, this.targets, this.move, this.useMode);
}
/** Removes all substitutes that were broken by this phase's invoked move */
protected updateSubstitutes(): void {
const targets = this.getTargets();
for (const target of targets) {
const substitute = target.getTag(SubstituteTag);
if (substitute && substitute.hp <= 0) {
target.lapseTag(BattlerTagType.SUBSTITUTE);
}
}
}
// # endregion Helpers
}
/**
* Check whether a given Move is able to be reflected by either
* {@linkcode MoveId.MAGIC_COAT | Magic Coat} or {@linkcode AbilityId.MAGIC_BOUNCE | Magic Bounce}.
* @param move - The {@linkcode Move} being used
* @param target - The targeted {@linkcode Pokemon} attempting to reflect the move
* @param useMode - The {@linkcode MoveUseMode} dictating how the move was used
* @returns Whether {@linkcode target} can reflect {@linkcode move}.
*/
function isMoveReflectableBy(move: Move, target: Pokemon, useMode: MoveUseMode): boolean {
return (
// The move must not have just been reflected
!isReflected(useMode) &&
// Reflections cannot occur while semi invulnerable
!target.getTag(SemiInvulnerableTag) &&
// Move must be reflectable
move.hasFlag(MoveFlags.REFLECTABLE) &&
// target must have a reflection effect active
(!!target.getTag(BattlerTagType.MAGIC_COAT) || target.hasAbilityWithAttr("ReflectStatusMoveAbAttr"))
);
}

View File

@ -0,0 +1,41 @@
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { Phase } from "#app/phase";
import type { MagicCoatTag } from "#data/battler-tags";
import { BattlerTagType } from "#enums/battler-tag-type";
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc
import type { MoveId } from "#enums/move-id";
import type { Pokemon } from "#field/pokemon";
import type { Move } from "#types/move-types";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc
/**
* The phase where Pokemon reflect moves via {@linkcode MoveId.MAGIC_COAT | Magic Coat} or {@linkcode AbilityId.MAGIC_BOUNCE | Magic Bounce}.
*/
export class MoveReflectPhase extends Phase {
public override readonly phaseName = "MoveReflectPhase";
/** The {@linkcode Pokemon} doing the reflecting. */
private readonly pokemon: Pokemon;
/** The pokemon having originally used the move. */
private opponent: Pokemon;
/** The {@linkcode Move} being reflected. */
private readonly move: Move;
constructor(pokemon: Pokemon, opponent: Pokemon, move: Move) {
super();
this.pokemon = pokemon;
this.opponent = opponent;
this.move = move;
}
override start(): void {
this.pokemon.turnData.extraTurns++;
// Magic Coat takes precedeence over Magic Bounce if both apply at once
const magicCoatTag = this.pokemon.getTag(BattlerTagType.MAGIC_COAT) as MagicCoatTag | undefined;
if (magicCoatTag) {
magicCoatTag.apply(this.pokemon, this.opponent, this.move);
} else {
applyAbAttrs("ReflectStatusMoveAbAttr", { pokemon: this.pokemon, opponent: this.opponent, move: this.move });
}
super.end();
}
}

View File

@ -127,6 +127,7 @@ export interface SessionSaveData {
battleType: BattleType;
trainer: TrainerData;
gameVersion: string;
runNameText: string;
timestamp: number;
challenges: ChallengeData[];
mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME,
@ -206,10 +207,12 @@ export interface StarterData {
[key: number]: StarterDataEntry;
}
export interface TutorialFlags {
[key: string]: boolean;
}
// TODO: Rework into a bitmask
export type TutorialFlags = {
[key in Tutorial]: boolean;
};
// TODO: Rework into a bitmask
export interface SeenDialogues {
[key: string]: boolean;
}
@ -822,52 +825,51 @@ export class GameData {
return true; // TODO: is `true` the correct return value?
}
private loadGamepadSettings(): boolean {
Object.values(SettingGamepad)
.map(setting => setting as SettingGamepad)
.forEach(setting => setSettingGamepad(setting, settingGamepadDefaults[setting]));
private loadGamepadSettings(): void {
Object.values(SettingGamepad).forEach(setting => {
setSettingGamepad(setting, settingGamepadDefaults[setting]);
});
if (!localStorage.hasOwnProperty("settingsGamepad")) {
return false;
return;
}
const settingsGamepad = JSON.parse(localStorage.getItem("settingsGamepad")!); // TODO: is this bang correct?
for (const setting of Object.keys(settingsGamepad)) {
setSettingGamepad(setting as SettingGamepad, settingsGamepad[setting]);
}
return true; // TODO: is `true` the correct return value?
}
public saveTutorialFlag(tutorial: Tutorial, flag: boolean): boolean {
const key = getDataTypeKey(GameDataType.TUTORIALS);
let tutorials: object = {};
if (localStorage.hasOwnProperty(key)) {
tutorials = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct?
/**
* Save the specified tutorial as having the specified completion status.
* @param tutorial - The {@linkcode Tutorial} whose completion status is being saved
* @param status - The completion status to set
*/
public saveTutorialFlag(tutorial: Tutorial, status: boolean): void {
// Grab the prior save data tutorial
const saveDataKey = getDataTypeKey(GameDataType.TUTORIALS);
const tutorials: TutorialFlags = localStorage.hasOwnProperty(saveDataKey)
? JSON.parse(localStorage.getItem(saveDataKey)!)
: {};
// TODO: We shouldn't be storing this like that
for (const key of Object.values(Tutorial)) {
if (key === tutorial) {
tutorials[key] = status;
} else {
tutorials[key] ??= false;
}
}
Object.keys(Tutorial)
.map(t => t as Tutorial)
.forEach(t => {
const key = Tutorial[t];
if (key === tutorial) {
tutorials[key] = flag;
} else {
tutorials[key] ??= false;
}
});
localStorage.setItem(key, JSON.stringify(tutorials));
return true;
localStorage.setItem(saveDataKey, JSON.stringify(tutorials));
}
public getTutorialFlags(): TutorialFlags {
const key = getDataTypeKey(GameDataType.TUTORIALS);
const ret: TutorialFlags = {};
Object.values(Tutorial)
.map(tutorial => tutorial as Tutorial)
.forEach(tutorial => (ret[Tutorial[tutorial]] = false));
const ret: TutorialFlags = Object.values(Tutorial).reduce((acc, tutorial) => {
acc[Tutorial[tutorial]] = false;
return acc;
}, {} as TutorialFlags);
if (!localStorage.hasOwnProperty(key)) {
return ret;
@ -979,6 +981,54 @@ export class GameData {
});
}
async renameSession(slotId: number, newName: string): Promise<boolean> {
return new Promise(async resolve => {
if (slotId < 0) {
return resolve(false);
}
const sessionData: SessionSaveData | null = await this.getSession(slotId);
if (!sessionData) {
return resolve(false);
}
if (newName === "") {
return resolve(true);
}
sessionData.runNameText = newName;
const updatedDataStr = JSON.stringify(sessionData);
const encrypted = encrypt(updatedDataStr, bypassLogin);
const secretId = this.secretId;
const trainerId = this.trainerId;
if (bypassLogin) {
localStorage.setItem(
`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`,
encrypt(updatedDataStr, bypassLogin),
);
resolve(true);
return;
}
pokerogueApi.savedata.session
.update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted)
.then(error => {
if (error) {
console.error("Failed to update session name:", error);
resolve(false);
} else {
localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted);
updateUserInfo().then(success => {
if (success !== null && !success) {
return resolve(false);
}
});
resolve(true);
}
});
});
}
loadSession(slotId: number, sessionData?: SessionSaveData): Promise<boolean> {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this
return new Promise(async (resolve, reject) => {

View File

@ -0,0 +1,54 @@
import i18next from "i18next";
import type { InputFieldConfig } from "./form-modal-ui-handler";
import { FormModalUiHandler } from "./form-modal-ui-handler";
import type { ModalConfig } from "./modal-ui-handler";
export class RenameRunFormUiHandler extends FormModalUiHandler {
getModalTitle(_config?: ModalConfig): string {
return i18next.t("menu:renamerun");
}
getWidth(_config?: ModalConfig): number {
return 160;
}
getMargin(_config?: ModalConfig): [number, number, number, number] {
return [0, 0, 48, 0];
}
getButtonLabels(_config?: ModalConfig): string[] {
return [i18next.t("menu:rename"), i18next.t("menu:cancel")];
}
getReadableErrorMessage(error: string): string {
const colonIndex = error?.indexOf(":");
if (colonIndex > 0) {
error = error.slice(0, colonIndex);
}
return super.getReadableErrorMessage(error);
}
override getInputFieldConfigs(): InputFieldConfig[] {
return [{ label: i18next.t("menu:runName") }];
}
show(args: any[]): boolean {
if (!super.show(args)) {
return false;
}
if (this.inputs?.length) {
this.inputs.forEach(input => {
input.text = "";
});
}
const config = args[0] as ModalConfig;
this.submitAction = _ => {
this.sanitizeInputs();
const sanitizedName = btoa(encodeURIComponent(this.inputs[0].text));
config.buttonActions[0](sanitizedName);
return true;
};
return true;
}
}

View File

@ -26,6 +26,7 @@ import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text";
import { UiHandler } from "#ui/ui-handler";
import { addWindow } from "#ui/ui-theme";
import { formatFancyLargeNumber, formatLargeNumber, formatMoney, getPlayTimeString } from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
import RoundRectangle from "phaser3-rex-plugins/plugins/roundrectangle";
@ -207,6 +208,10 @@ export class RunInfoUiHandler extends UiHandler {
headerText.setOrigin(0, 0);
headerText.setPositionRelative(headerBg, 8, 4);
this.runContainer.add(headerText);
const runName = addTextObject(0, 0, this.runInfo.runNameText, TextStyle.WINDOW);
runName.setOrigin(0, 0);
runName.setPositionRelative(headerBg, 60, 4);
this.runContainer.add(runName);
}
/**
@ -702,10 +707,7 @@ export class RunInfoUiHandler extends UiHandler {
rules.push(i18next.t("challenges:inverseBattle.shortName"));
break;
default: {
const localizationKey = Challenges[this.runInfo.challenges[i].id]
.split("_")
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("");
const localizationKey = toCamelCase(Challenges[this.runInfo.challenges[i].id]);
rules.push(i18next.t(`challenges:${localizationKey}.name`));
break;
}

View File

@ -1,12 +1,14 @@
import { GameMode } from "#app/game-mode";
import { globalScene } from "#app/global-scene";
import { Button } from "#enums/buttons";
import { GameModes } from "#enums/game-modes";
import { TextStyle } from "#enums/text-style";
import { UiMode } from "#enums/ui-mode";
// biome-ignore lint/performance/noNamespaceImport: See `src/system/game-data.ts`
import * as Modifier from "#modifiers/modifier";
import type { SessionSaveData } from "#system/game-data";
import type { PokemonData } from "#system/pokemon-data";
import type { OptionSelectConfig } from "#ui/abstract-option-select-ui-handler";
import { MessageUiHandler } from "#ui/message-ui-handler";
import { RunDisplayMode } from "#ui/run-info-ui-handler";
import { addTextObject } from "#ui/text";
@ -15,7 +17,7 @@ import { fixedInt, formatLargeNumber, getPlayTimeString, isNullOrUndefined } fro
import i18next from "i18next";
const SESSION_SLOTS_COUNT = 5;
const SLOTS_ON_SCREEN = 3;
const SLOTS_ON_SCREEN = 2;
export enum SaveSlotUiMode {
LOAD,
@ -33,6 +35,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
private uiMode: SaveSlotUiMode;
private saveSlotSelectCallback: SaveSlotSelectCallback | null;
protected manageDataConfig: OptionSelectConfig;
private scrollCursor = 0;
@ -101,6 +104,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
processInput(button: Button): boolean {
const ui = this.getUi();
const manageDataOptions: any[] = [];
let success = false;
let error = false;
@ -109,14 +113,115 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
const originalCallback = this.saveSlotSelectCallback;
if (button === Button.ACTION) {
const cursor = this.cursor + this.scrollCursor;
if (this.uiMode === SaveSlotUiMode.LOAD && !this.sessionSlots[cursor].hasData) {
const sessionSlot = this.sessionSlots[cursor];
if (this.uiMode === SaveSlotUiMode.LOAD && !sessionSlot.hasData) {
error = true;
} else {
switch (this.uiMode) {
case SaveSlotUiMode.LOAD:
this.saveSlotSelectCallback = null;
originalCallback?.(cursor);
if (!sessionSlot.malformed) {
manageDataOptions.push({
label: i18next.t("menu:loadGame"),
handler: () => {
globalScene.ui.revertMode();
originalCallback?.(cursor);
return true;
},
keepOpen: false,
});
manageDataOptions.push({
label: i18next.t("saveSlotSelectUiHandler:renameRun"),
handler: () => {
globalScene.ui.revertMode();
ui.setOverlayMode(
UiMode.RENAME_RUN,
{
buttonActions: [
(sanitizedName: string) => {
const name = decodeURIComponent(atob(sanitizedName));
globalScene.gameData.renameSession(cursor, name).then(response => {
if (response[0] === false) {
globalScene.reset(true);
} else {
this.clearSessionSlots();
this.cursorObj = null;
this.populateSessionSlots();
this.setScrollCursor(0);
this.setCursor(0);
ui.revertMode();
ui.showText("", 0);
}
});
},
() => {
ui.revertMode();
},
],
},
"",
);
return true;
},
});
}
this.manageDataConfig = {
xOffset: 0,
yOffset: 48,
options: manageDataOptions,
maxOptions: 4,
};
manageDataOptions.push({
label: i18next.t("saveSlotSelectUiHandler:deleteRun"),
handler: () => {
globalScene.ui.revertMode();
ui.showText(i18next.t("saveSlotSelectUiHandler:deleteData"), null, () => {
ui.setOverlayMode(
UiMode.CONFIRM,
() => {
globalScene.gameData.tryClearSession(cursor).then(response => {
if (response[0] === false) {
globalScene.reset(true);
} else {
this.clearSessionSlots();
this.cursorObj = null;
this.populateSessionSlots();
this.setScrollCursor(0);
this.setCursor(0);
ui.revertMode();
ui.showText("", 0);
}
});
},
() => {
ui.revertMode();
ui.showText("", 0);
},
false,
0,
19,
import.meta.env.DEV ? 300 : 2000,
);
});
return true;
},
keepOpen: false,
});
manageDataOptions.push({
label: i18next.t("menuUiHandler:cancel"),
handler: () => {
globalScene.ui.revertMode();
return true;
},
keepOpen: true,
});
ui.setOverlayMode(UiMode.MENU_OPTION_SELECT, this.manageDataConfig);
break;
case SaveSlotUiMode.SAVE: {
const saveAndCallback = () => {
const originalCallback = this.saveSlotSelectCallback;
@ -161,6 +266,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
}
} else {
this.saveSlotSelectCallback = null;
ui.showText("", 0);
originalCallback?.(-1);
success = true;
}
@ -267,33 +373,34 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
this.cursorObj = globalScene.add.container(0, 0);
const cursorBox = globalScene.add.nineslice(
0,
0,
15,
"select_cursor_highlight_thick",
undefined,
296,
44,
294,
this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60,
6,
6,
6,
6,
);
const rightArrow = globalScene.add.image(0, 0, "cursor");
rightArrow.setPosition(160, 0);
rightArrow.setPosition(160, 15);
rightArrow.setName("rightArrow");
this.cursorObj.add([cursorBox, rightArrow]);
this.sessionSlotsContainer.add(this.cursorObj);
}
const cursorPosition = cursor + this.scrollCursor;
const cursorIncrement = cursorPosition * 56;
const cursorIncrement = cursorPosition * 76;
if (this.sessionSlots[cursorPosition] && this.cursorObj) {
const hasData = this.sessionSlots[cursorPosition].hasData;
const session = this.sessionSlots[cursorPosition];
const hasData = session.hasData && !session.malformed;
// If the session slot lacks session data, it does not move from its default, central position.
// Only session slots with session data will move leftwards and have a visible arrow.
if (!hasData) {
this.cursorObj.setPosition(151, 26 + cursorIncrement);
this.cursorObj.setPosition(151, 20 + cursorIncrement);
this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement);
} else {
this.cursorObj.setPosition(145, 26 + cursorIncrement);
this.cursorObj.setPosition(145, 20 + cursorIncrement);
this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement);
}
this.setArrowVisibility(hasData);
@ -311,7 +418,8 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
revertSessionSlot(slotIndex: number): void {
const sessionSlot = this.sessionSlots[slotIndex];
if (sessionSlot) {
sessionSlot.setPosition(0, slotIndex * 56);
const valueHeight = 76;
sessionSlot.setPosition(0, slotIndex * valueHeight);
}
}
@ -340,7 +448,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
this.setCursor(this.cursor, prevSlotIndex);
globalScene.tweens.add({
targets: this.sessionSlotsContainer,
y: this.sessionSlotsContainerInitialY - 56 * scrollCursor,
y: this.sessionSlotsContainerInitialY - 76 * scrollCursor,
duration: fixedInt(325),
ease: "Sine.easeInOut",
});
@ -374,12 +482,14 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
class SessionSlot extends Phaser.GameObjects.Container {
public slotId: number;
public hasData: boolean;
/** Indicates the save slot ran into an error while being loaded */
public malformed: boolean;
private slotWindow: Phaser.GameObjects.NineSlice;
private loadingLabel: Phaser.GameObjects.Text;
public saveData: SessionSaveData;
constructor(slotId: number) {
super(globalScene, 0, slotId * 56);
super(globalScene, 0, slotId * 76);
this.slotId = slotId;
@ -387,32 +497,89 @@ class SessionSlot extends Phaser.GameObjects.Container {
}
setup() {
const slotWindow = addWindow(0, 0, 304, 52);
this.add(slotWindow);
this.slotWindow = addWindow(0, 0, 304, 70);
this.add(this.slotWindow);
this.loadingLabel = addTextObject(152, 26, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW);
this.loadingLabel = addTextObject(152, 33, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW);
this.loadingLabel.setOrigin(0.5, 0.5);
this.add(this.loadingLabel);
}
/**
* Generates a name for sessions that don't have a name yet.
* @param data - The {@linkcode SessionSaveData} being checked
* @returns The default name for the given data.
*/
decideFallback(data: SessionSaveData): string {
let fallbackName = `${GameMode.getModeName(data.gameMode)}`;
switch (data.gameMode) {
case GameModes.CLASSIC:
fallbackName += ` (${globalScene.gameData.gameStats.classicSessionsPlayed + 1})`;
break;
case GameModes.ENDLESS:
case GameModes.SPLICED_ENDLESS:
fallbackName += ` (${globalScene.gameData.gameStats.endlessSessionsPlayed + 1})`;
break;
case GameModes.DAILY: {
const runDay = new Date(data.timestamp).toLocaleDateString();
fallbackName += ` (${runDay})`;
break;
}
case GameModes.CHALLENGE: {
const activeChallenges = data.challenges.filter(c => c.value !== 0);
if (activeChallenges.length === 0) {
break;
}
fallbackName = "";
for (const challenge of activeChallenges.slice(0, 3)) {
if (fallbackName !== "") {
fallbackName += ", ";
}
fallbackName += challenge.toChallenge().getName();
}
if (activeChallenges.length > 3) {
fallbackName += ", ...";
} else if (fallbackName === "") {
// Something went wrong when retrieving the names of the active challenges,
// so fall back to just naming the run "Challenge"
fallbackName = `${GameMode.getModeName(data.gameMode)}`;
}
break;
}
}
return fallbackName;
}
async setupWithData(data: SessionSaveData) {
const hasName = data?.runNameText;
this.remove(this.loadingLabel, true);
if (hasName) {
const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW);
this.add(nameLabel);
} else {
const fallbackName = this.decideFallback(data);
await globalScene.gameData.renameSession(this.slotId, fallbackName);
const nameLabel = addTextObject(8, 5, fallbackName, TextStyle.WINDOW);
this.add(nameLabel);
}
const gameModeLabel = addTextObject(
8,
5,
19,
`${GameMode.getModeName(data.gameMode) || i18next.t("gameMode:unknown")} - ${i18next.t("saveSlotSelectUiHandler:wave")} ${data.waveIndex}`,
TextStyle.WINDOW,
);
this.add(gameModeLabel);
const timestampLabel = addTextObject(8, 19, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW);
const timestampLabel = addTextObject(8, 33, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW);
this.add(timestampLabel);
const playTimeLabel = addTextObject(8, 33, getPlayTimeString(data.playTime), TextStyle.WINDOW);
const playTimeLabel = addTextObject(8, 47, getPlayTimeString(data.playTime), TextStyle.WINDOW);
this.add(playTimeLabel);
const pokemonIconsContainer = globalScene.add.container(144, 4);
const pokemonIconsContainer = globalScene.add.container(144, 16);
data.party.forEach((p: PokemonData, i: number) => {
const iconContainer = globalScene.add.container(26 * i, 0);
iconContainer.setScale(0.75);
@ -427,13 +594,9 @@ class SessionSlot extends Phaser.GameObjects.Container {
TextStyle.PARTY,
{ fontSize: "54px", color: "#f8f8f8" },
);
text.setShadow(0, 0, undefined);
text.setStroke("#424242", 14);
text.setOrigin(1, 0);
iconContainer.add(icon);
iconContainer.add(text);
text.setShadow(0, 0, undefined).setStroke("#424242", 14).setOrigin(1, 0);
iconContainer.add([icon, text]);
pokemonIconsContainer.add(iconContainer);
pokemon.destroy();
@ -441,7 +604,7 @@ class SessionSlot extends Phaser.GameObjects.Container {
this.add(pokemonIconsContainer);
const modifierIconsContainer = globalScene.add.container(148, 30);
const modifierIconsContainer = globalScene.add.container(148, 38);
modifierIconsContainer.setScale(0.5);
let visibleModifierIndex = 0;
for (const m of data.modifiers) {
@ -464,22 +627,33 @@ class SessionSlot extends Phaser.GameObjects.Container {
load(): Promise<boolean> {
return new Promise<boolean>(resolve => {
globalScene.gameData.getSession(this.slotId).then(async sessionData => {
// Ignore the results if the view was exited
if (!this.active) {
return;
}
if (!sessionData) {
this.hasData = false;
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
resolve(false);
return;
}
this.hasData = true;
this.saveData = sessionData;
await this.setupWithData(sessionData);
resolve(true);
});
globalScene.gameData
.getSession(this.slotId)
.then(async sessionData => {
// Ignore the results if the view was exited
if (!this.active) {
return;
}
this.hasData = !!sessionData;
if (!sessionData) {
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
resolve(false);
return;
}
this.saveData = sessionData;
this.setupWithData(sessionData);
resolve(true);
})
.catch(e => {
if (!this.active) {
return;
}
console.warn(`Failed to load session slot #${this.slotId}:`, e);
this.loadingLabel.setText(i18next.t("menu:failedToLoadSession"));
this.hasData = true;
this.malformed = true;
resolve(true);
});
});
}
}

View File

@ -31,7 +31,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler {
// we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key
// Return in the format expected by i18next
return middleKey ? `${topKey}:${middleKey.map(m => m).join(".")}.${t}` : `${topKey}:${t}`;
return middleKey ? `${topKey}:${middleKey.join(".")}.${t}` : `${topKey}:${t}`;
}
})
.filter(t => t);

View File

@ -60,6 +60,7 @@ import { addWindow } from "#ui/ui-theme";
import { UnavailableModalUiHandler } from "#ui/unavailable-modal-ui-handler";
import { executeIf } from "#utils/common";
import i18next from "i18next";
import { RenameRunFormUiHandler } from "./rename-run-ui-handler";
const transitionModes = [
UiMode.SAVE_SLOT,
@ -98,6 +99,7 @@ const noTransitionModes = [
UiMode.SESSION_RELOAD,
UiMode.UNAVAILABLE,
UiMode.RENAME_POKEMON,
UiMode.RENAME_RUN,
UiMode.TEST_DIALOGUE,
UiMode.AUTO_COMPLETE,
UiMode.ADMIN,
@ -168,6 +170,7 @@ export class UI extends Phaser.GameObjects.Container {
new UnavailableModalUiHandler(),
new GameChallengesUiHandler(),
new RenameFormUiHandler(),
new RenameRunFormUiHandler(),
new RunHistoryUiHandler(),
new RunInfoUiHandler(),
new TestDialogueUiHandler(UiMode.TEST_DIALOGUE),

View File

@ -1,345 +0,0 @@
import { allAbilities, allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Magic Bounce", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.moveset([MoveId.GROWL, MoveId.SPLASH])
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.MAGIC_BOUNCE)
.enemyMoveset(MoveId.SPLASH);
});
it("should reflect basic status moves", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce moves while the target is in the semi-invulnerable state", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0);
});
it("should individually bounce back multi-target moves", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL, 0);
game.move.use(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
const user = game.scene.getPlayerField()[0];
expect(user.getStatStage(Stat.ATK)).toBe(-2);
});
it("should still bounce back a move that would otherwise fail", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.field.getEnemyPokemon().setStatStage(Stat.ATK, -6);
game.move.use(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move that was just bounced", async () => {
game.override.ability(AbilityId.MAGIC_BOUNCE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should receive the stat change after reflecting a move back to a mirror armor user", async () => {
game.override.ability(AbilityId.MIRROR_ARMOR);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move from a mold breaker user", async () => {
game.override.ability(AbilityId.MOLD_BREAKER);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should bounce back a spread status move against both pokemon", async () => {
game.override.battleStyle("double").enemyMoveset([MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL, 0);
game.move.use(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -2)).toBeTruthy();
});
it("should only bounce spikes back once in doubles when both targets have magic bounce", async () => {
game.override.battleStyle("double").moveset([MoveId.SPIKES]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
it("should bounce spikes even when the target is protected", async () => {
game.override.moveset([MoveId.SPIKES]).enemyMoveset([MoveId.PROTECT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
});
it("should not bounce spikes when the target is in the semi-invulnerable state", async () => {
game.override.moveset([MoveId.SPIKES]).enemyMoveset([MoveId.FLY]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPIKES);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)!["layers"]).toBe(1);
});
it("should not bounce back curse", async () => {
game.override.moveset([MoveId.CURSE]);
await game.classicMode.startBattle([SpeciesId.GASTLY]);
game.move.select(MoveId.CURSE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getTag(BattlerTagType.CURSED)).toBeDefined();
});
// TODO: enable when Magic Bounce is fixed to properly reset the hit count
it.todo("should not cause encore to be interrupted after bouncing", async () => {
game.override.moveset([MoveId.SPLASH, MoveId.GROWL, MoveId.ENCORE]).enemyMoveset([MoveId.TACKLE, MoveId.GROWL]);
// game.override.ability(AbilityId.MOLD_BREAKER);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
const playerAbilitySpy = game.field.mockAbility(playerPokemon, AbilityId.MOLD_BREAKER);
// turn 1
game.move.select(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
// turn 2
playerAbilitySpy.mockRestore();
game.move.select(MoveId.GROWL);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE);
});
// TODO: encore is failing if the last move was virtual.
it.todo("should not cause the bounced move to count for encore", async () => {
game.override
.moveset([MoveId.SPLASH, MoveId.GROWL, MoveId.ENCORE])
.enemyMoveset([MoveId.GROWL, MoveId.TACKLE])
.enemyAbility(AbilityId.MAGIC_BOUNCE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
// turn 1
game.move.select(MoveId.GROWL);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
vi.spyOn(playerPokemon, "getAbility").mockReturnValue(allAbilities[AbilityId.MOLD_BREAKER]);
// turn 2
game.move.select(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => {
game.override.battleStyle("single").moveset([MoveId.STOMPING_TANTRUM, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM];
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(MoveId.CHARM);
await game.toNextTurn();
game.move.select(MoveId.STOMPING_TANTRUM);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
// TODO: stomping tantrum should consider moves that were bounced
it.todo("should boost enemy's stomping tantrum after failed bounce", async () => {
game.override.enemyMoveset([MoveId.STOMPING_TANTRUM, MoveId.SPLASH, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM];
const enemy = game.field.getEnemyPokemon();
vi.spyOn(stomping_tantrum, "calculateBattlePower");
// Spore gets reflected back onto us
game.move.select(MoveId.SPORE);
await game.move.selectEnemyMove(MoveId.CHARM);
await game.toNextTurn();
expect(enemy.getLastXMoves(1)[0].result).toBe("success");
game.move.select(MoveId.SPORE);
await game.move.selectEnemyMove(MoveId.STOMPING_TANTRUM);
await game.toNextTurn();
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
it("should respect immunities when bouncing a move", async () => {
vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100);
game.override.moveset([MoveId.THUNDER_WAVE, MoveId.GROWL]).ability(AbilityId.SOUNDPROOF);
await game.classicMode.startBattle([SpeciesId.PHANPY]);
// Turn 1 - thunder wave immunity test
game.move.select(MoveId.THUNDER_WAVE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status).toBeUndefined();
// Turn 2 - soundproof immunity test
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0);
});
it("should bounce back a move before the accuracy check", async () => {
game.override.moveset([MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const attacker = game.field.getPlayerPokemon();
vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0);
game.move.select(MoveId.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.SLEEP);
});
it("should take the accuracy of the magic bounce user into account", async () => {
game.override.moveset([MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const opponent = game.field.getEnemyPokemon();
vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0);
game.move.select(MoveId.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status).toBeUndefined();
});
it("should always apply the leftmost available target's magic bounce when bouncing moves like sticky webs in doubles", async () => {
game.override.battleStyle("double").moveset([MoveId.STICKY_WEB, MoveId.SPLASH, MoveId.TRICK_ROOM]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
const [enemy_1, enemy_2] = game.scene.getEnemyField();
// set speed just incase logic erroneously checks for speed order
enemy_1.setStat(Stat.SPD, enemy_2.getStat(Stat.SPD) + 1);
// turn 1
game.move.select(MoveId.STICKY_WEB, 0);
game.move.select(MoveId.TRICK_ROOM, 1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(
game.scene.arena
.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)
?.getSourcePokemon()
?.getBattlerIndex(),
).toBe(BattlerIndex.ENEMY);
game.scene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER, true);
// turn 2
game.move.select(MoveId.STICKY_WEB, 0);
game.move.select(MoveId.TRICK_ROOM, 1);
await game.phaseInterceptor.to("BerryPhase");
expect(
game.scene.arena
.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)
?.getSourcePokemon()
?.getBattlerIndex(),
).toBe(BattlerIndex.ENEMY);
});
it("should not bounce back status moves that hit through semi-invulnerable states", async () => {
game.override.moveset([MoveId.TOXIC, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
game.move.select(MoveId.TOXIC);
await game.move.selectEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.TOXIC);
expect(game.field.getPlayerPokemon().status).toBeUndefined();
game.override.ability(AbilityId.NO_GUARD);
game.move.select(MoveId.CHARM);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-2);
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0);
});
});

View File

@ -7,8 +7,6 @@ import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
// TODO: When Magic Bounce is implemented, make a test for its interaction with mirror guard, use screech
describe("Ability - Mirror Armor", () => {
let phaserGame: Phaser.Game;
let game: GameManager;

View File

@ -385,5 +385,5 @@ describe("Moves - Delayed Attacks", () => {
});
// TODO: Implement and move to a power spot's test file
it.todo("Should activate ally's power spot when switched in during single battles");
it.todo("should activate ally's power spot when switched in during single battles");
});

View File

@ -1,10 +1,14 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id";
import { invalidEncoreMoves } from "#moves/invalid-moves";
import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -31,7 +35,6 @@ describe("Moves - Encore", () => {
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH, MoveId.TACKLE])
.startingLevel(100)
.enemyLevel(100);
});
@ -39,76 +42,154 @@ describe("Moves - Encore", () => {
it("should prevent the target from using any move except the last used move", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const enemyPokemon = game.field.getEnemyPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.SPLASH);
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
expect(enemy.isMoveRestricted(MoveId.TACKLE)).toBe(true);
expect(enemy.isMoveRestricted(MoveId.SPLASH)).toBe(false);
});
it("should be removed on turn end after triggering thrice, ignoring Instruct", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const enemy = game.field.getEnemyPokemon();
enemy.pushMoveHistory({ move: MoveId.SPLASH, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
// Should have ticked down once
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
expect(enemy.getTag(BattlerTagType.ENCORE)!.turnCount).toBe(2);
game.move.use(MoveId.INSTRUCT);
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.ENCORE)!.turnCount).toBe(1);
game.move.use(MoveId.INSTRUCT);
await game.toEndOfTurn(false);
// Tag should still be present until the `TurnEndPhase` ticks it down
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
await game.toNextTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined();
game.move.select(MoveId.SPLASH);
// The enemy AI would normally be inclined to use Tackle, but should be
// forced into using Splash.
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.getLastXMoves().every(turnMove => turnMove.move === MoveId.SPLASH)).toBeTruthy();
expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE);
expect(game.textInterceptor.logs).toContain(
i18next.t("battlerTags:encoreOnRemove", {
pokemonNameWithAffix: getPokemonNameWithAffix(enemy),
}),
);
});
describe("should fail against the following moves:", () => {
it.each([
{ moveId: MoveId.TRANSFORM, name: "Transform", delay: false },
{ moveId: MoveId.MIMIC, name: "Mimic", delay: true },
{ moveId: MoveId.SKETCH, name: "Sketch", delay: true },
{ moveId: MoveId.ENCORE, name: "Encore", delay: false },
{ moveId: MoveId.STRUGGLE, name: "Struggle", delay: false },
])("$name", async ({ moveId, delay }) => {
game.override.enemyMoveset(moveId);
it("should override any upcoming moves with the Encored move, while still consuming PP", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
// Fake enemy having used tackle the turn prior
const enemy = game.field.getEnemyPokemon();
game.move.changeMoveset(enemy, [MoveId.SPLASH, MoveId.TACKLE]);
enemy.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.use(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
if (delay) {
game.move.select(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
}
game.move.select(MoveId.ENCORE);
const turnOrder = delay ? [BattlerIndex.PLAYER, BattlerIndex.ENEMY] : [BattlerIndex.ENEMY, BattlerIndex.PLAYER];
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeUndefined();
});
expect(enemy).toHaveUsedMove({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
expect(enemy).toHaveUsedPP(MoveId.TACKLE, 1);
});
it("Pokemon under both Encore and Torment should alternate between Struggle and restricted move", async () => {
const turnOrder = [BattlerIndex.ENEMY, BattlerIndex.PLAYER];
game.override.moveset([MoveId.ENCORE, MoveId.TORMENT, MoveId.SPLASH]);
// TODO: Make test using `changeMoveset`
it.todo("should end at turn end if the user forgets the Encored move");
it("should be removed at turn end if the Encored move runs out of PP", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
// Fake enemy having used tackle the turn prior
const enemy = game.field.getEnemyPokemon();
game.move.changeMoveset(enemy, [MoveId.SPLASH, MoveId.TACKLE]);
enemy.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
enemy.moveset[1].ppUsed = enemy.moveset[1].getMovePp() - 2;
game.move.use(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(enemy).toHaveUsedMove({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
expect(enemy).toHaveUsedPP(MoveId.TACKLE, enemy.moveset[1].getMovePp() - 1);
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
game.move.use(MoveId.SPLASH);
await game.toEndOfTurn();
expect(enemy).toHaveUsedPP(MoveId.TACKLE, "all");
expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE);
});
const invalidMoves = [...invalidEncoreMoves].map(m => ({
name: MoveId[m],
move: m,
}));
it.each(invalidMoves)("should fail if the target's last move is $name", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
enemy.pushMoveHistory({ move, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
game.move.use(MoveId.ENCORE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE);
});
it("should fail if the target has not made a move", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.ENCORE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE);
});
it("should force a Tormented target to alternate between Struggle and the Encored move", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.ENCORE);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
game.move.select(MoveId.TORMENT);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.TORMENT)).toBeDefined();
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
game.move.use(MoveId.TORMENT);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
game.move.select(MoveId.SPLASH);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
const lastMove = enemyPokemon.getLastXMoves()[0];
expect(lastMove?.move).toBe(MoveId.STRUGGLE);
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
expect(enemy).toHaveBattlerTag(BattlerTagType.TORMENT);
game.move.use(MoveId.SPLASH);
await game.toEndOfTurn();
expect(enemy).toHaveUsedMove(MoveId.STRUGGLE);
});
});

View File

@ -0,0 +1,390 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Reflecting effects", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.MAGIC_COAT);
});
describe("Reflecting effects", () => {
it("should reflect basic status moves, copying them against the user", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.toEndOfTurn();
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
expect(enemy).toHaveUsedMove({
move: MoveId.GROWL,
useMode: MoveUseMode.REFLECTED,
targets: [BattlerIndex.PLAYER],
});
expect(player).toHaveStatStage(Stat.ATK, -1);
});
it("should bounce back multi-target moves against each target", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
const [karp1, karp2] = game.scene.getPlayerField();
expect(karp1).toHaveStatStage(Stat.ATK, -2);
expect(karp2).toHaveStatStage(Stat.ATK, -2);
});
// TODO: This is broken - failed moves never make it to a MEP
it.todo("should still bounce back a move that would otherwise fail", async () => {
game.override.enemyAbility(AbilityId.INSOMNIA);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.YAWN);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon()).toHaveBattlerTag(BattlerTagType.DROWSY);
});
it("should not bounce back a move that was just bounced", async () => {
game.override.battleStyle("double").ability(AbilityId.MAGIC_BOUNCE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.use(MoveId.MAGIC_COAT, BattlerIndex.PLAYER);
game.move.use(MoveId.GROWL, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, 0);
});
it("should take precedence over Mirror Armor", async () => {
game.override.enemyAbility(AbilityId.MIRROR_ARMOR);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.toEndOfTurn();
const enemy = game.field.getPlayerPokemon();
expect(enemy).toHaveStatStage(Stat.ATK, -1);
expect(enemy).not.toHaveAbilityApplied(AbilityId.MIRROR_ARMOR);
});
it("should not bounce back non-reflectable effects", async () => {
await game.classicMode.startBattle([SpeciesId.GASTLY]);
game.move.use(MoveId.CURSE);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon()).toHaveBattlerTag(BattlerTagType.CURSED);
});
it("should not cause encore to be interrupted after bouncing", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
const playerAbilitySpy = game.field.mockAbility(playerPokemon, AbilityId.MOLD_BREAKER);
// turn 1
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
// turn 2
playerAbilitySpy.mockRestore();
game.move.use(MoveId.GROWL);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE);
});
it("should not cause the bounced move to count for encore", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.ABRA]);
// Fake abra having mold breaker and the enemy having used Tackle
const [, abra, enemy1] = game.scene.getField();
game.field.mockAbility(abra, AbilityId.MOLD_BREAKER);
game.field.mockAbility(enemy1, AbilityId.MAGIC_BOUNCE);
game.move.changeMoveset(enemy1, [MoveId.TACKLE, MoveId.SPLASH]);
enemy1.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
// Magikarp uses growl as Abra attempts to encore enemy 1
game.move.use(MoveId.GROWL, BattlerIndex.PLAYER);
game.move.use(MoveId.ENCORE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.toNextTurn();
console.log(enemy1.getLastXMoves(-1));
// Encore locked into Tackle, replacing the enemy's Growl with another Tackle
expect(enemy1.getTag(BattlerTagType.ENCORE)?.["moveId"]).toBe(MoveId.TACKLE);
expect(enemy1).toHaveUsedMove({ move: MoveId.TACKLE, useMode: MoveUseMode.NORMAL });
});
it("should boost stomping tantrum after a failed bounce", async () => {
game.override.ability(AbilityId.INSOMNIA);
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const enemy = game.field.getEnemyPokemon();
const powerSpy = vi.spyOn(allMoves[MoveId.STOMPING_TANTRUM], "calculateBattlePower");
// Yawn gets reflected back onto us, failing due to Insomnia
game.move.use(MoveId.YAWN);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn();
expect(enemy).toHaveUsedMove({ move: MoveId.YAWN, result: MoveResult.FAIL, useMode: MoveUseMode.REFLECTED });
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.STOMPING_TANTRUM);
await game.toNextTurn();
expect(powerSpy).toHaveReturnedWith(150);
});
it("should respect immunities when bouncing a move", async () => {
vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100);
game.override.ability(AbilityId.SOUNDPROOF);
await game.classicMode.startBattle([SpeciesId.PHANPY]);
// Turn 1 - thunder wave immunity test
game.move.use(MoveId.THUNDER_WAVE);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon().status).toBeUndefined();
// Turn 2 - soundproof immunity test
game.move.use(MoveId.GROWL);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon()).toHaveStatStage(Stat.ATK, 0);
});
it("should ignore the original move's accuracy and use the user's accuracy", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const magikarp = game.field.getPlayerPokemon();
const feebas = game.field.getEnemyPokemon();
const karpMissSpy = vi.spyOn(magikarp, "getAccuracyMultiplier").mockReturnValue(0);
// Turn 1: Force a miss on initial move
game.move.use(MoveId.SPORE);
await game.phaseInterceptor.to("MoveEndPhase");
await game.toEndOfTurn();
// todo change once matchers fixed
expect(magikarp.status?.effect).toBe(StatusEffect.SLEEP);
magikarp.clearStatus(false, false);
karpMissSpy.mockRestore();
vi.spyOn(feebas, "getAccuracyMultiplier").mockReturnValue(0);
// Turn 2: Force a miss on Feebas' reflected move
game.move.use(MoveId.SPORE);
await game.toEndOfTurn();
expect(magikarp.status?.effect).toBeFalsy();
});
});
describe("Magic Bounce", () => {
beforeEach(() => {
game.override.enemyAbility(AbilityId.MAGIC_BOUNCE).enemyMoveset(MoveId.SPLASH);
});
// TODO: Change post speed order rework to check the FASTER pokemon's ability
it("should only apply the leftmost available target's magic bounce when bouncing field-targeted moves in doubles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
const [enemy1, enemy2] = game.scene.getEnemyField();
// set speed to different values just in case logic erroneously checks for speed order
enemy1.setStat(Stat.SPD, enemy2.getStat(Stat.SPD) + 1);
// turn 1
game.move.use(MoveId.SPIKES, 0);
game.move.use(MoveId.TRICK_ROOM, 1);
await game.toNextTurn();
// TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, sourceId: enemy1.id, layers: 1})
const tag = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!;
expect(tag).toBeDefined();
expect(tag.getSourcePokemon()).toBe(enemy1);
expect(tag["layers"]).toBe(1);
game.scene.arena.removeTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER, true);
// turn 2
game.move.use(MoveId.SPIKES, 0);
game.move.use(MoveId.TRICK_ROOM, 1);
await game.toEndOfTurn();
// TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, sourceId: enemy1.id, layers: 1})
expect(
game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)?.getSourcePokemon()?.getBattlerIndex(),
).toBe(BattlerIndex.ENEMY);
});
it("should not bounce back status moves against semi-invulnerable Pokemon, even with No Guard", async () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
// Turn 1: use charm while enemy is airborne; misses
game.move.use(MoveId.CHARM);
await game.move.forceEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(player).toHaveStatStage(Stat.ATK, 0);
expect(enemy).toHaveStatStage(Stat.ATK, 0);
// Turn 2: Use Charm through No Guard; should not be reflected
game.field.mockAbility(player, AbilityId.NO_GUARD);
game.move.use(MoveId.CHARM);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(player).toHaveStatStage(Stat.ATK, 0);
expect(enemy).toHaveStatStage(Stat.ATK, -2);
});
it("should be overridden by Magic Coat without stacking", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const karp = game.field.getPlayerPokemon();
game.move.use(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.toEndOfTurn();
expect(karp).toHaveStatStage(Stat.ATK, -1);
expect(game.field.getEnemyPokemon()).not.toHaveAbilityApplied(AbilityId.MAGIC_BOUNCE);
});
it("should bounce spikes even when the target is protected", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.SPIKES);
await game.move.forceEnemyMove(MoveId.PROTECT);
await game.toEndOfTurn();
// TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, layers: 1})
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
});
it("should not break subsequent multi-strike moves", async () => {
await game.classicMode.startBattle([SpeciesId.PALKIA]);
game.move.use(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.SURGING_STRIKES);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
const enemy = game.field.getEnemyPokemon();
expect(enemy.turnData.hitCount).toBe(3);
});
});
describe("Magic Coat", () => {
it("should fail if the user goes last in the turn", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.PROTECT);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail if called again in the same turn from Instruct", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.INSTRUCT);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should not reflect moves used on the next turn", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
// turn 1
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn();
// turn 2
game.move.use(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1);
});
it("should still bounce back a move from a mold breaker user", async () => {
game.override.ability(AbilityId.MOLD_BREAKER).moveset([MoveId.GROWL]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, 0);
expect(game.field.getPlayerPokemon()).toHaveStatStage(Stat.ATK, -1);
});
it("should only bounce spikes back once when both targets use magic coat in doubles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.SPIKES);
await game.toEndOfTurn();
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
});
});

View File

@ -1,285 +0,0 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Magic Coat", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.MAGIC_COAT);
});
it("should fail if the user goes last in the turn", async () => {
game.override.moveset([MoveId.PROTECT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.PROTECT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail if called again in the same turn due to moves like instruct", async () => {
game.override.moveset([MoveId.INSTRUCT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.INSTRUCT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should not reflect moves used on the next turn", async () => {
game.override.moveset([MoveId.GROWL, MoveId.SPLASH]).enemyMoveset([MoveId.MAGIC_COAT, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
// turn 1
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn();
// turn 2
game.move.select(MoveId.GROWL);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should reflect basic status moves", async () => {
game.override.moveset([MoveId.GROWL]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should individually bounce back multi-target moves when used by both targets in doubles", async () => {
game.override.battleStyle("double").moveset([MoveId.GROWL, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL, 0);
game.move.select(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
const user = game.scene.getPlayerField()[0];
expect(user.getStatStage(Stat.ATK)).toBe(-2);
});
it("should bounce back a spread status move against both pokemon", async () => {
game.override
.battleStyle("double")
.moveset([MoveId.GROWL, MoveId.SPLASH])
.enemyMoveset([MoveId.SPLASH, MoveId.MAGIC_COAT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL, 0);
game.move.select(MoveId.SPLASH, 1);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.MAGIC_COAT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -1)).toBeTruthy();
});
it("should still bounce back a move that would otherwise fail", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.field.getEnemyPokemon().setStatStage(Stat.ATK, -6);
game.override.moveset([MoveId.GROWL]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move that was just bounced", async () => {
game.override
.battleStyle("double")
.ability(AbilityId.MAGIC_BOUNCE)
.moveset([MoveId.GROWL, MoveId.MAGIC_COAT])
.enemyMoveset([MoveId.SPLASH, MoveId.MAGIC_COAT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.select(MoveId.MAGIC_COAT, 0);
game.move.select(MoveId.GROWL, 1);
await game.move.selectEnemyMove(MoveId.MAGIC_COAT);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyField()[0].getStatStage(Stat.ATK)).toBe(0);
});
// todo while Mirror Armor is not implemented
it.todo("should receive the stat change after reflecting a move back to a mirror armor user", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should still bounce back a move from a mold breaker user", async () => {
game.override.ability(AbilityId.MOLD_BREAKER).moveset([MoveId.GROWL]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(0);
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should only bounce spikes back once when both targets use magic coat in doubles", async () => {
game.override.battleStyle("double").moveset([MoveId.SPIKES]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
it("should not bounce back curse", async () => {
game.override.moveset([MoveId.CURSE]);
await game.classicMode.startBattle([SpeciesId.GASTLY]);
game.move.select(MoveId.CURSE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getTag(BattlerTagType.CURSED)).toBeDefined();
});
// TODO: encore is failing if the last move was virtual.
it.todo("should not cause the bounced move to count for encore", async () => {
game.override
.moveset([MoveId.GROWL, MoveId.ENCORE])
.enemyMoveset([MoveId.MAGIC_COAT, MoveId.TACKLE])
.enemyAbility(AbilityId.MAGIC_BOUNCE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyPokemon = game.field.getEnemyPokemon();
// turn 1
game.move.select(MoveId.GROWL);
await game.move.selectEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn();
// turn 2
game.move.select(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => {
game.override.battleStyle("single").moveset([MoveId.STOMPING_TANTRUM, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM];
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(MoveId.CHARM);
await game.toNextTurn();
game.move.select(MoveId.STOMPING_TANTRUM);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo(
"should properly cause the enemy's stomping tantrum to be doubled in power after bouncing and failing",
async () => {
game.override.enemyMoveset([MoveId.STOMPING_TANTRUM, MoveId.SPLASH, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM];
const enemy = game.field.getEnemyPokemon();
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(MoveId.SPORE);
await game.move.selectEnemyMove(MoveId.CHARM);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getLastXMoves(1)[0].result).toBe("success");
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
await game.toNextTurn();
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
},
);
it("should respect immunities when bouncing a move", async () => {
vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100);
game.override.moveset([MoveId.THUNDER_WAVE, MoveId.GROWL]).ability(AbilityId.SOUNDPROOF);
await game.classicMode.startBattle([SpeciesId.PHANPY]);
// Turn 1 - thunder wave immunity test
game.move.select(MoveId.THUNDER_WAVE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status).toBeUndefined();
// Turn 2 - soundproof immunity test
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0);
});
it("should bounce back a move before the accuracy check", async () => {
game.override.moveset([MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const attacker = game.field.getPlayerPokemon();
vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0);
game.move.select(MoveId.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.SLEEP);
});
it("should take the accuracy of the magic bounce user into account", async () => {
game.override.moveset([MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const opponent = game.field.getEnemyPokemon();
vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0);
game.move.select(MoveId.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status).toBeUndefined();
});
});

View File

@ -112,7 +112,7 @@ describe("Weird Dream - Mystery Encounter", () => {
it("should transform the new party into new species, 2 at +90/+110, the rest at +40/50 BST", async () => {
await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty);
const pokemonPrior = scene.getPlayerParty().map(pokemon => pokemon);
const pokemonPrior = scene.getPlayerParty().slice();
const bstsPrior = pokemonPrior.map(species => species.getSpeciesForm().getBaseStatTotal());
await runMysteryEncounterToEnd(game, 1);

View File

@ -0,0 +1,82 @@
import * as account from "#app/account";
import * as bypassLoginModule from "#app/global-vars/bypass-login";
import { pokerogueApi } from "#app/plugins/api/pokerogue-api";
import type { SessionSaveData } from "#app/system/game-data";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("System - Rename Run", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.SPLASH])
.battleStyle("single")
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
describe("renameSession", () => {
beforeEach(() => {
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(false);
vi.spyOn(account, "updateUserInfo").mockImplementation(async () => [true, 1]);
});
it("should return false if slotId < 0", async () => {
const result = await game.scene.gameData.renameSession(-1, "Named Run");
expect(result).toEqual(false);
});
it("should return false if getSession returns null", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue(null as unknown as SessionSaveData);
const result = await game.scene.gameData.renameSession(-1, "Named Run");
expect(result).toEqual(false);
});
it("should return true if bypassLogin is true", async () => {
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true);
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(true);
});
it("should return false if api returns error", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("Unknown Error!");
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(false);
});
it("should return true if api is succesfull", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("");
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(true);
expect(account.updateUserInfo).toHaveBeenCalled();
});
});
});

View File

@ -371,9 +371,13 @@ export class GameManager {
console.log("==================[New Turn]==================");
}
/** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */
async toEndOfTurn() {
await this.phaseInterceptor.to("TurnEndPhase");
/**
* Transition to the {@linkcode TurnEndPhase | end of the current turn}.
* @param runTarget - Whether or not to run the {@linkcode TurnEndPhase}; default `true`
* @returns A Promise that resolves once the turn has ended.
*/
async toEndOfTurn(runTarget = true): Promise<void> {
await this.phaseInterceptor.to("TurnEndPhase", runTarget);
console.log("==================[End of Turn]==================");
}
@ -532,14 +536,16 @@ export class GameManager {
}
/**
* Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value.
* Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value. \
* Used to manually modify Pokemon turn order.
* Note: This *DOES NOT* account for priority.
* @param order - The turn order to set as an array of {@linkcode BattlerIndex}es.
*
* @param order - The turn order to set, as an array of {@linkcode BattlerIndex}es
* @example
* ```ts
* await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]);
* ```
* @remarks
* This *does not* account for priority and will override Trick Room's effect.
*/
async setTurnOrder(order: BattlerIndex[]): Promise<void> {
await this.phaseInterceptor.to(TurnStartPhase, false);