Merge branch 'beta' into egg-move-scripts

This commit is contained in:
Bertie690 2025-09-01 11:39:48 -04:00 committed by GitHub
commit 042ad10682
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 692 additions and 233 deletions

View File

@ -20,6 +20,7 @@ permissions:
jobs:
create-release:
if: github.repository == 'pagefaultgames/pokerogue' && (vars.BETA_DEPLOY_BRANCH == '' || ! startsWith(vars.BETA_DEPLOY_BRANCH, 'release'))
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed for github cli commands
runs-on: ubuntu-latest
@ -36,11 +37,13 @@ jobs:
exit 1
fi
shell: bash
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.PAGEFAULT_APP_ID }}
private-key: ${{ secrets.PAGEFAULT_APP_PRIVATE_KEY }}
- name: Check out code
uses: actions/checkout@v4
with:
@ -48,8 +51,10 @@ jobs:
# Always base off of beta branch, regardless of the branch the workflow was triggered from.
ref: beta
token: ${{ steps.app-token.outputs.token }}
- name: Create release branch
run: git checkout -b release
# In order to be able to open a PR into beta, we need the branch to have at least one change.
- name: Overwrite RELEASE file
run: |
@ -58,11 +63,14 @@ jobs:
echo "Release v${{ github.event.inputs.versionName }}" > RELEASE
git add RELEASE
git commit -m "Stage release v${{ github.event.inputs.versionName }}"
- name: Push new branch
run: git push origin release
# The repository variable is used by the deploy-beta workflow to determine whether to deploy from beta or release.
- name: Set repository variable
run: GITHUB_TOKEN="${{ steps.app-token.outputs.token }}" gh variable set BETA_DEPLOY_BRANCH --body "release"
- name: Create pull request to main
run: |
gh pr create --base main \
@ -70,6 +78,7 @@ jobs:
--title "Release v${{ github.event.inputs.versionName }} to main" \
--body "This PR is for the release of v${{ github.event.inputs.versionName }}, and was created automatically by the GitHub Actions workflow invoked by ${{ github.actor }}" \
--draft
- name: Create pull request to beta
run: |
gh pr create --base beta \

View File

@ -12,6 +12,7 @@ on:
jobs:
deploy:
if: github.repository == 'pagefaultgames/pokerogue' && github.ref_name == (vars.BETA_DEPLOY_BRANCH || 'beta')
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

View File

@ -11,6 +11,7 @@ on:
jobs:
deploy:
if: github.repository == 'pagefaultgames/pokerogue'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

View File

@ -6,11 +6,13 @@ on:
- main
- beta
- release
- 'hotfix*'
pull_request:
branches:
- main
- beta
- release
- 'hotfix*'
merge_group:
types: [checks_requested]
@ -18,6 +20,7 @@ jobs:
pages:
name: Github Pages
if: github.repository == 'pagefaultgames/pokerogue'
timeout-minutes: 10
runs-on: ubuntu-latest
env:
api-dir: ./
@ -67,7 +70,7 @@ jobs:
pnpm exec typedoc --out /tmp/docs --githubPages false --entryPoints ./src/
- name: Commit & Push docs
if: github.event_name == 'push'
if: github.event_name == 'push' && (github.ref_name == 'beta' || github.ref_name == 'main')
run: |
cd pokerogue_gh
git config user.email "github-actions[bot]@users.noreply.github.com"

View File

@ -6,17 +6,20 @@ on:
- main
- beta
- release
- 'hotfix*'
pull_request:
branches:
- main
- beta
- release
- 'hotfix*'
merge_group:
types: [checks_requested]
jobs:
run-linters:
name: Run all linters
timeout-minutes: 10
runs-on: ubuntu-latest
steps:

View File

@ -6,6 +6,7 @@ jobs:
# Set the BETA_DEPLOY_BRANCH variable to beta when a release branch is deleted
update-release-var:
if: github.repository == 'pagefaultgames/pokerogue' && github.event.ref_type == 'branch' && github.event.ref == 'release'
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Set BETA_DEPLOY_BRANCH to beta

View File

@ -21,6 +21,7 @@ jobs:
test:
# We can't use dynmically named jobs until https://github.com/orgs/community/discussions/13261 is implemented
name: Shard
timeout-minutes: 10
runs-on: ubuntu-latest
if: ${{ !inputs.skip }}
steps:

View File

@ -6,17 +6,20 @@ on:
- main
- beta
- release
- 'hotfix*'
pull_request:
branches:
- main
- beta
- release
- 'hotfix*'
merge_group:
types: [checks_requested]
workflow_dispatch:
jobs:
check-path-change-filter:
timeout-minutes: 5
runs-on: ubuntu-latest
permissions:
pull-requests: read
@ -35,6 +38,8 @@ jobs:
name: Run Tests
needs: check-path-change-filter
strategy:
# don't stop upon 1 shard failing
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
uses: ./.github/workflows/test-shard-template.yml

View File

@ -1,7 +1,7 @@
{
"name": "pokemon-rogue-battle",
"private": true,
"version": "1.11.0",
"version": "1.10.4",
"type": "module",
"scripts": {
"start": "vite",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 151 B

@ -1 +1 @@
Subproject commit 58fa5f9b6e94469017bfbe69bef992ed48ef5343
Subproject commit 102cbdcd924e2a7cdc7eab64d1ce79f6ec7604ff

View File

@ -1,3 +1,7 @@
self.addEventListener('install', function () {
console.log('Service worker installing...');
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
})

View File

@ -1476,10 +1476,7 @@ export class BattleScene extends SceneBase {
pokemon.resetBattleAndWaveData();
pokemon.resetTera();
applyAbAttrs("PostBattleInitAbAttr", { pokemon });
if (
pokemon.hasSpecies(SpeciesId.TERAPAGOS) ||
(this.gameMode.isClassic && this.currentBattle.waveIndex > 180 && this.currentBattle.waveIndex <= 190)
) {
if (pokemon.hasSpecies(SpeciesId.TERAPAGOS)) {
this.arena.playerTerasUsed = 0;
}
}

View File

@ -53,12 +53,6 @@ export const defaultStarterSpecies: SpeciesId[] = [
SpeciesId.QUAXLY,
];
export const defaultStarterSpeciesAndEvolutions: SpeciesId[] = defaultStarterSpecies.flatMap(id => [
id,
(id + 1) as SpeciesId,
(id + 2) as SpeciesId,
]);
export const saveKey = "x0i2O7WRiANTqPmZ"; // Temporary; secure encryption is not yet necessary
/**

View File

@ -970,6 +970,8 @@ export class MoveImmunityStatStageChangeAbAttr extends MoveImmunityAbAttr {
export interface PostMoveInteractionAbAttrParams extends AugmentMoveInteractionAbAttrParams {
/** Stores the hit result of the move used in the interaction */
readonly hitResult: HitResult;
/** The amount of damage dealt in the interaction */
readonly damage: number;
}
export class PostDefendAbAttr extends AbAttr {
@ -1079,20 +1081,16 @@ export class PostDefendHpGatedStatStageChangeAbAttr extends PostDefendAbAttr {
this.selfTarget = selfTarget;
}
override canApply({ pokemon, opponent: attacker, move }: PostMoveInteractionAbAttrParams): boolean {
override canApply({ pokemon, opponent: attacker, move, damage }: PostMoveInteractionAbAttrParams): boolean {
const hpGateFlat: number = Math.ceil(pokemon.getMaxHp() * this.hpGate);
const lastAttackReceived = pokemon.turnData.attacksReceived[pokemon.turnData.attacksReceived.length - 1];
const damageReceived = lastAttackReceived?.damage || 0;
return (
this.condition(pokemon, attacker, move) && pokemon.hp <= hpGateFlat && pokemon.hp + damageReceived > hpGateFlat
);
return this.condition(pokemon, attacker, move) && pokemon.hp <= hpGateFlat && pokemon.hp + damage > hpGateFlat;
}
override apply({ simulated, pokemon, opponent: attacker }: PostMoveInteractionAbAttrParams): void {
override apply({ simulated, pokemon, opponent }: PostMoveInteractionAbAttrParams): void {
if (!simulated) {
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
(this.selfTarget ? pokemon : attacker).getBattlerIndex(),
(this.selfTarget ? pokemon : opponent).getBattlerIndex(),
true,
this.stats,
this.stages,
@ -1263,17 +1261,17 @@ export class PostDefendContactApplyTagChanceAbAttr extends PostDefendAbAttr {
this.turnCount = turnCount;
}
override canApply({ move, pokemon, opponent: attacker }: PostMoveInteractionAbAttrParams): boolean {
override canApply({ move, pokemon, opponent }: PostMoveInteractionAbAttrParams): boolean {
return (
move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon }) &&
move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: opponent, target: pokemon }) &&
pokemon.randBattleSeedInt(100) < this.chance &&
attacker.canAddTag(this.tagType)
opponent.canAddTag(this.tagType)
);
}
override apply({ simulated, opponent: attacker, move }: PostMoveInteractionAbAttrParams): void {
override apply({ pokemon, simulated, opponent, move }: PostMoveInteractionAbAttrParams): void {
if (!simulated) {
attacker.addTag(this.tagType, this.turnCount, move.id, attacker.id);
opponent.addTag(this.tagType, this.turnCount, move.id, pokemon.id);
}
}
}
@ -3014,41 +3012,44 @@ export class PostSummonFormChangeAbAttr extends PostSummonAbAttr {
}
}
/** Attempts to copy a pokemon's ability */
/**
* Attempts to copy a pokemon's ability
*
* @remarks
* Hardcodes idiosyncrasies specific to trace, so should not be used for other abilities
* that might copy abilities in the future
* @sealed
*/
export class PostSummonCopyAbilityAbAttr extends PostSummonAbAttr {
private target: Pokemon;
private targetAbilityName: string;
override canApply({ pokemon }: AbAttrBaseParams): boolean {
const targets = pokemon.getOpponents();
override canApply({ pokemon, simulated }: AbAttrBaseParams): boolean {
const targets = pokemon
.getOpponents()
.filter(t => t.getAbility().isCopiable || t.getAbility().id === AbilityId.WONDER_GUARD);
if (!targets.length) {
return false;
}
let target: Pokemon;
if (targets.length > 1) {
globalScene.executeWithSeedOffset(() => (target = randSeedItem(targets)), globalScene.currentBattle.waveIndex);
// simulated call always chooses first target so as to not advance RNG
if (targets.length > 1 && !simulated) {
target = targets[randSeedInt(targets.length)];
} else {
target = targets[0];
}
if (
!target!.getAbility().isCopiable &&
// Wonder Guard is normally uncopiable so has the attribute, but Trace specifically can copy it
!(pokemon.hasAbility(AbilityId.TRACE) && target!.getAbility().id === AbilityId.WONDER_GUARD)
) {
return false;
}
this.target = target!;
this.targetAbilityName = allAbilities[target!.getAbility().id].name;
this.target = target;
this.targetAbilityName = allAbilities[target.getAbility().id].name;
return true;
}
override apply({ pokemon, simulated }: AbAttrBaseParams): void {
if (!simulated) {
pokemon.setTempAbility(this.target!.getAbility());
setAbilityRevealed(this.target!);
// Protect against this somehow being called before canApply by ensuring target is defined
if (!simulated && this.target) {
pokemon.setTempAbility(this.target.getAbility());
setAbilityRevealed(this.target);
pokemon.updateInfo();
}
}

View File

@ -937,7 +937,7 @@ class StealthRockTag extends DamagingTrapTag {
protected override getTriggerMessage(pokemon: Pokemon): string {
return i18next.t("arenaTag:stealthRockActivateTrap", {
pokemonName: getPokemonNameWithAffix(pokemon),
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
});
}

View File

@ -190,7 +190,7 @@ export const speciesEggMoves = {
[SpeciesId.WYNAUT]: [ MoveId.RECOVER, MoveId.SHED_TAIL, MoveId.TAUNT, MoveId.COMEUPPANCE ],
[SpeciesId.SNORUNT]: [ MoveId.SPARKLY_SWIRL, MoveId.NASTY_PLOT, MoveId.EARTH_POWER, MoveId.BLOOD_MOON ],
[SpeciesId.SPHEAL]: [ MoveId.FLIP_TURN, MoveId.FREEZE_DRY, MoveId.SLACK_OFF, MoveId.STEAM_ERUPTION ],
[SpeciesId.CLAMPERL]: [ MoveId.SHELL_SIDE_ARM, MoveId.BOUNCY_BUBBLE, MoveId.FREEZE_DRY, MoveId.STEAM_ERUPTION ],
[SpeciesId.CLAMPERL]: [ MoveId.SHELL_SIDE_ARM, MoveId.SNIPE_SHOT, MoveId.GIGA_DRAIN, MoveId.BOUNCY_BUBBLE ],
[SpeciesId.RELICANTH]: [ MoveId.DRAGON_DANCE, MoveId.SHORE_UP, MoveId.WAVE_CRASH, MoveId.DIAMOND_STORM ],
[SpeciesId.LUVDISC]: [ MoveId.BATON_PASS, MoveId.HEART_SWAP, MoveId.GLITZY_GLOW, MoveId.REVIVAL_BLESSING ],
[SpeciesId.BAGON]: [ MoveId.HEADLONG_RUSH, MoveId.FIRE_LASH, MoveId.DRAGON_DANCE, MoveId.DRAGON_DARTS ],

View File

@ -402,7 +402,7 @@ export const starterPassiveAbilities: StarterPassiveAbilities = {
[SpeciesId.SPHEAL]: { 0: AbilityId.UNAWARE },
[SpeciesId.SEALEO]: { 0: AbilityId.UNAWARE },
[SpeciesId.WALREIN]: { 0: AbilityId.UNAWARE },
[SpeciesId.CLAMPERL]: { 0: AbilityId.DAUNTLESS_SHIELD },
[SpeciesId.CLAMPERL]: { 0: AbilityId.OVERCOAT },
[SpeciesId.GOREBYSS]: { 0: AbilityId.ARENA_TRAP },
[SpeciesId.HUNTAIL]: { 0: AbilityId.ARENA_TRAP },
[SpeciesId.RELICANTH]: { 0: AbilityId.PRIMORDIAL_SEA },

View File

@ -1,3 +1,4 @@
import { defaultStarterSpecies } from "#app/constants";
import { globalScene } from "#app/global-scene";
import { speciesStarterCosts } from "#balance/starters";
import { allMoves } from "#data/data-lists";
@ -76,7 +77,8 @@ export enum EvolutionItem {
LEADERS_CREST
}
type TyrogueMove = MoveId.LOW_SWEEP | MoveId.MACH_PUNCH | MoveId.RAPID_SPIN;
const tyrogueMoves = [MoveId.LOW_SWEEP, MoveId.MACH_PUNCH, MoveId.RAPID_SPIN] as const;
type TyrogueMove = typeof tyrogueMoves[number];
/**
* Pokemon Evolution tuple type consisting of:
@ -191,7 +193,7 @@ export class SpeciesEvolutionCondition {
case EvoCondKey.WEATHER:
return cond.weather.includes(globalScene.arena.getWeatherType());
case EvoCondKey.TYROGUE:
return pokemon.getMoveset(true).find(m => m.moveId as TyrogueMove)?.moveId === cond.move;
return pokemon.getMoveset(true).find(m => (tyrogueMoves as readonly MoveId[]) .includes(m.moveId))?.moveId === cond.move;
case EvoCondKey.NATURE:
return cond.nature.includes(pokemon.getNature());
case EvoCondKey.RANDOM_FORM: {
@ -1883,6 +1885,15 @@ export function initPokemonPrevolutions(): void {
// TODO: This may cause funny business for double starters such as Pichu/Pikachu
export const pokemonStarters: PokemonPrevolutions = {};
/**
* The default species and all their evolutions
*/
export const defaultStarterSpeciesAndEvolutions: SpeciesId[] = defaultStarterSpecies.flatMap(id => {
const stage2ids = pokemonEvolutions[id]?.map(e => e.speciesId) ?? [];
const stage3ids = stage2ids.flatMap(s2id => pokemonEvolutions[s2id]?.map(e => e.speciesId) ?? []);
return [id, ...stage2ids, ...stage3ids];
});
export function initPokemonStarters(): void {
const starterKeys = Object.keys(pokemonPrevolutions);
starterKeys.forEach(pk => {

View File

@ -1058,8 +1058,7 @@ export class SeedTag extends SerializableBattlerTag {
// Check which opponent to restore HP to
const source = pokemon.getOpponents().find(o => o.getBattlerIndex() === this.sourceIndex);
if (!source) {
console.warn(`Failed to get source Pokemon for SeedTag lapse; id: ${this.sourceId}`);
return false;
return true;
}
const cancelled = new BooleanHolder(false);

View File

@ -1,6 +1,6 @@
import type { FixedBattleConfig } from "#app/battle";
import { getRandomTrainerFunc } from "#app/battle";
import { defaultStarterSpeciesAndEvolutions } from "#app/constants";
import { defaultStarterSpeciesAndEvolutions } from "#balance/pokemon-evolutions";
import { speciesStarterCosts } from "#balance/starters";
import type { PokemonSpecies } from "#data/pokemon-species";
import { AbilityAttr } from "#enums/ability-attr";

View File

@ -2325,6 +2325,13 @@ export class HealOnAllyAttr extends HealAttr {
// Don't trigger if not targeting an ally
return target === user.getAlly() && super.canApply(user, target, _move, _args);
}
override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
if (user.isOpponent(target)) {
return false;
}
return super.apply(user, target, _move, _args);
}
}
/**
@ -3270,7 +3277,6 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr {
)
)
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
// Queue up an attack on the given slot.
globalScene.arena.positionalTagManager.addTag<PositionalTagType.DELAYED_ATTACK>({

View File

@ -237,7 +237,7 @@ export const BerriesAboundEncounter: MysteryEncounter = MysteryEncounterBuilder.
const config = globalScene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0];
config.pokemonConfigs![0].tags = [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON];
config.pokemonConfigs![0].mysteryEncounterBattleEffects = (pokemon: Pokemon) => {
queueEncounterMessage(`${namespace}:option.2.boss_enraged`);
queueEncounterMessage(`${namespace}:option.2.bossEnraged`);
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
pokemon.getBattlerIndex(),

View File

@ -249,7 +249,7 @@ async function tryApplyDigRewardItems() {
await showEncounterText(
i18next.t("battle:rewardGainCount", {
modifierName: leftovers.name,
count: 2,
count: 1,
}),
null,
undefined,

View File

@ -2,6 +2,7 @@ import { globalScene } from "#app/global-scene";
import { allSpecies, modifierTypes } from "#data/data-lists";
import { getLevelTotalExp } from "#data/exp";
import type { PokemonSpecies } from "#data/pokemon-species";
import { AbilityId } from "#enums/ability-id";
import { Challenges } from "#enums/challenges";
import { ModifierTier } from "#enums/modifier-tier";
import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode";
@ -10,8 +11,9 @@ import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { Nature } from "#enums/nature";
import { PartyMemberStrength } from "#enums/party-member-strength";
import { PlayerGender } from "#enums/player-gender";
import { PokemonType } from "#enums/pokemon-type";
import { MAX_POKEMON_TYPE, PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { TrainerType } from "#enums/trainer-type";
import type { PlayerPokemon, Pokemon } from "#field/pokemon";
import type { PokemonHeldItemModifier } from "#modifiers/modifier";
@ -219,6 +221,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit
await showEncounterText(`${namespace}:option.1.dreamComplete`);
await doNewTeamPostProcess(transformations);
globalScene.phaseManager.unshiftNew("PartyHealPhase", true);
setEncounterRewards({
guaranteedModifierTypeFuncs: [
modifierTypes.MEMORY_MUSHROOM,
@ -230,7 +233,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit
],
fillRemaining: false,
});
leaveEncounterWithoutBattle(true);
leaveEncounterWithoutBattle(false);
})
.build(),
)
@ -431,6 +434,8 @@ function getTeamTransformations(): PokemonTransformation[] {
newAbilityIndex,
undefined,
);
transformation.newPokemon.teraType = randSeedInt(MAX_POKEMON_TYPE);
}
return pokemonTransformations;
@ -440,6 +445,8 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) {
let atLeastOneNewStarter = false;
for (const transformation of transformations) {
const previousPokemon = transformation.previousPokemon;
const oldHpRatio = previousPokemon.getHpRatio(true);
const oldStatus = previousPokemon.status;
const newPokemon = transformation.newPokemon;
const speciesRootForm = newPokemon.species.getRootSpeciesId();
@ -462,6 +469,19 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) {
}
newPokemon.calculateStats();
if (oldHpRatio > 0) {
newPokemon.hp = Math.ceil(oldHpRatio * newPokemon.getMaxHp());
// Assume that the `status` instance can always safely be transferred to the new pokemon
// This is the case (as of version 1.10.4)
// Safeguard against COMATOSE here
if (!newPokemon.hasAbility(AbilityId.COMATOSE, false, true)) {
newPokemon.status = oldStatus;
}
} else {
newPokemon.hp = 0;
newPokemon.doSetStatus(StatusEffect.FAINT);
}
await newPokemon.updateInfo();
}

View File

@ -44,6 +44,34 @@ export abstract class PhasePriorityQueue {
public clear(): void {
this.queue.splice(0, this.queue.length);
}
/**
* Attempt to remove one or more Phases from the current queue.
* @param phaseFilter - The function to select phases for removal
* @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases;
* default `1`
* @returns The number of successfully removed phases
* @todo Remove this eventually once the patchwork bug this is used for is fixed
*/
public tryRemovePhase(phaseFilter: (phase: Phase) => boolean, removeCount: number | "all" = 1): number {
if (removeCount === "all") {
removeCount = this.queue.length;
} else if (removeCount < 1) {
return 0;
}
let numRemoved = 0;
do {
const phaseIndex = this.queue.findIndex(phaseFilter);
if (phaseIndex === -1) {
break;
}
this.queue.splice(phaseIndex, 1);
numRemoved++;
} while (numRemoved < removeCount && this.queue.length > 0);
return numRemoved;
}
}
/**

View File

@ -795,7 +795,7 @@ export class PokemonSpecies extends PokemonSpeciesForm implements Localizable {
return Gender.GENDERLESS;
}
if (randSeedFloat() <= this.malePercent) {
if (randSeedFloat() * 100 <= this.malePercent) {
return Gender.MALE;
}
return Gender.FEMALE;

View File

@ -11,6 +11,7 @@ import type { MoveId } from "#enums/move-id";
import type { Nature } from "#enums/nature";
import type { PokemonType } from "#enums/pokemon-type";
import type { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import type { AttackMoveResult } from "#types/attack-move-result";
import type { IllusionData } from "#types/illusion-data";
import type { TurnMove } from "#types/turn-move";
@ -326,6 +327,14 @@ export class PokemonTurnData {
public switchedInThisTurn = false;
public failedRunAway = false;
public joinedRound = false;
/** Tracker for a pending status effect
*
* @remarks
* Set whenever {@linkcode Pokemon#trySetStatus} succeeds in order to prevent subsequent status effects
* from being applied. Necessary because the status is not actually set until the {@linkcode ObtainStatusEffectPhase} runs,
* which may not happen before another status effect is attempted to be applied.
*/
public pendingStatus: StatusEffect = StatusEffect.NONE;
/**
* The amount of times this Pokemon has acted again and used a move in the current turn.
* Used to make sure multi-hits occur properly when the user is

View File

@ -20,3 +20,6 @@ export enum PokemonType {
FAIRY,
STELLAR
}
/** The largest legal value for a {@linkcode PokemonType} (includes Stellar) */
export const MAX_POKEMON_TYPE = PokemonType.STELLAR;

View File

@ -454,7 +454,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
getNameToRender(useIllusion = true) {
const illusion = this.summonData.illusion;
const name = useIllusion ? (illusion?.name ?? this.name) : this.name;
const nickname: string | undefined = useIllusion ? illusion?.nickname : this.nickname;
const nickname: string | undefined = useIllusion ? (illusion?.nickname ?? this.nickname) : this.nickname;
try {
if (nickname) {
return decodeURIComponent(escape(atob(nickname))); // TODO: Remove `atob` and `escape`... eventually...
@ -1768,7 +1768,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @returns Whether this Pokemon is currently fused with another species.
*/
isFusion(useIllusion = false): boolean {
return useIllusion ? !!this.summonData.illusion?.fusionSpecies : !!this.fusionSpecies;
return !!(useIllusion ? (this.summonData.illusion?.fusionSpecies ?? this.fusionSpecies) : this.fusionSpecies);
}
/**
@ -2221,8 +2221,16 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return this.hasPassive() && (!canApply || this.canApplyAbility(true)) && this.getPassiveAbility().hasAttr(attrType);
}
public getAbilityPriorities(): [number, number] {
return [this.getAbility().postSummonPriority, this.getPassiveAbility().postSummonPriority];
/**
* Return the ability priorities of the pokemon's ability and, if enabled, its passive ability
* @returns A tuple containing the ability priorities of the pokemon
*/
public getAbilityPriorities(): [number] | [activePriority: number, passivePriority: number] {
const abilityPriority = this.getAbility().postSummonPriority;
if (this.hasPassive()) {
return [abilityPriority, this.getPassiveAbility().postSummonPriority];
}
return [abilityPriority];
}
/**
@ -3062,14 +3070,17 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (this.level < levelMove[0]) {
break;
}
let weight = levelMove[0];
let weight = levelMove[0] + 20;
// Evolution Moves
if (weight === EVOLVE_MOVE) {
weight = 50;
if (levelMove[0] === EVOLVE_MOVE) {
weight = 70;
}
// Assume level 1 moves with 80+ BP are "move reminder" moves and bump their weight. Trainers use actual relearn moves.
if ((weight === 1 && allMoves[levelMove[1]].power >= 80) || (weight === RELEARN_MOVE && this.hasTrainer())) {
weight = 40;
if (
(levelMove[0] === 1 && allMoves[levelMove[1]].power >= 80) ||
(levelMove[0] === RELEARN_MOVE && this.hasTrainer())
) {
weight = 60;
}
if (!movePool.some(m => m[0] === levelMove[1]) && !allMoves[levelMove[1]].name.endsWith(" (N)")) {
movePool.push([levelMove[1], weight]);
@ -3099,11 +3110,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
if (compatible && !movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) {
if (tmPoolTiers[moveId] === ModifierTier.COMMON && this.level >= 15) {
movePool.push([moveId, 4]);
movePool.push([moveId, 24]);
} else if (tmPoolTiers[moveId] === ModifierTier.GREAT && this.level >= 30) {
movePool.push([moveId, 8]);
movePool.push([moveId, 28]);
} else if (tmPoolTiers[moveId] === ModifierTier.ULTRA && this.level >= 50) {
movePool.push([moveId, 14]);
movePool.push([moveId, 34]);
}
}
}
@ -3113,7 +3124,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
for (let i = 0; i < 3; i++) {
const moveId = speciesEggMoves[this.species.getRootSpeciesId()][i];
if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) {
movePool.push([moveId, 40]);
movePool.push([moveId, 60]);
}
}
const moveId = speciesEggMoves[this.species.getRootSpeciesId()][3];
@ -3124,13 +3135,13 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
!allMoves[moveId].name.endsWith(" (N)") &&
!this.isBoss()
) {
movePool.push([moveId, 30]);
movePool.push([moveId, 50]);
}
if (this.fusionSpecies) {
for (let i = 0; i < 3; i++) {
const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][i];
if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) {
movePool.push([moveId, 40]);
movePool.push([moveId, 60]);
}
}
const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][3];
@ -3141,7 +3152,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
!allMoves[moveId].name.endsWith(" (N)") &&
!this.isBoss()
) {
movePool.push([moveId, 30]);
movePool.push([moveId, 50]);
}
}
}
@ -3222,6 +3233,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
rand -= stabMovePool[index++][1];
}
this.moveset.push(new PokemonMove(stabMovePool[index][0]));
} else {
// If there are no damaging STAB moves, just force a random damaging move
const attackMovePool = baseWeights.filter(m => allMoves[m[0]].category !== MoveCategory.STATUS);
if (attackMovePool.length) {
const totalWeight = attackMovePool.reduce((v, m) => v + m[1], 0);
let rand = randSeedInt(totalWeight);
let index = 0;
while (rand > attackMovePool[index][1]) {
rand -= attackMovePool[index++][1];
}
this.moveset.push(new PokemonMove(attackMovePool[index][0], 0, 0));
}
}
while (baseWeights.length > this.moveset.length && this.moveset.length < 4) {
@ -4790,7 +4813,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (effect !== StatusEffect.FAINT) {
// Status-overriding moves (i.e. Rest) fail if their respective status already exists;
// all other moves fail if the target already has _any_ status
if (overrideStatus ? this.status?.effect === effect : this.status) {
if (overrideStatus ? this.status?.effect === effect : this.status || this.turnData.pendingStatus) {
this.queueStatusImmuneMessage(quiet, overrideStatus ? "overlap" : "other"); // having different status displays generic fail message
return false;
}
@ -4942,6 +4965,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (overrideStatus) {
this.resetStatus(false);
} else {
this.turnData.pendingStatus = effect;
}
globalScene.phaseManager.unshiftNew(
@ -4961,6 +4986,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect.
* @param effect - The {@linkcode StatusEffect} to set
* @remarks
* Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon.turnData | turnData}.
*
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
doSetStatus(effect: Exclude<StatusEffect, StatusEffect.SLEEP>): void;
@ -4969,6 +4996,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @param effect - {@linkcode StatusEffect.SLEEP}
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* @remarks
* Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}.
*
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
doSetStatus(effect: StatusEffect.SLEEP, sleepTurnsRemaining?: number): void;
@ -4978,6 +5007,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* and is unused for all non-sleep Statuses
* @remarks
* Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}.
*
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
doSetStatus(effect: StatusEffect, sleepTurnsRemaining?: number): void;
@ -4987,6 +5018,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* and is unused for all non-sleep Statuses
* @remarks
* Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}.
*
* This method does **not** check for feasibility; that is the responsibility of the caller.
* @todo Make this and all related fields private and change tests to use a field-based helper or similar
*/
@ -4994,6 +5027,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
effect: StatusEffect,
sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4),
): void {
// Reset any pending status
this.turnData.pendingStatus = StatusEffect.NONE;
switch (effect) {
case StatusEffect.POISON:
case StatusEffect.TOXIC:

View File

@ -10,6 +10,10 @@ import InputTextPlugin from "phaser3-rex-plugins/plugins/inputtext-plugin";
import TransitionImagePackPlugin from "phaser3-rex-plugins/templates/transitionimagepack/transitionimagepack-plugin";
import UIPlugin from "phaser3-rex-plugins/templates/ui/ui-plugin";
if (import.meta.env.DEV) {
document.title += " (Beta)";
}
// Catch global errors and display them in an alert so users can report the issue.
window.onerror = (_message, _source, _lineno, _colno, error) => {
console.error(error);

View File

@ -355,14 +355,23 @@ export class PhaseManager {
if (this.phaseQueuePrependSpliceIndex > -1) {
this.clearPhaseQueueSplice();
}
if (this.phaseQueuePrepend.length) {
while (this.phaseQueuePrepend.length) {
const poppedPhase = this.phaseQueuePrepend.pop();
if (poppedPhase) {
this.phaseQueue.unshift(poppedPhase);
}
this.phaseQueue.unshift(...this.phaseQueuePrepend);
this.phaseQueuePrepend.splice(0);
const unactivatedConditionalPhases: [() => boolean, Phase][] = [];
// Check if there are any conditional phases queued
for (const [condition, phase] of this.conditionalQueue) {
// Evaluate the condition associated with the phase
if (condition()) {
// If the condition is met, add the phase to the phase queue
this.pushPhase(phase);
} else {
// If the condition is not met, re-add the phase back to the end of the conditional queue
unactivatedConditionalPhases.push([condition, phase]);
}
}
this.conditionalQueue = unactivatedConditionalPhases;
if (!this.phaseQueue.length) {
this.populatePhaseQueue();
// Clear the conditionalQueue if there are no phases left in the phaseQueue
@ -371,24 +380,6 @@ export class PhaseManager {
this.currentPhase = this.phaseQueue.shift() ?? null;
const unactivatedConditionalPhases: [() => boolean, Phase][] = [];
// Check if there are any conditional phases queued
while (this.conditionalQueue?.length) {
// Retrieve the first conditional phase from the queue
const conditionalPhase = this.conditionalQueue.shift();
// Evaluate the condition associated with the phase
if (conditionalPhase?.[0]()) {
// If the condition is met, add the phase to the phase queue
this.pushPhase(conditionalPhase[1]);
} else if (conditionalPhase) {
// If the condition is not met, re-add the phase back to the front of the conditional queue
unactivatedConditionalPhases.push(conditionalPhase);
} else {
console.warn("condition phase is undefined/null!", conditionalPhase);
}
}
this.conditionalQueue.push(...unactivatedConditionalPhases);
if (this.currentPhase) {
console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;");
this.currentPhase.start();
@ -520,6 +511,25 @@ export class PhaseManager {
this.dynamicPhaseQueues[type].push(phase);
}
/**
* Attempt to remove one or more Phases from the given DynamicPhaseQueue, removing the equivalent amount of {@linkcode ActivatePriorityQueuePhase}s from the queue.
* @param type - The {@linkcode DynamicPhaseType} to check
* @param phaseFilter - The function to select phases for removal
* @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases;
* default `1`
* @todo Remove this eventually once the patchwork bug this is used for is fixed
*/
public tryRemoveDynamicPhase(
type: DynamicPhaseType,
phaseFilter: (phase: Phase) => boolean,
removeCount: number | "all" = 1,
): void {
const numRemoved = this.dynamicPhaseQueues[type].tryRemovePhase(phaseFilter, removeCount);
for (let x = 0; x < numRemoved; x++) {
this.tryRemovePhase(p => p.is("ActivatePriorityQueuePhase"));
}
}
/**
* Unshifts the top phase from the corresponding dynamic queue onto {@linkcode phaseQueue}
* @param type {@linkcode DynamicPhaseType} The type of dynamic phase to start

View File

@ -400,10 +400,17 @@ export class MoveEffectPhase extends PokemonPhase {
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param hitResult - The {@linkcode HitResult} of the attempted move
* @param damage - The amount of damage dealt to the target in the interaction
* @param wasCritical - `true` if the move was a critical hit
*/
protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void {
const params = { pokemon: target, opponent: user, move: this.move, hitResult };
protected applyOnGetHitAbEffects(
user: Pokemon,
target: Pokemon,
hitResult: HitResult,
damage: number,
wasCritical = false,
): void {
const params = { pokemon: target, opponent: user, move: this.move, hitResult, damage };
applyAbAttrs("PostDefendAbAttr", params);
if (wasCritical) {
@ -763,12 +770,12 @@ export class MoveEffectPhase extends PokemonPhase {
this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target);
const [hitResult, wasCritical] = this.applyMove(user, target, effectiveness);
const [hitResult, wasCritical, dmg] = this.applyMove(user, target, effectiveness);
// Apply effects to the user (always) and the target (if not blocked by substitute).
this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, true);
if (!this.move.hitsSubstitute(user, target)) {
this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical);
this.applyOnTargetEffects(user, target, hitResult, firstTarget, dmg, wasCritical);
}
if (this.lastHit) {
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger);
@ -788,9 +795,13 @@ export class MoveEffectPhase extends PokemonPhase {
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} targeted by the move
* @param effectiveness - The effectiveness of the move against the target
* @returns The {@linkcode HitResult} of the move against the target and a boolean indicating whether the target was crit
* @returns The {@linkcode HitResult} of the move against the target, a boolean indicating whether the target was crit, and the amount of damage dealt
*/
protected applyMoveDamage(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] {
protected applyMoveDamage(
user: Pokemon,
target: Pokemon,
effectiveness: TypeDamageMultiplier,
): [result: HitResult, critical: boolean, damage: number] {
const isCritical = target.getCriticalHitResult(user, this.move);
/*
@ -821,7 +832,7 @@ export class MoveEffectPhase extends PokemonPhase {
const isOneHitKo = result === HitResult.ONE_HIT_KO;
if (!dmg) {
return [result, false];
return [result, false, 0];
}
target.lapseTags(BattlerTagLapseType.HIT);
@ -850,7 +861,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
if (damage <= 0) {
return [result, isCritical];
return [result, isCritical, damage];
}
if (user.isPlayer()) {
@ -879,7 +890,7 @@ export class MoveEffectPhase extends PokemonPhase {
globalScene.applyModifiers(DamageMoneyRewardModifier, true, user, new NumberHolder(damage));
}
return [result, isCritical];
return [result, isCritical, damage];
}
/**
@ -932,12 +943,17 @@ export class MoveEffectPhase extends PokemonPhase {
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} struck by the move
* @param effectiveness - The effectiveness of the move against the target
* @returns The {@linkcode HitResult} of the move against the target, a boolean indicating whether the target was crit, and the amount of damage dealt
*/
protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] {
protected applyMove(
user: Pokemon,
target: Pokemon,
effectiveness: TypeDamageMultiplier,
): [HitResult, critical: boolean, damage: number] {
const moveCategory = user.getMoveCategory(target, this.move);
if (moveCategory === MoveCategory.STATUS) {
return [HitResult.STATUS, false];
return [HitResult.STATUS, false, 0];
}
const result = this.applyMoveDamage(user, target, effectiveness);
@ -960,6 +976,7 @@ export class MoveEffectPhase extends PokemonPhase {
* @param target - The {@linkcode Pokemon} targeted by the move
* @param hitResult - The {@linkcode HitResult} obtained from applying the move
* @param firstTarget - `true` if the target is the first Pokemon hit by the attack
* @param damage - The amount of damage dealt to the target in the interaction
* @param wasCritical - `true` if the move was a critical hit
*/
protected applyOnTargetEffects(
@ -967,6 +984,7 @@ export class MoveEffectPhase extends PokemonPhase {
target: Pokemon,
hitResult: HitResult,
firstTarget: boolean,
damage: number,
wasCritical = false,
): void {
/** Does {@linkcode hitResult} indicate that damage was dealt to the target? */
@ -979,8 +997,8 @@ export class MoveEffectPhase extends PokemonPhase {
this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, false);
this.applyHeldItemFlinchCheck(user, target, dealsDamage);
this.applyOnGetHitAbEffects(user, target, hitResult, wasCritical);
applyAbAttrs("PostAttackAbAttr", { pokemon: user, opponent: target, move: this.move, hitResult });
this.applyOnGetHitAbEffects(user, target, hitResult, damage, wasCritical);
applyAbAttrs("PostAttackAbAttr", { pokemon: user, opponent: target, move: this.move, hitResult, damage: damage });
// We assume only enemy Pokemon are able to have the EnemyAttackStatusEffectChanceModifier from tokens
if (!user.isPlayer() && this.move.is("AttackMove")) {

View File

@ -177,7 +177,7 @@ export class SelectModifierPhase extends BattlePhase {
this.openModifierMenu(modifierType, cost, modifierSelectCallback);
}
} else {
this.applyModifier(modifierType.newModifier()!);
this.applyModifier(modifierType.newModifier()!, cost);
}
return cost === -1;
}

View File

@ -1,4 +1,5 @@
import { globalScene } from "#app/global-scene";
import { DynamicPhaseType } from "#enums/dynamic-phase-type";
import { SwitchType } from "#enums/switch-type";
import { UiMode } from "#enums/ui-mode";
import { BattlePhase } from "#phases/battle-phase";
@ -75,8 +76,11 @@ export class SwitchPhase extends BattlePhase {
if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) {
// Remove any pre-existing PostSummonPhase under the same field index.
// Pre-existing PostSummonPhases may occur when this phase is invoked during a prompt to switch at the start of a wave.
globalScene.phaseManager.tryRemovePhase(
// TODO: Separate the animations from `SwitchSummonPhase` and co. into another phase and use that on initial switch - this is a band-aid fix
globalScene.phaseManager.tryRemoveDynamicPhase(
DynamicPhaseType.POST_SUMMON,
p => p.is("PostSummonPhase") && p.player && p.fieldIndex === this.fieldIndex,
"all",
);
const switchType = option === PartyOption.PASS_BATON ? SwitchType.BATON_PASS : this.switchType;
globalScene.phaseManager.unshiftNew("SwitchSummonPhase", switchType, fieldIndex, slotIndex, this.doReturn);

View File

@ -179,12 +179,11 @@ export class TurnStartPhase extends FieldPhase {
// https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179
phaseManager.pushNew("WeatherEffectPhase");
phaseManager.pushNew("PositionalTagPhase");
phaseManager.pushNew("BerryPhase");
/** Add a new phase to check who should be taking status damage */
phaseManager.pushNew("CheckStatusEffectPhase", moveOrder);
phaseManager.pushNew("PositionalTagPhase");
phaseManager.pushNew("TurnEndPhase");
/*

View File

@ -1515,6 +1515,7 @@ export class GameData {
switch (dataType) {
case GameDataType.SYSTEM: {
dataStr = this.convertSystemDataStr(dataStr);
dataStr = dataStr.replace(/"playTime":\d+/, `"playTime":${this.gameStats.playTime + 60}`);
const systemData = this.parseSystemData(dataStr);
valid = !!systemData.dexData && !!systemData.timestamp;
break;

View File

@ -13,7 +13,7 @@ import { SettingsGamepadUiHandler } from "#ui/settings-gamepad-ui-handler";
import { SettingsKeyboardUiHandler } from "#ui/settings-keyboard-ui-handler";
import { SettingsUiHandler } from "#ui/settings-ui-handler";
import { StarterSelectUiHandler } from "#ui/starter-select-ui-handler";
import type Phaser from "phaser";
import Phaser from "phaser";
type ActionKeys = Record<Button, () => void>;
@ -224,25 +224,26 @@ export class UiInputs {
buttonSpeedChange(up = true): void {
const settingGameSpeed = settingIndex(SettingKeys.Game_Speed);
const settingOptions = Setting[settingGameSpeed].options;
let currentSetting = settingOptions.findIndex(item => item.value === globalScene.gameSpeed.toString());
// if current setting is -1, then the current game speed is not a valid option, so default to index 5 (3x)
if (currentSetting === -1) {
currentSetting = 5;
}
let direction: number;
if (up && globalScene.gameSpeed < 5) {
globalScene.gameData.saveSetting(
SettingKeys.Game_Speed,
Setting[settingGameSpeed].options.findIndex(item => item.label === `${globalScene.gameSpeed}x`) + 1,
);
if (globalScene.ui?.getMode() === UiMode.SETTINGS) {
(globalScene.ui.getHandler() as SettingsUiHandler).show([]);
}
direction = 1;
} else if (!up && globalScene.gameSpeed > 1) {
direction = -1;
} else {
return;
}
globalScene.gameData.saveSetting(
SettingKeys.Game_Speed,
Math.max(
Setting[settingGameSpeed].options.findIndex(item => item.label === `${globalScene.gameSpeed}x`) - 1,
0,
),
Phaser.Math.Clamp(currentSetting + direction, 0, settingOptions.length - 1),
);
if (globalScene.ui?.getMode() === UiMode.SETTINGS) {
(globalScene.ui.getHandler() as SettingsUiHandler).show([]);
}
}
}
}

View File

@ -287,9 +287,6 @@ export abstract class BattleInfo extends Phaser.GameObjects.Container {
2.5,
);
this.splicedIcon.setVisible(pokemon.isFusion(true));
if (!this.splicedIcon.visible) {
return;
}
this.splicedIcon
.on("pointerover", () =>
globalScene.ui.showTooltip(
@ -323,6 +320,10 @@ export abstract class BattleInfo extends Phaser.GameObjects.Container {
.setVisible(pokemon.isShiny())
.setTint(getVariantTint(baseVariant));
this.shinyIcon
.on("pointerover", () => globalScene.ui.showTooltip("", i18next.t("common:shinyOnHover") + shinyDescriptor))
.on("pointerout", () => globalScene.ui.hideTooltip());
if (!this.shinyIcon.visible) {
return;
}
@ -335,10 +336,6 @@ export abstract class BattleInfo extends Phaser.GameObjects.Container {
}
shinyDescriptor += ")";
}
this.shinyIcon
.on("pointerover", () => globalScene.ui.showTooltip("", i18next.t("common:shinyOnHover") + shinyDescriptor))
.on("pointerout", () => globalScene.ui.hideTooltip());
}
initInfo(pokemon: Pokemon) {

View File

@ -36,7 +36,7 @@ export class EnemyBattleInfo extends BattleInfo {
override constructTypeIcons(): void {
this.type1Icon = globalScene.add.sprite(-15, -15.5, "pbinfo_enemy_type1").setName("icon_type_1").setOrigin(0);
this.type2Icon = globalScene.add.sprite(-15, -2.5, "pbinfo_enemy_type2").setName("icon_type_2").setOrigin(0);
this.type3Icon = globalScene.add.sprite(0, 15.5, "pbinfo_enemy_type3").setName("icon_type_3").setOrigin(0);
this.type3Icon = globalScene.add.sprite(0, -15.5, "pbinfo_enemy_type").setName("icon_type_3").setOrigin(0);
this.add([this.type1Icon, this.type2Icon, this.type3Icon]);
}

View File

@ -21,7 +21,7 @@ export class PlayerBattleInfo extends BattleInfo {
override constructTypeIcons(): void {
this.type1Icon = globalScene.add.sprite(-139, -17, "pbinfo_player_type1").setName("icon_type_1").setOrigin(0);
this.type2Icon = globalScene.add.sprite(-139, -1, "pbinfo_player_type2").setName("icon_type_2").setOrigin(0);
this.type3Icon = globalScene.add.sprite(-154, -17, "pbinfo_player_type3").setName("icon_type_3").setOrigin(0);
this.type3Icon = globalScene.add.sprite(-154, -17, "pbinfo_player_type").setName("icon_type_3").setOrigin(0);
this.add([this.type1Icon, this.type2Icon, this.type3Icon]);
}

View File

@ -563,7 +563,7 @@ export class PartyUiHandler extends MessageUiHandler {
const ui = this.getUi();
const option = this.options[this.optionsCursor];
if (option === PartyOption.TRANSFER) {
if (this.transferMode && option === PartyOption.TRANSFER) {
return this.processTransferOption();
}
@ -1021,7 +1021,8 @@ export class PartyUiHandler extends MessageUiHandler {
}
// Toggle item transfer mode to discard items or vice versa
if (this.cursor === 7) {
// Prevent changing mode, when currently transfering an item
if (this.cursor === 7 && !this.transferMode) {
switch (this.partyUiMode) {
case PartyUiMode.DISCARD:
this.partyUiMode = PartyUiMode.MODIFIER_TRANSFER;
@ -1609,7 +1610,7 @@ export class PartyUiHandler extends MessageUiHandler {
const modifier = formChangeItemModifiers[option - PartyOption.FORM_CHANGE_ITEM];
optionName = `${modifier.active ? i18next.t("partyUiHandler:deactivate") : i18next.t("partyUiHandler:activate")} ${modifier.type.name}`;
} else if (option === PartyOption.UNPAUSE_EVOLUTION) {
optionName = `${pokemon.pauseEvolutions ? i18next.t("partyUiHandler:unpausedEvolution") : i18next.t("partyUiHandler:pauseEvolution")}`;
optionName = `${pokemon.pauseEvolutions ? i18next.t("partyUiHandler:unpauseEvolution") : i18next.t("partyUiHandler:pauseEvolution")}`;
} else {
if (this.localizedOptions.includes(option)) {
optionName = i18next.t(`partyUiHandler:${toCamelCase(PartyOption[option])}`);
@ -2040,12 +2041,13 @@ class PartySlot extends Phaser.GameObjects.Container {
if (this.pokemon.isShiny()) {
const doubleShiny = this.pokemon.isDoubleShiny(false);
const largeIconTint = doubleShiny ? this.pokemon.getBaseVariant() : this.pokemon.getVariant();
const shinyStar = globalScene.add
.image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`)
.setOrigin(0)
.setPositionRelative(this.slotName, shinyIconToNameOffset.x, shinyIconToNameOffset.y)
.setTint(getVariantTint(this.pokemon.getBaseVariant()));
.setTint(getVariantTint(largeIconTint));
slotInfoContainer.add(shinyStar);
if (doubleShiny) {

View File

@ -181,7 +181,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
ui.setOverlayMode(
UiMode.CONFIRM,
() => {
globalScene.gameData.tryClearSession(cursor).then(response => {
globalScene.gameData.deleteSession(cursor).then(response => {
if (response[0] === false) {
globalScene.reset(true);
} else {

View File

@ -72,7 +72,7 @@ import {
rgbHexToRgba,
} from "#utils/common";
import type { StarterPreferences } from "#utils/data";
import { loadStarterPreferences, saveStarterPreferences } from "#utils/data";
import { deepCopy, loadStarterPreferences, saveStarterPreferences } from "#utils/data";
import { getPokemonSpeciesForm, getPokerusStarters } from "#utils/pokemon-utils";
import { toCamelCase, toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
@ -1148,7 +1148,8 @@ export class StarterSelectUiHandler extends MessageUiHandler {
this.starterSelectContainer.setVisible(true);
this.starterPreferences = loadStarterPreferences();
this.originalStarterPreferences = loadStarterPreferences();
// Deep copy the JSON (avoid re-loading from disk)
this.originalStarterPreferences = deepCopy(this.starterPreferences);
this.allSpecies.forEach((species, s) => {
const icon = this.starterContainers[s].icon;
@ -1212,6 +1213,8 @@ export class StarterSelectUiHandler extends MessageUiHandler {
preferences: StarterPreferences,
ignoreChallenge = false,
): StarterAttributes {
// if preferences for the species is undefined, set it to an empty object
preferences[species.speciesId] ??= {};
const starterAttributes = preferences[species.speciesId];
const { dexEntry, starterDataEntry: starterData } = this.getSpeciesData(species.speciesId, !ignoreChallenge);
@ -1828,9 +1831,15 @@ export class StarterSelectUiHandler extends MessageUiHandler {
// The persistent starter data to apply e.g. candy upgrades
const persistentStarterData = globalScene.gameData.starterData[this.lastSpecies.speciesId];
// The sanitized starter preferences
let starterAttributes = this.starterPreferences[this.lastSpecies.speciesId];
// The original starter preferences
const originalStarterAttributes = this.originalStarterPreferences[this.lastSpecies.speciesId];
if (this.starterPreferences[this.lastSpecies.speciesId] === undefined) {
this.starterPreferences[this.lastSpecies.speciesId] = {};
}
if (this.originalStarterPreferences[this.lastSpecies.speciesId] === undefined) {
this.originalStarterPreferences[this.lastSpecies.speciesId] = {};
}
// Bangs are safe here due to the above check
const starterAttributes = this.starterPreferences[this.lastSpecies.speciesId]!;
const originalStarterAttributes = this.originalStarterPreferences[this.lastSpecies.speciesId]!;
// this gets the correct pokemon cursor depending on whether you're in the starter screen or the party icons
if (!this.starterIconsCursorObj.visible) {
@ -2050,10 +2059,6 @@ export class StarterSelectUiHandler extends MessageUiHandler {
const option: OptionSelectItem = {
label: getNatureName(n, true, true, true, globalScene.uiTheme),
handler: () => {
// update default nature in starter save data
if (!starterAttributes) {
starterAttributes = this.starterPreferences[this.lastSpecies.speciesId] = {};
}
starterAttributes.nature = n;
originalStarterAttributes.nature = starterAttributes.nature;
this.clearText();
@ -2095,28 +2100,22 @@ export class StarterSelectUiHandler extends MessageUiHandler {
const passiveAttr = starterData.passiveAttr;
if (passiveAttr & PassiveAttr.UNLOCKED) {
// this is for enabling and disabling the passive
if (!(passiveAttr & PassiveAttr.ENABLED)) {
const label = i18next.t(
passiveAttr & PassiveAttr.ENABLED
? "starterSelectUiHandler:disablePassive"
: "starterSelectUiHandler:enablePassive",
);
options.push({
label: i18next.t("starterSelectUiHandler:enablePassive"),
handler: () => {
starterData.passiveAttr |= PassiveAttr.ENABLED;
ui.setMode(UiMode.STARTER_SELECT);
this.setSpeciesDetails(this.lastSpecies);
return true;
},
});
} else {
options.push({
label: i18next.t("starterSelectUiHandler:disablePassive"),
label,
handler: () => {
starterData.passiveAttr ^= PassiveAttr.ENABLED;
persistentStarterData.passiveAttr ^= PassiveAttr.ENABLED;
ui.setMode(UiMode.STARTER_SELECT);
this.setSpeciesDetails(this.lastSpecies);
return true;
},
});
}
}
// if container.favorite is false, show the favorite option
const isFavorite = starterAttributes?.favorite ?? false;
if (!isFavorite) {
@ -3414,8 +3413,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
if (species) {
const defaultDexAttr = this.getCurrentDexProps(species.speciesId);
const defaultProps = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr);
// Bang is correct due to the `?` before variant
const variant = this.starterPreferences[species.speciesId]?.variant
? (this.starterPreferences[species.speciesId].variant as Variant)
? (this.starterPreferences[species.speciesId]!.variant as Variant)
: defaultProps.variant;
const tint = getVariantTint(variant);
this.pokemonShinyIcon.setFrame(getVariantIcon(variant)).setTint(tint);
@ -3640,7 +3640,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
if (starterIndex > -1) {
props = globalScene.gameData.getSpeciesDexAttrProps(species, this.starterAttr[starterIndex]);
this.setSpeciesDetails(species, {
this.setSpeciesDetails(
species,
{
shiny: props.shiny,
formIndex: props.formIndex,
female: props.female,
@ -3648,7 +3650,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
abilityIndex: this.starterAbilityIndexes[starterIndex],
natureIndex: this.starterNatures[starterIndex],
teraType: this.starterTeras[starterIndex],
});
},
false,
);
} else {
const defaultAbilityIndex =
starterAttributes?.ability ?? globalScene.gameData.getStarterSpeciesDefaultAbilityIndex(species);
@ -3665,7 +3669,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
props.formIndex = starterAttributes?.form ?? props.formIndex;
props.female = starterAttributes?.female ?? props.female;
this.setSpeciesDetails(species, {
this.setSpeciesDetails(
species,
{
shiny: props.shiny,
formIndex: props.formIndex,
female: props.female,
@ -3673,7 +3679,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
abilityIndex: defaultAbilityIndex,
natureIndex: defaultNature,
teraType: starterAttributes?.tera,
});
},
false,
);
}
if (!isNullOrUndefined(props.formIndex)) {
@ -3710,7 +3718,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
const defaultNature = globalScene.gameData.getSpeciesDefaultNature(species);
const props = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr);
this.setSpeciesDetails(species, {
this.setSpeciesDetails(
species,
{
shiny: props.shiny,
formIndex: props.formIndex,
female: props.female,
@ -3718,7 +3728,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
abilityIndex: defaultAbilityIndex,
natureIndex: defaultNature,
forSeen: true,
});
},
false,
);
this.pokemonSprite.setTint(0x808080);
}
} else {
@ -3740,7 +3752,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
this.pokemonFormText.setVisible(false);
this.teraIcon.setVisible(false);
this.setSpeciesDetails(species!, {
this.setSpeciesDetails(
species!,
{
// TODO: is this bang correct?
shiny: false,
formIndex: 0,
@ -3748,7 +3762,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
variant: 0,
abilityIndex: 0,
natureIndex: 0,
});
},
false,
);
this.pokemonSprite.clearTint();
}
}
@ -3770,7 +3786,7 @@ export class StarterSelectUiHandler extends MessageUiHandler {
return { dexEntry: { ...copiedDexEntry }, starterDataEntry: { ...copiedStarterDataEntry } };
}
setSpeciesDetails(species: PokemonSpecies, options: SpeciesDetails = {}): void {
setSpeciesDetails(species: PokemonSpecies, options: SpeciesDetails = {}, save = true): void {
let { shiny, formIndex, female, variant, abilityIndex, natureIndex, teraType } = options;
const forSeen: boolean = options.forSeen ?? false;
const oldProps = species ? globalScene.gameData.getSpeciesDexAttrProps(species, this.dexAttrCursor) : null;
@ -4182,8 +4198,10 @@ export class StarterSelectUiHandler extends MessageUiHandler {
this.updateInstructions();
if (save) {
saveStarterPreferences(this.originalStarterPreferences);
}
}
setTypeIcons(type1: PokemonType | null, type2: PokemonType | null): void {
if (type1 !== null) {
@ -4618,6 +4636,8 @@ export class StarterSelectUiHandler extends MessageUiHandler {
clear(): void {
super.clear();
saveStarterPreferences(this.originalStarterPreferences);
this.clearStarterPreferences();
this.cursor = -1;
this.hideInstructions();

View File

@ -430,20 +430,21 @@ export class SummaryUiHandler extends UiHandler {
this.friendshipShadow.setCrop(0, 0, 16, 16 - 16 * ((this.pokemon?.friendship || 0) / 255));
const doubleShiny = this.pokemon.isDoubleShiny(false);
const baseVariant = this.pokemon.getBaseVariant(doubleShiny);
const bigIconVariant = doubleShiny ? this.pokemon.getBaseVariant(doubleShiny) : this.pokemon.getVariant();
this.shinyIcon.setPositionRelative(
this.nameText,
this.nameText.displayWidth + (this.splicedIcon.visible ? this.splicedIcon.displayWidth + 1 : 0) + 1,
3,
);
this.shinyIcon.setTexture(`shiny_star${doubleShiny ? "_1" : ""}`);
this.shinyIcon.setVisible(this.pokemon.isShiny(false));
this.shinyIcon.setTint(getVariantTint(baseVariant));
this.shinyIcon
.setTexture(`shiny_star${doubleShiny ? "_1" : ""}`)
.setVisible(this.pokemon.isShiny(false))
.setTint(getVariantTint(bigIconVariant));
if (this.shinyIcon.visible) {
let shinyDescriptor = "";
if (doubleShiny || baseVariant) {
shinyDescriptor = " (" + getShinyDescriptor(baseVariant);
if (doubleShiny || bigIconVariant) {
shinyDescriptor = " (" + getShinyDescriptor(bigIconVariant);
if (doubleShiny) {
shinyDescriptor += "/" + getShinyDescriptor(this.pokemon.fusionVariant);
}

View File

@ -141,7 +141,8 @@ export class TitleUiHandler extends OptionSelectUiHandler {
}),
);
this.appVersionText.setText("v" + version);
const betaText = import.meta.env.DEV ? " (Beta)" : "";
this.appVersionText.setText("v" + version + betaText);
const ui = this.getUi();

View File

@ -8,7 +8,7 @@ import { AES, enc } from "crypto-js";
* @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 {
export function deepCopy<T extends object>(values: T): T {
// 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));
}
@ -58,13 +58,28 @@ export function decrypt(data: string, bypassLogin: boolean): string {
return AES.decrypt(data, saveKey).toString(enc.Utf8);
}
/**
* Check if an object has no properties of its own (its shape is `{}`). An empty array is considered a bare object.
* @param obj - Object to check
* @returns - Whether the object is bare
*/
export function isBareObject(obj: any): boolean {
if (typeof obj !== "object") {
return false;
}
for (const _ in obj) {
return false;
}
return true;
}
// the latest data saved/loaded for the Starter Preferences. Required to reduce read/writes. Initialize as "{}", since this is the default value and no data needs to be stored if present.
// if they ever add private static variables, move this into StarterPrefs
const StarterPrefers_DEFAULT: string = "{}";
let StarterPrefers_private_latest: string = StarterPrefers_DEFAULT;
export interface StarterPreferences {
[key: number]: StarterAttributes;
[key: number]: StarterAttributes | undefined;
}
// called on starter selection show once
@ -74,11 +89,17 @@ export function loadStarterPreferences(): StarterPreferences {
localStorage.getItem(`starterPrefs_${loggedInUser?.username}`) || StarterPrefers_DEFAULT),
);
}
// called on starter selection clear, always
export function saveStarterPreferences(prefs: StarterPreferences): void {
const pStr: string = JSON.stringify(prefs);
// Fastest way to check if an object has any properties (does no allocation)
if (isBareObject(prefs)) {
console.warn("Refusing to save empty starter preferences");
return;
}
// no reason to store `{}` (for starters not customized)
const pStr: string = JSON.stringify(prefs, (_, value) => (isBareObject(value) ? undefined : value));
if (pStr !== StarterPrefers_private_latest) {
console.log("%cSaving starter preferences", "color: blue");
// something changed, store the update
localStorage.setItem(`starterPrefs_${loggedInUser?.username}`, pStr);
// update the latest prefs

View File

@ -35,13 +35,43 @@ describe("Abilities - Intimidate", () => {
it("should lower all opponents' ATK by 1 stage on entry and switch", async () => {
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
const [mightyena, poochyena] = game.scene.getPlayerParty();
const enemy = game.field.getEnemyPokemon();
expect(enemy.getStatStage(Stat.ATK)).toBe(-1);
expect(mightyena).toHaveAbilityApplied(AbilityId.INTIMIDATE);
game.doSwitchPokemon(1);
await game.toNextTurn();
expect(poochyena.isActive()).toBe(true);
expect(enemy.getStatStage(Stat.ATK)).toBe(-2);
expect(poochyena).toHaveAbilityApplied(AbilityId.INTIMIDATE);
});
it("should trigger once on initial switch prompt without cancelling opposing abilities", async () => {
await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
await game.classicMode.startBattleWithSwitch(1);
const [poochyena, mightyena] = game.scene.getPlayerParty();
expect(poochyena.species.speciesId).toBe(SpeciesId.POOCHYENA);
const enemy = game.field.getEnemyPokemon();
expect(enemy).toHaveStatStage(Stat.ATK, -1);
expect(poochyena).toHaveStatStage(Stat.ATK, -1);
expect(poochyena).toHaveAbilityApplied(AbilityId.INTIMIDATE);
expect(mightyena).not.toHaveAbilityApplied(AbilityId.INTIMIDATE);
});
it("should activate on reload with single party", async () => {
await game.classicMode.startBattle([SpeciesId.MIGHTYENA]);
expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1);
await game.reload.reloadSession();
expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1);
});
it("should lower ATK of all opponents in a double battle", async () => {

View File

@ -175,4 +175,27 @@ describe("Evolution", () => {
expect(fourForm.evoFormKey).toBe("four"); // meanwhile, according to the pokemon-forms, the evoFormKey for a 4 family maushold is "four"
}
});
it("tyrogue should evolve if move is not in first slot", async () => {
game.override
.moveset([MoveId.TACKLE, MoveId.RAPID_SPIN, MoveId.LOW_KICK])
.enemySpecies(SpeciesId.GOLEM)
.enemyMoveset(MoveId.SPLASH)
.startingWave(41)
.startingLevel(19)
.enemyLevel(30);
await game.classicMode.startBattle([SpeciesId.TYROGUE]);
const tyrogue = game.field.getPlayerPokemon();
const golem = game.field.getEnemyPokemon();
golem.hp = 1;
expect(golem.hp).toBe(1);
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("EndEvolutionPhase");
expect(tyrogue.species.speciesId).toBe(SpeciesId.HITMONTOP);
});
});

View File

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

View File

@ -200,7 +200,7 @@ describe("Moves - Entry Hazards", () => {
expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi);
expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTag:stealthRockActivateTrap", {
pokemonName: getPokemonNameWithAffix(enemy),
pokemonNameWithAffix: getPokemonNameWithAffix(enemy),
}),
);
});

View File

@ -61,4 +61,16 @@ describe("Moves - Pollen Puff", () => {
expect(target.battleData.hitCount).toBe(2);
});
// Regression test for pollen puff healing an enemy after dealing damage
it("should not heal an enemy after dealing damage", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const target = game.field.getEnemyPokemon();
game.move.use(MoveId.POLLEN_PUFF);
await game.phaseInterceptor.to("BerryPhase", false);
expect(target.hp).not.toBe(target.getMaxHp());
expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase");
});
});

View File

@ -0,0 +1,60 @@
import { allAbilities } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { ObtainStatusEffectPhase } from "#phases/obtain-status-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import type { PostAttackContactApplyStatusEffectAbAttr } from "#types/ability-types";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
describe("Status Effects - General", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.enemyLevel(5)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH)
.ability(AbilityId.BALL_FETCH);
});
test("multiple status effects from the same interaction should not overwrite each other", async () => {
game.override.ability(AbilityId.POISON_TOUCH).moveset([MoveId.NUZZLE]);
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
// Force poison touch to always apply
vi.spyOn(
allAbilities[AbilityId.POISON_TOUCH].getAttrs(
"PostAttackContactApplyStatusEffectAbAttr",
// expose chance, which is private, for testing purpose, but keep type safety otherwise
)[0] as unknown as Omit<PostAttackContactApplyStatusEffectAbAttr, "chance"> & { chance: number },
"chance",
"get",
).mockReturnValue(100);
const statusEffectPhaseSpy = vi.spyOn(ObtainStatusEffectPhase.prototype, "start");
game.move.select(MoveId.NUZZLE);
await game.toEndOfTurn();
expect(statusEffectPhaseSpy).toHaveBeenCalledOnce();
const enemy = game.field.getEnemyPokemon();
// This test does not care which status effect is applied, as long as one is.
expect(enemy.status?.effect).toBeOneOf([StatusEffect.POISON, StatusEffect.PARALYSIS]);
});
});

View File

@ -1,6 +1,7 @@
import { getGameMode } from "#app/game-mode";
import overrides from "#app/overrides";
import { BattleStyle } from "#enums/battle-style";
import { Button } from "#enums/buttons";
import { GameModes } from "#enums/game-modes";
import { Nature } from "#enums/nature";
import type { SpeciesId } from "#enums/species-id";
@ -100,4 +101,33 @@ export class ClassicModeHelper extends GameManagerHelper {
await this.game.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]==================");
}
/**
* Queue inputs to switch at the start of the next battle, and then start it.
* @param pokemonIndex - The 0-indexed position of the party pokemon to switch to.
* Should never be called with 0 as that will select the currently active pokemon and freeze
* @returns A Promise that resolves once the battle has been started and the switch prompt resolved
* @todo Make this work for double battles
* @example
* ```ts
* await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA])
* await game.startBattleWithSwitch(1);
* ```
*/
public async startBattleWithSwitch(pokemonIndex: number): Promise<void> {
this.game.scene.battleStyle = BattleStyle.SWITCH;
this.game.onNextPrompt(
"CheckSwitchPhase",
UiMode.CONFIRM,
() => {
this.game.scene.ui.getHandler().setCursor(0);
this.game.scene.ui.getHandler().processInput(Button.ACTION);
},
() => this.game.isCurrentPhase("CommandPhase") || this.game.isCurrentPhase("TurnInitPhase"),
);
this.game.doSelectPartyPokemon(pokemonIndex);
await this.game.phaseInterceptor.to("CommandPhase");
console.log("==================[New Battle (Initial Switch)]==================");
}
}

View File

@ -37,6 +37,7 @@ import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "#phases/next-encounter-phase";
import { PartyExpPhase } from "#phases/party-exp-phase";
import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase";
@ -181,6 +182,7 @@ export class PhaseInterceptor {
UnlockPhase,
PostGameOverPhase,
RevivalBlessingPhase,
PokemonHealPhase,
];
private endBySetMode = [

View File

@ -6,7 +6,7 @@ import { UiMode } from "#enums/ui-mode";
import type { Pokemon } from "#field/pokemon";
import { GameManager } from "#test/test-utils/game-manager";
import type { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler";
import type { PartyUiHandler } from "#ui/party-ui-handler";
import { type PartyUiHandler, PartyUiMode } from "#ui/party-ui-handler";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -169,4 +169,51 @@ describe("UI - Transfer Items", () => {
expect(pokemon.getHeldItems().map(h => h.stackCount)).toEqual([2, 2]);
}
});
// TODO: This test breaks when running all tests on github. Fix this once hotfix period is over.
it.todo("should not allow changing to discard mode when transfering items", async () => {
let handler: PartyUiHandler | undefined;
const { resolve, promise } = Promise.withResolvers<void>();
game.onNextPrompt("SelectModifierPhase", UiMode.MODIFIER_SELECT, async () => {
await new Promise(r => setTimeout(r, 100));
const modifierHandler = game.scene.ui.getHandler() as ModifierSelectUiHandler;
modifierHandler.processInput(Button.DOWN);
modifierHandler.setCursor(1);
modifierHandler.processInput(Button.ACTION);
});
game.onNextPrompt("SelectModifierPhase", UiMode.PARTY, async () => {
await new Promise(r => setTimeout(r, 100));
handler = game.scene.ui.getHandler() as PartyUiHandler;
handler.setCursor(0);
handler.processInput(Button.ACTION);
await new Promise(r => setTimeout(r, 100));
handler.processInput(Button.ACTION);
resolve();
});
await promise;
expect(handler).toBeDefined();
if (handler) {
const partyMode = handler["partyUiMode"];
expect(partyMode).toBe(PartyUiMode.MODIFIER_TRANSFER);
handler.setCursor(7);
handler.processInput(Button.ACTION);
// Should not change mode to discard
expect(handler["partyUiMode"]).toBe(PartyUiMode.MODIFIER_TRANSFER);
handler.processInput(Button.CANCEL);
handler.setCursor(7);
handler.processInput(Button.ACTION);
// Should change mode to discard
expect(handler["partyUiMode"]).toBe(PartyUiMode.DISCARD);
}
});
});

39
test/utils/data.test.ts Normal file
View File

@ -0,0 +1,39 @@
import { deepCopy, isBareObject } from "#utils/data";
import { describe, expect, it } from "vitest";
describe("Utils - Data", () => {
describe("deepCopy", () => {
it("should create a deep copy of an object", () => {
const original = { a: 1, b: { c: 2 } };
const copy = deepCopy(original);
// ensure the references are different
expect(copy === original, "copied object should not compare equal").not;
expect(copy).toEqual(original);
// update copy's `a` to a different value and ensure original is unaffected
copy.a = 42;
expect(original.a, "adjusting property of copy should not affect original").toBe(1);
// update copy's nested `b.c` to a different value and ensure original is unaffected
copy.b.c = 99;
expect(original.b.c, "adjusting nested property of copy should not affect original").toBe(2);
});
});
describe("isBareObject", () => {
it("should properly identify bare objects", () => {
expect(isBareObject({}), "{} should be considered bare");
expect(isBareObject(new Object()), "new Object() should be considered bare");
expect(isBareObject(Object.create(null)));
expect(isBareObject([]), "an empty array should be considered bare");
});
it("should properly reject non-objects", () => {
expect(isBareObject(new Date())).not;
expect(isBareObject(null)).not;
expect(isBareObject(42)).not;
expect(isBareObject("")).not;
expect(isBareObject(undefined)).not;
expect(isBareObject(() => {})).not;
expect(isBareObject(new (class A {})())).not;
});
});
});

View File

@ -59,5 +59,12 @@
},
// Exclude checking for script JS files as those are covered by the folder's `jsconfig.json`
"include": ["**/*.ts", "**/*.d.ts"],
"exclude": ["node_modules", "dist", "vite.config.ts", "vitest.config.ts", "vitest.workspace.ts"]
"exclude": [
"node_modules",
"dist",
"vite.config.ts",
"vitest.config.ts",
"vitest.workspace.ts",
"public/service-worker.js"
]
}