[Ability] (Partially) Implement synchronize ability with old psycho shift interaction (#2746)

* Initial changes for Synchronize ability

* Fix psycho shift interaction causing buggy behaviour

* Update to show ability even if opponent pokemon does not get statused

* Fix some spacing

* Update tests

* Formatting change

* Remove impossible `if` statement

* Add `simulated` support

* Apply suggestions from code review

Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com>

* Don't need those comments

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com>
Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com>
This commit is contained in:
Yiling Kang 2024-09-22 19:38:09 -07:00 committed by GitHub
parent 107a7497a9
commit b9b2491f95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 193 additions and 24 deletions

62
src/data/ability.ts Executable file → Normal file
View File

@ -1798,6 +1798,61 @@ export class PostDefendStealHeldItemAbAttr extends PostDefendAbAttr {
} }
} }
/**
* Base class for defining all {@linkcode Ability} Attributes after a status effect has been set.
* @see {@linkcode applyPostSetStatus()}.
*/
export class PostSetStatusAbAttr extends AbAttr {
/**
* Does nothing after a status condition is set.
* @param pokemon {@linkcode Pokemon} that status condition was set on.
* @param sourcePokemon {@linkcode Pokemon} that that set the status condition. Is `null` if status was not set by a Pokemon.
* @param passive Whether this ability is a passive.
* @param effect {@linkcode StatusEffect} that was set.
* @param args Set of unique arguments needed by this attribute.
* @returns `true` if application of the ability succeeds.
*/
applyPostSetStatus(pokemon: Pokemon, sourcePokemon: Pokemon | null = null, passive: boolean, effect: StatusEffect, simulated: boolean, args: any[]) : boolean | Promise<boolean> {
return false;
}
}
/**
* If another Pokemon burns, paralyzes, poisons, or badly poisons this Pokemon,
* that Pokemon receives the same non-volatile status condition as part of this
* ability attribute. For Synchronize ability.
*/
export class SynchronizeStatusAbAttr extends PostSetStatusAbAttr {
/**
* If the `StatusEffect` that was set is Burn, Paralysis, Poison, or Toxic, and the status
* was set by a source Pokemon, set the source Pokemon's status to the same `StatusEffect`.
* @param pokemon {@linkcode Pokemon} that status condition was set on.
* @param sourcePokemon {@linkcode Pokemon} that that set the status condition. Is null if status was not set by a Pokemon.
* @param passive Whether this ability is a passive.
* @param effect {@linkcode StatusEffect} that was set.
* @param args Set of unique arguments needed by this attribute.
* @returns `true` if application of the ability succeeds.
*/
override applyPostSetStatus(pokemon: Pokemon, sourcePokemon: Pokemon | null = null, passive: boolean, effect: StatusEffect, simulated: boolean, args: any[]): boolean {
/** Synchronizable statuses */
const syncStatuses = new Set<StatusEffect>([
StatusEffect.BURN,
StatusEffect.PARALYSIS,
StatusEffect.POISON,
StatusEffect.TOXIC
]);
if (sourcePokemon && syncStatuses.has(effect)) {
if (!simulated) {
sourcePokemon.trySetStatus(effect, true, pokemon);
}
return true;
}
return false;
}
}
export class PostVictoryAbAttr extends AbAttr { export class PostVictoryAbAttr extends AbAttr {
applyPostVictory(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean | Promise<boolean> { applyPostVictory(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean | Promise<boolean> {
return false; return false;
@ -4677,6 +4732,10 @@ export function applyStatMultiplierAbAttrs(attrType: Constructor<StatMultiplierA
pokemon: Pokemon, stat: BattleStat, statValue: Utils.NumberHolder, simulated: boolean = false, ...args: any[]): Promise<void> { pokemon: Pokemon, stat: BattleStat, statValue: Utils.NumberHolder, simulated: boolean = false, ...args: any[]): Promise<void> {
return applyAbAttrsInternal<StatMultiplierAbAttr>(attrType, pokemon, (attr, passive) => attr.applyStatStage(pokemon, passive, simulated, stat, statValue, args), args); return applyAbAttrsInternal<StatMultiplierAbAttr>(attrType, pokemon, (attr, passive) => attr.applyStatStage(pokemon, passive, simulated, stat, statValue, args), args);
} }
export function applyPostSetStatusAbAttrs(attrType: Constructor<PostSetStatusAbAttr>,
pokemon: Pokemon, effect: StatusEffect, sourcePokemon?: Pokemon | null, simulated: boolean = false, ...args: any[]): Promise<void> {
return applyAbAttrsInternal<PostSetStatusAbAttr>(attrType, pokemon, (attr, passive) => attr.applyPostSetStatus(pokemon, sourcePokemon, passive, effect, simulated, args), args, false, simulated);
}
/** /**
* Applies a field Stat multiplier attribute * Applies a field Stat multiplier attribute
@ -4907,7 +4966,8 @@ export function initAbilities() {
.attr(EffectSporeAbAttr), .attr(EffectSporeAbAttr),
new Ability(Abilities.SYNCHRONIZE, 3) new Ability(Abilities.SYNCHRONIZE, 3)
.attr(SyncEncounterNatureAbAttr) .attr(SyncEncounterNatureAbAttr)
.unimplemented(), .attr(SynchronizeStatusAbAttr)
.partial(), // interaction with psycho shift needs work, keeping to old Gen interaction for now
new Ability(Abilities.CLEAR_BODY, 3) new Ability(Abilities.CLEAR_BODY, 3)
.attr(ProtectStatAbAttr) .attr(ProtectStatAbAttr)
.ignorable(), .ignorable(),

View File

@ -2091,21 +2091,20 @@ export class PsychoShiftEffectAttr extends MoveEffectAttr {
if (target.status) { if (target.status) {
return false; return false;
} } else {
//@ts-ignore - how can target.status.effect be checked when we return `false` before when it's defined? const canSetStatus = target.canSetStatus(statusToApply, true, false, user);
if (!target.status || (target.status.effect === statusToApply && move.chance < 0)) { // TODO: resolve ts-ignore
const statusAfflictResult = target.trySetStatus(statusToApply, true, user); if (canSetStatus) {
if (statusAfflictResult) {
if (user.status) { if (user.status) {
user.scene.queueMessage(getStatusEffectHealText(user.status.effect, getPokemonNameWithAffix(user))); user.scene.queueMessage(getStatusEffectHealText(user.status.effect, getPokemonNameWithAffix(user)));
} }
user.resetStatus(); user.resetStatus();
user.updateInfo(); user.updateInfo();
} target.trySetStatus(statusToApply, true, user);
return statusAfflictResult;
} }
return false; return canSetStatus;
}
} }
getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {

View File

@ -20,7 +20,7 @@ import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "../data/tms";
import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, SubstituteTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag } from "../data/battler-tags"; import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, SubstituteTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag } from "../data/battler-tags";
import { WeatherType } from "../data/weather"; import { WeatherType } from "../data/weather";
import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "../data/arena-tag"; import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "../data/arena-tag";
import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr } from "../data/ability"; import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr, PostSetStatusAbAttr, applyPostSetStatusAbAttrs } from "../data/ability";
import PokemonData from "../system/pokemon-data"; import PokemonData from "../system/pokemon-data";
import { BattlerIndex } from "../battle"; import { BattlerIndex } from "../battle";
import { Mode } from "../ui/ui"; import { Mode } from "../ui/ui";
@ -3365,7 +3365,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
} }
if (asPhase) { if (asPhase) {
this.scene.unshiftPhase(new ObtainStatusEffectPhase(this.scene, this.getBattlerIndex(), effect, cureTurn, sourceText!, sourcePokemon!)); // TODO: are these bangs correct? this.scene.unshiftPhase(new ObtainStatusEffectPhase(this.scene, this.getBattlerIndex(), effect, cureTurn, sourceText, sourcePokemon));
return true; return true;
} }
@ -3399,6 +3399,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
if (effect !== StatusEffect.FAINT) { if (effect !== StatusEffect.FAINT) {
this.scene.triggerPokemonFormChange(this, SpeciesFormChangeStatusEffectTrigger, true); this.scene.triggerPokemonFormChange(this, SpeciesFormChangeStatusEffectTrigger, true);
applyPostSetStatusAbAttrs(PostSetStatusAbAttr, this, effect, sourcePokemon);
} }
return true; return true;

View File

@ -9,24 +9,24 @@ import { PokemonPhase } from "./pokemon-phase";
import { PostTurnStatusEffectPhase } from "./post-turn-status-effect-phase"; import { PostTurnStatusEffectPhase } from "./post-turn-status-effect-phase";
export class ObtainStatusEffectPhase extends PokemonPhase { export class ObtainStatusEffectPhase extends PokemonPhase {
private statusEffect: StatusEffect | undefined; private statusEffect?: StatusEffect | undefined;
private cureTurn: integer | null; private cureTurn?: integer | null;
private sourceText: string | null; private sourceText?: string | null;
private sourcePokemon: Pokemon | null; private sourcePokemon?: Pokemon | null;
constructor(scene: BattleScene, battlerIndex: BattlerIndex, statusEffect?: StatusEffect, cureTurn?: integer | null, sourceText?: string, sourcePokemon?: Pokemon) { constructor(scene: BattleScene, battlerIndex: BattlerIndex, statusEffect?: StatusEffect, cureTurn?: integer | null, sourceText?: string | null, sourcePokemon?: Pokemon | null) {
super(scene, battlerIndex); super(scene, battlerIndex);
this.statusEffect = statusEffect; this.statusEffect = statusEffect;
this.cureTurn = cureTurn!; // TODO: is this bang correct? this.cureTurn = cureTurn;
this.sourceText = sourceText!; // TODO: is this bang correct? this.sourceText = sourceText;
this.sourcePokemon = sourcePokemon!; // For tracking which Pokemon caused the status effect // TODO: is this bang correct? this.sourcePokemon = sourcePokemon; // For tracking which Pokemon caused the status effect
} }
start() { start() {
const pokemon = this.getPokemon(); const pokemon = this.getPokemon();
if (!pokemon?.status) { if (pokemon && !pokemon.status) {
if (pokemon?.trySetStatus(this.statusEffect, false, this.sourcePokemon)) { if (pokemon.trySetStatus(this.statusEffect, false, this.sourcePokemon)) {
if (this.cureTurn) { if (this.cureTurn) {
pokemon.status!.cureTurn = this.cureTurn; // TODO: is this bang correct? pokemon.status!.cureTurn = this.cureTurn; // TODO: is this bang correct?
} }
@ -40,8 +40,8 @@ export class ObtainStatusEffectPhase extends PokemonPhase {
}); });
return; return;
} }
} else if (pokemon.status.effect === this.statusEffect) { } else if (pokemon.status?.effect === this.statusEffect) {
this.scene.queueMessage(getStatusEffectOverlapText(this.statusEffect, getPokemonNameWithAffix(pokemon))); this.scene.queueMessage(getStatusEffectOverlapText(this.statusEffect ?? StatusEffect.NONE, getPokemonNameWithAffix(pokemon)));
} }
this.end(); this.end();
} }

View File

@ -0,0 +1,109 @@
import { StatusEffect } from "#app/data/status-effect";
import GameManager from "#app/test/utils/gameManager";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Synchronize", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleType("single")
.startingLevel(100)
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.SYNCHRONIZE)
.moveset([Moves.SPLASH, Moves.THUNDER_WAVE, Moves.SPORE, Moves.PSYCHO_SHIFT])
.ability(Abilities.NO_GUARD);
}, 20000);
it("does not trigger when no status is applied by opponent Pokemon", async () => {
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getParty()[0].status).toBeUndefined();
expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase");
}, 20000);
it("sets the status of the source pokemon to Paralysis when paralyzed by it", async () => {
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.THUNDER_WAVE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getParty()[0].status?.effect).toBe(StatusEffect.PARALYSIS);
expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.PARALYSIS);
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
}, 20000);
it("does not trigger on Sleep", async () => {
await game.classicMode.startBattle();
game.move.select(Moves.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getParty()[0].status?.effect).toBeUndefined();
expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.SLEEP);
expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase");
}, 20000);
it("does not trigger when Pokemon is statused by Toxic Spikes", async () => {
game.override
.ability(Abilities.SYNCHRONIZE)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Array(4).fill(Moves.TOXIC_SPIKES));
await game.classicMode.startBattle([Species.FEEBAS, Species.MILOTIC]);
game.move.select(Moves.SPLASH);
await game.toNextTurn();
game.doSwitchPokemon(1);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getParty()[0].status?.effect).toBe(StatusEffect.POISON);
expect(game.scene.getEnemyParty()[0].status?.effect).toBeUndefined();
expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase");
}, 20000);
it("shows ability even if it fails to set the status of the opponent Pokemon", async () => {
await game.classicMode.startBattle([Species.PIKACHU]);
game.move.select(Moves.THUNDER_WAVE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getParty()[0].status?.effect).toBeUndefined();
expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.PARALYSIS);
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
}, 20000);
it("should activate with Psycho Shift after the move clears the status", async () => {
game.override.statusEffect(StatusEffect.PARALYSIS);
await game.classicMode.startBattle();
game.move.select(Moves.PSYCHO_SHIFT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getParty()[0].status?.effect).toBe(StatusEffect.PARALYSIS); // keeping old gen < V impl for now since it's buggy otherwise
expect(game.scene.getEnemyParty()[0].status?.effect).toBe(StatusEffect.PARALYSIS);
expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase");
}, 20000);
});