Updated a few telekinesis tests to not brok

This commit is contained in:
Bertie690 2025-09-07 13:02:08 -04:00
parent 5eed7f7e01
commit c524cbd053
8 changed files with 120 additions and 52 deletions

View File

@ -115,7 +115,17 @@ export class BattlerTag implements BaseBattlerTag {
//#region non-serializable fields
// Fields that should never be serialized, as they must not change after instantiation
/**
* Whether this Tag can be transferred via {@linkcode MoveId.BATON_PASS}.
* @defaultValue `false`
* @todo Make this an overriddable getter on subclasses rather than a value defined in the constructor
*/
#isBatonPassable = false;
/**
* Whether this Tag can be transferred via {@linkcode MoveId.BATON_PASS}.
* @defaultValue `false`
*/
public get isBatonPassable(): boolean {
return this.#isBatonPassable;
}
@ -2239,7 +2249,7 @@ export abstract class TypeImmuneTag extends SerializableBattlerTag {
}
/**
* Battler Tag that lifts the affected Pokemon into the air and provides immunity to Ground type moves.
* Battler Tag that lifts the affected Pokemon into the air, providing immunity to Ground-type moves.
* @see {@link https://bulbapedia.bulbagarden.net/wiki/Magnet_Rise_(move) | MoveId.MAGNET_RISE}
* @see {@link https://bulbapedia.bulbagarden.net/wiki/Telekinesis_(move) | MoveId.TELEKINESIS}
*/
@ -3470,22 +3480,15 @@ export class SyrupBombTag extends SerializableBattlerTag {
}
/**
* Telekinesis raises the target into the air for three turns and causes all moves used against the target (aside from OHKO moves) to hit the target unless the target is in a semi-invulnerable state from Fly/Dig.
* The first effect is provided by {@linkcode FloatingTag}, the accuracy-bypass effect is provided by TelekinesisTag
* The effects of Telekinesis can be baton passed to a teammate.
* @see {@link https://bulbapedia.bulbagarden.net/wiki/Telekinesis_(move) | MoveId.TELEKINESIS}
* Tag used by {@linkcode MoveId.TELEKINESIS} to provide its guaranteed-hit effect. \
* The effects of Telekinesis can be Baton Passed to a teammate, including ones unaffected by the original move.
* A notable exception is Mega Gengar, which cannot receive either effect via Baton Pass.
* @see {@linkcode FloatingTag} - Tag used by Telekinesis to unground the target
*/
export class TelekinesisTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.TELEKINESIS;
constructor(sourceMove: MoveId) {
super(
BattlerTagType.TELEKINESIS,
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE],
3,
sourceMove,
undefined,
true,
);
constructor() {
super(BattlerTagType.TELEKINESIS, BattlerTagLapseType.TURN_END, 3, MoveId.TELEKINESIS, undefined, true);
}
override onAdd(pokemon: Pokemon) {
@ -3831,7 +3834,7 @@ export function getBattlerTag(
case BattlerTagType.SYRUP_BOMB:
return new SyrupBombTag(sourceId);
case BattlerTagType.TELEKINESIS:
return new TelekinesisTag(sourceMove);
return new TelekinesisTag();
case BattlerTagType.POWER_TRICK:
return new PowerTrickTag(sourceMove, sourceId);
case BattlerTagType.GRUDGE:

View File

@ -283,9 +283,12 @@ export const invalidEncoreMoves: ReadonlySet<MoveId> = new Set([
]);
/**
* Set of all {@linkcode SpeciesId}s that are barred from use by {@linkcode MoveId.TELEKINESIS}.
* Set of all {@linkcode SpeciesId}s that {@linkcode MoveId.TELEKINESIS} cannot directly affect.
* They can still receive the effect from Baton Passing, however.
*
* (Not included here is Gengar, which is only forbidden in its Mega form.)
* @remarks
* Not included here is Gengar, which is only forbidden in its Mega form and which
* _cannot_ receive either of Telekinesis' effects via Baton Pass.
*/
export const invalidTelekinesisSpecies: ReadonlySet<SpeciesId> = new Set([
SpeciesId.DIGLETT,
@ -296,4 +299,4 @@ export const invalidTelekinesisSpecies: ReadonlySet<SpeciesId> = new Set([
SpeciesId.PALOSSAND,
SpeciesId.WIGLETT,
SpeciesId.WUGTRIO,
] as const);
]);

View File

@ -77,7 +77,15 @@ import {
PreserveBerryModifier,
} from "#modifiers/modifier";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves, invalidTelekinesisSpecies } from "#moves/invalid-moves";
import {
invalidAssistMoves,
invalidCopycatMoves,
invalidMetronomeMoves,
invalidMirrorMoveMoves,
invalidSketchMoves,
invalidSleepTalkMoves,
invalidTelekinesisSpecies,
} from "#moves/invalid-moves";
import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import { MoveEndPhase } from "#phases/move-end-phase";
@ -10083,13 +10091,15 @@ export function initMoves() {
new StatusMove(MoveId.TELEKINESIS, PokemonType.PSYCHIC, -1, 15, -1, 0, 5)
.attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3)
.condition((_user, target, _move) => !(
.condition((_user, target) => !(
invalidTelekinesisSpecies.has(target.species.speciesId)
|| (target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === SpeciesFormKey.MEGA)
))
.condition(failOnGravityCondition)
.condition(failOnGroundedCondition)
.reflectable(),
.reflectable()
// Still preserves FLOATING tag when Baton Passed onto a Mega Gengar
.edgeCase(),
new StatusMove(MoveId.MAGIC_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5)
.ignoresProtect()
.target(MoveTarget.BOTH_SIDES)

View File

@ -2284,7 +2284,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Return whether this Pokemon is currently on the ground.
*
* To be considered grounded, a Pokemon must fulfill any of the following criteria:
* To be considered grounded, a Pokemon must either:
* * Be {@linkcode BattlerTagType.IGNORE_FLYING | forcibly grounded} from an effect like Smack Down or Ingrain
* * Be under the effects of {@linkcode ArenaTagType.GRAVITY | harsh gravity}
* * **Not** be any of the following things:
@ -4376,10 +4376,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Transferring stat changes and Tags
* @param source {@linkcode Pokemon} the pokemon whose stats/Tags are to be passed on from, ie: the Pokemon using Baton Pass
* Transfer stat changes and volatile status effects upon a Pokemon switching in via Baton Pass.
* Should be called prior to the recipient's summon data being reset.
* @param source - The {@linkcode Pokemon} using Baton Pass to source the effects from.
*/
transferSummon(source: Pokemon): void {
public transferSummon(source: Pokemon): void {
// Copy all stat stages
for (const s of BATTLE_STATS) {
const sourceStage = source.getStatStage(s);
@ -4389,12 +4390,20 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
this.setStatStage(s, sourceStage);
}
// Edge case: avoid transferring either of Telekinesis' effects when passing to Mega Gengar
// TODO: Rather than handling this logic in the core baton pass logic, move it to an
// overriddable helper function on the BattlerTag instance
const isMegaGengarReceivingTelekinesis =
this.species.speciesId === SpeciesId.GENGAR &&
this.getFormKey() === SpeciesFormKey.MEGA &&
!!source.getTag(BattlerTagType.TELEKINESIS);
// Copy all transferrable BattlerTags
for (const tag of source.summonData.tags) {
if (
!tag.isBatonPassable ||
(tag.tagType === BattlerTagType.TELEKINESIS &&
this.species.speciesId === SpeciesId.GENGAR &&
this.getFormKey() === "mega")
(isMegaGengarReceivingTelekinesis &&
(tag.tagType === BattlerTagType.TELEKINESIS || tag.tagType === BattlerTagType.FLOATING))
) {
continue;
}

View File

@ -27,7 +27,7 @@ describe("Arena - Gravity", () => {
.battleStyle("single")
.ability(AbilityId.UNNERVE)
.enemyAbility(AbilityId.BALL_FETCH)
.enemySpecies(SpeciesId.MAGIKARP)
.enemySpecies(SpeciesId.FLETCHLING)
.enemyLevel(5);
});
@ -41,7 +41,7 @@ describe("Arena - Gravity", () => {
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
expect(game).toHaveArenaTag(ArenaTagType.GRAVITY);
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.TACKLE].accuracy * 1.67);
});
@ -62,11 +62,12 @@ describe("Arena - Gravity", () => {
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
expect(enemy.isGrounded()).toBe(false);
game.move.use(MoveId.GRAVITY);
await game.toNextTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
expect(game).toHaveArenaTag(ArenaTagType.GRAVITY);
expect(player.isGrounded()).toBe(true);
expect(enemy.isGrounded()).toBe(true);
});

View File

@ -181,7 +181,7 @@ describe("Terrain -", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemy.getLastXMoves()[0].move).toBe(MoveId.FLY);
expect(enemy).toHaveUsedMove(MoveId.FLY);
expect(powerSpy).toHaveLastReturnedWith(basePower);
},
);
@ -201,11 +201,11 @@ describe("Terrain -", () => {
const pidgeot = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(pidgeot.status?.effect).toBe(StatusEffect.SLEEP);
expect(shuckle.status?.effect).toBeUndefined();
expect(pidgeot).toHaveStatusEffect(StatusEffect.SLEEP);
expect(shuckle).toHaveStatusEffect(StatusEffect.NONE);
// TODO: These don't work due to how move failures are propagated
// expect(pidgeot.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
// expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
// expect(pidgeot).toHaveUsedMove({ move: MoveId.SPORE, result: MoveResult.FAIL });
// expect(shuckle).toHaveUsedMove({ move: MoveId.SPORE, result: MoveResult.SUCCESS });
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
@ -227,9 +227,9 @@ describe("Terrain -", () => {
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
expect(shuckle.status?.effect).toBeUndefined();
expect(shuckle).toHaveStatusEffect(StatusEffect.NONE);
expect(statusSpy).toHaveLastReturnedWith(false);
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(blissey).toHaveUsedMove({ move: MoveId.RELIC_SONG, result: MoveResult.SUCCESS });
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:defaultBlockMessage", {
@ -256,8 +256,8 @@ describe("Terrain -", () => {
const shuckle = game.field.getEnemyPokemon();
// blissey is grounded & protected, shuckle isn't
expect(blissey.status?.effect).toBeUndefined();
expect(shuckle.status?.effect).toBe(StatusEffect.TOXIC);
expect(blissey).toHaveStatusEffect(StatusEffect.NONE);
expect(shuckle).toHaveStatusEffect(StatusEffect.TOXIC);
// TODO: These don't work due to how move failures are propagated
// expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
// expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
@ -313,8 +313,8 @@ describe("Terrain -", () => {
// Blissey was grounded and protected from effect, but still took damage
expect(blissey).not.toHaveFullHp();
expect(blissey).not.toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(blissey.status?.effect).toBe(StatusEffect.NONE);
expect(shuckle).toHaveUsedMove({ result: MoveResult.SUCCESS });
expect(blissey).toHaveStatusEffect(StatusEffect.NONE);
expect(shuckle).toHaveUsedMove({ move, result: MoveResult.SUCCESS });
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:mistyBlockMessage", {
@ -381,7 +381,7 @@ describe("Terrain -", () => {
category: "Enemy-targeting spread",
move: MoveId.DARK_VOID,
effect: () => {
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.SLEEP);
expect(game.field.getEnemyPokemon()).toHaveStatusEffect(StatusEffect.SLEEP);
},
},
])("should not block $category moves that become priority", async ({ move, effect }) => {

View File

@ -67,17 +67,57 @@ describe("Move - Telekinesis", () => {
expect(enemy.isGrounded()).toBe(false);
});
// TODO: Verify whether the 3 turn duration includes the turn the move is used
it.todo("should last 3 turns", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("MoveEndPhase");
const enemy = game.field.getEnemyPokemon();
expect(enemy).toHaveBattlerTag({ tagType: BattlerTagType.TELEKINESIS, turnCount: 2 });
await game.toNextTurn();
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(enemy).toHaveBattlerTag({ tagType: BattlerTagType.TELEKINESIS, turnCount: 1 });
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(enemy).not.toHaveBattlerTag(BattlerTagType.TELEKINESIS);
});
const cases = ([BattlerTagType.TELEKINESIS, BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING] as const).map(
t => ({
tagType: t,
name: getEnumStr(BattlerTagType, t),
}),
);
it.each(cases)("should fail if the target already has BattlerTagType.$name", async ({ tagType }) => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemy = game.field.getEnemyPokemon();
enemy.addTag(tagType);
game.move.use(MoveId.TELEKINESIS);
await game.toEndOfTurn();
const karp = game.field.getPlayerPokemon();
expect(karp).toHaveUsedMove({ move: MoveId.TELEKINESIS, result: MoveResult.FAIL });
});
const invalidSpecies = [...invalidTelekinesisSpecies].map(s => ({
species: s,
name: getEnumStr(SpeciesId, s, { casing: "Title" }),
name: getEnumStr(SpeciesId, s),
}));
it.each(invalidSpecies)(
"should fail if used on a $name, but can still be baton passed onto one",
"should fail if used on $name, but can still be Baton Passed onto one",
async ({ species }) => {
await game.classicMode.startBattle([species, SpeciesId.FEEBAS]);
const [invalidMon, feebas] = game.scene.getPlayerParty();
expect(invalidMon.species.speciesId).toBe(species);
expect(invalidTelekinesisSpecies).toContain(invalidMon.species.speciesId);
game.move.use(MoveId.TELEPORT);
game.doSelectPartyPokemon(1);
@ -90,6 +130,7 @@ describe("Move - Telekinesis", () => {
expect(invalidMon.isOnField()).toBe(false);
// Turn 2: Transfer Telekinesis from Feebas to the invalid pokemon
game.move.use(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
@ -115,13 +156,14 @@ describe("Move - Telekinesis", () => {
expect(enemy.summonData.speciesForm?.speciesId).toBe(SpeciesId.DIGLETT);
game.move.use(MoveId.TELEKINESIS);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
expect(enemy).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemy).toHaveBattlerTag(BattlerTagType.FLOATING);
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveUsedMove({ move: MoveId.TELEKINESIS, result: MoveResult.FAIL });
expect(feebas).toHaveUsedMove({ move: MoveId.TELEKINESIS, result: MoveResult.SUCCESS });
});
// TODO: Move to ingrain's test file
@ -176,7 +218,7 @@ describe("Move - Telekinesis", () => {
await game.toEndOfTurn();
// Should have not received tags
// Should have not received either tag from baton passing
expect(gengar.isOnField()).toBe(true);
expect(gengar).not.toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(gengar).not.toHaveBattlerTag(BattlerTagType.FLOATING);

View File

@ -33,16 +33,16 @@ export class MoveHelper extends GameManagerHelper {
}
/**
* Intercepts {@linkcode MoveEffectPhase} and mocks the phase's move's accuracy
* Intercepts the next upcoming {@linkcode MoveEffectPhase} and mocks the invoked move's accuracy
* to `0`, guaranteeing a miss.
* @param firstTargetOnly - Whether to only force a miss on the first target hit; default `false`.
* @returns A promise that resolves once the next MoveEffectPhase has been reached (not run).
* @returns A Promise that resolves once the next `MoveEffectPhase` has been reached (not run).
* @remarks
* This is notably useful for testing guaranteed-hit granting effects, as it will ensure
* a miss if the effect does not apply.
* the attacking move misses if the effect does not apply.
*/
public async forceMiss(firstTargetOnly = false): Promise<void> {
await this.game.phaseInterceptor.to(MoveEffectPhase, false);
await this.game.phaseInterceptor.to("MoveEffectPhase", false);
const moveEffectPhase = this.game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase;
const accuracy = vi.spyOn(moveEffectPhase.move, "calculateBattleAccuracy");