diff --git a/src/data/egg.ts b/src/data/egg.ts index ce27030ebef..1cd5c65fc18 100644 --- a/src/data/egg.ts +++ b/src/data/egg.ts @@ -222,7 +222,7 @@ export class Egg { let pokemonSpecies = getPokemonSpecies(this._species); // Special condition to have Phione eggs also have a chance of generating Manaphy - if (this._species === Species.PHIONE) { + if (this._species === Species.PHIONE && this._sourceType === EggSourceType.SAME_SPECIES_EGG) { pokemonSpecies = getPokemonSpecies(Utils.randSeedInt(MANAPHY_EGG_MANAPHY_RATE) ? Species.PHIONE : Species.MANAPHY); } @@ -326,7 +326,8 @@ export class Egg { break; } - return Utils.randSeedInt(baseChance * Math.pow(2, 3 - this.tier)) ? Utils.randSeedInt(3) : 3; + const tierMultiplier = this.isManaphyEgg() ? 2 : Math.pow(2, 3 - this.tier); + return Utils.randSeedInt(baseChance * tierMultiplier) ? Utils.randSeedInt(3) : 3; } private getEggTierDefaultHatchWaves(eggTier?: EggTier): number { @@ -361,7 +362,12 @@ export class Egg { * the species that was the legendary focus at the time */ if (this.isManaphyEgg()) { - const rand = Utils.randSeedInt(MANAPHY_EGG_MANAPHY_RATE); + /** + * Adding a technicality to make unit tests easier: By making this check pass + * when Utils.randSeedInt(8) = 1, and by making the generatePlayerPokemon() species + * check pass when Utils.randSeedInt(8) = 0, we can tell them apart during tests. + */ + const rand = (Utils.randSeedInt(MANAPHY_EGG_MANAPHY_RATE) !== 1); return rand ? Species.PHIONE : Species.MANAPHY; } else if (this.tier === EggTier.MASTER && this._sourceType === EggSourceType.GACHA_LEGENDARY) { diff --git a/src/phases/quiet-form-change-phase.ts b/src/phases/quiet-form-change-phase.ts index 6a1d31d137d..dde500e156a 100644 --- a/src/phases/quiet-form-change-phase.ts +++ b/src/phases/quiet-form-change-phase.ts @@ -65,7 +65,7 @@ export class QuietFormChangePhase extends BattlePhase { pokemonFormTintSprite.setVisible(false); pokemonFormTintSprite.setTintFill(0xFFFFFF); - this.scene.playSound("PRSFX- Transform"); + this.scene.playSound("battle_anims/PRSFX- Transform"); this.scene.tweens.add({ targets: pokemonTintSprite, diff --git a/src/test/eggs/manaphy-egg.test.ts b/src/test/eggs/manaphy-egg.test.ts new file mode 100644 index 00000000000..257bf330bb8 --- /dev/null +++ b/src/test/eggs/manaphy-egg.test.ts @@ -0,0 +1,118 @@ +import { Egg } from "#app/data/egg"; +import { EggSourceType } from "#app/enums/egg-source-types"; +import { EggTier } from "#app/enums/egg-type"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Manaphy Eggs", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + const EGG_HATCH_COUNT: integer = 48; + let rngSweepProgress: number = 0; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + game = new GameManager(phaserGame); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.restoreAllMocks(); + }); + + beforeEach(async () => { + await game.importData("src/test/utils/saves/everything.prsv"); + + /** + * In our tests, we will perform an "RNG sweep" by letting rngSweepProgress + * increase uniformly from 0 to 1 in order to get a uniform sample of the + * possible RNG outcomes. This will let us quickly and consistently find + * the probability of each RNG outcome. + */ + vi.spyOn(Phaser.Math.RND, "realInRange").mockImplementation((min: number, max: number) => { + return rngSweepProgress * (max - min) + min; + }); + }); + + it("should have correct Manaphy rates and Rare Egg Move rates, from the egg gacha", () => { + const scene = game.scene; + + let manaphyCount = 0; + let phioneCount = 0; + let rareEggMoveCount = 0; + for (let i = 0; i < EGG_HATCH_COUNT; i++) { + rngSweepProgress = (2 * i + 1) / (2 * EGG_HATCH_COUNT); + + const newEgg = new Egg({ scene, tier: EggTier.COMMON, sourceType: EggSourceType.GACHA_SHINY, id: 204 }); + const newHatch = newEgg.generatePlayerPokemon(scene); + if (newHatch.species.speciesId === Species.MANAPHY) { + manaphyCount++; + } else if (newHatch.species.speciesId === Species.PHIONE) { + phioneCount++; + } + if (newEgg.eggMoveIndex === 3) { + rareEggMoveCount++; + } + } + + expect(manaphyCount + phioneCount).toBe(EGG_HATCH_COUNT); + expect(manaphyCount).toBe(1/8 * EGG_HATCH_COUNT); + expect(rareEggMoveCount).toBe(1/12 * EGG_HATCH_COUNT); + }); + + it("should have correct Manaphy rates and Rare Egg Move rates, from Phione species eggs", () => { + const scene = game.scene; + + let manaphyCount = 0; + let phioneCount = 0; + let rareEggMoveCount = 0; + for (let i = 0; i < EGG_HATCH_COUNT; i++) { + rngSweepProgress = (2 * i + 1) / (2 * EGG_HATCH_COUNT); + + const newEgg = new Egg({ scene, species: Species.PHIONE, sourceType: EggSourceType.SAME_SPECIES_EGG }); + const newHatch = newEgg.generatePlayerPokemon(scene); + if (newHatch.species.speciesId === Species.MANAPHY) { + manaphyCount++; + } else if (newHatch.species.speciesId === Species.PHIONE) { + phioneCount++; + } + if (newEgg.eggMoveIndex === 3) { + rareEggMoveCount++; + } + } + + expect(manaphyCount + phioneCount).toBe(EGG_HATCH_COUNT); + expect(manaphyCount).toBe(1/8 * EGG_HATCH_COUNT); + expect(rareEggMoveCount).toBe(1/6 * EGG_HATCH_COUNT); + }); + + it("should have correct Manaphy rates and Rare Egg Move rates, from Manaphy species eggs", () => { + const scene = game.scene; + + let manaphyCount = 0; + let phioneCount = 0; + let rareEggMoveCount = 0; + for (let i = 0; i < EGG_HATCH_COUNT; i++) { + rngSweepProgress = (2 * i + 1) / (2 * EGG_HATCH_COUNT); + + const newEgg = new Egg({ scene, species: Species.MANAPHY, sourceType: EggSourceType.SAME_SPECIES_EGG }); + const newHatch = newEgg.generatePlayerPokemon(scene); + if (newHatch.species.speciesId === Species.MANAPHY) { + manaphyCount++; + } else if (newHatch.species.speciesId === Species.PHIONE) { + phioneCount++; + } + if (newEgg.eggMoveIndex === 3) { + rareEggMoveCount++; + } + } + + expect(phioneCount).toBe(0); + expect(manaphyCount).toBe(EGG_HATCH_COUNT); + expect(rareEggMoveCount).toBe(1/6 * EGG_HATCH_COUNT); + }); +}); diff --git a/src/ui/egg-summary-ui-handler.ts b/src/ui/egg-summary-ui-handler.ts index af82ab33438..1d18e75f530 100644 --- a/src/ui/egg-summary-ui-handler.ts +++ b/src/ui/egg-summary-ui-handler.ts @@ -29,8 +29,10 @@ export default class EggSummaryUiHandler extends MessageUiHandler { private summaryContainer: Phaser.GameObjects.Container; /** container for the mini pokemon sprites */ private pokemonIconSpritesContainer: Phaser.GameObjects.Container; - /** container for the icons displayed alongside the mini icons (e.g. shiny, HA capsule) */ + /** container for the icons displayed on top of the mini pokemon sprites (e.g. shiny, HA capsule) */ private pokemonIconsContainer: Phaser.GameObjects.Container; + /** container for the elements displayed behind the mini pokemon sprites (e.g. egg rarity bg) */ + private pokemonBackgroundContainer: Phaser.GameObjects.Container; /** hatch info container that displays the current pokemon / hatch (main element on left hand side) */ private infoContainer: PokemonHatchInfoContainer; /** handles jumping animations for the pokemon sprite icons */ @@ -71,15 +73,17 @@ export default class EggSummaryUiHandler extends MessageUiHandler { this.eggHatchBg.setOrigin(0, 0); this.eggHatchContainer.add(this.eggHatchBg); - this.pokemonIconsContainer = this.scene.add.container(iconContainerX, iconContainerY); - this.pokemonIconSpritesContainer = this.scene.add.container(iconContainerX, iconContainerY); - this.summaryContainer.add(this.pokemonIconsContainer); - this.summaryContainer.add(this.pokemonIconSpritesContainer); - this.cursorObj = this.scene.add.image(0, 0, "select_cursor"); this.cursorObj.setOrigin(0, 0); this.summaryContainer.add(this.cursorObj); + this.pokemonIconSpritesContainer = this.scene.add.container(iconContainerX, iconContainerY); + this.pokemonIconsContainer = this.scene.add.container(iconContainerX, iconContainerY); + this.pokemonBackgroundContainer = this.scene.add.container(iconContainerX, iconContainerY); + this.summaryContainer.add(this.pokemonBackgroundContainer); + this.summaryContainer.add(this.pokemonIconSpritesContainer); + this.summaryContainer.add(this.pokemonIconsContainer); + this.infoContainer = new PokemonHatchInfoContainer(this.scene, this.summaryContainer); this.infoContainer.setup(); this.infoContainer.changeToEggSummaryLayout(); @@ -95,6 +99,7 @@ export default class EggSummaryUiHandler extends MessageUiHandler { this.summaryContainer.setVisible(false); this.pokemonIconSpritesContainer.removeAll(true); this.pokemonIconsContainer.removeAll(true); + this.pokemonBackgroundContainer.removeAll(true); this.eggHatchBg.setVisible(false); this.getUi().hideTooltip(); // Note: Questions on garbage collection go to @frutescens @@ -164,25 +169,25 @@ export default class EggSummaryUiHandler extends MessageUiHandler { const offset = 2; const rightSideX = 12; - const bg = this.scene.add.image(x+2, y+5, "passive_bg"); - bg.setOrigin(0, 0); - bg.setScale(0.75); - bg.setVisible(true); - this.pokemonIconsContainer.add(bg); + const rarityBg = this.scene.add.image(x + 2, y + 5, "passive_bg"); + rarityBg.setOrigin(0, 0); + rarityBg.setScale(0.75); + rarityBg.setVisible(true); + this.pokemonBackgroundContainer.add(rarityBg); // set tint for passive bg switch (getEggTierForSpecies(displayPokemon.species)) { case EggTier.COMMON: - bg.setVisible(false); + rarityBg.setVisible(false); break; case EggTier.GREAT: - bg.setTint(0xabafff); + rarityBg.setTint(0xabafff); break; case EggTier.ULTRA: - bg.setTint(0xffffaa); + rarityBg.setTint(0xffffaa); break; case EggTier.MASTER: - bg.setTint(0xdfffaf); + rarityBg.setTint(0xdfffaf); break; } const species = displayPokemon.species; @@ -192,35 +197,31 @@ export default class EggSummaryUiHandler extends MessageUiHandler { const isShiny = displayPokemon.shiny; // set pokemon icon (and replace with base sprite if there is a mismatch) - const icon = this.scene.add.sprite(x - offset, y + offset, species.getIconAtlasKey(formIndex, isShiny, variant)); - icon.setScale(0.5); - icon.setOrigin(0, 0); - icon.setFrame(species.getIconId(female, formIndex, isShiny, variant)); + const pokemonIcon = this.scene.add.sprite(x - offset, y + offset, species.getIconAtlasKey(formIndex, isShiny, variant)); + pokemonIcon.setScale(0.5); + pokemonIcon.setOrigin(0, 0); + pokemonIcon.setFrame(species.getIconId(female, formIndex, isShiny, variant)); - if (icon.frame.name !== species.getIconId(female, formIndex, isShiny, variant)) { + if (pokemonIcon.frame.name !== species.getIconId(female, formIndex, isShiny, variant)) { console.log(`${species.name}'s variant icon does not exist. Replacing with default.`); - icon.setTexture(species.getIconAtlasKey(formIndex, false, variant)); - icon.setFrame(species.getIconId(female, formIndex, false, variant)); + pokemonIcon.setTexture(species.getIconAtlasKey(formIndex, false, variant)); + pokemonIcon.setFrame(species.getIconId(female, formIndex, false, variant)); } - this.pokemonIconSpritesContainer.add(icon); - this.iconAnimHandler.addOrUpdate(icon, PokemonIconAnimMode.NONE); + this.pokemonIconSpritesContainer.add(pokemonIcon); - const shiny = this.scene.add.image(x + rightSideX, y + offset * 2, "shiny_star_small"); - shiny.setScale(0.5); - shiny.setVisible(displayPokemon.shiny); - shiny.setTint(getVariantTint(displayPokemon.variant)); - this.pokemonIconsContainer.add(shiny); + const shinyIcon = this.scene.add.image(x + rightSideX, y + offset, "shiny_star_small"); + shinyIcon.setOrigin(0, 0); + shinyIcon.setScale(0.5); + shinyIcon.setVisible(displayPokemon.shiny); + shinyIcon.setTint(getVariantTint(displayPokemon.variant)); + this.pokemonIconsContainer.add(shinyIcon); - const ha = this.scene.add.image(x + rightSideX, y + 7, "ha_capsule"); - ha.setScale(0.5); - ha.setVisible((displayPokemon.hasAbility(displayPokemon.species.abilityHidden))); - this.pokemonIconsContainer.add(ha); + const haIcon = this.scene.add.image(x + rightSideX, y + offset * 4, "ha_capsule"); + haIcon.setOrigin(0, 0); + haIcon.setScale(0.5); + haIcon.setVisible(displayPokemon.abilityIndex === 2); + this.pokemonIconsContainer.add(haIcon); - const pb = this.scene.add.image(x + rightSideX, y + offset * 7, "icon_owned"); - pb.setOrigin(0, 0); - pb.setScale(0.5); - - // add animation for new unlocks (new catch or new shiny or new form) const dexEntry = value.dexEntryBeforeUpdate; const caughtAttr = dexEntry.caughtAttr; const newShiny = BigInt(1 << (displayPokemon.shiny ? 1 : 0)); @@ -228,17 +229,24 @@ export default class EggSummaryUiHandler extends MessageUiHandler { const newShinyOrVariant = ((newShiny & caughtAttr) === BigInt(0)) || ((newVariant & caughtAttr) === BigInt(0)); const newForm = (BigInt(1 << displayPokemon.formIndex) * DexAttr.DEFAULT_FORM & caughtAttr) === BigInt(0); - pb.setVisible(!caughtAttr || newForm); - if (!caughtAttr || newShinyOrVariant || newForm) { - this.iconAnimHandler.addOrUpdate(icon, PokemonIconAnimMode.PASSIVE); - } - this.pokemonIconsContainer.add(pb); + const pokeballIcon = this.scene.add.image(x + rightSideX, y + offset * 7, "icon_owned"); + pokeballIcon.setOrigin(0, 0); + pokeballIcon.setScale(0.5); + pokeballIcon.setVisible(!caughtAttr || newForm); + this.pokemonIconsContainer.add(pokeballIcon); - const em = this.scene.add.image(x, y + offset, "icon_egg_move"); - em.setOrigin(0, 0); - em.setScale(0.5); - em.setVisible(value.eggMoveUnlocked); - this.pokemonIconsContainer.add(em); + const eggMoveIcon = this.scene.add.image(x, y + offset, "icon_egg_move"); + eggMoveIcon.setOrigin(0, 0); + eggMoveIcon.setScale(0.5); + eggMoveIcon.setVisible(value.eggMoveUnlocked); + this.pokemonIconsContainer.add(eggMoveIcon); + + // add animation to the Pokemon sprite for new unlocks (new catch, new shiny or new form) + if (!caughtAttr || newShinyOrVariant || newForm) { + this.iconAnimHandler.addOrUpdate(pokemonIcon, PokemonIconAnimMode.PASSIVE); + } else { + this.iconAnimHandler.addOrUpdate(pokemonIcon, PokemonIconAnimMode.NONE); + } }); this.setCursor(0); diff --git a/src/ui/pokemon-info-container.ts b/src/ui/pokemon-info-container.ts index 49bfd4d7293..3c54e529d43 100644 --- a/src/ui/pokemon-info-container.ts +++ b/src/ui/pokemon-info-container.ts @@ -262,7 +262,7 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { this.pokemonFormText.disableInteractive(); } - const abilityTextStyle = pokemon.abilityIndex === (pokemon.species.ability2 ? 2 : 1) ? TextStyle.MONEY : TextStyle.WINDOW; + const abilityTextStyle = pokemon.abilityIndex === 2 ? TextStyle.MONEY : TextStyle.WINDOW; this.pokemonAbilityText.setText(pokemon.getAbility(true).name); this.pokemonAbilityText.setColor(getTextColor(abilityTextStyle, false, this.scene.uiTheme)); this.pokemonAbilityText.setShadowColor(getTextColor(abilityTextStyle, true, this.scene.uiTheme)); diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index e1269499b10..89f1b87bcf4 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -1724,7 +1724,8 @@ export default class StarterSelectUiHandler extends MessageUiHandler { } }); ui.setMode(Mode.STARTER_SELECT); - this.setSpeciesDetails(this.lastSpecies, undefined, undefined, undefined, undefined, undefined, undefined); + this.setSpeciesDetails(this.lastSpecies); + this.scene.playSound("se/buy"); // if starterContainer exists, update the passive background if (starterContainer) {