diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index ddb85600c18..957370f357b 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -327,6 +327,49 @@ export class ShellTrapTag extends BattlerTag { } } +/** + * BattlerTag implementing Rage + * Pokémon with this tag will recieve an attack boost when successfully damaged by an attacking move + * This tag will be lost if a target reaches the MOVE_EFFECT lapse condition with a move other than Rage + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Rage_(move) | Rage} + */ +export class RageTag extends BattlerTag { + constructor() { + super(BattlerTagType.RAGE, [BattlerTagLapseType.MOVE_EFFECT], 1, Moves.RAGE); + } + + /** + * Displays a message to show that the user has started Raging. + * This is message isn't displayed on cartridge, and was included for clarity during gameplay and while testing. + * @param pokemon {@linkcode Pokemon} the Pokémon this tag is being added to. + */ + onAdd(pokemon: Pokemon) { + super.onAdd(pokemon); + /* This message might not exist on cartridge */ + pokemon.scene.queueMessage(i18next.t("battlerTags:rageOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon)})); + } + + /** + * Checks to maintain a Pokémon should maintain their rage, and provides an attack boost when hit. + * @param pokemon {@linkcode Pokemon} The owner of this tag + * @param lapseType {@linkcode BattlerTagLapseType} the type of functionality invoked in battle + * @returns `true` if invoked with the MOVE_EFFECT lapse type and {@linkcode pokemon} most recently used Rage, + * or if invoked with the CUSTOM lapse type. false otherwise. + */ + lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { + if (lapseType === BattlerTagLapseType.MOVE_EFFECT) { + return (pokemon.scene.getCurrentPhase() as MovePhase).move.getMove().id === Moves.RAGE; + } else if (lapseType === BattlerTagLapseType.CUSTOM) { + pokemon.scene.unshiftPhase(new StatStageChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [Stat.ATK], 1, false)); + pokemon.scene.queueMessage(i18next.t("battlerTags:rageOnHit", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon)})); + return true; + } + return false; + } +} + export class TrappedTag extends BattlerTag { constructor(tagType: BattlerTagType, lapseType: BattlerTagLapseType, turnCount: number, sourceMove: Moves, sourceId: number) { super(tagType, lapseType, turnCount, sourceMove, sourceId, true); @@ -2125,6 +2168,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source case BattlerTagType.GULP_MISSILE_ARROKUDA: case BattlerTagType.GULP_MISSILE_PIKACHU: return new GulpMissileTag(tagType, sourceMove); + case BattlerTagType.RAGE: + return new RageTag(); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/move.ts b/src/data/move.ts index 19014c0eb30..b3b2774ed9f 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -6744,7 +6744,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPD ], 2, true), new AttackMove(Moves.QUICK_ATTACK, Type.NORMAL, MoveCategory.PHYSICAL, 40, 100, 30, -1, 1, 1), new AttackMove(Moves.RAGE, Type.NORMAL, MoveCategory.PHYSICAL, 20, 100, 20, -1, 0, 1) - .partial(), + .attr(AddBattlerTagAttr, BattlerTagType.RAGE, true, false, 0, 0, false, true), new SelfStatusMove(Moves.TELEPORT, Type.PSYCHIC, -1, 20, -1, -6, 1) .attr(ForceSwitchOutAttr, true) .hidesUser(), diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index a2bcf9e4c0e..6bda0971bfb 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -71,6 +71,7 @@ export enum BattlerTagType { GULP_MISSILE_PIKACHU = "GULP_MISSILE_PIKACHU", BEAK_BLAST_CHARGING = "BEAK_BLAST_CHARGING", SHELL_TRAP = "SHELL_TRAP", + RAGE = "RAGE", DRAGON_CHEER = "DRAGON_CHEER", - NO_RETREAT = "NO_RETREAT", + NO_RETREAT = "NO_RETREAT" } diff --git a/src/locales/en/battler-tags.json b/src/locales/en/battler-tags.json index 222aee4087c..4f4322f9d57 100644 --- a/src/locales/en/battler-tags.json +++ b/src/locales/en/battler-tags.json @@ -68,6 +68,8 @@ "cursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "cursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!", "stockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", + "rageOnAdd": "{{pokemonNameWithAffix}}'s rage is starting to build.", + "rageOnHit": "{{pokemonNameWithAffix}}'s rage is building.", "disabledOnAdd": "{{pokemonNameWithAffix}}'s {{moveName}}\nwas disabled!", "disabledLapse": "{{pokemonNameWithAffix}}'s {{moveName}}\nis no longer disabled." } diff --git a/src/locales/es/battler-tags.json b/src/locales/es/battler-tags.json index d917b6c74b5..4171c961600 100644 --- a/src/locales/es/battler-tags.json +++ b/src/locales/es/battler-tags.json @@ -67,5 +67,8 @@ "saltCuredLapse": "¡{{moveName}} ha herido a {{pokemonNameWithAffix}}!", "cursedOnAdd": "¡{{pokemonNameWithAffix}} sacrifica algunos PS y maldice a {{pokemonName}}!", "cursedLapse": "¡{{pokemonNameWithAffix}} es víctima de una maldición!", - "stockpilingOnAdd": "¡{{pokemonNameWithAffix}} ha reservado energía por {{stockpiledCount}}ª vez!" + "stockpilingOnAdd": "¡{{pokemonNameWithAffix}} ha reservado energía por {{stockpiledCount}}ª vez!", + "rageOnAdd": "¡La furia de {{pokemonNameWithAffix}} comienza a crecer!", + "rageOnHit": "¡La furia de {{pokemonNameWithAffix}} está aumentando!" } + diff --git a/src/locales/fr/battler-tags.json b/src/locales/fr/battler-tags.json index 46b086938b3..33f05f3153b 100644 --- a/src/locales/fr/battler-tags.json +++ b/src/locales/fr/battler-tags.json @@ -67,5 +67,7 @@ "saltCuredLapse": "{{pokemonNameWithAffix}} est blessé\npar la capacité {{moveName}} !", "cursedOnAdd": "{{pokemonNameWithAffix}} sacrifie des PV\net lance une malédiction sur {{pokemonName}} !", "cursedLapse": "{{pokemonNameWithAffix}} est touché par la malédiction !", - "stockpilingOnAdd": "{{pokemonNameWithAffix}} utilise\nla capacité Stockage {{stockpiledCount}} fois !" + "stockpilingOnAdd": "{{pokemonNameWithAffix}} utilise\nla capacité Stockage {{stockpiledCount}} fois !", + "rageOnAdd": "La Frénésie s’empare\nde {{pokemonNameWithAffix}} !", + "rageOnHit": "La Frénésie de {{pokemonNameWithAffix}}\naugmente !" } \ No newline at end of file diff --git a/src/locales/it/battler-tags.json b/src/locales/it/battler-tags.json index a0f852141f9..5144b8396c3 100644 --- a/src/locales/it/battler-tags.json +++ b/src/locales/it/battler-tags.json @@ -67,5 +67,7 @@ "saltCuredLapse": "{{pokemonNameWithAffix}} viene colpito da {{moveName}}!", "cursedOnAdd": "{{pokemonNameWithAffix}} ha sacrificato metà dei suoi PS per\nlanciare una maledizione su {{pokemonName}}!", "cursedLapse": "{{pokemonNameWithAffix}} subisce la maledizione!", - "stockpilingOnAdd": "{{pokemonNameWithAffix}} ha usato Accumulo per la\n{{stockpiledCount}}ª volta!" -} \ No newline at end of file + "stockpilingOnAdd": "{{pokemonNameWithAffix}} ha usato Accumulo per la\n{{stockpiledCount}}ª volta!", + "rageOnAdd": "{{pokemonNameWithAffix}} comincia ad accumulare ira!", + "rageOnHit": "L’ira di {{pokemonNameWithAffix}} aumenta!" +} diff --git a/src/locales/ko/battler-tags.json b/src/locales/ko/battler-tags.json index 47ddb26c183..352efb1b6b5 100644 --- a/src/locales/ko/battler-tags.json +++ b/src/locales/ko/battler-tags.json @@ -67,5 +67,7 @@ "saltCuredLapse": "{{pokemonNameWithAffix}}[[는]] 소금절이의\n데미지를 입고 있다.", "cursedOnAdd": "{{pokemonNameWithAffix}}[[는]] 자신의 체력을 깎아서\n{{pokemonName}}에게 저주를 걸었다!", "cursedLapse": "{{pokemonNameWithAffix}}[[는]]\n저주받고 있다!", - "stockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!" -} \ No newline at end of file + "stockpilingOnAdd": "{{pokemonNameWithAffix}}[[는]]\n{{stockpiledCount}}개 비축했다!", + "rageOnAdd": "{{pokemonNameWithAffix}}[[는]]\n분노 볼티지를 쌓기 시작했다.", + "rageOnHit": "{{pokemonNameWithAffix}}의\n분노 볼티지가 올라가고 있다!" +} diff --git a/src/locales/pt_BR/battler-tags.json b/src/locales/pt_BR/battler-tags.json index 560da13cc6f..8b2eaa52fb3 100644 --- a/src/locales/pt_BR/battler-tags.json +++ b/src/locales/pt_BR/battler-tags.json @@ -67,5 +67,7 @@ "saltCuredLapse": "{{pokemonNameWithAffix}} foi ferido pelo {{moveName}}!", "cursedOnAdd": "{{pokemonNameWithAffix}} cortou seus PS pela metade e amaldiçoou {{pokemonName}}!", "cursedLapse": "{{pokemonNameWithAffix}} foi ferido pelo Curse!", - "stockpilingOnAdd": "{{pokemonNameWithAffix}} estocou {{stockpiledCount}}!" -} \ No newline at end of file + "stockpilingOnAdd": "{{pokemonNameWithAffix}} estocou {{stockpiledCount}}!", + "rageOnAdd": "A raiva de {{pokemonNameWithAffix}} está começando a aumentar.", + "rageOnHit": "A raiva de {{pokemonNameWithAffix}} está aumentando." +} diff --git a/src/locales/zh_CN/battler-tags.json b/src/locales/zh_CN/battler-tags.json index 81838b5023a..d2f5403efc1 100644 --- a/src/locales/zh_CN/battler-tags.json +++ b/src/locales/zh_CN/battler-tags.json @@ -67,5 +67,7 @@ "saltCuredLapse": "{{pokemonNameWithAffix}}\n受到了{{moveName}}的伤害!", "cursedOnAdd": "{{pokemonNameWithAffix}}削减了自己的体力,\n并诅咒了{{pokemonName}}!", "cursedLapse": "{{pokemonNameWithAffix}}\n正受到诅咒!", - "stockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!" -} \ No newline at end of file + "stockpilingOnAdd": "{{pokemonNameWithAffix}}蓄力了{{stockpiledCount}}次!", + "rageOnAdd": "{{pokemonNameWithAffix}}的\n怒气开始上升了。", + "rageOnHit": "{{pokemonNameWithAffix}}的\n怒气正在上升!." +} diff --git a/src/locales/zh_TW/battler-tags.json b/src/locales/zh_TW/battler-tags.json index 9a35bb0d03f..623e07b25d3 100644 --- a/src/locales/zh_TW/battler-tags.json +++ b/src/locales/zh_TW/battler-tags.json @@ -66,5 +66,7 @@ "saltCuredOnAdd": "{{pokemonNameWithAffix}} 陷入了鹽腌狀態!", "saltCuredLapse": "{{pokemonNameWithAffix}} 受到了{{moveName}}的傷害!", "cursedOnAdd": "{{pokemonNameWithAffix}}削減了自己的體力,並詛咒了{{pokemonName}}!", - "cursedLapse": "{{pokemonNameWithAffix}}正受到詛咒!" -} \ No newline at end of file + "cursedLapse": "{{pokemonNameWithAffix}}正受到詛咒!", + "rageOnAdd": "{{pokemonNameWithAffix}}的\n怒氣開始上升了。", + "rageOnHit": "{{pokemonNameWithAffix}}的\n怒氣正在上升!" +} diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index fb2b82ada03..6db7b739287 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -262,6 +262,9 @@ export class MoveEffectPhase extends PokemonPhase { if (move.category === MoveCategory.PHYSICAL && user.isPlayer() !== target.isPlayer()) { target.lapseTag(BattlerTagType.SHELL_TRAP); } + if (hitResult < HitResult.NO_EFFECT && move.category !== MoveCategory.STATUS) { + target.lapseTag(BattlerTagType.RAGE); + } if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); } diff --git a/src/test/moves/rage.test.ts b/src/test/moves/rage.test.ts new file mode 100644 index 00000000000..d01618405b5 --- /dev/null +++ b/src/test/moves/rage.test.ts @@ -0,0 +1,259 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import GameManager from "#test/utils/gameManager"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import {StatusEffect} from "#enums/status-effect"; +import {RageTag} from "#app/data/battler-tags"; +import {PlayerPokemon} from "#app/field/pokemon"; +import {Nature} from "#enums/nature"; +import {CommandPhase} from "#app/phases/command-phase"; +import {BattlerIndex} from "#app/battle"; +import {TurnEndPhase} from "#app/phases/turn-end-phase"; +import {Stat} from "#enums/stat"; + + +const TIMEOUT = 20 * 1000; + +function fullOf(move: Moves) : Moves[] { + return [move, move, move, move]; +} +describe("Moves - Rage", () => { + 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("single") + .ability(Abilities.UNNERVE) + .starterSpecies(Species.BOLTUND) + .moveset([Moves.RAGE, Moves.SPLASH, Moves.SPORE, Moves.VITAL_THROW]) + .startingLevel(100) + .enemyLevel(100) + .disableCrits(); + }); + + /** + * Ally Attack-Boost Test. + * + * Checks that Rage provides an attack boost if the user it hit after use. + * + * Checks that Rage provides an attack boost if the user is hit before moving + * on the following turn, regardless of what move they selected. + * + * Checks that a pokemon stops raging if they use a different move. + */ + it( + "should raise attack if hit after use", + async () => { + game.override + .enemySpecies(Species.SHUCKLE) + .enemyMoveset(fullOf(Moves.TACKLE)); + await game.classicMode.startBattle(); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + // Player Boltund uses Rage. Opponent Shuckle uses Tackle. + // Boltund's attack is raised. + game.move.select(Moves.RAGE); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(1); + + // Opponent Shuckle uses Tackle. Player Boltund uses Vital Throw (Negative Priority). + // Boltund's attack is raised. + game.move.select(Moves.VITAL_THROW); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2); + + // Opponent Shuckle uses Tackle. Player Boltund uses Vital Throw (Negative Priority). + // Boltund's attack not raised. + game.move.select(Moves.VITAL_THROW); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2); + + }, TIMEOUT + ); + + /** + * Checks that Opponent Pokemon correctly receive the Attack boost from Rage + * Checks that using a Status Move on a Pokemon that used Rage does NOT provide an Attack Boost + * Checks that Pokemon do not lose the {@linkcode RageTag} BattlerTag when sleeping. + */ + it( + "should not raise ATK if hit by status move", + async () => { + game.override + .enemySpecies(Species.SHUCKLE) + .enemyMoveset(fullOf(Moves.RAGE)); + await game.classicMode.startBattle(); + + const leadPokemon = game.scene.getPlayerPokemon()!; + const oppPokemon = game.scene.getEnemyPokemon()!; + + // Opponent Shuckle uses Rage. Ally Boltund uses Vital Throw. + // Shuckle gets an Attack boost + game.move.select(Moves.VITAL_THROW); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(oppPokemon.getStatStage(Stat.ATK)).toBe(1); + + // Ally Boltund uses Spore. Shuckle is asleep. + // Shuckle does not get an attack boost. Shuckle still has the RageTag tag. + game.move.select(Moves.SPORE); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(oppPokemon.getStatStage(Stat.ATK)).toBe(1); + expect(oppPokemon.getTag(RageTag)).toBeTruthy; + }, TIMEOUT + ); + + /** + * Checks that the {@linkcode RageTag} tag is not given if the target is immune + */ + it( + "should not raise ATK if target is immune", + async () => { + game.override + .enemySpecies(Species.GASTLY) + .enemyMoveset(fullOf(Moves.TACKLE)); // Has semi-invulnerable turn + await game.classicMode.startBattle(); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + // Boltund uses rage, but it has no effect, Gastly uses Tackle + // Boltund does not have RageTag or Attack boost. + game.move.select(Moves.RAGE); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(leadPokemon.getTag(RageTag)).toBeNull; + }, TIMEOUT + ); + + /** + * Checks that the {@linkcode RageTag} tag is not given if the target is semi-invulnerable + * Checks that Pokémon does not get Attack boost if it uses Rage after getting hit on a turn + */ + it( + "should not raise ATK if target is semi-invulnerable", + async () => { + game.override + .enemySpecies(Species.REGIELEKI) + .enemyMoveset(fullOf(Moves.PHANTOM_FORCE)); // Has semi-invulnerable turn + await game.classicMode.startBattle(); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + // Regieliki uses Fly. Boltund uses Rage, but Regieleki is invulnerable + // Boltund does not gain RageTag or Attack boost + game.move.select(Moves.RAGE); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(leadPokemon.getTag(RageTag)).toBeNull; + + // Regieleki finishes Fly, Boltund uses Rage + // Boltund gains RageTag, but no boost + game.move.select(Moves.RAGE); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(leadPokemon.getTag(RageTag)).toBeTruthy; + }, TIMEOUT + ); + + it( + "should not stop raging if rage fails", + async () => { + game.override + .enemySpecies(Species.SHUCKLE) + .enemyMoveset(fullOf(Moves.PHANTOM_FORCE)); // Has semi-invulnerable turn + await game.classicMode.startBattle(); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + // Boltund uses Rage, Shuckle uses Fly + // Boltund gains RageTag + game.move.select(Moves.RAGE); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(leadPokemon.getTag(RageTag)).toBeTruthy; + + // Boltund uses Rage, Shuckle is underwater, Shuckle finishes Dive + // Boltund gains gains boost, does not lose RageTag + game.move.select(Moves.RAGE); + await game.toNextTurn(); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(1); + expect(leadPokemon.getTag(RageTag)).toBeTruthy; + }, TIMEOUT + ); + + /** + * Basic doubles test + * Checks that if a raging Pokemon is hit multiple times in one turn, they get multiple boosts + * Should also cover multi-hit moves + */ + it( + "should provide boost per hit in doubles", + async () => { + game.override + .moveset([Moves.RAGE, Moves.MEMENTO, Moves.SPORE, Moves.VITAL_THROW]) + .battleType("double") + .enemySpecies(Species.SHUCKLE) + .enemyMoveset(fullOf(Moves.TACKLE)); + await game.classicMode.startBattle([Species.BOLTUND, Species.BOLTUND]); + + const leadPokemon = game.scene.getParty()[0]; + + game.move.select(Moves.RAGE, 1, BattlerIndex.ENEMY); + await game.phaseInterceptor.to(CommandPhase); + + game.move.select(Moves.MEMENTO, 1, BattlerIndex.ENEMY_2); + + + await game.phaseInterceptor.to(TurnEndPhase, false); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2); + expect(leadPokemon.getTag(RageTag)).toBeTruthy; + }, TIMEOUT + ); + + /** + * Checks that a pokemon does not lose the RageTag if it is unable to act + * regardless of what it was otherwise going to do + */ + it( + "should stay raging if unable to act", + async () => { + game.override + .moveset([Moves.RAGE, Moves.SPLASH, Moves.SPORE, Moves.VITAL_THROW]) + .battleType("double") + .enemySpecies(Species.SHUCKLE) + .enemyMoveset(fullOf(Moves.SPLASH)); // Has semi-invulnerable turn + await game.classicMode.startBattle(); + + const leadPokemon: PlayerPokemon = game.scene.getParty()[0]; + // Ensure that second pokemon is faster. + leadPokemon.natureOverride = Nature.SASSY; + game.scene.getParty()[1].natureOverride = Nature.JOLLY; + + game.move.select(Moves.RAGE, 1, BattlerIndex.ENEMY); + await game.phaseInterceptor.to(CommandPhase); + + game.move.select(Moves.SPORE, 1, BattlerIndex.PLAYER); + + await game.phaseInterceptor.to(TurnEndPhase, false); + expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(leadPokemon.getTag(RageTag)).toBeTruthy; + expect(leadPokemon.status?.effect).toBe(StatusEffect.SLEEP); + }, TIMEOUT + ); +});