Compare commits

...

9 Commits

Author SHA1 Message Date
Bertie690
af377937bf
Merge a29161c2ed into f42237d415 2025-08-14 13:28:35 -04:00
Bertie690
f42237d415
[Refactor] Removed map(x => x) (#6256)
* Enforced a few usages of `toCamelCase`

* Removed `map(x => x)`

* Removed more maps and sufff

* Update test/mystery-encounter/encounters/weird-dream-encounter.test.ts

* Update game-data.ts types to work
2025-08-14 10:25:44 -07:00
fabske0
b44f0a4176
[Refactor] Remove bgm param from arena constructor (#6254) 2025-08-14 16:52:56 +00:00
Sirz Benjie
076ef81691
[Bug] [UI/UX] [Beta] Fix icons not showing in save slot selection (#6262)
Fix icons not showing in save slot selection
2025-08-13 20:49:46 -05:00
fabske0
23271901cf
[Docs] Add locale key naming info to localization.md (#6260) 2025-08-14 01:12:00 +00:00
Inês Simões
1517e0512e
[UI/UX] [Feature] Save Management Tool (Rename/Delete Saves) (#5978)
* Implement Name Run Feat
Modified load session ui component, adding a submenu when selecting a 3
slot. This menu has 4 options:
Load Game -> Behaves as before, allowing the player to continue
progress from the last saved state in the slot.

Rename Run -> Overlays a rename form, allowing the player to type a
name for the run, checking for string validity, with the option to
cancel or confirm (Rename).

Delete Run -> Prompts user confirmation to delete save data, removing
the current save slot from the users save data.

Cancel -> Hides menu overlay.

Modified game data to implement a function to accept and store
runNameText to the users data.

Modified run info ui component, to display the chosen name when
viewing run information.

Example: When loading the game, the user can choose the Load Game
menu option, then select a save slot, prompting the menu, then choose
"Rename Run" and type the name "Monotype Water Run" then confirm,
thus being able to better organize their save files.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Implement Rename Input Design and Tests for Name Run Feat
Created a test to verify Name Run Feature behaviour in the
backend (rename_run.test.ts), checking possible errors and
 expected behaviours.

Created a UiHandler RenameRunFormUiHandler
(rename-run-ui-handler.ts), creating a frontend input
overlay for the Name Run Feature.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Fixed formating and best practices issues:
Rewrote renameSession to be more inline with other
API call funtions, removed debugging comments and
whitespaces.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Minor Sanitization for aesthetics
Deleting the input when closing the overlay for
aesthetics purpose

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Fixed minor rebase alterations.

Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Inês Simões ines.p.simoes@tecnico.ulisboa.pt

* Implemented Default Name Logic
Altered logic in save-slot-select-ui-handler.ts to
support default naming of runs based on the run
game mode with decideFallback function.

In game-data.ts, to prevent inconsistent naming,
added check for unfilled input, ignoring empty
rename requests.

Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Inês Simões ines.p.simoes@tecnico.ulisboa.pt

* Replace fallback name logic: use first active challenge instead
of game mode

Previously used game mode as the fallback name, updated to use the
first active challenge instead (e.g. Monogen or Mono Type), which
better reflects the run's theme.
Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Rebasing and conflict resolution

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Lint fix

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Minor compile fix

* Dependency resolved

* Format name respected

* Add all active challenges to default challenge session name if possible

If more than 3 challenges are active, only the first 3 are added
to the name (to prevent the text going off-screen)
and then "..." is appended to the end to indicate
there were more challenges active than the ones listed

* Allow deleting malformed sessions

---------

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Wlowscha <54003515+Wlowscha@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-08-13 20:08:12 -05:00
Bertie690
a29161c2ed Removed bogus PB test 2025-08-05 22:12:35 -04:00
Bertie690
314f46a22b Fixed tests and such 2025-08-05 18:10:34 -04:00
Bertie690
4885a6abc5 Added toBeConfused matcher 2025-08-05 17:50:02 -04:00
40 changed files with 1453 additions and 772 deletions

View File

@ -90,9 +90,13 @@ If this feature requires new text, the text should be integrated into the code w
- For any feature pulled from the mainline Pokémon games (e.g. a Move or Ability implementation), it's best practice to include a source link for any added text.
[Poké Corpus](https://abcboy101.github.io/poke-corpus/) is a great resource for finding text from the mainline games; otherwise, a video/picture showing the text being displayed should suffice.
- You should also [notify the current Head of Translation](#notifying-translation) to ensure a fast response.
3. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes).
4. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`.
5. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment.
3. Your locales should use the following format:
- File names should be in `kebab-case`. Example: `trainer-names.json`
- Key names should be in `camelCase`. Example: `aceTrainer`
- If you make use of i18next's inbuilt [context support](https://www.i18next.com/translation-function/context), you need to use `snake_case` for the context key. Example: `aceTrainer_male`
4. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes).
5. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`.
6. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment.
[^2]: For those wondering, the reason for choosing English specifically is due to it being the master language set in Pontoon (the program used by the Translation Team to perform locale updates).
If a key is present in any language _except_ the master language, it won't appear anywhere else in the translation tool, rendering missing English keys quite a hassle.

View File

@ -104,6 +104,7 @@ import {
getLuckString,
getLuckTextTint,
getPartyLuckValue,
type ModifierType,
PokemonHeldItemModifierType,
} from "#modifiers/modifier-type";
import { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
@ -1203,7 +1204,9 @@ export class BattleScene extends SceneBase {
this.updateScoreText();
this.scoreText.setVisible(false);
[this.luckLabelText, this.luckText].map(t => t.setVisible(false));
[this.luckLabelText, this.luckText].forEach(t => {
t.setVisible(false);
});
this.newArena(Overrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN);
@ -1237,8 +1240,7 @@ export class BattleScene extends SceneBase {
Object.values(mp)
.flat()
.map(mt => mt.modifierType)
.filter(mt => "localize" in mt)
.map(lpb => lpb as unknown as Localizable),
.filter((mt): mt is ModifierType & Localizable => "localize" in mt && typeof mt.localize === "function"),
),
];
for (const item of localizable) {
@ -1513,8 +1515,8 @@ export class BattleScene extends SceneBase {
return this.currentBattle;
}
newArena(biome: BiomeId, playerFaints?: number): Arena {
this.arena = new Arena(biome, BiomeId[biome].toLowerCase(), playerFaints);
newArena(biome: BiomeId, playerFaints = 0): Arena {
this.arena = new Arena(biome, playerFaints);
this.eventTarget.dispatchEvent(new NewArenaEvent());
this.arenaBg.pipelineData = {
@ -2711,7 +2713,9 @@ export class BattleScene extends SceneBase {
}
}
this.party.map(p => p.updateInfo(instant));
this.party.forEach(p => {
p.updateInfo(instant);
});
} else {
const args = [this];
if (modifier.shouldApply(...args)) {

View File

@ -74,6 +74,7 @@ import {
randSeedItem,
toDmgValue,
} from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
export class Ability implements Localizable {
@ -109,13 +110,9 @@ export class Ability implements Localizable {
}
localize(): void {
const i18nKey = AbilityId[this.id]
.split("_")
.filter(f => f)
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("") as string;
const i18nKey = toCamelCase(AbilityId[this.id]);
this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`) as string}${this.nameAppend}` : "";
this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`)}${this.nameAppend}` : "";
this.description = this.id ? (i18next.t(`ability:${i18nKey}.description`) as string) : "";
}

View File

@ -1152,13 +1152,16 @@ export class GravityTag extends SerializableArenaTag {
onAdd(_arena: Arena): void {
globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnAdd"));
// Remove all flying-related effects from all on-field Pokemon.
globalScene.getField(true).forEach(pokemon => {
if (pokemon !== null) {
pokemon.removeTag(BattlerTagType.FLOATING);
pokemon.removeTag(BattlerTagType.TELEKINESIS);
if (pokemon.getTag(BattlerTagType.FLYING)) {
pokemon.addTag(BattlerTagType.INTERRUPTED);
}
pokemon.removeTag(BattlerTagType.FLOATING);
pokemon.removeTag(BattlerTagType.TELEKINESIS);
if (pokemon.getTag(BattlerTagType.FLYING)) {
pokemon.removeTag(BattlerTagType.FLYING);
// TODO: This is an extremely poor way of handling move interruption
pokemon.addTag(BattlerTagType.INTERRUPTED);
}
});
}

View File

@ -1866,17 +1866,16 @@ interface PokemonPrevolutions {
export const pokemonPrevolutions: PokemonPrevolutions = {};
export function initPokemonPrevolutions(): void {
const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ].map(sfk => sfk as string);
const prevolutionKeys = Object.keys(pokemonEvolutions);
prevolutionKeys.forEach(pk => {
const evolutions = pokemonEvolutions[pk];
// TODO: Why do we have empty strings in our array?
const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ];
for (const [pk, evolutions] of Object.entries(pokemonEvolutions)) {
for (const ev of evolutions) {
if (ev.evoFormKey && megaFormKeys.indexOf(ev.evoFormKey) > -1) {
continue;
}
pokemonPrevolutions[ev.speciesId] = Number.parseInt(pk) as SpeciesId;
}
});
}
}

View File

@ -736,6 +736,10 @@ export class FlinchedTag extends BattlerTag {
}
}
/**
* Tag to cancel the target's action when knocked out of a flying move by Smack Down or Gravity.
*/
// TODO: This is not a very good way to cancel a semi invulnerable turn
export class InterruptedTag extends BattlerTag {
public override readonly tagType = BattlerTagType.INTERRUPTED;
constructor(sourceMove: MoveId) {
@ -765,7 +769,7 @@ export class InterruptedTag extends BattlerTag {
}
/**
* BattlerTag that represents the {@link https://bulbapedia.bulbagarden.net/wiki/Confusion_(status_condition) Confusion} status condition
* BattlerTag that represents the {@link https://bulbapedia.bulbagarden.net/wiki/Confusion_(status_condition) | Confusion} status condition
*/
export class ConfusedTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.CONFUSED;
@ -774,8 +778,9 @@ export class ConfusedTag extends SerializableBattlerTag {
}
canAdd(pokemon: Pokemon): boolean {
const blockedByTerrain = pokemon.isGrounded() && globalScene.arena.terrain?.terrainType === TerrainType.MISTY;
const blockedByTerrain = pokemon.isGrounded() && globalScene.arena.getTerrainType() === TerrainType.MISTY;
if (blockedByTerrain) {
// TODO: this should not trigger if the current move is an attacking move
pokemon.queueStatusImmuneMessage(false, TerrainType.MISTY);
return false;
}

View File

@ -90,7 +90,7 @@ import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindS
import type { TurnMove } from "#types/turn-move";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import { toCamelCase, toTitleCase } from "#utils/strings";
import i18next from "i18next";
import { applyChallenges } from "#utils/challenge-utils";
@ -162,10 +162,16 @@ export abstract class Move implements Localizable {
}
localize(): void {
const i18nKey = MoveId[this.id].split("_").filter(f => f).map((f, i) => i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()).join("") as unknown as string;
const i18nKey = toCamelCase(MoveId[this.id])
this.name = this.id ? `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}` : "";
this.effect = this.id ? `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}` : "";
if (this.id === MoveId.NONE) {
this.name = "";
this.effect = ""
return;
}
this.name = `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}`;
this.effect = `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}`;
}
/**
@ -1530,6 +1536,7 @@ export class CritOnlyAttr extends MoveAttr {
}
}
// TODO: Fix subclasses to actually extend from `getDamage`
export class FixedDamageAttr extends MoveAttr {
private damage: number;
@ -5366,13 +5373,11 @@ export class VariableMoveTypeMultiplierAttr extends MoveAttr {
}
export class NeutralDamageAgainstFlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!target.getTag(BattlerTagType.IGNORE_FLYING)) {
const multiplier = args[0] as NumberHolder;
//When a flying type is hit, the first hit is always 1x multiplier.
if (target.isOfType(PokemonType.FLYING)) {
multiplier.value = 1;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean {
if (!target.isGrounded(true) && target.isOfType(PokemonType.FLYING)) {
const multiplier = args[0];
// When a flying type is hit, the first hit is always 1x multiplier.
multiplier.value = 1;
return true;
}
@ -5611,13 +5616,13 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
protected cancelOnFail: boolean;
private failOnOverlap: boolean;
constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: number = 0, turnCountMax?: number, lastHitOnly: boolean = false) {
constructor(tagType: BattlerTagType, selfTarget = false, failOnOverlap = false, turnCountMin: number = 0, turnCountMax = turnCountMin, lastHitOnly = false) {
super(selfTarget, { lastHitOnly: lastHitOnly });
this.tagType = tagType;
this.turnCountMin = turnCountMin;
this.turnCountMax = turnCountMax !== undefined ? turnCountMax : turnCountMin;
this.failOnOverlap = !!failOnOverlap;
this.turnCountMax = turnCountMax;
this.failOnOverlap = failOnOverlap;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
@ -5627,13 +5632,14 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
if (moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance) {
return (this.selfTarget ? user : target).addTag(this.tagType, user.randBattleSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id);
return (this.selfTarget ? user : target).addTag(this.tagType, user.randBattleSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id);
}
return false;
}
getCondition(): MoveConditionFunc | null {
// TODO: This should consider whether the tag can be added
return this.failOnOverlap
? (user, target, move) => !(this.selfTarget ? user : target).getTag(this.tagType)
: null;
@ -5711,8 +5717,10 @@ export class LeechSeedAttr extends AddBattlerTagAttr {
}
/**
* Adds the appropriate battler tag for Smack Down and Thousand arrows
* @extends AddBattlerTagAttr
* Attribute to add the {@linkcode BattlerTagType.IGNORE_FLYING | IGNORE_FLYING} battler tag to the target
* and remove any prior sources of ungroundedness.
*
* Does nothing if the target was not already ungrounded.
*/
export class FallDownAttr extends AddBattlerTagAttr {
constructor() {
@ -5720,18 +5728,35 @@ export class FallDownAttr extends AddBattlerTagAttr {
}
/**
* Adds Grounded Tag to the target and checks if fallDown message should be displayed
* @param user the {@linkcode Pokemon} using the move
* @param target the {@linkcode Pokemon} targeted by the move
* @param move the {@linkcode Move} invoking this effect
* Add `GroundedTag` to the target, remove all prior sources of ungroundedness
* and display a message.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
* @param move - The {@linkcode Move} invoking this effect
* @param args n/a
* @returns `true` if the effect successfully applies; `false` otherwise
* @returns Whether the target was successfully brought down to earth.
*
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!target.isGrounded()) {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) }));
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
// Smack down and similar only add their tag if the target is already ungrounded,
// barring any prior semi-invulnerability.
if (target.isGrounded(true)) {
return false;
}
return super.apply(user, target, move, args);
// Remove the target's prior sources of ungroundedness.
// NB: These effects cannot simply be part of the tag's `onAdd` effect as Ingrain also adds the tag
// but does not remove Telekinesis' accuracy boost
target.removeTag(BattlerTagType.FLOATING);
target.removeTag(BattlerTagType.TELEKINESIS);
if (target.getTag(BattlerTagType.FLYING)) {
target.removeTag(BattlerTagType.FLYING);
// TODO: This is an extremely poor way of handling move interruption
target.addTag(BattlerTagType.INTERRUPTED);
}
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) }));
return super.apply(user, target, move, _args);
}
}
@ -5835,6 +5860,7 @@ export class CurseAttr extends MoveEffectAttr {
}
}
// TODO: Delete this and make mortal spin use `RemoveBattlerTagAttr`
export class LapseBattlerTagAttr extends MoveEffectAttr {
public tagTypes: BattlerTagType[];
@ -8034,7 +8060,12 @@ const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean
return phase.isForcedLast() && slower;
};
const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY);
// #region Condition functions
// TODO: This needs to become unselectable, not merely fail
const failOnGravityCondition: MoveConditionFunc = () => !globalScene.arena.getTag(ArenaTagType.GRAVITY);
const failOnGroundedCondition: MoveConditionFunc = (_user, target) => !target.getTag(BattlerTagType.IGNORE_FLYING);
const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune();
@ -8065,6 +8096,10 @@ const failIfGhostTypeCondition: MoveConditionFunc = (user: Pokemon, target: Poke
const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0;
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(PokemonType.UNKNOWN);
// #endregion Condition functions
const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => {
const heldItems = target.getHeldItems().filter(i => i.isTransferable);
if (heldItems.length === 0) {
@ -8246,9 +8281,6 @@ export class ExposedMoveAttr extends AddBattlerTagAttr {
}
}
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(PokemonType.UNKNOWN);
export type MoveTargetSet = {
targets: BattlerIndex[];
multiple: boolean;
@ -8895,7 +8927,6 @@ export function initMoves() {
.ignoresProtect()
/* Transform:
* Does not copy the target's rage fist hit count
* Does not copy the target's volatile status conditions (ie BattlerTags)
* Renders user typeless when copying typeless opponent (should revert to original typing)
*/
.edgeCase(),
@ -9350,6 +9381,7 @@ export function initMoves() {
.attr(RandomMovesetMoveAttr, invalidAssistMoves, true),
new SelfStatusMove(MoveId.INGRAIN, PokemonType.GRASS, -1, 20, -1, 0, 3)
.attr(AddBattlerTagAttr, BattlerTagType.INGRAIN, true, true)
// NB: We add IGNORE_FLYING and remove floating tag directly to avoid removing Telekinesis' accuracy boost
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, true, true)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLOATING ], true),
new AttackMove(MoveId.SUPERPOWER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 3)
@ -9607,9 +9639,9 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.ROOSTED, true, false)
.triageMove(),
new StatusMove(MoveId.GRAVITY, PokemonType.PSYCHIC, -1, 5, -1, 0, 4)
.ignoresProtect()
.attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5)
.target(MoveTarget.BOTH_SIDES),
.target(MoveTarget.BOTH_SIDES)
.ignoresProtect(),
new StatusMove(MoveId.MIRACLE_EYE, PokemonType.PSYCHIC, -1, 40, -1, 0, 4)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK)
.ignoresSubstitute()
@ -9739,7 +9771,8 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true),
new SelfStatusMove(MoveId.MAGNET_RISE, PokemonType.ELECTRIC, -1, 10, -1, 0, 4)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, true, true, 5)
.condition((user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY) && [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag))),
.condition(failOnGravityCondition)
.condition(failOnGroundedCondition),
new AttackMove(MoveId.FLARE_BLITZ, PokemonType.FIRE, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 4)
.attr(RecoilAttr, false, 0.33)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)
@ -9968,12 +10001,12 @@ export function initMoves() {
.powderMove()
.attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true),
new StatusMove(MoveId.TELEKINESIS, PokemonType.PSYCHIC, -1, 15, -1, 0, 5)
.condition(failOnGravityCondition)
.condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId))
.condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega"))
.condition((_user, target, _move) => isNullOrUndefined(target.getTag(BattlerTagType.INGRAIN)) && isNullOrUndefined(target.getTag(BattlerTagType.IGNORE_FLYING)))
.attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3)
.condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId))
.condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega"))
.condition(failOnGravityCondition)
.condition(failOnGroundedCondition)
.reflectable(),
new StatusMove(MoveId.MAGIC_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5)
.ignoresProtect()
@ -9981,8 +10014,6 @@ export function initMoves() {
.unimplemented(),
new AttackMove(MoveId.SMACK_DOWN, PokemonType.ROCK, MoveCategory.PHYSICAL, 50, 100, 15, -1, 0, 5)
.attr(FallDownAttr)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.attr(HitsTagAttr, BattlerTagType.FLYING)
.makesContact(false),
new AttackMove(MoveId.STORM_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5)
@ -10444,8 +10475,6 @@ export function initMoves() {
.attr(FallDownAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING)
.attr(HitsTagAttr, BattlerTagType.FLOATING)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.makesContact(false)
.target(MoveTarget.ALL_NEAR_ENEMIES),
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, 100, 0, 6)

View File

@ -12,6 +12,7 @@ import { WeatherType } from "#enums/weather-type";
import type { Pokemon } from "#field/pokemon";
import type { PokemonFormChangeItemModifier } from "#modifiers/modifier";
import { type Constructor, coerceArray } from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
export abstract class SpeciesFormChangeTrigger {
@ -143,11 +144,7 @@ export class SpeciesFormChangeMoveLearnedTrigger extends SpeciesFormChangeTrigge
super();
this.move = move;
this.known = known;
const moveKey = MoveId[this.move]
.split("_")
.filter(f => f)
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("") as unknown as string;
const moveKey = toCamelCase(MoveId[this.move]);
this.description = known
? i18next.t("pokemonEvolutions:Forms.moveLearned", {
move: i18next.t(`move:${moveKey}.name`),

View File

@ -23,17 +23,21 @@ export class Terrain {
public terrainType: TerrainType;
public turnsLeft: number;
constructor(terrainType: TerrainType, turnsLeft?: number) {
constructor(terrainType: TerrainType, turnsLeft = 0) {
this.terrainType = terrainType;
this.turnsLeft = turnsLeft || 0;
this.turnsLeft = turnsLeft;
}
/**
* Tick down this terrain's duration.
* @returns Whether the current terrain should remain active (`turnsLeft > 0`)
*/
lapse(): boolean {
if (this.turnsLeft) {
return !!--this.turnsLeft;
// TODO: Add separate flag for infinite duration terrains
if (this.turnsLeft <= 0) {
return true;
}
return true;
return --this.turnsLeft > 0;
}
getAttackTypeMultiplier(attackType: PokemonType): number {

View File

@ -20,20 +20,25 @@ export class Weather {
public weatherType: WeatherType;
public turnsLeft: number;
constructor(weatherType: WeatherType, turnsLeft?: number) {
constructor(weatherType: WeatherType, turnsLeft = 0) {
this.weatherType = weatherType;
this.turnsLeft = !this.isImmutable() ? turnsLeft || 0 : 0;
this.turnsLeft = this.isImmutable() ? 0 : turnsLeft;
}
/**
* Tick down this weather's duration.
* @returns Whether the current weather should remain active (`turnsLeft > 0`)
*/
lapse(): boolean {
if (this.isImmutable()) {
return true;
}
if (this.turnsLeft) {
return !!--this.turnsLeft;
if (this.turnsLeft <= 0) {
return true;
}
return true;
return --this.turnsLeft > 0;
}
isImmutable(): boolean {
@ -127,6 +132,7 @@ export class Weather {
}
}
// TODO: These functions should return empty strings instead of `null` - requires bangs
export function getWeatherStartMessage(weatherType: WeatherType): string | null {
switch (weatherType) {
case WeatherType.SUNNY:

View File

@ -38,6 +38,7 @@ export enum UiMode {
UNAVAILABLE,
CHALLENGE_SELECT,
RENAME_POKEMON,
RENAME_RUN,
RUN_HISTORY,
RUN_INFO,
TEST_DIALOGUE,

View File

@ -54,7 +54,7 @@ export class Arena {
public bgm: string;
public ignoreAbilities: boolean;
public ignoringEffectSource: BattlerIndex | null;
public playerTerasUsed: number;
public playerTerasUsed = 0;
/**
* Saves the number of times a party pokemon faints during a arena encounter.
* {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave).
@ -68,12 +68,11 @@ export class Arena {
public readonly eventTarget: EventTarget = new EventTarget();
constructor(biome: BiomeId, bgm: string, playerFaints = 0) {
constructor(biome: BiomeId, playerFaints = 0) {
this.biomeType = biome;
this.bgm = bgm;
this.bgm = BiomeId[biome].toLowerCase();
this.trainerPool = biomeTrainerPools[biome];
this.updatePoolsForTimeOfDay();
this.playerTerasUsed = 0;
this.playerFaints = playerFaints;
}
@ -289,20 +288,18 @@ export class Arena {
}
/**
* Sets weather to the override specified in overrides.ts
* @param weather new {@linkcode WeatherType} to set
* @returns true to force trySetWeather to return true
* Sets weather to the override specified in overrides.ts`
*/
trySetWeatherOverride(weather: WeatherType): boolean {
private overrideWeather(): void {
const weather = Overrides.WEATHER_OVERRIDE;
this.weather = new Weather(weather, 0);
globalScene.phaseManager.unshiftNew("CommonAnimPhase", undefined, undefined, CommonAnim.SUNNY + (weather - 1));
globalScene.phaseManager.queueMessage(getWeatherStartMessage(weather)!); // TODO: is this bang correct?
return true;
}
/** Returns weather or not the weather can be changed to {@linkcode weather} */
canSetWeather(weather: WeatherType): boolean {
return !(this.weather?.weatherType === (weather || undefined));
return this.getWeatherType() !== weather;
}
/**
@ -313,14 +310,15 @@ export class Arena {
*/
trySetWeather(weather: WeatherType, user?: Pokemon): boolean {
if (Overrides.WEATHER_OVERRIDE) {
return this.trySetWeatherOverride(Overrides.WEATHER_OVERRIDE);
this.overrideWeather();
return true;
}
if (!this.canSetWeather(weather)) {
return false;
}
const oldWeatherType = this.weather?.weatherType || WeatherType.NONE;
const oldWeatherType = this.getWeatherType();
if (
this.weather?.isImmutable() &&
@ -344,7 +342,7 @@ export class Arena {
globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, weatherDuration);
}
this.weather = weather ? new Weather(weather, weatherDuration.value) : null;
this.weather = weather === WeatherType.NONE ? null : new Weather(weather, weatherDuration.value);
this.eventTarget.dispatchEvent(
new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!),
); // TODO: is this bang correct?
@ -405,25 +403,24 @@ export class Arena {
});
}
/** Returns whether or not the terrain can be set to {@linkcode terrain} */
/** Return whether or not the terrain can be set to {@linkcode terrain} */
canSetTerrain(terrain: TerrainType): boolean {
return !(this.terrain?.terrainType === (terrain || undefined));
return this.getTerrainType() !== terrain;
}
/**
* Attempts to set a new terrain effect to the battle
* @param terrain {@linkcode TerrainType} new {@linkcode TerrainType} to set
* @param ignoreAnim boolean if the terrain animation should be ignored
* @param user {@linkcode Pokemon} that caused the terrain effect
* @returns true if new terrain set, false if no terrain provided or attempting to set the same terrain as currently in use
* Attempt to set the current terrain to the specified type.
* @param terrain - The {@linkcode TerrainType} to try and set.
* @param ignoreAnim - Whether to prevent showing an the animation; default `false`
* @param user - The {@linkcode Pokemon} creating the terrain (if any)
* @returns Whether the terrain was successfully set.
*/
trySetTerrain(terrain: TerrainType, ignoreAnim = false, user?: Pokemon): boolean {
if (!this.canSetTerrain(terrain)) {
return false;
}
const oldTerrainType = this.terrain?.terrainType || TerrainType.NONE;
const oldTerrainType = this.getTerrainType();
const terrainDuration = new NumberHolder(0);
if (!isNullOrUndefined(user)) {
@ -431,7 +428,7 @@ export class Arena {
globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, terrainDuration);
}
this.terrain = terrain ? new Terrain(terrain, terrainDuration.value) : null;
this.terrain = terrain === TerrainType.NONE ? null : new Terrain(terrain, terrainDuration.value);
this.eventTarget.dispatchEvent(
new TerrainChangedEvent(oldTerrainType, this.terrain?.terrainType!, this.terrain?.turnsLeft!),
@ -465,6 +462,24 @@ export class Arena {
return true;
}
/** Attempt to override the terrain to the value set inside {@linkcode Overrides.STARTING_TERRAIN_OVERRIDE}. */
tryOverrideTerrain(): void {
const terrain = Overrides.STARTING_TERRAIN_OVERRIDE;
if (terrain === TerrainType.NONE) {
return;
}
// TODO: Add a flag for permanent terrains
this.terrain = new Terrain(terrain, 0);
globalScene.phaseManager.unshiftNew(
"CommonAnimPhase",
undefined,
undefined,
CommonAnim.MISTY_TERRAIN + (terrain - 1),
);
globalScene.phaseManager.queueMessage(getTerrainStartMessage(terrain) ?? ""); // TODO: Remove `?? ""` when terrain-fail-msg branch removes `null` from these signatures
}
public isMoveWeatherCancelled(user: Pokemon, move: Move): boolean {
return !!this.weather && !this.weather.isEffectSuppressed() && this.weather.isMoveWeatherCancelled(user, move);
}

View File

@ -2280,13 +2280,29 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return this.teraType;
}
public isGrounded(): boolean {
/**
* Return whether this Pokemon is currently on the ground.
*
* To be considered grounded, a Pokemon must either:
* * Be {@linkcode GroundedTag | forcibly grounded} from an effect like Smack Down or Ingrain
* * Be under the effects of {@linkcode ArenaTagType.GRAVITY | harsh gravity}
* * **Not** be all of the following things:
* * {@linkcode PokemonType.FLYING | Flying-type}
* * {@linkcode AbilityId.LEVITATE | Levitating}
* * {@linkcode BattlerTagType.FLOATING | Floating} from Magnet Rise or Telekinesis.
* * {@linkcode SemiInvulnerableTag | Semi-invulnerable} with `ignoreSemiInvulnerable` set to `false`
* @param ignoreSemiInvulnerable - Whether to ignore the target's semi-invulnerable state when determining groundedness;
default `false`
* @returns Whether this pokemon is currently grounded, as described above.
*/
public isGrounded(ignoreSemiInvulnerable = false): boolean {
return (
!!this.getTag(GroundedTag) ||
globalScene.arena.hasTag(ArenaTagType.GRAVITY) ||
(!this.isOfType(PokemonType.FLYING, true, true) &&
!this.hasAbility(AbilityId.LEVITATE) &&
!this.getTag(BattlerTagType.FLOATING) &&
!this.getTag(SemiInvulnerableTag))
(ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag)))
);
}
@ -2486,7 +2502,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// Handle flying v ground type immunity without removing flying type so effective types are still effective
// Related to https://github.com/pagefaultgames/pokerogue/issues/524
if (moveType === PokemonType.GROUND && (this.isGrounded() || arena.hasTag(ArenaTagType.GRAVITY))) {
if (moveType === PokemonType.GROUND && this.isGrounded()) {
const flyingIndex = types.indexOf(PokemonType.FLYING);
if (flyingIndex > -1) {
types.splice(flyingIndex, 1);
@ -3753,6 +3769,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
const isPhysical = moveCategory === MoveCategory.PHYSICAL;
/** Combined damage multiplier from field effects such as weather, terrain, etc. */
// TODO: This should be applied directly to base power
const arenaAttackTypeMultiplier = new NumberHolder(
globalScene.arena.getAttackTypeMultiplier(moveType, source.isGrounded()),
);

View File

@ -447,7 +447,9 @@ export class LoadingScene extends SceneBase {
);
if (!mobile) {
loadingGraphics.map(g => g.setVisible(false));
loadingGraphics.forEach(g => {
g.setVisible(false);
});
}
const intro = this.add.video(0, 0);

View File

@ -121,8 +121,8 @@ export class ModifierBar extends Phaser.GameObjects.Container {
}
updateModifierOverflowVisibility(ignoreLimit: boolean) {
const modifierIcons = this.getAll().reverse();
for (const modifier of modifierIcons.map(m => m as Phaser.GameObjects.Container).slice(iconOverflowIndex)) {
const modifierIcons = this.getAll().reverse() as Phaser.GameObjects.Container[];
for (const modifier of modifierIcons.slice(iconOverflowIndex)) {
modifier.setVisible(ignoreLimit);
}
}

View File

@ -1,4 +1,5 @@
import { type PokeballCounts } from "#app/battle-scene";
import { TerrainType } from "#app/data/terrain";
import { EvolutionItem } from "#balance/pokemon-evolutions";
import { Gender } from "#data/gender";
import { AbilityId } from "#enums/ability-id";
@ -61,6 +62,12 @@ class DefaultOverrides {
readonly SEED_OVERRIDE: string = "";
readonly DAILY_RUN_SEED_OVERRIDE: string | null = null;
readonly WEATHER_OVERRIDE: WeatherType = WeatherType.NONE;
/**
* If set, will override the in-game terrain at the start of each biome transition.
*
* Lasts until cleared or replaced by another effect, and is refreshed at the start of each new biome.
*/
readonly STARTING_TERRAIN_OVERRIDE: TerrainType = TerrainType.NONE;
/**
* If `null`, ignore this override.
*

View File

@ -690,6 +690,7 @@ export class EncounterPhase extends BattlePhase {
trySetWeatherIfNewBiome(): void {
if (!this.loaded) {
globalScene.arena.trySetWeather(getRandomWeatherType(globalScene.arena));
globalScene.arena.tryOverrideTerrain();
}
}
}

View File

@ -12,7 +12,7 @@ import { BooleanHolder, toDmgValue } from "#utils/common";
export class WeatherEffectPhase extends CommonAnimPhase {
public readonly phaseName = "WeatherEffectPhase";
public weather: Weather | null;
public weather: Weather | null; // TODO: This should not be `null`
constructor() {
super(

View File

@ -127,6 +127,7 @@ export interface SessionSaveData {
battleType: BattleType;
trainer: TrainerData;
gameVersion: string;
runNameText: string;
timestamp: number;
challenges: ChallengeData[];
mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME,
@ -206,10 +207,12 @@ export interface StarterData {
[key: number]: StarterDataEntry;
}
export interface TutorialFlags {
[key: string]: boolean;
}
// TODO: Rework into a bitmask
export type TutorialFlags = {
[key in Tutorial]: boolean;
};
// TODO: Rework into a bitmask
export interface SeenDialogues {
[key: string]: boolean;
}
@ -822,52 +825,51 @@ export class GameData {
return true; // TODO: is `true` the correct return value?
}
private loadGamepadSettings(): boolean {
Object.values(SettingGamepad)
.map(setting => setting as SettingGamepad)
.forEach(setting => setSettingGamepad(setting, settingGamepadDefaults[setting]));
private loadGamepadSettings(): void {
Object.values(SettingGamepad).forEach(setting => {
setSettingGamepad(setting, settingGamepadDefaults[setting]);
});
if (!localStorage.hasOwnProperty("settingsGamepad")) {
return false;
return;
}
const settingsGamepad = JSON.parse(localStorage.getItem("settingsGamepad")!); // TODO: is this bang correct?
for (const setting of Object.keys(settingsGamepad)) {
setSettingGamepad(setting as SettingGamepad, settingsGamepad[setting]);
}
return true; // TODO: is `true` the correct return value?
}
public saveTutorialFlag(tutorial: Tutorial, flag: boolean): boolean {
const key = getDataTypeKey(GameDataType.TUTORIALS);
let tutorials: object = {};
if (localStorage.hasOwnProperty(key)) {
tutorials = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct?
/**
* Save the specified tutorial as having the specified completion status.
* @param tutorial - The {@linkcode Tutorial} whose completion status is being saved
* @param status - The completion status to set
*/
public saveTutorialFlag(tutorial: Tutorial, status: boolean): void {
// Grab the prior save data tutorial
const saveDataKey = getDataTypeKey(GameDataType.TUTORIALS);
const tutorials: TutorialFlags = localStorage.hasOwnProperty(saveDataKey)
? JSON.parse(localStorage.getItem(saveDataKey)!)
: {};
// TODO: We shouldn't be storing this like that
for (const key of Object.values(Tutorial)) {
if (key === tutorial) {
tutorials[key] = status;
} else {
tutorials[key] ??= false;
}
}
Object.keys(Tutorial)
.map(t => t as Tutorial)
.forEach(t => {
const key = Tutorial[t];
if (key === tutorial) {
tutorials[key] = flag;
} else {
tutorials[key] ??= false;
}
});
localStorage.setItem(key, JSON.stringify(tutorials));
return true;
localStorage.setItem(saveDataKey, JSON.stringify(tutorials));
}
public getTutorialFlags(): TutorialFlags {
const key = getDataTypeKey(GameDataType.TUTORIALS);
const ret: TutorialFlags = {};
Object.values(Tutorial)
.map(tutorial => tutorial as Tutorial)
.forEach(tutorial => (ret[Tutorial[tutorial]] = false));
const ret: TutorialFlags = Object.values(Tutorial).reduce((acc, tutorial) => {
acc[Tutorial[tutorial]] = false;
return acc;
}, {} as TutorialFlags);
if (!localStorage.hasOwnProperty(key)) {
return ret;
@ -979,6 +981,54 @@ export class GameData {
});
}
async renameSession(slotId: number, newName: string): Promise<boolean> {
return new Promise(async resolve => {
if (slotId < 0) {
return resolve(false);
}
const sessionData: SessionSaveData | null = await this.getSession(slotId);
if (!sessionData) {
return resolve(false);
}
if (newName === "") {
return resolve(true);
}
sessionData.runNameText = newName;
const updatedDataStr = JSON.stringify(sessionData);
const encrypted = encrypt(updatedDataStr, bypassLogin);
const secretId = this.secretId;
const trainerId = this.trainerId;
if (bypassLogin) {
localStorage.setItem(
`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`,
encrypt(updatedDataStr, bypassLogin),
);
resolve(true);
return;
}
pokerogueApi.savedata.session
.update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted)
.then(error => {
if (error) {
console.error("Failed to update session name:", error);
resolve(false);
} else {
localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted);
updateUserInfo().then(success => {
if (success !== null && !success) {
return resolve(false);
}
});
resolve(true);
}
});
});
}
loadSession(slotId: number, sessionData?: SessionSaveData): Promise<boolean> {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this
return new Promise(async (resolve, reject) => {

View File

@ -0,0 +1,54 @@
import i18next from "i18next";
import type { InputFieldConfig } from "./form-modal-ui-handler";
import { FormModalUiHandler } from "./form-modal-ui-handler";
import type { ModalConfig } from "./modal-ui-handler";
export class RenameRunFormUiHandler extends FormModalUiHandler {
getModalTitle(_config?: ModalConfig): string {
return i18next.t("menu:renamerun");
}
getWidth(_config?: ModalConfig): number {
return 160;
}
getMargin(_config?: ModalConfig): [number, number, number, number] {
return [0, 0, 48, 0];
}
getButtonLabels(_config?: ModalConfig): string[] {
return [i18next.t("menu:rename"), i18next.t("menu:cancel")];
}
getReadableErrorMessage(error: string): string {
const colonIndex = error?.indexOf(":");
if (colonIndex > 0) {
error = error.slice(0, colonIndex);
}
return super.getReadableErrorMessage(error);
}
override getInputFieldConfigs(): InputFieldConfig[] {
return [{ label: i18next.t("menu:runName") }];
}
show(args: any[]): boolean {
if (!super.show(args)) {
return false;
}
if (this.inputs?.length) {
this.inputs.forEach(input => {
input.text = "";
});
}
const config = args[0] as ModalConfig;
this.submitAction = _ => {
this.sanitizeInputs();
const sanitizedName = btoa(encodeURIComponent(this.inputs[0].text));
config.buttonActions[0](sanitizedName);
return true;
};
return true;
}
}

View File

@ -26,6 +26,7 @@ import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text";
import { UiHandler } from "#ui/ui-handler";
import { addWindow } from "#ui/ui-theme";
import { formatFancyLargeNumber, formatLargeNumber, formatMoney, getPlayTimeString } from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
import RoundRectangle from "phaser3-rex-plugins/plugins/roundrectangle";
@ -207,6 +208,10 @@ export class RunInfoUiHandler extends UiHandler {
headerText.setOrigin(0, 0);
headerText.setPositionRelative(headerBg, 8, 4);
this.runContainer.add(headerText);
const runName = addTextObject(0, 0, this.runInfo.runNameText, TextStyle.WINDOW);
runName.setOrigin(0, 0);
runName.setPositionRelative(headerBg, 60, 4);
this.runContainer.add(runName);
}
/**
@ -702,10 +707,7 @@ export class RunInfoUiHandler extends UiHandler {
rules.push(i18next.t("challenges:inverseBattle.shortName"));
break;
default: {
const localizationKey = Challenges[this.runInfo.challenges[i].id]
.split("_")
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("");
const localizationKey = toCamelCase(Challenges[this.runInfo.challenges[i].id]);
rules.push(i18next.t(`challenges:${localizationKey}.name`));
break;
}

View File

@ -1,12 +1,14 @@
import { GameMode } from "#app/game-mode";
import { globalScene } from "#app/global-scene";
import { Button } from "#enums/buttons";
import { GameModes } from "#enums/game-modes";
import { TextStyle } from "#enums/text-style";
import { UiMode } from "#enums/ui-mode";
// biome-ignore lint/performance/noNamespaceImport: See `src/system/game-data.ts`
import * as Modifier from "#modifiers/modifier";
import type { SessionSaveData } from "#system/game-data";
import type { PokemonData } from "#system/pokemon-data";
import type { OptionSelectConfig } from "#ui/abstract-option-select-ui-handler";
import { MessageUiHandler } from "#ui/message-ui-handler";
import { RunDisplayMode } from "#ui/run-info-ui-handler";
import { addTextObject } from "#ui/text";
@ -15,7 +17,7 @@ import { fixedInt, formatLargeNumber, getPlayTimeString, isNullOrUndefined } fro
import i18next from "i18next";
const SESSION_SLOTS_COUNT = 5;
const SLOTS_ON_SCREEN = 3;
const SLOTS_ON_SCREEN = 2;
export enum SaveSlotUiMode {
LOAD,
@ -33,6 +35,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
private uiMode: SaveSlotUiMode;
private saveSlotSelectCallback: SaveSlotSelectCallback | null;
protected manageDataConfig: OptionSelectConfig;
private scrollCursor = 0;
@ -101,6 +104,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
processInput(button: Button): boolean {
const ui = this.getUi();
const manageDataOptions: any[] = [];
let success = false;
let error = false;
@ -109,14 +113,115 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
const originalCallback = this.saveSlotSelectCallback;
if (button === Button.ACTION) {
const cursor = this.cursor + this.scrollCursor;
if (this.uiMode === SaveSlotUiMode.LOAD && !this.sessionSlots[cursor].hasData) {
const sessionSlot = this.sessionSlots[cursor];
if (this.uiMode === SaveSlotUiMode.LOAD && !sessionSlot.hasData) {
error = true;
} else {
switch (this.uiMode) {
case SaveSlotUiMode.LOAD:
this.saveSlotSelectCallback = null;
originalCallback?.(cursor);
if (!sessionSlot.malformed) {
manageDataOptions.push({
label: i18next.t("menu:loadGame"),
handler: () => {
globalScene.ui.revertMode();
originalCallback?.(cursor);
return true;
},
keepOpen: false,
});
manageDataOptions.push({
label: i18next.t("saveSlotSelectUiHandler:renameRun"),
handler: () => {
globalScene.ui.revertMode();
ui.setOverlayMode(
UiMode.RENAME_RUN,
{
buttonActions: [
(sanitizedName: string) => {
const name = decodeURIComponent(atob(sanitizedName));
globalScene.gameData.renameSession(cursor, name).then(response => {
if (response[0] === false) {
globalScene.reset(true);
} else {
this.clearSessionSlots();
this.cursorObj = null;
this.populateSessionSlots();
this.setScrollCursor(0);
this.setCursor(0);
ui.revertMode();
ui.showText("", 0);
}
});
},
() => {
ui.revertMode();
},
],
},
"",
);
return true;
},
});
}
this.manageDataConfig = {
xOffset: 0,
yOffset: 48,
options: manageDataOptions,
maxOptions: 4,
};
manageDataOptions.push({
label: i18next.t("saveSlotSelectUiHandler:deleteRun"),
handler: () => {
globalScene.ui.revertMode();
ui.showText(i18next.t("saveSlotSelectUiHandler:deleteData"), null, () => {
ui.setOverlayMode(
UiMode.CONFIRM,
() => {
globalScene.gameData.tryClearSession(cursor).then(response => {
if (response[0] === false) {
globalScene.reset(true);
} else {
this.clearSessionSlots();
this.cursorObj = null;
this.populateSessionSlots();
this.setScrollCursor(0);
this.setCursor(0);
ui.revertMode();
ui.showText("", 0);
}
});
},
() => {
ui.revertMode();
ui.showText("", 0);
},
false,
0,
19,
import.meta.env.DEV ? 300 : 2000,
);
});
return true;
},
keepOpen: false,
});
manageDataOptions.push({
label: i18next.t("menuUiHandler:cancel"),
handler: () => {
globalScene.ui.revertMode();
return true;
},
keepOpen: true,
});
ui.setOverlayMode(UiMode.MENU_OPTION_SELECT, this.manageDataConfig);
break;
case SaveSlotUiMode.SAVE: {
const saveAndCallback = () => {
const originalCallback = this.saveSlotSelectCallback;
@ -161,6 +266,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
}
} else {
this.saveSlotSelectCallback = null;
ui.showText("", 0);
originalCallback?.(-1);
success = true;
}
@ -267,33 +373,34 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
this.cursorObj = globalScene.add.container(0, 0);
const cursorBox = globalScene.add.nineslice(
0,
0,
15,
"select_cursor_highlight_thick",
undefined,
296,
44,
294,
this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60,
6,
6,
6,
6,
);
const rightArrow = globalScene.add.image(0, 0, "cursor");
rightArrow.setPosition(160, 0);
rightArrow.setPosition(160, 15);
rightArrow.setName("rightArrow");
this.cursorObj.add([cursorBox, rightArrow]);
this.sessionSlotsContainer.add(this.cursorObj);
}
const cursorPosition = cursor + this.scrollCursor;
const cursorIncrement = cursorPosition * 56;
const cursorIncrement = cursorPosition * 76;
if (this.sessionSlots[cursorPosition] && this.cursorObj) {
const hasData = this.sessionSlots[cursorPosition].hasData;
const session = this.sessionSlots[cursorPosition];
const hasData = session.hasData && !session.malformed;
// If the session slot lacks session data, it does not move from its default, central position.
// Only session slots with session data will move leftwards and have a visible arrow.
if (!hasData) {
this.cursorObj.setPosition(151, 26 + cursorIncrement);
this.cursorObj.setPosition(151, 20 + cursorIncrement);
this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement);
} else {
this.cursorObj.setPosition(145, 26 + cursorIncrement);
this.cursorObj.setPosition(145, 20 + cursorIncrement);
this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement);
}
this.setArrowVisibility(hasData);
@ -311,7 +418,8 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
revertSessionSlot(slotIndex: number): void {
const sessionSlot = this.sessionSlots[slotIndex];
if (sessionSlot) {
sessionSlot.setPosition(0, slotIndex * 56);
const valueHeight = 76;
sessionSlot.setPosition(0, slotIndex * valueHeight);
}
}
@ -340,7 +448,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
this.setCursor(this.cursor, prevSlotIndex);
globalScene.tweens.add({
targets: this.sessionSlotsContainer,
y: this.sessionSlotsContainerInitialY - 56 * scrollCursor,
y: this.sessionSlotsContainerInitialY - 76 * scrollCursor,
duration: fixedInt(325),
ease: "Sine.easeInOut",
});
@ -374,12 +482,14 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
class SessionSlot extends Phaser.GameObjects.Container {
public slotId: number;
public hasData: boolean;
/** Indicates the save slot ran into an error while being loaded */
public malformed: boolean;
private slotWindow: Phaser.GameObjects.NineSlice;
private loadingLabel: Phaser.GameObjects.Text;
public saveData: SessionSaveData;
constructor(slotId: number) {
super(globalScene, 0, slotId * 56);
super(globalScene, 0, slotId * 76);
this.slotId = slotId;
@ -387,32 +497,89 @@ class SessionSlot extends Phaser.GameObjects.Container {
}
setup() {
const slotWindow = addWindow(0, 0, 304, 52);
this.add(slotWindow);
this.slotWindow = addWindow(0, 0, 304, 70);
this.add(this.slotWindow);
this.loadingLabel = addTextObject(152, 26, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW);
this.loadingLabel = addTextObject(152, 33, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW);
this.loadingLabel.setOrigin(0.5, 0.5);
this.add(this.loadingLabel);
}
/**
* Generates a name for sessions that don't have a name yet.
* @param data - The {@linkcode SessionSaveData} being checked
* @returns The default name for the given data.
*/
decideFallback(data: SessionSaveData): string {
let fallbackName = `${GameMode.getModeName(data.gameMode)}`;
switch (data.gameMode) {
case GameModes.CLASSIC:
fallbackName += ` (${globalScene.gameData.gameStats.classicSessionsPlayed + 1})`;
break;
case GameModes.ENDLESS:
case GameModes.SPLICED_ENDLESS:
fallbackName += ` (${globalScene.gameData.gameStats.endlessSessionsPlayed + 1})`;
break;
case GameModes.DAILY: {
const runDay = new Date(data.timestamp).toLocaleDateString();
fallbackName += ` (${runDay})`;
break;
}
case GameModes.CHALLENGE: {
const activeChallenges = data.challenges.filter(c => c.value !== 0);
if (activeChallenges.length === 0) {
break;
}
fallbackName = "";
for (const challenge of activeChallenges.slice(0, 3)) {
if (fallbackName !== "") {
fallbackName += ", ";
}
fallbackName += challenge.toChallenge().getName();
}
if (activeChallenges.length > 3) {
fallbackName += ", ...";
} else if (fallbackName === "") {
// Something went wrong when retrieving the names of the active challenges,
// so fall back to just naming the run "Challenge"
fallbackName = `${GameMode.getModeName(data.gameMode)}`;
}
break;
}
}
return fallbackName;
}
async setupWithData(data: SessionSaveData) {
const hasName = data?.runNameText;
this.remove(this.loadingLabel, true);
if (hasName) {
const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW);
this.add(nameLabel);
} else {
const fallbackName = this.decideFallback(data);
await globalScene.gameData.renameSession(this.slotId, fallbackName);
const nameLabel = addTextObject(8, 5, fallbackName, TextStyle.WINDOW);
this.add(nameLabel);
}
const gameModeLabel = addTextObject(
8,
5,
19,
`${GameMode.getModeName(data.gameMode) || i18next.t("gameMode:unknown")} - ${i18next.t("saveSlotSelectUiHandler:wave")} ${data.waveIndex}`,
TextStyle.WINDOW,
);
this.add(gameModeLabel);
const timestampLabel = addTextObject(8, 19, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW);
const timestampLabel = addTextObject(8, 33, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW);
this.add(timestampLabel);
const playTimeLabel = addTextObject(8, 33, getPlayTimeString(data.playTime), TextStyle.WINDOW);
const playTimeLabel = addTextObject(8, 47, getPlayTimeString(data.playTime), TextStyle.WINDOW);
this.add(playTimeLabel);
const pokemonIconsContainer = globalScene.add.container(144, 4);
const pokemonIconsContainer = globalScene.add.container(144, 16);
data.party.forEach((p: PokemonData, i: number) => {
const iconContainer = globalScene.add.container(26 * i, 0);
iconContainer.setScale(0.75);
@ -427,13 +594,9 @@ class SessionSlot extends Phaser.GameObjects.Container {
TextStyle.PARTY,
{ fontSize: "54px", color: "#f8f8f8" },
);
text.setShadow(0, 0, undefined);
text.setStroke("#424242", 14);
text.setOrigin(1, 0);
iconContainer.add(icon);
iconContainer.add(text);
text.setShadow(0, 0, undefined).setStroke("#424242", 14).setOrigin(1, 0);
iconContainer.add([icon, text]);
pokemonIconsContainer.add(iconContainer);
pokemon.destroy();
@ -441,7 +604,7 @@ class SessionSlot extends Phaser.GameObjects.Container {
this.add(pokemonIconsContainer);
const modifierIconsContainer = globalScene.add.container(148, 30);
const modifierIconsContainer = globalScene.add.container(148, 38);
modifierIconsContainer.setScale(0.5);
let visibleModifierIndex = 0;
for (const m of data.modifiers) {
@ -464,22 +627,33 @@ class SessionSlot extends Phaser.GameObjects.Container {
load(): Promise<boolean> {
return new Promise<boolean>(resolve => {
globalScene.gameData.getSession(this.slotId).then(async sessionData => {
// Ignore the results if the view was exited
if (!this.active) {
return;
}
if (!sessionData) {
this.hasData = false;
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
resolve(false);
return;
}
this.hasData = true;
this.saveData = sessionData;
await this.setupWithData(sessionData);
resolve(true);
});
globalScene.gameData
.getSession(this.slotId)
.then(async sessionData => {
// Ignore the results if the view was exited
if (!this.active) {
return;
}
this.hasData = !!sessionData;
if (!sessionData) {
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
resolve(false);
return;
}
this.saveData = sessionData;
this.setupWithData(sessionData);
resolve(true);
})
.catch(e => {
if (!this.active) {
return;
}
console.warn(`Failed to load session slot #${this.slotId}:`, e);
this.loadingLabel.setText(i18next.t("menu:failedToLoadSession"));
this.hasData = true;
this.malformed = true;
resolve(true);
});
});
}
}

View File

@ -31,7 +31,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler {
// we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key
// Return in the format expected by i18next
return middleKey ? `${topKey}:${middleKey.map(m => m).join(".")}.${t}` : `${topKey}:${t}`;
return middleKey ? `${topKey}:${middleKey.join(".")}.${t}` : `${topKey}:${t}`;
}
})
.filter(t => t);

View File

@ -60,6 +60,7 @@ import { addWindow } from "#ui/ui-theme";
import { UnavailableModalUiHandler } from "#ui/unavailable-modal-ui-handler";
import { executeIf } from "#utils/common";
import i18next from "i18next";
import { RenameRunFormUiHandler } from "./rename-run-ui-handler";
const transitionModes = [
UiMode.SAVE_SLOT,
@ -98,6 +99,7 @@ const noTransitionModes = [
UiMode.SESSION_RELOAD,
UiMode.UNAVAILABLE,
UiMode.RENAME_POKEMON,
UiMode.RENAME_RUN,
UiMode.TEST_DIALOGUE,
UiMode.AUTO_COMPLETE,
UiMode.ADMIN,
@ -168,6 +170,7 @@ export class UI extends Phaser.GameObjects.Container {
new UnavailableModalUiHandler(),
new GameChallengesUiHandler(),
new RenameFormUiHandler(),
new RenameRunFormUiHandler(),
new RunHistoryUiHandler(),
new RunInfoUiHandler(),
new TestDialogueUiHandler(UiMode.TEST_DIALOGUE),

View File

@ -278,27 +278,6 @@ describe("Abilities - Parental Bond", () => {
expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined();
});
it("Smack Down boosted by this ability should only ground the target after the second hit", async () => {
game.override.moveset([MoveId.SMACK_DOWN]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.SMACK_DOWN);
await game.move.forceHit();
await game.phaseInterceptor.to("DamageAnimPhase");
expect(leadPokemon.turnData.hitCount).toBe(2);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
});
it("U-turn boosted by this ability should strike twice before forcing a switch", async () => {
game.override.moveset([MoveId.U_TURN]);

View File

@ -1,5 +1,6 @@
import { Status } from "#data/status-effect";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
@ -104,27 +105,26 @@ describe("Abilities - SHIELDS DOWN", () => {
expect(game.field.getPlayerPokemon().status).toBe(undefined);
});
// toxic spikes currently does not poison flying types when gravity is in effect
test.todo("should become poisoned by toxic spikes when grounded", async () => {
game.override
.enemyMoveset([MoveId.GRAVITY, MoveId.TOXIC_SPIKES, MoveId.SPLASH])
.moveset([MoveId.GRAVITY, MoveId.SPLASH]);
it("should be poisoned by toxic spikes when Gravity is active before changing forms", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MINIOR]);
// turn 1
game.move.select(MoveId.GRAVITY);
await game.move.selectEnemyMove(MoveId.TOXIC_SPIKES);
// Change minior to core form in a state where it would revert on switch
const minior = game.scene.getPlayerParty()[1];
minior.formIndex = redCoreForm;
game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.TOXIC_SPIKES);
await game.toNextTurn();
// turn 2
expect(game).toHaveArenaTag(ArenaTagType.GRAVITY);
game.doSwitchPokemon(1);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
expect(game.field.getPlayerPokemon().species.speciesId).toBe(SpeciesId.MINIOR);
expect(game.field.getPlayerPokemon().species.formIndex).toBe(0);
expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.POISON);
expect(minior.species.speciesId).toBe(SpeciesId.MINIOR);
expect(minior.formIndex).toBe(0);
expect(minior.isGrounded()).toBe(true);
expect(minior).toHaveStatusEffect(StatusEffect.POISON);
});
test("should ignore yawn", async () => {

View File

@ -1,8 +1,6 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
@ -27,130 +25,49 @@ describe("Arena - Gravity", () => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.moveset([MoveId.TACKLE, MoveId.GRAVITY, MoveId.FISSURE])
.ability(AbilityId.UNNERVE)
.enemyAbility(AbilityId.BALL_FETCH)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyMoveset(MoveId.SPLASH)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyLevel(5);
});
// Reference: https://bulbapedia.bulbagarden.net/wiki/Gravity_(move)
it("non-OHKO move accuracy is multiplied by 1.67", async () => {
const moveToCheck = allMoves[MoveId.TACKLE];
vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn
it("should multiply all non-OHKO move accuracy by 1.67x", async () => {
const accSpy = vi.spyOn(allMoves[MoveId.TACKLE], "calculateBattleAccuracy");
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use non-OHKO move on second turn
await game.toNextTurn();
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(100 * 1.67);
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.TACKLE].accuracy * 1.67);
});
it("OHKO move accuracy is not affected", async () => {
/** See Fissure {@link https://bulbapedia.bulbagarden.net/wiki/Fissure_(move)} */
const moveToCheck = allMoves[MoveId.FISSURE];
vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn
it("should not affect OHKO move accuracy", async () => {
const accSpy = vi.spyOn(allMoves[MoveId.FISSURE], "calculateBattleAccuracy");
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.FISSURE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use OHKO move on second turn
await game.toNextTurn();
game.move.select(MoveId.FISSURE);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(30);
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.FISSURE].accuracy);
});
describe("Against flying types", () => {
it("can be hit by ground-type moves now", async () => {
game.override.enemySpecies(SpeciesId.PIDGEOT).moveset([MoveId.GRAVITY, MoveId.EARTHQUAKE]);
it("should forcibly ground all Pokemon for the duration of the effect", async () => {
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
const pidgeot = game.field.getEnemyPokemon();
vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
// Try earthquake on 1st turn (fails!);
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(0);
// Setup Gravity on 2nd turn
await game.toNextTurn();
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use ground move on 3rd turn
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(1);
});
it("keeps super-effective moves super-effective after using gravity", async () => {
game.override.enemySpecies(SpeciesId.PIDGEOT).moveset([MoveId.GRAVITY, MoveId.THUNDERBOLT]);
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
const pidgeot = game.field.getEnemyPokemon();
vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
// Setup Gravity on 1st turn
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use electric move on 2nd turn
await game.toNextTurn();
game.move.select(MoveId.THUNDERBOLT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(2);
});
});
it("cancels Fly if its user is semi-invulnerable", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX).enemyMoveset(MoveId.FLY).moveset([MoveId.GRAVITY, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const charizard = game.field.getPlayerPokemon();
const snorlax = game.field.getEnemyPokemon();
game.move.select(MoveId.SPLASH);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.GRAVITY);
await game.toNextTurn();
expect(snorlax.getTag(BattlerTagType.FLYING)).toBeDefined();
game.move.select(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(snorlax.getTag(BattlerTagType.INTERRUPTED)).toBeDefined();
await game.phaseInterceptor.to("TurnEndPhase");
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
expect(player.isGrounded()).toBe(true);
expect(enemy.isGrounded()).toBe(true);
});
});

View File

@ -1,69 +0,0 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Arena - Grassy Terrain", () => {
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
.battleStyle("single")
.criticalHits(false)
.enemyLevel(1)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.FLY)
.moveset([MoveId.GRASSY_TERRAIN, MoveId.EARTHQUAKE])
.ability(AbilityId.NO_GUARD);
});
it("halves the damage of Earthquake", async () => {
await game.classicMode.startBattle([SpeciesId.TAUROS]);
const eq = allMoves[MoveId.EARTHQUAKE];
vi.spyOn(eq, "calculateBattlePower");
game.move.select(MoveId.EARTHQUAKE);
await game.toNextTurn();
expect(eq.calculateBattlePower).toHaveReturnedWith(100);
game.move.select(MoveId.GRASSY_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("BerryPhase");
expect(eq.calculateBattlePower).toHaveReturnedWith(50);
});
it("Does not halve the damage of Earthquake if opponent is not grounded", async () => {
await game.classicMode.startBattle([SpeciesId.NINJASK]);
const eq = allMoves[MoveId.EARTHQUAKE];
vi.spyOn(eq, "calculateBattlePower");
game.move.select(MoveId.GRASSY_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("BerryPhase");
expect(eq.calculateBattlePower).toHaveReturnedWith(100);
});
});

View File

@ -1,59 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Arena - Psychic Terrain", () => {
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
.battleStyle("single")
.criticalHits(false)
.enemyLevel(1)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH)
.moveset([MoveId.PSYCHIC_TERRAIN, MoveId.RAIN_DANCE, MoveId.DARK_VOID])
.ability(AbilityId.NO_GUARD);
});
it("Dark Void with Prankster is not blocked", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.PSYCHIC_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.DARK_VOID);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.SLEEP);
});
it("Rain Dance with Prankster is not blocked", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.PSYCHIC_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.RAIN_DANCE);
await game.toEndOfTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.RAIN);
});
});

400
test/arena/terrain.test.ts Normal file
View File

@ -0,0 +1,400 @@
import { allMoves } from "#app/data/data-lists";
import { getTerrainName, TerrainType } from "#app/data/terrain";
import { getPokemonNameWithAffix } from "#app/messages";
import { randSeedInt } from "#app/utils/common";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Terrain -", () => {
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
.battleStyle("single")
.criticalHits(false)
.startingLevel(100)
.enemyLevel(100)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.passiveAbility(AbilityId.NO_GUARD);
});
// TODO: Terrain boosts currently apply to damage dealt, not base power
describe.todo.each<{ name: string; type: PokemonType; terrain: TerrainType; move: MoveId }>([
{ name: "Electric", type: PokemonType.ELECTRIC, terrain: TerrainType.ELECTRIC, move: MoveId.THUNDERBOLT },
{ name: "Psychic", type: PokemonType.PSYCHIC, terrain: TerrainType.PSYCHIC, move: MoveId.PSYCHIC },
{ name: "Grassy", type: PokemonType.GRASS, terrain: TerrainType.GRASSY, move: MoveId.ENERGY_BALL },
{ name: "Misty", type: PokemonType.FAIRY, terrain: TerrainType.MISTY, move: MoveId.DRAGON_BREATH },
])("Common Tests - $name Terrain", ({ type, terrain, move }) => {
// biome-ignore lint/suspicious/noDuplicateTestHooks: This is a TODO test case
beforeEach(() => {
game.override.terrain(terrain).enemyPassiveAbility(AbilityId.LEVITATE);
});
const typeStr = toTitleCase(PokemonType[type]);
it.skipIf(terrain === TerrainType.MISTY)(
`should boost power of grounded ${typeStr}-type moves by 1.3x, even against ungrounded targets`,
async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
game.move.use(move);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Player grounded attack got boosted while enemy ungrounded attack didn't
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power * 1.3);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power);
},
);
it.runIf(terrain === TerrainType.MISTY)(
"should cut power of grounded Dragon-type moves in half, even from ungrounded users",
async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
game.move.use(move);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Enemy dragon breath got nerfed against grounded player; player dragon breath did not
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power * 0.5);
},
);
// TODO: Move to a dedicated terrain pulse test file
it(`should change Terrain Pulse into a ${typeStr}-type move and double its base power`, async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[MoveId.TERRAIN_PULSE], "calculateBattlePower");
const playerTypeSpy = vi.spyOn(game.field.getPlayerPokemon(), "getMoveType");
const enemyTypeSpy = vi.spyOn(game.field.getEnemyPokemon(), "getMoveType");
game.move.use(MoveId.TERRAIN_PULSE);
await game.move.forceEnemyMove(MoveId.TERRAIN_PULSE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// player grounded terrain pulse was boosted & type converted; enemy ungrounded one wasn't
expect(powerSpy).toHaveLastReturnedWith(
allMoves[MoveId.TERRAIN_PULSE].power * (terrain === TerrainType.MISTY ? 2 : 2.6),
); // 2 * 1.3
expect(playerTypeSpy).toHaveLastReturnedWith(type);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[MoveId.TERRAIN_PULSE].power);
expect(enemyTypeSpy).toHaveNthReturnedWith(1, allMoves[MoveId.TERRAIN_PULSE].type);
});
});
describe("Grassy Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.GRASSY);
});
it("should heal all grounded, non semi-invulnerable Pokemon for 1/16th max HP at end of turn", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
// blissey is grounded, shuckle isn't
const blissey = game.field.getPlayerPokemon();
blissey.hp /= 2;
const shuckle = game.field.getEnemyPokemon();
game.field.mockAbility(shuckle, AbilityId.LEVITATE);
shuckle.hp /= 2;
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(game.phaseInterceptor.log).toContain("PokemonHealPhase");
expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
game.move.use(MoveId.DIG);
await game.toNextTurn();
// shuckle is airborne and blissey is semi-invulnerable, so nobody gets healed
expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
});
// TODO: Enable once magnitude to return a specific power rating
it.todo.each<{ name: string; move: MoveId; basePower?: number }>([
{ name: "Bulldoze", move: MoveId.BULLDOZE },
{ name: "Earthquake", move: MoveId.EARTHQUAKE },
{ name: "Magnitude", move: MoveId.MAGNITUDE, basePower: 150 }, // magnitude 10
])(
"should halve $name's base power against grounded, on-field targets",
async ({ move, basePower = allMoves[move].power }) => {
await game.classicMode.startBattle([SpeciesId.TAUROS]);
// force high rolls for guaranteed magnitude 10s
vi.fn(randSeedInt).mockReturnValue(100);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
const enemy = game.field.getEnemyPokemon();
// Turn 1: attack with grassy terrain active; 0.5x
game.move.use(move);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower / 2);
// Turn 2: Give enemy levitate to make ungrounded and attack; 1x
// (hits due to no guard)
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.LEVITATE);
game.move.use(move);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower);
// Turn 3: Remove levitate and make enemy semi-invulnerable; 1x
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.BALL_FETCH);
game.move.use(move);
await game.move.forceEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemy.getLastXMoves()[0].move).toBe(MoveId.FLY);
expect(powerSpy).toHaveLastReturnedWith(basePower);
},
);
});
describe("Electric Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.ELECTRIC);
});
it("should prevent all grounded Pokemon from being put to sleep", async () => {
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
game.move.use(MoveId.SPORE);
await game.move.forceEnemyMove(MoveId.SPORE);
await game.toEndOfTurn();
const pidgeot = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(pidgeot.status?.effect).toBe(StatusEffect.SLEEP);
expect(shuckle.status?.effect).toBeUndefined();
// 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(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.ELECTRIC),
}),
);
});
it("should prevent attack moves from applying sleep without showing text/failing move", async () => {
vi.spyOn(allMoves[MoveId.RELIC_SONG], "chance", "get").mockReturnValue(100);
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const shuckle = game.field.getEnemyPokemon();
const statusSpy = vi.spyOn(shuckle, "canSetStatus");
game.move.use(MoveId.RELIC_SONG);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
expect(shuckle.status?.effect).toBeUndefined();
expect(statusSpy).toHaveLastReturnedWith(false);
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.ELECTRIC),
}),
);
});
});
describe("Misty Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.MISTY).enemyPassiveAbility(AbilityId.LEVITATE);
});
it("should prevent all grounded Pokemon from gaining non-volatile statuses", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.TOXIC);
await game.move.forceEnemyMove(MoveId.TOXIC);
await game.toNextTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// blissey is grounded & protected, shuckle isn't
expect(blissey.status?.effect).toBeUndefined();
expect(shuckle.status?.effect).toBe(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);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
it("should block confusion and display message", async () => {
game.override.confusionActivation(false); // prevent self hits from cancelling move
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.CONFUSE_RAY);
await game.move.forceEnemyMove(MoveId.CONFUSE_RAY);
await game.toNextTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// blissey is grounded & protected, shuckle isn't
expect(blissey).not.toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(shuckle).toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
it.each<{ status: string; move: MoveId }>([
{ status: "Sleep", move: MoveId.RELIC_SONG },
{ status: "Burn", move: MoveId.SACRED_FIRE },
{ status: "Freeze", move: MoveId.ICE_BEAM },
{ status: "Paralysis", move: MoveId.NUZZLE },
{ status: "Poison", move: MoveId.SLUDGE_BOMB },
{ status: "Toxic Poison", move: MoveId.MALIGNANT_CHAIN },
// TODO: Confusion currently displays terrain block message even from damaging moves
// { status: "Confusion", move: MoveId.MAGICAL_TORQUE },
])("should prevent attack moves from applying $status without showing text/failing move", async ({ move }) => {
vi.spyOn(allMoves[move], "chance", "get").mockReturnValue(100);
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(move);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// 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(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
});
describe("Psychic Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.PSYCHIC).ability(AbilityId.GALE_WINGS).enemyAbility(AbilityId.PRANKSTER);
});
it("should block all opponent-targeted priority moves", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.FAKE_OUT);
await game.move.forceEnemyMove(MoveId.FOLLOW_ME);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.PSYCHIC),
}),
);
});
it("should affect moves that only become priority due to abilities", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.FEATHER_DANCE);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.PSYCHIC),
}),
);
});
it.each<{ category: string; move: MoveId; effect: () => void }>([
{
category: "Field-targeted",
move: MoveId.RAIN_DANCE,
effect: () => {
expect(game.scene.arena.getWeatherType()).toBe(WeatherType.RAIN);
},
},
{
category: "Enemy-targeting spread",
move: MoveId.DARK_VOID,
effect: () => {
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.SLEEP);
},
},
])("should not block $category moves that become priority", async ({ move, effect }) => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(move);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
effect();
});
});
});

View File

@ -0,0 +1,132 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Fly and Bounce", () => {
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
.battleStyle("single")
.ability(AbilityId.COMPOUND_EYES)
.enemySpecies(SpeciesId.SNORLAX)
.startingLevel(100)
.enemyLevel(100)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.TACKLE);
});
// TODO: Move to a global "charging moves" test file
it.each([
{ name: "Fly", move: MoveId.FLY },
{ name: "Bounce", move: MoveId.BOUNCE },
])("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.FLY);
await game.toEndOfTurn();
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
expect(player).toHaveBattlerTag(BattlerTagType.FLYING);
expect(enemy.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(player.hp).toBe(player.getMaxHp());
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(player.getMoveQueue()[0].move).toBe(MoveId.FLY);
await game.toEndOfTurn();
expect(player).not.toHaveBattlerTag(BattlerTagType.FLYING);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(player.getMoveHistory()).toHaveLength(2);
const playerFly = player.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(1);
});
// TODO: Move to a No Guard test file
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.use(MoveId.FLY);
await game.toEndOfTurn();
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
game.move.use(MoveId.FLY);
await game.toEndOfTurn();
expect(playerPokemon).not.toHaveBattlerTag(BattlerTagType.FLYING);
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
// TODO: We currently cancel Fly/Bounce in a really scuffed way
it.todo.each<{ name: string; move: MoveId }>([
{ name: "Smack Down", move: MoveId.SMACK_DOWN },
{ name: "Thousand Arrows", move: MoveId.THOUSAND_ARROWS },
{ name: "Gravity", move: MoveId.GRAVITY },
])("should be cancelled immediately when $name is used", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.AZURILL]);
game.move.use(MoveId.BOUNCE);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEndPhase");
// Bounce should've worked until hit
const azurill = game.field.getPlayerPokemon();
expect(azurill).toHaveBattlerTag(BattlerTagType.FLYING);
expect(azurill).not.toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
await game.phaseInterceptor.to("MoveEndPhase");
expect(azurill).not.toHaveBattlerTag(BattlerTagType.FLYING);
expect(azurill).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(azurill.getMoveQueue()).toHaveLength(0);
expect(azurill.visible).toBe(true);
if (move !== MoveId.GRAVITY) {
expect(azurill.hp).toBeLessThan(azurill.getMaxHp());
}
await game.toEndOfTurn();
const snorlax = game.field.getEnemyPokemon();
expect(snorlax.hp).toBe(snorlax.getMaxHp());
});
});

View File

@ -1,120 +0,0 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Fly", () => {
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
.moveset(MoveId.FLY)
.battleStyle("single")
.startingLevel(100)
.enemySpecies(SpeciesId.SNORLAX)
.enemyLevel(100)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.TACKLE);
vi.spyOn(allMoves[MoveId.FLY], "accuracy", "get").mockReturnValue(100);
});
it("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeDefined();
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveQueue()[0].move).toBe(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(1);
});
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
it("should be cancelled when another Pokemon uses Gravity", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.GRAVITY]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
await game.move.selectEnemyMove(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
});

View File

@ -1,3 +1,4 @@
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
@ -28,30 +29,16 @@ describe("Moves - Magnet Rise", () => {
.enemyLevel(1);
});
it("should make the user immune to ground-type moves", async () => {
it("should make the user ungrounded when used", async () => {
await game.classicMode.startBattle([SpeciesId.MAGNEZONE]);
game.move.use(MoveId.MAGNET_RISE);
await game.toEndOfTurn();
// magnezone levitated and was not hit by earthquake
const magnezone = game.field.getPlayerPokemon();
expect(magnezone.hp).toBe(magnezone.getMaxHp());
expect(magnezone.getTag(BattlerTagType.FLOATING)).toBeDefined();
expect(magnezone.isGrounded()).toBe(false);
});
it("should be removed by gravity", async () => {
await game.classicMode.startBattle([SpeciesId.MAGNEZONE]);
game.move.use(MoveId.MAGNET_RISE);
await game.toNextTurn();
const magnezone = game.field.getPlayerPokemon();
expect(magnezone.hp).toBe(magnezone.getMaxHp());
game.move.use(MoveId.GRAVITY);
await game.toEndOfTurn();
expect(magnezone.hp).toBeLessThan(magnezone.getMaxHp());
expect(magnezone.isGrounded()).toBe(true);
});
});

View File

@ -0,0 +1,124 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Smack Down and Thousand Arrows", () => {
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
.battleStyle("single")
.enemySpecies(SpeciesId.EELEKTROSS)
.startingLevel(100)
.enemyLevel(50)
.criticalHits(false)
.ability(AbilityId.COMPOUND_EYES)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH);
});
it.each([
{ name: "Smack Down", move: MoveId.SMACK_DOWN },
{ name: "Thousand Arrows", move: MoveId.THOUSAND_ARROWS },
])("$name should hit and ground ungrounded targets", async ({ move }) => {
game.override.enemySpecies(SpeciesId.TORNADUS);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemy = game.field.getEnemyPokemon();
expect(enemy.isGrounded()).toBe(false);
game.move.use(move);
await game.toEndOfTurn();
expect(enemy).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(enemy.isGrounded()).toBe(true);
});
it("should affect targets with Levitate", async () => {
game.override.enemyPassiveAbility(AbilityId.LEVITATE);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.isGrounded()).toBe(false);
game.move.use(MoveId.THOUSAND_ARROWS);
await game.toEndOfTurn();
expect(eelektross).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
expect(eelektross.isGrounded()).toBe(true);
});
it.each([
{ name: "TELEKINESIS", tag: BattlerTagType.TELEKINESIS },
{ name: "FLOATING", tag: BattlerTagType.FLOATING },
])("should cancel the effects of BattlerTagType.$name", async ({ tag }) => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const eelektross = game.field.getEnemyPokemon();
eelektross.addTag(tag);
game.move.use(MoveId.SMACK_DOWN);
await game.toEndOfTurn();
expect(eelektross).not.toHaveBattlerTag(tag);
expect(eelektross).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
});
// NB: This test might sound useless, but semi-invulnerable pokemon are technically considered "ungrounded"
// by most things
it("should not ground semi-invulnerable targets hit via No Guard unless already ungrounded", async () => {
game.override.ability(AbilityId.NO_GUARD);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.use(MoveId.THOUSAND_ARROWS);
await game.move.forceEnemyMove(MoveId.DIG);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Eelektross took damage but was not forcibly grounded
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.isGrounded()).toBe(true);
expect(eelektross).not.toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
});
// TODO: Sky drop is currently partially implemented
it.todo("should hit midair targets from Sky Drop without interrupting");
describe("Thousand Arrows", () => {
it("should deal a fixed 1x damage to ungrounded flying-types", async () => {
game.override.enemySpecies(SpeciesId.ARCHEOPS);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const archeops = game.field.getEnemyPokemon();
game.move.use(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to("MoveEffectPhase", false);
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toEndOfTurn();
expect(hitSpy).toHaveReturnedWith([expect.anything(), 1]);
expect(archeops).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(archeops.isGrounded()).toBe(true);
expect(archeops.hp).toBeLessThan(archeops.getMaxHp());
});
});
});

View File

@ -1,10 +1,13 @@
import { allMoves } from "#data/data-lists";
import { TerrainType } from "#app/data/terrain";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { HitCheckResult } from "#enums/hit-check-result";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -26,114 +29,117 @@ describe("Moves - Telekinesis", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.TELEKINESIS, MoveId.TACKLE, MoveId.MUD_SHOT, MoveId.SMACK_DOWN])
.battleStyle("single")
.enemySpecies(SpeciesId.SNORLAX)
.enemyLevel(60)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH]);
.enemyMoveset(MoveId.SPLASH);
});
it("Telekinesis makes the affected vulnerable to most attacking moves regardless of accuracy", async () => {
it("should cause opposing non-OHKO moves to always hit the target", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.TACKLE], "accuracy", "get").mockReturnValue(0);
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.isFullHp()).toBe(false);
expect(enemy).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemy).toHaveBattlerTag(BattlerTagType.FLOATING);
game.move.use(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceMiss();
await game.toEndOfTurn();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(player.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it("Telekinesis makes the affected airborne and immune to most Ground-moves", async () => {
it("should forcibly unground the target", async () => {
game.override.terrain(TerrainType.ELECTRIC);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
// Use Earthquake - should be ineffective
game.move.use(MoveId.EARTHQUAKE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase", false);
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.MUD_SHOT], "accuracy", "get").mockReturnValue(100);
game.move.select(MoveId.MUD_SHOT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.isFullHp()).toBe(true);
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(hitSpy).toHaveLastReturnedWith([HitCheckResult.NO_EFFECT, 0]);
// Use Spore - should succeed due to being ungrounded
game.move.use(MoveId.SPORE);
await game.toEndOfTurn();
expect(enemy.status?.effect).toBe(StatusEffect.SLEEP);
});
it("Telekinesis can still affect Pokemon that have been transformed into invalid Pokemon", async () => {
game.override.enemyMoveset(MoveId.TRANSFORM);
// TODO: Make an it.each testing the invalid species for Telekinesis
it.todo.each([])("should fail if used on $name", () => {});
it("should still affect enemies transformed into invalid Pokemon", async () => {
game.override.enemyAbility(AbilityId.IMPOSTER);
await game.classicMode.startBattle([SpeciesId.DIGLETT]);
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
game.move.use(MoveId.TELEKINESIS);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
expect(enemyOpponent).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemyOpponent).toHaveBattlerTag(BattlerTagType.FLOATING);
expect(enemyOpponent.summonData.speciesForm?.speciesId).toBe(SpeciesId.DIGLETT);
});
it("Moves like Smack Down and 1000 Arrows remove all effects of Telekinesis from the target Pokemon", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
await game.toNextTurn();
game.move.select(MoveId.SMACK_DOWN);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeUndefined();
});
it("Ingrain will remove the floating effect of Telekinesis, but not the 100% hit", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.INGRAIN]);
it("should become grounded when Ingrain is used, but not remove the guaranteed hit effect", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.MUD_SHOT], "accuracy", "get").mockReturnValue(0);
game.move.select(MoveId.MUD_SHOT);
await game.move.selectEnemyMove(MoveId.INGRAIN);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.INGRAIN)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
game.move.use(MoveId.MUD_SHOT);
await game.move.forceEnemyMove(MoveId.INGRAIN);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase");
await game.move.forceMiss();
await game.toEndOfTurn();
expect(enemy).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemy).toHaveBattlerTag(BattlerTagType.INGRAIN);
expect(enemy).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(enemy).not.toHaveBattlerTag(BattlerTagType.FLOATING);
expect(enemy.isGrounded()).toBe(true);
expect(playerPokemon).toHaveUsedMove({ move: MoveId.MUD_SHOT, result: MoveResult.SUCCESS });
});
it("should not be baton passed onto a mega gengar", async () => {
game.override
.moveset([MoveId.BATON_PASS])
.enemyMoveset([MoveId.TELEKINESIS])
.starterForms({ [SpeciesId.GENGAR]: 1 });
it("should not be baton passable onto a mega gengar", async () => {
game.override.starterForms({ [SpeciesId.GENGAR]: 1 });
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.GENGAR]);
game.move.select(MoveId.BATON_PASS);
game.move.use(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.move.forceEnemyMove(MoveId.TELEKINESIS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
await game.phaseInterceptor.to("MoveEndPhase");
expect(game.field.getPlayerPokemon()).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon()).not.toHaveBattlerTag(BattlerTagType.TELEKINESIS);
});
});

View File

@ -1,89 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { BerryPhase } from "#phases/berry-phase";
import { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Thousand Arrows", () => {
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
.battleStyle("single")
.enemySpecies(SpeciesId.TOGETIC)
.startingLevel(100)
.enemyLevel(100)
.moveset([MoveId.THOUSAND_ARROWS])
.enemyMoveset(MoveId.SPLASH);
});
it("move should hit and ground Flying-type targets", async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Enemy should not be grounded before move effect is applied
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
it("move should hit and ground targets with Levitate", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX).enemyAbility(AbilityId.LEVITATE);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Enemy should not be grounded before move effect is applied
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
it("move should hit and ground targets under the effects of Magnet Rise", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
enemyPokemon.addTag(BattlerTagType.FLOATING, undefined, MoveId.MAGNET_RISE);
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
});

View File

@ -112,7 +112,7 @@ describe("Weird Dream - Mystery Encounter", () => {
it("should transform the new party into new species, 2 at +90/+110, the rest at +40/50 BST", async () => {
await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty);
const pokemonPrior = scene.getPlayerParty().map(pokemon => pokemon);
const pokemonPrior = scene.getPlayerParty().slice();
const bstsPrior = pokemonPrior.map(species => species.getSpeciesForm().getBaseStatTotal());
await runMysteryEncounterToEnd(game, 1);

View File

@ -0,0 +1,82 @@
import * as account from "#app/account";
import * as bypassLoginModule from "#app/global-vars/bypass-login";
import { pokerogueApi } from "#app/plugins/api/pokerogue-api";
import type { SessionSaveData } from "#app/system/game-data";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("System - Rename Run", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.SPLASH])
.battleStyle("single")
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
describe("renameSession", () => {
beforeEach(() => {
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(false);
vi.spyOn(account, "updateUserInfo").mockImplementation(async () => [true, 1]);
});
it("should return false if slotId < 0", async () => {
const result = await game.scene.gameData.renameSession(-1, "Named Run");
expect(result).toEqual(false);
});
it("should return false if getSession returns null", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue(null as unknown as SessionSaveData);
const result = await game.scene.gameData.renameSession(-1, "Named Run");
expect(result).toEqual(false);
});
it("should return true if bypassLogin is true", async () => {
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true);
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(true);
});
it("should return false if api returns error", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("Unknown Error!");
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(false);
});
it("should return true if api is succesfull", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("");
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(true);
expect(account.updateUserInfo).toHaveBeenCalled();
});
});
});

View File

@ -1,7 +1,9 @@
/** biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { NewArenaEvent } from "#events/battle-scene";
/** biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { TerrainType } from "#app/data/terrain";
import type { BattleStyle, RandomTrainerOverride } from "#app/overrides";
import Overrides from "#app/overrides";
import { AbilityId } from "#enums/ability-id";
@ -359,6 +361,19 @@ export class OverridesHelper extends GameManagerHelper {
return this;
}
/**
* Override the starting {@linkcode TerrainType} that will be set on entering a new biome.
* @param type - The {@linkcode TerrainType} to set.
* @returns `this`
* @remarks
* The newly added terrain will be refreshed upon reaching a new biome, and will be overridden as normal if a new terrain is set.
*/
public terrain(type: TerrainType): this {
vi.spyOn(Overrides, "STARTING_TERRAIN_OVERRIDE", "get").mockReturnValue(type);
this.log(`Starting terrain for next biome set to ${TerrainType[type]} (=${type})!`);
return this;
}
/**
* Override the seed
* @param seed - The seed to set

View File

@ -36,6 +36,7 @@ import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "#phases/next-encounter-phase";
import { PartyExpPhase } from "#phases/party-exp-phase";
import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase";
@ -146,6 +147,7 @@ export class PhaseInterceptor {
[PositionalTagPhase, this.startPhase],
[PokemonTransformPhase, this.startPhase],
[MysteryEncounterPhase, this.startPhase],
[PokemonHealPhase, this.startPhase],
[MysteryEncounterOptionSelectedPhase, this.startPhase],
[MysteryEncounterBattlePhase, this.startPhase],
[MysteryEncounterRewardsPhase, this.startPhase],