Compare commits

...

15 Commits

Author SHA1 Message Date
Bertie690
9e03abc335
Merge 68a9d785aa into 30058ed70e 2025-08-14 14:49:04 -04:00
Sirz Benjie
30058ed70e
[Feature] Add per-species tracking for ribbons, show nuzlocke ribbon (#6246)
* Add tracking for nuzlocke completion

* Add ribbon to legacy ui folder

* Add tracking for friendship ribbon

* fix overlapping flag set

* Replace mass getters with a single method

* Add tracking for each generational ribbon

* Add ribbons for each challenge

* Apply Kev's suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-14 13:20:48 -05:00
Wlowscha
140e4ab142
[UI/UX] Party slots refactor (#6199)
* constants for position of discard button

* Moved transfer/discard button up in doubles

* Fixed the various `.setOrigin(0,0)`

* Small clean up

* Added `isBenched` property to slots; x origin of `slotBg` is now 0

* Also set y origin to 0

* Offsets are relevant to the same thing

* Introducing const object to store ui magic numbers

* More magic numbers in const

* Laid out numbers for slot positions

* Added smaller main slots for transfer mode in doubles

* Changed background to fit new slot disposition

* Apply suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

* Optimized PNGs

* Updated comment

* Removed "magicNumbers" container, added multiple comments

* Update src/ui/party-ui-handler.ts

Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com>

* Fainted pkmn slots displaying correctly

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
Co-authored-by: Adri1 <adrien.grivel@hotmail.fr>
Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-08-14 13:10:15 -05:00
fabske0
76d8357d0b
[Dev] Rename OPP_ overrides to ENEMY_ (#6255)
rename `OPP_` to `ENEMY_`
2025-08-14 18:06:24 +00:00
Bertie690
68a9d785aa Fixed a few more bugs and such 2025-08-08 17:46:02 -04:00
Bertie690
e61a0d4326 Fixed liquid ooze not working 2025-08-08 14:22:37 -04:00
Bertie690
6fd3c356c4 fixd heal block bug 2025-08-08 14:18:03 -04:00
Bertie690
2a611da07a fixed double phase start bug fr 2025-08-08 14:10:47 -04:00
Bertie690
a691c43d33 Fixed bugs 2025-08-08 13:47:30 -04:00
Bertie690
96e3b7f11c Merge remote-tracking branch 'upstream/beta' into heal-attr 2025-08-07 22:52:59 -04:00
Bertie690
751d824af8 Cleaned up Stockpile tests 2025-08-05 17:17:05 -04:00
Bertie690
89536fafda Added MoveHealBoostAbAttr + implemented Healing Pulse boost 2025-08-05 16:48:39 -04:00
Bertie690
95dbfe69a0 Added heal pulse tests and clarified rounding 2025-08-05 13:54:10 -04:00
Bertie690
90c9c71cd9 Cleaned up pokemonHealPhase + wrapped inside an object 2025-08-05 12:53:10 -04:00
Bertie690
e760ed9949 Squashed changes and such 2025-08-05 12:09:55 -04:00
52 changed files with 2097 additions and 1479 deletions

View File

@ -81,7 +81,7 @@ For example, here is how you could test a scenario where the player Pokemon has
```typescript ```typescript
const overrides = { const overrides = {
ABILITY_OVERRIDE: AbilityId.DROUGHT, ABILITY_OVERRIDE: AbilityId.DROUGHT,
OPP_MOVESET_OVERRIDE: MoveId.WATER_GUN, ENEMY_MOVESET_OVERRIDE: MoveId.WATER_GUN,
} satisfies Partial<InstanceType<typeof DefaultOverrides>>; } satisfies Partial<InstanceType<typeof DefaultOverrides>>;
``` ```

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 B

After

Width:  |  Height:  |  Size: 799 B

View File

@ -0,0 +1,146 @@
{
"textures": [
{
"image": "party_slot_main_short.png",
"format": "RGBA8888",
"size": {
"w": 110,
"h": 294
},
"scale": 1,
"frames": [
{
"filename": "party_slot_main_short",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_sel",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 41,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_fnt",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 82,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_fnt_sel",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 123,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_swap",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 164,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_swap_sel",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 205,
"w": 110,
"h": 41
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:29685f2f538901cf5bf7f0ed2ea867c3:a080ea6c8cccd1e03244214053e79796:565f7afc5ca419b6ba8dbce51ea30818$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,3 +1,5 @@
import type { RibbonData } from "#system/ribbons/ribbon-data";
export interface DexData { export interface DexData {
[key: number]: DexEntry; [key: number]: DexEntry;
} }
@ -10,4 +12,5 @@ export interface DexEntry {
caughtCount: number; caughtCount: number;
hatchedCount: number; hatchedCount: number;
ivs: number[]; ivs: number[];
ribbons: RibbonData;
} }

View File

@ -103,3 +103,12 @@ export type CoerceNullPropertiesToUndefined<T extends object> = {
* @typeParam T - The type to render partial * @typeParam T - The type to render partial
*/ */
export type AtLeastOne<T extends object> = Partial<T> & ObjectValues<{ [K in keyof T]: Pick<Required<T>, K> }>; export type AtLeastOne<T extends object> = Partial<T> & ObjectValues<{ [K in keyof T]: Pick<Required<T>, K> }>;
/** Type helper that adds a brand to a type, used for nominal typing.
*
* @remarks
* Brands should be either a string or unique symbol. This prevents overlap with other types.
*/
export declare class Brander<B> {
private __brand: B;
}

View File

@ -944,17 +944,17 @@ export class BattleScene extends SceneBase {
dataSource?: PokemonData, dataSource?: PokemonData,
postProcess?: (enemyPokemon: EnemyPokemon) => void, postProcess?: (enemyPokemon: EnemyPokemon) => void,
): EnemyPokemon { ): EnemyPokemon {
if (Overrides.OPP_LEVEL_OVERRIDE > 0) { if (Overrides.ENEMY_LEVEL_OVERRIDE > 0) {
level = Overrides.OPP_LEVEL_OVERRIDE; level = Overrides.ENEMY_LEVEL_OVERRIDE;
} }
if (Overrides.OPP_SPECIES_OVERRIDE) { if (Overrides.ENEMY_SPECIES_OVERRIDE) {
species = getPokemonSpecies(Overrides.OPP_SPECIES_OVERRIDE); species = getPokemonSpecies(Overrides.ENEMY_SPECIES_OVERRIDE);
// The fact that a Pokemon is a boss or not can change based on its Species and level // The fact that a Pokemon is a boss or not can change based on its Species and level
boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1; boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1;
} }
const pokemon = new EnemyPokemon(species, level, trainerSlot, boss, shinyLock, dataSource); const pokemon = new EnemyPokemon(species, level, trainerSlot, boss, shinyLock, dataSource);
if (Overrides.OPP_FUSION_OVERRIDE) { if (Overrides.ENEMY_FUSION_OVERRIDE) {
pokemon.generateFusionSpecies(); pokemon.generateFusionSpecies();
} }
@ -1766,10 +1766,10 @@ export class BattleScene extends SceneBase {
} }
getEncounterBossSegments(waveIndex: number, level: number, species?: PokemonSpecies, forceBoss = false): number { getEncounterBossSegments(waveIndex: number, level: number, species?: PokemonSpecies, forceBoss = false): number {
if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1) { if (Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1) {
return Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE; return Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE;
} }
if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE === 1) { if (Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE === 1) {
// The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss // The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss
return 0; return 0;
} }

View File

@ -101,3 +101,9 @@ export const ANTI_VARIANCE_WEIGHT_MODIFIER = 15;
* Default: `10000` (0.01%) * Default: `10000` (0.01%)
*/ */
export const FAKE_TITLE_LOGO_CHANCE = 10000; export const FAKE_TITLE_LOGO_CHANCE = 10000;
/**
* The ceiling on friendship amount that can be reached through the use of rare candies.
* Using rare candies will never increase friendship beyond this value.
*/
export const RARE_CANDY_FRIENDSHIP_CAP = 200;

View File

@ -749,11 +749,12 @@ export class TypeImmunityHealAbAttr extends TypeImmunityAbAttr {
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / 4), toDmgValue(pokemon.getMaxHp() / 4),
i18next.t("abilityTriggers:typeImmunityHeal", { {
message: i18next.t("abilityTriggers:typeImmunityHeal", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
abilityName, abilityName,
}), }),
true, },
); );
cancelled.value = true; // Suppresses "No Effect" message cancelled.value = true; // Suppresses "No Effect" message
} }
@ -1542,6 +1543,51 @@ export abstract class PreAttackAbAttr extends AbAttr {
private declare readonly _: never; 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 { export interface ModifyMoveEffectChanceAbAttrParams extends AbAttrBaseParams {
/** The move being used by the attacker */ /** The move being used by the attacker */
move: Move; move: Move;
@ -1684,7 +1730,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
*/ */
override canApply({ pokemon, opponent: target, move }: MoveTypeChangeAbAttrParams): boolean { override canApply({ pokemon, opponent: target, move }: MoveTypeChangeAbAttrParams): boolean {
return ( return (
(!this.condition || this.condition(pokemon, target, move)) && (this.condition?.(pokemon, target, move) ?? true) &&
!noAbilityTypeOverrideMoves.has(move.id) && !noAbilityTypeOverrideMoves.has(move.id) &&
!( !(
pokemon.isTerastallized && pokemon.isTerastallized &&
@ -2828,12 +2874,13 @@ export class PostSummonAllyHealAbAttr extends PostSummonAbAttr {
"PokemonHealPhase", "PokemonHealPhase",
target.getBattlerIndex(), target.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / this.healRatio), toDmgValue(pokemon.getMaxHp() / this.healRatio),
i18next.t("abilityTriggers:postSummonAllyHeal", { {
message: i18next.t("abilityTriggers:postSummonAllyHeal", {
pokemonNameWithAffix: getPokemonNameWithAffix(target), pokemonNameWithAffix: getPokemonNameWithAffix(target),
pokemonName: pokemon.name, pokemonName: pokemon.name,
}), }),
true, skipAnim: !this.showAnim,
!this.showAnim, },
); );
} }
} }
@ -4474,11 +4521,12 @@ export class PostWeatherLapseHealAbAttr extends PostWeatherLapseAbAttr {
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / (16 / this.healFactor)), toDmgValue(pokemon.getMaxHp() / (16 / this.healFactor)),
i18next.t("abilityTriggers:postWeatherLapseHeal", { {
message: i18next.t("abilityTriggers:postWeatherLapseHeal", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
abilityName, abilityName,
}), }),
true, },
); );
} }
} }
@ -4593,8 +4641,12 @@ export class PostTurnStatusHealAbAttr extends PostTurnAbAttr {
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / 8), toDmgValue(pokemon.getMaxHp() / 8),
i18next.t("abilityTriggers:poisonHeal", { pokemonName: getPokemonNameWithAffix(pokemon), abilityName }), {
true, message: i18next.t("abilityTriggers:poisonHeal", {
pokemonName: getPokemonNameWithAffix(pokemon),
abilityName,
}),
},
); );
} }
} }
@ -4841,11 +4893,12 @@ export class PostTurnHealAbAttr extends PostTurnAbAttr {
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / 16), toDmgValue(pokemon.getMaxHp() / 16),
i18next.t("abilityTriggers:postTurnHeal", { {
message: i18next.t("abilityTriggers:postTurnHeal", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
abilityName, abilityName,
}), }),
true, },
); );
} }
} }
@ -5222,11 +5275,12 @@ export class HealFromBerryUseAbAttr extends AbAttr {
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() * this.healPercent), toDmgValue(pokemon.getMaxHp() * this.healPercent),
i18next.t("abilityTriggers:healFromBerryUse", { {
message: i18next.t("abilityTriggers:healFromBerryUse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
abilityName, abilityName,
}), }),
true, },
); );
} }
} }
@ -6552,6 +6606,7 @@ const AbilityAttrs = Object.freeze({
PostDefendMoveDisableAbAttr, PostDefendMoveDisableAbAttr,
PostStatStageChangeStatStageChangeAbAttr, PostStatStageChangeStatStageChangeAbAttr,
PreAttackAbAttr, PreAttackAbAttr,
MoveHealBoostAbAttr,
MoveEffectChanceMultiplierAbAttr, MoveEffectChanceMultiplierAbAttr,
IgnoreMoveEffectsAbAttr, IgnoreMoveEffectsAbAttr,
VariableMovePowerAbAttr, VariableMovePowerAbAttr,
@ -7327,7 +7382,9 @@ export function initAbilities() {
.attr(PostSummonUserFieldRemoveStatusEffectAbAttr, StatusEffect.SLEEP) .attr(PostSummonUserFieldRemoveStatusEffectAbAttr, StatusEffect.SLEEP)
.attr(UserFieldBattlerTagImmunityAbAttr, BattlerTagType.DROWSY) .attr(UserFieldBattlerTagImmunityAbAttr, BattlerTagType.DROWSY)
.ignorable() .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) new Ability(AbilityId.STANCE_CHANGE, 6)
.attr(NoFusionAbilityAbAttr) .attr(NoFusionAbilityAbAttr)
.uncopiable() .uncopiable()
@ -7336,7 +7393,8 @@ export function initAbilities() {
new Ability(AbilityId.GALE_WINGS, 6) new Ability(AbilityId.GALE_WINGS, 6)
.attr(ChangeMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && pokemon.getMoveType(move) === PokemonType.FLYING, 1), .attr(ChangeMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && pokemon.getMoveType(move) === PokemonType.FLYING, 1),
new Ability(AbilityId.MEGA_LAUNCHER, 6) 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) new Ability(AbilityId.GRASS_PELT, 6)
.conditionalAttr(getTerrainCondition(TerrainType.GRASSY), StatMultiplierAbAttr, Stat.DEF, 1.5) .conditionalAttr(getTerrainCondition(TerrainType.GRASSY), StatMultiplierAbAttr, Stat.DEF, 1.5)
.ignorable(), .ignorable(),

View File

@ -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) // 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 damage = pokemon.damageAndUpdate(toDmgValue(pokemon.getMaxHp() / 8), { result: HitResult.INDIRECT });
const reverseDrain = pokemon.hasAbilityWithAttr("ReverseDrainAbAttr", false); const reverseDrain = pokemon.hasAbilityWithAttr("ReverseDrainAbAttr", false);
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew("PokemonHealPhase", source.getBattlerIndex(), reverseDrain ? -damage : damage, {
"PokemonHealPhase", message: i18next.t(reverseDrain ? "battlerTags:seededLapseShed" : "battlerTags:seededLapse", {
source.getBattlerIndex(),
reverseDrain ? -damage : damage,
i18next.t(reverseDrain ? "battlerTags:seededLapseShed" : "battlerTags:seededLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}), }),
false, showFullHpMessage: false,
true, skipAnim: true,
); });
return true; return true;
} }
@ -1382,10 +1380,11 @@ export class IngrainTag extends TrappedTag {
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / 16), toDmgValue(pokemon.getMaxHp() / 16),
i18next.t("battlerTags:ingrainLapse", { {
message: i18next.t("battlerTags:ingrainLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}), }),
true, },
); );
} }
@ -1455,11 +1454,12 @@ export class AquaRingTag extends SerializableBattlerTag {
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / 16), toDmgValue(pokemon.getMaxHp() / 16),
i18next.t("battlerTags:aquaRingLapse", { {
message: i18next.t("battlerTags:aquaRingLapse", {
moveName: this.getMoveName(), moveName: this.getMoveName(),
pokemonName: getPokemonNameWithAffix(pokemon), 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. * For each stat, an internal counter is incremented (by 1) if the stat was successfully changed.
*/ */
onAdd(pokemon: Pokemon): void { onAdd(pokemon: Pokemon): void {
if (this.stockpiledCount < 3) { if (this.stockpiledCount >= 3) {
return;
}
this.stockpiledCount++; this.stockpiledCount++;
globalScene.phaseManager.queueMessage( globalScene.phaseManager.queueMessage(
@ -2725,7 +2727,6 @@ export class StockpilingTag extends SerializableBattlerTag {
this.onStatStagesChanged.bind(this), this.onStatStagesChanged.bind(this),
); );
} }
}
onOverlap(pokemon: Pokemon): void { onOverlap(pokemon: Pokemon): void {
this.onAdd(pokemon); this.onAdd(pokemon);

View File

@ -73,16 +73,12 @@ export function getBerryEffectFunc(berryType: BerryType): BerryEffectFunc {
{ {
const hpHealed = new NumberHolder(toDmgValue(consumer.getMaxHp() / 4)); const hpHealed = new NumberHolder(toDmgValue(consumer.getMaxHp() / 4));
applyAbAttrs("DoubleBerryEffectAbAttr", { pokemon: consumer, effectValue: hpHealed }); applyAbAttrs("DoubleBerryEffectAbAttr", { pokemon: consumer, effectValue: hpHealed });
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew("PokemonHealPhase", consumer.getBattlerIndex(), hpHealed.value, {
"PokemonHealPhase", message: i18next.t("battle:hpHealBerry", {
consumer.getBattlerIndex(),
hpHealed.value,
i18next.t("battle:hpHealBerry", {
pokemonNameWithAffix: getPokemonNameWithAffix(consumer), pokemonNameWithAffix: getPokemonNameWithAffix(consumer),
berryName: getBerryName(berryType), berryName: getBerryName(berryType),
}), }),
true, });
);
} }
break; break;
case BerryType.LUM: case BerryType.LUM:

View File

@ -20,6 +20,7 @@ import { Trainer } from "#field/trainer";
import type { ModifierTypeOption } from "#modifiers/modifier-type"; import type { ModifierTypeOption } from "#modifiers/modifier-type";
import { PokemonMove } from "#moves/pokemon-move"; import { PokemonMove } from "#moves/pokemon-move";
import type { DexAttrProps, GameData } from "#system/game-data"; import type { DexAttrProps, GameData } from "#system/game-data";
import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data";
import { type BooleanHolder, isBetween, type NumberHolder, randSeedItem } from "#utils/common"; import { type BooleanHolder, isBetween, type NumberHolder, randSeedItem } from "#utils/common";
import { deepCopy } from "#utils/data"; import { deepCopy } from "#utils/data";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
@ -42,6 +43,15 @@ export abstract class Challenge {
public conditions: ChallengeCondition[]; public conditions: ChallengeCondition[];
/**
* The Ribbon awarded on challenge completion, or 0 if the challenge has no ribbon or is not enabled
*
* @defaultValue 0
*/
public get ribbonAwarded(): RibbonFlag {
return 0 as RibbonFlag;
}
/** /**
* @param id {@link Challenges} The enum value for the challenge * @param id {@link Challenges} The enum value for the challenge
*/ */
@ -423,6 +433,12 @@ type ChallengeCondition = (data: GameData) => boolean;
* Implements a mono generation challenge. * Implements a mono generation challenge.
*/ */
export class SingleGenerationChallenge extends Challenge { export class SingleGenerationChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
// NOTE: This logic will not work for the eventual mono gen 10 ribbon, as
// as its flag will not be in sequence with the other mono gen ribbons.
return this.value ? ((RibbonData.MONO_GEN_1 << (this.value - 1)) as RibbonFlag) : 0;
}
constructor() { constructor() {
super(Challenges.SINGLE_GENERATION, 9); super(Challenges.SINGLE_GENERATION, 9);
} }
@ -686,6 +702,12 @@ interface monotypeOverride {
* Implements a mono type challenge. * Implements a mono type challenge.
*/ */
export class SingleTypeChallenge extends Challenge { export class SingleTypeChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
// `this.value` represents the 1-based index of pokemon type
// `RibbonData.MONO_NORMAL` starts the flag position for the types,
// and we shift it by 1 for the specific type.
return this.value ? ((RibbonData.MONO_NORMAL << (this.value - 1)) as RibbonFlag) : 0;
}
private static TYPE_OVERRIDES: monotypeOverride[] = [ private static TYPE_OVERRIDES: monotypeOverride[] = [
{ species: SpeciesId.CASTFORM, type: PokemonType.NORMAL, fusion: false }, { species: SpeciesId.CASTFORM, type: PokemonType.NORMAL, fusion: false },
]; ];
@ -755,6 +777,9 @@ export class SingleTypeChallenge extends Challenge {
* Implements a fresh start challenge. * Implements a fresh start challenge.
*/ */
export class FreshStartChallenge extends Challenge { export class FreshStartChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.FRESH_START : 0;
}
constructor() { constructor() {
super(Challenges.FRESH_START, 2); super(Challenges.FRESH_START, 2);
} }
@ -828,6 +853,9 @@ export class FreshStartChallenge extends Challenge {
* Implements an inverse battle challenge. * Implements an inverse battle challenge.
*/ */
export class InverseBattleChallenge extends Challenge { export class InverseBattleChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.INVERSE : 0;
}
constructor() { constructor() {
super(Challenges.INVERSE_BATTLE, 1); super(Challenges.INVERSE_BATTLE, 1);
} }
@ -861,6 +889,9 @@ export class InverseBattleChallenge extends Challenge {
* Implements a flip stat challenge. * Implements a flip stat challenge.
*/ */
export class FlipStatChallenge extends Challenge { export class FlipStatChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.FLIP_STATS : 0;
}
constructor() { constructor() {
super(Challenges.FLIP_STAT, 1); super(Challenges.FLIP_STAT, 1);
} }
@ -941,6 +972,9 @@ export class LowerStarterPointsChallenge extends Challenge {
* Implements a No Support challenge * Implements a No Support challenge
*/ */
export class LimitedSupportChallenge extends Challenge { export class LimitedSupportChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? ((RibbonData.NO_HEAL << (this.value - 1)) as RibbonFlag) : 0;
}
constructor() { constructor() {
super(Challenges.LIMITED_SUPPORT, 3); super(Challenges.LIMITED_SUPPORT, 3);
} }
@ -973,6 +1007,9 @@ export class LimitedSupportChallenge extends Challenge {
* Implements a Limited Catch challenge * Implements a Limited Catch challenge
*/ */
export class LimitedCatchChallenge extends Challenge { export class LimitedCatchChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.LIMITED_CATCH : 0;
}
constructor() { constructor() {
super(Challenges.LIMITED_CATCH, 1); super(Challenges.LIMITED_CATCH, 1);
} }
@ -997,6 +1034,9 @@ export class LimitedCatchChallenge extends Challenge {
* Implements a Permanent Faint challenge * Implements a Permanent Faint challenge
*/ */
export class HardcoreChallenge extends Challenge { export class HardcoreChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.HARDCORE : 0;
}
constructor() { constructor() {
super(Challenges.HARDCORE, 1); super(Challenges.HARDCORE, 1);
} }

View File

@ -47,6 +47,7 @@ export class EggHatchData {
caughtCount: currDexEntry.caughtCount, caughtCount: currDexEntry.caughtCount,
hatchedCount: currDexEntry.hatchedCount, hatchedCount: currDexEntry.hatchedCount,
ivs: [...currDexEntry.ivs], ivs: [...currDexEntry.ivs],
ribbons: currDexEntry.ribbons,
}; };
this.starterDataEntryBeforeUpdate = { this.starterDataEntryBeforeUpdate = {
moveset: currStarterDataEntry.moveset, moveset: currStarterDataEntry.moveset,

View File

@ -1956,25 +1956,51 @@ export class AddSubstituteAttr extends MoveEffectAttr {
} }
/** /**
* Heals the user or target by {@linkcode healRatio} depending on the value of {@linkcode selfTarget} * Attribute to implement healing moves, such as {@linkcode MoveId.RECOVER} or {@linkcode MoveId.SOFT_BOILED}.
* @extends MoveEffectAttr * Heals the user or target of the move by a fixed amount relative to their maximum HP.
* @see {@linkcode apply}
*/ */
export class HealAttr extends MoveEffectAttr { export class HealAttr extends MoveEffectAttr {
/** The percentage of {@linkcode Stat.HP} to heal */ /** The percentage of {@linkcode Stat.HP} to heal; default `1` */
private healRatio: number; protected healRatio = 1
/** Should an animation be shown? */ /** Whether to display a healing animation upon healing the target; default `false` */
private showAnim: boolean; 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; constructor(
this.showAnim = !!showAnim; 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 { override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
this.addHealPhase(this.selfTarget ? user : target, this.healRatio); 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; return true;
} }
@ -1982,15 +2008,81 @@ export class HealAttr extends MoveEffectAttr {
* Creates a new {@linkcode PokemonHealPhase}. * Creates a new {@linkcode PokemonHealPhase}.
* This heals the target and shows the appropriate message. * This heals the target and shows the appropriate message.
*/ */
addHealPhase(target: Pokemon, healRatio: number) { protected addHealPhase(healedPokemon: Pokemon) {
globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), globalScene.phaseManager.unshiftNew("PokemonHealPhase", healedPokemon.getBattlerIndex(),
toDmgValue(target.getMaxHp() * healRatio), i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(target) }), true, !this.showAnim); // 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; const score = ((1 - (this.selfTarget ? user : target).getHpRatio()) * 20) - this.healRatio * 10;
return Math.round(score / (1 - this.healRatio / 2)); 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)
}
} }
/** /**
@ -2117,16 +2209,18 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr {
const pm = globalScene.phaseManager; const pm = globalScene.phaseManager;
pm.pushPhase( pm.pushPhase(
pm.create("PokemonHealPhase", pm.create(
"PokemonHealPhase",
user.getBattlerIndex(), user.getBattlerIndex(),
maxPartyMemberHp, maxPartyMemberHp,
i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }), {
true, message: i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }),
false, showFullHpMessage: false,
false, skipAnim: true,
true, healStatus: true,
false, fullRestorePP: this.restorePP,
this.restorePP), }
),
true); true);
return true; return true;
@ -2173,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. * 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. * Healing is based on {@linkcode healRatio} * the amount of damage dealt or a stat of the target.
@ -2329,7 +2317,9 @@ export class HitHealAttr extends MoveEffectAttr {
message = ""; 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; return true;
} }
@ -4362,7 +4352,8 @@ export class PunishmentPowerAttr extends VariablePowerAttr {
} }
export class PresentPowerAttr 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 * 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. * other than the first, this move cannot result in a heal.
@ -4371,17 +4362,21 @@ export class PresentPowerAttr extends VariablePowerAttr {
const powerSeed = randSeedInt(firstHit ? 100 : 80); const powerSeed = randSeedInt(firstHit ? 100 : 80);
if (powerSeed <= 40) { if (powerSeed <= 40) {
(args[0] as NumberHolder).value = 40; power.value = 40;
} else if (40 < powerSeed && powerSeed <= 70) { } else if (powerSeed <= 70) {
(args[0] as NumberHolder).value = 80; power.value = 80;
} else if (70 < powerSeed && powerSeed <= 80) { } else if (powerSeed <= 80) {
(args[0] as NumberHolder).value = 120; power.value = 120;
} else if (80 < powerSeed && powerSeed <= 100) { } else if (powerSeed <= 100) {
// If this move is multi-hit, disable all other hits // Disable all other hits and heal the target for 25% max HP
user.turnData.hitCount = 1; user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1; user.turnData.hitsLeft = 1;
globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), globalScene.phaseManager.unshiftNew(
toDmgValue(target.getMaxHp() / 4), i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) }), true); "PokemonHealPhase",
target.getBattlerIndex(),
toDmgValue(target.getMaxHp() / 4),
{message: i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) })}
)
} }
return true; return true;
@ -4422,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 hasStockpileStacksCondition: MoveConditionFunc = (user) => {
const hasStockpilingTag = user.getTag(StockpilingTag); const hasStockpilingTag = user.getTag(StockpilingTag);
return !!hasStockpilingTag && hasStockpilingTag.stockpiledCount > 0; return !!hasStockpilingTag && hasStockpilingTag.stockpiledCount > 0;
@ -8081,6 +8046,53 @@ const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) =
return message; 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 { export class MoveCondition {
protected func: MoveConditionFunc; protected func: MoveConditionFunc;
@ -8290,15 +8302,12 @@ const MoveAttrs = Object.freeze({
SacrificialAttrOnHit, SacrificialAttrOnHit,
HalfSacrificialAttr, HalfSacrificialAttr,
AddSubstituteAttr, AddSubstituteAttr,
HealAttr,
PartyStatusCureAttr, PartyStatusCureAttr,
FlameBurstAttr, FlameBurstAttr,
SacrificialFullRestoreAttr, SacrificialFullRestoreAttr,
IgnoreWeatherTypeDebuffAttr, IgnoreWeatherTypeDebuffAttr,
WeatherHealAttr, HealAttr,
PlantHealAttr, VariableHealAttr,
SandHealAttr,
BoostHealAttr,
HealOnAllyAttr, HealOnAllyAttr,
HitHealAttr, HitHealAttr,
IncrementMovePriorityAttr, IncrementMovePriorityAttr,
@ -8362,7 +8371,6 @@ const MoveAttrs = Object.freeze({
PresentPowerAttr, PresentPowerAttr,
WaterShurikenPowerAttr, WaterShurikenPowerAttr,
SpitUpPowerAttr, SpitUpPowerAttr,
SwallowHealAttr,
MultiHitPowerIncrementAttr, MultiHitPowerIncrementAttr,
LastMoveDoublePowerAttr, LastMoveDoublePowerAttr,
CombinedPledgePowerAttr, CombinedPledgePowerAttr,
@ -9220,13 +9228,13 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.ATK ], 1, true), .attr(StatStageChangeAttr, [ Stat.ATK ], 1, true),
new AttackMove(MoveId.VITAL_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 70, -1, 10, -1, -1, 2), 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) new SelfStatusMove(MoveId.MORNING_SUN, PokemonType.NORMAL, -1, 5, -1, 0, 2)
.attr(PlantHealAttr) .attr(VariableHealAttr, sunnyHealRatioFunc)
.triageMove(), .triageMove(),
new SelfStatusMove(MoveId.SYNTHESIS, PokemonType.GRASS, -1, 5, -1, 0, 2) new SelfStatusMove(MoveId.SYNTHESIS, PokemonType.GRASS, -1, 5, -1, 0, 2)
.attr(PlantHealAttr) .attr(VariableHealAttr, sunnyHealRatioFunc)
.triageMove(), .triageMove(),
new SelfStatusMove(MoveId.MOONLIGHT, PokemonType.FAIRY, -1, 5, -1, 0, 2) new SelfStatusMove(MoveId.MOONLIGHT, PokemonType.FAIRY, -1, 5, -1, 0, 2)
.attr(PlantHealAttr) .attr(VariableHealAttr, sunnyHealRatioFunc)
.triageMove(), .triageMove(),
new AttackMove(MoveId.HIDDEN_POWER, PokemonType.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 2) new AttackMove(MoveId.HIDDEN_POWER, PokemonType.NORMAL, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 2)
.attr(HiddenPowerTypeAttr), .attr(HiddenPowerTypeAttr),
@ -9283,15 +9291,15 @@ export function initMoves() {
.partial(), // Does not lock the user, does not stop Pokemon from sleeping .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) // Likely can make use of FrenzyAttr and an ArenaTag (just without the FrenzyMissFunc)
new SelfStatusMove(MoveId.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3) 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), .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3) new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3)
.condition(hasStockpileStacksCondition)
.attr(SpitUpPowerAttr, 100) .attr(SpitUpPowerAttr, 100)
.condition(hasStockpileStacksCondition)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true), .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true),
new SelfStatusMove(MoveId.SWALLOW, PokemonType.NORMAL, -1, 10, -1, 0, 3) new SelfStatusMove(MoveId.SWALLOW, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.attr(VariableHealAttr, swallowHealFunc, false, true, false)
.condition(hasStockpileStacksCondition) .condition(hasStockpileStacksCondition)
.attr(SwallowHealAttr)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true) .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true)
.triageMove(), .triageMove(),
new AttackMove(MoveId.HEAT_WAVE, PokemonType.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3) new AttackMove(MoveId.HEAT_WAVE, PokemonType.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3)
@ -10554,7 +10562,7 @@ export function initMoves() {
.unimplemented(), .unimplemented(),
/* End Unused */ /* End Unused */
new SelfStatusMove(MoveId.SHORE_UP, PokemonType.GROUND, -1, 5, -1, 0, 7) new SelfStatusMove(MoveId.SHORE_UP, PokemonType.GROUND, -1, 5, -1, 0, 7)
.attr(SandHealAttr) .attr(VariableHealAttr, shoreUpHealRatioFunc)
.triageMove(), .triageMove(),
new AttackMove(MoveId.FIRST_IMPRESSION, PokemonType.BUG, MoveCategory.PHYSICAL, 90, 100, 10, -1, 2, 7) new AttackMove(MoveId.FIRST_IMPRESSION, PokemonType.BUG, MoveCategory.PHYSICAL, 90, 100, 10, -1, 2, 7)
.condition(new FirstMoveCondition()), .condition(new FirstMoveCondition()),
@ -10574,7 +10582,7 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.SPD ], -1, true) .attr(StatStageChangeAttr, [ Stat.SPD ], -1, true)
.punchingMove(), .punchingMove(),
new StatusMove(MoveId.FLORAL_HEALING, PokemonType.FAIRY, -1, 10, -1, 0, 7) 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() .triageMove()
.reflectable(), .reflectable(),
new AttackMove(MoveId.HIGH_HORSEPOWER, PokemonType.GROUND, MoveCategory.PHYSICAL, 95, 95, 10, -1, 0, 7), new AttackMove(MoveId.HIGH_HORSEPOWER, PokemonType.GROUND, MoveCategory.PHYSICAL, 95, 95, 10, -1, 0, 7),
@ -11071,10 +11079,11 @@ export function initMoves() {
.attr(HealStatusEffectAttr, false, StatusEffect.FREEZE) .attr(HealStatusEffectAttr, false, StatusEffect.FREEZE)
.attr(StatusEffectAttr, StatusEffect.BURN), .attr(StatusEffectAttr, StatusEffect.BURN),
new StatusMove(MoveId.JUNGLE_HEALING, PokemonType.GRASS, -1, 10, -1, 0, 8) 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()) .attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects())
.target(MoveTarget.USER_AND_ALLIES) .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) new AttackMove(MoveId.WICKED_BLOW, PokemonType.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8)
.attr(CritOnlyAttr) .attr(CritOnlyAttr)
.punchingMove(), .punchingMove(),
@ -11176,10 +11185,11 @@ export function initMoves() {
.windMove() .windMove()
.target(MoveTarget.ALL_NEAR_ENEMIES), .target(MoveTarget.ALL_NEAR_ENEMIES),
new StatusMove(MoveId.LUNAR_BLESSING, PokemonType.PSYCHIC, -1, 5, -1, 0, 8) 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()) .attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects())
.target(MoveTarget.USER_AND_ALLIES) .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) new SelfStatusMove(MoveId.TAKE_HEART, PokemonType.PSYCHIC, -1, 10, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF ], 1, true) .attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF ], 1, true)
.attr(HealStatusEffectAttr, true, [ StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN, StatusEffect.SLEEP ]), .attr(HealStatusEffectAttr, true, [ StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN, StatusEffect.SLEEP ]),

View File

@ -155,13 +155,13 @@ export class WishTag extends PositionalTag implements WishArgs {
public override trigger(): void { public override trigger(): void {
// TODO: Rename this locales key - wish shows a message on REMOVAL, not addition // TODO: Rename this locales key - wish shows a message on REMOVAL, not addition
globalScene.phaseManager.queueMessage( // TODO: What messages does Wish show when healing a Pokemon at full HP?
i18next.t("arenaTag:wishTagOnAdd", { globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, {
message: i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: this.pokemonName, pokemonNameWithAffix: this.pokemonName,
}), }),
); showFullHpMessage: false,
});
globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, null, true, false);
} }
public override shouldTrigger(): boolean { public override shouldTrigger(): boolean {

View File

@ -1,7 +1,7 @@
import type { Ability, PreAttackModifyDamageAbAttrParams } from "#abilities/ability"; import type { Ability, PreAttackModifyDamageAbAttrParams } from "#abilities/ability";
import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs";
import type { AnySound, BattleScene } from "#app/battle-scene"; import type { AnySound, BattleScene } from "#app/battle-scene";
import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; import { PLAYER_PARTY_MAX_SIZE, RARE_CANDY_FRIENDSHIP_CAP } from "#app/constants";
import { timedEventManager } from "#app/global-event-manager"; import { timedEventManager } from "#app/global-event-manager";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
@ -24,11 +24,11 @@ import { NoCritTag, WeakenMoveScreenTag } from "#data/arena-tag";
import { import {
AutotomizedTag, AutotomizedTag,
BattlerTag, BattlerTag,
type BattlerTagTypeMap,
CritBoostTag, CritBoostTag,
EncoreTag, EncoreTag,
ExposedTag, ExposedTag,
GroundedTag, GroundedTag,
type GrudgeTag,
getBattlerTag, getBattlerTag,
HighestStatBoostTag, HighestStatBoostTag,
MoveRestrictionBattlerTag, MoveRestrictionBattlerTag,
@ -139,6 +139,8 @@ import { populateVariantColors, variantColorCache, variantData } from "#sprites/
import { achvs } from "#system/achv"; import { achvs } from "#system/achv";
import type { StarterDataEntry, StarterMoveset } from "#system/game-data"; import type { StarterDataEntry, StarterMoveset } from "#system/game-data";
import type { PokemonData } from "#system/pokemon-data"; import type { PokemonData } from "#system/pokemon-data";
import { RibbonData } from "#system/ribbons/ribbon-data";
import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods";
import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types"; import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types";
import type { DamageCalculationResult, DamageResult } from "#types/damage-result"; import type { DamageCalculationResult, DamageResult } from "#types/damage-result";
import type { IllusionData } from "#types/illusion-data"; import type { IllusionData } from "#types/illusion-data";
@ -1641,6 +1643,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return this.getMaxHp() - this.hp; return this.getMaxHp() - this.hp;
} }
// TODO: Why does this default to `false`?
getHpRatio(precise = false): number { getHpRatio(precise = false): number {
return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100; return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100;
} }
@ -1825,7 +1828,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// Overrides moveset based on arrays specified in overrides.ts // Overrides moveset based on arrays specified in overrides.ts
let overrideArray: MoveId | Array<MoveId> = this.isPlayer() let overrideArray: MoveId | Array<MoveId> = this.isPlayer()
? Overrides.MOVESET_OVERRIDE ? Overrides.MOVESET_OVERRIDE
: Overrides.OPP_MOVESET_OVERRIDE; : Overrides.ENEMY_MOVESET_OVERRIDE;
overrideArray = coerceArray(overrideArray); overrideArray = coerceArray(overrideArray);
if (overrideArray.length > 0) { if (overrideArray.length > 0) {
if (!this.isPlayer()) { if (!this.isPlayer()) {
@ -2030,8 +2033,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (Overrides.ABILITY_OVERRIDE && this.isPlayer()) { if (Overrides.ABILITY_OVERRIDE && this.isPlayer()) {
return allAbilities[Overrides.ABILITY_OVERRIDE]; return allAbilities[Overrides.ABILITY_OVERRIDE];
} }
if (Overrides.OPP_ABILITY_OVERRIDE && this.isEnemy()) { if (Overrides.ENEMY_ABILITY_OVERRIDE && this.isEnemy()) {
return allAbilities[Overrides.OPP_ABILITY_OVERRIDE]; return allAbilities[Overrides.ENEMY_ABILITY_OVERRIDE];
} }
if (this.isFusion()) { if (this.isFusion()) {
if (!isNullOrUndefined(this.fusionCustomPokemonData?.ability) && this.fusionCustomPokemonData.ability !== -1) { if (!isNullOrUndefined(this.fusionCustomPokemonData?.ability) && this.fusionCustomPokemonData.ability !== -1) {
@ -2060,8 +2063,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (Overrides.PASSIVE_ABILITY_OVERRIDE && this.isPlayer()) { if (Overrides.PASSIVE_ABILITY_OVERRIDE && this.isPlayer()) {
return allAbilities[Overrides.PASSIVE_ABILITY_OVERRIDE]; return allAbilities[Overrides.PASSIVE_ABILITY_OVERRIDE];
} }
if (Overrides.OPP_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) { if (Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) {
return allAbilities[Overrides.OPP_PASSIVE_ABILITY_OVERRIDE]; return allAbilities[Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE];
} }
if (!isNullOrUndefined(this.customPokemonData.passive) && this.customPokemonData.passive !== -1) { if (!isNullOrUndefined(this.customPokemonData.passive) && this.customPokemonData.passive !== -1) {
return allAbilities[this.customPokemonData.passive]; return allAbilities[this.customPokemonData.passive];
@ -2128,14 +2131,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// returns override if valid for current case // returns override if valid for current case
if ( if (
(Overrides.HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isPlayer()) || (Overrides.HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isPlayer()) ||
(Overrides.OPP_HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isEnemy()) (Overrides.ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isEnemy())
) { ) {
return false; return false;
} }
if ( if (
((Overrides.PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.HAS_PASSIVE_ABILITY_OVERRIDE) && ((Overrides.PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.HAS_PASSIVE_ABILITY_OVERRIDE) &&
this.isPlayer()) || this.isPlayer()) ||
((Overrides.OPP_PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.OPP_HAS_PASSIVE_ABILITY_OVERRIDE) && ((Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE) &&
this.isEnemy()) this.isEnemy())
) { ) {
return true; return true;
@ -3001,8 +3004,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (forStarter && this.isPlayer() && Overrides.STARTER_FUSION_SPECIES_OVERRIDE) { if (forStarter && this.isPlayer() && Overrides.STARTER_FUSION_SPECIES_OVERRIDE) {
fusionOverride = getPokemonSpecies(Overrides.STARTER_FUSION_SPECIES_OVERRIDE); fusionOverride = getPokemonSpecies(Overrides.STARTER_FUSION_SPECIES_OVERRIDE);
} else if (this.isEnemy() && Overrides.OPP_FUSION_SPECIES_OVERRIDE) { } else if (this.isEnemy() && Overrides.ENEMY_FUSION_SPECIES_OVERRIDE) {
fusionOverride = getPokemonSpecies(Overrides.OPP_FUSION_SPECIES_OVERRIDE); fusionOverride = getPokemonSpecies(Overrides.ENEMY_FUSION_SPECIES_OVERRIDE);
} }
this.fusionSpecies = this.fusionSpecies =
@ -4238,14 +4241,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return false; return false;
} }
/**@overload */
getTag(tagType: BattlerTagType.GRUDGE): GrudgeTag | undefined;
/** @overload */ /** @overload */
getTag(tagType: BattlerTagType.SUBSTITUTE): SubstituteTag | undefined; getTag<T extends BattlerTagType>(tagType: T): BattlerTagTypeMap[T] | undefined;
/** @overload */
getTag(tagType: BattlerTagType): BattlerTag | undefined;
/** @overload */ /** @overload */
getTag<T extends BattlerTag>(tagType: Constructor<T>): T | undefined; getTag<T extends BattlerTag>(tagType: Constructor<T>): T | undefined;
@ -5822,45 +5819,59 @@ export class PlayerPokemon extends Pokemon {
); );
}); });
} }
/**
* Add friendship to this Pokemon
*
* @remarks
* This adds friendship to the pokemon's friendship stat (used for evolution, return, etc.) and candy progress.
* For fusions, candy progress for each species in the fusion is halved.
*
* @param friendship - The amount of friendship to add. Negative values will reduce friendship, though not below 0.
* @param capped - If true, don't allow the friendship gain to exceed 200. Used to cap friendship gains from rare candies.
*/
addFriendship(friendship: number, capped = false): void {
// Short-circuit friendship loss, which doesn't impact candy friendship
if (friendship <= 0) {
this.friendship = Math.max(this.friendship + friendship, 0);
return;
}
addFriendship(friendship: number): void {
if (friendship > 0) {
const starterSpeciesId = this.species.getRootSpeciesId(); const starterSpeciesId = this.species.getRootSpeciesId();
const fusionStarterSpeciesId = this.isFusion() && this.fusionSpecies ? this.fusionSpecies.getRootSpeciesId() : 0; const fusionStarterSpeciesId = this.isFusion() && this.fusionSpecies ? this.fusionSpecies.getRootSpeciesId() : 0;
const starterData = [ const starterGameData = globalScene.gameData.starterData;
globalScene.gameData.starterData[starterSpeciesId], const starterData: [StarterDataEntry, SpeciesId][] = [[starterGameData[starterSpeciesId], starterSpeciesId]];
fusionStarterSpeciesId ? globalScene.gameData.starterData[fusionStarterSpeciesId] : null, if (fusionStarterSpeciesId) {
].filter(d => !!d); starterData.push([starterGameData[fusionStarterSpeciesId], fusionStarterSpeciesId]);
}
const amount = new NumberHolder(friendship); const amount = new NumberHolder(friendship);
globalScene.applyModifier(PokemonFriendshipBoosterModifier, true, this, amount); globalScene.applyModifier(PokemonFriendshipBoosterModifier, true, this, amount);
const candyFriendshipMultiplier = globalScene.gameMode.isClassic friendship = amount.value;
const newFriendship = this.friendship + friendship;
// If capped is true, only adjust friendship if the new friendship is less than or equal to 200.
if (!capped || newFriendship <= RARE_CANDY_FRIENDSHIP_CAP) {
this.friendship = Math.min(newFriendship, 255);
if (newFriendship >= 255) {
globalScene.validateAchv(achvs.MAX_FRIENDSHIP);
awardRibbonsToSpeciesLine(this.species.speciesId, RibbonData.FRIENDSHIP);
}
}
let candyFriendshipMultiplier = globalScene.gameMode.isClassic
? timedEventManager.getClassicFriendshipMultiplier() ? timedEventManager.getClassicFriendshipMultiplier()
: 1; : 1;
const fusionReduction = fusionStarterSpeciesId if (fusionStarterSpeciesId) {
? timedEventManager.areFusionsBoosted() candyFriendshipMultiplier /= timedEventManager.areFusionsBoosted() ? 1.5 : 2;
? 1.5 // Divide candy gain for fusions by 1.5 during events
: 2 // 2 for fusions outside events
: 1; // 1 for non-fused mons
const starterAmount = new NumberHolder(Math.floor((amount.value * candyFriendshipMultiplier) / fusionReduction));
// Add friendship to this PlayerPokemon
this.friendship = Math.min(this.friendship + amount.value, 255);
if (this.friendship === 255) {
globalScene.validateAchv(achvs.MAX_FRIENDSHIP);
} }
const candyFriendshipAmount = Math.floor(friendship * candyFriendshipMultiplier);
// Add to candy progress for this mon's starter species and its fused species (if it has one) // Add to candy progress for this mon's starter species and its fused species (if it has one)
starterData.forEach((sd: StarterDataEntry, i: number) => { starterData.forEach(([sd, id]: [StarterDataEntry, SpeciesId]) => {
const speciesId = !i ? starterSpeciesId : (fusionStarterSpeciesId as SpeciesId); sd.friendship = (sd.friendship || 0) + candyFriendshipAmount;
sd.friendship = (sd.friendship || 0) + starterAmount.value; if (sd.friendship >= getStarterValueFriendshipCap(speciesStarterCosts[id])) {
if (sd.friendship >= getStarterValueFriendshipCap(speciesStarterCosts[speciesId])) { globalScene.gameData.addStarterCandy(getPokemonSpecies(id), 1);
globalScene.gameData.addStarterCandy(getPokemonSpecies(speciesId), 1);
sd.friendship = 0; sd.friendship = 0;
} }
}); });
} else {
// Lose friendship upon fainting
this.friendship = Math.max(this.friendship + friendship, 0);
}
} }
getPossibleEvolution(evolution: SpeciesFormEvolution | null): Promise<Pokemon> { getPossibleEvolution(evolution: SpeciesFormEvolution | null): Promise<Pokemon> {
@ -6241,22 +6252,22 @@ export class EnemyPokemon extends Pokemon {
this.setBoss(boss, dataSource?.bossSegments); this.setBoss(boss, dataSource?.bossSegments);
} }
if (Overrides.OPP_STATUS_OVERRIDE) { if (Overrides.ENEMY_STATUS_OVERRIDE) {
this.status = new Status(Overrides.OPP_STATUS_OVERRIDE, 0, 4); this.status = new Status(Overrides.ENEMY_STATUS_OVERRIDE, 0, 4);
} }
if (Overrides.OPP_GENDER_OVERRIDE !== null) { if (Overrides.ENEMY_GENDER_OVERRIDE !== null) {
this.gender = Overrides.OPP_GENDER_OVERRIDE; this.gender = Overrides.ENEMY_GENDER_OVERRIDE;
} }
const speciesId = this.species.speciesId; const speciesId = this.species.speciesId;
if ( if (
speciesId in Overrides.OPP_FORM_OVERRIDES && speciesId in Overrides.ENEMY_FORM_OVERRIDES &&
!isNullOrUndefined(Overrides.OPP_FORM_OVERRIDES[speciesId]) && !isNullOrUndefined(Overrides.ENEMY_FORM_OVERRIDES[speciesId]) &&
this.species.forms[Overrides.OPP_FORM_OVERRIDES[speciesId]] this.species.forms[Overrides.ENEMY_FORM_OVERRIDES[speciesId]]
) { ) {
this.formIndex = Overrides.OPP_FORM_OVERRIDES[speciesId]; this.formIndex = Overrides.ENEMY_FORM_OVERRIDES[speciesId];
} else if (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) { } else if (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) {
const eventBoss = getDailyEventSeedBoss(globalScene.seed); const eventBoss = getDailyEventSeedBoss(globalScene.seed);
if (!isNullOrUndefined(eventBoss)) { if (!isNullOrUndefined(eventBoss)) {
@ -6266,21 +6277,21 @@ export class EnemyPokemon extends Pokemon {
if (!dataSource) { if (!dataSource) {
this.generateAndPopulateMoveset(); this.generateAndPopulateMoveset();
if (shinyLock || Overrides.OPP_SHINY_OVERRIDE === false) { if (shinyLock || Overrides.ENEMY_SHINY_OVERRIDE === false) {
this.shiny = false; this.shiny = false;
} else { } else {
this.trySetShiny(); this.trySetShiny();
} }
if (!this.shiny && Overrides.OPP_SHINY_OVERRIDE) { if (!this.shiny && Overrides.ENEMY_SHINY_OVERRIDE) {
this.shiny = true; this.shiny = true;
this.initShinySparkle(); this.initShinySparkle();
} }
if (this.shiny) { if (this.shiny) {
this.variant = this.generateShinyVariant(); this.variant = this.generateShinyVariant();
if (Overrides.OPP_VARIANT_OVERRIDE !== null) { if (Overrides.ENEMY_VARIANT_OVERRIDE !== null) {
this.variant = Overrides.OPP_VARIANT_OVERRIDE; this.variant = Overrides.ENEMY_VARIANT_OVERRIDE;
} }
} }

View File

@ -90,6 +90,7 @@ export class LoadingScene extends SceneBase {
this.loadAtlas("shiny_icons", "ui"); this.loadAtlas("shiny_icons", "ui");
this.loadImage("ha_capsule", "ui", "ha_capsule.png"); this.loadImage("ha_capsule", "ui", "ha_capsule.png");
this.loadImage("champion_ribbon", "ui", "champion_ribbon.png"); this.loadImage("champion_ribbon", "ui", "champion_ribbon.png");
this.loadImage("champion_ribbon_emerald", "ui", "champion_ribbon_emerald.png");
this.loadImage("icon_spliced", "ui"); this.loadImage("icon_spliced", "ui");
this.loadImage("icon_lock", "ui", "icon_lock.png"); this.loadImage("icon_lock", "ui", "icon_lock.png");
this.loadImage("icon_stop", "ui", "icon_stop.png"); this.loadImage("icon_stop", "ui", "icon_stop.png");
@ -122,6 +123,7 @@ export class LoadingScene extends SceneBase {
this.loadImage("party_bg_double", "ui"); this.loadImage("party_bg_double", "ui");
this.loadImage("party_bg_double_manage", "ui"); this.loadImage("party_bg_double_manage", "ui");
this.loadAtlas("party_slot_main", "ui"); this.loadAtlas("party_slot_main", "ui");
this.loadAtlas("party_slot_main_short", "ui");
this.loadAtlas("party_slot", "ui"); this.loadAtlas("party_slot", "ui");
this.loadImage("party_slot_overlay_lv", "ui"); this.loadImage("party_slot_overlay_lv", "ui");
this.loadImage("party_slot_hp_bar", "ui"); this.loadImage("party_slot_hp_bar", "ui");

View File

@ -1674,11 +1674,12 @@ export class TurnHealModifier extends PokemonHeldItemModifier {
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / 16) * this.stackCount, toDmgValue(pokemon.getMaxHp() / 16) * this.stackCount,
i18next.t("modifier:turnHealApply", { {
message: i18next.t("modifier:turnHealApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
typeName: this.type.name, typeName: this.type.name,
}), }),
true, },
); );
return true; return true;
} }
@ -1766,16 +1767,16 @@ export class HitHealModifier extends PokemonHeldItemModifier {
*/ */
override apply(pokemon: Pokemon): boolean { override apply(pokemon: Pokemon): boolean {
if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) { if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) {
// TODO: this shouldn't be undefined AFAIK
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.turnData.totalDamageDealt / 8) * this.stackCount, toDmgValue((pokemon.turnData.totalDamageDealt * this.stackCount) / 8),
i18next.t("modifier:hitHealApply", { {
message: i18next.t("modifier:hitHealApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
typeName: this.type.name, typeName: this.type.name,
}), }),
true, },
); );
} }
@ -1934,20 +1935,22 @@ export class PokemonInstantReviveModifier extends PokemonHeldItemModifier {
*/ */
override apply(pokemon: Pokemon): boolean { override apply(pokemon: Pokemon): boolean {
// Restore the Pokemon to half HP // Restore the Pokemon to half HP
// TODO: This should not use a phase to revive pokemon
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
toDmgValue(pokemon.getMaxHp() / 2), toDmgValue(pokemon.getMaxHp() / 2),
i18next.t("modifier:pokemonInstantReviveApply", { {
message: i18next.t("modifier:pokemonInstantReviveApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
typeName: this.type.name, typeName: this.type.name,
}), }),
false, revive: true,
false, },
true,
); );
// Remove the Pokemon's FAINT status // Remove the Pokemon's FAINT status
// TODO: Remove call to `resetStatus` once StatusEffect.FAINT is canned
pokemon.resetStatus(true, false, true, false); pokemon.resetStatus(true, false, true, false);
// Reapply Commander on the Pokemon's side of the field, if applicable // Reapply Commander on the Pokemon's side of the field, if applicable
@ -2304,7 +2307,7 @@ export class PokemonLevelIncrementModifier extends ConsumablePokemonModifier {
playerPokemon.levelExp = 0; playerPokemon.levelExp = 0;
} }
playerPokemon.addFriendship(FRIENDSHIP_GAIN_FROM_RARE_CANDY); playerPokemon.addFriendship(FRIENDSHIP_GAIN_FROM_RARE_CANDY, true);
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
"LevelUpPhase", "LevelUpPhase",
@ -3549,24 +3552,24 @@ export class EnemyTurnHealModifier extends EnemyPersistentModifier {
* @returns `true` if the {@linkcode Pokemon} was healed * @returns `true` if the {@linkcode Pokemon} was healed
*/ */
override apply(enemyPokemon: Pokemon): boolean { override apply(enemyPokemon: Pokemon): boolean {
if (!enemyPokemon.isFullHp()) { if (enemyPokemon.isFullHp()) {
return false;
}
// Prevent healing to full from healing tokens
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
"PokemonHealPhase", "PokemonHealPhase",
enemyPokemon.getBattlerIndex(), enemyPokemon.getBattlerIndex(),
Math.max(Math.floor(enemyPokemon.getMaxHp() / (100 / this.healPercent)) * this.stackCount, 1), (enemyPokemon.getMaxHp() * this.stackCount * this.healPercent) / 100,
i18next.t("modifier:enemyTurnHealApply", { {
message: i18next.t("modifier:enemyTurnHealApply", {
pokemonNameWithAffix: getPokemonNameWithAffix(enemyPokemon), pokemonNameWithAffix: getPokemonNameWithAffix(enemyPokemon),
}), }),
true, preventFullHeal: true,
false, },
false,
false,
true,
); );
return true;
}
return false; return true;
} }
getMaxStackCount(): number { getMaxStackCount(): number {
@ -3755,7 +3758,7 @@ export class EnemyFusionChanceModifier extends EnemyPersistentModifier {
export function overrideModifiers(isPlayer = true): void { export function overrideModifiers(isPlayer = true): void {
const modifiersOverride: ModifierOverride[] = isPlayer const modifiersOverride: ModifierOverride[] = isPlayer
? Overrides.STARTING_MODIFIER_OVERRIDE ? Overrides.STARTING_MODIFIER_OVERRIDE
: Overrides.OPP_MODIFIER_OVERRIDE; : Overrides.ENEMY_MODIFIER_OVERRIDE;
if (!modifiersOverride || modifiersOverride.length === 0 || !globalScene) { if (!modifiersOverride || modifiersOverride.length === 0 || !globalScene) {
return; return;
} }
@ -3797,7 +3800,7 @@ export function overrideModifiers(isPlayer = true): void {
export function overrideHeldItems(pokemon: Pokemon, isPlayer = true): void { export function overrideHeldItems(pokemon: Pokemon, isPlayer = true): void {
const heldItemsOverride: ModifierOverride[] = isPlayer const heldItemsOverride: ModifierOverride[] = isPlayer
? Overrides.STARTING_HELD_ITEMS_OVERRIDE ? Overrides.STARTING_HELD_ITEMS_OVERRIDE
: Overrides.OPP_HELD_ITEMS_OVERRIDE; : Overrides.ENEMY_HELD_ITEMS_OVERRIDE;
if (!heldItemsOverride || heldItemsOverride.length === 0 || !globalScene) { if (!heldItemsOverride || heldItemsOverride.length === 0 || !globalScene) {
return; return;
} }

View File

@ -179,25 +179,24 @@ class DefaultOverrides {
// -------------------------- // --------------------------
// OPPONENT / ENEMY OVERRIDES // OPPONENT / ENEMY OVERRIDES
// -------------------------- // --------------------------
// TODO: rename `OPP_` to `ENEMY_` readonly ENEMY_SPECIES_OVERRIDE: SpeciesId | number = 0;
readonly OPP_SPECIES_OVERRIDE: SpeciesId | number = 0;
/** /**
* This will make all opponents fused Pokemon * This will make all opponents fused Pokemon
*/ */
readonly OPP_FUSION_OVERRIDE: boolean = false; readonly ENEMY_FUSION_OVERRIDE: boolean = false;
/** /**
* This will override the species of the fusion only when the opponent is already a fusion * This will override the species of the fusion only when the opponent is already a fusion
*/ */
readonly OPP_FUSION_SPECIES_OVERRIDE: SpeciesId | number = 0; readonly ENEMY_FUSION_SPECIES_OVERRIDE: SpeciesId | number = 0;
readonly OPP_LEVEL_OVERRIDE: number = 0; readonly ENEMY_LEVEL_OVERRIDE: number = 0;
readonly OPP_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; readonly ENEMY_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE;
readonly OPP_PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; readonly ENEMY_PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE;
readonly OPP_HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null; readonly ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null;
readonly OPP_STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE; readonly ENEMY_STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE;
readonly OPP_GENDER_OVERRIDE: Gender | null = null; readonly ENEMY_GENDER_OVERRIDE: Gender | null = null;
readonly OPP_MOVESET_OVERRIDE: MoveId | Array<MoveId> = []; readonly ENEMY_MOVESET_OVERRIDE: MoveId | Array<MoveId> = [];
readonly OPP_SHINY_OVERRIDE: boolean | null = null; readonly ENEMY_SHINY_OVERRIDE: boolean | null = null;
readonly OPP_VARIANT_OVERRIDE: Variant | null = null; readonly ENEMY_VARIANT_OVERRIDE: Variant | null = null;
/** /**
* Overrides the IVs of enemy pokemon. Values must never be outside the range `0` to `31`! * Overrides the IVs of enemy pokemon. Values must never be outside the range `0` to `31`!
* - If set to a number between `0` and `31`, set all IVs of all enemy pokemon to that number. * - If set to a number between `0` and `31`, set all IVs of all enemy pokemon to that number.
@ -207,7 +206,7 @@ class DefaultOverrides {
readonly ENEMY_IVS_OVERRIDE: number | number[] | null = null; readonly ENEMY_IVS_OVERRIDE: number | number[] | null = null;
/** Override the nature of all enemy pokemon to the specified nature. Disabled if `null`. */ /** Override the nature of all enemy pokemon to the specified nature. Disabled if `null`. */
readonly ENEMY_NATURE_OVERRIDE: Nature | null = null; readonly ENEMY_NATURE_OVERRIDE: Nature | null = null;
readonly OPP_FORM_OVERRIDES: Partial<Record<SpeciesId, number>> = {}; readonly ENEMY_FORM_OVERRIDES: Partial<Record<SpeciesId, number>> = {};
/** /**
* Override to give the enemy Pokemon a given amount of health segments * Override to give the enemy Pokemon a given amount of health segments
* *
@ -215,7 +214,7 @@ class DefaultOverrides {
* 1: the Pokemon will have a single health segment and therefore will not be a boss * 1: the Pokemon will have a single health segment and therefore will not be a boss
* 2+: the Pokemon will be a boss with the given number of health segments * 2+: the Pokemon will be a boss with the given number of health segments
*/ */
readonly OPP_HEALTH_SEGMENTS_OVERRIDE: number = 0; readonly ENEMY_HEALTH_SEGMENTS_OVERRIDE: number = 0;
// ------------- // -------------
// EGG OVERRIDES // EGG OVERRIDES
@ -277,12 +276,12 @@ class DefaultOverrides {
* *
* Note that any previous modifiers are cleared. * Note that any previous modifiers are cleared.
*/ */
readonly OPP_MODIFIER_OVERRIDE: ModifierOverride[] = []; readonly ENEMY_MODIFIER_OVERRIDE: ModifierOverride[] = [];
/** Override array of {@linkcode ModifierOverride}s used to provide held items to first party member when starting a new game. */ /** Override array of {@linkcode ModifierOverride}s used to provide held items to first party member when starting a new game. */
readonly STARTING_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; readonly STARTING_HELD_ITEMS_OVERRIDE: ModifierOverride[] = [];
/** Override array of {@linkcode ModifierOverride}s used to provide held items to enemies on spawn. */ /** Override array of {@linkcode ModifierOverride}s used to provide held items to enemies on spawn. */
readonly OPP_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; readonly ENEMY_HELD_ITEMS_OVERRIDE: ModifierOverride[] = [];
/** /**
* Override array of {@linkcode ModifierOverride}s used to replace the generated item rolls after a wave. * Override array of {@linkcode ModifierOverride}s used to replace the generated item rolls after a wave.

View File

@ -229,7 +229,7 @@ export class EncounterPhase extends BattlePhase {
}), }),
); );
} else { } else {
const overridedBossSegments = Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1; const overridedBossSegments = Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1;
// for double battles, reduce the health segments for boss Pokemon unless there is an override // for double battles, reduce the health segments for boss Pokemon unless there is an override
if (!overridedBossSegments && battle.enemyParty.filter(p => p.isBoss()).length > 1) { if (!overridedBossSegments && battle.enemyParty.filter(p => p.isBoss()).length > 1) {
for (const enemyPokemon of battle.enemyParty) { for (const enemyPokemon of battle.enemyParty) {

View File

@ -19,8 +19,11 @@ import { ChallengeData } from "#system/challenge-data";
import type { SessionSaveData } from "#system/game-data"; import type { SessionSaveData } from "#system/game-data";
import { ModifierData as PersistentModifierData } from "#system/modifier-data"; import { ModifierData as PersistentModifierData } from "#system/modifier-data";
import { PokemonData } from "#system/pokemon-data"; import { PokemonData } from "#system/pokemon-data";
import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data";
import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods";
import { TrainerData } from "#system/trainer-data"; import { TrainerData } from "#system/trainer-data";
import { trainerConfigs } from "#trainers/trainer-config"; import { trainerConfigs } from "#trainers/trainer-config";
import { checkSpeciesValidForChallenge, isNuzlockeChallenge } from "#utils/challenge-utils";
import { isLocal, isLocalServerConnected } from "#utils/common"; import { isLocal, isLocalServerConnected } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils"; import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next"; import i18next from "i18next";
@ -111,6 +114,40 @@ export class GameOverPhase extends BattlePhase {
} }
} }
/**
* Submethod of {@linkcode handleGameOver} that awards ribbons to Pokémon in the player's party based on the current
* game mode and challenges.
*/
private awardRibbons(): void {
let ribbonFlags = 0;
if (globalScene.gameMode.isClassic) {
ribbonFlags |= RibbonData.CLASSIC;
}
if (isNuzlockeChallenge()) {
ribbonFlags |= RibbonData.NUZLOCKE;
}
for (const challenge of globalScene.gameMode.challenges) {
const ribbon = challenge.ribbonAwarded;
if (challenge.value && ribbon) {
ribbonFlags |= ribbon;
}
}
// Award ribbons to all Pokémon in the player's party that are considered valid
// for the current game mode and challenges.
for (const pokemon of globalScene.getPlayerParty()) {
const species = pokemon.species;
if (
checkSpeciesValidForChallenge(
species,
globalScene.gameData.getSpeciesDexAttrProps(species, pokemon.getDexAttr()),
false,
)
) {
awardRibbonsToSpeciesLine(species.speciesId, ribbonFlags as RibbonFlag);
}
}
}
handleGameOver(): void { handleGameOver(): void {
const doGameOver = (newClear: boolean) => { const doGameOver = (newClear: boolean) => {
globalScene.disableMenu = true; globalScene.disableMenu = true;
@ -122,12 +159,12 @@ export class GameOverPhase extends BattlePhase {
globalScene.validateAchv(achvs.UNEVOLVED_CLASSIC_VICTORY); globalScene.validateAchv(achvs.UNEVOLVED_CLASSIC_VICTORY);
globalScene.gameData.gameStats.sessionsWon++; globalScene.gameData.gameStats.sessionsWon++;
for (const pokemon of globalScene.getPlayerParty()) { for (const pokemon of globalScene.getPlayerParty()) {
this.awardRibbon(pokemon); this.awardFirstClassicCompletion(pokemon);
if (pokemon.species.getRootSpeciesId() !== pokemon.species.getRootSpeciesId(true)) { if (pokemon.species.getRootSpeciesId() !== pokemon.species.getRootSpeciesId(true)) {
this.awardRibbon(pokemon, true); this.awardFirstClassicCompletion(pokemon, true);
} }
} }
this.awardRibbons();
} else if (globalScene.gameMode.isDaily && newClear) { } else if (globalScene.gameMode.isDaily && newClear) {
globalScene.gameData.gameStats.dailyRunSessionsWon++; globalScene.gameData.gameStats.dailyRunSessionsWon++;
} }
@ -263,7 +300,7 @@ export class GameOverPhase extends BattlePhase {
} }
} }
awardRibbon(pokemon: Pokemon, forStarter = false): void { awardFirstClassicCompletion(pokemon: Pokemon, forStarter = false): void {
const speciesId = getPokemonSpecies(pokemon.species.speciesId); const speciesId = getPokemonSpecies(pokemon.species.speciesId);
const speciesRibbonCount = globalScene.gameData.incrementRibbonCount(speciesId, forStarter); const speciesRibbonCount = globalScene.gameData.incrementRibbonCount(speciesId, forStarter);
// first time classic win, award voucher // first time classic win, award voucher

View File

@ -1,39 +1,81 @@
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import type { HealBlockTag } from "#data/battler-tags";
import { getStatusEffectHealText } from "#data/status-effect"; import { getStatusEffectHealText } from "#data/status-effect";
import type { BattlerIndex } from "#enums/battler-index"; import type { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { HitResult } from "#enums/hit-result"; import { HitResult } from "#enums/hit-result";
import { CommonAnim } from "#enums/move-anims-common"; import { CommonAnim } from "#enums/move-anims-common";
import { StatusEffect } from "#enums/status-effect";
import { HealingBoosterModifier } from "#modifiers/modifier"; import { HealingBoosterModifier } from "#modifiers/modifier";
import { CommonAnimPhase } from "#phases/common-anim-phase"; import { CommonAnimPhase } from "#phases/common-anim-phase";
import { HealAchv } from "#system/achv"; import { HealAchv } from "#system/achv";
import { NumberHolder } from "#utils/common"; import { NumberHolder, toDmgValue } from "#utils/common";
import i18next from "i18next"; import i18next from "i18next";
export class PokemonHealPhase extends CommonAnimPhase { export class PokemonHealPhase extends CommonAnimPhase {
public readonly phaseName = "PokemonHealPhase"; public readonly phaseName = "PokemonHealPhase";
/** The base amount of HP to heal. */
private hpHealed: number; 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; private showFullHpMessage: boolean;
/**
* Whether to skip showing the healing animation.
* @defaultValue `false`
*/
private skipAnim: boolean; 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; 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; private healStatus: boolean;
/**
* Whether to prevent fully healing affected Pokemon, leaving them 1 HP below full.
* @defaultValue `false`
*/
private preventFullHeal: boolean; 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; private fullRestorePP: boolean;
constructor( constructor(
battlerIndex: BattlerIndex, battlerIndex: BattlerIndex,
hpHealed: number, hpHealed: number,
message: string | null, {
showFullHpMessage: boolean, message,
showFullHpMessage = true,
skipAnim = false, skipAnim = false,
revive = false, revive = false,
healStatus = false, healStatus = false,
preventFullHeal = false, preventFullHeal = false,
fullRestorePP = false, fullRestorePP = false,
}: {
message?: string;
showFullHpMessage?: boolean;
skipAnim?: boolean;
revive?: boolean;
healStatus?: boolean;
preventFullHeal?: boolean;
fullRestorePP?: boolean;
} = {},
) { ) {
super(battlerIndex, undefined, CommonAnim.HEALTH_UP); super(battlerIndex, undefined, CommonAnim.HEALTH_UP);
@ -47,89 +89,115 @@ export class PokemonHealPhase extends CommonAnimPhase {
this.fullRestorePP = fullRestorePP; this.fullRestorePP = fullRestorePP;
} }
start() { override start() {
if (!this.skipAnim && (this.revive || this.getPokemon().hp) && !this.getPokemon().isFullHp()) { // Only play animation if not skipped and target is not at full HP
if (!this.skipAnim && !this.getPokemon().isFullHp()) {
super.start(); super.start();
} else { } else {
this.end(); 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(); const pokemon = this.getPokemon();
if (!pokemon.isOnField() || (!this.revive && !pokemon.isActive())) { // Prevent healing off-field pokemon unless via revives
return super.end(); // TODO: Revival effects shouldn't use this phase
if (!this.revive && !pokemon.isActive(true)) {
return;
} }
const hasMessage = !!this.message; // Check for heal block, ending the phase early if healing was prevented
const healOrDamage = !pokemon.isFullHp() || this.hpHealed < 0; const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK);
const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK) as HealBlockTag;
let lastStatusEffect = StatusEffect.NONE;
if (healBlock && this.hpHealed > 0) { if (healBlock && this.hpHealed > 0) {
globalScene.phaseManager.queueMessage(healBlock.onActivation(pokemon)); globalScene.phaseManager.queueMessage(healBlock.onActivation(pokemon));
this.message = null; return;
return super.end();
} }
if (healOrDamage) {
const hpRestoreMultiplier = new NumberHolder(1); this.doHealPokemon();
if (!this.revive) {
globalScene.applyModifiers(HealingBoosterModifier, this.player, hpRestoreMultiplier); // Cure status as applicable
} // TODO: This should not be the job of the healing phase
const healAmount = new NumberHolder(Math.floor(this.hpHealed * hpRestoreMultiplier.value)); if (this.healStatus && pokemon.status) {
if (healAmount.value < 0) { this.message = getStatusEffectHealText(pokemon.status.effect, getPokemonNameWithAffix(pokemon));
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;
pokemon.resetStatus(); pokemon.resetStatus();
} }
// Restore PP.
// TODO: This should not be the job of the healing phase
if (this.fullRestorePP) { if (this.fullRestorePP) {
for (const move of this.getPokemon().getMoveset()) { pokemon.getMoveset().forEach(m => {
if (move) { m.ppUsed = 0;
move.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; * Heal the Pokemon affected by this Phase.
pokemon.resetStatus(); */
pokemon.updateInfo().then(() => super.end()); private doHealPokemon(): void {
} else if (this.showFullHpMessage) { 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", { this.message = i18next.t("battle:hpIsFull", {
pokemonName: getPokemonNameWithAffix(pokemon), pokemonName: getPokemonNameWithAffix(pokemon),
}); });
} }
return;
if (this.message) {
globalScene.phaseManager.queueMessage(this.message);
} }
if (this.healStatus && lastStatusEffect && !hasMessage) { const healAmount = this.getHealAmount();
globalScene.phaseManager.queueMessage(
getStatusEffectHealText(lastStatusEffect, getPokemonNameWithAffix(pokemon)), 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) { // Heal the pokemon, then show damage numbers and validate achievements.
super.end(); 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);
}
} }

View File

@ -154,16 +154,11 @@ export class QuietFormChangePhase extends BattlePhase {
this.pokemon.findAndRemoveTags(t => t.tagType === BattlerTagType.AUTOTOMIZED); this.pokemon.findAndRemoveTags(t => t.tagType === BattlerTagType.AUTOTOMIZED);
if (globalScene?.currentBattle.battleSpec === BattleSpec.FINAL_BOSS && this.pokemon.isEnemy()) { if (globalScene?.currentBattle.battleSpec === BattleSpec.FINAL_BOSS && this.pokemon.isEnemy()) {
globalScene.playBgm(); globalScene.playBgm();
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.pokemon.getBattlerIndex(), this.pokemon.getMaxHp(), {
"PokemonHealPhase", showFullHpMessage: false,
this.pokemon.getBattlerIndex(), healStatus: true,
this.pokemon.getMaxHp(), fullRestorePP: true,
null, });
false,
false,
false,
true,
);
this.pokemon.findAndRemoveTags(() => true); this.pokemon.findAndRemoveTags(() => true);
this.pokemon.bossSegments = 5; this.pokemon.bossSegments = 5;
this.pokemon.bossSegmentIndex = 4; this.pokemon.bossSegmentIndex = 4;

View File

@ -14,6 +14,7 @@ import {
TurnStatusEffectModifier, TurnStatusEffectModifier,
} from "#modifiers/modifier"; } from "#modifiers/modifier";
import { FieldPhase } from "#phases/field-phase"; import { FieldPhase } from "#phases/field-phase";
import { toDmgValue } from "#utils/common";
import i18next from "i18next"; import i18next from "i18next";
export class TurnEndPhase extends FieldPhase { export class TurnEndPhase extends FieldPhase {
@ -38,11 +39,12 @@ export class TurnEndPhase extends FieldPhase {
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
"PokemonHealPhase", "PokemonHealPhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
Math.max(pokemon.getMaxHp() >> 4, 1), toDmgValue(pokemon.getMaxHp() / 16),
i18next.t("battle:turnEndHpRestore", { {
message: i18next.t("battle:turnEndHpRestore", {
pokemonName: getPokemonNameWithAffix(pokemon), pokemonName: getPokemonNameWithAffix(pokemon),
}), }),
true, },
); );
} }

View File

@ -5,7 +5,6 @@ import {
FlipStatChallenge, FlipStatChallenge,
FreshStartChallenge, FreshStartChallenge,
InverseBattleChallenge, InverseBattleChallenge,
LimitedCatchChallenge,
SingleGenerationChallenge, SingleGenerationChallenge,
SingleTypeChallenge, SingleTypeChallenge,
} from "#data/challenge"; } from "#data/challenge";
@ -14,6 +13,7 @@ import { PlayerGender } from "#enums/player-gender";
import { getShortenedStatKey, Stat } from "#enums/stat"; import { getShortenedStatKey, Stat } from "#enums/stat";
import { TurnHeldItemTransferModifier } from "#modifiers/modifier"; import { TurnHeldItemTransferModifier } from "#modifiers/modifier";
import type { ConditionFn } from "#types/common"; import type { ConditionFn } from "#types/common";
import { isNuzlockeChallenge } from "#utils/challenge-utils";
import { NumberHolder } from "#utils/common"; import { NumberHolder } from "#utils/common";
import i18next from "i18next"; import i18next from "i18next";
import type { Modifier } from "typescript"; import type { Modifier } from "typescript";
@ -924,18 +924,7 @@ export const achvs = {
globalScene.gameMode.challenges.some(c => c.id === Challenges.INVERSE_BATTLE && c.value > 0), globalScene.gameMode.challenges.some(c => c.id === Challenges.INVERSE_BATTLE && c.value > 0),
).setSecret(), ).setSecret(),
// TODO: Decide on icon // TODO: Decide on icon
NUZLOCKE: new ChallengeAchv( NUZLOCKE: new ChallengeAchv("NUZLOCKE", "", "NUZLOCKE.description", "leaf_stone", 100, isNuzlockeChallenge),
"NUZLOCKE",
"",
"NUZLOCKE.description",
"leaf_stone",
100,
c =>
c instanceof LimitedCatchChallenge &&
c.value > 0 &&
globalScene.gameMode.challenges.some(c => c.id === Challenges.HARDCORE && c.value > 0) &&
globalScene.gameMode.challenges.some(c => c.id === Challenges.FRESH_START && c.value > 0),
),
BREEDERS_IN_SPACE: new Achv("BREEDERS_IN_SPACE", "", "BREEDERS_IN_SPACE.description", "moon_stone", 50).setSecret(), BREEDERS_IN_SPACE: new Achv("BREEDERS_IN_SPACE", "", "BREEDERS_IN_SPACE.description", "moon_stone", 50).setSecret(),
}; };

View File

@ -48,6 +48,7 @@ import { EggData } from "#system/egg-data";
import { GameStats } from "#system/game-stats"; import { GameStats } from "#system/game-stats";
import { ModifierData as PersistentModifierData } from "#system/modifier-data"; import { ModifierData as PersistentModifierData } from "#system/modifier-data";
import { PokemonData } from "#system/pokemon-data"; import { PokemonData } from "#system/pokemon-data";
import { RibbonData } from "#system/ribbons/ribbon-data";
import { resetSettings, SettingKeys, setSetting } from "#system/settings"; import { resetSettings, SettingKeys, setSetting } from "#system/settings";
import { SettingGamepad, setSettingGamepad, settingGamepadDefaults } from "#system/settings-gamepad"; import { SettingGamepad, setSettingGamepad, settingGamepadDefaults } from "#system/settings-gamepad";
import type { SettingKeyboard } from "#system/settings-keyboard"; import type { SettingKeyboard } from "#system/settings-keyboard";
@ -402,7 +403,7 @@ export class GameData {
} }
public initSystem(systemDataStr: string, cachedSystemDataStr?: string): Promise<boolean> { public initSystem(systemDataStr: string, cachedSystemDataStr?: string): Promise<boolean> {
return new Promise<boolean>(resolve => { const { promise, resolve } = Promise.withResolvers<boolean>();
try { try {
let systemData = this.parseSystemData(systemDataStr); let systemData = this.parseSystemData(systemDataStr);
@ -516,7 +517,7 @@ export class GameData {
console.error(err); console.error(err);
resolve(false); resolve(false);
} }
}); return promise;
} }
/** /**
@ -627,6 +628,9 @@ export class GameData {
} }
return ret; return ret;
} }
if (k === "ribbons") {
return RibbonData.fromJSON(v);
}
return k.endsWith("Attr") && !["natureAttr", "abilityAttr", "passiveAttr"].includes(k) ? BigInt(v ?? 0) : v; return k.endsWith("Attr") && !["natureAttr", "abilityAttr", "passiveAttr"].includes(k) ? BigInt(v ?? 0) : v;
}) as SystemSaveData; }) as SystemSaveData;
@ -1634,6 +1638,7 @@ export class GameData {
caughtCount: 0, caughtCount: 0,
hatchedCount: 0, hatchedCount: 0,
ivs: [0, 0, 0, 0, 0, 0], ivs: [0, 0, 0, 0, 0, 0],
ribbons: new RibbonData(0),
}; };
} }
@ -1878,6 +1883,12 @@ export class GameData {
}); });
} }
/**
* Increase the number of classic ribbons won with this species.
* @param species - The species to increment the ribbon count for
* @param forStarter - If true, will increment the ribbon count for the root species of the given species
* @returns The number of classic wins after incrementing.
*/
incrementRibbonCount(species: PokemonSpecies, forStarter = false): number { incrementRibbonCount(species: PokemonSpecies, forStarter = false): number {
const speciesIdToIncrement: SpeciesId = species.getRootSpeciesId(forStarter); const speciesIdToIncrement: SpeciesId = species.getRootSpeciesId(forStarter);
@ -2177,6 +2188,9 @@ export class GameData {
if (!entry.hasOwnProperty("natureAttr") || (entry.caughtAttr && !entry.natureAttr)) { if (!entry.hasOwnProperty("natureAttr") || (entry.caughtAttr && !entry.natureAttr)) {
entry.natureAttr = this.defaultDexData?.[k].natureAttr || 1 << randInt(25, 1); entry.natureAttr = this.defaultDexData?.[k].natureAttr || 1 << randInt(25, 1);
} }
if (!entry.hasOwnProperty("ribbons")) {
entry.ribbons = new RibbonData(0);
}
} }
} }

View File

@ -0,0 +1,148 @@
import type { Brander } from "#types/type-helpers";
export type RibbonFlag = (number & Brander<"RibbonFlag">) | 0;
/**
* Class for ribbon data management. Usually constructed via the {@linkcode fromJSON} method.
*
* @remarks
* Stores information about the ribbons earned by a species using a bitfield.
*/
export class RibbonData {
/** Internal bitfield storing the unlock state for each ribbon */
private payload: number;
//#region Ribbons
//#region Monotype challenge ribbons
/** Ribbon for winning the normal monotype challenge */
public static readonly MONO_NORMAL = 0x1 as RibbonFlag;
/** Ribbon for winning the fighting monotype challenge */
public static readonly MONO_FIGHTING = 0x2 as RibbonFlag;
/** Ribbon for winning the flying monotype challenge */
public static readonly MONO_FLYING = 0x4 as RibbonFlag;
/** Ribbon for winning the poision monotype challenge */
public static readonly MONO_POISON = 0x8 as RibbonFlag;
/** Ribbon for winning the ground monotype challenge */
public static readonly MONO_GROUND = 0x10 as RibbonFlag;
/** Ribbon for winning the rock monotype challenge */
public static readonly MONO_ROCK = 0x20 as RibbonFlag;
/** Ribbon for winning the bug monotype challenge */
public static readonly MONO_BUG = 0x40 as RibbonFlag;
/** Ribbon for winning the ghost monotype challenge */
public static readonly MONO_GHOST = 0x80 as RibbonFlag;
/** Ribbon for winning the steel monotype challenge */
public static readonly MONO_STEEL = 0x100 as RibbonFlag;
/** Ribbon for winning the fire monotype challenge */
public static readonly MONO_FIRE = 0x200 as RibbonFlag;
/** Ribbon for winning the water monotype challenge */
public static readonly MONO_WATER = 0x400 as RibbonFlag;
/** Ribbon for winning the grass monotype challenge */
public static readonly MONO_GRASS = 0x800 as RibbonFlag;
/** Ribbon for winning the electric monotype challenge */
public static readonly MONO_ELECTRIC = 0x1000 as RibbonFlag;
/** Ribbon for winning the psychic monotype challenge */
public static readonly MONO_PSYCHIC = 0x2000 as RibbonFlag;
/** Ribbon for winning the ice monotype challenge */
public static readonly MONO_ICE = 0x4000 as RibbonFlag;
/** Ribbon for winning the dragon monotype challenge */
public static readonly MONO_DRAGON = 0x8000 as RibbonFlag;
/** Ribbon for winning the dark monotype challenge */
public static readonly MONO_DARK = 0x10000 as RibbonFlag;
/** Ribbon for winning the fairy monotype challenge */
public static readonly MONO_FAIRY = 0x20000 as RibbonFlag;
//#endregion Monotype ribbons
//#region Monogen ribbons
/** Ribbon for winning the the mono gen 1 challenge */
public static readonly MONO_GEN_1 = 0x40000 as RibbonFlag;
/** Ribbon for winning the the mono gen 2 challenge */
public static readonly MONO_GEN_2 = 0x80000 as RibbonFlag;
/** Ribbon for winning the mono gen 3 challenge */
public static readonly MONO_GEN_3 = 0x100000 as RibbonFlag;
/** Ribbon for winning the mono gen 4 challenge */
public static readonly MONO_GEN_4 = 0x200000 as RibbonFlag;
/** Ribbon for winning the mono gen 5 challenge */
public static readonly MONO_GEN_5 = 0x400000 as RibbonFlag;
/** Ribbon for winning the mono gen 6 challenge */
public static readonly MONO_GEN_6 = 0x800000 as RibbonFlag;
/** Ribbon for winning the mono gen 7 challenge */
public static readonly MONO_GEN_7 = 0x1000000 as RibbonFlag;
/** Ribbon for winning the mono gen 8 challenge */
public static readonly MONO_GEN_8 = 0x2000000 as RibbonFlag;
/** Ribbon for winning the mono gen 9 challenge */
public static readonly MONO_GEN_9 = 0x4000000 as RibbonFlag;
//#endregion Monogen ribbons
/** Ribbon for winning classic */
public static readonly CLASSIC = 0x8000000 as RibbonFlag;
/** Ribbon for winning the nuzzlocke challenge */
public static readonly NUZLOCKE = 0x10000000 as RibbonFlag;
/** Ribbon for reaching max friendship */
public static readonly FRIENDSHIP = 0x20000000 as RibbonFlag;
/** Ribbon for winning the flip stats challenge */
public static readonly FLIP_STATS = 0x40000000 as RibbonFlag;
/** Ribbon for winning the inverse challenge */
public static readonly INVERSE = 0x80000000 as RibbonFlag;
/** Ribbon for winning the fresh start challenge */
public static readonly FRESH_START = 0x100000000 as RibbonFlag;
/** Ribbon for winning the hardcore challenge */
public static readonly HARDCORE = 0x200000000 as RibbonFlag;
/** Ribbon for winning the limited catch challenge */
public static readonly LIMITED_CATCH = 0x400000000 as RibbonFlag;
/** Ribbon for winning the limited support challenge set to no heal */
public static readonly NO_HEAL = 0x800000000 as RibbonFlag;
/** Ribbon for winning the limited uspport challenge set to no shop */
public static readonly NO_SHOP = 0x1000000000 as RibbonFlag;
/** Ribbon for winning the limited support challenge set to both*/
public static readonly NO_SUPPORT = 0x2000000000 as RibbonFlag;
// NOTE: max possible ribbon flag is 0x20000000000000 (53 total ribbons)
// Once this is exceeded, bitfield needs to be changed to a bigint or even a uint array
// Note that this has no impact on serialization as it is stored in hex.
//#endregion Ribbons
/** Create a new instance of RibbonData. Generally, {@linkcode fromJSON} is used instead. */
constructor(value: number) {
this.payload = value;
}
/** Serialize the bitfield payload as a hex encoded string */
public toJSON(): string {
return this.payload.toString(16);
}
/**
* Decode a hexadecimal string representation of the bitfield into a `RibbonData` instance
*
* @param value - Hexadecimal string representation of the bitfield (without the leading 0x)
* @returns A new instance of `RibbonData` initialized with the provided bitfield.
*/
public static fromJSON(value: string): RibbonData {
try {
return new RibbonData(Number.parseInt(value, 16));
} catch {
return new RibbonData(0);
}
}
/**
* Award one or more ribbons to the ribbon data by setting the corresponding flags in the bitfield.
*
* @param flags - The flags to set. Can be a single flag or multiple flags.
*/
public award(...flags: [RibbonFlag, ...RibbonFlag[]]): void {
for (const f of flags) {
this.payload |= f;
}
}
/**
* Check if a specific ribbon has been awarded
* @param flag - The ribbon to check
* @returns Whether the specified flag has been awarded
*/
public has(flag: RibbonFlag): boolean {
return !!(this.payload & flag);
}
}

View File

@ -0,0 +1,20 @@
import { globalScene } from "#app/global-scene";
import { pokemonPrevolutions } from "#balance/pokemon-evolutions";
import type { SpeciesId } from "#enums/species-id";
import type { RibbonFlag } from "#system/ribbons/ribbon-data";
import { isNullOrUndefined } from "#utils/common";
/**
* Award one or more ribbons to a species and its pre-evolutions
*
* @param id - The ID of the species to award ribbons to
* @param ribbons - The ribbon(s) to award (use bitwise OR to combine multiple)
*/
export function awardRibbonsToSpeciesLine(id: SpeciesId, ribbons: RibbonFlag): void {
const dexData = globalScene.gameData.dexData;
dexData[id].ribbons.award(ribbons);
// Mark all pre-evolutions of the Pokémon with the same ribbon flags.
for (let prevoId = pokemonPrevolutions[id]; !isNullOrUndefined(prevoId); prevoId = pokemonPrevolutions[prevoId]) {
dexData[id].ribbons.award(ribbons);
}
}

View File

@ -31,6 +31,11 @@ import { toTitleCase } from "#utils/strings";
import i18next from "i18next"; import i18next from "i18next";
import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext";
const DISCARD_BUTTON_X = 60;
const DISCARD_BUTTON_X_DOUBLES = 64;
const DISCARD_BUTTON_Y = -73;
const DISCARD_BUTTON_Y_DOUBLES = -58;
const defaultMessage = i18next.t("partyUiHandler:choosePokemon"); const defaultMessage = i18next.t("partyUiHandler:choosePokemon");
/** /**
@ -301,7 +306,7 @@ export class PartyUiHandler extends MessageUiHandler {
const partyMessageText = addTextObject(10, 8, defaultMessage, TextStyle.WINDOW, { maxLines: 2 }); const partyMessageText = addTextObject(10, 8, defaultMessage, TextStyle.WINDOW, { maxLines: 2 });
partyMessageText.setName("text-party-msg"); partyMessageText.setName("text-party-msg");
partyMessageText.setOrigin(0, 0); partyMessageText.setOrigin(0);
partyMessageBoxContainer.add(partyMessageText); partyMessageBoxContainer.add(partyMessageText);
this.message = partyMessageText; this.message = partyMessageText;
@ -317,10 +322,8 @@ export class PartyUiHandler extends MessageUiHandler {
this.iconAnimHandler = new PokemonIconAnimHandler(); this.iconAnimHandler = new PokemonIconAnimHandler();
this.iconAnimHandler.setup(); this.iconAnimHandler.setup();
const partyDiscardModeButton = new PartyDiscardModeButton(60, -globalScene.game.canvas.height / 15 - 1, this); const partyDiscardModeButton = new PartyDiscardModeButton(DISCARD_BUTTON_X, DISCARD_BUTTON_Y, this);
partyContainer.add(partyDiscardModeButton); partyContainer.add(partyDiscardModeButton);
this.partyDiscardModeButton = partyDiscardModeButton; this.partyDiscardModeButton = partyDiscardModeButton;
// prepare move overlay // prepare move overlay
@ -1233,7 +1236,7 @@ export class PartyUiHandler extends MessageUiHandler {
} }
if (!this.optionsCursorObj) { if (!this.optionsCursorObj) {
this.optionsCursorObj = globalScene.add.image(0, 0, "cursor"); this.optionsCursorObj = globalScene.add.image(0, 0, "cursor");
this.optionsCursorObj.setOrigin(0, 0); this.optionsCursorObj.setOrigin(0);
this.optionsContainer.add(this.optionsCursorObj); this.optionsContainer.add(this.optionsCursorObj);
} }
this.optionsCursorObj.setPosition( this.optionsCursorObj.setPosition(
@ -1605,7 +1608,7 @@ export class PartyUiHandler extends MessageUiHandler {
optionText.setColor("#40c8f8"); optionText.setColor("#40c8f8");
optionText.setShadowColor("#006090"); optionText.setShadowColor("#006090");
} }
optionText.setOrigin(0, 0); optionText.setOrigin(0);
/** For every item that has stack bigger than 1, display the current quantity selection */ /** For every item that has stack bigger than 1, display the current quantity selection */
const itemModifiers = this.getItemModifiers(pokemon); const itemModifiers = this.getItemModifiers(pokemon);
@ -1802,6 +1805,7 @@ class PartySlot extends Phaser.GameObjects.Container {
private selected: boolean; private selected: boolean;
private transfer: boolean; private transfer: boolean;
private slotIndex: number; private slotIndex: number;
private isBenched: boolean;
private pokemon: PlayerPokemon; private pokemon: PlayerPokemon;
private slotBg: Phaser.GameObjects.Image; private slotBg: Phaser.GameObjects.Image;
@ -1812,6 +1816,7 @@ class PartySlot extends Phaser.GameObjects.Container {
public slotHpText: Phaser.GameObjects.Text; public slotHpText: Phaser.GameObjects.Text;
public slotDescriptionLabel: Phaser.GameObjects.Text; // this is used to show text instead of the HP bar i.e. for showing "Able"/"Not Able" for TMs when you try to learn them public slotDescriptionLabel: Phaser.GameObjects.Text; // this is used to show text instead of the HP bar i.e. for showing "Able"/"Not Able" for TMs when you try to learn them
private slotBgKey: string;
private pokemonIcon: Phaser.GameObjects.Container; private pokemonIcon: Phaser.GameObjects.Container;
private iconAnimHandler: PokemonIconAnimHandler; private iconAnimHandler: PokemonIconAnimHandler;
@ -1822,19 +1827,34 @@ class PartySlot extends Phaser.GameObjects.Container {
partyUiMode: PartyUiMode, partyUiMode: PartyUiMode,
tmMoveId: MoveId, tmMoveId: MoveId,
) { ) {
super( const isBenched = slotIndex >= globalScene.currentBattle.getBattlerCount();
globalScene, const isDoubleBattle = globalScene.currentBattle.double;
slotIndex >= globalScene.currentBattle.getBattlerCount() ? 230.5 : 64, const isItemManageMode = partyUiMode === PartyUiMode.MODIFIER_TRANSFER || partyUiMode === PartyUiMode.DISCARD;
slotIndex >= globalScene.currentBattle.getBattlerCount()
? -184 + /*
(globalScene.currentBattle.double ? -40 : 0) + * Here we determine the position of the slot.
(28 + (globalScene.currentBattle.double ? 8 : 0)) * slotIndex * The x coordinate depends on whether the pokemon is on the field or in the bench.
: partyUiMode === PartyUiMode.MODIFIER_TRANSFER * The y coordinate depends on various factors, such as the number of pokémon on the field,
? -124 + (globalScene.currentBattle.double ? -20 : 0) + slotIndex * 55 * and whether the transfer/discard button is also on the screen.
: -124 + (globalScene.currentBattle.double ? -8 : 0) + slotIndex * 64, */
); const slotPositionX = isBenched ? 143 : 9;
let slotPositionY: number;
if (isBenched) {
slotPositionY = -196 + (isDoubleBattle ? -40 : 0);
slotPositionY += (28 + (isDoubleBattle ? 8 : 0)) * slotIndex;
} else {
slotPositionY = -148.5;
if (isDoubleBattle) {
slotPositionY += isItemManageMode ? -20 : -8;
}
slotPositionY += (isItemManageMode ? (isDoubleBattle ? 47 : 55) : 64) * slotIndex;
}
super(globalScene, slotPositionX, slotPositionY);
this.slotIndex = slotIndex; this.slotIndex = slotIndex;
this.isBenched = isBenched;
this.pokemon = pokemon; this.pokemon = pokemon;
this.iconAnimHandler = iconAnimHandler; this.iconAnimHandler = iconAnimHandler;
@ -1848,27 +1868,75 @@ class PartySlot extends Phaser.GameObjects.Container {
setup(partyUiMode: PartyUiMode, tmMoveId: MoveId) { setup(partyUiMode: PartyUiMode, tmMoveId: MoveId) {
const currentLanguage = i18next.resolvedLanguage ?? "en"; const currentLanguage = i18next.resolvedLanguage ?? "en";
const offsetJa = currentLanguage === "ja"; const offsetJa = currentLanguage === "ja";
const isItemManageMode = partyUiMode === PartyUiMode.MODIFIER_TRANSFER || partyUiMode === PartyUiMode.DISCARD;
const battlerCount = globalScene.currentBattle.getBattlerCount(); this.slotBgKey = this.isBenched
? "party_slot"
: isItemManageMode && globalScene.currentBattle.double
? "party_slot_main_short"
: "party_slot_main";
const fullSlotBgKey = this.pokemon.hp ? this.slotBgKey : `${this.slotBgKey}${"_fnt"}`;
this.slotBg = globalScene.add.sprite(0, 0, this.slotBgKey, fullSlotBgKey);
this.slotBg.setOrigin(0);
this.add(this.slotBg);
const slotKey = `party_slot${this.slotIndex >= battlerCount ? "" : "_main"}`; const genderSymbol = getGenderSymbol(this.pokemon.getGender(true));
const isFusion = this.pokemon.isFusion();
const slotBg = globalScene.add.sprite(0, 0, slotKey, `${slotKey}${this.pokemon.hp ? "" : "_fnt"}`); // Here we define positions and offsets
this.slotBg = slotBg; // Base values are for the active pokemon; they are changed for benched pokemon,
// or for active pokemon if in a double battle in item management mode.
this.add(slotBg); // icon position relative to slot background
let slotPb = { x: 4, y: 4 };
// name position relative to slot background
let namePosition = { x: 24, y: 10 + (offsetJa ? 2 : 0) };
// maximum allowed length of name; must accomodate fusion symbol
let maxNameTextWidth = 76 - (isFusion ? 8 : 0);
// "Lv." label position relative to slot background
let levelLabelPosition = { x: 24 + 8, y: 10 + 12 };
// offset from "Lv." to the level number; should not be changed.
const levelTextToLevelLabelOffset = { x: 9, y: offsetJa ? 1.5 : 0 };
// offests from "Lv." to gender, spliced and status icons, these depend on the type of slot.
let genderTextToLevelLabelOffset = { x: 68 - (isFusion ? 8 : 0), y: -9 };
let splicedIconToLevelLabelOffset = { x: 68, y: 3.5 - 12 };
let statusIconToLevelLabelOffset = { x: 55, y: 0 };
// offset from the name to the shiny icon (on the left); should not be changed.
const shinyIconToNameOffset = { x: -9, y: 3 };
// hp bar position relative to slot background
let hpBarPosition = { x: 8, y: 31 };
// offsets of hp bar overlay (showing the remaining hp) and number; should not be changed.
const hpOverlayToBarOffset = { x: 16, y: 2 };
const hpTextToBarOffset = { x: -3, y: -2 + (offsetJa ? 2 : 0) };
// description position relative to slot background
let descriptionLabelPosition = { x: 32, y: 46 };
const slotPb = globalScene.add.sprite( // If in item management mode, the active slots are shorter
this.slotIndex >= battlerCount ? -85.5 : -51, if (isItemManageMode && globalScene.currentBattle.double && !this.isBenched) {
this.slotIndex >= battlerCount ? 0 : -20.5, namePosition.y -= 8;
"party_pb", levelLabelPosition.y -= 8;
); hpBarPosition.y -= 8;
this.slotPb = slotPb; descriptionLabelPosition.y -= 8;
}
this.add(slotPb); // Benched slots have significantly different parameters
if (this.isBenched) {
slotPb = { x: 2, y: 12 };
namePosition = { x: 21, y: 2 + (offsetJa ? 2 : 0) };
maxNameTextWidth = 52;
levelLabelPosition = { x: 21 + 8, y: 2 + 12 };
genderTextToLevelLabelOffset = { x: 36, y: 0 };
splicedIconToLevelLabelOffset = { x: 36 + (genderSymbol ? 8 : 0), y: 0.5 };
statusIconToLevelLabelOffset = { x: 43, y: 0 };
hpBarPosition = { x: 72, y: 6 };
descriptionLabelPosition = { x: 94, y: 16 };
}
this.pokemonIcon = globalScene.addPokemonIcon(this.pokemon, slotPb.x, slotPb.y, 0.5, 0.5, true); this.slotPb = globalScene.add.sprite(0, 0, "party_pb");
this.slotPb.setPosition(slotPb.x, slotPb.y);
this.add(this.slotPb);
this.pokemonIcon = globalScene.addPokemonIcon(this.pokemon, this.slotPb.x, this.slotPb.y, 0.5, 0.5, true);
this.add(this.pokemonIcon); this.add(this.pokemonIcon);
this.iconAnimHandler.addOrUpdate(this.pokemonIcon, PokemonIconAnimMode.PASSIVE); this.iconAnimHandler.addOrUpdate(this.pokemonIcon, PokemonIconAnimMode.PASSIVE);
@ -1882,7 +1950,7 @@ class PartySlot extends Phaser.GameObjects.Container {
const nameSizeTest = addTextObject(0, 0, displayName, TextStyle.PARTY); const nameSizeTest = addTextObject(0, 0, displayName, TextStyle.PARTY);
nameTextWidth = nameSizeTest.displayWidth; nameTextWidth = nameSizeTest.displayWidth;
while (nameTextWidth > (this.slotIndex >= battlerCount ? 52 : 76 - (this.pokemon.fusionSpecies ? 8 : 0))) { while (nameTextWidth > maxNameTextWidth) {
displayName = `${displayName.slice(0, displayName.endsWith(".") ? -2 : -1).trimEnd()}.`; displayName = `${displayName.slice(0, displayName.endsWith(".") ? -2 : -1).trimEnd()}.`;
nameSizeTest.setText(displayName); nameSizeTest.setText(displayName);
nameTextWidth = nameSizeTest.displayWidth; nameTextWidth = nameSizeTest.displayWidth;
@ -1891,78 +1959,59 @@ class PartySlot extends Phaser.GameObjects.Container {
nameSizeTest.destroy(); nameSizeTest.destroy();
this.slotName = addTextObject(0, 0, displayName, TextStyle.PARTY); this.slotName = addTextObject(0, 0, displayName, TextStyle.PARTY);
this.slotName.setPositionRelative( this.slotName.setPositionRelative(this.slotBg, namePosition.x, namePosition.y);
slotBg, this.slotName.setOrigin(0);
this.slotIndex >= battlerCount ? 21 : 24,
(this.slotIndex >= battlerCount ? 2 : 10) + (offsetJa ? 2 : 0),
);
this.slotName.setOrigin(0, 0);
const slotLevelLabel = globalScene.add.image(0, 0, "party_slot_overlay_lv"); const slotLevelLabel = globalScene.add
slotLevelLabel.setPositionRelative( .image(0, 0, "party_slot_overlay_lv")
slotBg, .setPositionRelative(this.slotBg, levelLabelPosition.x, levelLabelPosition.y)
(this.slotIndex >= battlerCount ? 21 : 24) + 8, .setOrigin(0);
(this.slotIndex >= battlerCount ? 2 : 10) + 12,
);
slotLevelLabel.setOrigin(0, 0);
const slotLevelText = addTextObject( const slotLevelText = addTextObject(
0, 0,
0, 0,
this.pokemon.level.toString(), this.pokemon.level.toString(),
this.pokemon.level < globalScene.getMaxExpLevel() ? TextStyle.PARTY : TextStyle.PARTY_RED, this.pokemon.level < globalScene.getMaxExpLevel() ? TextStyle.PARTY : TextStyle.PARTY_RED,
); )
slotLevelText.setPositionRelative(slotLevelLabel, 9, offsetJa ? 1.5 : 0); .setPositionRelative(slotLevelLabel, levelTextToLevelLabelOffset.x, levelTextToLevelLabelOffset.y)
slotLevelText.setOrigin(0, 0.25); .setOrigin(0, 0.25);
slotInfoContainer.add([this.slotName, slotLevelLabel, slotLevelText]); slotInfoContainer.add([this.slotName, slotLevelLabel, slotLevelText]);
const genderSymbol = getGenderSymbol(this.pokemon.getGender(true));
if (genderSymbol) { if (genderSymbol) {
const slotGenderText = addTextObject(0, 0, genderSymbol, TextStyle.PARTY); const slotGenderText = addTextObject(0, 0, genderSymbol, TextStyle.PARTY)
slotGenderText.setColor(getGenderColor(this.pokemon.getGender(true))); .setColor(getGenderColor(this.pokemon.getGender(true)))
slotGenderText.setShadowColor(getGenderColor(this.pokemon.getGender(true), true)); .setShadowColor(getGenderColor(this.pokemon.getGender(true), true))
if (this.slotIndex >= battlerCount) { .setPositionRelative(slotLevelLabel, genderTextToLevelLabelOffset.x, genderTextToLevelLabelOffset.y)
slotGenderText.setPositionRelative(slotLevelLabel, 36, 0); .setOrigin(0, 0.25);
} else {
slotGenderText.setPositionRelative(this.slotName, 76 - (this.pokemon.fusionSpecies ? 8 : 0), 3);
}
slotGenderText.setOrigin(0, 0.25);
slotInfoContainer.add(slotGenderText); slotInfoContainer.add(slotGenderText);
} }
if (this.pokemon.fusionSpecies) { if (isFusion) {
const splicedIcon = globalScene.add.image(0, 0, "icon_spliced"); const splicedIcon = globalScene.add
splicedIcon.setScale(0.5); .image(0, 0, "icon_spliced")
splicedIcon.setOrigin(0, 0); .setScale(0.5)
if (this.slotIndex >= battlerCount) { .setOrigin(0)
splicedIcon.setPositionRelative(slotLevelLabel, 36 + (genderSymbol ? 8 : 0), 0.5); .setPositionRelative(slotLevelLabel, splicedIconToLevelLabelOffset.x, splicedIconToLevelLabelOffset.y);
} else {
splicedIcon.setPositionRelative(this.slotName, 76, 3.5);
}
slotInfoContainer.add(splicedIcon); slotInfoContainer.add(splicedIcon);
} }
if (this.pokemon.status) { if (this.pokemon.status) {
const statusIndicator = globalScene.add.sprite(0, 0, getLocalizedSpriteKey("statuses")); const statusIndicator = globalScene.add
statusIndicator.setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase()); .sprite(0, 0, getLocalizedSpriteKey("statuses"))
statusIndicator.setOrigin(0, 0); .setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase())
statusIndicator.setPositionRelative(slotLevelLabel, this.slotIndex >= battlerCount ? 43 : 55, 0); .setOrigin(0)
.setPositionRelative(slotLevelLabel, statusIconToLevelLabelOffset.x, statusIconToLevelLabelOffset.y);
slotInfoContainer.add(statusIndicator); slotInfoContainer.add(statusIndicator);
} }
if (this.pokemon.isShiny()) { if (this.pokemon.isShiny()) {
const doubleShiny = this.pokemon.isDoubleShiny(false); const doubleShiny = this.pokemon.isDoubleShiny(false);
const shinyStar = globalScene.add.image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`); const shinyStar = globalScene.add
shinyStar.setOrigin(0, 0); .image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`)
shinyStar.setPositionRelative(this.slotName, -9, 3); .setOrigin(0)
shinyStar.setTint(getVariantTint(this.pokemon.getBaseVariant())); .setPositionRelative(this.slotName, shinyIconToNameOffset.x, shinyIconToNameOffset.y)
.setTint(getVariantTint(this.pokemon.getBaseVariant()));
slotInfoContainer.add(shinyStar); slotInfoContainer.add(shinyStar);
if (doubleShiny) { if (doubleShiny) {
@ -1971,50 +2020,38 @@ class PartySlot extends Phaser.GameObjects.Container {
.setOrigin(0) .setOrigin(0)
.setPosition(shinyStar.x, shinyStar.y) .setPosition(shinyStar.x, shinyStar.y)
.setTint(getVariantTint(this.pokemon.fusionVariant)); .setTint(getVariantTint(this.pokemon.fusionVariant));
slotInfoContainer.add(fusionShinyStar); slotInfoContainer.add(fusionShinyStar);
} }
} }
this.slotHpBar = globalScene.add.image(0, 0, "party_slot_hp_bar"); this.slotHpBar = globalScene.add
this.slotHpBar.setPositionRelative( .image(0, 0, "party_slot_hp_bar")
slotBg, .setOrigin(0)
this.slotIndex >= battlerCount ? 72 : 8, .setVisible(false)
this.slotIndex >= battlerCount ? 6 : 31, .setPositionRelative(this.slotBg, hpBarPosition.x, hpBarPosition.y);
);
this.slotHpBar.setOrigin(0, 0);
this.slotHpBar.setVisible(false);
const hpRatio = this.pokemon.getHpRatio(); const hpRatio = this.pokemon.getHpRatio();
this.slotHpOverlay = globalScene.add.sprite( this.slotHpOverlay = globalScene.add
0, .sprite(0, 0, "party_slot_hp_overlay", hpRatio > 0.5 ? "high" : hpRatio > 0.25 ? "medium" : "low")
0, .setOrigin(0)
"party_slot_hp_overlay", .setPositionRelative(this.slotHpBar, hpOverlayToBarOffset.x, hpOverlayToBarOffset.y)
hpRatio > 0.5 ? "high" : hpRatio > 0.25 ? "medium" : "low", .setScale(hpRatio, 1)
); .setVisible(false);
this.slotHpOverlay.setPositionRelative(this.slotHpBar, 16, 2);
this.slotHpOverlay.setOrigin(0, 0);
this.slotHpOverlay.setScale(hpRatio, 1);
this.slotHpOverlay.setVisible(false);
this.slotHpText = addTextObject(0, 0, `${this.pokemon.hp}/${this.pokemon.getMaxHp()}`, TextStyle.PARTY); this.slotHpText = addTextObject(0, 0, `${this.pokemon.hp}/${this.pokemon.getMaxHp()}`, TextStyle.PARTY)
this.slotHpText.setPositionRelative( .setOrigin(1, 0)
.setPositionRelative(
this.slotHpBar, this.slotHpBar,
this.slotHpBar.width - 3, this.slotHpBar.width + hpTextToBarOffset.x,
this.slotHpBar.height - 2 + (offsetJa ? 2 : 0), this.slotHpBar.height + hpTextToBarOffset.y,
); ) // TODO: annoying because it contains the width
this.slotHpText.setOrigin(1, 0); .setVisible(false);
this.slotHpText.setVisible(false);
this.slotDescriptionLabel = addTextObject(0, 0, "", TextStyle.MESSAGE); this.slotDescriptionLabel = addTextObject(0, 0, "", TextStyle.MESSAGE)
this.slotDescriptionLabel.setPositionRelative( .setOrigin(0, 1)
slotBg, .setVisible(false)
this.slotIndex >= battlerCount ? 94 : 32, .setPositionRelative(this.slotBg, descriptionLabelPosition.x, descriptionLabelPosition.y);
this.slotIndex >= battlerCount ? 16 : 46,
);
this.slotDescriptionLabel.setOrigin(0, 1);
this.slotDescriptionLabel.setVisible(false);
slotInfoContainer.add([this.slotHpBar, this.slotHpOverlay, this.slotHpText, this.slotDescriptionLabel]); slotInfoContainer.add([this.slotHpBar, this.slotHpOverlay, this.slotHpText, this.slotDescriptionLabel]);
@ -2076,10 +2113,9 @@ class PartySlot extends Phaser.GameObjects.Container {
} }
private updateSlotTexture(): void { private updateSlotTexture(): void {
const battlerCount = globalScene.currentBattle.getBattlerCount();
this.slotBg.setTexture( this.slotBg.setTexture(
`party_slot${this.slotIndex >= battlerCount ? "" : "_main"}`, this.slotBgKey,
`party_slot${this.slotIndex >= battlerCount ? "" : "_main"}${this.transfer ? "_swap" : this.pokemon.hp ? "" : "_fnt"}${this.selected ? "_sel" : ""}`, `${this.slotBgKey}${this.transfer ? "_swap" : this.pokemon.hp ? "" : "_fnt"}${this.selected ? "_sel" : ""}`,
); );
} }
} }
@ -2198,10 +2234,6 @@ class PartyDiscardModeButton extends Phaser.GameObjects.Container {
this.discardIcon.setVisible(false); this.discardIcon.setVisible(false);
this.textBox.setVisible(true); this.textBox.setVisible(true);
this.textBox.setText(i18next.t("partyUiHandler:TRANSFER")); this.textBox.setText(i18next.t("partyUiHandler:TRANSFER"));
this.setPosition(
globalScene.currentBattle.double ? 64 : 60,
globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1,
);
this.transferIcon.displayWidth = this.textBox.text.length * 9 + 3; this.transferIcon.displayWidth = this.textBox.text.length * 9 + 3;
break; break;
case PartyUiMode.DISCARD: case PartyUiMode.DISCARD:
@ -2209,13 +2241,13 @@ class PartyDiscardModeButton extends Phaser.GameObjects.Container {
this.discardIcon.setVisible(true); this.discardIcon.setVisible(true);
this.textBox.setVisible(true); this.textBox.setVisible(true);
this.textBox.setText(i18next.t("partyUiHandler:DISCARD")); this.textBox.setText(i18next.t("partyUiHandler:DISCARD"));
this.setPosition(
globalScene.currentBattle.double ? 64 : 60,
globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1,
);
this.discardIcon.displayWidth = this.textBox.text.length * 9 + 3; this.discardIcon.displayWidth = this.textBox.text.length * 9 + 3;
break; break;
} }
this.setPosition(
globalScene.currentBattle.double ? DISCARD_BUTTON_X_DOUBLES : DISCARD_BUTTON_X,
globalScene.currentBattle.double ? DISCARD_BUTTON_Y_DOUBLES : DISCARD_BUTTON_Y,
);
} }
clear() { clear() {

View File

@ -45,6 +45,7 @@ import type { Variant } from "#sprites/variant";
import { getVariantIcon, getVariantTint } from "#sprites/variant"; import { getVariantIcon, getVariantTint } from "#sprites/variant";
import { achvs } from "#system/achv"; import { achvs } from "#system/achv";
import type { DexAttrProps, StarterAttributes, StarterMoveset } from "#system/game-data"; import type { DexAttrProps, StarterAttributes, StarterMoveset } from "#system/game-data";
import { RibbonData } from "#system/ribbons/ribbon-data";
import { SettingKeyboard } from "#system/settings-keyboard"; import { SettingKeyboard } from "#system/settings-keyboard";
import type { DexEntry } from "#types/dex-data"; import type { DexEntry } from "#types/dex-data";
import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler";
@ -3226,6 +3227,8 @@ export class StarterSelectUiHandler extends MessageUiHandler {
onScreenFirstIndex + maxRows * maxColumns - 1, onScreenFirstIndex + maxRows * maxColumns - 1,
); );
const gameData = globalScene.gameData;
this.starterSelectScrollBar.setScrollCursor(this.scrollCursor); this.starterSelectScrollBar.setScrollCursor(this.scrollCursor);
let pokerusCursorIndex = 0; let pokerusCursorIndex = 0;
@ -3265,9 +3268,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
container.label.setVisible(true); container.label.setVisible(true);
const speciesVariants = const speciesVariants =
speciesId && globalScene.gameData.dexData[speciesId].caughtAttr & DexAttr.SHINY speciesId && gameData.dexData[speciesId].caughtAttr & DexAttr.SHINY
? [DexAttr.DEFAULT_VARIANT, DexAttr.VARIANT_2, DexAttr.VARIANT_3].filter( ? [DexAttr.DEFAULT_VARIANT, DexAttr.VARIANT_2, DexAttr.VARIANT_3].filter(
v => !!(globalScene.gameData.dexData[speciesId].caughtAttr & v), v => !!(gameData.dexData[speciesId].caughtAttr & v),
) )
: []; : [];
for (let v = 0; v < 3; v++) { for (let v = 0; v < 3; v++) {
@ -3282,12 +3285,15 @@ export class StarterSelectUiHandler extends MessageUiHandler {
} }
} }
container.starterPassiveBgs.setVisible(!!globalScene.gameData.starterData[speciesId].passiveAttr); container.starterPassiveBgs.setVisible(!!gameData.starterData[speciesId].passiveAttr);
container.hiddenAbilityIcon.setVisible( container.hiddenAbilityIcon.setVisible(
!!globalScene.gameData.dexData[speciesId].caughtAttr && !!gameData.dexData[speciesId].caughtAttr && !!(gameData.starterData[speciesId].abilityAttr & 4),
!!(globalScene.gameData.starterData[speciesId].abilityAttr & 4), );
container.classicWinIcon
.setVisible(gameData.starterData[speciesId].classicWinCount > 0)
.setTexture(
gameData.dexData[speciesId].ribbons.has(RibbonData.NUZLOCKE) ? "champion_ribbon_emerald" : "champion_ribbon",
); );
container.classicWinIcon.setVisible(globalScene.gameData.starterData[speciesId].classicWinCount > 0);
container.favoriteIcon.setVisible(this.starterPreferences[speciesId]?.favorite ?? false); container.favoriteIcon.setVisible(this.starterPreferences[speciesId]?.favorite ?? false);
// 'Candy Icon' mode // 'Candy Icon' mode

View File

@ -4,6 +4,7 @@ import { pokemonEvolutions } from "#balance/pokemon-evolutions";
import { pokemonFormChanges } from "#data/pokemon-forms"; import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species"; import type { PokemonSpecies } from "#data/pokemon-species";
import { ChallengeType } from "#enums/challenge-type"; import { ChallengeType } from "#enums/challenge-type";
import { Challenges } from "#enums/challenges";
import type { MoveId } from "#enums/move-id"; import type { MoveId } from "#enums/move-id";
import type { MoveSourceType } from "#enums/move-source-type"; import type { MoveSourceType } from "#enums/move-source-type";
import type { SpeciesId } from "#enums/species-id"; import type { SpeciesId } from "#enums/species-id";
@ -378,7 +379,7 @@ export function checkStarterValidForChallenge(species: PokemonSpecies, props: De
* @param soft - If `true`, allow it if it could become valid through a form change. * @param soft - If `true`, allow it if it could become valid through a form change.
* @returns `true` if the species is considered valid. * @returns `true` if the species is considered valid.
*/ */
function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { export function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) {
const isValidForChallenge = new BooleanHolder(true); const isValidForChallenge = new BooleanHolder(true);
applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props); applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props);
if (!soft || !pokemonFormChanges.hasOwnProperty(species.speciesId)) { if (!soft || !pokemonFormChanges.hasOwnProperty(species.speciesId)) {
@ -407,3 +408,28 @@ function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrPr
}); });
return result; return result;
} }
/** @returns Whether the current game mode meets the criteria to be considered a Nuzlocke challenge */
export function isNuzlockeChallenge(): boolean {
let isFreshStart = false;
let isLimitedCatch = false;
let isHardcore = false;
for (const challenge of globalScene.gameMode.challenges) {
// value is 0 if challenge is not active
if (!challenge.value) {
continue;
}
switch (challenge.id) {
case Challenges.FRESH_START:
isFreshStart = true;
break;
case Challenges.LIMITED_CATCH:
isLimitedCatch = true;
break;
case Challenges.HARDCORE:
isHardcore = true;
break;
}
}
return isFreshStart && isLimitedCatch && isHardcore;
}

View File

@ -1,4 +1,5 @@
import type { TerrainType } from "#app/data/terrain"; import type { TerrainType } from "#app/data/terrain";
import type Overrides from "#app/overrides";
import type { AbilityId } from "#enums/ability-id"; import type { AbilityId } from "#enums/ability-id";
import type { BattlerTagType } from "#enums/battler-tag-type"; import type { BattlerTagType } from "#enums/battler-tag-type";
import type { MoveId } from "#enums/move-id"; 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 { StatusEffect } from "#enums/status-effect";
import type { WeatherType } from "#enums/weather-type"; import type { WeatherType } from "#enums/weather-type";
import type { Pokemon } from "#field/pokemon"; 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 { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect"; 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 { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
import type { TurnMove } from "#types/turn-move"; import type { TurnMove } from "#types/turn-move";
import type { AtLeastOne } from "#types/type-helpers"; import type { AtLeastOne } from "#types/type-helpers";
import type { toDmgValue } from "#utils/common";
import type { expect } from "vitest"; import type { expect } from "vitest";
import type Overrides from "#app/overrides";
import type { PokemonMove } from "#moves/pokemon-move";
declare module "vitest" { declare module "vitest" {
interface Assertion { interface Assertion {
@ -113,8 +114,9 @@ declare module "vitest" {
/** /**
* Check whether a {@linkcode Pokemon} has a specific amount of {@linkcode Stat.HP | HP}. * 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 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}). * Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}).
@ -130,7 +132,7 @@ declare module "vitest" {
* @param ppUsed - The numerical amount of PP that should have been consumed, * @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP * or `all` to indicate the move should be _out_ of PP
* @remarks * @remarks
* If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE}, * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE},
* does not contain {@linkcode expectedMove} * does not contain {@linkcode expectedMove}
* or contains the desired move more than once, this will fail the test. * or contains the desired move more than once, this will fail the test.
*/ */

View File

@ -2,7 +2,6 @@ import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { DamageAnimPhase } from "#phases/damage-anim-phase"; import { DamageAnimPhase } from "#phases/damage-anim-phase";
import { TurnEndPhase } from "#phases/turn-end-phase";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -54,7 +53,7 @@ describe("Items - Leftovers", () => {
const leadHpAfterDamage = leadPokemon.hp; const leadHpAfterDamage = leadPokemon.hp;
// Check if leftovers heal us // Check if leftovers heal us
await game.phaseInterceptor.to(TurnEndPhase); await game.toNextTurn();
expect(leadPokemon.hp).toBeGreaterThan(leadHpAfterDamage); expect(leadPokemon.hp).toBeGreaterThan(leadHpAfterDamage);
}); });
}); });

View File

@ -1,12 +1,15 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Pollen Puff", () => { describe("Move - Pollen Puff", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
let game: GameManager; let game: GameManager;
@ -23,42 +26,77 @@ describe("Moves - Pollen Puff", () => {
beforeEach(() => { beforeEach(() => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.moveset([MoveId.POLLEN_PUFF])
.ability(AbilityId.BALL_FETCH) .ability(AbilityId.BALL_FETCH)
.battleStyle("single") .battleStyle("single")
.criticalHits(false) .criticalHits(false)
.enemyLevel(100)
.enemySpecies(SpeciesId.MAGIKARP) .enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH) .enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH); .enemyMoveset(MoveId.SPLASH);
}); });
it("should not heal more than once when the user has a source of multi-hit", async () => { it("should damage an enemy when used, or heal an ally for 50% max HP", async () => {
game.override.battleStyle("double").moveset([MoveId.POLLEN_PUFF, MoveId.ENDURE]).ability(AbilityId.PARENTAL_BOND); game.override.battleStyle("double").ability(AbilityId.PARENTAL_BOND);
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.OMANYTE]); 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); expect(karp1).not.toHaveFullHp();
game.move.select(MoveId.ENDURE, 1); 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 const [bulbasaur, omantye] = game.scene.getPlayerField();
expect(rightPokemon.hp).toBeLessThanOrEqual(0.5 * rightPokemon.getMaxHp() + 1);
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 () => { 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]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.POLLEN_PUFF);
await game.toEndOfTurn();
const target = game.field.getEnemyPokemon(); const target = game.field.getEnemyPokemon();
game.move.select(MoveId.POLLEN_PUFF);
await game.phaseInterceptor.to("BerryPhase");
expect(target.battleData.hitCount).toBe(2); expect(target.battleData.hitCount).toBe(2);
}); });
}); });

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

View File

@ -1,13 +1,12 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id"; 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 { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; 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", () => { describe("Moves - Roost", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -27,219 +26,105 @@ describe("Moves - Roost", () => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.battleStyle("single") .battleStyle("single")
.enemySpecies(SpeciesId.RELICANTH) .enemySpecies(SpeciesId.SHUCKLE)
.ability(AbilityId.BALL_FETCH)
.startingLevel(100) .startingLevel(100)
.enemyLevel(100) .enemyLevel(100)
.enemyMoveset(MoveId.EARTHQUAKE) .enemyMoveset(MoveId.SPLASH);
.moveset([MoveId.ROOST, MoveId.BURN_UP, MoveId.DOUBLE_SHOCK]);
}); });
/** it("should remove the user's Flying type until end of turn", async () => {
* 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 () => {
await game.classicMode.startBattle([SpeciesId.HAWLUCHA]); 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 const hawlucha = game.field.getPlayerPokemon();
let playerPokemonTypes = playerPokemon.getTypes(); hawlucha.hp = 1;
expect(playerPokemonTypes[0] === PokemonType.FIGHTING).toBeTruthy();
expect(playerPokemonTypes.length === 1).toBeTruthy();
expect(playerPokemon.isGrounded()).toBeTruthy();
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 // Should lose flying type temporarily
playerPokemonTypes = playerPokemon.getTypes(); expect(hawlucha).toHaveBattlerTag(BattlerTagType.ROOSTED);
expect(playerPokemon.hp).toBeLessThan(playerPokemonStartingHP); expect(hawlucha).toHaveTypes([PokemonType.FIGHTING]);
expect(playerPokemonTypes[0] === PokemonType.FIGHTING).toBeTruthy(); expect(hawlucha.isGrounded()).toBe(true);
expect(playerPokemonTypes[1] === PokemonType.FLYING).toBeTruthy();
expect(playerPokemon.isGrounded()).toBeFalsy(); 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 () => { it("should preserve types of non-Flying type Pokemon", async () => {
game.override.starterForms({ [SpeciesId.ROTOM]: 4 }); await game.classicMode.startBattle([SpeciesId.MEW]);
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);
// Should only be pure eletric type and grounded const mew = game.field.getPlayerPokemon();
let playerPokemonTypes = playerPokemon.getTypes(); mew.hp = 1;
expect(playerPokemonTypes[0] === PokemonType.ELECTRIC).toBeTruthy();
expect(playerPokemonTypes.length === 1).toBeTruthy();
expect(playerPokemon.isGrounded()).toBeFalsy();
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 // Should remain psychic type
playerPokemonTypes = playerPokemon.getTypes(); expect(mew).toHaveTypes([PokemonType.PSYCHIC]);
expect(playerPokemon.hp).toBe(playerPokemonStartingHP); expect(mew.isGrounded()).toBe(true);
expect(playerPokemonTypes[0] === PokemonType.ELECTRIC).toBeTruthy();
expect(playerPokemonTypes[1] === PokemonType.FLYING).toBeTruthy();
expect(playerPokemon.isGrounded()).toBeFalsy();
}); });
test("A fire/flying type that uses burn up, then roost should be typeless until end of turn", async () => { it("should not remove the user's Tera Type", async () => {
await game.classicMode.startBattle([SpeciesId.MOLTRES]); await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
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);
// Should only be pure flying type after burn up const pidgeot = game.field.getPlayerPokemon();
let playerPokemonTypes = playerPokemon.getTypes(); pidgeot.hp = 1;
expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy(); pidgeot.teraType = PokemonType.FLYING;
expect(playerPokemonTypes.length === 1).toBeTruthy();
await game.phaseInterceptor.to(TurnEndPhase); game.move.use(MoveId.ROOST, BattlerIndex.PLAYER, undefined, true);
game.move.select(MoveId.ROOST); await game.toEndOfTurn(false);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to(MoveEffectPhase);
// Should only be typeless type after roost and is grounded // Should remain flying type
playerPokemonTypes = playerPokemon.getTypes(); expect(pidgeot).toHaveTypes([PokemonType.FLYING], { args: [true] });
expect(playerPokemon.getTag(BattlerTagType.ROOSTED)).toBeDefined(); expect(pidgeot.isGrounded()).toBe(false);
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();
}); });
test("An electric/flying type that uses double shock, then roost should be typeless until end of turn", async () => { it("should convert pure Flying types into normal types", async () => {
game.override.enemySpecies(SpeciesId.ZEKROM); await game.classicMode.startBattle([SpeciesId.TORNADUS]);
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);
// Should only be pure flying type after burn up const tornadus = game.field.getPlayerPokemon();
let playerPokemonTypes = playerPokemon.getTypes(); tornadus.hp = 1;
expect(playerPokemonTypes[0] === PokemonType.FLYING).toBeTruthy();
expect(playerPokemonTypes.length === 1).toBeTruthy();
await game.phaseInterceptor.to(TurnEndPhase); game.move.use(MoveId.ROOST);
game.move.select(MoveId.ROOST); await game.toEndOfTurn(false);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to(MoveEffectPhase);
// Should only be typeless type after roost and is grounded // Should only be normal type, and NOT flying type
playerPokemonTypes = playerPokemon.getTypes(); expect(tornadus).toHaveTypes([PokemonType.NORMAL]);
expect(playerPokemon.getTag(BattlerTagType.ROOSTED)).toBeDefined(); expect(tornadus.isGrounded()).toBe(true);
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();
}); });
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 () => { it.each<{ name: string; move: MoveId; species: SpeciesId }>([
game.override.enemyMoveset([ { name: "Burn Up", move: MoveId.BURN_UP, species: SpeciesId.MOLTRES },
MoveId.TRICK_OR_TREAT, { name: "Double Shock", move: MoveId.DOUBLE_SHOCK, species: SpeciesId.ZAPDOS },
MoveId.TRICK_OR_TREAT, ])("should render user typeless when roosting after using $name", async ({ move, species }) => {
MoveId.TRICK_OR_TREAT, await game.classicMode.startBattle([species]);
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);
let playerPokemonTypes = playerPokemon.getTypes(); const player = game.field.getPlayerPokemon();
expect(playerPokemonTypes[0] === PokemonType.FIRE).toBeTruthy(); player.hp = 1;
expect(playerPokemonTypes.length === 1).toBeTruthy();
expect(playerPokemon.isGrounded()).toBeTruthy();
await game.phaseInterceptor.to(TurnEndPhase); game.move.use(move);
await game.toNextTurn();
// Should be fire/flying/ghost // Should be pure flying type
playerPokemonTypes = playerPokemon.getTypes(); expect(player).toHaveTypes([PokemonType.FLYING]);
expect(playerPokemonTypes.filter(type => type === PokemonType.FLYING)).toHaveLength(1); expect(player.isGrounded()).toBe(false);
expect(playerPokemonTypes.filter(type => type === PokemonType.FIRE)).toHaveLength(1);
expect(playerPokemonTypes.filter(type => type === PokemonType.GHOST)).toHaveLength(1); game.move.use(MoveId.ROOST);
expect(playerPokemonTypes.length === 3).toBeTruthy(); await game.phaseInterceptor.to("MoveEffectPhase");
expect(playerPokemon.isGrounded()).toBeFalsy();
// 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);
}); });
}); });

View File

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

View File

@ -1,16 +1,14 @@
import { StockpilingTag } from "#data/battler-tags";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result"; import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat"; import { Stat } from "#enums/stat";
import { TurnInitPhase } from "#phases/turn-init-phase";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Stockpile", () => { describe("Moves - Stockpile", () => {
describe("integration tests", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
let game: GameManager; let game: GameManager;
@ -29,50 +27,56 @@ describe("Moves - Stockpile", () => {
.battleStyle("single") .battleStyle("single")
.enemySpecies(SpeciesId.RATTATA) .enemySpecies(SpeciesId.RATTATA)
.enemyMoveset(MoveId.SPLASH) .enemyMoveset(MoveId.SPLASH)
.enemyAbility(AbilityId.NONE) .enemyAbility(AbilityId.BALL_FETCH)
.startingLevel(2000) .startingLevel(2000)
.moveset([MoveId.STOCKPILE, MoveId.SPLASH]) .ability(AbilityId.BALL_FETCH);
.ability(AbilityId.NONE);
}); });
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]); await game.classicMode.startBattle([SpeciesId.ABOMASNOW]);
const user = game.field.getPlayerPokemon(); const user = game.field.getPlayerPokemon();
// Unfortunately, Stockpile stacks are not directly queryable (i.e. there is no pokemon.getStockpileStacks()), expect(user).toHaveStatStage(Stat.DEF, 0);
// we just have to know that they're implemented as a BattlerTag. expect(user).toHaveStatStage(Stat.SPDEF, 0);
expect(user.getTag(StockpilingTag)).toBeUndefined(); // use Stockpile thrice
expect(user.getStatStage(Stat.DEF)).toBe(0); for (let i = 0; i < 3; i++) {
expect(user.getStatStage(Stat.SPDEF)).toBe(0); game.move.use(MoveId.STOCKPILE);
// use Stockpile four times
for (let i = 0; i < 4; i++) {
game.move.select(MoveId.STOCKPILE);
await game.toNextTurn(); await game.toNextTurn();
const stockpilingTag = user.getTag(StockpilingTag)!; const stockpilingTag = user.getTag(BattlerTagType.STOCKPILING)!;
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);
expect(stockpilingTag).toBeDefined(); expect(stockpilingTag).toBeDefined();
expect(stockpilingTag.stockpiledCount).toBe(i + 1); expect(stockpilingTag.stockpiledCount).toBe(i + 1);
} else { expect(user).toHaveStatStage(Stat.DEF, i + 1);
// fourth should have failed expect(user).toHaveStatStage(Stat.SPDEF, i + 1);
expect(user.getStatStage(Stat.DEF)).toBe(3); }
expect(user.getStatStage(Stat.SPDEF)).toBe(3); });
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).toBeDefined();
expect(stockpilingTag.stockpiledCount).toBe(3); 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, 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 () => { 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.DEF, 6);
user.setStatStage(Stat.SPDEF, 6); user.setStatStage(Stat.SPDEF, 6);
expect(user.getTag(StockpilingTag)).toBeUndefined(); expect(user).not.toHaveBattlerTag(BattlerTagType.STOCKPILING);
expect(user.getStatStage(Stat.DEF)).toBe(6);
expect(user.getStatStage(Stat.SPDEF)).toBe(6);
game.move.select(MoveId.STOCKPILE); game.move.use(MoveId.STOCKPILE);
await game.phaseInterceptor.to(TurnInitPhase); await game.toNextTurn();
const stockpilingTag = user.getTag(StockpilingTag)!; const stockpilingTag = user.getTag(BattlerTagType.STOCKPILING)!;
expect(stockpilingTag).toBeDefined(); expect(stockpilingTag).toBeDefined();
expect(stockpilingTag.stockpiledCount).toBe(1); expect(stockpilingTag.stockpiledCount).toBe(1);
expect(user.getStatStage(Stat.DEF)).toBe(6); expect(user).toHaveStatStage(Stat.DEF, 6);
expect(user.getStatStage(Stat.SPDEF)).toBe(6); expect(user).toHaveStatStage(Stat.SPDEF, 6);
game.move.select(MoveId.STOCKPILE); game.move.use(MoveId.STOCKPILE);
await game.phaseInterceptor.to(TurnInitPhase); await game.toNextTurn();
const stockpilingTagAgain = user.getTag(StockpilingTag)!; const stockpilingTagAgain = user.getTag(BattlerTagType.STOCKPILING)!;
expect(stockpilingTagAgain).toBeDefined(); expect(stockpilingTagAgain).toBeDefined();
expect(stockpilingTagAgain.stockpiledCount).toBe(2); expect(stockpilingTagAgain.stockpiledCount).toBe(2);
expect(user.getStatStage(Stat.DEF)).toBe(6); expect(user).toHaveStatStage(Stat.DEF, 6);
expect(user.getStatStage(Stat.SPDEF)).toBe(6); expect(user).toHaveStatStage(Stat.SPDEF, 6);
});
}); });
}); });

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

View File

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

View File

@ -144,6 +144,11 @@ describe("Move - Wish", () => {
expectWishActive(0); expectWishActive(0);
const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); 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(healPhases).toHaveLength(4);
expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder); expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder);

View File

@ -224,7 +224,7 @@ export class GameManager {
// This will consider all battle entry dialog as seens and skip them // This will consider all battle entry dialog as seens and skip them
vi.spyOn(this.scene.ui, "shouldSkipDialogue").mockReturnValue(true); vi.spyOn(this.scene.ui, "shouldSkipDialogue").mockReturnValue(true);
if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0) { if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0) {
this.removeEnemyHeldItems(); this.removeEnemyHeldItems();
} }
@ -371,9 +371,12 @@ export class GameManager {
console.log("==================[New Turn]=================="); console.log("==================[New Turn]==================");
} }
/** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */ /**
async toEndOfTurn() { * Transition to the {@linkcode TurnEndPhase | end of the current turn}.
await this.phaseInterceptor.to("TurnEndPhase"); * @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]=================="); console.log("==================[End of Turn]==================");
} }

View File

@ -50,7 +50,7 @@ export class ChallengeModeHelper extends GameManagerHelper {
}); });
await this.game.phaseInterceptor.run(EncounterPhase); await this.game.phaseInterceptor.run(EncounterPhase);
if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) {
this.game.removeEnemyHeldItems(); this.game.removeEnemyHeldItems();
} }
} }

View File

@ -53,7 +53,7 @@ export class ClassicModeHelper extends GameManagerHelper {
}); });
await this.game.phaseInterceptor.to(EncounterPhase); await this.game.phaseInterceptor.to(EncounterPhase);
if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) {
this.game.removeEnemyHeldItems(); this.game.removeEnemyHeldItems();
} }
} }

View File

@ -37,7 +37,7 @@ export class DailyModeHelper extends GameManagerHelper {
await this.game.phaseInterceptor.to(EncounterPhase); await this.game.phaseInterceptor.to(EncounterPhase);
if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) {
this.game.removeEnemyHeldItems(); this.game.removeEnemyHeldItems();
} }
} }

View File

@ -228,8 +228,8 @@ export class MoveHelper extends GameManagerHelper {
console.warn("Player moveset override disabled due to use of `game.move.changeMoveset`!"); console.warn("Player moveset override disabled due to use of `game.move.changeMoveset`!");
} }
} else { } else {
if (coerceArray(Overrides.OPP_MOVESET_OVERRIDE).length > 0) { if (coerceArray(Overrides.ENEMY_MOVESET_OVERRIDE).length > 0) {
vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue([]);
console.warn("Enemy moveset override disabled due to use of `game.move.changeMoveset`!"); console.warn("Enemy moveset override disabled due to use of `game.move.changeMoveset`!");
} }
} }
@ -302,8 +302,8 @@ export class MoveHelper extends GameManagerHelper {
(this.game.scene.phaseManager.getCurrentPhase() as EnemyCommandPhase).getFieldIndex() (this.game.scene.phaseManager.getCurrentPhase() as EnemyCommandPhase).getFieldIndex()
]; ];
if ([Overrides.OPP_MOVESET_OVERRIDE].flat().length > 0) { if ([Overrides.ENEMY_MOVESET_OVERRIDE].flat().length > 0) {
vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue([]);
console.warn( console.warn(
"Warning: `forceEnemyMove` overwrites the Pokemon's moveset and disables the enemy moveset override!", "Warning: `forceEnemyMove` overwrites the Pokemon's moveset and disables the enemy moveset override!",
); );

View File

@ -406,7 +406,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemySpecies(species: SpeciesId | number): this { public enemySpecies(species: SpeciesId | number): this {
vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(species); vi.spyOn(Overrides, "ENEMY_SPECIES_OVERRIDE", "get").mockReturnValue(species);
this.log(`Enemy Pokemon species set to ${SpeciesId[species]} (=${species})!`); this.log(`Enemy Pokemon species set to ${SpeciesId[species]} (=${species})!`);
return this; return this;
} }
@ -416,7 +416,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enableEnemyFusion(): this { public enableEnemyFusion(): this {
vi.spyOn(Overrides, "OPP_FUSION_OVERRIDE", "get").mockReturnValue(true); vi.spyOn(Overrides, "ENEMY_FUSION_OVERRIDE", "get").mockReturnValue(true);
this.log("Enemy Pokemon is a random fusion!"); this.log("Enemy Pokemon is a random fusion!");
return this; return this;
} }
@ -427,7 +427,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyFusionSpecies(species: SpeciesId | number): this { public enemyFusionSpecies(species: SpeciesId | number): this {
vi.spyOn(Overrides, "OPP_FUSION_SPECIES_OVERRIDE", "get").mockReturnValue(species); vi.spyOn(Overrides, "ENEMY_FUSION_SPECIES_OVERRIDE", "get").mockReturnValue(species);
this.log(`Enemy Pokemon fusion species set to ${SpeciesId[species]} (=${species})!`); this.log(`Enemy Pokemon fusion species set to ${SpeciesId[species]} (=${species})!`);
return this; return this;
} }
@ -438,7 +438,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyAbility(ability: AbilityId): this { public enemyAbility(ability: AbilityId): this {
vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(ability); vi.spyOn(Overrides, "ENEMY_ABILITY_OVERRIDE", "get").mockReturnValue(ability);
this.log(`Enemy Pokemon ability set to ${AbilityId[ability]} (=${ability})!`); this.log(`Enemy Pokemon ability set to ${AbilityId[ability]} (=${ability})!`);
return this; return this;
} }
@ -449,7 +449,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyPassiveAbility(passiveAbility: AbilityId): this { public enemyPassiveAbility(passiveAbility: AbilityId): this {
vi.spyOn(Overrides, "OPP_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(passiveAbility); vi.spyOn(Overrides, "ENEMY_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(passiveAbility);
this.log(`Enemy Pokemon PASSIVE ability set to ${AbilityId[passiveAbility]} (=${passiveAbility})!`); this.log(`Enemy Pokemon PASSIVE ability set to ${AbilityId[passiveAbility]} (=${passiveAbility})!`);
return this; return this;
} }
@ -460,7 +460,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyHasPassiveAbility(hasPassiveAbility: boolean | null): this { public enemyHasPassiveAbility(hasPassiveAbility: boolean | null): this {
vi.spyOn(Overrides, "OPP_HAS_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(hasPassiveAbility); vi.spyOn(Overrides, "ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(hasPassiveAbility);
if (hasPassiveAbility === null) { if (hasPassiveAbility === null) {
this.log("Enemy Pokemon PASSIVE ability no longer force enabled or disabled!"); this.log("Enemy Pokemon PASSIVE ability no longer force enabled or disabled!");
} else { } else {
@ -475,7 +475,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyMoveset(moveset: MoveId | MoveId[]): this { public enemyMoveset(moveset: MoveId | MoveId[]): this {
vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(moveset); vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue(moveset);
moveset = coerceArray(moveset); moveset = coerceArray(moveset);
const movesetStr = moveset.map(moveId => MoveId[moveId]).join(", "); const movesetStr = moveset.map(moveId => MoveId[moveId]).join(", ");
this.log(`Enemy Pokemon moveset set to ${movesetStr} (=[${moveset.join(", ")}])!`); this.log(`Enemy Pokemon moveset set to ${movesetStr} (=[${moveset.join(", ")}])!`);
@ -488,7 +488,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyLevel(level: number): this { public enemyLevel(level: number): this {
vi.spyOn(Overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(level); vi.spyOn(Overrides, "ENEMY_LEVEL_OVERRIDE", "get").mockReturnValue(level);
this.log(`Enemy Pokemon level set to ${level}!`); this.log(`Enemy Pokemon level set to ${level}!`);
return this; return this;
} }
@ -499,7 +499,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyStatusEffect(statusEffect: StatusEffect): this { public enemyStatusEffect(statusEffect: StatusEffect): this {
vi.spyOn(Overrides, "OPP_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect); vi.spyOn(Overrides, "ENEMY_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect);
this.log(`Enemy Pokemon status-effect set to ${StatusEffect[statusEffect]} (=${statusEffect})!`); this.log(`Enemy Pokemon status-effect set to ${StatusEffect[statusEffect]} (=${statusEffect})!`);
return this; return this;
} }
@ -510,7 +510,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyHeldItems(items: ModifierOverride[]): this { public enemyHeldItems(items: ModifierOverride[]): this {
vi.spyOn(Overrides, "OPP_HELD_ITEMS_OVERRIDE", "get").mockReturnValue(items); vi.spyOn(Overrides, "ENEMY_HELD_ITEMS_OVERRIDE", "get").mockReturnValue(items);
this.log("Enemy Pokemon held items set to:", items); this.log("Enemy Pokemon held items set to:", items);
return this; return this;
} }
@ -571,7 +571,7 @@ export class OverridesHelper extends GameManagerHelper {
* @param variant - (Optional) The enemy's shiny {@linkcode Variant}. * @param variant - (Optional) The enemy's shiny {@linkcode Variant}.
*/ */
enemyShiny(shininess: boolean | null, variant?: Variant): this { enemyShiny(shininess: boolean | null, variant?: Variant): this {
vi.spyOn(Overrides, "OPP_SHINY_OVERRIDE", "get").mockReturnValue(shininess); vi.spyOn(Overrides, "ENEMY_SHINY_OVERRIDE", "get").mockReturnValue(shininess);
if (shininess === null) { if (shininess === null) {
this.log("Disabled enemy Pokemon shiny override!"); this.log("Disabled enemy Pokemon shiny override!");
} else { } else {
@ -579,7 +579,7 @@ export class OverridesHelper extends GameManagerHelper {
} }
if (variant !== undefined) { if (variant !== undefined) {
vi.spyOn(Overrides, "OPP_VARIANT_OVERRIDE", "get").mockReturnValue(variant); vi.spyOn(Overrides, "ENEMY_VARIANT_OVERRIDE", "get").mockReturnValue(variant);
this.log(`Set enemy shiny variant to be ${variant}!`); this.log(`Set enemy shiny variant to be ${variant}!`);
} }
return this; return this;
@ -594,7 +594,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyHealthSegments(healthSegments: number): this { public enemyHealthSegments(healthSegments: number): this {
vi.spyOn(Overrides, "OPP_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments); vi.spyOn(Overrides, "ENEMY_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments);
this.log("Enemy Pokemon health segments set to:", healthSegments); this.log("Enemy Pokemon health segments set to:", healthSegments);
return this; return this;
} }

View File

@ -2,15 +2,22 @@ import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore lint/correctness/noUnusedImports: TSDoc // biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils"; import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import { toDmgValue } from "#utils/common";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/** /**
* Matcher that checks if a Pokemon has a specific amount of HP. * Matcher that checks if a Pokemon has a specific amount of HP.
* @param received - The object to check. Should be a {@linkcode Pokemon}. * @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param expectedHp - The expected amount of HP the {@linkcode Pokemon} has * @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 * @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)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, 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 actualHp = received.hp;
const pass = actualHp === expectedHp; const pass = actualHp === expectedHp;

View File

@ -33,7 +33,7 @@ export function toHaveUsedPP(
}; };
} }
const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.OPP_MOVESET_OVERRIDE; const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.ENEMY_MOVESET_OVERRIDE;
if (coerceArray(override).length > 0) { if (coerceArray(override).length > 0) {
return { return {
pass: false, pass: false,

View File

@ -36,6 +36,7 @@ import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "#phases/next-encounter-phase"; import { NextEncounterPhase } from "#phases/next-encounter-phase";
import { PartyExpPhase } from "#phases/party-exp-phase"; import { PartyExpPhase } from "#phases/party-exp-phase";
import { PartyHealPhase } from "#phases/party-heal-phase"; import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase"; import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase"; import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-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. * `initPhases()` so that its subclasses can use `super.start()` properly.
*/ */
private PHASES = [ private PHASES = [
[PokemonHealPhase, this.startPhase],
[LoginPhase, this.startPhase], [LoginPhase, this.startPhase],
[TitlePhase, this.startPhase], [TitlePhase, this.startPhase],
[SelectGenderPhase, this.startPhase], [SelectGenderPhase, this.startPhase],