From 6feb63484c308d3f376e1f77ed2c05ae760d9fb3 Mon Sep 17 00:00:00 2001 From: Mumble <171087428+frutescens@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:29:20 -0800 Subject: [PATCH 1/4] [P3] Added `failIfSingleBattle` condtion to Doubles-only moves and display failure message when used in singles (#4839) * Added failIfSingleBattle condtion to Helping Hand * Added failIfSingleBattle conditions to Doubles-Only moves * Adjusted canMove failure condition. * Updated moves that failIfSingleBattle * Fixed condtional. --------- Co-authored-by: frutescens --- src/data/move.ts | 11 ++++++++--- src/phases/move-phase.ts | 19 ++++++------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/data/move.ts b/src/data/move.ts index 9cd4881488c..a79ac386a7e 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -8480,7 +8480,8 @@ export function initMoves() { new StatusMove(Moves.HELPING_HAND, Type.NORMAL, -1, 20, -1, 5, 3) .attr(AddBattlerTagAttr, BattlerTagType.HELPING_HAND) .ignoresSubstitute() - .target(MoveTarget.NEAR_ALLY), + .target(MoveTarget.NEAR_ALLY) + .condition(failIfSingleBattle), new StatusMove(Moves.TRICK, Type.PSYCHIC, 100, 10, -1, 0, 3) .unimplemented(), new StatusMove(Moves.ROLE_PLAY, Type.PSYCHIC, -1, 10, -1, 0, 3) @@ -9172,6 +9173,7 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES) .attr(RemoveHeldItemAttr, true), new StatusMove(Moves.QUASH, Type.DARK, 100, 15, -1, 0, 5) + .condition(failIfSingleBattle) .unimplemented(), new AttackMove(Moves.ACROBATICS, Type.FLYING, MoveCategory.PHYSICAL, 55, 100, 15, -1, 0, 5) .attr(MovePowerMultiplierAttr, (user, target, move) => Math.max(1, 2 - 0.2 * user.getHeldItems().filter(i => i.isTransferable).reduce((v, m) => v + m.stackCount, 0))), @@ -9459,6 +9461,7 @@ export function initMoves() { new StatusMove(Moves.AROMATIC_MIST, Type.FAIRY, -1, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.SPDEF ], 1) .ignoresSubstitute() + .condition(failIfSingleBattle) .target(MoveTarget.NEAR_ALLY), new StatusMove(Moves.EERIE_IMPULSE, Type.ELECTRIC, 100, 15, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.SPATK ], -2), @@ -9687,7 +9690,8 @@ export function initMoves() { new AttackMove(Moves.LEAFAGE, Type.GRASS, MoveCategory.PHYSICAL, 40, 100, 40, -1, 0, 7) .makesContact(false), new StatusMove(Moves.SPOTLIGHT, Type.NORMAL, -1, 15, -1, 3, 7) - .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, false), + .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, false) + .condition(failIfSingleBattle), new StatusMove(Moves.TOXIC_THREAD, Type.POISON, 100, 20, -1, 0, 7) .attr(StatusEffectAttr, StatusEffect.POISON) .attr(StatStageChangeAttr, [ Stat.SPD ], -1), @@ -10144,7 +10148,8 @@ export function initMoves() { .unimplemented(), new StatusMove(Moves.COACHING, Type.FIGHTING, -1, 10, -1, 0, 8) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], 1) - .target(MoveTarget.NEAR_ALLY), + .target(MoveTarget.NEAR_ALLY) + .condition(failIfSingleBattle), new AttackMove(Moves.FLIP_TURN, Type.WATER, MoveCategory.PHYSICAL, 60, 100, 20, -1, 0, 8) .attr(ForceSwitchOutAttr, true), new AttackMove(Moves.TRIPLE_AXEL, Type.ICE, MoveCategory.PHYSICAL, 20, 90, 10, -1, 0, 8) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 6bdef281d70..7cfa3b12476 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -120,13 +120,10 @@ export class MovePhase extends BattlePhase { console.log(Moves[this.move.moveId]); // Check if move is unusable (e.g. because it's out of PP due to a mid-turn Spite). - if (!this.canMove(true)) { - if (this.pokemon.isActive(true) && this.move.ppUsed >= this.move.getMovePp()) { - this.fail(); - this.showMoveText(); - this.showFailedText(); - } - + if (!this.canMove(true) && (this.pokemon.isActive(true) || this.move.ppUsed >= this.move.getMovePp())) { + this.fail(); + this.showMoveText(); + this.showFailedText(); return this.end(); } @@ -378,16 +375,12 @@ export class MovePhase extends BattlePhase { } else { this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual }); - let failedText: string | undefined; const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false)); - if (failureMessage) { - failedText = failureMessage; + this.showMoveText(); + this.showFailedText(failureMessage); } - this.showMoveText(); - this.showFailedText(failedText); - // Remove the user from its semi-invulnerable state (if applicable) this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); } From e5e392617615ec5023afe5662ceb3cd7c0720f6d Mon Sep 17 00:00:00 2001 From: Mumble <171087428+frutescens@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:13:15 -0800 Subject: [PATCH 2/4] [beta] Fix MovePhase not ending properly. (#4848) Co-authored-by: frutescens --- src/phases/move-phase.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 7cfa3b12476..378b72e1f56 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -120,10 +120,12 @@ export class MovePhase extends BattlePhase { console.log(Moves[this.move.moveId]); // Check if move is unusable (e.g. because it's out of PP due to a mid-turn Spite). - if (!this.canMove(true) && (this.pokemon.isActive(true) || this.move.ppUsed >= this.move.getMovePp())) { - this.fail(); - this.showMoveText(); - this.showFailedText(); + if (!this.canMove(true)) { + if (this.pokemon.isActive(true)) { + this.fail(); + this.showMoveText(); + this.showFailedText(); + } return this.end(); } From cebedd220bd4259b4bee449bb88cc686853b3474 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:56:16 -0800 Subject: [PATCH 3/4] [Balance] Rework Multi-Lens (#4831) * Rework Multi-Lens * Multi-Lens integration tests * Apply suggestions from code review Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Fix obsolete tests related to Multi-Lens * Fix flaky unburden tests * maybe fix flaky ceaseless edge test? * Fixed Multi-Lens apply comment * Fix ceaseless edge test for real this time * Update locales * Another locale update --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- public/locales | 2 +- src/data/ability.ts | 61 ++++----------- src/data/move.ts | 38 ++++++++- src/field/pokemon.ts | 9 ++- src/modifier/modifier.ts | 64 +++++++++++----- src/phases/move-effect-phase.ts | 11 +-- src/test/abilities/parental_bond.test.ts | 60 ++------------- src/test/items/multi_lens.test.ts | 98 ++++++++++++++++++++++++ src/test/moves/beat_up.test.ts | 25 ------ src/test/moves/ceaseless_edge.test.ts | 12 +-- src/test/moves/dragon_rage.test.ts | 11 --- src/test/moves/electro_shot.test.ts | 2 +- 12 files changed, 215 insertions(+), 178 deletions(-) create mode 100644 src/test/items/multi_lens.test.ts diff --git a/public/locales b/public/locales index d600913dbf1..5775faa6b31 160000 --- a/public/locales +++ b/public/locales @@ -1 +1 @@ -Subproject commit d600913dbf1f8b47dae8dccbd8296df78f1c51b5 +Subproject commit 5775faa6b3184082df73f6cdb96b253ea7dae3fe diff --git a/src/data/ability.ts b/src/data/ability.ts index 49763991e0e..4194be31405 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -7,7 +7,7 @@ import { Weather } from "#app/data/weather"; import { BattlerTag, BattlerTagLapseType, GroundedTag } from "./battler-tags"; import { getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "#app/data/status-effect"; import { Gender } from "./gender"; -import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move"; +import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move"; import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; import { BerryModifier, HitHealModifier, PokemonHeldItemModifier } from "../modifier/modifier"; import { TerrainType } from "./terrain"; @@ -1351,65 +1351,30 @@ export class AddSecondStrikeAbAttr extends PreAttackAbAttr { this.damageMultiplier = damageMultiplier; } - /** - * Determines whether this attribute can apply to a given move. - * @param {Move} move the move to which this attribute may apply - * @param numTargets the number of {@linkcode Pokemon} targeted by this move - * @returns true if the attribute can apply to the move, false otherwise - */ - canApplyPreAttack(move: Move, numTargets: integer): boolean { - /** - * Parental Bond cannot apply to multi-hit moves, charging moves, or - * moves that cause the user to faint. - */ - const exceptAttrs: Constructor[] = [ - MultiHitAttr, - SacrificialAttr, - SacrificialAttrOnHit - ]; - - /** Parental Bond cannot apply to these specific moves */ - const exceptMoves: Moves[] = [ - Moves.FLING, - Moves.UPROAR, - Moves.ROLLOUT, - Moves.ICE_BALL, - Moves.ENDEAVOR - ]; - - /** Also check if this move is an Attack move and if it's only targeting one Pokemon */ - return numTargets === 1 - && !move.isChargingMove() - && !exceptAttrs.some(attr => move.hasAttr(attr)) - && !exceptMoves.some(id => move.id === id) - && move.category !== MoveCategory.STATUS; - } - /** * If conditions are met, this doubles the move's hit count (via args[1]) * or multiplies the damage of secondary strikes (via args[2]) - * @param {Pokemon} pokemon the Pokemon using the move + * @param pokemon the {@linkcode Pokemon} using the move * @param passive n/a * @param defender n/a - * @param {Move} move the move used by the ability source - * @param args\[0\] the number of Pokemon this move is targeting - * @param {Utils.IntegerHolder} args\[1\] the number of strikes with this move - * @param {Utils.NumberHolder} args\[2\] the damage multiplier for the current strike + * @param move the {@linkcode Move} used by the ability source + * @param args Additional arguments: + * - `[0]` the number of strikes this move currently has ({@linkcode Utils.NumberHolder}) + * - `[1]` the damage multiplier for the current strike ({@linkcode Utils.NumberHolder}) * @returns */ applyPreAttack(pokemon: Pokemon, passive: boolean, simulated: boolean, defender: Pokemon, move: Move, args: any[]): boolean { - const numTargets = args[0] as integer; - const hitCount = args[1] as Utils.IntegerHolder; - const multiplier = args[2] as Utils.NumberHolder; + const hitCount = args[0] as Utils.NumberHolder; + const multiplier = args[1] as Utils.NumberHolder; - if (this.canApplyPreAttack(move, numTargets)) { + if (move.canBeMultiStrikeEnhanced(pokemon)) { this.showAbility = !!hitCount?.value; - if (!!hitCount?.value) { - hitCount.value *= 2; + if (hitCount?.value) { + hitCount.value += 1; } - if (!!multiplier?.value && pokemon.turnData.hitsLeft % 2 === 1 && pokemon.turnData.hitsLeft !== pokemon.turnData.hitCount) { - multiplier.value *= this.damageMultiplier; + if (multiplier?.value && pokemon.turnData.hitsLeft === 1) { + multiplier.value = this.damageMultiplier; } return true; } diff --git a/src/data/move.ts b/src/data/move.ts index a79ac386a7e..0de9d9b53a2 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -818,7 +818,7 @@ export default class Move implements Localizable { applyMoveAttrs(VariablePowerAttr, source, target, this, power); - source.scene.applyModifiers(PokemonMultiHitModifier, source.isPlayer(), source, new Utils.IntegerHolder(0), power); + source.scene.applyModifiers(PokemonMultiHitModifier, source.isPlayer(), source, this.id, null, power); if (!this.hasAttr(TypelessAttr)) { source.scene.arena.applyTags(WeakenMoveTypeTag, simulated, this.type, power); @@ -840,6 +840,42 @@ export default class Move implements Localizable { return priority.value; } + + /** + * Returns `true` if this move can be given additional strikes + * by enhancing effects. + * Currently used for {@link https://bulbapedia.bulbagarden.net/wiki/Parental_Bond_(Ability) | Parental Bond} + * and {@linkcode PokemonMultiHitModifier | Multi-Lens}. + */ + canBeMultiStrikeEnhanced(user: Pokemon): boolean { + // Multi-strike enhancers... + + // ...cannot enhance moves that hit multiple targets + const { targets, multiple } = getMoveTargets(user, this.id); + const isMultiTarget = multiple && targets.length > 1; + + // ...cannot enhance multi-hit or sacrificial moves + const exceptAttrs: Constructor[] = [ + MultiHitAttr, + SacrificialAttr, + SacrificialAttrOnHit + ]; + + // ...and cannot enhance these specific moves. + const exceptMoves: Moves[] = [ + Moves.FLING, + Moves.UPROAR, + Moves.ROLLOUT, + Moves.ICE_BALL, + Moves.ENDEAVOR + ]; + + return !isMultiTarget + && !this.isChargingMove() + && !exceptAttrs.some(attr => this.hasAttr(attr)) + && !exceptMoves.some(id => this.id === id) + && this.category !== MoveCategory.STATUS; + } } export class AttackMove extends Move { diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 9e5103656d3..5d912f7d6e6 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2642,10 +2642,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { const numTargets = multiple ? targets.length : 1; const targetMultiplier = (numTargets > 1) ? 0.75 : 1; - /** 0.25x multiplier if this is an added strike from the attacker's Parental Bond */ - const parentalBondMultiplier = new Utils.NumberHolder(1); + /** Multiplier for moves enhanced by Multi-Lens and/or Parental Bond */ + const multiStrikeEnhancementMultiplier = new Utils.NumberHolder(1); + source.scene.applyModifiers(PokemonMultiHitModifier, source.isPlayer(), source, move.id, null, multiStrikeEnhancementMultiplier); if (!ignoreSourceAbility) { - applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, simulated, numTargets, new Utils.IntegerHolder(0), parentalBondMultiplier); + applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, simulated, null, multiStrikeEnhancementMultiplier); } /** Doubles damage if this Pokemon's last move was Glaive Rush */ @@ -2722,7 +2723,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { damage.value = Utils.toDmgValue( baseDamage * targetMultiplier - * parentalBondMultiplier.value + * multiStrikeEnhancementMultiplier.value * arenaAttackTypeMultiplier.value * glaiveRushMultiplier.value * criticalMultiplier.value diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 90336780ba6..5e60d888072 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -6,7 +6,6 @@ import { allMoves } from "#app/data/move"; import { MAX_PER_TYPE_POKEBALLS } from "#app/data/pokeball"; import { type FormChangeItem, SpeciesFormChangeItemTrigger, SpeciesFormChangeLapseTeraTrigger, SpeciesFormChangeTeraTrigger } from "#app/data/pokemon-forms"; import { getStatusEffectHealText } from "#app/data/status-effect"; -import { Type } from "#enums/type"; import Pokemon, { type PlayerPokemon } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; @@ -22,11 +21,13 @@ import { BooleanHolder, hslToHex, isNullOrUndefined, NumberHolder, toDmgValue } import { Abilities } from "#enums/abilities"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; +import { Moves } from "#enums/moves"; import type { Nature } from "#enums/nature"; import type { PokeballType } from "#enums/pokeball"; import { Species } from "#enums/species"; import { type PermanentStat, type TempBattleStat, BATTLE_STATS, Stat, TEMP_BATTLE_STATS } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; +import { Type } from "#enums/type"; import i18next from "i18next"; import { type DoubleBattleChanceBoosterModifierType, type EvolutionItemModifierType, type FormChangeItemModifierType, type ModifierOverride, type ModifierType, type PokemonBaseStatTotalModifierType, type PokemonExpBoosterModifierType, type PokemonFriendshipBoosterModifierType, type PokemonMoveAccuracyBoosterModifierType, type PokemonMultiHitModifierType, type TerastallizeModifierType, type TmModifierType, getModifierType, ModifierPoolType, ModifierTypeGenerator, modifierTypes, PokemonHeldItemModifierType } from "./modifier-type"; import { Color, ShadowColor } from "#enums/color"; @@ -2689,32 +2690,57 @@ export class PokemonMultiHitModifier extends PokemonHeldItemModifier { } /** - * Applies {@linkcode PokemonMultiHitModifier} - * @param _pokemon The {@linkcode Pokemon} using the move - * @param count {@linkcode NumberHolder} holding the number of items - * @param power {@linkcode NumberHolder} holding the power of the move + * For each stack, converts 25 percent of attack damage into an additional strike. + * @param pokemon The {@linkcode Pokemon} using the move + * @param moveId The {@linkcode Moves | identifier} for the move being used + * @param count {@linkcode NumberHolder} holding the move's hit count for this turn + * @param damageMultiplier {@linkcode NumberHolder} holding a damage multiplier applied to a strike of this move * @returns always `true` */ - override apply(_pokemon: Pokemon, count: NumberHolder, power: NumberHolder): boolean { - count.value *= (this.getStackCount() + 1); - - switch (this.getStackCount()) { - case 1: - power.value *= 0.4; - break; - case 2: - power.value *= 0.25; - break; - case 3: - power.value *= 0.175; - break; + override apply(pokemon: Pokemon, moveId: Moves, count: NumberHolder | null = null, damageMultiplier: NumberHolder | null = null): boolean { + const move = allMoves[moveId]; + /** + * The move must meet Parental Bond's restrictions for this item + * to apply. This means + * - Only attacks are boosted + * - Multi-strike moves, charge moves, and self-sacrificial moves are not boosted + * (though Multi-Lens can still affect moves boosted by Parental Bond) + * - Multi-target moves are not boosted *unless* they can only hit a single Pokemon + * - Fling, Uproar, Rollout, Ice Ball, and Endeavor are not boosted + */ + if (!move.canBeMultiStrikeEnhanced(pokemon)) { + return false; } + if (!isNullOrUndefined(count)) { + return this.applyHitCountBoost(count); + } else if (!isNullOrUndefined(damageMultiplier)) { + return this.applyPowerModifier(pokemon, damageMultiplier); + } + + return false; + } + + /** Adds strikes to a move equal to the number of stacked Multi-Lenses */ + private applyHitCountBoost(count: NumberHolder): boolean { + count.value += this.getStackCount(); + return true; + } + + /** + * If applied to the first hit of a move, sets the damage multiplier + * equal to (1 - the number of stacked Multi-Lenses). + * Additional strikes beyond that are given a 0.25x damage multiplier + */ + private applyPowerModifier(pokemon: Pokemon, damageMultiplier: NumberHolder): boolean { + damageMultiplier.value = (pokemon.turnData.hitsLeft === pokemon.turnData.hitCount) + ? (1 - (0.25 * this.getStackCount())) + : 0.25; return true; } getMaxHeldItemCount(pokemon: Pokemon): number { - return 3; + return 2; } } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index ef863d64c50..24a0b51da96 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -26,7 +26,6 @@ import { applyMoveAttrs, AttackMove, DelayedAttackAttr, - FixedDamageAttr, HitsTagAttr, MissEffectAttr, MoveAttr, @@ -122,12 +121,10 @@ export class MoveEffectPhase extends PokemonPhase { const hitCount = new NumberHolder(1); // Assume single target for multi hit applyMoveAttrs(MultiHitAttr, user, this.getFirstTarget() ?? null, move, hitCount); - // If Parental Bond is applicable, double the hit count - applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, false, targets.length, hitCount, new NumberHolder(0)); - // If Multi-Lens is applicable, multiply the hit count by 1 + the number of Multi-Lenses held by the user - if (move instanceof AttackMove && !move.hasAttr(FixedDamageAttr)) { - this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, hitCount, new NumberHolder(0)); - } + // If Parental Bond is applicable, add another hit + applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, false, hitCount, null); + // If Multi-Lens is applicable, add hits equal to the number of held Multi-Lenses + this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, move.id, hitCount); // Set the user's relevant turnData fields to reflect the final hit count user.turnData.hitCount = hitCount.value; user.turnData.hitsLeft = hitCount.value; diff --git a/src/test/abilities/parental_bond.test.ts b/src/test/abilities/parental_bond.test.ts index d8f952ae6ad..4189941a51e 100644 --- a/src/test/abilities/parental_bond.test.ts +++ b/src/test/abilities/parental_bond.test.ts @@ -274,7 +274,7 @@ describe("Abilities - Parental Bond", () => { ); it( - "Moves boosted by this ability and Multi-Lens should strike 4 times", + "Moves boosted by this ability and Multi-Lens should strike 3 times", async () => { game.override.moveset([ Moves.TACKLE ]); game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); @@ -287,36 +287,12 @@ describe("Abilities - Parental Bond", () => { await game.phaseInterceptor.to("DamagePhase"); - expect(leadPokemon.turnData.hitCount).toBe(4); + expect(leadPokemon.turnData.hitCount).toBe(3); } ); it( - "Super Fang boosted by this ability and Multi-Lens should strike twice", - async () => { - game.override.moveset([ Moves.SUPER_FANG ]); - game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); - - await game.classicMode.startBattle([ Species.MAGIKARP ]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; - - game.move.select(Moves.SUPER_FANG); - await game.move.forceHit(); - - await game.phaseInterceptor.to("DamagePhase"); - - expect(leadPokemon.turnData.hitCount).toBe(2); - - await game.phaseInterceptor.to("MoveEndPhase", false); - - expect(enemyPokemon.hp).toBe(Math.ceil(enemyPokemon.getMaxHp() * 0.25)); - } - ); - - it( - "Seismic Toss boosted by this ability and Multi-Lens should strike twice", + "Seismic Toss boosted by this ability and Multi-Lens should strike 3 times", async () => { game.override.moveset([ Moves.SEISMIC_TOSS ]); game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); @@ -333,11 +309,11 @@ describe("Abilities - Parental Bond", () => { await game.phaseInterceptor.to("DamagePhase"); - expect(leadPokemon.turnData.hitCount).toBe(2); + expect(leadPokemon.turnData.hitCount).toBe(3); await game.phaseInterceptor.to("MoveEndPhase", false); - expect(enemyPokemon.hp).toBe(enemyStartingHp - 200); + expect(enemyPokemon.hp).toBe(enemyStartingHp - 300); } ); @@ -494,30 +470,4 @@ describe("Abilities - Parental Bond", () => { expect(enemyPokemon.getStatStage(Stat.SPATK)).toBe(1); } ); - - it( - "should not apply to multi-target moves with Multi-Lens", - async () => { - game.override.battleType("double"); - game.override.moveset([ Moves.EARTHQUAKE, Moves.SPLASH ]); - game.override.passiveAbility(Abilities.LEVITATE); - game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); - - await game.classicMode.startBattle([ Species.MAGIKARP, Species.FEEBAS ]); - - const enemyPokemon = game.scene.getEnemyField(); - - const enemyStartingHp = enemyPokemon.map(p => p.hp); - - game.move.select(Moves.EARTHQUAKE); - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to("DamagePhase"); - const enemyFirstHitDamage = enemyStartingHp.map((hp, i) => hp - enemyPokemon[i].hp); - - await game.phaseInterceptor.to("BerryPhase", false); - - enemyPokemon.forEach((p, i) => expect(enemyStartingHp[i] - p.hp).toBe(2 * enemyFirstHitDamage[i])); - } - ); }); diff --git a/src/test/items/multi_lens.test.ts b/src/test/items/multi_lens.test.ts new file mode 100644 index 00000000000..e4e4ab9863e --- /dev/null +++ b/src/test/items/multi_lens.test.ts @@ -0,0 +1,98 @@ +import { BattlerIndex } from "#app/battle"; +import { Stat } from "#enums/stat"; +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("Items - Multi Lens", () => { + 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.TACKLE, Moves.TRAILBLAZE, Moves.TACHYON_CUTTER ]) + .ability(Abilities.BALL_FETCH) + .startingHeldItems([{ name: "MULTI_LENS" }]) + .battleType("single") + .disableCrits() + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + it.each([ + { stackCount: 1, firstHitDamage: 0.75 }, + { stackCount: 2, firstHitDamage: 0.50 } + ])("$stackCount count: should deal {$firstHitDamage}x damage on the first hit, then hit $stackCount times for 0.25x", + async ({ stackCount, firstHitDamage }) => { + game.override.startingHeldItems([{ name: "MULTI_LENS", count: stackCount }]); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const spy = vi.spyOn(enemyPokemon, "getAttackDamage"); + vi.spyOn(enemyPokemon, "getBaseDamage").mockReturnValue(100); + + game.move.select(Moves.TACKLE); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.phaseInterceptor.to("MoveEndPhase"); + const damageResults = spy.mock.results.map(result => result.value?.damage); + + expect(damageResults).toHaveLength(1 + stackCount); + expect(damageResults[0]).toBe(firstHitDamage * 100); + damageResults.slice(1).forEach(dmg => expect(dmg).toBe(25)); + }); + + it("should stack additively with Parental Bond", async () => { + game.override.ability(Abilities.PARENTAL_BOND); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.TACKLE); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(playerPokemon.turnData.hitCount).toBe(3); + }); + + it("should apply secondary effects on each hit", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.TRAILBLAZE); + + await game.phaseInterceptor.to("BerryPhase", false); + expect(playerPokemon.getStatStage(Stat.SPD)).toBe(2); + }); + + it("should not enhance multi-hit moves", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.TACHYON_CUTTER); + + await game.phaseInterceptor.to("BerryPhase", false); + expect(playerPokemon.turnData.hitCount).toBe(2); + }); +}); diff --git a/src/test/moves/beat_up.test.ts b/src/test/moves/beat_up.test.ts index a0129621f0e..41e5b63471f 100644 --- a/src/test/moves/beat_up.test.ts +++ b/src/test/moves/beat_up.test.ts @@ -74,29 +74,4 @@ describe("Moves - Beat Up", () => { expect(playerPokemon.turnData.hitCount).toBe(5); } ); - - it( - "should hit twice for each player Pokemon if the user has Multi-Lens", - async () => { - game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); - await game.startBattle([ Species.MAGIKARP, Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE, Species.PIKACHU, Species.EEVEE ]); - - const playerPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; - let enemyStartingHp = enemyPokemon.hp; - - game.move.select(Moves.BEAT_UP); - - await game.phaseInterceptor.to(MoveEffectPhase); - - expect(playerPokemon.turnData.hitCount).toBe(12); - expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); - - while (playerPokemon.turnData.hitsLeft > 0) { - enemyStartingHp = enemyPokemon.hp; - await game.phaseInterceptor.to(MoveEffectPhase); - expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); - } - } - ); }); diff --git a/src/test/moves/ceaseless_edge.test.ts b/src/test/moves/ceaseless_edge.test.ts index 88c8c8cf011..3fbbb7b0aaf 100644 --- a/src/test/moves/ceaseless_edge.test.ts +++ b/src/test/moves/ceaseless_edge.test.ts @@ -34,7 +34,7 @@ describe("Moves - Ceaseless Edge", () => { game.override.startingLevel(100); game.override.enemyLevel(100); game.override.moveset([ Moves.CEASELESS_EDGE, Moves.SPLASH, Moves.ROAR ]); - game.override.enemyMoveset([ Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH ]); + game.override.enemyMoveset(Moves.SPLASH); vi.spyOn(allMoves[Moves.CEASELESS_EDGE], "accuracy", "get").mockReturnValue(100); }); @@ -42,7 +42,7 @@ describe("Moves - Ceaseless Edge", () => { test( "move should hit and apply spikes", async () => { - await game.startBattle([ Species.ILLUMISE ]); + await game.classicMode.startBattle([ Species.ILLUMISE ]); const enemyPokemon = game.scene.getEnemyPokemon()!; @@ -67,7 +67,7 @@ describe("Moves - Ceaseless Edge", () => { "move should hit twice with multi lens and apply two layers of spikes", async () => { game.override.startingHeldItems([{ name: "MULTI_LENS" }]); - await game.startBattle([ Species.ILLUMISE ]); + await game.classicMode.startBattle([ Species.ILLUMISE ]); const enemyPokemon = game.scene.getEnemyPokemon()!; @@ -92,9 +92,9 @@ describe("Moves - Ceaseless Edge", () => { "trainer - move should hit twice, apply two layers of spikes, force switch opponent - opponent takes damage", async () => { game.override.startingHeldItems([{ name: "MULTI_LENS" }]); - game.override.startingWave(5); + game.override.startingWave(25); - await game.startBattle([ Species.ILLUMISE ]); + await game.classicMode.startBattle([ Species.ILLUMISE ]); game.move.select(Moves.CEASELESS_EDGE); await game.phaseInterceptor.to(MoveEffectPhase, false); @@ -102,7 +102,7 @@ describe("Moves - Ceaseless Edge", () => { const tagBefore = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; expect(tagBefore instanceof ArenaTrapTag).toBeFalsy(); - await game.phaseInterceptor.to(TurnEndPhase, false); + await game.toNextTurn(); const tagAfter = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag; expect(tagAfter instanceof ArenaTrapTag).toBeTruthy(); expect(tagAfter.layers).toBe(2); diff --git a/src/test/moves/dragon_rage.test.ts b/src/test/moves/dragon_rage.test.ts index e8185f013e5..d5536ff9d2f 100644 --- a/src/test/moves/dragon_rage.test.ts +++ b/src/test/moves/dragon_rage.test.ts @@ -2,7 +2,6 @@ import { Stat } from "#enums/stat"; import { Type } from "#enums/type"; import { Species } from "#app/enums/species"; import { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; -import { modifierTypes } from "#app/modifier/modifier-type"; import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { Abilities } from "#enums/abilities"; import { BattlerTagType } from "#enums/battler-tag-type"; @@ -114,14 +113,4 @@ describe("Moves - Dragon Rage", () => { expect(enemyPokemon.getInverseHp()).toBe(dragonRageDamage); }); - - it("ignores multi hit", async () => { - game.override.disableCrits(); - game.scene.addModifier(modifierTypes.MULTI_LENS().newModifier(partyPokemon), false); - - game.move.select(Moves.DRAGON_RAGE); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(enemyPokemon.getInverseHp()).toBe(dragonRageDamage); - }); }); diff --git a/src/test/moves/electro_shot.test.ts b/src/test/moves/electro_shot.test.ts index 1373b4941eb..283154b3408 100644 --- a/src/test/moves/electro_shot.test.ts +++ b/src/test/moves/electro_shot.test.ts @@ -98,7 +98,7 @@ describe("Moves - Electro Shot", () => { game.move.select(Moves.ELECTRO_SHOT); await game.phaseInterceptor.to("MoveEndPhase"); - expect(playerPokemon.turnData.hitCount).toBe(2); + expect(playerPokemon.turnData.hitCount).toBe(1); expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1); }); }); From 4802f512ff7925037ecb90bf4d19505d1831d2a4 Mon Sep 17 00:00:00 2001 From: PigeonBar <56974298+PigeonBar@users.noreply.github.com> Date: Mon, 11 Nov 2024 18:22:27 -0500 Subject: [PATCH 4/4] [P1][Beta] Fix softlock when losing a run on local build (#4846) --- src/phases/game-over-phase.ts | 7 ++- src/test/phases/game-over-phase.test.ts | 77 +++++++++++++++++++++++++ src/test/utils/phaseInterceptor.ts | 24 +++++++- 3 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 src/test/phases/game-over-phase.test.ts diff --git a/src/phases/game-over-phase.ts b/src/phases/game-over-phase.ts index 26a0c45f449..84a4a4e8ef9 100644 --- a/src/phases/game-over-phase.ts +++ b/src/phases/game-over-phase.ts @@ -125,10 +125,9 @@ export class GameOverPhase extends BattlePhase { } const clear = (endCardPhase?: EndCardPhase) => { - if (newClear) { - this.handleUnlocks(); - } if (this.isVictory && newClear) { + this.handleUnlocks(); + for (const species of this.firstRibbons) { this.scene.unshiftPhase(new RibbonModifierRewardPhase(this.scene, modifierTypes.VOUCHER_PLUS, species)); } @@ -183,6 +182,8 @@ export class GameOverPhase extends BattlePhase { this.scene.gameData.offlineNewClear(this.scene).then(result => { doGameOver(result); }); + } else { + doGameOver(false); } } diff --git a/src/test/phases/game-over-phase.test.ts b/src/test/phases/game-over-phase.test.ts new file mode 100644 index 00000000000..2e19d5fe954 --- /dev/null +++ b/src/test/phases/game-over-phase.test.ts @@ -0,0 +1,77 @@ +import { Biome } from "#enums/biome"; +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"; +import { achvs } from "#app/system/achv"; +import { Unlockables } from "#app/system/unlockables"; + +describe("Game Over Phase", () => { + 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.MEMENTO, Moves.ICE_BEAM, Moves.SPLASH ]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH) + .startingWave(200) + .startingBiome(Biome.END) + .startingLevel(10000); + }); + + it("winning a run should give rewards", async () => { + await game.classicMode.startBattle([ Species.BULBASAUR ]); + vi.spyOn(game.scene, "validateAchv"); + + // Note: `game.doKillOpponents()` does not properly handle final boss + // Final boss phase 1 + game.move.select(Moves.ICE_BEAM); + await game.toNextTurn(); + + // Final boss phase 2 + game.move.select(Moves.ICE_BEAM); + await game.phaseInterceptor.to("PostGameOverPhase", false); + + // The game refused to actually give the vouchers during tests, + // so the best we can do is to check that their reward phases occurred. + expect(game.phaseInterceptor.log.includes("GameOverPhase")).toBe(true); + expect(game.phaseInterceptor.log.includes("UnlockPhase")).toBe(true); + expect(game.phaseInterceptor.log.includes("RibbonModifierRewardPhase")).toBe(true); + expect(game.scene.gameData.unlocks[Unlockables.ENDLESS_MODE]).toBe(true); + expect(game.scene.validateAchv).toHaveBeenCalledWith(achvs.CLASSIC_VICTORY); + expect(game.scene.gameData.achvUnlocks[achvs.CLASSIC_VICTORY.id]).toBeTruthy(); + }); + + it("losing a run should not give rewards", async () => { + await game.classicMode.startBattle([ Species.BULBASAUR ]); + vi.spyOn(game.scene, "validateAchv"); + + game.move.select(Moves.MEMENTO); + await game.phaseInterceptor.to("PostGameOverPhase", false); + + expect(game.phaseInterceptor.log.includes("GameOverPhase")).toBe(true); + expect(game.phaseInterceptor.log.includes("UnlockPhase")).toBe(false); + expect(game.phaseInterceptor.log.includes("RibbonModifierRewardPhase")).toBe(false); + expect(game.phaseInterceptor.log.includes("GameOverModifierRewardPhase")).toBe(false); + expect(game.scene.gameData.unlocks[Unlockables.ENDLESS_MODE]).toBe(false); + expect(game.scene.validateAchv).not.toHaveBeenCalledWith(achvs.CLASSIC_VICTORY); + expect(game.scene.gameData.achvUnlocks[achvs.CLASSIC_VICTORY.id]).toBeFalsy(); + }); +}); diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 17b6c7a6c81..4029e5e168c 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -55,6 +55,11 @@ import { import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; import { PartyExpPhase } from "#app/phases/party-exp-phase"; import { ExpPhase } from "#app/phases/exp-phase"; +import { GameOverPhase } from "#app/phases/game-over-phase"; +import { RibbonModifierRewardPhase } from "#app/phases/ribbon-modifier-reward-phase"; +import { GameOverModifierRewardPhase } from "#app/phases/game-over-modifier-reward-phase"; +import { UnlockPhase } from "#app/phases/unlock-phase"; +import { PostGameOverPhase } from "#app/phases/post-game-over-phase"; export interface PromptHandler { phaseTarget?: string; @@ -113,10 +118,15 @@ type PhaseClass = | typeof MysteryEncounterBattlePhase | typeof MysteryEncounterRewardsPhase | typeof PostMysteryEncounterPhase + | typeof RibbonModifierRewardPhase + | typeof GameOverModifierRewardPhase | typeof ModifierRewardPhase | typeof PartyExpPhase | typeof ExpPhase - | typeof EncounterPhase; + | typeof EncounterPhase + | typeof GameOverPhase + | typeof UnlockPhase + | typeof PostGameOverPhase; type PhaseString = | "LoginPhase" @@ -167,10 +177,15 @@ type PhaseString = | "MysteryEncounterBattlePhase" | "MysteryEncounterRewardsPhase" | "PostMysteryEncounterPhase" + | "RibbonModifierRewardPhase" + | "GameOverModifierRewardPhase" | "ModifierRewardPhase" | "PartyExpPhase" | "ExpPhase" - | "EncounterPhase"; + | "EncounterPhase" + | "GameOverPhase" + | "UnlockPhase" + | "PostGameOverPhase"; type PhaseInterceptorPhase = PhaseClass | PhaseString; @@ -245,10 +260,15 @@ export default class PhaseInterceptor { [ MysteryEncounterBattlePhase, this.startPhase ], [ MysteryEncounterRewardsPhase, this.startPhase ], [ PostMysteryEncounterPhase, this.startPhase ], + [ RibbonModifierRewardPhase, this.startPhase ], + [ GameOverModifierRewardPhase, this.startPhase ], [ ModifierRewardPhase, this.startPhase ], [ PartyExpPhase, this.startPhase ], [ ExpPhase, this.startPhase ], [ EncounterPhase, this.startPhase ], + [ GameOverPhase, this.startPhase ], + [ UnlockPhase, this.startPhase ], + [ PostGameOverPhase, this.startPhase ], ]; private endBySetMode = [