diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index b1543b2cb44..f2e17898334 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -20,6 +20,7 @@ permissions: jobs: create-release: if: github.repository == 'pagefaultgames/pokerogue' && (vars.BETA_DEPLOY_BRANCH == '' || ! startsWith(vars.BETA_DEPLOY_BRANCH, 'release')) + timeout-minutes: 10 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed for github cli commands runs-on: ubuntu-latest @@ -36,11 +37,13 @@ jobs: exit 1 fi shell: bash + - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ secrets.PAGEFAULT_APP_ID }} private-key: ${{ secrets.PAGEFAULT_APP_PRIVATE_KEY }} + - name: Check out code uses: actions/checkout@v4 with: @@ -48,8 +51,10 @@ jobs: # Always base off of beta branch, regardless of the branch the workflow was triggered from. ref: beta token: ${{ steps.app-token.outputs.token }} + - name: Create release branch run: git checkout -b release + # In order to be able to open a PR into beta, we need the branch to have at least one change. - name: Overwrite RELEASE file run: | @@ -58,11 +63,14 @@ jobs: echo "Release v${{ github.event.inputs.versionName }}" > RELEASE git add RELEASE git commit -m "Stage release v${{ github.event.inputs.versionName }}" + - name: Push new branch run: git push origin release + # The repository variable is used by the deploy-beta workflow to determine whether to deploy from beta or release. - name: Set repository variable run: GITHUB_TOKEN="${{ steps.app-token.outputs.token }}" gh variable set BETA_DEPLOY_BRANCH --body "release" + - name: Create pull request to main run: | gh pr create --base main \ @@ -70,6 +78,7 @@ jobs: --title "Release v${{ github.event.inputs.versionName }} to main" \ --body "This PR is for the release of v${{ github.event.inputs.versionName }}, and was created automatically by the GitHub Actions workflow invoked by ${{ github.actor }}" \ --draft + - name: Create pull request to beta run: | gh pr create --base beta \ diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index 0894032c8ad..341999dcd45 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -12,6 +12,7 @@ on: jobs: deploy: if: github.repository == 'pagefaultgames/pokerogue' && github.ref_name == (vars.BETA_DEPLOY_BRANCH || 'beta') + timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0e7102a41dd..528906196e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,6 +11,7 @@ on: jobs: deploy: if: github.repository == 'pagefaultgames/pokerogue' + timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 84d5964064f..46957c02e56 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -6,11 +6,13 @@ on: - main - beta - release + - 'hotfix*' pull_request: branches: - main - beta - release + - 'hotfix*' merge_group: types: [checks_requested] @@ -18,6 +20,7 @@ jobs: pages: name: Github Pages if: github.repository == 'pagefaultgames/pokerogue' + timeout-minutes: 10 runs-on: ubuntu-latest env: api-dir: ./ @@ -67,7 +70,7 @@ jobs: pnpm exec typedoc --out /tmp/docs --githubPages false --entryPoints ./src/ - name: Commit & Push docs - if: github.event_name == 'push' + if: github.event_name == 'push' && (github.ref_name == 'beta' || github.ref_name == 'main') run: | cd pokerogue_gh git config user.email "github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 3569216bab4..edecae64f95 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -6,17 +6,20 @@ on: - main - beta - release + - 'hotfix*' pull_request: branches: - main - beta - release + - 'hotfix*' merge_group: types: [checks_requested] jobs: run-linters: name: Run all linters + timeout-minutes: 10 runs-on: ubuntu-latest steps: diff --git a/.github/workflows/post-release-deleted.yml b/.github/workflows/post-release-deleted.yml index 65447e7826b..fe542365da4 100644 --- a/.github/workflows/post-release-deleted.yml +++ b/.github/workflows/post-release-deleted.yml @@ -6,6 +6,7 @@ jobs: # Set the BETA_DEPLOY_BRANCH variable to beta when a release branch is deleted update-release-var: if: github.repository == 'pagefaultgames/pokerogue' && github.event.ref_type == 'branch' && github.event.ref == 'release' + timeout-minutes: 5 runs-on: ubuntu-latest steps: - name: Set BETA_DEPLOY_BRANCH to beta diff --git a/.github/workflows/test-shard-template.yml b/.github/workflows/test-shard-template.yml index 124004f380f..79aea56bbd0 100644 --- a/.github/workflows/test-shard-template.yml +++ b/.github/workflows/test-shard-template.yml @@ -21,6 +21,7 @@ jobs: test: # We can't use dynmically named jobs until https://github.com/orgs/community/discussions/13261 is implemented name: Shard + timeout-minutes: 10 runs-on: ubuntu-latest if: ${{ !inputs.skip }} steps: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2b9f6dc9c0d..39506096298 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,17 +6,20 @@ on: - main - beta - release + - 'hotfix*' pull_request: branches: - main - beta - release + - 'hotfix*' merge_group: types: [checks_requested] workflow_dispatch: jobs: check-path-change-filter: + timeout-minutes: 5 runs-on: ubuntu-latest permissions: pull-requests: read @@ -35,6 +38,8 @@ jobs: name: Run Tests needs: check-path-change-filter strategy: + # don't stop upon 1 shard failing + fail-fast: false matrix: shard: [1, 2, 3, 4, 5] uses: ./.github/workflows/test-shard-template.yml diff --git a/package.json b/package.json index 1a6b3b8139c..0afa6363bef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pokemon-rogue-battle", "private": true, - "version": "1.11.0", + "version": "1.10.4", "type": "module", "scripts": { "start": "vite", diff --git a/public/images/ui/champion_ribbon.png b/public/images/ui/champion_ribbon.png index b188f4c92d2..a19bb01279b 100644 Binary files a/public/images/ui/champion_ribbon.png and b/public/images/ui/champion_ribbon.png differ diff --git a/public/images/ui/champion_ribbon_emerald.png b/public/images/ui/champion_ribbon_emerald.png index 4b0523f7f64..29a9503059d 100644 Binary files a/public/images/ui/champion_ribbon_emerald.png and b/public/images/ui/champion_ribbon_emerald.png differ diff --git a/public/images/ui/legacy/champion_ribbon.png b/public/images/ui/legacy/champion_ribbon.png index b188f4c92d2..a19bb01279b 100644 Binary files a/public/images/ui/legacy/champion_ribbon.png and b/public/images/ui/legacy/champion_ribbon.png differ diff --git a/public/images/ui/legacy/champion_ribbon_emerald.png b/public/images/ui/legacy/champion_ribbon_emerald.png index 4b0523f7f64..29a9503059d 100644 Binary files a/public/images/ui/legacy/champion_ribbon_emerald.png and b/public/images/ui/legacy/champion_ribbon_emerald.png differ diff --git a/public/locales b/public/locales index 58fa5f9b6e9..102cbdcd924 160000 --- a/public/locales +++ b/public/locales @@ -1 +1 @@ -Subproject commit 58fa5f9b6e94469017bfbe69bef992ed48ef5343 +Subproject commit 102cbdcd924e2a7cdc7eab64d1ce79f6ec7604ff diff --git a/public/service-worker.js b/public/service-worker.js index b45d2484709..ff380adca73 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,3 +1,7 @@ self.addEventListener('install', function () { console.log('Service worker installing...'); }); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}) \ No newline at end of file diff --git a/src/battle-scene.ts b/src/battle-scene.ts index be02962867c..153e274a296 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -1476,10 +1476,7 @@ export class BattleScene extends SceneBase { pokemon.resetBattleAndWaveData(); pokemon.resetTera(); applyAbAttrs("PostBattleInitAbAttr", { pokemon }); - if ( - pokemon.hasSpecies(SpeciesId.TERAPAGOS) || - (this.gameMode.isClassic && this.currentBattle.waveIndex > 180 && this.currentBattle.waveIndex <= 190) - ) { + if (pokemon.hasSpecies(SpeciesId.TERAPAGOS)) { this.arena.playerTerasUsed = 0; } } diff --git a/src/constants.ts b/src/constants.ts index 589a091153c..17cf08aa7e2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -53,12 +53,6 @@ export const defaultStarterSpecies: SpeciesId[] = [ SpeciesId.QUAXLY, ]; -export const defaultStarterSpeciesAndEvolutions: SpeciesId[] = defaultStarterSpecies.flatMap(id => [ - id, - (id + 1) as SpeciesId, - (id + 2) as SpeciesId, -]); - export const saveKey = "x0i2O7WRiANTqPmZ"; // Temporary; secure encryption is not yet necessary /** diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index afac857395c..66d00d950d2 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -970,6 +970,8 @@ export class MoveImmunityStatStageChangeAbAttr extends MoveImmunityAbAttr { export interface PostMoveInteractionAbAttrParams extends AugmentMoveInteractionAbAttrParams { /** Stores the hit result of the move used in the interaction */ readonly hitResult: HitResult; + /** The amount of damage dealt in the interaction */ + readonly damage: number; } export class PostDefendAbAttr extends AbAttr { @@ -1079,20 +1081,16 @@ export class PostDefendHpGatedStatStageChangeAbAttr extends PostDefendAbAttr { this.selfTarget = selfTarget; } - override canApply({ pokemon, opponent: attacker, move }: PostMoveInteractionAbAttrParams): boolean { + override canApply({ pokemon, opponent: attacker, move, damage }: PostMoveInteractionAbAttrParams): boolean { const hpGateFlat: number = Math.ceil(pokemon.getMaxHp() * this.hpGate); - const lastAttackReceived = pokemon.turnData.attacksReceived[pokemon.turnData.attacksReceived.length - 1]; - const damageReceived = lastAttackReceived?.damage || 0; - return ( - this.condition(pokemon, attacker, move) && pokemon.hp <= hpGateFlat && pokemon.hp + damageReceived > hpGateFlat - ); + return this.condition(pokemon, attacker, move) && pokemon.hp <= hpGateFlat && pokemon.hp + damage > hpGateFlat; } - override apply({ simulated, pokemon, opponent: attacker }: PostMoveInteractionAbAttrParams): void { + override apply({ simulated, pokemon, opponent }: PostMoveInteractionAbAttrParams): void { if (!simulated) { globalScene.phaseManager.unshiftNew( "StatStageChangePhase", - (this.selfTarget ? pokemon : attacker).getBattlerIndex(), + (this.selfTarget ? pokemon : opponent).getBattlerIndex(), true, this.stats, this.stages, @@ -1263,17 +1261,17 @@ export class PostDefendContactApplyTagChanceAbAttr extends PostDefendAbAttr { this.turnCount = turnCount; } - override canApply({ move, pokemon, opponent: attacker }: PostMoveInteractionAbAttrParams): boolean { + override canApply({ move, pokemon, opponent }: PostMoveInteractionAbAttrParams): boolean { return ( - move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon }) && + move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: opponent, target: pokemon }) && pokemon.randBattleSeedInt(100) < this.chance && - attacker.canAddTag(this.tagType) + opponent.canAddTag(this.tagType) ); } - override apply({ simulated, opponent: attacker, move }: PostMoveInteractionAbAttrParams): void { + override apply({ pokemon, simulated, opponent, move }: PostMoveInteractionAbAttrParams): void { if (!simulated) { - attacker.addTag(this.tagType, this.turnCount, move.id, attacker.id); + opponent.addTag(this.tagType, this.turnCount, move.id, pokemon.id); } } } @@ -3014,41 +3012,44 @@ export class PostSummonFormChangeAbAttr extends PostSummonAbAttr { } } -/** Attempts to copy a pokemon's ability */ +/** + * Attempts to copy a pokemon's ability + * + * @remarks + * Hardcodes idiosyncrasies specific to trace, so should not be used for other abilities + * that might copy abilities in the future + * @sealed + */ export class PostSummonCopyAbilityAbAttr extends PostSummonAbAttr { private target: Pokemon; private targetAbilityName: string; - override canApply({ pokemon }: AbAttrBaseParams): boolean { - const targets = pokemon.getOpponents(); + override canApply({ pokemon, simulated }: AbAttrBaseParams): boolean { + const targets = pokemon + .getOpponents() + .filter(t => t.getAbility().isCopiable || t.getAbility().id === AbilityId.WONDER_GUARD); if (!targets.length) { return false; } let target: Pokemon; - if (targets.length > 1) { - globalScene.executeWithSeedOffset(() => (target = randSeedItem(targets)), globalScene.currentBattle.waveIndex); + // simulated call always chooses first target so as to not advance RNG + if (targets.length > 1 && !simulated) { + target = targets[randSeedInt(targets.length)]; } else { target = targets[0]; } - if ( - !target!.getAbility().isCopiable && - // Wonder Guard is normally uncopiable so has the attribute, but Trace specifically can copy it - !(pokemon.hasAbility(AbilityId.TRACE) && target!.getAbility().id === AbilityId.WONDER_GUARD) - ) { - return false; - } - - this.target = target!; - this.targetAbilityName = allAbilities[target!.getAbility().id].name; + this.target = target; + this.targetAbilityName = allAbilities[target.getAbility().id].name; return true; } override apply({ pokemon, simulated }: AbAttrBaseParams): void { - if (!simulated) { - pokemon.setTempAbility(this.target!.getAbility()); - setAbilityRevealed(this.target!); + // Protect against this somehow being called before canApply by ensuring target is defined + if (!simulated && this.target) { + pokemon.setTempAbility(this.target.getAbility()); + setAbilityRevealed(this.target); pokemon.updateInfo(); } } diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index b03cc5b951a..cd02455af0f 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -937,7 +937,7 @@ class StealthRockTag extends DamagingTrapTag { protected override getTriggerMessage(pokemon: Pokemon): string { return i18next.t("arenaTag:stealthRockActivateTrap", { - pokemonName: getPokemonNameWithAffix(pokemon), + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }); } diff --git a/src/data/balance/egg-moves.ts b/src/data/balance/egg-moves.ts index 20f0787c318..1184de70b07 100644 --- a/src/data/balance/egg-moves.ts +++ b/src/data/balance/egg-moves.ts @@ -190,7 +190,7 @@ export const speciesEggMoves = { [SpeciesId.WYNAUT]: [ MoveId.RECOVER, MoveId.SHED_TAIL, MoveId.TAUNT, MoveId.COMEUPPANCE ], [SpeciesId.SNORUNT]: [ MoveId.SPARKLY_SWIRL, MoveId.NASTY_PLOT, MoveId.EARTH_POWER, MoveId.BLOOD_MOON ], [SpeciesId.SPHEAL]: [ MoveId.FLIP_TURN, MoveId.FREEZE_DRY, MoveId.SLACK_OFF, MoveId.STEAM_ERUPTION ], - [SpeciesId.CLAMPERL]: [ MoveId.SHELL_SIDE_ARM, MoveId.BOUNCY_BUBBLE, MoveId.FREEZE_DRY, MoveId.STEAM_ERUPTION ], + [SpeciesId.CLAMPERL]: [ MoveId.SHELL_SIDE_ARM, MoveId.SNIPE_SHOT, MoveId.GIGA_DRAIN, MoveId.BOUNCY_BUBBLE ], [SpeciesId.RELICANTH]: [ MoveId.DRAGON_DANCE, MoveId.SHORE_UP, MoveId.WAVE_CRASH, MoveId.DIAMOND_STORM ], [SpeciesId.LUVDISC]: [ MoveId.BATON_PASS, MoveId.HEART_SWAP, MoveId.GLITZY_GLOW, MoveId.REVIVAL_BLESSING ], [SpeciesId.BAGON]: [ MoveId.HEADLONG_RUSH, MoveId.FIRE_LASH, MoveId.DRAGON_DANCE, MoveId.DRAGON_DARTS ], diff --git a/src/data/balance/passives.ts b/src/data/balance/passives.ts index 0e34917fd80..1297ad71c36 100644 --- a/src/data/balance/passives.ts +++ b/src/data/balance/passives.ts @@ -402,7 +402,7 @@ export const starterPassiveAbilities: StarterPassiveAbilities = { [SpeciesId.SPHEAL]: { 0: AbilityId.UNAWARE }, [SpeciesId.SEALEO]: { 0: AbilityId.UNAWARE }, [SpeciesId.WALREIN]: { 0: AbilityId.UNAWARE }, - [SpeciesId.CLAMPERL]: { 0: AbilityId.DAUNTLESS_SHIELD }, + [SpeciesId.CLAMPERL]: { 0: AbilityId.OVERCOAT }, [SpeciesId.GOREBYSS]: { 0: AbilityId.ARENA_TRAP }, [SpeciesId.HUNTAIL]: { 0: AbilityId.ARENA_TRAP }, [SpeciesId.RELICANTH]: { 0: AbilityId.PRIMORDIAL_SEA }, diff --git a/src/data/balance/pokemon-evolutions.ts b/src/data/balance/pokemon-evolutions.ts index d42bce041c2..bf588784f24 100644 --- a/src/data/balance/pokemon-evolutions.ts +++ b/src/data/balance/pokemon-evolutions.ts @@ -1,3 +1,4 @@ +import { defaultStarterSpecies } from "#app/constants"; import { globalScene } from "#app/global-scene"; import { speciesStarterCosts } from "#balance/starters"; import { allMoves } from "#data/data-lists"; @@ -76,7 +77,8 @@ export enum EvolutionItem { LEADERS_CREST } -type TyrogueMove = MoveId.LOW_SWEEP | MoveId.MACH_PUNCH | MoveId.RAPID_SPIN; +const tyrogueMoves = [MoveId.LOW_SWEEP, MoveId.MACH_PUNCH, MoveId.RAPID_SPIN] as const; +type TyrogueMove = typeof tyrogueMoves[number]; /** * Pokemon Evolution tuple type consisting of: @@ -191,7 +193,7 @@ export class SpeciesEvolutionCondition { case EvoCondKey.WEATHER: return cond.weather.includes(globalScene.arena.getWeatherType()); case EvoCondKey.TYROGUE: - return pokemon.getMoveset(true).find(m => m.moveId as TyrogueMove)?.moveId === cond.move; + return pokemon.getMoveset(true).find(m => (tyrogueMoves as readonly MoveId[]) .includes(m.moveId))?.moveId === cond.move; case EvoCondKey.NATURE: return cond.nature.includes(pokemon.getNature()); case EvoCondKey.RANDOM_FORM: { @@ -1883,6 +1885,15 @@ export function initPokemonPrevolutions(): void { // TODO: This may cause funny business for double starters such as Pichu/Pikachu export const pokemonStarters: PokemonPrevolutions = {}; +/** + * The default species and all their evolutions + */ +export const defaultStarterSpeciesAndEvolutions: SpeciesId[] = defaultStarterSpecies.flatMap(id => { + const stage2ids = pokemonEvolutions[id]?.map(e => e.speciesId) ?? []; + const stage3ids = stage2ids.flatMap(s2id => pokemonEvolutions[s2id]?.map(e => e.speciesId) ?? []); + return [id, ...stage2ids, ...stage3ids]; +}); + export function initPokemonStarters(): void { const starterKeys = Object.keys(pokemonPrevolutions); starterKeys.forEach(pk => { diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 104eca0e407..3dbbf747c5c 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1058,8 +1058,7 @@ export class SeedTag extends SerializableBattlerTag { // Check which opponent to restore HP to const source = pokemon.getOpponents().find(o => o.getBattlerIndex() === this.sourceIndex); if (!source) { - console.warn(`Failed to get source Pokemon for SeedTag lapse; id: ${this.sourceId}`); - return false; + return true; } const cancelled = new BooleanHolder(false); diff --git a/src/data/challenge.ts b/src/data/challenge.ts index 3c282e7640e..01e6fa78ffc 100644 --- a/src/data/challenge.ts +++ b/src/data/challenge.ts @@ -1,6 +1,6 @@ import type { FixedBattleConfig } from "#app/battle"; import { getRandomTrainerFunc } from "#app/battle"; -import { defaultStarterSpeciesAndEvolutions } from "#app/constants"; +import { defaultStarterSpeciesAndEvolutions } from "#balance/pokemon-evolutions"; import { speciesStarterCosts } from "#balance/starters"; import type { PokemonSpecies } from "#data/pokemon-species"; import { AbilityAttr } from "#enums/ability-attr"; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index dc9f2306101..4a744d6e9c3 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -2325,6 +2325,13 @@ export class HealOnAllyAttr extends HealAttr { // Don't trigger if not targeting an ally return target === user.getAlly() && super.canApply(user, target, _move, _args); } + + override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { + if (user.isOpponent(target)) { + return false; + } + return super.apply(user, target, _move, _args); + } } /** @@ -3270,7 +3277,6 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { ) ) - user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn}) user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn}) // Queue up an attack on the given slot. globalScene.arena.positionalTagManager.addTag({ diff --git a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts index 196ca873f4e..358bba92a09 100644 --- a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts +++ b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts @@ -237,7 +237,7 @@ export const BerriesAboundEncounter: MysteryEncounter = MysteryEncounterBuilder. const config = globalScene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0]; config.pokemonConfigs![0].tags = [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON]; config.pokemonConfigs![0].mysteryEncounterBattleEffects = (pokemon: Pokemon) => { - queueEncounterMessage(`${namespace}:option.2.boss_enraged`); + queueEncounterMessage(`${namespace}:option.2.bossEnraged`); globalScene.phaseManager.unshiftNew( "StatStageChangePhase", pokemon.getBattlerIndex(), diff --git a/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts b/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts index af576ffd8be..ed588ea2884 100644 --- a/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts +++ b/src/data/mystery-encounters/encounters/trash-to-treasure-encounter.ts @@ -249,7 +249,7 @@ async function tryApplyDigRewardItems() { await showEncounterText( i18next.t("battle:rewardGainCount", { modifierName: leftovers.name, - count: 2, + count: 1, }), null, undefined, diff --git a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts index 790bdf0dbef..240a0df9e95 100644 --- a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts +++ b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts @@ -2,6 +2,7 @@ import { globalScene } from "#app/global-scene"; import { allSpecies, modifierTypes } from "#data/data-lists"; import { getLevelTotalExp } from "#data/exp"; import type { PokemonSpecies } from "#data/pokemon-species"; +import { AbilityId } from "#enums/ability-id"; import { Challenges } from "#enums/challenges"; import { ModifierTier } from "#enums/modifier-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; @@ -10,8 +11,9 @@ import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { Nature } from "#enums/nature"; import { PartyMemberStrength } from "#enums/party-member-strength"; import { PlayerGender } from "#enums/player-gender"; -import { PokemonType } from "#enums/pokemon-type"; +import { MAX_POKEMON_TYPE, PokemonType } from "#enums/pokemon-type"; import { SpeciesId } from "#enums/species-id"; +import { StatusEffect } from "#enums/status-effect"; import { TrainerType } from "#enums/trainer-type"; import type { PlayerPokemon, Pokemon } from "#field/pokemon"; import type { PokemonHeldItemModifier } from "#modifiers/modifier"; @@ -219,6 +221,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit await showEncounterText(`${namespace}:option.1.dreamComplete`); await doNewTeamPostProcess(transformations); + globalScene.phaseManager.unshiftNew("PartyHealPhase", true); setEncounterRewards({ guaranteedModifierTypeFuncs: [ modifierTypes.MEMORY_MUSHROOM, @@ -230,7 +233,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit ], fillRemaining: false, }); - leaveEncounterWithoutBattle(true); + leaveEncounterWithoutBattle(false); }) .build(), ) @@ -431,6 +434,8 @@ function getTeamTransformations(): PokemonTransformation[] { newAbilityIndex, undefined, ); + + transformation.newPokemon.teraType = randSeedInt(MAX_POKEMON_TYPE); } return pokemonTransformations; @@ -440,6 +445,8 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) { let atLeastOneNewStarter = false; for (const transformation of transformations) { const previousPokemon = transformation.previousPokemon; + const oldHpRatio = previousPokemon.getHpRatio(true); + const oldStatus = previousPokemon.status; const newPokemon = transformation.newPokemon; const speciesRootForm = newPokemon.species.getRootSpeciesId(); @@ -462,6 +469,19 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) { } newPokemon.calculateStats(); + if (oldHpRatio > 0) { + newPokemon.hp = Math.ceil(oldHpRatio * newPokemon.getMaxHp()); + // Assume that the `status` instance can always safely be transferred to the new pokemon + // This is the case (as of version 1.10.4) + // Safeguard against COMATOSE here + if (!newPokemon.hasAbility(AbilityId.COMATOSE, false, true)) { + newPokemon.status = oldStatus; + } + } else { + newPokemon.hp = 0; + newPokemon.doSetStatus(StatusEffect.FAINT); + } + await newPokemon.updateInfo(); } diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts index 88361b0f4fa..012344d59f6 100644 --- a/src/data/phase-priority-queue.ts +++ b/src/data/phase-priority-queue.ts @@ -44,6 +44,34 @@ export abstract class PhasePriorityQueue { public clear(): void { this.queue.splice(0, this.queue.length); } + + /** + * Attempt to remove one or more Phases from the current queue. + * @param phaseFilter - The function to select phases for removal + * @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases; + * default `1` + * @returns The number of successfully removed phases + * @todo Remove this eventually once the patchwork bug this is used for is fixed + */ + public tryRemovePhase(phaseFilter: (phase: Phase) => boolean, removeCount: number | "all" = 1): number { + if (removeCount === "all") { + removeCount = this.queue.length; + } else if (removeCount < 1) { + return 0; + } + let numRemoved = 0; + + do { + const phaseIndex = this.queue.findIndex(phaseFilter); + if (phaseIndex === -1) { + break; + } + this.queue.splice(phaseIndex, 1); + numRemoved++; + } while (numRemoved < removeCount && this.queue.length > 0); + + return numRemoved; + } } /** diff --git a/src/data/pokemon-species.ts b/src/data/pokemon-species.ts index 064ad57cfb3..fd8551f2289 100644 --- a/src/data/pokemon-species.ts +++ b/src/data/pokemon-species.ts @@ -795,7 +795,7 @@ export class PokemonSpecies extends PokemonSpeciesForm implements Localizable { return Gender.GENDERLESS; } - if (randSeedFloat() <= this.malePercent) { + if (randSeedFloat() * 100 <= this.malePercent) { return Gender.MALE; } return Gender.FEMALE; diff --git a/src/data/pokemon/pokemon-data.ts b/src/data/pokemon/pokemon-data.ts index 0bd6af0bb04..87ffbbab4cd 100644 --- a/src/data/pokemon/pokemon-data.ts +++ b/src/data/pokemon/pokemon-data.ts @@ -11,6 +11,7 @@ import type { MoveId } from "#enums/move-id"; import type { Nature } from "#enums/nature"; import type { PokemonType } from "#enums/pokemon-type"; import type { SpeciesId } from "#enums/species-id"; +import { StatusEffect } from "#enums/status-effect"; import type { AttackMoveResult } from "#types/attack-move-result"; import type { IllusionData } from "#types/illusion-data"; import type { TurnMove } from "#types/turn-move"; @@ -326,6 +327,14 @@ export class PokemonTurnData { public switchedInThisTurn = false; public failedRunAway = false; public joinedRound = false; + /** Tracker for a pending status effect + * + * @remarks + * Set whenever {@linkcode Pokemon#trySetStatus} succeeds in order to prevent subsequent status effects + * from being applied. Necessary because the status is not actually set until the {@linkcode ObtainStatusEffectPhase} runs, + * which may not happen before another status effect is attempted to be applied. + */ + public pendingStatus: StatusEffect = StatusEffect.NONE; /** * The amount of times this Pokemon has acted again and used a move in the current turn. * Used to make sure multi-hits occur properly when the user is diff --git a/src/enums/pokemon-type.ts b/src/enums/pokemon-type.ts index eca02bae275..210e3c3dcbe 100644 --- a/src/enums/pokemon-type.ts +++ b/src/enums/pokemon-type.ts @@ -20,3 +20,6 @@ export enum PokemonType { FAIRY, STELLAR } + +/** The largest legal value for a {@linkcode PokemonType} (includes Stellar) */ +export const MAX_POKEMON_TYPE = PokemonType.STELLAR; \ No newline at end of file diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index f29e8b204e8..8e2f26af158 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -454,7 +454,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { getNameToRender(useIllusion = true) { const illusion = this.summonData.illusion; const name = useIllusion ? (illusion?.name ?? this.name) : this.name; - const nickname: string | undefined = useIllusion ? illusion?.nickname : this.nickname; + const nickname: string | undefined = useIllusion ? (illusion?.nickname ?? this.nickname) : this.nickname; try { if (nickname) { return decodeURIComponent(escape(atob(nickname))); // TODO: Remove `atob` and `escape`... eventually... @@ -1768,7 +1768,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @returns Whether this Pokemon is currently fused with another species. */ isFusion(useIllusion = false): boolean { - return useIllusion ? !!this.summonData.illusion?.fusionSpecies : !!this.fusionSpecies; + return !!(useIllusion ? (this.summonData.illusion?.fusionSpecies ?? this.fusionSpecies) : this.fusionSpecies); } /** @@ -2221,8 +2221,16 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.hasPassive() && (!canApply || this.canApplyAbility(true)) && this.getPassiveAbility().hasAttr(attrType); } - public getAbilityPriorities(): [number, number] { - return [this.getAbility().postSummonPriority, this.getPassiveAbility().postSummonPriority]; + /** + * Return the ability priorities of the pokemon's ability and, if enabled, its passive ability + * @returns A tuple containing the ability priorities of the pokemon + */ + public getAbilityPriorities(): [number] | [activePriority: number, passivePriority: number] { + const abilityPriority = this.getAbility().postSummonPriority; + if (this.hasPassive()) { + return [abilityPriority, this.getPassiveAbility().postSummonPriority]; + } + return [abilityPriority]; } /** @@ -3062,14 +3070,17 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (this.level < levelMove[0]) { break; } - let weight = levelMove[0]; + let weight = levelMove[0] + 20; // Evolution Moves - if (weight === EVOLVE_MOVE) { - weight = 50; + if (levelMove[0] === EVOLVE_MOVE) { + weight = 70; } // Assume level 1 moves with 80+ BP are "move reminder" moves and bump their weight. Trainers use actual relearn moves. - if ((weight === 1 && allMoves[levelMove[1]].power >= 80) || (weight === RELEARN_MOVE && this.hasTrainer())) { - weight = 40; + if ( + (levelMove[0] === 1 && allMoves[levelMove[1]].power >= 80) || + (levelMove[0] === RELEARN_MOVE && this.hasTrainer()) + ) { + weight = 60; } if (!movePool.some(m => m[0] === levelMove[1]) && !allMoves[levelMove[1]].name.endsWith(" (N)")) { movePool.push([levelMove[1], weight]); @@ -3099,11 +3110,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } if (compatible && !movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { if (tmPoolTiers[moveId] === ModifierTier.COMMON && this.level >= 15) { - movePool.push([moveId, 4]); + movePool.push([moveId, 24]); } else if (tmPoolTiers[moveId] === ModifierTier.GREAT && this.level >= 30) { - movePool.push([moveId, 8]); + movePool.push([moveId, 28]); } else if (tmPoolTiers[moveId] === ModifierTier.ULTRA && this.level >= 50) { - movePool.push([moveId, 14]); + movePool.push([moveId, 34]); } } } @@ -3113,7 +3124,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { for (let i = 0; i < 3; i++) { const moveId = speciesEggMoves[this.species.getRootSpeciesId()][i]; if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { - movePool.push([moveId, 40]); + movePool.push([moveId, 60]); } } const moveId = speciesEggMoves[this.species.getRootSpeciesId()][3]; @@ -3124,13 +3135,13 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { !allMoves[moveId].name.endsWith(" (N)") && !this.isBoss() ) { - movePool.push([moveId, 30]); + movePool.push([moveId, 50]); } if (this.fusionSpecies) { for (let i = 0; i < 3; i++) { const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][i]; if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { - movePool.push([moveId, 40]); + movePool.push([moveId, 60]); } } const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][3]; @@ -3141,7 +3152,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { !allMoves[moveId].name.endsWith(" (N)") && !this.isBoss() ) { - movePool.push([moveId, 30]); + movePool.push([moveId, 50]); } } } @@ -3222,6 +3233,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { rand -= stabMovePool[index++][1]; } this.moveset.push(new PokemonMove(stabMovePool[index][0])); + } else { + // If there are no damaging STAB moves, just force a random damaging move + const attackMovePool = baseWeights.filter(m => allMoves[m[0]].category !== MoveCategory.STATUS); + if (attackMovePool.length) { + const totalWeight = attackMovePool.reduce((v, m) => v + m[1], 0); + let rand = randSeedInt(totalWeight); + let index = 0; + while (rand > attackMovePool[index][1]) { + rand -= attackMovePool[index++][1]; + } + this.moveset.push(new PokemonMove(attackMovePool[index][0], 0, 0)); + } } while (baseWeights.length > this.moveset.length && this.moveset.length < 4) { @@ -4790,7 +4813,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (effect !== StatusEffect.FAINT) { // Status-overriding moves (i.e. Rest) fail if their respective status already exists; // all other moves fail if the target already has _any_ status - if (overrideStatus ? this.status?.effect === effect : this.status) { + if (overrideStatus ? this.status?.effect === effect : this.status || this.turnData.pendingStatus) { this.queueStatusImmuneMessage(quiet, overrideStatus ? "overlap" : "other"); // having different status displays generic fail message return false; } @@ -4942,6 +4965,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (overrideStatus) { this.resetStatus(false); + } else { + this.turnData.pendingStatus = effect; } globalScene.phaseManager.unshiftNew( @@ -4961,6 +4986,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. * @param effect - The {@linkcode StatusEffect} to set * @remarks + * Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon.turnData | turnData}. + * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ doSetStatus(effect: Exclude): void; @@ -4969,6 +4996,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param effect - {@linkcode StatusEffect.SLEEP} * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 * @remarks + * Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}. + * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ doSetStatus(effect: StatusEffect.SLEEP, sleepTurnsRemaining?: number): void; @@ -4978,6 +5007,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 * and is unused for all non-sleep Statuses * @remarks + * Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}. + * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ doSetStatus(effect: StatusEffect, sleepTurnsRemaining?: number): void; @@ -4987,6 +5018,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4 * and is unused for all non-sleep Statuses * @remarks + * Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}. + * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. * @todo Make this and all related fields private and change tests to use a field-based helper or similar */ @@ -4994,6 +5027,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { effect: StatusEffect, sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4), ): void { + // Reset any pending status + this.turnData.pendingStatus = StatusEffect.NONE; switch (effect) { case StatusEffect.POISON: case StatusEffect.TOXIC: diff --git a/src/main.ts b/src/main.ts index 7e4943bdca5..80f2ca3ed8b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,10 @@ import InputTextPlugin from "phaser3-rex-plugins/plugins/inputtext-plugin"; import TransitionImagePackPlugin from "phaser3-rex-plugins/templates/transitionimagepack/transitionimagepack-plugin"; import UIPlugin from "phaser3-rex-plugins/templates/ui/ui-plugin"; +if (import.meta.env.DEV) { + document.title += " (Beta)"; +} + // Catch global errors and display them in an alert so users can report the issue. window.onerror = (_message, _source, _lineno, _colno, error) => { console.error(error); diff --git a/src/phase-manager.ts b/src/phase-manager.ts index aa01a0ffc10..8a31689f7b2 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -355,14 +355,23 @@ export class PhaseManager { if (this.phaseQueuePrependSpliceIndex > -1) { this.clearPhaseQueueSplice(); } - if (this.phaseQueuePrepend.length) { - while (this.phaseQueuePrepend.length) { - const poppedPhase = this.phaseQueuePrepend.pop(); - if (poppedPhase) { - this.phaseQueue.unshift(poppedPhase); - } + this.phaseQueue.unshift(...this.phaseQueuePrepend); + this.phaseQueuePrepend.splice(0); + + const unactivatedConditionalPhases: [() => boolean, Phase][] = []; + // Check if there are any conditional phases queued + for (const [condition, phase] of this.conditionalQueue) { + // Evaluate the condition associated with the phase + if (condition()) { + // If the condition is met, add the phase to the phase queue + this.pushPhase(phase); + } else { + // If the condition is not met, re-add the phase back to the end of the conditional queue + unactivatedConditionalPhases.push([condition, phase]); } } + this.conditionalQueue = unactivatedConditionalPhases; + if (!this.phaseQueue.length) { this.populatePhaseQueue(); // Clear the conditionalQueue if there are no phases left in the phaseQueue @@ -371,24 +380,6 @@ export class PhaseManager { this.currentPhase = this.phaseQueue.shift() ?? null; - const unactivatedConditionalPhases: [() => boolean, Phase][] = []; - // Check if there are any conditional phases queued - while (this.conditionalQueue?.length) { - // Retrieve the first conditional phase from the queue - const conditionalPhase = this.conditionalQueue.shift(); - // Evaluate the condition associated with the phase - if (conditionalPhase?.[0]()) { - // If the condition is met, add the phase to the phase queue - this.pushPhase(conditionalPhase[1]); - } else if (conditionalPhase) { - // If the condition is not met, re-add the phase back to the front of the conditional queue - unactivatedConditionalPhases.push(conditionalPhase); - } else { - console.warn("condition phase is undefined/null!", conditionalPhase); - } - } - this.conditionalQueue.push(...unactivatedConditionalPhases); - if (this.currentPhase) { console.log(`%cStart Phase ${this.currentPhase.constructor.name}`, "color:green;"); this.currentPhase.start(); @@ -520,6 +511,25 @@ export class PhaseManager { this.dynamicPhaseQueues[type].push(phase); } + /** + * Attempt to remove one or more Phases from the given DynamicPhaseQueue, removing the equivalent amount of {@linkcode ActivatePriorityQueuePhase}s from the queue. + * @param type - The {@linkcode DynamicPhaseType} to check + * @param phaseFilter - The function to select phases for removal + * @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases; + * default `1` + * @todo Remove this eventually once the patchwork bug this is used for is fixed + */ + public tryRemoveDynamicPhase( + type: DynamicPhaseType, + phaseFilter: (phase: Phase) => boolean, + removeCount: number | "all" = 1, + ): void { + const numRemoved = this.dynamicPhaseQueues[type].tryRemovePhase(phaseFilter, removeCount); + for (let x = 0; x < numRemoved; x++) { + this.tryRemovePhase(p => p.is("ActivatePriorityQueuePhase")); + } + } + /** * Unshifts the top phase from the corresponding dynamic queue onto {@linkcode phaseQueue} * @param type {@linkcode DynamicPhaseType} The type of dynamic phase to start diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 767d7a79968..c8dcbae907c 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -400,10 +400,17 @@ export class MoveEffectPhase extends PokemonPhase { * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - {@linkcode Pokemon} the current target of this phase's invoked move * @param hitResult - The {@linkcode HitResult} of the attempted move + * @param damage - The amount of damage dealt to the target in the interaction * @param wasCritical - `true` if the move was a critical hit */ - protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void { - const params = { pokemon: target, opponent: user, move: this.move, hitResult }; + protected applyOnGetHitAbEffects( + user: Pokemon, + target: Pokemon, + hitResult: HitResult, + damage: number, + wasCritical = false, + ): void { + const params = { pokemon: target, opponent: user, move: this.move, hitResult, damage }; applyAbAttrs("PostDefendAbAttr", params); if (wasCritical) { @@ -763,12 +770,12 @@ export class MoveEffectPhase extends PokemonPhase { this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target); - const [hitResult, wasCritical] = this.applyMove(user, target, effectiveness); + const [hitResult, wasCritical, dmg] = this.applyMove(user, target, effectiveness); // Apply effects to the user (always) and the target (if not blocked by substitute). this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, true); if (!this.move.hitsSubstitute(user, target)) { - this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical); + this.applyOnTargetEffects(user, target, hitResult, firstTarget, dmg, wasCritical); } if (this.lastHit) { globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); @@ -788,9 +795,13 @@ export class MoveEffectPhase extends PokemonPhase { * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - The {@linkcode Pokemon} targeted by the move * @param effectiveness - The effectiveness of the move against the target - * @returns The {@linkcode HitResult} of the move against the target and a boolean indicating whether the target was crit + * @returns The {@linkcode HitResult} of the move against the target, a boolean indicating whether the target was crit, and the amount of damage dealt */ - protected applyMoveDamage(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] { + protected applyMoveDamage( + user: Pokemon, + target: Pokemon, + effectiveness: TypeDamageMultiplier, + ): [result: HitResult, critical: boolean, damage: number] { const isCritical = target.getCriticalHitResult(user, this.move); /* @@ -821,7 +832,7 @@ export class MoveEffectPhase extends PokemonPhase { const isOneHitKo = result === HitResult.ONE_HIT_KO; if (!dmg) { - return [result, false]; + return [result, false, 0]; } target.lapseTags(BattlerTagLapseType.HIT); @@ -850,7 +861,7 @@ export class MoveEffectPhase extends PokemonPhase { } if (damage <= 0) { - return [result, isCritical]; + return [result, isCritical, damage]; } if (user.isPlayer()) { @@ -879,7 +890,7 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.applyModifiers(DamageMoneyRewardModifier, true, user, new NumberHolder(damage)); } - return [result, isCritical]; + return [result, isCritical, damage]; } /** @@ -932,12 +943,17 @@ export class MoveEffectPhase extends PokemonPhase { * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - The {@linkcode Pokemon} struck by the move * @param effectiveness - The effectiveness of the move against the target + * @returns The {@linkcode HitResult} of the move against the target, a boolean indicating whether the target was crit, and the amount of damage dealt */ - protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] { + protected applyMove( + user: Pokemon, + target: Pokemon, + effectiveness: TypeDamageMultiplier, + ): [HitResult, critical: boolean, damage: number] { const moveCategory = user.getMoveCategory(target, this.move); if (moveCategory === MoveCategory.STATUS) { - return [HitResult.STATUS, false]; + return [HitResult.STATUS, false, 0]; } const result = this.applyMoveDamage(user, target, effectiveness); @@ -960,6 +976,7 @@ export class MoveEffectPhase extends PokemonPhase { * @param target - The {@linkcode Pokemon} targeted by the move * @param hitResult - The {@linkcode HitResult} obtained from applying the move * @param firstTarget - `true` if the target is the first Pokemon hit by the attack + * @param damage - The amount of damage dealt to the target in the interaction * @param wasCritical - `true` if the move was a critical hit */ protected applyOnTargetEffects( @@ -967,6 +984,7 @@ export class MoveEffectPhase extends PokemonPhase { target: Pokemon, hitResult: HitResult, firstTarget: boolean, + damage: number, wasCritical = false, ): void { /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ @@ -979,8 +997,8 @@ export class MoveEffectPhase extends PokemonPhase { this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, false); this.applyHeldItemFlinchCheck(user, target, dealsDamage); - this.applyOnGetHitAbEffects(user, target, hitResult, wasCritical); - applyAbAttrs("PostAttackAbAttr", { pokemon: user, opponent: target, move: this.move, hitResult }); + this.applyOnGetHitAbEffects(user, target, hitResult, damage, wasCritical); + applyAbAttrs("PostAttackAbAttr", { pokemon: user, opponent: target, move: this.move, hitResult, damage: damage }); // We assume only enemy Pokemon are able to have the EnemyAttackStatusEffectChanceModifier from tokens if (!user.isPlayer() && this.move.is("AttackMove")) { diff --git a/src/phases/select-modifier-phase.ts b/src/phases/select-modifier-phase.ts index 05c890136ee..06a02af38b0 100644 --- a/src/phases/select-modifier-phase.ts +++ b/src/phases/select-modifier-phase.ts @@ -177,7 +177,7 @@ export class SelectModifierPhase extends BattlePhase { this.openModifierMenu(modifierType, cost, modifierSelectCallback); } } else { - this.applyModifier(modifierType.newModifier()!); + this.applyModifier(modifierType.newModifier()!, cost); } return cost === -1; } diff --git a/src/phases/switch-phase.ts b/src/phases/switch-phase.ts index a431d973a02..eaa8a723745 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -1,4 +1,5 @@ import { globalScene } from "#app/global-scene"; +import { DynamicPhaseType } from "#enums/dynamic-phase-type"; import { SwitchType } from "#enums/switch-type"; import { UiMode } from "#enums/ui-mode"; import { BattlePhase } from "#phases/battle-phase"; @@ -75,8 +76,11 @@ export class SwitchPhase extends BattlePhase { if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) { // Remove any pre-existing PostSummonPhase under the same field index. // Pre-existing PostSummonPhases may occur when this phase is invoked during a prompt to switch at the start of a wave. - globalScene.phaseManager.tryRemovePhase( + // TODO: Separate the animations from `SwitchSummonPhase` and co. into another phase and use that on initial switch - this is a band-aid fix + globalScene.phaseManager.tryRemoveDynamicPhase( + DynamicPhaseType.POST_SUMMON, p => p.is("PostSummonPhase") && p.player && p.fieldIndex === this.fieldIndex, + "all", ); const switchType = option === PartyOption.PASS_BATON ? SwitchType.BATON_PASS : this.switchType; globalScene.phaseManager.unshiftNew("SwitchSummonPhase", switchType, fieldIndex, slotIndex, this.doReturn); diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 8fc7a763c8f..59211a9eb03 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -179,12 +179,11 @@ export class TurnStartPhase extends FieldPhase { // https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179 phaseManager.pushNew("WeatherEffectPhase"); + phaseManager.pushNew("PositionalTagPhase"); phaseManager.pushNew("BerryPhase"); - /** Add a new phase to check who should be taking status damage */ phaseManager.pushNew("CheckStatusEffectPhase", moveOrder); - phaseManager.pushNew("PositionalTagPhase"); phaseManager.pushNew("TurnEndPhase"); /* diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 3a4dafb2de2..47d3c2df2f5 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -1515,6 +1515,7 @@ export class GameData { switch (dataType) { case GameDataType.SYSTEM: { dataStr = this.convertSystemDataStr(dataStr); + dataStr = dataStr.replace(/"playTime":\d+/, `"playTime":${this.gameStats.playTime + 60}`); const systemData = this.parseSystemData(dataStr); valid = !!systemData.dexData && !!systemData.timestamp; break; diff --git a/src/ui-inputs.ts b/src/ui-inputs.ts index 71c5ac1049e..72ae59faaec 100644 --- a/src/ui-inputs.ts +++ b/src/ui-inputs.ts @@ -13,7 +13,7 @@ import { SettingsGamepadUiHandler } from "#ui/settings-gamepad-ui-handler"; import { SettingsKeyboardUiHandler } from "#ui/settings-keyboard-ui-handler"; import { SettingsUiHandler } from "#ui/settings-ui-handler"; import { StarterSelectUiHandler } from "#ui/starter-select-ui-handler"; -import type Phaser from "phaser"; +import Phaser from "phaser"; type ActionKeys = Record void>; @@ -224,25 +224,26 @@ export class UiInputs { buttonSpeedChange(up = true): void { const settingGameSpeed = settingIndex(SettingKeys.Game_Speed); + const settingOptions = Setting[settingGameSpeed].options; + let currentSetting = settingOptions.findIndex(item => item.value === globalScene.gameSpeed.toString()); + // if current setting is -1, then the current game speed is not a valid option, so default to index 5 (3x) + if (currentSetting === -1) { + currentSetting = 5; + } + let direction: number; if (up && globalScene.gameSpeed < 5) { - globalScene.gameData.saveSetting( - SettingKeys.Game_Speed, - Setting[settingGameSpeed].options.findIndex(item => item.label === `${globalScene.gameSpeed}x`) + 1, - ); - if (globalScene.ui?.getMode() === UiMode.SETTINGS) { - (globalScene.ui.getHandler() as SettingsUiHandler).show([]); - } + direction = 1; } else if (!up && globalScene.gameSpeed > 1) { - globalScene.gameData.saveSetting( - SettingKeys.Game_Speed, - Math.max( - Setting[settingGameSpeed].options.findIndex(item => item.label === `${globalScene.gameSpeed}x`) - 1, - 0, - ), - ); - if (globalScene.ui?.getMode() === UiMode.SETTINGS) { - (globalScene.ui.getHandler() as SettingsUiHandler).show([]); - } + direction = -1; + } else { + return; + } + 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([]); } } } diff --git a/src/ui/battle-info/battle-info.ts b/src/ui/battle-info/battle-info.ts index 810d0c7c328..1641585a603 100644 --- a/src/ui/battle-info/battle-info.ts +++ b/src/ui/battle-info/battle-info.ts @@ -287,9 +287,6 @@ export abstract class BattleInfo extends Phaser.GameObjects.Container { 2.5, ); this.splicedIcon.setVisible(pokemon.isFusion(true)); - if (!this.splicedIcon.visible) { - return; - } this.splicedIcon .on("pointerover", () => globalScene.ui.showTooltip( @@ -323,6 +320,10 @@ export abstract class BattleInfo extends Phaser.GameObjects.Container { .setVisible(pokemon.isShiny()) .setTint(getVariantTint(baseVariant)); + this.shinyIcon + .on("pointerover", () => globalScene.ui.showTooltip("", i18next.t("common:shinyOnHover") + shinyDescriptor)) + .on("pointerout", () => globalScene.ui.hideTooltip()); + if (!this.shinyIcon.visible) { return; } @@ -335,10 +336,6 @@ export abstract class BattleInfo extends Phaser.GameObjects.Container { } shinyDescriptor += ")"; } - - this.shinyIcon - .on("pointerover", () => globalScene.ui.showTooltip("", i18next.t("common:shinyOnHover") + shinyDescriptor)) - .on("pointerout", () => globalScene.ui.hideTooltip()); } initInfo(pokemon: Pokemon) { diff --git a/src/ui/battle-info/enemy-battle-info.ts b/src/ui/battle-info/enemy-battle-info.ts index d426a49df5c..4b37adc74aa 100644 --- a/src/ui/battle-info/enemy-battle-info.ts +++ b/src/ui/battle-info/enemy-battle-info.ts @@ -36,7 +36,7 @@ export class EnemyBattleInfo extends BattleInfo { override constructTypeIcons(): void { this.type1Icon = globalScene.add.sprite(-15, -15.5, "pbinfo_enemy_type1").setName("icon_type_1").setOrigin(0); this.type2Icon = globalScene.add.sprite(-15, -2.5, "pbinfo_enemy_type2").setName("icon_type_2").setOrigin(0); - this.type3Icon = globalScene.add.sprite(0, 15.5, "pbinfo_enemy_type3").setName("icon_type_3").setOrigin(0); + this.type3Icon = globalScene.add.sprite(0, -15.5, "pbinfo_enemy_type").setName("icon_type_3").setOrigin(0); this.add([this.type1Icon, this.type2Icon, this.type3Icon]); } diff --git a/src/ui/battle-info/player-battle-info.ts b/src/ui/battle-info/player-battle-info.ts index 998f7cbb41f..f0b50748154 100644 --- a/src/ui/battle-info/player-battle-info.ts +++ b/src/ui/battle-info/player-battle-info.ts @@ -21,7 +21,7 @@ export class PlayerBattleInfo extends BattleInfo { override constructTypeIcons(): void { this.type1Icon = globalScene.add.sprite(-139, -17, "pbinfo_player_type1").setName("icon_type_1").setOrigin(0); this.type2Icon = globalScene.add.sprite(-139, -1, "pbinfo_player_type2").setName("icon_type_2").setOrigin(0); - this.type3Icon = globalScene.add.sprite(-154, -17, "pbinfo_player_type3").setName("icon_type_3").setOrigin(0); + this.type3Icon = globalScene.add.sprite(-154, -17, "pbinfo_player_type").setName("icon_type_3").setOrigin(0); this.add([this.type1Icon, this.type2Icon, this.type3Icon]); } diff --git a/src/ui/party-ui-handler.ts b/src/ui/party-ui-handler.ts index d3c67f66ec9..73fc1bfc010 100644 --- a/src/ui/party-ui-handler.ts +++ b/src/ui/party-ui-handler.ts @@ -563,7 +563,7 @@ export class PartyUiHandler extends MessageUiHandler { const ui = this.getUi(); const option = this.options[this.optionsCursor]; - if (option === PartyOption.TRANSFER) { + if (this.transferMode && option === PartyOption.TRANSFER) { return this.processTransferOption(); } @@ -1021,7 +1021,8 @@ export class PartyUiHandler extends MessageUiHandler { } // Toggle item transfer mode to discard items or vice versa - if (this.cursor === 7) { + // Prevent changing mode, when currently transfering an item + if (this.cursor === 7 && !this.transferMode) { switch (this.partyUiMode) { case PartyUiMode.DISCARD: this.partyUiMode = PartyUiMode.MODIFIER_TRANSFER; @@ -1609,7 +1610,7 @@ export class PartyUiHandler extends MessageUiHandler { const modifier = formChangeItemModifiers[option - PartyOption.FORM_CHANGE_ITEM]; optionName = `${modifier.active ? i18next.t("partyUiHandler:deactivate") : i18next.t("partyUiHandler:activate")} ${modifier.type.name}`; } else if (option === PartyOption.UNPAUSE_EVOLUTION) { - optionName = `${pokemon.pauseEvolutions ? i18next.t("partyUiHandler:unpausedEvolution") : i18next.t("partyUiHandler:pauseEvolution")}`; + optionName = `${pokemon.pauseEvolutions ? i18next.t("partyUiHandler:unpauseEvolution") : i18next.t("partyUiHandler:pauseEvolution")}`; } else { if (this.localizedOptions.includes(option)) { optionName = i18next.t(`partyUiHandler:${toCamelCase(PartyOption[option])}`); @@ -2040,12 +2041,13 @@ class PartySlot extends Phaser.GameObjects.Container { if (this.pokemon.isShiny()) { const doubleShiny = this.pokemon.isDoubleShiny(false); + const largeIconTint = doubleShiny ? this.pokemon.getBaseVariant() : this.pokemon.getVariant(); const shinyStar = globalScene.add .image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`) .setOrigin(0) .setPositionRelative(this.slotName, shinyIconToNameOffset.x, shinyIconToNameOffset.y) - .setTint(getVariantTint(this.pokemon.getBaseVariant())); + .setTint(getVariantTint(largeIconTint)); slotInfoContainer.add(shinyStar); if (doubleShiny) { diff --git a/src/ui/save-slot-select-ui-handler.ts b/src/ui/save-slot-select-ui-handler.ts index e9f9c5a0038..97cd3016479 100644 --- a/src/ui/save-slot-select-ui-handler.ts +++ b/src/ui/save-slot-select-ui-handler.ts @@ -181,7 +181,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { ui.setOverlayMode( UiMode.CONFIRM, () => { - globalScene.gameData.tryClearSession(cursor).then(response => { + globalScene.gameData.deleteSession(cursor).then(response => { if (response[0] === false) { globalScene.reset(true); } else { diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index 25d5277b4c2..c3214fa5420 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -72,7 +72,7 @@ import { rgbHexToRgba, } from "#utils/common"; import type { StarterPreferences } from "#utils/data"; -import { loadStarterPreferences, saveStarterPreferences } from "#utils/data"; +import { deepCopy, loadStarterPreferences, saveStarterPreferences } from "#utils/data"; import { getPokemonSpeciesForm, getPokerusStarters } from "#utils/pokemon-utils"; import { toCamelCase, toTitleCase } from "#utils/strings"; import { argbFromRgba } from "@material/material-color-utilities"; @@ -1148,7 +1148,8 @@ export class StarterSelectUiHandler extends MessageUiHandler { this.starterSelectContainer.setVisible(true); this.starterPreferences = loadStarterPreferences(); - this.originalStarterPreferences = loadStarterPreferences(); + // Deep copy the JSON (avoid re-loading from disk) + this.originalStarterPreferences = deepCopy(this.starterPreferences); this.allSpecies.forEach((species, s) => { const icon = this.starterContainers[s].icon; @@ -1212,6 +1213,8 @@ export class StarterSelectUiHandler extends MessageUiHandler { preferences: StarterPreferences, ignoreChallenge = false, ): StarterAttributes { + // if preferences for the species is undefined, set it to an empty object + preferences[species.speciesId] ??= {}; const starterAttributes = preferences[species.speciesId]; const { dexEntry, starterDataEntry: starterData } = this.getSpeciesData(species.speciesId, !ignoreChallenge); @@ -1828,9 +1831,15 @@ export class StarterSelectUiHandler extends MessageUiHandler { // The persistent starter data to apply e.g. candy upgrades const persistentStarterData = globalScene.gameData.starterData[this.lastSpecies.speciesId]; // The sanitized starter preferences - let starterAttributes = this.starterPreferences[this.lastSpecies.speciesId]; - // The original starter preferences - const originalStarterAttributes = this.originalStarterPreferences[this.lastSpecies.speciesId]; + if (this.starterPreferences[this.lastSpecies.speciesId] === undefined) { + this.starterPreferences[this.lastSpecies.speciesId] = {}; + } + if (this.originalStarterPreferences[this.lastSpecies.speciesId] === undefined) { + this.originalStarterPreferences[this.lastSpecies.speciesId] = {}; + } + // Bangs are safe here due to the above check + const starterAttributes = this.starterPreferences[this.lastSpecies.speciesId]!; + const originalStarterAttributes = this.originalStarterPreferences[this.lastSpecies.speciesId]!; // this gets the correct pokemon cursor depending on whether you're in the starter screen or the party icons if (!this.starterIconsCursorObj.visible) { @@ -2050,10 +2059,6 @@ export class StarterSelectUiHandler extends MessageUiHandler { const option: OptionSelectItem = { label: getNatureName(n, true, true, true, globalScene.uiTheme), handler: () => { - // update default nature in starter save data - if (!starterAttributes) { - starterAttributes = this.starterPreferences[this.lastSpecies.speciesId] = {}; - } starterAttributes.nature = n; originalStarterAttributes.nature = starterAttributes.nature; this.clearText(); @@ -2095,27 +2100,21 @@ export class StarterSelectUiHandler extends MessageUiHandler { const passiveAttr = starterData.passiveAttr; if (passiveAttr & PassiveAttr.UNLOCKED) { // this is for enabling and disabling the passive - if (!(passiveAttr & PassiveAttr.ENABLED)) { - options.push({ - label: i18next.t("starterSelectUiHandler:enablePassive"), - handler: () => { - starterData.passiveAttr |= PassiveAttr.ENABLED; - ui.setMode(UiMode.STARTER_SELECT); - this.setSpeciesDetails(this.lastSpecies); - return true; - }, - }); - } else { - options.push({ - label: i18next.t("starterSelectUiHandler:disablePassive"), - handler: () => { - starterData.passiveAttr ^= PassiveAttr.ENABLED; - ui.setMode(UiMode.STARTER_SELECT); - this.setSpeciesDetails(this.lastSpecies); - return true; - }, - }); - } + const label = i18next.t( + passiveAttr & PassiveAttr.ENABLED + ? "starterSelectUiHandler:disablePassive" + : "starterSelectUiHandler:enablePassive", + ); + options.push({ + label, + handler: () => { + starterData.passiveAttr ^= PassiveAttr.ENABLED; + persistentStarterData.passiveAttr ^= PassiveAttr.ENABLED; + ui.setMode(UiMode.STARTER_SELECT); + this.setSpeciesDetails(this.lastSpecies); + return true; + }, + }); } // if container.favorite is false, show the favorite option const isFavorite = starterAttributes?.favorite ?? false; @@ -3414,8 +3413,9 @@ export class StarterSelectUiHandler extends MessageUiHandler { if (species) { const defaultDexAttr = this.getCurrentDexProps(species.speciesId); const defaultProps = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr); + // Bang is correct due to the `?` before variant const variant = this.starterPreferences[species.speciesId]?.variant - ? (this.starterPreferences[species.speciesId].variant as Variant) + ? (this.starterPreferences[species.speciesId]!.variant as Variant) : defaultProps.variant; const tint = getVariantTint(variant); this.pokemonShinyIcon.setFrame(getVariantIcon(variant)).setTint(tint); @@ -3640,15 +3640,19 @@ export class StarterSelectUiHandler extends MessageUiHandler { if (starterIndex > -1) { props = globalScene.gameData.getSpeciesDexAttrProps(species, this.starterAttr[starterIndex]); - this.setSpeciesDetails(species, { - shiny: props.shiny, - formIndex: props.formIndex, - female: props.female, - variant: props.variant, - abilityIndex: this.starterAbilityIndexes[starterIndex], - natureIndex: this.starterNatures[starterIndex], - teraType: this.starterTeras[starterIndex], - }); + this.setSpeciesDetails( + species, + { + shiny: props.shiny, + formIndex: props.formIndex, + female: props.female, + variant: props.variant, + abilityIndex: this.starterAbilityIndexes[starterIndex], + natureIndex: this.starterNatures[starterIndex], + teraType: this.starterTeras[starterIndex], + }, + false, + ); } else { const defaultAbilityIndex = starterAttributes?.ability ?? globalScene.gameData.getStarterSpeciesDefaultAbilityIndex(species); @@ -3665,15 +3669,19 @@ export class StarterSelectUiHandler extends MessageUiHandler { props.formIndex = starterAttributes?.form ?? props.formIndex; props.female = starterAttributes?.female ?? props.female; - this.setSpeciesDetails(species, { - shiny: props.shiny, - formIndex: props.formIndex, - female: props.female, - variant: props.variant, - abilityIndex: defaultAbilityIndex, - natureIndex: defaultNature, - teraType: starterAttributes?.tera, - }); + this.setSpeciesDetails( + species, + { + shiny: props.shiny, + formIndex: props.formIndex, + female: props.female, + variant: props.variant, + abilityIndex: defaultAbilityIndex, + natureIndex: defaultNature, + teraType: starterAttributes?.tera, + }, + false, + ); } if (!isNullOrUndefined(props.formIndex)) { @@ -3710,15 +3718,19 @@ export class StarterSelectUiHandler extends MessageUiHandler { const defaultNature = globalScene.gameData.getSpeciesDefaultNature(species); const props = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr); - this.setSpeciesDetails(species, { - shiny: props.shiny, - formIndex: props.formIndex, - female: props.female, - variant: props.variant, - abilityIndex: defaultAbilityIndex, - natureIndex: defaultNature, - forSeen: true, - }); + this.setSpeciesDetails( + species, + { + shiny: props.shiny, + formIndex: props.formIndex, + female: props.female, + variant: props.variant, + abilityIndex: defaultAbilityIndex, + natureIndex: defaultNature, + forSeen: true, + }, + false, + ); this.pokemonSprite.setTint(0x808080); } } else { @@ -3740,15 +3752,19 @@ export class StarterSelectUiHandler extends MessageUiHandler { this.pokemonFormText.setVisible(false); this.teraIcon.setVisible(false); - this.setSpeciesDetails(species!, { - // TODO: is this bang correct? - shiny: false, - formIndex: 0, - female: false, - variant: 0, - abilityIndex: 0, - natureIndex: 0, - }); + this.setSpeciesDetails( + species!, + { + // TODO: is this bang correct? + shiny: false, + formIndex: 0, + female: false, + variant: 0, + abilityIndex: 0, + natureIndex: 0, + }, + false, + ); this.pokemonSprite.clearTint(); } } @@ -3770,7 +3786,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { return { dexEntry: { ...copiedDexEntry }, starterDataEntry: { ...copiedStarterDataEntry } }; } - setSpeciesDetails(species: PokemonSpecies, options: SpeciesDetails = {}): void { + setSpeciesDetails(species: PokemonSpecies, options: SpeciesDetails = {}, save = true): void { let { shiny, formIndex, female, variant, abilityIndex, natureIndex, teraType } = options; const forSeen: boolean = options.forSeen ?? false; const oldProps = species ? globalScene.gameData.getSpeciesDexAttrProps(species, this.dexAttrCursor) : null; @@ -4182,7 +4198,9 @@ export class StarterSelectUiHandler extends MessageUiHandler { this.updateInstructions(); - saveStarterPreferences(this.originalStarterPreferences); + if (save) { + saveStarterPreferences(this.originalStarterPreferences); + } } setTypeIcons(type1: PokemonType | null, type2: PokemonType | null): void { @@ -4618,6 +4636,8 @@ export class StarterSelectUiHandler extends MessageUiHandler { clear(): void { super.clear(); + saveStarterPreferences(this.originalStarterPreferences); + this.clearStarterPreferences(); this.cursor = -1; this.hideInstructions(); diff --git a/src/ui/summary-ui-handler.ts b/src/ui/summary-ui-handler.ts index b6447f03587..dfa70d1dc76 100644 --- a/src/ui/summary-ui-handler.ts +++ b/src/ui/summary-ui-handler.ts @@ -430,20 +430,21 @@ export class SummaryUiHandler extends UiHandler { this.friendshipShadow.setCrop(0, 0, 16, 16 - 16 * ((this.pokemon?.friendship || 0) / 255)); const doubleShiny = this.pokemon.isDoubleShiny(false); - const baseVariant = this.pokemon.getBaseVariant(doubleShiny); + const bigIconVariant = doubleShiny ? this.pokemon.getBaseVariant(doubleShiny) : this.pokemon.getVariant(); this.shinyIcon.setPositionRelative( this.nameText, this.nameText.displayWidth + (this.splicedIcon.visible ? this.splicedIcon.displayWidth + 1 : 0) + 1, 3, ); - this.shinyIcon.setTexture(`shiny_star${doubleShiny ? "_1" : ""}`); - this.shinyIcon.setVisible(this.pokemon.isShiny(false)); - this.shinyIcon.setTint(getVariantTint(baseVariant)); + this.shinyIcon + .setTexture(`shiny_star${doubleShiny ? "_1" : ""}`) + .setVisible(this.pokemon.isShiny(false)) + .setTint(getVariantTint(bigIconVariant)); if (this.shinyIcon.visible) { let shinyDescriptor = ""; - if (doubleShiny || baseVariant) { - shinyDescriptor = " (" + getShinyDescriptor(baseVariant); + if (doubleShiny || bigIconVariant) { + shinyDescriptor = " (" + getShinyDescriptor(bigIconVariant); if (doubleShiny) { shinyDescriptor += "/" + getShinyDescriptor(this.pokemon.fusionVariant); } diff --git a/src/ui/title-ui-handler.ts b/src/ui/title-ui-handler.ts index 5ae195231e5..ef814167631 100644 --- a/src/ui/title-ui-handler.ts +++ b/src/ui/title-ui-handler.ts @@ -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(); diff --git a/src/utils/data.ts b/src/utils/data.ts index 6580ecf2ee9..75047c38d25 100644 --- a/src/utils/data.ts +++ b/src/utils/data.ts @@ -8,7 +8,7 @@ import { AES, enc } from "crypto-js"; * @param values - The object to be deep copied. * @returns A new object that is a deep copy of the input. */ -export function deepCopy(values: object): object { +export function deepCopy(values: T): T { // Convert the object to a JSON string and parse it back to an object to perform a deep copy return JSON.parse(JSON.stringify(values)); } @@ -58,13 +58,28 @@ export function decrypt(data: string, bypassLogin: boolean): string { return AES.decrypt(data, saveKey).toString(enc.Utf8); } +/** + * Check if an object has no properties of its own (its shape is `{}`). An empty array is considered a bare object. + * @param obj - Object to check + * @returns - Whether the object is bare + */ +export function isBareObject(obj: any): boolean { + if (typeof obj !== "object") { + return false; + } + for (const _ in obj) { + return false; + } + return true; +} + // the latest data saved/loaded for the Starter Preferences. Required to reduce read/writes. Initialize as "{}", since this is the default value and no data needs to be stored if present. // if they ever add private static variables, move this into StarterPrefs const StarterPrefers_DEFAULT: string = "{}"; let StarterPrefers_private_latest: string = StarterPrefers_DEFAULT; export interface StarterPreferences { - [key: number]: StarterAttributes; + [key: number]: StarterAttributes | undefined; } // called on starter selection show once @@ -74,11 +89,17 @@ export function loadStarterPreferences(): StarterPreferences { localStorage.getItem(`starterPrefs_${loggedInUser?.username}`) || StarterPrefers_DEFAULT), ); } -// called on starter selection clear, always export function saveStarterPreferences(prefs: StarterPreferences): void { - const pStr: string = JSON.stringify(prefs); + // Fastest way to check if an object has any properties (does no allocation) + if (isBareObject(prefs)) { + console.warn("Refusing to save empty starter preferences"); + return; + } + // no reason to store `{}` (for starters not customized) + const pStr: string = JSON.stringify(prefs, (_, value) => (isBareObject(value) ? undefined : value)); if (pStr !== StarterPrefers_private_latest) { + console.log("%cSaving starter preferences", "color: blue"); // something changed, store the update localStorage.setItem(`starterPrefs_${loggedInUser?.username}`, pStr); // update the latest prefs diff --git a/test/abilities/intimidate.test.ts b/test/abilities/intimidate.test.ts index 3c283e0392b..8064f1e62aa 100644 --- a/test/abilities/intimidate.test.ts +++ b/test/abilities/intimidate.test.ts @@ -35,13 +35,43 @@ describe("Abilities - Intimidate", () => { it("should lower all opponents' ATK by 1 stage on entry and switch", async () => { await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + const [mightyena, poochyena] = game.scene.getPlayerParty(); + const enemy = game.field.getEnemyPokemon(); expect(enemy.getStatStage(Stat.ATK)).toBe(-1); + expect(mightyena).toHaveAbilityApplied(AbilityId.INTIMIDATE); game.doSwitchPokemon(1); await game.toNextTurn(); + expect(poochyena.isActive()).toBe(true); expect(enemy.getStatStage(Stat.ATK)).toBe(-2); + expect(poochyena).toHaveAbilityApplied(AbilityId.INTIMIDATE); + }); + + it("should trigger once on initial switch prompt without cancelling opposing abilities", async () => { + await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + await game.classicMode.startBattleWithSwitch(1); + + const [poochyena, mightyena] = game.scene.getPlayerParty(); + expect(poochyena.species.speciesId).toBe(SpeciesId.POOCHYENA); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy).toHaveStatStage(Stat.ATK, -1); + expect(poochyena).toHaveStatStage(Stat.ATK, -1); + + expect(poochyena).toHaveAbilityApplied(AbilityId.INTIMIDATE); + expect(mightyena).not.toHaveAbilityApplied(AbilityId.INTIMIDATE); + }); + + it("should activate on reload with single party", async () => { + await game.classicMode.startBattle([SpeciesId.MIGHTYENA]); + + expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1); + + await game.reload.reloadSession(); + + expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1); }); it("should lower ATK of all opponents in a double battle", async () => { diff --git a/test/evolution.test.ts b/test/evolution.test.ts index 3fb763e9190..7079404bdec 100644 --- a/test/evolution.test.ts +++ b/test/evolution.test.ts @@ -175,4 +175,27 @@ describe("Evolution", () => { expect(fourForm.evoFormKey).toBe("four"); // meanwhile, according to the pokemon-forms, the evoFormKey for a 4 family maushold is "four" } }); + + it("tyrogue should evolve if move is not in first slot", async () => { + game.override + .moveset([MoveId.TACKLE, MoveId.RAPID_SPIN, MoveId.LOW_KICK]) + .enemySpecies(SpeciesId.GOLEM) + .enemyMoveset(MoveId.SPLASH) + .startingWave(41) + .startingLevel(19) + .enemyLevel(30); + + await game.classicMode.startBattle([SpeciesId.TYROGUE]); + + const tyrogue = game.field.getPlayerPokemon(); + + const golem = game.field.getEnemyPokemon(); + golem.hp = 1; + expect(golem.hp).toBe(1); + + game.move.select(MoveId.TACKLE); + await game.phaseInterceptor.to("EndEvolutionPhase"); + + expect(tyrogue.species.speciesId).toBe(SpeciesId.HITMONTOP); + }); }); diff --git a/test/items/leftovers.test.ts b/test/items/leftovers.test.ts index 6ae4094799b..484843b81ff 100644 --- a/test/items/leftovers.test.ts +++ b/test/items/leftovers.test.ts @@ -2,7 +2,6 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { DamageAnimPhase } from "#phases/damage-anim-phase"; -import { TurnEndPhase } from "#phases/turn-end-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -54,7 +53,7 @@ describe("Items - Leftovers", () => { const leadHpAfterDamage = leadPokemon.hp; // Check if leftovers heal us - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("PokemonHealPhase"); expect(leadPokemon.hp).toBeGreaterThan(leadHpAfterDamage); }); }); diff --git a/test/moves/entry-hazards.test.ts b/test/moves/entry-hazards.test.ts index c4dead1bb67..af8145183a3 100644 --- a/test/moves/entry-hazards.test.ts +++ b/test/moves/entry-hazards.test.ts @@ -200,7 +200,7 @@ describe("Moves - Entry Hazards", () => { expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi); expect(game.textInterceptor.logs).toContain( i18next.t("arenaTag:stealthRockActivateTrap", { - pokemonName: getPokemonNameWithAffix(enemy), + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), }), ); }); diff --git a/test/moves/pollen-puff.test.ts b/test/moves/pollen-puff.test.ts index 76732a39c43..02772055f1f 100644 --- a/test/moves/pollen-puff.test.ts +++ b/test/moves/pollen-puff.test.ts @@ -61,4 +61,16 @@ describe("Moves - Pollen Puff", () => { expect(target.battleData.hitCount).toBe(2); }); + + // Regression test for pollen puff healing an enemy after dealing damage + it("should not heal an enemy after dealing damage", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + const target = game.field.getEnemyPokemon(); + game.move.use(MoveId.POLLEN_PUFF); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(target.hp).not.toBe(target.getMaxHp()); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + }); }); diff --git a/test/status-effects/general-status-effect.test.ts b/test/status-effects/general-status-effect.test.ts new file mode 100644 index 00000000000..db73265181b --- /dev/null +++ b/test/status-effects/general-status-effect.test.ts @@ -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 & { 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]); + }); +}); diff --git a/test/test-utils/helpers/classic-mode-helper.ts b/test/test-utils/helpers/classic-mode-helper.ts index 008648fcd0d..c625ebc911c 100644 --- a/test/test-utils/helpers/classic-mode-helper.ts +++ b/test/test-utils/helpers/classic-mode-helper.ts @@ -1,6 +1,7 @@ import { getGameMode } from "#app/game-mode"; import overrides from "#app/overrides"; import { BattleStyle } from "#enums/battle-style"; +import { Button } from "#enums/buttons"; import { GameModes } from "#enums/game-modes"; import { Nature } from "#enums/nature"; import type { SpeciesId } from "#enums/species-id"; @@ -100,4 +101,33 @@ export class ClassicModeHelper extends GameManagerHelper { await this.game.phaseInterceptor.to(CommandPhase); console.log("==================[New Turn]=================="); } + + /** + * Queue inputs to switch at the start of the next battle, and then start it. + * @param pokemonIndex - The 0-indexed position of the party pokemon to switch to. + * Should never be called with 0 as that will select the currently active pokemon and freeze + * @returns A Promise that resolves once the battle has been started and the switch prompt resolved + * @todo Make this work for double battles + * @example + * ```ts + * await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]) + * await game.startBattleWithSwitch(1); + * ``` + */ + public async startBattleWithSwitch(pokemonIndex: number): Promise { + 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)]=================="); + } } diff --git a/test/test-utils/phase-interceptor.ts b/test/test-utils/phase-interceptor.ts index 0d357a75557..996f00806c6 100644 --- a/test/test-utils/phase-interceptor.ts +++ b/test/test-utils/phase-interceptor.ts @@ -37,6 +37,7 @@ import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase"; import { NextEncounterPhase } from "#phases/next-encounter-phase"; import { PartyExpPhase } from "#phases/party-exp-phase"; import { PartyHealPhase } from "#phases/party-heal-phase"; +import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; import { PokemonTransformPhase } from "#phases/pokemon-transform-phase"; import { PositionalTagPhase } from "#phases/positional-tag-phase"; import { PostGameOverPhase } from "#phases/post-game-over-phase"; @@ -181,6 +182,7 @@ export class PhaseInterceptor { UnlockPhase, PostGameOverPhase, RevivalBlessingPhase, + PokemonHealPhase, ]; private endBySetMode = [ diff --git a/test/ui/item-manage-button.test.ts b/test/ui/item-manage-button.test.ts index a7ea76918a5..c28cd9e802e 100644 --- a/test/ui/item-manage-button.test.ts +++ b/test/ui/item-manage-button.test.ts @@ -6,7 +6,7 @@ import { UiMode } from "#enums/ui-mode"; import type { Pokemon } from "#field/pokemon"; import { GameManager } from "#test/test-utils/game-manager"; import type { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler"; -import type { PartyUiHandler } from "#ui/party-ui-handler"; +import { type PartyUiHandler, PartyUiMode } from "#ui/party-ui-handler"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -169,4 +169,51 @@ describe("UI - Transfer Items", () => { expect(pokemon.getHeldItems().map(h => h.stackCount)).toEqual([2, 2]); } }); + + // TODO: This test breaks when running all tests on github. Fix this once hotfix period is over. + it.todo("should not allow changing to discard mode when transfering items", async () => { + let handler: PartyUiHandler | undefined; + + const { resolve, promise } = Promise.withResolvers(); + + 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); + } + }); }); diff --git a/test/utils/data.test.ts b/test/utils/data.test.ts new file mode 100644 index 00000000000..c0b853e2643 --- /dev/null +++ b/test/utils/data.test.ts @@ -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; + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 1b212c49bcf..7bf82eaaca0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,5 +59,12 @@ }, // Exclude checking for script JS files as those are covered by the folder's `jsconfig.json` "include": ["**/*.ts", "**/*.d.ts"], - "exclude": ["node_modules", "dist", "vite.config.ts", "vitest.config.ts", "vitest.workspace.ts"] + "exclude": [ + "node_modules", + "dist", + "vite.config.ts", + "vitest.config.ts", + "vitest.workspace.ts", + "public/service-worker.js" + ] }