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 8d1c5a84962..46957c02e56 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -20,6 +20,7 @@ jobs: pages: name: Github Pages if: github.repository == 'pagefaultgames/pokerogue' + timeout-minutes: 10 runs-on: ubuntu-latest env: api-dir: ./ diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index ae23a515c4f..08327ee3653 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -19,6 +19,7 @@ on: jobs: run-linters: name: Run 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 748072c536f..39506096298 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,6 +19,7 @@ on: jobs: check-path-change-filter: + timeout-minutes: 5 runs-on: ubuntu-latest permissions: pull-requests: read @@ -37,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 93668f15c61..db07b9be8db 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/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/data/balance/pokemon-evolutions.ts b/src/data/balance/pokemon-evolutions.ts index bf90ebb7edc..bf588784f24 100644 --- a/src/data/balance/pokemon-evolutions.ts +++ b/src/data/balance/pokemon-evolutions.ts @@ -77,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: @@ -192,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: { 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/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 94fa4236f9d..8e2f26af158 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3070,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]); @@ -3107,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]); } } } @@ -3121,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]; @@ -3132,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]; @@ -3149,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]); } } } @@ -3230,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) { 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/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index a29a65aca80..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(); @@ -3408,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); @@ -3634,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); @@ -3659,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)) { @@ -3704,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 { @@ -3734,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(); } } @@ -3764,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; @@ -4176,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 { 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/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/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 dcbf7456df8..8becb4c00ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,5 +59,12 @@ "outDir": "./build", "noEmit": true }, - "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" + ] }