This commit is contained in:
Bertie690 2025-09-22 21:44:46 -04:00 committed by GitHub
commit 6fbd8a12a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 755 additions and 619 deletions

View File

@ -1,9 +1,10 @@
import type { MoveCategory } from "#enums/move-category"; import type { MoveCategory } from "#enums/move-category";
import type { Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
import type { Move } from "#types/move-types"; // biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Move, VariableMoveTypeChartAttr } from "#types/move-types";
/** /**
* Collection of types for methods like {@linkcode Pokemon#getBaseDamage} and {@linkcode Pokemon#getAttackDamage}. * Collection of types for methods like {@linkcode Pokemon.getBaseDamage} and {@linkcode Pokemon.getAttackDamage}.
* @module * @module
*/ */
@ -32,13 +33,45 @@ export interface damageParams {
} }
/** /**
* Type for the parameters of {@linkcode Pokemon#getBaseDamage | getBaseDamage} * Type for the parameters of {@linkcode Pokemon#=.getBaseDamage | getBaseDamage}
* @interface * @interface
*/ */
export type getBaseDamageParams = Omit<damageParams, "effectiveness">; export type getBaseDamageParams = Omit<damageParams, "effectiveness">;
/** /**
* Type for the parameters of {@linkcode Pokemon#getAttackDamage | getAttackDamage} * Type for the parameters of {@linkcode Pokemon#=.getAttackDamage | getAttackDamage}
* @interface * @interface
*/ */
export type getAttackDamageParams = Omit<damageParams, "moveCategory">; export type getAttackDamageParams = Omit<damageParams, "moveCategory">;
/**
* Type for the parameters of {@linkcode Pokemon.getAttackTypeEffectiveness | getAttackTypeEffectiveness}
* and associated helper functions.
*/
export interface getAttackTypeEffectivenessParams {
/**
* The {@linkcode Pokemon} using the move, used to check the user's Scrappy and Mind's Eye abilities
* and the effects of Foresight/Odor Sleuth.
*/
source?: Pokemon;
/**
* If `true`, ignores the effect of strong winds (used by anticipation, forewarn, stealth rocks)
* @defaultValue `false`
*/
ignoreStrongWinds?: boolean;
/**
* If `true`, will prevent changes to game state during calculations.
* @defaultValue `false`
*/
simulated?: boolean;
/**
* The {@linkcode Move} whose type effectiveness is being checked.
* Used for applying {@linkcode VariableMoveTypeChartAttr}
*/
move?: Move;
/**
* Whether to consider this Pokemon's {@linkcode IllusionData | illusion} when determining types.
* @defaultValue `false`
*/
useIllusion?: boolean;
}

View File

@ -733,8 +733,8 @@ export class AttackTypeImmunityAbAttr extends TypeImmunityAbAttr {
override canApply(params: TypeMultiplierAbAttrParams): boolean { override canApply(params: TypeMultiplierAbAttrParams): boolean {
const { move } = params; const { move } = params;
return ( return (
move.category !== MoveCategory.STATUS move.category !== MoveCategory.STATUS // TODO: make thousand arrows ignore levitate in a different manner
&& !move.hasAttr("NeutralDamageAgainstFlyingTypeMultiplierAttr") && !move.hasAttr("NeutralDamageAgainstFlyingTypeAttr")
&& super.canApply(params) && super.canApply(params)
); );
} }
@ -4163,71 +4163,43 @@ function getWeatherCondition(...weatherTypes: WeatherType[]): AbAttrCondition {
if (globalScene.arena.weather?.isEffectSuppressed()) { if (globalScene.arena.weather?.isEffectSuppressed()) {
return false; return false;
} }
const weatherType = globalScene.arena.weather?.weatherType; return weatherTypes.includes(globalScene.arena.getWeatherType());
return !!weatherType && weatherTypes.indexOf(weatherType) > -1;
}; };
} }
function getAnticipationCondition(): AbAttrCondition { /**
return (pokemon: Pokemon) => { * Condition used by {@linkcode AbilityId.ANTICIPATION} to show a message if any opponent knows a
for (const opponent of pokemon.getOpponents()) { * "dangerous" move.
for (const move of opponent.moveset) { * @param pokemon - The {@linkcode Pokemon} with this ability
// ignore null/undefined moves * @returns Whether the message should be shown
if (!move) { */
continue; const anticipationCondition: AbAttrCondition = (pokemon: Pokemon) =>
} pokemon.getOpponents().some(opponent =>
// the move's base type (not accounting for variable type changes) is super effective opponent.moveset.some(movesetMove => {
if ( // ignore non-attacks
move.getMove().is("AttackMove") const move = movesetMove.getMove();
&& pokemon.getAttackTypeEffectiveness(move.getMove().type, opponent, true, undefined, move.getMove()) >= 2 if (!move.is("AttackMove")) {
) { return false;
return true;
}
// move is a OHKO
if (move.getMove().hasAttr("OneHitKOAttr")) {
return true;
}
// edge case for hidden power, type is computed
if (move.getMove().id === MoveId.HIDDEN_POWER) {
const iv_val = Math.floor(
(((opponent.ivs[Stat.HP] & 1)
+ (opponent.ivs[Stat.ATK] & 1) * 2
+ (opponent.ivs[Stat.DEF] & 1) * 4
+ (opponent.ivs[Stat.SPD] & 1) * 8
+ (opponent.ivs[Stat.SPATK] & 1) * 16
+ (opponent.ivs[Stat.SPDEF] & 1) * 32)
* 15)
/ 63,
);
const type = [
PokemonType.FIGHTING,
PokemonType.FLYING,
PokemonType.POISON,
PokemonType.GROUND,
PokemonType.ROCK,
PokemonType.BUG,
PokemonType.GHOST,
PokemonType.STEEL,
PokemonType.FIRE,
PokemonType.WATER,
PokemonType.GRASS,
PokemonType.ELECTRIC,
PokemonType.PSYCHIC,
PokemonType.ICE,
PokemonType.DRAGON,
PokemonType.DARK,
][iv_val];
if (pokemon.getAttackTypeEffectiveness(type, opponent) >= 2) {
return true;
}
}
} }
}
return false; if (move.hasAttr("OneHitKOAttr")) {
}; return true;
} }
// Check whether the move's base type (not accounting for variable type changes) is super effective
const type = new NumberHolder(
pokemon.getAttackTypeEffectiveness(move.type, {
source: opponent,
ignoreStrongWinds: true,
move,
}),
);
// edge case for hidden power, type is computed
applyMoveAttrs("HiddenPowerTypeAttr", opponent, pokemon, move, type);
return type.value >= 2;
}),
);
/** /**
* Creates an ability condition that causes the ability to fail if that ability * Creates an ability condition that causes the ability to fail if that ability
@ -7035,7 +7007,7 @@ export function initAbilities() {
.attr(PostFaintContactDamageAbAttr, 4) .attr(PostFaintContactDamageAbAttr, 4)
.bypassFaint(), .bypassFaint(),
new Ability(AbilityId.ANTICIPATION, 4) new Ability(AbilityId.ANTICIPATION, 4)
.conditionalAttr(getAnticipationCondition(), PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAnticipation", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })), .conditionalAttr(anticipationCondition, PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAnticipation", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })),
new Ability(AbilityId.FOREWARN, 4) new Ability(AbilityId.FOREWARN, 4)
.attr(ForewarnAbAttr), .attr(ForewarnAbAttr),
new Ability(AbilityId.UNAWARE, 4) new Ability(AbilityId.UNAWARE, 4)

View File

@ -950,7 +950,7 @@ class StealthRockTag extends DamagingTrapTag {
} }
protected override getDamageHpRatio(pokemon: Pokemon): number { protected override getDamageHpRatio(pokemon: Pokemon): number {
const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true); const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, { ignoreStrongWinds: true });
return 0.125 * effectiveness; return 0.125 * effectiveness;
} }

View File

@ -68,7 +68,7 @@ import { StatusEffect } from "#enums/status-effect";
import { SwitchType } from "#enums/switch-type"; import { SwitchType } from "#enums/switch-type";
import { WeatherType } from "#enums/weather-type"; import { WeatherType } from "#enums/weather-type";
import { MoveUsedEvent } from "#events/battle-scene"; import { MoveUsedEvent } from "#events/battle-scene";
import type { EnemyPokemon, Pokemon } from "#field/pokemon"; import { EnemyPokemon, Pokemon } from "#field/pokemon";
import { import {
AttackTypeBoosterModifier, AttackTypeBoosterModifier,
BerryModifier, BerryModifier,
@ -1012,7 +1012,7 @@ export class AttackMove extends Move {
const ret = super.getTargetBenefitScore(user, target, move); const ret = super.getTargetBenefitScore(user, target, move);
let attackScore = 0; let attackScore = 0;
const effectiveness = target.getAttackTypeEffectiveness(this.type, user, undefined, undefined, this); const effectiveness = target.getAttackTypeEffectiveness(this.type, {source: user, move: this});
attackScore = Math.pow(effectiveness - 1, 2) * (effectiveness < 1 ? -2 : 2); attackScore = Math.pow(effectiveness - 1, 2) * (effectiveness < 1 ? -2 : 2);
const [ thisStat, offStat ]: EffectiveStat[] = this.category === MoveCategory.PHYSICAL ? [ Stat.ATK, Stat.SPATK ] : [ Stat.SPATK, Stat.ATK ]; const [ thisStat, offStat ]: EffectiveStat[] = this.category === MoveCategory.PHYSICAL ? [ Stat.ATK, Stat.SPATK ] : [ Stat.SPATK, Stat.ATK ];
const statHolder = new NumberHolder(user.getEffectiveStat(thisStat, target)); const statHolder = new NumberHolder(user.getEffectiveStat(thisStat, target));
@ -1811,7 +1811,7 @@ export class SacrificialAttr extends MoveEffectAttr {
if (user.isBoss()) { if (user.isBoss()) {
return -20; return -20;
} }
return Math.ceil(((1 - user.getHpRatio()) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, user) - 0.5)); return Math.ceil(((1 - user.getHpRatio()) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, {source: user}) - 0.5));
} }
} }
@ -1847,7 +1847,7 @@ export class SacrificialAttrOnHit extends MoveEffectAttr {
if (user.isBoss()) { if (user.isBoss()) {
return -20; return -20;
} }
return Math.ceil(((1 - user.getHpRatio()) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, user) - 0.5)); return Math.ceil(((1 - user.getHpRatio()) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, {source: user}) - 0.5));
} }
} }
@ -1887,7 +1887,7 @@ export class HalfSacrificialAttr extends MoveEffectAttr {
if (user.isBoss()) { if (user.isBoss()) {
return -10; return -10;
} }
return Math.ceil(((1 - user.getHpRatio() / 2) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, user) - 0.5)); return Math.ceil(((1 - user.getHpRatio() / 2) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, {source: user}) - 0.5));
} }
} }
@ -5348,86 +5348,98 @@ export class CombinedPledgeTypeAttr extends VariableMoveTypeAttr {
} }
} }
export class VariableMoveTypeMultiplierAttr extends MoveAttr { /**
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { * Attribute for moves which have a custom type chart interaction.
return false; */
} export abstract class VariableMoveTypeChartAttr extends MoveAttr {
}
export class NeutralDamageAgainstFlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!target.getTag(BattlerTagType.IGNORE_FLYING)) {
const multiplier = args[0] as NumberHolder;
//When a flying type is hit, the first hit is always 1x multiplier.
if (target.isOfType(PokemonType.FLYING)) {
multiplier.value = 1;
}
return true;
}
return false;
}
}
export class IceNoEffectTypeAttr extends VariableMoveTypeMultiplierAttr {
/** /**
* Checks to see if the Target is Ice-Type or not. If so, the move will have no effect. * Apply the attribute to change the move's type effectiveness multiplier.
* @param user n/a * @param user - The {@linkcode Pokemon} using the move
* @param target The {@linkcode Pokemon} targeted by the move * @param target - The {@linkcode Pokemon} targeted by the move
* @param move n/a * @param move - The {@linkcode Move} with this attribute
* @param args `[0]` a {@linkcode NumberHolder | NumberHolder} containing a type effectiveness multiplier * @param args -
* @returns `true` if this Ice-type immunity applies; `false` otherwise * - `[0]`: A {@linkcode NumberHolder} holding the current type effectiveness
* - `[1]`: The target's entire defensive type profile
* - `[2]`: The current {@linkcode PokemonType} of the move
* @returns `true` if application of the attribute succeeds
*/ */
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { public abstract override apply(user: Pokemon, target: Pokemon, move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean;
const multiplier = args[0] as NumberHolder;
if (target.isOfType(PokemonType.ICE)) {
multiplier.value = 0;
return true;
}
return false;
}
} }
export class FlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr { /**
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { * Attribute to implement {@linkcode MoveId.FREEZE_DRY}'s guaranteed water type super effectiveness.
const multiplier = args[0] as NumberHolder; */
multiplier.value *= target.getAttackTypeEffectiveness(PokemonType.FLYING, user); export class FreezeDryAttr extends VariableMoveTypeChartAttr {
public override apply(user: Pokemon, target: Pokemon, move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
const [multiplier, types, moveType] = args;
if (!types.includes(PokemonType.WATER)) {
return false;
}
// Replace whatever the prior "normal" water effectiveness was with a guaranteed 2x multi
const normalEff = getTypeDamageMultiplier(moveType, PokemonType.WATER)
multiplier.value *= 2 / normalEff;
return true; return true;
} }
} }
/** /**
* Attribute for moves which have a custom type chart interaction. * Attribute used by {@linkcode MoveId.THOUSAND_ARROWS} to cause it to deal a fixed 1x damage
* against all ungrounded flying types.
*/ */
export class VariableMoveTypeChartAttr extends MoveAttr { export class NeutralDamageAgainstFlyingTypeAttr extends VariableMoveTypeChartAttr {
/** public override apply(user: Pokemon, target: Pokemon, move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
* @param user {@linkcode Pokemon} using the move const [multiplier, types] = args;
* @param target {@linkcode Pokemon} target of the move if (target.isGrounded() || !types.includes(PokemonType.FLYING)) {
* @param move {@linkcode Move} with this attribute return false;
* @param args [0] {@linkcode NumberHolder} holding the type effectiveness }
* @param args [1] A single defensive type of the target multiplier.value = 1;
* return true;
* @returns true if application of the attribute succeeds
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
return false;
} }
} }
/** /**
* This class forces Freeze-Dry to be super effective against Water Type. * Attribute used by {@linkcode MoveId.SYNCHRONOISE} to render the move ineffective
* against all targets who do not share a type with the user.
*/ */
export class FreezeDryAttr extends VariableMoveTypeChartAttr { export class HitsSameTypeAttr extends VariableMoveTypeChartAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { public override apply(user: Pokemon, _target: Pokemon, _move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
const multiplier = args[0] as NumberHolder; const [multiplier, types] = args;
const defType = args[1] as PokemonType; const userTypes = user.getTypes(true);
// Synchronoise is never effective if the user is typeless
if (defType === PokemonType.WATER) { if (!userTypes.includes(PokemonType.UNKNOWN) && userTypes.some(type => types.includes(type))) {
multiplier.value = 2;
return true;
} else {
return false; return false;
} }
multiplier.value = 0;
return true;
}
}
/**
* Attribute used by {@linkcode MoveId.FLYING_PRESS} to add the Flying Type to its type effectiveness.
*/
export class FlyingTypeMultiplierAttr extends VariableMoveTypeChartAttr {
apply(user: Pokemon, target: Pokemon, _move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
const multiplier = args[0];
// Intentionally exclude `move` to not re-trigger the effects of various moves
// TODO: Do we need to pass `useIllusion` here?
multiplier.value *= target.getAttackTypeEffectiveness(PokemonType.FLYING, {source: user});
return true;
}
}
/**
* Attribute used by {@linkcode MoveId.SHEER_COLD} to implement its Gen VII+ ice ineffectiveness.
*/
export class IceNoEffectTypeAttr extends VariableMoveTypeChartAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
const [multiplier, types] = args;
if (types.includes(PokemonType.ICE)) {
multiplier.value = 0;
return true;
}
return false;
} }
} }
@ -8042,25 +8054,15 @@ export class UpperHandCondition extends MoveCondition {
} }
} }
export class HitsSameTypeAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const multiplier = args[0] as NumberHolder;
if (!user.getTypes(true).some(type => target.getTypes(true).includes(type))) {
multiplier.value = 0;
return true;
}
return false;
}
}
/** /**
* Attribute used for Conversion 2, to convert the user's type to a random type that resists the target's last used move. * Attribute used for Conversion 2, to convert the user's type to a random type that resists the target's last used move.
* Fails if the user already has ALL types that resist the target's last used move. * ~~Fails~~ Does nothing if the user already has ALL types that resist the target's last used move.
* Fails if the opponent has not used a move yet * Fails if the opponent has not used a move yet
* Fails if the type is unknown or stellar * ~~Fails~~ Does nothing if the type is unknown or stellar
* *
* TODO: * TODO:
* If a move has its type changed (e.g. {@linkcode MoveId.HIDDEN_POWER}), it will check the new type. * If a move has its type changed (e.g. {@linkcode MoveId.HIDDEN_POWER}), it will check the new type.
* Does not fail when it should
*/ */
export class ResistLastMoveTypeAttr extends MoveEffectAttr { export class ResistLastMoveTypeAttr extends MoveEffectAttr {
constructor() { constructor() {
@ -8090,8 +8092,7 @@ export class ResistLastMoveTypeAttr extends MoveEffectAttr {
if (moveData.type === PokemonType.STELLAR || moveData.type === PokemonType.UNKNOWN) { if (moveData.type === PokemonType.STELLAR || moveData.type === PokemonType.UNKNOWN) {
return false; return false;
} }
const userTypes = user.getTypes(); const validTypes = this.getTypeResistances(user, moveData.type)
const validTypes = this.getTypeResistances(globalScene.gameMode, moveData.type).filter(t => !userTypes.includes(t)); // valid types are ones that are not already the user's types
if (!validTypes.length) { if (!validTypes.length) {
return false; return false;
} }
@ -8105,21 +8106,26 @@ export class ResistLastMoveTypeAttr extends MoveEffectAttr {
/** /**
* Retrieve the types resisting a given type. Used by Conversion 2 * Retrieve the types resisting a given type. Used by Conversion 2
* @returns An array populated with Types, or an empty array if no resistances exist (Unknown or Stellar type) * @param moveType - The type of the move having been used
* @returns An array containing all types that resist the given move's type
* and are not currently shared by the user
*/ */
getTypeResistances(gameMode: GameMode, type: number): PokemonType[] { private getTypeResistances(user: Pokemon, moveType: PokemonType): PokemonType[] {
const typeResistances: PokemonType[] = []; const resistances: PokemonType[] = [];
const userTypes = user.getTypes(true, true)
for (let i = 0; i < Object.keys(PokemonType).length; i++) {
const multiplier = new NumberHolder(1); for (const type of getEnumValues(PokemonType)) {
multiplier.value = getTypeDamageMultiplier(type, i); if (userTypes.includes(type)) {
applyChallenges(ChallengeType.TYPE_EFFECTIVENESS, multiplier); continue;
if (multiplier.value < 1) { }
typeResistances.push(i); const multiplier = getTypeDamageMultiplier(moveType, type);
if (multiplier < 1) {
resistances.push(type);
} }
} }
return typeResistances; return resistances;
} }
getCondition(): MoveConditionFunc { getCondition(): MoveConditionFunc {
@ -8160,8 +8166,6 @@ export class ExposedMoveAttr extends AddBattlerTagAttr {
} }
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(PokemonType.UNKNOWN);
export type MoveTargetSet = { export type MoveTargetSet = {
targets: BattlerIndex[]; targets: BattlerIndex[];
multiple: boolean; multiple: boolean;
@ -8308,13 +8312,13 @@ const MoveAttrs = Object.freeze({
TeraStarstormTypeAttr, TeraStarstormTypeAttr,
MatchUserTypeAttr, MatchUserTypeAttr,
CombinedPledgeTypeAttr, CombinedPledgeTypeAttr,
VariableMoveTypeMultiplierAttr, NeutralDamageAgainstFlyingTypeAttr,
NeutralDamageAgainstFlyingTypeMultiplierAttr,
IceNoEffectTypeAttr, IceNoEffectTypeAttr,
FlyingTypeMultiplierAttr, FlyingTypeMultiplierAttr,
VariableMoveTypeChartAttr, VariableMoveTypeChartAttr,
FreezeDryAttr, FreezeDryAttr,
OneHitKOAccuracyAttr, OneHitKOAccuracyAttr,
HitsSameTypeAttr,
SheerColdAccuracyAttr, SheerColdAccuracyAttr,
MissEffectAttr, MissEffectAttr,
NoEffectAttr, NoEffectAttr,
@ -8382,7 +8386,6 @@ const MoveAttrs = Object.freeze({
VariableTargetAttr, VariableTargetAttr,
AfterYouAttr, AfterYouAttr,
ForceLastAttr, ForceLastAttr,
HitsSameTypeAttr,
ResistLastMoveTypeAttr, ResistLastMoveTypeAttr,
ExposedMoveAttr, ExposedMoveAttr,
}); });
@ -9909,9 +9912,8 @@ export function initMoves() {
.attr(CompareWeightPowerAttr) .attr(CompareWeightPowerAttr)
.attr(HitsTagForDoubleDamageAttr, BattlerTagType.MINIMIZED), .attr(HitsTagForDoubleDamageAttr, BattlerTagType.MINIMIZED),
new AttackMove(MoveId.SYNCHRONOISE, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 5) new AttackMove(MoveId.SYNCHRONOISE, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 5)
.target(MoveTarget.ALL_NEAR_OTHERS) .attr(HitsSameTypeAttr)
.condition(unknownTypeCondition) .target(MoveTarget.ALL_NEAR_OTHERS),
.attr(HitsSameTypeAttr),
new AttackMove(MoveId.ELECTRO_BALL, PokemonType.ELECTRIC, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 5) new AttackMove(MoveId.ELECTRO_BALL, PokemonType.ELECTRIC, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 5)
.attr(ElectroBallPowerAttr) .attr(ElectroBallPowerAttr)
.ballBombMove(), .ballBombMove(),
@ -10349,7 +10351,7 @@ export function initMoves() {
.attr(HitHealAttr, 0.75) .attr(HitHealAttr, 0.75)
.triageMove(), .triageMove(),
new AttackMove(MoveId.THOUSAND_ARROWS, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) new AttackMove(MoveId.THOUSAND_ARROWS, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
.attr(NeutralDamageAgainstFlyingTypeMultiplierAttr) .attr(NeutralDamageAgainstFlyingTypeAttr)
.attr(FallDownAttr) .attr(FallDownAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING) .attr(HitsTagAttr, BattlerTagType.FLYING)
.attr(HitsTagAttr, BattlerTagType.FLOATING) .attr(HitsTagAttr, BattlerTagType.FLOATING)
@ -11290,9 +11292,9 @@ export function initMoves() {
new AttackMove(MoveId.RUINATION, PokemonType.DARK, MoveCategory.SPECIAL, -1, 90, 10, -1, 0, 9) new AttackMove(MoveId.RUINATION, PokemonType.DARK, MoveCategory.SPECIAL, -1, 90, 10, -1, 0, 9)
.attr(TargetHalfHpDamageAttr), .attr(TargetHalfHpDamageAttr),
new AttackMove(MoveId.COLLISION_COURSE, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 9) new AttackMove(MoveId.COLLISION_COURSE, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 9)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 4 / 3 : 1), .attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, {source: user}) >= 2 ? 4 / 3 : 1),
new AttackMove(MoveId.ELECTRO_DRIFT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 9) new AttackMove(MoveId.ELECTRO_DRIFT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 9)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 4 / 3 : 1) .attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, {source: user}) >= 2 ? 4 / 3 : 1)
.makesContact(), .makesContact(),
new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9) new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(AddSubstituteAttr, 0.5, true) .attr(AddSubstituteAttr, 0.5, true)

View File

@ -1,8 +1,28 @@
import { ChallengeType } from "#enums/challenge-type";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
import { applyChallenges } from "#utils/challenge-utils";
import { NumberHolder } from "#utils/common";
export type TypeDamageMultiplier = 0 | 0.125 | 0.25 | 0.5 | 1 | 2 | 4 | 8; export type TypeDamageMultiplier = 0 | 0.125 | 0.25 | 0.5 | 1 | 2 | 4 | 8;
export function getTypeDamageMultiplier(attackType: PokemonType, defType: PokemonType): TypeDamageMultiplier { export type SingleTypeDamageMultiplier = 0 | 0.5 | 1 | 2;
/**
* Get the base type effectiveness of one `PokemonType` against another. \
* Accounts for Inverse Battle's reversed type effectiveness, but does not apply any other effects.
* @param attackType - The {@linkcode PokemonType} of the attacker
* @param defType - The {@linkcode PokemonType} of the defender
* @returns The type damage multiplier between the two types;
* will be either `0`, `0.5`, `1` or `2`.
*/
export function getTypeDamageMultiplier(attackType: PokemonType, defType: PokemonType): SingleTypeDamageMultiplier {
const multi = new NumberHolder(getTypeChartMultiplier(attackType, defType));
applyChallenges(ChallengeType.TYPE_EFFECTIVENESS, multi);
return multi.value as SingleTypeDamageMultiplier;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This simulates the Pokemon type chart with nested `switch case`s
function getTypeChartMultiplier(attackType: PokemonType, defType: PokemonType): SingleTypeDamageMultiplier {
if (attackType === PokemonType.UNKNOWN || defType === PokemonType.UNKNOWN) { if (attackType === PokemonType.UNKNOWN || defType === PokemonType.UNKNOWN) {
return 1; return 1;
} }
@ -263,10 +283,7 @@ export function getTypeDamageMultiplier(attackType: PokemonType, defType: Pokemo
case PokemonType.STELLAR: case PokemonType.STELLAR:
return 1; return 1;
} }
return 1;
} }
/** /**
* Retrieve the color corresponding to a specific damage multiplier * Retrieve the color corresponding to a specific damage multiplier
* @returns A color or undefined if the default color should be used * @returns A color or undefined if the default color should be used

View File

@ -1,4 +1,5 @@
export enum PokemonType { export enum PokemonType {
/** Typeless */
UNKNOWN = -1, UNKNOWN = -1,
NORMAL = 0, NORMAL = 0,
FIGHTING, FIGHTING,

View File

@ -141,7 +141,11 @@ import type { PokemonData } from "#system/pokemon-data";
import { RibbonData } from "#system/ribbons/ribbon-data"; import { RibbonData } from "#system/ribbons/ribbon-data";
import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods"; import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods";
import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types"; import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types";
import type { getAttackDamageParams, getBaseDamageParams } from "#types/damage-params"; import type {
getAttackDamageParams,
getAttackTypeEffectivenessParams,
getBaseDamageParams,
} from "#types/damage-params";
import type { DamageCalculationResult, DamageResult } from "#types/damage-result"; import type { DamageCalculationResult, DamageResult } from "#types/damage-result";
import type { IllusionData } from "#types/illusion-data"; import type { IllusionData } from "#types/illusion-data";
import type { StarterDataEntry, StarterMoveset } from "#types/save-data"; import type { StarterDataEntry, StarterMoveset } from "#types/save-data";
@ -2435,11 +2439,10 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
const typeMultiplier = new NumberHolder( const typeMultiplier = new NumberHolder(
move.category !== MoveCategory.STATUS || move.hasAttr("RespectAttackTypeImmunityAttr") move.category !== MoveCategory.STATUS || move.hasAttr("RespectAttackTypeImmunityAttr")
? this.getAttackTypeEffectiveness(moveType, source, false, simulated, move, useIllusion) ? this.getAttackTypeEffectiveness(moveType, { source, simulated, move, useIllusion })
: 1, : 1,
); );
applyMoveAttrs("VariableMoveTypeMultiplierAttr", source, this, move, typeMultiplier);
if (this.getTypes(true, true).find(t => move.isTypeImmune(source, this, t))) { if (this.getTypes(true, true).find(t => move.isTypeImmune(source, this, t))) {
typeMultiplier.value = 0; typeMultiplier.value = 0;
} }
@ -2500,85 +2503,115 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
} }
/** /**
* Calculates the move's type effectiveness multiplier based on the target's type/s. * Calculate the type effectiveness multiplier of a Move used **against** this Pokemon.
* @param moveType {@linkcode PokemonType} the type of the move being used * @param moveType - The {@linkcode PokemonType} of the move being used
* @param source {@linkcode Pokemon} the Pokemon using the move * @param source - The {@linkcode Pokemon} using the move, used to check the user's Scrappy and Mind's Eye abilities
* @param ignoreStrongWinds whether or not this ignores strong winds (anticipation, forewarn, stealth rocks) * and the effects of Foresight/Odor Sleuth
* @param simulated tag to only apply the strong winds effect message when the move is used * @param ignoreStrongWinds - If `true`, ignores the effect of strong winds (used by anticipation, forewarn, stealth rocks);
* @param move (optional) the move whose type effectiveness is to be checked. Used for applying {@linkcode VariableMoveTypeChartAttr} * default `false`
* @param useIllusion - Whether we want the attack type effectiveness on the illusion or not * @param simulated - If `true`, will prevent changes to game state during calculations; default `false`
* @returns a multiplier for the type effectiveness * @param move - The {@linkcode Move} whose type effectiveness is being checked. Used for applying {@linkcode VariableMoveTypeChartAttr}
* @param useIllusion - Whether to consider this Pokemon's {@linkcode IllusionData | illusion} when determining types; default `false`
* @returns The computed type effectiveness multiplier.
*/ */
getAttackTypeEffectiveness( getAttackTypeEffectiveness(
moveType: PokemonType, moveType: PokemonType,
source?: Pokemon, {
ignoreStrongWinds = false, source,
simulated = true, ignoreStrongWinds = false,
move?: Move, simulated = true,
useIllusion = false, move,
useIllusion = false,
}: getAttackTypeEffectivenessParams = {},
): TypeDamageMultiplier { ): TypeDamageMultiplier {
if (moveType === PokemonType.STELLAR) { if (moveType === PokemonType.STELLAR) {
return this.isTerastallized ? 2 : 1; return this.isTerastallized ? 2 : 1;
} }
const types = this.getTypes(true, true, undefined, useIllusion);
const types = this.getTypes(true, true, false, useIllusion);
const arena = globalScene.arena; const arena = globalScene.arena;
// Handle flying v ground type immunity without removing flying type so effective types are still effective // Handle flying v ground type immunity without removing flying type so effective types are still effective
// Related to https://github.com/pagefaultgames/pokerogue/issues/524 // Related to https://github.com/pagefaultgames/pokerogue/issues/524
if (moveType === PokemonType.GROUND && (this.isGrounded() || arena.hasTag(ArenaTagType.GRAVITY))) { // TODO: Fix once gravity makes pokemon actually grounded
const flyingIndex = types.indexOf(PokemonType.FLYING); if (
if (flyingIndex > -1) { moveType === PokemonType.GROUND
types.splice(flyingIndex, 1); && types.includes(PokemonType.FLYING)
} && (this.isGrounded() || arena.hasTag(ArenaTagType.GRAVITY))
) {
types.splice(types.indexOf(PokemonType.FLYING), 1);
} }
let multiplier = types const multi = new NumberHolder(1);
.map(defenderType => { for (const defenderType of types) {
const multiplier = new NumberHolder(getTypeDamageMultiplier(moveType, defenderType)); const typeMulti = getTypeDamageMultiplier(moveType, defenderType);
applyChallenges(ChallengeType.TYPE_EFFECTIVENESS, multiplier); // If the target is immune to the type in question, check for effects that would ignore said nullification
if (move) { // TODO: Review if the `isActive` check is needed anymore
applyMoveAttrs("VariableMoveTypeChartAttr", null, this, move, multiplier, defenderType); if (
} source?.isActive(true)
if (source) { && typeMulti === 0
const ignoreImmunity = new BooleanHolder(false); && this.checkIgnoreTypeImmunity({ source, simulated, moveType, defenderType })
if (source.isActive(true) && source.hasAbilityWithAttr("IgnoreTypeImmunityAbAttr")) { ) {
applyAbAttrs("IgnoreTypeImmunityAbAttr", { continue;
pokemon: source, }
cancelled: ignoreImmunity, multi.value *= typeMulti;
simulated, }
moveType,
defenderType,
});
}
if (ignoreImmunity.value && multiplier.value === 0) {
return 1;
}
const exposedTags = this.findTags(tag => tag instanceof ExposedTag) as ExposedTag[]; // Apply any typing changes from Freeze-Dry, etc.
if (exposedTags.some(t => t.ignoreImmunity(defenderType, moveType)) && multiplier.value === 0) { if (move) {
return 1; applyMoveAttrs("VariableMoveTypeChartAttr", source ?? null, this, move, multi, types, moveType);
} }
}
return multiplier.value;
})
.reduce((acc, cur) => acc * cur, 1) as TypeDamageMultiplier;
const typeMultiplierAgainstFlying = new NumberHolder(getTypeDamageMultiplier(moveType, PokemonType.FLYING));
applyChallenges(ChallengeType.TYPE_EFFECTIVENESS, typeMultiplierAgainstFlying);
// Handle strong winds lowering effectiveness of types super effective against pure flying // Handle strong winds lowering effectiveness of types super effective against pure flying
if ( if (
!ignoreStrongWinds !ignoreStrongWinds
&& arena.weather?.weatherType === WeatherType.STRONG_WINDS && arena.getWeatherType() === WeatherType.STRONG_WINDS
&& !arena.weather.isEffectSuppressed() && !arena.weather?.isEffectSuppressed()
&& this.isOfType(PokemonType.FLYING) && this.isOfType(PokemonType.FLYING)
&& typeMultiplierAgainstFlying.value === 2 && getTypeDamageMultiplier(moveType, PokemonType.FLYING) === 2
) { ) {
multiplier /= 2; multi.value /= 2;
if (!simulated) { if (!simulated) {
globalScene.phaseManager.queueMessage(i18next.t("weather:strongWindsEffectMessage")); globalScene.phaseManager.queueMessage(i18next.t("weather:strongWindsEffectMessage"));
} }
} }
return multiplier as TypeDamageMultiplier; return multi.value as TypeDamageMultiplier;
}
/**
* Sub-method of {@linkcode getAttackTypeEffectiveness} that handles nullifying type immunities.
* @param source - The {@linkcode Pokemon} from whom the attack is sourced
* @param simulated - If `true`, will prevent displaying messages upon activation
* @param moveType - The {@linkcode PokemonType} whose offensive typing is being checked
* @param defenderType - The defender's {@linkcode PokemonType} being checked
* @returns Whether the type immunity was bypassed
*/
private checkIgnoreTypeImmunity({
source,
simulated,
moveType,
defenderType,
}: {
source: Pokemon;
simulated: boolean;
moveType: PokemonType;
defenderType: PokemonType;
}): boolean {
const exposedTags = this.findTags(tag => tag instanceof ExposedTag) as ExposedTag[];
const hasExposed = exposedTags.some(t => t.ignoreImmunity(defenderType, moveType));
if (hasExposed) {
return true;
}
const ignoreImmunity = new BooleanHolder(false);
applyAbAttrs("IgnoreTypeImmunityAbAttr", {
pokemon: source,
cancelled: ignoreImmunity,
simulated,
moveType,
defenderType,
});
return ignoreImmunity.value;
} }
/** /**
@ -2599,10 +2632,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* Based on how effectively this Pokemon defends against the opponent's types. * Based on how effectively this Pokemon defends against the opponent's types.
* This score cannot be higher than 4. * This score cannot be higher than 4.
*/ */
let defScore = 1 / Math.max(this.getAttackTypeEffectiveness(enemyTypes[0], opponent), 0.25); let defScore = 1 / Math.max(this.getAttackTypeEffectiveness(enemyTypes[0], { source: opponent }), 0.25);
if (enemyTypes.length > 1) { if (enemyTypes.length > 1) {
defScore *= defScore *=
1 / Math.max(this.getAttackTypeEffectiveness(enemyTypes[1], opponent, false, false, undefined, true), 0.25); // TODO: Shouldn't this pass `simulated=true` here?
1
/ Math.max(
this.getAttackTypeEffectiveness(enemyTypes[1], { source: opponent, simulated: false, useIllusion: true }),
0.25,
);
} }
const moveset = this.moveset; const moveset = this.moveset;
@ -2616,7 +2654,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
continue; continue;
} }
const moveType = resolvedMove.type; const moveType = resolvedMove.type;
let thisScore = opponent.getAttackTypeEffectiveness(moveType, this, false, true, undefined, true); let thisScore = opponent.getAttackTypeEffectiveness(moveType, {
source: this,
simulated: true,
useIllusion: true,
});
// Add STAB multiplier for attack type effectiveness. // Add STAB multiplier for attack type effectiveness.
// For now, simply don't apply STAB to moves that may change type // For now, simply don't apply STAB to moves that may change type
@ -4090,25 +4132,43 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
} }
/** /**
* Find the first `BattlerTag` matching the specified predicate * Find the first `BattlerTag` matching the specified predicate.
* @param tagFilter - The predicate to match against
* @returns The first matching tag, or `undefined` if none match.
* @remarks * @remarks
* Equivalent to `this.summonData.tags.find(tagFilter)`. * Equivalent to `this.summonData.tags.find(tagFilter)`.
* @param tagFilter - The predicate to match against
* @returns The first matching tag, or `undefined` if none match
*/ */
public findTag(tagFilter: (tag: BattlerTag) => boolean) { findTag<T extends BattlerTag>(tagFilter: (tag: BattlerTag) => tag is T): T | undefined;
return this.summonData.tags.find(tagFilter); /**
* Find the first `BattlerTag` matching the specified predicate.
* @param tagFilter - The predicate to match against
* @returns The first matching tag, or `undefined` if none match.
* @remarks
* Equivalent to `this.summonData.tags.find(tagFilter)`.
*/
findTag(tagFilter: (tag: BattlerTag) => boolean): BattlerTag | undefined;
findTag(tagFilter: (tag: BattlerTag) => boolean) {
return this.summonData.tags.find(t => tagFilter(t));
} }
/** /**
* Return the list of `BattlerTag`s that satisfy the given predicate * Return all `BattlerTag`s satisfying the given predicate.
* @param tagFilter - The predicate to match against
* @returns The filtered list of tags.
* @remarks * @remarks
* Equivalent to `this.summonData.tags.filter(tagFilter)`. * Equivalent to `this.summonData.tags.filter(tagFilter)`.
* @param tagFilter - The predicate to match against
* @returns The filtered list of tags
*/ */
public findTags(tagFilter: (tag: BattlerTag) => boolean): BattlerTag[] { findTags<T extends BattlerTag>(tagFilter: (tag: BattlerTag) => tag is T): T[];
return this.summonData.tags.filter(tagFilter); /**
* Return all `BattlerTag`s satisfying the given predicate.
* @param tagFilter - The predicate to match against
* @returns The filtered list of tags.
* @remarks
* Equivalent to `this.summonData.tags.filter(tagFilter)`.
*/
findTags(tagFilter: (tag: BattlerTag) => boolean): BattlerTag[];
findTags(tagFilter: (tag: BattlerTag) => boolean): BattlerTag[] {
return this.summonData.tags.filter(t => tagFilter(t));
} }
/** /**

View File

@ -62,15 +62,24 @@ export class GameMode implements GameModeConfig {
/** /**
* Enables challenges if they are disabled and sets the specified challenge's value * Enables challenges if they are disabled and sets the specified challenge's value
* @param challenge The challenge to set * @param challenge - The challenge to set
* @param value The value to give the challenge. Impact depends on the specific challenge * @param value - The value to give the challenge. Impact depends on the specific challenge
* @param severity - If provided, will override the given severity amount. Unused if `challenge` does not use severity
* @todo Add severity support to daily mode challenge setting
*/ */
setChallengeValue(challenge: Challenges, value: number) { setChallengeValue(challenge: Challenges, value: number, severity?: number) {
if (!this.isChallenge) { if (!this.isChallenge) {
this.isChallenge = true; this.isChallenge = true;
this.challenges = allChallenges.map(c => copyChallenge(c)); this.challenges = allChallenges.map(c => copyChallenge(c));
} }
this.challenges.filter((chal: Challenge) => chal.id === challenge).map((chal: Challenge) => (chal.value = value)); this.challenges
.filter((chal: Challenge) => chal.id === challenge)
.forEach(chal => {
chal.value = value;
if (chal.hasSeverity()) {
chal.severity = severity ?? chal.severity;
}
});
} }
/** /**

View File

@ -88,6 +88,7 @@ describe("Abilities - Illusion", () => {
expect(game.field.getPlayerPokemon().summonData.illusion).toBeFalsy(); expect(game.field.getPlayerPokemon().summonData.illusion).toBeFalsy();
}); });
// TODO: This doesn't actually check that the ai calls the function this way... useless test
it("causes enemy AI to consider the illusion's type instead of the actual type when considering move effectiveness", async () => { it("causes enemy AI to consider the illusion's type instead of the actual type when considering move effectiveness", async () => {
game.override.enemyMoveset([MoveId.FLAMETHROWER, MoveId.PSYCHIC, MoveId.TACKLE]); game.override.enemyMoveset([MoveId.FLAMETHROWER, MoveId.PSYCHIC, MoveId.TACKLE]);
await game.classicMode.startBattle([SpeciesId.ZOROARK, SpeciesId.FEEBAS]); await game.classicMode.startBattle([SpeciesId.ZOROARK, SpeciesId.FEEBAS]);
@ -97,22 +98,16 @@ describe("Abilities - Illusion", () => {
const flameThrower = enemy.getMoveset()[0]!.getMove(); const flameThrower = enemy.getMoveset()[0]!.getMove();
const psychic = enemy.getMoveset()[1]!.getMove(); const psychic = enemy.getMoveset()[1]!.getMove();
const flameThrowerEffectiveness = zoroark.getAttackTypeEffectiveness( const flameThrowerEffectiveness = zoroark.getAttackTypeEffectiveness(flameThrower.type, {
flameThrower.type, source: enemy,
enemy, move: flameThrower,
undefined, useIllusion: true,
undefined, });
flameThrower, const psychicEffectiveness = zoroark.getAttackTypeEffectiveness(psychic.type, {
true, source: enemy,
); move: psychic,
const psychicEffectiveness = zoroark.getAttackTypeEffectiveness( useIllusion: true,
psychic.type, });
enemy,
undefined,
undefined,
psychic,
true,
);
expect(psychicEffectiveness).above(flameThrowerEffectiveness); expect(psychicEffectiveness).above(flameThrowerEffectiveness);
}); });

View File

@ -1,6 +1,7 @@
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; import Phaser from "phaser";
@ -113,4 +114,18 @@ describe("Abilities - Tera Shell", () => {
} }
expect(spy).toHaveReturnedTimes(2); expect(spy).toHaveReturnedTimes(2);
}); });
it("should overwrite Freeze-Dry", async () => {
await game.classicMode.startBattle([SpeciesId.TERAPAGOS]);
const terapagos = game.field.getPlayerPokemon();
terapagos.summonData.types = [PokemonType.WATER];
const spy = vi.spyOn(terapagos, "getMoveEffectiveness");
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.FREEZE_DRY);
await game.toEndOfTurn();
expect(spy).toHaveLastReturnedWith(0.5);
});
}); });

View File

@ -42,7 +42,7 @@ describe("Weather - Strong Winds", () => {
game.move.select(MoveId.THUNDERBOLT); game.move.select(MoveId.THUNDERBOLT);
await game.phaseInterceptor.to(TurnStartPhase); await game.phaseInterceptor.to(TurnStartPhase);
expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.THUNDERBOLT].type, pikachu)).toBe(0.5); expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.THUNDERBOLT].type, { source: pikachu })).toBe(0.5);
}); });
it("electric type move is neutral for flying type pokemon", async () => { it("electric type move is neutral for flying type pokemon", async () => {
@ -53,7 +53,7 @@ describe("Weather - Strong Winds", () => {
game.move.select(MoveId.THUNDERBOLT); game.move.select(MoveId.THUNDERBOLT);
await game.phaseInterceptor.to(TurnStartPhase); await game.phaseInterceptor.to(TurnStartPhase);
expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.THUNDERBOLT].type, pikachu)).toBe(1); expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.THUNDERBOLT].type, { source: pikachu })).toBe(1);
}); });
it("ice type move is neutral for flying type pokemon", async () => { it("ice type move is neutral for flying type pokemon", async () => {
@ -64,7 +64,7 @@ describe("Weather - Strong Winds", () => {
game.move.select(MoveId.ICE_BEAM); game.move.select(MoveId.ICE_BEAM);
await game.phaseInterceptor.to(TurnStartPhase); await game.phaseInterceptor.to(TurnStartPhase);
expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.ICE_BEAM].type, pikachu)).toBe(1); expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.ICE_BEAM].type, { source: pikachu })).toBe(1);
}); });
it("rock type move is neutral for flying type pokemon", async () => { it("rock type move is neutral for flying type pokemon", async () => {
@ -75,7 +75,7 @@ describe("Weather - Strong Winds", () => {
game.move.select(MoveId.ROCK_SLIDE); game.move.select(MoveId.ROCK_SLIDE);
await game.phaseInterceptor.to(TurnStartPhase); await game.phaseInterceptor.to(TurnStartPhase);
expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.ROCK_SLIDE].type, pikachu)).toBe(1); expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.ROCK_SLIDE].type, { source: pikachu })).toBe(1);
}); });
it("weather goes away when last trainer pokemon dies to indirect damage", async () => { it("weather goes away when last trainer pokemon dies to indirect damage", async () => {

View File

@ -106,21 +106,6 @@ describe("Inverse Battle", () => {
expect(currentHp).toBeGreaterThan((maxHp * 31) / 32 - 1); expect(currentHp).toBeGreaterThan((maxHp * 31) / 32 - 1);
}); });
it("Freeze Dry is 2x effective against Water Type like other Ice type Move - Freeze Dry against Squirtle", async () => {
game.override.moveset([MoveId.FREEZE_DRY]).enemySpecies(SpeciesId.SQUIRTLE);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(2);
});
it("Water Absorb should heal against water moves - Water Absorb against Water gun", async () => { it("Water Absorb should heal against water moves - Water Absorb against Water gun", async () => {
game.override.moveset([MoveId.WATER_GUN]).enemyAbility(AbilityId.WATER_ABSORB); game.override.moveset([MoveId.WATER_GUN]).enemyAbility(AbilityId.WATER_ABSORB);
@ -202,21 +187,6 @@ describe("Inverse Battle", () => {
expect(player.getTypes()[0]).toBe(PokemonType.DRAGON); expect(player.getTypes()[0]).toBe(PokemonType.DRAGON);
}); });
it("Flying Press should be 0.25x effective against Grass + Dark Type - Flying Press against Meowscarada", async () => {
game.override.moveset([MoveId.FLYING_PRESS]).enemySpecies(SpeciesId.MEOWSCARADA);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FLYING_PRESS);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(0.25);
});
it("Scrappy ability has no effect - Tackle against Ghost Type still 2x effective with Scrappy", async () => { it("Scrappy ability has no effect - Tackle against Ghost Type still 2x effective with Scrappy", async () => {
game.override.moveset([MoveId.TACKLE]).ability(AbilityId.SCRAPPY).enemySpecies(SpeciesId.GASTLY); game.override.moveset([MoveId.TACKLE]).ability(AbilityId.SCRAPPY).enemySpecies(SpeciesId.GASTLY);

View File

@ -196,7 +196,7 @@ describe("Moves - Entry Hazards", () => {
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]); await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]);
const enemy = game.field.getEnemyPokemon(); const enemy = game.field.getEnemyPokemon();
expect(enemy.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true)).toBe(multi); expect(enemy.getAttackTypeEffectiveness(PokemonType.ROCK, { ignoreStrongWinds: true })).toBe(multi);
expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi); expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi);
expect(game.textInterceptor.logs).toContain( expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTag:stealthRockActivateTrap", { i18next.t("arenaTag:stealthRockActivateTrap", {

View File

@ -0,0 +1,132 @@
import { allAbilities, allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import type { EnemyPokemon, PlayerPokemon } from "#field/pokemon";
import { GameManager } from "#test/test-utils/game-manager";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import Phaser from "phaser";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
describe.sequential("Move - Flying Press", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
let hawlucha: PlayerPokemon;
let enemy: EnemyPokemon;
beforeAll(async () => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.HAWLUCHA]);
hawlucha = game.field.getPlayerPokemon();
enemy = game.field.getEnemyPokemon();
});
afterAll(() => {
game.phaseInterceptor.restoreOg();
});
// Reset temp data after each test
afterEach(() => {
hawlucha.resetSummonData();
enemy.resetSummonData();
});
const pokemonTypes = getEnumValues(PokemonType);
function checkEffForAllTypes(primaryType: PokemonType) {
for (const type of pokemonTypes) {
enemy.summonData.types = [type];
const primaryEff = enemy.getAttackTypeEffectiveness(primaryType, { source: hawlucha });
const flyingEff = enemy.getAttackTypeEffectiveness(PokemonType.FLYING, { source: hawlucha });
const flyingPressEff = enemy.getAttackTypeEffectiveness(hawlucha.getMoveType(allMoves[MoveId.FLYING_PRESS]), {
source: hawlucha,
move: allMoves[MoveId.FLYING_PRESS],
});
expect
.soft(
flyingPressEff,
// biome-ignore lint/complexity/noUselessStringConcat: Biome can't detect multiline concats with operators before line
`Flying Press effectiveness against ${toTitleCase(PokemonType[type])} was incorrect!`
+ `\nExpected: ${flyingPressEff},`
+ `\nActual: ${primaryEff * flyingEff} (=${primaryEff} * ${flyingEff})`,
)
.toBe(primaryEff * flyingEff);
}
}
describe("Normal -", () => {
it("should deal damage as a Fighting/Flying type move by default", async () => {
checkEffForAllTypes(PokemonType.FIGHTING);
});
it("should deal damage as an Electric/Flying type move when Electrify is active", async () => {
hawlucha.addTag(BattlerTagType.ELECTRIFIED);
checkEffForAllTypes(PokemonType.ELECTRIC);
});
it("should deal damage as a Normal/Flying type move when Normalize is active", async () => {
hawlucha.setTempAbility(allAbilities[AbilityId.NORMALIZE]);
checkEffForAllTypes(PokemonType.NORMAL);
});
it("should deal 8x damage against a Normal/Ice type with Grass added", () => {
enemy.summonData.types = [PokemonType.NORMAL, PokemonType.ICE];
enemy.summonData.addedType = PokemonType.GRASS;
const moveType = hawlucha.getMoveType(allMoves[MoveId.FLYING_PRESS]);
const flyingPressEff = enemy.getAttackTypeEffectiveness(moveType, {
source: hawlucha,
move: allMoves[MoveId.FLYING_PRESS],
});
expect(flyingPressEff).toBe(8);
});
});
describe("Inverse Battle -", () => {
beforeAll(() => {
game.challengeMode.overrideGameWithChallenges(Challenges.INVERSE_BATTLE, 1, 1);
});
it("should deal damage as a Fighting/Flying type move by default", async () => {
checkEffForAllTypes(PokemonType.FIGHTING);
});
it("should deal damage as an Electric/Flying type move when Electrify is active", async () => {
hawlucha.addTag(BattlerTagType.ELECTRIFIED);
checkEffForAllTypes(PokemonType.ELECTRIC);
});
it("should deal damage as a Normal/Flying type move when Normalize is active", async () => {
hawlucha.setTempAbility(allAbilities[AbilityId.NORMALIZE]);
checkEffForAllTypes(PokemonType.NORMAL);
});
it("should deal 0.125x damage against a Normal/Ice type with Grass added", () => {
enemy.summonData.types = [PokemonType.NORMAL, PokemonType.ICE];
enemy.summonData.addedType = PokemonType.GRASS;
const moveType = hawlucha.getMoveType(allMoves[MoveId.FLYING_PRESS]);
const flyingPressEff = enemy.getAttackTypeEffectiveness(moveType, {
source: hawlucha,
move: allMoves[MoveId.FLYING_PRESS],
});
expect(flyingPressEff).toBe(0.125);
});
});
});

View File

@ -1,330 +1,140 @@
import { allMoves } from "#data/data-lists";
import type { TypeDamageMultiplier } from "#data/type";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type";
import { Challenges } from "#enums/challenges"; import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import type { EnemyPokemon, PlayerPokemon } from "#field/pokemon";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import { stringifyEnumArray } from "#test/test-utils/string-utils";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
describe("Moves - Freeze-Dry", () => { type typesArray = [PokemonType] | [PokemonType, PokemonType] | [PokemonType, PokemonType, PokemonType];
describe.sequential("Move - Freeze-Dry", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
let game: GameManager; let game: GameManager;
beforeAll(() => { let feebas: PlayerPokemon;
let enemy: EnemyPokemon;
beforeAll(async () => {
phaserGame = new Phaser.Game({ phaserGame = new Phaser.Game({
type: Phaser.HEADLESS, type: Phaser.HEADLESS,
}); });
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.battleStyle("single") .battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP) .enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH) .enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH) .enemyMoveset(MoveId.SPLASH)
.starterSpecies(SpeciesId.FEEBAS) .ability(AbilityId.BALL_FETCH);
.ability(AbilityId.BALL_FETCH)
.moveset([MoveId.FREEZE_DRY, MoveId.FORESTS_CURSE, MoveId.SOAK]); await game.classicMode.startBattle([SpeciesId.FEEBAS]);
feebas = game.field.getPlayerPokemon();
enemy = game.field.getEnemyPokemon();
}); });
it("should deal 2x damage to pure water types", async () => { // Reset temp data after each test
await game.classicMode.startBattle(); afterEach(() => {
feebas.resetSummonData();
const enemy = game.field.getEnemyPokemon(); enemy.resetSummonData();
vi.spyOn(enemy, "getMoveEffectiveness"); enemy.isTerastallized = false;
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2);
}); });
it("should deal 4x damage to water/flying types", async () => { afterAll(() => {
game.override.enemySpecies(SpeciesId.WINGULL); game.phaseInterceptor.restoreOg();
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(4);
});
it("should deal 1x damage to water/fire types", async () => {
game.override.enemySpecies(SpeciesId.VOLCANION);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(1);
}); });
/** /**
* Freeze drys forced super effectiveness should overwrite wonder guard * Check that Freeze-Dry is the given effectiveness against the given type.
* @param types - The base {@linkcode PokemonType}s to set; will populate `addedType` if above 3
* @param multi - The expected {@linkcode TypeDamageMultiplier}
*/ */
it("should deal 2x dmg against soaked wonder guard target", async () => { function expectEffectiveness(types: typesArray, multi: TypeDamageMultiplier): void {
game.override enemy.summonData.types = types.slice(0, 2);
.enemySpecies(SpeciesId.SHEDINJA) if (types[2] !== undefined) {
.enemyMoveset(MoveId.SPLASH) enemy.summonData.addedType = types[2];
.starterSpecies(SpeciesId.MAGIKARP) }
.moveset([MoveId.SOAK, MoveId.FREEZE_DRY]);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon(); const moveType = feebas.getMoveType(allMoves[MoveId.FREEZE_DRY]);
vi.spyOn(enemy, "getMoveEffectiveness"); const eff = enemy.getAttackTypeEffectiveness(moveType, { source: feebas, move: allMoves[MoveId.FREEZE_DRY] });
expect(
eff,
`Freeze-dry effectiveness against ${stringifyEnumArray(PokemonType, types)} was ${eff} instead of ${multi}!`,
).toBe(multi);
}
game.move.select(MoveId.SOAK); describe("Normal -", () => {
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); it.each<{ name: string; types: typesArray; eff: TypeDamageMultiplier }>([
await game.toNextTurn(); { name: "Pure Water", types: [PokemonType.WATER], eff: 2 },
{ name: "Water/Ground", types: [PokemonType.WATER, PokemonType.GROUND], eff: 4 },
{ name: "Water/Flying/Grass", types: [PokemonType.WATER, PokemonType.FLYING, PokemonType.GRASS], eff: 8 },
{ name: "Water/Fire", types: [PokemonType.WATER, PokemonType.FIRE], eff: 1 },
])("should be $effx effective against a $name-type opponent", ({ types, eff }) => {
expectEffectiveness(types, eff);
});
game.move.select(MoveId.FREEZE_DRY); it("should deal 2x dmg against soaked wonder guard target", async () => {
await game.phaseInterceptor.to("MoveEffectPhase"); game.field.mockAbility(enemy, AbilityId.WONDER_GUARD);
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2); expectEffectiveness([PokemonType.WATER], 2);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); });
it("should consider the target's Tera Type", async () => {
// Steel type terastallized into Water; 2x
enemy.teraType = PokemonType.WATER;
enemy.isTerastallized = true;
expectEffectiveness([PokemonType.STEEL], 2);
// Water type terastallized into steel; 0.5x
enemy.teraType = PokemonType.STEEL;
expectEffectiveness([PokemonType.WATER], 0.5);
});
it.each<{ name: string; types: typesArray; eff: TypeDamageMultiplier }>([
{ name: "Pure Water", types: [PokemonType.WATER], eff: 2 },
{ name: "Water/Ghost", types: [PokemonType.WATER, PokemonType.GHOST], eff: 0 },
])("should be $effx effective against a $name-type opponent with Normalize", ({ types, eff }) => {
game.field.mockAbility(feebas, AbilityId.NORMALIZE);
expectEffectiveness(types, eff);
});
it("should not stack with Electrify", async () => {
feebas.addTag(BattlerTagType.ELECTRIFIED);
expect(feebas.getMoveType(allMoves[MoveId.FREEZE_DRY])).toBe(PokemonType.ELECTRIC);
expectEffectiveness([PokemonType.WATER], 2);
});
}); });
it("should deal 8x damage to water/ground/grass type under Forest's Curse", async () => { describe("Inverse Battle -", () => {
game.override.enemySpecies(SpeciesId.QUAGSIRE); beforeAll(() => {
await game.classicMode.startBattle(); game.challengeMode.overrideGameWithChallenges(Challenges.INVERSE_BATTLE, 1, 1);
});
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness"); it("should deal 2x damage to Water type", async () => {
expectEffectiveness([PokemonType.WATER], 2);
game.move.select(MoveId.FORESTS_CURSE); });
await game.toNextTurn();
it("should deal 2x damage to Water type under Normalize", async () => {
game.move.select(MoveId.FREEZE_DRY); game.field.mockAbility(feebas, AbilityId.NORMALIZE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); expectEffectiveness([PokemonType.WATER], 2);
await game.phaseInterceptor.to("MoveEffectPhase"); });
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(8); it("should still deal 2x damage to Water type under Electrify", async () => {
}); feebas.addTag(BattlerTagType.ELECTRIFIED);
expectEffectiveness([PokemonType.WATER], 2);
it("should deal 2x damage to steel type terastallized into water", async () => { });
game.override.enemySpecies(SpeciesId.SKARMORY);
await game.classicMode.startBattle(); it("should deal 1x damage to Water/Flying type under Electrify", async () => {
feebas.addTag(BattlerTagType.ELECTRIFIED);
const enemy = game.field.getEnemyPokemon(); expectEffectiveness([PokemonType.WATER, PokemonType.FLYING], 1);
enemy.teraType = PokemonType.WATER; });
enemy.isTerastallized = true;
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2);
});
it("should deal 0.5x damage to water type terastallized into fire", async () => {
game.override.enemySpecies(SpeciesId.PELIPPER);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
enemy.teraType = PokemonType.FIRE;
enemy.isTerastallized = true;
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0.5);
});
it("should deal 0.5x damage to water type Terapagos with Tera Shell", async () => {
game.override.enemySpecies(SpeciesId.TERAPAGOS).enemyAbility(AbilityId.TERA_SHELL);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.SOAK);
await game.toNextTurn();
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0.5);
});
it("should deal 2x damage to water type under Normalize", async () => {
game.override.ability(AbilityId.NORMALIZE);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2);
});
it("should deal 0.25x damage to rock/steel type under Normalize", async () => {
game.override.ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.SHIELDON);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0.25);
});
it("should deal 0x damage to water/ghost type under Normalize", async () => {
game.override.ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.JELLICENT);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0);
});
it("should deal 2x damage to water type under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2);
});
it("should deal 4x damage to water/flying type under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]).enemySpecies(SpeciesId.GYARADOS);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(4);
});
it("should deal 0x damage to water/ground type under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]).enemySpecies(SpeciesId.BARBOACH);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0);
});
it("should deal 0.25x damage to Grass/Dragon type under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]).enemySpecies(SpeciesId.FLAPPLE);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0.25);
});
it("should deal 2x damage to Water type during inverse battle", async () => {
game.override.moveset([MoveId.FREEZE_DRY]).enemySpecies(SpeciesId.MAGIKARP);
game.challengeMode.addChallenge(Challenges.INVERSE_BATTLE, 1, 1);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(2);
});
it("should deal 2x damage to Water type during inverse battle under Normalize", async () => {
game.override.moveset([MoveId.FREEZE_DRY]).ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.MAGIKARP);
game.challengeMode.addChallenge(Challenges.INVERSE_BATTLE, 1, 1);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(2);
});
it("should deal 2x damage to Water type during inverse battle under Electrify", async () => {
game.override.moveset([MoveId.FREEZE_DRY]).enemySpecies(SpeciesId.MAGIKARP).enemyMoveset([MoveId.ELECTRIFY]);
game.challengeMode.addChallenge(Challenges.INVERSE_BATTLE, 1, 1);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(2);
});
it("should deal 1x damage to water/flying type during inverse battle under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]).enemySpecies(SpeciesId.GYARADOS);
game.challengeMode.addChallenge(Challenges.INVERSE_BATTLE, 1, 1);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(1);
}); });
}); });

View File

@ -1,10 +1,12 @@
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Synchronoise", () => { describe("Moves - Synchronoise", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -23,7 +25,6 @@ describe("Moves - Synchronoise", () => {
beforeEach(() => { beforeEach(() => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.moveset([MoveId.SYNCHRONOISE])
.ability(AbilityId.BALL_FETCH) .ability(AbilityId.BALL_FETCH)
.battleStyle("single") .battleStyle("single")
.criticalHits(false) .criticalHits(false)
@ -32,16 +33,79 @@ describe("Moves - Synchronoise", () => {
.enemyMoveset(MoveId.SPLASH); .enemyMoveset(MoveId.SPLASH);
}); });
it("should consider the user's tera type if it is terastallized", async () => { it("should affect all opponents that share a type with the user", async () => {
await game.classicMode.startBattle([SpeciesId.BIDOOF]); game.override.battleStyle("double");
const playerPokemon = game.field.getPlayerPokemon(); await game.classicMode.startBattle([SpeciesId.BIBAREL, SpeciesId.STARLY]);
const enemyPokemon = game.field.getEnemyPokemon();
// force the player to be terastallized const [bidoof, starly] = game.scene.getPlayerField();
playerPokemon.teraType = PokemonType.WATER; const [karp1, karp2] = game.scene.getEnemyField();
playerPokemon.isTerastallized = true; // Mock 2nd magikarp to be a completely different type
game.move.select(MoveId.SYNCHRONOISE); vi.spyOn(karp2, "getTypes").mockReturnValue([PokemonType.GRASS]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); game.move.use(MoveId.SYNCHRONOISE, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
expect(bidoof).toHaveUsedMove({ move: MoveId.SYNCHRONOISE, result: MoveResult.SUCCESS });
expect(starly).not.toHaveFullHp();
expect(karp1).not.toHaveFullHp();
expect(karp2).toHaveFullHp();
});
it("should consider the user's Tera Type if it is Terastallized", async () => {
await game.classicMode.startBattle([SpeciesId.BIDOOF]);
const bidoof = game.field.getPlayerPokemon();
const karp = game.field.getEnemyPokemon();
game.field.forceTera(bidoof, PokemonType.WATER);
game.move.use(MoveId.SYNCHRONOISE);
await game.toEndOfTurn();
expect(bidoof).toHaveUsedMove({ move: MoveId.SYNCHRONOISE, result: MoveResult.SUCCESS });
expect(karp).not.toHaveFullHp();
});
it("should consider the user/target's normal types if Terastallized into Tera Stellar", async () => {
await game.classicMode.startBattle([SpeciesId.ABRA]);
const abra = game.field.getPlayerPokemon();
const karp = game.field.getEnemyPokemon();
game.field.forceTera(abra, PokemonType.STELLAR);
game.field.forceTera(karp, PokemonType.STELLAR);
game.move.use(MoveId.SYNCHRONOISE);
await game.toEndOfTurn();
expect(abra).toHaveUsedMove({ move: MoveId.SYNCHRONOISE, result: MoveResult.MISS });
expect(karp).toHaveFullHp();
});
it("should count as ineffective if no enemies share types with the user", async () => {
await game.classicMode.startBattle([SpeciesId.MAGNETON]);
const magneton = game.field.getPlayerPokemon();
const karp = game.field.getEnemyPokemon();
game.move.use(MoveId.SYNCHRONOISE);
await game.toEndOfTurn();
expect(magneton).toHaveUsedMove({ move: MoveId.SYNCHRONOISE, result: MoveResult.MISS });
expect(karp).toHaveFullHp();
});
it("should never affect any Pokemon if the user is typeless", async () => {
await game.classicMode.startBattle([SpeciesId.BIBAREL]);
const bibarel = game.field.getPlayerPokemon();
const karp = game.field.getEnemyPokemon();
bibarel.summonData.types = [PokemonType.UNKNOWN];
karp.summonData.types = [PokemonType.UNKNOWN];
game.move.use(MoveId.SYNCHRONOISE);
await game.toEndOfTurn();
expect(bibarel).toHaveUsedMove({ move: MoveId.SYNCHRONOISE, result: MoveResult.MISS });
expect(karp).toHaveFullHp();
}); });
}); });

View File

@ -12,6 +12,8 @@ import { TurnInitPhase } from "#phases/turn-init-phase";
import { generateStarters } from "#test/test-utils/game-manager-utils"; import { generateStarters } from "#test/test-utils/game-manager-utils";
import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper"; import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper";
type challengeStub = { id: Challenges; value: number; severity: number };
/** /**
* Helper to handle Challenge mode specifics * Helper to handle Challenge mode specifics
*/ */
@ -30,11 +32,24 @@ export class ChallengeModeHelper extends GameManagerHelper {
} }
/** /**
* Runs the Challenge game to the summon phase. * Runs the challenge game to the summon phase.
* @param gameMode - Optional game mode to set. * @param speciesIds - An array of {@linkcode Species} to summon.
* @returns A promise that resolves when the summon phase is reached. * @returns A promise that resolves when the summon phase is reached.
* @todo This duplicates all but 1 line of code from the classic mode variant...
*/ */
async runToSummon(speciesIds?: SpeciesId[]) { async runToSummon(speciesIds: SpeciesId[]): Promise<void>;
/**
* Runs the challenge game to the summon phase.
* Selects 3 daily run starters with a fixed seed of "test"
* (see `DailyRunConfig.getDailyRunStarters` in `daily-run.ts` for more info).
* @returns A promise that resolves when the summon phase is reached.
* @deprecated - Specifying the starters helps prevent inconsistencies from internal RNG changes.
* @todo This duplicates all but 1 line of code from the classic mode variant...
*/
// biome-ignore lint/style/useUnifiedTypeSignatures: Marks for deprecation
async runToSummon(): Promise<void>;
async runToSummon(speciesIds?: SpeciesId[]): Promise<void>;
async runToSummon(speciesIds?: SpeciesId[]): Promise<void> {
await this.game.runToTitle(); await this.game.runToTitle();
if (this.game.override.disableShinies) { if (this.game.override.disableShinies) {
@ -56,10 +71,22 @@ export class ChallengeModeHelper extends GameManagerHelper {
} }
/** /**
* Transitions to the start of a battle. * Transitions the challenge game to the start of a new battle.
* @param species - Optional array of species to start the battle with. * @param species - An array of {@linkcode Species} to summon.
* @returns A promise that resolves when the battle is started. * @returns A promise that resolves when the battle is started.
* @todo This duplicates all its code with the classic mode variant...
*/ */
async startBattle(species: SpeciesId[]): Promise<void>;
/**
* Transitions the challenge game to the start of a new battle.
* Selects 3 daily run starters with a fixed seed of "test"
* (see `DailyRunConfig.getDailyRunStarters` in `daily-run.ts` for more info).
* @returns A promise that resolves when the battle is started.
* @deprecated - Specifying the starters helps prevent inconsistencies from internal RNG changes.
* @todo This duplicates all its code with the classic mode variant...
*/
// biome-ignore lint/style/useUnifiedTypeSignatures: Marks for deprecation
async startBattle(): Promise<void>;
async startBattle(species?: SpeciesId[]) { async startBattle(species?: SpeciesId[]) {
await this.runToSummon(species); await this.runToSummon(species);
@ -88,4 +115,26 @@ export class ChallengeModeHelper extends GameManagerHelper {
await this.game.phaseInterceptor.to(CommandPhase); await this.game.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]=================="); console.log("==================[New Turn]==================");
} }
/**
* Override an already-started game with the given challenges.
* @param id - The challenge id
* @param value - The challenge value
* @param severity - The challenge severity
* @todo Make severity optional for challenges that do not require it
*/
public overrideGameWithChallenges(id: Challenges, value: number, severity: number): void;
/**
* Override an already-started game with the given challenges.
* @param challenges - One or more challenges to set.
*/
public overrideGameWithChallenges(challenges: challengeStub[]): void;
public overrideGameWithChallenges(challenges: challengeStub[] | Challenges, value?: number, severity?: number): void {
if (typeof challenges !== "object") {
challenges = [{ id: challenges, value: value!, severity: severity! }];
}
for (const challenge of challenges) {
this.game.scene.gameMode.setChallengeValue(challenge.id, challenge.value, challenge.severity);
}
}
} }

View File

@ -18,7 +18,7 @@ export class DailyModeHelper extends GameManagerHelper {
* @returns A promise that resolves when the summon phase is reached. * @returns A promise that resolves when the summon phase is reached.
* @remarks Please do not use for starting normal battles - use {@linkcode startBattle} instead * @remarks Please do not use for starting normal battles - use {@linkcode startBattle} instead
*/ */
async runToSummon(): Promise<void> { private async runToSummon(): Promise<void> {
await this.game.runToTitle(); await this.game.runToTitle();
if (this.game.override.disableShinies) { if (this.game.override.disableShinies) {

View File

@ -1,5 +1,6 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */ /* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { globalScene } from "#app/global-scene"; import type { globalScene } from "#app/global-scene";
import type { MoveHelper } from "#test/test-utils/helpers/move-helper";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */ /* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import type { Ability } from "#abilities/ability"; import type { Ability } from "#abilities/ability";
@ -94,12 +95,14 @@ export class FieldHelper extends GameManagerHelper {
} }
/** /**
* Force a given Pokemon to be terastallized to the given type. * Force a given Pokemon to be Terastallized to the given type.
* *
* @param pokemon - The pokemon to terastallize. * @param pokemon - The pokemon to Terastallize
* @param teraType - The {@linkcode PokemonType} to terastallize into; defaults to the pokemon's primary type. * @param teraType - The {@linkcode PokemonType} to Terastallize into; defaults to `pokemon`'s primary type if not provided
* @remarks * @remarks
* This function only mocks the Pokemon's tera-related variables; it does NOT activate any tera-related abilities. * This function only mocks the Pokemon's tera-related variables; it does NOT activate any tera-related abilities.
* If activating on-Terastallize effects is desired, use either {@linkcode MoveHelper.use} with `useTera=true`,
* or {@linkcode MoveHelper.selectWithTera} instead.
*/ */
public forceTera(pokemon: Pokemon, teraType: PokemonType = pokemon.getSpeciesForm(true).type1): void { public forceTera(pokemon: Pokemon, teraType: PokemonType = pokemon.getSpeciesForm(true).type1): void {
vi.spyOn(pokemon, "isTerastallized", "get").mockReturnValue(true); vi.spyOn(pokemon, "isTerastallized", "get").mockReturnValue(true);

View File

@ -98,11 +98,15 @@ export class MoveHelper extends GameManagerHelper {
} }
/** /**
* Select a move _already in the player's moveset_ to be used during the next {@linkcode CommandPhase}, **which will also terastallize on this turn**. * Select a move _already in the player's moveset_ to be used during the next {@linkcode CommandPhase},
* **which will also terastallize on this turn**.
* Activates all relevant abilities and effects on Terastallizing (equivalent to inputting the command manually)
* @param move - The {@linkcode MoveId} to use. * @param move - The {@linkcode MoveId} to use.
* @param pkmIndex - The {@linkcode BattlerIndex} of the player Pokemon using the move. Relevant for double battles only and defaults to {@linkcode BattlerIndex.PLAYER} if not specified. * @param pkmIndex - The {@linkcode BattlerIndex} of the player Pokemon using the move. Relevant for double battles only and defaults to {@linkcode BattlerIndex.PLAYER} if not specified.
* @param targetIndex - The {@linkcode BattlerIndex} of the Pokemon to target for single-target moves; should be omitted for multi-target moves. * @param targetIndex - The {@linkcode BattlerIndex} of the Pokemon to target for single-target moves; should be omitted for multi-target moves.
* If set to `null`, will forgo normal target selection entirely (useful for UI tests) * If set to `null`, will forgo normal target selection entirely (useful for UI tests)
* @remarks
* Will fail the current test if the move being selected is not in the user's moveset.
*/ */
public selectWithTera( public selectWithTera(
move: MoveId, move: MoveId,