Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Move] Partially Implement Instruct #4813

Closed
wants to merge 14 commits into from
120 changes: 119 additions & 1 deletion src/data/move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6623,6 +6623,124 @@ export class CopyMoveAttr extends OverrideMoveEffectAttr {
}
}

/**
* Attribute used for moves that causes the target to repeat their last used move.4
*
* Used for [Instruct](https://bulbapedia.bulbagarden.net/wiki/After_You_(move)).
*/
export class RepeatMoveAttr extends OverrideMoveEffectAttr {
/**
* Forces the target to re-use their last used move again
*
* @param user {@linkcode Pokemon} that used the attack
* @param target {@linkcode Pokemon} targeted by the attack
* @param move {@linkcode Move} being used
* @param args N/A
* @returns {boolean} true if the move succeeds
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
// get the last move used (excluding status based failures) as well as the corresponding moveset slot
const lastMove = target.getLastXMoves(-1).find(m => m.move !== Moves.NONE);
innerthunder marked this conversation as resolved.
Show resolved Hide resolved
const movesetMove = target.getMoveset().find(m => m?.moveId === lastMove?.move);
const moveTargets = lastMove?.targets;

user.scene.queueMessage(i18next.t("moveTriggers:instructingMove", {
userPokemonName: getPokemonNameWithAffix(user),
targetPokemonName: getPokemonNameWithAffix(target)
}));
target.getMoveQueue().unshift({ move: lastMove?.move!, targets: moveTargets!, ignorePP: false });
target.scene.unshiftPhase(new MovePhase(target.scene, target, moveTargets!, movesetMove!, false, false));

return true;
}

getCondition(): MoveConditionFunc {
return (user, target, move) => {
// TODO: Confirm behavior of instructing move known by target but called by another move
const lastMove = target.getLastXMoves(-1).find(m => m.move !== Moves.NONE);
const movesetMove = target.getMoveset().find(m => m?.moveId === lastMove?.move);
const moveTargets = lastMove?.targets!;
// TODO: Add a way of adding moves to list procedurally rather than a pre-defined blacklist
const unrepeatablemoves = [
// Locking/Continually Executed moves
Moves.OUTRAGE,
Moves.RAGING_FURY,
Moves.ROLLOUT,
Moves.PETAL_DANCE,
Moves.THRASH,
Moves.ICE_BALL,
// Multi-turn Moves
Moves.BIDE,
Moves.SHELL_TRAP,
Moves.BEAK_BLAST,
Moves.FOCUS_PUNCH,
// "First Turn Only" moves
Moves.FAKE_OUT,
Moves.FIRST_IMPRESSION,
Moves.MAT_BLOCK,
// Moves with a recharge turn
Moves.HYPER_BEAM,
Moves.ETERNABEAM,
Moves.FRENZY_PLANT,
Moves.BLAST_BURN,
Moves.HYDRO_CANNON,
Moves.GIGA_IMPACT,
Moves.PRISMATIC_LASER,
Moves.ROAR_OF_TIME,
Moves.ROCK_WRECKER,
Moves.METEOR_ASSAULT,
// Charging & 2-turn moves
Moves.DIG,
Moves.FLY,
Moves.BOUNCE,
Moves.SHADOW_FORCE,
Moves.PHANTOM_FORCE,
Moves.DIVE,
Moves.ELECTRO_SHOT,
Moves.ICE_BURN,
Moves.GEOMANCY,
Moves.FREEZE_SHOCK,
Moves.SKY_DROP,
Moves.SKY_ATTACK,
Moves.SKULL_BASH,
Moves.SOLAR_BEAM,
Moves.SOLAR_BLADE,
Moves.METEOR_BEAM,
// Other moves
Moves.INSTRUCT,
Moves.KINGS_SHIELD,
Moves.SKETCH,
Moves.TRANSFORM,
Moves.MIMIC,
Moves.STRUGGLE,
// TODO: Add Z-move & Max Move blockage if/when they are implemented
];

if (!movesetMove || // called move not in target's moveset (dancer, forgetting the move, etc.)
movesetMove.ppUsed === movesetMove.getMovePp() || // move out of pp
allMoves[lastMove!.move].isChargingMove() || // called move is a charging/recharging move
!moveTargets.length || // called move has no targets
unrepeatablemoves.includes(lastMove?.move!)) { // called move is explicitly in the banlist
return false;
}
return true;
};
}

getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): integer {
// TODO: Make the AI acutally use instruct
/* Ideally, the AI would score instruct based on the scorings of the on-field pokemons'
* last used moves at the time of using Instruct (by the time the instructor gets to act)
* with respect to the user's side.
* It would then take the greatest of said scores and use it as the score for instruct
* (since that'd be the mon it would be most utile to use Instruct on).
* In 99.9% of cases, this would be the pokemon's ally (unless the target had last
* used a move like decorate on the user or its ally)
*/
return 2;
}
}

/**
* Attribute used for moves that reduce PP of the target's last used move.
* Used for Spite.
Expand Down Expand Up @@ -9786,7 +9904,7 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.ATK ], -1),
new StatusMove(Moves.INSTRUCT, Type.PSYCHIC, -1, 15, -1, 0, 7)
.ignoresSubstitute()
.unimplemented(),
.attr(RepeatMoveAttr),
new AttackMove(Moves.BEAK_BLAST, Type.FLYING, MoveCategory.PHYSICAL, 100, 100, 15, -1, -3, 7)
.attr(BeakBlastHeaderAttr)
.ballBombMove()
Expand Down
2 changes: 1 addition & 1 deletion src/phases/move-effect-phase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ export class MoveEffectPhase extends PokemonPhase {
}

/**
* Create a Promise that applys *all* effects from the invoked move's MoveEffectAttrs.
* Create a Promise that applies *all* effects from the invoked move's MoveEffectAttrs.
* These are ordered by trigger type (see {@linkcode MoveEffectTrigger}), and each trigger
* type requires different conditions to be met with respect to the move's hit result.
*/
Expand Down
242 changes: 242 additions & 0 deletions src/test/moves/instruct.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { BattlerIndex } from "#app/battle";
import GameManager from "#test/utils/gameManager";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#app/enums/abilities";
import { StatusEffect } from "#app/enums/status-effect";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";

describe("Moves - Instruct", () => {
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("double");
game.override.enemySpecies(Species.KARTANA);
game.override.enemyAbility(Abilities.COMPOUND_EYES);
game.override.enemyLevel(100);
game.override.starterSpecies(Species.AMOONGUSS);
game.override.passiveAbility(Abilities.COMPOUND_EYES);
game.override.startingLevel(100);
game.override.removeEnemyStartingItems = true;
game.override.moveset([ Moves.INSTRUCT, Moves.SONIC_BOOM, Moves.SUBSTITUTE, Moves.TORMENT ]);
game.override.disableCrits();
});

it("should repeat enemy's attack move when moving last", async () => {
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
await game.classicMode.startBattle([ Species.AMOONGUSS ]);

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.SONIC_BOOM, BattlerIndex.PLAYER);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// player lost 40 hp from 2 attacks
expect(game.scene.getPlayerPokemon()?.getInverseHp()).toBe(40);
});

it("should repeat enemy's move through substitute", async () => {
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);

await game.classicMode.startBattle([ Species.AMOONGUSS ]);

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.SUBSTITUTE, BattlerIndex.ATTACKER);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.toNextTurn();

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.SONIC_BOOM, BattlerIndex.PLAYER);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// lost 40 hp from 2 attacks
expect(game.scene.getPlayerPokemon()?.getInverseHp()).toBe(40);
});

it("should repeat ally's attack on enemy", async () => {
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
await game.classicMode.startBattle([ Species.AMOONGUSS, Species.SHUCKLE ]);

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
game.move.select(Moves.SONIC_BOOM, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.VINE_WHIP);
await game.setTurnOrder([ BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// used 2 pp and spanked enemy twice
expect(game.scene.getPlayerField()[1].getMoveset().find(m => m?.moveId === Moves.SONIC_BOOM)!.ppUsed).toBe(2);
expect(game.scene.getEnemyPokemon()!.getInverseHp()).toBe(40);
});

/*
TODO: Re-add test case once gigaton hammer successfully gets unjanked
it("should repeat enemy's Gigaton Hammer", async () => {
game.override.enemyMoveset([ Moves.GIGATON_HAMMER, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
await game.classicMode.startBattle([ Species.LUCARIO, Species.HISUI_AVALUGG ]);

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
game.move.select(Moves.SONIC_BOOM, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.GIGATON_HAMMER, BattlerIndex.PLAYER_2);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2 ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// used 2 pp and spanked us twice, using 2 pp
const moveUsed = game.scene.getEnemyPokemon()?.getLastXMoves(-1)!;
expect(moveUsed[0].targets![0]).toBe(BattlerIndex.PLAYER_2);
// Gigaton hammer is guaranteed OHKO against avalugg 100% of the time,
// so the 2nd attack should redirect to pokemon #1
expect(game.scene.getPlayerParty()[1].isFainted()).toBe(true);
expect(game.scene.getPlayerField()[0].getInverseHp()).toBeGreaterThan(0);
expect(game.scene.getEnemyPokemon()!.getMoveset().find(m => m?.moveId === Moves.GIGATON_HAMMER)!.ppUsed).toBe(2);
});*/

it("should respect enemy's status condition & give chance to remove condition", async () => {
game.override.enemyStatusEffect(StatusEffect.FREEZE);
game.override.statusActivation(true);
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
await game.classicMode.startBattle([ Species.AMOONGUSS ]);

const enemyPokemon = game.scene.getEnemyPokemon()!;
// fake move history
enemyPokemon.battleSummonData.moveHistory = [{ move: Moves.SONIC_BOOM, targets: [ BattlerIndex.PLAYER ], result: MoveResult.SUCCESS, virtual: false }];

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.PROTECT);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("MovePhase", true);
game.override.statusActivation(false); // should cure freeze
await game.phaseInterceptor.to("TurnEndPhase", false);

// protect not recorded as last move due to full para blockage
// instructed sonic boom still works as pokemon was defrosted before attack
const moveUsed = game.scene.getEnemyPokemon()!.getLastXMoves(-1);
expect(moveUsed.find(m => m?.move !== Moves.NONE)?.move).toBe(Moves.SONIC_BOOM);
});

it("should not repeat enemy's out of pp move", async () => {
game.override.enemySpecies(Species.UNOWN);
await game.classicMode.startBattle([ Species.AMOONGUSS ]);

const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.generateAndPopulateMoveset();
const moveUsed = enemyPokemon?.moveset.find(m => m?.moveId === Moves.HIDDEN_POWER)!;
moveUsed.ppUsed = moveUsed.getMovePp() - 1; // deduct all but 1 pp

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.HIDDEN_POWER, BattlerIndex.PLAYER);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// instruct "should" fail as it tries to force the enemy to use an out of pp move
// TODO: Check showdown behavior of instructing out of pp moves
const playerMove = game.scene.getPlayerPokemon()!.getLastXMoves()!;
const enemyMove = enemyPokemon.getLastXMoves(2);
expect(enemyMove[0].result).toBe(MoveResult.SUCCESS);
expect(playerMove[0].result).toBe(MoveResult.FAIL);
});

it("should fail if no move has yet been used by target", async () => {
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
await game.classicMode.startBattle([ Species.AMOONGUSS ]);

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.SONIC_BOOM, BattlerIndex.PLAYER);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// should fail to execute
expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});

it("should try to repeat enemy's disabled move, but fail", async () => {
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
game.override.moveset([ Moves.INSTRUCT, Moves.SONIC_BOOM, Moves.DISABLE, Moves.SPLASH ]);
await game.classicMode.startBattle([ Species.AMOONGUSS, Species.DROWZEE ]);

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
game.move.select(Moves.DISABLE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.SONIC_BOOM, BattlerIndex.PLAYER);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// instruction should succeed but move itself should fail without consuming pp
expect(game.scene.getPlayerField()[0].getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
const enemyMove = game.scene.getEnemyPokemon()!.getLastXMoves()[0];
expect(enemyMove.result).toBe(MoveResult.FAIL);
expect(game.scene.getEnemyPokemon()!.getMoveset().find(m => m?.moveId === Moves.SONIC_BOOM)?.ppUsed).toBe(0);

});

it("should not repeat enemy's move through protect", async () => {
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
await game.classicMode.startBattle([ Species.AMOONGUSS ]);

const enemyPokemon = game.scene.getEnemyPokemon()!;
// fake move history
enemyPokemon.battleSummonData.moveHistory = [{ move: Moves.SONIC_BOOM, targets: [ BattlerIndex.PLAYER ], result: MoveResult.SUCCESS, virtual: false }];

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.PROTECT, BattlerIndex.ATTACKER);
await game.phaseInterceptor.to("TurnEndPhase", false);

// protect still last move as instruct was blocked from repeating anything
expect(game.scene.getEnemyPokemon()!.getLastXMoves()[0].move).toBe(Moves.PROTECT);
});

it("should not repeat enemy's charging move", async () => {
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
await game.classicMode.startBattle([ Species.DUSKNOIR ]);

const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.battleSummonData.moveHistory = [{ move: Moves.SONIC_BOOM, targets: [ BattlerIndex.PLAYER ], result: MoveResult.SUCCESS, virtual: false }];

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.HYPER_BEAM);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// hyper beam charging prevented instruct from working
expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0]!.result).toBe(MoveResult.FAIL);

await game.toNextTurn();
game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.HYPER_BEAM);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// hyper beam charging prevented instruct from working
expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0]!.result).toBe(MoveResult.FAIL);
});

it("should not repeat dance move not known by target", async () => {
game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]);
game.override.moveset([ Moves.INSTRUCT, Moves.FIERY_DANCE, Moves.SUBSTITUTE, Moves.TORMENT ]);
game.override.enemyAbility(Abilities.DANCER);
await game.classicMode.startBattle([ Species.DUSKNOIR, Species.ABOMASNOW ]);

game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
game.move.select(Moves.FIERY_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.forceEnemyMove(Moves.PROTECT, BattlerIndex.ATTACKER);
await game.setTurnOrder([ BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("TurnEndPhase", false);

// Pokemon 2 uses dance; dancer reciprocates
// instruct fails as it cannot copy the unknown dance move
expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0]!.result).toBe(MoveResult.FAIL);
});
});