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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
{ {
"name": "pokemon-rogue-battle", "name": "pokemon-rogue-battle",
"private": true, "private": true,
"version": "1.11.0", "version": "1.10.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "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 () { self.addEventListener('install', function () {
console.log('Service worker installing...'); 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.resetBattleAndWaveData();
pokemon.resetTera(); pokemon.resetTera();
applyAbAttrs("PostBattleInitAbAttr", { pokemon }); applyAbAttrs("PostBattleInitAbAttr", { pokemon });
if ( if (pokemon.hasSpecies(SpeciesId.TERAPAGOS)) {
pokemon.hasSpecies(SpeciesId.TERAPAGOS) ||
(this.gameMode.isClassic && this.currentBattle.waveIndex > 180 && this.currentBattle.waveIndex <= 190)
) {
this.arena.playerTerasUsed = 0; this.arena.playerTerasUsed = 0;
} }
} }

View File

@ -53,12 +53,6 @@ export const defaultStarterSpecies: SpeciesId[] = [
SpeciesId.QUAXLY, 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 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 { export interface PostMoveInteractionAbAttrParams extends AugmentMoveInteractionAbAttrParams {
/** Stores the hit result of the move used in the interaction */ /** Stores the hit result of the move used in the interaction */
readonly hitResult: HitResult; readonly hitResult: HitResult;
/** The amount of damage dealt in the interaction */
readonly damage: number;
} }
export class PostDefendAbAttr extends AbAttr { export class PostDefendAbAttr extends AbAttr {
@ -1079,20 +1081,16 @@ export class PostDefendHpGatedStatStageChangeAbAttr extends PostDefendAbAttr {
this.selfTarget = selfTarget; 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 hpGateFlat: number = Math.ceil(pokemon.getMaxHp() * this.hpGate);
const lastAttackReceived = pokemon.turnData.attacksReceived[pokemon.turnData.attacksReceived.length - 1]; return this.condition(pokemon, attacker, move) && pokemon.hp <= hpGateFlat && pokemon.hp + damage > hpGateFlat;
const damageReceived = lastAttackReceived?.damage || 0;
return (
this.condition(pokemon, attacker, move) && pokemon.hp <= hpGateFlat && pokemon.hp + damageReceived > hpGateFlat
);
} }
override apply({ simulated, pokemon, opponent: attacker }: PostMoveInteractionAbAttrParams): void { override apply({ simulated, pokemon, opponent }: PostMoveInteractionAbAttrParams): void {
if (!simulated) { if (!simulated) {
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
"StatStageChangePhase", "StatStageChangePhase",
(this.selfTarget ? pokemon : attacker).getBattlerIndex(), (this.selfTarget ? pokemon : opponent).getBattlerIndex(),
true, true,
this.stats, this.stats,
this.stages, this.stages,
@ -1263,17 +1261,17 @@ export class PostDefendContactApplyTagChanceAbAttr extends PostDefendAbAttr {
this.turnCount = turnCount; this.turnCount = turnCount;
} }
override canApply({ move, pokemon, opponent: attacker }: PostMoveInteractionAbAttrParams): boolean { override canApply({ move, pokemon, opponent }: PostMoveInteractionAbAttrParams): boolean {
return ( 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 && 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) { 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 { export class PostSummonCopyAbilityAbAttr extends PostSummonAbAttr {
private target: Pokemon; private target: Pokemon;
private targetAbilityName: string; private targetAbilityName: string;
override canApply({ pokemon }: AbAttrBaseParams): boolean { override canApply({ pokemon, simulated }: AbAttrBaseParams): boolean {
const targets = pokemon.getOpponents(); const targets = pokemon
.getOpponents()
.filter(t => t.getAbility().isCopiable || t.getAbility().id === AbilityId.WONDER_GUARD);
if (!targets.length) { if (!targets.length) {
return false; return false;
} }
let target: Pokemon; let target: Pokemon;
if (targets.length > 1) { // simulated call always chooses first target so as to not advance RNG
globalScene.executeWithSeedOffset(() => (target = randSeedItem(targets)), globalScene.currentBattle.waveIndex); if (targets.length > 1 && !simulated) {
target = targets[randSeedInt(targets.length)];
} else { } else {
target = targets[0]; target = targets[0];
} }
if ( this.target = target;
!target!.getAbility().isCopiable && this.targetAbilityName = allAbilities[target.getAbility().id].name;
// 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;
return true; return true;
} }
override apply({ pokemon, simulated }: AbAttrBaseParams): void { override apply({ pokemon, simulated }: AbAttrBaseParams): void {
if (!simulated) { // Protect against this somehow being called before canApply by ensuring target is defined
pokemon.setTempAbility(this.target!.getAbility()); if (!simulated && this.target) {
setAbilityRevealed(this.target!); pokemon.setTempAbility(this.target.getAbility());
setAbilityRevealed(this.target);
pokemon.updateInfo(); pokemon.updateInfo();
} }
} }

View File

@ -937,7 +937,7 @@ class StealthRockTag extends DamagingTrapTag {
protected override getTriggerMessage(pokemon: Pokemon): string { protected override getTriggerMessage(pokemon: Pokemon): string {
return i18next.t("arenaTag:stealthRockActivateTrap", { 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.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.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.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.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.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 ], [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.SPHEAL]: { 0: AbilityId.UNAWARE },
[SpeciesId.SEALEO]: { 0: AbilityId.UNAWARE }, [SpeciesId.SEALEO]: { 0: AbilityId.UNAWARE },
[SpeciesId.WALREIN]: { 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.GOREBYSS]: { 0: AbilityId.ARENA_TRAP },
[SpeciesId.HUNTAIL]: { 0: AbilityId.ARENA_TRAP }, [SpeciesId.HUNTAIL]: { 0: AbilityId.ARENA_TRAP },
[SpeciesId.RELICANTH]: { 0: AbilityId.PRIMORDIAL_SEA }, [SpeciesId.RELICANTH]: { 0: AbilityId.PRIMORDIAL_SEA },

View File

@ -1,3 +1,4 @@
import { defaultStarterSpecies } from "#app/constants";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { speciesStarterCosts } from "#balance/starters"; import { speciesStarterCosts } from "#balance/starters";
import { allMoves } from "#data/data-lists"; import { allMoves } from "#data/data-lists";
@ -76,7 +77,8 @@ export enum EvolutionItem {
LEADERS_CREST 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: * Pokemon Evolution tuple type consisting of:
@ -191,7 +193,7 @@ export class SpeciesEvolutionCondition {
case EvoCondKey.WEATHER: case EvoCondKey.WEATHER:
return cond.weather.includes(globalScene.arena.getWeatherType()); return cond.weather.includes(globalScene.arena.getWeatherType());
case EvoCondKey.TYROGUE: 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: case EvoCondKey.NATURE:
return cond.nature.includes(pokemon.getNature()); return cond.nature.includes(pokemon.getNature());
case EvoCondKey.RANDOM_FORM: { 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 // TODO: This may cause funny business for double starters such as Pichu/Pikachu
export const pokemonStarters: PokemonPrevolutions = {}; 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 { export function initPokemonStarters(): void {
const starterKeys = Object.keys(pokemonPrevolutions); const starterKeys = Object.keys(pokemonPrevolutions);
starterKeys.forEach(pk => { starterKeys.forEach(pk => {

View File

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

View File

@ -1,6 +1,6 @@
import type { FixedBattleConfig } from "#app/battle"; import type { FixedBattleConfig } from "#app/battle";
import { getRandomTrainerFunc } 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 { speciesStarterCosts } from "#balance/starters";
import type { PokemonSpecies } from "#data/pokemon-species"; import type { PokemonSpecies } from "#data/pokemon-species";
import { AbilityAttr } from "#enums/ability-attr"; 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 // Don't trigger if not targeting an ally
return target === user.getAlly() && super.canApply(user, target, _move, _args); 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}) user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
// Queue up an attack on the given slot. // Queue up an attack on the given slot.
globalScene.arena.positionalTagManager.addTag<PositionalTagType.DELAYED_ATTACK>({ 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]; const config = globalScene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0];
config.pokemonConfigs![0].tags = [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON]; config.pokemonConfigs![0].tags = [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON];
config.pokemonConfigs![0].mysteryEncounterBattleEffects = (pokemon: Pokemon) => { config.pokemonConfigs![0].mysteryEncounterBattleEffects = (pokemon: Pokemon) => {
queueEncounterMessage(`${namespace}:option.2.boss_enraged`); queueEncounterMessage(`${namespace}:option.2.bossEnraged`);
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
"StatStageChangePhase", "StatStageChangePhase",
pokemon.getBattlerIndex(), pokemon.getBattlerIndex(),

View File

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

View File

@ -2,6 +2,7 @@ import { globalScene } from "#app/global-scene";
import { allSpecies, modifierTypes } from "#data/data-lists"; import { allSpecies, modifierTypes } from "#data/data-lists";
import { getLevelTotalExp } from "#data/exp"; import { getLevelTotalExp } from "#data/exp";
import type { PokemonSpecies } from "#data/pokemon-species"; import type { PokemonSpecies } from "#data/pokemon-species";
import { AbilityId } from "#enums/ability-id";
import { Challenges } from "#enums/challenges"; import { Challenges } from "#enums/challenges";
import { ModifierTier } from "#enums/modifier-tier"; import { ModifierTier } from "#enums/modifier-tier";
import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; 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 { Nature } from "#enums/nature";
import { PartyMemberStrength } from "#enums/party-member-strength"; import { PartyMemberStrength } from "#enums/party-member-strength";
import { PlayerGender } from "#enums/player-gender"; 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 { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { TrainerType } from "#enums/trainer-type"; import { TrainerType } from "#enums/trainer-type";
import type { PlayerPokemon, Pokemon } from "#field/pokemon"; import type { PlayerPokemon, Pokemon } from "#field/pokemon";
import type { PokemonHeldItemModifier } from "#modifiers/modifier"; import type { PokemonHeldItemModifier } from "#modifiers/modifier";
@ -219,6 +221,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit
await showEncounterText(`${namespace}:option.1.dreamComplete`); await showEncounterText(`${namespace}:option.1.dreamComplete`);
await doNewTeamPostProcess(transformations); await doNewTeamPostProcess(transformations);
globalScene.phaseManager.unshiftNew("PartyHealPhase", true);
setEncounterRewards({ setEncounterRewards({
guaranteedModifierTypeFuncs: [ guaranteedModifierTypeFuncs: [
modifierTypes.MEMORY_MUSHROOM, modifierTypes.MEMORY_MUSHROOM,
@ -230,7 +233,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit
], ],
fillRemaining: false, fillRemaining: false,
}); });
leaveEncounterWithoutBattle(true); leaveEncounterWithoutBattle(false);
}) })
.build(), .build(),
) )
@ -431,6 +434,8 @@ function getTeamTransformations(): PokemonTransformation[] {
newAbilityIndex, newAbilityIndex,
undefined, undefined,
); );
transformation.newPokemon.teraType = randSeedInt(MAX_POKEMON_TYPE);
} }
return pokemonTransformations; return pokemonTransformations;
@ -440,6 +445,8 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) {
let atLeastOneNewStarter = false; let atLeastOneNewStarter = false;
for (const transformation of transformations) { for (const transformation of transformations) {
const previousPokemon = transformation.previousPokemon; const previousPokemon = transformation.previousPokemon;
const oldHpRatio = previousPokemon.getHpRatio(true);
const oldStatus = previousPokemon.status;
const newPokemon = transformation.newPokemon; const newPokemon = transformation.newPokemon;
const speciesRootForm = newPokemon.species.getRootSpeciesId(); const speciesRootForm = newPokemon.species.getRootSpeciesId();
@ -462,6 +469,19 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) {
} }
newPokemon.calculateStats(); 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(); await newPokemon.updateInfo();
} }

View File

@ -44,6 +44,34 @@ export abstract class PhasePriorityQueue {
public clear(): void { public clear(): void {
this.queue.splice(0, this.queue.length); 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; return Gender.GENDERLESS;
} }
if (randSeedFloat() <= this.malePercent) { if (randSeedFloat() * 100 <= this.malePercent) {
return Gender.MALE; return Gender.MALE;
} }
return Gender.FEMALE; return Gender.FEMALE;

View File

@ -11,6 +11,7 @@ import type { MoveId } from "#enums/move-id";
import type { Nature } from "#enums/nature"; import type { Nature } from "#enums/nature";
import type { PokemonType } from "#enums/pokemon-type"; import type { PokemonType } from "#enums/pokemon-type";
import type { SpeciesId } from "#enums/species-id"; import type { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import type { AttackMoveResult } from "#types/attack-move-result"; import type { AttackMoveResult } from "#types/attack-move-result";
import type { IllusionData } from "#types/illusion-data"; import type { IllusionData } from "#types/illusion-data";
import type { TurnMove } from "#types/turn-move"; import type { TurnMove } from "#types/turn-move";
@ -326,6 +327,14 @@ export class PokemonTurnData {
public switchedInThisTurn = false; public switchedInThisTurn = false;
public failedRunAway = false; public failedRunAway = false;
public joinedRound = 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. * 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 * Used to make sure multi-hits occur properly when the user is

View File

@ -20,3 +20,6 @@ export enum PokemonType {
FAIRY, FAIRY,
STELLAR 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) { getNameToRender(useIllusion = true) {
const illusion = this.summonData.illusion; const illusion = this.summonData.illusion;
const name = useIllusion ? (illusion?.name ?? this.name) : this.name; 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 { try {
if (nickname) { if (nickname) {
return decodeURIComponent(escape(atob(nickname))); // TODO: Remove `atob` and `escape`... eventually... 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. * @returns Whether this Pokemon is currently fused with another species.
*/ */
isFusion(useIllusion = false): boolean { 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); 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]) { if (this.level < levelMove[0]) {
break; break;
} }
let weight = levelMove[0]; let weight = levelMove[0] + 20;
// Evolution Moves // Evolution Moves
if (weight === EVOLVE_MOVE) { if (levelMove[0] === EVOLVE_MOVE) {
weight = 50; weight = 70;
} }
// Assume level 1 moves with 80+ BP are "move reminder" moves and bump their weight. Trainers use actual relearn moves. // 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())) { if (
weight = 40; (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)")) { if (!movePool.some(m => m[0] === levelMove[1]) && !allMoves[levelMove[1]].name.endsWith(" (N)")) {
movePool.push([levelMove[1], weight]); 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 (compatible && !movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) {
if (tmPoolTiers[moveId] === ModifierTier.COMMON && this.level >= 15) { 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) { } 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) { } 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++) { for (let i = 0; i < 3; i++) {
const moveId = speciesEggMoves[this.species.getRootSpeciesId()][i]; const moveId = speciesEggMoves[this.species.getRootSpeciesId()][i];
if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { 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]; const moveId = speciesEggMoves[this.species.getRootSpeciesId()][3];
@ -3124,13 +3135,13 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
!allMoves[moveId].name.endsWith(" (N)") && !allMoves[moveId].name.endsWith(" (N)") &&
!this.isBoss() !this.isBoss()
) { ) {
movePool.push([moveId, 30]); movePool.push([moveId, 50]);
} }
if (this.fusionSpecies) { if (this.fusionSpecies) {
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][i]; const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][i];
if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { 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]; const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][3];
@ -3141,7 +3152,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
!allMoves[moveId].name.endsWith(" (N)") && !allMoves[moveId].name.endsWith(" (N)") &&
!this.isBoss() !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]; rand -= stabMovePool[index++][1];
} }
this.moveset.push(new PokemonMove(stabMovePool[index][0])); 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) { 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) { if (effect !== StatusEffect.FAINT) {
// Status-overriding moves (i.e. Rest) fail if their respective status already exists; // Status-overriding moves (i.e. Rest) fail if their respective status already exists;
// all other moves fail if the target already has _any_ status // 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 this.queueStatusImmuneMessage(quiet, overrideStatus ? "overlap" : "other"); // having different status displays generic fail message
return false; return false;
} }
@ -4942,6 +4965,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (overrideStatus) { if (overrideStatus) {
this.resetStatus(false); this.resetStatus(false);
} else {
this.turnData.pendingStatus = effect;
} }
globalScene.phaseManager.unshiftNew( 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. * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect.
* @param effect - The {@linkcode StatusEffect} to set * @param effect - The {@linkcode StatusEffect} to set
* @remarks * @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. * This method does **not** check for feasibility; that is the responsibility of the caller.
*/ */
doSetStatus(effect: Exclude<StatusEffect, StatusEffect.SLEEP>): void; 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 effect - {@linkcode StatusEffect.SLEEP}
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* @remarks * @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. * This method does **not** check for feasibility; that is the responsibility of the caller.
*/ */
doSetStatus(effect: StatusEffect.SLEEP, sleepTurnsRemaining?: number): void; 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 * @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 * and is unused for all non-sleep Statuses
* @remarks * @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. * This method does **not** check for feasibility; that is the responsibility of the caller.
*/ */
doSetStatus(effect: StatusEffect, sleepTurnsRemaining?: number): void; 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 * @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 * and is unused for all non-sleep Statuses
* @remarks * @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. * 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 * @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, effect: StatusEffect,
sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4), sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4),
): void { ): void {
// Reset any pending status
this.turnData.pendingStatus = StatusEffect.NONE;
switch (effect) { switch (effect) {
case StatusEffect.POISON: case StatusEffect.POISON:
case StatusEffect.TOXIC: 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 TransitionImagePackPlugin from "phaser3-rex-plugins/templates/transitionimagepack/transitionimagepack-plugin";
import UIPlugin from "phaser3-rex-plugins/templates/ui/ui-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. // Catch global errors and display them in an alert so users can report the issue.
window.onerror = (_message, _source, _lineno, _colno, error) => { window.onerror = (_message, _source, _lineno, _colno, error) => {
console.error(error); console.error(error);

View File

@ -355,14 +355,23 @@ export class PhaseManager {
if (this.phaseQueuePrependSpliceIndex > -1) { if (this.phaseQueuePrependSpliceIndex > -1) {
this.clearPhaseQueueSplice(); this.clearPhaseQueueSplice();
} }
if (this.phaseQueuePrepend.length) { this.phaseQueue.unshift(...this.phaseQueuePrepend);
while (this.phaseQueuePrepend.length) { this.phaseQueuePrepend.splice(0);
const poppedPhase = this.phaseQueuePrepend.pop();
if (poppedPhase) { const unactivatedConditionalPhases: [() => boolean, Phase][] = [];
this.phaseQueue.unshift(poppedPhase); // 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) { if (!this.phaseQueue.length) {
this.populatePhaseQueue(); this.populatePhaseQueue();
// Clear the conditionalQueue if there are no phases left in the phaseQueue // 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; 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) { if (this.currentPhase) {
console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;"); console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;");
this.currentPhase.start(); this.currentPhase.start();
@ -520,6 +511,25 @@ export class PhaseManager {
this.dynamicPhaseQueues[type].push(phase); 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} * Unshifts the top phase from the corresponding dynamic queue onto {@linkcode phaseQueue}
* @param type {@linkcode DynamicPhaseType} The type of dynamic phase to start * @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 user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of 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 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 * @param wasCritical - `true` if the move was a critical hit
*/ */
protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void { protected applyOnGetHitAbEffects(
const params = { pokemon: target, opponent: user, move: this.move, hitResult }; 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); applyAbAttrs("PostDefendAbAttr", params);
if (wasCritical) { if (wasCritical) {
@ -763,12 +770,12 @@ export class MoveEffectPhase extends PokemonPhase {
this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target); 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). // Apply effects to the user (always) and the target (if not blocked by substitute).
this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, true); this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, true);
if (!this.move.hitsSubstitute(user, target)) { if (!this.move.hitsSubstitute(user, target)) {
this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical); this.applyOnTargetEffects(user, target, hitResult, firstTarget, dmg, wasCritical);
} }
if (this.lastHit) { if (this.lastHit) {
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); 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 user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} targeted by the move * @param target - The {@linkcode Pokemon} targeted by the move
* @param effectiveness - The effectiveness of the move against the target * @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); const isCritical = target.getCriticalHitResult(user, this.move);
/* /*
@ -821,7 +832,7 @@ export class MoveEffectPhase extends PokemonPhase {
const isOneHitKo = result === HitResult.ONE_HIT_KO; const isOneHitKo = result === HitResult.ONE_HIT_KO;
if (!dmg) { if (!dmg) {
return [result, false]; return [result, false, 0];
} }
target.lapseTags(BattlerTagLapseType.HIT); target.lapseTags(BattlerTagLapseType.HIT);
@ -850,7 +861,7 @@ export class MoveEffectPhase extends PokemonPhase {
} }
if (damage <= 0) { if (damage <= 0) {
return [result, isCritical]; return [result, isCritical, damage];
} }
if (user.isPlayer()) { if (user.isPlayer()) {
@ -879,7 +890,7 @@ export class MoveEffectPhase extends PokemonPhase {
globalScene.applyModifiers(DamageMoneyRewardModifier, true, user, new NumberHolder(damage)); 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 user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} struck by the move * @param target - The {@linkcode Pokemon} struck by the move
* @param effectiveness - The effectiveness of the move against the target * @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); const moveCategory = user.getMoveCategory(target, this.move);
if (moveCategory === MoveCategory.STATUS) { if (moveCategory === MoveCategory.STATUS) {
return [HitResult.STATUS, false]; return [HitResult.STATUS, false, 0];
} }
const result = this.applyMoveDamage(user, target, effectiveness); 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 target - The {@linkcode Pokemon} targeted by the move
* @param hitResult - The {@linkcode HitResult} obtained from applying 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 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 * @param wasCritical - `true` if the move was a critical hit
*/ */
protected applyOnTargetEffects( protected applyOnTargetEffects(
@ -967,6 +984,7 @@ export class MoveEffectPhase extends PokemonPhase {
target: Pokemon, target: Pokemon,
hitResult: HitResult, hitResult: HitResult,
firstTarget: boolean, firstTarget: boolean,
damage: number,
wasCritical = false, wasCritical = false,
): void { ): void {
/** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ /** 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.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, false);
this.applyHeldItemFlinchCheck(user, target, dealsDamage); this.applyHeldItemFlinchCheck(user, target, dealsDamage);
this.applyOnGetHitAbEffects(user, target, hitResult, wasCritical); this.applyOnGetHitAbEffects(user, target, hitResult, damage, wasCritical);
applyAbAttrs("PostAttackAbAttr", { pokemon: user, opponent: target, move: this.move, hitResult }); 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 // We assume only enemy Pokemon are able to have the EnemyAttackStatusEffectChanceModifier from tokens
if (!user.isPlayer() && this.move.is("AttackMove")) { if (!user.isPlayer() && this.move.is("AttackMove")) {

View File

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

View File

@ -1,4 +1,5 @@
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { DynamicPhaseType } from "#enums/dynamic-phase-type";
import { SwitchType } from "#enums/switch-type"; import { SwitchType } from "#enums/switch-type";
import { UiMode } from "#enums/ui-mode"; import { UiMode } from "#enums/ui-mode";
import { BattlePhase } from "#phases/battle-phase"; import { BattlePhase } from "#phases/battle-phase";
@ -75,8 +76,11 @@ export class SwitchPhase extends BattlePhase {
if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) { if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) {
// Remove any pre-existing PostSummonPhase under the same field index. // 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. // 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, p => p.is("PostSummonPhase") && p.player && p.fieldIndex === this.fieldIndex,
"all",
); );
const switchType = option === PartyOption.PASS_BATON ? SwitchType.BATON_PASS : this.switchType; const switchType = option === PartyOption.PASS_BATON ? SwitchType.BATON_PASS : this.switchType;
globalScene.phaseManager.unshiftNew("SwitchSummonPhase", switchType, fieldIndex, slotIndex, this.doReturn); 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 // https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179
phaseManager.pushNew("WeatherEffectPhase"); phaseManager.pushNew("WeatherEffectPhase");
phaseManager.pushNew("PositionalTagPhase");
phaseManager.pushNew("BerryPhase"); phaseManager.pushNew("BerryPhase");
/** Add a new phase to check who should be taking status damage */
phaseManager.pushNew("CheckStatusEffectPhase", moveOrder); phaseManager.pushNew("CheckStatusEffectPhase", moveOrder);
phaseManager.pushNew("PositionalTagPhase");
phaseManager.pushNew("TurnEndPhase"); phaseManager.pushNew("TurnEndPhase");
/* /*

View File

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

View File

@ -13,7 +13,7 @@ import { SettingsGamepadUiHandler } from "#ui/settings-gamepad-ui-handler";
import { SettingsKeyboardUiHandler } from "#ui/settings-keyboard-ui-handler"; import { SettingsKeyboardUiHandler } from "#ui/settings-keyboard-ui-handler";
import { SettingsUiHandler } from "#ui/settings-ui-handler"; import { SettingsUiHandler } from "#ui/settings-ui-handler";
import { StarterSelectUiHandler } from "#ui/starter-select-ui-handler"; import { StarterSelectUiHandler } from "#ui/starter-select-ui-handler";
import type Phaser from "phaser"; import Phaser from "phaser";
type ActionKeys = Record<Button, () => void>; type ActionKeys = Record<Button, () => void>;
@ -224,25 +224,26 @@ export class UiInputs {
buttonSpeedChange(up = true): void { buttonSpeedChange(up = true): void {
const settingGameSpeed = settingIndex(SettingKeys.Game_Speed); 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) { if (up && globalScene.gameSpeed < 5) {
globalScene.gameData.saveSetting( direction = 1;
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([]);
}
} else if (!up && globalScene.gameSpeed > 1) { } else if (!up && globalScene.gameSpeed > 1) {
globalScene.gameData.saveSetting( direction = -1;
SettingKeys.Game_Speed, } else {
Math.max( return;
Setting[settingGameSpeed].options.findIndex(item => item.label === `${globalScene.gameSpeed}x`) - 1, }
0, globalScene.gameData.saveSetting(
), SettingKeys.Game_Speed,
); Phaser.Math.Clamp(currentSetting + direction, 0, settingOptions.length - 1),
if (globalScene.ui?.getMode() === UiMode.SETTINGS) { );
(globalScene.ui.getHandler() as SettingsUiHandler).show([]); 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, 2.5,
); );
this.splicedIcon.setVisible(pokemon.isFusion(true)); this.splicedIcon.setVisible(pokemon.isFusion(true));
if (!this.splicedIcon.visible) {
return;
}
this.splicedIcon this.splicedIcon
.on("pointerover", () => .on("pointerover", () =>
globalScene.ui.showTooltip( globalScene.ui.showTooltip(
@ -323,6 +320,10 @@ export abstract class BattleInfo extends Phaser.GameObjects.Container {
.setVisible(pokemon.isShiny()) .setVisible(pokemon.isShiny())
.setTint(getVariantTint(baseVariant)); .setTint(getVariantTint(baseVariant));
this.shinyIcon
.on("pointerover", () => globalScene.ui.showTooltip("", i18next.t("common:shinyOnHover") + shinyDescriptor))
.on("pointerout", () => globalScene.ui.hideTooltip());
if (!this.shinyIcon.visible) { if (!this.shinyIcon.visible) {
return; return;
} }
@ -335,10 +336,6 @@ export abstract class BattleInfo extends Phaser.GameObjects.Container {
} }
shinyDescriptor += ")"; shinyDescriptor += ")";
} }
this.shinyIcon
.on("pointerover", () => globalScene.ui.showTooltip("", i18next.t("common:shinyOnHover") + shinyDescriptor))
.on("pointerout", () => globalScene.ui.hideTooltip());
} }
initInfo(pokemon: Pokemon) { initInfo(pokemon: Pokemon) {

View File

@ -36,7 +36,7 @@ export class EnemyBattleInfo extends BattleInfo {
override constructTypeIcons(): void { override constructTypeIcons(): void {
this.type1Icon = globalScene.add.sprite(-15, -15.5, "pbinfo_enemy_type1").setName("icon_type_1").setOrigin(0); 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.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]); this.add([this.type1Icon, this.type2Icon, this.type3Icon]);
} }

View File

@ -21,7 +21,7 @@ export class PlayerBattleInfo extends BattleInfo {
override constructTypeIcons(): void { override constructTypeIcons(): void {
this.type1Icon = globalScene.add.sprite(-139, -17, "pbinfo_player_type1").setName("icon_type_1").setOrigin(0); 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.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]); this.add([this.type1Icon, this.type2Icon, this.type3Icon]);
} }

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import { AES, enc } from "crypto-js";
* @param values - The object to be deep copied. * @param values - The object to be deep copied.
* @returns A new object that is a deep copy of the input. * @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 // 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)); 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); 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. // 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 // if they ever add private static variables, move this into StarterPrefs
const StarterPrefers_DEFAULT: string = "{}"; const StarterPrefers_DEFAULT: string = "{}";
let StarterPrefers_private_latest: string = StarterPrefers_DEFAULT; let StarterPrefers_private_latest: string = StarterPrefers_DEFAULT;
export interface StarterPreferences { export interface StarterPreferences {
[key: number]: StarterAttributes; [key: number]: StarterAttributes | undefined;
} }
// called on starter selection show once // called on starter selection show once
@ -74,11 +89,17 @@ export function loadStarterPreferences(): StarterPreferences {
localStorage.getItem(`starterPrefs_${loggedInUser?.username}`) || StarterPrefers_DEFAULT), localStorage.getItem(`starterPrefs_${loggedInUser?.username}`) || StarterPrefers_DEFAULT),
); );
} }
// called on starter selection clear, always
export function saveStarterPreferences(prefs: StarterPreferences): void { 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) { if (pStr !== StarterPrefers_private_latest) {
console.log("%cSaving starter preferences", "color: blue");
// something changed, store the update // something changed, store the update
localStorage.setItem(`starterPrefs_${loggedInUser?.username}`, pStr); localStorage.setItem(`starterPrefs_${loggedInUser?.username}`, pStr);
// update the latest prefs // 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 () => { it("should lower all opponents' ATK by 1 stage on entry and switch", async () => {
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
const [mightyena, poochyena] = game.scene.getPlayerParty();
const enemy = game.field.getEnemyPokemon(); const enemy = game.field.getEnemyPokemon();
expect(enemy.getStatStage(Stat.ATK)).toBe(-1); expect(enemy.getStatStage(Stat.ATK)).toBe(-1);
expect(mightyena).toHaveAbilityApplied(AbilityId.INTIMIDATE);
game.doSwitchPokemon(1); game.doSwitchPokemon(1);
await game.toNextTurn(); await game.toNextTurn();
expect(poochyena.isActive()).toBe(true);
expect(enemy.getStatStage(Stat.ATK)).toBe(-2); 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 () => { 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" 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 { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { DamageAnimPhase } from "#phases/damage-anim-phase"; import { DamageAnimPhase } from "#phases/damage-anim-phase";
import { TurnEndPhase } from "#phases/turn-end-phase";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -54,7 +53,7 @@ describe("Items - Leftovers", () => {
const leadHpAfterDamage = leadPokemon.hp; const leadHpAfterDamage = leadPokemon.hp;
// Check if leftovers heal us // Check if leftovers heal us
await game.phaseInterceptor.to(TurnEndPhase); await game.phaseInterceptor.to("PokemonHealPhase");
expect(leadPokemon.hp).toBeGreaterThan(leadHpAfterDamage); expect(leadPokemon.hp).toBeGreaterThan(leadHpAfterDamage);
}); });
}); });

View File

@ -200,7 +200,7 @@ describe("Moves - Entry Hazards", () => {
expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi); expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi);
expect(game.textInterceptor.logs).toContain( expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTag:stealthRockActivateTrap", { 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); 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 { getGameMode } from "#app/game-mode";
import overrides from "#app/overrides"; import overrides from "#app/overrides";
import { BattleStyle } from "#enums/battle-style"; import { BattleStyle } from "#enums/battle-style";
import { Button } from "#enums/buttons";
import { GameModes } from "#enums/game-modes"; import { GameModes } from "#enums/game-modes";
import { Nature } from "#enums/nature"; import { Nature } from "#enums/nature";
import type { SpeciesId } from "#enums/species-id"; import type { SpeciesId } from "#enums/species-id";
@ -100,4 +101,33 @@ export class ClassicModeHelper extends GameManagerHelper {
await this.game.phaseInterceptor.to(CommandPhase); await this.game.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]=================="); 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 { NextEncounterPhase } from "#phases/next-encounter-phase";
import { PartyExpPhase } from "#phases/party-exp-phase"; import { PartyExpPhase } from "#phases/party-exp-phase";
import { PartyHealPhase } from "#phases/party-heal-phase"; import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase"; import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase"; import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase"; import { PostGameOverPhase } from "#phases/post-game-over-phase";
@ -181,6 +182,7 @@ export class PhaseInterceptor {
UnlockPhase, UnlockPhase,
PostGameOverPhase, PostGameOverPhase,
RevivalBlessingPhase, RevivalBlessingPhase,
PokemonHealPhase,
]; ];
private endBySetMode = [ private endBySetMode = [

View File

@ -6,7 +6,7 @@ import { UiMode } from "#enums/ui-mode";
import type { Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import type { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler"; 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 Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; 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]); 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` // Exclude checking for script JS files as those are covered by the folder's `jsconfig.json`
"include": ["**/*.ts", "**/*.d.ts"], "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"
]
} }