Reworked status code, fixed bugs and added Rest tests

This commit is contained in:
Bertie690 2025-05-20 21:44:07 -04:00
parent 663c64fdb4
commit 45bbaf2b25
9 changed files with 313 additions and 94 deletions

View File

@ -1246,7 +1246,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
/**
* Determine if the move type change attribute can be applied
*
*
* Can be applied if:
* - The ability's condition is met, e.g. pixilate only boosts normal moves,
* - The move is not forbidden from having its type changed by an ability, e.g. {@linkcode Moves.MULTI_ATTACK}
@ -1262,7 +1262,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
*/
override canApplyPreAttack(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _defender: Pokemon | null, move: Move, _args: [NumberHolder?, NumberHolder?, ...any]): boolean {
return (!this.condition || this.condition(pokemon, _defender, move)) &&
!noAbilityTypeOverrideMoves.has(move.id) &&
!noAbilityTypeOverrideMoves.has(move.id) &&
(!pokemon.isTerastallized ||
(move.id !== Moves.TERA_BLAST &&
(move.id !== Moves.TERA_STARSTORM || pokemon.getTeraType() !== PokemonType.STELLAR || !pokemon.hasSpecies(Species.TERAPAGOS))));
@ -7444,7 +7444,7 @@ export function initAbilities() {
new Ability(Abilities.ORICHALCUM_PULSE, 9)
.attr(PostSummonWeatherChangeAbAttr, WeatherType.SUNNY)
.attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.SUNNY)
.conditionalAttr(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN), StatMultiplierAbAttr, Stat.ATK, 4 / 3),
.conditionalAttr(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN), StatMultiplierAbAttr, Stat.ATK, 4 / 3), // No game freak rounding jank
new Ability(Abilities.HADRON_ENGINE, 9)
.attr(PostSummonTerrainChangeAbAttr, TerrainType.ELECTRIC)
.attr(PostBiomeChangeTerrainChangeAbAttr, TerrainType.ELECTRIC)

View File

@ -827,32 +827,37 @@ class ToxicSpikesTag extends ArenaTrapTag {
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (pokemon.isGrounded()) {
if (simulated) {
return true;
}
if (pokemon.isOfType(PokemonType.POISON)) {
this.neutralized = true;
if (globalScene.arena.removeTag(this.tagType)) {
globalScene.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: this.getMoveName(),
}),
);
return true;
}
} else if (!pokemon.status) {
const toxic = this.layers > 1;
if (
pokemon.trySetStatus(!toxic ? StatusEffect.POISON : StatusEffect.TOXIC, true, null, 0, this.getMoveName())
) {
return true;
}
}
if (!pokemon.isGrounded()) {
return false;
}
if (simulated) {
return true;
}
return false;
// poision types will neutralize toxic spikes
if (pokemon.isOfType(PokemonType.POISON)) {
this.neutralized = true;
if (globalScene.arena.removeTag(this.tagType)) {
globalScene.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: this.getMoveName(),
}),
);
}
return true;
}
if (pokemon.status) {
return false;
}
return pokemon.trySetStatus(
this.layers === 1 ? StatusEffect.POISON : StatusEffect.TOXIC,
true,
null,
this.getMoveName(),
);
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {

View File

@ -2440,31 +2440,36 @@ export class WaterShurikenMultiHitTypeAttr extends ChangeMultiHitTypeAttr {
export class StatusEffectAttr extends MoveEffectAttr {
public effect: StatusEffect;
public turnsRemaining?: number;
public overrideStatus: boolean = false;
private overrideStatus: boolean;
constructor(effect: StatusEffect, selfTarget?: boolean, turnsRemaining?: number, overrideStatus: boolean = false) {
constructor(effect: StatusEffect, selfTarget = false, overrideStatus = false) {
super(selfTarget);
this.effect = effect;
this.turnsRemaining = turnsRemaining;
this.overrideStatus = overrideStatus;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
const statusCheck = moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance;
if (!statusCheck) {
return false;
}
const quiet = move.category !== MoveCategory.STATUS;
if (statusCheck) {
const pokemon = this.selfTarget ? user : target;
if (user !== target && move.category === MoveCategory.STATUS && !target.canSetStatus(this.effect, quiet, false, user, true)) {
return false;
}
if (((!pokemon.status || this.overrideStatus) || (pokemon.status.effect === this.effect && moveChance < 0))
&& pokemon.trySetStatus(this.effect, true, user, this.turnsRemaining, null, this.overrideStatus, quiet)) {
applyPostAttackAbAttrs(ConfusionOnStatusEffectAbAttr, user, target, move, null, false, this.effect);
return true;
}
// TODO: why
const pokemon = this.selfTarget ? user : target;
if (user !== target && move.category === MoveCategory.STATUS && !target.canSetStatus(this.effect, quiet, this.overrideStatus, user, true)) {
return false;
}
// TODO: What does a chance of -1 have to do with any of this???
if (
(!pokemon.status || (pokemon.status.effect === this.effect && moveChance < 0))
&& pokemon.trySetStatus(this.effect, true, user, null, this.overrideStatus, quiet)
) {
applyPostAttackAbAttrs(ConfusionOnStatusEffectAbAttr, user, target, move, null, false, this.effect);
return true;
}
return false;
}
@ -2478,11 +2483,37 @@ export class StatusEffectAttr extends MoveEffectAttr {
}
}
/**
* Attribute to put the target to sleep for a fixed duration and cure its status.
* Used for {@linkcode Moves.REST}.
*/
export class RestAttr extends StatusEffectAttr {
private duration: number;
constructor(
duration: number,
overrideStatus: boolean
){
// Sleep is the only duration-based status ATM
super(StatusEffect.SLEEP, true, overrideStatus);
this.duration = duration;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const didStatus = super.apply(user, target, move, args);
if (didStatus && user.status?.effect === this.effect) {
user.status.sleepTurnsRemaining = this.duration;
}
return didStatus;
}
}
export class MultiStatusEffectAttr extends StatusEffectAttr {
public effects: StatusEffect[];
constructor(effects: StatusEffect[], selfTarget?: boolean, turnsRemaining?: number, overrideStatus?: boolean) {
super(effects[0], selfTarget, turnsRemaining, overrideStatus);
constructor(effects: StatusEffect[], selfTarget?: boolean) {
super(effects[0], selfTarget);
this.effects = effects;
}
@ -8704,7 +8735,7 @@ export function initMoves() {
.attr(MultiHitAttr, MultiHitType._2)
.makesContact(false),
new SelfStatusMove(Moves.REST, PokemonType.PSYCHIC, -1, 5, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP, true, 3, true)
.attr(RestAttr, 3, true)
.attr(HealAttr, 1, true)
.condition((user, target, move) => !user.isFullHp() && user.canSetStatus(StatusEffect.SLEEP, true, true, user))
.triageMove(),

View File

@ -5433,12 +5433,12 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Checks if a status effect can be applied to the Pokemon.
* Check if a status effect can be applied to this {@linckode Pokemon}.
*
* @param effect The {@linkcode StatusEffect} whose applicability is being checked
* @param quiet Whether in-battle messages should trigger or not
* @param overrideStatus Whether the Pokemon's current status can be overriden
* @param sourcePokemon The Pokemon that is setting the status effect
* @param effect - The {@linkcode StatusEffect} whose applicability is being checked
* @param quiet - Whether to suppress in-battle messages for status checks; default `false`
* @param overrideStatus - Whether to allow overriding the Pokemon's current status with a different one; default `false`
* @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target
* @param ignoreField Whether any field effects (weather, terrain, etc.) should be considered
*/
canSetStatus(
@ -5586,7 +5586,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
effect?: StatusEffect,
asPhase = false,
sourcePokemon: Pokemon | null = null,
turnsRemaining = 0,
sourceText: string | null = null,
overrideStatus?: boolean,
quiet = true,
@ -5618,7 +5617,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
new ObtainStatusEffectPhase(
this.getBattlerIndex(),
effect,
turnsRemaining,
sourceText,
sourcePokemon,
),

View File

@ -1756,7 +1756,7 @@ export class TurnStatusEffectModifier extends PokemonHeldItemModifier {
* @returns `true` if the status effect was applied successfully
*/
override apply(pokemon: Pokemon): boolean {
return pokemon.trySetStatus(this.effect, true, undefined, undefined, this.type.name);
return pokemon.trySetStatus(this.effect, true, pokemon, this.type.name);
}
getMaxHeldItemCount(_pokemon: Pokemon): number {

View File

@ -10,58 +10,54 @@ import { SpeciesFormChangeStatusEffectTrigger } from "#app/data/pokemon-forms";
import { applyPostSetStatusAbAttrs, PostSetStatusAbAttr } from "#app/data/abilities/ability";
import { isNullOrUndefined } from "#app/utils/common";
/** The phase where pokemon obtain status effects. */
export class ObtainStatusEffectPhase extends PokemonPhase {
private statusEffect?: StatusEffect;
private turnsRemaining?: number;
private sourceText?: string | null;
private sourcePokemon?: Pokemon | null;
constructor(
battlerIndex: BattlerIndex,
statusEffect?: StatusEffect,
turnsRemaining?: number,
sourceText?: string | null,
sourcePokemon?: Pokemon | null,
) {
super(battlerIndex);
this.statusEffect = statusEffect;
this.turnsRemaining = turnsRemaining;
this.sourceText = sourceText;
this.sourcePokemon = sourcePokemon;
}
start() {
const pokemon = this.getPokemon();
if (pokemon && !pokemon.status) {
if (pokemon.trySetStatus(this.statusEffect, false, this.sourcePokemon)) {
if (this.turnsRemaining) {
pokemon.status!.sleepTurnsRemaining = this.turnsRemaining;
}
pokemon.updateInfo(true);
new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect! - 1), pokemon).play(false, () => {
globalScene.queueMessage(
getStatusEffectObtainText(
this.statusEffect,
getPokemonNameWithAffix(pokemon),
this.sourceText ?? undefined,
),
);
if (!isNullOrUndefined(this.statusEffect) && this.statusEffect !== StatusEffect.FAINT) {
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeStatusEffectTrigger, true);
// If mold breaker etc was used to set this status, it shouldn't apply to abilities activated afterwards
globalScene.arena.setIgnoreAbilities(false);
applyPostSetStatusAbAttrs(PostSetStatusAbAttr, pokemon, this.statusEffect, this.sourcePokemon);
}
this.end();
});
return;
}
} else if (pokemon.status?.effect === this.statusEffect) {
if (pokemon.status?.effect === this.statusEffect) {
globalScene.queueMessage(
getStatusEffectOverlapText(this.statusEffect ?? StatusEffect.NONE, getPokemonNameWithAffix(pokemon)),
);
this.end();
return;
}
this.end();
if (!pokemon.trySetStatus(this.statusEffect, false, this.sourcePokemon)) {
// status application passes
this.end();
return;
}
pokemon.updateInfo(true);
new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect! - 1), pokemon).play(false, () => {
globalScene.queueMessage(
getStatusEffectObtainText(this.statusEffect, getPokemonNameWithAffix(pokemon), this.sourceText ?? undefined),
);
if (!isNullOrUndefined(this.statusEffect) && this.statusEffect !== StatusEffect.FAINT) {
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeStatusEffectTrigger, true);
// If mold breaker etc was used to set this status, it shouldn't apply to abilities activated afterwards
// TODO: We may need to reset this for Ice Fang, etc.
globalScene.arena.setIgnoreAbilities(false);
applyPostSetStatusAbAttrs(PostSetStatusAbAttr, pokemon, this.statusEffect, this.sourcePokemon);
}
this.end();
});
}
}

View File

@ -1,6 +1,7 @@
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { StatusEffect } from "#enums/status-effect";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -22,7 +23,7 @@ describe("Abilities - Corrosion", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([Moves.SPLASH])
.moveset([Moves.SPLASH, Moves.TOXIC, Moves.TOXIC_SPIKES])
.battleStyle("single")
.disableCrits()
.enemySpecies(Species.GRIMER)
@ -30,9 +31,35 @@ describe("Abilities - Corrosion", () => {
.enemyMoveset(Moves.TOXIC);
});
it("If a Poison- or Steel-type Pokémon with this Ability poisons a target with Synchronize, Synchronize does not gain the ability to poison Poison- or Steel-type Pokémon.", async () => {
it.each<{ name: string; species: Species }>([
{ name: "Poison", species: Species.GRIMER },
{ name: "Steel", species: Species.KLINK },
])("should grant the user the ability to poison $name-type opponents", async ({ species }) => {
game.override.ability(Abilities.SYNCHRONIZE);
await game.classicMode.startBattle([Species.FEEBAS]);
await game.classicMode.startBattle([species]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon.status).toBeUndefined();
game.move.select(Moves.TOXIC);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.status).toBeDefined();
});
it("should not affect Toxic Spikes", async () => {
await game.classicMode.startBattle([Species.SALANDIT]);
game.move.select(Moves.TOXIC_SPIKES);
await game.doKillOpponents();
await game.toNextWave();
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon.status).toBeUndefined();
});
it("should not affect an opponent's Synchronize ability", async () => {
game.override.ability(Abilities.SYNCHRONIZE);
await game.classicMode.startBattle([Species.ARBOK]);
const playerPokemon = game.scene.getPlayerPokemon();
const enemyPokemon = game.scene.getEnemyPokemon();
@ -43,4 +70,16 @@ describe("Abilities - Corrosion", () => {
expect(playerPokemon!.status).toBeDefined();
expect(enemyPokemon!.status).toBeUndefined();
});
it("should affect the user's held a Toxic Orb", async () => {
game.override.startingHeldItems([{ name: "TOXIC_ORB", count: 1 }]);
await game.classicMode.startBattle([Species.SALAZZLE]);
const salazzle = game.scene.getPlayerPokemon()!;
expect(salazzle.status?.effect).toBeUndefined();
game.move.select(Moves.SPLASH);
await game.toNextTurn();
expect(salazzle.status?.effect).toBe(StatusEffect.TOXIC);
});
});

134
test/moves/rest.test.ts Normal file
View File

@ -0,0 +1,134 @@
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { StatusEffect } from "#enums/status-effect";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Rest", () => {
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.REST, Moves.SLEEP_TALK])
.ability(Abilities.BALL_FETCH)
.battleStyle("single")
.disableCrits()
.enemySpecies(Species.EKANS)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
it("should fully heal the user, cure its status and put it to sleep", async () => {
await game.classicMode.startBattle([Species.SNORLAX]);
const snorlax = game.scene.getPlayerPokemon()!;
snorlax.hp = 1;
snorlax.trySetStatus(StatusEffect.POISON);
expect(snorlax.status?.effect).toBe(StatusEffect.POISON);
game.move.select(Moves.REST);
await game.phaseInterceptor.to("BerryPhase");
expect(snorlax.isFullHp()).toBe(true);
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
});
it("should preserve non-volatile conditions", async () => {
await game.classicMode.startBattle([Species.SNORLAX]);
const snorlax = game.scene.getPlayerPokemon()!;
snorlax.hp = 1;
snorlax.addTag(BattlerTagType.CONFUSED, 999);
game.move.select(Moves.REST);
await game.phaseInterceptor.to("BerryPhase");
expect(snorlax.getTag(BattlerTagType.CONFUSED)).toBeDefined();
});
it.each<{ name: string; status?: StatusEffect; ability?: Abilities; dmg?: number }>([
{ name: "is at full HP", dmg: 0 },
{ name: "is affected by Electric Terrain", ability: Abilities.ELECTRIC_SURGE },
{ name: "is affected by Misty Terrain", ability: Abilities.MISTY_SURGE },
{ name: "has Comatose", ability: Abilities.COMATOSE },
])("should fail if the user $name", async ({ status = StatusEffect.NONE, ability = Abilities.NONE, dmg = 1 }) => {
game.override.ability(ability);
await game.classicMode.startBattle([Species.SNORLAX]);
const snorlax = game.scene.getPlayerPokemon()!;
snorlax.trySetStatus(status);
snorlax.hp = snorlax.getMaxHp() - dmg;
game.move.select(Moves.REST);
await game.phaseInterceptor.to("BerryPhase");
expect(snorlax.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail if called while already asleep", async () => {
await game.classicMode.startBattle([Species.SNORLAX]);
const snorlax = game.scene.getPlayerPokemon()!;
snorlax.hp = 1;
snorlax.trySetStatus(StatusEffect.SLEEP);
game.move.select(Moves.SLEEP_TALK);
await game.phaseInterceptor.to("BerryPhase");
expect(snorlax.isFullHp()).toBe(false);
expect(snorlax.status?.effect).toBeUndefined();
expect(snorlax.getLastXMoves().map(tm => tm.result)).toEqual([MoveResult.FAIL, MoveResult.SUCCESS]);
});
it("should succeed if called the turn after waking up", async () => {
await game.classicMode.startBattle([Species.SNORLAX]);
const snorlax = game.scene.getPlayerPokemon()!;
snorlax.hp = 1;
// Turn 1
game.move.select(Moves.REST);
await game.toNextTurn();
snorlax.hp = 1;
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
// Turn 2
game.move.select(Moves.REST);
await game.toNextTurn();
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
// Turn 3
game.move.select(Moves.REST);
await game.toNextTurn();
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
// Turn 4 (wakeup)
game.move.select(Moves.REST);
await game.toNextTurn();
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
expect(snorlax.isFullHp()).toBe(true);
expect(snorlax.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(snorlax.status?.sleepTurnsRemaining).toBe(3);
});
});

View File

@ -36,6 +36,31 @@ describe("Moves - Sleep Talk", () => {
.enemyLevel(100);
});
it("should call a random valid move if the user is asleep", async () => {
game.override.moveset([Moves.SLEEP_TALK, Moves.DIG, Moves.FLY, Moves.SWORDS_DANCE]); // Dig and Fly are invalid moves, Swords Dance should always be called
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SLEEP_TALK);
await game.toNextTurn();
const feebas = game.scene.getPlayerPokemon()!;
expect(feebas.getStatStage(Stat.ATK)).toBe(2);
expect(feebas.getLastXMoves()).toEqual(
expect.arrayContaining([
expect.objectContaining({
move: Moves.SWORDS_DANCE,
result: MoveResult.SUCCESS,
virtual: true,
}),
expect.objectContaining({
move: Moves.SLEEP_TALK,
result: MoveResult.SUCCESS,
virtual: false,
}),
]),
);
});
it("should fail when the user is not asleep", async () => {
game.override.statusEffect(StatusEffect.NONE);
await game.classicMode.startBattle([Species.FEEBAS]);
@ -54,15 +79,6 @@ describe("Moves - Sleep Talk", () => {
expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should call a random valid move if the user is asleep", async () => {
game.override.moveset([Moves.SLEEP_TALK, Moves.DIG, Moves.FLY, Moves.SWORDS_DANCE]); // Dig and Fly are invalid moves, Swords Dance should always be called
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SLEEP_TALK);
await game.toNextTurn();
expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK));
});
it("should apply secondary effects of a move", async () => {
game.override.moveset([Moves.SLEEP_TALK, Moves.DIG, Moves.FLY, Moves.WOOD_HAMMER]); // Dig and Fly are invalid moves, Wood Hammer should always be called
await game.classicMode.startBattle();