mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-08-20 14:29:28 +02:00
Compare commits
17 Commits
e74b56b688
...
cfded0164d
Author | SHA1 | Date | |
---|---|---|---|
|
cfded0164d | ||
|
f42237d415 | ||
|
b44f0a4176 | ||
|
076ef81691 | ||
|
23271901cf | ||
|
1517e0512e | ||
|
68a9d785aa | ||
|
e61a0d4326 | ||
|
6fd3c356c4 | ||
|
2a611da07a | ||
|
a691c43d33 | ||
|
96e3b7f11c | ||
|
751d824af8 | ||
|
89536fafda | ||
|
95dbfe69a0 | ||
|
90c9c71cd9 | ||
|
e760ed9949 |
@ -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.
|
||||
|
@ -104,6 +104,7 @@ import {
|
||||
getLuckString,
|
||||
getLuckTextTint,
|
||||
getPartyLuckValue,
|
||||
type ModifierType,
|
||||
PokemonHeldItemModifierType,
|
||||
} from "#modifiers/modifier-type";
|
||||
import { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
|
||||
@ -1203,7 +1204,9 @@ export class BattleScene extends SceneBase {
|
||||
this.updateScoreText();
|
||||
this.scoreText.setVisible(false);
|
||||
|
||||
[this.luckLabelText, this.luckText].map(t => t.setVisible(false));
|
||||
[this.luckLabelText, this.luckText].forEach(t => {
|
||||
t.setVisible(false);
|
||||
});
|
||||
|
||||
this.newArena(Overrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN);
|
||||
|
||||
@ -1237,8 +1240,7 @@ export class BattleScene extends SceneBase {
|
||||
Object.values(mp)
|
||||
.flat()
|
||||
.map(mt => mt.modifierType)
|
||||
.filter(mt => "localize" in mt)
|
||||
.map(lpb => lpb as unknown as Localizable),
|
||||
.filter((mt): mt is ModifierType & Localizable => "localize" in mt && typeof mt.localize === "function"),
|
||||
),
|
||||
];
|
||||
for (const item of localizable) {
|
||||
@ -1513,8 +1515,8 @@ export class BattleScene extends SceneBase {
|
||||
return this.currentBattle;
|
||||
}
|
||||
|
||||
newArena(biome: BiomeId, playerFaints?: number): Arena {
|
||||
this.arena = new Arena(biome, BiomeId[biome].toLowerCase(), playerFaints);
|
||||
newArena(biome: BiomeId, playerFaints = 0): Arena {
|
||||
this.arena = new Arena(biome, playerFaints);
|
||||
this.eventTarget.dispatchEvent(new NewArenaEvent());
|
||||
|
||||
this.arenaBg.pipelineData = {
|
||||
@ -2711,7 +2713,9 @@ export class BattleScene extends SceneBase {
|
||||
}
|
||||
}
|
||||
|
||||
this.party.map(p => p.updateInfo(instant));
|
||||
this.party.forEach(p => {
|
||||
p.updateInfo(instant);
|
||||
});
|
||||
} else {
|
||||
const args = [this];
|
||||
if (modifier.shouldApply(...args)) {
|
||||
|
@ -74,6 +74,7 @@ import {
|
||||
randSeedItem,
|
||||
toDmgValue,
|
||||
} from "#utils/common";
|
||||
import { toCamelCase } from "#utils/strings";
|
||||
import i18next from "i18next";
|
||||
|
||||
export class Ability implements Localizable {
|
||||
@ -109,13 +110,9 @@ export class Ability implements Localizable {
|
||||
}
|
||||
|
||||
localize(): void {
|
||||
const i18nKey = AbilityId[this.id]
|
||||
.split("_")
|
||||
.filter(f => f)
|
||||
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
|
||||
.join("") as string;
|
||||
const i18nKey = toCamelCase(AbilityId[this.id]);
|
||||
|
||||
this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`) as string}${this.nameAppend}` : "";
|
||||
this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`)}${this.nameAppend}` : "";
|
||||
this.description = this.id ? (i18next.t(`ability:${i18nKey}.description`) as string) : "";
|
||||
}
|
||||
|
||||
@ -752,11 +749,12 @@ export class TypeImmunityHealAbAttr extends TypeImmunityAbAttr {
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / 4),
|
||||
i18next.t("abilityTriggers:typeImmunityHeal", {
|
||||
{
|
||||
message: i18next.t("abilityTriggers:typeImmunityHeal", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
abilityName,
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
cancelled.value = true; // Suppresses "No Effect" message
|
||||
}
|
||||
@ -1545,6 +1543,51 @@ export abstract class PreAttackAbAttr extends AbAttr {
|
||||
private declare readonly _: never;
|
||||
}
|
||||
|
||||
export interface MoveHealBoostAbAttrParams extends AugmentMoveInteractionAbAttrParams {
|
||||
/** The base amount of HP being healed, as a fraction of the recipient's maximum HP. */
|
||||
healRatio: NumberHolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ability attribute to boost the healing potency of the user's moves.
|
||||
* Used by {@linkcode AbilityId.MEGA_LAUNCHER} to implement Heal Pulse boosting.
|
||||
*/
|
||||
export class MoveHealBoostAbAttr extends AbAttr {
|
||||
/**
|
||||
* The amount to boost the healing by, as a multiplier of the base amount.
|
||||
*/
|
||||
private healMulti: number;
|
||||
/**
|
||||
* A lambda function determining whether to boost the heal amount.
|
||||
* The ability will not be applied if this evaluates to `false`.
|
||||
*/
|
||||
// TODO: Use a `MoveConditionFunc` maybe?
|
||||
private boostCondition: (user: Pokemon, target: Pokemon, move: Move) => boolean;
|
||||
|
||||
constructor(
|
||||
boostCondition: (user: Pokemon, target: Pokemon, move: Move) => boolean,
|
||||
healMulti: number,
|
||||
showAbility = false,
|
||||
) {
|
||||
super(showAbility);
|
||||
|
||||
if (healMulti === 1) {
|
||||
throw new Error("Calling `MoveHealBoostAbAttr` with a multiplier of 1 is useless!");
|
||||
}
|
||||
|
||||
this.healMulti = healMulti;
|
||||
this.boostCondition = boostCondition;
|
||||
}
|
||||
|
||||
override canApply({ pokemon: user, opponent: target, move }: MoveHealBoostAbAttrParams): boolean {
|
||||
return this.boostCondition?.(user, target, move) ?? true;
|
||||
}
|
||||
|
||||
override apply({ healRatio }: MoveHealBoostAbAttrParams): void {
|
||||
healRatio.value *= this.healMulti;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModifyMoveEffectChanceAbAttrParams extends AbAttrBaseParams {
|
||||
/** The move being used by the attacker */
|
||||
move: Move;
|
||||
@ -1687,7 +1730,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
|
||||
*/
|
||||
override canApply({ pokemon, opponent: target, move }: MoveTypeChangeAbAttrParams): boolean {
|
||||
return (
|
||||
(!this.condition || this.condition(pokemon, target, move)) &&
|
||||
(this.condition?.(pokemon, target, move) ?? true) &&
|
||||
!noAbilityTypeOverrideMoves.has(move.id) &&
|
||||
!(
|
||||
pokemon.isTerastallized &&
|
||||
@ -2831,12 +2874,13 @@ export class PostSummonAllyHealAbAttr extends PostSummonAbAttr {
|
||||
"PokemonHealPhase",
|
||||
target.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / this.healRatio),
|
||||
i18next.t("abilityTriggers:postSummonAllyHeal", {
|
||||
{
|
||||
message: i18next.t("abilityTriggers:postSummonAllyHeal", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(target),
|
||||
pokemonName: pokemon.name,
|
||||
}),
|
||||
true,
|
||||
!this.showAnim,
|
||||
skipAnim: !this.showAnim,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4477,11 +4521,12 @@ export class PostWeatherLapseHealAbAttr extends PostWeatherLapseAbAttr {
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / (16 / this.healFactor)),
|
||||
i18next.t("abilityTriggers:postWeatherLapseHeal", {
|
||||
{
|
||||
message: i18next.t("abilityTriggers:postWeatherLapseHeal", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
abilityName,
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4596,8 +4641,12 @@ export class PostTurnStatusHealAbAttr extends PostTurnAbAttr {
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / 8),
|
||||
i18next.t("abilityTriggers:poisonHeal", { pokemonName: getPokemonNameWithAffix(pokemon), abilityName }),
|
||||
true,
|
||||
{
|
||||
message: i18next.t("abilityTriggers:poisonHeal", {
|
||||
pokemonName: getPokemonNameWithAffix(pokemon),
|
||||
abilityName,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4844,11 +4893,12 @@ export class PostTurnHealAbAttr extends PostTurnAbAttr {
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / 16),
|
||||
i18next.t("abilityTriggers:postTurnHeal", {
|
||||
{
|
||||
message: i18next.t("abilityTriggers:postTurnHeal", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
abilityName,
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -5225,11 +5275,12 @@ export class HealFromBerryUseAbAttr extends AbAttr {
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() * this.healPercent),
|
||||
i18next.t("abilityTriggers:healFromBerryUse", {
|
||||
{
|
||||
message: i18next.t("abilityTriggers:healFromBerryUse", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
abilityName,
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6555,6 +6606,7 @@ const AbilityAttrs = Object.freeze({
|
||||
PostDefendMoveDisableAbAttr,
|
||||
PostStatStageChangeStatStageChangeAbAttr,
|
||||
PreAttackAbAttr,
|
||||
MoveHealBoostAbAttr,
|
||||
MoveEffectChanceMultiplierAbAttr,
|
||||
IgnoreMoveEffectsAbAttr,
|
||||
VariableMovePowerAbAttr,
|
||||
@ -7330,7 +7382,9 @@ export function initAbilities() {
|
||||
.attr(PostSummonUserFieldRemoveStatusEffectAbAttr, StatusEffect.SLEEP)
|
||||
.attr(UserFieldBattlerTagImmunityAbAttr, BattlerTagType.DROWSY)
|
||||
.ignorable()
|
||||
.partial(), // Mold Breaker ally should not be affected by Sweet Veil
|
||||
// Mold Breaker ally should not be affected by Sweet Veil
|
||||
// TODO: Review this
|
||||
.partial(),
|
||||
new Ability(AbilityId.STANCE_CHANGE, 6)
|
||||
.attr(NoFusionAbilityAbAttr)
|
||||
.uncopiable()
|
||||
@ -7339,7 +7393,8 @@ export function initAbilities() {
|
||||
new Ability(AbilityId.GALE_WINGS, 6)
|
||||
.attr(ChangeMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && pokemon.getMoveType(move) === PokemonType.FLYING, 1),
|
||||
new Ability(AbilityId.MEGA_LAUNCHER, 6)
|
||||
.attr(MovePowerBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5),
|
||||
.attr(MovePowerBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5)
|
||||
.attr(MoveHealBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5),
|
||||
new Ability(AbilityId.GRASS_PELT, 6)
|
||||
.conditionalAttr(getTerrainCondition(TerrainType.GRASSY), StatMultiplierAbAttr, Stat.DEF, 1.5)
|
||||
.ignorable(),
|
||||
|
@ -1866,17 +1866,16 @@ interface PokemonPrevolutions {
|
||||
export const pokemonPrevolutions: PokemonPrevolutions = {};
|
||||
|
||||
export function initPokemonPrevolutions(): void {
|
||||
const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ].map(sfk => sfk as string);
|
||||
const prevolutionKeys = Object.keys(pokemonEvolutions);
|
||||
prevolutionKeys.forEach(pk => {
|
||||
const evolutions = pokemonEvolutions[pk];
|
||||
// TODO: Why do we have empty strings in our array?
|
||||
const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ];
|
||||
for (const [pk, evolutions] of Object.entries(pokemonEvolutions)) {
|
||||
for (const ev of evolutions) {
|
||||
if (ev.evoFormKey && megaFormKeys.indexOf(ev.evoFormKey) > -1) {
|
||||
continue;
|
||||
}
|
||||
pokemonPrevolutions[ev.speciesId] = Number.parseInt(pk) as SpeciesId;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -1076,18 +1076,16 @@ export class SeedTag extends SerializableBattlerTag {
|
||||
);
|
||||
|
||||
// Damage the target and restore our HP (or take damage in the case of liquid ooze)
|
||||
// TODO: Liquid ooze should queue a damage anim phase directly
|
||||
const damage = pokemon.damageAndUpdate(toDmgValue(pokemon.getMaxHp() / 8), { result: HitResult.INDIRECT });
|
||||
const reverseDrain = pokemon.hasAbilityWithAttr("ReverseDrainAbAttr", false);
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
source.getBattlerIndex(),
|
||||
reverseDrain ? -damage : damage,
|
||||
i18next.t(reverseDrain ? "battlerTags:seededLapseShed" : "battlerTags:seededLapse", {
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", source.getBattlerIndex(), reverseDrain ? -damage : damage, {
|
||||
message: i18next.t(reverseDrain ? "battlerTags:seededLapseShed" : "battlerTags:seededLapse", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
}),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
showFullHpMessage: false,
|
||||
skipAnim: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1382,10 +1380,11 @@ export class IngrainTag extends TrappedTag {
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / 16),
|
||||
i18next.t("battlerTags:ingrainLapse", {
|
||||
{
|
||||
message: i18next.t("battlerTags:ingrainLapse", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -1455,11 +1454,12 @@ export class AquaRingTag extends SerializableBattlerTag {
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / 16),
|
||||
i18next.t("battlerTags:aquaRingLapse", {
|
||||
{
|
||||
message: i18next.t("battlerTags:aquaRingLapse", {
|
||||
moveName: this.getMoveName(),
|
||||
pokemonName: getPokemonNameWithAffix(pokemon),
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -2702,7 +2702,9 @@ export class StockpilingTag extends SerializableBattlerTag {
|
||||
* For each stat, an internal counter is incremented (by 1) if the stat was successfully changed.
|
||||
*/
|
||||
onAdd(pokemon: Pokemon): void {
|
||||
if (this.stockpiledCount < 3) {
|
||||
if (this.stockpiledCount >= 3) {
|
||||
return;
|
||||
}
|
||||
this.stockpiledCount++;
|
||||
|
||||
globalScene.phaseManager.queueMessage(
|
||||
@ -2725,7 +2727,6 @@ export class StockpilingTag extends SerializableBattlerTag {
|
||||
this.onStatStagesChanged.bind(this),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onOverlap(pokemon: Pokemon): void {
|
||||
this.onAdd(pokemon);
|
||||
|
@ -73,16 +73,12 @@ export function getBerryEffectFunc(berryType: BerryType): BerryEffectFunc {
|
||||
{
|
||||
const hpHealed = new NumberHolder(toDmgValue(consumer.getMaxHp() / 4));
|
||||
applyAbAttrs("DoubleBerryEffectAbAttr", { pokemon: consumer, effectValue: hpHealed });
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
consumer.getBattlerIndex(),
|
||||
hpHealed.value,
|
||||
i18next.t("battle:hpHealBerry", {
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", consumer.getBattlerIndex(), hpHealed.value, {
|
||||
message: i18next.t("battle:hpHealBerry", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(consumer),
|
||||
berryName: getBerryName(berryType),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case BerryType.LUM:
|
||||
|
@ -90,7 +90,7 @@ import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindS
|
||||
import type { TurnMove } from "#types/turn-move";
|
||||
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
|
||||
import { getEnumValues } from "#utils/enums";
|
||||
import { toTitleCase } from "#utils/strings";
|
||||
import { toCamelCase, toTitleCase } from "#utils/strings";
|
||||
import i18next from "i18next";
|
||||
import { applyChallenges } from "#utils/challenge-utils";
|
||||
|
||||
@ -162,10 +162,16 @@ export abstract class Move implements Localizable {
|
||||
}
|
||||
|
||||
localize(): void {
|
||||
const i18nKey = MoveId[this.id].split("_").filter(f => f).map((f, i) => i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()).join("") as unknown as string;
|
||||
const i18nKey = toCamelCase(MoveId[this.id])
|
||||
|
||||
this.name = this.id ? `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}` : "";
|
||||
this.effect = this.id ? `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}` : "";
|
||||
if (this.id === MoveId.NONE) {
|
||||
this.name = "";
|
||||
this.effect = ""
|
||||
return;
|
||||
}
|
||||
|
||||
this.name = `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}`;
|
||||
this.effect = `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1950,25 +1956,51 @@ export class AddSubstituteAttr extends MoveEffectAttr {
|
||||
}
|
||||
|
||||
/**
|
||||
* Heals the user or target by {@linkcode healRatio} depending on the value of {@linkcode selfTarget}
|
||||
* @extends MoveEffectAttr
|
||||
* @see {@linkcode apply}
|
||||
* Attribute to implement healing moves, such as {@linkcode MoveId.RECOVER} or {@linkcode MoveId.SOFT_BOILED}.
|
||||
* Heals the user or target of the move by a fixed amount relative to their maximum HP.
|
||||
*/
|
||||
export class HealAttr extends MoveEffectAttr {
|
||||
/** The percentage of {@linkcode Stat.HP} to heal */
|
||||
private healRatio: number;
|
||||
/** Should an animation be shown? */
|
||||
private showAnim: boolean;
|
||||
/** The percentage of {@linkcode Stat.HP} to heal; default `1` */
|
||||
protected healRatio = 1
|
||||
/** Whether to display a healing animation upon healing the target; default `false` */
|
||||
private showAnim = false
|
||||
|
||||
constructor(healRatio?: number, showAnim?: boolean, selfTarget?: boolean) {
|
||||
super(selfTarget === undefined || selfTarget);
|
||||
/**
|
||||
* Whether the move should fail if the target is at full HP.
|
||||
* @defaultValue `true`
|
||||
* @todo Remove post move failure rework - this solely exists to prevent Lunar Blessing and co. from failing
|
||||
*/
|
||||
private failOnFullHp = true;
|
||||
|
||||
this.healRatio = healRatio || 1;
|
||||
this.showAnim = !!showAnim;
|
||||
constructor(
|
||||
healRatio = 1,
|
||||
showAnim = false,
|
||||
selfTarget = true,
|
||||
failOnFullHp = true
|
||||
) {
|
||||
super(selfTarget);
|
||||
this.healRatio = healRatio;
|
||||
this.showAnim = showAnim;
|
||||
this.failOnFullHp = failOnFullHp;
|
||||
}
|
||||
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
||||
this.addHealPhase(this.selfTarget ? user : target, this.healRatio);
|
||||
override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
||||
if (!super.apply(user, target, move, args)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply any boosts to healing amounts (i.e. Heal Pulse + Mega Launcher).
|
||||
const hp = new NumberHolder(this.healRatio)
|
||||
applyAbAttrs("MoveHealBoostAbAttr", {
|
||||
pokemon: user,
|
||||
opponent: target,
|
||||
move,
|
||||
healRatio: hp
|
||||
})
|
||||
this.healRatio = hp.value;
|
||||
|
||||
|
||||
this.addHealPhase(this.selfTarget ? user : target);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1976,15 +2008,81 @@ export class HealAttr extends MoveEffectAttr {
|
||||
* Creates a new {@linkcode PokemonHealPhase}.
|
||||
* This heals the target and shows the appropriate message.
|
||||
*/
|
||||
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);
|
||||
protected addHealPhase(healedPokemon: Pokemon) {
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", healedPokemon.getBattlerIndex(),
|
||||
// Healing moves round half UP the hp healed
|
||||
// (unlike most other sources which round down)
|
||||
Math.round(healedPokemon.getMaxHp() * this.healRatio),
|
||||
{
|
||||
message: i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(healedPokemon) }),
|
||||
showFullHpMessage: true,
|
||||
skipAnim: !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));
|
||||
}
|
||||
|
||||
override getCondition(): MoveConditionFunc {
|
||||
return (user, target) => !(this.failOnFullHp && (this.selfTarget ? user : target).isFullHp());
|
||||
}
|
||||
|
||||
override getFailedText(user: Pokemon, target: Pokemon): string | undefined {
|
||||
const healedPokemon = this.selfTarget ? user : target;
|
||||
return i18next.t("battle:hpIsFull", {
|
||||
pokemonName: getPokemonNameWithAffix(healedPokemon),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute for moves with variable healing amounts.
|
||||
* Heals the user/target by an amount depending on the return value of {@linkcode healFunc}.
|
||||
*
|
||||
* Used for:
|
||||
* - {@linkcode MoveId.MOONLIGHT} and variants
|
||||
* - {@linkcode MoveId.SHORE_UP}
|
||||
* - {@linkcode MoveId.FLORAL_HEALING}
|
||||
* - {@linkcode MoveId.SWALLOW}
|
||||
*/
|
||||
export class VariableHealAttr extends HealAttr {
|
||||
constructor(
|
||||
/** A function yielding the amount of HP to heal. */
|
||||
private healFunc: (user: Pokemon, target: Pokemon, move: Move) => number,
|
||||
showAnim = false,
|
||||
selfTarget = true,
|
||||
failOnFullHp = true,
|
||||
) {
|
||||
super(1, showAnim, selfTarget, failOnFullHp);
|
||||
this.healFunc = healFunc;
|
||||
}
|
||||
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
|
||||
this.healRatio = this.healFunc(user, target, move)
|
||||
return super.apply(user, target, move, _args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heals the target only if it is an ally.
|
||||
* Used for {@linkcode MoveId.POLLEN_PUFF}.
|
||||
*/
|
||||
export class HealOnAllyAttr extends HealAttr {
|
||||
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 getCondition(): MoveConditionFunc {
|
||||
return (user, target, _move) => user.getAlly() !== target || super.getCondition()(user, target, _move)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2111,16 +2209,18 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr {
|
||||
const pm = globalScene.phaseManager;
|
||||
|
||||
pm.pushPhase(
|
||||
pm.create("PokemonHealPhase",
|
||||
pm.create(
|
||||
"PokemonHealPhase",
|
||||
user.getBattlerIndex(),
|
||||
maxPartyMemberHp,
|
||||
i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
this.restorePP),
|
||||
{
|
||||
message: i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }),
|
||||
showFullHpMessage: false,
|
||||
skipAnim: true,
|
||||
healStatus: true,
|
||||
fullRestorePP: this.restorePP,
|
||||
}
|
||||
),
|
||||
true);
|
||||
|
||||
return true;
|
||||
@ -2167,112 +2267,6 @@ export class IgnoreWeatherTypeDebuffAttr extends MoveAttr {
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class WeatherHealAttr extends HealAttr {
|
||||
constructor() {
|
||||
super(0.5);
|
||||
}
|
||||
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
||||
let healRatio = 0.5;
|
||||
if (!globalScene.arena.weather?.isEffectSuppressed()) {
|
||||
const weatherType = globalScene.arena.weather?.weatherType || WeatherType.NONE;
|
||||
healRatio = this.getWeatherHealRatio(weatherType);
|
||||
}
|
||||
this.addHealPhase(user, healRatio);
|
||||
return true;
|
||||
}
|
||||
|
||||
abstract getWeatherHealRatio(weatherType: WeatherType): number;
|
||||
}
|
||||
|
||||
export class PlantHealAttr extends WeatherHealAttr {
|
||||
getWeatherHealRatio(weatherType: WeatherType): number {
|
||||
switch (weatherType) {
|
||||
case WeatherType.SUNNY:
|
||||
case WeatherType.HARSH_SUN:
|
||||
return 2 / 3;
|
||||
case WeatherType.RAIN:
|
||||
case WeatherType.SANDSTORM:
|
||||
case WeatherType.HAIL:
|
||||
case WeatherType.SNOW:
|
||||
case WeatherType.FOG:
|
||||
case WeatherType.HEAVY_RAIN:
|
||||
return 0.25;
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SandHealAttr extends WeatherHealAttr {
|
||||
getWeatherHealRatio(weatherType: WeatherType): number {
|
||||
switch (weatherType) {
|
||||
case WeatherType.SANDSTORM:
|
||||
return 2 / 3;
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heals the target or the user by either {@linkcode normalHealRatio} or {@linkcode boostedHealRatio}
|
||||
* depending on the evaluation of {@linkcode condition}
|
||||
* @extends HealAttr
|
||||
* @see {@linkcode apply}
|
||||
*/
|
||||
export class BoostHealAttr extends HealAttr {
|
||||
/** Healing received when {@linkcode condition} is false */
|
||||
private normalHealRatio: number;
|
||||
/** Healing received when {@linkcode condition} is true */
|
||||
private boostedHealRatio: number;
|
||||
/** The lambda expression to check against when boosting the healing value */
|
||||
private condition?: MoveConditionFunc;
|
||||
|
||||
constructor(normalHealRatio: number = 0.5, boostedHealRatio: number = 2 / 3, showAnim?: boolean, selfTarget?: boolean, condition?: MoveConditionFunc) {
|
||||
super(normalHealRatio, showAnim, selfTarget);
|
||||
this.normalHealRatio = normalHealRatio;
|
||||
this.boostedHealRatio = boostedHealRatio;
|
||||
this.condition = condition;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 move was successful
|
||||
*/
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
||||
const healRatio: number = (this.condition ? this.condition(user, target, move) : false) ? this.boostedHealRatio : this.normalHealRatio;
|
||||
this.addHealPhase(target, healRatio);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heals the target only if it is the ally
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heals user as a side effect of a move that hits a target.
|
||||
* Healing is based on {@linkcode healRatio} * the amount of damage dealt or a stat of the target.
|
||||
@ -2323,7 +2317,9 @@ export class HitHealAttr extends MoveEffectAttr {
|
||||
message = "";
|
||||
}
|
||||
}
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", user.getBattlerIndex(), healAmount, message, false, true);
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", user.getBattlerIndex(), healAmount,
|
||||
{message, showFullHpMessage: false, skipAnim: true}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -4356,7 +4352,8 @@ export class PunishmentPowerAttr extends VariablePowerAttr {
|
||||
}
|
||||
|
||||
export class PresentPowerAttr extends VariablePowerAttr {
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean {
|
||||
const power = args[0]
|
||||
/**
|
||||
* If this move is multi-hit, and this attribute is applied to any hit
|
||||
* other than the first, this move cannot result in a heal.
|
||||
@ -4365,17 +4362,21 @@ export class PresentPowerAttr extends VariablePowerAttr {
|
||||
|
||||
const powerSeed = randSeedInt(firstHit ? 100 : 80);
|
||||
if (powerSeed <= 40) {
|
||||
(args[0] as NumberHolder).value = 40;
|
||||
} else if (40 < powerSeed && powerSeed <= 70) {
|
||||
(args[0] as NumberHolder).value = 80;
|
||||
} else if (70 < powerSeed && powerSeed <= 80) {
|
||||
(args[0] as NumberHolder).value = 120;
|
||||
} else if (80 < powerSeed && powerSeed <= 100) {
|
||||
// If this move is multi-hit, disable all other hits
|
||||
power.value = 40;
|
||||
} else if (powerSeed <= 70) {
|
||||
power.value = 80;
|
||||
} else if (powerSeed <= 80) {
|
||||
power.value = 120;
|
||||
} else if (powerSeed <= 100) {
|
||||
// Disable all other hits and heal the target for 25% max HP
|
||||
user.turnData.hitCount = 1;
|
||||
user.turnData.hitsLeft = 1;
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(),
|
||||
toDmgValue(target.getMaxHp() / 4), i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) }), true);
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
target.getBattlerIndex(),
|
||||
toDmgValue(target.getMaxHp() / 4),
|
||||
{message: i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) })}
|
||||
)
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -4416,36 +4417,6 @@ export class SpitUpPowerAttr extends VariablePowerAttr {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute used to apply Swallow's healing, which scales with Stockpile stacks.
|
||||
* Does NOT remove stockpiled stacks.
|
||||
*/
|
||||
export class SwallowHealAttr extends HealAttr {
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
||||
const stockpilingTag = user.getTag(StockpilingTag);
|
||||
|
||||
if (stockpilingTag && stockpilingTag.stockpiledCount > 0) {
|
||||
const stockpiled = stockpilingTag.stockpiledCount;
|
||||
let healRatio: number;
|
||||
|
||||
if (stockpiled === 1) {
|
||||
healRatio = 0.25;
|
||||
} else if (stockpiled === 2) {
|
||||
healRatio = 0.50;
|
||||
} else { // stockpiled >= 3
|
||||
healRatio = 1.00;
|
||||
}
|
||||
|
||||
if (healRatio) {
|
||||
this.addHealPhase(user, healRatio);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const hasStockpileStacksCondition: MoveConditionFunc = (user) => {
|
||||
const hasStockpilingTag = user.getTag(StockpilingTag);
|
||||
return !!hasStockpilingTag && hasStockpilingTag.stockpiledCount > 0;
|
||||
@ -8075,6 +8046,53 @@ const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) =
|
||||
return message;
|
||||
};
|
||||
|
||||
const sunnyHealRatioFunc = (): number => {
|
||||
if (globalScene.arena.weather?.isEffectSuppressed()) {
|
||||
return 1 / 2;
|
||||
}
|
||||
|
||||
switch (globalScene.arena.getWeatherType()) {
|
||||
case WeatherType.SUNNY:
|
||||
case WeatherType.HARSH_SUN:
|
||||
return 2 / 3;
|
||||
case WeatherType.RAIN:
|
||||
case WeatherType.SANDSTORM:
|
||||
case WeatherType.HAIL:
|
||||
case WeatherType.SNOW:
|
||||
case WeatherType.HEAVY_RAIN:
|
||||
case WeatherType.FOG:
|
||||
return 1 / 4;
|
||||
case WeatherType.STRONG_WINDS:
|
||||
default:
|
||||
return 1 / 2;
|
||||
}
|
||||
}
|
||||
|
||||
const shoreUpHealRatioFunc = (): number => {
|
||||
if (globalScene.arena.weather?.isEffectSuppressed()) {
|
||||
return 1 / 2;
|
||||
}
|
||||
|
||||
return globalScene.arena.getWeatherType() === WeatherType.SANDSTORM ? 2 / 3 : 1 / 2;
|
||||
}
|
||||
|
||||
const swallowHealFunc = (user: Pokemon): number => {
|
||||
const tag = user.getTag(BattlerTagType.STOCKPILING);
|
||||
if (!tag || tag.stockpiledCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (tag.stockpiledCount) {
|
||||
case 1:
|
||||
return 0.25;
|
||||
case 2:
|
||||
return 0.5;
|
||||
case 3:
|
||||
default: // in case we ever get more stacks
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export class MoveCondition {
|
||||
protected func: MoveConditionFunc;
|
||||
|
||||
@ -8284,15 +8302,12 @@ const MoveAttrs = Object.freeze({
|
||||
SacrificialAttrOnHit,
|
||||
HalfSacrificialAttr,
|
||||
AddSubstituteAttr,
|
||||
HealAttr,
|
||||
PartyStatusCureAttr,
|
||||
FlameBurstAttr,
|
||||
SacrificialFullRestoreAttr,
|
||||
IgnoreWeatherTypeDebuffAttr,
|
||||
WeatherHealAttr,
|
||||
PlantHealAttr,
|
||||
SandHealAttr,
|
||||
BoostHealAttr,
|
||||
HealAttr,
|
||||
VariableHealAttr,
|
||||
HealOnAllyAttr,
|
||||
HitHealAttr,
|
||||
IncrementMovePriorityAttr,
|
||||
@ -8356,7 +8371,6 @@ const MoveAttrs = Object.freeze({
|
||||
PresentPowerAttr,
|
||||
WaterShurikenPowerAttr,
|
||||
SpitUpPowerAttr,
|
||||
SwallowHealAttr,
|
||||
MultiHitPowerIncrementAttr,
|
||||
LastMoveDoublePowerAttr,
|
||||
CombinedPledgePowerAttr,
|
||||
@ -9214,13 +9228,13 @@ export function initMoves() {
|
||||
.attr(StatStageChangeAttr, [ Stat.ATK ], 1, true),
|
||||
new AttackMove(MoveId.VITAL_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 70, -1, 10, -1, -1, 2),
|
||||
new SelfStatusMove(MoveId.MORNING_SUN, PokemonType.NORMAL, -1, 5, -1, 0, 2)
|
||||
.attr(PlantHealAttr)
|
||||
.attr(VariableHealAttr, sunnyHealRatioFunc)
|
||||
.triageMove(),
|
||||
new SelfStatusMove(MoveId.SYNTHESIS, PokemonType.GRASS, -1, 5, -1, 0, 2)
|
||||
.attr(PlantHealAttr)
|
||||
.attr(VariableHealAttr, sunnyHealRatioFunc)
|
||||
.triageMove(),
|
||||
new SelfStatusMove(MoveId.MOONLIGHT, PokemonType.FAIRY, -1, 5, -1, 0, 2)
|
||||
.attr(PlantHealAttr)
|
||||
.attr(VariableHealAttr, sunnyHealRatioFunc)
|
||||
.triageMove(),
|
||||
new AttackMove(MoveId.HIDDEN_POWER, PokemonType.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 2)
|
||||
.attr(HiddenPowerTypeAttr),
|
||||
@ -9277,15 +9291,15 @@ export function initMoves() {
|
||||
.partial(), // Does not lock the user, does not stop Pokemon from sleeping
|
||||
// Likely can make use of FrenzyAttr and an ArenaTag (just without the FrenzyMissFunc)
|
||||
new SelfStatusMove(MoveId.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3)
|
||||
.condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3)
|
||||
.condition(user => (user.getTag(BattlerTagType.STOCKPILING)?.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)
|
||||
.attr(VariableHealAttr, swallowHealFunc, false, true, false)
|
||||
.condition(hasStockpileStacksCondition)
|
||||
.attr(SwallowHealAttr)
|
||||
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true)
|
||||
.triageMove(),
|
||||
new AttackMove(MoveId.HEAT_WAVE, PokemonType.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3)
|
||||
@ -10548,7 +10562,7 @@ export function initMoves() {
|
||||
.unimplemented(),
|
||||
/* End Unused */
|
||||
new SelfStatusMove(MoveId.SHORE_UP, PokemonType.GROUND, -1, 5, -1, 0, 7)
|
||||
.attr(SandHealAttr)
|
||||
.attr(VariableHealAttr, shoreUpHealRatioFunc)
|
||||
.triageMove(),
|
||||
new AttackMove(MoveId.FIRST_IMPRESSION, PokemonType.BUG, MoveCategory.PHYSICAL, 90, 100, 10, -1, 2, 7)
|
||||
.condition(new FirstMoveCondition()),
|
||||
@ -10568,7 +10582,7 @@ export function initMoves() {
|
||||
.attr(StatStageChangeAttr, [ Stat.SPD ], -1, true)
|
||||
.punchingMove(),
|
||||
new StatusMove(MoveId.FLORAL_HEALING, PokemonType.FAIRY, -1, 10, -1, 0, 7)
|
||||
.attr(BoostHealAttr, 0.5, 2 / 3, true, false, (user, target, move) => globalScene.arena.terrain?.terrainType === TerrainType.GRASSY)
|
||||
.attr(VariableHealAttr, () => globalScene.arena.getTerrainType() === TerrainType.GRASSY ? 2 / 3 : 1 / 2, true, false)
|
||||
.triageMove()
|
||||
.reflectable(),
|
||||
new AttackMove(MoveId.HIGH_HORSEPOWER, PokemonType.GROUND, MoveCategory.PHYSICAL, 95, 95, 10, -1, 0, 7),
|
||||
@ -11065,10 +11079,11 @@ export function initMoves() {
|
||||
.attr(HealStatusEffectAttr, false, StatusEffect.FREEZE)
|
||||
.attr(StatusEffectAttr, StatusEffect.BURN),
|
||||
new StatusMove(MoveId.JUNGLE_HEALING, PokemonType.GRASS, -1, 10, -1, 0, 8)
|
||||
.attr(HealAttr, 0.25, true, false)
|
||||
.attr(HealAttr, 0.25, true, false, false)
|
||||
.attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects())
|
||||
.target(MoveTarget.USER_AND_ALLIES)
|
||||
.triageMove(),
|
||||
.triageMove()
|
||||
.edgeCase(), // TODO: Review if jungle healing fails if HP cannot be restored and status cannot be cured
|
||||
new AttackMove(MoveId.WICKED_BLOW, PokemonType.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8)
|
||||
.attr(CritOnlyAttr)
|
||||
.punchingMove(),
|
||||
@ -11170,10 +11185,11 @@ export function initMoves() {
|
||||
.windMove()
|
||||
.target(MoveTarget.ALL_NEAR_ENEMIES),
|
||||
new StatusMove(MoveId.LUNAR_BLESSING, PokemonType.PSYCHIC, -1, 5, -1, 0, 8)
|
||||
.attr(HealAttr, 0.25, true, false)
|
||||
.attr(HealAttr, 0.25, true, false, false)
|
||||
.attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects())
|
||||
.target(MoveTarget.USER_AND_ALLIES)
|
||||
.triageMove(),
|
||||
.triageMove()
|
||||
.edgeCase(), // TODO: Review if lunar blessing fails if HP cannot be restored and status cannot be cured
|
||||
new SelfStatusMove(MoveId.TAKE_HEART, PokemonType.PSYCHIC, -1, 10, -1, 0, 8)
|
||||
.attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF ], 1, true)
|
||||
.attr(HealStatusEffectAttr, true, [ StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN, StatusEffect.SLEEP ]),
|
||||
|
@ -12,6 +12,7 @@ import { WeatherType } from "#enums/weather-type";
|
||||
import type { Pokemon } from "#field/pokemon";
|
||||
import type { PokemonFormChangeItemModifier } from "#modifiers/modifier";
|
||||
import { type Constructor, coerceArray } from "#utils/common";
|
||||
import { toCamelCase } from "#utils/strings";
|
||||
import i18next from "i18next";
|
||||
|
||||
export abstract class SpeciesFormChangeTrigger {
|
||||
@ -143,11 +144,7 @@ export class SpeciesFormChangeMoveLearnedTrigger extends SpeciesFormChangeTrigge
|
||||
super();
|
||||
this.move = move;
|
||||
this.known = known;
|
||||
const moveKey = MoveId[this.move]
|
||||
.split("_")
|
||||
.filter(f => f)
|
||||
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
|
||||
.join("") as unknown as string;
|
||||
const moveKey = toCamelCase(MoveId[this.move]);
|
||||
this.description = known
|
||||
? i18next.t("pokemonEvolutions:Forms.moveLearned", {
|
||||
move: i18next.t(`move:${moveKey}.name`),
|
||||
|
@ -155,13 +155,13 @@ export class WishTag extends PositionalTag implements WishArgs {
|
||||
|
||||
public override trigger(): void {
|
||||
// TODO: Rename this locales key - wish shows a message on REMOVAL, not addition
|
||||
globalScene.phaseManager.queueMessage(
|
||||
i18next.t("arenaTag:wishTagOnAdd", {
|
||||
// TODO: What messages does Wish show when healing a Pokemon at full HP?
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, {
|
||||
message: i18next.t("arenaTag:wishTagOnAdd", {
|
||||
pokemonNameWithAffix: this.pokemonName,
|
||||
}),
|
||||
);
|
||||
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, null, true, false);
|
||||
showFullHpMessage: false,
|
||||
});
|
||||
}
|
||||
|
||||
public override shouldTrigger(): boolean {
|
||||
|
@ -38,6 +38,7 @@ export enum UiMode {
|
||||
UNAVAILABLE,
|
||||
CHALLENGE_SELECT,
|
||||
RENAME_POKEMON,
|
||||
RENAME_RUN,
|
||||
RUN_HISTORY,
|
||||
RUN_INFO,
|
||||
TEST_DIALOGUE,
|
||||
|
@ -54,7 +54,7 @@ export class Arena {
|
||||
public bgm: string;
|
||||
public ignoreAbilities: boolean;
|
||||
public ignoringEffectSource: BattlerIndex | null;
|
||||
public playerTerasUsed: number;
|
||||
public playerTerasUsed = 0;
|
||||
/**
|
||||
* Saves the number of times a party pokemon faints during a arena encounter.
|
||||
* {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave).
|
||||
@ -68,12 +68,11 @@ export class Arena {
|
||||
|
||||
public readonly eventTarget: EventTarget = new EventTarget();
|
||||
|
||||
constructor(biome: BiomeId, bgm: string, playerFaints = 0) {
|
||||
constructor(biome: BiomeId, playerFaints = 0) {
|
||||
this.biomeType = biome;
|
||||
this.bgm = bgm;
|
||||
this.bgm = BiomeId[biome].toLowerCase();
|
||||
this.trainerPool = biomeTrainerPools[biome];
|
||||
this.updatePoolsForTimeOfDay();
|
||||
this.playerTerasUsed = 0;
|
||||
this.playerFaints = playerFaints;
|
||||
}
|
||||
|
||||
|
@ -24,11 +24,11 @@ import { NoCritTag, WeakenMoveScreenTag } from "#data/arena-tag";
|
||||
import {
|
||||
AutotomizedTag,
|
||||
BattlerTag,
|
||||
type BattlerTagTypeMap,
|
||||
CritBoostTag,
|
||||
EncoreTag,
|
||||
ExposedTag,
|
||||
GroundedTag,
|
||||
type GrudgeTag,
|
||||
getBattlerTag,
|
||||
HighestStatBoostTag,
|
||||
MoveRestrictionBattlerTag,
|
||||
@ -1641,6 +1641,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
return this.getMaxHp() - this.hp;
|
||||
}
|
||||
|
||||
// TODO: Why does this default to `false`?
|
||||
getHpRatio(precise = false): number {
|
||||
return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100;
|
||||
}
|
||||
@ -4239,13 +4240,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
}
|
||||
|
||||
/** @overload */
|
||||
getTag(tagType: BattlerTagType.GRUDGE): GrudgeTag | undefined;
|
||||
|
||||
/** @overload */
|
||||
getTag(tagType: BattlerTagType.SUBSTITUTE): SubstituteTag | undefined;
|
||||
|
||||
/** @overload */
|
||||
getTag(tagType: BattlerTagType): BattlerTag | undefined;
|
||||
getTag<T extends BattlerTagType>(tagType: T): BattlerTagTypeMap[T] | undefined;
|
||||
|
||||
/** @overload */
|
||||
getTag<T extends BattlerTag>(tagType: Constructor<T>): T | undefined;
|
||||
|
@ -447,7 +447,9 @@ export class LoadingScene extends SceneBase {
|
||||
);
|
||||
|
||||
if (!mobile) {
|
||||
loadingGraphics.map(g => g.setVisible(false));
|
||||
loadingGraphics.forEach(g => {
|
||||
g.setVisible(false);
|
||||
});
|
||||
}
|
||||
|
||||
const intro = this.add.video(0, 0);
|
||||
|
@ -121,8 +121,8 @@ export class ModifierBar extends Phaser.GameObjects.Container {
|
||||
}
|
||||
|
||||
updateModifierOverflowVisibility(ignoreLimit: boolean) {
|
||||
const modifierIcons = this.getAll().reverse();
|
||||
for (const modifier of modifierIcons.map(m => m as Phaser.GameObjects.Container).slice(iconOverflowIndex)) {
|
||||
const modifierIcons = this.getAll().reverse() as Phaser.GameObjects.Container[];
|
||||
for (const modifier of modifierIcons.slice(iconOverflowIndex)) {
|
||||
modifier.setVisible(ignoreLimit);
|
||||
}
|
||||
}
|
||||
@ -1674,11 +1674,12 @@ export class TurnHealModifier extends PokemonHeldItemModifier {
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / 16) * this.stackCount,
|
||||
i18next.t("modifier:turnHealApply", {
|
||||
{
|
||||
message: i18next.t("modifier:turnHealApply", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
typeName: this.type.name,
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@ -1766,16 +1767,16 @@ export class HitHealModifier extends PokemonHeldItemModifier {
|
||||
*/
|
||||
override apply(pokemon: Pokemon): boolean {
|
||||
if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) {
|
||||
// TODO: this shouldn't be undefined AFAIK
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.turnData.totalDamageDealt / 8) * this.stackCount,
|
||||
i18next.t("modifier:hitHealApply", {
|
||||
toDmgValue((pokemon.turnData.totalDamageDealt * this.stackCount) / 8),
|
||||
{
|
||||
message: i18next.t("modifier:hitHealApply", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
typeName: this.type.name,
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -1934,20 +1935,22 @@ export class PokemonInstantReviveModifier extends PokemonHeldItemModifier {
|
||||
*/
|
||||
override apply(pokemon: Pokemon): boolean {
|
||||
// Restore the Pokemon to half HP
|
||||
// TODO: This should not use a phase to revive pokemon
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
toDmgValue(pokemon.getMaxHp() / 2),
|
||||
i18next.t("modifier:pokemonInstantReviveApply", {
|
||||
{
|
||||
message: i18next.t("modifier:pokemonInstantReviveApply", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
typeName: this.type.name,
|
||||
}),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
revive: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Remove the Pokemon's FAINT status
|
||||
// TODO: Remove call to `resetStatus` once StatusEffect.FAINT is canned
|
||||
pokemon.resetStatus(true, false, true, false);
|
||||
|
||||
// Reapply Commander on the Pokemon's side of the field, if applicable
|
||||
@ -3549,24 +3552,24 @@ export class EnemyTurnHealModifier extends EnemyPersistentModifier {
|
||||
* @returns `true` if the {@linkcode Pokemon} was healed
|
||||
*/
|
||||
override apply(enemyPokemon: Pokemon): boolean {
|
||||
if (!enemyPokemon.isFullHp()) {
|
||||
if (enemyPokemon.isFullHp()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent healing to full from healing tokens
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
enemyPokemon.getBattlerIndex(),
|
||||
Math.max(Math.floor(enemyPokemon.getMaxHp() / (100 / this.healPercent)) * this.stackCount, 1),
|
||||
i18next.t("modifier:enemyTurnHealApply", {
|
||||
(enemyPokemon.getMaxHp() * this.stackCount * this.healPercent) / 100,
|
||||
{
|
||||
message: i18next.t("modifier:enemyTurnHealApply", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(enemyPokemon),
|
||||
}),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
preventFullHeal: true,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getMaxStackCount(): number {
|
||||
|
@ -1,39 +1,81 @@
|
||||
import { globalScene } from "#app/global-scene";
|
||||
import { getPokemonNameWithAffix } from "#app/messages";
|
||||
import type { HealBlockTag } from "#data/battler-tags";
|
||||
import { getStatusEffectHealText } from "#data/status-effect";
|
||||
import type { BattlerIndex } from "#enums/battler-index";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import { HitResult } from "#enums/hit-result";
|
||||
import { CommonAnim } from "#enums/move-anims-common";
|
||||
import { StatusEffect } from "#enums/status-effect";
|
||||
import { HealingBoosterModifier } from "#modifiers/modifier";
|
||||
import { CommonAnimPhase } from "#phases/common-anim-phase";
|
||||
import { HealAchv } from "#system/achv";
|
||||
import { NumberHolder } from "#utils/common";
|
||||
import { NumberHolder, toDmgValue } from "#utils/common";
|
||||
import i18next from "i18next";
|
||||
|
||||
export class PokemonHealPhase extends CommonAnimPhase {
|
||||
public readonly phaseName = "PokemonHealPhase";
|
||||
|
||||
/** The base amount of HP to heal. */
|
||||
private hpHealed: number;
|
||||
private message: string | null;
|
||||
/**
|
||||
* The message to display upon healing the target, or `undefined` to show no message.
|
||||
* Will be overridden by the full HP message if {@linkcode showFullHpMessage} is set to `true`
|
||||
*/
|
||||
private message: string | undefined;
|
||||
/**
|
||||
* Whether to show a failure message upon healing a Pokemon already at full HP.
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
private showFullHpMessage: boolean;
|
||||
/**
|
||||
* Whether to skip showing the healing animation.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
private skipAnim: boolean;
|
||||
/**
|
||||
* Whether to revive the affected Pokemon in addition to healing.
|
||||
* Revives will not be affected by any Healing Charms.
|
||||
* @todo Remove post modifier rework as revives will not be using phases to heal stuff
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
private revive: boolean;
|
||||
/**
|
||||
* Whether to heal the affected Pokemon's status condition.
|
||||
* @todo This should not be the healing phase's job
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
private healStatus: boolean;
|
||||
/**
|
||||
* Whether to prevent fully healing affected Pokemon, leaving them 1 HP below full.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
private preventFullHeal: boolean;
|
||||
/**
|
||||
* Whether to fully restore PP upon healing.
|
||||
* @todo This should not be the healing phase's job
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
private fullRestorePP: boolean;
|
||||
|
||||
constructor(
|
||||
battlerIndex: BattlerIndex,
|
||||
hpHealed: number,
|
||||
message: string | null,
|
||||
showFullHpMessage: boolean,
|
||||
{
|
||||
message,
|
||||
showFullHpMessage = true,
|
||||
skipAnim = false,
|
||||
revive = false,
|
||||
healStatus = false,
|
||||
preventFullHeal = false,
|
||||
fullRestorePP = false,
|
||||
}: {
|
||||
message?: string;
|
||||
showFullHpMessage?: boolean;
|
||||
skipAnim?: boolean;
|
||||
revive?: boolean;
|
||||
healStatus?: boolean;
|
||||
preventFullHeal?: boolean;
|
||||
fullRestorePP?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
super(battlerIndex, undefined, CommonAnim.HEALTH_UP);
|
||||
|
||||
@ -47,89 +89,115 @@ export class PokemonHealPhase extends CommonAnimPhase {
|
||||
this.fullRestorePP = fullRestorePP;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.skipAnim && (this.revive || this.getPokemon().hp) && !this.getPokemon().isFullHp()) {
|
||||
override start() {
|
||||
// Only play animation if not skipped and target is not at full HP
|
||||
if (!this.skipAnim && !this.getPokemon().isFullHp()) {
|
||||
super.start();
|
||||
} else {
|
||||
this.end();
|
||||
}
|
||||
}
|
||||
|
||||
end() {
|
||||
// This is required as `commonAnimPhase` calls `this.end` once the animation finishes
|
||||
// TODO: This is a really shitty process and i hate it
|
||||
override end() {
|
||||
this.heal().then(() => {
|
||||
super.end();
|
||||
});
|
||||
}
|
||||
|
||||
private async heal() {
|
||||
const pokemon = this.getPokemon();
|
||||
|
||||
if (!pokemon.isOnField() || (!this.revive && !pokemon.isActive())) {
|
||||
return super.end();
|
||||
// Prevent healing off-field pokemon unless via revives
|
||||
// TODO: Revival effects shouldn't use this phase
|
||||
if (!this.revive && !pokemon.isActive(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMessage = !!this.message;
|
||||
const healOrDamage = !pokemon.isFullHp() || this.hpHealed < 0;
|
||||
const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK) as HealBlockTag;
|
||||
let lastStatusEffect = StatusEffect.NONE;
|
||||
|
||||
// Check for heal block, ending the phase early if healing was prevented
|
||||
const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK);
|
||||
if (healBlock && this.hpHealed > 0) {
|
||||
globalScene.phaseManager.queueMessage(healBlock.onActivation(pokemon));
|
||||
this.message = null;
|
||||
return super.end();
|
||||
return;
|
||||
}
|
||||
if (healOrDamage) {
|
||||
const hpRestoreMultiplier = new NumberHolder(1);
|
||||
if (!this.revive) {
|
||||
globalScene.applyModifiers(HealingBoosterModifier, this.player, hpRestoreMultiplier);
|
||||
}
|
||||
const healAmount = new NumberHolder(Math.floor(this.hpHealed * hpRestoreMultiplier.value));
|
||||
if (healAmount.value < 0) {
|
||||
pokemon.damageAndUpdate(healAmount.value * -1, { result: HitResult.INDIRECT });
|
||||
healAmount.value = 0;
|
||||
}
|
||||
// Prevent healing to full if specified (in case of healing tokens so Sturdy doesn't cause a softlock)
|
||||
if (this.preventFullHeal && pokemon.hp + healAmount.value >= pokemon.getMaxHp()) {
|
||||
healAmount.value = pokemon.getMaxHp() - pokemon.hp - 1;
|
||||
}
|
||||
healAmount.value = pokemon.heal(healAmount.value);
|
||||
if (healAmount.value) {
|
||||
globalScene.damageNumberHandler.add(pokemon, healAmount.value, HitResult.HEAL);
|
||||
}
|
||||
if (pokemon.isPlayer()) {
|
||||
globalScene.validateAchvs(HealAchv, healAmount);
|
||||
if (healAmount.value > globalScene.gameData.gameStats.highestHeal) {
|
||||
globalScene.gameData.gameStats.highestHeal = healAmount.value;
|
||||
}
|
||||
}
|
||||
if (this.healStatus && !this.revive && pokemon.status) {
|
||||
lastStatusEffect = pokemon.status.effect;
|
||||
|
||||
this.doHealPokemon();
|
||||
|
||||
// Cure status as applicable
|
||||
// TODO: This should not be the job of the healing phase
|
||||
if (this.healStatus && pokemon.status) {
|
||||
this.message = getStatusEffectHealText(pokemon.status.effect, getPokemonNameWithAffix(pokemon));
|
||||
pokemon.resetStatus();
|
||||
}
|
||||
|
||||
// Restore PP.
|
||||
// TODO: This should not be the job of the healing phase
|
||||
if (this.fullRestorePP) {
|
||||
for (const move of this.getPokemon().getMoveset()) {
|
||||
if (move) {
|
||||
move.ppUsed = 0;
|
||||
pokemon.getMoveset().forEach(m => {
|
||||
m.ppUsed = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Show message, update info boxes and then wrap up.
|
||||
if (this.message) {
|
||||
globalScene.phaseManager.queueMessage(this.message);
|
||||
}
|
||||
await pokemon.updateInfo();
|
||||
}
|
||||
pokemon.updateInfo().then(() => super.end());
|
||||
} else if (this.healStatus && !this.revive && pokemon.status) {
|
||||
lastStatusEffect = pokemon.status.effect;
|
||||
pokemon.resetStatus();
|
||||
pokemon.updateInfo().then(() => super.end());
|
||||
} else if (this.showFullHpMessage) {
|
||||
|
||||
/**
|
||||
* Heal the Pokemon affected by this Phase.
|
||||
*/
|
||||
private doHealPokemon(): void {
|
||||
const pokemon = this.getPokemon()!;
|
||||
|
||||
// If we would heal the user past full HP, don't.
|
||||
if (this.hpHealed > 0 && pokemon.isFullHp()) {
|
||||
if (this.showFullHpMessage) {
|
||||
this.message = i18next.t("battle:hpIsFull", {
|
||||
pokemonName: getPokemonNameWithAffix(pokemon),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.message) {
|
||||
globalScene.phaseManager.queueMessage(this.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.healStatus && lastStatusEffect && !hasMessage) {
|
||||
globalScene.phaseManager.queueMessage(
|
||||
getStatusEffectHealText(lastStatusEffect, getPokemonNameWithAffix(pokemon)),
|
||||
);
|
||||
const healAmount = this.getHealAmount();
|
||||
|
||||
if (healAmount < 0) {
|
||||
// If Liquid Ooze is active, damage the user for the healing amount, then return.
|
||||
// TODO: Consider refactoring liquid ooze to not use a heal phase to do damage
|
||||
pokemon.damageAndUpdate(-healAmount, { result: HitResult.INDIRECT });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!healOrDamage && !lastStatusEffect) {
|
||||
super.end();
|
||||
// Heal the pokemon, then show damage numbers and validate achievements.
|
||||
pokemon.heal(healAmount);
|
||||
globalScene.damageNumberHandler.add(pokemon, healAmount, HitResult.HEAL);
|
||||
if (pokemon.isPlayer()) {
|
||||
globalScene.validateAchvs(HealAchv, healAmount);
|
||||
globalScene.gameData.gameStats.highestHeal = Math.max(globalScene.gameData.gameStats.highestHeal, healAmount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the amount of HP to be healed during this Phase.
|
||||
* @returns The updated healing amount post-modifications, capped at the Pokemon's maximum HP.
|
||||
* @remarks
|
||||
* The effect of Healing Charms are rounded down for parity with the closest mainline counterpart
|
||||
* (i.e. Big Root).
|
||||
*/
|
||||
private getHealAmount(): number {
|
||||
if (this.revive) {
|
||||
return toDmgValue(this.hpHealed);
|
||||
}
|
||||
|
||||
// Apply the effect of healing charms for non-revival items before rounding down and capping at max HP
|
||||
// (or 1 below max for healing tokens).
|
||||
// Liquid Ooze damage (being negative) remains uncapped as normal.
|
||||
const healMulti = new NumberHolder(1);
|
||||
globalScene.applyModifiers(HealingBoosterModifier, this.player, healMulti);
|
||||
// TODO: we need to round liquid ooze dmg towards 0, not down
|
||||
return Math.min(Math.floor(this.hpHealed * healMulti.value), this.getPokemon().getMaxHp() - +this.preventFullHeal);
|
||||
}
|
||||
}
|
||||
|
@ -154,16 +154,11 @@ export class QuietFormChangePhase extends BattlePhase {
|
||||
this.pokemon.findAndRemoveTags(t => t.tagType === BattlerTagType.AUTOTOMIZED);
|
||||
if (globalScene?.currentBattle.battleSpec === BattleSpec.FINAL_BOSS && this.pokemon.isEnemy()) {
|
||||
globalScene.playBgm();
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
this.pokemon.getBattlerIndex(),
|
||||
this.pokemon.getMaxHp(),
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.pokemon.getBattlerIndex(), this.pokemon.getMaxHp(), {
|
||||
showFullHpMessage: false,
|
||||
healStatus: true,
|
||||
fullRestorePP: true,
|
||||
});
|
||||
this.pokemon.findAndRemoveTags(() => true);
|
||||
this.pokemon.bossSegments = 5;
|
||||
this.pokemon.bossSegmentIndex = 4;
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
TurnStatusEffectModifier,
|
||||
} from "#modifiers/modifier";
|
||||
import { FieldPhase } from "#phases/field-phase";
|
||||
import { toDmgValue } from "#utils/common";
|
||||
import i18next from "i18next";
|
||||
|
||||
export class TurnEndPhase extends FieldPhase {
|
||||
@ -38,11 +39,12 @@ export class TurnEndPhase extends FieldPhase {
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"PokemonHealPhase",
|
||||
pokemon.getBattlerIndex(),
|
||||
Math.max(pokemon.getMaxHp() >> 4, 1),
|
||||
i18next.t("battle:turnEndHpRestore", {
|
||||
toDmgValue(pokemon.getMaxHp() / 16),
|
||||
{
|
||||
message: i18next.t("battle:turnEndHpRestore", {
|
||||
pokemonName: getPokemonNameWithAffix(pokemon),
|
||||
}),
|
||||
true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -127,6 +127,7 @@ export interface SessionSaveData {
|
||||
battleType: BattleType;
|
||||
trainer: TrainerData;
|
||||
gameVersion: string;
|
||||
runNameText: string;
|
||||
timestamp: number;
|
||||
challenges: ChallengeData[];
|
||||
mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME,
|
||||
@ -206,10 +207,12 @@ export interface StarterData {
|
||||
[key: number]: StarterDataEntry;
|
||||
}
|
||||
|
||||
export interface TutorialFlags {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
// TODO: Rework into a bitmask
|
||||
export type TutorialFlags = {
|
||||
[key in Tutorial]: boolean;
|
||||
};
|
||||
|
||||
// TODO: Rework into a bitmask
|
||||
export interface SeenDialogues {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
@ -822,52 +825,51 @@ export class GameData {
|
||||
return true; // TODO: is `true` the correct return value?
|
||||
}
|
||||
|
||||
private loadGamepadSettings(): boolean {
|
||||
Object.values(SettingGamepad)
|
||||
.map(setting => setting as SettingGamepad)
|
||||
.forEach(setting => setSettingGamepad(setting, settingGamepadDefaults[setting]));
|
||||
private loadGamepadSettings(): void {
|
||||
Object.values(SettingGamepad).forEach(setting => {
|
||||
setSettingGamepad(setting, settingGamepadDefaults[setting]);
|
||||
});
|
||||
|
||||
if (!localStorage.hasOwnProperty("settingsGamepad")) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
const settingsGamepad = JSON.parse(localStorage.getItem("settingsGamepad")!); // TODO: is this bang correct?
|
||||
|
||||
for (const setting of Object.keys(settingsGamepad)) {
|
||||
setSettingGamepad(setting as SettingGamepad, settingsGamepad[setting]);
|
||||
}
|
||||
|
||||
return true; // TODO: is `true` the correct return value?
|
||||
}
|
||||
|
||||
public saveTutorialFlag(tutorial: Tutorial, flag: boolean): boolean {
|
||||
const key = getDataTypeKey(GameDataType.TUTORIALS);
|
||||
let tutorials: object = {};
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
tutorials = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct?
|
||||
}
|
||||
/**
|
||||
* Save the specified tutorial as having the specified completion status.
|
||||
* @param tutorial - The {@linkcode Tutorial} whose completion status is being saved
|
||||
* @param status - The completion status to set
|
||||
*/
|
||||
public saveTutorialFlag(tutorial: Tutorial, status: boolean): void {
|
||||
// Grab the prior save data tutorial
|
||||
const saveDataKey = getDataTypeKey(GameDataType.TUTORIALS);
|
||||
const tutorials: TutorialFlags = localStorage.hasOwnProperty(saveDataKey)
|
||||
? JSON.parse(localStorage.getItem(saveDataKey)!)
|
||||
: {};
|
||||
|
||||
Object.keys(Tutorial)
|
||||
.map(t => t as Tutorial)
|
||||
.forEach(t => {
|
||||
const key = Tutorial[t];
|
||||
// TODO: We shouldn't be storing this like that
|
||||
for (const key of Object.values(Tutorial)) {
|
||||
if (key === tutorial) {
|
||||
tutorials[key] = flag;
|
||||
tutorials[key] = status;
|
||||
} else {
|
||||
tutorials[key] ??= false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(tutorials));
|
||||
|
||||
return true;
|
||||
localStorage.setItem(saveDataKey, JSON.stringify(tutorials));
|
||||
}
|
||||
|
||||
public getTutorialFlags(): TutorialFlags {
|
||||
const key = getDataTypeKey(GameDataType.TUTORIALS);
|
||||
const ret: TutorialFlags = {};
|
||||
Object.values(Tutorial)
|
||||
.map(tutorial => tutorial as Tutorial)
|
||||
.forEach(tutorial => (ret[Tutorial[tutorial]] = false));
|
||||
const ret: TutorialFlags = Object.values(Tutorial).reduce((acc, tutorial) => {
|
||||
acc[Tutorial[tutorial]] = false;
|
||||
return acc;
|
||||
}, {} as TutorialFlags);
|
||||
|
||||
if (!localStorage.hasOwnProperty(key)) {
|
||||
return ret;
|
||||
@ -979,6 +981,54 @@ export class GameData {
|
||||
});
|
||||
}
|
||||
|
||||
async renameSession(slotId: number, newName: string): Promise<boolean> {
|
||||
return new Promise(async resolve => {
|
||||
if (slotId < 0) {
|
||||
return resolve(false);
|
||||
}
|
||||
const sessionData: SessionSaveData | null = await this.getSession(slotId);
|
||||
|
||||
if (!sessionData) {
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
if (newName === "") {
|
||||
return resolve(true);
|
||||
}
|
||||
|
||||
sessionData.runNameText = newName;
|
||||
const updatedDataStr = JSON.stringify(sessionData);
|
||||
const encrypted = encrypt(updatedDataStr, bypassLogin);
|
||||
const secretId = this.secretId;
|
||||
const trainerId = this.trainerId;
|
||||
|
||||
if (bypassLogin) {
|
||||
localStorage.setItem(
|
||||
`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`,
|
||||
encrypt(updatedDataStr, bypassLogin),
|
||||
);
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
pokerogueApi.savedata.session
|
||||
.update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted)
|
||||
.then(error => {
|
||||
if (error) {
|
||||
console.error("Failed to update session name:", error);
|
||||
resolve(false);
|
||||
} else {
|
||||
localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted);
|
||||
updateUserInfo().then(success => {
|
||||
if (success !== null && !success) {
|
||||
return resolve(false);
|
||||
}
|
||||
});
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
loadSession(slotId: number, sessionData?: SessionSaveData): Promise<boolean> {
|
||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this
|
||||
return new Promise(async (resolve, reject) => {
|
||||
|
54
src/ui/rename-run-ui-handler.ts
Normal file
54
src/ui/rename-run-ui-handler.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -26,6 +26,7 @@ import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text";
|
||||
import { UiHandler } from "#ui/ui-handler";
|
||||
import { addWindow } from "#ui/ui-theme";
|
||||
import { formatFancyLargeNumber, formatLargeNumber, formatMoney, getPlayTimeString } from "#utils/common";
|
||||
import { toCamelCase } from "#utils/strings";
|
||||
import i18next from "i18next";
|
||||
import RoundRectangle from "phaser3-rex-plugins/plugins/roundrectangle";
|
||||
|
||||
@ -207,6 +208,10 @@ export class RunInfoUiHandler extends UiHandler {
|
||||
headerText.setOrigin(0, 0);
|
||||
headerText.setPositionRelative(headerBg, 8, 4);
|
||||
this.runContainer.add(headerText);
|
||||
const runName = addTextObject(0, 0, this.runInfo.runNameText, TextStyle.WINDOW);
|
||||
runName.setOrigin(0, 0);
|
||||
runName.setPositionRelative(headerBg, 60, 4);
|
||||
this.runContainer.add(runName);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -702,10 +707,7 @@ export class RunInfoUiHandler extends UiHandler {
|
||||
rules.push(i18next.t("challenges:inverseBattle.shortName"));
|
||||
break;
|
||||
default: {
|
||||
const localizationKey = Challenges[this.runInfo.challenges[i].id]
|
||||
.split("_")
|
||||
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
|
||||
.join("");
|
||||
const localizationKey = toCamelCase(Challenges[this.runInfo.challenges[i].id]);
|
||||
rules.push(i18next.t(`challenges:${localizationKey}.name`));
|
||||
break;
|
||||
}
|
||||
|
@ -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;
|
||||
if (!sessionSlot.malformed) {
|
||||
manageDataOptions.push({
|
||||
label: i18next.t("menu:loadGame"),
|
||||
handler: () => {
|
||||
globalScene.ui.revertMode();
|
||||
originalCallback?.(cursor);
|
||||
return true;
|
||||
},
|
||||
keepOpen: false,
|
||||
});
|
||||
|
||||
manageDataOptions.push({
|
||||
label: i18next.t("saveSlotSelectUiHandler:renameRun"),
|
||||
handler: () => {
|
||||
globalScene.ui.revertMode();
|
||||
ui.setOverlayMode(
|
||||
UiMode.RENAME_RUN,
|
||||
{
|
||||
buttonActions: [
|
||||
(sanitizedName: string) => {
|
||||
const name = decodeURIComponent(atob(sanitizedName));
|
||||
globalScene.gameData.renameSession(cursor, name).then(response => {
|
||||
if (response[0] === false) {
|
||||
globalScene.reset(true);
|
||||
} else {
|
||||
this.clearSessionSlots();
|
||||
this.cursorObj = null;
|
||||
this.populateSessionSlots();
|
||||
this.setScrollCursor(0);
|
||||
this.setCursor(0);
|
||||
ui.revertMode();
|
||||
ui.showText("", 0);
|
||||
}
|
||||
});
|
||||
},
|
||||
() => {
|
||||
ui.revertMode();
|
||||
},
|
||||
],
|
||||
},
|
||||
"",
|
||||
);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.manageDataConfig = {
|
||||
xOffset: 0,
|
||||
yOffset: 48,
|
||||
options: manageDataOptions,
|
||||
maxOptions: 4,
|
||||
};
|
||||
|
||||
manageDataOptions.push({
|
||||
label: i18next.t("saveSlotSelectUiHandler:deleteRun"),
|
||||
handler: () => {
|
||||
globalScene.ui.revertMode();
|
||||
ui.showText(i18next.t("saveSlotSelectUiHandler:deleteData"), null, () => {
|
||||
ui.setOverlayMode(
|
||||
UiMode.CONFIRM,
|
||||
() => {
|
||||
globalScene.gameData.tryClearSession(cursor).then(response => {
|
||||
if (response[0] === false) {
|
||||
globalScene.reset(true);
|
||||
} else {
|
||||
this.clearSessionSlots();
|
||||
this.cursorObj = null;
|
||||
this.populateSessionSlots();
|
||||
this.setScrollCursor(0);
|
||||
this.setCursor(0);
|
||||
ui.revertMode();
|
||||
ui.showText("", 0);
|
||||
}
|
||||
});
|
||||
},
|
||||
() => {
|
||||
ui.revertMode();
|
||||
ui.showText("", 0);
|
||||
},
|
||||
false,
|
||||
0,
|
||||
19,
|
||||
import.meta.env.DEV ? 300 : 2000,
|
||||
);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
keepOpen: false,
|
||||
});
|
||||
|
||||
manageDataOptions.push({
|
||||
label: i18next.t("menuUiHandler:cancel"),
|
||||
handler: () => {
|
||||
globalScene.ui.revertMode();
|
||||
return true;
|
||||
},
|
||||
keepOpen: true,
|
||||
});
|
||||
|
||||
ui.setOverlayMode(UiMode.MENU_OPTION_SELECT, this.manageDataConfig);
|
||||
break;
|
||||
|
||||
case SaveSlotUiMode.SAVE: {
|
||||
const saveAndCallback = () => {
|
||||
const originalCallback = this.saveSlotSelectCallback;
|
||||
@ -161,6 +266,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
||||
}
|
||||
} else {
|
||||
this.saveSlotSelectCallback = null;
|
||||
ui.showText("", 0);
|
||||
originalCallback?.(-1);
|
||||
success = true;
|
||||
}
|
||||
@ -267,33 +373,34 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
||||
this.cursorObj = globalScene.add.container(0, 0);
|
||||
const cursorBox = globalScene.add.nineslice(
|
||||
0,
|
||||
0,
|
||||
15,
|
||||
"select_cursor_highlight_thick",
|
||||
undefined,
|
||||
296,
|
||||
44,
|
||||
294,
|
||||
this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
);
|
||||
const rightArrow = globalScene.add.image(0, 0, "cursor");
|
||||
rightArrow.setPosition(160, 0);
|
||||
rightArrow.setPosition(160, 15);
|
||||
rightArrow.setName("rightArrow");
|
||||
this.cursorObj.add([cursorBox, rightArrow]);
|
||||
this.sessionSlotsContainer.add(this.cursorObj);
|
||||
}
|
||||
const cursorPosition = cursor + this.scrollCursor;
|
||||
const cursorIncrement = cursorPosition * 56;
|
||||
const cursorIncrement = cursorPosition * 76;
|
||||
if (this.sessionSlots[cursorPosition] && this.cursorObj) {
|
||||
const hasData = this.sessionSlots[cursorPosition].hasData;
|
||||
const session = this.sessionSlots[cursorPosition];
|
||||
const hasData = session.hasData && !session.malformed;
|
||||
// If the session slot lacks session data, it does not move from its default, central position.
|
||||
// Only session slots with session data will move leftwards and have a visible arrow.
|
||||
if (!hasData) {
|
||||
this.cursorObj.setPosition(151, 26 + cursorIncrement);
|
||||
this.cursorObj.setPosition(151, 20 + cursorIncrement);
|
||||
this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement);
|
||||
} else {
|
||||
this.cursorObj.setPosition(145, 26 + cursorIncrement);
|
||||
this.cursorObj.setPosition(145, 20 + cursorIncrement);
|
||||
this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement);
|
||||
}
|
||||
this.setArrowVisibility(hasData);
|
||||
@ -311,7 +418,8 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
||||
revertSessionSlot(slotIndex: number): void {
|
||||
const sessionSlot = this.sessionSlots[slotIndex];
|
||||
if (sessionSlot) {
|
||||
sessionSlot.setPosition(0, slotIndex * 56);
|
||||
const valueHeight = 76;
|
||||
sessionSlot.setPosition(0, slotIndex * valueHeight);
|
||||
}
|
||||
}
|
||||
|
||||
@ -340,7 +448,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
||||
this.setCursor(this.cursor, prevSlotIndex);
|
||||
globalScene.tweens.add({
|
||||
targets: this.sessionSlotsContainer,
|
||||
y: this.sessionSlotsContainerInitialY - 56 * scrollCursor,
|
||||
y: this.sessionSlotsContainerInitialY - 76 * scrollCursor,
|
||||
duration: fixedInt(325),
|
||||
ease: "Sine.easeInOut",
|
||||
});
|
||||
@ -374,12 +482,14 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
||||
class SessionSlot extends Phaser.GameObjects.Container {
|
||||
public slotId: number;
|
||||
public hasData: boolean;
|
||||
/** Indicates the save slot ran into an error while being loaded */
|
||||
public malformed: boolean;
|
||||
private slotWindow: Phaser.GameObjects.NineSlice;
|
||||
private loadingLabel: Phaser.GameObjects.Text;
|
||||
|
||||
public saveData: SessionSaveData;
|
||||
|
||||
constructor(slotId: number) {
|
||||
super(globalScene, 0, slotId * 56);
|
||||
super(globalScene, 0, slotId * 76);
|
||||
|
||||
this.slotId = slotId;
|
||||
|
||||
@ -387,32 +497,89 @@ class SessionSlot extends Phaser.GameObjects.Container {
|
||||
}
|
||||
|
||||
setup() {
|
||||
const slotWindow = addWindow(0, 0, 304, 52);
|
||||
this.add(slotWindow);
|
||||
this.slotWindow = addWindow(0, 0, 304, 70);
|
||||
this.add(this.slotWindow);
|
||||
|
||||
this.loadingLabel = addTextObject(152, 26, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW);
|
||||
this.loadingLabel = addTextObject(152, 33, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW);
|
||||
this.loadingLabel.setOrigin(0.5, 0.5);
|
||||
this.add(this.loadingLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a name for sessions that don't have a name yet.
|
||||
* @param data - The {@linkcode SessionSaveData} being checked
|
||||
* @returns The default name for the given data.
|
||||
*/
|
||||
decideFallback(data: SessionSaveData): string {
|
||||
let fallbackName = `${GameMode.getModeName(data.gameMode)}`;
|
||||
switch (data.gameMode) {
|
||||
case GameModes.CLASSIC:
|
||||
fallbackName += ` (${globalScene.gameData.gameStats.classicSessionsPlayed + 1})`;
|
||||
break;
|
||||
case GameModes.ENDLESS:
|
||||
case GameModes.SPLICED_ENDLESS:
|
||||
fallbackName += ` (${globalScene.gameData.gameStats.endlessSessionsPlayed + 1})`;
|
||||
break;
|
||||
case GameModes.DAILY: {
|
||||
const runDay = new Date(data.timestamp).toLocaleDateString();
|
||||
fallbackName += ` (${runDay})`;
|
||||
break;
|
||||
}
|
||||
case GameModes.CHALLENGE: {
|
||||
const activeChallenges = data.challenges.filter(c => c.value !== 0);
|
||||
if (activeChallenges.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
fallbackName = "";
|
||||
for (const challenge of activeChallenges.slice(0, 3)) {
|
||||
if (fallbackName !== "") {
|
||||
fallbackName += ", ";
|
||||
}
|
||||
fallbackName += challenge.toChallenge().getName();
|
||||
}
|
||||
|
||||
if (activeChallenges.length > 3) {
|
||||
fallbackName += ", ...";
|
||||
} else if (fallbackName === "") {
|
||||
// Something went wrong when retrieving the names of the active challenges,
|
||||
// so fall back to just naming the run "Challenge"
|
||||
fallbackName = `${GameMode.getModeName(data.gameMode)}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
async setupWithData(data: SessionSaveData) {
|
||||
const hasName = data?.runNameText;
|
||||
this.remove(this.loadingLabel, true);
|
||||
if (hasName) {
|
||||
const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW);
|
||||
this.add(nameLabel);
|
||||
} else {
|
||||
const fallbackName = this.decideFallback(data);
|
||||
await globalScene.gameData.renameSession(this.slotId, fallbackName);
|
||||
const nameLabel = addTextObject(8, 5, fallbackName, TextStyle.WINDOW);
|
||||
this.add(nameLabel);
|
||||
}
|
||||
|
||||
const gameModeLabel = addTextObject(
|
||||
8,
|
||||
5,
|
||||
19,
|
||||
`${GameMode.getModeName(data.gameMode) || i18next.t("gameMode:unknown")} - ${i18next.t("saveSlotSelectUiHandler:wave")} ${data.waveIndex}`,
|
||||
TextStyle.WINDOW,
|
||||
);
|
||||
this.add(gameModeLabel);
|
||||
|
||||
const timestampLabel = addTextObject(8, 19, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW);
|
||||
const timestampLabel = addTextObject(8, 33, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW);
|
||||
this.add(timestampLabel);
|
||||
|
||||
const playTimeLabel = addTextObject(8, 33, getPlayTimeString(data.playTime), TextStyle.WINDOW);
|
||||
const playTimeLabel = addTextObject(8, 47, getPlayTimeString(data.playTime), TextStyle.WINDOW);
|
||||
this.add(playTimeLabel);
|
||||
|
||||
const pokemonIconsContainer = globalScene.add.container(144, 4);
|
||||
const pokemonIconsContainer = globalScene.add.container(144, 16);
|
||||
data.party.forEach((p: PokemonData, i: number) => {
|
||||
const iconContainer = globalScene.add.container(26 * i, 0);
|
||||
iconContainer.setScale(0.75);
|
||||
@ -427,13 +594,9 @@ class SessionSlot extends Phaser.GameObjects.Container {
|
||||
TextStyle.PARTY,
|
||||
{ fontSize: "54px", color: "#f8f8f8" },
|
||||
);
|
||||
text.setShadow(0, 0, undefined);
|
||||
text.setStroke("#424242", 14);
|
||||
text.setOrigin(1, 0);
|
||||
|
||||
iconContainer.add(icon);
|
||||
iconContainer.add(text);
|
||||
text.setShadow(0, 0, undefined).setStroke("#424242", 14).setOrigin(1, 0);
|
||||
|
||||
iconContainer.add([icon, text]);
|
||||
pokemonIconsContainer.add(iconContainer);
|
||||
|
||||
pokemon.destroy();
|
||||
@ -441,7 +604,7 @@ class SessionSlot extends Phaser.GameObjects.Container {
|
||||
|
||||
this.add(pokemonIconsContainer);
|
||||
|
||||
const modifierIconsContainer = globalScene.add.container(148, 30);
|
||||
const modifierIconsContainer = globalScene.add.container(148, 38);
|
||||
modifierIconsContainer.setScale(0.5);
|
||||
let visibleModifierIndex = 0;
|
||||
for (const m of data.modifiers) {
|
||||
@ -464,20 +627,31 @@ class SessionSlot extends Phaser.GameObjects.Container {
|
||||
|
||||
load(): Promise<boolean> {
|
||||
return new Promise<boolean>(resolve => {
|
||||
globalScene.gameData.getSession(this.slotId).then(async sessionData => {
|
||||
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.hasData = false;
|
||||
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
this.hasData = true;
|
||||
this.saveData = sessionData;
|
||||
await this.setupWithData(sessionData);
|
||||
this.setupWithData(sessionData);
|
||||
resolve(true);
|
||||
})
|
||||
.catch(e => {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
console.warn(`Failed to load session slot #${this.slotId}:`, e);
|
||||
this.loadingLabel.setText(i18next.t("menu:failedToLoadSession"));
|
||||
this.hasData = true;
|
||||
this.malformed = true;
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
|
@ -31,7 +31,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler {
|
||||
// we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key
|
||||
|
||||
// Return in the format expected by i18next
|
||||
return middleKey ? `${topKey}:${middleKey.map(m => m).join(".")}.${t}` : `${topKey}:${t}`;
|
||||
return middleKey ? `${topKey}:${middleKey.join(".")}.${t}` : `${topKey}:${t}`;
|
||||
}
|
||||
})
|
||||
.filter(t => t);
|
||||
|
@ -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),
|
||||
|
8
test/@types/vitest.d.ts
vendored
8
test/@types/vitest.d.ts
vendored
@ -1,4 +1,5 @@
|
||||
import type { TerrainType } from "#app/data/terrain";
|
||||
import type Overrides from "#app/overrides";
|
||||
import type { AbilityId } from "#enums/ability-id";
|
||||
import type { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import type { MoveId } from "#enums/move-id";
|
||||
@ -7,14 +8,14 @@ import type { BattleStat, EffectiveStat, Stat } from "#enums/stat";
|
||||
import type { StatusEffect } from "#enums/status-effect";
|
||||
import type { WeatherType } from "#enums/weather-type";
|
||||
import type { Pokemon } from "#field/pokemon";
|
||||
import type { PokemonMove } from "#moves/pokemon-move";
|
||||
import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
|
||||
import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect";
|
||||
import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
|
||||
import type { TurnMove } from "#types/turn-move";
|
||||
import type { AtLeastOne } from "#types/type-helpers";
|
||||
import type { toDmgValue } from "#utils/common";
|
||||
import type { expect } from "vitest";
|
||||
import type Overrides from "#app/overrides";
|
||||
import type { PokemonMove } from "#moves/pokemon-move";
|
||||
|
||||
declare module "vitest" {
|
||||
interface Assertion {
|
||||
@ -113,8 +114,9 @@ declare module "vitest" {
|
||||
/**
|
||||
* Check whether a {@linkcode Pokemon} has a specific amount of {@linkcode Stat.HP | HP}.
|
||||
* @param expectedHp - The expected amount of {@linkcode Stat.HP | HP} to have
|
||||
* @param roundDown - Whether to round down {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true`
|
||||
*/
|
||||
toHaveHp(expectedHp: number): void;
|
||||
toHaveHp(expectedHp: number, roundDown?: boolean): void;
|
||||
|
||||
/**
|
||||
* Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}).
|
||||
|
@ -2,7 +2,6 @@ import { AbilityId } from "#enums/ability-id";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { DamageAnimPhase } from "#phases/damage-anim-phase";
|
||||
import { TurnEndPhase } from "#phases/turn-end-phase";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
@ -54,7 +53,7 @@ describe("Items - Leftovers", () => {
|
||||
const leadHpAfterDamage = leadPokemon.hp;
|
||||
|
||||
// Check if leftovers heal us
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
await game.toNextTurn();
|
||||
expect(leadPokemon.hp).toBeGreaterThan(leadHpAfterDamage);
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { getPokemonNameWithAffix } from "#app/messages";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { MoveResult } from "#enums/move-result";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import i18next from "i18next";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("Moves - Pollen Puff", () => {
|
||||
describe("Move - Pollen Puff", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
let game: GameManager;
|
||||
|
||||
@ -23,42 +26,77 @@ describe("Moves - Pollen Puff", () => {
|
||||
beforeEach(() => {
|
||||
game = new GameManager(phaserGame);
|
||||
game.override
|
||||
.moveset([MoveId.POLLEN_PUFF])
|
||||
.ability(AbilityId.BALL_FETCH)
|
||||
.battleStyle("single")
|
||||
.criticalHits(false)
|
||||
.enemyLevel(100)
|
||||
.enemySpecies(SpeciesId.MAGIKARP)
|
||||
.enemyAbility(AbilityId.BALL_FETCH)
|
||||
.enemyMoveset(MoveId.SPLASH);
|
||||
});
|
||||
|
||||
it("should not heal more than once when the user has a source of multi-hit", async () => {
|
||||
game.override.battleStyle("double").moveset([MoveId.POLLEN_PUFF, MoveId.ENDURE]).ability(AbilityId.PARENTAL_BOND);
|
||||
it("should damage an enemy when used, or heal an ally for 50% max HP", async () => {
|
||||
game.override.battleStyle("double").ability(AbilityId.PARENTAL_BOND);
|
||||
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.OMANYTE]);
|
||||
|
||||
const [_, rightPokemon] = game.scene.getPlayerField();
|
||||
const [_, omantye, karp1] = game.scene.getField();
|
||||
omantye.hp = 1;
|
||||
|
||||
rightPokemon.damageAndUpdate(rightPokemon.hp - 1);
|
||||
game.move.use(MoveId.POLLEN_PUFF, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
|
||||
game.move.use(MoveId.POLLEN_PUFF, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
|
||||
await game.toNextTurn();
|
||||
|
||||
game.move.select(MoveId.POLLEN_PUFF, 0, BattlerIndex.PLAYER_2);
|
||||
game.move.select(MoveId.ENDURE, 1);
|
||||
expect(karp1).not.toHaveFullHp();
|
||||
expect(omantye).toHaveHp(omantye.getMaxHp() / 2 + 1);
|
||||
expect(game.phaseInterceptor.log).toContain("PokemonHealPhase");
|
||||
});
|
||||
|
||||
await game.phaseInterceptor.to("BerryPhase");
|
||||
it("should display message & count as failed when healing a full HP ally", async () => {
|
||||
game.override.battleStyle("double").ability(AbilityId.PARENTAL_BOND);
|
||||
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.OMANYTE]);
|
||||
|
||||
// Pollen Puff heals with a ratio of 0.5, as long as Pollen Puff triggers only once the pokemon will always be <= (0.5 * Max HP) + 1
|
||||
expect(rightPokemon.hp).toBeLessThanOrEqual(0.5 * rightPokemon.getMaxHp() + 1);
|
||||
const [bulbasaur, omantye] = game.scene.getPlayerField();
|
||||
|
||||
game.move.use(MoveId.POLLEN_PUFF, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
|
||||
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// move failed without unshifting a phase
|
||||
expect(omantye).toHaveFullHp();
|
||||
expect(bulbasaur).toHaveUsedMove({ move: MoveId.POLLEN_PUFF, result: MoveResult.FAIL });
|
||||
expect(game.textInterceptor.logs).toContain(
|
||||
i18next.t("battle:hpIsFull", {
|
||||
pokemonName: getPokemonNameWithAffix(omantye),
|
||||
}),
|
||||
);
|
||||
expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase");
|
||||
});
|
||||
|
||||
it("should not heal more than once if the user has a source of multi-hit", async () => {
|
||||
game.override.battleStyle("double").ability(AbilityId.PARENTAL_BOND);
|
||||
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.OMANYTE]);
|
||||
|
||||
const [bulbasaur, omantye] = game.scene.getPlayerField();
|
||||
|
||||
omantye.hp = 1;
|
||||
|
||||
game.move.use(MoveId.POLLEN_PUFF, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
|
||||
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(bulbasaur.turnData.hitCount).toBe(1);
|
||||
expect(omantye).toHaveHp(omantye.getMaxHp() / 2 + 1);
|
||||
expect(game.phaseInterceptor.log.filter(l => l === "PokemonHealPhase")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should damage an enemy multiple times when the user has a source of multi-hit", async () => {
|
||||
game.override.moveset([MoveId.POLLEN_PUFF]).ability(AbilityId.PARENTAL_BOND).enemyLevel(100);
|
||||
game.override.ability(AbilityId.PARENTAL_BOND);
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
game.move.use(MoveId.POLLEN_PUFF);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
const target = game.field.getEnemyPokemon();
|
||||
|
||||
game.move.select(MoveId.POLLEN_PUFF);
|
||||
|
||||
await game.phaseInterceptor.to("BerryPhase");
|
||||
|
||||
expect(target.battleData.hitCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
223
test/moves/recovery-moves.test.ts
Normal file
223
test/moves/recovery-moves.test.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { getPokemonNameWithAffix } from "#app/messages";
|
||||
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 { Stat } from "#enums/stat";
|
||||
import { WeatherType } from "#enums/weather-type";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import { getEnumValues } from "#utils/enums";
|
||||
import { toTitleCase } from "#utils/strings";
|
||||
import i18next from "i18next";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("Moves - ", () => {
|
||||
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.MAGIC_GUARD) // prevents passive weather damage
|
||||
.battleStyle("single")
|
||||
.criticalHits(false)
|
||||
.enemySpecies(SpeciesId.CHANSEY)
|
||||
.enemyAbility(AbilityId.BALL_FETCH)
|
||||
.enemyMoveset(MoveId.SPLASH)
|
||||
.startingLevel(100)
|
||||
.enemyLevel(100);
|
||||
});
|
||||
|
||||
describe("Self-Healing Moves -", () => {
|
||||
describe.each<{ name: string; move: MoveId }>([
|
||||
{ name: "Recover", move: MoveId.RECOVER },
|
||||
{ name: "Soft-Boiled", move: MoveId.SOFT_BOILED },
|
||||
{ name: "Milk Drink", move: MoveId.MILK_DRINK },
|
||||
{ name: "Slack Off", move: MoveId.SLACK_OFF },
|
||||
{ name: "Heal Order", move: MoveId.HEAL_ORDER },
|
||||
{ name: "Roost", move: MoveId.ROOST },
|
||||
{ name: "Weather-based Healing Moves", move: MoveId.SYNTHESIS },
|
||||
{ name: "Shore Up", move: MoveId.SHORE_UP },
|
||||
])("$name", ({ move }) => {
|
||||
it("should heal 50% of the user's maximum HP, rounded half up", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
const blissey = game.field.getPlayerPokemon();
|
||||
blissey.hp = 1;
|
||||
blissey.setStat(Stat.HP, 501); // half is 250.5, rounded half up to 251
|
||||
|
||||
game.move.use(move);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(game.phaseInterceptor.log).toContain("PokemonHealPhase");
|
||||
expect(game.textInterceptor.logs).toContain(
|
||||
i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(blissey) }),
|
||||
);
|
||||
expect(blissey).toHaveHp(252); // 251 + 1
|
||||
});
|
||||
|
||||
it("should fail if the user is at full HP", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
game.move.use(move);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
const blissey = game.field.getPlayerPokemon();
|
||||
expect(blissey).toHaveFullHp();
|
||||
expect(game.textInterceptor.logs).toContain(
|
||||
i18next.t("battle:hpIsFull", {
|
||||
pokemonName: getPokemonNameWithAffix(blissey),
|
||||
}),
|
||||
);
|
||||
expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase");
|
||||
expect(blissey).toHaveUsedMove({ move, result: MoveResult.FAIL });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weather-based Healing Moves", () => {
|
||||
it.each([
|
||||
{ name: "Harsh Sunlight", weather: WeatherType.SUNNY },
|
||||
{ name: "Extremely Harsh Sunlight", weather: WeatherType.HARSH_SUN },
|
||||
])("should heal 66% of the user's maximum HP under $name", async ({ weather }) => {
|
||||
game.override.weather(weather);
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
const blissey = game.field.getPlayerPokemon();
|
||||
blissey.hp = 1;
|
||||
|
||||
game.move.use(MoveId.MOONLIGHT);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(blissey.getHpRatio()).toBeCloseTo(0.66, 1);
|
||||
});
|
||||
|
||||
const nonSunWTs = getEnumValues(WeatherType)
|
||||
.filter(
|
||||
wt => ![WeatherType.SUNNY, WeatherType.HARSH_SUN, WeatherType.NONE, WeatherType.STRONG_WINDS].includes(wt),
|
||||
)
|
||||
.map(wt => ({
|
||||
name: toTitleCase(WeatherType[wt]),
|
||||
weather: wt,
|
||||
}));
|
||||
|
||||
it.each(nonSunWTs)("should heal 25% of the user's maximum HP under $name", async ({ weather }) => {
|
||||
game.override.weather(weather);
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
const blissey = game.field.getPlayerPokemon();
|
||||
blissey.hp = 1;
|
||||
|
||||
game.move.use(MoveId.MOONLIGHT);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(blissey.getHpRatio()).toBeCloseTo(0.25, 1);
|
||||
});
|
||||
|
||||
it("should heal 50% of the user's maximum HP under strong winds", async () => {
|
||||
game.override.ability(AbilityId.DELTA_STREAM);
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
const blissey = game.field.getPlayerPokemon();
|
||||
blissey.hp = 1;
|
||||
|
||||
game.move.use(MoveId.MOONLIGHT);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(blissey.getHpRatio()).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Shore Up", () => {
|
||||
it("should heal 66% of the user's maximum HP in a sandstorm", async () => {
|
||||
game.override.weather(WeatherType.SANDSTORM);
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
const blissey = game.field.getPlayerPokemon();
|
||||
blissey.hp = 1;
|
||||
|
||||
game.move.use(MoveId.SHORE_UP);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(blissey.getHpRatio()).toBeCloseTo(0.66, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: "Heal Pulse",
|
||||
move: MoveId.HEAL_PULSE,
|
||||
percent: 3 / 4,
|
||||
ability: AbilityId.MEGA_LAUNCHER,
|
||||
condText: "user has Mega Launcher",
|
||||
},
|
||||
{
|
||||
name: "Floral Healing",
|
||||
move: MoveId.FLORAL_HEALING,
|
||||
percent: 2 / 3,
|
||||
ability: AbilityId.GRASSY_SURGE,
|
||||
condText: "Grassy Terrain is active",
|
||||
},
|
||||
])("Target-Healing Moves - $name", ({ move, percent, ability, condText }) => {
|
||||
it("should heal 50% of the target's maximum HP, rounded half up", async () => {
|
||||
// NB: Shore Up and co. round down in mainline, but we keep them the same as others for consistency's sake
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
const chansey = game.field.getEnemyPokemon();
|
||||
chansey.hp = 1;
|
||||
chansey.setStat(Stat.HP, 501); // half is 250.5, rounded half up to 251
|
||||
|
||||
game.move.use(move);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(game.phaseInterceptor.log).toContain("PokemonHealPhase");
|
||||
expect(game.textInterceptor.logs).toContain(
|
||||
i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(chansey) }),
|
||||
);
|
||||
expect(chansey).toHaveHp(252); // 251 + 1
|
||||
});
|
||||
|
||||
it("should fail if the target is at full HP", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
game.move.use(move);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
const blissey = game.field.getPlayerPokemon();
|
||||
const chansey = game.field.getEnemyPokemon();
|
||||
expect(chansey).toHaveFullHp();
|
||||
expect(game.textInterceptor.logs).toContain(
|
||||
i18next.t("battle:hpIsFull", {
|
||||
pokemonName: getPokemonNameWithAffix(chansey),
|
||||
}),
|
||||
);
|
||||
expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase");
|
||||
expect(blissey).toHaveUsedMove({ move, result: MoveResult.FAIL });
|
||||
});
|
||||
|
||||
it(`should heal ${(percent * 100).toPrecision(2)}% of the target's maximum HP if ${condText}`, async () => {
|
||||
// prevents passive turn heal from grassy terrain
|
||||
game.override.ability(ability).enemyAbility(AbilityId.LEVITATE);
|
||||
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
|
||||
|
||||
const chansey = game.field.getEnemyPokemon();
|
||||
chansey.hp = 1;
|
||||
|
||||
game.move.use(move);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(chansey).toHaveHp(Math.round(percent * chansey.getMaxHp()) + 1);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,13 +1,12 @@
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { PokemonType } from "#enums/pokemon-type";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { MoveEffectPhase } from "#phases/move-effect-phase";
|
||||
import { TurnEndPhase } from "#phases/turn-end-phase";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("Moves - Roost", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
@ -27,219 +26,105 @@ describe("Moves - Roost", () => {
|
||||
game = new GameManager(phaserGame);
|
||||
game.override
|
||||
.battleStyle("single")
|
||||
.enemySpecies(SpeciesId.RELICANTH)
|
||||
.enemySpecies(SpeciesId.SHUCKLE)
|
||||
.ability(AbilityId.BALL_FETCH)
|
||||
.startingLevel(100)
|
||||
.enemyLevel(100)
|
||||
.enemyMoveset(MoveId.EARTHQUAKE)
|
||||
.moveset([MoveId.ROOST, MoveId.BURN_UP, MoveId.DOUBLE_SHOCK]);
|
||||
.enemyMoveset(MoveId.SPLASH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Roost's behavior should be defined as:
|
||||
* The pokemon loses its flying type for a turn. If the pokemon was ungroundd solely due to being a flying type, it will be grounded until end of turn.
|
||||
* 1. Pure Flying type pokemon -> become normal type until end of turn
|
||||
* 2. Dual Flying/X type pokemon -> become type X until end of turn
|
||||
* 3. Pokemon that use burn up into roost (ex. Moltres) -> become flying due to burn up, then typeless until end of turn after using roost
|
||||
* 4. If a pokemon is afflicted with Forest's Curse or Trick or treat, dual type pokemon will become 3 type pokemon after the flying type is regained
|
||||
* Pure flying types become (Grass or Ghost) and then back to flying/ (Grass or Ghost),
|
||||
* and pokemon post Burn up become ()
|
||||
* 5. If a pokemon is also ungrounded due to other reasons (such as levitate), it will stay ungrounded post roost, despite not being flying type.
|
||||
* 6. Non flying types using roost (such as dunsparce) are already grounded, so this move will only heal and have no other effects.
|
||||
*/
|
||||
|
||||
test("Non flying type uses roost -> no type change, took damage", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.DUNSPARCE]);
|
||||
const playerPokemon = game.field.getPlayerPokemon();
|
||||
const playerPokemonStartingHP = playerPokemon.hp;
|
||||
game.move.select(MoveId.ROOST);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
|
||||
// Should only be normal type, and NOT flying type
|
||||
let playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemonTypes[0] === PokemonType.NORMAL).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeTruthy();
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
// Lose HP, still normal type
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP);
|
||||
expect(playerPokemonTypes[0] === PokemonType.NORMAL).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Pure flying type -> becomes normal after roost and takes damage from ground moves -> regains flying", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.TORNADUS]);
|
||||
const playerPokemon = game.field.getPlayerPokemon();
|
||||
const playerPokemonStartingHP = playerPokemon.hp;
|
||||
game.move.select(MoveId.ROOST);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
|
||||
// Should only be normal type, and NOT flying type
|
||||
let playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemonTypes[0] === PokemonType.NORMAL).toBeTruthy();
|
||||
expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeFalsy();
|
||||
expect(playerPokemon.isGrounded()).toBeTruthy();
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
// Should have lost HP and is now back to being pure flying
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP);
|
||||
expect(playerPokemonTypes[0] === PokemonType.NORMAL).toBeFalsy();
|
||||
expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Dual X/flying type -> becomes type X after roost and takes damage from ground moves -> regains flying", async () => {
|
||||
it("should remove the user's Flying type until end of turn", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.HAWLUCHA]);
|
||||
const playerPokemon = game.field.getPlayerPokemon();
|
||||
const playerPokemonStartingHP = playerPokemon.hp;
|
||||
game.move.select(MoveId.ROOST);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
|
||||
// Should only be pure fighting type and grounded
|
||||
let playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemonTypes[0] === PokemonType.FIGHTING).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeTruthy();
|
||||
const hawlucha = game.field.getPlayerPokemon();
|
||||
hawlucha.hp = 1;
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
game.move.use(MoveId.ROOST);
|
||||
await game.phaseInterceptor.to("MoveEffectPhase");
|
||||
|
||||
// Should have lost HP and is now back to being fighting/flying
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP);
|
||||
expect(playerPokemonTypes[0] === PokemonType.FIGHTING).toBeTruthy();
|
||||
expect(playerPokemonTypes[1] === PokemonType.FLYING).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeFalsy();
|
||||
// Should lose flying type temporarily
|
||||
expect(hawlucha).toHaveBattlerTag(BattlerTagType.ROOSTED);
|
||||
expect(hawlucha).toHaveTypes([PokemonType.FIGHTING]);
|
||||
expect(hawlucha.isGrounded()).toBe(true);
|
||||
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// Should have changed back to fighting/flying
|
||||
expect(hawlucha).toHaveTypes([PokemonType.FIGHTING, PokemonType.FLYING]);
|
||||
expect(hawlucha.isGrounded()).toBe(false);
|
||||
});
|
||||
|
||||
test("Pokemon with levitate after using roost should lose flying type but still be unaffected by ground moves", async () => {
|
||||
game.override.starterForms({ [SpeciesId.ROTOM]: 4 });
|
||||
await game.classicMode.startBattle([SpeciesId.ROTOM]);
|
||||
const playerPokemon = game.field.getPlayerPokemon();
|
||||
const playerPokemonStartingHP = playerPokemon.hp;
|
||||
game.move.select(MoveId.ROOST);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
it("should preserve types of non-Flying type Pokemon", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MEW]);
|
||||
|
||||
// Should only be pure eletric type and grounded
|
||||
let playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemonTypes[0] === PokemonType.ELECTRIC).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeFalsy();
|
||||
const mew = game.field.getPlayerPokemon();
|
||||
mew.hp = 1;
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
game.move.use(MoveId.ROOST);
|
||||
await game.toEndOfTurn(false);
|
||||
|
||||
// Should have lost HP and is now back to being electric/flying
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemon.hp).toBe(playerPokemonStartingHP);
|
||||
expect(playerPokemonTypes[0] === PokemonType.ELECTRIC).toBeTruthy();
|
||||
expect(playerPokemonTypes[1] === PokemonType.FLYING).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeFalsy();
|
||||
// Should remain psychic type
|
||||
expect(mew).toHaveTypes([PokemonType.PSYCHIC]);
|
||||
expect(mew.isGrounded()).toBe(true);
|
||||
});
|
||||
|
||||
test("A fire/flying type that uses burn up, then roost should be typeless until end of turn", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MOLTRES]);
|
||||
const playerPokemon = game.field.getPlayerPokemon();
|
||||
const playerPokemonStartingHP = playerPokemon.hp;
|
||||
game.move.select(MoveId.BURN_UP);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
it("should not remove the user's Tera Type", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
|
||||
|
||||
// Should only be pure flying type after burn up
|
||||
let playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
const pidgeot = game.field.getPlayerPokemon();
|
||||
pidgeot.hp = 1;
|
||||
pidgeot.teraType = PokemonType.FLYING;
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
game.move.select(MoveId.ROOST);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
game.move.use(MoveId.ROOST, BattlerIndex.PLAYER, undefined, true);
|
||||
await game.toEndOfTurn(false);
|
||||
|
||||
// Should only be typeless type after roost and is grounded
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemon.getTag(BattlerTagType.ROOSTED)).toBeDefined();
|
||||
expect(playerPokemonTypes[0] === PokemonType.UNKNOWN).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeTruthy();
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
// Should go back to being pure flying and have taken damage from earthquake, and is ungrounded again
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP);
|
||||
expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeFalsy();
|
||||
// Should remain flying type
|
||||
expect(pidgeot).toHaveTypes([PokemonType.FLYING], { args: [true] });
|
||||
expect(pidgeot.isGrounded()).toBe(false);
|
||||
});
|
||||
|
||||
test("An electric/flying type that uses double shock, then roost should be typeless until end of turn", async () => {
|
||||
game.override.enemySpecies(SpeciesId.ZEKROM);
|
||||
await game.classicMode.startBattle([SpeciesId.ZAPDOS]);
|
||||
const playerPokemon = game.field.getPlayerPokemon();
|
||||
const playerPokemonStartingHP = playerPokemon.hp;
|
||||
game.move.select(MoveId.DOUBLE_SHOCK);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
it("should convert pure Flying types into normal types", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.TORNADUS]);
|
||||
|
||||
// Should only be pure flying type after burn up
|
||||
let playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
const tornadus = game.field.getPlayerPokemon();
|
||||
tornadus.hp = 1;
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
game.move.select(MoveId.ROOST);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
game.move.use(MoveId.ROOST);
|
||||
await game.toEndOfTurn(false);
|
||||
|
||||
// Should only be typeless type after roost and is grounded
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemon.getTag(BattlerTagType.ROOSTED)).toBeDefined();
|
||||
expect(playerPokemonTypes[0] === PokemonType.UNKNOWN).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeTruthy();
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
// Should go back to being pure flying and have taken damage from earthquake, and is ungrounded again
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP);
|
||||
expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeFalsy();
|
||||
// Should only be normal type, and NOT flying type
|
||||
expect(tornadus).toHaveTypes([PokemonType.NORMAL]);
|
||||
expect(tornadus.isGrounded()).toBe(true);
|
||||
});
|
||||
|
||||
test("Dual Type Pokemon afflicted with Forests Curse/Trick or Treat and post roost will become dual type and then become 3 type at end of turn", async () => {
|
||||
game.override.enemyMoveset([
|
||||
MoveId.TRICK_OR_TREAT,
|
||||
MoveId.TRICK_OR_TREAT,
|
||||
MoveId.TRICK_OR_TREAT,
|
||||
MoveId.TRICK_OR_TREAT,
|
||||
]);
|
||||
await game.classicMode.startBattle([SpeciesId.MOLTRES]);
|
||||
const playerPokemon = game.field.getPlayerPokemon();
|
||||
game.move.select(MoveId.ROOST);
|
||||
await game.phaseInterceptor.to(MoveEffectPhase);
|
||||
it.each<{ name: string; move: MoveId; species: SpeciesId }>([
|
||||
{ name: "Burn Up", move: MoveId.BURN_UP, species: SpeciesId.MOLTRES },
|
||||
{ name: "Double Shock", move: MoveId.DOUBLE_SHOCK, species: SpeciesId.ZAPDOS },
|
||||
])("should render user typeless when roosting after using $name", async ({ move, species }) => {
|
||||
await game.classicMode.startBattle([species]);
|
||||
|
||||
let playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemonTypes[0] === PokemonType.FIRE).toBeTruthy();
|
||||
expect(playerPokemonTypes.length === 1).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeTruthy();
|
||||
const player = game.field.getPlayerPokemon();
|
||||
player.hp = 1;
|
||||
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
game.move.use(move);
|
||||
await game.toNextTurn();
|
||||
|
||||
// Should be fire/flying/ghost
|
||||
playerPokemonTypes = playerPokemon.getTypes();
|
||||
expect(playerPokemonTypes.filter(type => type === PokemonType.FLYING)).toHaveLength(1);
|
||||
expect(playerPokemonTypes.filter(type => type === PokemonType.FIRE)).toHaveLength(1);
|
||||
expect(playerPokemonTypes.filter(type => type === PokemonType.GHOST)).toHaveLength(1);
|
||||
expect(playerPokemonTypes.length === 3).toBeTruthy();
|
||||
expect(playerPokemon.isGrounded()).toBeFalsy();
|
||||
// Should be pure flying type
|
||||
expect(player).toHaveTypes([PokemonType.FLYING]);
|
||||
expect(player.isGrounded()).toBe(false);
|
||||
|
||||
game.move.use(MoveId.ROOST);
|
||||
await game.phaseInterceptor.to("MoveEffectPhase");
|
||||
|
||||
// Should be typeless
|
||||
expect(player).toHaveBattlerTag(BattlerTagType.ROOSTED);
|
||||
expect(player).toHaveTypes([PokemonType.UNKNOWN]);
|
||||
expect(player.isGrounded()).toBe(true);
|
||||
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// Should go back to being pure flying
|
||||
expect(player).toHaveTypes([PokemonType.FLYING]);
|
||||
expect(player.isGrounded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
@ -1,201 +0,0 @@
|
||||
import { StockpilingTag } from "#data/battler-tags";
|
||||
import { allMoves } from "#data/data-lists";
|
||||
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 type { Move } from "#moves/move";
|
||||
import { MovePhase } from "#phases/move-phase";
|
||||
import { TurnInitPhase } from "#phases/turn-init-phase";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("Moves - Spit Up", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
let game: GameManager;
|
||||
|
||||
let spitUp: Move;
|
||||
|
||||
beforeAll(() => {
|
||||
phaserGame = new Phaser.Game({ type: Phaser.HEADLESS });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
game.phaseInterceptor.restoreOg();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spitUp = allMoves[MoveId.SPIT_UP];
|
||||
game = new GameManager(phaserGame);
|
||||
|
||||
game.override
|
||||
.battleStyle("single")
|
||||
.enemySpecies(SpeciesId.RATTATA)
|
||||
.enemyMoveset(MoveId.SPLASH)
|
||||
.enemyAbility(AbilityId.NONE)
|
||||
.enemyLevel(2000)
|
||||
.moveset(MoveId.SPIT_UP)
|
||||
.ability(AbilityId.NONE);
|
||||
|
||||
vi.spyOn(spitUp, "calculateBattlePower");
|
||||
});
|
||||
|
||||
describe("consumes all stockpile stacks to deal damage (scaling with stacks)", () => {
|
||||
it("1 stack -> 100 power", async () => {
|
||||
const stacksToSetup = 1;
|
||||
const expectedPower = 100;
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup);
|
||||
|
||||
game.move.select(MoveId.SPIT_UP);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce();
|
||||
expect(spitUp.calculateBattlePower).toHaveReturnedWith(expectedPower);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("2 stacks -> 200 power", async () => {
|
||||
const stacksToSetup = 2;
|
||||
const expectedPower = 200;
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup);
|
||||
|
||||
game.move.select(MoveId.SPIT_UP);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce();
|
||||
expect(spitUp.calculateBattlePower).toHaveReturnedWith(expectedPower);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("3 stacks -> 300 power", async () => {
|
||||
const stacksToSetup = 3;
|
||||
const expectedPower = 300;
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup);
|
||||
|
||||
game.move.select(MoveId.SPIT_UP);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce();
|
||||
expect(spitUp.calculateBattlePower).toHaveReturnedWith(expectedPower);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("fails without stacks", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeUndefined();
|
||||
|
||||
game.move.select(MoveId.SPIT_UP);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
|
||||
move: MoveId.SPIT_UP,
|
||||
result: MoveResult.FAIL,
|
||||
targets: [game.field.getEnemyPokemon().getBattlerIndex()],
|
||||
});
|
||||
|
||||
expect(spitUp.calculateBattlePower).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("restores stat boosts granted by stacks", () => {
|
||||
it("decreases stats based on stored values (both boosts equal)", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
|
||||
game.move.select(MoveId.SPIT_UP);
|
||||
await game.phaseInterceptor.to(MovePhase);
|
||||
|
||||
expect(pokemon.getStatStage(Stat.DEF)).toBe(1);
|
||||
expect(pokemon.getStatStage(Stat.SPDEF)).toBe(1);
|
||||
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
|
||||
move: MoveId.SPIT_UP,
|
||||
result: MoveResult.SUCCESS,
|
||||
targets: [game.field.getEnemyPokemon().getBattlerIndex()],
|
||||
});
|
||||
|
||||
expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce();
|
||||
|
||||
expect(pokemon.getStatStage(Stat.DEF)).toBe(0);
|
||||
expect(pokemon.getStatStage(Stat.SPDEF)).toBe(0);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("decreases stats based on stored values (different boosts)", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
|
||||
// for the sake of simplicity (and because other tests cover the setup), set boost amounts directly
|
||||
stockpilingTag.statChangeCounts = {
|
||||
[Stat.DEF]: -1,
|
||||
[Stat.SPDEF]: 2,
|
||||
};
|
||||
|
||||
game.move.select(MoveId.SPIT_UP);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
|
||||
move: MoveId.SPIT_UP,
|
||||
result: MoveResult.SUCCESS,
|
||||
targets: [game.field.getEnemyPokemon().getBattlerIndex()],
|
||||
});
|
||||
|
||||
expect(spitUp.calculateBattlePower).toHaveBeenCalledOnce();
|
||||
|
||||
expect(pokemon.getStatStage(Stat.DEF)).toBe(1);
|
||||
expect(pokemon.getStatStage(Stat.SPDEF)).toBe(-2);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,16 +1,14 @@
|
||||
import { StockpilingTag } from "#data/battler-tags";
|
||||
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 { TurnInitPhase } from "#phases/turn-init-phase";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("Moves - Stockpile", () => {
|
||||
describe("integration tests", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
let game: GameManager;
|
||||
|
||||
@ -29,50 +27,56 @@ describe("Moves - Stockpile", () => {
|
||||
.battleStyle("single")
|
||||
.enemySpecies(SpeciesId.RATTATA)
|
||||
.enemyMoveset(MoveId.SPLASH)
|
||||
.enemyAbility(AbilityId.NONE)
|
||||
.enemyAbility(AbilityId.BALL_FETCH)
|
||||
.startingLevel(2000)
|
||||
.moveset([MoveId.STOCKPILE, MoveId.SPLASH])
|
||||
.ability(AbilityId.NONE);
|
||||
.ability(AbilityId.BALL_FETCH);
|
||||
});
|
||||
|
||||
it("gains a stockpile stack and raises user's DEF and SPDEF stat stages by 1 on each use, fails at max stacks (3)", async () => {
|
||||
it("should gain a stockpile stack and raise DEF and SPDEF when used, up to 3 times", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const user = game.field.getPlayerPokemon();
|
||||
|
||||
// Unfortunately, Stockpile stacks are not directly queryable (i.e. there is no pokemon.getStockpileStacks()),
|
||||
// we just have to know that they're implemented as a BattlerTag.
|
||||
expect(user).toHaveStatStage(Stat.DEF, 0);
|
||||
expect(user).toHaveStatStage(Stat.SPDEF, 0);
|
||||
|
||||
expect(user.getTag(StockpilingTag)).toBeUndefined();
|
||||
expect(user.getStatStage(Stat.DEF)).toBe(0);
|
||||
expect(user.getStatStage(Stat.SPDEF)).toBe(0);
|
||||
|
||||
// use Stockpile four times
|
||||
for (let i = 0; i < 4; i++) {
|
||||
game.move.select(MoveId.STOCKPILE);
|
||||
// use Stockpile thrice
|
||||
for (let i = 0; i < 3; i++) {
|
||||
game.move.use(MoveId.STOCKPILE);
|
||||
await game.toNextTurn();
|
||||
|
||||
const stockpilingTag = user.getTag(StockpilingTag)!;
|
||||
|
||||
if (i < 3) {
|
||||
// first three uses should behave normally
|
||||
expect(user.getStatStage(Stat.DEF)).toBe(i + 1);
|
||||
expect(user.getStatStage(Stat.SPDEF)).toBe(i + 1);
|
||||
const stockpilingTag = user.getTag(BattlerTagType.STOCKPILING)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(i + 1);
|
||||
} else {
|
||||
// fourth should have failed
|
||||
expect(user.getStatStage(Stat.DEF)).toBe(3);
|
||||
expect(user.getStatStage(Stat.SPDEF)).toBe(3);
|
||||
expect(user).toHaveStatStage(Stat.DEF, i + 1);
|
||||
expect(user).toHaveStatStage(Stat.SPDEF, i + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail when used at max stacks", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const user = game.field.getPlayerPokemon();
|
||||
|
||||
user.addTag(BattlerTagType.STOCKPILING);
|
||||
user.addTag(BattlerTagType.STOCKPILING);
|
||||
user.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = user.getTag(BattlerTagType.STOCKPILING)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(3);
|
||||
expect(user.getMoveHistory().at(-1)).toMatchObject({
|
||||
result: MoveResult.FAIL,
|
||||
|
||||
game.move.use(MoveId.STOCKPILE);
|
||||
await game.toNextTurn();
|
||||
|
||||
// should have failed
|
||||
expect(user).toHaveStatStage(Stat.DEF, 3);
|
||||
expect(user).toHaveStatStage(Stat.SPDEF, 3);
|
||||
expect(stockpilingTag.stockpiledCount).toBe(3);
|
||||
expect(user).toHaveUsedMove({
|
||||
move: MoveId.STOCKPILE,
|
||||
targets: [user.getBattlerIndex()],
|
||||
result: MoveResult.FAIL,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gains a stockpile stack even if user's DEF and SPDEF stat stages are at +6", async () => {
|
||||
@ -83,27 +87,24 @@ describe("Moves - Stockpile", () => {
|
||||
user.setStatStage(Stat.DEF, 6);
|
||||
user.setStatStage(Stat.SPDEF, 6);
|
||||
|
||||
expect(user.getTag(StockpilingTag)).toBeUndefined();
|
||||
expect(user.getStatStage(Stat.DEF)).toBe(6);
|
||||
expect(user.getStatStage(Stat.SPDEF)).toBe(6);
|
||||
expect(user).not.toHaveBattlerTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
game.move.select(MoveId.STOCKPILE);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
game.move.use(MoveId.STOCKPILE);
|
||||
await game.toNextTurn();
|
||||
|
||||
const stockpilingTag = user.getTag(StockpilingTag)!;
|
||||
const stockpilingTag = user.getTag(BattlerTagType.STOCKPILING)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(1);
|
||||
expect(user.getStatStage(Stat.DEF)).toBe(6);
|
||||
expect(user.getStatStage(Stat.SPDEF)).toBe(6);
|
||||
expect(user).toHaveStatStage(Stat.DEF, 6);
|
||||
expect(user).toHaveStatStage(Stat.SPDEF, 6);
|
||||
|
||||
game.move.select(MoveId.STOCKPILE);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
game.move.use(MoveId.STOCKPILE);
|
||||
await game.toNextTurn();
|
||||
|
||||
const stockpilingTagAgain = user.getTag(StockpilingTag)!;
|
||||
const stockpilingTagAgain = user.getTag(BattlerTagType.STOCKPILING)!;
|
||||
expect(stockpilingTagAgain).toBeDefined();
|
||||
expect(stockpilingTagAgain.stockpiledCount).toBe(2);
|
||||
expect(user.getStatStage(Stat.DEF)).toBe(6);
|
||||
expect(user.getStatStage(Stat.SPDEF)).toBe(6);
|
||||
});
|
||||
expect(user).toHaveStatStage(Stat.DEF, 6);
|
||||
expect(user).toHaveStatStage(Stat.SPDEF, 6);
|
||||
});
|
||||
});
|
||||
|
233
test/moves/swallow-spit-up.test.ts
Normal file
233
test/moves/swallow-spit-up.test.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { StockpilingTag } from "#app/data/battler-tags";
|
||||
import { allMoves } from "#app/data/data-lists";
|
||||
import { BattlerTagType } from "#app/enums/battler-tag-type";
|
||||
import { getPokemonNameWithAffix } from "#app/messages";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { MoveResult } from "#enums/move-result";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { Stat } from "#enums/stat";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import i18next from "i18next";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
|
||||
|
||||
describe("Moves - Swallow & Spit Up - ", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
let game: GameManager;
|
||||
|
||||
beforeAll(() => {
|
||||
phaserGame = new Phaser.Game({
|
||||
type: Phaser.HEADLESS,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
game.phaseInterceptor.restoreOg();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
game = new GameManager(phaserGame);
|
||||
game.override
|
||||
.battleStyle("single")
|
||||
.enemySpecies(SpeciesId.RATTATA)
|
||||
.enemyMoveset(MoveId.SPLASH)
|
||||
.enemyAbility(AbilityId.BALL_FETCH)
|
||||
.enemyLevel(100)
|
||||
.startingLevel(100)
|
||||
.ability(AbilityId.BALL_FETCH);
|
||||
});
|
||||
|
||||
describe("Swallow", () => {
|
||||
it.each<{ stackCount: number; healPercent: number }>([
|
||||
{ stackCount: 1, healPercent: 25 },
|
||||
{ stackCount: 2, healPercent: 50 },
|
||||
{ stackCount: 3, healPercent: 100 },
|
||||
])(
|
||||
"should heal the user by $healPercent% max HP when consuming $stackCount stockpile stacks",
|
||||
async ({ stackCount, healPercent }) => {
|
||||
await game.classicMode.startBattle([SpeciesId.SWALOT]);
|
||||
|
||||
const swalot = game.field.getPlayerPokemon();
|
||||
swalot.hp = 1;
|
||||
|
||||
for (let i = 0; i < stackCount; i++) {
|
||||
swalot.addTag(BattlerTagType.STOCKPILING);
|
||||
}
|
||||
|
||||
const stockpilingTag = swalot.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(stackCount);
|
||||
|
||||
game.move.use(MoveId.SWALLOW);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(swalot).toHaveHp(Math.min(swalot.getMaxHp(), Math.round((swalot.getMaxHp() * healPercent) / 100) + 1));
|
||||
expect(swalot).not.toHaveBattlerTag(BattlerTagType.STOCKPILING);
|
||||
},
|
||||
);
|
||||
|
||||
it("should fail without Stockpile stacks", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const player = game.field.getPlayerPokemon();
|
||||
player.hp = 1;
|
||||
|
||||
expect(player).not.toHaveBattlerTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
game.move.use(MoveId.SWALLOW);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(player).toHaveUsedMove({
|
||||
move: MoveId.SWALLOW,
|
||||
result: MoveResult.FAIL,
|
||||
});
|
||||
});
|
||||
|
||||
it("should count as a success and consume stacks despite displaying message at full HP", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.SWALOT]);
|
||||
|
||||
const swalot = game.field.getPlayerPokemon();
|
||||
swalot.addTag(BattlerTagType.STOCKPILING);
|
||||
expect(swalot).toHaveBattlerTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
game.move.use(MoveId.SWALLOW);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// Swallow counted as a "success" as its other effect (removing Stockpile) _did_ work
|
||||
expect(swalot).toHaveUsedMove({
|
||||
move: MoveId.SWALLOW,
|
||||
result: MoveResult.SUCCESS,
|
||||
});
|
||||
expect(game.textInterceptor.logs).toContain(
|
||||
i18next.t("battle:hpIsFull", {
|
||||
pokemonName: getPokemonNameWithAffix(swalot),
|
||||
}),
|
||||
);
|
||||
expect(swalot).not.toHaveBattlerTag(BattlerTagType.STOCKPILING);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Spit Up", () => {
|
||||
let spitUpSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
spitUpSpy = vi.spyOn(allMoves[MoveId.SPIT_UP], "calculateBattlePower");
|
||||
});
|
||||
|
||||
it.each<{ stackCount: number; power: number }>([
|
||||
{ stackCount: 1, power: 100 },
|
||||
{ stackCount: 2, power: 200 },
|
||||
{ stackCount: 3, power: 300 },
|
||||
])("should have $power base power when consuming $stackCount stockpile stacks", async ({ stackCount, power }) => {
|
||||
await game.classicMode.startBattle([SpeciesId.SWALOT]);
|
||||
|
||||
const swalot = game.field.getPlayerPokemon();
|
||||
|
||||
for (let i = 0; i < stackCount; i++) {
|
||||
swalot.addTag(BattlerTagType.STOCKPILING);
|
||||
}
|
||||
|
||||
const stockpilingTag = swalot.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(stackCount);
|
||||
|
||||
game.move.use(MoveId.SPIT_UP);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(spitUpSpy).toHaveReturnedWith(power);
|
||||
expect(swalot).not.toHaveBattlerTag(BattlerTagType.STOCKPILING);
|
||||
});
|
||||
|
||||
it("should fail without Stockpile stacks", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const player = game.field.getPlayerPokemon();
|
||||
|
||||
expect(player).not.toHaveBattlerTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
game.move.use(MoveId.SPIT_UP);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(player).toHaveUsedMove({
|
||||
move: MoveId.SPIT_UP,
|
||||
result: MoveResult.FAIL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stockpile stack removal", () => {
|
||||
it("should undo stat boosts when losing stacks", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.SWALOT]);
|
||||
|
||||
const player = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.use(MoveId.STOCKPILE);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(player).toHaveBattlerTag(BattlerTagType.STOCKPILING);
|
||||
expect(player).toHaveStatStage(Stat.DEF, 1);
|
||||
expect(player).toHaveStatStage(Stat.SPDEF, 1);
|
||||
|
||||
// remove the prior stat boost phases from the log
|
||||
game.phaseInterceptor.clearLogs();
|
||||
|
||||
game.move.use(MoveId.SWALLOW);
|
||||
await game.move.forceEnemyMove(MoveId.ACID_SPRAY);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(player).toHaveStatStage(Stat.DEF, 0);
|
||||
expect(player).toHaveStatStage(Stat.SPDEF, -2); // +1 --> -1 --> -2
|
||||
expect(game.phaseInterceptor.log.filter(l => l === "StatStageChangePhase")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should double stat drops when gaining Simple", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const player = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.use(MoveId.STOCKPILE);
|
||||
await game.move.forceEnemyMove(MoveId.SIMPLE_BEAM);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(player).toHaveStatStage(Stat.DEF, 1);
|
||||
expect(player).toHaveStatStage(Stat.SPDEF, 1);
|
||||
expect(player.hasAbility(AbilityId.SIMPLE)).toBe(true);
|
||||
|
||||
game.move.use(MoveId.SWALLOW);
|
||||
await game.move.forceEnemyMove(MoveId.SPLASH);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// should have fallen by 2 stages from Simple
|
||||
expect(player).toHaveStatStage(Stat.DEF, -1);
|
||||
expect(player).toHaveStatStage(Stat.SPDEF, -1);
|
||||
});
|
||||
|
||||
it("should invert stat drops when gaining Contrary", async () => {
|
||||
game.override.enemyAbility(AbilityId.CONTRARY);
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const player = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.use(MoveId.STOCKPILE);
|
||||
await game.move.forceEnemyMove(MoveId.ENTRAINMENT);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(player).toHaveStatStage(Stat.DEF, 1);
|
||||
expect(player).toHaveStatStage(Stat.SPDEF, 1);
|
||||
expect(player.hasAbility(AbilityId.CONTRARY)).toBe(true);
|
||||
|
||||
game.move.use(MoveId.SPIT_UP);
|
||||
await game.move.forceEnemyMove(MoveId.SPLASH);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// should have risen 1 stage from Contrary
|
||||
expect(player).toHaveStatStage(Stat.DEF, 2);
|
||||
expect(player).toHaveStatStage(Stat.SPDEF, 2);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,204 +0,0 @@
|
||||
import { StockpilingTag } from "#data/battler-tags";
|
||||
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 { MovePhase } from "#phases/move-phase";
|
||||
import { TurnInitPhase } from "#phases/turn-init-phase";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("Moves - Swallow", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
let game: GameManager;
|
||||
|
||||
beforeAll(() => {
|
||||
phaserGame = new Phaser.Game({ type: Phaser.HEADLESS });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
game.phaseInterceptor.restoreOg();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
game = new GameManager(phaserGame);
|
||||
|
||||
game.override
|
||||
.battleStyle("single")
|
||||
.enemySpecies(SpeciesId.RATTATA)
|
||||
.enemyMoveset(MoveId.SPLASH)
|
||||
.enemyAbility(AbilityId.NONE)
|
||||
.enemyLevel(2000)
|
||||
.moveset(MoveId.SWALLOW)
|
||||
.ability(AbilityId.NONE);
|
||||
});
|
||||
|
||||
describe("consumes all stockpile stacks to heal (scaling with stacks)", () => {
|
||||
it("1 stack -> 25% heal", async () => {
|
||||
const stacksToSetup = 1;
|
||||
const expectedHeal = 25;
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100);
|
||||
pokemon["hp"] = 1;
|
||||
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup);
|
||||
|
||||
vi.spyOn(pokemon, "heal");
|
||||
|
||||
game.move.select(MoveId.SWALLOW);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.heal).toHaveBeenCalledOnce();
|
||||
expect(pokemon.heal).toHaveReturnedWith(expectedHeal);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("2 stacks -> 50% heal", async () => {
|
||||
const stacksToSetup = 2;
|
||||
const expectedHeal = 50;
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100);
|
||||
pokemon["hp"] = 1;
|
||||
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup);
|
||||
|
||||
vi.spyOn(pokemon, "heal");
|
||||
|
||||
game.move.select(MoveId.SWALLOW);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.heal).toHaveBeenCalledOnce();
|
||||
expect(pokemon.heal).toHaveReturnedWith(expectedHeal);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("3 stacks -> 100% heal", async () => {
|
||||
const stacksToSetup = 3;
|
||||
const expectedHeal = 100;
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
vi.spyOn(pokemon, "getMaxHp").mockReturnValue(100);
|
||||
pokemon["hp"] = 0.0001;
|
||||
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
expect(stockpilingTag.stockpiledCount).toBe(stacksToSetup);
|
||||
|
||||
vi.spyOn(pokemon, "heal");
|
||||
|
||||
game.move.select(MoveId.SWALLOW);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.heal).toHaveBeenCalledOnce();
|
||||
expect(pokemon.heal).toHaveReturnedWith(expect.closeTo(expectedHeal));
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("fails without stacks", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeUndefined();
|
||||
|
||||
game.move.select(MoveId.SWALLOW);
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
|
||||
move: MoveId.SWALLOW,
|
||||
result: MoveResult.FAIL,
|
||||
targets: [pokemon.getBattlerIndex()],
|
||||
});
|
||||
});
|
||||
|
||||
describe("restores stat stage boosts granted by stacks", () => {
|
||||
it("decreases stats based on stored values (both boosts equal)", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
|
||||
game.move.select(MoveId.SWALLOW);
|
||||
await game.phaseInterceptor.to(MovePhase);
|
||||
|
||||
expect(pokemon.getStatStage(Stat.DEF)).toBe(1);
|
||||
expect(pokemon.getStatStage(Stat.SPDEF)).toBe(1);
|
||||
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
|
||||
move: MoveId.SWALLOW,
|
||||
result: MoveResult.SUCCESS,
|
||||
targets: [pokemon.getBattlerIndex()],
|
||||
});
|
||||
|
||||
expect(pokemon.getStatStage(Stat.DEF)).toBe(0);
|
||||
expect(pokemon.getStatStage(Stat.SPDEF)).toBe(0);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lower stat stages based on stored values (different boosts)", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
|
||||
|
||||
const pokemon = game.field.getPlayerPokemon();
|
||||
pokemon.addTag(BattlerTagType.STOCKPILING);
|
||||
|
||||
const stockpilingTag = pokemon.getTag(StockpilingTag)!;
|
||||
expect(stockpilingTag).toBeDefined();
|
||||
|
||||
// for the sake of simplicity (and because other tests cover the setup), set boost amounts directly
|
||||
stockpilingTag.statChangeCounts = {
|
||||
[Stat.DEF]: -1,
|
||||
[Stat.SPDEF]: 2,
|
||||
};
|
||||
|
||||
game.move.select(MoveId.SWALLOW);
|
||||
|
||||
await game.phaseInterceptor.to(TurnInitPhase);
|
||||
|
||||
expect(pokemon.getMoveHistory().at(-1)).toMatchObject({
|
||||
move: MoveId.SWALLOW,
|
||||
result: MoveResult.SUCCESS,
|
||||
targets: [pokemon.getBattlerIndex()],
|
||||
});
|
||||
|
||||
expect(pokemon.getStatStage(Stat.DEF)).toBe(1);
|
||||
expect(pokemon.getStatStage(Stat.SPDEF)).toBe(-2);
|
||||
|
||||
expect(pokemon.getTag(StockpilingTag)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
@ -144,6 +144,11 @@ describe("Move - Wish", () => {
|
||||
expectWishActive(0);
|
||||
|
||||
const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase"));
|
||||
// account for phase interceptor stopping jank
|
||||
const currPhase = game.scene.phaseManager.getCurrentPhase()!;
|
||||
if (currPhase.is("PokemonHealPhase")) {
|
||||
healPhases.unshift(currPhase);
|
||||
}
|
||||
expect(healPhases).toHaveLength(4);
|
||||
expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder);
|
||||
|
||||
|
@ -112,7 +112,7 @@ describe("Weird Dream - Mystery Encounter", () => {
|
||||
it("should transform the new party into new species, 2 at +90/+110, the rest at +40/50 BST", async () => {
|
||||
await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty);
|
||||
|
||||
const pokemonPrior = scene.getPlayerParty().map(pokemon => pokemon);
|
||||
const pokemonPrior = scene.getPlayerParty().slice();
|
||||
const bstsPrior = pokemonPrior.map(species => species.getSpeciesForm().getBaseStatTotal());
|
||||
|
||||
await runMysteryEncounterToEnd(game, 1);
|
||||
|
82
test/system/rename-run.test.ts
Normal file
82
test/system/rename-run.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
@ -371,9 +371,12 @@ export class GameManager {
|
||||
console.log("==================[New Turn]==================");
|
||||
}
|
||||
|
||||
/** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */
|
||||
async toEndOfTurn() {
|
||||
await this.phaseInterceptor.to("TurnEndPhase");
|
||||
/**
|
||||
* Transition to the {@linkcode TurnEndPhase | end of the current turn}.
|
||||
* @param endTurn - Whether to run the TurnEndPhase or not; default `true`
|
||||
*/
|
||||
async toEndOfTurn(endTurn = true) {
|
||||
await this.phaseInterceptor.to("TurnEndPhase", endTurn);
|
||||
console.log("==================[End of Turn]==================");
|
||||
}
|
||||
|
||||
|
@ -2,15 +2,22 @@ import { getPokemonNameWithAffix } from "#app/messages";
|
||||
// biome-ignore lint/correctness/noUnusedImports: TSDoc
|
||||
import type { Pokemon } from "#field/pokemon";
|
||||
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
|
||||
import { toDmgValue } from "#utils/common";
|
||||
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
|
||||
|
||||
/**
|
||||
* Matcher that checks if a Pokemon has a specific amount of HP.
|
||||
* @param received - The object to check. Should be a {@linkcode Pokemon}.
|
||||
* @param expectedHp - The expected amount of HP the {@linkcode Pokemon} has
|
||||
* @param roundDown - Whether to round down {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true`
|
||||
* @returns Whether the matcher passed
|
||||
*/
|
||||
export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult {
|
||||
export function toHaveHp(
|
||||
this: MatcherState,
|
||||
received: unknown,
|
||||
expectedHp: number,
|
||||
roundDown = true,
|
||||
): SyncExpectationResult {
|
||||
if (!isPokemonInstance(received)) {
|
||||
return {
|
||||
pass: false,
|
||||
@ -18,6 +25,9 @@ export function toHaveHp(this: MatcherState, received: unknown, expectedHp: numb
|
||||
};
|
||||
}
|
||||
|
||||
if (roundDown) {
|
||||
expectedHp = toDmgValue(expectedHp);
|
||||
}
|
||||
const actualHp = received.hp;
|
||||
const pass = actualHp === expectedHp;
|
||||
|
||||
|
@ -36,6 +36,7 @@ import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase";
|
||||
import { NextEncounterPhase } from "#phases/next-encounter-phase";
|
||||
import { PartyExpPhase } from "#phases/party-exp-phase";
|
||||
import { PartyHealPhase } from "#phases/party-heal-phase";
|
||||
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
|
||||
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
|
||||
import { PositionalTagPhase } from "#phases/positional-tag-phase";
|
||||
import { PostGameOverPhase } from "#phases/post-game-over-phase";
|
||||
@ -100,6 +101,7 @@ export class PhaseInterceptor {
|
||||
* `initPhases()` so that its subclasses can use `super.start()` properly.
|
||||
*/
|
||||
private PHASES = [
|
||||
[PokemonHealPhase, this.startPhase],
|
||||
[LoginPhase, this.startPhase],
|
||||
[TitlePhase, this.startPhase],
|
||||
[SelectGenderPhase, this.startPhase],
|
||||
|
Loading…
Reference in New Issue
Block a user