[Move] Add type immunity removal moves (Foresight, Odor Sleuth, Miracle Eye) (#3379)

* Update Foresight PR to current beta

Implements Foresight, Miracle Eye, and Odor Sleuth

* Add placeholder i18n strings

* Minor tsdoc updates

* Fix placement of evasion level modifier, add tests

* Add first batch of translations

Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com>
Co-authored-by: Lugiad' <adrien.grivel@hotmail.fr>
Co-authored-by: José Ricardo Fleury Oliveira <josefleury@discente.ufg.br>

* Second batch of translations

Co-authored-by: Enoch <enoch.jwsong@gmail.com>
Co-authored-by: mercurius-00 <80205689+mercurius-00@users.noreply.github.com>

* Add Catalan and Japanese translation placeholder strings

* Fix issue caused by merge

---------

Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com>
Co-authored-by: Lugiad' <adrien.grivel@hotmail.fr>
Co-authored-by: José Ricardo Fleury Oliveira <josefleury@discente.ufg.br>
Co-authored-by: Enoch <enoch.jwsong@gmail.com>
Co-authored-by: mercurius-00 <80205689+mercurius-00@users.noreply.github.com>
This commit is contained in:
NightKev 2024-08-07 07:59:28 -07:00 committed by GitHub
parent db3fae1180
commit 548bd8978f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 227 additions and 4 deletions

View File

@ -1680,6 +1680,47 @@ export class GulpMissileTag extends BattlerTag {
}
}
/**
* Tag that makes the target drop all of it type immunities
* and all accuracy checks ignore its evasiveness stat.
*
* Applied by moves: {@linkcode Moves.ODOR_SLEUTH | Odor Sleuth},
* {@linkcode Moves.MIRACLE_EYE | Miracle Eye} and {@linkcode Moves.FORESIGHT | Foresight}.
*
* @extends BattlerTag
* @see {@linkcode ignoreImmunity}
*/
export class ExposedTag extends BattlerTag {
private defenderType: Type;
private allowedTypes: Type[];
constructor(tagType: BattlerTagType, sourceMove: Moves, defenderType: Type, allowedTypes: Type[]) {
super(tagType, BattlerTagLapseType.CUSTOM, 1, sourceMove);
this.defenderType = defenderType;
this.allowedTypes = allowedTypes;
}
/**
* When given a battler tag or json representing one, load the data for it.
* @param {BattlerTag | any} source A battler tag
*/
loadTag(source: BattlerTag | any): void {
super.loadTag(source);
this.defenderType = source.defenderType as Type;
this.allowedTypes = source.allowedTypes as Type[];
}
/**
* @param types {@linkcode Type} of the defending Pokemon
* @param moveType {@linkcode Type} of the move targetting it
* @returns `true` if the move should be allowed to target the defender.
*/
ignoreImmunity(type: Type, moveType: Type): boolean {
return type === this.defenderType && this.allowedTypes.includes(moveType);
}
}
export function getBattlerTag(tagType: BattlerTagType, turnCount: number, sourceMove: Moves, sourceId: number): BattlerTag {
switch (tagType) {
case BattlerTagType.RECHARGING:
@ -1801,6 +1842,10 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new StockpilingTag(sourceMove);
case BattlerTagType.OCTOLOCK:
return new OctolockTag(sourceId);
case BattlerTagType.IGNORE_GHOST:
return new ExposedTag(tagType, sourceMove, Type.GHOST, [Type.NORMAL, Type.FIGHTING]);
case BattlerTagType.IGNORE_DARK:
return new ExposedTag(tagType, sourceMove, Type.DARK, [Type.PSYCHIC]);
case BattlerTagType.GULP_MISSILE_ARROKUDA:
case BattlerTagType.GULP_MISSILE_PIKACHU:
return new GulpMissileTag(tagType, sourceMove);

View File

@ -5979,6 +5979,39 @@ export class ResistLastMoveTypeAttr extends MoveEffectAttr {
}
}
/**
* Drops the target's immunity to types it is immune to
* and makes its evasiveness be ignored during accuracy
* checks. Used by: {@linkcode Moves.ODOR_SLEUTH | Odor Sleuth}, {@linkcode Moves.MIRACLE_EYE | Miracle Eye} and {@linkcode Moves.FORESIGHT | Foresight}
*
* @extends AddBattlerTagAttr
* @see {@linkcode apply}
*/
export class ExposedMoveAttr extends AddBattlerTagAttr {
constructor(tagType: BattlerTagType) {
super(tagType, false, true);
}
/**
* Applies {@linkcode ExposedTag} to the target.
* @param user {@linkcode Pokemon} using this move
* @param target {@linkcode Pokemon} target of this move
* @param move {@linkcode Move} being used
* @param args N/A
* @returns `true` if the function succeeds
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!super.apply(user, target, move, args)) {
return false;
}
user.scene.queueMessage(i18next.t("moveTriggers:exposedMove", { pokemonName: getPokemonNameWithAffix(user), targetPokemonName: getPokemonNameWithAffix(target)}));
return true;
}
}
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(Type.UNKNOWN);
export type MoveTargetSet = {
@ -6575,7 +6608,7 @@ export function initMoves() {
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.ballBombMove(),
new StatusMove(Moves.FORESIGHT, Type.NORMAL, -1, 40, -1, 0, 2)
.unimplemented(),
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST),
new SelfStatusMove(Moves.DESTINY_BOND, Type.GHOST, -1, 5, -1, 0, 2)
.ignoresProtect()
.attr(DestinyBondAttr),
@ -6935,7 +6968,7 @@ export function initMoves() {
.attr(StatChangeAttr, BattleStat.SPATK, -2, true)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE),
new StatusMove(Moves.ODOR_SLEUTH, Type.NORMAL, -1, 40, -1, 0, 3)
.unimplemented(),
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_GHOST),
new AttackMove(Moves.ROCK_TOMB, Type.ROCK, MoveCategory.PHYSICAL, 60, 95, 15, 100, 0, 3)
.attr(StatChangeAttr, BattleStat.SPD, -1)
.makesContact(false),
@ -7045,7 +7078,7 @@ export function initMoves() {
.attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5)
.target(MoveTarget.BOTH_SIDES),
new StatusMove(Moves.MIRACLE_EYE, Type.PSYCHIC, -1, 40, -1, 0, 4)
.unimplemented(),
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK),
new AttackMove(Moves.WAKE_UP_SLAP, Type.FIGHTING, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 4)
.attr(MovePowerMultiplierAttr, (user, target, move) => targetSleptOrComatoseCondition(user, target, move) ? 2 : 1)
.attr(HealStatusEffectAttr, false, StatusEffect.SLEEP),

View File

@ -63,6 +63,8 @@ export enum BattlerTagType {
STOCKPILING = "STOCKPILING",
RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE",
ALWAYS_GET_HIT = "ALWAYS_GET_HIT",
IGNORE_GHOST = "IGNORE_GHOST",
IGNORE_DARK = "IGNORE_DARK",
GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA",
GULP_MISSILE_PIKACHU = "GULP_MISSILE_PIKACHU"
}

View File

@ -19,7 +19,7 @@ import { pokemonEvolutions, pokemonPrevolutions, SpeciesFormEvolution, SpeciesEv
import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "../data/tms";
import { DamagePhase, FaintPhase, LearnMovePhase, MoveEffectPhase, ObtainStatusEffectPhase, StatChangePhase, SwitchSummonPhase, ToggleDoublePositionPhase, MoveEndPhase } from "../phases";
import { BattleStat } from "../data/battle-stat";
import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag } from "../data/battler-tags";
import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, ExposedTag } from "../data/battler-tags";
import { WeatherType } from "../data/weather";
import { TempBattleStat } from "../data/temp-battle-stat";
import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "../data/arena-tag";
@ -1259,6 +1259,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
if (ignoreImmunity.value) {
return 1;
}
const exposedTags = this.findTags(tag => tag instanceof ExposedTag) as ExposedTag[];
if (exposedTags.some(t => t.ignoreImmunity(defType, moveType))) {
return 1;
}
}
return getTypeDamageMultiplier(moveType, defType);
@ -1865,6 +1870,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
applyMoveAttrs(IgnoreOpponentStatChangesAttr, this, target, sourceMove, targetEvasionLevel);
this.scene.applyModifiers(TempBattleStatBoosterModifier, this.isPlayer(), TempBattleStat.ACC, userAccuracyLevel);
if (target.findTag(t => t instanceof ExposedTag)) {
targetEvasionLevel.value = Math.min(0, targetEvasionLevel.value);
}
const accuracyMultiplier = new Utils.NumberHolder(1);
if (userAccuracyLevel.value !== targetEvasionLevel.value) {
accuracyMultiplier.value = userAccuracyLevel.value > targetEvasionLevel.value

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}}'s type became the same as\n{{targetPokemonName}}'s type!",
"suppressAbilities": "{{pokemonName}}'s ability\nwas suppressed!",
"swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!",
"exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}} hat den Typ von {{targetPokemonName}} angenommen!",
"suppressAbilities": "Die Fähigkeit von {{pokemonName}} wirkt nicht mehr!",
"swapArenaTags": "{{pokemonName}} hat die Effekte, die auf den beiden Seiten des Kampffeldes wirken, miteinander getauscht!",
"exposedMove": "{{pokemonName}} erkennt {{targetPokemonName}}!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}}'s type became the same as\n{{targetPokemonName}}'s type!",
"suppressAbilities": "{{pokemonName}}'s ability\nwas suppressed!",
"swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!",
"exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}}'s type\nchanged to match {{targetPokemonName}}'s!",
"suppressAbilities": "{{pokemonName}}'s ability\nwas suppressed!",
"swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!",
"exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}} prend le type\nde {{targetPokemonName}} !",
"suppressAbilities": "Le talent de {{pokemonName}}\na été rendu inactif !",
"swapArenaTags": "Les effets affectant chaque côté du terrain\nont été échangés par {{pokemonName}} !",
"exposedMove": "{{targetPokemonName}} est identifié\npar {{pokemonName}} !",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}} assume il tipo\ndi {{targetPokemonName}}!",
"suppressAbilities": "Labilità di {{pokemonName}}\nperde ogni efficacia!",
"swapArenaTags": "{{pokemonName}} ha invertito gli effetti attivi\nnelle due metà del campo!",
"exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}}は {{targetPokemonName}}と\n同じタイプに なった",
"suppressAbilities": "{{pokemonName}}の とくせいが きかなくなった!",
"swapArenaTags": "{{pokemonName}}は\nおたがいの ばのこうかを いれかえた",
"exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}}[[는]]\n{{targetPokemonName}}[[와]] 같은 타입이 되었다!",
"suppressAbilities": "{{pokemonName}}의\n특성이 효과를 발휘하지 못하게 되었다!",
"swapArenaTags": "{{pokemonName}}[[는]]\n서로의 필드 효과를 교체했다!",
"exposedMove": "{{pokemonName}}[[는]]\n{{targetPokemonName}}의 정체를 꿰뚫어 보았다!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "O tipo de {{pokemonName}}\nmudou para combinar com {{targetPokemonName}}!",
"suppressAbilities": "A habilidade de {{pokemonName}}\nfoi suprimida!",
"swapArenaTags": "{{pokemonName}} trocou os efeitos de batalha que afetam cada lado do campo!",
"exposedMove": "{{pokemonName}} identificou\n{{targetPokemonName}}!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}}\n变成了{{targetPokemonName}}的属性!",
"suppressAbilities": "{{pokemonName}}的特性\n变得无效了",
"swapArenaTags": "{{pokemonName}}\n交换了双方的场地效果",
"exposedMove": "{{pokemonName}}识破了\n{{targetPokemonName}}的原型!",
} as const;

View File

@ -59,4 +59,5 @@ export const moveTriggers: SimpleTranslationEntries = {
"copyType": "{{pokemonName}}變成了{{targetPokemonName}}的屬性!",
"suppressAbilities": "{{pokemonName}}的特性\n變得無效了",
"swapArenaTags": "{{pokemonName}}\n交換了雙方的場地效果",
"exposedMove": "{{pokemonName}}識破了\n{{targetPokemonName}}的原形!",
} as const;

View File

@ -0,0 +1,72 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import Phaser from "phaser";
import GameManager from "#test/utils/gameManager";
import { Species } from "#app/enums/species.js";
import { SPLASH_ONLY } from "../utils/testUtils";
import { Moves } from "#app/enums/moves.js";
import { getMovePosition } from "../utils/gameManagerUtils";
import { MoveEffectPhase } from "#app/phases.js";
describe("Internals", () => {
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
.disableCrits()
.enemySpecies(Species.GASTLY)
.enemyMoveset(SPLASH_ONLY)
.enemyLevel(5)
.starterSpecies(Species.MAGIKARP)
.moveset([Moves.FORESIGHT, Moves.QUICK_ATTACK, Moves.MACH_PUNCH]);
});
it("should allow Normal and Fighting moves to hit Ghost types", async () => {
await game.startBattle();
const enemy = game.scene.getEnemyPokemon();
game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_ATTACK));
await game.toNextTurn();
expect(enemy.hp).toBe(enemy.getMaxHp());
game.doAttack(getMovePosition(game.scene, 0, Moves.FORESIGHT));
await game.toNextTurn();
game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_ATTACK));
await game.toNextTurn();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
enemy.hp = enemy.getMaxHp();
game.doAttack(getMovePosition(game.scene, 0, Moves.MACH_PUNCH));
await game.phaseInterceptor.to(MoveEffectPhase);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
});
it("should ignore target's evasiveness boosts", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.MINIMIZE));
await game.startBattle();
const pokemon = game.scene.getPlayerPokemon();
vi.spyOn(pokemon, "getAccuracyMultiplier");
game.doAttack(getMovePosition(game.scene, 0, Moves.FORESIGHT));
await game.toNextTurn();
game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_ATTACK));
await game.phaseInterceptor.to(MoveEffectPhase);
expect(pokemon.getAccuracyMultiplier).toHaveReturnedWith(1);
});
});

View File

@ -0,0 +1,51 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import Phaser from "phaser";
import GameManager from "#test/utils/gameManager";
import { Species } from "#app/enums/species.js";
import { SPLASH_ONLY } from "../utils/testUtils";
import { Moves } from "#app/enums/moves.js";
import { getMovePosition } from "../utils/gameManagerUtils";
import { MoveEffectPhase } from "#app/phases.js";
describe("Internals", () => {
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
.disableCrits()
.enemySpecies(Species.UMBREON)
.enemyMoveset(SPLASH_ONLY)
.enemyLevel(5)
.starterSpecies(Species.MAGIKARP)
.moveset([Moves.MIRACLE_EYE, Moves.CONFUSION]);
});
it("should allow Psychic moves to hit Dark types", async () => {
await game.startBattle();
const enemy = game.scene.getEnemyPokemon();
game.doAttack(getMovePosition(game.scene, 0, Moves.CONFUSION));
await game.toNextTurn();
expect(enemy.hp).toBe(enemy.getMaxHp());
game.doAttack(getMovePosition(game.scene, 0, Moves.MIRACLE_EYE));
await game.toNextTurn();
game.doAttack(getMovePosition(game.scene, 0, Moves.CONFUSION));
await game.phaseInterceptor.to(MoveEffectPhase);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
});
});