diff --git a/buttons/nutHub/back.js b/buttons/nutHub/back.js new file mode 100644 index 0000000..01d19d7 --- /dev/null +++ b/buttons/nutHub/back.js @@ -0,0 +1,14 @@ +const { + showNutHub +} = require("../../utils/nutHub"); + + +module.exports = { + name: "nutHubBack", + + async execute(interaction) { + return showNutHub( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjack.js b/buttons/nutHub/blackjack.js new file mode 100644 index 0000000..a57236c --- /dev/null +++ b/buttons/nutHub/blackjack.js @@ -0,0 +1,19 @@ +const { + showNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutHubBlackjack", + + async execute( + interaction + ) { + return showNutBlackjack( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjackBet10.js b/buttons/nutHub/blackjackBet10.js new file mode 100644 index 0000000..ea9252d --- /dev/null +++ b/buttons/nutHub/blackjackBet10.js @@ -0,0 +1,20 @@ +const { + startNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutBlackjackBet10", + + async execute( + interaction + ) { + return startNutBlackjack( + interaction, + 10 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjackBet100.js b/buttons/nutHub/blackjackBet100.js new file mode 100644 index 0000000..9c1d697 --- /dev/null +++ b/buttons/nutHub/blackjackBet100.js @@ -0,0 +1,20 @@ +const { + startNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutBlackjackBet100", + + async execute( + interaction + ) { + return startNutBlackjack( + interaction, + 100 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjackBet1000.js b/buttons/nutHub/blackjackBet1000.js new file mode 100644 index 0000000..6a842e1 --- /dev/null +++ b/buttons/nutHub/blackjackBet1000.js @@ -0,0 +1,20 @@ +const { + startNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutBlackjackBet1000", + + async execute( + interaction + ) { + return startNutBlackjack( + interaction, + 1000 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjackBet50.js b/buttons/nutHub/blackjackBet50.js new file mode 100644 index 0000000..de05a47 --- /dev/null +++ b/buttons/nutHub/blackjackBet50.js @@ -0,0 +1,20 @@ +const { + startNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutBlackjackBet50", + + async execute( + interaction + ) { + return startNutBlackjack( + interaction, + 50 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjackBet500.js b/buttons/nutHub/blackjackBet500.js new file mode 100644 index 0000000..caa543d --- /dev/null +++ b/buttons/nutHub/blackjackBet500.js @@ -0,0 +1,20 @@ +const { + startNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutBlackjackBet500", + + async execute( + interaction + ) { + return startNutBlackjack( + interaction, + 500 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjackDouble.js b/buttons/nutHub/blackjackDouble.js new file mode 100644 index 0000000..e1a2d10 --- /dev/null +++ b/buttons/nutHub/blackjackDouble.js @@ -0,0 +1,19 @@ +const { + doubleNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutBlackjackDouble", + + async execute( + interaction + ) { + return doubleNutBlackjack( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjackHit.js b/buttons/nutHub/blackjackHit.js new file mode 100644 index 0000000..446c276 --- /dev/null +++ b/buttons/nutHub/blackjackHit.js @@ -0,0 +1,19 @@ +const { + hitNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutBlackjackHit", + + async execute( + interaction + ) { + return hitNutBlackjack( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/blackjackStand.js b/buttons/nutHub/blackjackStand.js new file mode 100644 index 0000000..5287c54 --- /dev/null +++ b/buttons/nutHub/blackjackStand.js @@ -0,0 +1,19 @@ +const { + standNutBlackjack +} = require( + "../../utils/nutBlackjack" +); + + +module.exports = { + name: + "nutBlackjackStand", + + async execute( + interaction + ) { + return standNutBlackjack( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/heist.js b/buttons/nutHub/heist.js new file mode 100644 index 0000000..1d65033 --- /dev/null +++ b/buttons/nutHub/heist.js @@ -0,0 +1,19 @@ +const { + showNutHeist +} = require( + "../../utils/nutHeist" +); + + +module.exports = { + name: + "nutHubHeist", + + async execute( + interaction + ) { + return showNutHeist( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/heistHack.js b/buttons/nutHub/heistHack.js new file mode 100644 index 0000000..6f9f434 --- /dev/null +++ b/buttons/nutHub/heistHack.js @@ -0,0 +1,22 @@ +// buttons/nutHub/heistHack.js + +const { + performNutHeist +} = require( + "../../utils/nutHeist" +); + + +module.exports = { + name: + "nutHeistHack", + + async execute( + interaction + ) { + return performNutHeist( + interaction, + "Hack" + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/heistSmash.js b/buttons/nutHub/heistSmash.js new file mode 100644 index 0000000..5963f90 --- /dev/null +++ b/buttons/nutHub/heistSmash.js @@ -0,0 +1,22 @@ +// buttons/nutHub/heistSmash.js + +const { + performNutHeist +} = require( + "../../utils/nutHeist" +); + + +module.exports = { + name: + "nutHeistSmash", + + async execute( + interaction + ) { + return performNutHeist( + interaction, + "Smash" + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/heistSneak.js b/buttons/nutHub/heistSneak.js new file mode 100644 index 0000000..1745807 --- /dev/null +++ b/buttons/nutHub/heistSneak.js @@ -0,0 +1,22 @@ +// buttons/nutHub/heistSneak.js + +const { + performNutHeist +} = require( + "../../utils/nutHeist" +); + + +module.exports = { + name: + "nutHeistSneak", + + async execute( + interaction + ) { + return performNutHeist( + interaction, + "Sneak" + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/roulette.js b/buttons/nutHub/roulette.js new file mode 100644 index 0000000..296eaa7 --- /dev/null +++ b/buttons/nutHub/roulette.js @@ -0,0 +1,21 @@ +// buttons/nutHub/roulette.js + +const { + showNutRoulette +} = require( + "../../utils/nutRoulette" +); + + +module.exports = { + name: + "nutHubRoulette", + + async execute( + interaction + ) { + return showNutRoulette( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteBet10.js b/buttons/nutHub/rouletteBet10.js new file mode 100644 index 0000000..a67ab38 --- /dev/null +++ b/buttons/nutHub/rouletteBet10.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteBet10.js +const { + chooseNutRouletteBet +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteBet10", + + async execute(interaction) { + return chooseNutRouletteBet( + interaction, + 10 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteBet100.js b/buttons/nutHub/rouletteBet100.js new file mode 100644 index 0000000..0d2f459 --- /dev/null +++ b/buttons/nutHub/rouletteBet100.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteBet100.js +const { + chooseNutRouletteBet +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteBet100", + + async execute(interaction) { + return chooseNutRouletteBet( + interaction, + 100 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteBet1000.js b/buttons/nutHub/rouletteBet1000.js new file mode 100644 index 0000000..c36a393 --- /dev/null +++ b/buttons/nutHub/rouletteBet1000.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteBet1000.js +const { + chooseNutRouletteBet +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteBet1000", + + async execute(interaction) { + return chooseNutRouletteBet( + interaction, + 1000 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteBet50.js b/buttons/nutHub/rouletteBet50.js new file mode 100644 index 0000000..3b67720 --- /dev/null +++ b/buttons/nutHub/rouletteBet50.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteBet50.js +const { + chooseNutRouletteBet +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteBet50", + + async execute(interaction) { + return chooseNutRouletteBet( + interaction, + 50 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteBet500.js b/buttons/nutHub/rouletteBet500.js new file mode 100644 index 0000000..bb61322 --- /dev/null +++ b/buttons/nutHub/rouletteBet500.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteBet500.js +const { + chooseNutRouletteBet +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteBet500", + + async execute(interaction) { + return chooseNutRouletteBet( + interaction, + 500 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteBlack.js b/buttons/nutHub/rouletteBlack.js new file mode 100644 index 0000000..d2ed12b --- /dev/null +++ b/buttons/nutHub/rouletteBlack.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteBlack.js +const { + playNutRoulette +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteBlack", + + async execute(interaction) { + return playNutRoulette( + interaction, + "Black" + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteEven.js b/buttons/nutHub/rouletteEven.js new file mode 100644 index 0000000..c26f2f9 --- /dev/null +++ b/buttons/nutHub/rouletteEven.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteEven.js +const { + playNutRoulette +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteEven", + + async execute(interaction) { + return playNutRoulette( + interaction, + "Even" + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteOdd.js b/buttons/nutHub/rouletteOdd.js new file mode 100644 index 0000000..d388b6b --- /dev/null +++ b/buttons/nutHub/rouletteOdd.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteOdd.js +const { + playNutRoulette +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteOdd", + + async execute(interaction) { + return playNutRoulette( + interaction, + "Odd" + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteRed.js b/buttons/nutHub/rouletteRed.js new file mode 100644 index 0000000..4f33639 --- /dev/null +++ b/buttons/nutHub/rouletteRed.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteRed.js +const { + playNutRoulette +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteRed", + + async execute(interaction) { + return playNutRoulette( + interaction, + "Red" + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/rouletteZero.js b/buttons/nutHub/rouletteZero.js new file mode 100644 index 0000000..3500331 --- /dev/null +++ b/buttons/nutHub/rouletteZero.js @@ -0,0 +1,15 @@ +// buttons/nutHub/rouletteZero.js +const { + playNutRoulette +} = require("../../utils/nutRoulette"); + +module.exports = { + name: "nutRouletteZero", + + async execute(interaction) { + return playNutRoulette( + interaction, + "Zero" + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/slotBet10.js b/buttons/nutHub/slotBet10.js new file mode 100644 index 0000000..525d690 --- /dev/null +++ b/buttons/nutHub/slotBet10.js @@ -0,0 +1,19 @@ +const { + playNutSlots +} = require( + "../../utils/nutSlots" +); + +module.exports = { + name: + "nutSlotsBet10", + + async execute( + interaction + ) { + return playNutSlots( + interaction, + 10 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/slotBet100.js b/buttons/nutHub/slotBet100.js new file mode 100644 index 0000000..b867ca5 --- /dev/null +++ b/buttons/nutHub/slotBet100.js @@ -0,0 +1,19 @@ +const { + playNutSlots +} = require( + "../../utils/nutSlots" +); + +module.exports = { + name: + "nutSlotsBet100", + + async execute( + interaction + ) { + return playNutSlots( + interaction, + 100 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/slotBet1000.js b/buttons/nutHub/slotBet1000.js new file mode 100644 index 0000000..1bb7d61 --- /dev/null +++ b/buttons/nutHub/slotBet1000.js @@ -0,0 +1,19 @@ +const { + playNutSlots +} = require( + "../../utils/nutSlots" +); + +module.exports = { + name: + "nutSlotsBet1000", + + async execute( + interaction + ) { + return playNutSlots( + interaction, + 1000 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/slotBet50.js b/buttons/nutHub/slotBet50.js new file mode 100644 index 0000000..3fd772e --- /dev/null +++ b/buttons/nutHub/slotBet50.js @@ -0,0 +1,19 @@ +const { + playNutSlots +} = require( + "../../utils/nutSlots" +); + +module.exports = { + name: + "nutSlotsBet50", + + async execute( + interaction + ) { + return playNutSlots( + interaction, + 50 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/slotBet500.js b/buttons/nutHub/slotBet500.js new file mode 100644 index 0000000..be6f36a --- /dev/null +++ b/buttons/nutHub/slotBet500.js @@ -0,0 +1,19 @@ +const { + playNutSlots +} = require( + "../../utils/nutSlots" +); + +module.exports = { + name: + "nutSlotsBet500", + + async execute( + interaction + ) { + return playNutSlots( + interaction, + 500 + ); + } +}; \ No newline at end of file diff --git a/buttons/nutHub/slots.js b/buttons/nutHub/slots.js new file mode 100644 index 0000000..9c246a7 --- /dev/null +++ b/buttons/nutHub/slots.js @@ -0,0 +1,21 @@ +// buttons/nutHub/slots.js + +const { + showNutSlots +} = require( + "../../utils/nutSlots" +); + + +module.exports = { + name: + "nutHubSlots", + + async execute( + interaction + ) { + return showNutSlots( + interaction + ); + } +}; \ No newline at end of file diff --git a/buttons/nuts/nuts_give_confirm.js b/buttons/nuts/nuts_give_confirm.js index e288ce9..34f87d4 100644 --- a/buttons/nuts/nuts_give_confirm.js +++ b/buttons/nuts/nuts_give_confirm.js @@ -13,11 +13,11 @@ module.exports = { const db = mClient.db(process.env.M_DB) const nutsColl = db.collection('items_nuts') - await nutsColl.findOneAndUpdate({ userID: from.id }, { $inc: { nuts: -amount } }, { upsert: true }) - await nutsColl.findOneAndUpdate({ userID: to.id }, { $inc: { nuts: +amount } }, { upsert: true }) + await nutsColl.findOneAndUpdate({ userId: from.id }, { $inc: { nuts: -amount } }, { upsert: true }) + await nutsColl.findOneAndUpdate({ userId: to.id }, { $inc: { nuts: +amount } }, { upsert: true }) - const yourBalance = await nutsColl.findOne({ userID: from.id }) - const theirBalance = await nutsColl.findOne({ userID: to.id }) + const yourBalance = await nutsColl.findOne({ userId: from.id }) + const theirBalance = await nutsColl.findOne({ userId: to.id }) const embed = new EmbedBuilder() .setTitle('Gib Nuss!') .setDescription(`Willst du wirklich **${amount}** Nüsse an <@${to.id}> senden?`) diff --git a/buttons/nuts/nuts_give_fake.js b/buttons/nuts/nuts_give_fake.js index 5226238..37439b3 100644 --- a/buttons/nuts/nuts_give_fake.js +++ b/buttons/nuts/nuts_give_fake.js @@ -12,8 +12,8 @@ module.exports = { const db = mClient.db(process.env.M_DB) const nutsColl = db.collection('items_nuts') - const yourBalance = await nutsColl.findOne({ userID: from.id }) - const theirBalance = await nutsColl.findOne({ userID: to.id }) + const yourBalance = await nutsColl.findOne({ userId: from.id }) + const theirBalance = await nutsColl.findOne({ userId: to.id }) const embed = new EmbedBuilder() .setTitle('Gib Nuss!') diff --git a/buttons/nuts/nuts_leaderboard_left.js b/buttons/nuts/nuts_leaderboard_left.js index 830300b..aecc42c 100644 --- a/buttons/nuts/nuts_leaderboard_left.js +++ b/buttons/nuts/nuts_leaderboard_left.js @@ -19,7 +19,7 @@ module.exports = { let fields nutsData.forEach((data) => { skip++ - fields = (fields ? fields : '') + (`${skip}. <@${data.userID}> : ${data.nuts} Nuts\r\n`) + fields = (fields ? fields : '') + (`${skip}. <@${data.userId}> : ${data.nuts} Nuts\r\n`) }) const embed = new EmbedBuilder() .setTitle('Nuts Leaderboard') diff --git a/buttons/nuts/nuts_leaderboard_right.js b/buttons/nuts/nuts_leaderboard_right.js index e160006..419c330 100644 --- a/buttons/nuts/nuts_leaderboard_right.js +++ b/buttons/nuts/nuts_leaderboard_right.js @@ -17,7 +17,7 @@ module.exports = { let fields nutsData.forEach((data) => { skip++ - fields = (fields ? fields : '') + (`${skip}. <@${data.userID}> : ${data.nuts} Nuts\r\n`) + fields = (fields ? fields : '') + (`${skip}. <@${data.userId}> : ${data.nuts} Nuts\r\n`) }) const embed = new EmbedBuilder() .setTitle('Nuts Leaderboard') diff --git a/buttons/nuts/nuts_leaderboard_self.js b/buttons/nuts/nuts_leaderboard_self.js index 05b91a9..7cde07f 100644 --- a/buttons/nuts/nuts_leaderboard_self.js +++ b/buttons/nuts/nuts_leaderboard_self.js @@ -8,7 +8,7 @@ module.exports = { const db = mClient.db(process.env.M_DB) const nutsColl = db.collection('items_nuts') - const self = await nutsColl.find({ userID: interaction.user.id }).toArray() + const self = await nutsColl.find({ userId: interaction.user.id }).toArray() const all = await nutsColl.find({ nuts: { $gt: 0 } }).sort({ nuts: -1 }).toArray() function findIndex(){ @@ -16,7 +16,7 @@ module.exports = { for (let i = 0; i < all.length; i++) { index++ if (all[i].nuts === self[0].nuts) { - return index + return index - 1 } } } @@ -27,7 +27,7 @@ module.exports = { let fields nutsData.forEach((data) => { skip++ - fields = (fields ? fields : '') + (`${skip}. <@${data.userID}> : ${data.nuts} Nuts\r\n`) + fields = (fields ? fields : '') + (`${skip}. <@${data.userId}> : ${data.nuts} Nuts\r\n`) }) const embed = new EmbedBuilder() .setTitle('Nuts Leaderboard') diff --git a/commands/legacy/dev/ping.js b/commands/legacy/dev/ping.js index 30bd96f..df6759c 100644 --- a/commands/legacy/dev/ping.js +++ b/commands/legacy/dev/ping.js @@ -1,4 +1,4 @@ -const ping = require("../../../src/ping") +const ping = require("../../../utils/ping") module.exports = { name: 'ping', diff --git a/commands/legacy/nuts/nutsCheck.js b/commands/legacy/nuts/nutsCheck.js index b803f14..326a92d 100644 --- a/commands/legacy/nuts/nutsCheck.js +++ b/commands/legacy/nuts/nutsCheck.js @@ -1,54 +1,158 @@ -const { EmbedBuilder } = require('discord.js') -const { mClient } = require('../../../index') -function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } -require('dotenv').config() +const { + EmbedBuilder +} = require("discord.js"); + +const { + getNutBalance +} = require( + "../../../utils/nutEconomy" +); + + +function delay(ms) { + return new Promise( + resolve => + setTimeout( + resolve, + ms + ) + ); +} + + module.exports = { - name: 'checknuts', - description: 'get nuts', - aliases: ['chn'], - async execute(message, args) { - var target = message.author - var addressing = `Du hast` - if (args[0]) { - if (!args[0].startsWith('<@' || !args[0].endsWith('>'))) { return message.reply('Invalid User Specification') } - try { - target = await message.guild.members.fetch(args[0].slice(2, -1)) - } catch (error) { - return message.reply({ - content: `User: ${args[0]} nicht gefunden!` - }).then(async (msg) => { - await delay(4000) - message.delete() - msg.delete() - }) + name: + "checknuts", + + description: + "get nuts", + + aliases: [ + "chn" + ], + + + async execute( + message, + args + ) { + let target = + message.author; + + + let addressing = + "Du hast"; + + + if ( + args[0] + ) { + if ( + !args[0].startsWith("<@") + || + !args[0].endsWith(">") + ) { + return message.reply( + "Invalid User Specification" + ); } - if (target.user.id !== message.author.id) { - if (!target.user.globalName) { - addressing = `${target.user.username} hat` - } else { - addressing = `${target.user.globalName} hat` + + try { + target = + await message.guild.members.fetch( + args[0].slice( + 2, + -1 + ) + ); + + } catch (error) { + return message.reply({ + content: + `User: ${args[0]} nicht gefunden!` + }) + .then( + async msg => { + await delay( + 4000 + ); + + + message.delete(); + msg.delete(); + } + ); + } + + + if ( + target.user.id !== + message.author.id + ) { + if ( + !target.user.globalName + ) { + addressing = + `${target.user.username} hat`; + } + + + else { + addressing = + `${target.user.globalName} hat`; } } } - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_nuts') - var nutsData = await nutsColl.findOne({ userID: target.id }) - let content - if (!nutsData) { - content = `${addressing} noch keine Nüsse gesammelt :(` - } else { - content = `${addressing} bereits **${nutsData.nuts}** Nüsse gesammelt!` + + const targetId = + target.id + ?? + target.user?.id; + + + const balance = + await getNutBalance( + targetId + ); + + + let content; + + + if ( + balance <= + 0 + ) { + content = + `${addressing} noch keine Nüsse gesammelt :(`; } - const embed = new EmbedBuilder() - .setTitle('Checknuts!') - .setThumbnail(target.displayAvatarURL()) - .setDescription(content) + + else { + content = + `${addressing} bereits **${balance}** Nüsse gesammelt!`; + } + + + const embed = + new EmbedBuilder() + .setTitle( + "Checknuts!" + ) + .setThumbnail( + target.displayAvatarURL() + ) + .setDescription( + content + ); + return message.reply({ - embeds: [embed] - }) + embeds: [ + embed + ] + }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/commands/legacy/nuts/nutsCooldown.js b/commands/legacy/nuts/nutsCooldown.js index 86fa762..5ff1faf 100644 --- a/commands/legacy/nuts/nutsCooldown.js +++ b/commands/legacy/nuts/nutsCooldown.js @@ -1,31 +1,88 @@ -const { EmbedBuilder } = require('discord.js') -const { mClient } = require('../../../index') -require('dotenv').config() -module.exports = { - name: 'nutsCooldown', - description: 'shows nut cooldown', - aliases: ['ncd'], - async execute(message, args) { - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_cooldowns') - const cooldown = await nutsColl.findOne({ userID: message.author.id }) +const { + EmbedBuilder +} = require("discord.js"); - let content = `Du kannst wieder nussen! :)` - let thumbnail = 'https://cdn-icons-png.flaticon.com/512/7451/7451659.png' - let title = 'Go Nuts!' - if (cooldown) { - content = ` kannst du wieder nussen! ;)` - thumbnail = 'https://cdn.discordapp.com/attachments/1152723542836772914/1152987472788193361/No-nuts-PhotoRoom.png-PhotoRoom.png' - title = 'To Nut or Not to Nut...' +const { + getCooldown, + getUnixTime +} = require( + "../../../utils/nutEconomy" +); + + +module.exports = { + name: + "nutsCooldown", + + description: + "shows nut cooldown", + + aliases: [ + "ncd" + ], + + + async execute( + message, + args + ) { + const cooldown = + await getCooldown( + message.author.id, + "nut" + ); + + + const now = + getUnixTime(); + + + let content = + "Du kannst wieder nussen! :)"; + + + let thumbnail = + "https://cdn-icons-png.flaticon.com/512/7451/7451659.png"; + + + let title = + "Go Nuts!"; + + + if ( + cooldown > + now + ) { + content = + ` kannst du wieder nussen! ;)`; + + + thumbnail = + "https://cdn.discordapp.com/attachments/1152723542836772914/1152987472788193361/No-nuts-PhotoRoom.png-PhotoRoom.png"; + + + title = + "To Nut or Not to Nut..."; } - const embed = new EmbedBuilder() - .setThumbnail(thumbnail) - .setTitle(title) - .setDescription(content) + + const embed = + new EmbedBuilder() + .setThumbnail( + thumbnail + ) + .setTitle( + title + ) + .setDescription( + content + ); + await message.reply({ - embeds: [embed] - }) + embeds: [ + embed + ] + }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/commands/legacy/nuts/nutsGet.js b/commands/legacy/nuts/nutsGet.js index 6a8c3c2..edbad63 100644 --- a/commands/legacy/nuts/nutsGet.js +++ b/commands/legacy/nuts/nutsGet.js @@ -1,64 +1,131 @@ -const { mClient } = require('../../../index') -require('dotenv').config() +const { + getCollections, + changeNuts, + claimCooldown, + randomInteger +} = require( + "../../../utils/nutEconomy" +); + + module.exports = { - name: 'nuts', - description: 'get nuts', - aliases: ['n', 'nget', 'nutsget'], - async execute(message, args) { - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_nuts') - const nutsStatsColl = db.collection('stats_nuts') - const cdColl = db.collection('items_cooldowns') - const cdData = await cdColl.findOne({ userID: message.author.id }) + name: + "nuts", - if (cdData?.cooldown > (Date.now() / 1000)) { - return await message.reply({ - content: `Du kannst erst wieder nussen :(`, - }) - } + description: + "get nuts", - let cd = Math.floor((Date.now() + 3600000) / 1000) - await cdColl.findOneAndUpdate({ - userID: message.author.id - }, { - $set: { - cooldown: cd, - application: 'nuts' - } - }, { - upsert: true - }) + aliases: [ + "n", + "nget", + "nutsget" + ], - const amount = Math.floor(Math.random() * 10) - await nutsColl.findOneAndUpdate({ - userID: message.author.id - }, { - $inc: { nuts: amount } - }, { - upsert: true - }) + async execute( + message, + args + ) { + const { + db + } = + getCollections(); - await nutsStatsColl.findOneAndUpdate( - { userID: message.author.id }, - { $inc: { [`stat.${amount}`]: 1 } }, - { upsert: true } - ) - await nutsStatsColl.findOneAndUpdate( - { userID: "__global" }, - { $inc: { [`stat.${amount}`]: 1 } }, - { upsert: true } - ) - // Helper for singular/plural - const formatNut = (amount) => `${amount === 1 ? 'eine' : amount} ${amount === 1 ? 'Nuss' : 'Nüsse'}` + const nutsStatsColl = + db.collection( + "stats_nuts" + ); - let nutText = `Du hast ${formatNut(amount)} bekommen! :chestnut:` - if (amount <= 0) { - nutText = `Du hast keine Nuss bekommen! :yoink_help:` - } - return await message.reply({ - content: nutText - }) - } -} \ No newline at end of file + + const cooldown = + await claimCooldown( + message.author.id, + "nut", + 60 * 60 + ); + + + if ( + !cooldown.claimed + ) { + return message.reply({ + content: + `Du kannst erst wieder nussen :(` + }); + } + + + const amount = + randomInteger( + 0, + 9 + ); + + + await changeNuts( + message.author.id, + amount + ); + + + await nutsStatsColl.findOneAndUpdate( + { + userId: + message.author.id + }, + { + $inc: { + [`stat.${amount}`]: + 1 + } + }, + { + upsert: + true + } + ); + + + await nutsStatsColl.findOneAndUpdate( + { + userId: + "__global" + }, + { + $inc: { + [`stat.${amount}`]: + 1 + } + }, + { + upsert: + true + } + ); + + + const formatNut = + amount => + `${amount === 1 ? "eine" : amount} ${amount === 1 ? "Nuss" : "Nüsse"}`; + + + let nutText = + `Du hast ${formatNut(amount)} bekommen! :chestnut:`; + + + if ( + amount <= + 0 + ) { + nutText = + "Du hast keine Nuss bekommen! :yoink_help:"; + } + + + return message.reply({ + content: + nutText + }); + } +}; \ No newline at end of file diff --git a/commands/legacy/nuts/nutsGive.js b/commands/legacy/nuts/nutsGive.js index eef3866..53e0bae 100644 --- a/commands/legacy/nuts/nutsGive.js +++ b/commands/legacy/nuts/nutsGive.js @@ -1,78 +1,224 @@ -const { EmbedBuilder } = require('discord.js') -const { mClient } = require('../../../index') -function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } -require('dotenv').config() +const { + EmbedBuilder +} = require("discord.js"); + +const { + getNutBalance, + transferNuts +} = require( + "../../../utils/nutEconomy" +); + + +function delay(ms) { + return new Promise( + resolve => + setTimeout( + resolve, + ms + ) + ); +} + + module.exports = { - name: 'givenuts', - description: 'give nuts', - aliases: ['gn'], - async execute(message, args) { - const from = message.author - if (args[0] == `<@${message.author.id}>` || !args[0]) { + name: + "givenuts", + + description: + "give nuts", + + aliases: [ + "gn" + ], + + + async execute( + message, + args + ) { + const from = + message.author; + + + if ( + args[0] == + `<@${message.author.id}>` + || + !args[0] + ) { return message.reply({ - content: "Du musst einen anderen User erwähnen" - }).then(async (msg) => { - await delay(4000) - message.delete() - msg.delete() + content: + "Du musst einen anderen User erwähnen" }) + .then( + async msg => { + await delay( + 4000 + ); + + + message.delete(); + msg.delete(); + } + ); } - if (!args[0].startsWith('<@' || !args[0].endsWith('>'))) { return message.reply('Invalid User Specification') } - try { - target = await message.guild.members.fetch(args[0].slice(2, -1)) - } catch (error) { - return message.reply({ - content: `User: ${args[0]} nicht gefunden!` - }).then(async (msg) => { - await delay(4000) - message.delete() - msg.delete() - }) + + + if ( + !args[0].startsWith("<@") + || + !args[0].endsWith(">") + ) { + return message.reply( + "Invalid User Specification" + ); } - const to = target + + + let target; + try { - var amount = Number(args[1]) + target = + await message.guild.members.fetch( + args[0].slice( + 2, + -1 + ) + ); + } catch (error) { - console.log(error) return message.reply({ - content: 'Du musst eine Menge angeben!' - }).then(async (msg) => { - await delay(4000) - message.delete() - msg.delete() + content: + `User: ${args[0]} nicht gefunden!` }) - } - if (!amount) { - return message.reply({content : 'Danke für nichts, du Hu-'}) + .then( + async msg => { + await delay( + 4000 + ); + + + message.delete(); + msg.delete(); + } + ); } - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_nuts') - const yourBalance = await nutsColl.findOne({ userID: from.id }) - if (amount <= 0) { - return await message.reply({ - content: `Hier wird nicht geklaut >:(\r\nDas gibt **${yourBalance.nuts}** Abzug!` - }) - } - if (yourBalance.nuts < amount) { - return await message.reply({ - content: 'Du kannst nicht mehr geben als du hast!', - ephemeral: true - }) - } - var content = `Du sendest **${amount}** Nüsse an <@${target.id}>` - - if (amount == 1) { - content = `Du sendest **eine** Nuss an <@${target.id}>` - } - await nutsColl.findOneAndUpdate({ userID: from.id }, { $inc: { nuts: -amount } }, { upsert: true }) - await nutsColl.findOneAndUpdate({ userID: to.id }, { $inc: { nuts: +amount } }, { upsert: true }) - const embed = new EmbedBuilder() - .setTitle('Gib Nuss!') - .setDescription(content) - .setThumbnail(target.displayAvatarURL()) - return message.reply({ embeds: [embed] }) + const to = + target; + + + let amount; + + + try { + amount = + Number( + args[1] + ); + + } catch (error) { + console.log( + error + ); + + + return message.reply({ + content: + "Du musst eine Menge angeben!" + }) + .then( + async msg => { + await delay( + 4000 + ); + + + message.delete(); + msg.delete(); + } + ); + } + + + if ( + !amount + ) { + return message.reply({ + content: + "Danke für nichts, du Hu-" + }); + } + + + const yourBalance = + await getNutBalance( + from.id + ); + + + if ( + amount <= + 0 + ) { + return message.reply({ + content: + `Hier wird nicht geklaut >:(\r\nDas gibt **${yourBalance}** Abzug!` + }); + } + + + if ( + yourBalance < + amount + ) { + return message.reply({ + content: + "Du kannst nicht mehr geben als du hast!" + }); + } + + + let content = + `Du sendest **${amount}** Nüsse an <@${target.id}>`; + + + if ( + amount == + 1 + ) { + content = + `Du sendest **eine** Nuss an <@${target.id}>`; + } + + + await transferNuts( + from.id, + to.id, + amount + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "Gib Nuss!" + ) + .setDescription( + content + ) + .setThumbnail( + target.displayAvatarURL() + ); + + + return message.reply({ + embeds: [ + embed + ] + }); } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/commands/legacy/nuts/nutsLeaderboard.js b/commands/legacy/nuts/nutsLeaderboard.js index e22d91a..3118852 100644 --- a/commands/legacy/nuts/nutsLeaderboard.js +++ b/commands/legacy/nuts/nutsLeaderboard.js @@ -1,49 +1,162 @@ -const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js') -const { mClient } = require('../../../index') -function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } -require('dotenv').config() +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require("discord.js"); + +const { + getCollections +} = require( + "../../../utils/nutEconomy" +); + + module.exports = { - name: 'nutsleaderboard', - description: 'show nuts leaderboard', - aliases: ['nl', 'tn', 'topnuts'], - async execute(message, args) { - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel('◄') - .setStyle(ButtonStyle.Primary) - .setCustomId('nuts_leaderboard_left') - ) - .addComponents( - new ButtonBuilder() - .setLabel('🧑') - .setStyle(ButtonStyle.Primary) - .setCustomId('nuts_leaderboard_self') - ) - .addComponents( - new ButtonBuilder() - .setLabel('►') - .setStyle(ButtonStyle.Primary) - .setCustomId('nuts_leaderboard_right') - ) - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_nuts') - let skip = 0 - const nutsData = await nutsColl.find({ nuts: { $gt: 0 } }).sort({ nuts: -1 }).skip(skip).limit(5).toArray() - let fields - let placements = skip + 1 - nutsData.forEach((data) => { - fields = (fields ? fields : '') + (`${placements}. <@${data.userID}> : ${data.nuts} Nuts\r\n`) - }) - const embed = new EmbedBuilder() - .setTitle('Nuts Leaderboard') - .setThumbnail(message.guild.iconURL({ dynamic: true})) - .setDescription(fields.toString()) - .setColor('#5865F2') // Discord's blurple color - .setFooter({ text: 'Use ◄ ► to navigate' }); + name: + "nutsleaderboard", + + description: + "show nuts leaderboard", + + aliases: [ + "nl", + "tn", + "topnuts" + ], + + + async execute( + message, + args + ) { + 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 placements = + skip + + 1; + + + nutsData.forEach( + data => { + fields = + ( + fields + ? + fields + : + "" + ) + + + `${placements}. <@${data.userId}> : ${data.nuts} Nuts\r\n`; + + + placements++; + } + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "Nuts Leaderboard" + ) + .setThumbnail( + message.guild.iconURL({ + dynamic: + true + }) + ) + .setDescription( + fields.toString() + ) + .setColor( + "#5865F2" + ) + .setFooter({ + text: + "Use ◄ ► to navigate" + }); + + await message.reply({ - embeds: [embed], - components: [row] - }) + embeds: [ + embed + ], + + components: [ + row + ] + }); } -} +}; \ No newline at end of file diff --git a/commands/slash/admin/ping.js b/commands/slash/admin/ping.js index d5613dd..75c646e 100644 --- a/commands/slash/admin/ping.js +++ b/commands/slash/admin/ping.js @@ -1,5 +1,5 @@ const { SlashCommandBuilder } = require("discord.js"); -const ping = require("../../../src/ping"); +const ping = require("../../../utils/ping"); module.exports = { data: new SlashCommandBuilder() diff --git a/commands/slash/applications/nutHub.js b/commands/slash/applications/nutHub.js new file mode 100644 index 0000000..3f1f69f --- /dev/null +++ b/commands/slash/applications/nutHub.js @@ -0,0 +1,32 @@ +// commands/applications/nutHub.js + +const { + SlashCommandBuilder +} = require("discord.js"); + +const { + showNutHub +} = require( + "../../../utils/nutHub" +); + + +module.exports = { + data: + new SlashCommandBuilder() + .setName( + "nuthub" + ) + .setDescription( + "open the Nut Hub" + ), + + + async execute( + interaction + ) { + return showNutHub( + interaction + ); + } +}; \ No newline at end of file diff --git a/commands/slash/applications/nuts.js b/commands/slash/applications/nuts.js index a0cfb8c..417681f 100644 --- a/commands/slash/applications/nuts.js +++ b/commands/slash/applications/nuts.js @@ -1,454 +1,1514 @@ -const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, AttachmentBuilder } = require('discord.js') -const { mClient } = require('../../../index') -require('dotenv').config() +const { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags +} = require("discord.js"); -function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +const { + getCollections, + getNutBalance, + changeNuts, + getCooldown, + claimCooldown, + getUnixTime, + randomInteger +} = require("../../../utils/nutEconomy"); -const asset_path = './assets/Command_Nuts/' -async function nutsGive(interaction) { - async function tradeWindow(buttonID1, buttonID2) { - 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.nuts} (**-${amount}**)`, inline: true }, - { name: `Your New Balance`, value: `${yourBalance.nuts - amount}`, inline: true }, - { name: '\u200B', value: '\u200B' }, - { name: `Their Current Balance`, value: `${theirBalance.nuts} (**+${amount}**)`, inline: true }, - { name: `Their New Balance`, value: `${theirBalance.nuts + amount}`, inline: true } +/* + * ============================================================ + * CONFIG + * ============================================================ + */ + +const asset_path = + "./assets/Command_Nuts/"; + + +const NUT_COOLDOWN = + 60 * 60; + + +/* + * ============================================================ + * DELAY + * ============================================================ + */ + +function delay(ms) { + return new Promise( + resolve => + setTimeout( + resolve, + ms ) - .setThumbnail('attachment://nuts_main.png') - - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel('✔') - .setCustomId(buttonID1) - .setStyle(ButtonStyle.Success) - ) - .addComponents( - new ButtonBuilder() - .setLabel('✖') - .setCustomId(buttonID2) - .setStyle(ButtonStyle.Danger) - ) - - await interaction.reply({ - embeds: [embed], - components: [row], - flags: MessageFlags.Ephemeral - }) - } - - const from = interaction.user - const to = await interaction.options.getUser('target') - var amount = await interaction.options.getNumber('amount') - - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_nuts') - const yourBalance = await nutsColl.findOne({ userID: from.id }) - const theirBalance = await nutsColl.findOne({ userID: to.id }) - - if (amount <= 0) { - amount = yourBalance.nuts - return tradeWindow('nuts_give_fake', 'abort') - } - - if (yourBalance.nuts < amount) { - return await interaction.reply({ - content: 'Du kannst nicht mehr geben als du hast!', - flags: MessageFlags.Ephemeral - }) - } - - return tradeWindow('nuts_give_confirm', 'abort') -} - -async function nutsCheck(interaction) { - let target = interaction.options.getUser('target') - let addressing - - if (!target) { - target = interaction.user - addressing = `Du hast` - } - - if (!target.globalName) { - addressing = `${target.username} hat` - } else { - addressing = `${target.globalName} hat` - } - - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_nuts') - const nutsData = await nutsColl.findOne({ userID: target.id }) - - let content - if (!nutsData) { - content = `${addressing} noch keine Nüsse gesammelt :(` - } else { - content = `${addressing} bereits **${nutsData.nuts}** Nüsse gesammelt!` - } - - 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 = title + ' .' - await delay(500); - } - - embed - .setTitle(content) - .setThumbnail('attachment://nuts_main.png') - - await interaction.editReply({ - embeds: [embed], - files: [`${asset_path}nuts_main.png`] - }) -} - -async function nutsLeaderboard(interaction) { - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setLabel('◄') - .setStyle(ButtonStyle.Primary) - .setCustomId('nuts_leaderboard_left') - ) - .addComponents( - new ButtonBuilder() - .setLabel('🧑') - .setStyle(ButtonStyle.Primary) - .setCustomId('nuts_leaderboard_self') - ) - .addComponents( - new ButtonBuilder() - .setLabel('►') - .setStyle(ButtonStyle.Primary) - .setCustomId('nuts_leaderboard_right') - ) - - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_nuts') - let skip = 0 - - const nutsData = await nutsColl.find({ nuts: { $gt: 0 } }).sort({ nuts: -1 }).skip(skip).limit(5).toArray() - - let fields - let placements = skip + 1 - - nutsData.forEach((data) => { - fields = (fields ? fields : '') + (`${placements}. <@${data.userID}> : ${data.nuts} Nuts\r\n`) - placements++ - }) - - const embed = new EmbedBuilder() - .setTitle('Nuts Leaderboard') - .setThumbnail(interaction.guild.iconURL({ dynamic: true})) - .setDescription(fields.toString()) - .setColor('#5865F2') - .setFooter({ text: 'Use ◄ ► to navigate' }) - - await interaction.editReply({ - embeds: [embed], - components: [row] - }) -} - -async function nutsStats(interaction) { - const db = mClient.db(process.env.M_DB); - const nStatsColl = db.collection('stats_nuts'); - var type = interaction?.options?.getString('type') || 'lookup' - var target = interaction?.options?.getUser('lookup') || {} - - if (type == 'global') { - target.id = '__global' - } else if (!target?.id) { - target = interaction.user - } - - const statsDoc = await nStatsColl.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, [num, count]) => sum + Number(num) * count, - 0 ); +} - const rolledNumbers = Object.entries(stat).filter(([_, count]) => count > 0); - let nutMin = { number: 0, count: 0 }; - let nutMax = { number: 0, count: 0 }; +/* + * ============================================================ + * GIVE + * ============================================================ + */ - if (rolledNumbers.length > 0) { - nutMin = nutMax = { number: Number(rolledNumbers[0][0]), count: rolledNumbers[0][1] }; +async function nutsGive( + interaction +) { + const from = + interaction.user; - for (const [numStr, count] of rolledNumbers) { - const num = Number(numStr); - if (count > nutMax.count || (count === nutMax.count && num > nutMax.number)) { - nutMax = { number: num, count }; - } + const to = + interaction.options.getUser( + "target", + true + ); - if (count < nutMin.count || (count === nutMin.count && num < nutMin.number)) { - nutMin = { number: num, count }; - } - } + + 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 nutAvg = totalCount ? totalNuts / totalCount : 0; - const fields = Array.from({ length: 11 }, (_, i) => ({ - name: `[${i}]`, - value: `x${stat[i] ?? 0}`, - inline: true - })); + const [ + yourBalance, + theirBalance + ] = + await Promise.all([ + getNutBalance( + from.id + ), - 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') + getNutBalance( + to.id + ) + ]); - await interaction.editReply({ - embeds: [embed], - files: [`${asset_path}nuts_main.png`] + + /* + * 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 }); } -async function nutsCooldown(interaction) { - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_cooldowns') - const cooldown = await nutsColl.findOne({ userID: interaction.user.id }) - let content = `Du kannst wieder nussen! :)` - let thumbnail = 'attachment://nuts_main.png' - let file = `${asset_path}nuts_main.png` - let title = 'Go Nuts!' +/* + * ============================================================ + * CHECK + * ============================================================ + */ - let date = (Date.now() / 1000) - if (cooldown.cooldown > date) { - content = ` kannst du wieder nussen! ;)` - thumbnail = 'attachment://nuts_onCD.avif' - file = `${asset_path}nuts_onCD.avif` - title = 'To Nut or Not to Nut...' +async function nutsCheck( + interaction +) { + let target = + interaction.options.getUser( + "target" + ); + + + let addressing; + + + if (!target) { + target = + interaction.user; + + addressing = + "Du hast"; } - const embed = new EmbedBuilder() - .setThumbnail(thumbnail) - .setTitle(title) - .setDescription(content) - await interaction.editReply({ - embeds: [embed], - files: [file] - }) -} - -async function nutsNut(interaction, quickMode) { - const db = mClient.db(process.env.M_DB) - const nutsColl = db.collection('items_nuts') - const cdColl = db.collection('items_cooldowns') - const nutsStatsColl = db.collection('stats_nuts') - - const cdData = await cdColl.findOne({ userID: interaction.user.id }) - 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' + else if ( + !target.globalName + ) { + addressing = + `${target.username} hat`; } - // Helper for singular/plural - const formatNut = (amount) => `${amount === 1 ? 'eine' : amount} ${amount === 1 ? 'Nuss' : 'Nüsse'}` - // check cooldown - if (cdData?.cooldown > Date.now() / 1000) { - // on cooldown - embed.setTitle('To Nut or Not to Nut...') - embed.setDescription(` kannst du wieder nussen! ;)`) - embed.setThumbnail(assets.onCD) - - return interaction.editReply({ - embeds: [embed], - files: [`${asset_path}nuts_onCD.avif`] - }) - } - // set 1 hour cooldown - const cd = Math.floor((Date.now() + 3600000) / 1000) - await cdColl.findOneAndUpdate({ - userID: interaction.user.id - }, { - $set: { - cooldown: cd, - application: 'nuts' - } - }, { - upsert: true - }) - - // generate nut amount - const amount = Math.floor(Math.random() * 10) - - // update nuts and stats - await nutsColl.findOneAndUpdate( - { userID: interaction.user.id }, - { $inc: { nuts: amount } }, - { upsert: true } - ) - - await nutsStatsColl.findOneAndUpdate( - { userID: interaction.user.id }, - { $inc: { [`stat.${amount}`]: 1 } }, - { upsert: true } - ) - - await nutsStatsColl.findOneAndUpdate( - { userID: "__global" }, - { $inc: { [`stat.${amount}`]: 1 } }, - { upsert: true } - ) - - let nutText = `Du hast ${formatNut(amount)} bekommen! :chestnut:` - if (amount <= 0) { - nutText = `Du hast keine Nuss bekommen! :yoink_help:` + else { + addressing = + `${target.globalName} hat`; } - if (quickMode) { - return interaction.editReply({ content: nutText }) + + 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 + ); } - // 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) - 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( + content + ) + .setThumbnail( + "attachment://nuts_main.png" + ); - embed.setTitle(nutText) - embed.setThumbnail(assetFile) - embed.setImage(null) return interaction.editReply({ - embeds: [embed], - files: [`${asset_path}${assetFile.split('://')[1]}`] - }) - + embeds: [ + embed + ], + files: [ + `${asset_path}nuts_main.png` + ] + }); } -module.exports = { - data: new SlashCommandBuilder() - .setName('nuts') - .setDescription('rund um die nuss') - .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))) - .addSubcommand(s => - s - .setName('check') - .setDescription('check out deez nuts') - .addUserOption(o => o.setName('target').setDescription('check out those nuts!'))) - .addSubcommand(s => - s - .setName('leaderboard') - .setDescription('wer hat die dicksten Nüsse?')) - .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') - ) + + +/* + * ============================================================ + * 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"; - .addSubcommand(s => - s - .setName('cooldown') - .setDescription('wie lange bis ich nussen kann?')) - .addSubcommand(s => - s - .setName('nut') - .setDescription('willst du nuss?')) - .addSubcommand(s => - s.setName('quick') - .setDescription('A quick nut, mlord?') - ), - async execute(interaction) { - switch (interaction.options._subcommand) { - case 'give': - nutsGive(interaction) - break; - case 'check': - await interaction.deferReply() - nutsCheck(interaction) - break; - case 'leaderboard': - await interaction.deferReply() - nutsLeaderboard(interaction) - break; - case 'stats': - await interaction.deferReply() - nutsStats(interaction) - break; - case 'cooldown': - await interaction.deferReply() - nutsCooldown(interaction) - break; - case 'nut': - await interaction.deferReply() - nutsNut(interaction, false) - break; - case 'quick': - await interaction.deferReply() - nutsNut(interaction, true) - break; - default: - break; + 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 + ); + } + } +}; \ No newline at end of file diff --git a/events/InteractionCreate.js b/events/InteractionCreate.js index 34f1827..2ccb6c4 100644 --- a/events/InteractionCreate.js +++ b/events/InteractionCreate.js @@ -60,83 +60,199 @@ async function selectMenuHandler(interaction) { } } +function getParticipantWeight(participant, now = Date.now()) { + // Never selected before -> strongly favor them + if (!participant.lastExpertAt) { + return 6; + } + + const lastExpertAt = new Date(participant.lastExpertAt).getTime(); + + // Invalid date safety + if (Number.isNaN(lastExpertAt)) { + return 6; + } + + const daysSinceExpert = Math.max( + 0, + (now - lastExpertAt) / (24 * 60 * 60 * 1000) + ); + + /* + * Square-root weighting prevents very old participants + * from completely dominating the random selection. + * + * Examples: + * 1 day -> ~1.41 + * 7 days -> ~2.83 + * 30 days -> ~5.57 + */ + return Math.sqrt(daysSinceExpert + 1); +} + +function weightedRandom(participants) { + if (!participants.length) { + return null; + } + + const weightedParticipants = participants.map(participant => ({ + participant, + weight: getParticipantWeight(participant) + })); + + const totalWeight = weightedParticipants.reduce( + (total, item) => total + item.weight, + 0 + ); + + let random = Math.random() * totalWeight; + + for (const item of weightedParticipants) { + random -= item.weight; + + if (random <= 0) { + return item.participant; + } + } + + // Floating-point safety fallback + return weightedParticipants[weightedParticipants.length - 1].participant; +} + +async function selectWgeParticipant( + participateColl, + { excludeUserId = null } = {} +) { + const filter = { + status: true + }; + + if (excludeUserId) { + filter.userID = { + $ne: excludeUserId + }; + } + + const participants = await participateColl + .find(filter) + .toArray(); + + return weightedRandom(participants); +} + async function modalSubmitHandler(interaction) { - if (interaction.customId !== 'wgeBaseModal') return; + if (interaction.customId !== 'wgeBaseModal') { + return; + } const db = mClient.db('neo-db'); + const wgeColl = db.collection('timer_wge'); const participateColl = db.collection('items_wge'); const historyWgeColl = db.collection('history_wge'); const assigningUser = interaction.user; - const selectedUserId = interaction.components[0].component.values[0]; - const topic = interaction.fields.getTextInputValue('wgeTopicInput'); - const defaultAssignedTime = 7 * 24 * 60 * 60 * 1000; // 7 days - // Get current running WGE so we can exclude the current expert + const selectedUserId = + interaction.components[0].component.values[0]; + + const topic = interaction.fields.getTextInputValue( + 'wgeTopicInput' + ); + + const defaultAssignedTime = + 7 * 24 * 60 * 60 * 1000; + + // Get current running WGE so the previous expert + // cannot immediately be selected again. const prevWge = await wgeColl.findOne({ debug: false, state: 'running' }); let assignedUser; + let selectedParticipant; - // Random assignment + // --------------------------------- + // RANDOM / WEIGHTED ASSIGNMENT + // --------------------------------- if (selectedUserId === process.env.D_ID) { - const randomParticipant = await participateColl.aggregate([ + selectedParticipant = await selectWgeParticipant( + participateColl, { - $match: { - status: true, - ...(prevWge && { - userID: { $ne: prevWge.assignedUser } - }) - } - }, - { $sample: { size: 1 } } - ]).toArray(); + excludeUserId: prevWge?.assignedUser + } + ); - if (randomParticipant.length === 0) { + if (!selectedParticipant) { return interaction.reply({ - content: 'There are no eligible participants available.', + content: + 'There are no eligible participants available.', + flags: MessageFlags.Ephemeral + }); + } + + const member = await interaction.guild.members + .fetch(selectedParticipant.userID) + .catch(() => null); + + if (!member) { + return interaction.reply({ + content: + 'The selected participant could not be found in the server.', flags: MessageFlags.Ephemeral }); } - const member = await interaction.guild.members.fetch(randomParticipant[0].userID); assignedUser = member.user; } - // Manual assignment + + // --------------------------------- + // MANUAL ASSIGNMENT + // --------------------------------- else { - const participant = await participateColl.findOne({ + selectedParticipant = await participateColl.findOne({ userID: selectedUserId, status: true }); - if (!participant) { + if (!selectedParticipant) { return interaction.reply({ - content: `<@${selectedUserId}> is not participating!`, + content: + `<@${selectedUserId}> is not participating!`, flags: MessageFlags.Ephemeral }); } - if (prevWge && selectedUserId === prevWge.assignedUser) { + if ( + prevWge && + selectedUserId === prevWge.assignedUser + ) { return interaction.reply({ - content: `<@${selectedUserId}> was the previous expert and cannot be selected again this round.`, + content: + `<@${selectedUserId}> was the previous expert and cannot be selected again this round.`, flags: MessageFlags.Ephemeral }); } - assignedUser = { id: selectedUserId }; + assignedUser = { + id: selectedUserId + }; } - // Archive previous WGE + // --------------------------------- + // ARCHIVE PREVIOUS WGE + // --------------------------------- if (prevWge) { await historyWgeColl.insertOne({ assignedUser: prevWge.assignedUser, assigningUser: prevWge.assigningUser, + timeRemaining: prevWge.timeRemaining, + topic: prevWge.topic, debug: prevWge.debug, + createdAt: prevWge.createdAt }); @@ -150,7 +266,28 @@ async function modalSubmitHandler(interaction) { }); } - // Create new WGE + const assignedAt = new Date(); + + // --------------------------------- + // UPDATE PARTICIPANT STATISTICS + // --------------------------------- + await participateColl.updateOne( + { + _id: selectedParticipant._id + }, + { + $set: { + lastExpertAt: assignedAt + }, + $inc: { + expertCount: 1 + } + } + ); + + // --------------------------------- + // CREATE NEW WGE + // --------------------------------- await wgeColl.insertOne({ assignedUser: assignedUser.id, assigningUser: assigningUser.id, @@ -169,10 +306,11 @@ async function modalSubmitHandler(interaction) { topic, debug: false, - createdAt: new Date() + createdAt: assignedAt }); return interaction.reply({ - content: `<@${assignedUser.id}> is the next expert! Their topic is **${topic}**` + content: + `<@${assignedUser.id}> is the next expert! Their topic is **${topic}**` }); } \ No newline at end of file diff --git a/events/timerComplete.js b/events/timerComplete.js index af01ddc..6e4428c 100644 --- a/events/timerComplete.js +++ b/events/timerComplete.js @@ -1,4 +1,4 @@ -const { mClient } = require(".."); +const { mClient } = require('..'); async function getMember(client, userId) { const guild = client.guilds.cache.get(process.env.D_GuildID); @@ -7,15 +7,89 @@ async function getMember(client, userId) { return null; } - // Cache first - let member = guild.members.cache.get(userId); - - // Fallback to API fetch - if (!member) { - member = await guild.members.fetch(userId).catch(() => null); + return ( + guild.members.cache.get(userId) || + await guild.members.fetch(userId).catch(() => null) + ); +} +function getParticipantWeight(participant, now = Date.now()) { + // Never selected before -> strongly favor them + if (!participant.lastExpertAt) { + return 6; } - return member; + const lastExpertAt = new Date(participant.lastExpertAt).getTime(); + + // Invalid date safety + if (Number.isNaN(lastExpertAt)) { + return 6; + } + + const daysSinceExpert = Math.max( + 0, + (now - lastExpertAt) / (24 * 60 * 60 * 1000) + ); + + /* + * Square-root weighting prevents very old participants + * from completely dominating the random selection. + * + * Examples: + * 1 day -> ~1.41 + * 7 days -> ~2.83 + * 30 days -> ~5.57 + */ + return Math.sqrt(daysSinceExpert + 1); +} + +function weightedRandom(participants) { + if (!participants.length) { + return null; + } + + const weightedParticipants = participants.map(participant => ({ + participant, + weight: getParticipantWeight(participant) + })); + + const totalWeight = weightedParticipants.reduce( + (total, item) => total + item.weight, + 0 + ); + + let random = Math.random() * totalWeight; + + for (const item of weightedParticipants) { + random -= item.weight; + + if (random <= 0) { + return item.participant; + } + } + + // Floating-point safety fallback + return weightedParticipants[weightedParticipants.length - 1].participant; +} + +async function selectWgeParticipant( + participateColl, + { excludeUserId = null } = {} +) { + const filter = { + status: true + }; + + if (excludeUserId) { + filter.userID = { + $ne: excludeUserId + }; + } + + const participants = await participateColl + .find(filter) + .toArray(); + + return weightedRandom(participants); } module.exports = { @@ -35,50 +109,55 @@ module.exports = { return; } - // Notify previous assignee via DM - const previousMember = await getMember(client, timer.assignedUser); + // Notify previous expert + const previousMember = await getMember( + client, + timer.assignedUser + ); if (previousMember) { await previousMember.send( `❌ Timer ran out: **${timer.topic}** ...Re-assigning randomly...` - ).catch(() => {}); + ).catch(error => { + console.error( + `timerComplete: Failed to DM ${timer.assignedUser}:`, + error + ); + }); } - // Find the configured WGE channel + // Get configured WGE channel const wgeChannelConfig = await channelColl.findOne({ purpose: 'wge', guildID: process.env.D_GuildID }); if (!wgeChannelConfig) { - console.error('timerComplete: WGE channel configuration not found.'); + console.error( + 'timerComplete: WGE channel configuration not found.' + ); return; } - const channel = client.channels.cache.get(wgeChannelConfig.channelID); + const channel = client.channels.cache.get( + wgeChannelConfig.channelID + ); if (!channel) { console.error('timerComplete: WGE channel not found.'); return; } - // Pick a random participant, excluding the previous assignee - const randomParticipant = await participateColl.aggregate([ + // Weighted random selection. + // Previous expert is explicitly excluded. + const selectedParticipant = await selectWgeParticipant( + participateColl, { - $match: { - status: true, - userID: { $ne: timer.assignedUser } - } - }, - { - $sample: { - size: 1 - } + excludeUserId: timer.assignedUser } - ]).toArray(); + ); - // Nobody else is available - if (!randomParticipant.length) { + if (!selectedParticipant) { await channel.send({ content: `The timer for <@${timer.assignedUser}> has run out, ` + @@ -89,12 +168,12 @@ module.exports = { } const newMember = await guild.members - .fetch(randomParticipant[0].userID) + .fetch(selectedParticipant.userID) .catch(() => null); if (!newMember) { console.error( - `timerComplete: Could not fetch selected user ${randomParticipant[0].userID}.` + `timerComplete: Could not fetch selected user ${selectedParticipant.userID}.` ); return; } @@ -102,14 +181,36 @@ module.exports = { const newAssignedUser = newMember.user; const wgeDefaultAssignedTime = - 7 * 24 * 60 * 60 * 1000; // 7 days + 7 * 24 * 60 * 60 * 1000; - // Remove the expired timer specifically + const assignedAt = new Date(); + + /* + * Update participant statistics. + * + * $inc automatically creates expertCount with value 1 + * if the field does not exist yet. + */ + await participateColl.updateOne( + { + _id: selectedParticipant._id + }, + { + $set: { + lastExpertAt: assignedAt + }, + $inc: { + expertCount: 1 + } + } + ); + + // Remove the expired timer await wgeColl.deleteOne({ _id: timer._id }); - // Create the new timer + // Create new timer await wgeColl.insertOne({ assignedUser: newAssignedUser.id, assigningUser: timer.assigningUser, @@ -128,14 +229,17 @@ module.exports = { topic: timer.topic, debug: false, - createdAt: new Date() + createdAt: assignedAt }); - // Announce reassignment await channel.send({ content: `The timer for <@${timer.assignedUser}> has run out! Assigning randomly...\n` + `<@${newAssignedUser.id}> is the next expert! Their topic is still **${timer.topic}**` }); + + console.log( + `timerComplete: Reassigned WGE from ${timer.assignedUser} to ${newAssignedUser.id}.` + ); } }; \ No newline at end of file diff --git a/index.js b/index.js index 58b9571..db61fa1 100644 --- a/index.js +++ b/index.js @@ -1,33 +1,212 @@ -const { Client, GatewayIntentBits, Collection, Partials } = require('discord.js') -const { MongoClient } = require('mongodb') -require('dotenv').config() -const mClient = new MongoClient(process.env.M_URI) -exports.mClient = mClient; -const fs = require('node:fs') +const { + Client, + GatewayIntentBits, + Collection, + Partials +} = require("discord.js"); -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildPresences, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - ], - partials: [Partials.Channel, Partials.Message, Partials.User, Partials.GuildMember, Partials.Reaction] -}); -exports.client = client +const { + MongoClient +} = require("mongodb"); -client.commands = new Collection() // slash commands collection -client.legacyCommands = new Collection() // legacy commands collection -client.aliases = new Collection() // list of command aliases -client.buttons = new Collection() // list of buttons -client.selectMenus = new Collection() // list of selectMenus +const fs = + require("node:fs"); -fs.readdirSync('./handlers').forEach((handler) => { - require(`./handlers/${handler}`)(client) -}); +require("dotenv").config(); -client.login(process.env.D_Token) \ No newline at end of file + +/* + * ============================================================ + * MONGODB + * ============================================================ + */ + +const mClient = + new MongoClient( + process.env.M_URI + ); + + +/* + * Export this BEFORE handlers/commands are loaded. + * + * utils/nutEconomy.js imports mClient from index.js. + */ +exports.mClient = + mClient; + + +/* + * ============================================================ + * DISCORD CLIENT + * ============================================================ + */ + +const client = + new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildPresences, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent + ], + + partials: [ + Partials.Channel, + Partials.Message, + Partials.User, + Partials.GuildMember, + Partials.Reaction + ] + }); + + +exports.client = + client; + + +/* + * ============================================================ + * COLLECTIONS + * ============================================================ + */ + +client.commands = + new Collection(); + +client.legacyCommands = + new Collection(); + +client.aliases = + new Collection(); + +client.buttons = + new Collection(); + +client.selectMenus = + new Collection(); + + +/* + * ============================================================ + * STARTUP + * ============================================================ + */ + +async function start() { + try { + /* + * Connect Mongo first. + */ + + await mClient.connect(); + + + console.log( + "[MongoDB] Connected" + ); + + + /* + * Load handlers only after MongoDB is ready. + */ + + const handlers = + fs.readdirSync( + "./handlers" + ); + + + for ( + const handler + of handlers + ) { + require( + `./handlers/${handler}` + )( + client + ); + } + + + /* + * Finally connect to Discord. + */ + + await client.login( + process.env.D_Token + ); + + } catch (error) { + console.error( + "Failed to start bot:", + error + ); + + + process.exit( + 1 + ); + } +} + + +/* + * ============================================================ + * SHUTDOWN + * ============================================================ + */ + +async function shutdown( + signal +) { + console.log( + `[System] ${signal} received, shutting down...` + ); + + + try { + client.destroy(); + } catch {} + + + try { + await mClient.close(); + } catch {} + + + process.exit( + 0 + ); +} + + +process.on( + "SIGINT", + () => + shutdown( + "SIGINT" + ) +); + + +process.on( + "SIGTERM", + () => + shutdown( + "SIGTERM" + ) +); + + +/* + * ============================================================ + * START + * ============================================================ + */ + +start(); \ No newline at end of file diff --git a/utils/nutBlackjack.js b/utils/nutBlackjack.js new file mode 100644 index 0000000..66ac57a --- /dev/null +++ b/utils/nutBlackjack.js @@ -0,0 +1,1792 @@ +// utils/nutBlackjack.js + +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require("discord.js"); + +const { + randomUUID +} = require("node:crypto"); + +const { + getCollections, + getNutBalance, + transferNuts, + temporaryEphemeralReply, + getUnixTime +} = require("./nutEconomy"); + + +/* + * ============================================================ + * CONFIG + * ============================================================ + */ + +const SESSION_COLLECTION = + "sessions_blackjack"; + + +const BLACKJACK_BETS = [ + 10, + 50, + 100, + 500, + 1000 +]; + + +/* + * ============================================================ + * SHOW BLACKJACK MENU + * ============================================================ + */ + +async function showNutBlackjack( + interaction +) { + const ownerId = + getBlackjackSessionOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht deine Nut-Hub-Session." + } + ); + } + + + /* + * Resume active hand if one exists. + */ + + const active = + await getActiveBlackjack( + interaction.user.id + ); + + + if (active) { + return interaction.update({ + embeds: [ + buildGameEmbed( + active + ) + ], + + components: + buildGameButtons( + active + ) + }); + } + + + const balance = + await getNutBalance( + interaction.user.id + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "🃏 Nut Blackjack" + ) + + .setDescription( + [ + "Schlag den Dealer ohne über **21** zu kommen.", + "", + "**Dealer stands on 17.**", + "**Blackjack pays 3:2.**", + "**Push returns your wager.**", + "", + "Choose your wager." + ].join("\n") + ) + + .addFields({ + name: + "Deine Nüsse", + + value: + `**${balance.toLocaleString()} 🥜**` + }) + + .setFooter({ + text: + `nutBlackjackMenu:${interaction.user.id}` + }); + + + return interaction.update({ + embeds: [ + embed + ], + + components: + buildBetButtons() + }); +} + + +/* + * ============================================================ + * START BLACKJACK + * ============================================================ + */ + +async function startNutBlackjack( + interaction, + bet +) { + if ( + !BLACKJACK_BETS.includes( + bet + ) + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Dieser Blackjack-Einsatz ist nicht verfügbar." + } + ); + } + + + const ownerId = + getBlackjackSessionOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht dein Blackjack-Tisch." + } + ); + } + + + /* + * Prevent multiple active hands. + */ + + const existing = + await getActiveBlackjack( + interaction.user.id + ); + + + if (existing) { + return temporaryEphemeralReply( + interaction, + { + content: + "Du hast bereits eine aktive Blackjack-Hand." + } + ); + } + + + const playerId = + interaction.user.id; + + + const houseId = + interaction.client.user.id; + + + const [ + playerBalance, + houseBalance + ] = + await Promise.all([ + getNutBalance( + playerId + ), + + getNutBalance( + houseId + ) + ]); + + + if ( + playerBalance < + bet + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `Du brauchst **${bet.toLocaleString()} Nüsse**.` + } + ); + } + + + /* + * Natural Blackjack can require 2.5x total + * returned to the player. + * + * Since the player's wager moves into the house + * before resolving, the house needs enough liquidity + * to cover the extra 1.5x. + */ + + if ( + houseBalance < + Math.ceil( + bet * + 1.5 + ) + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Arthonor's Nut Stash kann diesen Einsatz aktuell nicht absichern." + } + ); + } + + + try { + await transferNuts( + playerId, + houseId, + bet + ); + + } catch (error) { + if ( + error.message === + "INSUFFICIENT_NUTS" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Dein Nut-Bestand hat sich verändert." + } + ); + } + + + throw error; + } + + + /* + * Fresh real 52-card deck. + */ + + const deck = + createDeck(); + + + shuffleDeck( + deck + ); + + + const playerCards = [ + deck.pop(), + deck.pop() + ]; + + + const dealerCards = [ + deck.pop(), + deck.pop() + ]; + + + const game = { + sessionId: + randomUUID(), + + userId: + playerId, + + state: + "Playing", + + bet, + + originalBet: + bet, + + doubled: + false, + + deck, + + playerCards, + + dealerCards, + + createdAt: + getUnixTime() + }; + + + const { + db + } = + getCollections(); + + + await db + .collection( + SESSION_COLLECTION + ) + .insertOne( + game + ); + + + /* + * Natural Blackjack check. + */ + + const playerBlackjack = + isBlackjack( + playerCards + ); + + + const dealerBlackjack = + isBlackjack( + dealerCards + ); + + + if ( + playerBlackjack || + dealerBlackjack + ) { + if ( + playerBlackjack && + dealerBlackjack + ) { + return finishNutBlackjack( + interaction, + game, + "Push" + ); + } + + + if (playerBlackjack) { + return finishNutBlackjack( + interaction, + game, + "Blackjack" + ); + } + + + return finishNutBlackjack( + interaction, + game, + "DealerBlackjack" + ); + } + + + return interaction.update({ + embeds: [ + buildGameEmbed( + game + ) + ], + + components: + buildGameButtons( + game + ) + }); +} + + +/* + * ============================================================ + * HIT + * ============================================================ + */ + +async function hitNutBlackjack( + interaction +) { + const game = + await getBlackjackFromInteraction( + interaction + ); + + + if ( + !game || + game.state !== + "Playing" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Diese Blackjack-Hand existiert nicht mehr." + } + ); + } + + + if ( + game.userId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht deine Blackjack-Hand." + } + ); + } + + + const deck = + [ + ...game.deck + ]; + + + const playerCards = + [ + ...game.playerCards + ]; + + + playerCards.push( + deck.pop() + ); + + + const updated = { + ...game, + deck, + playerCards + }; + + + await updateBlackjackSession( + game.sessionId, + { + deck, + playerCards + } + ); + + + const score = + getHandValue( + playerCards + ); + + + if ( + score > + 21 + ) { + return finishNutBlackjack( + interaction, + updated, + "Bust" + ); + } + + + /* + * Auto-stand on 21. + */ + + if ( + score === + 21 + ) { + return standNutBlackjack( + interaction, + updated + ); + } + + + return interaction.update({ + embeds: [ + buildGameEmbed( + updated + ) + ], + + components: + buildGameButtons( + updated + ) + }); +} + + +/* + * ============================================================ + * STAND + * ============================================================ + */ + +async function standNutBlackjack( + interaction, + suppliedGame = + null +) { + const game = + suppliedGame + ?? + await getBlackjackFromInteraction( + interaction + ); + + + if (!game) { + return temporaryEphemeralReply( + interaction, + { + content: + "Diese Blackjack-Hand existiert nicht mehr." + } + ); + } + + + if ( + game.userId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht deine Blackjack-Hand." + } + ); + } + + + const deck = + [ + ...game.deck + ]; + + + const dealerCards = + [ + ...game.dealerCards + ]; + + + /* + * Dealer stands on all 17s. + */ + + while ( + getHandValue( + dealerCards + ) + < + 17 + ) { + dealerCards.push( + deck.pop() + ); + } + + + const updated = { + ...game, + deck, + dealerCards + }; + + + const playerScore = + getHandValue( + updated.playerCards + ); + + + const dealerScore = + getHandValue( + dealerCards + ); + + + let result; + + + if ( + dealerScore > + 21 + ) { + result = + "DealerBust"; + } + + + else if ( + playerScore > + dealerScore + ) { + result = + "Win"; + } + + + else if ( + playerScore < + dealerScore + ) { + result = + "Loss"; + } + + + else { + result = + "Push"; + } + + + return finishNutBlackjack( + interaction, + updated, + result + ); +} + + +/* + * ============================================================ + * DOUBLE + * ============================================================ + */ + +async function doubleNutBlackjack( + interaction +) { + const game = + await getBlackjackFromInteraction( + interaction + ); + + + if ( + !game || + game.state !== + "Playing" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Diese Blackjack-Hand existiert nicht mehr." + } + ); + } + + + if ( + game.userId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht deine Blackjack-Hand." + } + ); + } + + + /* + * Double is only possible on the original hand. + */ + + if ( + game.playerCards.length !== + 2 + || + game.doubled + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Double ist nur mit den ersten zwei Karten möglich." + } + ); + } + + + const playerBalance = + await getNutBalance( + interaction.user.id + ); + + + if ( + playerBalance < + game.originalBet + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `Du brauchst weitere **${game.originalBet.toLocaleString()} Nüsse** für Double.` + } + ); + } + + + try { + await transferNuts( + interaction.user.id, + interaction.client.user.id, + game.originalBet + ); + + } catch (error) { + if ( + error.message === + "INSUFFICIENT_NUTS" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Du hast nicht mehr genug Nüsse für Double." + } + ); + } + + + throw error; + } + + + const deck = + [ + ...game.deck + ]; + + + const playerCards = + [ + ...game.playerCards + ]; + + + /* + * Double receives exactly one additional card. + */ + + playerCards.push( + deck.pop() + ); + + + const updated = { + ...game, + + deck, + playerCards, + + bet: + game.bet + + game.originalBet, + + doubled: + true + }; + + + await updateBlackjackSession( + game.sessionId, + { + deck, + playerCards, + + bet: + updated.bet, + + doubled: + true + } + ); + + + if ( + getHandValue( + playerCards + ) + > + 21 + ) { + return finishNutBlackjack( + interaction, + updated, + "Bust" + ); + } + + + /* + * Double automatically stands. + */ + + return standNutBlackjack( + interaction, + updated + ); +} + + +/* + * ============================================================ + * FINISH BLACKJACK + * ============================================================ + */ + +async function finishNutBlackjack( + interaction, + game, + result +) { + let payout = + 0; + + + switch (result) { + /* + * Normal win: + * + * return original wager + winnings. + */ + + case "Win": + case "DealerBust": + payout = + game.bet * + 2; + break; + + + /* + * Push: + * + * wager returned. + */ + + case "Push": + payout = + game.bet; + break; + + + /* + * Natural Blackjack: + * + * wager + 3:2 winnings + * = 2.5x returned. + */ + + case "Blackjack": + payout = + Math.floor( + game.bet * + 2.5 + ); + break; + + + /* + * Bust / Loss / Dealer Blackjack: + * house keeps wager. + */ + + default: + payout = + 0; + } + + + if ( + payout > + 0 + ) { + try { + await transferNuts( + interaction.client.user.id, + game.userId, + payout + ); + + } catch (error) { + console.error( + "[Nut Blackjack] Payout failed:", + error + ); + + + const errorEmbed = + new EmbedBuilder() + .setTitle( + "🃏 Blackjack Error" + ) + + .setDescription( + [ + "The hand resolved, but Arthonor's Nut Stash could not cover the payout.", + "", + "Please contact an administrator." + ].join("\n") + ) + + .setFooter({ + text: + `nutBlackjackMenu:${game.userId}` + }); + + + return interaction.update({ + embeds: [ + errorEmbed + ], + + components: + buildResultButtons() + }); + } + } + + + /* + * sessions_* represents currently active state. + * + * Finished hand = remove session. + */ + + const { + db + } = + getCollections(); + + + await db + .collection( + SESSION_COLLECTION + ) + .deleteOne({ + sessionId: + game.sessionId + }); + + + const balance = + await getNutBalance( + game.userId + ); + + + const profit = + payout - + game.bet; + + + const embed = + new EmbedBuilder() + .setTitle( + getResultTitle( + result + ) + ) + + .setDescription( + [ + "**Dealer**", + formatCards( + game.dealerCards + ), + `Score: **${getHandValue(game.dealerCards)}**`, + "", + "**Du**", + formatCards( + game.playerCards + ), + `Score: **${getHandValue(game.playerCards)}**` + ].join("\n") + ) + + .addFields( + { + name: + "Einsatz", + + value: + `${game.bet.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Payout", + + value: + `${payout.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Profit", + + value: + `${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Deine Nüsse", + + value: + `${balance.toLocaleString()} 🥜` + } + ) + + .setFooter({ + text: + `nutBlackjackMenu:${game.userId}` + }); + + + return interaction.update({ + embeds: [ + embed + ], + + components: + buildResultButtons() + }); +} + + +/* + * ============================================================ + * ACTIVE GAME EMBED + * ============================================================ + */ + +function buildGameEmbed( + game +) { + const playerScore = + getHandValue( + game.playerCards + ); + + + return new EmbedBuilder() + .setTitle( + "🃏 Nut Blackjack" + ) + + .setDescription( + [ + "**Dealer**", + `${formatCard(game.dealerCards[0])} 🂠`, + "", + "**Du**", + formatCards( + game.playerCards + ), + `Score: **${playerScore}**` + ].join("\n") + ) + + .addFields( + { + name: + "Einsatz", + + value: + `${game.bet.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Status", + + value: + "**Dein Zug**", + + inline: + true + } + ) + + .setFooter({ + text: + `nutBlackjack:${game.sessionId}:${game.userId}` + }); +} + + +/* + * ============================================================ + * BET BUTTONS + * ============================================================ + */ + +function buildBetButtons() { + return [ + new ActionRowBuilder() + .addComponents( + ...BLACKJACK_BETS.map( + bet => + new ButtonBuilder() + .setCustomId( + `nutBlackjackBet${bet}` + ) + .setLabel( + bet.toLocaleString() + ) + .setEmoji( + "🥜" + ) + .setStyle( + bet >= + 500 + ? ButtonStyle.Primary + : ButtonStyle.Secondary + ) + ) + ), + + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubBack" + ) + .setLabel( + "Nut Hub" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +/* + * ============================================================ + * GAME BUTTONS + * ============================================================ + */ + +function buildGameButtons( + game +) { + const canDouble = + game.playerCards.length === + 2 + && + !game.doubled; + + + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutBlackjackHit" + ) + .setLabel( + "Hit" + ) + .setEmoji( + "➕" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutBlackjackStand" + ) + .setLabel( + "Stand" + ) + .setEmoji( + "✋" + ) + .setStyle( + ButtonStyle.Success + ), + + new ButtonBuilder() + .setCustomId( + "nutBlackjackDouble" + ) + .setLabel( + "Double" + ) + .setEmoji( + "💰" + ) + .setDisabled( + !canDouble + ) + .setStyle( + ButtonStyle.Danger + ) + ) + ]; +} + + +/* + * ============================================================ + * RESULT BUTTONS + * ============================================================ + */ + +function buildResultButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubBlackjack" + ) + .setLabel( + "Play Again" + ) + .setEmoji( + "🃏" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutHubBack" + ) + .setLabel( + "Nut Hub" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +/* + * ============================================================ + * DECK + * ============================================================ + */ + +function createDeck() { + const suits = [ + "♠️", + "♥️", + "♦️", + "♣️" + ]; + + + const ranks = [ + "A", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "J", + "Q", + "K" + ]; + + + const deck = []; + + + for ( + const suit + of suits + ) { + for ( + const rank + of ranks + ) { + deck.push({ + rank, + suit + }); + } + } + + + return deck; +} + + +/* + * Fisher-Yates shuffle. + */ + +function shuffleDeck( + deck +) { + for ( + let i = + deck.length - + 1; + i > + 0; + i-- + ) { + const j = + Math.floor( + Math.random() * + ( + i + + 1 + ) + ); + + + [ + deck[i], + deck[j] + ] = [ + deck[j], + deck[i] + ]; + } +} + + +/* + * ============================================================ + * HAND VALUE + * ============================================================ + */ + +function getHandValue( + cards +) { + let total = + 0; + + + let aces = + 0; + + + for ( + const card + of cards + ) { + if ( + card.rank === + "A" + ) { + total += + 11; + + + aces++; + } + + + else if ( + [ + "J", + "Q", + "K" + ].includes( + card.rank + ) + ) { + total += + 10; + } + + + else { + total += + Number( + card.rank + ); + } + } + + + /* + * Convert Aces from 11 → 1 as required. + */ + + while ( + total > + 21 + && + aces > + 0 + ) { + total -= + 10; + + + aces--; + } + + + return total; +} + + +function isBlackjack( + cards +) { + return ( + cards.length === + 2 + && + getHandValue( + cards + ) === + 21 + ); +} + + +/* + * ============================================================ + * CARD DISPLAY + * ============================================================ + */ + +function formatCard( + card +) { + return `**${card.rank}${card.suit}**`; +} + + +function formatCards( + cards +) { + return cards + .map( + formatCard + ) + .join( + " " + ); +} + + +/* + * ============================================================ + * ACTIVE SESSION + * ============================================================ + */ + +async function getActiveBlackjack( + userId +) { + const { + db + } = + getCollections(); + + + return db + .collection( + SESSION_COLLECTION + ) + .findOne({ + userId, + + state: + "Playing" + }); +} + + +/* + * ============================================================ + * GET SESSION FROM MESSAGE + * ============================================================ + */ + +async function getBlackjackFromInteraction( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? + []; + + + if ( + parts[0] !== + "nutBlackjack" + ) { + return null; + } + + + const sessionId = + parts[1]; + + + const { + db + } = + getCollections(); + + + return db + .collection( + SESSION_COLLECTION + ) + .findOne({ + sessionId, + + state: + "Playing" + }); +} + + +/* + * ============================================================ + * UPDATE SESSION + * ============================================================ + */ + +async function updateBlackjackSession( + sessionId, + fields +) { + const { + db + } = + getCollections(); + + + return db + .collection( + SESSION_COLLECTION + ) + .updateOne( + { + sessionId, + + state: + "Playing" + }, + { + $set: + fields + } + ); +} + + +/* + * ============================================================ + * SESSION OWNERSHIP + * ============================================================ + */ + +function getBlackjackSessionOwner( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? + []; + + + /* + * Nut Hub: + * + * nutHub: + */ + + if ( + parts[0] === + "nutHub" + ) { + return parts[1]; + } + + + /* + * Blackjack menu/result: + * + * nutBlackjackMenu: + */ + + if ( + parts[0] === + "nutBlackjackMenu" + ) { + return parts[1]; + } + + + /* + * Active Blackjack: + * + * nutBlackjack:: + */ + + if ( + parts[0] === + "nutBlackjack" + ) { + return parts[2]; + } + + + return null; +} + + +/* + * ============================================================ + * RESULT TITLES + * ============================================================ + */ + +function getResultTitle( + result +) { + switch ( + result + ) { + case "Blackjack": + return "🃏 BLACKJACK!"; + + + case "Win": + return "🎉 Du gewinnst!"; + + + case "DealerBust": + return "💥 Dealer Bust!"; + + + case "Push": + return "🤝 Push"; + + + case "Bust": + return "💥 Bust!"; + + + case "DealerBlackjack": + return "🃏 Dealer Blackjack"; + + + default: + return "🃏 Dealer gewinnt"; + } +} + + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + +module.exports = { + showNutBlackjack, + startNutBlackjack, + hitNutBlackjack, + standNutBlackjack, + doubleNutBlackjack +}; \ No newline at end of file diff --git a/utils/nutEconomy.js b/utils/nutEconomy.js new file mode 100644 index 0000000..4d0c3bb --- /dev/null +++ b/utils/nutEconomy.js @@ -0,0 +1,1013 @@ +const { + MessageFlags +} = require("discord.js"); + +const { + mClient +} = require("../index"); + + +/* + * ============================================================ + * DATABASE + * ============================================================ + */ + +const DATABASE_NAME = + process.env.M_DB; + + +/* + * Persistent user-owned Nut balance. + * + * Existing schema is preserved: + * + * { + * userId: "123456789", + * nuts: 500 + * } + */ +const NUT_COLLECTION = + "items_nuts"; + + +/* + * Unified per-user cooldown state. + * + * Example: + * + * { + * userId: "123456789", + * + * nut: 1787000000, + * steal: 1787000000, + * heist: 1787000000, + * blackjack: 1787000000, + * roulette: 1787000000 + * } + */ +const COOLDOWN_COLLECTION = + "items_cooldowns"; + + +/* + * ============================================================ + * COLLECTION ACCESS + * ============================================================ + */ + +function getCollections() { + if (!DATABASE_NAME) { + throw new Error( + "M_DB is not configured in .env" + ); + } + + + const db = + mClient.db( + DATABASE_NAME + ); + + + return { + db, + + nuts: + db.collection( + NUT_COLLECTION + ), + + cooldowns: + db.collection( + COOLDOWN_COLLECTION + ) + }; +} + + +/* + * ============================================================ + * UNIX TIME + * ============================================================ + */ + +function getUnixTime() { + return Math.floor( + Date.now() / + 1000 + ); +} + + +/* + * ============================================================ + * GET NUT BALANCE + * ============================================================ + */ + +async function getNutBalance( + userId +) { + const { + nuts + } = + getCollections(); + + + const document = + await nuts.findOne({ + userId: + userId + }); + + + return Math.max( + 0, + document?.nuts ?? + 0 + ); +} + + +/* + * ============================================================ + * CHANGE NUT BALANCE + * ============================================================ + * + * Positive: + * + * changeNuts(userId, 50) + * + * Negative: + * + * changeNuts(userId, -50) + * + * Negative changes are protected against dropping + * the balance below zero. + */ + +async function changeNuts( + userId, + amount +) { + if ( + !Number.isSafeInteger( + amount + ) + ) { + throw new Error( + "INVALID_NUT_AMOUNT" + ); + } + + + if ( + amount === + 0 + ) { + return getNutBalance( + userId + ); + } + + + const { + nuts + } = + getCollections(); + + + /* + * Add Nuts. + */ + + if ( + amount > + 0 + ) { + await nuts.updateOne( + { + userId: + userId + }, + { + $inc: { + nuts: + amount + }, + + $setOnInsert: { + userId: + userId + } + }, + { + upsert: + true + } + ); + + + return getNutBalance( + userId + ); + } + + + /* + * Remove Nuts atomically. + */ + + const removeAmount = + Math.abs( + amount + ); + + + const result = + await nuts.updateOne( + { + userId: + userId, + + nuts: { + $gte: + removeAmount + } + }, + { + $inc: { + nuts: + -removeAmount + } + } + ); + + + if ( + result.modifiedCount !== + 1 + ) { + throw new Error( + "INSUFFICIENT_NUTS" + ); + } + + + return getNutBalance( + userId + ); +} + + +/* + * ============================================================ + * TRANSFER NUTS + * ============================================================ + * + * No Mongo transaction required. + * + * Source debit is atomic. + * + * If destination credit fails, a best-effort + * compensating credit is sent back to the source. + */ + +async function transferNuts( + fromuserId, + touserId, + amount +) { + if ( + fromuserId === + touserId + ) { + throw new Error( + "SAME_ACCOUNT" + ); + } + + + if ( + !Number.isSafeInteger( + amount + ) + || + amount <= + 0 + ) { + throw new Error( + "INVALID_NUT_AMOUNT" + ); + } + + + const { + nuts + } = + getCollections(); + + + /* + * Atomic source debit. + */ + + const debit = + await nuts.updateOne( + { + userId: + fromuserId, + + nuts: { + $gte: + amount + } + }, + { + $inc: { + nuts: + -amount + } + } + ); + + + if ( + debit.modifiedCount !== + 1 + ) { + throw new Error( + "INSUFFICIENT_NUTS" + ); + } + + + /* + * Destination credit. + */ + + try { + await nuts.updateOne( + { + userId: + touserId + }, + { + $inc: { + nuts: + amount + }, + + $setOnInsert: { + userId: + touserId + } + }, + { + upsert: + true + } + ); + + } catch (error) { + /* + * Best-effort compensation. + */ + + try { + await nuts.updateOne( + { + userId: + fromuserId + }, + { + $inc: { + nuts: + amount + }, + + $setOnInsert: { + userId: + fromuserId + } + }, + { + upsert: + true + } + ); + + } catch (refundError) { + console.error( + "CRITICAL: Nut transfer compensation failed:", + { + fromuserId, + touserId, + amount, + refundError + } + ); + } + + + throw error; + } + + + return { + from: + fromuserId, + + to: + touserId, + + amount + }; +} + + +/* + * ============================================================ + * GET COOLDOWN + * ============================================================ + * + * Returns the Unix timestamp at which the named + * cooldown expires. + * + * Returns 0 if it does not exist. + */ + +async function getCooldown( + userId, + cooldownName +) { + validateCooldownName( + cooldownName + ); + + + const { + cooldowns + } = + getCollections(); + + + const document = + await cooldowns.findOne( + { + userId + }, + { + projection: { + [cooldownName]: + 1 + } + } + ); + + + return Number( + document?.[ + cooldownName + ] + ?? + 0 + ); +} + + +/* + * ============================================================ + * SET COOLDOWN + * ============================================================ + */ + +async function setCooldown( + userId, + cooldownName, + durationSeconds +) { + validateCooldownName( + cooldownName + ); + + + validateCooldownDuration( + durationSeconds + ); + + + const { + cooldowns + } = + getCollections(); + + + const next = + getUnixTime() + + durationSeconds; + + + await cooldowns.updateOne( + { + userId + }, + { + $set: { + [cooldownName]: + next + }, + + $setOnInsert: { + userId + } + }, + { + upsert: + true + } + ); + + + return next; +} + + +/* + * ============================================================ + * CLAIM COOLDOWN + * ============================================================ + * + * Atomic helper for actions where double-clicking must + * not allow the same action to resolve twice. + * + * Returns: + * + * { + * claimed: true, + * next: 1787000000 + * } + * + * or: + * + * { + * claimed: false, + * next: 1787000000 + * } + */ + +async function claimCooldown( + userId, + cooldownName, + durationSeconds +) { + validateCooldownName( + cooldownName + ); + + + validateCooldownDuration( + durationSeconds + ); + + + const { + cooldowns + } = + getCollections(); + + + const now = + getUnixTime(); + + + const next = + now + + durationSeconds; + + + /* + * ======================================================== + * ENSURE USER DOCUMENT EXISTS + * ======================================================== + * + * This creates: + * + * { + * userId: "..." + * } + * + * only when the user does not already have a + * cooldown document. + */ + + await cooldowns.updateOne( + { + userId + }, + { + $setOnInsert: { + userId + } + }, + { + upsert: + true + } + ); + + + /* + * ======================================================== + * ATOMIC COOLDOWN CLAIM + * ======================================================== + * + * Mongo will only update the document when: + * + * - the cooldown does not exist + * OR + * - the cooldown has expired + */ + + const result = + await cooldowns.updateOne( + { + userId, + + $or: [ + { + [cooldownName]: { + $exists: + false + } + }, + + { + [cooldownName]: { + $lte: + now + } + } + ] + }, + { + $set: { + [cooldownName]: + next + } + } + ); + + + /* + * We successfully claimed it. + */ + + if ( + result.modifiedCount === + 1 + ) { + return { + claimed: + true, + + next + }; + } + + + /* + * ======================================================== + * COOLDOWN IS ACTIVE + * ======================================================== + */ + + const current = + await getCooldown( + userId, + cooldownName + ); + + + return { + claimed: + false, + + next: + current + }; +} + +/* + * ============================================================ + * CLEAR COOLDOWN + * ============================================================ + */ + +async function clearCooldown( + userId, + cooldownName +) { + validateCooldownName( + cooldownName + ); + + + const { + cooldowns + } = + getCollections(); + + + await cooldowns.updateOne( + { + userId + }, + { + $unset: { + [cooldownName]: + "" + } + } + ); +} + + +/* + * ============================================================ + * TEMPORARY EPHEMERAL REPLY + * ============================================================ + */ + +async function temporaryEphemeralReply( + interaction, + options, + deleteAfter = + 6000 +) { + if ( + interaction.replied || + interaction.deferred + ) { + return temporaryEphemeralFollowUp( + interaction, + options, + deleteAfter + ); + } + + + await interaction.reply({ + ...options, + + flags: + MessageFlags.Ephemeral + }); + + + if ( + deleteAfter > + 0 + ) { + setTimeout( + async () => { + try { + await interaction.deleteReply(); + } catch {} + }, + deleteAfter + ); + } +} + + +/* + * ============================================================ + * TEMPORARY EPHEMERAL FOLLOW-UP + * ============================================================ + */ + +async function temporaryEphemeralFollowUp( + interaction, + options, + deleteAfter = + 6000 +) { + const message = + await interaction.followUp({ + ...options, + + flags: + MessageFlags.Ephemeral, + + fetchReply: + true + }); + + + if ( + deleteAfter > + 0 + ) { + setTimeout( + async () => { + try { + await interaction.webhook.deleteMessage( + message.id + ); + } catch {} + }, + deleteAfter + ); + } + + + return message; +} + + +/* + * ============================================================ + * RANDOM INTEGER + * ============================================================ + * + * Inclusive. + */ + +function randomInteger( + min, + max +) { + const minimum = + Math.ceil( + min + ); + + + const maximum = + Math.floor( + max + ); + + + return Math.floor( + Math.random() * + ( + maximum - + minimum + + 1 + ) + ) + + + minimum; +} + + +/* + * ============================================================ + * RANDOM FLOAT + * ============================================================ + */ + +function randomFloat( + min, + max +) { + return ( + Math.random() * + ( + max - + min + ) + ) + + + min; +} + + +/* + * ============================================================ + * RANDOM ITEM + * ============================================================ + */ + +function randomItem( + items +) { + if ( + !Array.isArray( + items + ) + || + items.length === + 0 + ) { + return undefined; + } + + + return items[ + Math.floor( + Math.random() * + items.length + ) + ]; +} + + +/* + * ============================================================ + * FORMAT PERCENTAGE + * ============================================================ + */ + +function formatPercentage( + value, + decimals = + 1 +) { + return ( + ( + value * + 100 + ) + .toFixed( + decimals + ) + + + "%" + ); +} + + +/* + * ============================================================ + * VALIDATION + * ============================================================ + */ + +function validateCooldownName( + cooldownName +) { + if ( + typeof cooldownName !== + "string" + || + !/^[A-Za-z0-9_]+$/.test( + cooldownName + ) + ) { + throw new Error( + "INVALID_COOLDOWN_NAME" + ); + } +} + + +function validateCooldownDuration( + durationSeconds +) { + if ( + !Number.isSafeInteger( + durationSeconds + ) + || + durationSeconds < + 0 + ) { + throw new Error( + "INVALID_COOLDOWN_DURATION" + ); + } +} + + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + +module.exports = { + getCollections, + + getNutBalance, + changeNuts, + transferNuts, + + getCooldown, + setCooldown, + claimCooldown, + clearCooldown, + + temporaryEphemeralReply, + temporaryEphemeralFollowUp, + + getUnixTime, + + randomInteger, + randomFloat, + randomItem, + formatPercentage +}; \ No newline at end of file diff --git a/utils/nutHeist.js b/utils/nutHeist.js new file mode 100644 index 0000000..bab5092 --- /dev/null +++ b/utils/nutHeist.js @@ -0,0 +1,777 @@ +// utils/nutHeist.js + +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags +} = require("discord.js"); + +const { + getNutBalance, + transferNuts, + claimCooldown +} = require("./nutEconomy"); + + +/* + * ============================================================ + * CONFIG + * ============================================================ + */ + +const HEIST_COOLDOWN = + 60 * 60; + +const HEIST_ACTIONS = { + Sneak: { + label: + "Sneak", + + emoji: + "🥷", + + successChance: + 0.55, + + minReward: + 10, + + maxReward: + 100, + + minLoss: + 5, + + maxLoss: + 40 + }, + + Hack: { + label: + "Hack", + + emoji: + "💻", + + successChance: + 0.40, + + minReward: + 50, + + maxReward: + 250, + + minLoss: + 20, + + maxLoss: + 100 + }, + + Smash: { + label: + "Smash", + + emoji: + "💥", + + successChance: + 0.25, + + minReward: + 150, + + maxReward: + 500, + + minLoss: + 50, + + maxLoss: + 200 + } +}; + + +/* + * ============================================================ + * HELPERS + * ============================================================ + */ + +function randomInteger( + min, + max +) { + return Math.floor( + Math.random() * + ( + max - + min + + 1 + ) + ) + min; +} + + +function getHeistOwner( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + const parts = + footer?.split(":") + ?? + []; + + + /* + * Coming from Nut Hub. + * + * nutHub: + */ + + if ( + parts[0] === + "nutHub" + ) { + return parts[1]; + } + + + /* + * Coming from Heist. + * + * nutHeist: + */ + + if ( + parts[0] === + "nutHeist" + ) { + return parts[1]; + } + + + return null; +} + + +function buildHeistButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHeistSneak" + ) + .setLabel( + "Sneak" + ) + .setEmoji( + "🥷" + ) + .setStyle( + ButtonStyle.Success + ), + + new ButtonBuilder() + .setCustomId( + "nutHeistHack" + ) + .setLabel( + "Hack" + ) + .setEmoji( + "💻" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutHeistSmash" + ) + .setLabel( + "Smash" + ) + .setEmoji( + "💥" + ) + .setStyle( + ButtonStyle.Danger + ) + ), + + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubBack" + ) + .setLabel( + "Nut Hub" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +function buildReturnButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubHeist" + ) + .setLabel( + "Heist Menu" + ) + .setEmoji( + "🏦" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutHubBack" + ) + .setLabel( + "Nut Hub" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +/* + * ============================================================ + * TEMPORARY EPHEMERAL MESSAGE + * ============================================================ + */ + +async function temporaryEphemeralReply( + interaction, + options +) { + const reply = + await interaction.reply({ + ...options, + + flags: + MessageFlags.Ephemeral + }); + + + /* + * Ephemeral messages cannot truly be given + * a Discord-side expiration time. + * + * We attempt to delete the reply after 5 seconds. + */ + + setTimeout( + async () => { + try { + await interaction.deleteReply(); + } + + catch { + // Ignore expired/deleted interaction. + } + }, + 5000 + ); + + + return reply; +} + + +/* + * ============================================================ + * SHOW HEIST MENU + * ============================================================ + */ + +async function showNutHeist( + interaction +) { + const ownerId = + getHeistOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht deine Nut-Hub-Session." + } + ); + } + + + await interaction.deferUpdate(); + + + const balance = + await getNutBalance( + interaction.user.id + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "🏦 Nut Vault Heist" + ) + + .setDescription( + [ + "Arthonor bewacht seinen Vorrat an Nüssen.", + "", + "Wähle deinen Ansatz.", + "", + "### 🥷 Sneak", + "**55%** Erfolgschance", + "Kleine Beute, geringes Risiko.", + "", + "### 💻 Hack", + "**40%** Erfolgschance", + "Mittlere Beute, mittleres Risiko.", + "", + "### 💥 Smash", + "**25%** Erfolgschance", + "Große Beute, hohes Risiko.", + "", + `Ein Heist hat **${Math.floor(HEIST_COOLDOWN / 60)} Minuten Cooldown**.` + ].join("\n") + ) + + .addFields({ + name: + "Deine Nüsse", + + value: + `**${balance.toLocaleString()} 🥜**` + }) + + .setFooter({ + text: + `nutHeist:${interaction.user.id}` + }); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: + buildHeistButtons() + }); +} + + +/* + * ============================================================ + * PERFORM HEIST + * ============================================================ + */ + +async function performNutHeist( + interaction, + actionName +) { + const ownerId = + getHeistOwner( + interaction + ); + + + /* + * Prevent another player from using + * somebody else's Heist menu. + */ + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht dein Heist." + } + ); + } + + + const action = + HEIST_ACTIONS[ + actionName + ]; + + + if ( + !action + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Unbekannte Heist-Aktion." + } + ); + } + + + /* + * Acknowledge immediately. + */ + + await interaction.deferUpdate(); + + + /* + * ======================================================== + * CLAIM SHARED HEIST COOLDOWN + * ======================================================== + * + * Sneak / Hack / Smash all use the same field: + * + * items_cooldowns + * + * { + * userId: "...", + * nut: ..., + * heist: ... + * } + */ + + const cooldown = + await claimCooldown( + interaction.user.id, + "heist", + HEIST_COOLDOWN + ); + + + if ( + !cooldown.claimed + ) { + const embed = + new EmbedBuilder() + .setTitle( + "🏦 Vault Locked Down" + ) + + .setDescription( + [ + "Arthonor ist noch auf der Hut.", + "", + `Du kannst wieder einen Heist versuchen.` + ].join("\n") + ) + + .setFooter({ + text: + `nutHeist:${interaction.user.id}` + }); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: + buildReturnButtons() + }); + } + + + /* + * ======================================================== + * ROLL RESULT + * ======================================================== + */ + + const success = + Math.random() < + action.successChance; + + + const playerId = + interaction.user.id; + + + /* + * Arthonor himself acts as the vault. + */ + + const vaultId = + interaction.client.user.id; + + + /* + * ======================================================== + * SUCCESS + * ======================================================== + */ + + if ( + success + ) { + let reward = + randomInteger( + action.minReward, + action.maxReward + ); + + + /* + * Never steal more Nuts than + * Arthonor currently owns. + */ + + const vaultBalance = + await getNutBalance( + vaultId + ); + + + reward = + Math.min( + reward, + vaultBalance + ); + + + if ( + reward <= + 0 + ) { + const embed = + new EmbedBuilder() + .setTitle( + "🏦 The Vault Is Empty" + ) + + .setDescription( + [ + `${action.emoji} **${action.label}** succeeded...`, + "", + "but Arthonor's Nut stash is empty.", + "", + "You escape with nothing." + ].join("\n") + ) + + .setFooter({ + text: + `nutHeist:${playerId}` + }); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: + buildReturnButtons() + }); + } + + + await transferNuts( + vaultId, + playerId, + reward + ); + + + const balance = + await getNutBalance( + playerId + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "💰 Heist Successful!" + ) + + .setDescription( + [ + `${action.emoji} **${action.label}** worked!`, + "", + `You stole **${reward.toLocaleString()} 🥜** from Arthonor's stash.` + ].join("\n") + ) + + .addFields({ + name: + "Deine Nüsse", + + value: + `**${balance.toLocaleString()} 🥜**` + }) + + .setFooter({ + text: + `nutHeist:${playerId}` + }); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: + buildReturnButtons() + }); + } + + + /* + * ======================================================== + * FAILURE + * ======================================================== + */ + + let loss = + randomInteger( + action.minLoss, + action.maxLoss + ); + + + /* + * Don't take more than the player owns. + */ + + const playerBalance = + await getNutBalance( + playerId + ); + + + loss = + Math.min( + loss, + playerBalance + ); + + + if ( + loss > + 0 + ) { + await transferNuts( + playerId, + vaultId, + loss + ); + } + + + const balance = + await getNutBalance( + playerId + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "🚨 Heist Failed!" + ) + + .setDescription( + loss > 0 + ? [ + `${action.emoji} **${action.label}** went wrong.`, + "", + "Arthonor caught you.", + "", + `You lost **${loss.toLocaleString()} 🥜**.` + ].join("\n") + + : [ + `${action.emoji} **${action.label}** went wrong.`, + "", + "Arthonor caught you...", + "", + "but you didn't have any Nuts for him to take." + ].join("\n") + ) + + .addFields({ + name: + "Deine Nüsse", + + value: + `**${balance.toLocaleString()} 🥜**` + }) + + .setFooter({ + text: + `nutHeist:${playerId}` + }); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: + buildReturnButtons() + }); +} + + +/* + * ============================================================ + * EXPORTS + * ============================================================ + */ + +module.exports = { + showNutHeist, + performNutHeist +}; \ No newline at end of file diff --git a/utils/nutHub.js b/utils/nutHub.js new file mode 100644 index 0000000..d07af86 --- /dev/null +++ b/utils/nutHub.js @@ -0,0 +1,240 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags +} = require("discord.js"); + +const { + getNutBalance +} = require("./nutEconomy"); + + +/* + * ============================================================ + * SHOW HUB + * ============================================================ + */ + +async function showNutHub( + interaction +) { + const balance = + await getNutBalance( + interaction.user.id + ); + + + const embed = + buildNutHubEmbed( + interaction.user, + balance + ); + + + const components = + buildNutHubButtons(); + + + /* + * Slash command / fresh interaction. + */ + + if ( + !interaction.replied && + !interaction.deferred + ) { + return interaction.reply({ + embeds: [ + embed + ], + + components + }); + } + + + /* + * Existing game/menu message. + */ + + return interaction.update({ + embeds: [ + embed + ], + + components + }); +} + + +/* + * ============================================================ + * EMBED + * ============================================================ + */ + +function buildNutHubEmbed( + user, + balance +) { + return new EmbedBuilder() + .setTitle( + "🥜 Nut Hub" + ) + + .setDescription( + [ + `Willkommen, ${user}!`, + "", + "Hier werden Nüsse gewonnen, verloren und gelegentlich konfisziert.", + "", + "### 🎰 Nut Slots", + "Dreh die Walzen und jag den Jackpot.", + "", + "### 🃏 Nut Blackjack", + "Schlag den Dealer ohne über 21 zu kommen.", + "", + "### 🎡 Nut Roulette", + "Setz auf Rot, Schwarz, Ungerade, Gerade oder Zero.", + "", + "### 🏦 Nut Heist", + "Versuch dich direkt am Nut Vault des Bots.", + "", + "*Choose wisely. The house has very large nuts.*" + ].join("\n") + ) + + .addFields({ + name: + "Deine Nüsse", + + value: + `**${balance.toLocaleString()} 🥜**` + }) + + .setThumbnail( + user.displayAvatarURL({ + size: + 256 + }) + ) + + .setFooter({ + text: + `nutHub:${user.id}` + }); +} + + +/* + * ============================================================ + * BUTTONS + * ============================================================ + */ + +function buildNutHubButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubSlots" + ) + .setLabel( + "Slots" + ) + .setEmoji( + "🎰" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutHubBlackjack" + ) + .setLabel( + "Blackjack" + ) + .setEmoji( + "🃏" + ) + .setStyle( + ButtonStyle.Success + ), + + new ButtonBuilder() + .setCustomId( + "nutHubRoulette" + ) + .setLabel( + "Roulette" + ) + .setEmoji( + "🎡" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutHubHeist" + ) + .setLabel( + "Vault Heist" + ) + .setEmoji( + "🏦" + ) + .setStyle( + ButtonStyle.Danger + ) + ) + ]; +} + + +/* + * ============================================================ + * OWNERSHIP + * ============================================================ + */ + +function getNutHubOwner( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? + []; + + + if ( + parts[0] !== + "nutHub" + ) { + return null; + } + + + return parts[1]; +} + + +module.exports = { + showNutHub, + buildNutHubEmbed, + buildNutHubButtons, + getNutHubOwner +}; \ No newline at end of file diff --git a/utils/nutRoulette.js b/utils/nutRoulette.js new file mode 100644 index 0000000..a18423d --- /dev/null +++ b/utils/nutRoulette.js @@ -0,0 +1,975 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require("discord.js"); + +const { + getNutBalance, + transferNuts, + temporaryEphemeralReply +} = require("./nutEconomy"); + + +const ROULETTE_BETS = [ + 10, + 50, + 100, + 500, + 1000 +]; + + +const CHOICES = { + Red: { + name: + "Red", + + emoji: + "🔴", + + multiplier: + 2 + }, + + Black: { + name: + "Black", + + emoji: + "⚫", + + multiplier: + 2 + }, + + Odd: { + name: + "Odd", + + emoji: + "1️⃣", + + multiplier: + 2 + }, + + Even: { + name: + "Even", + + emoji: + "2️⃣", + + multiplier: + 2 + }, + + Zero: { + name: + "Zero", + + emoji: + "🟢", + + multiplier: + 36 + } +}; + + +const RED_NUMBERS = + new Set([ + 1, + 3, + 5, + 7, + 9, + 12, + 14, + 16, + 18, + 19, + 21, + 23, + 25, + 27, + 30, + 32, + 34, + 36 + ]); + + +/* + * ============================================================ + * MENU + * ============================================================ + */ + +async function showNutRoulette( + interaction +) { + const ownerId = + getRouletteOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht deine Nut-Hub-Session." + } + ); + } + + + const balance = + await getNutBalance( + interaction.user.id + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "🎡 Nut Roulette" + ) + + .setDescription( + [ + "European Roulette — **0 bis 36**.", + "", + "🔴 **Red** — x2", + "⚫ **Black** — x2", + "1️⃣ **Odd** — x2", + "2️⃣ **Even** — x2", + "🟢 **Zero** — x36", + "", + "Choose your wager." + ].join("\n") + ) + + .addFields({ + name: + "Deine Nüsse", + + value: + `${balance.toLocaleString()} 🥜` + }) + + .setFooter({ + text: + `nutRouletteMenu:${interaction.user.id}` + }); + + + return interaction.update({ + embeds: [ + embed + ], + + components: + buildBetButtons() + }); +} + + +/* + * ============================================================ + * WAGER + * ============================================================ + */ + +async function chooseNutRouletteBet( + interaction, + bet +) { + const ownerId = + getRouletteOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht dein Roulette-Tisch." + } + ); + } + + + if ( + !ROULETTE_BETS.includes( + bet + ) + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Dieser Einsatz ist ungültig." + } + ); + } + + + const balance = + await getNutBalance( + interaction.user.id + ); + + + if ( + balance < + bet + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `Du brauchst **${bet.toLocaleString()} Nüsse**.` + } + ); + } + + + const embed = + new EmbedBuilder() + .setTitle( + "🎡 Nut Roulette" + ) + + .setDescription( + [ + `Einsatz: **${bet.toLocaleString()} 🥜**`, + "", + "Wo sollen deine Nüsse landen?", + "", + "🔴 Red", + "⚫ Black", + "1️⃣ Odd", + "2️⃣ Even", + "🟢 Zero" + ].join("\n") + ) + + .setFooter({ + text: + `nutRouletteBet:${interaction.user.id}:${bet}` + }); + + + return interaction.update({ + embeds: [ + embed + ], + + components: + buildChoiceButtons() + }); +} + + +/* + * ============================================================ + * PLAY + * ============================================================ + */ + +async function playNutRoulette( + interaction, + choiceKey +) { + const choice = + CHOICES[ + choiceKey + ]; + + + if (!choice) { + return temporaryEphemeralReply( + interaction, + { + content: + "Diese Roulette-Wette existiert nicht." + } + ); + } + + + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? + []; + + + if ( + parts[0] !== + "nutRouletteBet" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Diese Roulette-Wette ist nicht mehr gültig." + } + ); + } + + + const ownerId = + parts[1]; + + + const bet = + Number( + parts[2] + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht dein Roulette-Tisch." + } + ); + } + + + const playerId = + interaction.user.id; + + + const houseId = + interaction.client.user.id; + + + const [ + playerBalance, + houseBalance + ] = + await Promise.all([ + getNutBalance( + playerId + ), + + getNutBalance( + houseId + ) + ]); + + + if ( + playerBalance < + bet + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Du hast nicht mehr genug Nüsse." + } + ); + } + + + const maxPayout = + bet * + choice.multiplier; + + + if ( + houseBalance + + bet < + maxPayout + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Der Nut Vault kann diesen Einsatz nicht absichern." + } + ); + } + + + await transferNuts( + playerId, + houseId, + bet + ); + + + /* + * Pseudo animation. + */ + + await interaction.update({ + embeds: [ + buildSpinEmbed( + choice, + bet, + randomRouletteNumber(), + "Das Rad beginnt sich zu drehen..." + ) + ], + + components: [] + }); + + + await delay( + 650 + ); + + + await interaction.editReply({ + embeds: [ + buildSpinEmbed( + choice, + bet, + randomRouletteNumber(), + "Round and round..." + ) + ] + }); + + + await delay( + 650 + ); + + + await interaction.editReply({ + embeds: [ + buildSpinEmbed( + choice, + bet, + randomRouletteNumber(), + "Die Kugel wird langsamer..." + ) + ] + }); + + + await delay( + 750 + ); + + + const result = + randomRouletteNumber(); + + + const won = + didBetWin( + choiceKey, + result + ); + + + const payout = + won + ? bet * + choice.multiplier + : 0; + + + if ( + payout > + 0 + ) { + await transferNuts( + houseId, + playerId, + payout + ); + } + + + const balance = + await getNutBalance( + playerId + ); + + + const profit = + payout - + bet; + + + const embed = + new EmbedBuilder() + .setTitle( + won + ? "🎉 Nut Roulette — WIN!" + : "🎡 Nut Roulette" + ) + + .setDescription( + [ + "## Die Kugel landet auf...", + "", + `# ${formatRouletteNumber(result)}`, + "", + `${choice.emoji} Deine Wette: **${choice.name}**`, + "", + won + ? `🎉 **${payout.toLocaleString()} Nüsse gewonnen!**` + : "Das Haus gewinnt." + ].join("\n") + ) + + .addFields( + { + name: + "Einsatz", + + value: + `${bet.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Payout", + + value: + `${payout.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Profit", + + value: + `${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Deine Nüsse", + + value: + `${balance.toLocaleString()} 🥜` + } + ) + + .setFooter({ + text: + `nutRouletteResult:${playerId}` + }); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: + buildResultButtons() + }); +} + + +/* + * ============================================================ + * RULES + * ============================================================ + */ + +function didBetWin( + choice, + number +) { + if ( + number === + 0 + ) { + return choice === + "Zero"; + } + + + switch (choice) { + case "Red": + return RED_NUMBERS.has( + number + ); + + case "Black": + return !RED_NUMBERS.has( + number + ); + + case "Odd": + return number % + 2 === + 1; + + case "Even": + return number % + 2 === + 0; + + default: + return false; + } +} + + +function formatRouletteNumber( + number +) { + if ( + number === + 0 + ) { + return "🟢 0"; + } + + + if ( + RED_NUMBERS.has( + number + ) + ) { + return `🔴 ${number}`; + } + + + return `⚫ ${number}`; +} + + +/* + * ============================================================ + * DISPLAY + * ============================================================ + */ + +function buildSpinEmbed( + choice, + bet, + number, + text +) { + return new EmbedBuilder() + .setTitle( + "🎡 Nut Roulette" + ) + + .setDescription( + [ + "## 🎡 Spinning...", + "", + `### ${formatRouletteNumber(number)}`, + "", + `**${text}**` + ].join("\n") + ) + + .addFields( + { + name: + "Wette", + + value: + `${choice.emoji} ${choice.name}`, + + inline: + true + }, + + { + name: + "Einsatz", + + value: + `${bet.toLocaleString()} 🥜`, + + inline: + true + } + ); +} + + +/* + * ============================================================ + * BUTTONS + * ============================================================ + */ + +function buildBetButtons() { + return [ + new ActionRowBuilder() + .addComponents( + ...ROULETTE_BETS.map( + bet => + new ButtonBuilder() + .setCustomId( + `nutRouletteBet${bet}` + ) + .setLabel( + bet.toLocaleString() + ) + .setEmoji( + "🥜" + ) + .setStyle( + bet >= + 500 + ? ButtonStyle.Primary + : ButtonStyle.Secondary + ) + ) + ), + + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubBack" + ) + .setLabel( + "Nut Hub" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +function buildChoiceButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutRouletteRed" + ) + .setLabel( + "Red" + ) + .setEmoji( + "🔴" + ) + .setStyle( + ButtonStyle.Danger + ), + + new ButtonBuilder() + .setCustomId( + "nutRouletteBlack" + ) + .setLabel( + "Black" + ) + .setEmoji( + "⚫" + ) + .setStyle( + ButtonStyle.Secondary + ), + + new ButtonBuilder() + .setCustomId( + "nutRouletteOdd" + ) + .setLabel( + "Odd" + ) + .setEmoji( + "1️⃣" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutRouletteEven" + ) + .setLabel( + "Even" + ) + .setEmoji( + "2️⃣" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutRouletteZero" + ) + .setLabel( + "Zero" + ) + .setEmoji( + "🟢" + ) + .setStyle( + ButtonStyle.Success + ) + ) + ]; +} + + +function buildResultButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubRoulette" + ) + .setLabel( + "Play Again" + ) + .setEmoji( + "🎡" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutHubBack" + ) + .setLabel( + "Nut Hub" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +/* + * ============================================================ + * OWNERSHIP + * ============================================================ + */ + +function getRouletteOwner( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? + []; + + + if ( + parts[0] === + "nutHub" + ) { + return parts[1]; + } + + + if ( + parts[0] === + "nutRouletteMenu" + || + parts[0] === + "nutRouletteResult" + ) { + return parts[1]; + } + + + if ( + parts[0] === + "nutRouletteBet" + ) { + return parts[1]; + } + + + return null; +} + + +function randomRouletteNumber() { + return Math.floor( + Math.random() * + 37 + ); +} + + +function delay( + ms +) { + return new Promise( + resolve => + setTimeout( + resolve, + ms + ) + ); +} + + +module.exports = { + showNutRoulette, + chooseNutRouletteBet, + playNutRoulette +}; \ No newline at end of file diff --git a/utils/nutSlots.js b/utils/nutSlots.js new file mode 100644 index 0000000..288d094 --- /dev/null +++ b/utils/nutSlots.js @@ -0,0 +1,795 @@ +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} = require("discord.js"); + +const { + getNutBalance, + transferNuts, + temporaryEphemeralReply, + randomItem +} = require("./nutEconomy"); + + +/* + * ============================================================ + * CONFIG + * ============================================================ + */ + +const SLOT_BETS = [ + 10, + 50, + 100, + 500, + 1000 +]; + + +/* + * ============================================================ + * NUT SYMBOLS + * ============================================================ + * + * All symbols are nut-themed. + * + * 🌰 Chestnut + * 🥜 Peanuts + * 🥥 Coconut + * 🍩 Doughnut + */ + +const SYMBOLS = [ + "🌰", + "🥜", + "🥥", + "🍩" +]; + + +/* + * Weighted reel. + * + * Chestnuts are the common symbol. + * Peanuts are less common. + * Coconuts are rare. + * Doughnuts are the jackpot symbol. + */ + +const REEL = [ + "🌰", + "🌰", + "🌰", + "🌰", + "🌰", + + "🥜", + "🥜", + "🥜", + "🥜", + + "🥥", + "🥥", + + "🍩" +]; + + +/* + * Triple-symbol payouts. + * + * Payout includes the original wager. + */ + +const PAYOUTS = { + "🌰": + 1.5, + + "🥜": + 2, + + "🥥": + 4, + + "🍩": + 10 +}; + + +/* + * ============================================================ + * SHOW SLOTS + * ============================================================ + */ + +async function showNutSlots( + interaction +) { + + const ownerId = + getNutSlotsOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht deine Nut-Hub-Session." + } + ); + } + + + /* + * Acknowledge the button immediately. + */ + await interaction.deferUpdate(); + + + const balance = + await getNutBalance( + interaction.user.id + ); + + + const embed = + new EmbedBuilder() + .setTitle( + "🎰 Nut Slots" + ) + + .setDescription( + [ + "Dreh die Walzen und jag den **Nut Jackpot**.", + "", + "### 🥜 Payouts", + "🌰 🌰 🌰 — **×1.5**", + "🥜 🥜 🥜 — **×2**", + "🥥 🥥 🥥 — **×4**", + "🍩 🍩 🍩 — **×10**", + "", + "Choose your wager." + ].join("\n") + ) + + .addFields({ + name: + "Deine Nüsse", + + value: + `**${balance.toLocaleString()} 🥜**` + }) + + .setFooter({ + text: + `nutSlots:${interaction.user.id}` + }); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: + buildSlotBetButtons() + }); +} + +/* + * ============================================================ + * PLAY + * ============================================================ + */ + +async function playNutSlots( + interaction, + bet +) { + if ( + !SLOT_BETS.includes( + bet + ) + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Dieser Einsatz ist nicht verfügbar." + } + ); + } + + + const ownerId = + getNutSlotsOwner( + interaction + ); + + + if ( + ownerId !== + interaction.user.id + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Das ist nicht deine Slots-Session." + } + ); + } + + + const playerId = + interaction.user.id; + + + const houseId = + interaction.client.user.id; + + + const [ + playerBalance, + houseBalance + ] = + await Promise.all([ + getNutBalance( + playerId + ), + + getNutBalance( + houseId + ) + ]); + + + if ( + playerBalance < + bet + ) { + return temporaryEphemeralReply( + interaction, + { + content: + `Du brauchst **${bet.toLocaleString()} Nüsse** für diesen Einsatz.` + } + ); + } + + + /* + * Maximum possible return = x10. + * + * After receiving the wager, the house must + * still be able to cover the total payout. + */ + + const maxPayout = + bet * + 10; + + + if ( + houseBalance + + bet < + maxPayout + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Der Nut Vault kann diesen Einsatz aktuell nicht absichern." + } + ); + } + + + try { + await transferNuts( + playerId, + houseId, + bet + ); + + } catch (error) { + if ( + error.message === + "INSUFFICIENT_NUTS" + ) { + return temporaryEphemeralReply( + interaction, + { + content: + "Dein Nut-Bestand hat sich verändert." + } + ); + } + + + throw error; + } + + + /* + * Initial animation state. + */ + + await interaction.update({ + embeds: [ + buildSpinEmbed( + bet, + [ + "❔", + "❔", + "❔" + ], + "Die Walzen drehen..." + ) + ], + + components: [] + }); + + + await delay( + 500 + ); + + + await interaction.editReply({ + embeds: [ + buildSpinEmbed( + bet, + [ + randomReelSymbol(), + "❔", + "❔" + ], + "Die erste Walze stoppt..." + ) + ] + }); + + + await delay( + 500 + ); + + + const first = + randomReelSymbol(); + + + const second = + randomReelSymbol(); + + + await interaction.editReply({ + embeds: [ + buildSpinEmbed( + bet, + [ + first, + second, + "❔" + ], + "Noch eine..." + ) + ] + }); + + + await delay( + 600 + ); + + + /* + * Final actual result. + * + * Generate all three here so animation values + * don't alter game odds. + */ + + const result = [ + randomReelSymbol(), + randomReelSymbol(), + randomReelSymbol() + ]; + + + const multiplier = + getSlotMultiplier( + result + ); + + + const payout = + Math.floor( + bet * + multiplier + ); + + + if ( + payout > + 0 + ) { + await transferNuts( + houseId, + playerId, + payout + ); + } + + + const balanceAfter = + await getNutBalance( + playerId + ); + + + const profit = + payout - + bet; + + + const embed = + new EmbedBuilder() + .setTitle( + multiplier > + 0 + ? "🎰 Nut Slots — WIN!" + : "🎰 Nut Slots" + ) + + .setDescription( + [ + "## " + + result.join( + " " + ), + "", + multiplier > + 0 + ? `🎉 **x${multiplier}**` + : "Die Nüsse bleiben beim Haus." + ].join("\n") + ) + + .addFields( + { + name: + "Einsatz", + + value: + `${bet.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Payout", + + value: + `${payout.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Profit", + + value: + `${profit >= 0 ? "+" : ""}${profit.toLocaleString()} 🥜`, + + inline: + true + }, + + { + name: + "Deine Nüsse", + + value: + `${balanceAfter.toLocaleString()} 🥜` + } + ) + + .setFooter({ + text: + `nutSlots:${interaction.user.id}` + }); + + + return interaction.editReply({ + embeds: [ + embed + ], + + components: + buildSlotResultButtons() + }); +} + + +/* + * ============================================================ + * PAYOUT + * ============================================================ + */ + +function getSlotMultiplier( + result +) { + const [ + first, + second, + third + ] = result; + + + /* + * Triple. + */ + + if ( + first === second && + second === third + ) { + return PAYOUTS[first] ?? 0; + } + + + /* + * Two matching symbols. + * + * Half the wager is returned. + */ + + if ( + first === second || + first === third || + second === third + ) { + return 0.5; + } + + + /* + * Nut Salad: + * + * Chestnut + Peanut + Coconut + * in any order. + * + * Returns the wager. + */ + + const nutSalad = + new Set( + result + ); + + + if ( + nutSalad.size === 3 && + nutSalad.has("🌰") && + nutSalad.has("🥜") && + nutSalad.has("🥥") + ) { + return 1; + } + + + return 0; +} + + +/* + * ============================================================ + * DISPLAY + * ============================================================ + */ + +function buildSpinEmbed( + bet, + symbols, + status +) { + return new EmbedBuilder() + .setTitle( + "🎰 Nut Slots" + ) + + .setDescription( + [ + "## " + + symbols.join( + " " + ), + "", + `**${status}**` + ].join("\n") + ) + + .addFields({ + name: + "Einsatz", + + value: + `${bet.toLocaleString()} 🥜` + }); +} + + +/* + * ============================================================ + * BUTTONS + * ============================================================ + */ + +function buildSlotBetButtons() { + return [ + new ActionRowBuilder() + .addComponents( + ...SLOT_BETS.map( + bet => + new ButtonBuilder() + .setCustomId( + `nutSlotsBet${bet}` + ) + .setLabel( + bet.toLocaleString() + ) + .setEmoji( + "🥜" + ) + .setStyle( + bet >= + 500 + ? ButtonStyle.Primary + : ButtonStyle.Secondary + ) + ) + ), + + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubBack" + ) + .setLabel( + "Nut Hub" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +function buildSlotResultButtons() { + return [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId( + "nutHubSlots" + ) + .setLabel( + "Play Again" + ) + .setEmoji( + "🎰" + ) + .setStyle( + ButtonStyle.Primary + ), + + new ButtonBuilder() + .setCustomId( + "nutHubBack" + ) + .setLabel( + "Nut Hub" + ) + .setEmoji( + "◀️" + ) + .setStyle( + ButtonStyle.Secondary + ) + ) + ]; +} + + +/* + * ============================================================ + * SESSION OWNER + * ============================================================ + */ + +function getNutSlotsOwner( + interaction +) { + const footer = + interaction.message + ?.embeds?.[0] + ?.footer + ?.text; + + + const parts = + footer?.split(":") + ?? + []; + + + if ( + parts[0] === + "nutHub" + ) { + return parts[1]; + } + + + if ( + parts[0] === + "nutSlots" + ) { + return parts[1]; + } + + + return null; +} + +/* + * ============================================================ + * RANDOM + * ============================================================ + */ + +function randomReelSymbol() { + return randomItem( + REEL + ); +} + + +function delay( + ms +) { + return new Promise( + resolve => + setTimeout( + resolve, + ms + ) + ); +} + + +module.exports = { + showNutSlots, + playNutSlots +}; \ No newline at end of file diff --git a/src/ping.js b/utils/ping.js similarity index 100% rename from src/ping.js rename to utils/ping.js