[Refactor/Bug/Ability] Reworked BattleData, fixed Rage Fist, Harvest, Belch + Implemented Cud Chew (#5655)

* Grabbed reverted changes from stuff

* Added version migrator for rage fist data + deepMergeSpriteData tests

* fixed formattign

* Fied a few

* Fixed constructor (maybe), moved deepCopy and deepMergeSpriteData to own file

`common.ts` is hella bloated so seems legit

* Moved empty moveset verification mapping thing to upgrade script bc i wanted to

* Fixed tests

* test added

* Fixed summondata being cleared inside summonPhase, removed `summonDataPrimer`

like seriously how come no-one checked this

* Fixed test

I forgot that we outsped and oneshot

* Fixed test

* huhjjjjjb

* Hopefully fixed bug

my sanity and homework are paying the price for this lol

* added commented out console.log statement

uncomment to see new berry data

* Fixed migrate script, re-added deprecated attributes out of necessity

* Fixed failing test by not trying to mock rng

* Fixed test

* Fixed tests

* Update ability.ts

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

* Update ability.ts

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

* Update overrides.ts

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

* Update berry-phase.ts

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

* Update encounter-phase.ts

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

* Update game-data.ts

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

* Update move-phase.ts

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

* Added utility function `randSeedFloat`

basically just `Phaser.math.RND.realInRange(0, 1)`

* Applied review comments, cleaned up code a bit

* Removed unnecessary null checks for turnData and co.

I explicitly made them initialized by default for this very reason

* Added tests for Last Resort regarding moveHistory

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update battle-scene.ts

Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>

* Update the-winstrate-challenge-encounter.ts

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

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update pokemon.ts

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

* Update ability.ts

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

* Update move.ts

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

* Update move.ts

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

* Update move.ts

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

* Update battle-anims.ts

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

* Update pokemon.ts comments

* Fixed a few outstanding issues with documentation

* Updated switch summon phase comment

* Re-added BattleSummonData as TempSummonData

* Hppefully fixed -1 sprite scale glitch

* Fixed comment

* Reveted `pokemon-forms.ts`

* Fuxed constructor

* fixed -1 bug

* Revert "Added utility function `randSeedFloat`"

This reverts commit 4c3447c851.

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
This commit is contained in:
Bertie690 2025-05-02 01:06:07 -04:00 committed by GitHub
parent fa86ea3214
commit 6d90649b92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 2541 additions and 1292 deletions

View File

@ -7,7 +7,6 @@ import type PokemonSpecies from "#app/data/pokemon-species";
import { allSpecies, getPokemonSpecies } from "#app/data/pokemon-species"; import { allSpecies, getPokemonSpecies } from "#app/data/pokemon-species";
import { import {
fixedInt, fixedInt,
deepMergeObjects,
getIvsFromId, getIvsFromId,
randSeedInt, randSeedInt,
getEnumValues, getEnumValues,
@ -19,6 +18,7 @@ import {
BooleanHolder, BooleanHolder,
type Constructor, type Constructor,
} from "#app/utils/common"; } from "#app/utils/common";
import { deepMergeSpriteData } from "#app/utils/data";
import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "./modifier/modifier"; import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "./modifier/modifier";
import { import {
ConsumableModifier, ConsumableModifier,
@ -787,7 +787,7 @@ export default class BattleScene extends SceneBase {
return; return;
} }
const expVariantData = await this.cachedFetch("./images/pokemon/variant/_exp_masterlist.json").then(r => r.json()); const expVariantData = await this.cachedFetch("./images/pokemon/variant/_exp_masterlist.json").then(r => r.json());
deepMergeObjects(variantData, expVariantData); deepMergeSpriteData(variantData, expVariantData);
} }
cachedFetch(url: string, init?: RequestInit): Promise<Response> { cachedFetch(url: string, init?: RequestInit): Promise<Response> {
@ -835,6 +835,7 @@ export default class BattleScene extends SceneBase {
return this.getPlayerField().find(p => p.isActive() && (includeSwitching || p.switchOutStatus === false)); return this.getPlayerField().find(p => p.isActive() && (includeSwitching || p.switchOutStatus === false));
} }
// TODO: Add `undefined` to return type
/** /**
* Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not. * Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not.
* Does not actually check if the pokemon are on the field or not. * Does not actually check if the pokemon are on the field or not.
@ -850,9 +851,9 @@ export default class BattleScene extends SceneBase {
} }
/** /**
* @returns The first {@linkcode EnemyPokemon} that is {@linkcode getEnemyField on the field} * @returns The first {@linkcode EnemyPokemon} that is {@linkcode getEnemyField | on the field}
* and {@linkcode EnemyPokemon.isActive is active} * and {@linkcode EnemyPokemon.isActive | is active}
* (aka {@linkcode EnemyPokemon.isAllowedInBattle is allowed in battle}), * (aka {@linkcode EnemyPokemon.isAllowedInBattle | is allowed in battle}),
* or `undefined` if there are no valid pokemon * or `undefined` if there are no valid pokemon
* @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true` * @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true`
*/ */
@ -873,8 +874,8 @@ export default class BattleScene extends SceneBase {
/** /**
* Returns an array of Pokemon on both sides of the battle - player first, then enemy. * Returns an array of Pokemon on both sides of the battle - player first, then enemy.
* Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type. * Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type.
* @param activeOnly Whether to consider only active pokemon * @param activeOnly - Whether to consider only active pokemon; default `false`
* @returns array of {@linkcode Pokemon} * @returns An array of {@linkcode Pokemon}, as described above.
*/ */
public getField(activeOnly = false): Pokemon[] { public getField(activeOnly = false): Pokemon[] {
const ret = new Array(4).fill(null); const ret = new Array(4).fill(null);
@ -1307,14 +1308,13 @@ export default class BattleScene extends SceneBase {
return isNewBiome; return isNewBiome;
} }
// TODO: ...this never actually returns `null`, right?
newBattle( newBattle(
waveIndex?: number, waveIndex?: number,
battleType?: BattleType, battleType?: BattleType,
trainerData?: TrainerData, trainerData?: TrainerData,
double?: boolean, double?: boolean,
mysteryEncounterType?: MysteryEncounterType, mysteryEncounterType?: MysteryEncounterType,
): Battle | null { ): Battle {
const _startingWave = Overrides.STARTING_WAVE_OVERRIDE || startingWave; const _startingWave = Overrides.STARTING_WAVE_OVERRIDE || startingWave;
const newWaveIndex = waveIndex || (this.currentBattle?.waveIndex || _startingWave - 1) + 1; const newWaveIndex = waveIndex || (this.currentBattle?.waveIndex || _startingWave - 1) + 1;
let newDouble: boolean | undefined; let newDouble: boolean | undefined;
@ -1496,7 +1496,7 @@ export default class BattleScene extends SceneBase {
}); });
for (const pokemon of this.getPlayerParty()) { for (const pokemon of this.getPlayerParty()) {
pokemon.resetBattleData(); pokemon.resetBattleAndWaveData();
pokemon.resetTera(); pokemon.resetTera();
applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon); applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon);
if ( if (
@ -3264,6 +3264,7 @@ export default class BattleScene extends SceneBase {
[this.modifierBar, this.enemyModifierBar].map(m => m.setVisible(visible)); [this.modifierBar, this.enemyModifierBar].map(m => m.setVisible(visible));
} }
// TODO: Document this
updateModifiers(player = true, instant?: boolean): void { updateModifiers(player = true, instant?: boolean): void {
const modifiers = player ? this.modifiers : (this.enemyModifiers as PersistentModifier[]); const modifiers = player ? this.modifiers : (this.enemyModifiers as PersistentModifier[]);
for (let m = 0; m < modifiers.length; m++) { for (let m = 0; m < modifiers.length; m++) {
@ -3316,8 +3317,8 @@ export default class BattleScene extends SceneBase {
* gets removed. This function does NOT apply in-battle effects, such as Unburden. * gets removed. This function does NOT apply in-battle effects, such as Unburden.
* If in-battle effects are needed, use {@linkcode Pokemon.loseHeldItem} instead. * If in-battle effects are needed, use {@linkcode Pokemon.loseHeldItem} instead.
* @param modifier The item to be removed. * @param modifier The item to be removed.
* @param enemy If `true`, remove an item owned by the enemy. If `false`, remove an item owned by the player. Default is `false`. * @param enemy `true` to remove an item owned by the enemy rather than the player; default `false`.
* @returns `true` if the item exists and was successfully removed, `false` otherwise. * @returns `true` if the item exists and was successfully removed, `false` otherwise
*/ */
removeModifier(modifier: PersistentModifier, enemy = false): boolean { removeModifier(modifier: PersistentModifier, enemy = false): boolean {
const modifiers = !enemy ? this.modifiers : this.enemyModifiers; const modifiers = !enemy ? this.modifiers : this.enemyModifiers;

View File

@ -6,6 +6,10 @@ export abstract class AbAttr {
public showAbility: boolean; public showAbility: boolean;
private extraCondition: AbAttrCondition; private extraCondition: AbAttrCondition;
/**
* @param showAbility - Whether to show this ability as a flyout during battle; default `true`.
* Should be kept in parity with mainline where possible.
*/
constructor(showAbility = true) { constructor(showAbility = true) {
this.showAbility = showAbility; this.showAbility = showAbility;
} }

View File

@ -60,6 +60,11 @@ import { SwitchType } from "#enums/switch-type";
import { MoveFlags } from "#enums/MoveFlags"; import { MoveFlags } from "#enums/MoveFlags";
import { MoveTarget } from "#enums/MoveTarget"; import { MoveTarget } from "#enums/MoveTarget";
import { MoveCategory } from "#enums/MoveCategory"; import { MoveCategory } from "#enums/MoveCategory";
import type { BerryType } from "#enums/berry-type";
import { CommonAnimPhase } from "#app/phases/common-anim-phase";
import { CommonAnim } from "../battle-anims";
import { getBerryEffectFunc } from "../berry";
import { BerryUsedEvent } from "#app/events/battle-scene";
// Type imports // Type imports
@ -2675,7 +2680,7 @@ export class PostSummonCopyAllyStatsAbAttr extends PostSummonAbAttr {
} }
/** /**
* Used by Imposter * Attribute used by {@linkcode Abilities.IMPOSTER} to transform into a random opposing pokemon on entry.
*/ */
export class PostSummonTransformAbAttr extends PostSummonAbAttr { export class PostSummonTransformAbAttr extends PostSummonAbAttr {
constructor() { constructor() {
@ -2710,7 +2715,7 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr {
const targets = pokemon.getOpponents(); const targets = pokemon.getOpponents();
const target = this.getTarget(targets); const target = this.getTarget(targets);
if (!!target.summonData?.illusion) { if (target.summonData.illusion) {
return false; return false;
} }
@ -3657,14 +3662,10 @@ export class SuppressWeatherEffectAbAttr extends PreWeatherEffectAbAttr {
* Condition function to applied to abilities related to Sheer Force. * Condition function to applied to abilities related to Sheer Force.
* Checks if last move used against target was affected by a Sheer Force user and: * Checks if last move used against target was affected by a Sheer Force user and:
* Disables: Color Change, Pickpocket, Berserk, Anger Shell * Disables: Color Change, Pickpocket, Berserk, Anger Shell
* @returns {AbAttrCondition} If false disables the ability which the condition is applied to. * @returns An {@linkcode AbAttrCondition} to disable the ability under the proper conditions.
*/ */
function getSheerForceHitDisableAbCondition(): AbAttrCondition { function getSheerForceHitDisableAbCondition(): AbAttrCondition {
return (pokemon: Pokemon) => { return (pokemon: Pokemon) => {
if (!pokemon.turnData) {
return true;
}
const lastReceivedAttack = pokemon.turnData.attacksReceived[0]; const lastReceivedAttack = pokemon.turnData.attacksReceived[0];
if (!lastReceivedAttack) { if (!lastReceivedAttack) {
return true; return true;
@ -3675,7 +3676,7 @@ function getSheerForceHitDisableAbCondition(): AbAttrCondition {
return true; return true;
} }
/**if the last move chance is greater than or equal to cero, and the last attacker's ability is sheer force*/ /** `true` if the last move's chance is above 0 and the last attacker's ability is sheer force */
const SheerForceAffected = allMoves[lastReceivedAttack.move].chance >= 0 && lastAttacker.hasAbility(Abilities.SHEER_FORCE); const SheerForceAffected = allMoves[lastReceivedAttack.move].chance >= 0 && lastAttacker.hasAbility(Abilities.SHEER_FORCE);
return !SheerForceAffected; return !SheerForceAffected;
@ -3745,7 +3746,7 @@ function getAnticipationCondition(): AbAttrCondition {
*/ */
function getOncePerBattleCondition(ability: Abilities): AbAttrCondition { function getOncePerBattleCondition(ability: Abilities): AbAttrCondition {
return (pokemon: Pokemon) => { return (pokemon: Pokemon) => {
return !pokemon.battleData?.abilitiesApplied.includes(ability); return !pokemon.waveData.abilitiesApplied.has(ability);
}; };
} }
@ -4034,7 +4035,7 @@ export class PostTurnStatusHealAbAttr extends PostTurnAbAttr {
/** /**
* After the turn ends, resets the status of either the ability holder or their ally * After the turn ends, resets the status of either the ability holder or their ally
* @param {boolean} allyTarget Whether to target ally, defaults to false (self-target) * @param allyTarget Whether to target ally, defaults to false (self-target)
*/ */
export class PostTurnResetStatusAbAttr extends PostTurnAbAttr { export class PostTurnResetStatusAbAttr extends PostTurnAbAttr {
private allyTarget: boolean; private allyTarget: boolean;
@ -4066,79 +4067,153 @@ export class PostTurnResetStatusAbAttr extends PostTurnAbAttr {
} }
/** /**
* After the turn ends, try to create an extra item * Attribute to try and restore eaten berries after the turn ends.
* Used by {@linkcode Abilities.HARVEST}.
*/ */
export class PostTurnLootAbAttr extends PostTurnAbAttr { export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr {
/** /**
* @param itemType - The type of item to create * Array containing all {@linkcode BerryType | BerryTypes} that are under cap and able to be restored.
* @param procChance - Chance to create an item * Stored inside the class for a minor performance boost
* @see {@linkcode applyPostTurn()} */
private berriesUnderCap: BerryType[]
/**
* @param procChance - function providing chance to restore an item
* @see {@linkcode createEatenBerry()}
*/ */
constructor( constructor(
/** Extend itemType to add more options */
private itemType: "EATEN_BERRIES" | "HELD_BERRIES",
private procChance: (pokemon: Pokemon) => number private procChance: (pokemon: Pokemon) => number
) { ) {
super(); super();
} }
override canApplyPostTurn(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean { override canApplyPostTurn(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): boolean {
// Clamp procChance to [0, 1]. Skip if didn't proc (less than pass) // Ensure we have at least 1 recoverable berry (at least 1 berry in berriesEaten is not capped)
const pass = Phaser.Math.RND.realInRange(0, 1); const cappedBerries = new Set(
return !(Math.max(Math.min(this.procChance(pokemon), 1), 0) < pass) && this.itemType === "EATEN_BERRIES" && !!pokemon.battleData.berriesEaten; globalScene.getModifiers(BerryModifier, pokemon.isPlayer()).filter(
} bm => bm.pokemonId === pokemon.id && bm.getCountUnderMax() < 1
).map(bm => bm.berryType)
);
override applyPostTurn(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): void { this.berriesUnderCap = pokemon.battleData.berriesEaten.filter(
this.createEatenBerry(pokemon, simulated); bt => !cappedBerries.has(bt)
} );
/** if (!this.berriesUnderCap.length) {
* Create a new berry chosen randomly from the berries the pokemon ate this battle
* @param pokemon The pokemon with this ability
* @param simulated whether the associated ability call is simulated
* @returns whether a new berry was created
*/
createEatenBerry(pokemon: Pokemon, simulated: boolean): boolean {
const berriesEaten = pokemon.battleData.berriesEaten;
if (!berriesEaten.length) {
return false; return false;
} }
if (simulated) { // Clamp procChance to [0, 1]. Skip if didn't proc (less than pass)
return true; const pass = Phaser.Math.RND.realInRange(0, 1);
return Phaser.Math.Clamp(this.procChance(pokemon), 0, 1) >= pass;
} }
const randomIdx = randSeedInt(berriesEaten.length); override applyPostTurn(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): void {
const chosenBerryType = berriesEaten[randomIdx]; if (!simulated) {
const chosenBerry = new BerryModifierType(chosenBerryType); this.createEatenBerry(pokemon);
berriesEaten.splice(randomIdx); // Remove berry from memory }
}
/**
* Create a new berry chosen randomly from all berries the pokemon ate this battle
* @param pokemon - The {@linkcode Pokemon} with this ability
* @returns `true` if a new berry was created
*/
createEatenBerry(pokemon: Pokemon): boolean {
// Pick a random available berry to yoink
const randomIdx = randSeedInt(this.berriesUnderCap.length);
const chosenBerryType = this.berriesUnderCap[randomIdx];
pokemon.battleData.berriesEaten.splice(randomIdx, 1); // Remove berry from memory
const chosenBerry = new BerryModifierType(chosenBerryType);
// Add the randomly chosen berry or update the existing one
const berryModifier = globalScene.findModifier( const berryModifier = globalScene.findModifier(
(m) => m instanceof BerryModifier && m.berryType === chosenBerryType, (m) => m instanceof BerryModifier && m.berryType === chosenBerryType && m.pokemonId == pokemon.id,
pokemon.isPlayer() pokemon.isPlayer()
) as BerryModifier | undefined; ) as BerryModifier | undefined;
if (!berryModifier) { if (berryModifier) {
berryModifier.stackCount++
} else {
const newBerry = new BerryModifier(chosenBerry, pokemon.id, chosenBerryType, 1); const newBerry = new BerryModifier(chosenBerry, pokemon.id, chosenBerryType, 1);
if (pokemon.isPlayer()) { if (pokemon.isPlayer()) {
globalScene.addModifier(newBerry); globalScene.addModifier(newBerry);
} else { } else {
globalScene.addEnemyModifier(newBerry); globalScene.addEnemyModifier(newBerry);
} }
} else if (berryModifier.stackCount < berryModifier.getMaxHeldItemCount(pokemon)) {
berryModifier.stackCount++;
} }
globalScene.queueMessage(i18next.t("abilityTriggers:postTurnLootCreateEatenBerry", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), berryName: chosenBerry.name }));
globalScene.updateModifiers(pokemon.isPlayer()); globalScene.updateModifiers(pokemon.isPlayer());
globalScene.queueMessage(i18next.t("abilityTriggers:postTurnLootCreateEatenBerry", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), berryName: chosenBerry.name }));
return true; return true;
} }
} }
/** /**
* Attribute used for {@linkcode Abilities.MOODY} * Attribute to track and re-trigger last turn's berries at the end of the `BerryPhase`.
* Used by {@linkcode Abilities.CUD_CHEW}.
*/
export class RepeatBerryNextTurnAbAttr extends PostTurnAbAttr {
/**
* @returns `true` if the pokemon ate anything last turn
*/
override canApply(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): boolean {
// force ability popup for ability triggers on normal turns.
// Still not used if ability doesn't proc
this.showAbility = true;
return !!pokemon.summonData.berriesEatenLast.length;
}
/**
* Cause this {@linkcode Pokemon} to regurgitate and eat all berries inside its `berriesEatenLast` array.
* Triggers a berry use animation, but does *not* count for other berry or item-related abilities.
* @param pokemon - The {@linkcode Pokemon} having a bad tummy ache
* @param _passive - N/A
* @param _simulated - N/A
* @param _cancelled - N/A
* @param _args - N/A
*/
override apply(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _cancelled: BooleanHolder | null, _args: any[]): void {
globalScene.unshiftPhase(
new CommonAnimPhase(pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.USE_ITEM),
);
// Re-apply effects of all berries previously scarfed.
// This doesn't count as "eating" a berry (for unnerve/stuff cheeks/unburden) as no item is consumed.
for (const berryType of pokemon.summonData.berriesEatenLast) {
getBerryEffectFunc(berryType)(pokemon);
const bMod = new BerryModifier(new BerryModifierType(berryType), pokemon.id, berryType, 1);
globalScene.eventTarget.dispatchEvent(new BerryUsedEvent(bMod)); // trigger message
}
// uncomment to make cheek pouch work with cud chew
// applyAbAttrs(HealFromBerryUseAbAttr, pokemon, new BooleanHolder(false));
}
/**
* @returns always `true` as we always want to move berries into summon data
*/
override canApplyPostTurn(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): boolean {
this.showAbility = false; // don't show popup for turn end berry moving (should ideally be hidden)
return true;
}
/**
* Move this {@linkcode Pokemon}'s `berriesEaten` array from `PokemonTurnData`
* into `PokemonSummonData` on turn end.
* Both arrays are cleared on switch.
* @param pokemon - The {@linkcode Pokemon} having a nice snack
* @param _passive - N/A
* @param _simulated - N/A
* @param _args - N/A
*/
override applyPostTurn(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): void {
pokemon.summonData.berriesEatenLast = pokemon.turnData.berriesEaten;
}
}
/**
* Attribute used for {@linkcode Abilities.MOODY} to randomly raise and lower stats at turn end.
*/ */
export class MoodyAbAttr extends PostTurnAbAttr { export class MoodyAbAttr extends PostTurnAbAttr {
constructor() { constructor() {
@ -4232,7 +4307,7 @@ export class PostTurnHurtIfSleepingAbAttr extends PostTurnAbAttr {
} }
/** /**
* Deals damage to all sleeping opponents equal to 1/8 of their max hp (min 1) * Deals damage to all sleeping opponents equal to 1/8 of their max hp (min 1)
* @param pokemon Pokemon that has this ability * @param pokemon {@linkcode Pokemon} with this ability
* @param passive N/A * @param passive N/A
* @param simulated `true` if applying in a simulated call. * @param simulated `true` if applying in a simulated call.
* @param args N/A * @param args N/A
@ -4414,7 +4489,7 @@ export class PostItemLostAbAttr extends AbAttr {
} }
/** /**
* Applies a Battler Tag to the Pokemon after it loses or consumes item * Applies a Battler Tag to the Pokemon after it loses or consumes an item
* @extends PostItemLostAbAttr * @extends PostItemLostAbAttr
*/ */
export class PostItemLostApplyBattlerTagAbAttr extends PostItemLostAbAttr { export class PostItemLostApplyBattlerTagAbAttr extends PostItemLostAbAttr {
@ -4503,8 +4578,19 @@ export class DoubleBerryEffectAbAttr extends AbAttr {
} }
} }
/**
* Attribute to prevent opposing berry use while on the field.
* Used by {@linkcode Abilities.UNNERVE}, {@linkcode Abilities.AS_ONE_GLASTRIER} and {@linkcode Abilities.AS_ONE_SPECTRIER}
*/
export class PreventBerryUseAbAttr extends AbAttr { export class PreventBerryUseAbAttr extends AbAttr {
override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: BooleanHolder, args: any[]): void { /**
* Prevent use of opposing berries.
* @param _pokemon - Unused
* @param _passive - Unused
* @param _simulated - Unused
* @param cancelled - {@linkcode BooleanHolder} containing whether to block berry use
*/
override apply(_pokemon: Pokemon, _passive: boolean, _simulated: boolean, cancelled: BooleanHolder): void {
cancelled.value = true; cancelled.value = true;
} }
} }
@ -4526,8 +4612,11 @@ export class HealFromBerryUseAbAttr extends AbAttr {
} }
override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, ...args: [BooleanHolder, any[]]): void { override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, ...args: [BooleanHolder, any[]]): void {
if (simulated) {
return;
}
const { name: abilityName } = passive ? pokemon.getPassiveAbility() : pokemon.getAbility(); const { name: abilityName } = passive ? pokemon.getPassiveAbility() : pokemon.getAbility();
if (!simulated) {
globalScene.unshiftPhase( globalScene.unshiftPhase(
new PokemonHealPhase( new PokemonHealPhase(
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
@ -4538,7 +4627,6 @@ export class HealFromBerryUseAbAttr extends AbAttr {
); );
} }
} }
}
export class RunSuccessAbAttr extends AbAttr { export class RunSuccessAbAttr extends AbAttr {
override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: BooleanHolder, args: any[]): void { override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: BooleanHolder, args: any[]): void {
@ -4568,7 +4656,8 @@ export class CheckTrappedAbAttr extends AbAttr {
simulated: boolean, simulated: boolean,
trapped: BooleanHolder, trapped: BooleanHolder,
otherPokemon: Pokemon, otherPokemon: Pokemon,
args: any[]): boolean { args: any[],
): boolean {
return true; return true;
} }
@ -5176,7 +5265,6 @@ export class IllusionPreSummonAbAttr extends PreSummonAbAttr {
} }
override canApplyPreSummon(pokemon: Pokemon, passive: boolean, args: any[]): boolean { override canApplyPreSummon(pokemon: Pokemon, passive: boolean, args: any[]): boolean {
pokemon.initSummondata()
if (pokemon.hasTrainer()) { if (pokemon.hasTrainer()) {
const party: Pokemon[] = (pokemon.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty()).filter(p => p.isAllowedInBattle()); const party: Pokemon[] = (pokemon.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty()).filter(p => p.isAllowedInBattle());
const lastPokemon: Pokemon = party.filter(p => p !==pokemon).at(-1) || pokemon; const lastPokemon: Pokemon = party.filter(p => p !==pokemon).at(-1) || pokemon;
@ -5221,7 +5309,7 @@ export class PostDefendIllusionBreakAbAttr extends PostDefendAbAttr {
override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { override canApplyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
const breakIllusion: HitResult[] = [ HitResult.EFFECTIVE, HitResult.SUPER_EFFECTIVE, HitResult.NOT_VERY_EFFECTIVE, HitResult.ONE_HIT_KO ]; const breakIllusion: HitResult[] = [ HitResult.EFFECTIVE, HitResult.SUPER_EFFECTIVE, HitResult.NOT_VERY_EFFECTIVE, HitResult.ONE_HIT_KO ];
return breakIllusion.includes(hitResult) && !!pokemon.summonData?.illusion return breakIllusion.includes(hitResult) && !!pokemon.summonData.illusion
} }
} }
@ -5442,11 +5530,8 @@ function applySingleAbAttrs<TAttr extends AbAttr>(
globalScene.queueAbilityDisplay(pokemon, passive, false); globalScene.queueAbilityDisplay(pokemon, passive, false);
} }
if (pokemon.summonData && !pokemon.summonData.abilitiesApplied.includes(ability.id)) { if (!simulated) {
pokemon.summonData.abilitiesApplied.push(ability.id); pokemon.waveData.abilitiesApplied.add(ability.id);
}
if (pokemon.battleData && !simulated && !pokemon.battleData.abilitiesApplied.includes(ability.id)) {
pokemon.battleData.abilitiesApplied.push(ability.id);
} }
globalScene.clearPhaseQueueSplice(); globalScene.clearPhaseQueueSplice();
@ -5637,6 +5722,7 @@ export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr {
this.hpRatio = hpRatio; this.hpRatio = hpRatio;
} }
// TODO: Refactor to use more early returns
public override canApplyPostDamage( public override canApplyPostDamage(
pokemon: Pokemon, pokemon: Pokemon,
damage: number, damage: number,
@ -5664,6 +5750,7 @@ export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr {
if (fordbiddenDefendingMoves.includes(enemyLastMoveUsed.move) || enemyLastMoveUsed.move === Moves.SKY_DROP && enemyLastMoveUsed.result === MoveResult.OTHER) { if (fordbiddenDefendingMoves.includes(enemyLastMoveUsed.move) || enemyLastMoveUsed.move === Moves.SKY_DROP && enemyLastMoveUsed.result === MoveResult.OTHER) {
return false; return false;
// Will not activate if the Pokémon's HP falls below half by a move affected by Sheer Force. // Will not activate if the Pokémon's HP falls below half by a move affected by Sheer Force.
// TODO: Make this use the sheer force disable condition
} else if (allMoves[enemyLastMoveUsed.move].chance >= 0 && source.hasAbility(Abilities.SHEER_FORCE)) { } else if (allMoves[enemyLastMoveUsed.move].chance >= 0 && source.hasAbility(Abilities.SHEER_FORCE)) {
return false; return false;
// Activate only after the last hit of multistrike moves // Activate only after the last hit of multistrike moves
@ -6326,17 +6413,14 @@ export function applyOnLoseAbAttrs(pokemon: Pokemon, passive = false, simulated
/** /**
* Sets the ability of a Pokémon as revealed. * Sets the ability of a Pokémon as revealed.
*
* @param pokemon - The Pokémon whose ability is being revealed. * @param pokemon - The Pokémon whose ability is being revealed.
*/ */
function setAbilityRevealed(pokemon: Pokemon): void { function setAbilityRevealed(pokemon: Pokemon): void {
if (pokemon.battleData) { pokemon.waveData.abilityRevealed = true;
pokemon.battleData.abilityRevealed = true;
}
} }
/** /**
* Returns the Pokemon with weather-based forms * Returns all Pokemon on field with weather-based forms
*/ */
function getPokemonWithWeatherBasedForms() { function getPokemonWithWeatherBasedForms() {
return globalScene.getField(true).filter(p => return globalScene.getField(true).filter(p =>
@ -6784,8 +6868,7 @@ export function initAbilities() {
.attr(MovePowerBoostAbAttr, (user, target, move) => move.category === MoveCategory.SPECIAL && user?.status?.effect === StatusEffect.BURN, 1.5), .attr(MovePowerBoostAbAttr, (user, target, move) => move.category === MoveCategory.SPECIAL && user?.status?.effect === StatusEffect.BURN, 1.5),
new Ability(Abilities.HARVEST, 5) new Ability(Abilities.HARVEST, 5)
.attr( .attr(
PostTurnLootAbAttr, PostTurnRestoreBerryAbAttr,
"EATEN_BERRIES",
/** Rate is doubled when under sun {@link https://dex.pokemonshowdown.com/abilities/harvest} */ /** Rate is doubled when under sun {@link https://dex.pokemonshowdown.com/abilities/harvest} */
(pokemon) => 0.5 * (getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN)(pokemon) ? 2 : 1) (pokemon) => 0.5 * (getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN)(pokemon) ? 2 : 1)
) )
@ -6907,7 +6990,7 @@ export function initAbilities() {
.attr(HealFromBerryUseAbAttr, 1 / 3), .attr(HealFromBerryUseAbAttr, 1 / 3),
new Ability(Abilities.PROTEAN, 6) new Ability(Abilities.PROTEAN, 6)
.attr(PokemonTypeChangeAbAttr), .attr(PokemonTypeChangeAbAttr),
//.condition((p) => !p.summonData?.abilitiesApplied.includes(Abilities.PROTEAN)), //Gen 9 Implementation //.condition((p) => !p.summonData.abilitiesApplied.includes(Abilities.PROTEAN)), //Gen 9 Implementation
new Ability(Abilities.FUR_COAT, 6) new Ability(Abilities.FUR_COAT, 6)
.attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => move.category === MoveCategory.PHYSICAL, 0.5) .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => move.category === MoveCategory.PHYSICAL, 0.5)
.ignorable(), .ignorable(),
@ -7153,7 +7236,7 @@ export function initAbilities() {
.attr(PostSummonStatStageChangeAbAttr, [ Stat.DEF ], 1, true), .attr(PostSummonStatStageChangeAbAttr, [ Stat.DEF ], 1, true),
new Ability(Abilities.LIBERO, 8) new Ability(Abilities.LIBERO, 8)
.attr(PokemonTypeChangeAbAttr), .attr(PokemonTypeChangeAbAttr),
//.condition((p) => !p.summonData?.abilitiesApplied.includes(Abilities.LIBERO)), //Gen 9 Implementation //.condition((p) => !p.summonData.abilitiesApplied.includes(Abilities.LIBERO)), //Gen 9 Implementation
new Ability(Abilities.BALL_FETCH, 8) new Ability(Abilities.BALL_FETCH, 8)
.attr(FetchBallAbAttr) .attr(FetchBallAbAttr)
.condition(getOncePerBattleCondition(Abilities.BALL_FETCH)), .condition(getOncePerBattleCondition(Abilities.BALL_FETCH)),
@ -7368,7 +7451,7 @@ export function initAbilities() {
new Ability(Abilities.OPPORTUNIST, 9) new Ability(Abilities.OPPORTUNIST, 9)
.attr(StatStageChangeCopyAbAttr), .attr(StatStageChangeCopyAbAttr),
new Ability(Abilities.CUD_CHEW, 9) new Ability(Abilities.CUD_CHEW, 9)
.unimplemented(), .attr(RepeatBerryNextTurnAbAttr),
new Ability(Abilities.SHARPNESS, 9) new Ability(Abilities.SHARPNESS, 9)
.attr(MovePowerBoostAbAttr, (user, target, move) => move.hasFlag(MoveFlags.SLICING_MOVE), 1.5), .attr(MovePowerBoostAbAttr, (user, target, move) => move.hasFlag(MoveFlags.SLICING_MOVE), 1.5),
new Ability(Abilities.SUPREME_OVERLORD, 9) new Ability(Abilities.SUPREME_OVERLORD, 9)

View File

@ -768,15 +768,16 @@ class SpikesTag extends ArenaTrapTag {
} }
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (pokemon.isGrounded()) { if (!pokemon.isGrounded()) {
return false;
}
const cancelled = new BooleanHolder(false); const cancelled = new BooleanHolder(false);
applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled); applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled);
if (simulated || cancelled.value) {
if (simulated) {
return !cancelled.value; return !cancelled.value;
} }
if (!cancelled.value) {
const damageHpRatio = 1 / (10 - 2 * this.layers); const damageHpRatio = 1 / (10 - 2 * this.layers);
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio); const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
@ -786,17 +787,11 @@ class SpikesTag extends ArenaTrapTag {
}), }),
); );
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT }); pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
if (pokemon.turnData) {
pokemon.turnData.damageTaken += damage; pokemon.turnData.damageTaken += damage;
}
return true; return true;
} }
} }
return false;
}
}
/** /**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) Toxic Spikes}. * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) Toxic Spikes}.
* Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon who is * Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon who is
@ -962,17 +957,19 @@ class StealthRockTag extends ArenaTrapTag {
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
const cancelled = new BooleanHolder(false); const cancelled = new BooleanHolder(false);
applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled); applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled);
if (cancelled.value) { if (cancelled.value) {
return false; return false;
} }
const damageHpRatio = this.getDamageHpRatio(pokemon); const damageHpRatio = this.getDamageHpRatio(pokemon);
if (!damageHpRatio) {
return false;
}
if (damageHpRatio) {
if (simulated) { if (simulated) {
return true; return true;
} }
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio); const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
globalScene.queueMessage( globalScene.queueMessage(
i18next.t("arenaTag:stealthRockActivateTrap", { i18next.t("arenaTag:stealthRockActivateTrap", {
@ -980,15 +977,10 @@ class StealthRockTag extends ArenaTrapTag {
}), }),
); );
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT }); pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
if (pokemon.turnData) {
pokemon.turnData.damageTaken += damage; pokemon.turnData.damageTaken += damage;
}
return true; return true;
} }
return false;
}
getMatchupScoreMultiplier(pokemon: Pokemon): number { getMatchupScoreMultiplier(pokemon: Pokemon): number {
const damageHpRatio = this.getDamageHpRatio(pokemon); const damageHpRatio = this.getDamageHpRatio(pokemon);
return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio)); return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio));

View File

@ -1132,7 +1132,6 @@ export abstract class BattleAnim {
if (priority === 0) { if (priority === 0) {
// Place the sprite in front of the pokemon on the field. // Place the sprite in front of the pokemon on the field.
targetSprite = globalScene.getEnemyField().find(p => p) ?? globalScene.getPlayerField().find(p => p); targetSprite = globalScene.getEnemyField().find(p => p) ?? globalScene.getPlayerField().find(p => p);
console.log(typeof targetSprite);
moveFunc = globalScene.field.moveBelow; moveFunc = globalScene.field.moveBelow;
} else if (priority === 2 && this.bgSprite) { } else if (priority === 2 && this.bgSprite) {
moveFunc = globalScene.field.moveAbove; moveFunc = globalScene.field.moveAbove;

View File

@ -1,4 +1,5 @@
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import Overrides from "#app/overrides";
import { import {
applyAbAttrs, applyAbAttrs,
BlockNonDirectDamageAbAttr, BlockNonDirectDamageAbAttr,
@ -91,7 +92,12 @@ export class BattlerTag {
onOverlap(_pokemon: Pokemon): void {} onOverlap(_pokemon: Pokemon): void {}
/**
* Tick down this {@linkcode BattlerTag}'s duration.
* @returns `true` if the tag should be kept (`turnCount` > 0`)
*/
lapse(_pokemon: Pokemon, _lapseType: BattlerTagLapseType): boolean { lapse(_pokemon: Pokemon, _lapseType: BattlerTagLapseType): boolean {
// TODO: Maybe flip this (return `true` if tag needs removal)
return --this.turnCount > 0; return --this.turnCount > 0;
} }
@ -108,9 +114,9 @@ export class BattlerTag {
} }
/** /**
* When given a battler tag or json representing one, load the data for it. * Load the data for a given {@linkcode BattlerTag} or JSON representation thereof.
* This is meant to be inherited from by any battler tag with custom attributes * Should be inherited from by any battler tag with custom attributes.
* @param {BattlerTag | any} source A battler tag * @param source The battler tag to load
*/ */
loadTag(source: BattlerTag | any): void { loadTag(source: BattlerTag | any): void {
this.turnCount = source.turnCount; this.turnCount = source.turnCount;
@ -120,7 +126,7 @@ export class BattlerTag {
/** /**
* Helper function that retrieves the source Pokemon object * Helper function that retrieves the source Pokemon object
* @returns The source {@linkcode Pokemon} or `null` if none is found * @returns The source {@linkcode Pokemon}, or `null` if none is found
*/ */
public getSourcePokemon(): Pokemon | null { public getSourcePokemon(): Pokemon | null {
return this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; return this.sourceId ? globalScene.getPokemonById(this.sourceId) : null;
@ -140,8 +146,8 @@ export interface TerrainBattlerTag {
* in-game. This is not to be confused with {@linkcode Moves.DISABLE}. * in-game. This is not to be confused with {@linkcode Moves.DISABLE}.
* *
* Descendants can override {@linkcode isMoveRestricted} to restrict moves that * Descendants can override {@linkcode isMoveRestricted} to restrict moves that
* match a condition. A restricted move gets cancelled before it is used. Players and enemies should not be allowed * match a condition. A restricted move gets cancelled before it is used.
* to select restricted moves. * Players and enemies should not be allowed to select restricted moves.
*/ */
export abstract class MoveRestrictionBattlerTag extends BattlerTag { export abstract class MoveRestrictionBattlerTag extends BattlerTag {
constructor( constructor(
@ -746,9 +752,12 @@ export class ConfusedTag extends BattlerTag {
} }
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
const ret = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType); const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType);
if (!shouldLapse) {
return false;
}
if (ret) {
globalScene.queueMessage( globalScene.queueMessage(
i18next.t("battlerTags:confusedLapse", { i18next.t("battlerTags:confusedLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
@ -757,20 +766,19 @@ export class ConfusedTag extends BattlerTag {
globalScene.unshiftPhase(new CommonAnimPhase(pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION)); globalScene.unshiftPhase(new CommonAnimPhase(pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION));
// 1/3 chance of hitting self with a 40 base power move // 1/3 chance of hitting self with a 40 base power move
if (pokemon.randSeedInt(3) === 0) { if (pokemon.randSeedInt(3) === 0 || Overrides.CONFUSION_ACTIVATION_OVERRIDE === true) {
const atk = pokemon.getEffectiveStat(Stat.ATK); const atk = pokemon.getEffectiveStat(Stat.ATK);
const def = pokemon.getEffectiveStat(Stat.DEF); const def = pokemon.getEffectiveStat(Stat.DEF);
const damage = toDmgValue( const damage = toDmgValue(
((((2 * pokemon.level) / 5 + 2) * 40 * atk) / def / 50 + 2) * (pokemon.randSeedIntRange(85, 100) / 100), ((((2 * pokemon.level) / 5 + 2) * 40 * atk) / def / 50 + 2) * (pokemon.randSeedIntRange(85, 100) / 100),
); );
// Intentionally don't increment rage fist's hitCount
globalScene.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself")); globalScene.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself"));
pokemon.damageAndUpdate(damage, { result: HitResult.CONFUSION }); pokemon.damageAndUpdate(damage, { result: HitResult.CONFUSION });
pokemon.battleData.hitCount++;
(globalScene.getCurrentPhase() as MovePhase).cancel(); (globalScene.getCurrentPhase() as MovePhase).cancel();
} }
}
return ret; return true;
} }
getDescriptor(): string { getDescriptor(): string {
@ -1117,8 +1125,8 @@ export class FrenzyTag extends BattlerTag {
} }
/** /**
* Applies the effects of the move Encore onto the target Pokemon * Applies the effects of {@linkcode Moves.ENCORE} onto the target Pokemon.
* Encore forces the target Pokemon to use its most-recent move for 3 turns * Encore forces the target Pokemon to use its most-recent move for 3 turns.
*/ */
export class EncoreTag extends MoveRestrictionBattlerTag { export class EncoreTag extends MoveRestrictionBattlerTag {
public moveId: Moves; public moveId: Moves;
@ -1133,10 +1141,6 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
); );
} }
/**
* When given a battler tag or json representing one, load the data for it.
* @param {BattlerTag | any} source A battler tag
*/
loadTag(source: BattlerTag | any): void { loadTag(source: BattlerTag | any): void {
super.loadTag(source); super.loadTag(source);
this.moveId = source.moveId as Moves; this.moveId = source.moveId as Moves;

View File

@ -5,10 +5,8 @@ import { getStatusEffectHealText } from "./status-effect";
import { NumberHolder, toDmgValue, randSeedInt } from "#app/utils/common"; import { NumberHolder, toDmgValue, randSeedInt } from "#app/utils/common";
import { import {
DoubleBerryEffectAbAttr, DoubleBerryEffectAbAttr,
PostItemLostAbAttr,
ReduceBerryUseThresholdAbAttr, ReduceBerryUseThresholdAbAttr,
applyAbAttrs, applyAbAttrs,
applyPostItemLostAbAttrs,
} from "./abilities/ability"; } from "./abilities/ability";
import i18next from "i18next"; import i18next from "i18next";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
@ -70,97 +68,94 @@ export function getBerryPredicate(berryType: BerryType): BerryPredicate {
} }
} }
export type BerryEffectFunc = (pokemon: Pokemon, berryOwner?: Pokemon) => void; export type BerryEffectFunc = (consumer: Pokemon) => void;
export function getBerryEffectFunc(berryType: BerryType): BerryEffectFunc { export function getBerryEffectFunc(berryType: BerryType): BerryEffectFunc {
return (consumer: Pokemon) => {
// Apply an effect pertaining to what berry we're using
switch (berryType) { switch (berryType) {
case BerryType.SITRUS: case BerryType.SITRUS:
case BerryType.ENIGMA: case BerryType.ENIGMA:
return (pokemon: Pokemon, berryOwner?: Pokemon) => { {
if (pokemon.battleData) { const hpHealed = new NumberHolder(toDmgValue(consumer.getMaxHp() / 4));
pokemon.battleData.berriesEaten.push(berryType); applyAbAttrs(DoubleBerryEffectAbAttr, consumer, null, false, hpHealed);
}
const hpHealed = new NumberHolder(toDmgValue(pokemon.getMaxHp() / 4));
applyAbAttrs(DoubleBerryEffectAbAttr, pokemon, null, false, hpHealed);
globalScene.unshiftPhase( globalScene.unshiftPhase(
new PokemonHealPhase( new PokemonHealPhase(
pokemon.getBattlerIndex(), consumer.getBattlerIndex(),
hpHealed.value, hpHealed.value,
i18next.t("battle:hpHealBerry", { i18next.t("battle:hpHealBerry", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(consumer),
berryName: getBerryName(berryType), berryName: getBerryName(berryType),
}), }),
true, true,
), ),
); );
applyPostItemLostAbAttrs(PostItemLostAbAttr, berryOwner ?? pokemon, false); }
}; break;
case BerryType.LUM: case BerryType.LUM:
return (pokemon: Pokemon, berryOwner?: Pokemon) => { {
if (pokemon.battleData) { if (consumer.status) {
pokemon.battleData.berriesEaten.push(berryType); globalScene.queueMessage(
getStatusEffectHealText(consumer.status.effect, getPokemonNameWithAffix(consumer)),
);
} }
if (pokemon.status) { consumer.resetStatus(true, true);
globalScene.queueMessage(getStatusEffectHealText(pokemon.status.effect, getPokemonNameWithAffix(pokemon))); consumer.updateInfo();
} }
pokemon.resetStatus(true, true); break;
pokemon.updateInfo();
applyPostItemLostAbAttrs(PostItemLostAbAttr, berryOwner ?? pokemon, false);
};
case BerryType.LIECHI: case BerryType.LIECHI:
case BerryType.GANLON: case BerryType.GANLON:
case BerryType.PETAYA: case BerryType.PETAYA:
case BerryType.APICOT: case BerryType.APICOT:
case BerryType.SALAC: case BerryType.SALAC:
return (pokemon: Pokemon, berryOwner?: Pokemon) => { {
if (pokemon.battleData) { // Offset BerryType such that LIECHI --> Stat.ATK = 1, GANLON --> Stat.DEF = 2, etc etc.
pokemon.battleData.berriesEaten.push(berryType);
}
// Offset BerryType such that LIECHI -> Stat.ATK = 1, GANLON -> Stat.DEF = 2, so on and so forth
const stat: BattleStat = berryType - BerryType.ENIGMA; const stat: BattleStat = berryType - BerryType.ENIGMA;
const statStages = new NumberHolder(1); const statStages = new NumberHolder(1);
applyAbAttrs(DoubleBerryEffectAbAttr, pokemon, null, false, statStages); applyAbAttrs(DoubleBerryEffectAbAttr, consumer, null, false, statStages);
globalScene.unshiftPhase(new StatStageChangePhase(pokemon.getBattlerIndex(), true, [stat], statStages.value)); globalScene.unshiftPhase(
applyPostItemLostAbAttrs(PostItemLostAbAttr, berryOwner ?? pokemon, false); new StatStageChangePhase(consumer.getBattlerIndex(), true, [stat], statStages.value),
}; );
}
break;
case BerryType.LANSAT: case BerryType.LANSAT:
return (pokemon: Pokemon, berryOwner?: Pokemon) => { {
if (pokemon.battleData) { consumer.addTag(BattlerTagType.CRIT_BOOST);
pokemon.battleData.berriesEaten.push(berryType);
} }
pokemon.addTag(BattlerTagType.CRIT_BOOST); break;
applyPostItemLostAbAttrs(PostItemLostAbAttr, berryOwner ?? pokemon, false);
};
case BerryType.STARF: case BerryType.STARF:
return (pokemon: Pokemon, berryOwner?: Pokemon) => { {
if (pokemon.battleData) {
pokemon.battleData.berriesEaten.push(berryType);
}
const randStat = randSeedInt(Stat.SPD, Stat.ATK); const randStat = randSeedInt(Stat.SPD, Stat.ATK);
const stages = new NumberHolder(2); const stages = new NumberHolder(2);
applyAbAttrs(DoubleBerryEffectAbAttr, pokemon, null, false, stages); applyAbAttrs(DoubleBerryEffectAbAttr, consumer, null, false, stages);
globalScene.unshiftPhase(new StatStageChangePhase(pokemon.getBattlerIndex(), true, [randStat], stages.value)); globalScene.unshiftPhase(
applyPostItemLostAbAttrs(PostItemLostAbAttr, berryOwner ?? pokemon, false); new StatStageChangePhase(consumer.getBattlerIndex(), true, [randStat], stages.value),
}; );
case BerryType.LEPPA:
return (pokemon: Pokemon, berryOwner?: Pokemon) => {
if (pokemon.battleData) {
pokemon.battleData.berriesEaten.push(berryType);
} }
const ppRestoreMove = pokemon.getMoveset().find(m => !m.getPpRatio()) break;
? pokemon.getMoveset().find(m => !m.getPpRatio())
: pokemon.getMoveset().find(m => m.getPpRatio() < 1); case BerryType.LEPPA:
if (ppRestoreMove !== undefined) { {
ppRestoreMove!.ppUsed = Math.max(ppRestoreMove!.ppUsed - 10, 0); // Pick the first move completely out of PP, or else the first one that has any PP missing
const ppRestoreMove =
consumer.getMoveset().find(m => m.ppUsed === m.getMovePp()) ??
consumer.getMoveset().find(m => m.ppUsed < m.getMovePp());
if (ppRestoreMove) {
ppRestoreMove.ppUsed = Math.max(ppRestoreMove.ppUsed - 10, 0);
globalScene.queueMessage( globalScene.queueMessage(
i18next.t("battle:ppHealBerry", { i18next.t("battle:ppHealBerry", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(consumer),
moveName: ppRestoreMove!.getName(), moveName: ppRestoreMove.getName(),
berryName: getBerryName(berryType), berryName: getBerryName(berryType),
}), }),
); );
applyPostItemLostAbAttrs(PostItemLostAbAttr, berryOwner ?? pokemon, false); }
}
break;
default:
console.error("Incorrect BerryType %d passed to GetBerryEffectFunc", berryType);
} }
}; };
} }
}

View File

@ -1,4 +1,5 @@
import { BooleanHolder, type NumberHolder, randSeedItem, deepCopy } from "#app/utils/common"; import { BooleanHolder, type NumberHolder, randSeedItem } from "#app/utils/common";
import { deepCopy } from "#app/utils/data";
import i18next from "i18next"; import i18next from "i18next";
import type { DexAttrProps, GameData } from "#app/system/game-data"; import type { DexAttrProps, GameData } from "#app/system/game-data";
import { defaultStarterSpecies } from "#app/system/game-data"; import { defaultStarterSpecies } from "#app/system/game-data";

View File

@ -1,36 +1,31 @@
import type { Abilities } from "#enums/abilities"; import type { Abilities } from "#enums/abilities";
import type { PokemonType } from "#enums/pokemon-type"; import type { PokemonType } from "#enums/pokemon-type";
import { isNullOrUndefined } from "#app/utils/common";
import type { Nature } from "#enums/nature"; import type { Nature } from "#enums/nature";
/** /**
* Data that can customize a Pokemon in non-standard ways from its Species * Data that can customize a Pokemon in non-standard ways from its Species.
* Used by Mystery Encounters and Mints * Includes abilities, nature, changed types, etc.
* Also used as a counter how often a Pokemon got hit until new arena encounter
*/ */
export class CustomPokemonData { export class CustomPokemonData {
public spriteScale: number; // TODO: Change the default value for all these from -1 to something a bit more sensible
/**
* The scale at which to render this Pokemon's sprite.
*/
public spriteScale = -1;
public ability: Abilities | -1; public ability: Abilities | -1;
public passive: Abilities | -1; public passive: Abilities | -1;
public nature: Nature | -1; public nature: Nature | -1;
public types: PokemonType[]; public types: PokemonType[];
/** `hitsReceivedCount` aka `hitsRecCount` saves how often the pokemon got hit until a new arena encounter (used for Rage Fist) */ /** Deprecated but needed for session save migration */
public hitsRecCount: number; // TODO: Remove this once pre-session migration is implemented
public hitsRecCount: number | null = null;
constructor(data?: CustomPokemonData | Partial<CustomPokemonData>) { constructor(data?: CustomPokemonData | Partial<CustomPokemonData>) {
if (!isNullOrUndefined(data)) { this.spriteScale = data?.spriteScale ?? -1;
Object.assign(this, data); this.ability = data?.ability ?? -1;
} this.passive = data?.passive ?? -1;
this.nature = data?.nature ?? -1;
this.spriteScale = this.spriteScale ?? -1; this.types = data?.types ?? [];
this.ability = this.ability ?? -1; this.hitsRecCount = data?.hitsRecCount ?? null;
this.passive = this.passive ?? -1;
this.nature = this.nature ?? -1;
this.types = this.types ?? [];
this.hitsRecCount = this.hitsRecCount ?? 0;
}
resetHitReceivedCount(): void {
this.hitsRecCount = 0;
} }
} }

View File

@ -2532,10 +2532,10 @@ export class PsychoShiftEffectAttr extends MoveEffectAttr {
return !target.status && target.canSetStatus(user.status?.effect, true, false, user) ? -10 : 0; return !target.status && target.canSetStatus(user.status?.effect, true, false, user) ? -10 : 0;
} }
} }
/** /**
* The following needs to be implemented for Thief * Attribute to steal items upon this move's use.
* "If the user faints due to the target's Ability (Rough Skin or Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item." * Used for {@linkcode Moves.THIEF} and {@linkcode Moves.COVET}.
* "If Knock Off causes a Pokémon with the Sticky Hold Ability to faint, it can now remove that Pokémon's held item."
*/ */
export class StealHeldItemChanceAttr extends MoveEffectAttr { export class StealHeldItemChanceAttr extends MoveEffectAttr {
private chance: number; private chance: number;
@ -2550,19 +2550,23 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr {
if (rand >= this.chance) { if (rand >= this.chance) {
return false; return false;
} }
const heldItems = this.getTargetHeldItems(target).filter((i) => i.isTransferable); const heldItems = this.getTargetHeldItems(target).filter((i) => i.isTransferable);
if (heldItems.length) { if (!heldItems.length) {
return false;
}
const poolType = target.isPlayer() ? ModifierPoolType.PLAYER : target.hasTrainer() ? ModifierPoolType.TRAINER : ModifierPoolType.WILD; const poolType = target.isPlayer() ? ModifierPoolType.PLAYER : target.hasTrainer() ? ModifierPoolType.TRAINER : ModifierPoolType.WILD;
const highestItemTier = heldItems.map((m) => m.type.getOrInferTier(poolType)).reduce((highestTier, tier) => Math.max(tier!, highestTier), 0); // TODO: is the bang after tier correct? const highestItemTier = heldItems.map((m) => m.type.getOrInferTier(poolType)).reduce((highestTier, tier) => Math.max(tier!, highestTier), 0); // TODO: is the bang after tier correct?
const tierHeldItems = heldItems.filter((m) => m.type.getOrInferTier(poolType) === highestItemTier); const tierHeldItems = heldItems.filter((m) => m.type.getOrInferTier(poolType) === highestItemTier);
const stolenItem = tierHeldItems[user.randSeedInt(tierHeldItems.length)]; const stolenItem = tierHeldItems[user.randSeedInt(tierHeldItems.length)];
if (globalScene.tryTransferHeldItemModifier(stolenItem, user, false)) { if (!globalScene.tryTransferHeldItemModifier(stolenItem, user, false)) {
return false;
}
globalScene.queueMessage(i18next.t("moveTriggers:stoleItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: stolenItem.type.name })); globalScene.queueMessage(i18next.t("moveTriggers:stoleItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: stolenItem.type.name }));
return true; return true;
} }
}
return false;
}
getTargetHeldItems(target: Pokemon): PokemonHeldItemModifier[] { getTargetHeldItems(target: Pokemon): PokemonHeldItemModifier[] {
return globalScene.findModifiers(m => m instanceof PokemonHeldItemModifier return globalScene.findModifiers(m => m instanceof PokemonHeldItemModifier
@ -2585,59 +2589,63 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr {
* Used for Incinerate and Knock Off. * Used for Incinerate and Knock Off.
* Not Implemented Cases: (Same applies for Thief) * Not Implemented Cases: (Same applies for Thief)
* "If the user faints due to the target's Ability (Rough Skin or Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item." * "If the user faints due to the target's Ability (Rough Skin or Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item."
* "If Knock Off causes a Pokémon with the Sticky Hold Ability to faint, it can now remove that Pokémon's held item." * "If the Pokémon is knocked out by the attack, Sticky Hold does not protect the held item.""
*/ */
export class RemoveHeldItemAttr extends MoveEffectAttr { export class RemoveHeldItemAttr extends MoveEffectAttr {
/** Optional restriction for item pool to berries only i.e. Differentiating Incinerate and Knock Off */ /** Optional restriction for item pool to berries only; i.e. Incinerate */
private berriesOnly: boolean; private berriesOnly: boolean;
constructor(berriesOnly: boolean) { constructor(berriesOnly: boolean = false) {
super(false); super(false);
this.berriesOnly = berriesOnly; this.berriesOnly = berriesOnly;
} }
/** /**
* * Attempt to permanently remove a held
* @param user {@linkcode Pokemon} that used the move * @param user - The {@linkcode Pokemon} that used the move
* @param target Target {@linkcode Pokemon} that the moves applies to * @param target - The {@linkcode Pokemon} targeted by the move
* @param move {@linkcode Move} that is used * @param move - N/A
* @param args N/A * @param args N/A
* @returns True if an item was removed * @returns `true` if an item was able to be removed
*/ */
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!this.berriesOnly && target.isPlayer()) { // "Wild Pokemon cannot knock off Player Pokemon's held items" (See Bulbapedia) if (!this.berriesOnly && target.isPlayer()) { // "Wild Pokemon cannot knock off Player Pokemon's held items" (See Bulbapedia)
return false; return false;
} }
// Check for abilities that block item theft
// TODO: This should not trigger if the target would faint beforehand
const cancelled = new BooleanHolder(false); const cancelled = new BooleanHolder(false);
applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // Check for abilities that block item theft applyAbAttrs(BlockItemTheftAbAttr, target, cancelled);
if (cancelled.value === true) { if (cancelled.value) {
return false; return false;
} }
// Considers entire transferrable item pool by default (Knock Off). Otherwise berries only if specified (Incinerate). // Considers entire transferrable item pool by default (Knock Off).
// Otherwise only consider berries (Incinerate).
let heldItems = this.getTargetHeldItems(target).filter(i => i.isTransferable); let heldItems = this.getTargetHeldItems(target).filter(i => i.isTransferable);
if (this.berriesOnly) { if (this.berriesOnly) {
heldItems = heldItems.filter(m => m instanceof BerryModifier && m.pokemonId === target.id, target.isPlayer()); heldItems = heldItems.filter(m => m instanceof BerryModifier && m.pokemonId === target.id, target.isPlayer());
} }
if (heldItems.length) { if (!heldItems.length) {
return false;
}
const removedItem = heldItems[user.randSeedInt(heldItems.length)]; const removedItem = heldItems[user.randSeedInt(heldItems.length)];
// Decrease item amount and update icon // Decrease item amount and update icon
target.loseHeldItem(removedItem); target.loseHeldItem(removedItem);
globalScene.updateModifiers(target.isPlayer()); globalScene.updateModifiers(target.isPlayer());
if (this.berriesOnly) { if (this.berriesOnly) {
globalScene.queueMessage(i18next.t("moveTriggers:incineratedItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name })); globalScene.queueMessage(i18next.t("moveTriggers:incineratedItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name }));
} else { } else {
globalScene.queueMessage(i18next.t("moveTriggers:knockedOffItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name })); globalScene.queueMessage(i18next.t("moveTriggers:knockedOffItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name }));
} }
}
return true; return true;
} }
@ -2662,17 +2670,18 @@ export class RemoveHeldItemAttr extends MoveEffectAttr {
* Attribute that causes targets of the move to eat a berry. Used for Teatime, Stuff Cheeks * Attribute that causes targets of the move to eat a berry. Used for Teatime, Stuff Cheeks
*/ */
export class EatBerryAttr extends MoveEffectAttr { export class EatBerryAttr extends MoveEffectAttr {
protected chosenBerry: BerryModifier | undefined; protected chosenBerry: BerryModifier;
constructor(selfTarget: boolean) { constructor(selfTarget: boolean) {
super(selfTarget); super(selfTarget);
} }
/** /**
* Causes the target to eat a berry. * Causes the target to eat a berry.
* @param user {@linkcode Pokemon} Pokemon that used the move * @param user The {@linkcode Pokemon} Pokemon that used the move
* @param target {@linkcode Pokemon} Pokemon that will eat a berry * @param target The {@linkcode Pokemon} Pokemon that will eat the berry
* @param move {@linkcode Move} The move being used * @param move The {@linkcode Move} being used
* @param args Unused * @param args Unused
* @returns {boolean} true if the function succeeds * @returns `true` if the function succeeds
*/ */
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!super.apply(user, target, move, args)) { if (!super.apply(user, target, move, args)) {
@ -2685,6 +2694,8 @@ export class EatBerryAttr extends MoveEffectAttr {
if (heldBerries.length <= 0) { if (heldBerries.length <= 0) {
return false; return false;
} }
// pick a random berry to gobble and check if we preserve it
this.chosenBerry = heldBerries[user.randSeedInt(heldBerries.length)]; this.chosenBerry = heldBerries[user.randSeedInt(heldBerries.length)];
const preserve = new BooleanHolder(false); const preserve = new BooleanHolder(false);
// check for berry pouch preservation // check for berry pouch preservation
@ -2692,7 +2703,10 @@ export class EatBerryAttr extends MoveEffectAttr {
if (!preserve.value) { if (!preserve.value) {
this.reduceBerryModifier(pokemon); this.reduceBerryModifier(pokemon);
} }
this.eatBerry(pokemon);
// Don't update harvest for berries preserved via Berry pouch (no item dupes lol)
this.eatBerry(target, undefined, !preserve.value);
return true; return true;
} }
@ -2708,46 +2722,64 @@ export class EatBerryAttr extends MoveEffectAttr {
globalScene.updateModifiers(target.isPlayer()); globalScene.updateModifiers(target.isPlayer());
} }
eatBerry(consumer: Pokemon, berryOwner?: Pokemon) {
getBerryEffectFunc(this.chosenBerry!.berryType)(consumer, berryOwner); // consumer eats the berry /**
* Internal function to apply berry effects.
*
* @param consumer - The {@linkcode Pokemon} eating the berry; assumed to also be owner if `berryOwner` is omitted
* @param berryOwner - The {@linkcode Pokemon} whose berry is being eaten; defaults to `consumer` if not specified.
* @param updateHarvest - Whether to prevent harvest from tracking berries;
* defaults to whether `consumer` equals `berryOwner` (i.e. consuming own berry).
*/
protected eatBerry(consumer: Pokemon, berryOwner: Pokemon = consumer, updateHarvest = consumer === berryOwner) {
// consumer eats berry, owner triggers unburden and similar effects
getBerryEffectFunc(this.chosenBerry.berryType)(consumer);
applyPostItemLostAbAttrs(PostItemLostAbAttr, berryOwner, false);
applyAbAttrs(HealFromBerryUseAbAttr, consumer, new BooleanHolder(false)); applyAbAttrs(HealFromBerryUseAbAttr, consumer, new BooleanHolder(false));
consumer.recordEatenBerry(this.chosenBerry.berryType, updateHarvest);
} }
} }
/** /**
* Attribute used for moves that steal a random berry from the target. The user then eats the stolen berry. * Attribute used for moves that steal and eat a random berry from the target.
* Used for Pluck & Bug Bite. * Used for {@linkcode Moves.PLUCK} & {@linkcode Moves.BUG_BITE}.
*/ */
export class StealEatBerryAttr extends EatBerryAttr { export class StealEatBerryAttr extends EatBerryAttr {
constructor() { constructor() {
super(false); super(false);
} }
/** /**
* User steals a random berry from the target and then eats it. * User steals a random berry from the target and then eats it.
* @param user - Pokemon that used the move and will eat the stolen berry * @param user - The {@linkcode Pokemon} using the move; will eat the stolen berry
* @param target - Pokemon that will have its berry stolen * @param target - The {@linkcode Pokemon} having its berry stolen
* @param move - Move being used * @param move - The {@linkcode Move} being used
* @param args Unused * @param args N/A
* @returns true if the function succeeds * @returns `true` if the function succeeds
*/ */
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
// check for abilities that block item theft
const cancelled = new BooleanHolder(false); const cancelled = new BooleanHolder(false);
applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // check for abilities that block item theft applyAbAttrs(BlockItemTheftAbAttr, target, cancelled);
if (cancelled.value === true) { if (cancelled.value === true) {
return false; return false;
} }
// check if the target even _has_ a berry in the first place
// TODO: Check on cart if Pluck displays messages when used against sticky hold mons w/o berries
const heldBerries = this.getTargetHeldBerries(target); const heldBerries = this.getTargetHeldBerries(target);
if (heldBerries.length <= 0) { if (heldBerries.length <= 0) {
return false; return false;
} }
// if the target has berries, pick a random berry and steal it
// pick a random berry and eat it
this.chosenBerry = heldBerries[user.randSeedInt(heldBerries.length)]; this.chosenBerry = heldBerries[user.randSeedInt(heldBerries.length)];
applyPostItemLostAbAttrs(PostItemLostAbAttr, target, false); applyPostItemLostAbAttrs(PostItemLostAbAttr, target, false);
const message = i18next.t("battle:stealEatBerry", { pokemonName: user.name, targetName: target.name, berryName: this.chosenBerry.type.name }); const message = i18next.t("battle:stealEatBerry", { pokemonName: user.name, targetName: target.name, berryName: this.chosenBerry.type.name });
globalScene.queueMessage(message); globalScene.queueMessage(message);
this.reduceBerryModifier(target); this.reduceBerryModifier(target);
this.eatBerry(user, target); this.eatBerry(user, target);
return true; return true;
} }
} }
@ -4100,30 +4132,23 @@ export class FriendshipPowerAttr extends VariablePowerAttr {
/** /**
* This Attribute calculates the current power of {@linkcode Moves.RAGE_FIST}. * This Attribute calculates the current power of {@linkcode Moves.RAGE_FIST}.
* The counter for power calculation does not reset on every wave but on every new arena encounter * The counter for power calculation does not reset on every wave but on every new arena encounter.
* Self-inflicted confusion damage and hits taken by a Subsitute are ignored.
*/ */
export class RageFistPowerAttr extends VariablePowerAttr { export class RageFistPowerAttr extends VariablePowerAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const { hitCount, prevHitCount } = user.battleData; /* Reasons this works correctly:
* Confusion calls user.damageAndUpdate() directly (no counter increment),
* Substitute hits call user.damageAndUpdate() with a damage value of 0, also causing
no counter increment
*/
const hitCount = user.battleData.hitCount;
const basePower: NumberHolder = args[0]; const basePower: NumberHolder = args[0];
this.updateHitReceivedCount(user, hitCount, prevHitCount); basePower.value = 50 * (1 + Math.min(hitCount, 6));
basePower.value = 50 + (Math.min(user.customPokemonData.hitsRecCount, 6) * 50);
return true; return true;
} }
/**
* Updates the number of hits the Pokemon has taken in battle
* @param user Pokemon calling Rage Fist
* @param hitCount The number of received hits this battle
* @param previousHitCount The number of received hits this battle since last time Rage Fist was used
*/
protected updateHitReceivedCount(user: Pokemon, hitCount: number, previousHitCount: number): void {
user.customPokemonData.hitsRecCount += (hitCount - previousHitCount);
user.battleData.prevHitCount = hitCount;
}
} }
/** /**
@ -4354,10 +4379,10 @@ export class LastMoveDoublePowerAttr extends VariablePowerAttr {
const userAlly = user.getAlly(); const userAlly = user.getAlly();
const enemyAlly = enemy?.getAlly(); const enemyAlly = enemy?.getAlly();
if (!isNullOrUndefined(userAlly) && userAlly.turnData.acted) { if (userAlly?.turnData.acted) {
pokemonActed.push(userAlly); pokemonActed.push(userAlly);
} }
if (!isNullOrUndefined(enemyAlly) && enemyAlly.turnData.acted) { if (enemyAlly?.turnData.acted) {
pokemonActed.push(enemyAlly); pokemonActed.push(enemyAlly);
} }
} }
@ -4425,13 +4450,10 @@ export class CombinedPledgeStabBoostAttr extends MoveAttr {
* @extends VariablePowerAttr * @extends VariablePowerAttr
*/ */
export class RoundPowerAttr extends VariablePowerAttr { export class RoundPowerAttr extends VariablePowerAttr {
override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { override apply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean {
const power = args[0]; const power = args[0];
if (!(power instanceof NumberHolder)) {
return false;
}
if (user.turnData?.joinedRound) { if (user.turnData.joinedRound) {
power.value *= 2; power.value *= 2;
return true; return true;
} }
@ -7764,17 +7786,27 @@ export class StatusIfBoostedAttr extends MoveEffectAttr {
} }
} }
/**
* Attribute to fail move usage unless all of the user's other moves have been used at least once.
* Used by {@linkcode Moves.LAST_RESORT}.
*/
export class LastResortAttr extends MoveAttr { export class LastResortAttr extends MoveAttr {
// TODO: Verify behavior as Bulbapedia page is _extremely_ poorly documented
getCondition(): MoveConditionFunc { getCondition(): MoveConditionFunc {
return (user: Pokemon, target: Pokemon, move: Move) => { return (user: Pokemon, _target: Pokemon, move: Move) => {
const uniqueUsedMoveIds = new Set<Moves>(); const movesInMoveset = new Set<Moves>(user.getMoveset().map(m => m.moveId));
const movesetMoveIds = user.getMoveset().map(m => m.moveId); if (!movesInMoveset.delete(move.id) || !movesInMoveset.size) {
user.getMoveHistory().map(m => { return false; // Last resort fails if used when not in user's moveset or no other moves exist
if (m.move !== move.id && movesetMoveIds.find(mm => mm === m.move)) {
uniqueUsedMoveIds.add(m.move);
} }
});
return uniqueUsedMoveIds.size >= movesetMoveIds.length - 1; const movesInHistory = new Set(
user.getMoveHistory()
.filter(m => !m.virtual) // TODO: Change to (m) => m < MoveUseType.INDIRECT after Dancer PR refactors virtual into enum
.map(m => m.move)
);
// Since `Set.intersection()` is only present in ESNext, we have to coerce it to an array to check inclusion
return [...movesInMoveset].every(m => movesInHistory.has(m))
}; };
} }
} }
@ -7982,13 +8014,18 @@ export class MoveCondition {
} }
} }
/**
* Condition to allow a move's use only on the first turn this Pokemon is sent into battle
* (or the start of a new wave, whichever comes first).
*/
export class FirstMoveCondition extends MoveCondition { export class FirstMoveCondition extends MoveCondition {
constructor() { constructor() {
super((user, target, move) => user.battleSummonData?.waveTurnCount === 1); super((user, _target, _move) => user.tempSummonData.waveTurnCount === 1);
} }
getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { getUserBenefitScore(user: Pokemon, _target: Pokemon, _move: Move): number {
return this.apply(user, target, move) ? 10 : -20; return this.apply(user, _target, _move) ? 10 : -20;
} }
} }
@ -8626,7 +8663,7 @@ export function initMoves() {
new StatusMove(Moves.TRANSFORM, PokemonType.NORMAL, -1, 10, -1, 0, 1) new StatusMove(Moves.TRANSFORM, PokemonType.NORMAL, -1, 10, -1, 0, 1)
.attr(TransformAttr) .attr(TransformAttr)
.condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE)) .condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE))
.condition((user, target, move) => !target.summonData?.illusion && !user.summonData?.illusion) .condition((user, target, move) => !target.summonData.illusion && !user.summonData.illusion)
// transforming from or into fusion pokemon causes various problems (such as crashes) // transforming from or into fusion pokemon causes various problems (such as crashes)
.condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE) && !user.fusionSpecies && !target.fusionSpecies) .condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE) && !user.fusionSpecies && !target.fusionSpecies)
.ignoresProtect() .ignoresProtect()
@ -8701,7 +8738,10 @@ export function initMoves() {
.attr(MultiHitPowerIncrementAttr, 3) .attr(MultiHitPowerIncrementAttr, 3)
.checkAllHits(), .checkAllHits(),
new AttackMove(Moves.THIEF, PokemonType.DARK, MoveCategory.PHYSICAL, 60, 100, 25, -1, 0, 2) new AttackMove(Moves.THIEF, PokemonType.DARK, MoveCategory.PHYSICAL, 60, 100, 25, -1, 0, 2)
.attr(StealHeldItemChanceAttr, 0.3), .attr(StealHeldItemChanceAttr, 0.3)
.edgeCase(),
// Should not be able to steal held item if user faints due to Rough Skin, Iron Barbs, etc.
// Should be able to steal items from pokemon with Sticky Hold if the damage causes them to faint
new StatusMove(Moves.SPIDER_WEB, PokemonType.BUG, -1, 10, -1, 0, 2) new StatusMove(Moves.SPIDER_WEB, PokemonType.BUG, -1, 10, -1, 0, 2)
.condition(failIfGhostTypeCondition) .condition(failIfGhostTypeCondition)
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1) .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1)
@ -8991,6 +9031,7 @@ export function initMoves() {
.soundBased() .soundBased()
.target(MoveTarget.RANDOM_NEAR_ENEMY) .target(MoveTarget.RANDOM_NEAR_ENEMY)
.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)
new SelfStatusMove(Moves.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3) new SelfStatusMove(Moves.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3)
.condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3) .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3)
.attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true), .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
@ -9088,7 +9129,10 @@ export function initMoves() {
.reflectable(), .reflectable(),
new AttackMove(Moves.KNOCK_OFF, PokemonType.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3) new AttackMove(Moves.KNOCK_OFF, PokemonType.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1) .attr(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1)
.attr(RemoveHeldItemAttr, false), .attr(RemoveHeldItemAttr, false)
.edgeCase(),
// Should not be able to remove held item if user faints due to Rough Skin, Iron Barbs, etc.
// Should be able to remove items from pokemon with Sticky Hold if the damage causes them to faint
new AttackMove(Moves.ENDEAVOR, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 3) new AttackMove(Moves.ENDEAVOR, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 3)
.attr(MatchHpAttr) .attr(MatchHpAttr)
.condition(failOnBossCondition), .condition(failOnBossCondition),
@ -9276,7 +9320,10 @@ export function initMoves() {
.attr(HighCritAttr) .attr(HighCritAttr)
.attr(StatusEffectAttr, StatusEffect.POISON), .attr(StatusEffectAttr, StatusEffect.POISON),
new AttackMove(Moves.COVET, PokemonType.NORMAL, MoveCategory.PHYSICAL, 60, 100, 25, -1, 0, 3) new AttackMove(Moves.COVET, PokemonType.NORMAL, MoveCategory.PHYSICAL, 60, 100, 25, -1, 0, 3)
.attr(StealHeldItemChanceAttr, 0.3), .attr(StealHeldItemChanceAttr, 0.3)
.edgeCase(),
// Should not be able to steal held item if user faints due to Rough Skin, Iron Barbs, etc.
// Should be able to steal items from pokemon with Sticky Hold if the damage causes them to faint
new AttackMove(Moves.VOLT_TACKLE, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 3) new AttackMove(Moves.VOLT_TACKLE, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 3)
.attr(RecoilAttr, false, 0.33) .attr(RecoilAttr, false, 0.33)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS) .attr(StatusEffectAttr, StatusEffect.PARALYSIS)
@ -9338,6 +9385,11 @@ export function initMoves() {
new AttackMove(Moves.NATURAL_GIFT, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 4) new AttackMove(Moves.NATURAL_GIFT, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 4)
.makesContact(false) .makesContact(false)
.unimplemented(), .unimplemented(),
/*
NOTE: To whoever tries to implement this, reminder to push to battleData.berriesEaten
and enable the harvest test..
Do NOT push to berriesEatenLast or else cud chew will puke the berry.
*/
new AttackMove(Moves.FEINT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 30, 100, 10, -1, 2, 4) new AttackMove(Moves.FEINT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 30, 100, 10, -1, 2, 4)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.PROTECTED ]) .attr(RemoveBattlerTagAttr, [ BattlerTagType.PROTECTED ])
.attr(RemoveArenaTagsAttr, [ ArenaTagType.QUICK_GUARD, ArenaTagType.WIDE_GUARD, ArenaTagType.MAT_BLOCK, ArenaTagType.CRAFTY_SHIELD ], false) .attr(RemoveArenaTagsAttr, [ ArenaTagType.QUICK_GUARD, ArenaTagType.WIDE_GUARD, ArenaTagType.MAT_BLOCK, ArenaTagType.CRAFTY_SHIELD ], false)
@ -9415,7 +9467,8 @@ export function initMoves() {
.makesContact(true) .makesContact(true)
.attr(PunishmentPowerAttr), .attr(PunishmentPowerAttr),
new AttackMove(Moves.LAST_RESORT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 140, 100, 5, -1, 0, 4) new AttackMove(Moves.LAST_RESORT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 140, 100, 5, -1, 0, 4)
.attr(LastResortAttr), .attr(LastResortAttr)
.edgeCase(), // May or may not need to ignore remotely called moves depending on how it works
new StatusMove(Moves.WORRY_SEED, PokemonType.GRASS, 100, 10, -1, 0, 4) new StatusMove(Moves.WORRY_SEED, PokemonType.GRASS, 100, 10, -1, 0, 4)
.attr(AbilityChangeAttr, Abilities.INSOMNIA) .attr(AbilityChangeAttr, Abilities.INSOMNIA)
.reflectable(), .reflectable(),
@ -9782,7 +9835,9 @@ export function initMoves() {
.hidesTarget(), .hidesTarget(),
new AttackMove(Moves.INCINERATE, PokemonType.FIRE, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5) new AttackMove(Moves.INCINERATE, PokemonType.FIRE, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5)
.target(MoveTarget.ALL_NEAR_ENEMIES) .target(MoveTarget.ALL_NEAR_ENEMIES)
.attr(RemoveHeldItemAttr, true), .attr(RemoveHeldItemAttr, true)
.edgeCase(),
// Should be able to remove items from pokemon with Sticky Hold if the damage causes them to faint
new StatusMove(Moves.QUASH, PokemonType.DARK, 100, 15, -1, 0, 5) new StatusMove(Moves.QUASH, PokemonType.DARK, 100, 15, -1, 0, 5)
.condition(failIfSingleBattle) .condition(failIfSingleBattle)
.condition((user, target, move) => !target.turnData.acted) .condition((user, target, move) => !target.turnData.acted)
@ -9957,7 +10012,7 @@ export function initMoves() {
.condition(new FirstMoveCondition()) .condition(new FirstMoveCondition())
.condition(failIfLastCondition), .condition(failIfLastCondition),
new AttackMove(Moves.BELCH, PokemonType.POISON, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 6) new AttackMove(Moves.BELCH, PokemonType.POISON, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 6)
.condition((user, target, move) => user.battleData.berriesEaten.length > 0), .condition((user, target, move) => user.battleData.hasEatenBerry),
new StatusMove(Moves.ROTOTILLER, PokemonType.GROUND, -1, 10, -1, 0, 6) new StatusMove(Moves.ROTOTILLER, PokemonType.GROUND, -1, 10, -1, 0, 6)
.target(MoveTarget.ALL) .target(MoveTarget.ALL)
.condition((user, target, move) => { .condition((user, target, move) => {
@ -11083,7 +11138,6 @@ export function initMoves() {
new AttackMove(Moves.TWIN_BEAM, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 40, 100, 10, -1, 0, 9) new AttackMove(Moves.TWIN_BEAM, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 40, 100, 10, -1, 0, 9)
.attr(MultiHitAttr, MultiHitType._2), .attr(MultiHitAttr, MultiHitType._2),
new AttackMove(Moves.RAGE_FIST, PokemonType.GHOST, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 9) new AttackMove(Moves.RAGE_FIST, PokemonType.GHOST, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 9)
.edgeCase() // Counter incorrectly increases on confusion self-hits
.attr(RageFistPowerAttr) .attr(RageFistPowerAttr)
.punchingMove(), .punchingMove(),
new AttackMove(Moves.ARMOR_CANNON, PokemonType.FIRE, MoveCategory.SPECIAL, 120, 100, 5, -1, 0, 9) new AttackMove(Moves.ARMOR_CANNON, PokemonType.FIRE, MoveCategory.SPECIAL, 120, 100, 5, -1, 0, 9)

View File

@ -397,9 +397,6 @@ export const ClowningAroundEncounter: MysteryEncounter = MysteryEncounterBuilder
newTypes.push(secondType); newTypes.push(secondType);
// Apply the type changes (to both base and fusion, if pokemon is fused) // Apply the type changes (to both base and fusion, if pokemon is fused)
if (!pokemon.customPokemonData) {
pokemon.customPokemonData = new CustomPokemonData();
}
pokemon.customPokemonData.types = newTypes; pokemon.customPokemonData.types = newTypes;
if (pokemon.isFusion()) { if (pokemon.isFusion()) {
if (!pokemon.fusionCustomPokemonData) { if (!pokemon.fusionCustomPokemonData) {

View File

@ -684,7 +684,7 @@ function doPokemonTradeSequence(tradedPokemon: PlayerPokemon, receivedPokemon: P
sprite.setPipelineData("shiny", tradedPokemon.shiny); sprite.setPipelineData("shiny", tradedPokemon.shiny);
sprite.setPipelineData("variant", tradedPokemon.variant); sprite.setPipelineData("variant", tradedPokemon.variant);
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
if (tradedPokemon.summonData?.speciesForm) { if (tradedPokemon.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
sprite.pipelineData[k] = tradedPokemon.getSprite().pipelineData[k]; sprite.pipelineData[k] = tradedPokemon.getSprite().pipelineData[k];
@ -710,7 +710,7 @@ function doPokemonTradeSequence(tradedPokemon: PlayerPokemon, receivedPokemon: P
sprite.setPipelineData("shiny", receivedPokemon.shiny); sprite.setPipelineData("shiny", receivedPokemon.shiny);
sprite.setPipelineData("variant", receivedPokemon.variant); sprite.setPipelineData("variant", receivedPokemon.variant);
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
if (receivedPokemon.summonData?.speciesForm) { if (receivedPokemon.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
sprite.pipelineData[k] = receivedPokemon.getSprite().pipelineData[k]; sprite.pipelineData[k] = receivedPokemon.getSprite().pipelineData[k];

View File

@ -222,7 +222,8 @@ function endTrainerBattleAndShowDialogue(): Promise<void> {
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeAbilityTrigger); globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeAbilityTrigger);
} }
pokemon.resetBattleData(); // Each trainer battle is supposed to be a new fight, so reset all per-battle activation effects
pokemon.resetBattleAndWaveData();
applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon); applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon);
} }

View File

@ -23,7 +23,6 @@ import { allSpecies, getPokemonSpecies } from "#app/data/pokemon-species";
import type { PokemonHeldItemModifier } from "#app/modifier/modifier"; import type { PokemonHeldItemModifier } from "#app/modifier/modifier";
import { HiddenAbilityRateBoosterModifier, PokemonFormChangeItemModifier } from "#app/modifier/modifier"; import { HiddenAbilityRateBoosterModifier, PokemonFormChangeItemModifier } from "#app/modifier/modifier";
import { achvs } from "#app/system/achv"; import { achvs } from "#app/system/achv";
import { CustomPokemonData } from "#app/data/custom-pokemon-data";
import { showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; import { showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils";
import type { PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; import type { PokemonHeldItemModifierType } from "#app/modifier/modifier-type";
import { modifierTypes } from "#app/modifier/modifier-type"; import { modifierTypes } from "#app/modifier/modifier-type";
@ -601,9 +600,6 @@ async function postProcessTransformedPokemon(
newType = randSeedInt(18) as PokemonType; newType = randSeedInt(18) as PokemonType;
} }
newTypes.push(newType); newTypes.push(newType);
if (!newPokemon.customPokemonData) {
newPokemon.customPokemonData = new CustomPokemonData();
}
newPokemon.customPokemonData.types = newTypes; newPokemon.customPokemonData.types = newTypes;
// Enable passive if previous had it // Enable passive if previous had it

View File

@ -10,7 +10,7 @@ import {
import { showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; import { showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils";
import type { AiType, PlayerPokemon } from "#app/field/pokemon"; import type { AiType, PlayerPokemon } from "#app/field/pokemon";
import type Pokemon from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon";
import { EnemyPokemon, FieldPosition, PokemonMove, PokemonSummonData } from "#app/field/pokemon"; import { EnemyPokemon, FieldPosition, PokemonMove } from "#app/field/pokemon";
import type { CustomModifierSettings, ModifierType } from "#app/modifier/modifier-type"; import type { CustomModifierSettings, ModifierType } from "#app/modifier/modifier-type";
import { import {
getPartyLuckValue, getPartyLuckValue,
@ -348,11 +348,6 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig):
enemyPokemon.status = new Status(status, 0, cureTurn); enemyPokemon.status = new Status(status, 0, cureTurn);
} }
// Set summon data fields
if (!enemyPokemon.summonData) {
enemyPokemon.summonData = new PokemonSummonData();
}
// Set ability // Set ability
if (!isNullOrUndefined(config.abilityIndex)) { if (!isNullOrUndefined(config.abilityIndex)) {
enemyPokemon.abilityIndex = config.abilityIndex; enemyPokemon.abilityIndex = config.abilityIndex;
@ -390,14 +385,11 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig):
} }
} }
// mysteryEncounterBattleEffects will only be used IFF MYSTERY_ENCOUNTER_POST_SUMMON tag is applied // mysteryEncounterBattleEffects will only be used if MYSTERY_ENCOUNTER_POST_SUMMON tag is applied
if (config.mysteryEncounterBattleEffects) { if (config.mysteryEncounterBattleEffects) {
enemyPokemon.mysteryEncounterBattleEffects = config.mysteryEncounterBattleEffects; enemyPokemon.mysteryEncounterBattleEffects = config.mysteryEncounterBattleEffects;
} }
// Requires re-priming summon data to update everything properly
enemyPokemon.primeSummonData(enemyPokemon.summonData);
if (enemyPokemon.isShiny() && !enemyPokemon["shinySparkle"]) { if (enemyPokemon.isShiny() && !enemyPokemon["shinySparkle"]) {
enemyPokemon.initShinySparkle(); enemyPokemon.initShinySparkle();
} }

View File

@ -1031,9 +1031,6 @@ export function applyAbilityOverrideToPokemon(pokemon: Pokemon, ability: Abiliti
} }
pokemon.fusionCustomPokemonData.ability = ability; pokemon.fusionCustomPokemonData.ability = ability;
} else { } else {
if (!pokemon.customPokemonData) {
pokemon.customPokemonData = new CustomPokemonData();
}
pokemon.customPokemonData.ability = ability; pokemon.customPokemonData.ability = ability;
} }
} }

View File

@ -88,7 +88,7 @@ export function doPokemonTransformationSequence(
sprite.setPipelineData("shiny", previousPokemon.shiny); sprite.setPipelineData("shiny", previousPokemon.shiny);
sprite.setPipelineData("variant", previousPokemon.variant); sprite.setPipelineData("variant", previousPokemon.variant);
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
if (previousPokemon.summonData?.speciesForm) { if (previousPokemon.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
sprite.pipelineData[k] = previousPokemon.getSprite().pipelineData[k]; sprite.pipelineData[k] = previousPokemon.getSprite().pipelineData[k];
@ -108,7 +108,7 @@ export function doPokemonTransformationSequence(
sprite.setPipelineData("shiny", transformPokemon.shiny); sprite.setPipelineData("shiny", transformPokemon.shiny);
sprite.setPipelineData("variant", transformPokemon.variant); sprite.setPipelineData("variant", transformPokemon.variant);
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
if (transformPokemon.summonData?.speciesForm) { if (transformPokemon.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
sprite.pipelineData[k] = transformPokemon.getSprite().pipelineData[k]; sprite.pipelineData[k] = transformPokemon.getSprite().pipelineData[k];

View File

@ -33,6 +33,7 @@ import { SpeciesFormKey } from "#enums/species-form-key";
import { starterPassiveAbilities } from "#app/data/balance/passives"; import { starterPassiveAbilities } from "#app/data/balance/passives";
import { loadPokemonVariantAssets } from "#app/sprites/pokemon-sprite"; import { loadPokemonVariantAssets } from "#app/sprites/pokemon-sprite";
import { hasExpSprite } from "#app/sprites/sprite-utils"; import { hasExpSprite } from "#app/sprites/sprite-utils";
import { Gender } from "./gender";
export enum Region { export enum Region {
NORMAL, NORMAL,
@ -485,10 +486,10 @@ export abstract class PokemonSpeciesForm {
break; break;
case Species.ZACIAN: case Species.ZACIAN:
case Species.ZAMAZENTA: case Species.ZAMAZENTA:
// biome-ignore lint/suspicious/noFallthroughSwitchClause: Falls through
if (formSpriteKey.startsWith("behemoth")) { if (formSpriteKey.startsWith("behemoth")) {
formSpriteKey = "crowned"; formSpriteKey = "crowned";
} }
// biome-ignore lint/suspicious/no-fallthrough: Falls through
default: default:
ret += `-${formSpriteKey}`; ret += `-${formSpriteKey}`;
break; break;
@ -749,7 +750,7 @@ export abstract class PokemonSpeciesForm {
let paletteColors: Map<number, number> = new Map(); let paletteColors: Map<number, number> = new Map();
const originalRandom = Math.random; const originalRandom = Math.random;
Math.random = () => Phaser.Math.RND.realInRange(0, 1); Math.random = Phaser.Math.RND.frac;
globalScene.executeWithSeedOffset( globalScene.executeWithSeedOffset(
() => { () => {
@ -879,6 +880,21 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
return this.name; return this.name;
} }
/**
* Pick and return a random {@linkcode Gender} for a {@linkcode Pokemon}.
* @returns A randomly rolled gender based on this Species' {@linkcode malePercent}.
*/
generateGender(): Gender {
if (isNullOrUndefined(this.malePercent)) {
return Gender.GENDERLESS;
}
if (Phaser.Math.RND.realInRange(0, 1) <= this.malePercent) {
return Gender.MALE;
}
return Gender.FEMALE;
}
/** /**
* Find the name of species with proper attachments for regionals and separate starter forms (Floette, Ursaluna) * Find the name of species with proper attachments for regionals and separate starter forms (Floette, Ursaluna)
* @returns a string with the region name or other form name attached * @returns a string with the region name or other form name attached

View File

@ -1,4 +1,5 @@
export enum Biome { export enum Biome {
// TODO: Should -1 be part of the enum signature (for "unknown place")
TOWN, TOWN,
PLAINS, PLAINS,
GRASS, GRASS,

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import Phaser from "phaser"; import Phaser from "phaser";
import { deepCopy, getEnumValues } from "#app/utils/common"; import { getEnumValues } from "#app/utils/common";
import { deepCopy } from "#app/utils/data";
import pad_generic from "./configs/inputs/pad_generic"; import pad_generic from "./configs/inputs/pad_generic";
import pad_unlicensedSNES from "./configs/inputs/pad_unlicensedSNES"; import pad_unlicensedSNES from "./configs/inputs/pad_unlicensedSNES";
import pad_xbox360 from "./configs/inputs/pad_xbox360"; import pad_xbox360 from "./configs/inputs/pad_xbox360";

View File

@ -790,6 +790,7 @@ export class BerryModifierType extends PokemonHeldItemModifierType implements Ge
); );
this.berryType = berryType; this.berryType = berryType;
this.id = "BERRY"; // needed to prevent harvest item deletion; remove after modifier rework
} }
get name(): string { get name(): string {

View File

@ -47,7 +47,12 @@ import {
} from "./modifier-type"; } from "./modifier-type";
import { Color, ShadowColor } from "#enums/color"; import { Color, ShadowColor } from "#enums/color";
import { FRIENDSHIP_GAIN_FROM_RARE_CANDY } from "#app/data/balance/starters"; import { FRIENDSHIP_GAIN_FROM_RARE_CANDY } from "#app/data/balance/starters";
import { applyAbAttrs, CommanderAbAttr } from "#app/data/abilities/ability"; import {
applyAbAttrs,
applyPostItemLostAbAttrs,
CommanderAbAttr,
PostItemLostAbAttr,
} from "#app/data/abilities/ability";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
export type ModifierPredicate = (modifier: Modifier) => boolean; export type ModifierPredicate = (modifier: Modifier) => boolean;
@ -232,6 +237,10 @@ export abstract class PersistentModifier extends Modifier {
abstract getMaxStackCount(forThreshold?: boolean): number; abstract getMaxStackCount(forThreshold?: boolean): number;
getCountUnderMax(): number {
return this.getMaxStackCount() - this.getStackCount();
}
isIconVisible(): boolean { isIconVisible(): boolean {
return true; return true;
} }
@ -653,7 +662,9 @@ export class TerastallizeAccessModifier extends PersistentModifier {
} }
export abstract class PokemonHeldItemModifier extends PersistentModifier { export abstract class PokemonHeldItemModifier extends PersistentModifier {
/** The ID of the {@linkcode Pokemon} that this item belongs to. */
public pokemonId: number; public pokemonId: number;
/** Whether this item can be transfered to or stolen by another Pokemon. */
public isTransferable = true; public isTransferable = true;
constructor(type: ModifierType, pokemonId: number, stackCount?: number) { constructor(type: ModifierType, pokemonId: number, stackCount?: number) {
@ -1639,14 +1650,15 @@ export class FlinchChanceModifier extends PokemonHeldItemModifier {
} }
/** /**
* Applies {@linkcode FlinchChanceModifier} * Applies {@linkcode FlinchChanceModifier} to randomly flinch targets hit.
* @param pokemon the {@linkcode Pokemon} that holds the item * @param pokemon - The {@linkcode Pokemon} that holds the item
* @param flinched {@linkcode BooleanHolder} that is `true` if the pokemon flinched * @param flinched - A {@linkcode BooleanHolder} holding whether the pokemon has flinched
* @returns `true` if {@linkcode FlinchChanceModifier} has been applied * @returns `true` if {@linkcode FlinchChanceModifier} was applied successfully
*/ */
override apply(pokemon: Pokemon, flinched: BooleanHolder): boolean { override apply(pokemon: Pokemon, flinched: BooleanHolder): boolean {
// The check for pokemon.battleSummonData is to ensure that a crash doesn't occur when a Pokemon with King's Rock procs a flinch // The check for pokemon.summonData is to ensure that a crash doesn't occur when a Pokemon with King's Rock procs a flinch
if (pokemon.battleSummonData && !flinched.value && pokemon.randSeedInt(100) < this.getStackCount() * this.chance) { // TODO: Since summonData is always defined now, we can probably remove this
if (pokemon.summonData && !flinched.value && pokemon.randSeedInt(100) < this.getStackCount() * this.chance) {
flinched.value = true; flinched.value = true;
return true; return true;
} }
@ -1772,6 +1784,7 @@ 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.unshiftPhase( globalScene.unshiftPhase(
new PokemonHealPhase( new PokemonHealPhase(
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),
@ -1867,11 +1880,15 @@ export class BerryModifier extends PokemonHeldItemModifier {
override apply(pokemon: Pokemon): boolean { override apply(pokemon: Pokemon): boolean {
const preserve = new BooleanHolder(false); const preserve = new BooleanHolder(false);
globalScene.applyModifiers(PreserveBerryModifier, pokemon.isPlayer(), pokemon, preserve); globalScene.applyModifiers(PreserveBerryModifier, pokemon.isPlayer(), pokemon, preserve);
this.consumed = !preserve.value;
// munch the berry and trigger unburden-like effects
getBerryEffectFunc(this.berryType)(pokemon); getBerryEffectFunc(this.berryType)(pokemon);
if (!preserve.value) { applyPostItemLostAbAttrs(PostItemLostAbAttr, pokemon, false);
this.consumed = true;
} // Update berry eaten trackers for Belch, Harvest, Cud Chew, etc.
// Don't recover it if we proc berry pouch (no item duplication)
pokemon.recordEatenBerry(this.berryType, this.consumed);
return true; return true;
} }
@ -1910,9 +1927,7 @@ export class PreserveBerryModifier extends PersistentModifier {
* @returns always `true` * @returns always `true`
*/ */
override apply(pokemon: Pokemon, doPreserve: BooleanHolder): boolean { override apply(pokemon: Pokemon, doPreserve: BooleanHolder): boolean {
if (!doPreserve.value) { doPreserve.value ||= pokemon.randSeedInt(10) < this.getStackCount() * 3;
doPreserve.value = pokemon.randSeedInt(10) < this.getStackCount() * 3;
}
return true; return true;
} }
@ -3716,13 +3731,13 @@ export class EnemyEndureChanceModifier extends EnemyPersistentModifier {
* @returns `true` if {@linkcode Pokemon} endured * @returns `true` if {@linkcode Pokemon} endured
*/ */
override apply(target: Pokemon): boolean { override apply(target: Pokemon): boolean {
if (target.battleData.endured || target.randSeedInt(100) >= this.chance * this.getStackCount()) { if (target.waveData.endured || target.randSeedInt(100) >= this.chance * this.getStackCount()) {
return false; return false;
} }
target.addTag(BattlerTagType.ENDURE_TOKEN, 1); target.addTag(BattlerTagType.ENDURE_TOKEN, 1);
target.battleData.endured = true; target.waveData.endured = true;
return true; return true;
} }

View File

@ -104,8 +104,16 @@ class DefaultOverrides {
readonly BYPASS_TUTORIAL_SKIP_OVERRIDE: boolean = false; readonly BYPASS_TUTORIAL_SKIP_OVERRIDE: boolean = false;
/** Set to `true` to be able to re-earn already unlocked achievements */ /** Set to `true` to be able to re-earn already unlocked achievements */
readonly ACHIEVEMENTS_REUNLOCK_OVERRIDE: boolean = false; readonly ACHIEVEMENTS_REUNLOCK_OVERRIDE: boolean = false;
/** Set to `true` to force Paralysis and Freeze to always activate, or `false` to force them to not activate */ /**
* Set to `true` to force Paralysis and Freeze to always activate,
* or `false` to force them to not activate (or clear for freeze).
*/
readonly STATUS_ACTIVATION_OVERRIDE: boolean | null = null; readonly STATUS_ACTIVATION_OVERRIDE: boolean | null = null;
/**
* Set to `true` to force confusion to always trigger,
* or `false` to force it to never trigger.
*/
readonly CONFUSION_ACTIVATION_OVERRIDE: boolean | null = null;
// ---------------- // ----------------
// PLAYER OVERRIDES // PLAYER OVERRIDES

View File

@ -59,8 +59,8 @@ export class BattleEndPhase extends BattlePhase {
} }
for (const pokemon of globalScene.getField()) { for (const pokemon of globalScene.getField()) {
if (pokemon?.battleSummonData) { if (pokemon) {
pokemon.battleSummonData.waveTurnCount = 1; pokemon.tempSummonData.waveTurnCount = 1;
} }
} }

View File

@ -1,4 +1,9 @@
import { applyAbAttrs, PreventBerryUseAbAttr, HealFromBerryUseAbAttr } from "#app/data/abilities/ability"; import {
applyAbAttrs,
PreventBerryUseAbAttr,
HealFromBerryUseAbAttr,
RepeatBerryNextTurnAbAttr,
} from "#app/data/abilities/ability";
import { CommonAnim } from "#app/data/battle-anims"; import { CommonAnim } from "#app/data/battle-anims";
import { BerryUsedEvent } from "#app/events/battle-scene"; import { BerryUsedEvent } from "#app/events/battle-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
@ -8,47 +13,65 @@ import { BooleanHolder } from "#app/utils/common";
import { FieldPhase } from "./field-phase"; import { FieldPhase } from "./field-phase";
import { CommonAnimPhase } from "./common-anim-phase"; import { CommonAnimPhase } from "./common-anim-phase";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import type Pokemon from "#app/field/pokemon";
/** The phase after attacks where the pokemon eat berries */ /**
* The phase after attacks where the pokemon eat berries.
* Also triggers Cud Chew's "repeat berry use" effects
*/
export class BerryPhase extends FieldPhase { export class BerryPhase extends FieldPhase {
start() { start() {
super.start(); super.start();
this.executeForAll(pokemon => { this.executeForAll(pokemon => {
const hasUsableBerry = !!globalScene.findModifier(m => { this.eatBerries(pokemon);
return m instanceof BerryModifier && m.shouldApply(pokemon); applyAbAttrs(RepeatBerryNextTurnAbAttr, pokemon, null);
}, pokemon.isPlayer()); });
if (hasUsableBerry) { this.end();
}
/**
* Attempt to eat all of a given {@linkcode Pokemon}'s berries once.
* @param pokemon - The {@linkcode Pokemon} to check
*/
eatBerries(pokemon: Pokemon): void {
const hasUsableBerry = !!globalScene.findModifier(
m => m instanceof BerryModifier && m.shouldApply(pokemon),
pokemon.isPlayer(),
);
if (!hasUsableBerry) {
return;
}
// TODO: If both opponents on field have unnerve, which one displays its message?
const cancelled = new BooleanHolder(false); const cancelled = new BooleanHolder(false);
pokemon.getOpponents().map(opp => applyAbAttrs(PreventBerryUseAbAttr, opp, cancelled)); pokemon.getOpponents().forEach(opp => applyAbAttrs(PreventBerryUseAbAttr, opp, cancelled));
if (cancelled.value) { if (cancelled.value) {
globalScene.queueMessage( globalScene.queueMessage(
i18next.t("abilityTriggers:preventBerryUse", { i18next.t("abilityTriggers:preventBerryUse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}), }),
); );
} else { return;
}
globalScene.unshiftPhase( globalScene.unshiftPhase(
new CommonAnimPhase(pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.USE_ITEM), new CommonAnimPhase(pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.USE_ITEM),
); );
for (const berryModifier of globalScene.applyModifiers(BerryModifier, pokemon.isPlayer(), pokemon)) { for (const berryModifier of globalScene.applyModifiers(BerryModifier, pokemon.isPlayer(), pokemon)) {
// No need to track berries being eaten; already done inside applyModifiers
if (berryModifier.consumed) { if (berryModifier.consumed) {
berryModifier.consumed = false; berryModifier.consumed = false;
pokemon.loseHeldItem(berryModifier); pokemon.loseHeldItem(berryModifier);
} }
globalScene.eventTarget.dispatchEvent(new BerryUsedEvent(berryModifier)); // Announce a berry was used globalScene.eventTarget.dispatchEvent(new BerryUsedEvent(berryModifier));
} }
globalScene.updateModifiers(pokemon.isPlayer()); globalScene.updateModifiers(pokemon.isPlayer());
// Abilities.CHEEK_POUCH only works once per round of nom noms
applyAbAttrs(HealFromBerryUseAbAttr, pokemon, new BooleanHolder(false)); applyAbAttrs(HealFromBerryUseAbAttr, pokemon, new BooleanHolder(false));
} }
} }
});
this.end();
}
}

View File

@ -113,12 +113,6 @@ export class EncounterPhase extends BattlePhase {
} }
if (!this.loaded) { if (!this.loaded) {
if (battle.battleType === BattleType.TRAINER) { if (battle.battleType === BattleType.TRAINER) {
//resets hitRecCount during Trainer ecnounter
for (const pokemon of globalScene.getPlayerParty()) {
if (pokemon) {
pokemon.customPokemonData.resetHitReceivedCount();
}
}
battle.enemyParty[e] = battle.trainer?.genPartyMember(e)!; // TODO:: is the bang correct here? battle.enemyParty[e] = battle.trainer?.genPartyMember(e)!; // TODO:: is the bang correct here?
} else { } else {
let enemySpecies = globalScene.randomSpecies(battle.waveIndex, level, true); let enemySpecies = globalScene.randomSpecies(battle.waveIndex, level, true);
@ -140,7 +134,6 @@ export class EncounterPhase extends BattlePhase {
if (globalScene.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) { if (globalScene.currentBattle.battleSpec === BattleSpec.FINAL_BOSS) {
battle.enemyParty[e].ivs = new Array(6).fill(31); battle.enemyParty[e].ivs = new Array(6).fill(31);
} }
// biome-ignore lint/complexity/noForEach: Improves readability
globalScene globalScene
.getPlayerParty() .getPlayerParty()
.slice(0, !battle.double ? 1 : 2) .slice(0, !battle.double ? 1 : 2)
@ -195,7 +188,7 @@ export class EncounterPhase extends BattlePhase {
]; ];
const moveset: string[] = []; const moveset: string[] = [];
for (const move of enemyPokemon.getMoveset()) { for (const move of enemyPokemon.getMoveset()) {
moveset.push(move!.getName()); // TODO: remove `!` after moveset-null removal PR moveset.push(move.getName());
} }
console.log( console.log(
@ -288,6 +281,7 @@ export class EncounterPhase extends BattlePhase {
}); });
if (!this.loaded && battle.battleType !== BattleType.MYSTERY_ENCOUNTER) { if (!this.loaded && battle.battleType !== BattleType.MYSTERY_ENCOUNTER) {
// generate modifiers for MEs, overriding prior ones as applicable
regenerateModifierPoolThresholds( regenerateModifierPoolThresholds(
globalScene.getEnemyField(), globalScene.getEnemyField(),
battle.battleType === BattleType.TRAINER ? ModifierPoolType.TRAINER : ModifierPoolType.WILD, battle.battleType === BattleType.TRAINER ? ModifierPoolType.TRAINER : ModifierPoolType.WILD,
@ -300,8 +294,8 @@ export class EncounterPhase extends BattlePhase {
} }
} }
if (battle.battleType === BattleType.TRAINER) { if (battle.battleType === BattleType.TRAINER && globalScene.currentBattle.trainer) {
globalScene.currentBattle.trainer!.genAI(globalScene.getEnemyParty()); globalScene.currentBattle.trainer.genAI(globalScene.getEnemyParty());
} }
globalScene.ui.setMode(UiMode.MESSAGE).then(() => { globalScene.ui.setMode(UiMode.MESSAGE).then(() => {
@ -342,8 +336,10 @@ export class EncounterPhase extends BattlePhase {
} }
for (const pokemon of globalScene.getPlayerParty()) { for (const pokemon of globalScene.getPlayerParty()) {
// Currently, a new wave is not considered a new battle if there is no arena reset
// Therefore, we only reset wave data here
if (pokemon) { if (pokemon) {
pokemon.resetBattleData(); pokemon.resetWaveData();
} }
} }

View File

@ -146,7 +146,7 @@ export class EvolutionPhase extends Phase {
sprite.setPipelineData("shiny", this.pokemon.shiny); sprite.setPipelineData("shiny", this.pokemon.shiny);
sprite.setPipelineData("variant", this.pokemon.variant); sprite.setPipelineData("variant", this.pokemon.variant);
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
if (this.pokemon.summonData?.speciesForm) { if (this.pokemon.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
sprite.pipelineData[k] = this.pokemon.getSprite().pipelineData[k]; sprite.pipelineData[k] = this.pokemon.getSprite().pipelineData[k];
@ -178,7 +178,7 @@ export class EvolutionPhase extends Phase {
sprite.setPipelineData("shiny", evolvedPokemon.shiny); sprite.setPipelineData("shiny", evolvedPokemon.shiny);
sprite.setPipelineData("variant", evolvedPokemon.variant); sprite.setPipelineData("variant", evolvedPokemon.variant);
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
if (evolvedPokemon.summonData?.speciesForm) { if (evolvedPokemon.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
sprite.pipelineData[k] = evolvedPokemon.getSprite().pipelineData[k]; sprite.pipelineData[k] = evolvedPokemon.getSprite().pipelineData[k];

View File

@ -118,7 +118,7 @@ export class FaintPhase extends PokemonPhase {
pokemon.resetTera(); pokemon.resetTera();
if (pokemon.turnData?.attacksReceived?.length) { if (pokemon.turnData.attacksReceived?.length) {
const lastAttack = pokemon.turnData.attacksReceived[0]; const lastAttack = pokemon.turnData.attacksReceived[0];
applyPostFaintAbAttrs( applyPostFaintAbAttrs(
PostFaintAbAttr, PostFaintAbAttr,
@ -136,7 +136,7 @@ export class FaintPhase extends PokemonPhase {
for (const p of alivePlayField) { for (const p of alivePlayField) {
applyPostKnockOutAbAttrs(PostKnockOutAbAttr, p, pokemon); applyPostKnockOutAbAttrs(PostKnockOutAbAttr, p, pokemon);
} }
if (pokemon.turnData?.attacksReceived?.length) { if (pokemon.turnData.attacksReceived?.length) {
const defeatSource = this.source; const defeatSource = this.source;
if (defeatSource?.isOnField()) { if (defeatSource?.isOnField()) {

View File

@ -6,8 +6,7 @@ type PokemonFunc = (pokemon: Pokemon) => void;
export abstract class FieldPhase extends BattlePhase { export abstract class FieldPhase extends BattlePhase {
executeForAll(func: PokemonFunc): void { executeForAll(func: PokemonFunc): void {
const field = globalScene.getField(true).filter(p => p.summonData); for (const pokemon of globalScene.getField(true)) {
for (const pokemon of field) {
func(pokemon); func(pokemon);
} }
} }

View File

@ -51,7 +51,7 @@ export class FormChangePhase extends EvolutionPhase {
sprite.setPipelineData("shiny", transformedPokemon.shiny); sprite.setPipelineData("shiny", transformedPokemon.shiny);
sprite.setPipelineData("variant", transformedPokemon.variant); sprite.setPipelineData("variant", transformedPokemon.variant);
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
if (transformedPokemon.summonData?.speciesForm) { if (transformedPokemon.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
sprite.pipelineData[k] = transformedPokemon.getSprite().pipelineData[k]; sprite.pipelineData[k] = transformedPokemon.getSprite().pipelineData[k];

View File

@ -277,9 +277,6 @@ export class MoveEffectPhase extends PokemonPhase {
super.end(); super.end();
return; return;
} }
if (isNullOrUndefined(user.turnData)) {
user.resetTurnData();
}
} }
/** /**

View File

@ -618,7 +618,7 @@ export class MovePhase extends BattlePhase {
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed)); globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed));
} }
if (this.cancelled && this.pokemon.summonData?.tags?.find(t => t.tagType === BattlerTagType.FRENZY)) { if (this.cancelled && this.pokemon.summonData.tags?.find(t => t.tagType === BattlerTagType.FRENZY)) {
frenzyMissFunc(this.pokemon, this.move.getMove()); frenzyMissFunc(this.pokemon, this.move.getMove());
} }

View File

@ -229,8 +229,7 @@ export class MysteryEncounterBattleStartCleanupPhase extends Phase {
// Lapse any residual flinches/endures but ignore all other turn-end battle tags // Lapse any residual flinches/endures but ignore all other turn-end battle tags
const includedLapseTags = [BattlerTagType.FLINCHED, BattlerTagType.ENDURING]; const includedLapseTags = [BattlerTagType.FLINCHED, BattlerTagType.ENDURING];
const field = globalScene.getField(true).filter(p => p.summonData); globalScene.getField(true).forEach(pokemon => {
field.forEach(pokemon => {
const tags = pokemon.summonData.tags; const tags = pokemon.summonData.tags;
tags tags
.filter( .filter(

View File

@ -7,16 +7,16 @@ export class NewBiomeEncounterPhase extends NextEncounterPhase {
doEncounter(): void { doEncounter(): void {
globalScene.playBgm(undefined, true); globalScene.playBgm(undefined, true);
// Reset all battle and wave data, perform form changes, etc.
// We do this because new biomes are considered "arena transitions" akin to MEs and trainer battles
for (const pokemon of globalScene.getPlayerParty()) { for (const pokemon of globalScene.getPlayerParty()) {
if (pokemon) { if (pokemon) {
pokemon.resetBattleData(); pokemon.resetBattleAndWaveData();
pokemon.customPokemonData.resetHitReceivedCount(); if (pokemon.isOnField()) {
}
}
for (const pokemon of globalScene.getPlayerParty().filter(p => p.isOnField())) {
applyAbAttrs(PostBiomeChangeAbAttr, pokemon, null); applyAbAttrs(PostBiomeChangeAbAttr, pokemon, null);
} }
}
}
const enemyField = globalScene.getEnemyField(); const enemyField = globalScene.getEnemyField();
const moveTargets: any[] = [globalScene.arenaEnemy, enemyField]; const moveTargets: any[] = [globalScene.arenaEnemy, enemyField];

View File

@ -1,6 +1,10 @@
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { EncounterPhase } from "./encounter-phase"; import { EncounterPhase } from "./encounter-phase";
/**
* The phase between defeating an encounter and starting another wild wave.
* Handles generating, loading and preparing for it.
*/
export class NextEncounterPhase extends EncounterPhase { export class NextEncounterPhase extends EncounterPhase {
start() { start() {
super.start(); super.start();
@ -9,9 +13,12 @@ export class NextEncounterPhase extends EncounterPhase {
doEncounter(): void { doEncounter(): void {
globalScene.playBgm(undefined, true); globalScene.playBgm(undefined, true);
// Reset all player transient wave data/intel before starting a new wild encounter.
// We exclusively reset wave data here as wild waves are considered one continuous "battle"
// for lack of an arena transition.
for (const pokemon of globalScene.getPlayerParty()) { for (const pokemon of globalScene.getPlayerParty()) {
if (pokemon) { if (pokemon) {
pokemon.resetBattleData(); pokemon.resetWaveData();
} }
} }

View File

@ -74,7 +74,7 @@ export class QuietFormChangePhase extends BattlePhase {
isTerastallized: this.pokemon.isTerastallized, isTerastallized: this.pokemon.isTerastallized,
}); });
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
if (this.pokemon.summonData?.speciesForm) { if (this.pokemon.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
sprite.pipelineData[k] = this.pokemon.getSprite().pipelineData[k]; sprite.pipelineData[k] = this.pokemon.getSprite().pipelineData[k];

View File

@ -50,9 +50,7 @@ export class ShowAbilityPhase extends PokemonPhase {
} }
globalScene.abilityBar.showAbility(this.pokemonName, this.abilityName, this.passive, this.player).then(() => { globalScene.abilityBar.showAbility(this.pokemonName, this.abilityName, this.passive, this.player).then(() => {
if (pokemon?.battleData) { pokemon.waveData.abilityRevealed = true;
pokemon.battleData.abilityRevealed = true;
}
this.end(); this.end();
}); });

View File

@ -217,16 +217,8 @@ export class StatStageChangePhase extends PokemonPhase {
for (const s of filteredStats) { for (const s of filteredStats) {
if (stages.value > 0 && pokemon.getStatStage(s) < 6) { if (stages.value > 0 && pokemon.getStatStage(s) < 6) {
if (!pokemon.turnData) {
// Temporary fix for missing turn data struct on turn 1
pokemon.resetTurnData();
}
pokemon.turnData.statStagesIncreased = true; pokemon.turnData.statStagesIncreased = true;
} else if (stages.value < 0 && pokemon.getStatStage(s) > -6) { } else if (stages.value < 0 && pokemon.getStatStage(s) > -6) {
if (!pokemon.turnData) {
// Temporary fix for missing turn data struct on turn 1
pokemon.resetTurnData();
}
pokemon.turnData.statStagesDecreased = true; pokemon.turnData.statStagesDecreased = true;
} }

View File

@ -177,11 +177,7 @@ export class SummonPhase extends PartyMemberPokemonPhase {
} }
globalScene.currentBattle.seenEnemyPartyMemberIds.add(pokemon.id); globalScene.currentBattle.seenEnemyPartyMemberIds.add(pokemon.id);
} }
addPokeballOpenParticles( addPokeballOpenParticles(pokemon.x, pokemon.y - 16, pokemon.getPokeball(true));
pokemon.x,
pokemon.y - 16,
pokemon.getPokeball(true),
);
globalScene.updateModifiers(this.player); globalScene.updateModifiers(this.player);
globalScene.updateFieldScale(); globalScene.updateFieldScale();
pokemon.showInfo(); pokemon.showInfo();
@ -200,9 +196,8 @@ export class SummonPhase extends PartyMemberPokemonPhase {
onComplete: () => { onComplete: () => {
pokemon.cry(pokemon.getHpRatio() > 0.25 ? undefined : { rate: 0.85 }); pokemon.cry(pokemon.getHpRatio() > 0.25 ? undefined : { rate: 0.85 });
pokemon.getSprite().clearTint(); pokemon.getSprite().clearTint();
pokemon.resetSummonData();
// necessary to stay transformed during wild waves // necessary to stay transformed during wild waves
if (pokemon.summonData?.speciesForm) { if (pokemon.summonData.speciesForm) {
pokemon.loadAssets(false); pokemon.loadAssets(false);
} }
globalScene.time.delayedCall(1000, () => this.end()); globalScene.time.delayedCall(1000, () => this.end());
@ -266,7 +261,6 @@ export class SummonPhase extends PartyMemberPokemonPhase {
onComplete: () => { onComplete: () => {
pokemon.cry(pokemon.getHpRatio() > 0.25 ? undefined : { rate: 0.85 }); pokemon.cry(pokemon.getHpRatio() > 0.25 ? undefined : { rate: 0.85 });
pokemon.getSprite().clearTint(); pokemon.getSprite().clearTint();
pokemon.resetSummonData();
globalScene.updateFieldScale(); globalScene.updateFieldScale();
globalScene.time.delayedCall(1000, () => this.end()); globalScene.time.delayedCall(1000, () => this.end());
}, },

View File

@ -33,10 +33,10 @@ export class SwitchSummonPhase extends SummonPhase {
* @param fieldIndex - Position on the battle field * @param fieldIndex - Position on the battle field
* @param slotIndex - The index of pokemon (in party of 6) to switch into * @param slotIndex - The index of pokemon (in party of 6) to switch into
* @param doReturn - Whether to render "comeback" dialogue * @param doReturn - Whether to render "comeback" dialogue
* @param player - (Optional) `true` if the switch is from the player * @param player - Whether the switch came from the player or enemy; default `true`
*/ */
constructor(switchType: SwitchType, fieldIndex: number, slotIndex: number, doReturn: boolean, player?: boolean) { constructor(switchType: SwitchType, fieldIndex: number, slotIndex: number, doReturn: boolean, player = true) {
super(fieldIndex, player !== undefined ? player : true); super(fieldIndex, player);
this.switchType = switchType; this.switchType = switchType;
this.slotIndex = slotIndex; this.slotIndex = slotIndex;
@ -67,7 +67,8 @@ export class SwitchSummonPhase extends SummonPhase {
!(this.player ? globalScene.getPlayerParty() : globalScene.getEnemyParty())[this.slotIndex]) !(this.player ? globalScene.getPlayerParty() : globalScene.getEnemyParty())[this.slotIndex])
) { ) {
if (this.player) { if (this.player) {
return this.switchAndSummon(); this.switchAndSummon();
return;
} }
globalScene.time.delayedCall(750, () => this.switchAndSummon()); globalScene.time.delayedCall(750, () => this.switchAndSummon());
return; return;
@ -120,14 +121,23 @@ export class SwitchSummonPhase extends SummonPhase {
switchAndSummon() { switchAndSummon() {
const party = this.player ? this.getParty() : globalScene.getEnemyParty(); const party = this.player ? this.getParty() : globalScene.getEnemyParty();
const switchedInPokemon = party[this.slotIndex]; const switchedInPokemon: Pokemon | undefined = party[this.slotIndex];
this.lastPokemon = this.getPokemon(); this.lastPokemon = this.getPokemon();
applyPreSummonAbAttrs(PreSummonAbAttr, switchedInPokemon); applyPreSummonAbAttrs(PreSummonAbAttr, switchedInPokemon);
applyPreSwitchOutAbAttrs(PreSwitchOutAbAttr, this.lastPokemon); applyPreSwitchOutAbAttrs(PreSwitchOutAbAttr, this.lastPokemon);
if (this.switchType === SwitchType.BATON_PASS && switchedInPokemon) { if (!switchedInPokemon) {
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon => this.end();
return;
}
if (this.switchType === SwitchType.BATON_PASS) {
// If switching via baton pass, update opposing tags coming from the prior pokemon
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach((enemyPokemon: Pokemon) =>
enemyPokemon.transferTagsBySourceId(this.lastPokemon.id, switchedInPokemon.id), enemyPokemon.transferTagsBySourceId(this.lastPokemon.id, switchedInPokemon.id),
); );
// If the recipient pokemon lacks a baton, give our baton to it during the swap
if ( if (
!globalScene.findModifier( !globalScene.findModifier(
m => m =>
@ -140,14 +150,8 @@ export class SwitchSummonPhase extends SummonPhase {
m instanceof SwitchEffectTransferModifier && m instanceof SwitchEffectTransferModifier &&
(m as SwitchEffectTransferModifier).pokemonId === this.lastPokemon.id, (m as SwitchEffectTransferModifier).pokemonId === this.lastPokemon.id,
) as SwitchEffectTransferModifier; ) as SwitchEffectTransferModifier;
if (
batonPassModifier && if (batonPassModifier) {
!globalScene.findModifier(
m =>
m instanceof SwitchEffectTransferModifier &&
(m as SwitchEffectTransferModifier).pokemonId === switchedInPokemon.id,
)
) {
globalScene.tryTransferHeldItemModifier( globalScene.tryTransferHeldItemModifier(
batonPassModifier, batonPassModifier,
switchedInPokemon, switchedInPokemon,
@ -160,7 +164,7 @@ export class SwitchSummonPhase extends SummonPhase {
} }
} }
} }
if (switchedInPokemon) {
party[this.slotIndex] = this.lastPokemon; party[this.slotIndex] = this.lastPokemon;
party[this.fieldIndex] = switchedInPokemon; party[this.fieldIndex] = switchedInPokemon;
const showTextAndSummon = () => { const showTextAndSummon = () => {
@ -176,8 +180,9 @@ export class SwitchSummonPhase extends SummonPhase {
pokemonName: this.getPokemon().getNameToRender(), pokemonName: this.getPokemon().getNameToRender(),
}), }),
); );
/** /**
* If this switch is passing a Substitute, make the switched Pokemon match the returned Pokemon's state as it left. * If this switch is passing a Substitute, make the switched Pokemon matches the returned Pokemon's state as it left.
* Otherwise, clear any persisting tags on the returned Pokemon. * Otherwise, clear any persisting tags on the returned Pokemon.
*/ */
if (this.switchType === SwitchType.BATON_PASS || this.switchType === SwitchType.SHED_TAIL) { if (this.switchType === SwitchType.BATON_PASS || this.switchType === SwitchType.SHED_TAIL) {
@ -192,6 +197,7 @@ export class SwitchSummonPhase extends SummonPhase {
} }
this.summon(); this.summon();
}; };
if (this.player) { if (this.player) {
showTextAndSummon(); showTextAndSummon();
} else { } else {
@ -201,9 +207,6 @@ export class SwitchSummonPhase extends SummonPhase {
showTextAndSummon(); showTextAndSummon();
}); });
} }
} else {
this.end();
}
} }
onEnd(): void { onEnd(): void {
@ -220,15 +223,15 @@ export class SwitchSummonPhase extends SummonPhase {
const lastPokemonHasForceSwitchAbAttr = const lastPokemonHasForceSwitchAbAttr =
this.lastPokemon.hasAbilityWithAttr(PostDamageForceSwitchAbAttr) && !this.lastPokemon.isFainted(); this.lastPokemon.hasAbilityWithAttr(PostDamageForceSwitchAbAttr) && !this.lastPokemon.isFainted();
// Compensate for turn spent summoning // Compensate for turn spent summoning/forced switch if switched out pokemon is not fainted.
// Or compensate for force switch move if switched out pokemon is not fainted // Needed as we increment turn counters in `TurnEndPhase`.
if ( if (
currentCommand === Command.POKEMON || currentCommand === Command.POKEMON ||
lastPokemonIsForceSwitchedAndNotFainted || lastPokemonIsForceSwitchedAndNotFainted ||
lastPokemonHasForceSwitchAbAttr lastPokemonHasForceSwitchAbAttr
) { ) {
pokemon.battleSummonData.turnCount--; pokemon.tempSummonData.turnCount--;
pokemon.battleSummonData.waveTurnCount--; pokemon.tempSummonData.waveTurnCount--;
} }
if (this.switchType === SwitchType.BATON_PASS && pokemon) { if (this.switchType === SwitchType.BATON_PASS && pokemon) {
@ -240,12 +243,13 @@ export class SwitchSummonPhase extends SummonPhase {
} }
} }
// Reset turn data if not initial switch (since it gets initialized to an empty object on turn start)
if (this.switchType !== SwitchType.INITIAL_SWITCH) { if (this.switchType !== SwitchType.INITIAL_SWITCH) {
pokemon.resetTurnData(); pokemon.resetTurnData();
pokemon.turnData.switchedInThisTurn = true; pokemon.turnData.switchedInThisTurn = true;
} }
this.lastPokemon?.resetSummonData(); this.lastPokemon.resetSummonData();
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true);
// Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out

View File

@ -54,11 +54,10 @@ export class TurnEndPhase extends FieldPhase {
} }
globalScene.applyModifiers(TurnStatusEffectModifier, pokemon.isPlayer(), pokemon); globalScene.applyModifiers(TurnStatusEffectModifier, pokemon.isPlayer(), pokemon);
globalScene.applyModifiers(TurnHeldItemTransferModifier, pokemon.isPlayer(), pokemon); globalScene.applyModifiers(TurnHeldItemTransferModifier, pokemon.isPlayer(), pokemon);
pokemon.battleSummonData.turnCount++; pokemon.tempSummonData.turnCount++;
pokemon.battleSummonData.waveTurnCount++; pokemon.tempSummonData.waveTurnCount++;
}; };
this.executeForAll(handlePokemon); this.executeForAll(handlePokemon);

View File

@ -72,10 +72,7 @@ export class TurnStartPhase extends FieldPhase {
// This occurs before the main loop because of battles with more than two Pokemon // This occurs before the main loop because of battles with more than two Pokemon
const battlerBypassSpeed = {}; const battlerBypassSpeed = {};
globalScene globalScene.getField(true).map(p => {
.getField(true)
.filter(p => p.summonData)
.map(p => {
const bypassSpeed = new BooleanHolder(false); const bypassSpeed = new BooleanHolder(false);
const canCheckHeldItems = new BooleanHolder(true); const canCheckHeldItems = new BooleanHolder(true);
applyAbAttrs(BypassSpeedChanceAbAttr, p, null, false, bypassSpeed); applyAbAttrs(BypassSpeedChanceAbAttr, p, null, false, bypassSpeed);

View File

@ -1145,7 +1145,7 @@ export class GameData {
? trainerConfig?.doubleOnly || sessionData.trainer?.variant === TrainerVariant.DOUBLE ? trainerConfig?.doubleOnly || sessionData.trainer?.variant === TrainerVariant.DOUBLE
: sessionData.enemyParty.length > 1, : sessionData.enemyParty.length > 1,
mysteryEncounterType, mysteryEncounterType,
)!; // TODO: is this bang correct? );
battle.enemyLevels = sessionData.enemyParty.map(p => p.level); battle.enemyLevels = sessionData.enemyParty.map(p => p.level);
globalScene.arena.init(); globalScene.arena.init();
@ -1198,13 +1198,16 @@ export class GameData {
} }
} }
if (globalScene.modifiers.length) {
console.warn("Existing modifiers not cleared on session load, deleting...");
globalScene.modifiers = [];
}
for (const modifierData of sessionData.modifiers) { for (const modifierData of sessionData.modifiers) {
const modifier = modifierData.toModifier(Modifier[modifierData.className]); const modifier = modifierData.toModifier(Modifier[modifierData.className]);
if (modifier) { if (modifier) {
globalScene.addModifier(modifier, true); globalScene.addModifier(modifier, true);
} }
} }
globalScene.updateModifiers(true); globalScene.updateModifiers(true);
for (const enemyModifierData of sessionData.enemyModifiers) { for (const enemyModifierData of sessionData.enemyModifiers) {
@ -1342,68 +1345,67 @@ export class GameData {
} }
parseSessionData(dataStr: string): SessionSaveData { parseSessionData(dataStr: string): SessionSaveData {
// TODO: Add `null`/`undefined` to the corresponding type signatures for this
// (or prevent them from being null)
// If the value is able to *not exist*, it should say so in the code
const sessionData = JSON.parse(dataStr, (k: string, v: any) => { const sessionData = JSON.parse(dataStr, (k: string, v: any) => {
if (k === "party" || k === "enemyParty") { // TODO: Add pre-parse migrate scripts
switch (k) {
case "party":
case "enemyParty": {
const ret: PokemonData[] = []; const ret: PokemonData[] = [];
if (v === null) { for (const pd of v ?? []) {
v = [];
}
for (const pd of v) {
ret.push(new PokemonData(pd)); ret.push(new PokemonData(pd));
} }
return ret; return ret;
} }
if (k === "trainer") { case "trainer":
return v ? new TrainerData(v) : null; return v ? new TrainerData(v) : null;
}
if (k === "modifiers" || k === "enemyModifiers") { case "modifiers":
const player = k === "modifiers"; case "enemyModifiers": {
const ret: PersistentModifierData[] = []; const ret: PersistentModifierData[] = [];
if (v === null) { for (const md of v ?? []) {
v = [];
}
for (const md of v) {
if (md?.className === "ExpBalanceModifier") { if (md?.className === "ExpBalanceModifier") {
// Temporarily limit EXP Balance until it gets reworked // Temporarily limit EXP Balance until it gets reworked
md.stackCount = Math.min(md.stackCount, 4); md.stackCount = Math.min(md.stackCount, 4);
} }
if ( if (
(md instanceof Modifier.EnemyAttackStatusEffectChanceModifier && md.effect === StatusEffect.FREEZE) || md instanceof Modifier.EnemyAttackStatusEffectChanceModifier &&
md.effect === StatusEffect.SLEEP (md.effect === StatusEffect.FREEZE || md.effect === StatusEffect.SLEEP)
) { ) {
// Discard any old "sleep/freeze chance tokens".
// TODO: make this migrate script
continue; continue;
} }
ret.push(new PersistentModifierData(md, player));
ret.push(new PersistentModifierData(md, k === "modifiers"));
} }
return ret; return ret;
} }
if (k === "arena") { case "arena":
return new ArenaData(v); return new ArenaData(v);
}
if (k === "challenges") { case "challenges": {
const ret: ChallengeData[] = []; const ret: ChallengeData[] = [];
if (v === null) { for (const c of v ?? []) {
v = [];
}
for (const c of v) {
ret.push(new ChallengeData(c)); ret.push(new ChallengeData(c));
} }
return ret; return ret;
} }
if (k === "mysteryEncounterType") { case "mysteryEncounterType":
return v as MysteryEncounterType; return v as MysteryEncounterType;
}
if (k === "mysteryEncounterSaveData") { case "mysteryEncounterSaveData":
return new MysteryEncounterSaveData(v); return new MysteryEncounterSaveData(v);
}
default:
return v; return v;
}
}) as SessionSaveData; }) as SessionSaveData;
applySessionVersionMigration(sessionData); applySessionVersionMigration(sessionData);
@ -1456,7 +1458,7 @@ export class GameData {
encrypt(JSON.stringify(sessionData), bypassLogin), encrypt(JSON.stringify(sessionData), bypassLogin),
); );
console.debug("Session data saved"); console.debug("Session data saved!");
if (!bypassLogin && sync) { if (!bypassLogin && sync) {
pokerogueApi.savedata.updateAll(request).then(error => { pokerogueApi.savedata.updateAll(request).then(error => {

View File

@ -1,16 +1,15 @@
import { BattleType } from "#enums/battle-type"; import { BattleType } from "#enums/battle-type";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import type { Gender } from "../data/gender"; import type { Gender } from "../data/gender";
import type { Nature } from "#enums/nature"; import { Nature } from "#enums/nature";
import type { PokeballType } from "#enums/pokeball"; import { PokeballType } from "#enums/pokeball";
import { getPokemonSpecies, getPokemonSpeciesForm } from "../data/pokemon-species"; import { getPokemonSpecies, getPokemonSpeciesForm } from "../data/pokemon-species";
import { Status } from "../data/status-effect"; import type { Status } from "../data/status-effect";
import Pokemon, { EnemyPokemon, PokemonMove, PokemonSummonData } from "../field/pokemon"; import Pokemon, { EnemyPokemon, PokemonBattleData, PokemonMove, PokemonSummonData } from "../field/pokemon";
import { TrainerSlot } from "#enums/trainer-slot"; import { TrainerSlot } from "#enums/trainer-slot";
import type { Variant } from "#app/sprites/variant"; import type { Variant } from "#app/sprites/variant";
import { loadBattlerTag } from "../data/battler-tags";
import type { Biome } from "#enums/biome"; import type { Biome } from "#enums/biome";
import { Moves } from "#enums/moves"; import type { Moves } from "#enums/moves";
import type { Species } from "#enums/species"; import type { Species } from "#enums/species";
import { CustomPokemonData } from "#app/data/custom-pokemon-data"; import { CustomPokemonData } from "#app/data/custom-pokemon-data";
import type { PokemonType } from "#enums/pokemon-type"; import type { PokemonType } from "#enums/pokemon-type";
@ -60,79 +59,66 @@ export default class PokemonData {
public fusionTeraType: PokemonType; public fusionTeraType: PokemonType;
public boss: boolean; public boss: boolean;
public bossSegments?: number; public bossSegments: number;
// Effects that need to be preserved between waves
public summonData: PokemonSummonData; public summonData: PokemonSummonData;
public battleData: PokemonBattleData;
public summonDataSpeciesFormIndex: number; public summonDataSpeciesFormIndex: number;
/** Data that can customize a Pokemon in non-standard ways from its Species */
public customPokemonData: CustomPokemonData; public customPokemonData: CustomPokemonData;
public fusionCustomPokemonData: CustomPokemonData; public fusionCustomPokemonData: CustomPokemonData;
// Deprecated attributes, needed for now to allow SessionData migration (see PR#4619 comments) // Deprecated attributes, needed for now to allow SessionData migration (see PR#4619 comments)
// TODO: Remove these once pre-session migration is implemented
public natureOverride: Nature | -1; public natureOverride: Nature | -1;
public mysteryEncounterPokemonData: CustomPokemonData | null; public mysteryEncounterPokemonData: CustomPokemonData | null;
public fusionMysteryEncounterPokemonData: CustomPokemonData | null; public fusionMysteryEncounterPokemonData: CustomPokemonData | null;
constructor(source: Pokemon | any, forHistory = false) { /**
const sourcePokemon = source instanceof Pokemon ? source : null; * Construct a new {@linkcode PokemonData} instance out of a {@linkcode Pokemon}
* or JSON representation thereof.
* @param source The {@linkcode Pokemon} to convert into data (or a JSON object representing one)
*/
// TODO: Remove any from type signature in favor of 2 separate method funcs
constructor(source: Pokemon | any) {
const sourcePokemon = source instanceof Pokemon ? source : undefined;
this.id = source.id; this.id = source.id;
this.player = sourcePokemon ? sourcePokemon.isPlayer() : source.player; this.player = sourcePokemon?.isPlayer() ?? source.player;
this.species = sourcePokemon ? sourcePokemon.species.speciesId : source.species; this.species = sourcePokemon?.species.speciesId ?? source.species;
this.nickname = sourcePokemon this.nickname = sourcePokemon?.summonData.illusion?.basePokemon.nickname ?? source.nickname;
? (!!sourcePokemon.summonData?.illusion ? sourcePokemon.summonData.illusion.basePokemon.nickname : sourcePokemon.nickname)
: source.nickname;
this.formIndex = Math.max(Math.min(source.formIndex, getPokemonSpecies(this.species).forms.length - 1), 0); this.formIndex = Math.max(Math.min(source.formIndex, getPokemonSpecies(this.species).forms.length - 1), 0);
this.abilityIndex = source.abilityIndex; this.abilityIndex = source.abilityIndex;
this.passive = source.passive; this.passive = source.passive;
this.shiny = sourcePokemon ? sourcePokemon.isShiny() : source.shiny; this.shiny = sourcePokemon?.isShiny() ?? source.shiny;
this.variant = sourcePokemon ? sourcePokemon.getVariant() : source.variant; this.variant = sourcePokemon?.getVariant() ?? source.variant;
this.pokeball = source.pokeball; this.pokeball = source.pokeball ?? PokeballType.POKEBALL;
this.level = source.level; this.level = source.level;
this.exp = source.exp; this.exp = source.exp;
if (!forHistory) {
this.levelExp = source.levelExp; this.levelExp = source.levelExp;
}
this.gender = source.gender; this.gender = source.gender;
if (!forHistory) {
this.hp = source.hp; this.hp = source.hp;
}
this.stats = source.stats; this.stats = source.stats;
this.ivs = source.ivs; this.ivs = source.ivs;
this.nature = source.nature !== undefined ? source.nature : (0 as Nature);
this.friendship = // TODO: Can't we move some of this verification stuff to an upgrade script?
source.friendship !== undefined ? source.friendship : getPokemonSpecies(this.species).baseFriendship; this.nature = source.nature ?? Nature.HARDY;
this.moveset = source.moveset.map((m: any) => PokemonMove.loadMove(m));
this.status = source.status ?? null;
this.friendship = source.friendship ?? getPokemonSpecies(this.species).baseFriendship;
this.metLevel = source.metLevel || 5; this.metLevel = source.metLevel || 5;
this.metBiome = source.metBiome !== undefined ? source.metBiome : -1; this.metBiome = source.metBiome ?? -1;
this.metSpecies = source.metSpecies; this.metSpecies = source.metSpecies;
this.metWave = source.metWave ?? (this.metBiome === -1 ? -1 : 0); this.metWave = source.metWave ?? (this.metBiome === -1 ? -1 : 0);
this.luck = source.luck !== undefined ? source.luck : source.shiny ? source.variant + 1 : 0; this.luck = source.luck ?? (source.shiny ? source.variant + 1 : 0);
if (!forHistory) {
this.pauseEvolutions = !!source.pauseEvolutions; this.pauseEvolutions = !!source.pauseEvolutions;
this.evoCounter = source.evoCounter ?? 0;
}
this.pokerus = !!source.pokerus; this.pokerus = !!source.pokerus;
this.teraType = source.teraType as PokemonType;
this.isTerastallized = source.isTerastallized || false;
this.stellarTypesBoosted = source.stellarTypesBoosted || [];
this.fusionSpecies = sourcePokemon ? sourcePokemon.fusionSpecies?.speciesId : source.fusionSpecies;
this.fusionFormIndex = source.fusionFormIndex;
this.fusionAbilityIndex = source.fusionAbilityIndex;
this.fusionShiny = sourcePokemon
? (!!sourcePokemon.summonData?.illusion ? sourcePokemon.summonData.illusion.basePokemon.fusionShiny : sourcePokemon.fusionShiny)
: source.fusionShiny;
this.fusionVariant = sourcePokemon
? (!!sourcePokemon.summonData?.illusion ? sourcePokemon.summonData.illusion.basePokemon.fusionVariant : sourcePokemon.fusionVariant)
: source.fusionVariant;
this.fusionGender = source.fusionGender;
this.fusionLuck =
source.fusionLuck !== undefined ? source.fusionLuck : source.fusionShiny ? source.fusionVariant + 1 : 0;
this.fusionCustomPokemonData = new CustomPokemonData(source.fusionCustomPokemonData);
this.fusionTeraType = (source.fusionTeraType ?? 0) as PokemonType;
this.usedTMs = source.usedTMs ?? []; this.usedTMs = source.usedTMs ?? [];
this.evoCounter = source.evoCounter ?? 0;
this.customPokemonData = new CustomPokemonData(source.customPokemonData); this.teraType = source.teraType as PokemonType;
this.isTerastallized = !!source.isTerastallized;
this.stellarTypesBoosted = source.stellarTypesBoosted ?? [];
// Deprecated, but needed for session data migration // Deprecated, but needed for session data migration
this.natureOverride = source.natureOverride; this.natureOverride = source.natureOverride;
@ -143,52 +129,25 @@ export default class PokemonData {
? new CustomPokemonData(source.fusionMysteryEncounterPokemonData) ? new CustomPokemonData(source.fusionMysteryEncounterPokemonData)
: null; : null;
if (!forHistory) { this.fusionSpecies = sourcePokemon?.fusionSpecies?.speciesId ?? source.fusionSpecies;
this.fusionFormIndex = source.fusionFormIndex;
this.fusionAbilityIndex = source.fusionAbilityIndex;
this.fusionShiny = sourcePokemon?.summonData.illusion?.basePokemon.fusionShiny ?? source.fusionShiny;
this.fusionVariant = sourcePokemon?.summonData.illusion?.basePokemon.fusionVariant ?? source.fusionVariant;
this.fusionGender = source.fusionGender;
this.fusionLuck = source.fusionLuck ?? (source.fusionShiny ? source.fusionVariant + 1 : 0);
this.fusionTeraType = (source.fusionTeraType ?? 0) as PokemonType;
this.boss = (source instanceof EnemyPokemon && !!source.bossSegments) || (!this.player && !!source.boss); this.boss = (source instanceof EnemyPokemon && !!source.bossSegments) || (!this.player && !!source.boss);
this.bossSegments = source.bossSegments; this.bossSegments = source.bossSegments ?? 0;
}
if (sourcePokemon) { this.summonData = new PokemonSummonData(source.summonData);
this.moveset = sourcePokemon.moveset; this.battleData = new PokemonBattleData(source.battleData);
if (!forHistory) { this.summonDataSpeciesFormIndex =
this.status = sourcePokemon.status; sourcePokemon?.summonData.speciesForm?.formIndex ?? source.summonDataSpeciesFormIndex;
if (this.player && sourcePokemon.summonData) {
this.summonData = sourcePokemon.summonData;
this.summonDataSpeciesFormIndex = this.getSummonDataSpeciesFormIndex();
}
}
} else {
this.moveset = (source.moveset || [new PokemonMove(Moves.TACKLE), new PokemonMove(Moves.GROWL)])
.filter(m => m)
.map((m: any) => new PokemonMove(m.moveId, m.ppUsed, m.ppUp, m.virtual, m.maxPpOverride));
if (!forHistory) {
this.status = source.status
? new Status(source.status.effect, source.status.toxicTurnCount, source.status.sleepTurnsRemaining)
: null;
}
this.summonData = new PokemonSummonData(); this.customPokemonData = new CustomPokemonData(source.customPokemonData);
if (!forHistory && source.summonData) { this.fusionCustomPokemonData = new CustomPokemonData(source.fusionCustomPokemonData);
this.summonData.stats = source.summonData.stats;
this.summonData.statStages = source.summonData.statStages;
this.summonData.moveQueue = source.summonData.moveQueue;
this.summonData.abilitySuppressed = source.summonData.abilitySuppressed;
this.summonData.abilitiesApplied = source.summonData.abilitiesApplied;
this.summonData.ability = source.summonData.ability;
this.summonData.moveset = source.summonData.moveset?.map(m => PokemonMove.loadMove(m));
this.summonData.types = source.summonData.types;
this.summonData.speciesForm = source.summonData.speciesForm;
this.summonDataSpeciesFormIndex = source.summonDataSpeciesFormIndex;
this.summonData.illusionBroken = source.summonData.illusionBroken;
if (source.summonData.tags) {
this.summonData.tags = source.summonData.tags?.map(t => loadBattlerTag(t));
} else {
this.summonData.tags = [];
}
}
}
} }
toPokemon(battleType?: BattleType, partyMemberIndex = 0, double = false): Pokemon { toPokemon(battleType?: BattleType, partyMemberIndex = 0, double = false): Pokemon {
@ -223,30 +182,15 @@ export default class PokemonData {
false, false,
this, this,
); );
if (this.summonData) {
// when loading from saved session, recover summonData.speciesFrom and form index species object // when loading from saved session, recover summonData.speciesFrom and form index species object
// used to stay transformed on reload session // used to stay transformed on reload session
if (this.summonData.speciesForm) { if (this.summonData.speciesForm) {
this.summonData.speciesForm = getPokemonSpeciesForm( this.summonData.speciesForm = getPokemonSpeciesForm(
this.summonData.speciesForm.speciesId, this.summonData.speciesForm.speciesId,
this.summonDataSpeciesFormIndex, this.summonDataSpeciesFormIndex,
); );
} }
ret.primeSummonData(this.summonData);
}
return ret; return ret;
} }
/**
* Method to save summon data species form index
* Necessary in case the pokemon is transformed
* to reload the correct form
*/
getSummonDataSpeciesFormIndex(): number {
if (this.summonData.speciesForm) {
return this.summonData.speciesForm.formIndex;
}
return 0;
}
} }

View File

@ -59,6 +59,10 @@ import * as v1_7_0 from "./versions/v1_7_0";
// biome-ignore lint/style/noNamespaceImport: Convenience // biome-ignore lint/style/noNamespaceImport: Convenience
import * as v1_8_3 from "./versions/v1_8_3"; import * as v1_8_3 from "./versions/v1_8_3";
// --- v1.9.0 PATCHES --- //
// biome-ignore lint/style/noNamespaceImport: Convenience
import * as v1_9_0 from "./versions/v1_9_0";
/** Current game version */ /** Current game version */
const LATEST_VERSION = version; const LATEST_VERSION = version;
@ -80,6 +84,7 @@ systemMigrators.push(...v1_8_3.systemMigrators);
const sessionMigrators: SessionSaveMigrator[] = []; const sessionMigrators: SessionSaveMigrator[] = [];
sessionMigrators.push(...v1_0_4.sessionMigrators); sessionMigrators.push(...v1_0_4.sessionMigrators);
sessionMigrators.push(...v1_7_0.sessionMigrators); sessionMigrators.push(...v1_7_0.sessionMigrators);
sessionMigrators.push(...v1_9_0.sessionMigrators);
/** All settings migrators */ /** All settings migrators */
const settingsMigrators: SettingsSaveMigrator[] = []; const settingsMigrators: SettingsSaveMigrator[] = [];

View File

@ -0,0 +1,47 @@
import type { SessionSaveMigrator } from "#app/@types/SessionSaveMigrator";
import { Status } from "#app/data/status-effect";
import { PokemonMove } from "#app/field/pokemon";
import type { SessionSaveData } from "#app/system/game-data";
import type PokemonData from "#app/system/pokemon-data";
import { Moves } from "#enums/moves";
/**
* Migrate all lingering rage fist data inside `CustomPokemonData`,
* as well as enforcing default values across the board.
* @param data - {@linkcode SystemSaveData}
*/
const migratePartyData: SessionSaveMigrator = {
version: "1.9.0",
migrate: (data: SessionSaveData): void => {
// this stuff is copied straight from the constructor fwiw
const mapParty = (pkmnData: PokemonData) => {
pkmnData.status &&= new Status(
pkmnData.status.effect,
pkmnData.status.toxicTurnCount,
pkmnData.status.sleepTurnsRemaining,
);
// remove empty moves from moveset
pkmnData.moveset = (pkmnData.moveset ?? [new PokemonMove(Moves.TACKLE), new PokemonMove(Moves.GROWL)])
.filter(m => !!m)
.map(m => PokemonMove.loadMove(m));
// only edit summondata moveset if exists
pkmnData.summonData.moveset &&= pkmnData.summonData.moveset.filter(m => !!m).map(m => PokemonMove.loadMove(m));
if (
pkmnData.customPokemonData &&
"hitsRecCount" in pkmnData.customPokemonData &&
typeof pkmnData.customPokemonData["hitsRecCount"] === "number"
) {
// transfer old hit count stat to battleData.
pkmnData.battleData.hitCount = pkmnData.customPokemonData["hitsRecCount"];
pkmnData.customPokemonData["hitsRecCount"] = null;
}
return pkmnData;
};
data.party = data.party.map(mapParty);
data.enemyParty = data.enemyParty.map(mapParty);
},
};
export const sessionMigrators: Readonly<SessionSaveMigrator[]> = [migratePartyData] as const;

View File

@ -617,7 +617,7 @@ export default class BattleInfo extends Phaser.GameObjects.Container {
return resolve(); return resolve();
} }
const gender: Gender = pokemon.summonData?.illusion ? pokemon.summonData?.illusion.gender : pokemon.gender; const gender = pokemon.summonData.illusion?.gender ?? pokemon.gender;
this.genderText.setText(getGenderSymbol(gender)); this.genderText.setText(getGenderSymbol(gender));
this.genderText.setColor(getGenderColor(gender)); this.genderText.setColor(getGenderColor(gender));
@ -794,7 +794,7 @@ export default class BattleInfo extends Phaser.GameObjects.Container {
const nameSizeTest = addTextObject(0, 0, displayName, TextStyle.BATTLE_INFO); const nameSizeTest = addTextObject(0, 0, displayName, TextStyle.BATTLE_INFO);
nameTextWidth = nameSizeTest.displayWidth; nameTextWidth = nameSizeTest.displayWidth;
const gender: Gender = pokemon.summonData?.illusion ? pokemon.summonData?.illusion.gender : pokemon.gender; const gender = pokemon.summonData.illusion?.gender ?? pokemon.gender;
while ( while (
nameTextWidth > nameTextWidth >
(this.player || !this.boss ? 60 : 98) - (this.player || !this.boss ? 60 : 98) -

View File

@ -127,7 +127,7 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
messageHandler.commandWindow.setVisible(false); messageHandler.commandWindow.setVisible(false);
messageHandler.movesWindowContainer.setVisible(true); messageHandler.movesWindowContainer.setVisible(true);
const pokemon = (globalScene.getCurrentPhase() as CommandPhase).getPokemon(); const pokemon = (globalScene.getCurrentPhase() as CommandPhase).getPokemon();
if (pokemon.battleSummonData.turnCount <= 1) { if (pokemon.tempSummonData.turnCount <= 1) {
this.setCursor(0); this.setCursor(0);
} else { } else {
this.setCursor(this.getCursor()); this.setCursor(this.getCursor());
@ -305,7 +305,7 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
const effectiveness = opponent.getMoveEffectiveness( const effectiveness = opponent.getMoveEffectiveness(
pokemon, pokemon,
pokemonMove.getMove(), pokemonMove.getMove(),
!opponent.battleData?.abilityRevealed, !opponent.waveData.abilityRevealed,
undefined, undefined,
undefined, undefined,
true, true,
@ -356,7 +356,7 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
opponent.getMoveEffectiveness( opponent.getMoveEffectiveness(
pokemon, pokemon,
pokemonMove.getMove(), pokemonMove.getMove(),
!opponent.battleData.abilityRevealed, !opponent.waveData.abilityRevealed,
undefined, undefined,
undefined, undefined,
true, true,

View File

@ -1581,7 +1581,7 @@ class PartySlot extends Phaser.GameObjects.Container {
fusionShinyStar.setOrigin(0, 0); fusionShinyStar.setOrigin(0, 0);
fusionShinyStar.setPosition(shinyStar.x, shinyStar.y); fusionShinyStar.setPosition(shinyStar.x, shinyStar.y);
fusionShinyStar.setTint( fusionShinyStar.setTint(
getVariantTint(this.pokemon.summonData?.illusion?.basePokemon.fusionVariant ?? this.pokemon.fusionVariant), getVariantTint(this.pokemon.summonData.illusion?.basePokemon.fusionVariant ?? this.pokemon.fusionVariant),
); );
slotInfoContainer.add(fusionShinyStar); slotInfoContainer.add(fusionShinyStar);

View File

@ -359,15 +359,15 @@ export default class SummaryUiHandler extends UiHandler {
this.pokemonSprite.setPipelineData("spriteKey", this.pokemon.getSpriteKey()); this.pokemonSprite.setPipelineData("spriteKey", this.pokemon.getSpriteKey());
this.pokemonSprite.setPipelineData( this.pokemonSprite.setPipelineData(
"shiny", "shiny",
this.pokemon.summonData?.illusion?.basePokemon.shiny ?? this.pokemon.shiny, this.pokemon.summonData.illusion?.basePokemon.shiny ?? this.pokemon.shiny,
); );
this.pokemonSprite.setPipelineData( this.pokemonSprite.setPipelineData(
"variant", "variant",
this.pokemon.summonData?.illusion?.basePokemon.variant ?? this.pokemon.variant, this.pokemon.summonData.illusion?.basePokemon.variant ?? this.pokemon.variant,
); );
["spriteColors", "fusionSpriteColors"].map(k => { ["spriteColors", "fusionSpriteColors"].map(k => {
delete this.pokemonSprite.pipelineData[`${k}Base`]; delete this.pokemonSprite.pipelineData[`${k}Base`];
if (this.pokemon?.summonData?.speciesForm) { if (this.pokemon?.summonData.speciesForm) {
k += "Base"; k += "Base";
} }
this.pokemonSprite.pipelineData[k] = this.pokemon?.getSprite().pipelineData[k]; this.pokemonSprite.pipelineData[k] = this.pokemon?.getSprite().pipelineData[k];
@ -462,7 +462,7 @@ export default class SummaryUiHandler extends UiHandler {
this.fusionShinyIcon.setVisible(doubleShiny); this.fusionShinyIcon.setVisible(doubleShiny);
if (isFusion) { if (isFusion) {
this.fusionShinyIcon.setTint( this.fusionShinyIcon.setTint(
getVariantTint(this.pokemon.summonData?.illusion?.basePokemon.fusionVariant ?? this.pokemon.fusionVariant), getVariantTint(this.pokemon.summonData.illusion?.basePokemon.fusionVariant ?? this.pokemon.fusionVariant),
); );
} }

View File

@ -71,7 +71,7 @@ export default class TargetSelectUiHandler extends UiHandler {
*/ */
resetCursor(cursorN: number, user: Pokemon): void { resetCursor(cursorN: number, user: Pokemon): void {
if (!isNullOrUndefined(cursorN)) { if (!isNullOrUndefined(cursorN)) {
if ([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2].includes(cursorN) || user.battleSummonData.waveTurnCount === 1) { if ([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2].includes(cursorN) || user.tempSummonData.waveTurnCount === 1) {
// Reset cursor on the first turn of a fight or if an ally was targeted last turn // Reset cursor on the first turn of a fight or if an ally was targeted last turn
cursorN = -1; cursorN = -1;
} }

View File

@ -467,35 +467,22 @@ export function truncateString(str: string, maxLength = 10) {
return str; return str;
} }
/**
* Perform a deep copy of an object.
*
* @param values - The object to be deep copied.
* @returns A new object that is a deep copy of the input.
*/
export function deepCopy(values: object): object {
// Convert the object to a JSON string and parse it back to an object to perform a deep copy
return JSON.parse(JSON.stringify(values));
}
/** /**
* Convert a space-separated string into a capitalized and underscored string. * Convert a space-separated string into a capitalized and underscored string.
*
* @param input - The string to be converted. * @param input - The string to be converted.
* @returns The converted string with words capitalized and separated by underscores. * @returns The converted string with words capitalized and separated by underscores.
*/ */
export function reverseValueToKeySetting(input) { export function reverseValueToKeySetting(input: string) {
// Split the input string into an array of words // Split the input string into an array of words
const words = input.split(" "); const words = input.split(" ");
// Capitalize the first letter of each word and convert the rest to lowercase // Capitalize the first letter of each word and convert the rest to lowercase
const capitalizedWords = words.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); const capitalizedWords = words.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
// Join the capitalized words with underscores and return the result // Join the capitalized words with underscores and return the result
return capitalizedWords.join("_"); return capitalizedWords.join("_");
} }
/** /**
* Capitalize a string. * Capitalize a string.
*
* @param str - The string to be capitalized. * @param str - The string to be capitalized.
* @param sep - The separator between the words of the string. * @param sep - The separator between the words of the string.
* @param lowerFirstChar - Whether the first character of the string should be lowercase or not. * @param lowerFirstChar - Whether the first character of the string should be lowercase or not.
@ -579,25 +566,3 @@ export function animationFileName(move: Moves): string {
export function camelCaseToKebabCase(str: string): string { export function camelCaseToKebabCase(str: string): string {
return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (s, o) => (o ? "-" : "") + s.toLowerCase()); return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (s, o) => (o ? "-" : "") + s.toLowerCase());
} }
/**
* Merges the two objects, such that for each property in `b` that matches a property in `a`,
* the value in `a` is replaced by the value in `b`. This is done recursively if the property is a non-array object
*
* If the property does not exist in `a` or its `typeof` evaluates differently, the property is skipped.
* If the value of the property is an array, the array is replaced. If it is any other object, the object is merged recursively.
*/
// biome-ignore lint/complexity/noBannedTypes: This function is designed to merge json objects
export function deepMergeObjects(a: Object, b: Object) {
for (const key in b) {
// !(key in a) is redundant here, yet makes it clear that we're explicitly interested in properties that exist in `a`
if (!(key in a) || typeof a[key] !== typeof b[key]) {
continue;
}
if (typeof b[key] === "object" && !Array.isArray(b[key])) {
deepMergeObjects(a[key], b[key]);
} else {
a[key] = b[key];
}
}
}

40
src/utils/data.ts Normal file
View File

@ -0,0 +1,40 @@
/**
* Perform a deep copy of an object.
* @param values - The object to be deep copied.
* @returns A new object that is a deep copy of the input.
*/
export function deepCopy(values: object): object {
// Convert the object to a JSON string and parse it back to an object to perform a deep copy
return JSON.parse(JSON.stringify(values));
}
/**
* Deeply merge two JSON objects' common properties together.
* This copies all values from `source` that match properties inside `dest`,
* checking recursively for non-null nested objects.
* If a property in `source` does not exist in `dest` or its `typeof` evaluates differently, it is skipped.
* If it is a non-array object, its properties are recursed into and checked in turn.
* All other values are copied verbatim.
* @param dest - The object to merge values into
* @param source - The object to source merged values from
* @remarks Do not use for regular objects; this is specifically made for JSON copying.
*/
export function deepMergeSpriteData(dest: object, source: object) {
for (const key of Object.keys(source)) {
if (
!(key in dest) ||
typeof source[key] !== typeof dest[key] ||
Array.isArray(source[key]) !== Array.isArray(dest[key])
) {
continue;
}
// Pure objects get recursed into; everything else gets overwritten
if (typeof source[key] !== "object" || source[key] === null || Array.isArray(source[key])) {
dest[key] = source[key];
} else {
deepMergeSpriteData(dest[key], source[key]);
}
}
}

View File

@ -0,0 +1,322 @@
import { RepeatBerryNextTurnAbAttr } from "#app/data/abilities/ability";
import Pokemon from "#app/field/pokemon";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { Abilities } from "#enums/abilities";
import { BerryType } from "#enums/berry-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { Stat } from "#enums/stat";
import GameManager from "#test/testUtils/gameManager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Cud Chew", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([Moves.BUG_BITE, Moves.SPLASH, Moves.HYPER_VOICE, Moves.STUFF_CHEEKS])
.startingHeldItems([{ name: "BERRY", type: BerryType.SITRUS, count: 1 }])
.ability(Abilities.CUD_CHEW)
.battleStyle("single")
.disableCrits()
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
describe("tracks berries eaten", () => {
it("stores inside summonData at end of turn", async () => {
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = 1; // needed to allow sitrus procs
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
// berries tracked in turnData; not moved to battleData yet
expect(farigiraf.summonData.berriesEatenLast).toEqual([]);
expect(farigiraf.turnData.berriesEaten).toEqual([BerryType.SITRUS]);
await game.phaseInterceptor.to("TurnEndPhase");
// berries stored in battleData; not yet cleared from turnData
expect(farigiraf.summonData.berriesEatenLast).toEqual([BerryType.SITRUS]);
expect(farigiraf.turnData.berriesEaten).toEqual([BerryType.SITRUS]);
await game.toNextTurn();
// turnData cleared on turn start
expect(farigiraf.summonData.berriesEatenLast).toEqual([BerryType.SITRUS]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
});
it("shows ability popup for eating berry, even if berry is useless", async () => {
const abDisplaySpy = vi.spyOn(globalScene, "queueAbilityDisplay");
game.override.enemyMoveset([Moves.SPLASH, Moves.HEAL_PULSE]);
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
// Dip below half to eat berry
farigiraf.hp = farigiraf.getMaxHp() / 2 - 1;
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
// doesn't trigger since cud chew hasn't eaten berry yet
expect(farigiraf.summonData.berriesEatenLast).toContain(BerryType.SITRUS);
expect(abDisplaySpy).not.toHaveBeenCalledWith(farigiraf);
await game.toNextTurn();
// get heal pulsed back to full before the cud chew proc
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.HEAL_PULSE);
await game.phaseInterceptor.to("TurnEndPhase");
// globalScene.queueAbilityDisplay should be called twice:
// once to show cud chew text before regurgitating berries,
// once to hide ability text after finishing.
expect(abDisplaySpy).toBeCalledTimes(2);
expect(abDisplaySpy.mock.calls[0][0]).toBe(farigiraf);
expect(abDisplaySpy.mock.calls[0][2]).toBe(true);
expect(abDisplaySpy.mock.calls[1][0]).toBe(farigiraf);
expect(abDisplaySpy.mock.calls[1][2]).toBe(false);
// should display messgae
expect(game.textInterceptor.getLatestMessage()).toBe(
i18next.t("battle:hpIsFull", {
pokemonName: getPokemonNameWithAffix(farigiraf),
}),
);
// not called again at turn end
expect(abDisplaySpy).toBeCalledTimes(2);
});
it("can store multiple berries across 2 turns with teatime", async () => {
// always eat first berry for stuff cheeks & company
vi.spyOn(Pokemon.prototype, "randSeedInt").mockReturnValue(0);
game.override
.startingHeldItems([
{ name: "BERRY", type: BerryType.PETAYA, count: 3 },
{ name: "BERRY", type: BerryType.LIECHI, count: 3 },
])
.enemyMoveset(Moves.TEATIME);
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = 1; // needed to allow berry procs
game.move.select(Moves.STUFF_CHEEKS);
await game.toNextTurn();
// Ate 2 petayas from moves + 1 of each at turn end; all 4 get tallied on turn end
expect(farigiraf.summonData.berriesEatenLast).toEqual([
BerryType.PETAYA,
BerryType.PETAYA,
BerryType.PETAYA,
BerryType.LIECHI,
]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
game.move.select(Moves.SPLASH);
await game.toNextTurn();
// previous berries eaten and deleted from summon data as remaining eaten berries move to replace them
expect(farigiraf.summonData.berriesEatenLast).toEqual([BerryType.LIECHI, BerryType.LIECHI]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
expect(farigiraf.getStatStage(Stat.SPATK)).toBe(6); // 3+0+3
expect(farigiraf.getStatStage(Stat.ATK)).toBe(4); // 1+2+1
});
it("should reset both arrays on switch", async () => {
await game.classicMode.startBattle([Species.FARIGIRAF, Species.GIRAFARIG]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = 1;
// eat berry turn 1, switch out turn 2
game.move.select(Moves.SPLASH);
await game.toNextTurn();
const turn1Hp = farigiraf.hp;
game.doSwitchPokemon(1);
await game.toNextTurn();
// summonData got cleared due to switch, turnData got cleared due to turn end
expect(farigiraf.summonData.berriesEatenLast).toEqual([]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
expect(farigiraf.hp).toEqual(turn1Hp);
game.doSwitchPokemon(1);
await game.toNextTurn();
// TurnData gets cleared while switching in
expect(farigiraf.summonData.berriesEatenLast).toEqual([]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
expect(farigiraf.hp).toEqual(turn1Hp);
});
it("clears array if disabled", async () => {
game.override.enemyAbility(Abilities.NEUTRALIZING_GAS);
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = 1;
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(farigiraf.summonData.berriesEatenLast).toEqual([]);
expect(farigiraf.turnData.berriesEaten).toEqual([BerryType.SITRUS]);
await game.toNextTurn();
// both arrays empty since neut gas disabled both the mid-turn and post-turn effects
expect(farigiraf.summonData.berriesEatenLast).toEqual([]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
});
});
describe("regurgiates berries", () => {
it("re-triggers effects on eater without pushing to array", async () => {
const apply = vi.spyOn(RepeatBerryNextTurnAbAttr.prototype, "apply");
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = 1;
game.move.select(Moves.SPLASH);
await game.toNextTurn();
// ate 1 sitrus the turn prior, spitball pending
expect(farigiraf.summonData.berriesEatenLast).toEqual([BerryType.SITRUS]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
expect(apply.mock.lastCall).toBeUndefined();
const turn1Hp = farigiraf.hp;
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
// healed back up to half without adding any more to array
expect(farigiraf.hp).toBeGreaterThan(turn1Hp);
expect(farigiraf.summonData.berriesEatenLast).toEqual([]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
});
it("bypasses unnerve", async () => {
game.override.enemyAbility(Abilities.UNNERVE);
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = 1;
game.move.select(Moves.SPLASH);
await game.toNextTurn();
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
// Turn end proc set the berriesEatenLast array back to being empty
expect(farigiraf.summonData.berriesEatenLast).toEqual([]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
expect(farigiraf.hp).toBeGreaterThanOrEqual(farigiraf.hp / 2);
});
it("doesn't trigger on non-eating removal", async () => {
game.override.enemyMoveset(Moves.INCINERATE);
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = farigiraf.getMaxHp() / 4;
game.move.select(Moves.SPLASH);
await game.toNextTurn();
// no berries eaten due to getting cooked
expect(farigiraf.summonData.berriesEatenLast).toEqual([]);
expect(farigiraf.turnData.berriesEaten).toEqual([]);
expect(farigiraf.hp).toBeLessThan(farigiraf.getMaxHp() / 4);
});
it("works with pluck", async () => {
game.override
.enemySpecies(Species.BLAZIKEN)
.enemyHeldItems([{ name: "BERRY", type: BerryType.PETAYA, count: 1 }])
.startingHeldItems([]);
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
game.move.select(Moves.BUG_BITE);
await game.toNextTurn();
game.move.select(Moves.SPLASH);
await game.toNextTurn();
// berry effect triggered twice - once for bug bite, once for cud chew
expect(farigiraf.getStatStage(Stat.SPATK)).toBe(2);
});
it("works with Ripen", async () => {
game.override.passiveAbility(Abilities.RIPEN);
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = 1;
game.move.select(Moves.SPLASH);
await game.toNextTurn();
game.move.select(Moves.SPLASH);
await game.toNextTurn();
// Rounding errors only ever cost a maximum of 4 hp
expect(farigiraf.getInverseHp()).toBeLessThanOrEqual(3);
});
it("is preserved on reload/wave clear", async () => {
game.override.enemyLevel(1);
await game.classicMode.startBattle([Species.FARIGIRAF]);
const farigiraf = game.scene.getPlayerPokemon()!;
farigiraf.hp = 1;
game.move.select(Moves.HYPER_VOICE);
await game.toNextWave();
// berry went yummy yummy in big fat giraffe tummy
expect(farigiraf.summonData.berriesEatenLast).toEqual([BerryType.SITRUS]);
expect(farigiraf.hp).toBeGreaterThan(1);
// reload and the berry should still be there
await game.reload.reloadSession();
const farigirafReloaded = game.scene.getPlayerPokemon()!;
expect(farigirafReloaded.summonData.berriesEatenLast).toEqual([BerryType.SITRUS]);
const wave1Hp = farigirafReloaded.hp;
// blow up next wave and we should proc the repeat eating
game.move.select(Moves.HYPER_VOICE);
await game.toNextWave();
expect(farigirafReloaded.hp).toBeGreaterThan(wave1Hp);
});
});
});

View File

@ -49,7 +49,7 @@ describe("Abilities - Good As Gold", () => {
await game.phaseInterceptor.to("BerryPhase"); await game.phaseInterceptor.to("BerryPhase");
expect(player.battleData.abilitiesApplied[0]).toBe(Abilities.GOOD_AS_GOLD); expect(player.waveData.abilitiesApplied).toContain(Abilities.GOOD_AS_GOLD);
expect(player.getStatStage(Stat.ATK)).toBe(0); expect(player.getStatStage(Stat.ATK)).toBe(0);
}); });

View File

@ -0,0 +1,346 @@
import { BattlerIndex } from "#app/battle";
import { PostTurnRestoreBerryAbAttr } from "#app/data/abilities/ability";
import type Pokemon from "#app/field/pokemon";
import { BerryModifier, PreserveBerryModifier } from "#app/modifier/modifier";
import type { ModifierOverride } from "#app/modifier/modifier-type";
import type { BooleanHolder } from "#app/utils/common";
import { Abilities } from "#enums/abilities";
import { BerryType } from "#enums/berry-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { Stat } from "#enums/stat";
import { WeatherType } from "#enums/weather-type";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Harvest", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const getPlayerBerries = () =>
game.scene.getModifiers(BerryModifier, true).filter(b => b.pokemonId === game.scene.getPlayerPokemon()?.id);
/** Check whether the player's Modifiers contains the specified berries and nothing else. */
function expectBerriesContaining(...berries: ModifierOverride[]): void {
const actualBerries: ModifierOverride[] = getPlayerBerries().map(
// only grab berry type and quantity since that's literally all we care about
b => ({ name: "BERRY", type: b.berryType, count: b.getStackCount() }),
);
expect(actualBerries).toEqual(berries);
}
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([Moves.SPLASH, Moves.NATURAL_GIFT, Moves.FALSE_SWIPE, Moves.GASTRO_ACID])
.ability(Abilities.HARVEST)
.startingLevel(100)
.battleStyle("single")
.disableCrits()
.statusActivation(false) // Since we're using nuzzle to proc both enigma and sitrus berries
.weather(WeatherType.SUNNY) // guaranteed recovery
.enemyLevel(1)
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset([Moves.SPLASH, Moves.NUZZLE, Moves.KNOCK_OFF, Moves.INCINERATE]);
});
it("replenishes eaten berries", async () => {
game.override.startingHeldItems([{ name: "BERRY", type: BerryType.LUM, count: 1 }]);
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.NUZZLE);
await game.phaseInterceptor.to("BerryPhase");
expect(getPlayerBerries()).toHaveLength(0);
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toHaveLength(1);
await game.phaseInterceptor.to("TurnEndPhase");
expectBerriesContaining({ name: "BERRY", type: BerryType.LUM, count: 1 });
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
});
it("tracks berries eaten while disabled/not present", async () => {
// Note: this also checks for harvest not being present as neutralizing gas works by making
// the game consider all other pokemon to *not* have their respective abilities.
game.override
.startingHeldItems([
{ name: "BERRY", type: BerryType.ENIGMA, count: 2 },
{ name: "BERRY", type: BerryType.LUM, count: 2 },
])
.enemyAbility(Abilities.NEUTRALIZING_GAS);
await game.classicMode.startBattle([Species.MILOTIC]);
const milotic = game.scene.getPlayerPokemon()!;
expect(milotic).toBeDefined();
// Chug a few berries without harvest (should get tracked)
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.NUZZLE);
await game.toNextTurn();
expect(milotic.battleData.berriesEaten).toEqual(expect.arrayContaining([BerryType.ENIGMA, BerryType.LUM]));
expect(getPlayerBerries()).toHaveLength(2);
// Give ourselves harvest and disable enemy neut gas,
// but force our roll to fail so we don't accidentally recover anything
vi.spyOn(PostTurnRestoreBerryAbAttr.prototype, "canApplyPostTurn").mockReturnValueOnce(false);
game.override.ability(Abilities.HARVEST);
game.move.select(Moves.GASTRO_ACID);
await game.forceEnemyMove(Moves.NUZZLE);
await game.toNextTurn();
expect(milotic.battleData.berriesEaten).toEqual(
expect.arrayContaining([BerryType.ENIGMA, BerryType.LUM, BerryType.ENIGMA, BerryType.LUM]),
);
expect(getPlayerBerries()).toHaveLength(0);
// proc a high roll and we _should_ get a berry back!
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SPLASH);
await game.toNextTurn();
expect(milotic.battleData.berriesEaten).toHaveLength(3);
expect(getPlayerBerries()).toHaveLength(1);
});
it("remembers berries eaten array across waves", async () => {
game.override
.startingHeldItems([{ name: "BERRY", type: BerryType.PETAYA, count: 2 }])
.ability(Abilities.BALL_FETCH); // don't actually need harvest for this test
await game.classicMode.startBattle([Species.REGIELEKI]);
const regieleki = game.scene.getPlayerPokemon()!;
regieleki.hp = 1;
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SPLASH);
await game.doKillOpponents();
await game.phaseInterceptor.to("TurnEndPhase");
// ate 1 berry without recovering (no harvest)
expect(regieleki.battleData.berriesEaten).toEqual([BerryType.PETAYA]);
expectBerriesContaining({ name: "BERRY", count: 1, type: BerryType.PETAYA });
expect(regieleki.getStatStage(Stat.SPATK)).toBe(1);
await game.toNextWave();
expect(regieleki.battleData.berriesEaten).toEqual([BerryType.PETAYA]);
expectBerriesContaining({ name: "BERRY", count: 1, type: BerryType.PETAYA });
expect(regieleki.getStatStage(Stat.SPATK)).toBe(1);
});
it("keeps harvested berries across reloads", async () => {
game.override
.startingHeldItems([{ name: "BERRY", type: BerryType.PETAYA, count: 1 }])
.moveset([Moves.SPLASH, Moves.EARTHQUAKE])
.enemyMoveset([Moves.SUPER_FANG, Moves.HEAL_PULSE])
.enemyAbility(Abilities.COMPOUND_EYES);
await game.classicMode.startBattle([Species.REGIELEKI]);
const regieleki = game.scene.getPlayerPokemon()!;
regieleki.hp = regieleki.getMaxHp() / 4 + 1;
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SUPER_FANG);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
// ate 1 berry and recovered it
expect(regieleki.battleData.berriesEaten).toEqual([]);
expect(getPlayerBerries()).toEqual([expect.objectContaining({ berryType: BerryType.PETAYA, stackCount: 1 })]);
expect(game.scene.getPlayerPokemon()?.getStatStage(Stat.SPATK)).toBe(1);
// heal up so harvest doesn't proc and kill enemy
game.move.select(Moves.EARTHQUAKE);
await game.forceEnemyMove(Moves.HEAL_PULSE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave();
expectBerriesContaining({ name: "BERRY", count: 1, type: BerryType.PETAYA });
expect(game.scene.getPlayerPokemon()?.getStatStage(Stat.SPATK)).toBe(1);
await game.reload.reloadSession();
expect(regieleki.battleData.berriesEaten).toEqual([]);
expectBerriesContaining({ name: "BERRY", count: 1, type: BerryType.PETAYA });
expect(game.scene.getPlayerPokemon()?.getStatStage(Stat.SPATK)).toBe(1);
});
it("cannot restore capped berries", async () => {
const initBerries: ModifierOverride[] = [
{ name: "BERRY", type: BerryType.LUM, count: 2 },
{ name: "BERRY", type: BerryType.STARF, count: 2 },
];
game.override.startingHeldItems(initBerries);
await game.classicMode.startBattle([Species.FEEBAS]);
const feebas = game.scene.getPlayerPokemon()!;
feebas.battleData.berriesEaten = [BerryType.LUM, BerryType.STARF];
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
// Force RNG roll to hit the first berry we find that matches.
// This does nothing on a success (since there'd only be a starf left to grab),
// but ensures we don't accidentally let any false positives through.
vi.spyOn(Phaser.Math.RND, "integerInRange").mockReturnValue(0);
await game.phaseInterceptor.to("TurnEndPhase");
// recovered a starf
expectBerriesContaining(
{ name: "BERRY", type: BerryType.LUM, count: 2 },
{ name: "BERRY", type: BerryType.STARF, count: 3 },
);
});
it("does nothing if all berries are capped", async () => {
const initBerries: ModifierOverride[] = [
{ name: "BERRY", type: BerryType.LUM, count: 2 },
{ name: "BERRY", type: BerryType.STARF, count: 3 },
];
game.override.startingHeldItems(initBerries);
await game.classicMode.startBattle([Species.FEEBAS]);
const player = game.scene.getPlayerPokemon()!;
player.battleData.berriesEaten = [BerryType.LUM, BerryType.STARF];
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expectBerriesContaining(...initBerries);
});
describe("move/ability interactions", () => {
it("cannot restore incinerated berries", async () => {
game.override.startingHeldItems([{ name: "BERRY", type: BerryType.STARF, count: 3 }]);
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.INCINERATE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
});
it("cannot restore knocked off berries", async () => {
game.override.startingHeldItems([{ name: "BERRY", type: BerryType.STARF, count: 3 }]);
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.KNOCK_OFF);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
});
it("can restore berries eaten by Teatime", async () => {
const initBerries: ModifierOverride[] = [{ name: "BERRY", type: BerryType.STARF, count: 1 }];
game.override.startingHeldItems(initBerries).enemyMoveset(Moves.TEATIME);
await game.classicMode.startBattle([Species.FEEBAS]);
// nom nom the berr berr yay yay
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
expectBerriesContaining(...initBerries);
});
it("cannot restore Plucked berries for either side", async () => {
const initBerries: ModifierOverride[] = [{ name: "BERRY", type: BerryType.PETAYA, count: 1 }];
game.override.startingHeldItems(initBerries).enemyAbility(Abilities.HARVEST).enemyMoveset(Moves.PLUCK);
await game.classicMode.startBattle([Species.FEEBAS]);
// gobble gobble gobble
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
// pluck triggers harvest for neither side
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
expect(game.scene.getEnemyPokemon()?.battleData.berriesEaten).toEqual([]);
expect(getPlayerBerries()).toEqual([]);
});
it("cannot restore berries preserved via Berry Pouch", async () => {
// mock berry pouch to have a 100% success rate
vi.spyOn(PreserveBerryModifier.prototype, "apply").mockImplementation(
(_pokemon: Pokemon, doPreserve: BooleanHolder): boolean => {
doPreserve.value = false;
return true;
},
);
const initBerries: ModifierOverride[] = [{ name: "BERRY", type: BerryType.PETAYA, count: 1 }];
game.override.startingHeldItems(initBerries).startingModifier([{ name: "BERRY_POUCH", count: 1 }]);
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase", false);
// won't trigger harvest since we didn't lose the berry (it just doesn't ever add it to the array)
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
expectBerriesContaining(...initBerries);
});
it("can restore stolen berries", async () => {
const initBerries: ModifierOverride[] = [{ name: "BERRY", type: BerryType.SITRUS, count: 1 }];
game.override.enemyHeldItems(initBerries).passiveAbility(Abilities.MAGICIAN).hasPassiveAbility(true);
await game.classicMode.startBattle([Species.MEOWSCARADA]);
// pre damage
const player = game.scene.getPlayerPokemon()!;
player.hp = 1;
// steal a sitrus and immediately consume it
game.move.select(Moves.FALSE_SWIPE);
await game.forceEnemyMove(Moves.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(player.battleData.berriesEaten).toEqual([BerryType.SITRUS]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.battleData.berriesEaten).toEqual([]);
expectBerriesContaining(...initBerries);
});
// TODO: Enable once fling actually works...???
it.todo("can restore berries flung at user", async () => {
game.override.enemyHeldItems([{ name: "BERRY", type: BerryType.STARF, count: 1 }]).enemyMoveset(Moves.FLING);
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toBe([]);
expect(getPlayerBerries()).toEqual([]);
});
// TODO: Enable once Nat Gift gets implemented...???
it.todo("can restore berries consumed via Natural Gift", async () => {
const initBerries: ModifierOverride[] = [{ name: "BERRY", type: BerryType.STARF, count: 1 }];
game.override.startingHeldItems(initBerries);
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.NATURAL_GIFT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toHaveLength(0);
expectBerriesContaining(...initBerries);
});
});
});

View File

@ -38,8 +38,8 @@ describe("Abilities - Illusion", () => {
const zoroark = game.scene.getPlayerPokemon()!; const zoroark = game.scene.getPlayerPokemon()!;
const zorua = game.scene.getEnemyPokemon()!; const zorua = game.scene.getEnemyPokemon()!;
expect(!!zoroark.summonData?.illusion).equals(true); expect(!!zoroark.summonData.illusion).equals(true);
expect(!!zorua.summonData?.illusion).equals(true); expect(!!zorua.summonData.illusion).equals(true);
}); });
it("break after receiving damaging move", async () => { it("break after receiving damaging move", async () => {
@ -50,7 +50,7 @@ describe("Abilities - Illusion", () => {
const zorua = game.scene.getEnemyPokemon()!; const zorua = game.scene.getEnemyPokemon()!;
expect(!!zorua.summonData?.illusion).equals(false); expect(!!zorua.summonData.illusion).equals(false);
expect(zorua.name).equals("Zorua"); expect(zorua.name).equals("Zorua");
}); });
@ -62,7 +62,7 @@ describe("Abilities - Illusion", () => {
const zorua = game.scene.getEnemyPokemon()!; const zorua = game.scene.getEnemyPokemon()!;
expect(!!zorua.summonData?.illusion).equals(false); expect(!!zorua.summonData.illusion).equals(false);
}); });
it("break with neutralizing gas", async () => { it("break with neutralizing gas", async () => {
@ -71,7 +71,7 @@ describe("Abilities - Illusion", () => {
const zorua = game.scene.getEnemyPokemon()!; const zorua = game.scene.getEnemyPokemon()!;
expect(!!zorua.summonData?.illusion).equals(false); expect(!!zorua.summonData.illusion).equals(false);
}); });
it("causes enemy AI to consider the illusion's type instead of the actual type when considering move effectiveness", async () => { it("causes enemy AI to consider the illusion's type instead of the actual type when considering move effectiveness", async () => {
@ -116,7 +116,7 @@ describe("Abilities - Illusion", () => {
const zoroark = game.scene.getPlayerPokemon()!; const zoroark = game.scene.getPlayerPokemon()!;
expect(!!zoroark.summonData?.illusion).equals(true); expect(!!zoroark.summonData.illusion).equals(true);
}); });
it("copies the the name, nickname, gender, shininess, and pokeball from the illusion source", async () => { it("copies the the name, nickname, gender, shininess, and pokeball from the illusion source", async () => {

View File

@ -68,7 +68,7 @@ describe("Abilities - Infiltrator", () => {
const postScreenDmg = enemy.getAttackDamage({ source: player, move: allMoves[move] }).damage; const postScreenDmg = enemy.getAttackDamage({ source: player, move: allMoves[move] }).damage;
expect(postScreenDmg).toBe(preScreenDmg); expect(postScreenDmg).toBe(preScreenDmg);
expect(player.battleData.abilitiesApplied[0]).toBe(Abilities.INFILTRATOR); expect(player.waveData.abilitiesApplied).toContain(Abilities.INFILTRATOR);
}); });
it("should bypass the target's Safeguard", async () => { it("should bypass the target's Safeguard", async () => {
@ -83,7 +83,7 @@ describe("Abilities - Infiltrator", () => {
await game.phaseInterceptor.to("BerryPhase", false); await game.phaseInterceptor.to("BerryPhase", false);
expect(enemy.status?.effect).toBe(StatusEffect.SLEEP); expect(enemy.status?.effect).toBe(StatusEffect.SLEEP);
expect(player.battleData.abilitiesApplied[0]).toBe(Abilities.INFILTRATOR); expect(player.waveData.abilitiesApplied).toContain(Abilities.INFILTRATOR);
}); });
// TODO: fix this interaction to pass this test // TODO: fix this interaction to pass this test
@ -99,7 +99,7 @@ describe("Abilities - Infiltrator", () => {
await game.phaseInterceptor.to("MoveEndPhase"); await game.phaseInterceptor.to("MoveEndPhase");
expect(enemy.getStatStage(Stat.ATK)).toBe(-1); expect(enemy.getStatStage(Stat.ATK)).toBe(-1);
expect(player.battleData.abilitiesApplied[0]).toBe(Abilities.INFILTRATOR); expect(player.waveData.abilitiesApplied).toContain(Abilities.INFILTRATOR);
}); });
it("should bypass the target's Substitute", async () => { it("should bypass the target's Substitute", async () => {
@ -114,6 +114,6 @@ describe("Abilities - Infiltrator", () => {
await game.phaseInterceptor.to("MoveEndPhase"); await game.phaseInterceptor.to("MoveEndPhase");
expect(enemy.getStatStage(Stat.ATK)).toBe(-1); expect(enemy.getStatStage(Stat.ATK)).toBe(-1);
expect(player.battleData.abilitiesApplied[0]).toBe(Abilities.INFILTRATOR); expect(player.waveData.abilitiesApplied).toContain(Abilities.INFILTRATOR);
}); });
}); });

View File

@ -67,7 +67,7 @@ describe("Abilities - Libero", () => {
game.move.select(Moves.AGILITY); game.move.select(Moves.AGILITY);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied.filter(a => a === Abilities.LIBERO)).toHaveLength(1); expect(leadPokemon.waveData.abilitiesApplied).toContain(Abilities.LIBERO);
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]]; const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]];
const moveType = PokemonType[allMoves[Moves.AGILITY].type]; const moveType = PokemonType[allMoves[Moves.AGILITY].type];
expect(leadPokemonType).not.toBe(moveType); expect(leadPokemonType).not.toBe(moveType);
@ -99,7 +99,7 @@ describe("Abilities - Libero", () => {
game.move.select(Moves.WEATHER_BALL); game.move.select(Moves.WEATHER_BALL);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).toContain(Abilities.LIBERO); expect(leadPokemon.waveData.abilitiesApplied).toContain(Abilities.LIBERO);
expect(leadPokemon.getTypes()).toHaveLength(1); expect(leadPokemon.getTypes()).toHaveLength(1);
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]], const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]],
moveType = PokemonType[PokemonType.FIRE]; moveType = PokemonType[PokemonType.FIRE];
@ -118,7 +118,7 @@ describe("Abilities - Libero", () => {
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).toContain(Abilities.LIBERO); expect(leadPokemon.waveData.abilitiesApplied).toContain(Abilities.LIBERO);
expect(leadPokemon.getTypes()).toHaveLength(1); expect(leadPokemon.getTypes()).toHaveLength(1);
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]], const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]],
moveType = PokemonType[PokemonType.ICE]; moveType = PokemonType[PokemonType.ICE];
@ -214,7 +214,7 @@ describe("Abilities - Libero", () => {
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.LIBERO); expect(leadPokemon.waveData.abilitiesApplied).not.toContain(Abilities.LIBERO);
}); });
test("ability is not applied if pokemon is terastallized", async () => { test("ability is not applied if pokemon is terastallized", async () => {
@ -230,7 +230,7 @@ describe("Abilities - Libero", () => {
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.LIBERO); expect(leadPokemon.waveData.abilitiesApplied).not.toContain(Abilities.LIBERO);
}); });
test("ability is not applied if pokemon uses struggle", async () => { test("ability is not applied if pokemon uses struggle", async () => {
@ -244,7 +244,7 @@ describe("Abilities - Libero", () => {
game.move.select(Moves.STRUGGLE); game.move.select(Moves.STRUGGLE);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.LIBERO); expect(leadPokemon.waveData.abilitiesApplied).not.toContain(Abilities.LIBERO);
}); });
test("ability is not applied if the pokemon's move fails", async () => { test("ability is not applied if the pokemon's move fails", async () => {
@ -258,7 +258,7 @@ describe("Abilities - Libero", () => {
game.move.select(Moves.BURN_UP); game.move.select(Moves.BURN_UP);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.LIBERO); expect(leadPokemon.waveData.abilitiesApplied).not.toContain(Abilities.LIBERO);
}); });
test("ability applies correctly even if the pokemon's Trick-or-Treat fails", async () => { test("ability applies correctly even if the pokemon's Trick-or-Treat fails", async () => {
@ -293,7 +293,7 @@ describe("Abilities - Libero", () => {
}); });
function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: Moves) { function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: Moves) {
expect(pokemon.summonData.abilitiesApplied).toContain(Abilities.LIBERO); expect(pokemon.waveData.abilitiesApplied).toContain(Abilities.LIBERO);
expect(pokemon.getTypes()).toHaveLength(1); expect(pokemon.getTypes()).toHaveLength(1);
const pokemonType = PokemonType[pokemon.getTypes()[0]], const pokemonType = PokemonType[pokemon.getTypes()[0]],
moveType = PokemonType[allMoves[move].type]; moveType = PokemonType[allMoves[move].type];

View File

@ -67,7 +67,7 @@ describe("Abilities - Protean", () => {
game.move.select(Moves.AGILITY); game.move.select(Moves.AGILITY);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied.filter(a => a === Abilities.PROTEAN)).toHaveLength(1); expect(leadPokemon.waveData.abilitiesApplied).toContain(Abilities.PROTEAN);
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]]; const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]];
const moveType = PokemonType[allMoves[Moves.AGILITY].type]; const moveType = PokemonType[allMoves[Moves.AGILITY].type];
expect(leadPokemonType).not.toBe(moveType); expect(leadPokemonType).not.toBe(moveType);
@ -99,7 +99,7 @@ describe("Abilities - Protean", () => {
game.move.select(Moves.WEATHER_BALL); game.move.select(Moves.WEATHER_BALL);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).toContain(Abilities.PROTEAN); expect(leadPokemon.waveData.abilitiesApplied).toContain(Abilities.PROTEAN);
expect(leadPokemon.getTypes()).toHaveLength(1); expect(leadPokemon.getTypes()).toHaveLength(1);
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]], const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]],
moveType = PokemonType[PokemonType.FIRE]; moveType = PokemonType[PokemonType.FIRE];
@ -118,7 +118,7 @@ describe("Abilities - Protean", () => {
game.move.select(Moves.TACKLE); game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).toContain(Abilities.PROTEAN); expect(leadPokemon.waveData.abilitiesApplied).toContain(Abilities.PROTEAN);
expect(leadPokemon.getTypes()).toHaveLength(1); expect(leadPokemon.getTypes()).toHaveLength(1);
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]], const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]],
moveType = PokemonType[PokemonType.ICE]; moveType = PokemonType[PokemonType.ICE];
@ -214,7 +214,7 @@ describe("Abilities - Protean", () => {
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.PROTEAN); expect(leadPokemon.waveData.abilitiesApplied).not.toContain(Abilities.PROTEAN);
}); });
test("ability is not applied if pokemon is terastallized", async () => { test("ability is not applied if pokemon is terastallized", async () => {
@ -230,7 +230,7 @@ describe("Abilities - Protean", () => {
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.PROTEAN); expect(leadPokemon.waveData.abilitiesApplied).not.toContain(Abilities.PROTEAN);
}); });
test("ability is not applied if pokemon uses struggle", async () => { test("ability is not applied if pokemon uses struggle", async () => {
@ -244,7 +244,7 @@ describe("Abilities - Protean", () => {
game.move.select(Moves.STRUGGLE); game.move.select(Moves.STRUGGLE);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.PROTEAN); expect(leadPokemon.waveData.abilitiesApplied).not.toContain(Abilities.PROTEAN);
}); });
test("ability is not applied if the pokemon's move fails", async () => { test("ability is not applied if the pokemon's move fails", async () => {
@ -258,7 +258,7 @@ describe("Abilities - Protean", () => {
game.move.select(Moves.BURN_UP); game.move.select(Moves.BURN_UP);
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to(TurnEndPhase);
expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.PROTEAN); expect(leadPokemon.waveData.abilitiesApplied).not.toContain(Abilities.PROTEAN);
}); });
test("ability applies correctly even if the pokemon's Trick-or-Treat fails", async () => { test("ability applies correctly even if the pokemon's Trick-or-Treat fails", async () => {
@ -293,7 +293,7 @@ describe("Abilities - Protean", () => {
}); });
function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: Moves) { function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: Moves) {
expect(pokemon.summonData.abilitiesApplied).toContain(Abilities.PROTEAN); expect(pokemon.waveData.abilitiesApplied).toContain(Abilities.PROTEAN);
expect(pokemon.getTypes()).toHaveLength(1); expect(pokemon.getTypes()).toHaveLength(1);
const pokemonType = PokemonType[pokemon.getTypes()[0]], const pokemonType = PokemonType[pokemon.getTypes()[0]],
moveType = PokemonType[allMoves[move].type]; moveType = PokemonType[allMoves[move].type];

View File

@ -54,7 +54,7 @@ describe("Abilities - Quick Draw", () => {
expect(pokemon.isFainted()).toBe(false); expect(pokemon.isFainted()).toBe(false);
expect(enemy.isFainted()).toBe(true); expect(enemy.isFainted()).toBe(true);
expect(pokemon.battleData.abilitiesApplied).contain(Abilities.QUICK_DRAW); expect(pokemon.waveData.abilitiesApplied).contain(Abilities.QUICK_DRAW);
}, 20000); }, 20000);
test( test(
@ -76,7 +76,7 @@ describe("Abilities - Quick Draw", () => {
expect(pokemon.isFainted()).toBe(true); expect(pokemon.isFainted()).toBe(true);
expect(enemy.isFainted()).toBe(false); expect(enemy.isFainted()).toBe(false);
expect(pokemon.battleData.abilitiesApplied).not.contain(Abilities.QUICK_DRAW); expect(pokemon.waveData.abilitiesApplied).not.contain(Abilities.QUICK_DRAW);
}, },
); );
@ -96,6 +96,6 @@ describe("Abilities - Quick Draw", () => {
expect(pokemon.isFainted()).toBe(true); expect(pokemon.isFainted()).toBe(true);
expect(enemy.isFainted()).toBe(false); expect(enemy.isFainted()).toBe(false);
expect(pokemon.battleData.abilitiesApplied).contain(Abilities.QUICK_DRAW); expect(pokemon.waveData.abilitiesApplied).contain(Abilities.QUICK_DRAW);
}, 20000); }, 20000);
}); });

View File

@ -155,7 +155,7 @@ describe("Abilities - Wimp Out", () => {
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("SwitchSummonPhase", false); await game.phaseInterceptor.to("SwitchSummonPhase", false);
expect(wimpod.summonData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT); expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT);
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");

View File

@ -179,12 +179,12 @@ describe("Inverse Battle", () => {
expect(enemy.status?.effect).toBe(StatusEffect.PARALYSIS); expect(enemy.status?.effect).toBe(StatusEffect.PARALYSIS);
}); });
it("Anticipation should trigger on 2x effective moves - Anticipation against Thunderbolt", async () => { it("Anticipation should trigger on 2x effective moves", async () => {
game.override.moveset([Moves.THUNDERBOLT]).enemySpecies(Species.SANDSHREW).enemyAbility(Abilities.ANTICIPATION); game.override.moveset([Moves.THUNDERBOLT]).enemySpecies(Species.SANDSHREW).enemyAbility(Abilities.ANTICIPATION);
await game.challengeMode.startBattle(); await game.challengeMode.startBattle();
expect(game.scene.getEnemyPokemon()?.summonData.abilitiesApplied[0]).toBe(Abilities.ANTICIPATION); expect(game.scene.getEnemyPokemon()?.waveData.abilitiesApplied).toContain(Abilities.ANTICIPATION);
}); });
it("Conversion 2 should change the type to the resistive type - Conversion 2 against Dragonite", async () => { it("Conversion 2 should change the type to the resistive type - Conversion 2 against Dragonite", async () => {

View File

@ -42,7 +42,6 @@ describe("BattlerTag - SubstituteTag", () => {
// simulate a Trapped tag set by another Pokemon, then expect the filter to catch it. // simulate a Trapped tag set by another Pokemon, then expect the filter to catch it.
const trapTag = new BindTag(5, 0); const trapTag = new BindTag(5, 0);
expect(tagFilter(trapTag)).toBeTruthy(); expect(tagFilter(trapTag)).toBeTruthy();
return true;
}) as Pokemon["findAndRemoveTags"], }) as Pokemon["findAndRemoveTags"],
} as unknown as Pokemon; } as unknown as Pokemon;

View File

@ -105,7 +105,7 @@ describe("Moves - Dive", () => {
await game.phaseInterceptor.to("MoveEndPhase"); await game.phaseInterceptor.to("MoveEndPhase");
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp()); expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.battleData.abilitiesApplied[0]).toBe(Abilities.ROUGH_SKIN); expect(enemyPokemon.waveData.abilitiesApplied).toContain(Abilities.ROUGH_SKIN);
}); });
it("should cancel attack after Harsh Sunlight is set", async () => { it("should cancel attack after Harsh Sunlight is set", async () => {

View File

@ -26,64 +26,71 @@ describe("Moves - Fake Out", () => {
.moveset([Moves.FAKE_OUT, Moves.SPLASH]) .moveset([Moves.FAKE_OUT, Moves.SPLASH])
.enemyMoveset(Moves.SPLASH) .enemyMoveset(Moves.SPLASH)
.enemyLevel(10) .enemyLevel(10)
.startingLevel(10) // prevent LevelUpPhase from happening .startingLevel(1) // prevent LevelUpPhase from happening
.disableCrits(); .disableCrits();
}); });
it("can only be used on the first turn a pokemon is sent out in a battle", async () => { it("should only work the first turn a pokemon is sent out in a battle", async () => {
await game.classicMode.startBattle([Species.FEEBAS]); await game.classicMode.startBattle([Species.FEEBAS]);
const enemy = game.scene.getEnemyPokemon()!; const corv = game.scene.getEnemyPokemon()!;
game.move.select(Moves.FAKE_OUT); game.move.select(Moves.FAKE_OUT);
await game.toNextTurn(); await game.toNextTurn();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); expect(corv.hp).toBeLessThan(corv.getMaxHp());
const postTurnOneHp = enemy.hp; const postTurnOneHp = corv.hp;
game.move.select(Moves.FAKE_OUT); game.move.select(Moves.FAKE_OUT);
await game.toNextTurn(); await game.toNextTurn();
expect(enemy.hp).toBe(postTurnOneHp); expect(corv.hp).toBe(postTurnOneHp);
}, 20000); });
// This is a PokeRogue buff to Fake Out // This is a PokeRogue buff to Fake Out
it("can be used at the start of every wave even if the pokemon wasn't recalled", async () => { it("should succeed at the start of each new wave, even if user wasn't recalled", async () => {
await game.classicMode.startBattle([Species.FEEBAS]); await game.classicMode.startBattle([Species.FEEBAS]);
const enemy = game.scene.getEnemyPokemon()!; // set hp to 1 for easy knockout
enemy.damageAndUpdate(enemy.getMaxHp() - 1); game.scene.getEnemyPokemon()!.hp = 1;
game.move.select(Moves.FAKE_OUT); game.move.select(Moves.FAKE_OUT);
await game.toNextWave(); await game.toNextWave();
game.move.select(Moves.FAKE_OUT); game.move.select(Moves.FAKE_OUT);
await game.toNextTurn(); await game.toNextTurn();
expect(game.scene.getEnemyPokemon()!.isFullHp()).toBe(false); const corv = game.scene.getEnemyPokemon()!;
}, 20000); expect(corv).toBeDefined();
expect(corv?.hp).toBeLessThan(corv?.getMaxHp());
});
it("can be used again if recalled and sent back out", async () => { // This is a PokeRogue buff to Fake Out
game.override.startingWave(4); it("should succeed at the start of each new wave, even if user wasn't recalled", async () => {
await game.classicMode.startBattle([Species.FEEBAS]);
// set hp to 1 for easy knockout
game.scene.getEnemyPokemon()!.hp = 1;
game.move.select(Moves.FAKE_OUT);
await game.toNextWave();
game.move.select(Moves.FAKE_OUT);
await game.toNextTurn();
const corv = game.scene.getEnemyPokemon()!;
expect(corv).toBeDefined();
expect(corv.hp).toBeLessThan(corv.getMaxHp());
});
it("should succeed if recalled and sent back out", async () => {
await game.classicMode.startBattle([Species.FEEBAS, Species.MAGIKARP]); await game.classicMode.startBattle([Species.FEEBAS, Species.MAGIKARP]);
const enemy1 = game.scene.getEnemyPokemon()!;
game.move.select(Moves.FAKE_OUT);
await game.phaseInterceptor.to("MoveEndPhase");
expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp());
await game.doKillOpponents();
await game.toNextWave();
game.move.select(Moves.FAKE_OUT); game.move.select(Moves.FAKE_OUT);
await game.toNextTurn(); await game.toNextTurn();
const enemy2 = game.scene.getEnemyPokemon()!; const corv = game.scene.getEnemyPokemon()!;
expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp()); expect(corv.hp).toBeLessThan(corv.getMaxHp());
enemy2.hp = enemy2.getMaxHp(); corv.hp = corv.getMaxHp();
game.doSwitchPokemon(1); game.doSwitchPokemon(1);
await game.toNextTurn(); await game.toNextTurn();
@ -94,6 +101,6 @@ describe("Moves - Fake Out", () => {
game.move.select(Moves.FAKE_OUT); game.move.select(Moves.FAKE_OUT);
await game.toNextTurn(); await game.toNextTurn();
expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp()); expect(corv.hp).toBeLessThan(corv.getMaxHp());
}, 20000); });
}); });

View File

@ -228,7 +228,7 @@ describe("Moves - Instruct", () => {
const amoonguss = game.scene.getPlayerPokemon()!; const amoonguss = game.scene.getPlayerPokemon()!;
game.move.changeMoveset(amoonguss, Moves.SEED_BOMB); game.move.changeMoveset(amoonguss, Moves.SEED_BOMB);
amoonguss.battleSummonData.moveHistory = [ amoonguss.summonData.moveHistory = [
{ {
move: Moves.SEED_BOMB, move: Moves.SEED_BOMB,
targets: [BattlerIndex.ENEMY], targets: [BattlerIndex.ENEMY],
@ -301,7 +301,7 @@ describe("Moves - Instruct", () => {
const player = game.scene.getPlayerPokemon()!; const player = game.scene.getPlayerPokemon()!;
const enemy = game.scene.getEnemyPokemon()!; const enemy = game.scene.getEnemyPokemon()!;
enemy.battleSummonData.moveHistory = [ enemy.summonData.moveHistory = [
{ {
move: Moves.SONIC_BOOM, move: Moves.SONIC_BOOM,
targets: [BattlerIndex.PLAYER], targets: [BattlerIndex.PLAYER],
@ -350,7 +350,7 @@ describe("Moves - Instruct", () => {
await game.classicMode.startBattle([Species.LUCARIO, Species.BANETTE]); await game.classicMode.startBattle([Species.LUCARIO, Species.BANETTE]);
const enemyPokemon = game.scene.getEnemyPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.battleSummonData.moveHistory = [ enemyPokemon.summonData.moveHistory = [
{ {
move: Moves.WHIRLWIND, move: Moves.WHIRLWIND,
targets: [BattlerIndex.PLAYER], targets: [BattlerIndex.PLAYER],

View File

@ -0,0 +1,166 @@
import { BattlerIndex } from "#app/battle";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Last Resort", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
function expectLastResortFail() {
expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0]).toEqual(
expect.objectContaining({
move: Moves.LAST_RESORT,
result: MoveResult.FAIL,
}),
);
}
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(Abilities.BALL_FETCH)
.battleStyle("single")
.disableCrits()
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
it("should fail unless all other moves (excluding itself) has been used at least once", async () => {
game.override.moveset([Moves.LAST_RESORT, Moves.SPLASH, Moves.GROWL, Moves.GROWTH]);
await game.classicMode.startBattle([Species.BLISSEY]);
const blissey = game.scene.getPlayerPokemon()!;
expect(blissey).toBeDefined();
// Last resort by itself
game.move.select(Moves.LAST_RESORT);
await game.phaseInterceptor.to("TurnEndPhase");
expectLastResortFail();
// Splash (1/3)
blissey.pushMoveHistory({ move: Moves.SPLASH, targets: [BattlerIndex.PLAYER] });
game.move.select(Moves.LAST_RESORT);
await game.phaseInterceptor.to("TurnEndPhase");
expectLastResortFail();
// Growl (2/3)
blissey.pushMoveHistory({ move: Moves.GROWL, targets: [BattlerIndex.ENEMY] });
game.move.select(Moves.LAST_RESORT);
await game.phaseInterceptor.to("TurnEndPhase");
expectLastResortFail(); // Were last resort itself counted, it would error here
// Growth (3/3)
blissey.pushMoveHistory({ move: Moves.GROWTH, targets: [BattlerIndex.PLAYER] });
game.move.select(Moves.LAST_RESORT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0]).toEqual(
expect.objectContaining({
move: Moves.LAST_RESORT,
result: MoveResult.SUCCESS,
}),
);
});
it("should disregard virtually invoked moves", async () => {
game.override
.moveset([Moves.LAST_RESORT, Moves.SWORDS_DANCE, Moves.ABSORB, Moves.MIRROR_MOVE])
.enemyMoveset([Moves.SWORDS_DANCE, Moves.ABSORB])
.ability(Abilities.DANCER)
.enemySpecies(Species.ABOMASNOW); // magikarp has 50% chance to be okho'd on absorb crit
await game.classicMode.startBattle([Species.BLISSEY]);
// use mirror move normally to trigger absorb virtually
game.move.select(Moves.MIRROR_MOVE);
await game.forceEnemyMove(Moves.ABSORB);
await game.toNextTurn();
game.move.select(Moves.LAST_RESORT);
await game.forceEnemyMove(Moves.SWORDS_DANCE); // goes first to proc dancer ahead of time
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expectLastResortFail();
});
it("should fail if no other moves in moveset", async () => {
game.override.moveset(Moves.LAST_RESORT);
await game.classicMode.startBattle([Species.BLISSEY]);
game.move.select(Moves.LAST_RESORT);
await game.phaseInterceptor.to("TurnEndPhase");
expectLastResortFail();
});
it("should work if invoked virtually when all other moves have been used", async () => {
game.override.moveset([Moves.LAST_RESORT, Moves.SLEEP_TALK]).ability(Abilities.COMATOSE);
await game.classicMode.startBattle([Species.KOMALA]);
game.move.select(Moves.SLEEP_TALK);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.getLastXMoves(-1)).toEqual([
expect.objectContaining({
move: Moves.LAST_RESORT,
result: MoveResult.SUCCESS,
virtual: true,
}),
expect.objectContaining({
move: Moves.SLEEP_TALK,
result: MoveResult.SUCCESS,
}),
]);
});
it("should preserve usability status on reload", async () => {
game.override.moveset([Moves.LAST_RESORT, Moves.SPLASH]).ability(Abilities.COMATOSE);
await game.classicMode.startBattle([Species.BLISSEY]);
game.move.select(Moves.SPLASH);
await game.doKillOpponents();
await game.toNextWave();
const oldMoveHistory = game.scene.getPlayerPokemon()?.summonData.moveHistory;
await game.reload.reloadSession();
const newMoveHistory = game.scene.getPlayerPokemon()?.summonData.moveHistory;
expect(oldMoveHistory).toEqual(newMoveHistory);
// use last resort and it should kill the karp just fine
game.move.select(Moves.LAST_RESORT);
game.scene.getEnemyPokemon()!.hp = 1;
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.isVictory()).toBe(true);
});
it("should fail if used while not in moveset", async () => {
game.override.moveset(Moves.MIRROR_MOVE).enemyMoveset([Moves.ABSORB, Moves.LAST_RESORT]);
await game.classicMode.startBattle([Species.BLISSEY]);
// ensure enemy last resort succeeds
game.move.select(Moves.MIRROR_MOVE);
await game.forceEnemyMove(Moves.ABSORB);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.select(Moves.MIRROR_MOVE);
await game.forceEnemyMove(Moves.LAST_RESORT);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expectLastResortFail();
});
});

View File

@ -146,7 +146,7 @@ describe("Moves - Powder", () => {
await game.phaseInterceptor.to(BerryPhase, false); await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(enemyPokemon.summonData?.types).not.toBe(PokemonType.FIRE); expect(enemyPokemon.summonData.types).not.toBe(PokemonType.FIRE);
}); });
it("should cancel Fire-type moves generated by the target's Dancer ability", async () => { it("should cancel Fire-type moves generated by the target's Dancer ability", async () => {

View File

@ -7,6 +7,7 @@ import type Move from "#app/data/moves/move";
import GameManager from "#test/testUtils/gameManager"; import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { BattleType } from "#enums/battle-type";
describe("Moves - Rage Fist", () => { describe("Moves - Rage Fist", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -28,19 +29,18 @@ describe("Moves - Rage Fist", () => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.battleStyle("single") .battleStyle("single")
.moveset([Moves.RAGE_FIST, Moves.SPLASH, Moves.SUBSTITUTE]) .moveset([Moves.RAGE_FIST, Moves.SPLASH, Moves.SUBSTITUTE, Moves.TIDY_UP])
.startingLevel(100) .startingLevel(100)
.enemyLevel(1) .enemyLevel(1)
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH) .enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.DOUBLE_KICK); .enemyMoveset(Moves.DOUBLE_KICK);
vi.spyOn(move, "calculateBattlePower"); vi.spyOn(move, "calculateBattlePower");
}); });
it("should have 100 more power if hit twice before calling Rage Fist", async () => { it("should gain power per hit taken", async () => {
game.override.enemySpecies(Species.MAGIKARP); await game.classicMode.startBattle([Species.FEEBAS]);
await game.classicMode.startBattle([Species.MAGIKARP]);
game.move.select(Moves.RAGE_FIST); game.move.select(Moves.RAGE_FIST);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
@ -49,51 +49,95 @@ describe("Moves - Rage Fist", () => {
expect(move.calculateBattlePower).toHaveLastReturnedWith(150); expect(move.calculateBattlePower).toHaveLastReturnedWith(150);
}); });
it("should maintain its power during next battle if it is within the same arena encounter", async () => { it("caps at 6 hits taken", async () => {
game.override.enemySpecies(Species.MAGIKARP).startingWave(1); await game.classicMode.startBattle([Species.FEEBAS]);
await game.classicMode.startBattle([Species.MAGIKARP]); // spam splash against magikarp hitting us 2 times per turn
game.move.select(Moves.SPLASH);
await game.toNextTurn();
game.move.select(Moves.SPLASH);
await game.toNextTurn();
game.move.select(Moves.SPLASH);
await game.toNextTurn();
game.move.select(Moves.RAGE_FIST);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
// hit 8 times, but nothing else
expect(game.scene.getPlayerPokemon()?.battleData.hitCount).toBe(8);
expect(move.calculateBattlePower).toHaveLastReturnedWith(350);
});
it("should not count substitute hits or confusion damage", async () => {
game.override.enemySpecies(Species.SHUCKLE).enemyMoveset([Moves.CONFUSE_RAY, Moves.DOUBLE_KICK]);
await game.classicMode.startBattle([Species.REGIROCK]);
game.move.select(Moves.SUBSTITUTE);
await game.forceEnemyMove(Moves.DOUBLE_KICK);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
// no increase due to substitute
expect(game.scene.getPlayerPokemon()?.battleData.hitCount).toBe(0);
// remove substitute and get confused
game.move.select(Moves.TIDY_UP);
await game.forceEnemyMove(Moves.CONFUSE_RAY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
game.move.select(Moves.RAGE_FIST);
await game.forceEnemyMove(Moves.CONFUSE_RAY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceConfusionActivation(true);
await game.toNextTurn();
// didn't go up from hitting ourself
expect(game.scene.getPlayerPokemon()?.battleData.hitCount).toBe(0);
});
it("should maintain hits recieved between wild waves", async () => {
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.RAGE_FIST); game.move.select(Moves.RAGE_FIST);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextWave(); await game.toNextWave();
expect(game.scene.getPlayerPokemon()?.battleData.hitCount).toBe(2);
game.move.select(Moves.RAGE_FIST); game.move.select(Moves.RAGE_FIST);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase", false); await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.battleData.hitCount).toBe(4);
expect(move.calculateBattlePower).toHaveLastReturnedWith(250); expect(move.calculateBattlePower).toHaveLastReturnedWith(250);
}); });
it("should reset the hitRecCounter if we enter new trainer battle", async () => { it("should reset hits recieved before trainer battles", async () => {
game.override.enemySpecies(Species.MAGIKARP).startingWave(4); await game.classicMode.startBattle([Species.IRON_HANDS]);
await game.classicMode.startBattle([Species.MAGIKARP]); const ironHands = game.scene.getPlayerPokemon()!;
expect(ironHands).toBeDefined();
// beat up a magikarp
game.move.select(Moves.RAGE_FIST); game.move.select(Moves.RAGE_FIST);
await game.forceEnemyMove(Moves.DOUBLE_KICK);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.isVictory()).toBe(true);
expect(ironHands.battleData.hitCount).toBe(2);
expect(move.calculateBattlePower).toHaveLastReturnedWith(150);
game.override.battleType(BattleType.TRAINER);
await game.toNextWave(); await game.toNextWave();
game.move.select(Moves.RAGE_FIST); expect(ironHands.battleData.hitCount).toBe(0);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase", false);
expect(move.calculateBattlePower).toHaveLastReturnedWith(150);
}); });
it("should not increase the hitCounter if Substitute is hit", async () => { it("should reset hits recieved before new biome", async () => {
game.override.enemySpecies(Species.MAGIKARP).startingWave(4);
await game.classicMode.startBattle([Species.MAGIKARP]);
game.move.select(Moves.SUBSTITUTE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(game.scene.getPlayerPokemon()?.customPokemonData.hitsRecCount).toBe(0);
});
it("should reset the hitRecCounter if we enter new biome", async () => {
game.override.enemySpecies(Species.MAGIKARP).startingWave(10); game.override.enemySpecies(Species.MAGIKARP).startingWave(10);
await game.classicMode.startBattle([Species.MAGIKARP]); await game.classicMode.startBattle([Species.MAGIKARP]);
@ -109,25 +153,50 @@ describe("Moves - Rage Fist", () => {
expect(move.calculateBattlePower).toHaveLastReturnedWith(150); expect(move.calculateBattlePower).toHaveLastReturnedWith(150);
}); });
it("should not reset the hitRecCounter if switched out", async () => { it("should not reset if switched out or on reload", async () => {
game.override.enemySpecies(Species.MAGIKARP).startingWave(1).enemyMoveset(Moves.TACKLE); game.override.enemyMoveset(Moves.TACKLE);
const getPartyHitCount = () =>
game.scene
.getPlayerParty()
.filter(p => !!p)
.map(m => m.battleData.hitCount);
await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]);
// Charizard hit
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn(); await game.toNextTurn();
expect(getPartyHitCount()).toEqual([1, 0]);
// blastoise switched in & hit
game.doSwitchPokemon(1); game.doSwitchPokemon(1);
await game.toNextTurn(); await game.toNextTurn();
expect(getPartyHitCount()).toEqual([1, 1]);
// charizard switched in & hit
game.doSwitchPokemon(1); game.doSwitchPokemon(1);
await game.toNextTurn(); await game.toNextTurn();
expect(getPartyHitCount()).toEqual([2, 1]);
// Charizard rage fist
game.move.select(Moves.RAGE_FIST); game.move.select(Moves.RAGE_FIST);
await game.phaseInterceptor.to("MoveEndPhase"); await game.phaseInterceptor.to("MoveEndPhase");
expect(game.scene.getPlayerParty()[0].species.speciesId).toBe(Species.CHARIZARD); const charizard = game.scene.getPlayerPokemon()!;
expect(charizard).toBeDefined();
expect(charizard.species.speciesId).toBe(Species.CHARIZARD);
expect(move.calculateBattlePower).toHaveLastReturnedWith(150);
// go to new wave, reload game and beat up another poor sap
await game.toNextWave();
await game.reload.reloadSession();
// outsped and oneshot means power rmains same as prior
game.move.select(Moves.RAGE_FIST);
await game.phaseInterceptor.to("MoveEndPhase");
expect(move.calculateBattlePower).toHaveLastReturnedWith(150); expect(move.calculateBattlePower).toHaveLastReturnedWith(150);
}); });
}); });

View File

@ -129,7 +129,7 @@ describe("Moves - Toxic Spikes", () => {
await game.phaseInterceptor.to("BattleEndPhase"); await game.phaseInterceptor.to("BattleEndPhase");
await game.toNextWave(); await game.toNextWave();
const sessionData: SessionSaveData = gameData["getSessionSaveData"](); const sessionData: SessionSaveData = gameData.getSessionSaveData();
localStorage.setItem("sessionTestData", encrypt(JSON.stringify(sessionData), true)); localStorage.setItem("sessionTestData", encrypt(JSON.stringify(sessionData), true));
const recoveredData: SessionSaveData = gameData.parseSessionData( const recoveredData: SessionSaveData = gameData.parseSessionData(
decrypt(localStorage.getItem("sessionTestData")!, true), decrypt(localStorage.getItem("sessionTestData")!, true),

View File

@ -4,7 +4,7 @@ import GameManager from "#test/testUtils/gameManager";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { TurnEndPhase } from "#app/phases/turn-end-phase";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Stat, BATTLE_STATS, EFFECTIVE_STATS } from "#enums/stat"; import { Stat, EFFECTIVE_STATS } from "#enums/stat";
import { Abilities } from "#enums/abilities"; import { Abilities } from "#enums/abilities";
import { BattlerIndex } from "#app/battle"; import { BattlerIndex } from "#app/battle";
@ -49,30 +49,18 @@ describe("Moves - Transform", () => {
expect(player.getAbility()).toBe(enemy.getAbility()); expect(player.getAbility()).toBe(enemy.getAbility());
expect(player.getGender()).toBe(enemy.getGender()); expect(player.getGender()).toBe(enemy.getGender());
// copies all stats except hp
expect(player.getStat(Stat.HP, false)).not.toBe(enemy.getStat(Stat.HP)); expect(player.getStat(Stat.HP, false)).not.toBe(enemy.getStat(Stat.HP));
for (const s of EFFECTIVE_STATS) { for (const s of EFFECTIVE_STATS) {
expect(player.getStat(s, false)).toBe(enemy.getStat(s, false)); expect(player.getStat(s, false)).toBe(enemy.getStat(s, false));
} }
for (const s of BATTLE_STATS) { expect(player.getStatStages()).toEqual(enemy.getStatStages());
expect(player.getStatStage(s)).toBe(enemy.getStatStage(s));
}
const playerMoveset = player.getMoveset(); // move IDs are equal
const enemyMoveset = enemy.getMoveset(); expect(player.getMoveset().map(m => m.moveId)).toEqual(enemy.getMoveset().map(m => m.moveId));
expect(playerMoveset.length).toBe(enemyMoveset.length); expect(player.getTypes()).toEqual(enemy.getTypes());
for (let i = 0; i < playerMoveset.length && i < enemyMoveset.length; i++) {
expect(playerMoveset[i]?.moveId).toBe(enemyMoveset[i]?.moveId);
}
const playerTypes = player.getTypes();
const enemyTypes = enemy.getTypes();
expect(playerTypes.length).toBe(enemyTypes.length);
for (let i = 0; i < playerTypes.length && i < enemyTypes.length; i++) {
expect(playerTypes[i]).toBe(enemyTypes[i]);
}
}); });
it("should copy in-battle overridden stats", async () => { it("should copy in-battle overridden stats", async () => {

View File

@ -65,7 +65,7 @@ describe("Moves - U-turn", () => {
// assert // assert
const playerPkm = game.scene.getPlayerPokemon()!; const playerPkm = game.scene.getPlayerPokemon()!;
expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp()); expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp());
expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated
expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); expect(playerPkm.species.speciesId).toEqual(Species.RAICHU);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
}, 20000); }, 20000);
@ -84,7 +84,7 @@ describe("Moves - U-turn", () => {
const playerPkm = game.scene.getPlayerPokemon()!; const playerPkm = game.scene.getPlayerPokemon()!;
expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON); expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON);
expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); expect(playerPkm.species.speciesId).toEqual(Species.RAICHU);
expect(game.scene.getEnemyPokemon()!.battleData.abilityRevealed).toBe(true); // proxy for asserting ability activated expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
}, 20000); }, 20000);

View File

@ -2,7 +2,7 @@ import cfg_keyboard_qwerty from "#app/configs/inputs/cfg_keyboard_qwerty";
import { getKeyWithKeycode, getKeyWithSettingName } from "#app/configs/inputs/configHandler"; import { getKeyWithKeycode, getKeyWithSettingName } from "#app/configs/inputs/configHandler";
import type { InterfaceConfig } from "#app/inputs-controller"; import type { InterfaceConfig } from "#app/inputs-controller";
import { SettingKeyboard } from "#app/system/settings/settings-keyboard"; import { SettingKeyboard } from "#app/system/settings/settings-keyboard";
import { deepCopy } from "#app/utils/common"; import { deepCopy } from "#app/utils/data";
import { Button } from "#enums/buttons"; import { Button } from "#enums/buttons";
import { Device } from "#enums/devices"; import { Device } from "#enums/devices";
import { InGameManip } from "#test/settingMenu/helpers/inGameManip"; import { InGameManip } from "#test/settingMenu/helpers/inGameManip";

View File

@ -579,9 +579,8 @@ export default class GameManager {
/** /**
* Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value. * Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value.
* Used to manually modify Pokemon turn order. * Used to manually modify Pokemon turn order.
* Note: This *DOES NOT* account for priority. * Note: This *DOES NOT* account for priority.
* @param order - The turn order to set * @param order - The turn order to set as an array of {@linkcode BattlerIndex}es.
* @example * @example
* ```ts * ```ts
* await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]); * await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]);

View File

@ -103,6 +103,17 @@ export class MoveHelper extends GameManagerHelper {
vi.spyOn(Overrides, "STATUS_ACTIVATION_OVERRIDE", "get").mockReturnValue(null); vi.spyOn(Overrides, "STATUS_ACTIVATION_OVERRIDE", "get").mockReturnValue(null);
} }
/**
* Forces the Confusion status to activate on the next move by temporarily mocking {@linkcode Overrides.CONFUSION_ACTIVATION_OVERRIDE},
* advancing to the next `MovePhase`, and then resetting the override to `null`
* @param activated - `true` to force the Pokemon to hit themself, `false` to forcibly disable it
*/
public async forceConfusionActivation(activated: boolean): Promise<void> {
vi.spyOn(Overrides, "CONFUSION_ACTIVATION_OVERRIDE", "get").mockReturnValue(activated);
await this.game.phaseInterceptor.to("MovePhase");
vi.spyOn(Overrides, "CONFUSION_ACTIVATION_OVERRIDE", "get").mockReturnValue(null);
}
/** /**
* Changes a pokemon's moveset to the given move(s). * Changes a pokemon's moveset to the given move(s).
* Used when the normal moveset override can't be used (such as when it's necessary to check or update properties of the moveset). * Used when the normal moveset override can't be used (such as when it's necessary to check or update properties of the moveset).

View File

@ -522,6 +522,21 @@ export class OverridesHelper extends GameManagerHelper {
return this; return this;
} }
/**
* Override confusion to always or never activate
* @param activate - `true` to force activation, `false` to force no activation, `null` to disable the override
* @returns `this`
*/
public confusionActivation(activate: boolean | null): this {
vi.spyOn(Overrides, "CONFUSION_ACTIVATION_OVERRIDE", "get").mockReturnValue(activate);
if (activate !== null) {
this.log(`Confusion forced to ${activate ? "always" : "never"} activate!`);
} else {
this.log("Confusion activation override disabled!");
}
return this;
}
/** /**
* Override the encounter chance for a mystery encounter. * Override the encounter chance for a mystery encounter.
* @param percentage - The encounter chance in % * @param percentage - The encounter chance in %

View File

@ -46,6 +46,16 @@ export class ReloadHelper extends GameManagerHelper {
scene.unshiftPhase(titlePhase); scene.unshiftPhase(titlePhase);
this.game.endPhase(); // End the currently ongoing battle this.game.endPhase(); // End the currently ongoing battle
// remove all persistent mods before loading
// TODO: Look into why these aren't removed before load
if (this.game.scene.modifiers.length) {
console.log(
"Removing %d modifiers from scene on load...",
this.game.scene.modifiers.length,
this.game.scene.modifiers,
);
this.game.scene.modifiers = [];
}
titlePhase.loadSaveSlot(-1); // Load the desired session data titlePhase.loadSaveSlot(-1); // Load the desired session data
this.game.phaseInterceptor.shift(); // Loading the save slot also ended TitlePhase, clean it up this.game.phaseInterceptor.shift(); // Loading the save slot also ended TitlePhase, clean it up
@ -73,6 +83,6 @@ export class ReloadHelper extends GameManagerHelper {
} }
await this.game.phaseInterceptor.to(CommandPhase); await this.game.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]=================="); console.log("==================[New Turn (Reloaded)]==================");
} }
} }

View File

@ -1,5 +1,6 @@
import { expect, describe, it, beforeAll } from "vitest"; import { expect, describe, it, beforeAll } from "vitest";
import { randomString, padInt } from "#app/utils/common"; import { randomString, padInt } from "#app/utils/common";
import { deepMergeSpriteData } from "#app/utils/data";
import Phaser from "phaser"; import Phaser from "phaser";
@ -9,6 +10,7 @@ describe("utils", () => {
type: Phaser.HEADLESS, type: Phaser.HEADLESS,
}); });
}); });
describe("randomString", () => { describe("randomString", () => {
it("should return a string of the specified length", () => { it("should return a string of the specified length", () => {
const str = randomString(10); const str = randomString(10);
@ -46,4 +48,33 @@ describe("utils", () => {
expect(result).toBe("1"); expect(result).toBe("1");
}); });
}); });
describe("deepMergeSpriteData", () => {
it("should merge two objects' common properties", () => {
const dest = { a: 1, b: 2 };
const source = { a: 3, b: 3, e: 4 };
deepMergeSpriteData(dest, source);
expect(dest).toEqual({ a: 3, b: 3 });
});
it("does nothing for identical objects", () => {
const dest = { a: 1, b: 2 };
const source = { a: 1, b: 2 };
deepMergeSpriteData(dest, source);
expect(dest).toEqual({ a: 1, b: 2 });
});
it("should preserve missing and mistyped properties", () => {
const dest = { a: 1, c: 56, d: "test" };
const source = { a: "apple", b: 3, d: "no hablo español" };
deepMergeSpriteData(dest, source);
expect(dest).toEqual({ a: 1, c: 56, d: "no hablo español" });
});
it("should copy arrays verbatim even with mismatches", () => {
const dest = { a: 1, b: [{ d: 1 }, { d: 2 }, { d: 3 }] };
const source = { a: 3, b: [{ c: [4, 5] }, { p: [7, 8] }], e: 4 };
deepMergeSpriteData(dest, source);
expect(dest).toEqual({ a: 3, b: [{ c: [4, 5] }, { p: [7, 8] }] });
});
});
}); });