diff --git a/src/@types/arena-tags.ts b/src/@types/arena-tags.ts index bac9e815c31..4ac7abf6f3d 100644 --- a/src/@types/arena-tags.ts +++ b/src/@types/arena-tags.ts @@ -1,5 +1,7 @@ import type { ArenaTagTypeMap } from "#data/arena-tag"; import type { ArenaTagType } from "#enums/arena-tag-type"; +// biome-ignore lint/correctness/noUnusedImports: TSDocs +import type { SessionSaveData } from "#system/game-data"; /** Subset of {@linkcode ArenaTagType}s that apply some negative effect to pokemon that switch in ({@link https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards#List_of_traps | entry hazards} and Imprison. */ export type EntryHazardTagType = @@ -19,6 +21,9 @@ export type TurnProtectArenaTagType = | ArenaTagType.MAT_BLOCK | ArenaTagType.CRAFTY_SHIELD; +/** Subset of {@linkcode ArenaTagType}s that create Trick Room-like effects which are removed upon overlap. */ +export type RoomArenaTagType = ArenaTagType.TRICK_ROOM; + /** Subset of {@linkcode ArenaTagType}s that cannot persist across turns, and thus should not be serialized in {@linkcode SessionSaveData}. */ export type NonSerializableArenaTagType = ArenaTagType.NONE | TurnProtectArenaTagType | ArenaTagType.ION_DELUGE; diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index ab8b9f7990f..12abe3f1b6e 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -25,6 +25,7 @@ import type { ArenaScreenTagType, ArenaTagTypeData, EntryHazardTagType, + RoomArenaTagType, SerializableArenaTagType, } from "#types/arena-tags"; import type { Mutable } from "#types/type-helpers"; @@ -1152,12 +1153,28 @@ class ImprisonTag extends EntryHazardTag { } } +/** + * Abstract base class for all Room {@linkcode ArenaTag}s, characterized by their immediate removal + * upon overlap. + */ +abstract class RoomArenaTag extends SerializableArenaTag { + declare abstract tagType: RoomArenaTagType; + + /** + * Immediately remove this Tag upon overlapping. + * @sealed + */ + override onOverlap(): void { + globalScene.arena.removeTagOnSide(this.tagType, this.side); + } +} + /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Trick_Room_(move) Trick Room}. * Reverses the Speed stats for all Pokémon on the field as long as this arena tag is up, * also reversing the turn order for all Pokémon on the field as well. */ -export class TrickRoomTag extends SerializableArenaTag { +export class TrickRoomTag extends RoomArenaTag { public readonly tagType = ArenaTagType.TRICK_ROOM; constructor(turnCount: number, sourceId?: number) { super(turnCount, MoveId.TRICK_ROOM, sourceId); diff --git a/src/enums/battler-index.ts b/src/enums/battler-index.ts index 253e5bfc3ed..97c44f51c5c 100644 --- a/src/enums/battler-index.ts +++ b/src/enums/battler-index.ts @@ -1,5 +1,5 @@ /** - * The index of a given Pokemon on-field. + * The index of a given Pokemon on-field. \ * Used as an index into `globalScene.getField`, as well as for most target-specifying effects. */ export enum BattlerIndex { diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index c7794ca7f07..8fc7a763c8f 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -20,6 +20,7 @@ export class TurnStartPhase extends FieldPhase { * Helper method to retrieve the current speed order of the combattants. * It also checks for Trick Room and reverses the array if it is present. * @returns The {@linkcode BattlerIndex}es of all on-field Pokemon, sorted in speed order. + * @todo Make this private */ getSpeedOrder(): BattlerIndex[] { const playerField = globalScene.getPlayerField().filter(p => p.isActive()); diff --git a/test/moves/trick-room.test.ts b/test/moves/trick-room.test.ts new file mode 100644 index 00000000000..a1d81efb17e --- /dev/null +++ b/test/moves/trick-room.test.ts @@ -0,0 +1,82 @@ +import { AbilityId } from "#enums/ability-id"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { TurnStartPhase } from "#phases/turn-start-phase"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Move - Trick Room", () => { + 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 + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH); + }); + + it("should reverse the speed order of combatants while active", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const feebas = game.field.getPlayerPokemon(); + const karp = game.field.getEnemyPokemon(); + feebas.setStat(Stat.SPD, 2); + karp.setStat(Stat.SPD, 1); + expect(game.field.getSpeedOrder(true)).toEqual([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + + // Add trick room to the field + game.move.use(MoveId.TRICK_ROOM); + await game.toNextTurn(); + + expect(game).toHaveArenaTag({ + tagType: ArenaTagType.TRICK_ROOM, + side: ArenaTagSide.BOTH, + sourceId: feebas.id, + sourceMove: MoveId.TRICK_ROOM, + turnCount: 4, // The 5 turn limit _includes_ the current turn! + }); + + // Now, check that speed was indeed reduced + const turnOrderSpy = vi.spyOn(TurnStartPhase.prototype, "getSpeedOrder"); + + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); + + expect(turnOrderSpy).toHaveLastReturnedWith([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + }); + + it("should be removed when overlapped", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const feebas = game.field.getPlayerPokemon(); + + // Add trick room to the field, then add it again! + game.scene.arena.addTag(ArenaTagType.TRICK_ROOM, 5, MoveId.TRICK_ROOM, feebas.id); + + expect(game).toHaveArenaTag(ArenaTagType.TRICK_ROOM); + + game.scene.arena.addTag(ArenaTagType.TRICK_ROOM, 5, MoveId.TRICK_ROOM, feebas.id); + + expect(game).not.toHaveArenaTag(ArenaTagType.TRICK_ROOM); + }); +}); diff --git a/test/test-utils/helpers/field-helper.ts b/test/test-utils/helpers/field-helper.ts index 2d8fd8ee701..29eb70ae20c 100644 --- a/test/test-utils/helpers/field-helper.ts +++ b/test/test-utils/helpers/field-helper.ts @@ -5,6 +5,7 @@ import type { globalScene } from "#app/global-scene"; import type { Ability } from "#abilities/ability"; import { allAbilities } from "#data/data-lists"; import type { AbilityId } from "#enums/ability-id"; +import type { BattlerIndex } from "#enums/battler-index"; import type { PokemonType } from "#enums/pokemon-type"; import { Stat } from "#enums/stat"; import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon"; @@ -45,20 +46,38 @@ export class FieldHelper extends GameManagerHelper { /** * Helper function to return all on-field {@linkcode Pokemon} in speed order (fastest first). - * @returns An array containing all {@linkcode Pokemon} on the field in order of descending Speed. + * @param indices - Whether to only return {@linkcode BattlerIndex}es instead of full Pokemon objects + * (such as for comparison with other speed order-related mechanisms); default `false` + * @returns An array containing all on-field {@linkcode Pokemon} in order of descending Speed. \ * Speed ties are returned in increasing order of index. * * @remarks * This does not account for Trick Room as it does not modify the _speed_ of Pokemon on the field, * only their turn order. */ - public getSpeedOrder(): Pokemon[] { - return this.game.scene + public getSpeedOrder(indices?: false): Pokemon[]; + + /** + * Helper function to return all on-field {@linkcode Pokemon} in speed order (fastest first). + * @param indices - Whether to only return {@linkcode BattlerIndex}es instead of full Pokemon objects + * (such as for comparison with other speed order-related mechanisms); default `false` + * @returns An array containing the {@linkcode BattlerIndex}es of all on-field {@linkcode Pokemon} on the field in order of descending Speed. \ + * Speed ties are returned in increasing order of index. + * + * @remarks + * This does not account for Trick Room as it does not modify the _speed_ of Pokemon on the field, + * only their turn order. + */ + public getSpeedOrder(indices: true): BattlerIndex[]; + public getSpeedOrder(indices = false): BattlerIndex[] | Pokemon[] { + const ret = this.game.scene .getField(true) .sort( (pA, pB) => pB.getEffectiveStat(Stat.SPD) - pA.getEffectiveStat(Stat.SPD) || pA.getBattlerIndex() - pB.getBattlerIndex(), ); + + return indices ? ret.map(p => p.getBattlerIndex()) : ret; } /**