diff --git a/src/battle-scene.ts b/src/battle-scene.ts index bbae01bf2f6..6b53ffd6a7f 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -1669,7 +1669,11 @@ export default class BattleScene extends SceneBase { this.scoreText.setVisible(this.gameMode.isDaily); } - updateAndShowText(duration: integer): void { + /** + * Displays the current luck value. + * @param duration The time for this label to fade in, if it is not already visible. + */ + updateAndShowText(duration: number): void { const labels = [ this.luckLabelText, this.luckText ]; labels.forEach(t => t.setAlpha(0)); const luckValue = getPartyLuckValue(this.getParty()); diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 871c7160cc2..fda4d2226c0 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1914,10 +1914,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (!this.shiny || (!variantData.hasOwnProperty(variantDataIndex) && !variantData.hasOwnProperty(this.species.speciesId))) { return 0; } - const rand = Utils.randSeedInt(10); - if (rand >= 4) { + const rand = new Utils.NumberHolder(0); + this.scene.executeWithSeedOffset(() => { + rand.value = Utils.randSeedInt(10); + }, this.id, this.scene.waveSeed); + if (rand.value >= 4) { return 0; // 6/10 - } else if (rand >= 1) { + } else if (rand.value >= 1) { return 1; // 3/10 } else { return 2; // 1/10 diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index 8ba3599e72b..9ce555a617e 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -1719,7 +1719,8 @@ const modifierPool: ModifierPool = { new WeightedModifierType(modifierTypes.FORM_CHANGE_ITEM, (party: Pokemon[]) => Math.min(Math.ceil(party[0].scene.currentBattle.waveIndex / 50), 4) * 6, 24), new WeightedModifierType(modifierTypes.AMULET_COIN, skipInLastClassicWaveOrDefault(3)), new WeightedModifierType(modifierTypes.EVIOLITE, (party: Pokemon[]) => { - if (!party[0].scene.gameMode.isFreshStartChallenge() && party[0].scene.gameData.unlocks[Unlockables.EVIOLITE]) { + const { gameMode, gameData } = party[0].scene; + if (gameMode.isDaily || (!gameMode.isFreshStartChallenge() && gameData.isUnlocked(Unlockables.EVIOLITE))) { return party.some(p => ((p.getSpeciesForm(true).speciesId in pokemonEvolutions) || (p.isFusion() && (p.getFusionSpeciesForm(true).speciesId in pokemonEvolutions))) && !p.getHeldItems().some(i => i instanceof Modifiers.EvolutionStatBoosterModifier) && !p.isMax()) ? 10 : 0; } @@ -1804,7 +1805,7 @@ const modifierPool: ModifierPool = { new WeightedModifierType(modifierTypes.VOUCHER_PREMIUM, (party: Pokemon[], rerollCount: integer) => !party[0].scene.gameMode.isDaily && !party[0].scene.gameMode.isEndless && !party[0].scene.gameMode.isSplicedOnly ? Math.max(5 - rerollCount * 2, 0) : 0, 5), new WeightedModifierType(modifierTypes.DNA_SPLICERS, (party: Pokemon[]) => !party[0].scene.gameMode.isSplicedOnly && party.filter(p => !p.fusionSpecies).length > 1 ? 24 : 0, 24), - new WeightedModifierType(modifierTypes.MINI_BLACK_HOLE, (party: Pokemon[]) => (!party[0].scene.gameMode.isFreshStartChallenge() && party[0].scene.gameData.unlocks[Unlockables.MINI_BLACK_HOLE]) ? 1 : 0, 1), + new WeightedModifierType(modifierTypes.MINI_BLACK_HOLE, (party: Pokemon[]) => (party[0].scene.gameMode.isDaily || (!party[0].scene.gameMode.isFreshStartChallenge() && party[0].scene.gameData.isUnlocked(Unlockables.MINI_BLACK_HOLE))) ? 1 : 0, 1), ].map(m => { m.setTier(ModifierTier.MASTER); return m; }) @@ -1996,9 +1997,16 @@ export function getModifierPoolForType(poolType: ModifierPoolType): ModifierPool } const tierWeights = [ 768 / 1024, 195 / 1024, 48 / 1024, 12 / 1024, 1 / 1024 ]; +/** + * Allows a unit test to check if an item exists in the Modifier Pool. Checks the pool directly, rather than attempting to reroll for the item. + */ +export const itemPoolChecks: Map = new Map(); export function regenerateModifierPoolThresholds(party: Pokemon[], poolType: ModifierPoolType, rerollCount: integer = 0) { const pool = getModifierPoolForType(poolType); + itemPoolChecks.forEach((v, k) => { + itemPoolChecks.set(k, false); + }); const ignoredIndexes = {}; const modifierTableData = {}; @@ -2035,6 +2043,9 @@ export function regenerateModifierPoolThresholds(party: Pokemon[], poolType: Mod ignoredIndexes[t].push(i++); return total; } + if (itemPoolChecks.has(modifierType.modifierType.id as ModifierTypeKeys)) { + itemPoolChecks.set(modifierType.modifierType.id as ModifierTypeKeys, true); + } thresholds.set(total, i++); return total; }, 0); @@ -2437,10 +2448,22 @@ export class ModifierTypeOption { } } +/** + * Calculates the team's luck value. + * @param party The player's party. + * @returns A number between 0 and 14 based on the party's total luck value, or a random number between 0 and 14 if the player is in Daily Run mode. + */ export function getPartyLuckValue(party: Pokemon[]): integer { + if (party[0].scene.gameMode.isDaily) { + const DailyLuck = new Utils.NumberHolder(0); + party[0].scene.executeWithSeedOffset(() => { + DailyLuck.value = Utils.randSeedInt(15); // Random number between 0 and 14 + }, 0, party[0].scene.seed); + return DailyLuck.value; + } const luck = Phaser.Math.Clamp(party.map(p => p.isAllowedInBattle() ? p.getLuck() : 0) .reduce((total: integer, value: integer) => total += value, 0), 0, 14); - return luck || 0; + return luck ?? 0; } export function getLuckString(luckValue: integer): string { diff --git a/src/overrides.ts b/src/overrides.ts index 35ca299721b..852961db8d7 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -12,6 +12,7 @@ import { type PokeballCounts } from "./battle-scene"; import { Gender } from "./data/gender"; import { Variant } from "./data/variant"; import { type ModifierOverride } from "./modifier/modifier-type"; +import { Unlockables } from "./system/unlockables"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; @@ -70,8 +71,10 @@ class DefaultOverrides { [PokeballType.MASTER_BALL]: 0, }, }; + /** Forces an item to be UNLOCKED */ + readonly ITEM_UNLOCK_OVERRIDE: Unlockables[] = []; /** Set to `true` to show all tutorials */ - readonly BYPASS_TUTORIAL_SKIP: boolean = false; + readonly BYPASS_TUTORIAL_SKIP_OVERRIDE: boolean = false; // ---------------- // PLAYER OVERRIDES diff --git a/src/phases/title-phase.ts b/src/phases/title-phase.ts index 52503501837..b23a5ec0c89 100644 --- a/src/phases/title-phase.ts +++ b/src/phases/title-phase.ts @@ -76,7 +76,8 @@ export class TitlePhase extends Phase { this.scene.ui.clearText(); this.end(); }; - if (this.scene.gameData.unlocks[Unlockables.ENDLESS_MODE]) { + const { gameData } = this.scene; + if (gameData.isUnlocked(Unlockables.ENDLESS_MODE)) { const options: OptionSelectItem[] = [ { label: GameMode.getModeName(GameModes.CLASSIC), @@ -100,7 +101,7 @@ export class TitlePhase extends Phase { } } ]; - if (this.scene.gameData.unlocks[Unlockables.SPLICED_ENDLESS_MODE]) { + if (gameData.isUnlocked(Unlockables.SPLICED_ENDLESS_MODE)) { options.push({ label: GameMode.getModeName(GameModes.SPLICED_ENDLESS), handler: () => { @@ -220,6 +221,7 @@ export class TitlePhase extends Phase { const modifiers: Modifier[] = Array(3).fill(null).map(() => modifierTypes.EXP_SHARE().withIdFromFunc(modifierTypes.EXP_SHARE).newModifier()) .concat(Array(3).fill(null).map(() => modifierTypes.GOLDEN_EXP_CHARM().withIdFromFunc(modifierTypes.GOLDEN_EXP_CHARM).newModifier())) + .concat([modifierTypes.MAP().withIdFromFunc(modifierTypes.MAP).newModifier()]) .concat(getDailyRunStarterModifiers(party)) .filter((m) => m !== null); diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 04fef4a81da..22a793e9aca 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -372,6 +372,18 @@ export class GameData { }; } + /** + * Checks if an `Unlockable` has been unlocked. + * @param unlockable The Unlockable to check + * @returns `true` if the player has unlocked this `Unlockable` or an override has enabled it + */ + public isUnlocked(unlockable: Unlockables): boolean { + if (Overrides.ITEM_UNLOCK_OVERRIDE.includes(unlockable)) { + return true; + } + return this.unlocks[unlockable]; + } + public saveSystem(): Promise { return new Promise(resolve => { this.scene.ui.savingIcon.show(); diff --git a/src/test/daily_mode.test.ts b/src/test/daily_mode.test.ts index 5cc61a62874..58692330272 100644 --- a/src/test/daily_mode.test.ts +++ b/src/test/daily_mode.test.ts @@ -1,5 +1,12 @@ +import { MapModifier } from "#app/modifier/modifier"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import GameManager from "./utils/gameManager"; +import { Moves } from "#app/enums/moves"; +import { Biome } from "#app/enums/biome"; +import { Mode } from "#app/ui/ui"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; + +//const TIMEOUT = 20 * 1000; describe("Daily Mode", () => { let phaserGame: Phaser.Game; @@ -28,5 +35,66 @@ describe("Daily Mode", () => { expect(pkm.level).toBe(20); expect(pkm.moveset.length).toBeGreaterThan(0); }); + expect(game.scene.getModifiers(MapModifier).length).toBeGreaterThan(0); + }); +}); + +describe("Shop modifications", async () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + beforeEach(() => { + game = new GameManager(phaserGame); + + game.override + .startingWave(9) + .startingBiome(Biome.ICE_CAVE) // Will lead to Snowy Forest with randomly generated weather + .battleType("single") + .startingLevel(100) // Avoid levelling up + .enemyLevel(1000) // Avoid opponent dying before game.doKillOpponents() + .disableTrainerWaves() + .moveset([Moves.KOWTOW_CLEAVE]) + .enemyMoveset(Moves.SPLASH); + game.modifiers + .addCheck("EVIOLITE") + .addCheck("MINI_BLACK_HOLE"); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + game.modifiers.clearChecks(); + }); + + it("should not have Eviolite and Mini Black Hole available in Classic if not unlocked", async () => { + await game.classicMode.startBattle(); + game.move.select(Moves.KOWTOW_CLEAVE); + await game.phaseInterceptor.to("DamagePhase"); + await game.doKillOpponents(); + await game.phaseInterceptor.to("BattleEndPhase"); + game.onNextPrompt("SelectModifierPhase", Mode.MODIFIER_SELECT, () => { + expect(game.scene.ui.getHandler()).toBeInstanceOf(ModifierSelectUiHandler); + game.modifiers + .testCheck("EVIOLITE", false) + .testCheck("MINI_BLACK_HOLE", false); + }); + }); + + it("should have Eviolite and Mini Black Hole available in Daily", async () => { + await game.dailyMode.startBattle(); + game.move.select(Moves.KOWTOW_CLEAVE); + await game.phaseInterceptor.to("DamagePhase"); + await game.doKillOpponents(); + await game.phaseInterceptor.to("BattleEndPhase"); + game.onNextPrompt("SelectModifierPhase", Mode.MODIFIER_SELECT, () => { + expect(game.scene.ui.getHandler()).toBeInstanceOf(ModifierSelectUiHandler); + game.modifiers + .testCheck("EVIOLITE", true) + .testCheck("MINI_BLACK_HOLE", true); + }); }); }); diff --git a/src/test/game-mode.test.ts b/src/test/game-mode.test.ts index ccec3a3aa16..11994a102af 100644 --- a/src/test/game-mode.test.ts +++ b/src/test/game-mode.test.ts @@ -2,6 +2,7 @@ import { GameMode, GameModes, getGameMode } from "#app/game-mode"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as Utils from "../utils"; import GameManager from "./utils/gameManager"; + describe("game-mode", () => { let phaserGame: Phaser.Game; let game: GameManager; @@ -12,6 +13,7 @@ describe("game-mode", () => { }); afterEach(() => { game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); vi.resetAllMocks(); }); beforeEach(() => { diff --git a/src/test/reload.test.ts b/src/test/reload.test.ts index 7c4523dd9ef..daf8e43a0cd 100644 --- a/src/test/reload.test.ts +++ b/src/test/reload.test.ts @@ -1,9 +1,13 @@ -import { Species } from "#app/enums/species"; import { GameModes } from "#app/game-mode"; +import OptionSelectUiHandler from "#app/ui/settings/option-select-ui-handler"; +import { Mode } from "#app/ui/ui"; +import { Biome } from "#enums/biome"; +import { Button } from "#enums/buttons"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; +import { MockClock } from "#test/utils/mocks/mockClock"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { Moves } from "#app/enums/moves"; -import { Biome } from "#app/enums/biome"; describe("Reload", () => { let phaserGame: Phaser.Game; @@ -50,6 +54,13 @@ describe("Reload", () => { game.move.select(Moves.KOWTOW_CLEAVE); await game.phaseInterceptor.to("DamagePhase"); await game.doKillOpponents(); + game.onNextPrompt("SelectBiomePhase", Mode.OPTION_SELECT, () => { + (game.scene.time as MockClock).overrideDelay = null; + const optionSelectUiHandler = game.scene.ui.getHandler() as OptionSelectUiHandler; + game.scene.time.delayedCall(1010, () => optionSelectUiHandler.processInput(Button.ACTION)); + game.endPhase(); + (game.scene.time as MockClock).overrideDelay = 1; + }); await game.toNextWave(); expect(game.phaseInterceptor.log).toContain("NewBiomeEncounterPhase"); diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index f48fe3ef228..6fad87df182 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -47,6 +47,7 @@ import { MoveHelper } from "./helpers/moveHelper"; import { OverridesHelper } from "./helpers/overridesHelper"; import { SettingsHelper } from "./helpers/settingsHelper"; import { ReloadHelper } from "./helpers/reloadHelper"; +import { ModifierHelper } from "./helpers/modifiersHelper"; import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; import BattleMessageUiHandler from "#app/ui/battle-message-ui-handler"; import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; @@ -71,6 +72,7 @@ export default class GameManager { public readonly challengeMode: ChallengeModeHelper; public readonly settings: SettingsHelper; public readonly reload: ReloadHelper; + public readonly modifiers: ModifierHelper; /** * Creates an instance of GameManager. @@ -93,6 +95,7 @@ export default class GameManager { this.challengeMode = new ChallengeModeHelper(this); this.settings = new SettingsHelper(this); this.reload = new ReloadHelper(this); + this.modifiers = new ModifierHelper(this); // Disables Mystery Encounters on all tests (can be overridden at test level) this.override.mysteryEncounterChance(0); diff --git a/src/test/utils/helpers/modifiersHelper.ts b/src/test/utils/helpers/modifiersHelper.ts new file mode 100644 index 00000000000..c38bf5770a8 --- /dev/null +++ b/src/test/utils/helpers/modifiersHelper.ts @@ -0,0 +1,58 @@ +import { expect } from "vitest"; +import { GameManagerHelper } from "./gameManagerHelper"; +import { itemPoolChecks, ModifierTypeKeys } from "#app/modifier/modifier-type"; + +export class ModifierHelper extends GameManagerHelper { + /** + * Adds a Modifier to the list of modifiers to check for. + * + * Note that all modifiers are updated during the start of `SelectModifierPhase`. + * @param modifier The Modifier to add. + * @returns `this` + */ + addCheck(modifier: ModifierTypeKeys): this { + itemPoolChecks.set(modifier, undefined); + return this; + } + + /** + * `get`s a value from the `itemPoolChecks` map. + * + * If the item is in the Modifier Pool, and the player can get it, will return `true`. + * + * If the item is *not* in the Modifier Pool, will return `false`. + * + * If a `SelectModifierPhase` has not occurred, and we do not know if the item is in the Modifier Pool or not, will return `undefined`. + * @param modifier + * @returns + */ + getCheck(modifier: ModifierTypeKeys): boolean | undefined { + return itemPoolChecks.get(modifier); + } + + /** + * `expect`s a Modifier `toBeTruthy` (in the Modifier Pool) or `Falsy` (unobtainable on this floor). Use during a test. + * + * Note that if a `SelectModifierPhase` has not been run yet, these values will be `undefined`, and the check will fail. + * @param modifier The modifier to check. + * @param expectToBePreset Whether the Modifier should be in the Modifier Pool. Set to `false` to expect it to be absent instead. + * @returns `this` + */ + testCheck(modifier: ModifierTypeKeys, expectToBePreset: boolean): this { + if (expectToBePreset) { + expect(itemPoolChecks.get(modifier)).toBeTruthy(); + } + expect(itemPoolChecks.get(modifier)).toBeFalsy(); + return this; + } + + /** Removes all modifier checks. @returns `this` */ + clearChecks() { + itemPoolChecks.clear(); + return this; + } + + private log(...params: any[]) { + console.log("Modifiers:", ...params); + } +} diff --git a/src/test/utils/helpers/overridesHelper.ts b/src/test/utils/helpers/overridesHelper.ts index 686de58e874..84cb1719e0b 100644 --- a/src/test/utils/helpers/overridesHelper.ts +++ b/src/test/utils/helpers/overridesHelper.ts @@ -10,6 +10,8 @@ import { ModifierOverride } from "#app/modifier/modifier-type"; import Overrides from "#app/overrides"; import { vi } from "vitest"; import { GameManagerHelper } from "./gameManagerHelper"; +import { Unlockables } from "#app/system/unlockables"; +import { Variant } from "#app/data/variant"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; @@ -300,6 +302,17 @@ export class OverridesHelper extends GameManagerHelper { return this; } + /** + * Gives the player access to an Unlockable. + * @param unlockable The Unlockable(s) to enable. + * @returns `this` + */ + enableUnlockable(unlockable: Unlockables[]) { + vi.spyOn(Overrides, "ITEM_UNLOCK_OVERRIDE", "get").mockReturnValue(unlockable); + this.log("Temporarily unlocked the following content: ", unlockable); + return this; + } + /** * Override the items rolled at the end of a battle * @param items the items to be rolled @@ -311,6 +324,25 @@ export class OverridesHelper extends GameManagerHelper { return this; } + /** + * Override player shininess + * @param shininess Whether the player's Pokemon should be shiny. + */ + shinyLevel(shininess: boolean): this { + vi.spyOn(Overrides, "SHINY_OVERRIDE", "get").mockReturnValue(shininess); + this.log(`Set player Pokemon as ${shininess ? "" : "not "}shiny!`); + return this; + } + /** + * Override player shiny variant + * @param variant The player's shiny variant. + */ + variantLevel(variant: Variant): this { + vi.spyOn(Overrides, "VARIANT_OVERRIDE", "get").mockReturnValue(variant); + this.log(`Set player Pokemon's shiny variant to ${variant}!`); + return this; + } + /** * Override the enemy (Pokemon) to have the given amount of health segments * @param healthSegments the number of segments to give diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 46bb757c867..6c4c9a6cf94 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -43,6 +43,7 @@ import { UnavailablePhase } from "#app/phases/unavailable-phase"; import { VictoryPhase } from "#app/phases/victory-phase"; import { PartyHealPhase } from "#app/phases/party-heal-phase"; import UI, { Mode } from "#app/ui/ui"; +import { SelectBiomePhase } from "#app/phases/select-biome-phase"; import { MysteryEncounterBattlePhase, MysteryEncounterOptionSelectedPhase, @@ -122,6 +123,7 @@ export default class PhaseInterceptor { [EndEvolutionPhase, this.startPhase], [LevelCapPhase, this.startPhase], [AttemptRunPhase, this.startPhase], + [SelectBiomePhase, this.startPhase], [MysteryEncounterPhase, this.startPhase], [MysteryEncounterOptionSelectedPhase, this.startPhase], [MysteryEncounterBattlePhase, this.startPhase], @@ -346,7 +348,8 @@ export default class PhaseInterceptor { console.log("setMode", `${Mode[mode]} (=${mode})`, args); const ret = this.originalSetMode.apply(instance, [mode, ...args]); if (!this.phases[currentPhase.constructor.name]) { - throw new Error(`missing ${currentPhase.constructor.name} in phaseInterceptor PHASES list`); + throw new Error(`missing ${currentPhase.constructor.name} in phaseInterceptor PHASES list --- Add it to PHASES inside of /test/utils/phaseInterceptor.ts`); + } if (this.phases[currentPhase.constructor.name].endBySetMode) { this.inProgress?.callback(); diff --git a/src/tutorial.ts b/src/tutorial.ts index 18d8291d227..3934ffee57f 100644 --- a/src/tutorial.ts +++ b/src/tutorial.ts @@ -74,11 +74,11 @@ const tutorialHandlers = { * @returns a promise with result `true` if the tutorial was run and finished, `false` otherwise */ export async function handleTutorial(scene: BattleScene, tutorial: Tutorial): Promise { - if (!scene.enableTutorials && !Overrides.BYPASS_TUTORIAL_SKIP) { + if (!scene.enableTutorials && !Overrides.BYPASS_TUTORIAL_SKIP_OVERRIDE) { return false; } - if (scene.gameData.getTutorialFlags()[tutorial] && !Overrides.BYPASS_TUTORIAL_SKIP) { + if (scene.gameData.getTutorialFlags()[tutorial] && !Overrides.BYPASS_TUTORIAL_SKIP_OVERRIDE) { return false; }