Compare commits

...

70 Commits

Author SHA1 Message Date
Bertie690
c7bbfacbb0
Merge d776df4b21 into 23271901cf 2025-08-14 01:12:43 +00:00
Sirz Benjie
d776df4b21
Merge branch 'beta' into rest 2025-08-13 20:12:40 -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
3ffa13e6b7 Merge remote-tracking branch 'upstream/beta' into rest 2025-08-10 23:48:41 -04:00
Bertie690
d85bcf2324 ran boime 2025-08-05 23:20:29 -04:00
Bertie690
7264f56fa1
Merge branch 'beta' into rest 2025-08-05 07:56:30 -04:00
Bertie690
c34134159d ran biome 2025-08-03 12:58:49 -04:00
Bertie690
99e1163752
Merge branch 'beta' into rest 2025-08-03 12:51:48 -04:00
Bertie690
a82768f040
Update toxic spikes status set text to be quiet 2025-08-03 12:28:25 -04:00
Bertie690
8b1b1cd38a
Merge branch 'beta' into rest 2025-07-29 22:48:50 -04:00
Bertie690
d6c77e0afa Fixed test file syntax err 2025-07-28 12:07:45 -04:00
Bertie690
9ee0bfb84b Merge remote-tracking branch 'upstream/beta' into rest 2025-07-28 11:40:39 -04:00
Bertie690
c36394b781 ddddd 2025-07-25 19:42:11 -04:00
Bertie690
a1c718322f Merge remote-tracking branch 'upstream/beta' into rest 2025-07-25 19:23:59 -04:00
Bertie690
2df1eaff10 Merge remote-tracking branch 'upstream/beta' into rest 2025-07-24 15:03:40 -04:00
Bertie690
b3eea02779
Merge branch 'beta' into rest 2025-07-17 19:05:07 -04:00
Bertie690
b55872ef32
Merge branch 'beta' into rest 2025-07-15 09:09:51 +02:00
Bertie690
ff6ec7f945
Revert pokemon-heal-phase.ts 2025-07-15 09:05:33 +02:00
NightKev
af77e2ef7f Fix merge issues and apply Biome 2025-07-13 00:49:14 -07:00
NightKev
6edf75ca42 Merge branch 'beta' into rest 2025-07-13 00:47:48 -07:00
Bertie690
bda0f39df0
Update obtain-status-effect-phase.ts
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-07-13 08:48:42 +02:00
Bertie690
1bfdd8f24b
Update modifier.ts
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-07-13 08:38:09 +02:00
Bertie690
19c0fba6f4
Update modifier.ts
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-07-13 08:38:03 +02:00
Bertie690
259a9b328d
Update pokemon.ts
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-07-13 08:37:44 +02:00
NightKev
1f9eaf49c6 Remove missed line in TSDocs 2025-07-12 23:26:14 -07:00
Bertie690
e1c999566e
Update pokemon.ts documentation 2025-07-13 08:20:43 +02:00
Sirz Benjie
1cb1797f3b
Merge branch 'beta' into rest 2025-07-13 00:05:14 -06:00
Bertie690
e385c73188
Update corrosion.test.ts 2025-07-12 13:29:20 +02:00
Bertie690
4fd44e8029
Update move.ts 2025-07-12 13:28:26 +02:00
Bertie690
d614588783
Fix pokemon.ts 2025-07-12 13:26:45 +02:00
Bertie690
adeedb84d5
Merge branch 'beta' into rest 2025-07-12 13:20:33 +02:00
Bertie690
eb1696e93e Fixed infiltrator bug 2025-06-24 12:03:49 -04:00
Bertie690
7d0ee7a9ed Merge remote-tracking branch 'upstream/beta' into rest 2025-06-23 22:54:01 -04:00
Bertie690
d82c105d55 Ran linting 2025-06-23 19:07:25 -04:00
Bertie690
e7b5f5fe99 Merge remote-tracking branch 'upstream/beta' into rest 2025-06-23 15:12:53 -04:00
Bertie690
fefa8e408f Fixed corrosion test 2025-06-17 16:42:27 -04:00
Bertie690
903d1a33dd Fixed the tests 2025-06-17 16:20:12 -04:00
Bertie690
7f2766f832 ddddd 2025-06-17 16:05:43 -04:00
Bertie690
c6c3cd9f3c Added pokemon heal phase to the turn queue 2025-06-17 16:04:27 -04:00
Bertie690
0c3ae62d1e Fixed tests 2025-06-17 15:46:56 -04:00
Bertie690
779c95ba93 Merge remote-tracking branch 'upstream/beta' into rest 2025-06-17 15:11:20 -04:00
Bertie690
0402b07122 wip 2025-06-15 13:02:46 -04:00
Bertie690
ba39af1bbe Fixed tests 2025-06-14 20:03:00 -04:00
Bertie690
d20a47d082 readded swallow conds 2025-06-14 15:08:32 -04:00
Bertie690
9301e6db87 Fixed rest thing...? 2025-06-14 15:03:53 -04:00
Bertie690
c24f630caf fixed stockpile to no longer fail on stack full 2025-06-14 14:52:09 -04:00
Bertie690
14f5849502 Fixed cirrc dep isuse 2025-06-14 13:55:24 -04:00
Bertie690
374474720b Fixed pollen puff 2025-06-14 13:46:58 -04:00
Bertie690
2eae68785d Reverted swallow test
fixing in other prs
2025-06-14 13:39:55 -04:00
Bertie690
c919ea78cb Fixed swallow test 2025-06-14 12:54:27 -04:00
Bertie690
e40b1bd452 Added pollen puff tests back again 2025-06-14 12:31:01 -04:00
Bertie690
a179fdeac6 Fixed the tests 2025-06-14 12:27:01 -04:00
Bertie690
455f3b6be1 Merge remote-tracking branch 'upstream/beta' into rest 2025-06-14 11:20:41 -04:00
Bertie690
059b9b2a95 Condensed all status immunity tests under 1 roof 2025-06-14 11:18:55 -04:00
Bertie690
b9a4e631db Fixed message code a bit 2025-06-14 10:30:37 -04:00
Bertie690
3e2d050d70 Reverted healing changes to move to other PR 2025-06-14 09:45:23 -04:00
Bertie690
8a10cc2037 Split up trySetStatus fully; fixed rest turn order display to match mainline 2025-06-14 09:35:59 -04:00
Bertie690
c76739f629 Merge remote-tracking branch 'upstream/beta' into rest 2025-06-13 15:53:34 -04:00
Bertie690
6f676e4438 Maybe did stuff 2025-06-13 15:41:42 -04:00
Bertie690
3bd10f09e9 Added edge case to rest about locales 2025-06-05 13:59:51 -04:00
Bertie690
8a915d2dc8 Merge remote-tracking branch 'upstream/beta' into rest 2025-06-05 13:52:37 -04:00
Bertie690
d456b3849a Merge remote-tracking branch 'upstream/beta' into rest 2025-06-03 15:33:46 -04:00
Bertie690
82920fb059 Merge remote-tracking branch 'upstream/beta' into rest 2025-05-29 12:12:43 -04:00
Bertie690
4d93958627
Merge branch 'beta' into rest 2025-05-27 09:29:41 -04:00
Bertie690
47c45bc63e Cleaned up comments and such 2025-05-27 09:28:40 -04:00
Bertie690
db927e8adb Fixed bugs, split up status code, re-added required Rest parameter 2025-05-27 08:44:18 -04:00
Bertie690
9bfb1bba88 Merge remote-tracking branch 'upstream/beta' into rest 2025-05-25 19:50:07 -04:00
Bertie690
96e4bb5e0e Fixed rest bug 2025-05-25 19:50:01 -04:00
Bertie690
45bbaf2b25 Reworked status code, fixed bugs and added Rest tests 2025-05-20 21:44:07 -04:00
41 changed files with 1174 additions and 739 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

@ -1238,7 +1238,7 @@ export class PostDefendContactApplyStatusEffectAbAttr extends PostDefendAbAttr {
// TODO: Probably want to check against simulated here
const effect =
this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randBattleSeedInt(this.effects.length)];
attacker.trySetStatus(effect, true, pokemon);
attacker.trySetStatus(effect, pokemon);
}
}
@ -2231,7 +2231,7 @@ export class PostAttackApplyStatusEffectAbAttr extends PostAttackAbAttr {
apply({ pokemon, opponent }: PostMoveInteractionAbAttrParams): void {
const effect =
this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randBattleSeedInt(this.effects.length)];
opponent.trySetStatus(effect, true, pokemon);
opponent.trySetStatus(effect, pokemon);
}
}
@ -2386,7 +2386,7 @@ export class SynchronizeStatusAbAttr extends PostSetStatusAbAttr {
*/
override apply({ simulated, effect, sourcePokemon, pokemon }: PostSetStatusAbAttrParams): void {
if (!simulated && sourcePokemon) {
sourcePokemon.trySetStatus(effect, true, pokemon);
sourcePokemon.trySetStatus(effect, pokemon);
}
}
}
@ -3662,7 +3662,8 @@ export class PreSetStatusEffectImmunityAbAttr extends PreSetStatusAbAttr {
protected immuneEffects: StatusEffect[];
/**
* @param immuneEffects - The status effects to which the Pokémon is immune.
* @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application.
* If none are provided, will block **all** status effects regardless of type.
*/
constructor(...immuneEffects: StatusEffect[]) {
super();
@ -3671,7 +3672,7 @@ export class PreSetStatusEffectImmunityAbAttr extends PreSetStatusAbAttr {
}
override canApply({ effect }: PreSetStatusAbAttrParams): boolean {
return (effect !== StatusEffect.FAINT && this.immuneEffects.length < 1) || this.immuneEffects.includes(effect);
return (this.immuneEffects.length === 0 && effect !== StatusEffect.FAINT) || this.immuneEffects.includes(effect);
}
/**
@ -3723,6 +3724,11 @@ export interface UserFieldStatusEffectImmunityAbAttrParams extends AbAttrBasePar
*/
export class UserFieldStatusEffectImmunityAbAttr extends AbAttr {
protected immuneEffects: StatusEffect[];
/**
* @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application.
* If none are provided, will block **all** status effects regardless of type.
*/
constructor(...immuneEffects: StatusEffect[]) {
super();
@ -3731,7 +3737,7 @@ export class UserFieldStatusEffectImmunityAbAttr extends AbAttr {
override canApply({ effect, cancelled }: UserFieldStatusEffectImmunityAbAttrParams): boolean {
return (
(!cancelled.value && effect !== StatusEffect.FAINT && this.immuneEffects.length < 1) ||
(!cancelled.value && this.immuneEffects.length === 0 && effect !== StatusEffect.FAINT) ||
this.immuneEffects.includes(effect)
);
}
@ -3757,6 +3763,10 @@ export class ConditionalUserFieldStatusEffectImmunityAbAttr extends UserFieldSta
*/
private condition: (target: Pokemon, source: Pokemon | null) => boolean;
/**
* @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application.
* If none are provided, will block **all** status effects regardless of type.
*/
constructor(condition: (target: Pokemon, source: Pokemon | null) => boolean, ...immuneEffects: StatusEffect[]) {
super(...immuneEffects);
@ -7482,8 +7492,7 @@ export function initAbilities() {
.unsuppressable()
.bypassFaint(),
new Ability(AbilityId.CORROSION, 7)
.attr(IgnoreTypeStatusEffectImmunityAbAttr, [ StatusEffect.POISON, StatusEffect.TOXIC ], [ PokemonType.STEEL, PokemonType.POISON ])
.edgeCase(), // Should poison itself with toxic orb.
.attr(IgnoreTypeStatusEffectImmunityAbAttr, [ StatusEffect.POISON, StatusEffect.TOXIC ], [ PokemonType.STEEL, PokemonType.POISON ]),
new Ability(AbilityId.COMATOSE, 7)
.attr(StatusEffectImmunityAbAttr, ...getNonVolatileStatusEffects())
.attr(BattlerTagImmunityAbAttr, BattlerTagType.DROWSY)

View File

@ -890,32 +890,31 @@ class ToxicSpikesTag extends ArenaTrapTag {
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (pokemon.isGrounded()) {
if (simulated) {
return true;
}
if (pokemon.isOfType(PokemonType.POISON)) {
this.#neutralized = true;
if (globalScene.arena.removeTag(this.tagType)) {
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: this.getMoveName(),
}),
);
return true;
}
} else if (!pokemon.status) {
const toxic = this.layers > 1;
if (
pokemon.trySetStatus(!toxic ? StatusEffect.POISON : StatusEffect.TOXIC, true, null, 0, this.getMoveName())
) {
return true;
}
}
if (!pokemon.isGrounded()) {
return false;
}
return false;
if (simulated) {
return true;
}
if (pokemon.isOfType(PokemonType.POISON)) {
// Neutralize the tag and remove it from the field.
// Message cannot be moved to `onRemove` as that requires a reference to the neutralizing pokemon
this.#neutralized = true;
globalScene.arena.removeTagOnSide(this.tagType, this.side);
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: this.getMoveName(),
}),
);
return true;
}
// Attempt to poison the target, suppressing any immunity messages that arise.
const effect = this.layers === 1 ? StatusEffect.POISON : StatusEffect.TOXIC;
return pokemon.trySetStatus(effect, null, undefined, this.getMoveName(), false, true);
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {

View File

@ -563,7 +563,7 @@ export class BeakBlastChargingTag extends BattlerTag {
target: pokemon,
})
) {
phaseData.attacker.trySetStatus(StatusEffect.BURN, true, pokemon);
phaseData.attacker.trySetStatus(StatusEffect.BURN, pokemon);
}
return true;
}
@ -1509,7 +1509,7 @@ export class DrowsyTag extends SerializableBattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (!super.lapse(pokemon, lapseType)) {
pokemon.trySetStatus(StatusEffect.SLEEP, true);
pokemon.trySetStatus(StatusEffect.SLEEP);
return false;
}
@ -1859,7 +1859,7 @@ export class ContactSetStatusProtectedTag extends DamageProtectedTag {
* @param user - The pokemon that is being attacked and has the tag
*/
override onContact(attacker: Pokemon, user: Pokemon): void {
attacker.trySetStatus(this.#statusEffect, true, user);
attacker.trySetStatus(this.#statusEffect, user);
}
}
@ -2803,7 +2803,7 @@ export class GulpMissileTag extends SerializableBattlerTag {
if (this.tagType === BattlerTagType.GULP_MISSILE_ARROKUDA) {
globalScene.phaseManager.unshiftNew("StatStageChangePhase", attacker.getBattlerIndex(), false, [Stat.DEF], -1);
} else {
attacker.trySetStatus(StatusEffect.PARALYSIS, true, pokemon);
attacker.trySetStatus(StatusEffect.PARALYSIS, pokemon);
}
}
return false;

View File

@ -88,7 +88,7 @@ import type { AttackMoveResult } from "#types/attack-move-result";
import type { Localizable } from "#types/locales";
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types";
import type { TurnMove } from "#types/turn-move";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { BooleanHolder, coerceArray, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
@ -1184,8 +1184,9 @@ export abstract class MoveAttr {
}
/**
* @virtual
* @returns the {@linkcode MoveCondition} or {@linkcode MoveConditionFunc} for this {@linkcode Move}
* Return this `MoveAttr`'s associated {@linkcode MoveCondition} or {@linkcode MoveConditionFunc}.
* The specified condition will be added to all {@linkcode Move}s with this attribute,
* and moves **will fail upon use** if _at least 1_ of their attached conditions returns `false`.
*/
getCondition(): MoveCondition | MoveConditionFunc | null {
return null;
@ -1298,15 +1299,21 @@ export class MoveEffectAttr extends MoveAttr {
}
/**
* Determines whether the {@linkcode Move}'s effects are valid to {@linkcode apply}
* @virtual
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* @param args Set of unique arguments needed by this attribute
* @returns true if basic application of the ability attribute should be possible
* Determine whether this {@linkcode MoveAttr}'s effects are able to {@linkcode apply | be applied} to the target.
*
* Will **NOT** cause the move to fail upon returning `false` (unlike {@linkcode getCondition};
* merely that the effect for this attribute will be nullified.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} being targeted by the move, or {@linkcode user} if the move is
* {@linkcode selfTarget | self-targeting}
* @param move - The {@linkcode Move} being used
* @param _args - Set of unique arguments needed by this attribute
* @returns `true` if basic application of this `MoveAttr`s effects should be possible
*/
canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) {
// TODO: Decouple this check from the `apply` step
// TODO: Make non-damaging moves fail by default if none of their attributes can apply
canApply(user: Pokemon, target: Pokemon, move: Move, _args?: any[]) {
// TODO: These checks seem redundant
return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp)
&& (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) ||
move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target }));
@ -1955,19 +1962,17 @@ export class AddSubstituteAttr extends MoveEffectAttr {
* @see {@linkcode apply}
*/
export class HealAttr extends MoveEffectAttr {
/** The percentage of {@linkcode Stat.HP} to heal */
private healRatio: number;
/** Should an animation be shown? */
private showAnim: boolean;
constructor(healRatio?: number, showAnim?: boolean, selfTarget?: boolean) {
super(selfTarget === undefined || selfTarget);
this.healRatio = healRatio || 1;
this.showAnim = !!showAnim;
constructor(
/** The percentage of {@linkcode Stat.HP} to heal. */
private healRatio: number,
/** Whether to display a healing animation when healing the target; default `false` */
private showAnim = false,
selfTarget = true
) {
super(selfTarget);
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
this.addHealPhase(this.selfTarget ? user : target, this.healRatio);
return true;
}
@ -1976,15 +1981,65 @@ export class HealAttr extends MoveEffectAttr {
* Creates a new {@linkcode PokemonHealPhase}.
* This heals the target and shows the appropriate message.
*/
addHealPhase(target: Pokemon, healRatio: number) {
protected addHealPhase(target: Pokemon, healRatio: number) {
globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(),
toDmgValue(target.getMaxHp() * healRatio), i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(target) }), true, !this.showAnim);
}
getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
override getTargetBenefitScore(user: Pokemon, target: Pokemon, _move: Move): number {
const score = ((1 - (this.selfTarget ? user : target).getHpRatio()) * 20) - this.healRatio * 10;
return Math.round(score / (1 - this.healRatio / 2));
}
// TODO: Change to fail move
override canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean {
if (!super.canApply(user, target, _move, _args)) {
return false;
}
const healedPokemon = this.selfTarget ? user : target;
if (healedPokemon.isFullHp()) {
globalScene.phaseManager.queueMessage(i18next.t("battle:hpIsFull", {
pokemonName: getPokemonNameWithAffix(healedPokemon),
}))
return false;
}
return true;
}
}
/**
* Attribute to put the user to sleep for a fixed duration, fully heal them and cure their status.
* Used for {@linkcode MoveId.REST}.
*/
export class RestAttr extends HealAttr {
private duration: number;
constructor(duration: number) {
super(1, true);
this.duration = duration;
}
override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const wasSet = user.trySetStatus(StatusEffect.SLEEP, user, this.duration, null, true, true,
i18next.t("moveTriggers:restBecameHealthy", {
pokemonName: getPokemonNameWithAffix(user),
}));
return wasSet && super.apply(user, target, move, args);
}
override addHealPhase(user: Pokemon): void {
globalScene.phaseManager.unshiftNew("PokemonHealPhase", user.getBattlerIndex(), user.getMaxHp(), null)
}
// TODO: change after HealAttr is changed to fail move
override getCondition(): MoveConditionFunc {
return (user, target, move) =>
super.canApply(user, target, move, [])
// Intentionally suppress messages here as we display generic fail msg
// TODO: This might have order-of-operation jank
&& user.canSetStatus(StatusEffect.SLEEP, true, true, user)
}
}
/**
@ -2256,20 +2311,9 @@ export class BoostHealAttr extends HealAttr {
* @see {@linkcode apply}
*/
export class HealOnAllyAttr extends HealAttr {
/**
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* @param args N/A
* @returns true if the function succeeds
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (user.getAlly() === target) {
super.apply(user, target, move, args);
return true;
}
return false;
override canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean {
// Don't trigger if not targeting an ally
return target === user.getAlly() && super.canApply(user, target, _move, _args);
}
}
@ -2280,6 +2324,7 @@ export class HealOnAllyAttr extends HealAttr {
* @see {@linkcode apply}
* @see {@linkcode getUserBenefitScore}
*/
// TODO: Make Strength Sap its own attribute that extends off of this one
export class HitHealAttr extends MoveEffectAttr {
private healRatio: number;
private healStat: EffectiveStat | null;
@ -2530,49 +2575,50 @@ export class WaterShurikenMultiHitTypeAttr extends ChangeMultiHitTypeAttr {
export class StatusEffectAttr extends MoveEffectAttr {
public effect: StatusEffect;
public turnsRemaining?: number;
public overrideStatus: boolean = false;
constructor(effect: StatusEffect, selfTarget?: boolean, turnsRemaining?: number, overrideStatus: boolean = false) {
constructor(effect: StatusEffect, selfTarget = false) {
super(selfTarget);
this.effect = effect;
this.turnsRemaining = turnsRemaining;
this.overrideStatus = overrideStatus;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
const statusCheck = moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance;
if (!statusCheck) {
return false;
}
// non-status moves don't play sound effects for failures
const quiet = move.category !== MoveCategory.STATUS;
if (statusCheck) {
const pokemon = this.selfTarget ? user : target;
if (user !== target && move.category === MoveCategory.STATUS && !target.canSetStatus(this.effect, quiet, false, user, true)) {
return false;
}
if (((!pokemon.status || this.overrideStatus) || (pokemon.status.effect === this.effect && moveChance < 0))
&& pokemon.trySetStatus(this.effect, true, user, this.turnsRemaining, null, this.overrideStatus, quiet)) {
applyAbAttrs("ConfusionOnStatusEffectAbAttr", {pokemon: user, opponent: target, move, effect: this.effect});
return true;
}
if (
target.trySetStatus(this.effect, user, undefined, null, false, quiet)
) {
applyAbAttrs("ConfusionOnStatusEffectAbAttr", {pokemon: user, opponent: target, move, effect: this.effect});
return true;
}
return false;
}
getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, false);
const score = (moveChance < 0) ? -10 : Math.floor(moveChance * -0.1);
const score = moveChance < 0 ? -10 : Math.floor(moveChance * -0.1);
const pokemon = this.selfTarget ? user : target;
return !pokemon.status && pokemon.canSetStatus(this.effect, true, false, user) ? score : 0;
return pokemon.canSetStatus(this.effect, true, false, user) ? score : 0;
}
}
/**
* Attribute to randomly apply one of several statuses to the target.
* Used for {@linkcode Moves.TRI_ATTACK} and {@linkcode Moves.DIRE_CLAW}.
*/
export class MultiStatusEffectAttr extends StatusEffectAttr {
public effects: StatusEffect[];
constructor(effects: StatusEffect[], selfTarget?: boolean, turnsRemaining?: number, overrideStatus?: boolean) {
super(effects[0], selfTarget, turnsRemaining, overrideStatus);
constructor(effects: StatusEffect[], selfTarget?: boolean) {
super(effects[0], selfTarget);
this.effects = effects;
}
@ -2605,26 +2651,41 @@ export class PsychoShiftEffectAttr extends MoveEffectAttr {
* @returns - Whether the effect was successfully applied to the target.
*/
apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
const statusToApply: StatusEffect | undefined = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined);
const statusToApply = user.status?.effect ??
(user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE);
if (target.status || !statusToApply) {
// Bang is justified as condition func returns early if no status is found
if (!target.trySetStatus(statusToApply, user)) {
return false;
} else {
const canSetStatus = target.canSetStatus(statusToApply, true, false, user);
const trySetStatus = canSetStatus ? target.trySetStatus(statusToApply, true, user) : false;
}
if (trySetStatus && user.status) {
// PsychoShiftTag is added to the user if move succeeds so that the user is healed of its status effect after its move
user.addTag(BattlerTagType.PSYCHO_SHIFT);
if (user.status) {
// Add tag to user to heal its status effect after the move ends (unless we have comatose);
// occurs after move use to ensure correct Synchronize timing
user.addTag(BattlerTagType.PSYCHO_SHIFT)
}
return true;
}
getCondition(): MoveConditionFunc {
return (user, target) => {
if (target.status?.effect) {
return false;
}
return trySetStatus;
const statusToApply = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE);
return !!statusToApply && target.canSetStatus(statusToApply, false, false, user);
}
}
getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
const statusToApply = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined);
return !target.status && statusToApply && target.canSetStatus(statusToApply, true, false, user) ? -10 : 0;
const statusToApply =
user.status?.effect ??
(user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE);
// TODO: Give this a positive user benefit score
return !target.status?.effect && statusToApply && target.canSetStatus(statusToApply, true, false, user) ? -10 : 0;
}
}
@ -2684,7 +2745,7 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr {
* Used for Incinerate and Knock Off.
* Not Implemented Cases: (Same applies for Thief)
* "If the user faints due to the target's Ability (Rough Skin or Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item."
* "If the Pokémon is knocked out by the attack, Sticky Hold does not protect the held item.""
* "If the Pokémon is knocked out by the attack, Sticky Hold does not protect the held item."
*/
export class RemoveHeldItemAttr extends MoveEffectAttr {
@ -2894,7 +2955,7 @@ export class HealStatusEffectAttr extends MoveEffectAttr {
*/
constructor(selfTarget: boolean, effects: StatusEffect | StatusEffect[]) {
super(selfTarget, { lastHitOnly: true });
this.effects = [ effects ].flat(1);
this.effects = coerceArray(effects)
}
/**
@ -4421,6 +4482,10 @@ export class SpitUpPowerAttr extends VariablePowerAttr {
* Does NOT remove stockpiled stacks.
*/
export class SwallowHealAttr extends HealAttr {
constructor() {
super(1)
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const stockpilingTag = user.getTag(StockpilingTag);
@ -7903,7 +7968,7 @@ export class StatusIfBoostedAttr extends MoveEffectAttr {
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (target.turnData.statStagesIncreased) {
target.trySetStatus(this.effect, true, user);
target.trySetStatus(this.effect, user);
}
return true;
}
@ -8050,11 +8115,11 @@ const failIfDampCondition: MoveConditionFunc = (user, target, move) => {
return !cancelled.value;
};
const userSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => user.status?.effect === StatusEffect.SLEEP || user.hasAbility(AbilityId.COMATOSE);
const userSleptOrComatoseCondition: MoveConditionFunc = (user) => user.status?.effect === StatusEffect.SLEEP || user.hasAbility(AbilityId.COMATOSE);
const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE);
const targetSleptOrComatoseCondition: MoveConditionFunc = (_user: Pokemon, target: Pokemon, _move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE);
const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.phaseQueue.find(phase => phase.is("MovePhase")) !== undefined;
const failIfLastCondition: MoveConditionFunc = () => globalScene.phaseManager.findPhase(phase => phase.is("MovePhase")) !== undefined;
const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => {
const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
@ -8932,9 +8997,7 @@ export function initMoves() {
.attr(MultiHitAttr, MultiHitType._2)
.makesContact(false),
new SelfStatusMove(MoveId.REST, PokemonType.PSYCHIC, -1, 5, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP, true, 3, true)
.attr(HealAttr, 1, true)
.condition((user, target, move) => !user.isFullHp() && user.canSetStatus(StatusEffect.SLEEP, true, true, user))
.attr(RestAttr, 3)
.triageMove(),
new AttackMove(MoveId.ROCK_SLIDE, PokemonType.ROCK, MoveCategory.PHYSICAL, 75, 90, 10, 30, 0, 1)
.attr(FlinchAttr)
@ -9280,14 +9343,16 @@ export function initMoves() {
.condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3)
.attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3)
.condition(hasStockpileStacksCondition)
.attr(SpitUpPowerAttr, 100)
.condition(hasStockpileStacksCondition)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true),
new SelfStatusMove(MoveId.SWALLOW, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.condition(hasStockpileStacksCondition)
.attr(SwallowHealAttr)
.condition(hasStockpileStacksCondition)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true)
.triageMove(),
.triageMove()
// TODO: Verify if using Swallow at full HP still consumes stacks or not
.edgeCase(),
new AttackMove(MoveId.HEAT_WAVE, PokemonType.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)
.attr(StatusEffectAttr, StatusEffect.BURN)
@ -9673,14 +9738,8 @@ export function initMoves() {
.unimplemented(),
new StatusMove(MoveId.PSYCHO_SHIFT, PokemonType.PSYCHIC, 100, 10, -1, 0, 4)
.attr(PsychoShiftEffectAttr)
.condition((user, target, move) => {
let statusToApply = user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined;
if (user.status?.effect && isNonVolatileStatusEffect(user.status.effect)) {
statusToApply = user.status.effect;
}
return !!statusToApply && target.canSetStatus(statusToApply, false, false, user);
}
),
// TODO: Verify status applied if a statused pokemon obtains Comatose (via Transform) and uses Psycho Shift
.edgeCase(),
new AttackMove(MoveId.TRUMP_CARD, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, -1, 5, -1, 0, 4)
.makesContact()
.attr(LessPPMorePowerAttr),

View File

@ -243,8 +243,9 @@ export const FieryFalloutEncounter: MysteryEncounter = MysteryEncounterBuilder.w
if (burnable?.length > 0) {
const roll = randSeedInt(burnable.length);
const chosenPokemon = burnable[roll];
if (chosenPokemon.trySetStatus(StatusEffect.BURN)) {
if (chosenPokemon.canSetStatus(StatusEffect.BURN, true)) {
// Burn applied
chosenPokemon.doSetStatus(StatusEffect.BURN);
encounter.setDialogueToken("burnedPokemon", chosenPokemon.getNameToRender());
encounter.setDialogueToken("abilityName", allAbilities[AbilityId.HEATPROOF].name);
queueEncounterMessage(`${namespace}:option.2.target_burned`);

View File

@ -309,7 +309,7 @@ export function getRandomSpeciesByStarterCost(
*/
export function koPlayerPokemon(pokemon: PlayerPokemon) {
pokemon.hp = 0;
pokemon.trySetStatus(StatusEffect.FAINT);
pokemon.doSetStatus(StatusEffect.FAINT);
pokemon.updateInfo();
queueEncounterMessage(
i18next.t("battle:fainted", {

View File

@ -1,3 +1,5 @@
/** Enum representing all non-volatile status effects. */
// TODO: Remove StatusEffect.FAINT
export enum StatusEffect {
NONE,
POISON,

View File

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

View File

@ -235,6 +235,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
public ivs: number[];
public nature: Nature;
public moveset: PokemonMove[];
/**
* This Pokemon's current {@link https://m.bulbapedia.bulbagarden.net/wiki/Status_condition#Non-volatile_status | non-volatile status condition},
* or `null` if none exist.
* @todo Make private
*/
public status: Status | null;
public friendship: number;
public metLevel: number;
@ -4744,7 +4749,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @param reason - The reason for the status application failure -
* can be "overlap" (already has same status), "other" (generic fail message)
* or a {@linkcode TerrainType} for terrain-based blockages.
* Defaults to "other".
* Default `"other"`
*/
queueStatusImmuneMessage(
quiet: boolean,
@ -4773,15 +4778,20 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Checks if a status effect can be applied to the Pokemon.
* Check if a status effect can be applied to this {@linkcode Pokemon}.
*
* @param effect The {@linkcode StatusEffect} whose applicability is being checked
* @param quiet Whether in-battle messages should trigger or not
* @param overrideStatus Whether the Pokemon's current status can be overriden
* @param sourcePokemon The Pokemon that is setting the status effect
* @param ignoreField Whether any field effects (weather, terrain, etc.) should be considered
* @param effect - The {@linkcode StatusEffect} whose applicability is being checked
* @param quiet - Whether to suppress in-battle messages for status checks; default `false`
* @param overrideStatus - Whether to allow overriding the Pokemon's current status with a different one; default `false`
* @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target,
* or `null` if the status is applied from a non-Pokemon source (hazards, etc.); default `null`
* @param ignoreField - Whether to ignore field effects (weather, terrain, etc.) preventing status application;
* default `false`
* @returns Whether {@linkcode effect} can be applied to this Pokemon.
*/
canSetStatus(
// TODO: Review and verify the message order precedence in mainline if multiple status-blocking effects are present at once
// TODO: Make argument order consistent with `trySetStatus`
public canSetStatus(
effect: StatusEffect,
quiet = false,
overrideStatus = false,
@ -4789,6 +4799,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
ignoreField = false,
): boolean {
if (effect !== StatusEffect.FAINT) {
// Status-overriding moves (i.e. Rest) fail if their respective status already exists;
// all other moves fail if the target already has _any_ status
if (overrideStatus ? this.status?.effect === effect : this.status) {
this.queueStatusImmuneMessage(quiet, overrideStatus ? "overlap" : "other"); // having different status displays generic fail message
return false;
@ -4801,73 +4813,62 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
const types = this.getTypes(true, true);
/* Whether the target is immune to the specific status being applied. */
let isImmune = false;
/** The reason for a potential blockage; default "other" for type-based. */
let reason: "other" | Exclude<TerrainType, TerrainType.NONE> = "other";
switch (effect) {
case StatusEffect.POISON:
case StatusEffect.TOXIC: {
// Check if the Pokemon is immune to Poison/Toxic or if the source pokemon is canceling the immunity
const poisonImmunity = types.map(defType => {
// Check if the Pokemon is not immune to Poison/Toxic
case StatusEffect.TOXIC:
// Check for type based immunities and/or Corrosion from the applier.
isImmune = types.some(defType => {
// only 1 immunity needed to block
if (defType !== PokemonType.POISON && defType !== PokemonType.STEEL) {
return false;
}
// Check if the source Pokemon has an ability that cancels the Poison/Toxic immunity
// No source (such as from Toxic Spikes) = blocked by default
if (!sourcePokemon) {
return true;
}
const cancelImmunity = new BooleanHolder(false);
// TODO: Determine if we need to pass `quiet` as the value for simulated in this call
if (sourcePokemon) {
applyAbAttrs("IgnoreTypeStatusEffectImmunityAbAttr", {
pokemon: sourcePokemon,
cancelled: cancelImmunity,
statusEffect: effect,
defenderType: defType,
});
if (cancelImmunity.value) {
return false;
}
}
return true;
applyAbAttrs("IgnoreTypeStatusEffectImmunityAbAttr", {
pokemon: sourcePokemon,
cancelled: cancelImmunity,
statusEffect: effect,
defenderType: defType,
});
return !cancelImmunity.value;
});
if (this.isOfType(PokemonType.POISON) || this.isOfType(PokemonType.STEEL)) {
if (poisonImmunity.includes(true)) {
this.queueStatusImmuneMessage(quiet);
return false;
}
}
break;
}
case StatusEffect.PARALYSIS:
if (this.isOfType(PokemonType.ELECTRIC)) {
this.queueStatusImmuneMessage(quiet);
return false;
}
isImmune = this.isOfType(PokemonType.ELECTRIC);
break;
case StatusEffect.SLEEP:
if (this.isGrounded() && globalScene.arena.terrain?.terrainType === TerrainType.ELECTRIC) {
this.queueStatusImmuneMessage(quiet, TerrainType.ELECTRIC);
return false;
}
isImmune = this.isGrounded() && globalScene.arena.getTerrainType() === TerrainType.ELECTRIC;
reason = TerrainType.ELECTRIC;
break;
case StatusEffect.FREEZE:
if (
case StatusEffect.FREEZE: {
const weatherType = globalScene.arena.getWeatherType();
isImmune =
this.isOfType(PokemonType.ICE) ||
(!ignoreField &&
globalScene?.arena?.weather?.weatherType &&
[WeatherType.SUNNY, WeatherType.HARSH_SUN].includes(globalScene.arena.weather.weatherType))
) {
this.queueStatusImmuneMessage(quiet);
return false;
}
(!ignoreField && (weatherType === WeatherType.SUNNY || weatherType === WeatherType.HARSH_SUN));
break;
}
case StatusEffect.BURN:
if (this.isOfType(PokemonType.FIRE)) {
this.queueStatusImmuneMessage(quiet);
return false;
}
isImmune = this.isOfType(PokemonType.FIRE);
break;
}
if (isImmune) {
this.queueStatusImmuneMessage(quiet, reason);
return false;
}
// Check for cancellations from self/ally abilities
const cancelled = new BooleanHolder(false);
applyAbAttrs("StatusEffectImmunityAbAttr", { pokemon: this, effect, cancelled, simulated: quiet });
if (cancelled.value) {
@ -4884,14 +4885,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
source: sourcePokemon,
});
if (cancelled.value) {
break;
return false;
}
}
if (cancelled.value) {
return false;
}
// Perform safeguard checks
if (sourcePokemon && sourcePokemon !== this && this.isSafeguarded(sourcePokemon)) {
if (!quiet) {
globalScene.phaseManager.queueMessage(
@ -4904,18 +4902,36 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return true;
}
trySetStatus(
effect?: StatusEffect,
asPhase = false,
/**
* Attempt to set this Pokemon's status to the specified condition.
* Enqueues a new `ObtainStatusEffectPhase` to trigger animations, etc.
* @param effect - The {@linkcode StatusEffect} to set
* @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target,
* or `null` if the status is applied from a non-Pokemon source (hazards, etc.); default `null`
* @param sleepTurnsRemaining - The number of turns to set {@linkcode StatusEffect.SLEEP} for;
* defaults to a random number between 2 and 4 and is unused for non-Sleep statuses
* @param sourceText - The text to show for the source of the status effect, if any; default `null`
* @param overrideStatus - Whether to allow overriding the Pokemon's current status with a different one; default `false`
* @param quiet - Whether to suppress in-battle messages for status checks; default `true`
* @param overrideMessage - String containing text to be displayed upon status setting; defaults to normal key for status
* and is used exclusively for Rest
* @returns Whether the status effect phase was successfully created.
* @see {@linkcode doSetStatus} - alternate function that sets status immediately (albeit without condition checks).
*/
public trySetStatus(
effect: StatusEffect,
sourcePokemon: Pokemon | null = null,
turnsRemaining = 0,
sleepTurnsRemaining?: number,
sourceText: string | null = null,
overrideStatus?: boolean,
quiet = true,
overrideMessage?: string,
): boolean {
// TODO: This needs to propagate failure status for status moves
if (!effect) {
return false;
}
if (!this.canSetStatus(effect, quiet, overrideStatus, sourcePokemon)) {
return false;
}
@ -4935,48 +4951,79 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
}
if (asPhase) {
if (overrideStatus) {
this.resetStatus(false);
}
globalScene.phaseManager.unshiftNew(
"ObtainStatusEffectPhase",
this.getBattlerIndex(),
effect,
turnsRemaining,
sourceText,
sourcePokemon,
);
return true;
if (overrideStatus) {
this.resetStatus(false);
}
let sleepTurnsRemaining: NumberHolder;
globalScene.phaseManager.unshiftNew(
"ObtainStatusEffectPhase",
this.getBattlerIndex(),
effect,
sourcePokemon,
sleepTurnsRemaining,
sourceText,
overrideMessage,
);
return true;
}
/**
* Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect.
* @param effect - The {@linkcode StatusEffect} to set
* @remarks
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
doSetStatus(effect: Exclude<StatusEffect, StatusEffect.SLEEP>): void;
/**
* Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect.
* @param effect - {@linkcode StatusEffect.SLEEP}
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* @remarks
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
doSetStatus(effect: StatusEffect.SLEEP, sleepTurnsRemaining?: number): void;
/**
* Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect.
* @param effect - The {@linkcode StatusEffect} to set
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* and is unused for all non-sleep Statuses
* @remarks
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
doSetStatus(effect: StatusEffect, sleepTurnsRemaining?: number): void;
/**
* Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect.
* @param effect - The {@linkcode StatusEffect} to set
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* and is unused for all non-sleep Statuses
* @remarks
* This method does **not** check for feasibility; that is the responsibility of the caller.
* @todo Make this and all related fields private and change tests to use a field-based helper or similar
*/
doSetStatus(
effect: StatusEffect,
sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4),
): void {
if (effect === StatusEffect.SLEEP) {
sleepTurnsRemaining = new NumberHolder(this.randBattleSeedIntRange(2, 4));
this.setFrameRate(4);
// If the user is invulnerable, lets remove their invulnerability when they fall asleep
const invulnerableTags = [
// If the user is semi-invulnerable when put asleep (such as due to Yawm),
// remove their invulnerability and cancel the upcoming move from the queue
const invulnTagTypes = [
BattlerTagType.FLYING,
BattlerTagType.UNDERGROUND,
BattlerTagType.UNDERWATER,
BattlerTagType.HIDDEN,
BattlerTagType.FLYING,
];
const tag = invulnerableTags.find(t => this.getTag(t));
if (tag) {
this.removeTag(tag);
this.getMoveQueue().pop();
if (this.findTag(t => invulnTagTypes.includes(t.tagType))) {
this.findAndRemoveTags(t => invulnTagTypes.includes(t.tagType));
this.getMoveQueue().shift();
}
}
sleepTurnsRemaining = sleepTurnsRemaining!; // tell TS compiler it's defined
this.status = new Status(effect, 0, sleepTurnsRemaining?.value);
return true;
this.status = new Status(effect, 0, sleepTurnsRemaining);
}
/**

View File

@ -1733,12 +1733,12 @@ export class TurnStatusEffectModifier extends PokemonHeldItemModifier {
}
/**
* Tries to inflicts the holder with the associated {@linkcode StatusEffect}.
* @param pokemon {@linkcode Pokemon} that holds the held item
* Attempt to inflict the holder with the associated {@linkcode StatusEffect}.
* @param pokemon - The {@linkcode Pokemon} holding the item
* @returns `true` if the status effect was applied successfully
*/
override apply(pokemon: Pokemon): boolean {
return pokemon.trySetStatus(this.effect, true, undefined, undefined, this.type.name);
return pokemon.trySetStatus(this.effect, pokemon, undefined, this.type.name);
}
getMaxHeldItemCount(_pokemon: Pokemon): number {
@ -3605,7 +3605,7 @@ export class EnemyAttackStatusEffectChanceModifier extends EnemyPersistentModifi
*/
override apply(enemyPokemon: Pokemon): boolean {
if (randSeedFloat() <= this.chance * this.getStackCount()) {
return enemyPokemon.trySetStatus(this.effect, true);
return enemyPokemon.trySetStatus(this.effect);
}
return false;

View File

@ -268,7 +268,7 @@ export class AttemptCapturePhase extends PokemonPhase {
const removePokemon = () => {
globalScene.addFaintedEnemyScore(pokemon);
pokemon.hp = 0;
pokemon.trySetStatus(StatusEffect.FAINT);
pokemon.doSetStatus(StatusEffect.FAINT);
globalScene.clearEnemyHeldItemModifiers();
pokemon.leaveField(true, true, true);
};

View File

@ -45,7 +45,7 @@ export class AttemptRunPhase extends FieldPhase {
enemyField.forEach(enemyPokemon => {
enemyPokemon.hideInfo().then(() => enemyPokemon.destroy());
enemyPokemon.hp = 0;
enemyPokemon.trySetStatus(StatusEffect.FAINT);
enemyPokemon.doSetStatus(StatusEffect.FAINT);
});
globalScene.phaseManager.pushNew("BattleEndPhase", false);

View File

@ -205,7 +205,7 @@ export class FaintPhase extends PokemonPhase {
pokemon.lapseTags(BattlerTagLapseType.FAINT);
pokemon.y -= 150;
pokemon.trySetStatus(StatusEffect.FAINT);
pokemon.doSetStatus(StatusEffect.FAINT);
if (pokemon.isPlayer()) {
globalScene.currentBattle.removeFaintedParticipant(pokemon as PlayerPokemon);
} else {

View File

@ -269,8 +269,8 @@ export class MovePhase extends BattlePhase {
globalScene.phaseManager.queueMessage(
getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)),
);
this.pokemon.resetStatus();
this.pokemon.updateInfo();
// cannot use `asPhase=true` as it will cause status to be reset _after_ move condition checks fire
this.pokemon.resetStatus(false, false, false, false);
}
}

View File

@ -3,71 +3,64 @@ import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { CommonBattleAnim } from "#data/battle-anims";
import { SpeciesFormChangeStatusEffectTrigger } from "#data/form-change-triggers";
import { getStatusEffectObtainText, getStatusEffectOverlapText } from "#data/status-effect";
import { getStatusEffectObtainText } from "#data/status-effect";
import type { BattlerIndex } from "#enums/battler-index";
import { CommonAnim } from "#enums/move-anims-common";
import { StatusEffect } from "#enums/status-effect";
import type { Pokemon } from "#field/pokemon";
import { PokemonPhase } from "#phases/pokemon-phase";
import { isNullOrUndefined } from "#utils/common";
export class ObtainStatusEffectPhase extends PokemonPhase {
public readonly phaseName = "ObtainStatusEffectPhase";
private statusEffect?: StatusEffect;
private turnsRemaining?: number;
private sourceText?: string | null;
private sourcePokemon?: Pokemon | null;
/**
* @param battlerIndex - The {@linkcode BattlerIndex} of the Pokemon obtaining the status effect.
* @param statusEffect - The {@linkcode StatusEffect} being applied.
* @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target,
* or `null` if the status is applied from a non-Pokemon source (hazards, etc.); default `null`.
* @param sleepTurnsRemaining - The number of turns to set {@linkcode StatusEffect.SLEEP} for;
* defaults to a random number between 2 and 4 and is unused for non-Sleep statuses.
* @param sourceText - The text to show for the source of the status effect, if any; default `null`.
* @param statusMessage - A string containing text to be displayed upon status setting;
* defaults to normal key for status if empty or omitted.
*/
constructor(
battlerIndex: BattlerIndex,
statusEffect?: StatusEffect,
turnsRemaining?: number,
sourceText?: string | null,
sourcePokemon?: Pokemon | null,
private statusEffect: StatusEffect,
private sourcePokemon: Pokemon | null = null,
private sleepTurnsRemaining?: number,
sourceText: string | null = null, // TODO: This should take `undefined` instead of `null`
private statusMessage = "",
) {
super(battlerIndex);
this.statusEffect = statusEffect;
this.turnsRemaining = turnsRemaining;
this.sourceText = sourceText;
this.sourcePokemon = sourcePokemon;
this.statusMessage ||= getStatusEffectObtainText(
statusEffect,
getPokemonNameWithAffix(this.getPokemon()),
sourceText ?? undefined,
);
}
start() {
const pokemon = this.getPokemon();
if (pokemon && !pokemon.status) {
if (pokemon.trySetStatus(this.statusEffect, false, this.sourcePokemon)) {
if (this.turnsRemaining) {
pokemon.status!.sleepTurnsRemaining = this.turnsRemaining;
}
pokemon.updateInfo(true);
new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect! - 1), pokemon).play(false, () => {
globalScene.phaseManager.queueMessage(
getStatusEffectObtainText(
this.statusEffect,
getPokemonNameWithAffix(pokemon),
this.sourceText ?? undefined,
),
);
if (!isNullOrUndefined(this.statusEffect) && this.statusEffect !== StatusEffect.FAINT) {
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeStatusEffectTrigger, true);
// If mold breaker etc was used to set this status, it shouldn't apply to abilities activated afterwards
globalScene.arena.setIgnoreAbilities(false);
applyAbAttrs("PostSetStatusAbAttr", {
pokemon,
effect: this.statusEffect,
sourcePokemon: this.sourcePokemon ?? undefined,
});
}
this.end();
pokemon.doSetStatus(this.statusEffect, this.sleepTurnsRemaining);
pokemon.updateInfo(true);
new CommonBattleAnim(CommonAnim.POISON + (this.statusEffect - 1), pokemon).play(false, () => {
globalScene.phaseManager.queueMessage(this.statusMessage);
if (this.statusEffect && this.statusEffect !== StatusEffect.FAINT) {
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeStatusEffectTrigger, true);
// If the status was applied from a move, ensure abilities are not ignored for follow-up triggers.
// TODO: Ensure this isn't breaking any other phases unshifted afterwards
globalScene.arena.setIgnoreAbilities(false);
applyAbAttrs("PostSetStatusAbAttr", {
pokemon,
effect: this.statusEffect,
sourcePokemon: this.sourcePokemon ?? undefined,
});
return;
}
} else if (pokemon.status?.effect === this.statusEffect) {
globalScene.phaseManager.queueMessage(
getStatusEffectOverlapText(this.statusEffect ?? StatusEffect.NONE, getPokemonNameWithAffix(pokemon)),
);
}
this.end();
this.end();
});
}
}

View File

@ -13,6 +13,7 @@ import { HealAchv } from "#system/achv";
import { NumberHolder } from "#utils/common";
import i18next from "i18next";
// TODO: Refactor this - it has far too many arguments
export class PokemonHealPhase extends CommonAnimPhase {
public readonly phaseName = "PokemonHealPhase";
private hpHealed: number;
@ -28,7 +29,7 @@ export class PokemonHealPhase extends CommonAnimPhase {
battlerIndex: BattlerIndex,
hpHealed: number,
message: string | null,
showFullHpMessage: boolean,
showFullHpMessage = true,
skipAnim = false,
revive = false,
healStatus = false,
@ -72,6 +73,7 @@ export class PokemonHealPhase extends CommonAnimPhase {
this.message = null;
return super.end();
}
if (healOrDamage) {
const hpRestoreMultiplier = new NumberHolder(1);
if (!this.revive) {

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,
@ -979,6 +980,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

@ -207,6 +207,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);
}
/**

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);
@ -441,7 +608,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 +631,32 @@ 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;
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

@ -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

@ -1,6 +1,7 @@
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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -22,25 +23,66 @@ describe("Abilities - Corrosion", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.SPLASH])
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.GRIMER)
.enemyAbility(AbilityId.CORROSION)
.enemyMoveset(MoveId.TOXIC);
.ability(AbilityId.CORROSION)
.enemyAbility(AbilityId.NO_GUARD)
.enemyMoveset(MoveId.SPLASH);
});
it("If a Poison- or Steel-type Pokémon with this Ability poisons a target with Synchronize, Synchronize does not gain the ability to poison Poison- or Steel-type Pokémon.", async () => {
game.override.ability(AbilityId.SYNCHRONIZE);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
it.each<{ name: string; species: SpeciesId }>([
{ name: "Poison", species: SpeciesId.GRIMER },
{ name: "Steel", species: SpeciesId.KLINK },
])("should grant the user the ability to poison $name-type opponents", async ({ species }) => {
game.override.enemySpecies(species);
await game.classicMode.startBattle([SpeciesId.SALANDIT]);
const playerPokemon = game.scene.getPlayerPokemon();
const enemyPokemon = game.scene.getEnemyPokemon();
expect(playerPokemon!.status).toBeUndefined();
const enemy = game.field.getEnemyPokemon();
expect(enemy.status?.effect).toBeUndefined();
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(playerPokemon!.status).toBeDefined();
expect(enemyPokemon!.status).toBeUndefined();
game.move.use(MoveId.POISON_GAS);
await game.toEndOfTurn();
expect(enemy.status?.effect).toBe(StatusEffect.POISON);
});
it("should not affect Toxic Spikes", async () => {
await game.classicMode.startBattle([SpeciesId.SALANDIT]);
game.move.use(MoveId.TOXIC_SPIKES);
await game.doKillOpponents();
await game.toNextWave();
const enemyPokemon = game.field.getEnemyPokemon();
expect(enemyPokemon.status).toBeUndefined();
});
it("should not affect an opponent's Synchronize ability", async () => {
game.override.enemyAbility(AbilityId.SYNCHRONIZE);
await game.classicMode.startBattle([SpeciesId.ARBOK]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
expect(enemyPokemon.status?.effect).toBeUndefined();
game.move.use(MoveId.TOXIC);
await game.toEndOfTurn();
expect(enemyPokemon.status?.effect).toBe(StatusEffect.TOXIC);
expect(playerPokemon.status?.effect).toBeUndefined();
});
it("should affect the user's held Toxic Orb", async () => {
game.override.startingHeldItems([{ name: "TOXIC_ORB", count: 1 }]);
await game.classicMode.startBattle([SpeciesId.SALAZZLE]);
const salazzle = game.field.getPlayerPokemon();
expect(salazzle.status?.effect).toBeUndefined();
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(salazzle.status?.effect).toBe(StatusEffect.TOXIC);
});
});

View File

@ -49,6 +49,7 @@ describe("Abilities - Healer", () => {
const user = game.field.getPlayerPokemon();
// Only want one magikarp to have the ability
vi.spyOn(user, "getAbility").mockReturnValue(allAbilities[AbilityId.HEALER]);
game.move.select(MoveId.SPLASH);
// faint the ally
game.move.select(MoveId.LUNAR_DANCE, 1);
@ -62,9 +63,10 @@ describe("Abilities - Healer", () => {
it("should heal the status of an ally if the ally has a status", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
const [user, ally] = game.scene.getPlayerField();
// Only want one magikarp to have the ability.
vi.spyOn(user, "getAbility").mockReturnValue(allAbilities[AbilityId.HEALER]);
expect(ally.trySetStatus(StatusEffect.BURN)).toBe(true);
ally.doSetStatus(StatusEffect.BURN);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.SPLASH, 1);
@ -80,7 +82,7 @@ describe("Abilities - Healer", () => {
const [user, ally] = game.scene.getPlayerField();
// Only want one magikarp to have the ability.
vi.spyOn(user, "getAbility").mockReturnValue(allAbilities[AbilityId.HEALER]);
expect(ally.trySetStatus(StatusEffect.BURN)).toBe(true);
ally.doSetStatus(StatusEffect.BURN);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("TurnEndPhase");

View File

@ -79,9 +79,9 @@ describe("Abilities - Infiltrator", () => {
game.scene.arena.addTag(ArenaTagType.SAFEGUARD, 1, MoveId.NONE, enemy.id, ArenaTagSide.ENEMY, true);
game.move.select(MoveId.SPORE);
game.move.use(MoveId.SPORE);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemy.status?.effect).toBe(StatusEffect.SLEEP);
expect(player.waveData.abilitiesApplied).toContain(AbilityId.INFILTRATOR);
});

View File

@ -1,51 +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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Insomnia", () => {
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.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should remove sleep when gained", async () => {
game.override
.ability(AbilityId.INSOMNIA)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset(MoveId.SKILL_SWAP)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon();
enemy?.trySetStatus(StatusEffect.SLEEP);
expect(enemy?.status?.effect).toBe(StatusEffect.SLEEP);
game.move.select(MoveId.SKILL_SWAP);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy?.status).toBeNull();
});
});

View File

@ -1,51 +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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Limber", () => {
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.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should remove paralysis when gained", async () => {
game.override
.ability(AbilityId.LIMBER)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset(MoveId.SKILL_SWAP)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon();
enemy?.trySetStatus(StatusEffect.PARALYSIS);
expect(enemy?.status?.effect).toBe(StatusEffect.PARALYSIS);
game.move.select(MoveId.SKILL_SWAP);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy?.status).toBeNull();
});
});

View File

@ -1,51 +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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Magma Armor", () => {
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.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should remove freeze when gained", async () => {
game.override
.ability(AbilityId.MAGMA_ARMOR)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset(MoveId.SKILL_SWAP)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon();
enemy?.trySetStatus(StatusEffect.FREEZE);
expect(enemy?.status?.effect).toBe(StatusEffect.FREEZE);
game.move.select(MoveId.SKILL_SWAP);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy?.status).toBeNull();
});
});

View File

@ -0,0 +1,95 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
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 { StatusEffectAttr } from "#moves/move";
import { GameManager } from "#test/test-utils/game-manager";
import { toTitleCase } from "#utils/strings";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe.each<{ name: string; ability: AbilityId; status: StatusEffect }>([
{ name: "Vital Spirit", ability: AbilityId.VITAL_SPIRIT, status: StatusEffect.SLEEP },
{ name: "Insomnia", ability: AbilityId.INSOMNIA, status: StatusEffect.SLEEP },
{ name: "Immunity", ability: AbilityId.IMMUNITY, status: StatusEffect.POISON },
{ name: "Magma Armor", ability: AbilityId.MAGMA_ARMOR, status: StatusEffect.FREEZE },
{ name: "Limber", ability: AbilityId.LIMBER, status: StatusEffect.PARALYSIS },
{ name: "Thermal Exchange", ability: AbilityId.THERMAL_EXCHANGE, status: StatusEffect.BURN },
{ name: "Water Veil", ability: AbilityId.WATER_VEIL, status: StatusEffect.BURN },
{ name: "Water Bubble", ability: AbilityId.WATER_BUBBLE, status: StatusEffect.BURN },
])("Abilities - $name", ({ ability, status }) => {
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(100)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(ability)
.enemyMoveset(MoveId.SPLASH);
// Mock Lumina Crash and Spore to be our status-inflicting moves of choice
vi.spyOn(allMoves[MoveId.LUMINA_CRASH], "attrs", "get").mockReturnValue([new StatusEffectAttr(status, false)]);
vi.spyOn(allMoves[MoveId.SPORE], "attrs", "get").mockReturnValue([new StatusEffectAttr(status, false)]);
});
const statusStr = toTitleCase(StatusEffect[status]);
it(`should prevent application of ${statusStr} without failing damaging moves`, async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
expect(karp.status?.effect).toBeUndefined();
expect(karp.canSetStatus(status)).toBe(false);
game.move.use(MoveId.LUMINA_CRASH);
await game.toEndOfTurn();
expect(karp.status?.effect).toBeUndefined();
expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it(`should cure ${statusStr} upon being gained`, async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const feebas = game.field.getPlayerPokemon();
feebas.doSetStatus(status);
expect(feebas.status?.effect).toBe(status);
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SKILL_SWAP);
await game.toEndOfTurn();
expect(feebas.status?.effect).toBeUndefined();
});
// TODO: This does not propagate failures currently
it.todo(
`should cause status moves inflicting ${statusStr} to count as failed if no other effects can be applied`,
async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.use(MoveId.SPORE);
await game.toEndOfTurn();
const karp = game.field.getEnemyPokemon();
expect(karp.status?.effect).toBeUndefined();
expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
},
);
});

View File

@ -1,51 +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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Thermal Exchange", () => {
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.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should remove burn when gained", async () => {
game.override
.ability(AbilityId.THERMAL_EXCHANGE)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset(MoveId.SKILL_SWAP)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon();
enemy?.trySetStatus(StatusEffect.BURN);
expect(enemy?.status?.effect).toBe(StatusEffect.BURN);
game.move.select(MoveId.SKILL_SWAP);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy?.status).toBeNull();
});
});

View File

@ -1,51 +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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Vital Spirit", () => {
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.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should remove sleep when gained", async () => {
game.override
.ability(AbilityId.INSOMNIA)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset(MoveId.SKILL_SWAP)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon();
enemy?.trySetStatus(StatusEffect.SLEEP);
expect(enemy?.status?.effect).toBe(StatusEffect.SLEEP);
game.move.select(MoveId.SKILL_SWAP);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy?.status).toBeNull();
});
});

View File

@ -1,51 +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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Water Bubble", () => {
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.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should remove burn when gained", async () => {
game.override
.ability(AbilityId.THERMAL_EXCHANGE)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset(MoveId.SKILL_SWAP)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon();
enemy?.trySetStatus(StatusEffect.BURN);
expect(enemy?.status?.effect).toBe(StatusEffect.BURN);
game.move.select(MoveId.SKILL_SWAP);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy?.status).toBeNull();
});
});

View File

@ -1,51 +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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Water Veil", () => {
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.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should remove burn when gained", async () => {
game.override
.ability(AbilityId.THERMAL_EXCHANGE)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset(MoveId.SKILL_SWAP)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon();
enemy?.trySetStatus(StatusEffect.BURN);
expect(enemy?.status?.effect).toBe(StatusEffect.BURN);
game.move.select(MoveId.SKILL_SWAP);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy?.status).toBeNull();
});
});

View File

@ -6,7 +6,7 @@ import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Immunity", () => {
describe("Spec - Pokemon Functions", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
@ -23,29 +23,29 @@ describe("Abilities - Immunity", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.startingLevel(100)
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.ability(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should remove poison when gained", async () => {
game.override
.ability(AbilityId.IMMUNITY)
.enemyAbility(AbilityId.BALL_FETCH)
.moveset(MoveId.SKILL_SWAP)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.scene.getEnemyPokemon();
enemy?.trySetStatus(StatusEffect.POISON);
expect(enemy?.status?.effect).toBe(StatusEffect.POISON);
describe("doSetStatus", () => {
it("should change the Pokemon's status, ignoring feasibility checks", async () => {
await game.classicMode.startBattle([SpeciesId.ACCELGOR]);
game.move.select(MoveId.SKILL_SWAP);
await game.phaseInterceptor.to("BerryPhase");
const player = game.field.getPlayerPokemon();
expect(enemy?.status).toBeNull();
expect(player.status?.effect).toBeUndefined();
player.doSetStatus(StatusEffect.BURN);
expect(player.status?.effect).toBe(StatusEffect.BURN);
expect(player.canSetStatus(StatusEffect.SLEEP)).toBe(false);
player.doSetStatus(StatusEffect.SLEEP, 5);
expect(player.status?.effect).toBe(StatusEffect.SLEEP);
expect(player.status?.sleepTurnsRemaining).toBe(5);
});
});
});

View File

@ -25,15 +25,6 @@ describe("Spec - Pokemon", () => {
game = new GameManager(phaserGame);
});
it("should not crash when trying to set status of undefined", async () => {
await game.classicMode.runToSummon([SpeciesId.ABRA]);
const pkm = game.field.getPlayerPokemon();
expect(pkm).toBeDefined();
expect(pkm.trySetStatus(undefined)).toBe(false);
});
describe("Add To Party", () => {
let scene: BattleScene;

View File

@ -73,7 +73,7 @@ describe("Moves - Beat Up", () => {
const playerPokemon = game.field.getPlayerPokemon();
game.scene.getPlayerParty()[1].trySetStatus(StatusEffect.BURN);
game.scene.getPlayerParty()[1].doSetStatus(StatusEffect.BURN);
game.move.select(MoveId.BEAT_UP);

View File

@ -44,7 +44,7 @@ describe("Moves - Fusion Flare", () => {
await game.phaseInterceptor.to(TurnStartPhase, false);
// Inflict freeze quietly and check if it was properly inflicted
partyMember.trySetStatus(StatusEffect.FREEZE, false);
partyMember.doSetStatus(StatusEffect.FREEZE);
expect(partyMember.status!.effect).toBe(StatusEffect.FREEZE);
await game.toNextTurn();

146
test/moves/rest.test.ts Normal file
View File

@ -0,0 +1,146 @@
import { AbilityId } from "#enums/ability-id";
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 { Stat } from "#enums/stat";
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("Move - Rest", () => {
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
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.EKANS)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should fully heal the user, cure its prior status and put it to sleep", async () => {
game.override.statusEffect(StatusEffect.POISON);
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const snorlax = game.field.getPlayerPokemon();
snorlax.hp = 1;
expect(snorlax.status?.effect).toBe(StatusEffect.POISON);
game.move.use(MoveId.REST);
await game.toEndOfTurn();
expect(snorlax.hp).toBe(snorlax.getMaxHp());
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
});
it("should always last 3 turns", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const snorlax = game.field.getPlayerPokemon();
snorlax.hp = 1;
// Cf https://bulbapedia.bulbagarden.net/wiki/Rest_(move):
// > The user is unable to use MoveId while asleep for 2 turns after the turn when Rest is used.
game.move.use(MoveId.REST);
await game.toNextTurn();
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
expect(snorlax.status?.sleepTurnsRemaining).toBe(3);
game.move.use(MoveId.SWORDS_DANCE);
await game.toNextTurn();
expect(snorlax.status?.sleepTurnsRemaining).toBe(2);
game.move.use(MoveId.SWORDS_DANCE);
await game.toNextTurn();
expect(snorlax.status?.sleepTurnsRemaining).toBe(1);
game.move.use(MoveId.SWORDS_DANCE);
await game.toNextTurn();
expect(snorlax.status?.effect).toBeUndefined();
expect(snorlax.getStatStage(Stat.ATK)).toBe(2);
});
it("should preserve non-volatile status conditions", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const snorlax = game.field.getPlayerPokemon();
snorlax.hp = 1;
snorlax.addTag(BattlerTagType.CONFUSED, 999);
game.move.use(MoveId.REST);
await game.toEndOfTurn();
expect(snorlax.getTag(BattlerTagType.CONFUSED)).toBeDefined();
});
it.each<{ name: string; status?: StatusEffect; ability?: AbilityId; dmg?: number }>([
{ name: "is at full HP", dmg: 0 },
{ name: "is grounded on Electric Terrain", ability: AbilityId.ELECTRIC_SURGE },
{ name: "is grounded on Misty Terrain", ability: AbilityId.MISTY_SURGE },
{ name: "has Comatose", ability: AbilityId.COMATOSE },
])("should fail if the user $name", async ({ status = StatusEffect.NONE, ability = AbilityId.NONE, dmg = 1 }) => {
game.override.ability(ability).statusEffect(status);
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const snorlax = game.field.getPlayerPokemon();
snorlax.hp = snorlax.getMaxHp() - dmg;
game.move.use(MoveId.REST);
await game.toEndOfTurn();
expect(snorlax.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail if called while already asleep", async () => {
game.override.statusEffect(StatusEffect.SLEEP).moveset([MoveId.REST, MoveId.SLEEP_TALK]);
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const snorlax = game.field.getPlayerPokemon();
snorlax.hp = 1;
// Need to use sleep talk here since you normally can't move while asleep
game.move.select(MoveId.SLEEP_TALK);
await game.toEndOfTurn();
expect(snorlax.isFullHp()).toBe(false);
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
expect(snorlax.getLastXMoves(-1).map(tm => tm.result)).toEqual([MoveResult.FAIL, MoveResult.SUCCESS]);
});
it("should succeed if called the same turn as the user wakes", async () => {
game.override.statusEffect(StatusEffect.SLEEP);
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const snorlax = game.field.getPlayerPokemon();
snorlax.hp = 1;
expect(snorlax.status?.effect).toBe(StatusEffect.SLEEP);
snorlax.status!.sleepTurnsRemaining = 1;
game.move.use(MoveId.REST);
await game.toNextTurn();
expect(snorlax.status!.effect).toBe(StatusEffect.SLEEP);
expect(snorlax.isFullHp()).toBe(true);
expect(snorlax.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(snorlax.status!.sleepTurnsRemaining).toBeGreaterThan(1);
});
});

View File

@ -1,6 +1,7 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
@ -31,13 +32,36 @@ describe("Moves - Sleep Talk", () => {
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyAbility(AbilityId.NO_GUARD)
.enemyMoveset(MoveId.SPLASH)
.enemyLevel(100);
});
it("should fail when the user is not asleep", async () => {
game.override.statusEffect(StatusEffect.NONE);
it("should call a random valid move if the user is asleep", async () => {
game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.FLY, MoveId.SWORDS_DANCE]); // Dig and Fly are invalid moves, Swords Dance should always be called
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.select(MoveId.SLEEP_TALK);
await game.toNextTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas.getStatStage(Stat.ATK)).toBe(2);
expect(feebas.getLastXMoves(2)).toEqual([
expect.objectContaining({
move: MoveId.SWORDS_DANCE,
result: MoveResult.SUCCESS,
useMode: MoveUseMode.FOLLOW_UP,
}),
expect.objectContaining({
move: MoveId.SLEEP_TALK,
result: MoveResult.SUCCESS,
useMode: MoveUseMode.NORMAL,
}),
]);
});
it("should fail if the user is not asleep", async () => {
game.override.statusEffect(StatusEffect.POISON);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.select(MoveId.SLEEP_TALK);
@ -45,6 +69,19 @@ describe("Moves - Sleep Talk", () => {
expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail the turn the user wakes up from Sleep", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const feebas = game.field.getPlayerPokemon();
expect(feebas.status?.effect).toBe(StatusEffect.SLEEP);
feebas.status!.sleepTurnsRemaining = 1;
game.move.select(MoveId.SLEEP_TALK);
await game.toNextTurn();
expect(feebas).toHaveUsedMove({ result: MoveResult.FAIL });
});
it("should fail if the user has no valid moves", async () => {
game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.METRONOME, MoveId.SOLAR_BEAM]);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
@ -54,22 +91,15 @@ describe("Moves - Sleep Talk", () => {
expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should call a random valid move if the user is asleep", async () => {
game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.FLY, MoveId.SWORDS_DANCE]); // Dig and Fly are invalid moves, Swords Dance should always be called
it("should apply secondary effects of the called move", async () => {
game.override.moveset([MoveId.SLEEP_TALK, MoveId.SCALE_SHOT]);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.select(MoveId.SLEEP_TALK);
await game.toNextTurn();
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK));
});
it("should apply secondary effects of a move", async () => {
game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.FLY, MoveId.WOOD_HAMMER]); // Dig and Fly are invalid moves, Wood Hammer should always be called
await game.classicMode.startBattle();
game.move.select(MoveId.SLEEP_TALK);
await game.toNextTurn();
expect(game.field.getPlayerPokemon().isFullHp()).toBeFalsy(); // Wood Hammer recoil effect should be applied
const feebas = game.field.getPlayerPokemon();
expect(feebas.getStatStage(Stat.SPD)).toBe(1);
expect(feebas.getStatStage(Stat.DEF)).toBe(-1);
});
});

View File

@ -63,7 +63,7 @@ describe("Mystery Encounter Utils", () => {
// Both pokemon fainted
scene.getPlayerParty().forEach(p => {
p.hp = 0;
p.trySetStatus(StatusEffect.FAINT);
p.doSetStatus(StatusEffect.FAINT);
void p.updateInfo();
});
@ -83,7 +83,7 @@ describe("Mystery Encounter Utils", () => {
// Only faint 1st pokemon
const party = scene.getPlayerParty();
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].doSetStatus(StatusEffect.FAINT);
await party[0].updateInfo();
// Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal)
@ -102,7 +102,7 @@ describe("Mystery Encounter Utils", () => {
// Only faint 1st pokemon
const party = scene.getPlayerParty();
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].doSetStatus(StatusEffect.FAINT);
await party[0].updateInfo();
// Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal)
@ -121,7 +121,7 @@ describe("Mystery Encounter Utils", () => {
// Only faint 1st pokemon
const party = scene.getPlayerParty();
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].doSetStatus(StatusEffect.FAINT);
await party[0].updateInfo();
// Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal)
@ -167,7 +167,7 @@ describe("Mystery Encounter Utils", () => {
const party = scene.getPlayerParty();
party[0].level = 100;
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].doSetStatus(StatusEffect.FAINT);
await party[0].updateInfo();
party[1].level = 10;
@ -206,7 +206,7 @@ describe("Mystery Encounter Utils", () => {
const party = scene.getPlayerParty();
party[0].level = 10;
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].doSetStatus(StatusEffect.FAINT);
await party[0].updateInfo();
party[1].level = 100;

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();
});
});
});