const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } = require("discord.js"); const { getCollections, getNutBalance, changeNuts, getCooldown, claimCooldown, getUnixTime, randomInteger } = require("../../../utils/nutEconomy"); /* * ============================================================ * CONFIG * ============================================================ */ const asset_path = "./assets/Command_Nuts/"; const NUT_COOLDOWN = 60 * 60; /* * ============================================================ * DELAY * ============================================================ */ function delay(ms) { return new Promise( resolve => setTimeout( resolve, ms ) ); } /* * ============================================================ * GIVE * ============================================================ */ async function nutsGive( interaction ) { const from = interaction.user; const to = interaction.options.getUser( "target", true ); let amount = interaction.options.getNumber( "amount", true ); /* * Prevent self transfer. */ if ( from.id === to.id ) { return interaction.reply({ content: "Du kannst dir nicht selbst Nüsse geben!", flags: MessageFlags.Ephemeral }); } const [ yourBalance, theirBalance ] = await Promise.all([ getNutBalance( from.id ), getNutBalance( to.id ) ]); /* * Keep the existing behavior: * * amount <= 0 means give everything. */ if ( amount <= 0 ) { amount = yourBalance; return tradeWindow( interaction, to, amount, yourBalance, theirBalance, "nuts_give_fake", "abort" ); } if ( yourBalance < amount ) { return interaction.reply({ content: "Du kannst nicht mehr geben als du hast!", flags: MessageFlags.Ephemeral }); } return tradeWindow( interaction, to, amount, yourBalance, theirBalance, "nuts_give_confirm", "abort" ); } /* * ============================================================ * GIVE CONFIRMATION WINDOW * ============================================================ */ async function tradeWindow( interaction, to, amount, yourBalance, theirBalance, confirmButtonId, abortButtonId ) { const embed = new EmbedBuilder() .setTitle( "Gib Nuss!" ) .setDescription( `Willst du wirklich **${amount}** Nüsse an <@${to.id}> senden?` ) .addFields( { name: "Your Current Balance", value: `${yourBalance} (**-${amount}**)`, inline: true }, { name: "Your New Balance", value: `${yourBalance - amount}`, inline: true }, { name: "\u200B", value: "\u200B" }, { name: "Their Current Balance", value: `${theirBalance} (**+${amount}**)`, inline: true }, { name: "Their New Balance", value: `${theirBalance + amount}`, inline: true } ) .setThumbnail( "attachment://nuts_main.png" ) /* * Keep enough ownership/transfer state in * the footer for a future button cleanup. */ .setFooter({ text: `nutsGive:${interaction.user.id}:${to.id}:${amount}` }); const row = new ActionRowBuilder() .addComponents( new ButtonBuilder() .setLabel( "✔" ) .setCustomId( confirmButtonId ) .setStyle( ButtonStyle.Success ), new ButtonBuilder() .setLabel( "✖" ) .setCustomId( abortButtonId ) .setStyle( ButtonStyle.Danger ) ); return interaction.reply({ embeds: [ embed ], components: [ row ], files: [ `${asset_path}nuts_main.png` ], flags: MessageFlags.Ephemeral }); } /* * ============================================================ * CHECK * ============================================================ */ async function nutsCheck( interaction ) { let target = interaction.options.getUser( "target" ); let addressing; if (!target) { target = interaction.user; addressing = "Du hast"; } else if ( !target.globalName ) { addressing = `${target.username} hat`; } else { addressing = `${target.globalName} hat`; } const balance = await getNutBalance( target.id ); const content = balance <= 0 ? `${addressing} noch keine Nüsse gesammelt :(` : `${addressing} bereits **${balance}** Nüsse gesammelt!`; /* * Existing pseudo counting animation. */ let title = "Zähle Nüsse"; const embed = new EmbedBuilder(); for ( let i = 0; i < 4; i++ ) { embed.setTitle( title ); await interaction.editReply({ embeds: [ embed ] }); title += " ."; await delay( 500 ); } embed .setTitle( content ) .setThumbnail( "attachment://nuts_main.png" ); return interaction.editReply({ embeds: [ embed ], files: [ `${asset_path}nuts_main.png` ] }); } /* * ============================================================ * LEADERBOARD * ============================================================ */ async function nutsLeaderboard( interaction ) { const row = new ActionRowBuilder() .addComponents( new ButtonBuilder() .setLabel( "◄" ) .setStyle( ButtonStyle.Primary ) .setCustomId( "nuts_leaderboard_left" ), new ButtonBuilder() .setLabel( "🧑" ) .setStyle( ButtonStyle.Primary ) .setCustomId( "nuts_leaderboard_self" ), new ButtonBuilder() .setLabel( "►" ) .setStyle( ButtonStyle.Primary ) .setCustomId( "nuts_leaderboard_right" ) ); const { nuts } = getCollections(); const skip = 0; const nutsData = await nuts .find({ nuts: { $gt: 0 } }) .sort({ nuts: -1 }) .skip( skip ) .limit( 5 ) .toArray(); let fields = ""; let placement = skip + 1; for ( const data of nutsData ) { fields += `${placement}. <@${data.userId}> : ${data.nuts} Nuts\r\n`; placement++; } const embed = new EmbedBuilder() .setTitle( "Nuts Leaderboard" ) .setThumbnail( interaction.guild?.iconURL({ dynamic: true }) ) .setDescription( fields || "Noch keine Nüsse :(" ) .setColor( "#5865F2" ) .setFooter({ text: "Use ◄ ► to navigate" }); return interaction.editReply({ embeds: [ embed ], components: [ row ] }); } /* * ============================================================ * STATS * ============================================================ * * stats_nuts remains the only statistical collection * currently required. */ async function nutsStats( interaction ) { const { db } = getCollections(); const nutsStats = db.collection( "stats_nuts" ); const type = interaction.options.getString( "type" ) || "lookup"; let target = interaction.options.getUser( "lookup" ) || {}; if ( type === "global" ) { target.id = "__global"; } else if ( !target?.id ) { target = interaction.user; } const statsDoc = await nutsStats.findOne({ userId: target.id }); const stat = statsDoc?.stat || {}; const totalCount = Object.values( stat ) .reduce( ( a, b ) => a + b, 0 ); const totalNuts = Object.entries( stat ) .reduce( ( sum, [ number, count ] ) => sum + Number( number ) * count, 0 ); const rolledNumbers = Object.entries( stat ) .filter( ( [ , count ] ) => count > 0 ); let nutMin = { number: 0, count: 0 }; let nutMax = { number: 0, count: 0 }; if ( rolledNumbers.length > 0 ) { nutMin = { number: Number( rolledNumbers[0][0] ), count: rolledNumbers[0][1] }; nutMax = { ...nutMin }; for ( const [ numStr, count ] of rolledNumbers ) { const number = Number( numStr ); if ( count > nutMax.count || ( count === nutMax.count && number > nutMax.number ) ) { nutMax = { number, count }; } if ( count < nutMin.count || ( count === nutMin.count && number < nutMin.number ) ) { nutMin = { number, count }; } } } const nutAvg = totalCount ? totalNuts / totalCount : 0; const fields = Array.from( { length: 11 }, ( _, i ) => ({ name: `[${i}]`, value: `x${stat[i] ?? 0}`, inline: true }) ); const embed = new EmbedBuilder() .setTitle( "Nut Statistic" ) .setDescription( `Total Nut Actions: **${totalCount}**\r\n` + `Total Nuts nutted: **${totalNuts}**\r\n` + `Nut Average: **${nutAvg.toFixed(3)}**\r\n` + `Most Common Nut: **${nutMax.number} (x${nutMax.count})**\r\n` + `Least Common Nut: **${nutMin.number} (x${nutMin.count})**` ) .addFields( fields ) .setColor( 0x51267 ) .setTimestamp() .setThumbnail( "attachment://nuts_main.png" ); return interaction.editReply({ embeds: [ embed ], files: [ `${asset_path}nuts_main.png` ] }); } /* * ============================================================ * COOLDOWN * ============================================================ */ async function nutsCooldown( interaction ) { const cooldown = await getCooldown( interaction.user.id, "nut" ); const now = getUnixTime(); let content = "Du kannst wieder nussen! :)"; let thumbnail = "attachment://nuts_main.png"; let file = `${asset_path}nuts_main.png`; let title = "Go Nuts!"; if ( cooldown > now ) { content = ` kannst du wieder nussen! ;)`; thumbnail = "attachment://nuts_onCD.avif"; file = `${asset_path}nuts_onCD.avif`; title = "To Nut or Not to Nut..."; } const embed = new EmbedBuilder() .setThumbnail( thumbnail ) .setTitle( title ) .setDescription( content ); return interaction.editReply({ embeds: [ embed ], files: [ file ] }); } /* * ============================================================ * NUT * ============================================================ */ async function nutsNut( interaction, quickMode ) { const { db } = getCollections(); const nutsStats = db.collection( "stats_nuts" ); const embed = new EmbedBuilder(); const assets = { high: "attachment://nuts_high.jpg", normal: "attachment://nuts_normal.jpg", low: "attachment://nuts_low.jpg", none: "attachment://nuts_none.avif", onCD: "attachment://nuts_onCD.avif", box: "attachment://nuts_box.png", explosion: "attachment://nuts_explosion.gif" }; const formatNut = amount => `${amount === 1 ? "eine" : amount} ${amount === 1 ? "Nuss" : "Nüsse"}`; /* * ======================================================== * ATOMIC COOLDOWN CLAIM * ======================================================== * * This is better than: * * read cooldown * set cooldown * * because rapid simultaneous requests cannot both * successfully claim the action. */ const cooldown = await claimCooldown( interaction.user.id, "nut", NUT_COOLDOWN ); if ( !cooldown.claimed ) { embed .setTitle( "To Nut or Not to Nut..." ) .setDescription( ` kannst du wieder nussen! ;)` ) .setThumbnail( assets.onCD ); return interaction.editReply({ embeds: [ embed ], files: [ `${asset_path}nuts_onCD.avif` ] }); } /* * Preserve the current 0–9 roll behavior. */ const amount = randomInteger( 0, 9 ); /* * Balance. */ await changeNuts( interaction.user.id, amount ); /* * Individual Nut statistics. */ await nutsStats.updateOne( { userId: interaction.user.id }, { $inc: { [`stat.${amount}`]: 1 }, $setOnInsert: { userId: interaction.user.id } }, { upsert: true } ); /* * Global Nut statistics. */ await nutsStats.updateOne( { userId: "__global" }, { $inc: { [`stat.${amount}`]: 1 }, $setOnInsert: { userId: "__global" } }, { upsert: true } ); let nutText = `Du hast ${formatNut(amount)} bekommen! :chestnut:`; if ( amount <= 0 ) { nutText = "Du hast keine Nuss bekommen! :yoink_help:"; } /* * Quick mode preserves the existing plain text response. */ if (quickMode) { return interaction.editReply({ content: nutText }); } /* * ======================================================== * PSEUDO ANIMATION * ======================================================== */ embed.setImage( assets.box ); await interaction.editReply({ embeds: [ embed ], files: [ `${asset_path}nuts_box.png` ] }); await delay( 1337 ); embed.setImage( assets.explosion ); await interaction.editReply({ embeds: [ embed ], files: [ `${asset_path}nuts_explosion.gif` ] }); await delay( 1337 ); /* * Result asset. */ let assetFile; if ( amount === 0 ) { assetFile = assets.none; } else if ( amount > 8 ) { assetFile = assets.high; } else if ( amount > 4 ) { assetFile = assets.normal; } else { assetFile = assets.low; } embed .setTitle( nutText ) .setThumbnail( assetFile ) .setImage( null ); return interaction.editReply({ embeds: [ embed ], files: [ `${asset_path}${assetFile.split("://")[1]}` ] }); } /* * ============================================================ * COMMAND * ============================================================ */ module.exports = { data: new SlashCommandBuilder() .setName( "nuts" ) .setDescription( "rund um die nuss" ) /* * GIVE */ .addSubcommand(s => s .setName( "give" ) .setDescription( "gib nuss!!" ) .addUserOption(o => o .setName( "target" ) .setDescription( "wer bekommt da nuts?" ) .setRequired( true ) ) .addNumberOption(o => o .setName( "amount" ) .setDescription( "wie viele?" ) .setRequired( true ) ) ) /* * CHECK */ .addSubcommand(s => s .setName( "check" ) .setDescription( "check out deez nuts" ) .addUserOption(o => o .setName( "target" ) .setDescription( "check out those nuts!" ) ) ) /* * LEADERBOARD */ .addSubcommand(s => s .setName( "leaderboard" ) .setDescription( "wer hat die dicksten Nüsse?" ) ) /* * STATS */ .addSubcommand(s => s .setName( "stats" ) .setDescription( "wie viele Nüsse wurden genusst, Genosse?" ) .addStringOption(o => o .setName( "type" ) .setDescription( "set type to global or user" ) .addChoices( { name: "global", value: "global" }, { name: "user", value: "user" } ) ) .addUserOption(o => o .setName( "lookup" ) .setDescription( "look up specific user nut statistic" ) ) ) /* * COOLDOWN */ .addSubcommand(s => s .setName( "cooldown" ) .setDescription( "wie lange bis ich nussen kann?" ) ) /* * NUT */ .addSubcommand(s => s .setName( "nut" ) .setDescription( "willst du nuss?" ) ) /* * QUICK */ .addSubcommand(s => s .setName( "quick" ) .setDescription( "A quick nut, mlord?" ) ), async execute( interaction ) { /* * Public Discord.js API. * * Avoid interaction.options._subcommand. */ const subcommand = interaction.options.getSubcommand(); switch ( subcommand ) { case "give": return nutsGive( interaction ); case "check": await interaction.deferReply(); return nutsCheck( interaction ); case "leaderboard": await interaction.deferReply(); return nutsLeaderboard( interaction ); case "stats": await interaction.deferReply(); return nutsStats( interaction ); case "cooldown": await interaction.deferReply(); return nutsCooldown( interaction ); case "nut": await interaction.deferReply(); return nutsNut( interaction, false ); case "quick": await interaction.deferReply(); return nutsNut( interaction, true ); } } };