diff --git a/changelog.md b/changelog.md index 124fd141..4f26f18b 100644 --- a/changelog.md +++ b/changelog.md @@ -7,7 +7,9 @@ - Team colors - Target indicators - Improved mobile display -- Fix help command + - Add more battle tab emojis + - Display current active Pokemon +- Fix `/help` command - Add bulk buy to shop - Added user settings - Profile privacy @@ -30,6 +32,17 @@ - Super effective attacks deal more damage - Add X target pattern shape - You can now view other users' profiles with `/trainerinfo`! You can make your profile private in `/settings`. +- Improve pokemon info + - Add some emojis + - Add a training button +- Rebalance Pokemon EXP gain + - Gain much more EXP in early game (low trainer level) + - Gain slightly more EXP at high trainer level + - Gain slightly more EXP when beating low-level PVE NPCs +- Added experimental smart positioning to `/party auto` +- `/evolve` will display evolution requirements if not met +- Improve `/pokemart` with emojis and better button descriptions +- Fixed a bunch of bugs TODO: diff --git a/src/battle/engine/Battle.js b/src/battle/engine/Battle.js index 1308d664..f5f32137 100644 --- a/src/battle/engine/Battle.js +++ b/src/battle/engine/Battle.js @@ -465,7 +465,7 @@ class Battle { Math.floor( Object.values(this.allPokemon).reduce((acc, pokemon) => { if (pokemon.isFainted) { - return acc + (this.minLevel || pokemon.level); + return acc + Math.max(12, this.minLevel || pokemon.level); } return acc; }, 0) * this.pokemonExpMultiplier diff --git a/src/battle/engine/BattlePokemon.js b/src/battle/engine/BattlePokemon.js index 10778828..47e0a852 100644 --- a/src/battle/engine/BattlePokemon.js +++ b/src/battle/engine/BattlePokemon.js @@ -153,6 +153,7 @@ class BattlePokemon { this.hittable = true; this.incapacitated = false; this.restricted = false; + this.shiny = pokemonData.shiny; } /** diff --git a/src/commands/battle/partyAuto.js b/src/commands/battle/partyAuto.js index 5e59679b..1ef031b9 100644 --- a/src/commands/battle/partyAuto.js +++ b/src/commands/battle/partyAuto.js @@ -1,8 +1,6 @@ /** * @file * @author Elvis Wei - * @date 2023 - * @section Description * * partyAuto.js is used to automatically make a party. */ @@ -16,7 +14,6 @@ const { getUserSelectedDevice } = require("../../utils/trainerUtils"); * creates an automatic party for the given user, uses dependencies to get other relevant data. * @param {*} user the user given to get the relevant data from. * @param {*} option the option type for making the automatic party. - * @returns Error or message to send. */ const partyAuto = async (user, option) => { // get trainer @@ -55,13 +52,28 @@ const partyAuto = async (user, option) => { }; } + // sort pokemon by defensive stats + bestPokemons.data.sort((a, b) => { + const [hpA, , defA, , spdA] = a.stats; + const [hpB, , defB, , spdB] = b.stats; + return hpB + defB + spdB - b.level - (hpA + defA + spdA - a.level); + }); + // add best pokemons to party in random positions - for (const pokemon of bestPokemons.data) { + for (const [index, pokemon] of bestPokemons.data.entries()) { // if position is already taken, get new position // eslint-disable-next-line no-constant-condition while (true) { - // get random position - const position = Math.floor(Math.random() * length); + let position; + if (index < 2) { + // get random position in first row + position = Math.floor(Math.random() * party.cols); + } else { + // get random position not in first row + position = + Math.floor(Math.random() * (party.cols * (party.rows - 1))) + + party.cols; + } // if position is empty, add pokemon if (!party.pokemonIds[position]) { party.pokemonIds[position] = pokemon._id.toString(); diff --git a/src/commands/pokemon/evolve.js b/src/commands/pokemon/evolve.js index 984ae3c1..96ad408a 100644 --- a/src/commands/pokemon/evolve.js +++ b/src/commands/pokemon/evolve.js @@ -1,8 +1,6 @@ /** * @file * @author Elvis Wei - * @date 2023 - * @section Description * * evolve.js looks up a pokemon, returning an embed with the pokemon's valid evolution options. */ @@ -15,13 +13,13 @@ const { } = require("../../components/idConfigSelectRow"); const { buildPokemonEmbed } = require("../../embeds/pokemonEmbeds"); const { eventNames } = require("../../config/eventConfig"); +const { buildSpeciesEvolutionString } = require("../../utils/pokemonUtils"); /** * Looks up a Pokemon, returning an embed with the Pokemon's valid * evolution options. - * @param {Object} user User who initiated the command. - * @param {String} pokemonId ID of the Pokemon to evolve. - * @returns Embed with Pokemon's valid evolution options. + * @param {object} user User who initiated the command. + * @param {string} pokemonId ID of the Pokemon to evolve. */ const evolve = async (user, pokemonId) => { // get trainer @@ -55,7 +53,14 @@ const evolve = async (user, pokemonId) => { // if empty, pokemon cannot evolve if (evolutionSpeciesIds.length === 0) { - return { send: null, err: `${pokemon.data.name} cannot evolve yet!` }; + return { + send: null, + err: `${ + pokemon.data.name + } cannot evolve yet! It evolves with the following requirements:\n${buildSpeciesEvolutionString( + speciesData + )}`, + }; } // build pokemon embed diff --git a/src/components/battleInfoActionRow.js b/src/components/battleInfoActionRow.js index a6fad3e0..1a243784 100644 --- a/src/components/battleInfoActionRow.js +++ b/src/components/battleInfoActionRow.js @@ -39,6 +39,7 @@ const buildBattleInfoActionRow = (battle, stateId, selectionIndex = 0) => { buttonConfigs.push({ label: "Moves", disabled: false, + emoji: "⚔ī¸", data: { ...infoRowData, selectionIndex: i, @@ -48,6 +49,7 @@ const buildBattleInfoActionRow = (battle, stateId, selectionIndex = 0) => { buttonConfigs.push({ label: "Hide", disabled: false, + emoji: "âŦ‡ī¸", data: { ...infoRowData, selectionIndex: i + 1, @@ -58,6 +60,7 @@ const buildBattleInfoActionRow = (battle, stateId, selectionIndex = 0) => { buttonConfigs.push({ label: "Refresh", disabled: false, + emoji: "🔄", data: { ...infoRowData, selectionIndex: i + 2, diff --git a/src/config/battleConfig.js b/src/config/battleConfig.js index b12df392..705ed92a 100644 --- a/src/config/battleConfig.js +++ b/src/config/battleConfig.js @@ -1086,7 +1086,7 @@ const effectConfig = Object.freeze({ dispellable: true, effectAdd(battle, _source, target) { battle.addToLog( - `${target.name} is restricted and cannot gain combat readiness!` + `${target.name} is restricted and cannot gain combat readiness via boosts!` ); target.restricted = true; }, @@ -6477,7 +6477,7 @@ const moveExecutes = { const pokemons = source.getPatternTargets( party, targetPatterns.ALL_EXCEPT_SELF, - 1 + source.position ); if (pokemons.length > 0) { const pokemon = pokemons[Math.floor(Math.random() * pokemons.length)]; @@ -7461,7 +7461,7 @@ const moveExecutes = { const pokemons = source.getPatternTargets( party, targetPatterns.ALL_EXCEPT_SELF, - 1 + source.position ); if (pokemons.length > 0) { const pokemon = pokemons.reduce((a, b) => @@ -10043,7 +10043,7 @@ const moveExecutes = { const pokemons = source.getPatternTargets( party, targetPatterns.ALL_EXCEPT_SELF, - 1 + source.position ); if (pokemons.length > 0) { const pokemon = pokemons[Math.floor(Math.random() * pokemons.length)]; @@ -11049,7 +11049,7 @@ const moveExecutes = { const pokemons = source.getPatternTargets( party, targetPatterns.ALL_EXCEPT_SELF, - 1 + source.position ); if (pokemons.length > 0) { const pokemon = pokemons[Math.floor(Math.random() * pokemons.length)]; diff --git a/src/config/questConfig.js b/src/config/questConfig.js index afa8f489..acda3438 100644 --- a/src/config/questConfig.js +++ b/src/config/questConfig.js @@ -63,9 +63,8 @@ const newTutorialConfigRaw = { buyPokeballs: { name: "Buying Pokeballs", emoji: emojis.POKEBALL, - description: - "One of the best ways to get more Pokeballs is to buy them from the Pokemart every day. Use `/pokemart` to buy Pokeballs, or use `/buy itemid: 0 quantity: 5` to buy the maximum amount.", - requirementString: "Buy 5x Pokeballs", + description: `One of the best ways to get more Pokeballs is to buy them from the Pokemart every day. Use \`/pokemart\` to buy ${emojis.POKEBALL} Pokeballs, or use \`/buy itemid: 0 quantity: 5\` to buy the maximum amount.`, + requirementString: `Buy 5x ${emojis.POKEBALL} Pokeballs`, proceedString: "Use `/pokemart` or `/buy itemid: 0 quantity: 5` to buy 5 Pokeballs!", checkRequirements: async (trainer) => @@ -160,7 +159,7 @@ const newTutorialConfigRaw = { name: "Learning to Battle: Taking Turns", emoji: "➡ī¸", description: - "Before you battle, you must learn how they work! Battles in Pokestar are unique; all 6 Pokemon fight at a time!\n\n**Taking turns is based off combat readiness.** Pokemon with higher speed gain combat readiness faster. The current active Pokemon is highlighted in asterisks.\n\nYou may view the combat readiness of a team by clicking the **NPC** or **Player** tabs. There is also an indicator of which Pokemon moves next.", + "Before you battle, you must learn how they work! Battles in Pokestar are unique; **all 6 Pokemon fight at a time!**\n\n**Taking turns is based off combat readiness.** Pokemon with higher speed gain combat readiness faster. The current active Pokemon is highlighted in asterisks.\n\nYou may view the combat readiness of a team by clicking the **🔴 NPC** or **đŸ”ĩ Player** tabs. There is also an indicator of which Pokemon moves next.", requirementString: "Complete the previous stage", proceedString: "Read the description to learn about battles, and complete the previous stage.", @@ -176,7 +175,7 @@ const newTutorialConfigRaw = { name: "Learning to Battle: Using Moves", emoji: "đŸ”Ĩ", description: - "When its your turn, you can use a move! **Use the dropdown menu to select a move.**\n\nWhen selecting a move, a description will appear. This includes important move information such as its type, power, effect, and cooldown.", + "When its your turn, you can use a move! **Use the dropdown menu to select a move.**\n\nWhen selecting a move, a description will appear. This includes important move information such as its **type, power, effect, and cooldown.**", requirementString: "Complete the previous stage", proceedString: "Read the description to learn about battles, and complete the previous stage.", @@ -193,7 +192,7 @@ const newTutorialConfigRaw = { emoji: "đŸŽ¯", description: "When using a move, you must select a target! **Click on the Pokemon you want to target from the dropdown menu.** Most moves may only target certain Pokemon, which is indicated in the **Target:** section of the move description." + - "\n\n**Some moves may affect an area of Pokemon.** When a target is selected, that area is indicated by a wider border. **When satisfied, click the confirm button to use the move.** This can be disabled in your `/settings`." + + "\n\n**Some moves may affect an area of Pokemon.** When a target is selected, that area is indicated by a wider border. **When satisfied, click the ⚔ī¸ Confirm button to use the move.** This can be disabled in your `/settings`." + "\n\nFor more detailed battle mechanics, check out the [documentation on Github](https://github.com/ewei068/pokestar?tab=readme-ov-file#-battle-mechanics).", requirementString: "Complete the previous stage", proceedString: @@ -210,7 +209,7 @@ const newTutorialConfigRaw = { name: "Battle an NPC!", emoji: "⚔ī¸", description: - "Now that you know how to battle, **use `/pve` to battle an NPC!** I'd recommend starting with the Bug Catcher on Very Easy difficulty.", + "Now that you know how to battle, **use `/pve` to battle an NPC!** I'd recommend starting with the <:bugcatcher:1117871382399815812> Bug Catcher on Very Easy difficulty.", requirementString: "Win any NPC Battle", proceedString: "Use `/pve` to battle an NPC!", checkRequirements: async (trainer) => @@ -263,15 +262,15 @@ const newTutorialConfigRaw = { name: "Training Pokemon", emoji: "🏋ī¸", description: - "One way to give your Pokemon more EXP is by training! Copy a Pokemon's ID, then **Use `/train` to train a Pokemon to level 8.**", - requirementString: "Have 1x Pokemon at level 8 or higher", - proceedString: "Use `/train` to train a Pokemon to level 8.", + "One way to give your Pokemon more EXP is by training! Copy a Pokemon's ID, then **Use `/train` to train a Pokemon to level 12.**\n\nTip: On desktop, you can use the Up Arrow key to use your last command.", + requirementString: "Have 1x Pokemon at level 12 or higher", + proceedString: "Use `/train` to train a Pokemon to level 12.", checkRequirements: async (trainer) => { const { data: pokemons } = await listPokemons(trainer, { pageSize: 1, page: 1, filter: { - level: { $gte: 8 }, + level: { $gte: 12 }, }, }); return (pokemons?.length ?? 0) > 0; @@ -338,8 +337,8 @@ const newTutorialConfigRaw = { name: "Purchasing Locations", emoji: "🌍", description: - "You may purchase and upgrade locations from the `/pokemart` for permanent boosts! **Use `/pokemart` to purchase the Home location and upgrade it to level 3.** The Home locations improves the EXP gained from `/train`!\n\nYou can use `/locations` to view your locations.", - requirementString: "Have a level 3 Home location", + "You may purchase and upgrade locations from the `/pokemart` for permanent boosts! **Use `/pokemart` to purchase the 🏠 Home location and upgrade it to level 3.** The Home locations improves the EXP gained from `/train`!\n\nYou can use `/locations` to view your locations.", + requirementString: "Have a level 3 🏠 Home location", proceedString: "Use `/pokemart` to purchase the Home location and upgrade it to level 3!", checkRequirements: async (trainer) => @@ -358,7 +357,7 @@ const newTutorialConfigRaw = { name: "Beginner Pokemon Leveling", emoji: "📈", description: - "Now that you can `/train` your Pokemon faster, **train 6 Pokemon to level 15.**", + "Now that you can `/train` your Pokemon faster, **train 6 Pokemon to level 15.**\n\nTip: On desktop, you can use the Up Arrow key to use your last command.", requirementString: "Train 6x Pokemon to level 15", proceedString: "Use `/train` to train 6 Pokemon to level 15!", checkRequirements: async (trainer) => { diff --git a/src/config/trainerConfig.js b/src/config/trainerConfig.js index d131b664..62683e82 100644 --- a/src/config/trainerConfig.js +++ b/src/config/trainerConfig.js @@ -1566,8 +1566,8 @@ const levelConfig = { const getTrainerLevelExp = (level) => 50 * (level ** 2 - level); const expMultiplier = (level) => - // 3 * x ^ (1/2) - 3 * level ** (1 / 2); + // 2.5 * x ^ (1/2) + 15 + 2.5 * level ** (1 / 2) + 15; const NUM_DAILY_REWARDS = process.env.STAGE === stageNames.ALPHA ? 100 : 5; const NUM_DAILY_SHARDS = process.env.STAGE === stageNames.ALPHA ? 100 : 5; diff --git a/src/deact/deact.js b/src/deact/deact.js index d321577a..99ad0206 100644 --- a/src/deact/deact.js +++ b/src/deact/deact.js @@ -94,6 +94,23 @@ const createElement = ( isDeactCreateElement: true, }); +/** + * TODO: hacky? should I be keeping track of interaction rather than message ref? + * @param {DeactInstance} rootInstance + */ +const forceUpdate = async (rootInstance) => { + const renderedElement = await rootInstance.renderCurrentElement(); + if ( + !renderedElement.messageRef || + !renderedElement.element || + renderedElement.err + ) { + return; + } + + await renderedElement.messageRef.edit(renderedElement.element); +}; + // TODO: figure out how to remove ref parameter /** @@ -379,7 +396,6 @@ function useEffect(callback, deps, ref) { } cleanupRef.current = cleanup; } - return cleanupRef.current; } /** @@ -389,8 +405,16 @@ function useEffect(callback, deps, ref) { * @returns {Promise<(() => void) | void>} */ async function useAwaitedEffect(callback, deps, ref) { - const promise = await useEffect(callback, deps, ref); - return await promise; + // TODO: can't seem to use useEffect because we have to await the cleanup callback + const cleanupRef = useRef(null, ref); + const haveDepsChanged = useCompareAndSetDeps(deps, ref); + if (haveDepsChanged || !ref.finishedMounting) { + const cleanup = await callback(); + if (cleanupRef.current) { + await cleanupRef.current(); + } + cleanupRef.current = cleanup; + } } /** @@ -408,6 +432,7 @@ module.exports = { userTypeEnum, createRoot, createElement, + forceUpdate, createModal, triggerBoundCallback, makeComponentIdWithStateId, diff --git a/src/elements/pokemon/PokemonList.js b/src/elements/pokemon/PokemonList.js index 2c823383..28c3a099 100644 --- a/src/elements/pokemon/PokemonList.js +++ b/src/elements/pokemon/PokemonList.js @@ -354,12 +354,14 @@ module.exports = async ( [ createElement(Button, { emoji: "⚙ī¸", + label: "Filter / Sort", callbackBindingKey: settingsActionBinding, style: filtersShown ? ButtonStyle.Primary : ButtonStyle.Secondary, data: {}, }), createElement(Button, { emoji: "❌", + label: "Clear Filters", callbackBindingKey: clearFiltersActionBinding, style: ButtonStyle.Secondary, data: {}, diff --git a/src/elements/quest/TutorialStage.js b/src/elements/quest/TutorialStage.js index e1f73dd4..04762e63 100644 --- a/src/elements/quest/TutorialStage.js +++ b/src/elements/quest/TutorialStage.js @@ -3,6 +3,9 @@ const { useCallbackBinding, createElement, useAwaitedMemo, + useAwaitedEffect, + useCallback, + forceUpdate, } = require("../../deact/deact"); const ReturnButton = require("../foundation/ReturnButton"); const useSingleItemScroll = require("../../hooks/useSingleItemScroll"); @@ -21,6 +24,9 @@ const { getRewardsString, flattenRewards, } = require("../../utils/trainerUtils"); +const { getTrainer } = require("../../services/trainer"); +const { getOrCreateState, getState } = require("../../services/state"); +const { generateTutorialStateId } = require("../../utils/questUtils"); /** * @param {DeactElement} ref @@ -110,6 +116,34 @@ module.exports = async ( style: ButtonStyle.Primary, }; + const refreshTrainer = useCallback( + async () => { + await getTrainer(user).then((getTrainerRes) => { + if (!getTrainerRes.err && getTrainerRes.data) { + setTrainer?.(getTrainerRes.data); + } + }); + }, + [user, setTrainer], + ref + ); + // keep updated on every render + const tutorialState = getOrCreateState(generateTutorialStateId(user.id), { + ttl: 120, + }); + tutorialState.currentStage = currentStage; + tutorialState.rootStateId = ref.rootInstance.stateId; + tutorialState.refreshTutorialState = async () => { + if (!getState(ref.rootInstance.stateId)) { + return; + } + await refreshTrainer(); + await forceUpdate(ref.rootInstance); + }; + tutorialState.messageRef = ref.rootInstance.messageRef; + + await useAwaitedEffect(refreshTrainer, [refreshTrainer, currentStage], ref); + const hasMetRequirements = await useAwaitedMemo( async () => { if (trainer.tutorialData.completedTutorialStages[currentStage]) { diff --git a/src/embeds/battleEmbeds.js b/src/embeds/battleEmbeds.js index e57e2aa5..5fb6a413 100644 --- a/src/embeds/battleEmbeds.js +++ b/src/embeds/battleEmbeds.js @@ -15,6 +15,7 @@ const { buildDungeonDifficultyString, buildCompactPartyString, buildRaidDifficultyString, + buildActivePokemonFieldStrings, } = require("../utils/battleUtils"); const { buildPokemonStatString, @@ -195,7 +196,6 @@ const buildBattleEmbed = ( const embed = new EmbedBuilder(); embed.setTitle(`Battle State`); embed.setColor(0xffffff); - embed.setDescription(battle.log[battle.log.length - 1] || "No log yet."); // build weather field const negatedString = battle.isWeatherNegated() ? " (negated)" : ""; if (battle.weather.weatherId) { @@ -233,6 +233,17 @@ const buildBattleEmbed = ( } } + if (battle.activePokemon) { + const { pokemonHeader, pokemonString } = buildActivePokemonFieldStrings( + battle.activePokemon + ); + embed.addFields({ + name: pokemonHeader, + value: pokemonString, + inline: false, + }); + } + const fields = [ { name: `${team1.emoji} ${team1.name} | ${team1UserString}`, diff --git a/src/embeds/pokemonEmbeds.js b/src/embeds/pokemonEmbeds.js index d4095632..c828093a 100644 --- a/src/embeds/pokemonEmbeds.js +++ b/src/embeds/pokemonEmbeds.js @@ -33,6 +33,7 @@ const { buildBoostString, getMoveIds, buildCompactEquipmentString, + buildSpeciesEvolutionString, } = require("../utils/pokemonUtils"); const { buildMoveString } = require("../utils/battleUtils"); const { @@ -526,7 +527,7 @@ const buildEquipmentEmbed = (pokemon, oldPokemon) => { * @param {EquipmentTypeEnum} equipmentType * @param {Equipment} equipment * @param {boolean=} upgrade - * @param {boolean=} slotReroll + * @param {(boolean | string)=} slotReroll * @returns {EmbedBuilder} */ const buildEquipmentUpgradeEmbed = ( @@ -786,20 +787,7 @@ const buildSpeciesDexEmbed = (id, speciesData, tab, ownershipData) => { embed.setImage(speciesData.sprite); } else if (tab === "growth") { // display: growth rate, base stats, total, evolutions - let evolutionString = ""; - if (speciesData.evolution) { - for (let i = 0; i < speciesData.evolution.length; i += 1) { - const evolution = speciesData.evolution[i]; - evolutionString += `Lv. ${evolution.level}: #${evolution.id} ${ - pokemonConfig[evolution.id].name - }`; - if (i < speciesData.evolution.length - 1) { - evolutionString += "\n"; - } - } - } else { - evolutionString = "No evolutions!"; - } + const evolutionString = buildSpeciesEvolutionString(speciesData); embed.setDescription(`Growth information for #${id} ${speciesData.name}:`); embed.addFields( diff --git a/src/enums/emojis.js b/src/enums/emojis.js index 8afc7ce9..2fd18a60 100644 --- a/src/enums/emojis.js +++ b/src/enums/emojis.js @@ -9,6 +9,8 @@ const emojis = { GREATBALL: "<:greatball:1100296107759779840>", ULTRABALL: "<:ultraball:1100296166555521035>", MASTERBALL: "<:masterball:1100296005041262612>", + + POWER_WEIGHT: "<:powerweight:1112557998234148874>", }; module.exports = { diff --git a/src/events/pokemon/pokemonActionButton.js b/src/events/pokemon/pokemonActionButton.js index b95542f3..cd40df05 100644 --- a/src/events/pokemon/pokemonActionButton.js +++ b/src/events/pokemon/pokemonActionButton.js @@ -11,7 +11,25 @@ const pokemonActionButton = async (interaction, data) => { if (err) { return { err }; } - await interaction.update(send); + await interaction + .update(send) + .then(() => { + if (action === "train") { + return interaction.followUp({ + content: + "To train this pokemon, copy the following command. For Mobile users, Long Press -> Copy Text.", + ephemeral: true, + }); + } + }) + .then(() => { + if (action === "train") { + return interaction.followUp({ + content: `/train pokemonid: ${data.id}`, + ephemeral: true, + }); + } + }); }; module.exports = pokemonActionButton; diff --git a/src/handlers/commandHandler.js b/src/handlers/commandHandler.js index 1d487e72..b6a8a19a 100644 --- a/src/handlers/commandHandler.js +++ b/src/handlers/commandHandler.js @@ -24,7 +24,10 @@ const { removeInteractionInstance } = require("../deact/interactions"); const { hasUserMetCurrentTutorialStageRequirements, } = require("../services/quest"); -const { sendUpsells } = require("../services/misc"); +const { + sendUpsells, + getPreInteractionUpsellData, +} = require("../services/misc"); const { prefix } = stageConfig[process.env.STAGE]; @@ -253,8 +256,12 @@ const runMessageCommand = async (message, client) => { // execute command try { // TODO: global trainer context per-interaction? seems like it would be a good idea TBH. Only issue is keeping it up-to-date - const hasCompletedCurrentTutorialStage = - await hasUserMetCurrentTutorialStageRequirements(message.author); + const preInteractionUpsellData = await getPreInteractionUpsellData({ + user: message.author, + }).catch((e) => { + logger.error(e); + return {}; + }); // eslint-disable-next-line no-param-reassign message.content = message.content.split(" ").slice(1).join(" "); const res = await messageCommands[command](message, client); @@ -277,7 +284,9 @@ const runMessageCommand = async (message, client) => { await sendUpsells({ interaction: message, user: message.author, - hasCompletedCurrentTutorialStage, + preInteractionUpsellData, + }).catch((e) => { + logger.error(e); }); } catch (error) { logger.error(error); @@ -327,8 +336,12 @@ const runSlashCommand = async (interaction, client) => { // execute command try { - const hasCompletedCurrentTutorialStage = - await hasUserMetCurrentTutorialStageRequirements(interaction.user); + const preInteractionUpsellData = await getPreInteractionUpsellData({ + user: interaction.user, + }).catch((e) => { + logger.error(e); + return {}; + }); const commandData = commandLookup[`${command}`]; const res = await slashCommands[command](interaction, client); @@ -352,7 +365,9 @@ const runSlashCommand = async (interaction, client) => { await sendUpsells({ interaction, user: interaction.user, - hasCompletedCurrentTutorialStage, + preInteractionUpsellData, + }).catch((e) => { + logger.error(e); }); } catch (error) { logger.error(error); diff --git a/src/handlers/eventHandler.js b/src/handlers/eventHandler.js index 4b97d932..c8583b14 100644 --- a/src/handlers/eventHandler.js +++ b/src/handlers/eventHandler.js @@ -14,7 +14,10 @@ const { attemptToReply } = require("../utils/utils"); const { hasUserMetCurrentTutorialStageRequirements, } = require("../services/quest"); -const { sendUpsells } = require("../services/misc"); +const { + sendUpsells, + getPreInteractionUpsellData, +} = require("../services/misc"); const eventHandlers = {}; const eventsDirectory = path.join(__dirname, "../events"); @@ -46,8 +49,12 @@ const handleEvent = async (interaction, client) => { // execute event try { - const hasCompletedCurrentTutorialStage = - await hasUserMetCurrentTutorialStageRequirements(interaction.user); + const preInteractionUpsellData = await getPreInteractionUpsellData({ + user: interaction.user, + }).catch((e) => { + logger.error(e); + return {}; + }); let res; if (data.dSID) { res = await triggerBoundCallback(interaction, data); @@ -86,7 +93,9 @@ const handleEvent = async (interaction, client) => { await sendUpsells({ interaction, user: interaction.user, - hasCompletedCurrentTutorialStage, + preInteractionUpsellData, + }).catch((e) => { + logger.error(e); }); } catch (error) { logger.error(`Error executing event ${eventName}`); diff --git a/src/services/misc.js b/src/services/misc.js index 2de64a56..7e879047 100644 --- a/src/services/misc.js +++ b/src/services/misc.js @@ -2,28 +2,74 @@ const { newTutorialStages } = require("../config/questConfig"); const { timeEnum, upsellEnum } = require("../enums/miscEnums"); +const { generateTutorialStateId } = require("../utils/questUtils"); const { attemptToReply } = require("../utils/utils"); const { hasTrainerMetCurrentTutorialStageRequirements, hasTrainerMetTutorialStageRequirements, } = require("./quest"); +const { getState, deleteState } = require("./state"); const { getTrainer, updateTrainer } = require("./trainer"); const TUTORIAL_UPSELL_TIME_1 = timeEnum.DAY; const TUTORIAL_UPSELL_TIME_2 = 3 * timeEnum.DAY; +/** + * @param {object} param0 + * @param {DiscordUser} param0.user + * @returns {Promise<{ + * hasCompletedCurrentTutorialStage?: boolean, + * hasCompletedCurrentTutorialStateStage?: boolean, + * currentTutorialStateStage?: TutorialStageEnum, + * }>} + */ +const getPreInteractionUpsellData = async ({ user }) => { + const { data: trainer, err } = await getTrainer(user); + if (err || !trainer) { + return {}; + } + + const hasCompletedCurrentTutorialStage = + await hasTrainerMetCurrentTutorialStageRequirements(trainer); + + const tutorialStateId = generateTutorialStateId(user.id); + const tutorialState = getState(tutorialStateId); + let currentTutorialStateStage; + let hasCompletedCurrentTutorialStateStage = false; + if (tutorialState) { + const rootState = getState(tutorialState.rootStateId); + if (!rootState) { + deleteState(tutorialStateId); // delete state if root state is missing and proceed + } else { + currentTutorialStateStage = tutorialState.currentStage; + hasCompletedCurrentTutorialStateStage = + await hasTrainerMetTutorialStageRequirements( + trainer, + currentTutorialStateStage + ); + } + } + + return { + hasCompletedCurrentTutorialStage, // has completed current stage trainer is "on" + hasCompletedCurrentTutorialStateStage, // has completed current stage trainer has open in /tutorial + currentTutorialStateStage, + }; +}; + /** * Decides the upsells to send to the user, then sends them * @param {object} param0 * @param {any} param0.interaction * @param {DiscordUser} param0.user - * @param {boolean=} param0.hasCompletedCurrentTutorialStage + * @param {Awaited>} param0.preInteractionUpsellData */ const sendUpsells = async ({ interaction, user, - hasCompletedCurrentTutorialStage = true, + preInteractionUpsellData = {}, }) => { + const { hasCompletedCurrentTutorialStage } = preInteractionUpsellData; const { data: trainer, err } = await getTrainer(user); if (err || !trainer) { return; @@ -31,9 +77,45 @@ const sendUpsells = async ({ const { upsellData } = trainer; const currentTime = Date.now(); - // tutorial upsell + // tutorial completion upsell const { lastSeen = 0, timesSeen = 0 } = upsellData[upsellEnum.TUTORIAL_UPSELL] || {}; + const tutorialStateId = generateTutorialStateId(user.id); + const tutorialState = getState(tutorialStateId); + const shouldShowUpsellBasedOnTutorialStateStage = + tutorialState?.currentStage && + tutorialState.currentStage === + preInteractionUpsellData.currentTutorialStateStage && + !preInteractionUpsellData.hasCompletedCurrentTutorialStateStage && + (await hasTrainerMetTutorialStageRequirements( + trainer, + tutorialState.currentStage + )); + if ( + shouldShowUpsellBasedOnTutorialStateStage || + (!hasCompletedCurrentTutorialStage && + (await hasTrainerMetCurrentTutorialStageRequirements(trainer))) + ) { + // attempt to update tutorial state + if (tutorialState?.refreshTutorialState) { + await tutorialState.refreshTutorialState(); + } + + const replyString = tutorialState?.messageRef + ? "**Press the replied-to message to return to the tutorial,** or " + : ""; + + // skip if haven't yet seen first tutorial upsell + if (timesSeen !== 0) { + await attemptToReply( + tutorialState?.messageRef || interaction, + `You have completed a tutorial stage! ${replyString}Use \`/tutorial\` to claim your rewards.` + ); + return; + } + } + + // tutorial upsell let shouldShowTutorialUpsell = false; let shouldComputeTutorialUpsell = false; if (timesSeen === 0) { @@ -45,7 +127,7 @@ const sendUpsells = async ({ ) { shouldComputeTutorialUpsell = true; } else if ( - timesSeen < 3 && + timesSeen < 4 && currentTime - lastSeen >= TUTORIAL_UPSELL_TIME_2 ) { shouldComputeTutorialUpsell = true; @@ -87,26 +169,15 @@ const sendUpsells = async ({ // update upsell data trainer.upsellData[upsellEnum.TUTORIAL_UPSELL] = { lastSeen: currentTime, // update last seen so we don't constantly recompute tutorial completions - timesSeen: shouldComputeTutorialUpsell ? timesSeen + 1 : timesSeen, + timesSeen: shouldShowTutorialUpsell ? timesSeen + 1 : timesSeen, }; // update trainer await updateTrainer(trainer); - return; - } - - // tutorial completion upsell - if ( - !hasCompletedCurrentTutorialStage && - (await hasTrainerMetCurrentTutorialStageRequirements(trainer)) - ) { - await attemptToReply( - interaction, - `You have completed a tutorial stage! Use \`/tutorial\` to claim your rewards.` - ); } }; module.exports = { + getPreInteractionUpsellData, sendUpsells, }; diff --git a/src/services/pokemon.js b/src/services/pokemon.js index 8dd72795..3b3a10eb 100644 --- a/src/services/pokemon.js +++ b/src/services/pokemon.js @@ -76,6 +76,7 @@ const { partyAddRow } = require("../components/partyAddRow"); const { buildPokemonSelectRow } = require("../components/pokemonSelectRow"); const { buildEquipmentSelectRow } = require("../components/equipmentSelectRow"); const { stageNames } = require("../config/stageConfig"); +const { emojis } = require("../enums/emojis"); // TODO: move this? const PAGE_SIZE = 10; @@ -992,7 +993,6 @@ const buildPokemonAllInfoSend = async ({ * @param {string?=} param0.pokemonId * @param {string?=} param0.tab * @param {string?=} param0.action - * @returns */ const buildPokemonInfoSend = async ({ user = null, @@ -1045,16 +1045,19 @@ const buildPokemonInfoSend = async ({ label: "Info", disabled: tab === "info", data: { id: pokemonId, tab: "info" }, + emoji: "ℹī¸", }, { - label: "Battle", + label: "Battle Info", disabled: tab === "battle", data: { id: pokemonId, tab: "battle" }, + emoji: "⚔ī¸", }, { label: "Equipment", disabled: tab === "equipment", data: { id: pokemonId, tab: "equipment" }, + emoji: emojis.POWER_WEIGHT, }, ]; const tabActionRow = buildButtonActionRow( @@ -1069,11 +1072,19 @@ const buildPokemonInfoSend = async ({ label: pokemon.data.locked ? "Unlock" : "Lock", disabled: false, data: { id: pokemonId, action: "lock" }, + emoji: pokemon.data.locked ? "🔓" : "🔒", }, { label: "Add to Party", disabled: false, data: { id: pokemonId, action: "add" }, + emoji: "➕", + }, + { + label: "Train", + disabled: false, + data: { id: pokemonId, action: "train" }, + emoji: "🏋ī¸", }, ]; const actionActionRow = buildButtonActionRow( diff --git a/src/services/shop.js b/src/services/shop.js index 4da7e127..ee11a310 100644 --- a/src/services/shop.js +++ b/src/services/shop.js @@ -39,6 +39,21 @@ const { getItems, } = require("../utils/trainerUtils"); +// map item id to location id +const itemIdToLocationId = { + [shopItems.HOME]: locations.HOME, + [shopItems.RESTAURANT]: locations.RESTAURANT, + [shopItems.GYM]: locations.GYM, + [shopItems.DOJO]: locations.DOJO, + [shopItems.TEMPLE]: locations.TEMPLE, + [shopItems.SCHOOL]: locations.SCHOOL, + [shopItems.TRACK]: locations.TRACK, + [shopItems.BERRY_BUSH]: locations.BERRY_BUSH, + [shopItems.BERRY_FARM]: locations.BERRY_FARM, + [shopItems.COMPUTER_LAB]: locations.COMPUTER_LAB, + [shopItems.ILEX_SHRINE]: locations.ILEX_SHRINE, +}; + /** * * @param {WithId} trainer @@ -93,21 +108,6 @@ const canBuyItem = (trainer, itemId, quantity) => { }; } - // map item id to location id - const itemIdToLocationId = { - [shopItems.HOME]: locations.HOME, - [shopItems.RESTAURANT]: locations.RESTAURANT, - [shopItems.GYM]: locations.GYM, - [shopItems.DOJO]: locations.DOJO, - [shopItems.TEMPLE]: locations.TEMPLE, - [shopItems.SCHOOL]: locations.SCHOOL, - [shopItems.TRACK]: locations.TRACK, - [shopItems.BERRY_BUSH]: locations.BERRY_BUSH, - [shopItems.BERRY_FARM]: locations.BERRY_FARM, - [shopItems.COMPUTER_LAB]: locations.COMPUTER_LAB, - [shopItems.ILEX_SHRINE]: locations.ILEX_SHRINE, - }; - const locationId = itemIdToLocationId[itemId]; const locationData = locationConfig[locationId]; @@ -225,21 +225,6 @@ const buyItem = async (trainer, itemId, quantity) => { }; } - // map item id to location id - const itemIdToLocationId = { - [shopItems.HOME]: locations.HOME, - [shopItems.RESTAURANT]: locations.RESTAURANT, - [shopItems.GYM]: locations.GYM, - [shopItems.DOJO]: locations.DOJO, - [shopItems.TEMPLE]: locations.TEMPLE, - [shopItems.SCHOOL]: locations.SCHOOL, - [shopItems.TRACK]: locations.TRACK, - [shopItems.BERRY_BUSH]: locations.BERRY_BUSH, - [shopItems.BERRY_FARM]: locations.BERRY_FARM, - [shopItems.COMPUTER_LAB]: locations.COMPUTER_LAB, - [shopItems.ILEX_SHRINE]: locations.ILEX_SHRINE, - }; - const locationId = itemIdToLocationId[itemId]; const locationData = locationConfig[locationId]; @@ -339,11 +324,11 @@ const buildShopSend = async ({ const state = getState(stateId); // get trainer - let trainer = await getTrainer(user); - if (trainer.err) { - return { send: null, err: trainer.err }; + const trainerRes = await getTrainer(user); + if (trainerRes.err) { + return { send: null, err: trainerRes.err }; } - trainer = trainer.data; + const trainer = trainerRes.data; const send = { embeds: [], @@ -393,6 +378,11 @@ const buildShopSend = async ({ const embed = buildShopItemEmbed(trainer, option); send.embeds.push(embed); + const isLocation = + shopItemConfig[option].category === shopCategories.LOCATIONS; + const locationId = itemIdToLocationId[option]; + const isUpgrade = isLocation && (trainer.locations?.[locationId] ?? 0) > 0; + const buttonData = { stateId, itemId: option, @@ -401,9 +391,10 @@ const buildShopSend = async ({ // create buy button const buttonConfigs = [ { - label: "Buy", + label: isUpgrade ? "Upgrade" : "Buy", disabled: canBuyItem(trainer, option, 1).err !== null, data: buttonData, + emoji: shopItemConfig[option].emoji, }, ]; // get max quantity trainer can buy, up to 10 @@ -419,6 +410,7 @@ const buildShopSend = async ({ label: `Buy ${maxQuantity}`, disabled: false, data: { ...buttonData, quantity: maxQuantity }, + emoji: shopItemConfig[option].emoji, }); } const buyButton = buildButtonActionRow(buttonConfigs, eventNames.SHOP_BUY); diff --git a/src/services/state.js b/src/services/state.js index 3567c006..e3b117f2 100644 --- a/src/services/state.js +++ b/src/services/state.js @@ -1,8 +1,6 @@ /** * @file * @author Elvis Wei - * @date 2023 - * @section Description * * state.js the base state logic for holding information for the current state of the user's interactions with the bot. */ @@ -29,9 +27,9 @@ const handleTimeout = (stateId) => { } }; -const setState = (state, ttl = 60) => { +const setState = (state, ttl = 60, stateIdOverride = undefined) => { // generate random state UUID - const stateId = shortid.generate(); + const stateId = stateIdOverride || shortid.generate(); // add state to states object states[stateId] = state; @@ -71,6 +69,15 @@ const getState = (stateId, refresh = true) => { return null; }; +const getOrCreateState = (stateId, { refresh = true, ttl = 60 } = {}) => { + let state = getState(stateId, refresh); + if (!state) { + state = {}; + setState(state, ttl, stateId); + } + return state; +}; + const deleteState = (stateId) => { // check if state exists if (stateId in states) { @@ -93,6 +100,7 @@ const setTtl = (stateId, ttl) => { module.exports = { setState, getState, + getOrCreateState, updateState, deleteState, getStateCount, diff --git a/src/utils/battleUtils.js b/src/utils/battleUtils.js index ffe4b201..6d81b0f3 100644 --- a/src/utils/battleUtils.js +++ b/src/utils/battleUtils.js @@ -318,12 +318,10 @@ const buildMoveString = (moveData, cooldown = 0) => { }; }; -/** - * @param {BattlePokemon} pokemon - * @returns {{pokemonHeader: string, pokemonString: string}} - */ -const buildBattlePokemonString = (pokemon) => { - let pokemonHeader = ""; +const buildBattlePokemonHeader = (pokemon) => { + let pokemonHeader = `${pokemon.shiny ? "✨ " : ""}${ + pokemonConfig[pokemon.speciesId].emoji + } `; if (pokemon.isFainted) { pokemonHeader += "[FNT] "; } else { @@ -351,21 +349,23 @@ const buildBattlePokemonString = (pokemon) => { } } - pokemonHeader += `[${pokemon.position}] [Lv. ${pokemon.level}] ${pokemon.name}`; - let pokemonString = ""; - // build hp percent string + pokemonHeader += `${pokemon.name}`; + return pokemonHeader; +}; + +const buildBattlePokemonInfoString = (pokemon) => + `Position: ${pokemon.position} â€ĸ Lv. ${pokemon.level}`; + +const buildBattlePokemonHpString = (pokemon, barWidth = 10) => { const hpPercent = Math.min( Math.round(Math.floor((pokemon.hp * 100) / pokemon.maxHp)), 100 ); - pokemonString += `**HP** ${getPBar(hpPercent, 10)} ${hpPercent}\n`; - // build cr string - const crPercent = Math.min( - Math.round(Math.floor((pokemon.combatReadiness * 100) / 100)), - 100 - ); - pokemonString += `**CR** ${getPBar(crPercent, 10)} ${crPercent}\n`; - // build effects string + return `**HP** ${getPBar(hpPercent, barWidth)} ${hpPercent}`; +}; + +const buildBattlePokemonEffectsString = (pokemon) => { + let pokemonString = ""; if (Object.keys(pokemon.effectIds).length > 0) { pokemonString += `**Effects:** ${Object.keys(pokemon.effectIds) .map((effectId) => { @@ -386,6 +386,42 @@ const buildBattlePokemonString = (pokemon) => { } else { pokemonString += `**Effects:** None`; } + return pokemonString; +}; + +/** + * @param {BattlePokemon} pokemon + */ +const buildActivePokemonFieldStrings = (pokemon) => { + const pokemonHeader = buildBattlePokemonHeader(pokemon); + let pokemonString = ""; + pokemonString += `${buildBattlePokemonInfoString(pokemon)}\n`; + pokemonString += `${buildBattlePokemonHpString(pokemon, 15)}\n`; + pokemonString += `${buildBattlePokemonEffectsString(pokemon)}`; + return { + pokemonHeader, + pokemonString, + }; +}; + +/** + * @param {BattlePokemon} pokemon + * @returns {{pokemonHeader: string, pokemonString: string}} + */ +const buildBattlePokemonString = (pokemon) => { + const pokemonHeader = buildBattlePokemonHeader(pokemon); + let pokemonString = ""; + pokemonString += `${buildBattlePokemonInfoString(pokemon)}\n`; + // build hp percent string + pokemonString += `${buildBattlePokemonHpString(pokemon, 10)}\n`; + // build cr string + const crPercent = Math.min( + Math.round(Math.floor((pokemon.combatReadiness * 100) / 100)), + 100 + ); + pokemonString += `**CR** ${getPBar(crPercent, 10)} ${crPercent}\n`; + // build effects string + pokemonString += buildBattlePokemonEffectsString(pokemon); return { pokemonHeader, @@ -627,6 +663,7 @@ module.exports = { buildPartyString, buildCompactPartyString, buildMoveString, + buildActivePokemonFieldStrings, buildBattlePokemonString, buildNpcDifficultyString, buildDungeonDifficultyString, diff --git a/src/utils/pokemonUtils.js b/src/utils/pokemonUtils.js index c5d7ac03..307038aa 100644 --- a/src/utils/pokemonUtils.js +++ b/src/utils/pokemonUtils.js @@ -391,6 +391,24 @@ const buildBoostString = (oldPokemon, newPokemon) => { return boostString; }; +const buildSpeciesEvolutionString = (speciesData) => { + let evolutionString = ""; + if (speciesData.evolution) { + for (let i = 0; i < speciesData.evolution.length; i += 1) { + const evolution = speciesData.evolution[i]; + evolutionString += `Lv. ${evolution.level}: #${evolution.id} ${ + pokemonConfig[evolution.id].name + }`; + if (i < speciesData.evolution.length - 1) { + evolutionString += "\n"; + } + } + } else { + evolutionString = "No evolutions!"; + } + return evolutionString; +}; + /** * @param {WithId} trainer * @returns {string[]} @@ -433,6 +451,7 @@ module.exports = { buildEquipmentString, buildCompactEquipmentString, buildBoostString, + buildSpeciesEvolutionString, getPartyPokemonIds, getMoveIds, }; diff --git a/src/utils/questUtils.js b/src/utils/questUtils.js new file mode 100644 index 00000000..a9753e24 --- /dev/null +++ b/src/utils/questUtils.js @@ -0,0 +1,5 @@ +const generateTutorialStateId = (userId) => `${userId}_tutorial_state`; + +module.exports = { + generateTutorialStateId, +};