Compare commits

...

47 Commits

Author SHA1 Message Date
Alex Van Liew 5f9f96cce5
Merge 559097d309 into 6030b780f2 2024-09-17 20:07:27 -07:00
flx-sta 6030b780f2
[Move][Mirror] Update HitTagAttr attributes v2 (#4297)
* [Move] Updated HitAttr tags

Affects Whirlwind/Fly, Steamroller/Minimize, and Malicious Moonsault/Minimize

* [Move] Update for MinimizeAccuracyAttr

Affects Steamroller and Malicious Moonsault

* add: whirlwind test

* add: steamroller test

* rename: `AlwaysHitMinimizeAttr` (from `MinimizeAccuracyAttr`)

* rename: `DealsDoubleDamageToTagAttr` (from `HitsTagAttr`)

---------

Co-authored-by: chaosgrimmon <31082757+chaosgrimmon@users.noreply.github.com>
2024-09-17 22:41:46 -04:00
Jannik Tappert e386504977
[BUG][Beta] Fix English Status Symbols (#4293)
* Have English Status Symbols show up again :)

* It now uses the function instead

---------

Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com>
2024-09-17 22:26:47 -04:00
podar 106ed6b27b
[Bug] Using default animation for errors that occur. (#4266)
* Using default animation for errors that occur.

* Renaming function to make it clear that logging happens

* Updating logging for missing animations

* Missed committing linter changes

* Update src/data/battle-anims.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/data/battle-anims.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/data/battle-anims.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com>
2024-09-17 22:15:47 -04:00
flx-sta 4389bff5d0
[Bug] Fix stat-protection-attribute not taking inverts (e.g. Contrary) into account (#4031)
* add generic types to`Pokemon.getAbilityAttrs()`

* add invert check to `ProtectStatAbAttr.apply...`

This makes sure that a stat is only protected if no other ability inverts the change. E.g. `Contrary` inverts any decrease to an increase

* migrate contrary.test.ts to game.classicMode

* move `StatStageChangeMultiplierAbAttr` resolve above `ProtectStatAbAttr`

The effect of StatStageChangeMultiplierAbAttr is now applied before resolving any ProtectStatAbAttr. Thus the stage (level) of the BattleStat change was properly altered at the time of resolving the protection

* revert ability.ts changes

* add automated tests for `Clear Body` + `Contrary`

* StateStageChangePhase replace ~~`IntegerHolder`~~ with `NumberHolder`

Update Utils import and replace all occurcences of `Utils.`

* contrary.test.ts: remove `js` import
2024-09-17 19:14:41 -07:00
Madmadness65 00ba2eebc8
Add new biome BGM by Firel (#4301) 2024-09-17 21:57:34 +01:00
Leo Kim fe69bd2b55
add missing translation for korean (#4295) 2024-09-17 10:50:51 -07:00
Dakurei 1fb5389765
Auto center the window vertically (#2686)
+ When the game is played in landscape format
    or in portrait format when it's not a touchscreen
2024-09-17 17:55:50 +01:00
NightKev 559097d309 More cleanup 2024-09-17 05:11:01 -07:00
NightKev b400cbf0c1 Test updating 2024-09-17 04:34:10 -07:00
NightKev d1f0bf8c2f Merge branch 'beta' into pursuit-impl 2024-09-17 04:04:56 -07:00
Alex Van Liew 5a582db148 add more failing tests cause I found more stuff that's broke 2024-08-20 19:22:03 -07:00
Alex Van Liew c47c82e634 fix onNextPhase callback 2024-08-20 19:13:14 -07:00
Alex Van Liew e80f158691 fix imports 2024-08-20 16:00:34 -07:00
Alex Van Liew 6faaa9edaa Merge branch 'beta' into pursuit-impl 2024-08-20 15:56:54 -07:00
Alex Van Liew 772a6fc079 command order test 2024-08-20 15:50:16 -07:00
Alex Van Liew f0eac00179 fix follow me with pursuit 2024-08-15 15:29:04 -07:00
Alex Van Liew b057c144ac Merge branch 'beta' into pursuit-impl 2024-08-15 14:59:07 -07:00
snoozbuster 113e8ab3bd fix another broken test 2024-08-13 21:15:02 -07:00
snoozbuster f9ea476ef3 fix more test bugs 2024-08-13 21:10:14 -07:00
snoozbuster 88dcaa3d20 fix more bad merge (source move not passed to tag) 2024-08-13 21:10:13 -07:00
snoozbuster f5fabbb159 fix bad merge 2024-08-13 21:10:13 -07:00
snoozbuster 74cbdbb837 finish writing pursuit tests (5 failures to fix) 2024-08-13 21:10:12 -07:00
snoozbuster daebb3a91c fix some issues with prompt timeouts 2024-08-13 21:10:12 -07:00
snoozbuster 1b7ccb1912 don't crash if a test faints a pokemon between CommandPhase and TurnStartPhase 2024-08-13 21:10:11 -07:00
snoozbuster d45ed3d9d3 add onNextPhase and advance to phaseInterceptor 2024-08-13 21:10:11 -07:00
snoozbuster eb11b951e6 log turn order changes 2024-08-13 21:10:11 -07:00
snoozbuster b681bc386a add better phase logging and a fast timeout for stalled prompts 2024-08-13 21:10:10 -07:00
snoozbuster c31809ed35 add helper for forcing AI move targetting 2024-08-13 21:10:10 -07:00
snoozbuster 1bb79b87f6 slightly better overrides 2024-08-13 21:10:09 -07:00
snoozbuster c628759756 fix some types 2024-08-13 21:10:09 -07:00
snoozbuster 8a4a297cd6 remove whenAboutToRun 2024-08-13 21:10:08 -07:00
snoozbuster 9d721f6610 basic pursuit functionality tested 2024-08-13 19:59:57 -07:00
snoozbuster c080ba0b46 add methods to overridesHelper for new overrides 2024-08-13 19:59:57 -07:00
snoozbuster eb1d195866 add shorthand for setting a moveset to all one move 2024-08-13 19:59:57 -07:00
snoozbuster c6e93985ca bonus uturn test 2024-08-13 19:59:56 -07:00
snoozbuster d6ec174715 add trainer party override 2024-08-13 19:59:56 -07:00
snoozbuster e325af1f0b rename overide to match 2024-08-13 19:59:56 -07:00
Alex Van Liew c1019bac39 fix switching override in doubles 2024-08-13 19:59:56 -07:00
Alex Van Liew a086b3a0ad fix switch moves again 2024-08-13 19:59:56 -07:00
Alex Van Liew 5e21005fe9 fail more obviously 2024-08-13 19:59:55 -07:00
snoozbuster a8b4d6a9de minor strict-null issue 2024-08-13 19:59:55 -07:00
snoozbuster 7517e16c84 mostly implemented (missing some edge cases/interactions) 2024-08-13 19:59:55 -07:00
snoozbuster a3a42931ba add move attrs for pursuit 2024-08-13 19:59:55 -07:00
snoozbuster a0f8a4df5b add new tag types for pursuit 2024-08-13 19:59:09 -07:00
snoozbuster 939a3f32a8 minor doc improvement 2024-08-13 19:58:47 -07:00
snoozbuster f428fd114c add override for forcing switches 2024-08-13 19:58:47 -07:00
40 changed files with 1551 additions and 198 deletions

View File

@ -55,7 +55,7 @@ Check out [Github Issues](https://github.com/pagefaultgames/pokerogue/issues) to
- Pokémon Sword/Shield - Pokémon Sword/Shield
- Pokémon Legends: Arceus - Pokémon Legends: Arceus
- Pokémon Scarlet/Violet - Pokémon Scarlet/Violet
- Firel (Custom Laboratory, Metropolis, Seabed, and Space biome music) - Firel (Custom Ice Cave, Laboratory, Metropolis, Plains, Power Plant, Seabed, Space, and Volcano biome music)
- Lmz (Custom Jungle biome music) - Lmz (Custom Jungle biome music)
- Andr06 (Custom Slum and Sea biome music) - Andr06 (Custom Slum and Sea biome music)

View File

@ -26,11 +26,37 @@ body {
#app { #app {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center;
} }
#app > div:first-child { #app > div:first-child {
transform-origin: center !important;
}
/*
Supports automatic vertical centering as suggested in PR#1114, but only via CSS
Condition factorized to deduce CSS rules:
true if (isLandscape && !isMobile() && !hasTouchscreen() || (hasTouchscreen() && !isTouchControlsEnabled))
*/
/* isLandscape && !isMobile() && !hasTouchscreen() */
@media (orientation: landscape) and (pointer: fine) {
#app {
align-items: center;
}
}
@media (pointer: coarse) {
/* hasTouchscreen() && !isTouchControlsEnabled */
body:has(> #touchControls[class=visible]) #app {
align-items: start;
}
body:has(> #touchControls[class=visible]) #app > div:first-child {
transform-origin: top !important; transform-origin: top !important;
} }
}
#layout:fullscreen #dpad, #layout:fullscreen { #layout:fullscreen #dpad, #layout:fullscreen {
bottom: 6rem; bottom: 6rem;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2625,7 +2625,11 @@ export class PreStatStageChangeAbAttr extends AbAttr {
} }
} }
/**
* Protect one or all {@linkcode BattleStat} from reductions caused by other Pokémon's moves and Abilities
*/
export class ProtectStatAbAttr extends PreStatStageChangeAbAttr { export class ProtectStatAbAttr extends PreStatStageChangeAbAttr {
/** {@linkcode BattleStat} to protect or `undefined` if **all** {@linkcode BattleStat} are protected */
private protectedStat?: BattleStat; private protectedStat?: BattleStat;
constructor(protectedStat?: BattleStat) { constructor(protectedStat?: BattleStat) {
@ -2634,7 +2638,17 @@ export class ProtectStatAbAttr extends PreStatStageChangeAbAttr {
this.protectedStat = protectedStat; this.protectedStat = protectedStat;
} }
applyPreStatStageChange(_pokemon: Pokemon, _passive: boolean, simulated: boolean, stat: BattleStat, cancelled: Utils.BooleanHolder, _args: any[]): boolean { /**
* Apply the {@linkcode ProtectedStatAbAttr} to an interaction
* @param _pokemon
* @param _passive
* @param simulated
* @param stat the {@linkcode BattleStat} being affected
* @param cancelled The {@linkcode Utils.BooleanHolder} that will be set to true if the stat is protected
* @param _args
* @returns true if the stat is protected, false otherwise
*/
applyPreStatStageChange(_pokemon: Pokemon, _passive: boolean, _simulated: boolean, stat: BattleStat, cancelled: Utils.BooleanHolder, _args: any[]): boolean {
if (Utils.isNullOrUndefined(this.protectedStat) || stat === this.protectedStat) { if (Utils.isNullOrUndefined(this.protectedStat) || stat === this.protectedStat) {
cancelled.value = true; cancelled.value = true;
return true; return true;
@ -3757,7 +3771,7 @@ export class StatStageChangeMultiplierAbAttr extends AbAttr {
this.multiplier = multiplier; this.multiplier = multiplier;
} }
apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean {
(args[0] as Utils.IntegerHolder).value *= this.multiplier; (args[0] as Utils.IntegerHolder).value *= this.multiplier;
return true; return true;

View File

@ -488,14 +488,14 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise<void> {
} else { } else {
moveAnims.set(move, null); moveAnims.set(move, null);
const defaultMoveAnim = allMoves[move] instanceof AttackMove ? Moves.TACKLE : allMoves[move] instanceof SelfStatusMove ? Moves.FOCUS_ENERGY : Moves.TAIL_WHIP; const defaultMoveAnim = allMoves[move] instanceof AttackMove ? Moves.TACKLE : allMoves[move] instanceof SelfStatusMove ? Moves.FOCUS_ENERGY : Moves.TAIL_WHIP;
const moveName = Moves[move].toLowerCase().replace(/\_/g, "-");
const fetchAnimAndResolve = (move: Moves) => { const fetchAnimAndResolve = (move: Moves) => {
scene.cachedFetch(`./battle-anims/${moveName}.json`) scene.cachedFetch(`./battle-anims/${Utils.animationFileName(move)}.json`)
.then(response => { .then(response => {
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
if (!response.ok || contentType?.indexOf("application/json") === -1) { if (!response.ok || contentType?.indexOf("application/json") === -1) {
console.error(`Could not load animation file for move '${moveName}'`, response.status, response.statusText); useDefaultAnim(move, defaultMoveAnim);
populateMoveAnim(move, moveAnims.get(defaultMoveAnim)); logMissingMoveAnim(move, response.status, response.statusText);
return resolve(); return resolve();
} }
return response.json(); return response.json();
@ -515,6 +515,11 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise<void> {
} else { } else {
resolve(); resolve();
} }
})
.catch(error => {
useDefaultAnim(move, defaultMoveAnim);
logMissingMoveAnim(move, error);
return resolve();
}); });
}; };
fetchAnimAndResolve(move); fetchAnimAndResolve(move);
@ -522,6 +527,29 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise<void> {
}); });
} }
/**
* Populates the default animation for the given move.
*
* @param move the move to populate an animation for
* @param defaultMoveAnim the move to use as the default animation
*/
function useDefaultAnim(move: Moves, defaultMoveAnim: Moves) {
populateMoveAnim(move, moveAnims.get(defaultMoveAnim));
}
/**
* Helper method for printing a warning to the console when a move animation is missing.
*
* @param move the move to populate an animation for
* @param optionalParams parameters to add to the error logging
*
* @remarks use {@linkcode useDefaultAnim} to use a default animation
*/
function logMissingMoveAnim(move: Moves, ...optionalParams: any[]) {
const moveName = Utils.animationFileName(move);
console.warn(`Could not load animation file for move '${moveName}'`, ...optionalParams);
}
/** /**
* Fetches animation configs to be used in a Mystery Encounter * Fetches animation configs to be used in a Mystery Encounter
* @param scene * @param scene

View File

@ -2490,6 +2490,10 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new SubstituteTag(sourceMove, sourceId); return new SubstituteTag(sourceMove, sourceId);
case BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON: case BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON:
return new MysteryEncounterPostSummonTag(); return new MysteryEncounterPostSummonTag();
case BattlerTagType.ANTICIPATING_ACTION:
return new BattlerTag(BattlerTagType.ANTICIPATING_ACTION, BattlerTagLapseType.TURN_END, 1, sourceMove);
case BattlerTagType.ESCAPING:
return new BattlerTag(BattlerTagType.ESCAPING, BattlerTagLapseType.TURN_END, 1, sourceMove);
case BattlerTagType.NONE: case BattlerTagType.NONE:
default: default:
return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);

View File

@ -1086,8 +1086,7 @@ export class AddBattlerTagHeaderAttr extends MoveHeaderAttr {
} }
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
user.addTag(this.tagType); return user.addTag(this.tagType, 0, move.id);
return true;
} }
} }
@ -3839,7 +3838,7 @@ export class StormAccuracyAttr extends VariableAccuracyAttr {
* @extends VariableAccuracyAttr * @extends VariableAccuracyAttr
* @see {@linkcode apply} * @see {@linkcode apply}
*/ */
export class MinimizeAccuracyAttr extends VariableAccuracyAttr { export class AlwaysHitMinimizeAttr extends VariableAccuracyAttr {
/** /**
* @see {@linkcode apply} * @see {@linkcode apply}
* @param user N/A * @param user N/A
@ -3887,6 +3886,21 @@ export class BlizzardAccuracyAttr extends VariableAccuracyAttr {
} }
} }
const isPursuingFunc = (user: Pokemon, target: Pokemon) =>
user.getTag(BattlerTagType.ANTICIPATING_ACTION) && target.getTag(BattlerTagType.ESCAPING);
export class PursuitAccuracyAttr extends VariableAccuracyAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (isPursuingFunc(user, target)) {
const accuracy = args[0] as Utils.NumberHolder;
accuracy.value = -1;
return true;
}
return false;
}
}
export class VariableMoveCategoryAttr extends MoveAttr { export class VariableMoveCategoryAttr extends MoveAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
return false; return false;
@ -4438,8 +4452,21 @@ export class TypelessAttr extends MoveAttr { }
/** /**
* Attribute used for moves which ignore redirection effects, and always target their original target, i.e. Snipe Shot * Attribute used for moves which ignore redirection effects, and always target their original target, i.e. Snipe Shot
* Bypasses Storm Drain, Follow Me, Ally Switch, and the like. * Bypasses Storm Drain, Follow Me, Ally Switch, and the like.
*
* Optionally accepts a function to run which can be used to conditionally bypass redirection effects.
*/ */
export class BypassRedirectAttr extends MoveAttr { } export class BypassRedirectAttr extends MoveAttr {
private bypassConditionFn?: (user: Pokemon | null, target: Pokemon | null, move: Move) => boolean;
constructor(bypassConditionFn?: (user: Pokemon | null, target: Pokemon | null, move: Move) => boolean) {
super();
this.bypassConditionFn = bypassConditionFn;
}
apply(user: Pokemon | null, target: Pokemon | null, move: Move) {
return this.bypassConditionFn?.(user, target, move) ?? true;
}
}
export class FrenzyAttr extends MoveEffectAttr { export class FrenzyAttr extends MoveEffectAttr {
constructor() { constructor() {
@ -4699,8 +4726,8 @@ export class LapseBattlerTagAttr extends MoveEffectAttr {
export class RemoveBattlerTagAttr extends MoveEffectAttr { export class RemoveBattlerTagAttr extends MoveEffectAttr {
public tagTypes: BattlerTagType[]; public tagTypes: BattlerTagType[];
constructor(tagTypes: BattlerTagType[], selfTarget: boolean = false) { constructor(tagTypes: BattlerTagType[], selfTarget: boolean = false, trigger?: MoveEffectTrigger) {
super(selfTarget); super(selfTarget, trigger);
this.tagTypes = tagTypes; this.tagTypes = tagTypes;
} }
@ -4855,10 +4882,10 @@ export class RemoveAllSubstitutesAttr extends MoveEffectAttr {
* Attribute used when a move hits a {@linkcode BattlerTagType} for double damage * Attribute used when a move hits a {@linkcode BattlerTagType} for double damage
* @extends MoveAttr * @extends MoveAttr
*/ */
export class HitsTagAttr extends MoveAttr { export class DealsDoubleDamageToTagAttr extends MoveAttr {
/** The {@linkcode BattlerTagType} this move hits */ /** The {@linkcode BattlerTagType} this move hits */
public tagType: BattlerTagType; public tagType: BattlerTagType;
/** Should this move deal double damage against {@linkcode HitsTagAttr.tagType}? */ /** Should this move deal double damage against {@linkcode DealsDoubleDamageToTagAttr.tagType}? */
public doubleDamage: boolean; public doubleDamage: boolean;
constructor(tagType: BattlerTagType, doubleDamage?: boolean) { constructor(tagType: BattlerTagType, doubleDamage?: boolean) {
@ -5157,6 +5184,11 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
private user: boolean; private user: boolean;
private batonPass: boolean; private batonPass: boolean;
/**
* @param user Indicates if this move effect will switch out the user of the
* move (true) or the target of the move (false).
* @param batonPass Indicates if this move is a usage of Baton Pass.
*/
constructor(user?: boolean, batonPass?: boolean) { constructor(user?: boolean, batonPass?: boolean) {
super(false, MoveEffectTrigger.POST_APPLY, false, true); super(false, MoveEffectTrigger.POST_APPLY, false, true);
this.user = !!user; this.user = !!user;
@ -5175,26 +5207,76 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
return resolve(false); return resolve(false);
} }
// Move the switch out logic inside the conditional block
// This ensures that the switch out only happens when the conditions are met
const switchOutTarget = this.user ? user : target; const switchOutTarget = this.user ? user : target;
let willBePursued = false;
if (switchOutTarget.hp > 0 && !this.batonPass && this.user && move.id !== Moves.TELEPORT) {
switchOutTarget.addTag(BattlerTagType.ESCAPING);
const opposingField = user.isPlayer() ? user.scene.getEnemyField() : user.scene.getPlayerField();
const opposingPursuitUsers = opposingField
.filter((op: Pokemon) => op.getTag(BattlerTagType.ANTICIPATING_ACTION)?.sourceMove === Moves.PURSUIT)
.sort((a, b) => b.turnData.order - a.turnData.order);
if (opposingPursuitUsers.length) {
willBePursued = true;
opposingPursuitUsers.forEach(pursuiter => {
if (user.scene.tryRemovePhase(p => p instanceof MovePhase && p.pokemon.id === pursuiter.id)) {
user.scene.prependToPhase(
new MovePhase(
user.scene,
pursuiter,
[switchOutTarget.getBattlerIndex()],
pursuiter.getMoveset().find(m =>
m?.moveId === Moves.PURSUIT) || new PokemonMove(Moves.PURSUIT),
false,
false
),
MoveEndPhase
);
}
});
}
}
if (switchOutTarget instanceof PlayerPokemon) { if (switchOutTarget instanceof PlayerPokemon) {
if (!willBePursued) {
switchOutTarget.leaveField(!this.batonPass); switchOutTarget.leaveField(!this.batonPass);
}
if (switchOutTarget.hp > 0) { if (switchOutTarget.hp > 0) {
user.scene.prependToPhase(new SwitchPhase(user.scene, switchOutTarget.getFieldIndex(), true, true), MoveEndPhase); user.scene.prependToPhase(
new SwitchPhase(user.scene, switchOutTarget.getFieldIndex(), "moveEffect", willBePursued),
MoveEndPhase
);
resolve(true); resolve(true);
} else { } else {
resolve(false); resolve(false);
} }
return; return;
} else if (user.scene.currentBattle.battleType !== BattleType.WILD) { } else if (user.scene.currentBattle.battleType !== BattleType.WILD) {
if (!user.scene.currentBattle.trainer) {
return resolve(false); // what are we even doing here
}
// Switch out logic for trainer battles // Switch out logic for trainer battles
if (!willBePursued) {
switchOutTarget.leaveField(!this.batonPass); switchOutTarget.leaveField(!this.batonPass);
}
if (switchOutTarget.hp > 0) { if (switchOutTarget.hp > 0) {
// for opponent switching out // for opponent switching out
user.scene.prependToPhase(new SwitchSummonPhase(user.scene, switchOutTarget.getFieldIndex(), (user.scene.currentBattle.trainer ? user.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), false, this.batonPass, false), MoveEndPhase); user.scene.prependToPhase(
new SwitchSummonPhase(
user.scene,
switchOutTarget.getFieldIndex(),
user.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot),
willBePursued,
this.batonPass,
false
),
MoveEndPhase
);
} }
} else { } else {
// Switch out logic for everything else (eg: WILD battles) // Switch out logic for everything else (eg: WILD battles)
@ -6752,12 +6834,11 @@ export function initMoves() {
new AttackMove(Moves.CUT, Type.NORMAL, MoveCategory.PHYSICAL, 50, 95, 30, -1, 0, 1) new AttackMove(Moves.CUT, Type.NORMAL, MoveCategory.PHYSICAL, 50, 95, 30, -1, 0, 1)
.slicingMove(), .slicingMove(),
new AttackMove(Moves.GUST, Type.FLYING, MoveCategory.SPECIAL, 40, 100, 35, -1, 0, 1) new AttackMove(Moves.GUST, Type.FLYING, MoveCategory.SPECIAL, 40, 100, 35, -1, 0, 1)
.attr(HitsTagAttr, BattlerTagType.FLYING, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.FLYING, true)
.windMove(), .windMove(),
new AttackMove(Moves.WING_ATTACK, Type.FLYING, MoveCategory.PHYSICAL, 60, 100, 35, -1, 0, 1), new AttackMove(Moves.WING_ATTACK, Type.FLYING, MoveCategory.PHYSICAL, 60, 100, 35, -1, 0, 1),
new StatusMove(Moves.WHIRLWIND, Type.NORMAL, -1, 20, -1, -6, 1) new StatusMove(Moves.WHIRLWIND, Type.NORMAL, -1, 20, -1, -6, 1)
.attr(ForceSwitchOutAttr) .attr(ForceSwitchOutAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING, false)
.ignoresSubstitute() .ignoresSubstitute()
.hidesTarget() .hidesTarget()
.windMove(), .windMove(),
@ -6770,8 +6851,8 @@ export function initMoves() {
new AttackMove(Moves.SLAM, Type.NORMAL, MoveCategory.PHYSICAL, 80, 75, 20, -1, 0, 1), new AttackMove(Moves.SLAM, Type.NORMAL, MoveCategory.PHYSICAL, 80, 75, 20, -1, 0, 1),
new AttackMove(Moves.VINE_WHIP, Type.GRASS, MoveCategory.PHYSICAL, 45, 100, 25, -1, 0, 1), new AttackMove(Moves.VINE_WHIP, Type.GRASS, MoveCategory.PHYSICAL, 45, 100, 25, -1, 0, 1),
new AttackMove(Moves.STOMP, Type.NORMAL, MoveCategory.PHYSICAL, 65, 100, 20, 30, 0, 1) new AttackMove(Moves.STOMP, Type.NORMAL, MoveCategory.PHYSICAL, 65, 100, 20, 30, 0, 1)
.attr(MinimizeAccuracyAttr) .attr(AlwaysHitMinimizeAttr)
.attr(HitsTagAttr, BattlerTagType.MINIMIZED, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.MINIMIZED, true)
.attr(FlinchAttr), .attr(FlinchAttr),
new AttackMove(Moves.DOUBLE_KICK, Type.FIGHTING, MoveCategory.PHYSICAL, 30, 100, 30, -1, 0, 1) new AttackMove(Moves.DOUBLE_KICK, Type.FIGHTING, MoveCategory.PHYSICAL, 30, 100, 30, -1, 0, 1)
.attr(MultiHitAttr, MultiHitType._2), .attr(MultiHitAttr, MultiHitType._2),
@ -6795,8 +6876,8 @@ export function initMoves() {
.attr(OneHitKOAccuracyAttr), .attr(OneHitKOAccuracyAttr),
new AttackMove(Moves.TACKLE, Type.NORMAL, MoveCategory.PHYSICAL, 40, 100, 35, -1, 0, 1), new AttackMove(Moves.TACKLE, Type.NORMAL, MoveCategory.PHYSICAL, 40, 100, 35, -1, 0, 1),
new AttackMove(Moves.BODY_SLAM, Type.NORMAL, MoveCategory.PHYSICAL, 85, 100, 15, 30, 0, 1) new AttackMove(Moves.BODY_SLAM, Type.NORMAL, MoveCategory.PHYSICAL, 85, 100, 15, 30, 0, 1)
.attr(MinimizeAccuracyAttr) .attr(AlwaysHitMinimizeAttr)
.attr(HitsTagAttr, BattlerTagType.MINIMIZED, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.MINIMIZED, true)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS), .attr(StatusEffectAttr, StatusEffect.PARALYSIS),
new AttackMove(Moves.WRAP, Type.NORMAL, MoveCategory.PHYSICAL, 15, 90, 20, -1, 0, 1) new AttackMove(Moves.WRAP, Type.NORMAL, MoveCategory.PHYSICAL, 15, 90, 20, -1, 0, 1)
.attr(TrapAttr, BattlerTagType.WRAP), .attr(TrapAttr, BattlerTagType.WRAP),
@ -6864,7 +6945,7 @@ export function initMoves() {
new AttackMove(Moves.HYDRO_PUMP, Type.WATER, MoveCategory.SPECIAL, 110, 80, 5, -1, 0, 1), new AttackMove(Moves.HYDRO_PUMP, Type.WATER, MoveCategory.SPECIAL, 110, 80, 5, -1, 0, 1),
new AttackMove(Moves.SURF, Type.WATER, MoveCategory.SPECIAL, 90, 100, 15, -1, 0, 1) new AttackMove(Moves.SURF, Type.WATER, MoveCategory.SPECIAL, 90, 100, 15, -1, 0, 1)
.target(MoveTarget.ALL_NEAR_OTHERS) .target(MoveTarget.ALL_NEAR_OTHERS)
.attr(HitsTagAttr, BattlerTagType.UNDERWATER, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.UNDERWATER, true)
.attr(GulpMissileTagAttr), .attr(GulpMissileTagAttr),
new AttackMove(Moves.ICE_BEAM, Type.ICE, MoveCategory.SPECIAL, 90, 100, 10, 10, 0, 1) new AttackMove(Moves.ICE_BEAM, Type.ICE, MoveCategory.SPECIAL, 90, 100, 10, 10, 0, 1)
.attr(StatusEffectAttr, StatusEffect.FREEZE), .attr(StatusEffectAttr, StatusEffect.FREEZE),
@ -6947,18 +7028,18 @@ export function initMoves() {
new AttackMove(Moves.THUNDER, Type.ELECTRIC, MoveCategory.SPECIAL, 110, 70, 10, 30, 0, 1) new AttackMove(Moves.THUNDER, Type.ELECTRIC, MoveCategory.SPECIAL, 110, 70, 10, 30, 0, 1)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS) .attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.attr(ThunderAccuracyAttr) .attr(ThunderAccuracyAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING, false), .attr(DealsDoubleDamageToTagAttr, BattlerTagType.FLYING, false),
new AttackMove(Moves.ROCK_THROW, Type.ROCK, MoveCategory.PHYSICAL, 50, 90, 15, -1, 0, 1) new AttackMove(Moves.ROCK_THROW, Type.ROCK, MoveCategory.PHYSICAL, 50, 90, 15, -1, 0, 1)
.makesContact(false), .makesContact(false),
new AttackMove(Moves.EARTHQUAKE, Type.GROUND, MoveCategory.PHYSICAL, 100, 100, 10, -1, 0, 1) new AttackMove(Moves.EARTHQUAKE, Type.GROUND, MoveCategory.PHYSICAL, 100, 100, 10, -1, 0, 1)
.attr(HitsTagAttr, BattlerTagType.UNDERGROUND, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.UNDERGROUND, true)
.attr(MovePowerMultiplierAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.GRASSY && target.isGrounded() ? 0.5 : 1) .attr(MovePowerMultiplierAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.GRASSY && target.isGrounded() ? 0.5 : 1)
.makesContact(false) .makesContact(false)
.target(MoveTarget.ALL_NEAR_OTHERS), .target(MoveTarget.ALL_NEAR_OTHERS),
new AttackMove(Moves.FISSURE, Type.GROUND, MoveCategory.PHYSICAL, 200, 30, 5, -1, 0, 1) new AttackMove(Moves.FISSURE, Type.GROUND, MoveCategory.PHYSICAL, 200, 30, 5, -1, 0, 1)
.attr(OneHitKOAttr) .attr(OneHitKOAttr)
.attr(OneHitKOAccuracyAttr) .attr(OneHitKOAccuracyAttr)
.attr(HitsTagAttr, BattlerTagType.UNDERGROUND, false) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.UNDERGROUND, false)
.makesContact(false), .makesContact(false),
new AttackMove(Moves.DIG, Type.GROUND, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 1) new AttackMove(Moves.DIG, Type.GROUND, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 1)
.attr(ChargeAttr, ChargeAnim.DIG_CHARGING, i18next.t("moveTriggers:dugAHole", {pokemonName: "{USER}"}), BattlerTagType.UNDERGROUND) .attr(ChargeAttr, ChargeAnim.DIG_CHARGING, i18next.t("moveTriggers:dugAHole", {pokemonName: "{USER}"}), BattlerTagType.UNDERGROUND)
@ -7347,7 +7428,7 @@ export function initMoves() {
.attr(PreMoveMessageAttr, magnitudeMessageFunc) .attr(PreMoveMessageAttr, magnitudeMessageFunc)
.attr(MagnitudePowerAttr) .attr(MagnitudePowerAttr)
.attr(MovePowerMultiplierAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.GRASSY && target.isGrounded() ? 0.5 : 1) .attr(MovePowerMultiplierAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.GRASSY && target.isGrounded() ? 0.5 : 1)
.attr(HitsTagAttr, BattlerTagType.UNDERGROUND, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.UNDERGROUND, true)
.makesContact(false) .makesContact(false)
.target(MoveTarget.ALL_NEAR_OTHERS), .target(MoveTarget.ALL_NEAR_OTHERS),
new AttackMove(Moves.DYNAMIC_PUNCH, Type.FIGHTING, MoveCategory.PHYSICAL, 100, 50, 5, 100, 0, 2) new AttackMove(Moves.DYNAMIC_PUNCH, Type.FIGHTING, MoveCategory.PHYSICAL, 100, 50, 5, 100, 0, 2)
@ -7364,7 +7445,11 @@ export function initMoves() {
.ignoresSubstitute() .ignoresSubstitute()
.condition((user, target, move) => new EncoreTag(user.id).canAdd(target)), .condition((user, target, move) => new EncoreTag(user.id).canAdd(target)),
new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2) new AttackMove(Moves.PURSUIT, Type.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2)
.partial(), .attr(PursuitAccuracyAttr)
.attr(BypassRedirectAttr, (user, target) => Boolean(user && target && isPursuingFunc(user, target)))
.attr(AddBattlerTagHeaderAttr, BattlerTagType.ANTICIPATING_ACTION)
.attr(RemoveBattlerTagAttr, [BattlerTagType.ANTICIPATING_ACTION], true, MoveEffectTrigger.POST_APPLY)
.attr(MovePowerMultiplierAttr, (user, target) => isPursuingFunc(user, target) ? 2 : 1),
new AttackMove(Moves.RAPID_SPIN, Type.NORMAL, MoveCategory.PHYSICAL, 50, 100, 40, 100, 0, 2) new AttackMove(Moves.RAPID_SPIN, Type.NORMAL, MoveCategory.PHYSICAL, 50, 100, 40, 100, 0, 2)
.attr(StatStageChangeAttr, [ Stat.SPD ], 1, true) .attr(StatStageChangeAttr, [ Stat.SPD ], 1, true)
.attr(RemoveBattlerTagAttr, [ .attr(RemoveBattlerTagAttr, [
@ -7403,7 +7488,7 @@ export function initMoves() {
new AttackMove(Moves.CROSS_CHOP, Type.FIGHTING, MoveCategory.PHYSICAL, 100, 80, 5, -1, 0, 2) new AttackMove(Moves.CROSS_CHOP, Type.FIGHTING, MoveCategory.PHYSICAL, 100, 80, 5, -1, 0, 2)
.attr(HighCritAttr), .attr(HighCritAttr),
new AttackMove(Moves.TWISTER, Type.DRAGON, MoveCategory.SPECIAL, 40, 100, 20, 20, 0, 2) new AttackMove(Moves.TWISTER, Type.DRAGON, MoveCategory.SPECIAL, 40, 100, 20, 20, 0, 2)
.attr(HitsTagAttr, BattlerTagType.FLYING, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.FLYING, true)
.attr(FlinchAttr) .attr(FlinchAttr)
.windMove() .windMove()
.target(MoveTarget.ALL_NEAR_ENEMIES), .target(MoveTarget.ALL_NEAR_ENEMIES),
@ -7435,7 +7520,7 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.DEF ], -1), .attr(StatStageChangeAttr, [ Stat.DEF ], -1),
new AttackMove(Moves.WHIRLPOOL, Type.WATER, MoveCategory.SPECIAL, 35, 85, 15, -1, 0, 2) new AttackMove(Moves.WHIRLPOOL, Type.WATER, MoveCategory.SPECIAL, 35, 85, 15, -1, 0, 2)
.attr(TrapAttr, BattlerTagType.WHIRLPOOL) .attr(TrapAttr, BattlerTagType.WHIRLPOOL)
.attr(HitsTagAttr, BattlerTagType.UNDERWATER, true), .attr(DealsDoubleDamageToTagAttr, BattlerTagType.UNDERWATER, true),
new AttackMove(Moves.BEAT_UP, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 2) new AttackMove(Moves.BEAT_UP, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 2)
.attr(MultiHitAttr, MultiHitType.BEAT_UP) .attr(MultiHitAttr, MultiHitType.BEAT_UP)
.attr(BeatUpAttr) .attr(BeatUpAttr)
@ -7658,7 +7743,7 @@ export function initMoves() {
new AttackMove(Moves.EXTRASENSORY, Type.PSYCHIC, MoveCategory.SPECIAL, 80, 100, 20, 10, 0, 3) new AttackMove(Moves.EXTRASENSORY, Type.PSYCHIC, MoveCategory.SPECIAL, 80, 100, 20, 10, 0, 3)
.attr(FlinchAttr), .attr(FlinchAttr),
new AttackMove(Moves.SKY_UPPERCUT, Type.FIGHTING, MoveCategory.PHYSICAL, 85, 90, 15, -1, 0, 3) new AttackMove(Moves.SKY_UPPERCUT, Type.FIGHTING, MoveCategory.PHYSICAL, 85, 90, 15, -1, 0, 3)
.attr(HitsTagAttr, BattlerTagType.FLYING) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.FLYING)
.punchingMove(), .punchingMove(),
new AttackMove(Moves.SAND_TOMB, Type.GROUND, MoveCategory.PHYSICAL, 35, 85, 15, -1, 0, 3) new AttackMove(Moves.SAND_TOMB, Type.GROUND, MoveCategory.PHYSICAL, 35, 85, 15, -1, 0, 3)
.attr(TrapAttr, BattlerTagType.SAND_TOMB) .attr(TrapAttr, BattlerTagType.SAND_TOMB)
@ -7889,8 +7974,8 @@ export function initMoves() {
new AttackMove(Moves.DRAGON_PULSE, Type.DRAGON, MoveCategory.SPECIAL, 85, 100, 10, -1, 0, 4) new AttackMove(Moves.DRAGON_PULSE, Type.DRAGON, MoveCategory.SPECIAL, 85, 100, 10, -1, 0, 4)
.pulseMove(), .pulseMove(),
new AttackMove(Moves.DRAGON_RUSH, Type.DRAGON, MoveCategory.PHYSICAL, 100, 75, 10, 20, 0, 4) new AttackMove(Moves.DRAGON_RUSH, Type.DRAGON, MoveCategory.PHYSICAL, 100, 75, 10, 20, 0, 4)
.attr(MinimizeAccuracyAttr) .attr(AlwaysHitMinimizeAttr)
.attr(HitsTagAttr, BattlerTagType.MINIMIZED, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.MINIMIZED, true)
.attr(FlinchAttr), .attr(FlinchAttr),
new AttackMove(Moves.POWER_GEM, Type.ROCK, MoveCategory.SPECIAL, 80, 100, 20, -1, 0, 4), new AttackMove(Moves.POWER_GEM, Type.ROCK, MoveCategory.SPECIAL, 80, 100, 20, -1, 0, 4),
new AttackMove(Moves.DRAIN_PUNCH, Type.FIGHTING, MoveCategory.PHYSICAL, 75, 100, 10, -1, 0, 4) new AttackMove(Moves.DRAIN_PUNCH, Type.FIGHTING, MoveCategory.PHYSICAL, 75, 100, 10, -1, 0, 4)
@ -8087,7 +8172,7 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, false, false, 1, 1, true) .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, false, false, 1, 1, true)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED) .attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [BattlerTagType.FLYING, BattlerTagType.MAGNET_RISEN]) .attr(RemoveBattlerTagAttr, [BattlerTagType.FLYING, BattlerTagType.MAGNET_RISEN])
.attr(HitsTagAttr, BattlerTagType.FLYING, false) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.FLYING, false)
.makesContact(false), .makesContact(false),
new AttackMove(Moves.STORM_THROW, Type.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5) new AttackMove(Moves.STORM_THROW, Type.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5)
.attr(CritOnlyAttr), .attr(CritOnlyAttr),
@ -8100,9 +8185,9 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true) .attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true)
.danceMove(), .danceMove(),
new AttackMove(Moves.HEAVY_SLAM, Type.STEEL, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 5) new AttackMove(Moves.HEAVY_SLAM, Type.STEEL, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 5)
.attr(MinimizeAccuracyAttr) .attr(AlwaysHitMinimizeAttr)
.attr(CompareWeightPowerAttr) .attr(CompareWeightPowerAttr)
.attr(HitsTagAttr, BattlerTagType.MINIMIZED, true), .attr(DealsDoubleDamageToTagAttr, BattlerTagType.MINIMIZED, true),
new AttackMove(Moves.SYNCHRONOISE, Type.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 5) new AttackMove(Moves.SYNCHRONOISE, Type.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 5)
.target(MoveTarget.ALL_NEAR_OTHERS) .target(MoveTarget.ALL_NEAR_OTHERS)
.condition(unknownTypeCondition) .condition(unknownTypeCondition)
@ -8253,12 +8338,14 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.DEF ], -1) .attr(StatStageChangeAttr, [ Stat.DEF ], -1)
.slicingMove(), .slicingMove(),
new AttackMove(Moves.HEAT_CRASH, Type.FIRE, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 5) new AttackMove(Moves.HEAT_CRASH, Type.FIRE, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 5)
.attr(MinimizeAccuracyAttr) .attr(AlwaysHitMinimizeAttr)
.attr(CompareWeightPowerAttr) .attr(CompareWeightPowerAttr)
.attr(HitsTagAttr, BattlerTagType.MINIMIZED, true), .attr(DealsDoubleDamageToTagAttr, BattlerTagType.MINIMIZED, true),
new AttackMove(Moves.LEAF_TORNADO, Type.GRASS, MoveCategory.SPECIAL, 65, 90, 10, 50, 0, 5) new AttackMove(Moves.LEAF_TORNADO, Type.GRASS, MoveCategory.SPECIAL, 65, 90, 10, 50, 0, 5)
.attr(StatStageChangeAttr, [ Stat.ACC ], -1), .attr(StatStageChangeAttr, [ Stat.ACC ], -1),
new AttackMove(Moves.STEAMROLLER, Type.BUG, MoveCategory.PHYSICAL, 65, 100, 20, 30, 0, 5) new AttackMove(Moves.STEAMROLLER, Type.BUG, MoveCategory.PHYSICAL, 65, 100, 20, 30, 0, 5)
.attr(AlwaysHitMinimizeAttr)
.attr(DealsDoubleDamageToTagAttr, BattlerTagType.MINIMIZED, true)
.attr(FlinchAttr), .attr(FlinchAttr),
new SelfStatusMove(Moves.COTTON_GUARD, Type.GRASS, -1, 10, -1, 0, 5) new SelfStatusMove(Moves.COTTON_GUARD, Type.GRASS, -1, 10, -1, 0, 5)
.attr(StatStageChangeAttr, [ Stat.DEF ], 3, true), .attr(StatStageChangeAttr, [ Stat.DEF ], 3, true),
@ -8271,7 +8358,7 @@ export function initMoves() {
new AttackMove(Moves.HURRICANE, Type.FLYING, MoveCategory.SPECIAL, 110, 70, 10, 30, 0, 5) new AttackMove(Moves.HURRICANE, Type.FLYING, MoveCategory.SPECIAL, 110, 70, 10, 30, 0, 5)
.attr(ThunderAccuracyAttr) .attr(ThunderAccuracyAttr)
.attr(ConfuseAttr) .attr(ConfuseAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING, false) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.FLYING, false)
.windMove(), .windMove(),
new AttackMove(Moves.HEAD_CHARGE, Type.NORMAL, MoveCategory.PHYSICAL, 120, 100, 15, -1, 0, 5) new AttackMove(Moves.HEAD_CHARGE, Type.NORMAL, MoveCategory.PHYSICAL, 120, 100, 15, -1, 0, 5)
.attr(RecoilAttr) .attr(RecoilAttr)
@ -8325,9 +8412,9 @@ export function initMoves() {
.attr(LastMoveDoublePowerAttr, Moves.FUSION_FLARE) .attr(LastMoveDoublePowerAttr, Moves.FUSION_FLARE)
.makesContact(false), .makesContact(false),
new AttackMove(Moves.FLYING_PRESS, Type.FIGHTING, MoveCategory.PHYSICAL, 100, 95, 10, -1, 0, 6) new AttackMove(Moves.FLYING_PRESS, Type.FIGHTING, MoveCategory.PHYSICAL, 100, 95, 10, -1, 0, 6)
.attr(MinimizeAccuracyAttr) .attr(AlwaysHitMinimizeAttr)
.attr(FlyingTypeMultiplierAttr) .attr(FlyingTypeMultiplierAttr)
.attr(HitsTagAttr, BattlerTagType.MINIMIZED, true) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.MINIMIZED, true)
.condition(failOnGravityCondition), .condition(failOnGravityCondition),
new StatusMove(Moves.MAT_BLOCK, Type.FIGHTING, -1, 10, -1, 0, 6) new StatusMove(Moves.MAT_BLOCK, Type.FIGHTING, -1, 10, -1, 0, 6)
.target(MoveTarget.USER_SIDE) .target(MoveTarget.USER_SIDE)
@ -8498,8 +8585,8 @@ export function initMoves() {
new AttackMove(Moves.THOUSAND_ARROWS, Type.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) new AttackMove(Moves.THOUSAND_ARROWS, Type.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
.attr(NeutralDamageAgainstFlyingTypeMultiplierAttr) .attr(NeutralDamageAgainstFlyingTypeMultiplierAttr)
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, false, false, 1, 1, true) .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, false, false, 1, 1, true)
.attr(HitsTagAttr, BattlerTagType.FLYING, false) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.FLYING, false)
.attr(HitsTagAttr, BattlerTagType.MAGNET_RISEN, false) .attr(DealsDoubleDamageToTagAttr, BattlerTagType.MAGNET_RISEN, false)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED) .attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [BattlerTagType.FLYING, BattlerTagType.MAGNET_RISEN]) .attr(RemoveBattlerTagAttr, [BattlerTagType.FLYING, BattlerTagType.MAGNET_RISEN])
.makesContact(false) .makesContact(false)
@ -8756,6 +8843,8 @@ export function initMoves() {
.partial() .partial()
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.MALICIOUS_MOONSAULT, Type.DARK, MoveCategory.PHYSICAL, 180, -1, 1, -1, 0, 7) new AttackMove(Moves.MALICIOUS_MOONSAULT, Type.DARK, MoveCategory.PHYSICAL, 180, -1, 1, -1, 0, 7)
.attr(AlwaysHitMinimizeAttr)
.attr(DealsDoubleDamageToTagAttr, BattlerTagType.MINIMIZED, true)
.partial() .partial()
.ignoresVirtual(), .ignoresVirtual(),
new AttackMove(Moves.OCEANIC_OPERETTA, Type.WATER, MoveCategory.SPECIAL, 195, -1, 1, -1, 0, 7) new AttackMove(Moves.OCEANIC_OPERETTA, Type.WATER, MoveCategory.SPECIAL, 195, -1, 1, -1, 0, 7)

View File

@ -80,4 +80,14 @@ export enum BattlerTagType {
BURNED_UP = "BURNED_UP", BURNED_UP = "BURNED_UP",
DOUBLE_SHOCKED = "DOUBLE_SHOCKED", DOUBLE_SHOCKED = "DOUBLE_SHOCKED",
MYSTERY_ENCOUNTER_POST_SUMMON = "MYSTERY_ENCOUNTER_POST_SUMMON", MYSTERY_ENCOUNTER_POST_SUMMON = "MYSTERY_ENCOUNTER_POST_SUMMON",
/**
* Tag which indicates the battler is waiting for their opponent to make some
* sort of action (switch out, use a type of move, make contact, etc)
*/
ANTICIPATING_ACTION = "ANTICIPATING_ACTION",
/**
* Tag which indicates the battler is about to switch out.
*/
ESCAPING = "ESCAPING",
} }

View File

@ -746,7 +746,7 @@ export class Arena {
case Biome.TOWN: case Biome.TOWN:
return 7.288; return 7.288;
case Biome.PLAINS: case Biome.PLAINS:
return 7.693; return 17.485;
case Biome.GRASS: case Biome.GRASS:
return 1.995; return 1.995;
case Biome.TALL_GRASS: case Biome.TALL_GRASS:
@ -774,13 +774,13 @@ export class Arena {
case Biome.DESERT: case Biome.DESERT:
return 1.143; return 1.143;
case Biome.ICE_CAVE: case Biome.ICE_CAVE:
return 15.010; return 0.000;
case Biome.MEADOW: case Biome.MEADOW:
return 3.891; return 3.891;
case Biome.POWER_PLANT: case Biome.POWER_PLANT:
return 2.810; return 9.447;
case Biome.VOLCANO: case Biome.VOLCANO:
return 5.116; return 17.637;
case Biome.GRAVEYARD: case Biome.GRAVEYARD:
return 3.232; return 3.232;
case Biome.DOJO: case Biome.DOJO:

View File

@ -3,7 +3,7 @@ import BattleScene, { AnySound } from "../battle-scene";
import { Variant, VariantSet, variantColorCache } from "#app/data/variant"; import { Variant, VariantSet, variantColorCache } from "#app/data/variant";
import { variantData } from "#app/data/variant"; import { variantData } from "#app/data/variant";
import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "../ui/battle-info"; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "../ui/battle-info";
import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget } from "../data/move"; import Move, { HighCritAttr, DealsDoubleDamageToTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget } from "../data/move";
import { default as PokemonSpecies, PokemonSpeciesForm, SpeciesFormKey, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm, getStarterValueFriendshipCap, speciesStarters, starterPassiveAbilities } from "../data/pokemon-species"; import { default as PokemonSpecies, PokemonSpeciesForm, SpeciesFormKey, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm, getStarterValueFriendshipCap, speciesStarters, starterPassiveAbilities } from "../data/pokemon-species";
import { Constructor, isNullOrUndefined, randSeedInt } from "#app/utils"; import { Constructor, isNullOrUndefined, randSeedInt } from "#app/utils";
import * as Utils from "../utils"; import * as Utils from "../utils";
@ -1273,13 +1273,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @param attrType {@linkcode AbAttr} The ability attribute to check for. * @param attrType {@linkcode AbAttr} The ability attribute to check for.
* @param canApply {@linkcode Boolean} If false, it doesn't check whether the ability is currently active * @param canApply {@linkcode Boolean} If false, it doesn't check whether the ability is currently active
* @param ignoreOverride {@linkcode Boolean} If true, it ignores ability changing effects * @param ignoreOverride {@linkcode Boolean} If true, it ignores ability changing effects
* @returns {AbAttr[]} A list of all the ability attributes on this ability. * @returns A list of all the ability attributes on this ability.
*/ */
getAbilityAttrs(attrType: { new(...args: any[]): AbAttr }, canApply: boolean = true, ignoreOverride?: boolean): AbAttr[] { getAbilityAttrs<T extends AbAttr = AbAttr>(attrType: { new(...args: any[]): T }, canApply: boolean = true, ignoreOverride?: boolean): T[] {
const abilityAttrs: AbAttr[] = []; const abilityAttrs: T[] = [];
if (!canApply || this.canApplyAbility()) { if (!canApply || this.canApplyAbility()) {
abilityAttrs.push(...this.getAbility(ignoreOverride).getAttrs(attrType)); abilityAttrs.push(...this.getAbility(ignoreOverride).getAttrs<T>(attrType));
} }
if (!canApply || this.canApplyAbility(true)) { if (!canApply || this.canApplyAbility(true)) {
@ -1513,7 +1513,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
const immuneTags = this.findTags(tag => tag instanceof TypeImmuneTag && tag.immuneType === moveType); const immuneTags = this.findTags(tag => tag instanceof TypeImmuneTag && tag.immuneType === moveType);
for (const tag of immuneTags) { for (const tag of immuneTags) {
if (move && !move.getAttrs(HitsTagAttr).some(attr => attr.tagType === tag.tagType)) { if (move && !move.getAttrs(DealsDoubleDamageToTagAttr).some(attr => attr.tagType === tag.tagType)) {
typeMultiplier.value = 0; typeMultiplier.value = 0;
break; break;
} }
@ -2489,13 +2489,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
this.scene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, move.category, this.scene.currentBattle.double, screenMultiplier); this.scene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, move.category, this.scene.currentBattle.double, screenMultiplier);
/** /**
* For each {@linkcode HitsTagAttr} the move has, doubles the damage of the move if: * For each {@linkcode DealsDoubleDamageToTagAttr} the move has, doubles the damage of the move if:
* The target has a {@linkcode BattlerTagType} that this move interacts with * The target has a {@linkcode BattlerTagType} that this move interacts with
* AND * AND
* The move doubles damage when used against that tag * The move doubles damage when used against that tag
*/ */
const hitsTagMultiplier = new Utils.NumberHolder(1); const hitsTagMultiplier = new Utils.NumberHolder(1);
move.getAttrs(HitsTagAttr).filter(hta => hta.doubleDamage).forEach(hta => { move.getAttrs(DealsDoubleDamageToTagAttr).filter(hta => hta.doubleDamage).forEach(hta => {
if (this.getTag(hta.tagType)) { if (this.getTag(hta.tagType)) {
hitsTagMultiplier.value *= 2; hitsTagMultiplier.value *= 2;
} }

View File

@ -21,6 +21,7 @@ import i18next from "i18next";
import { PartyMemberStrength } from "#enums/party-member-strength"; import { PartyMemberStrength } from "#enums/party-member-strength";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { TrainerType } from "#enums/trainer-type"; import { TrainerType } from "#enums/trainer-type";
import Overrides from "#app/overrides";
export enum TrainerVariant { export enum TrainerVariant {
DEFAULT, DEFAULT,
@ -267,6 +268,17 @@ export default class Trainer extends Phaser.GameObjects.Container {
let ret: EnemyPokemon; let ret: EnemyPokemon;
this.scene.executeWithSeedOffset(() => { this.scene.executeWithSeedOffset(() => {
if (Overrides.TRAINER_PARTY_OVERRIDE?.length) {
ret = this.scene.addEnemyPokemon(
getPokemonSpecies(Overrides.TRAINER_PARTY_OVERRIDE[index % Overrides.TRAINER_PARTY_OVERRIDE.length]),
level,
!this.isDouble() || !(index % 2)
? TrainerSlot.TRAINER
: TrainerSlot.TRAINER_PARTNER
);
return;
}
const template = this.getPartyTemplate(); const template = this.getPartyTemplate();
const strength: PartyMemberStrength = template.getStrength(index); const strength: PartyMemberStrength = template.getStrength(index);
@ -440,6 +452,11 @@ export default class Trainer extends Phaser.GameObjects.Container {
const party = this.scene.getEnemyParty(); const party = this.scene.getEnemyParty();
const nonFaintedLegalPartyMembers = party.slice(this.scene.currentBattle.getBattlerCount()).filter(p => p.isAllowedInBattle()).filter(p => !trainerSlot || p.trainerSlot === trainerSlot); const nonFaintedLegalPartyMembers = party.slice(this.scene.currentBattle.getBattlerCount()).filter(p => p.isAllowedInBattle()).filter(p => !trainerSlot || p.trainerSlot === trainerSlot);
if (Overrides.TRAINER_ALWAYS_SWITCHES_OVERRIDE) {
return nonFaintedLegalPartyMembers.map(p => [party.indexOf(p), 100]);
}
const partyMemberScores = nonFaintedLegalPartyMembers.map(p => { const partyMemberScores = nonFaintedLegalPartyMembers.map(p => {
const playerField = this.scene.getPlayerField().filter(p => p.isAllowedInBattle()); const playerField = this.scene.getPlayerField().filter(p => p.isAllowedInBattle());
let score = 0; let score = 0;

View File

@ -108,7 +108,7 @@
"forest": "PMD EoS Dusk Forest", "forest": "PMD EoS Dusk Forest",
"grass": "PMD EoS Apple Woods", "grass": "PMD EoS Apple Woods",
"graveyard": "PMD EoS Mystifying Forest", "graveyard": "PMD EoS Mystifying Forest",
"ice_cave": "PMD EoS Vast Ice Mountain", "ice_cave": "Firel - -60F",
"island": "PMD EoS Craggy Coast", "island": "PMD EoS Craggy Coast",
"jungle": "Lmz - Jungle", "jungle": "Lmz - Jungle",
"laboratory": "Firel - Laboratory", "laboratory": "Firel - Laboratory",
@ -116,8 +116,8 @@
"meadow": "PMD EoS Sky Peak Forest", "meadow": "PMD EoS Sky Peak Forest",
"metropolis": "Firel - Metropolis", "metropolis": "Firel - Metropolis",
"mountain": "PMD EoS Mt. Horn", "mountain": "PMD EoS Mt. Horn",
"plains": "PMD EoS Sky Peak Prairie", "plains": "Firel - Route 888",
"power_plant": "PMD EoS Far Amp Plains", "power_plant": "Firel - The Klink",
"ruins": "PMD EoS Deep Sealed Ruin", "ruins": "PMD EoS Deep Sealed Ruin",
"sea": "Andr06 - Marine Mystique", "sea": "Andr06 - Marine Mystique",
"seabed": "Firel - Seabed", "seabed": "Firel - Seabed",
@ -128,7 +128,7 @@
"tall_grass": "PMD EoS Foggy Forest", "tall_grass": "PMD EoS Foggy Forest",
"temple": "PMD EoS Aegis Cave", "temple": "PMD EoS Aegis Cave",
"town": "PMD EoS Random Dungeon Theme 3", "town": "PMD EoS Random Dungeon Theme 3",
"volcano": "PMD EoS Steam Cave", "volcano": "Firel - Twisturn Volcano",
"wasteland": "PMD EoS Hidden Highland", "wasteland": "PMD EoS Hidden Highland",
"encounter_ace_trainer": "BW Trainers' Eyes Meet (Ace Trainer)", "encounter_ace_trainer": "BW Trainers' Eyes Meet (Ace Trainer)",
"encounter_backpacker": "BW Trainers' Eyes Meet (Backpacker)", "encounter_backpacker": "BW Trainers' Eyes Meet (Backpacker)",

View File

@ -13,8 +13,10 @@
"ALL": "전부", "ALL": "전부",
"PASS_BATON": "배턴터치한다", "PASS_BATON": "배턴터치한다",
"UNPAUSE_EVOLUTION": "진화 재개", "UNPAUSE_EVOLUTION": "진화 재개",
"PAUSE_EVOLUTION": "진화 중지",
"REVIVE": "되살린다", "REVIVE": "되살린다",
"RENAME": "닉네임 바꾸기", "RENAME": "닉네임 바꾸기",
"SELECT": "선택한다",
"choosePokemon": "포켓몬을 선택하세요.", "choosePokemon": "포켓몬을 선택하세요.",
"doWhatWithThisPokemon": "포켓몬을 어떻게 하겠습니까?", "doWhatWithThisPokemon": "포켓몬을 어떻게 하겠습니까?",
"noEnergy": "{{pokemonName}}[[는]] 싸울 수 있는\n기력이 남아 있지 않습니다!", "noEnergy": "{{pokemonName}}[[는]] 싸울 수 있는\n기력이 남아 있지 않습니다!",
@ -23,6 +25,7 @@
"tooManyItems": "{{pokemonName}}[[는]] 지닌 도구의 수가\n너무 많습니다", "tooManyItems": "{{pokemonName}}[[는]] 지닌 도구의 수가\n너무 많습니다",
"anyEffect": "써도 효과가 없다.", "anyEffect": "써도 효과가 없다.",
"unpausedEvolutions": "{{pokemonName}}의 진화가 재개되었다.", "unpausedEvolutions": "{{pokemonName}}의 진화가 재개되었다.",
"pausedEvolutions": "{{pokemonName}}[[가]] 진화하지 않도록 했다.",
"unspliceConfirmation": "{{pokemonName}}로부터 {{fusionName}}의 융합을 해제하시겠습니까?\n{{fusionName}}는 사라지게 됩니다.", "unspliceConfirmation": "{{pokemonName}}로부터 {{fusionName}}의 융합을 해제하시겠습니까?\n{{fusionName}}는 사라지게 됩니다.",
"wasReverted": "{{fusionName}}은 {{pokemonName}}의 모습으로 돌아갔습니다!", "wasReverted": "{{fusionName}}은 {{pokemonName}}의 모습으로 돌아갔습니다!",
"releaseConfirmation": "{{pokemonName}}[[를]]\n정말 놓아주겠습니까?", "releaseConfirmation": "{{pokemonName}}[[를]]\n정말 놓아주겠습니까?",

View File

@ -11,6 +11,10 @@
"expGainsSpeed": "경험치 획득 속도", "expGainsSpeed": "경험치 획득 속도",
"expPartyDisplay": "파티 경험치 표시", "expPartyDisplay": "파티 경험치 표시",
"skipSeenDialogues": "본 대화 생략", "skipSeenDialogues": "본 대화 생략",
"eggSkip": "알 스킵",
"never": "안 함",
"always": "항상",
"ask": "확인하기",
"battleStyle": "시합 룰", "battleStyle": "시합 룰",
"enableRetries": "재도전 허용", "enableRetries": "재도전 허용",
"hideIvs": "개체값탐지기 효과 끄기", "hideIvs": "개체값탐지기 효과 끄기",

View File

@ -128,6 +128,25 @@ class DefaultOverrides {
*/ */
readonly OPP_HEALTH_SEGMENTS_OVERRIDE: number = 0; readonly OPP_HEALTH_SEGMENTS_OVERRIDE: number = 0;
// --------------------------
// TRAINER/AI OVERRIDES
// --------------------------
/**
* Force enemy AI to always switch pkmn
*/
readonly TRAINER_ALWAYS_SWITCHES_OVERRIDE: boolean = false;
/**
* Force enemy trainer battles to always pick pkmn in this order. If the
* trainer would have more Pokemon than in the array, it will wrap around to
* the beginning. If the trainer would have less Pokemon than in the array,
* it will ignore the extras.
*
* Has no effect on wild battles. Only affects newly generated trainers (eg,
* won't work on a saved trainer wave). OPP_SPECIES_OVERRIDE and other OPP_
* overrides will supercede this value.
*/
readonly TRAINER_PARTY_OVERRIDE: Species[] = [];
// ------------- // -------------
// EGG OVERRIDES // EGG OVERRIDES
// ------------- // -------------

View File

@ -50,7 +50,7 @@ export class CheckSwitchPhase extends BattlePhase {
this.scene.ui.setMode(Mode.CONFIRM, () => { this.scene.ui.setMode(Mode.CONFIRM, () => {
this.scene.ui.setMode(Mode.MESSAGE); this.scene.ui.setMode(Mode.MESSAGE);
this.scene.tryRemovePhase(p => p instanceof PostSummonPhase && p.player && p.fieldIndex === this.fieldIndex); this.scene.tryRemovePhase(p => p instanceof PostSummonPhase && p.player && p.fieldIndex === this.fieldIndex);
this.scene.unshiftPhase(new SwitchPhase(this.scene, this.fieldIndex, false, true)); this.scene.unshiftPhase(new SwitchPhase(this.scene, this.fieldIndex, "switchMode", true));
this.end(); this.end();
}, () => { }, () => {
this.scene.ui.setMode(Mode.MESSAGE); this.scene.ui.setMode(Mode.MESSAGE);

View File

@ -106,7 +106,7 @@ export class FaintPhase extends PokemonPhase {
* If previous conditions weren't met, and the player has at least 1 legal Pokemon off the field, * If previous conditions weren't met, and the player has at least 1 legal Pokemon off the field,
* push a phase that prompts the player to summon a Pokemon from their party. * push a phase that prompts the player to summon a Pokemon from their party.
*/ */
this.scene.pushPhase(new SwitchPhase(this.scene, this.fieldIndex, true, false)); this.scene.pushPhase(new SwitchPhase(this.scene, this.fieldIndex, "faint", false));
} }
} else { } else {
this.scene.unshiftPhase(new VictoryPhase(this.scene, this.battlerIndex)); this.scene.unshiftPhase(new VictoryPhase(this.scene, this.battlerIndex));

View File

@ -4,7 +4,7 @@ import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr,
import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag";
import { MoveAnim } from "#app/data/battle-anims"; import { MoveAnim } from "#app/data/battle-anims";
import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags"; import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags";
import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, MoveEffectTrigger, ChargeAttr, MoveCategory, NoEffectAttr, HitsTagAttr } from "#app/data/move"; import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, MoveEffectTrigger, ChargeAttr, MoveCategory, NoEffectAttr, DealsDoubleDamageToTagAttr } from "#app/data/move";
import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms";
import { BattlerTagType } from "#app/enums/battler-tag-type"; import { BattlerTagType } from "#app/enums/battler-tag-type";
import { Moves } from "#app/enums/moves"; import { Moves } from "#app/enums/moves";
@ -394,7 +394,7 @@ export class MoveEffectPhase extends PokemonPhase {
} }
const semiInvulnerableTag = target.getTag(SemiInvulnerableTag); const semiInvulnerableTag = target.getTag(SemiInvulnerableTag);
if (semiInvulnerableTag && !this.move.getMove().getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType)) { if (semiInvulnerableTag && !this.move.getMove().getAttrs(DealsDoubleDamageToTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType)) {
return false; return false;
} }

View File

@ -95,9 +95,11 @@ export class MovePhase extends BattlePhase {
} }
}); });
//Check if this move is immune to being redirected, and restore its target to the intended target if it is. //Check if this move is immune to being redirected, and restore its target to the intended target if it is.
if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) || this.move.getMove().hasAttr(BypassRedirectAttr))) { const abilityRedirectImmune = this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr);
const moveRedirectImmune = this.move.getMove().getAttrs(BypassRedirectAttr).some(attr => attr.apply(this.pokemon, this.scene.getField(false)[oldTarget], this.move.getMove()));
if (abilityRedirectImmune || moveRedirectImmune) {
//If an ability prevented this move from being redirected, display its ability pop up. //If an ability prevented this move from being redirected, display its ability pop up.
if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) && !this.move.getMove().hasAttr(BypassRedirectAttr)) && oldTarget !== moveTarget.value) { if (abilityRedirectImmune && !moveRedirectImmune && oldTarget !== moveTarget.value) {
this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr))); this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr)));
} }
moveTarget.value = oldTarget; moveTarget.value = oldTarget;

View File

@ -6,7 +6,7 @@ import Pokemon from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import { ResetNegativeStatStageModifier } from "#app/modifier/modifier"; import { ResetNegativeStatStageModifier } from "#app/modifier/modifier";
import { handleTutorial, Tutorial } from "#app/tutorial"; import { handleTutorial, Tutorial } from "#app/tutorial";
import * as Utils from "#app/utils"; import { NumberHolder, BooleanHolder } from "#app/utils";
import i18next from "i18next"; import i18next from "i18next";
import { PokemonPhase } from "./pokemon-phase"; import { PokemonPhase } from "./pokemon-phase";
import { Stat, type BattleStat, getStatKey, getStatStageChangeDescriptionKey } from "#enums/stat"; import { Stat, type BattleStat, getStatKey, getStatStageChangeDescriptionKey } from "#enums/stat";
@ -42,17 +42,23 @@ export class StatStageChangePhase extends PokemonPhase {
return this.end(); return this.end();
} }
const stages = new NumberHolder(this.stages);
if (!this.ignoreAbilities) {
applyAbAttrs(StatStageChangeMultiplierAbAttr, pokemon, null, false, stages);
}
let simulate = false; let simulate = false;
const filteredStats = this.stats.filter(stat => { const filteredStats = this.stats.filter(stat => {
const cancelled = new Utils.BooleanHolder(false); const cancelled = new BooleanHolder(false);
if (!this.selfTarget && this.stages < 0) { if (!this.selfTarget && stages.value < 0) {
// TODO: Include simulate boolean when tag applications can be simulated // TODO: Include simulate boolean when tag applications can be simulated
this.scene.arena.applyTagsForSide(MistTag, pokemon.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY, cancelled); this.scene.arena.applyTagsForSide(MistTag, pokemon.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY, cancelled);
} }
if (!cancelled.value && !this.selfTarget && this.stages < 0) { if (!cancelled.value && !this.selfTarget && stages.value < 0) {
applyPreStatStageChangeAbAttrs(ProtectStatAbAttr, pokemon, stat, cancelled, simulate); applyPreStatStageChangeAbAttrs(ProtectStatAbAttr, pokemon, stat, cancelled, simulate);
} }
@ -64,12 +70,6 @@ export class StatStageChangePhase extends PokemonPhase {
return !cancelled.value; return !cancelled.value;
}); });
const stages = new Utils.IntegerHolder(this.stages);
if (!this.ignoreAbilities) {
applyAbAttrs(StatStageChangeMultiplierAbAttr, pokemon, null, false, stages);
}
const relLevels = filteredStats.map(s => (stages.value >= 1 ? Math.min(pokemon.getStatStage(s) + stages.value, 6) : Math.max(pokemon.getStatStage(s) + stages.value, -6)) - pokemon.getStatStage(s)); const relLevels = filteredStats.map(s => (stages.value >= 1 ? Math.min(pokemon.getStatStage(s) + stages.value, 6) : Math.max(pokemon.getStatStage(s) + stages.value, -6)) - pokemon.getStatStage(s));
this.onChange && this.onChange(this.getPokemon(), filteredStats, relLevels); this.onChange && this.onChange(this.getPokemon(), filteredStats, relLevels);

View File

@ -10,52 +10,56 @@ import { SwitchSummonPhase } from "./switch-summon-phase";
*/ */
export class SwitchPhase extends BattlePhase { export class SwitchPhase extends BattlePhase {
protected fieldIndex: integer; protected fieldIndex: integer;
private isModal: boolean; private switchReason: "faint" | "moveEffect" | "switchMode";
private doReturn: boolean; private doReturn: boolean;
/** /**
* Creates a new SwitchPhase * Creates a new SwitchPhase
* @param scene {@linkcode BattleScene} Current battle scene * @param scene {@linkcode BattleScene} Current battle scene
* @param fieldIndex Field index to switch out * @param fieldIndex Field index to switch out
* @param isModal Indicates if the switch should be forced (true) or is * @param switchReason Indicates why this switch is occurring. The valid options are
* optional (false). * `'faint'` (party member fainted), `'moveEffect'` (uturn, baton pass, dragon
* @param doReturn Indicates if the party member on the field should be * tail, etc), and `'switchMode'` (start-of-battle optional switch). This
* recalled to ball or has already left the field. Passed to {@linkcode SwitchSummonPhase}. * helps the phase determine both if the switch should be cancellable by the
* user, as well as determine if the party UI should be shown at all.
* @param doReturn Indicates if this switch should call back the pokemon at
* the {@linkcode fieldIndex} (true), or if the mon has already been recalled
* (false).
*/ */
constructor(scene: BattleScene, fieldIndex: integer, isModal: boolean, doReturn: boolean) { constructor(scene: BattleScene, fieldIndex: integer, switchReason: "faint" | "moveEffect" | "switchMode", doReturn: boolean) {
super(scene); super(scene);
this.fieldIndex = fieldIndex; this.fieldIndex = fieldIndex;
this.isModal = isModal; this.switchReason = switchReason;
this.doReturn = doReturn; this.doReturn = doReturn;
} }
start() { start() {
super.start(); super.start();
// Skip modal switch if impossible (no remaining party members that aren't in battle) const isForcedSwitch = this.switchReason !== "switchMode";
if (this.isModal && !this.scene.getParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) {
// Skip forced switch if impossible (no remaining party members that aren't in battle)
if (isForcedSwitch && !this.scene.getParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) {
return super.end(); return super.end();
} }
// Skip if the fainted party member has been revived already. doReturn is // Skip if the fainted party member has been revived already. see also; battle.test.ts
// only passed as `false` from FaintPhase (as opposed to other usages such if (this.switchReason === "faint" && !this.scene.getParty()[this.fieldIndex].isFainted()) {
// as ForceSwitchOutAttr or CheckSwitchPhase), so we only want to check this
// if the mon should have already been returned but is still alive and well
// on the field. see also; battle.test.ts
if (this.isModal && !this.doReturn && !this.scene.getParty()[this.fieldIndex].isFainted()) {
return super.end(); return super.end();
} }
// Check if there is any space still in field // Check if there is any space still in field
if (this.isModal && this.scene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length >= this.scene.currentBattle.getBattlerCount()) { const numActiveBattlers = this.scene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length;
const willReturnModifer = (this.doReturn ? 1 : 0); // need to subtract this if doReturn is true, because the pokemon in the given index hasn't left the field yet. (used for volt switch + pursuit, etc)
if (isForcedSwitch && numActiveBattlers - willReturnModifer >= this.scene.currentBattle.getBattlerCount()) {
return super.end(); return super.end();
} }
// Override field index to 0 in case of double battle where 2/3 remaining legal party members fainted at once // Override field index to 0 in case of double battle where 2/3 remaining legal party members fainted at once
const fieldIndex = this.scene.currentBattle.getBattlerCount() === 1 || this.scene.getParty().filter(p => p.isAllowedInBattle()).length > 1 ? this.fieldIndex : 0; const fieldIndex = this.scene.currentBattle.getBattlerCount() === 1 || this.scene.getParty().filter(p => p.isAllowedInBattle()).length > 1 ? this.fieldIndex : 0;
this.scene.ui.setMode(Mode.PARTY, this.isModal ? PartyUiMode.FAINT_SWITCH : PartyUiMode.POST_BATTLE_SWITCH, fieldIndex, (slotIndex: integer, option: PartyOption) => { this.scene.ui.setMode(Mode.PARTY, isForcedSwitch ? PartyUiMode.FAINT_SWITCH : PartyUiMode.POST_BATTLE_SWITCH, fieldIndex, (slotIndex: integer, option: PartyOption) => {
if (slotIndex >= this.scene.currentBattle.getBattlerCount() && slotIndex < 6) { if (slotIndex >= this.scene.currentBattle.getBattlerCount() && slotIndex < 6) {
this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, fieldIndex, slotIndex, this.doReturn, option === PartyOption.PASS_BATON)); this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, fieldIndex, slotIndex, this.doReturn, option === PartyOption.PASS_BATON));
} }

View File

@ -1,7 +1,11 @@
import { BattlerIndex } from "#app/battle";
import BattleScene from "#app/battle-scene"; import BattleScene from "#app/battle-scene";
import { applyAbAttrs, BypassSpeedChanceAbAttr, PreventBypassSpeedChanceAbAttr, ChangeMovePriorityAbAttr } from "#app/data/ability"; import { applyAbAttrs, BypassSpeedChanceAbAttr, ChangeMovePriorityAbAttr, PreventBypassSpeedChanceAbAttr } from "#app/data/ability";
import { TrickRoomTag } from "#app/data/arena-tag";
import { allMoves, applyMoveAttrs, IncrementMovePriorityAttr, MoveHeaderAttr } from "#app/data/move"; import { allMoves, applyMoveAttrs, IncrementMovePriorityAttr, MoveHeaderAttr } from "#app/data/move";
import { Abilities } from "#app/enums/abilities"; import { Abilities } from "#app/enums/abilities";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { Moves } from "#app/enums/moves";
import { Stat } from "#app/enums/stat"; import { Stat } from "#app/enums/stat";
import Pokemon, { PokemonMove } from "#app/field/pokemon"; import Pokemon, { PokemonMove } from "#app/field/pokemon";
import { BypassSpeedChanceModifier } from "#app/modifier/modifier"; import { BypassSpeedChanceModifier } from "#app/modifier/modifier";
@ -17,8 +21,6 @@ import { PostTurnStatusEffectPhase } from "./post-turn-status-effect-phase";
import { SwitchSummonPhase } from "./switch-summon-phase"; import { SwitchSummonPhase } from "./switch-summon-phase";
import { TurnEndPhase } from "./turn-end-phase"; import { TurnEndPhase } from "./turn-end-phase";
import { WeatherEffectPhase } from "./weather-effect-phase"; import { WeatherEffectPhase } from "./weather-effect-phase";
import { BattlerIndex } from "#app/battle";
import { TrickRoomTag } from "#app/data/arena-tag";
export class TurnStartPhase extends FieldPhase { export class TurnStartPhase extends FieldPhase {
constructor(scene: BattleScene) { constructor(scene: BattleScene) {
@ -87,14 +89,22 @@ export class TurnStartPhase extends FieldPhase {
// Non-FIGHT commands (SWITCH, BALL, RUN) have a higher command priority and will always occur before any FIGHT commands. // Non-FIGHT commands (SWITCH, BALL, RUN) have a higher command priority and will always occur before any FIGHT commands.
moveOrder = moveOrder.slice(0); moveOrder = moveOrder.slice(0);
moveOrder.sort((a, b) => { moveOrder.sort((a, b) => {
const aCommand = this.scene.currentBattle.turnCommands[a]; const aCommand = this.scene.currentBattle.turnCommands[a]!;
const bCommand = this.scene.currentBattle.turnCommands[b]; const bCommand = this.scene.currentBattle.turnCommands[b]!;
if (aCommand?.command !== bCommand?.command) { if (aCommand.command !== bCommand.command) {
if (aCommand?.command === Command.FIGHT) { if (aCommand.command === Command.FIGHT) {
return 1; if (aCommand.move?.move === Moves.PURSUIT && bCommand.command === Command.POKEMON) {
} else if (bCommand?.command === Command.FIGHT) {
return -1; return -1;
} else {
return 1;
}
} else if (bCommand.command === Command.FIGHT) {
if (bCommand.move?.move === Moves.PURSUIT && aCommand.command === Command.POKEMON) {
return 1;
} else {
return -1;
}
} }
} else if (aCommand?.command === Command.FIGHT) { } else if (aCommand?.command === Command.FIGHT) {
const aMove = allMoves[aCommand.move!.move]; const aMove = allMoves[aCommand.move!.move];
@ -160,26 +170,54 @@ export class TurnStartPhase extends FieldPhase {
if (!queuedMove) { if (!queuedMove) {
continue; continue;
} }
const move = pokemon.getMoveset().find(m => m?.moveId === queuedMove.move) || new PokemonMove(queuedMove.move); const move = pokemon.getMoveset().find(m => m?.moveId === queuedMove.move) ?? new PokemonMove(queuedMove.move);
if (move.getMove().hasAttr(MoveHeaderAttr)) { if (move.getMove().hasAttr(MoveHeaderAttr)) {
this.scene.unshiftPhase(new MoveHeaderPhase(this.scene, pokemon, move)); this.scene.unshiftPhase(new MoveHeaderPhase(this.scene, pokemon, move));
} }
// even though pursuit is ordered before Pokemon commands in the move
// order, the SwitchSummonPhase is unshifted onto the phase list, which
// would cause it to run before pursuit if pursuit was pushed normally.
// the SwitchSummonPhase can't be changed to a push either, because then
// the MoveHeaderPhase for all moves would run prior to the switch-out,
// which is not correct (eg, when focus punching a switching opponent,
// the correct order is switch -> tightening focus message -> attack
// fires, not focus -> switch -> attack). so, we have to specifically
// unshift pursuit when there are other pokemon commands after it, as
// well as order it before any Pokemon commands, otherwise it won't go first.
const remainingMoves = moveOrder.slice(moveOrder.findIndex(mo => mo === o) + 1);
const pendingOpposingPokemonCommands = remainingMoves.filter(o =>
this.scene.currentBattle.turnCommands[o]!.command === Command.POKEMON
&& (pokemon.isPlayer() ? o >= BattlerIndex.ENEMY : o < BattlerIndex.ENEMY)
);
const arePokemonCommandsLeftInQueue = Boolean(pendingOpposingPokemonCommands.length);
const addPhase = (
queuedMove.move === Moves.PURSUIT && arePokemonCommandsLeftInQueue
? this.scene.unshiftPhase
: this.scene.pushPhase
).bind(this.scene);
// pursuit also hits the first pokemon to switch out in doubles,
// regardless of original target
const targets = queuedMove.move === Moves.PURSUIT && arePokemonCommandsLeftInQueue
? [pendingOpposingPokemonCommands[0]]
: turnCommand.targets || turnCommand.move!.targets;
if (pokemon.isPlayer()) { if (pokemon.isPlayer()) {
if (turnCommand.cursor === -1) { if (turnCommand.cursor === -1) {
this.scene.pushPhase(new MovePhase(this.scene, pokemon, turnCommand.targets || turnCommand.move!.targets, move));//TODO: is the bang correct here? addPhase(new MovePhase(this.scene, pokemon, targets, move));
} else { } else {
const playerPhase = new MovePhase(this.scene, pokemon, turnCommand.targets || turnCommand.move!.targets, move, false, queuedMove.ignorePP);//TODO: is the bang correct here? const playerPhase = new MovePhase(this.scene, pokemon, targets, move, false, queuedMove.ignorePP);
this.scene.pushPhase(playerPhase); addPhase(playerPhase);
} }
} else { } else {
this.scene.pushPhase(new MovePhase(this.scene, pokemon, turnCommand.targets || turnCommand.move!.targets, move, false, queuedMove.ignorePP));//TODO: is the bang correct here? addPhase(new MovePhase(this.scene, pokemon, targets, move, false, queuedMove.ignorePP));
} }
break; break;
case Command.BALL: case Command.BALL:
this.scene.unshiftPhase(new AttemptCapturePhase(this.scene, turnCommand.targets![0] % 2, turnCommand.cursor!));//TODO: is the bang correct here? this.scene.unshiftPhase(new AttemptCapturePhase(this.scene, turnCommand.targets![0] % 2, turnCommand.cursor!));//TODO: is the bang correct here?
break; break;
case Command.POKEMON: case Command.POKEMON:
this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, pokemon.getFieldIndex(), turnCommand.cursor!, true, turnCommand.args![0] as boolean, pokemon.isPlayer()));//TODO: is the bang correct here? pokemon.addTag(BattlerTagType.ESCAPING);
this.scene.unshiftPhase(new SwitchSummonPhase(this.scene, pokemon.getFieldIndex(), turnCommand.cursor!, true, turnCommand.args![0] as boolean, pokemon.isPlayer()));
break; break;
case Command.RUN: case Command.RUN:
let runningPokemon = pokemon; let runningPokemon = pokemon;

View File

@ -31,7 +31,7 @@ describe("Abilities - Contrary", () => {
}); });
it("should invert stat changes when applied", async() => { it("should invert stat changes when applied", async() => {
await game.startBattle([ await game.classicMode.startBattle([
Species.SLOWBRO Species.SLOWBRO
]); ]);
@ -39,4 +39,39 @@ describe("Abilities - Contrary", () => {
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1);
}, 20000); }, 20000);
describe("With Clear Body", () => {
it("should apply positive effects", async () => {
game.override
.enemyPassiveAbility(Abilities.CLEAR_BODY)
.moveset([Moves.TAIL_WHIP]);
await game.classicMode.startBattle([Species.SLOWBRO]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1);
game.move.select(Moves.TAIL_WHIP);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyPokemon.getStatStage(Stat.DEF)).toBe(1);
});
it("should block negative effects", async () => {
game.override
.enemyPassiveAbility(Abilities.CLEAR_BODY)
.enemyMoveset([Moves.HOWL, Moves.HOWL, Moves.HOWL, Moves.HOWL])
.moveset([Moves.SPLASH]);
await game.classicMode.startBattle([Species.SLOWBRO]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1);
});
});
}); });

View File

@ -0,0 +1,834 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/move";
import { TerrainType } from "#app/data/terrain";
import { Abilities } from "#app/enums/abilities";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import { Stat } from "#app/enums/stat";
import Pokemon, { MoveResult } from "#app/field/pokemon";
import { EncounterPhase } from "#app/phases/encounter-phase";
import { EnemyCommandPhase } from "#app/phases/enemy-command-phase";
import { SwitchSummonPhase } from "#app/phases/switch-summon-phase";
import { TurnStartPhase } from "#app/phases/turn-start-phase";
import GameManager from "#test/utils/gameManager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
interface PokemonAssertionChainer {
and(expectation: (p?: Pokemon) => PokemonAssertionChainer): PokemonAssertionChainer;
and(expectation: (p?: Pokemon) => void): void
}
function chain(pokemon?: Pokemon): PokemonAssertionChainer {
return {
and: (expectation) => {
return expectation(pokemon);
}
};
}
function toArray<T>(a?: T | T[]) {
return Array.isArray(a) ? a : [a!];
}
describe("Moves - Pursuit", { timeout: 10000 }, () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const pursuitMoveDef = allMoves[Moves.PURSUIT];
const playerLead = Species.MUDSDALE;
const enemyLead = Species.KANGASKHAN;
function startBattle() {
return game.startBattle([playerLead, Species.RAICHU, Species.ABSOL]);
}
/**
* Many of these tests can't run only to {@linkcode BerryPhase} because of interactions with fainting
* during switches. We need to run tests all the way through to the start of the next turn
* to ensure that, for example, the game doesn't attempt to switch two pokemon into the same
* slot (once in the originally queued summon phase, and the next as a result of {@linkcode FaintPhase})
* @param to Phase to run to (default {@linkcode TurnInitPhase})
*/
async function runCombatTurn(to: string = "TurnInitPhase"): Promise<void> {
return await game.phaseInterceptor.to(to, false);
}
function afterFirstSwitch(action: () => void) {
game.phaseInterceptor.onNextPhase(EnemyCommandPhase, () =>
game.phaseInterceptor.advance().then(action));
}
function playerDoesNothing() {
game.override.moveset(Moves.SPLASH);
game.move.select(Moves.SPLASH);
if (game.scene.currentBattle.double) {
game.move.select(Moves.SPLASH, 1);
}
}
function playerUsesPursuit(pokemonIndex: 0 | 1 = 0, targetIndex: BattlerIndex = BattlerIndex.ENEMY) {
game.move.select(Moves.PURSUIT, pokemonIndex, targetIndex);
}
function playerUsesSwitchMove(pokemonIndex: 0 | 1 = 0) {
game.move.select(Moves.U_TURN, pokemonIndex);
game.doSelectPartyPokemon(2);
}
function playerSwitches(pokemonIndex: number = 1) {
game.doSwitchPokemon(pokemonIndex);
}
function enemyUses(move: Moves) {
game.override.enemyMoveset(move);
}
function enemySwitches(once?: boolean) {
game.override.forceTrainerSwitches();
if (once) {
afterFirstSwitch(() => game.override.forceTrainerSwitches(false));
}
}
function forceMovesLast(pokemon?: Pokemon | Pokemon[]) {
pokemon = toArray(pokemon);
const otherPkmn = game.scene.getField().filter(p => p && !pokemon.find(p1 => p1 === p));
const moveOrder = ([...otherPkmn, ...pokemon].map(pkmn => pkmn.getBattlerIndex()));
game.phaseInterceptor.onNextPhase(TurnStartPhase, p => {
vi.spyOn(p, "getCommandOrder").mockReturnValue(
// TurnStartPhase crashes if a BI returned by getOrder() is fainted.
// not an issue normally but some of the test setups can cause this
moveOrder!.filter(i => game.scene.getField(false)[i]?.isActive(true))
);
});
}
function forceMovesFirst(pokemon?: Pokemon | Pokemon[]) {
pokemon = toArray(pokemon);
const otherPkmn = game.scene.getField().filter(p => p && !pokemon.find(p1 => p1 === p));
const moveOrder = ([...pokemon, ...otherPkmn].map(pkmn => pkmn.getBattlerIndex()));
game.phaseInterceptor.onNextPhase(TurnStartPhase, p => {
vi.spyOn(p, "getCommandOrder").mockReturnValue(
// TurnStartPhase crashes if a BI returned by getOrder() is fainted.
// not an issue normally but some of the test setups can cause this
moveOrder!.filter(i => game.scene.getField(false)[i]?.isActive(true))
);
});
}
function forceLowestPriorityBracket() {
vi.spyOn(pursuitMoveDef, "priority", "get").mockReturnValue(-6);
}
function expectPursuitPowerDoubled() {
expect(pursuitMoveDef.calculateBattlePower).toHaveReturnedWith(80);
}
function expectPursuitPowerUnchanged() {
expect(pursuitMoveDef.calculateBattlePower).toHaveReturnedWith(40);
}
function expectPursuitSucceeded(pokemon?: Pokemon) {
const lastMove = pokemon!.getLastXMoves(0)[0];
expect(lastMove.move).toBe(Moves.PURSUIT);
expect(lastMove.result).toBe(MoveResult.SUCCESS);
return chain(pokemon);
}
function expectPursuitFailed(pokemon?: Pokemon) {
const lastMove = pokemon!.getLastXMoves(0)[0];
expect(lastMove.result).toBe(MoveResult.FAIL);
return chain(pokemon);
}
function expectWasHit(pokemon?: Pokemon) {
expect(pokemon!.hp).toBeLessThan(pokemon!.getMaxHp());
return chain(pokemon);
}
function expectWasNotHit(pokemon?: Pokemon) {
expect(pokemon!.hp).toBe(pokemon!.getMaxHp());
return chain(pokemon);
}
function expectOnField(pokemon?: Pokemon) {
expect(pokemon!.isOnField()).toBe(true);
return chain(pokemon);
}
function expectNotOnField(pokemon?: Pokemon) {
expect(pokemon!.isOnField()).toBe(false);
return chain(pokemon);
}
function expectHasFled(pokemon?: Pokemon) {
expect(pokemon!.wildFlee).toBe(true);
return chain(pokemon);
}
function expectIsSpecies(species: Species) {
return (pokemon?: Pokemon) => {
expect(pokemon!.species.speciesId).toBe(species);
return chain(pokemon);
};
}
function findPartyMember(party: Pokemon[], species: Species) {
return party.find(pkmn => pkmn.species.speciesId === species);
}
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleType("single")
.enemyParty([enemyLead, Species.SNORLAX, Species.BASCULIN, Species.ALCREMIE])
.startingLevel(20)
.startingWave(25)
.moveset([Moves.PURSUIT, Moves.U_TURN, Moves.DRAGON_TAIL, Moves.FOLLOW_ME])
.enemyMoveset(Moves.SPLASH)
.disableCrits();
vi.spyOn(pursuitMoveDef, "calculateBattlePower");
});
it("should hit for normal power if the target is not switching", async () => {
// arrange
await startBattle();
// act
playerUsesPursuit();
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasHit(game.scene.getEnemyPokemon());
});
it("should not hit a uturning target for double power if the pursuiter moves before the uturner", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasNotHit(game.scene.getEnemyPokemon());
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectNotOnField);
});
it("should hit a hard-switching target for double power (player attacks, enemy switches)", async () => {
// arrange
await startBattle();
// act
playerUsesPursuit();
enemySwitches();
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasNotHit(game.scene.getEnemyPokemon());
expectNotOnField(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectWasHit);
});
it("should hit a hard-switching target for double power (player switches, enemy attacks)", async () => {
// arrange
await startBattle();
// act
playerSwitches();
enemyUses(Moves.PURSUIT);
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasNotHit(game.scene.getPlayerPokemon());
expectNotOnField(findPartyMember(game.scene.getParty(), playerLead))
.and(expectWasHit);
});
it("should hit an outgoing uturning target if pursuiter has not moved yet (player attacks, enemy switches)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasNotHit(game.scene.getEnemyPokemon());
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectNotOnField);
});
it("should hit an outgoing uturning target if pursuiter has not moved yet (player switches, enemy attacks)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
// act
playerUsesSwitchMove();
enemyUses(Moves.PURSUIT);
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasNotHit(game.scene.getPlayerPokemon());
expectWasHit(findPartyMember(game.scene.getParty(), playerLead))
.and(expectNotOnField);
});
it("should bypass accuracy checks when hitting a hard-switching target", async () => {
// arrange
await startBattle();
game.scene.getPlayerPokemon()!.summonData.statStages[Stat.ACC] = -6;
game.scene.getEnemyPokemon()!.summonData.statStages[Stat.EVA] = 6;
// act
playerUsesPursuit();
enemySwitches();
await runCombatTurn();
// assert
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead));
});
it("should bypass accuracy checks when hitting a uturning target", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
game.scene.getEnemyPokemon()!.summonData.statStages[Stat.ACC] = -6;
game.scene.getPlayerPokemon()!.summonData.statStages[Stat.EVA] = 6;
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn();
// assert
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead));
});
it.todo("should bypass substitute when hitting an escaping target (hard switch)");
it.todo("should bypass substitute when hitting an escaping target (switch move)");
it.todo("should not bypass substitute when hitting a non-escaping target");
it("should hit a grounded, switching target under Psychic Terrain (switch move)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
game.scene.arena.trySetTerrain(TerrainType.PSYCHIC, false, true);
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead));
});
it("should hit a grounded, switching target under Psychic Terrain (hard-switch)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
game.scene.arena.trySetTerrain(TerrainType.PSYCHIC, false, true);
// act
playerUsesPursuit();
enemySwitches();
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead));
});
it("should not hit a baton pass user", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
game.scene.getEnemyPokemon()!.summonData.statStages[Stat.ACC] = -6;
game.scene.getPlayerPokemon()!.summonData.statStages[Stat.EVA] = 6;
// act
playerUsesPursuit();
enemyUses(Moves.BATON_PASS);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasHit(game.scene.getEnemyPokemon());
expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectNotOnField);
});
it("should not hit a teleport user", () => async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
forceLowestPriorityBracket();
// act
playerUsesPursuit();
enemyUses(Moves.TELEPORT);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasHit(game.scene.getEnemyPokemon());
expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectNotOnField);
});
it("should not hit a fleeing wild pokemon", async () => {
// arrange
game.override
.startingWave(24)
.disableTrainerWaves();
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn("BerryPhase");
// assert
expectPursuitFailed(game.scene.getPlayerPokemon());
expectWasNotHit(game.scene.getEnemyParty()[0])
.and(expectHasFled);
});
it("should not hit a switch move user for double damage if the switch move fails and does not switch out the user", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
// act
playerUsesPursuit();
enemyUses(Moves.VOLT_SWITCH);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectOnField);
expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead));
});
it("triggers contact abilities on the pokemon that is switching out (hard-switch)", async () => {
// arrange
game.override.enemyAbility(Abilities.ROUGH_SKIN);
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
// act
playerUsesPursuit();
enemySwitches();
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead));
expectWasHit(game.scene.getPlayerPokemon());
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
});
it("triggers contact abilities on the pokemon that is switching out (switch move, player switching)", async () => {
// arrange
game.override.ability(Abilities.STAMINA);
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
// act
playerUsesSwitchMove();
enemyUses(Moves.PURSUIT);
await runCombatTurn();
// assert
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
});
it("triggers contact abilities on the pokemon that is switching out (switch move, enemy switching)", async () => {
// arrange
game.override.enemyAbility(Abilities.ROUGH_SKIN);
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
// act
playerUsesSwitchMove();
enemySwitches();
await runCombatTurn();
// assert
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
});
it("should not have a pokemon fainted by a switch move pursue its killer", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
game.scene.getPlayerPokemon()!.hp = 1;
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn();
// assert
expect(game.scene.getParty()[0]!.isFainted()).toBe(true);
expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(expectNotOnField);
});
it("should not cause two pokemon to enter the field if a switching-out pokemon is fainted by pursuit (hard-switch, enemy faints)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
game.scene.getEnemyPokemon()!.hp = 1;
// act
playerUsesPursuit();
enemySwitches();
await runCombatTurn();
// assert
expect(game.scene.getEnemyParty().filter(p => p.isOnField())).toHaveLength(1);
});
it("should not cause two pokemon to enter the field if a switching-out pokemon is fainted by pursuit (u-turn, enemy faints)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerPokemon());
game.scene.getEnemyPokemon()!.hp = 1;
// act
playerUsesPursuit();
enemyUses(Moves.U_TURN);
await runCombatTurn();
// assert
expect(game.scene.getEnemyParty().filter(p => p.isOnField())).toHaveLength(1);
});
it("should not cause two pokemon to enter the field or a premature switch if a switching-out pokemon is fainted by pursuit (hard-switch, player faints)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
game.scene.getPlayerPokemon()!.hp = 1;
// act
playerSwitches();
enemyUses(Moves.PURSUIT);
await runCombatTurn("TurnEndPhase");
// assert
expect(game.scene.getParty().filter(p => p.isOnField())).toHaveLength(1);
// SwitchPhase for fainted pokemon happens after TurnEndPhase - if we
// skipped the uturn switch, we shouldn't have executed a switchout by this point
expect(game.scene.getPlayerField()[0]?.isFainted()).toBeTruthy();
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
});
it("should not cause two pokemon to enter the field or a premature switch if a switching-out pokemon is fainted by pursuit (u-turn, player faints)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
game.scene.getPlayerPokemon()!.hp = 1;
// act
playerUsesSwitchMove();
enemyUses(Moves.PURSUIT);
await runCombatTurn("TurnEndPhase");
// assert
expect(game.scene.getParty().filter(p => p.isOnField())).toHaveLength(1);
// SwitchPhase for fainted pokemon happens after TurnEndPhase - if we
// skipped the uturn switch, we shouldn't have executed a switchout by this point
expect(game.scene.getPlayerField()[0]?.isFainted()).toBeTruthy();
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
});
describe("doubles interactions", () => {
beforeEach(() => {
game.override
.battleType("double")
.enemyMoveset([Moves.PURSUIT, Moves.U_TURN]);
});
it("should bypass follow me when hitting a switching target", async () => {
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
game.move.select(Moves.FOLLOW_ME);
playerUsesSwitchMove(1);
game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.PLAYER_2);
await game.killPokemon(game.scene.getEnemyField()[1]);
await runCombatTurn();
expectPursuitPowerDoubled();
expectWasHit(findPartyMember(game.scene.getParty(), Species.RAICHU))
.and(expectNotOnField);
expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead));
});
it("should not bypass follow me when hitting a non-switching target", async () => {
await startBattle();
forceMovesLast(game.scene.getEnemyPokemon());
game.override.moveset([Moves.FOLLOW_ME, Moves.SPLASH]);
game.move.select(Moves.FOLLOW_ME);
game.move.select(Moves.SPLASH, 1);
game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.PLAYER_2);
await game.killPokemon(game.scene.getEnemyField()[1]);
await runCombatTurn();
expectPursuitPowerUnchanged();
expectWasHit(findPartyMember(game.scene.getParty(), playerLead));
expectWasNotHit(findPartyMember(game.scene.getParty(), Species.RAICHU));
});
it("should not cause the enemy AI to send out a fainted pokemon if they command 2 switches and one of the outgoing pokemon faints to pursuit", async () => {
await startBattle();
forceMovesLast(game.scene.getPlayerField());
game.scene.getEnemyField()[0]!.hp = 1;
playerUsesPursuit(0);
playerUsesPursuit(1);
enemySwitches();
await runCombatTurn();
expect(game.scene.getEnemyParty().filter(p => p.isOnField())).toHaveLength(2);
expect(game.scene.getEnemyParty().filter(p => p.isOnField() && p.isFainted())).toHaveLength(0);
});
// TODO: is this correct behavior?
it("should fail if both pokemon use pursuit on a target that is switching out and it faints after the first one with no other targets on field", async () => {
await startBattle();
forceMovesLast(game.scene.getPlayerField());
game.scene.getEnemyField()[0]!.hp = 1;
playerUsesPursuit(0);
playerUsesPursuit(1);
enemySwitches();
await game.killPokemon(game.scene.getEnemyField()[1]);
await runCombatTurn();
expectPursuitPowerDoubled();
expectPursuitSucceeded(game.scene.getPlayerField()[0]);
expectPursuitFailed(game.scene.getPlayerField()[1]);
});
// TODO: is this correct behavior?
it("should attack the second switching pokemon if both pokemon switch and the first is KOd", async () => {
game.phaseInterceptor.onNextPhase(EncounterPhase, () => {
game.scene.currentBattle.enemyLevels = [...game.scene.currentBattle.enemyLevels!, game.scene.currentBattle.enemyLevels![0]];
});
await startBattle();
forceMovesLast(game.scene.getPlayerField());
game.scene.getEnemyField()[0]!.hp = 1;
playerUsesPursuit(0);
playerUsesPursuit(1);
enemySwitches();
afterFirstSwitch(() => {
vi.spyOn(game.scene.currentBattle.trainer!, "getPartyMemberMatchupScores").mockReturnValue([[3, 100]]);
vi.spyOn(game.scene.getPlayerPokemon()!, "getMatchupScore").mockReturnValue(0);
});
await runCombatTurn();
expectPursuitPowerDoubled();
expectPursuitSucceeded(game.scene.getPlayerField()[0]);
expectPursuitSucceeded(game.scene.getPlayerField()[1]);
expectWasHit(findPartyMember(game.scene.getEnemyParty(), Species.SNORLAX));
expectNotOnField(findPartyMember(game.scene.getEnemyParty(), enemyLead))
.and(p => expect(p?.isFainted()));
});
// TODO: confirm correct behavior and add tests for other pursuit/switch combos in doubles
it("should not hit a pokemon being forced out with dragon tail", async () => {
await startBattle();
forceMovesLast(game.scene.getPlayerField());
game.move.select(Moves.DRAGON_TAIL);
playerUsesPursuit(1);
enemyUses(Moves.SPLASH);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasHit(game.scene.getEnemyPokemon()).and(pkmn => {
expect(pkmn?.turnData.attacksReceived[0]).toEqual(expect.objectContaining({
move: Moves.PURSUIT,
result: MoveResult.SUCCESS,
}));
});
});
// fails: command re-ordering does not work due to particulars of sort/move ordering;
// pursuit moves after switch
it("should hit the first pokemon to switch out in a double battle regardless of who was targeted", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerField());
// act
playerUsesPursuit(0, BattlerIndex.ENEMY_2);
playerUsesPursuit(1, BattlerIndex.ENEMY_2);
enemySwitches(true);
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField);
expectWasNotHit(game.scene.getEnemyField()[0]);
expectWasNotHit(game.scene.getEnemyField()[1]);
expectPursuitSucceeded(game.scene.getPlayerField()[0]);
expectPursuitSucceeded(game.scene.getPlayerField()[1]);
});
it("should not hit both pokemon in a double battle if both switch out", async () => {
// arrange
game.phaseInterceptor.onNextPhase(EncounterPhase, () => {
game.scene.currentBattle.enemyLevels = [...game.scene.currentBattle.enemyLevels!, game.scene.currentBattle.enemyLevels![0]];
});
await startBattle();
forceMovesLast(game.scene.getPlayerField());
// act
playerUsesPursuit(0, BattlerIndex.ENEMY);
playerUsesPursuit(1, BattlerIndex.ENEMY);
enemySwitches();
afterFirstSwitch(() => {
vi.spyOn(game.scene.currentBattle.trainer!, "getPartyMemberMatchupScores").mockReturnValue([[3, 100]]);
vi.spyOn(game.scene.getPlayerPokemon()!, "getMatchupScore").mockReturnValue(0);
});
await runCombatTurn();
// assert
expectPursuitPowerDoubled();
expectWasHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField);
expectWasNotHit(game.scene.getEnemyField()[0]).and(expectIsSpecies(Species.BASCULIN));
expectWasNotHit(game.scene.getEnemyField()[1]).and(expectIsSpecies(Species.ALCREMIE));
expectPursuitSucceeded(game.scene.getPlayerField()[0]);
expectPursuitSucceeded(game.scene.getPlayerField()[1]);
});
// This test is hard to verify, because it's hard to observe independently -
// but depending on exactly how the command ordering is done, it is possible
// for the command order to put one ally's Pursuit move before the other
// ally's Pokemon command, even if the pursuit move does not target a
// pursuer. At this time, this "appears" to work correctly, because of
// nuances in when phases are pushed vs. shifted; but the pursuit
// MoveHeaderPhase is actually run before the switch, which is the only way
// to find the issue at present. Asserting the direct output of the command order
// is probably a better solution.
it("should not move or apply tags before switch when ally switches and not pursuing an enemy", async () => {
// arrange
await startBattle();
forceMovesFirst(game.scene.getPlayerField().reverse());
// act
playerSwitches(2);
playerUsesPursuit(0);
enemyUses(Moves.SPLASH);
await game.phaseInterceptor.to(SwitchSummonPhase);
// assert
expect(game.phaseInterceptor.log).not.toContain("MovePhase");
expect(game.phaseInterceptor.log).not.toContain("MoveHeaderPhase");
});
it("should not hit a switching ally for double damage (hard-switch, player field)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getPlayerField());
// act
playerSwitches(2);
playerUsesPursuit(1, BattlerIndex.PLAYER);
enemyUses(Moves.SPLASH);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasHit(game.scene.getPlayerField()[0]);
expectWasNotHit(findPartyMember(game.scene.getParty(), playerLead)).and(expectNotOnField);
});
it("should not hit a switching ally for double damage (hard-switch, enemy field)", async () => {
await startBattle();
forceMovesLast(game.scene.getEnemyField());
playerDoesNothing();
enemySwitches(true);
game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.ENEMY);
await runCombatTurn();
expectPursuitPowerUnchanged();
expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField);
expectWasHit(game.scene.getEnemyField()[0]);
});
it("should not hit a switching ally for double damage (switch move, player field)", async () => {
// arrange
await startBattle();
forceMovesLast([...game.scene.getPlayerField()].reverse());
// act
playerUsesPursuit(0, BattlerIndex.PLAYER_2);
playerUsesSwitchMove(1); // prompts need to be queued in the order of pursuit, then switch move or else they will softlock
enemyUses(Moves.SPLASH);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasHit(game.scene.getPlayerField()[1]);
expectWasNotHit(findPartyMember(game.scene.getParty(), Species.RAICHU)).and(expectNotOnField);
});
it("should not hit a switching ally for double damage (switch move, enemy field)", async () => {
// arrange
await startBattle();
forceMovesLast(game.scene.getEnemyField());
// act
playerDoesNothing();
game.forceEnemyMove(Moves.U_TURN);
game.forceEnemyMove(Moves.PURSUIT, BattlerIndex.ENEMY);
await runCombatTurn();
// assert
expectPursuitPowerUnchanged();
expectWasNotHit(findPartyMember(game.scene.getEnemyParty(), enemyLead)).and(expectNotOnField);
expectWasHit(game.scene.getEnemyField()[0]);
});
});
});

View File

@ -0,0 +1,58 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/move";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { DamageCalculationResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Steamroller", () => {
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.moveset([Moves.STEAMROLLER]).battleType("single").enemyAbility(Abilities.BALL_FETCH);
});
it("should always hit a minimzed target with double damage", async () => {
game.override.enemySpecies(Species.DITTO).enemyMoveset(Moves.MINIMIZE);
await game.classicMode.startBattle([Species.IRON_BOULDER]);
const ditto = game.scene.getEnemyPokemon()!;
vi.spyOn(ditto, "getAttackDamage");
ditto.hp = 5000;
const steamroller = allMoves[Moves.STEAMROLLER];
vi.spyOn(steamroller, "calculateBattleAccuracy");
const ironBoulder = game.scene.getPlayerPokemon()!;
vi.spyOn(ironBoulder, "getAccuracyMultiplier");
// Turn 1
game.move.select(Moves.STEAMROLLER);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
// Turn 2
game.move.select(Moves.STEAMROLLER);
await game.toNextTurn();
const [dmgCalcTurn1, dmgCalcTurn2]: DamageCalculationResult[] = vi
.mocked(ditto.getAttackDamage)
.mock.results.map((r) => r.value);
expect(dmgCalcTurn2.damage).toBeGreaterThanOrEqual(dmgCalcTurn1.damage * 2);
expect(ditto.getTag(BattlerTagType.MINIMIZED)).toBeDefined();
expect(steamroller.calculateBattleAccuracy).toHaveReturnedWith(-1);
});
});

View File

@ -35,64 +35,46 @@ describe("Moves - U-turn", () => {
}); });
it("triggers regenerator a single time when a regenerator user switches out with u-turn", async () => { it("triggers regenerator a single time when a regenerator user switches out with u-turn", async () => {
// arrange
const playerHp = 1; const playerHp = 1;
game.override.ability(Abilities.REGENERATOR); game.override.ability(Abilities.REGENERATOR);
await game.startBattle([ await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
Species.RAICHU,
Species.SHUCKLE
]);
game.scene.getPlayerPokemon()!.hp = playerHp; game.scene.getPlayerPokemon()!.hp = playerHp;
// act
game.move.select(Moves.U_TURN); game.move.select(Moves.U_TURN);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
// assert expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE);
expect(game.scene.getParty()[1].hp).toEqual(Math.floor(game.scene.getParty()[1].getMaxHp() * 0.33 + playerHp)); expect(game.scene.getParty()[1].hp).toEqual(Math.floor(game.scene.getParty()[1].getMaxHp() * 0.33 + playerHp));
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE);
}, 20000); }, 20000);
it("triggers rough skin on the u-turn user before a new pokemon is switched in", async () => { it("triggers rough skin on the u-turn user before a new pokemon is switched in", async () => {
// arrange
game.override.enemyAbility(Abilities.ROUGH_SKIN); game.override.enemyAbility(Abilities.ROUGH_SKIN);
await game.startBattle([ await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
Species.RAICHU,
Species.SHUCKLE
]);
// act
game.move.select(Moves.U_TURN); game.move.select(Moves.U_TURN);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to(SwitchPhase, false); await game.phaseInterceptor.to(SwitchPhase, false);
// assert
const playerPkm = game.scene.getPlayerPokemon()!; const playerPkm = game.scene.getPlayerPokemon()!;
expect(playerPkm.species.speciesId).toEqual(Species.RAICHU);
expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp()); expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp());
expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated
expect(playerPkm.species.speciesId).toEqual(Species.RAICHU);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
}, 20000); }, 20000);
it("triggers contact abilities on the u-turn user (eg poison point) before a new pokemon is switched in", async () => { it("triggers contact abilities on the u-turn user (eg poison point) before a new pokemon is switched in", async () => {
// arrange
game.override.enemyAbility(Abilities.POISON_POINT); game.override.enemyAbility(Abilities.POISON_POINT);
await game.startBattle([ await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
Species.RAICHU,
Species.SHUCKLE
]);
vi.spyOn(game.scene.getEnemyPokemon()!, "randSeedInt").mockReturnValue(0); vi.spyOn(game.scene.getEnemyPokemon()!, "randSeedInt").mockReturnValue(0);
// act
game.move.select(Moves.U_TURN); game.move.select(Moves.U_TURN);
await game.phaseInterceptor.to(SwitchPhase, false); await game.phaseInterceptor.to(SwitchPhase, false);
// assert
const playerPkm = game.scene.getPlayerPokemon()!; const playerPkm = game.scene.getPlayerPokemon()!;
expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON);
expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); expect(playerPkm.species.speciesId).toEqual(Species.RAICHU);
expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON);
expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
}, 20000); }, 20000);

View File

@ -0,0 +1,50 @@
import { BerryPhase } from "#app/phases/berry-phase";
import { Mode } from "#app/ui/ui";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Volt Switch", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const TIMEOUT = 20 * 1000;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([Moves.SPLASH])
.battleType("single")
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
it("does not switch out the user if the move fails", async () => {
game.override
.enemySpecies(Species.DUGTRIO)
.moveset(Moves.VOLT_SWITCH);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
game.move.select(Moves.VOLT_SWITCH);
game.onNextPrompt("SwitchPhase", Mode.PARTY, () => {
expect.fail("Switch was forced");
}, () => game.isCurrentPhase(BerryPhase));
await game.phaseInterceptor.to(BerryPhase, false);
const playerPkm = game.scene.getPlayerPokemon()!;
expect(playerPkm.species.speciesId).toEqual(Species.RAICHU);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
}, TIMEOUT);
});

View File

@ -0,0 +1,53 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/move";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest";
describe("Moves - Whirlwind", () => {
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
.battleType("single")
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.WHIRLWIND)
.enemySpecies(Species.PIDGEY);
});
it.each([
{ move: Moves.FLY, name: "Fly" },
{ move: Moves.BOUNCE, name: "Bounce" },
{ move: Moves.SKY_DROP, name: "Sky Drop" },
])("should not hit a flying target: $name (=$move)", async ({ move }) => {
game.override.moveset([move]);
await game.classicMode.startBattle([Species.STARAPTOR]);
const staraptor = game.scene.getPlayerPokemon()!;
const whirlwind = allMoves[Moves.WHIRLWIND];
vi.spyOn(whirlwind, "getFailedText");
game.move.select(move);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(staraptor.findTag((t) => t.tagType === BattlerTagType.FLYING)).toBeDefined();
expect(whirlwind.getFailedText).toHaveBeenCalledOnce();
});
});

View File

@ -88,7 +88,7 @@ describe("UI - Starter select", () => {
resolve(); resolve();
}); });
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR);
expect(game.scene.getParty()[0].shiny).toBe(true); expect(game.scene.getParty()[0].shiny).toBe(true);
@ -149,7 +149,7 @@ describe("UI - Starter select", () => {
resolve(); resolve();
}); });
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR);
expect(game.scene.getParty()[0].shiny).toBe(true); expect(game.scene.getParty()[0].shiny).toBe(true);
@ -213,7 +213,7 @@ describe("UI - Starter select", () => {
resolve(); resolve();
}); });
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR);
expect(game.scene.getParty()[0].shiny).toBe(true); expect(game.scene.getParty()[0].shiny).toBe(true);
@ -276,7 +276,7 @@ describe("UI - Starter select", () => {
resolve(); resolve();
}); });
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR);
expect(game.scene.getParty()[0].shiny).toBe(true); expect(game.scene.getParty()[0].shiny).toBe(true);
@ -337,7 +337,7 @@ describe("UI - Starter select", () => {
resolve(); resolve();
}); });
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR);
expect(game.scene.getParty()[0].shiny).toBe(false); expect(game.scene.getParty()[0].shiny).toBe(false);
@ -398,7 +398,7 @@ describe("UI - Starter select", () => {
resolve(); resolve();
}); });
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR);
expect(game.scene.getParty()[0].shiny).toBe(true); expect(game.scene.getParty()[0].shiny).toBe(true);
@ -458,7 +458,7 @@ describe("UI - Starter select", () => {
resolve(); resolve();
}); });
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR);
expect(game.scene.getParty()[0].shiny).toBe(true); expect(game.scene.getParty()[0].shiny).toBe(true);
@ -525,7 +525,7 @@ describe("UI - Starter select", () => {
const saveSlotSelectUiHandler = game.scene.ui.getHandler() as SaveSlotSelectUiHandler; const saveSlotSelectUiHandler = game.scene.ui.getHandler() as SaveSlotSelectUiHandler;
saveSlotSelectUiHandler.processInput(Button.ACTION); saveSlotSelectUiHandler.processInput(Button.ACTION);
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.CATERPIE); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.CATERPIE);
}, 20000); }, 20000);
@ -591,7 +591,7 @@ describe("UI - Starter select", () => {
const saveSlotSelectUiHandler = game.scene.ui.getHandler() as SaveSlotSelectUiHandler; const saveSlotSelectUiHandler = game.scene.ui.getHandler() as SaveSlotSelectUiHandler;
saveSlotSelectUiHandler.processInput(Button.ACTION); saveSlotSelectUiHandler.processInput(Button.ACTION);
}); });
await game.phaseInterceptor.whenAboutToRun(EncounterPhase); await game.phaseInterceptor.to(EncounterPhase, false);
expect(game.scene.getParty()[0].species.speciesId).toBe(Species.NIDORAN_M); expect(game.scene.getParty()[0].species.speciesId).toBe(Species.NIDORAN_M);
}, 20000); }, 20000);
}); });

View File

@ -1,20 +1,23 @@
import { updateUserInfo } from "#app/account"; import { updateUserInfo } from "#app/account";
import { BattlerIndex } from "#app/battle"; import { BattlerIndex } from "#app/battle";
import BattleScene from "#app/battle-scene"; import BattleScene from "#app/battle-scene";
import { BattleStyle } from "#app/enums/battle-style";
import { Moves } from "#app/enums/moves";
import { getMoveTargets } from "#app/data/move"; import { getMoveTargets } from "#app/data/move";
import { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; import { BattleStyle } from "#app/enums/battle-style";
import { ExpGainsSpeed } from "#app/enums/exp-gains-speed";
import { Moves } from "#app/enums/moves";
import Pokemon from "#app/field/pokemon";
import Trainer from "#app/field/trainer"; import Trainer from "#app/field/trainer";
import { GameModes, getGameMode } from "#app/game-mode"; import { GameModes, getGameMode } from "#app/game-mode";
import { ModifierTypeOption, modifierTypes } from "#app/modifier/modifier-type"; import { ModifierTypeOption, modifierTypes } from "#app/modifier/modifier-type";
import overrides from "#app/overrides"; import overrides from "#app/overrides";
import { CheckSwitchPhase } from "#app/phases/check-switch-phase";
import { CommandPhase } from "#app/phases/command-phase"; import { CommandPhase } from "#app/phases/command-phase";
import { EncounterPhase } from "#app/phases/encounter-phase"; import { EncounterPhase } from "#app/phases/encounter-phase";
import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; import { EnemyCommandPhase } from "#app/phases/enemy-command-phase";
import { FaintPhase } from "#app/phases/faint-phase"; import { FaintPhase } from "#app/phases/faint-phase";
import { LoginPhase } from "#app/phases/login-phase"; import { LoginPhase } from "#app/phases/login-phase";
import { MovePhase } from "#app/phases/move-phase"; import { MovePhase } from "#app/phases/move-phase";
import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases";
import { NewBattlePhase } from "#app/phases/new-battle-phase"; import { NewBattlePhase } from "#app/phases/new-battle-phase";
import { SelectStarterPhase } from "#app/phases/select-starter-phase"; import { SelectStarterPhase } from "#app/phases/select-starter-phase";
import { SelectTargetPhase } from "#app/phases/select-target-phase"; import { SelectTargetPhase } from "#app/phases/select-target-phase";
@ -24,13 +27,16 @@ import { TurnInitPhase } from "#app/phases/turn-init-phase";
import { TurnStartPhase } from "#app/phases/turn-start-phase"; import { TurnStartPhase } from "#app/phases/turn-start-phase";
import ErrorInterceptor from "#app/test/utils/errorInterceptor"; import ErrorInterceptor from "#app/test/utils/errorInterceptor";
import InputsHandler from "#app/test/utils/inputsHandler"; import InputsHandler from "#app/test/utils/inputsHandler";
import BattleMessageUiHandler from "#app/ui/battle-message-ui-handler";
import CommandUiHandler from "#app/ui/command-ui-handler"; import CommandUiHandler from "#app/ui/command-ui-handler";
import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler";
import PartyUiHandler from "#app/ui/party-ui-handler"; import PartyUiHandler from "#app/ui/party-ui-handler";
import TargetSelectUiHandler from "#app/ui/target-select-ui-handler"; import TargetSelectUiHandler from "#app/ui/target-select-ui-handler";
import { Mode } from "#app/ui/ui"; import { Mode } from "#app/ui/ui";
import { isNullOrUndefined } from "#app/utils";
import { Button } from "#enums/buttons"; import { Button } from "#enums/buttons";
import { ExpNotification } from "#enums/exp-notification"; import { ExpNotification } from "#enums/exp-notification";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { PlayerGender } from "#enums/player-gender"; import { PlayerGender } from "#enums/player-gender";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { generateStarter, waitUntil } from "#test/utils/gameManagerUtils"; import { generateStarter, waitUntil } from "#test/utils/gameManagerUtils";
@ -39,21 +45,14 @@ import PhaseInterceptor from "#test/utils/phaseInterceptor";
import TextInterceptor from "#test/utils/TextInterceptor"; import TextInterceptor from "#test/utils/TextInterceptor";
import { AES, enc } from "crypto-js"; import { AES, enc } from "crypto-js";
import fs from "fs"; import fs from "fs";
import { vi } from "vitest"; import { expect, vi } from "vitest";
import { ChallengeModeHelper } from "./helpers/challengeModeHelper";
import { ClassicModeHelper } from "./helpers/classicModeHelper"; import { ClassicModeHelper } from "./helpers/classicModeHelper";
import { DailyModeHelper } from "./helpers/dailyModeHelper"; import { DailyModeHelper } from "./helpers/dailyModeHelper";
import { ChallengeModeHelper } from "./helpers/challengeModeHelper";
import { MoveHelper } from "./helpers/moveHelper"; import { MoveHelper } from "./helpers/moveHelper";
import { OverridesHelper } from "./helpers/overridesHelper"; import { OverridesHelper } from "./helpers/overridesHelper";
import { SettingsHelper } from "./helpers/settingsHelper";
import { ReloadHelper } from "./helpers/reloadHelper"; import { ReloadHelper } from "./helpers/reloadHelper";
import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; import { SettingsHelper } from "./helpers/settingsHelper";
import BattleMessageUiHandler from "#app/ui/battle-message-ui-handler";
import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases";
import { expect } from "vitest";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { isNullOrUndefined } from "#app/utils";
import { ExpGainsSpeed } from "#app/enums/exp-gains-speed";
/** /**
* Class to manage the game state and transitions between phases. * Class to manage the game state and transitions between phases.
@ -141,7 +140,7 @@ export default class GameManager {
* @returns A promise that resolves when the title phase is reached. * @returns A promise that resolves when the title phase is reached.
*/ */
async runToTitle(): Promise<void> { async runToTitle(): Promise<void> {
await this.phaseInterceptor.whenAboutToRun(LoginPhase); await this.phaseInterceptor.to(LoginPhase, false);
this.phaseInterceptor.pop(); this.phaseInterceptor.pop();
await this.phaseInterceptor.run(TitlePhase); await this.phaseInterceptor.run(TitlePhase);
@ -251,7 +250,7 @@ export default class GameManager {
* @param {BattlerIndex} targetIndex The index of the attack target, or `undefined` for multi-target attacks * @param {BattlerIndex} targetIndex The index of the attack target, or `undefined` for multi-target attacks
* @param movePosition The index of the move in the pokemon's moveset array * @param movePosition The index of the move in the pokemon's moveset array
*/ */
selectTarget(movePosition: integer, targetIndex?: BattlerIndex) { selectTarget(movePosition: number, targetIndex?: BattlerIndex) {
this.onNextPrompt("SelectTargetPhase", Mode.TARGET_SELECT, () => { this.onNextPrompt("SelectTargetPhase", Mode.TARGET_SELECT, () => {
const handler = this.scene.ui.getHandler() as TargetSelectUiHandler; const handler = this.scene.ui.getHandler() as TargetSelectUiHandler;
const move = (this.scene.getCurrentPhase() as SelectTargetPhase).getPokemon().getMoveset()[movePosition]!.getMove(); // TODO: is the bang correct? const move = (this.scene.getCurrentPhase() as SelectTargetPhase).getPokemon().getMoveset()[movePosition]!.getMove(); // TODO: is the bang correct?
@ -397,7 +396,7 @@ export default class GameManager {
return updateUserInfo(); return updateUserInfo();
} }
async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) { async killPokemon(pokemon: Pokemon) {
return new Promise<void>(async (resolve, reject) => { return new Promise<void>(async (resolve, reject) => {
pokemon.hp = 0; pokemon.hp = 0;
this.scene.pushPhase(new FaintPhase(this.scene, pokemon.getBattlerIndex(), true)); this.scene.pushPhase(new FaintPhase(this.scene, pokemon.getBattlerIndex(), true));
@ -441,6 +440,10 @@ export default class GameManager {
*/ */
doSelectPartyPokemon(slot: number, inPhase = "SwitchPhase") { doSelectPartyPokemon(slot: number, inPhase = "SwitchPhase") {
this.onNextPrompt(inPhase, Mode.PARTY, () => { this.onNextPrompt(inPhase, Mode.PARTY, () => {
if (this.scene.getParty()[slot].isActive(true)) {
throw new Error("Attempting to switch in a party member that is already active");
}
const partyHandler = this.scene.ui.getHandler() as PartyUiHandler; const partyHandler = this.scene.ui.getHandler() as PartyUiHandler;
partyHandler.setCursor(slot); partyHandler.setCursor(slot);
@ -458,10 +461,11 @@ export default class GameManager {
* await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]); * await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]);
* ``` * ```
*/ */
async setTurnOrder(order: BattlerIndex[]): Promise<void> { async setTurnOrder(order: BattlerIndex[], modifyPriority: boolean = false): Promise<void> {
await this.phaseInterceptor.to(TurnStartPhase, false); await this.phaseInterceptor.to(TurnStartPhase, false);
console.log(`${modifyPriority ? "Turn" : "Speed"} order modified to: `, order);
vi.spyOn(this.scene.getCurrentPhase() as TurnStartPhase, "getSpeedOrder").mockReturnValue(order); vi.spyOn(this.scene.getCurrentPhase() as TurnStartPhase, (modifyPriority ? "getCommandOrder" : "getSpeedOrder")).mockReturnValue(order);
} }
/** /**

View File

@ -8,10 +8,10 @@ import * as GameMode from "#app/game-mode";
import { GameModes, getGameMode } from "#app/game-mode"; import { GameModes, getGameMode } from "#app/game-mode";
import { ModifierOverride } from "#app/modifier/modifier-type"; import { ModifierOverride } from "#app/modifier/modifier-type";
import Overrides from "#app/overrides"; import Overrides from "#app/overrides";
import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { vi } from "vitest"; import { vi } from "vitest";
import { GameManagerHelper } from "./gameManagerHelper"; import { GameManagerHelper } from "./gameManagerHelper";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { MysteryEncounterTier } from "#enums/mystery-encounter-tier";
/** /**
* Helper to handle overrides in tests * Helper to handle overrides in tests
@ -325,6 +325,27 @@ export class OverridesHelper extends GameManagerHelper {
return this; return this;
} }
/**
* Overrides the trainer AI's party
* @param species List of pokemon to generate in the party
* @returns this
*/
enemyParty(species: Species[]) {
vi.spyOn(Overrides, "TRAINER_PARTY_OVERRIDE", "get").mockReturnValue(species);
this.log("Enemy trainer party set to:", species);
return this;
}
/**
* Forces the AI to always switch out, or reset to allow normal switching decisions
* @returns this
*/
forceTrainerSwitches(newValue: boolean = true) {
vi.spyOn(Overrides, "TRAINER_ALWAYS_SWITCHES_OVERRIDE", "get").mockReturnValue(newValue);
this.log("Trainers will always switch out set to:", newValue);
return this;
}
/** /**
* Override the encounter chance for a mystery encounter. * Override the encounter chance for a mystery encounter.
* @param percentage the encounter chance in % * @param percentage the encounter chance in %

View File

@ -1,5 +1,4 @@
import { Phase } from "#app/phase"; import { Phase } from "#app/phase";
import ErrorInterceptor from "#app/test/utils/errorInterceptor";
import { AttemptRunPhase } from "#app/phases/attempt-run-phase"; import { AttemptRunPhase } from "#app/phases/attempt-run-phase";
import { BattleEndPhase } from "#app/phases/battle-end-phase"; import { BattleEndPhase } from "#app/phases/battle-end-phase";
import { BerryPhase } from "#app/phases/berry-phase"; import { BerryPhase } from "#app/phases/berry-phase";
@ -11,17 +10,28 @@ import { EncounterPhase } from "#app/phases/encounter-phase";
import { EndEvolutionPhase } from "#app/phases/end-evolution-phase"; import { EndEvolutionPhase } from "#app/phases/end-evolution-phase";
import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; import { EnemyCommandPhase } from "#app/phases/enemy-command-phase";
import { EvolutionPhase } from "#app/phases/evolution-phase"; import { EvolutionPhase } from "#app/phases/evolution-phase";
import { ExpPhase } from "#app/phases/exp-phase";
import { FaintPhase } from "#app/phases/faint-phase"; import { FaintPhase } from "#app/phases/faint-phase";
import { LearnMovePhase } from "#app/phases/learn-move-phase"; import { LearnMovePhase } from "#app/phases/learn-move-phase";
import { LevelCapPhase } from "#app/phases/level-cap-phase"; import { LevelCapPhase } from "#app/phases/level-cap-phase";
import { LoginPhase } from "#app/phases/login-phase"; import { LoginPhase } from "#app/phases/login-phase";
import { MessagePhase } from "#app/phases/message-phase"; import { MessagePhase } from "#app/phases/message-phase";
import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase";
import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { MoveEndPhase } from "#app/phases/move-end-phase"; import { MoveEndPhase } from "#app/phases/move-end-phase";
import { MovePhase } from "#app/phases/move-phase"; import { MovePhase } from "#app/phases/move-phase";
import {
MysteryEncounterBattlePhase,
MysteryEncounterOptionSelectedPhase,
MysteryEncounterPhase,
MysteryEncounterRewardsPhase,
PostMysteryEncounterPhase
} from "#app/phases/mystery-encounter-phases";
import { NewBattlePhase } from "#app/phases/new-battle-phase"; import { NewBattlePhase } from "#app/phases/new-battle-phase";
import { NewBiomeEncounterPhase } from "#app/phases/new-biome-encounter-phase"; import { NewBiomeEncounterPhase } from "#app/phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "#app/phases/next-encounter-phase"; import { NextEncounterPhase } from "#app/phases/next-encounter-phase";
import { PartyExpPhase } from "#app/phases/party-exp-phase";
import { PartyHealPhase } from "#app/phases/party-heal-phase";
import { PostSummonPhase } from "#app/phases/post-summon-phase"; import { PostSummonPhase } from "#app/phases/post-summon-phase";
import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase"; import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase";
import { SelectGenderPhase } from "#app/phases/select-gender-phase"; import { SelectGenderPhase } from "#app/phases/select-gender-phase";
@ -41,17 +51,11 @@ import { TurnInitPhase } from "#app/phases/turn-init-phase";
import { TurnStartPhase } from "#app/phases/turn-start-phase"; import { TurnStartPhase } from "#app/phases/turn-start-phase";
import { UnavailablePhase } from "#app/phases/unavailable-phase"; import { UnavailablePhase } from "#app/phases/unavailable-phase";
import { VictoryPhase } from "#app/phases/victory-phase"; import { VictoryPhase } from "#app/phases/victory-phase";
import { PartyHealPhase } from "#app/phases/party-heal-phase"; import ErrorInterceptor from "#app/test/utils/errorInterceptor";
import UI, { Mode } from "#app/ui/ui"; import UI, { Mode } from "#app/ui/ui";
import { import { expect } from "vitest";
MysteryEncounterBattlePhase,
MysteryEncounterOptionSelectedPhase, type PhaseClassType = (abstract new (...args: any) => Phase); // `typeof Phase` does not work here because of some issue with ctor signatures
MysteryEncounterPhase,
MysteryEncounterRewardsPhase,
PostMysteryEncounterPhase
} from "#app/phases/mystery-encounter-phases";
import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase";
import { PartyExpPhase } from "#app/phases/party-exp-phase";
export interface PromptHandler { export interface PromptHandler {
phaseTarget?: string; phaseTarget?: string;
@ -60,7 +64,6 @@ export interface PromptHandler {
expireFn?: () => void; expireFn?: () => void;
awaitingActionInput?: boolean; awaitingActionInput?: boolean;
} }
import { ExpPhase } from "#app/phases/exp-phase";
export default class PhaseInterceptor { export default class PhaseInterceptor {
public scene; public scene;
@ -211,13 +214,21 @@ export default class PhaseInterceptor {
}); });
} }
/**
* Advance a single phase
* @returns A promise that resolves when the next phase has started
*/
advance(): Promise<void> {
return this.run(this.onHold[0]);
}
/** /**
* Method to run a phase with an optional skip function. * Method to run a phase with an optional skip function.
* @param phaseTarget - The phase to run. * @param phaseTarget - The phase to run.
* @param skipFn - Optional skip function. * @param skipFn - Optional skip function.
* @returns A promise that resolves when the phase is run. * @returns A promise that resolves when the phase is run.
*/ */
run(phaseTarget, skipFn?): Promise<void> { async run(phaseTarget, skipFn?): Promise<void> {
const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name; const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name;
this.scene.moveAnimations = null; // Mandatory to avoid crash this.scene.moveAnimations = null; // Mandatory to avoid crash
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
@ -251,19 +262,25 @@ export default class PhaseInterceptor {
}); });
} }
whenAboutToRun(phaseTarget, skipFn?): Promise<void> { /**
const targetName = typeof phaseTarget === "string" ? phaseTarget : phaseTarget.name; * The next time a phase of the given type would be run, first run the provided callback.
* The phase instance is passed to the callback, for easier mocking.
*
* This function does not actually start running phases - for that, see {@linkcode to()}.
* @param phaseType Class type of the phase you want to tap
* @param cb callback to run when the phase next arrives
*/
onNextPhase<T extends PhaseClassType>(phaseType: T, cb: (phase: InstanceType<T>) => void) {
const targetName = phaseType.name;
this.scene.moveAnimations = null; // Mandatory to avoid crash this.scene.moveAnimations = null; // Mandatory to avoid crash
return new Promise(async (resolve, reject) => {
ErrorInterceptor.getInstance().add(this); ErrorInterceptor.getInstance().add(this);
const interval = setInterval(async () => { const interval = setInterval(async () => {
const currentPhase = this.onHold[0]; const currentPhase = this.onHold[0];
if (currentPhase?.name === targetName) { if (currentPhase?.name === targetName) {
clearInterval(interval); clearInterval(interval);
resolve(); cb(this.scene.getCurrentPhase());
} }
}); });
});
} }
pop() { pop() {
@ -359,6 +376,13 @@ export default class PhaseInterceptor {
* Method to start the prompt handler. * Method to start the prompt handler.
*/ */
startPromptHandler() { startPromptHandler() {
const PROMPT_TIMEOUT = 2000;
let timeSpentInPrompt = 0;
let lastTime: number | undefined = undefined;
let lastPhase, lastPromptPhase, lastMode;
let warned = false;
this.promptInterval = setInterval(() => { this.promptInterval = setInterval(() => {
if (this.prompts.length) { if (this.prompts.length) {
const actionForNextPrompt = this.prompts[0]; const actionForNextPrompt = this.prompts[0];
@ -366,8 +390,31 @@ export default class PhaseInterceptor {
const currentMode = this.scene.ui.getMode(); const currentMode = this.scene.ui.getMode();
const currentPhase = this.scene.getCurrentPhase()?.constructor.name; const currentPhase = this.scene.getCurrentPhase()?.constructor.name;
const currentHandler = this.scene.ui.getHandler(); const currentHandler = this.scene.ui.getHandler();
if (lastPhase === currentPhase && lastPromptPhase === actionForNextPrompt.phaseTarget && lastMode === currentMode && currentMode !== Mode.MESSAGE) {
const currentTime = Date.now();
timeSpentInPrompt += lastTime === undefined ? 0 : currentTime - lastTime;
lastTime = currentTime;
if (timeSpentInPrompt > PROMPT_TIMEOUT && !warned) {
warned = true;
console.error("Prompt handling stalled waiting for prompt:", actionForNextPrompt);
expect.fail("Prompt timeout");
}
} else {
warned = false;
lastMode = currentMode;
lastPhase = currentPhase;
lastPromptPhase = actionForNextPrompt.phaseTarget;
timeSpentInPrompt = 0;
}
if (expireFn) { if (expireFn) {
this.prompts.shift(); this.prompts.shift();
console.log(`Prompt for ${actionForNextPrompt.phaseTarget} (mode ${actionForNextPrompt.mode}) has expired`);
timeSpentInPrompt = 0;
lastTime = undefined;
} else if (currentMode === actionForNextPrompt.mode && currentPhase === actionForNextPrompt.phaseTarget && currentHandler.active && (!actionForNextPrompt.awaitingActionInput || (actionForNextPrompt.awaitingActionInput && currentHandler.awaitingActionInput))) { } else if (currentMode === actionForNextPrompt.mode && currentPhase === actionForNextPrompt.phaseTarget && currentHandler.active && (!actionForNextPrompt.awaitingActionInput || (actionForNextPrompt.awaitingActionInput && currentHandler.awaitingActionInput))) {
const prompt = this.prompts.shift(); const prompt = this.prompts.shift();
if (prompt?.callback) { if (prompt?.callback) {
@ -394,6 +441,7 @@ export default class PhaseInterceptor {
expireFn, expireFn,
awaitingActionInput awaitingActionInput
}); });
console.log(`Prompt added for ${phaseTarget} (mode ${mode})`);
} }
/** /**

View File

@ -162,7 +162,7 @@ export default class BattleInfo extends Phaser.GameObjects.Container {
this.splicedIcon.setInteractive(new Phaser.Geom.Rectangle(0, 0, 12, 15), Phaser.Geom.Rectangle.Contains); this.splicedIcon.setInteractive(new Phaser.Geom.Rectangle(0, 0, 12, 15), Phaser.Geom.Rectangle.Contains);
this.add(this.splicedIcon); this.add(this.splicedIcon);
this.statusIndicator = this.scene.add.sprite(0, 0, `statuses_${i18next.resolvedLanguage}`); this.statusIndicator = this.scene.add.sprite(0, 0, Utils.getLocalizedSpriteKey("statuses"));
this.statusIndicator.setName("icon_status"); this.statusIndicator.setName("icon_status");
this.statusIndicator.setVisible(false); this.statusIndicator.setVisible(false);
this.statusIndicator.setOrigin(0, 0); this.statusIndicator.setOrigin(0, 0);

View File

@ -91,7 +91,7 @@ export default class MoveInfoOverlay extends Phaser.GameObjects.Container implem
valuesBg.setOrigin(0, 0); valuesBg.setOrigin(0, 0);
this.val.add(valuesBg); this.val.add(valuesBg);
this.typ = this.scene.add.sprite(25, EFF_HEIGHT - 35, `types${Utils.verifyLang(i18next.language) ? `_${i18next.language}` : ""}`, "unknown"); this.typ = this.scene.add.sprite(25, EFF_HEIGHT - 35, Utils.getLocalizedSpriteKey("types"), "unknown");
this.typ.setScale(0.8); this.typ.setScale(0.8);
this.val.add(this.typ); this.val.add(this.typ);
@ -138,7 +138,7 @@ export default class MoveInfoOverlay extends Phaser.GameObjects.Container implem
this.pow.setText(move.power >= 0 ? move.power.toString() : "---"); this.pow.setText(move.power >= 0 ? move.power.toString() : "---");
this.acc.setText(move.accuracy >= 0 ? move.accuracy.toString() : "---"); this.acc.setText(move.accuracy >= 0 ? move.accuracy.toString() : "---");
this.pp.setText(move.pp >= 0 ? move.pp.toString() : "---"); this.pp.setText(move.pp >= 0 ? move.pp.toString() : "---");
this.typ.setTexture(`types${Utils.verifyLang(i18next.language) ? `_${i18next.language}` : ""}`, Type[move.type].toLowerCase()); this.typ.setTexture(Utils.getLocalizedSpriteKey("types"), Type[move.type].toLowerCase());
this.cat.setFrame(MoveCategory[move.category].toLowerCase()); this.cat.setFrame(MoveCategory[move.category].toLowerCase());
this.desc.setText(move?.effect || ""); this.desc.setText(move?.effect || "");

View File

@ -1272,7 +1272,7 @@ class PartySlot extends Phaser.GameObjects.Container {
} }
if (this.pokemon.status) { if (this.pokemon.status) {
const statusIndicator = this.scene.add.sprite(0, 0, `statuses_${i18next.resolvedLanguage}`); const statusIndicator = this.scene.add.sprite(0, 0, Utils.getLocalizedSpriteKey("statuses"));
statusIndicator.setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase()); statusIndicator.setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase());
statusIndicator.setOrigin(0, 0); statusIndicator.setOrigin(0, 0);
statusIndicator.setPositionRelative(slotLevelLabel, this.slotIndex >= battlerCount ? 43 : 55, 0); statusIndicator.setPositionRelative(slotLevelLabel, this.slotIndex >= battlerCount ? 43 : 55, 0);

View File

@ -214,7 +214,7 @@ export default class SummaryUiHandler extends UiHandler {
this.statusContainer.add(statusLabel); this.statusContainer.add(statusLabel);
this.status = this.scene.add.sprite(91, 4, `statuses_${i18next.resolvedLanguage}`); this.status = this.scene.add.sprite(91, 4, Utils.getLocalizedSpriteKey("statuses"));
this.status.setOrigin(0.5, 0); this.status.setOrigin(0.5, 0);
this.statusContainer.add(this.status); this.statusContainer.add(this.status);

View File

@ -1,4 +1,5 @@
import { MoneyFormat } from "#enums/money-format"; import { MoneyFormat } from "#enums/money-format";
import { Moves } from "#enums/moves";
import i18next from "i18next"; import i18next from "i18next";
export const MissingTextureKey = "__MISSING"; export const MissingTextureKey = "__MISSING";
@ -628,3 +629,12 @@ export function getLocalizedSpriteKey(baseKey: string) {
export function isBetween(num: number, min: number, max: number): boolean { export function isBetween(num: number, min: number, max: number): boolean {
return num >= min && num <= max; return num >= min && num <= max;
} }
/**
* Helper method to return the animation filename for a given move
*
* @param move the move for which the animation filename is needed
*/
export function animationFileName(move: Moves): string {
return Moves[move].toLowerCase().replace(/\_/g, "-");
}