From 81b0e567340f87df0cb93b386bbaa1b30f2c2c50 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 01:52:34 +0700 Subject: [PATCH 01/53] Update bot.js --- src/config/bot.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/bot.js b/src/config/bot.js index 86dc861cc3..01aea0567e 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "stalking", // this is what people actually see + state: "hari hari mau eek", // this is what people actually see type: 4, // Custom }, ], @@ -648,4 +648,4 @@ export function getRandomColor() { return colors[Math.floor(Math.random() * colors.length)]; } -export default botConfig; \ No newline at end of file +export default botConfig; From 4e781a28fcd2884dce312bbbd0ea3852e772d880 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 01:55:13 +0700 Subject: [PATCH 02/53] Update bot.js --- src/config/bot.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/bot.js b/src/config/bot.js index 01aea0567e..de9489bc04 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -97,7 +97,7 @@ export const botConfig = { secondary: "#2F3136", // Standard status colors for success/error/warning/info messages. - success: "#57F287", + success: "#ff88d7", error: "#ED4245", warning: "#FEE75C", info: "#3498DB", @@ -141,7 +141,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "MannNayla", // Footer icon URL (null = no icon). icon: null, }, From 9ced70ab5e2f8dada6bb4f91a38d6bc7cbdd3dbe Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 01:57:01 +0700 Subject: [PATCH 03/53] Update fish.js --- src/commands/Economy/fish.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 5288f048bb..c9212e0042 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -4,7 +4,7 @@ import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -const FISH_COOLDOWN = 45 * 60 * 1000; +const FISH_COOLDOWN = 0; const BASE_MIN_REWARD = 300; const BASE_MAX_REWARD = 900; const FISHING_ROD_MULTIPLIER = 1.5; @@ -125,8 +125,8 @@ export default { inline: true, } ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'fish' }) -}; \ No newline at end of file +}; From b8d1925c52f5fba192b4b4d9746662e00ca90ed0 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 02:31:26 +0700 Subject: [PATCH 04/53] Update fish.js --- src/commands/Economy/fish.js | 103 +++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 24 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index c9212e0042..ccd4af5916 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -10,15 +10,45 @@ const BASE_MAX_REWARD = 900; const FISHING_ROD_MULTIPLIER = 1.5; const FISH_TYPES = [ + // Common { name: 'Bass', emoji: '๐ŸŸ', rarity: 'common' }, - { name: 'Salmon', emoji: '๐ŸŸ', rarity: 'common' }, + { name: 'Salmon', emoji: '๐Ÿ ', rarity: 'common' }, { name: 'Trout', emoji: '๐ŸŸ', rarity: 'common' }, - { name: 'Tuna', emoji: '๐Ÿ ', rarity: 'uncommon' }, - { name: 'Swordfish', emoji: '๐Ÿ ', rarity: 'uncommon' }, - { name: 'Octopus', emoji: '๐Ÿ™', rarity: 'rare' }, + + // Uncommon + { name: 'Tuna', emoji: '๐Ÿก', rarity: 'uncommon' }, + { name: 'Swordfish', emoji: '๐ŸŸ', rarity: 'uncommon' }, + { name: 'Octopus', emoji: '๐Ÿ™', rarity: 'uncommon' }, + + // Rare { name: 'Lobster', emoji: '๐Ÿฆž', rarity: 'rare' }, - { name: 'Shark', emoji: '๐Ÿฆˆ', rarity: 'epic' }, - { name: 'Whale', emoji: '๐Ÿ‹', rarity: 'legendary' }, + { name: 'Shark', emoji: '๐Ÿฆˆ', rarity: 'rare' }, + { name: 'Manta Ray', emoji: '๐ŸŸ', rarity: 'rare' }, + + // Epic + { name: 'Golden Koi', emoji: '๐Ÿ ', rarity: 'epic' }, + { name: 'Blue Marlin', emoji: '๐ŸŸ', rarity: 'epic' }, + { name: 'Crystal Fish', emoji: '๐Ÿ’Ž', rarity: 'epic' }, + + // Legendary + { name: 'Golden Shark', emoji: '๐Ÿฆˆ', rarity: 'legendary' }, + { name: 'Sea Dragon', emoji: '๐Ÿ‰', rarity: 'legendary' }, + { name: 'Golden Whale', emoji: '๐Ÿ‹', rarity: 'legendary' }, + + // Mythic + { name: 'Leviathan', emoji: '๐Ÿ‰', rarity: 'mythic' }, + { name: 'Megalodon', emoji: '๐Ÿฆˆ', rarity: 'mythic' }, + { name: 'Phoenix Koi', emoji: '๐Ÿ”ฅ', rarity: 'mythic' }, + + // Secret + { name: 'Void Whale', emoji: '๐ŸŒŒ', rarity: 'secret' }, + { name: 'Ancient Kraken', emoji: '๐Ÿ™', rarity: 'secret' }, + { name: 'Abyss Serpent', emoji: '๐Ÿ', rarity: 'secret' }, + + // Divine (Above Secret) + { name: 'Cosmic Leviathan', emoji: '๐ŸŒ ', rarity: 'divine' }, + { name: 'God of Ocean', emoji: '๐Ÿ”ฑ', rarity: 'divine' }, + { name: 'Eternal Whale', emoji: '๐ŸŒŒ', rarity: 'divine' } ]; const CATCH_MESSAGES = [ @@ -62,28 +92,50 @@ export default { } const rand = Math.random(); - let fishCaught; - - if (rand < 0.5) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'common')[Math.floor(Math.random() * 3)]; - } else if (rand < 0.75) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'uncommon')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.9) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'rare')[Math.floor(Math.random() * 2)]; + + let selectedRarity; + + if (rand < 0.45) { + selectedRarity = 'common'; + } else if (rand < 0.70) { + selectedRarity = 'uncommon'; + } else if (rand < 0.85) { + selectedRarity = 'rare'; + } else if (rand < 0.94) { + selectedRarity = 'epic'; } else if (rand < 0.98) { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'epic'); + selectedRarity = 'legendary'; + } else if (rand < 0.995) { + selectedRarity = 'mythic'; + } else if (rand < 0.9995) { + selectedRarity = 'secret'; } else { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary'); + selectedRarity = 'divine'; } + const availableFish = FISH_TYPES.filter( + f => f.rarity === selectedRarity + ); + + const fishCaught = availableFish[ + Math.floor(Math.random() * availableFish.length) + ]; + const fishPrices = { + common: [300, 900], + uncommon: [1000, 2000], + rare: [2500, 4000], + epic: [5000, 8000], + legendary: [10000, 20000], + mythic: [30000, 50000], + secret: [75000, 150000], + divine: [250000, 500000] + }; + + const [minPrice, maxPrice] = fishPrices[fishCaught.rarity]; + const baseEarned = Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1) - ) + BASE_MIN_REWARD; + Math.random() * (maxPrice - minPrice + 1) + ) + minPrice; let finalEarned = baseEarned; let multiplierMessage = ""; @@ -105,7 +157,10 @@ export default { uncommon: '#2ECC71', rare: '#3498DB', epic: '#9B59B6', - legendary: '#F1C40F' + legendary: '#F1C40F', + mythic: '#E91E63', + secret: '#FF0000', + divine: '#00FFFF' }; const embed = createEmbed({ From cf2a9c5613a3827a4060755fb0e55812f58f8bf4 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 03:04:29 +0700 Subject: [PATCH 05/53] Update items.js --- src/config/shop/items.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 5f47eaeaf5..85ce6ed85b 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -2,7 +2,7 @@ export const shopItems = [ { id: 'extra_work', name: 'Extra Work Shift', - price: 5000, + price: 300000, description: 'Allows 1 extra use of the `/work` command.', type: 'consumable', maxQuantity: 5, @@ -16,7 +16,7 @@ cooldown: 86400000, { id: 'bank_upgrade_1', name: 'Bank Upgrade I', - price: 15000, + price: 150000, description: 'Increases bank capacity and allows more funds to be deposited.', type: 'upgrade', maxLevel: 5, @@ -40,7 +40,7 @@ cooldown: 86400000, { id: 'premium_role', name: 'Premium Server Role', - price: 15000, + price: 1000000, description: 'A special role granting a fancy color and a 10% daily bonus.', type: 'role', roleId: null, @@ -52,7 +52,7 @@ roleId: null, { id: 'lucky_clover', name: 'Lucky Clover', - price: 10000, + price: 100000, description: 'Increases the chance of winning a higher payout on `/gamble` once.', type: 'consumable', maxQuantity: 10, @@ -77,7 +77,7 @@ roleId: null, { id: 'pickaxe', name: 'โ›๏ธ Pickaxe', - price: 7500, + price: 75000, description: 'Used for mining commands', type: 'tool', durability: 100, @@ -89,7 +89,7 @@ roleId: null, { id: 'laptop', name: '๐Ÿ’ป Laptop', - price: 15000, + price: 500000, description: 'Increases work earnings', type: 'tool', durability: 200, @@ -101,10 +101,10 @@ roleId: null, { id: 'lucky_charm', name: '๐Ÿ€ Lucky Charm', - price: 10000, + price: 100000, description: 'Increases luck for gambling. Has 3 uses before being consumed.', type: 'consumable', - maxQuantity: 10, + maxQuantity: 100, effect: { type: 'gamble_boost', multiplier: 1.3, @@ -114,7 +114,7 @@ roleId: null, { id: 'bank_note', name: '๐Ÿ“œ Bank Note', - price: 25000, + price: 250000, description: 'Increases bank capacity by 10,000. Can be purchased multiple times.', type: 'tool', durability: null, @@ -126,7 +126,7 @@ roleId: null, { id: 'personal_safe', name: '๐Ÿ”’ Personal Safe', - price: 30000, + price: 300000, description: 'Protects your money from theft. Prevents others from robbing you.', type: 'tool', durability: null, @@ -200,4 +200,4 @@ export function validatePurchase(itemId, userData) { } return { valid: true }; -} \ No newline at end of file +} From 98fa6c446bd661a350959fb25b41477d1e1eb138 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 03:42:46 +0700 Subject: [PATCH 06/53] Update fish.js --- src/commands/Economy/fish.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index ccd4af5916..1287f2e70a 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -121,14 +121,14 @@ export default { Math.floor(Math.random() * availableFish.length) ]; const fishPrices = { - common: [300, 900], - uncommon: [1000, 2000], - rare: [2500, 4000], - epic: [5000, 8000], - legendary: [10000, 20000], + common: [300, 800], + uncommon: [900, 1500], + rare: [2000, 2500], + epic: [3500, 6000], + legendary: [7500, 15000], mythic: [30000, 50000], - secret: [75000, 150000], - divine: [250000, 500000] + secret: [60000, 100000], + divine: [125000, 350000] }; const [minPrice, maxPrice] = fishPrices[fishCaught.rarity]; From 503e9cce48004df1fc63e2e7e95b2899d0fed87b Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 10:03:56 +0000 Subject: [PATCH 07/53] feat: Buat Kode Pancingan dan Ramuan Keberuntungan Co-authored-by: traeagent --- src/commands/Economy/fish.js | 57 ++++++++++++++-- src/config/shop/items.js | 129 +++++++++++++++++++++++++++++++++-- 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 1287f2e70a..4d7ce4cd61 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -4,10 +4,35 @@ import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getItemById } from '../../config/shop/items.js'; + const FISH_COOLDOWN = 0; const BASE_MIN_REWARD = 300; const BASE_MAX_REWARD = 900; -const FISHING_ROD_MULTIPLIER = 1.5; + +// Helper: find the highest-tier fishing rod the user owns +function getBestFishingRod(inventory) { + for (let tier = 5; tier >= 1; tier--) { + const id = `fishing_rod_${tier}`; + if ((inventory[id] || 0) > 0) { + const item = getItemById(id); + if (item) return item; + } + } + return null; +} + +// Helper: find the highest-tier luck potion the user owns +function getBestLuckPotion(inventory) { + for (let tier = 5; tier >= 1; tier--) { + const id = `luck_potion_${tier}`; + if ((inventory[id] || 0) > 0) { + const item = getItemById(id); + if (item) return item; + } + } + return null; +} const FISH_TYPES = [ // Common @@ -74,7 +99,10 @@ export default { const userData = await getEconomyData(client, guildId, userId); const lastFish = userData.lastFish || 0; - const hasFishingRod = userData.inventory["fishing_rod"] || 0; + const inventory = userData.inventory || {}; + + const bestRod = getBestFishingRod(inventory); + const bestPotion = getBestLuckPotion(inventory); if (now < lastFish + FISH_COOLDOWN) { const remaining = lastFish + FISH_COOLDOWN - now; @@ -91,7 +119,19 @@ export default { ); } - const rand = Math.random(); + let rand = Math.random(); + + // Apply luck potion boost (shifts roll toward rarer fish) + let luckMultiplier = 1; + let luckMessage = ""; + if (bestPotion) { + luckMultiplier = bestPotion.effect.multiplier || 1; + rand = Math.min(rand * luckMultiplier, 0.9999); + luckMessage = `\n๐Ÿงช **${bestPotion.name} Active: x${luckMultiplier} luck**`; + // Consume one use of the potion + inventory[bestPotion.id] = (inventory[bestPotion.id] || 0) - 1; + if (inventory[bestPotion.id] <= 0) delete inventory[bestPotion.id]; + } let selectedRarity; @@ -140,15 +180,18 @@ export default { let finalEarned = baseEarned; let multiplierMessage = ""; - if (hasFishingRod > 0) { - finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER); - multiplierMessage = `\n๐ŸŽฃ **Fishing Rod Bonus: +50%**`; + if (bestRod) { + const rodMultiplier = bestRod.effect.multiplier || 1; + finalEarned = Math.floor(baseEarned * rodMultiplier); + const boostPct = Math.round((rodMultiplier - 1) * 100); + multiplierMessage = `\n๐ŸŽฃ **${bestRod.name} Bonus: +${boostPct}%**`; } const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; userData.wallet += finalEarned; userData.lastFish = now; + userData.inventory = inventory; await setEconomyData(client, guildId, userId, userData); @@ -165,7 +208,7 @@ export default { const embed = createEmbed({ title: 'Fishing Success!', - description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, + description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}${luckMessage}`, color: rarityColors[fishCaught.rarity] }) .addFields( diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 85ce6ed85b..6d247c1ab5 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -62,16 +62,137 @@ roleId: null, uses: 1 } }, + // ===== Fishing Rods (Tiers I - V) ===== { - id: 'fishing_rod', - name: '๐ŸŽฃ Fishing Rod', + id: 'fishing_rod_1', + name: '๐ŸŽฃ Fishing Rod I', price: 5000, - description: 'Used for fishing commands', + description: 'A basic fishing rod. Boosts fishing earnings by 0%.', type: 'tool', durability: 100, effect: { type: 'fishing_yield', - multiplier: 1.0 + multiplier: 1.0, + tier: 1 + } + }, + { + id: 'fishing_rod_2', + name: '๐ŸŽฃ Fishing Rod II', + price: 25000, + description: 'A sturdy fishing rod. Boosts fishing earnings by 50%.', + type: 'tool', + durability: 150, + effect: { + type: 'fishing_yield', + multiplier: 1.5, + tier: 2 + } + }, + { + id: 'fishing_rod_3', + name: '๐ŸŽฃ Fishing Rod III', + price: 75000, + description: 'A reinforced fishing rod. Boosts fishing earnings by 100%.', + type: 'tool', + durability: 200, + effect: { + type: 'fishing_yield', + multiplier: 2.0, + tier: 3 + } + }, + { + id: 'fishing_rod_4', + name: '๐ŸŽฃ Fishing Rod IV', + price: 200000, + description: 'A premium fishing rod. Boosts fishing earnings by 150%.', + type: 'tool', + durability: 250, + effect: { + type: 'fishing_yield', + multiplier: 2.5, + tier: 4 + } + }, + { + id: 'fishing_rod_5', + name: '๐ŸŽฃ Fishing Rod V', + price: 500000, + description: 'A legendary fishing rod. Boosts fishing earnings by 200%.', + type: 'tool', + durability: 300, + effect: { + type: 'fishing_yield', + multiplier: 3.0, + tier: 5 + } + }, + + // ===== Luck Potions (Tiers I - V) ===== + { + id: 'luck_potion_1', + name: '๐Ÿงช Luck Potion I', + price: 10000, + description: 'Boosts your fishing luck slightly for one catch. Higher chance of rarer fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 1.15, + uses: 1 + } + }, + { + id: 'luck_potion_2', + name: '๐Ÿงช Luck Potion II', + price: 30000, + description: 'Boosts your fishing luck for one catch. Noticeably higher chance of rarer fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 1.3, + uses: 1 + } + }, + { + id: 'luck_potion_3', + name: '๐Ÿงช Luck Potion III', + price: 75000, + description: 'Boosts your fishing luck for one catch. Good chance of rarer fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 1.5, + uses: 1 + } + }, + { + id: 'luck_potion_4', + name: '๐Ÿงช Luck Potion IV', + price: 150000, + description: 'Boosts your fishing luck for one catch. Great chance of rarer fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 1.8, + uses: 1 + } + }, + { + id: 'luck_potion_5', + name: '๐Ÿงช Luck Potion V', + price: 300000, + description: 'Maximizes your fishing luck for one catch. Best chance of legendary+ fish.', + type: 'consumable', + maxQuantity: 10, + effect: { + type: 'fishing_luck', + multiplier: 2.2, + uses: 1 } }, { From 6c18db0025bedb6874d10830f245bd35c69da29b Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 10:11:24 +0000 Subject: [PATCH 08/53] nerf: kurangi harga ikan & multiplier rod biar lebih susah --- src/commands/Economy/fish.js | 16 ++++++++-------- src/config/shop/items.js | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 4d7ce4cd61..e47e7d118a 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -161,14 +161,14 @@ export default { Math.floor(Math.random() * availableFish.length) ]; const fishPrices = { - common: [300, 800], - uncommon: [900, 1500], - rare: [2000, 2500], - epic: [3500, 6000], - legendary: [7500, 15000], - mythic: [30000, 50000], - secret: [60000, 100000], - divine: [125000, 350000] + common: [150, 400], + uncommon: [450, 750], + rare: [1000, 1250], + epic: [1750, 3000], + legendary: [3750, 7500], + mythic: [15000, 25000], + secret: [30000, 50000], + divine: [62500, 175000] }; const [minPrice, maxPrice] = fishPrices[fishCaught.rarity]; diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 6d247c1ab5..23d2a77f3e 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -80,12 +80,12 @@ roleId: null, id: 'fishing_rod_2', name: '๐ŸŽฃ Fishing Rod II', price: 25000, - description: 'A sturdy fishing rod. Boosts fishing earnings by 50%.', + description: 'A sturdy fishing rod. Boosts fishing earnings by 25%.', type: 'tool', durability: 150, effect: { type: 'fishing_yield', - multiplier: 1.5, + multiplier: 1.25, tier: 2 } }, @@ -93,12 +93,12 @@ roleId: null, id: 'fishing_rod_3', name: '๐ŸŽฃ Fishing Rod III', price: 75000, - description: 'A reinforced fishing rod. Boosts fishing earnings by 100%.', + description: 'A reinforced fishing rod. Boosts fishing earnings by 50%.', type: 'tool', durability: 200, effect: { type: 'fishing_yield', - multiplier: 2.0, + multiplier: 1.5, tier: 3 } }, @@ -106,12 +106,12 @@ roleId: null, id: 'fishing_rod_4', name: '๐ŸŽฃ Fishing Rod IV', price: 200000, - description: 'A premium fishing rod. Boosts fishing earnings by 150%.', + description: 'A premium fishing rod. Boosts fishing earnings by 75%.', type: 'tool', durability: 250, effect: { type: 'fishing_yield', - multiplier: 2.5, + multiplier: 1.75, tier: 4 } }, @@ -119,12 +119,12 @@ roleId: null, id: 'fishing_rod_5', name: '๐ŸŽฃ Fishing Rod V', price: 500000, - description: 'A legendary fishing rod. Boosts fishing earnings by 200%.', + description: 'A legendary fishing rod. Boosts fishing earnings by 100%.', type: 'tool', durability: 300, effect: { type: 'fishing_yield', - multiplier: 3.0, + multiplier: 2.0, tier: 5 } }, From 4ec18d7061d74d3938c36c50c01b1f6e93628872 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 17:50:22 +0700 Subject: [PATCH 09/53] Update fish.js --- src/commands/Economy/fish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index e47e7d118a..f4d4e7c95b 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -161,7 +161,7 @@ export default { Math.floor(Math.random() * availableFish.length) ]; const fishPrices = { - common: [150, 400], + common: [200, 450], uncommon: [450, 750], rare: [1000, 1250], epic: [1750, 3000], From a964f14f2e096e1e0d1b4f21597db9f2cb3e18eb Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 18:26:36 +0700 Subject: [PATCH 10/53] Update items.js --- src/config/shop/items.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 23d2a77f3e..03310b382a 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -64,7 +64,7 @@ roleId: null, }, // ===== Fishing Rods (Tiers I - V) ===== { - id: 'fishing_rod_1', + id: 'rod1', name: '๐ŸŽฃ Fishing Rod I', price: 5000, description: 'A basic fishing rod. Boosts fishing earnings by 0%.', @@ -77,7 +77,7 @@ roleId: null, } }, { - id: 'fishing_rod_2', + id: 'rod2', name: '๐ŸŽฃ Fishing Rod II', price: 25000, description: 'A sturdy fishing rod. Boosts fishing earnings by 25%.', @@ -90,7 +90,7 @@ roleId: null, } }, { - id: 'fishing_rod_3', + id: 'rod3', name: '๐ŸŽฃ Fishing Rod III', price: 75000, description: 'A reinforced fishing rod. Boosts fishing earnings by 50%.', @@ -103,7 +103,7 @@ roleId: null, } }, { - id: 'fishing_rod_4', + id: 'rod4', name: '๐ŸŽฃ Fishing Rod IV', price: 200000, description: 'A premium fishing rod. Boosts fishing earnings by 75%.', @@ -116,7 +116,7 @@ roleId: null, } }, { - id: 'fishing_rod_5', + id: 'rod5', name: '๐ŸŽฃ Fishing Rod V', price: 500000, description: 'A legendary fishing rod. Boosts fishing earnings by 100%.', @@ -131,7 +131,7 @@ roleId: null, // ===== Luck Potions (Tiers I - V) ===== { - id: 'luck_potion_1', + id: 'luck1', name: '๐Ÿงช Luck Potion I', price: 10000, description: 'Boosts your fishing luck slightly for one catch. Higher chance of rarer fish.', @@ -144,7 +144,7 @@ roleId: null, } }, { - id: 'luck_potion_2', + id: 'luck2', name: '๐Ÿงช Luck Potion II', price: 30000, description: 'Boosts your fishing luck for one catch. Noticeably higher chance of rarer fish.', @@ -157,7 +157,7 @@ roleId: null, } }, { - id: 'luck_potion_3', + id: 'luck3', name: '๐Ÿงช Luck Potion III', price: 75000, description: 'Boosts your fishing luck for one catch. Good chance of rarer fish.', @@ -170,7 +170,7 @@ roleId: null, } }, { - id: 'luck_potion_4', + id: 'luck4', name: '๐Ÿงช Luck Potion IV', price: 150000, description: 'Boosts your fishing luck for one catch. Great chance of rarer fish.', @@ -183,7 +183,7 @@ roleId: null, } }, { - id: 'luck_potion_5', + id: 'luck5', name: '๐Ÿงช Luck Potion V', price: 300000, description: 'Maximizes your fishing luck for one catch. Best chance of legendary+ fish.', From 2ff2509070663637d4ad28a54c7d7df9564bd818 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 19:05:19 +0700 Subject: [PATCH 11/53] Update items.js --- src/config/shop/items.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 03310b382a..5dcbcebb39 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -64,7 +64,7 @@ roleId: null, }, // ===== Fishing Rods (Tiers I - V) ===== { - id: 'rod1', + id: 'fishing_rod_1', name: '๐ŸŽฃ Fishing Rod I', price: 5000, description: 'A basic fishing rod. Boosts fishing earnings by 0%.', @@ -77,7 +77,7 @@ roleId: null, } }, { - id: 'rod2', + id: 'fishing_rod_2', name: '๐ŸŽฃ Fishing Rod II', price: 25000, description: 'A sturdy fishing rod. Boosts fishing earnings by 25%.', @@ -90,7 +90,7 @@ roleId: null, } }, { - id: 'rod3', + id: 'fishing_rod_3', name: '๐ŸŽฃ Fishing Rod III', price: 75000, description: 'A reinforced fishing rod. Boosts fishing earnings by 50%.', @@ -103,7 +103,7 @@ roleId: null, } }, { - id: 'rod4', + id: 'fishing_rod_4', name: '๐ŸŽฃ Fishing Rod IV', price: 200000, description: 'A premium fishing rod. Boosts fishing earnings by 75%.', @@ -116,7 +116,7 @@ roleId: null, } }, { - id: 'rod5', + id: 'fishibg_rod_5', name: '๐ŸŽฃ Fishing Rod V', price: 500000, description: 'A legendary fishing rod. Boosts fishing earnings by 100%.', From 3bb0b184dc72f55d6640ec4c4bc1dfec547f9870 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 19:08:51 +0700 Subject: [PATCH 12/53] Update items.js --- src/config/shop/items.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 5dcbcebb39..3607f9f84e 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -131,7 +131,7 @@ roleId: null, // ===== Luck Potions (Tiers I - V) ===== { - id: 'luck1', + id: 'luck_potion_1', name: '๐Ÿงช Luck Potion I', price: 10000, description: 'Boosts your fishing luck slightly for one catch. Higher chance of rarer fish.', @@ -144,7 +144,7 @@ roleId: null, } }, { - id: 'luck2', + id: 'luck_potion_2', name: '๐Ÿงช Luck Potion II', price: 30000, description: 'Boosts your fishing luck for one catch. Noticeably higher chance of rarer fish.', @@ -157,7 +157,7 @@ roleId: null, } }, { - id: 'luck3', + id: 'luck_potion_3', name: '๐Ÿงช Luck Potion III', price: 75000, description: 'Boosts your fishing luck for one catch. Good chance of rarer fish.', @@ -170,7 +170,7 @@ roleId: null, } }, { - id: 'luck4', + id: 'luck_potion_4', name: '๐Ÿงช Luck Potion IV', price: 150000, description: 'Boosts your fishing luck for one catch. Great chance of rarer fish.', @@ -183,7 +183,7 @@ roleId: null, } }, { - id: 'luck5', + id: 'luck_potion_5', name: '๐Ÿงช Luck Potion V', price: 300000, description: 'Maximizes your fishing luck for one catch. Best chance of legendary+ fish.', From e9d70802ddca3fe846652c163b7640f62d0f19b2 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 19:18:22 +0700 Subject: [PATCH 13/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 3607f9f84e..c78934103c 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -72,7 +72,7 @@ roleId: null, durability: 100, effect: { type: 'fishing_yield', - multiplier: 1.0, + multiplier: 1.1, tier: 1 } }, From fc164e2382152e4a55231f5e90995934d981f121 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 19:19:02 +0700 Subject: [PATCH 14/53] Update items.js --- src/config/shop/items.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index c78934103c..d19f9f43d4 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -40,13 +40,13 @@ cooldown: 86400000, { id: 'premium_role', name: 'Premium Server Role', - price: 1000000, + price: 1500000, description: 'A special role granting a fancy color and a 10% daily bonus.', type: 'role', roleId: null, effect: { type: 'daily_bonus', - multiplier: 1.1 + multiplier: 5 } }, { From d1f262967ebbab32139635126f0342755af4aa58 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 13:14:39 +0000 Subject: [PATCH 15/53] fix: move premium bonus to fishing --- src/commands/Economy/buy.js | 19 +++++++++- src/commands/Economy/daily.js | 31 +++------------- src/commands/Economy/fish.js | 60 +++++++++++++++++++++++-------- src/commands/Economy/inventory.js | 41 ++++++++++----------- src/config/shop/items.js | 28 +++++++-------- 5 files changed, 102 insertions(+), 77 deletions(-) diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 1a90387315..0781d6f52b 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -7,6 +7,14 @@ import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHan import { InteractionHelper } from '../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; +const LUCK_POTION_IDS = new Set([ + 'luck_potion_1', + 'luck_potion_2', + 'luck_potion_3', + 'luck_potion_4', + 'luck_potion_5', +]); +const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; export default { data: new SlashCommandBuilder() @@ -136,6 +144,15 @@ export default { } else if (item.type === "upgrade") { userData.upgrades[itemId] = true; successDescription += `\n\n**โœจ Your upgrade is now active!**`; + } else if (item.type === "consumable" && LUCK_POTION_IDS.has(itemId)) { + const now = Date.now(); + const potionExpiries = userData.luckPotionExpiresAt || {}; + const currentExpiry = Math.max(now, Number(potionExpiries[itemId]) || 0); + const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; + potionExpiries[itemId] = currentExpiry + (duration * quantity); + userData.luckPotionExpiresAt = potionExpiries; + const activeMinutes = Math.ceil((potionExpiries[itemId] - now) / 60000); + successDescription += `\n\n๐Ÿงช **Active for ${activeMinutes} minutes. Timer started at purchase.**`; } else if (item.type === "consumable" || item.type === "tool") { userData.inventory[itemId] = (userData.inventory[itemId] || 0) + quantity; @@ -157,4 +174,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); }, { command: 'buy' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js index 4045d23ed0..32cf3977a0 100644 --- a/src/commands/Economy/daily.js +++ b/src/commands/Economy/daily.js @@ -1,7 +1,6 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/config/guildConfig.js'; import { formatDuration } from '../../utils/embeds.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; @@ -10,7 +9,6 @@ import { botConfig } from '../../config/bot.js'; const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; const DAILY_AMOUNT = botConfig.economy?.dailyAmount ?? 100; -const PREMIUM_BONUS_PERCENTAGE = 0.1; export default { data: new SlashCommandBuilder() @@ -50,25 +48,7 @@ export default { ); } - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - let earned = DAILY_AMOUNT; - let bonusMessage = ""; - let hasPremiumRole = false; - - if ( - PREMIUM_ROLE_ID && - interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) - ) { - const bonusAmount = Math.floor( - DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, - ); - earned += bonusAmount; - bonusMessage = `\nโœจ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; - hasPremiumRole = true; - } + const earned = DAILY_AMOUNT; userData.wallet = (userData.wallet || 0) + earned; userData.lastDaily = now; @@ -80,13 +60,12 @@ export default { guildId, amount: earned, newWallet: userData.wallet, - hasPremium: hasPremiumRole, timestamp: new Date().toISOString() }); const embed = successEmbed( "โœ… Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` + `You have claimed your daily **$${earned.toLocaleString()}**!` ) .addFields({ name: "New Cash Balance", @@ -94,11 +73,9 @@ export default { inline: true, }) .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, + text: `Next claim in 24 hours.`, }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'daily' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index f4d4e7c95b..a82dfc9ef2 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -3,12 +3,14 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; - import { getItemById } from '../../config/shop/items.js'; +import { getGuildConfig } from '../../services/config/guildConfig.js'; const FISH_COOLDOWN = 0; const BASE_MIN_REWARD = 300; const BASE_MAX_REWARD = 900; +const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; +const PREMIUM_FISHING_MULTIPLIER = getItemById('premium_role')?.effect?.multiplier ?? 1.1; // Helper: find the highest-tier fishing rod the user owns function getBestFishingRod(inventory) { @@ -22,11 +24,11 @@ function getBestFishingRod(inventory) { return null; } -// Helper: find the highest-tier luck potion the user owns -function getBestLuckPotion(inventory) { +// Helper: find the highest-tier luck potion with an active timer +function getBestActiveLuckPotion(potionExpiries, now) { for (let tier = 5; tier >= 1; tier--) { const id = `luck_potion_${tier}`; - if ((inventory[id] || 0) > 0) { + if (Number(potionExpiries[id]) > now) { const item = getItemById(id); if (item) return item; } @@ -100,9 +102,28 @@ export default { const userData = await getEconomyData(client, guildId, userId); const lastFish = userData.lastFish || 0; const inventory = userData.inventory || {}; + const potionExpiries = { ...(userData.luckPotionExpiresAt || {}) }; const bestRod = getBestFishingRod(inventory); - const bestPotion = getBestLuckPotion(inventory); + for (let tier = 1; tier <= 5; tier++) { + const potionId = `luck_potion_${tier}`; + const legacyQuantity = Number(inventory[potionId] || 0); + if (legacyQuantity > 0) { + const currentExpiry = Math.max(now, Number(potionExpiries[potionId]) || 0); + const duration = Number(getItemById(potionId)?.effect?.duration) + || DEFAULT_LUCK_POTION_DURATION_MS; + potionExpiries[potionId] = currentExpiry + (duration * legacyQuantity); + delete inventory[potionId]; + } + } + const bestPotion = getBestActiveLuckPotion(potionExpiries, now); + userData.luckPotionExpiresAt = potionExpiries; + + const guildConfig = await getGuildConfig(client, guildId); + const premiumRoleId = guildConfig.premiumRoleId; + const hasPremiumRole = Boolean( + premiumRoleId && interaction.member?.roles?.cache?.has(premiumRoleId) + ); if (now < lastFish + FISH_COOLDOWN) { const remaining = lastFish + FISH_COOLDOWN - now; @@ -121,16 +142,16 @@ export default { let rand = Math.random(); - // Apply luck potion boost (shifts roll toward rarer fish) + // Terapkan potion terkuat yang masih aktif tanpa menghabiskannya. let luckMultiplier = 1; let luckMessage = ""; if (bestPotion) { luckMultiplier = bestPotion.effect.multiplier || 1; rand = Math.min(rand * luckMultiplier, 0.9999); - luckMessage = `\n๐Ÿงช **${bestPotion.name} Active: x${luckMultiplier} luck**`; - // Consume one use of the potion - inventory[bestPotion.id] = (inventory[bestPotion.id] || 0) - 1; - if (inventory[bestPotion.id] <= 0) delete inventory[bestPotion.id]; + const minutesLeft = Math.ceil( + (Number(potionExpiries[bestPotion.id]) - now) / 60000 + ); + luckMessage = `\n๐Ÿงช **${bestPotion.name} Active: x${luckMultiplier} luck (${minutesLeft}m left)**`; } let selectedRarity; @@ -177,21 +198,30 @@ export default { Math.random() * (maxPrice - minPrice + 1) ) + minPrice; - let finalEarned = baseEarned; - let multiplierMessage = ""; - + let fishingMultiplier = 1; + const bonusMessages = []; if (bestRod) { const rodMultiplier = bestRod.effect.multiplier || 1; - finalEarned = Math.floor(baseEarned * rodMultiplier); + fishingMultiplier *= rodMultiplier; const boostPct = Math.round((rodMultiplier - 1) * 100); - multiplierMessage = `\n๐ŸŽฃ **${bestRod.name} Bonus: +${boostPct}%**`; + bonusMessages.push(`๐ŸŽฃ **${bestRod.name} Bonus: +${boostPct}%**`); + } + if (hasPremiumRole) { + fishingMultiplier *= PREMIUM_FISHING_MULTIPLIER; + const boostPct = Math.round((PREMIUM_FISHING_MULTIPLIER - 1) * 100); + bonusMessages.push(`โœจ **Premium Fishing Bonus: +${boostPct}%**`); } + const finalEarned = Math.floor(baseEarned * fishingMultiplier); + const multiplierMessage = bonusMessages.length + ? `\n${bonusMessages.join('\n')}` + : ""; const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; userData.wallet += finalEarned; userData.lastFish = now; userData.inventory = inventory; + userData.luckPotionExpiresAt = potionExpiries; await setEconomyData(client, guildId, userId, userData); diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js index b7efcfec80..8e2c41b13e 100644 --- a/src/commands/Economy/inventory.js +++ b/src/commands/Economy/inventory.js @@ -34,25 +34,26 @@ export default { } const inventory = userData.inventory || {}; - - let inventoryDescription = "Your inventory is currently empty."; - - if (Object.keys(inventory).length > 0) { - inventoryDescription = Object.entries(inventory) - .filter( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return quantity > 0 && item; - } - ) - .map( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return `**${item.name}:** ${quantity}x`; - } - ) - .join("\n"); - } + const inventoryLines = Object.entries(inventory) + .filter(([itemId, quantity]) => { + const item = SHOP_ITEMS.find(i => i.id === itemId); + return Number(quantity) > 0 && item; + }) + .map(([itemId, quantity]) => { + const item = SHOP_ITEMS.find(i => i.id === itemId); + return `**${item.name}:** ${quantity}x`; + }); + const potionLines = Object.entries(userData.luckPotionExpiresAt || {}) + .filter(([, expiresAt]) => Number(expiresAt) > Date.now()) + .map(([itemId, expiresAt]) => { + const item = SHOP_ITEMS.find(i => i.id === itemId); + if (!item) return null; + const minutesLeft = Math.ceil((Number(expiresAt) - Date.now()) / 60000); + return `๐Ÿงช **${item.name}:** Active (${minutesLeft}m left)`; + }) + .filter(Boolean); + const inventoryDescription = [...inventoryLines, ...potionLines].join("\n") + || "Your inventory is currently empty."; logger.info(`[ECONOMY] Inventory retrieved`, { userId, @@ -67,4 +68,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'inventory' }) -}; \ No newline at end of file +}; diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 23d2a77f3e..f337220954 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -41,11 +41,11 @@ cooldown: 86400000, id: 'premium_role', name: 'Premium Server Role', price: 1000000, - description: 'A special role granting a fancy color and a 10% daily bonus.', + description: 'A special role granting a fancy color and a 10% fishing earnings bonus.', type: 'role', roleId: null, effect: { - type: 'daily_bonus', + type: 'fishing_yield', multiplier: 1.1 } }, @@ -67,12 +67,12 @@ roleId: null, id: 'fishing_rod_1', name: '๐ŸŽฃ Fishing Rod I', price: 5000, - description: 'A basic fishing rod. Boosts fishing earnings by 0%.', + description: 'A basic fishing rod. Boosts fishing earnings by 10%.', type: 'tool', durability: 100, effect: { type: 'fishing_yield', - multiplier: 1.0, + multiplier: 1.1, tier: 1 } }, @@ -134,65 +134,65 @@ roleId: null, id: 'luck_potion_1', name: '๐Ÿงช Luck Potion I', price: 10000, - description: 'Boosts your fishing luck slightly for one catch. Higher chance of rarer fish.', + description: 'Boosts your fishing luck slightly for 5 minutes. Higher chance of rarer fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 1.15, - uses: 1 + duration: 300000 } }, { id: 'luck_potion_2', name: '๐Ÿงช Luck Potion II', price: 30000, - description: 'Boosts your fishing luck for one catch. Noticeably higher chance of rarer fish.', + description: 'Boosts your fishing luck for 5 minutes. Noticeably higher chance of rarer fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 1.3, - uses: 1 + duration: 300000 } }, { id: 'luck_potion_3', name: '๐Ÿงช Luck Potion III', price: 75000, - description: 'Boosts your fishing luck for one catch. Good chance of rarer fish.', + description: 'Boosts your fishing luck for 5 minutes. Good chance of rarer fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 1.5, - uses: 1 + duration: 300000 } }, { id: 'luck_potion_4', name: '๐Ÿงช Luck Potion IV', price: 150000, - description: 'Boosts your fishing luck for one catch. Great chance of rarer fish.', + description: 'Boosts your fishing luck for 5 minutes. Great chance of rarer fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 1.8, - uses: 1 + duration: 300000 } }, { id: 'luck_potion_5', name: '๐Ÿงช Luck Potion V', price: 300000, - description: 'Maximizes your fishing luck for one catch. Best chance of legendary+ fish.', + description: 'Maximizes your fishing luck for 5 minutes. Best chance of legendary+ fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 2.2, - uses: 1 + duration: 300000 } }, { From 335b5c6fe9a2e7f51e40ac0225ca6f407dc95fee Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 20:19:09 +0700 Subject: [PATCH 16/53] fix: move premium bonus to fishing (#1) Co-authored-by: mannnayla1-code --- src/commands/Economy/buy.js | 19 ++++++++- src/commands/Economy/daily.js | 31 ++------------- src/commands/Economy/fish.js | 65 +++++++++++++++++++++++-------- src/commands/Economy/inventory.js | 48 +++++++++++++---------- src/config/shop/items.js | 30 +++++++------- 5 files changed, 114 insertions(+), 79 deletions(-) diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 1a90387315..0781d6f52b 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -7,6 +7,14 @@ import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHan import { InteractionHelper } from '../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; +const LUCK_POTION_IDS = new Set([ + 'luck_potion_1', + 'luck_potion_2', + 'luck_potion_3', + 'luck_potion_4', + 'luck_potion_5', +]); +const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; export default { data: new SlashCommandBuilder() @@ -136,6 +144,15 @@ export default { } else if (item.type === "upgrade") { userData.upgrades[itemId] = true; successDescription += `\n\n**โœจ Your upgrade is now active!**`; + } else if (item.type === "consumable" && LUCK_POTION_IDS.has(itemId)) { + const now = Date.now(); + const potionExpiries = userData.luckPotionExpiresAt || {}; + const currentExpiry = Math.max(now, Number(potionExpiries[itemId]) || 0); + const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; + potionExpiries[itemId] = currentExpiry + (duration * quantity); + userData.luckPotionExpiresAt = potionExpiries; + const activeMinutes = Math.ceil((potionExpiries[itemId] - now) / 60000); + successDescription += `\n\n๐Ÿงช **Active for ${activeMinutes} minutes. Timer started at purchase.**`; } else if (item.type === "consumable" || item.type === "tool") { userData.inventory[itemId] = (userData.inventory[itemId] || 0) + quantity; @@ -157,4 +174,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); }, { command: 'buy' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js index 4045d23ed0..32cf3977a0 100644 --- a/src/commands/Economy/daily.js +++ b/src/commands/Economy/daily.js @@ -1,7 +1,6 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/config/guildConfig.js'; import { formatDuration } from '../../utils/embeds.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; @@ -10,7 +9,6 @@ import { botConfig } from '../../config/bot.js'; const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; const DAILY_AMOUNT = botConfig.economy?.dailyAmount ?? 100; -const PREMIUM_BONUS_PERCENTAGE = 0.1; export default { data: new SlashCommandBuilder() @@ -50,25 +48,7 @@ export default { ); } - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - let earned = DAILY_AMOUNT; - let bonusMessage = ""; - let hasPremiumRole = false; - - if ( - PREMIUM_ROLE_ID && - interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) - ) { - const bonusAmount = Math.floor( - DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, - ); - earned += bonusAmount; - bonusMessage = `\nโœจ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; - hasPremiumRole = true; - } + const earned = DAILY_AMOUNT; userData.wallet = (userData.wallet || 0) + earned; userData.lastDaily = now; @@ -80,13 +60,12 @@ export default { guildId, amount: earned, newWallet: userData.wallet, - hasPremium: hasPremiumRole, timestamp: new Date().toISOString() }); const embed = successEmbed( "โœ… Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` + `You have claimed your daily **$${earned.toLocaleString()}**!` ) .addFields({ name: "New Cash Balance", @@ -94,11 +73,9 @@ export default { inline: true, }) .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, + text: `Next claim in 24 hours.`, }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'daily' }) -}; \ No newline at end of file +}; diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index f4d4e7c95b..d704964319 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -3,18 +3,23 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; - import { getItemById } from '../../config/shop/items.js'; +import { getGuildConfig } from '../../services/config/guildConfig.js'; const FISH_COOLDOWN = 0; const BASE_MIN_REWARD = 300; const BASE_MAX_REWARD = 900; +const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; +const PREMIUM_FISHING_MULTIPLIER = getItemById('premium_role')?.effect?.multiplier ?? 1.1; // Helper: find the highest-tier fishing rod the user owns function getBestFishingRod(inventory) { for (let tier = 5; tier >= 1; tier--) { const id = `fishing_rod_${tier}`; - if ((inventory[id] || 0) > 0) { + const legacyTierFiveQuantity = tier === 5 + ? Number(inventory.fishibg_rod_5 || 0) + : 0; + if (Number(inventory[id] || 0) > 0 || legacyTierFiveQuantity > 0) { const item = getItemById(id); if (item) return item; } @@ -22,11 +27,11 @@ function getBestFishingRod(inventory) { return null; } -// Helper: find the highest-tier luck potion the user owns -function getBestLuckPotion(inventory) { +// Helper: find the highest-tier luck potion with an active timer +function getBestActiveLuckPotion(potionExpiries, now) { for (let tier = 5; tier >= 1; tier--) { const id = `luck_potion_${tier}`; - if ((inventory[id] || 0) > 0) { + if (Number(potionExpiries[id]) > now) { const item = getItemById(id); if (item) return item; } @@ -100,9 +105,28 @@ export default { const userData = await getEconomyData(client, guildId, userId); const lastFish = userData.lastFish || 0; const inventory = userData.inventory || {}; + const potionExpiries = { ...(userData.luckPotionExpiresAt || {}) }; const bestRod = getBestFishingRod(inventory); - const bestPotion = getBestLuckPotion(inventory); + for (let tier = 1; tier <= 5; tier++) { + const potionId = `luck_potion_${tier}`; + const legacyQuantity = Number(inventory[potionId] || 0); + if (legacyQuantity > 0) { + const currentExpiry = Math.max(now, Number(potionExpiries[potionId]) || 0); + const duration = Number(getItemById(potionId)?.effect?.duration) + || DEFAULT_LUCK_POTION_DURATION_MS; + potionExpiries[potionId] = currentExpiry + (duration * legacyQuantity); + delete inventory[potionId]; + } + } + const bestPotion = getBestActiveLuckPotion(potionExpiries, now); + userData.luckPotionExpiresAt = potionExpiries; + + const guildConfig = await getGuildConfig(client, guildId); + const premiumRoleId = guildConfig.premiumRoleId; + const hasPremiumRole = Boolean( + premiumRoleId && interaction.member?.roles?.cache?.has(premiumRoleId) + ); if (now < lastFish + FISH_COOLDOWN) { const remaining = lastFish + FISH_COOLDOWN - now; @@ -121,16 +145,16 @@ export default { let rand = Math.random(); - // Apply luck potion boost (shifts roll toward rarer fish) + // Terapkan potion terkuat yang masih aktif tanpa menghabiskannya. let luckMultiplier = 1; let luckMessage = ""; if (bestPotion) { luckMultiplier = bestPotion.effect.multiplier || 1; rand = Math.min(rand * luckMultiplier, 0.9999); - luckMessage = `\n๐Ÿงช **${bestPotion.name} Active: x${luckMultiplier} luck**`; - // Consume one use of the potion - inventory[bestPotion.id] = (inventory[bestPotion.id] || 0) - 1; - if (inventory[bestPotion.id] <= 0) delete inventory[bestPotion.id]; + const minutesLeft = Math.ceil( + (Number(potionExpiries[bestPotion.id]) - now) / 60000 + ); + luckMessage = `\n๐Ÿงช **${bestPotion.name} Active: x${luckMultiplier} luck (${minutesLeft}m left)**`; } let selectedRarity; @@ -177,21 +201,30 @@ export default { Math.random() * (maxPrice - minPrice + 1) ) + minPrice; - let finalEarned = baseEarned; - let multiplierMessage = ""; - + let fishingMultiplier = 1; + const bonusMessages = []; if (bestRod) { const rodMultiplier = bestRod.effect.multiplier || 1; - finalEarned = Math.floor(baseEarned * rodMultiplier); + fishingMultiplier *= rodMultiplier; const boostPct = Math.round((rodMultiplier - 1) * 100); - multiplierMessage = `\n๐ŸŽฃ **${bestRod.name} Bonus: +${boostPct}%**`; + bonusMessages.push(`๐ŸŽฃ **${bestRod.name} Bonus: +${boostPct}%**`); + } + if (hasPremiumRole) { + fishingMultiplier *= PREMIUM_FISHING_MULTIPLIER; + const boostPct = Math.round((PREMIUM_FISHING_MULTIPLIER - 1) * 100); + bonusMessages.push(`โœจ **Premium Fishing Bonus: +${boostPct}%**`); } + const finalEarned = Math.floor(baseEarned * fishingMultiplier); + const multiplierMessage = bonusMessages.length + ? `\n${bonusMessages.join('\n')}` + : ""; const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; userData.wallet += finalEarned; userData.lastFish = now; userData.inventory = inventory; + userData.luckPotionExpiresAt = potionExpiries; await setEconomyData(client, guildId, userId, userData); diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js index b7efcfec80..f5bd537a29 100644 --- a/src/commands/Economy/inventory.js +++ b/src/commands/Economy/inventory.js @@ -8,6 +8,13 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; +function getShopItem(itemId) { + return SHOP_ITEMS.find(item => item.id === itemId) + || (itemId === 'fishibg_rod_5' + ? SHOP_ITEMS.find(item => item.id === 'fishing_rod_5') + : null); +} + export default { data: new SlashCommandBuilder() .setName('inventory') @@ -34,25 +41,26 @@ export default { } const inventory = userData.inventory || {}; - - let inventoryDescription = "Your inventory is currently empty."; - - if (Object.keys(inventory).length > 0) { - inventoryDescription = Object.entries(inventory) - .filter( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return quantity > 0 && item; - } - ) - .map( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return `**${item.name}:** ${quantity}x`; - } - ) - .join("\n"); - } + const inventoryLines = Object.entries(inventory) + .filter(([itemId, quantity]) => { + const item = getShopItem(itemId); + return Number(quantity) > 0 && item; + }) + .map(([itemId, quantity]) => { + const item = getShopItem(itemId); + return `**${item.name}:** ${quantity}x`; + }); + const potionLines = Object.entries(userData.luckPotionExpiresAt || {}) + .filter(([, expiresAt]) => Number(expiresAt) > Date.now()) + .map(([itemId, expiresAt]) => { + const item = getShopItem(itemId); + if (!item) return null; + const minutesLeft = Math.ceil((Number(expiresAt) - Date.now()) / 60000); + return `๐Ÿงช **${item.name}:** Active (${minutesLeft}m left)`; + }) + .filter(Boolean); + const inventoryDescription = [...inventoryLines, ...potionLines].join("\n") + || "Your inventory is currently empty."; logger.info(`[ECONOMY] Inventory retrieved`, { userId, @@ -67,4 +75,4 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'inventory' }) -}; \ No newline at end of file +}; diff --git a/src/config/shop/items.js b/src/config/shop/items.js index d19f9f43d4..528b31bf5e 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -41,12 +41,12 @@ cooldown: 86400000, id: 'premium_role', name: 'Premium Server Role', price: 1500000, - description: 'A special role granting a fancy color and a 10% daily bonus.', + description: 'A special role granting a fancy color and a 10% fishing earnings bonus.', type: 'role', roleId: null, effect: { - type: 'daily_bonus', - multiplier: 5 + type: 'fishing_yield', + multiplier: 1.1 } }, { @@ -67,7 +67,7 @@ roleId: null, id: 'fishing_rod_1', name: '๐ŸŽฃ Fishing Rod I', price: 5000, - description: 'A basic fishing rod. Boosts fishing earnings by 0%.', + description: 'A basic fishing rod. Boosts fishing earnings by 10%.', type: 'tool', durability: 100, effect: { @@ -116,7 +116,7 @@ roleId: null, } }, { - id: 'fishibg_rod_5', + id: 'fishing_rod_5', name: '๐ŸŽฃ Fishing Rod V', price: 500000, description: 'A legendary fishing rod. Boosts fishing earnings by 100%.', @@ -134,65 +134,65 @@ roleId: null, id: 'luck_potion_1', name: '๐Ÿงช Luck Potion I', price: 10000, - description: 'Boosts your fishing luck slightly for one catch. Higher chance of rarer fish.', + description: 'Boosts your fishing luck slightly for 5 minutes. Higher chance of rarer fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 1.15, - uses: 1 + duration: 300000 } }, { id: 'luck_potion_2', name: '๐Ÿงช Luck Potion II', price: 30000, - description: 'Boosts your fishing luck for one catch. Noticeably higher chance of rarer fish.', + description: 'Boosts your fishing luck for 5 minutes. Noticeably higher chance of rarer fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 1.3, - uses: 1 + duration: 300000 } }, { id: 'luck_potion_3', name: '๐Ÿงช Luck Potion III', price: 75000, - description: 'Boosts your fishing luck for one catch. Good chance of rarer fish.', + description: 'Boosts your fishing luck for 5 minutes. Good chance of rarer fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 1.5, - uses: 1 + duration: 300000 } }, { id: 'luck_potion_4', name: '๐Ÿงช Luck Potion IV', price: 150000, - description: 'Boosts your fishing luck for one catch. Great chance of rarer fish.', + description: 'Boosts your fishing luck for 5 minutes. Great chance of rarer fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 1.8, - uses: 1 + duration: 300000 } }, { id: 'luck_potion_5', name: '๐Ÿงช Luck Potion V', price: 300000, - description: 'Maximizes your fishing luck for one catch. Best chance of legendary+ fish.', + description: 'Maximizes your fishing luck for 5 minutes. Best chance of legendary+ fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 2.2, - uses: 1 + duration: 300000 } }, { From de92c60c2722b6dc499f7474613723eb3532191c Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 13:31:59 +0000 Subject: [PATCH 17/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 528b31bf5e..9a955d75f9 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -46,7 +46,7 @@ cooldown: 86400000, roleId: null, effect: { type: 'fishing_yield', - multiplier: 1.1 + multiplier: 20 } }, { From 0fc9e0cbfdeff2e46b11fd5d759b9af5c3993d97 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 13:34:54 +0000 Subject: [PATCH 18/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 9a955d75f9..84b96f01c2 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -46,7 +46,7 @@ cooldown: 86400000, roleId: null, effect: { type: 'fishing_yield', - multiplier: 20 + multiplier: 1000 } }, { From 37a5f72a96290e11eef070bf9e21eb7ba8d7066f Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 13:38:09 +0000 Subject: [PATCH 19/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 84b96f01c2..fa4e7e9253 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -46,7 +46,7 @@ cooldown: 86400000, roleId: null, effect: { type: 'fishing_yield', - multiplier: 1000 + multiplier: 10 } }, { From 9fbd79dd22bd12aa48d1ca2f52e2a1e33db9e81f Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 13:44:11 +0000 Subject: [PATCH 20/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index fa4e7e9253..6a73642a33 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -46,7 +46,7 @@ cooldown: 86400000, roleId: null, effect: { type: 'fishing_yield', - multiplier: 10 + multiplier: 10000 } }, { From 3c24ab43555dd540b18be7508a6dca35f99ec707 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 13:45:20 +0000 Subject: [PATCH 21/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 6a73642a33..18ad2c2812 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -46,7 +46,7 @@ cooldown: 86400000, roleId: null, effect: { type: 'fishing_yield', - multiplier: 10000 + multiplier: 5 } }, { From 3f1c4751ba0d0e204fd39d4d97c3cb366d01e29a Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 13:50:39 +0000 Subject: [PATCH 22/53] Update fish.js From ef825a6c429374b89bfd4ece2224918d678d8bc0 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 13:52:42 +0000 Subject: [PATCH 23/53] Update fish.js --- src/commands/Economy/fish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index d704964319..971cd572a6 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -212,7 +212,7 @@ export default { if (hasPremiumRole) { fishingMultiplier *= PREMIUM_FISHING_MULTIPLIER; const boostPct = Math.round((PREMIUM_FISHING_MULTIPLIER - 1) * 100); - bonusMessages.push(`โœจ **Premium Fishing Bonus: +${boostPct}%**`); + bonusMessages.push(`โœจ **Premium Fishing Bonus: +${boostPct}%**`); } const finalEarned = Math.floor(baseEarned * fishingMultiplier); const multiplierMessage = bonusMessages.length From a9f1026f1f37357a63a65fabf96ff1e12911d86c Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 14:07:03 +0000 Subject: [PATCH 24/53] feat: update workspace Co-authored-by: traeagent --- src/commands/Economy/fish.js | 53 +++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index d704964319..7dcba1a502 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -44,41 +44,82 @@ const FISH_TYPES = [ { name: 'Bass', emoji: '๐ŸŸ', rarity: 'common' }, { name: 'Salmon', emoji: '๐Ÿ ', rarity: 'common' }, { name: 'Trout', emoji: '๐ŸŸ', rarity: 'common' }, + { name: 'Carp', emoji: '๐ŸŸ', rarity: 'common' }, + { name: 'Catfish', emoji: '๐ŸŸ', rarity: 'common' }, + { name: 'Perch', emoji: '๐ŸŸ', rarity: 'common' }, + { name: 'Sardine', emoji: '๐ŸŸ', rarity: 'common' }, + { name: 'Mackerel', emoji: '๐ŸŸ', rarity: 'common' }, // Uncommon { name: 'Tuna', emoji: '๐Ÿก', rarity: 'uncommon' }, { name: 'Swordfish', emoji: '๐ŸŸ', rarity: 'uncommon' }, { name: 'Octopus', emoji: '๐Ÿ™', rarity: 'uncommon' }, + { name: 'Barracuda', emoji: '๐ŸŸ', rarity: 'uncommon' }, + { name: 'Pufferfish', emoji: '๐Ÿก', rarity: 'uncommon' }, + { name: 'Snapper', emoji: '๐Ÿ ', rarity: 'uncommon' }, + { name: 'Mahi Mahi', emoji: '๐Ÿ ', rarity: 'uncommon' }, // Rare { name: 'Lobster', emoji: '๐Ÿฆž', rarity: 'rare' }, { name: 'Shark', emoji: '๐Ÿฆˆ', rarity: 'rare' }, { name: 'Manta Ray', emoji: '๐ŸŸ', rarity: 'rare' }, + { name: 'Stingray', emoji: '๐ŸŸ', rarity: 'rare' }, + { name: 'Anglerfish', emoji: '๐Ÿก', rarity: 'rare' }, + { name: 'Moray Eel', emoji: '๐Ÿ', rarity: 'rare' }, + { name: 'Hammerhead Shark', emoji: '๐Ÿฆˆ', rarity: 'rare' }, // Epic { name: 'Golden Koi', emoji: '๐Ÿ ', rarity: 'epic' }, { name: 'Blue Marlin', emoji: '๐ŸŸ', rarity: 'epic' }, { name: 'Crystal Fish', emoji: '๐Ÿ’Ž', rarity: 'epic' }, + { name: 'Moonfish', emoji: '๐ŸŒ™', rarity: 'epic' }, + { name: 'Electric Eel', emoji: 'โšก', rarity: 'epic' }, + { name: 'Ruby Grouper', emoji: '๐Ÿ”ด', rarity: 'epic' }, + { name: 'Sapphire Tuna', emoji: '๐Ÿ”ต', rarity: 'epic' }, // Legendary { name: 'Golden Shark', emoji: '๐Ÿฆˆ', rarity: 'legendary' }, { name: 'Sea Dragon', emoji: '๐Ÿ‰', rarity: 'legendary' }, { name: 'Golden Whale', emoji: '๐Ÿ‹', rarity: 'legendary' }, + { name: 'Storm Serpent', emoji: '๐ŸŒฉ๏ธ', rarity: 'legendary' }, + { name: 'Aurora Whale', emoji: '๐ŸŒŒ', rarity: 'legendary' }, + { name: 'Thunder Marlin', emoji: 'โšก', rarity: 'legendary' }, + { name: 'Emerald Leviathan', emoji: '๐Ÿ’š', rarity: 'legendary' }, // Mythic { name: 'Leviathan', emoji: '๐Ÿ‰', rarity: 'mythic' }, { name: 'Megalodon', emoji: '๐Ÿฆˆ', rarity: 'mythic' }, { name: 'Phoenix Koi', emoji: '๐Ÿ”ฅ', rarity: 'mythic' }, + { name: 'Celestial Turtle', emoji: '๐Ÿข', rarity: 'mythic' }, + { name: 'Abyssal Dragon', emoji: '๐Ÿ‰', rarity: 'mythic' }, + { name: 'Star Kraken', emoji: '๐Ÿ™', rarity: 'mythic' }, + { name: 'Obsidian Whale', emoji: '๐Ÿ‹', rarity: 'mythic' }, // Secret { name: 'Void Whale', emoji: '๐ŸŒŒ', rarity: 'secret' }, { name: 'Ancient Kraken', emoji: '๐Ÿ™', rarity: 'secret' }, { name: 'Abyss Serpent', emoji: '๐Ÿ', rarity: 'secret' }, + { name: 'Shadow Leviathan', emoji: '๐ŸŒ‘', rarity: 'secret' }, + { name: 'Eclipse Ray', emoji: '๐ŸŒ˜', rarity: 'secret' }, + { name: 'Genesis Eel', emoji: 'โšก', rarity: 'secret' }, + { name: 'Nebula Shark', emoji: '๐Ÿฆˆ', rarity: 'secret' }, // Divine (Above Secret) { name: 'Cosmic Leviathan', emoji: '๐ŸŒ ', rarity: 'divine' }, { name: 'God of Ocean', emoji: '๐Ÿ”ฑ', rarity: 'divine' }, - { name: 'Eternal Whale', emoji: '๐ŸŒŒ', rarity: 'divine' } + { name: 'Eternal Whale', emoji: '๐ŸŒŒ', rarity: 'divine' }, + { name: 'Ocean Titan', emoji: '๐ŸŒŠ', rarity: 'divine' }, + { name: 'Holy Megalodon', emoji: 'โœจ', rarity: 'divine' }, + { name: 'Astral Dragon', emoji: '๐Ÿ‰', rarity: 'divine' }, + { name: 'Solar Koi', emoji: 'โ˜€๏ธ', rarity: 'divine' }, + + // Transcendent (Above Divine) + { name: 'Chronos Whale', emoji: 'โณ', rarity: 'transcendent' }, + { name: 'Omnipotent Kraken', emoji: '๐Ÿ‘‘', rarity: 'transcendent' }, + { name: 'Primordial Serpent', emoji: '๐Ÿ', rarity: 'transcendent' }, + { name: 'Infinity Koi', emoji: 'โ™พ๏ธ', rarity: 'transcendent' }, + { name: 'Genesis Leviathan', emoji: '๐ŸŒŸ', rarity: 'transcendent' }, + { name: 'Celestia Dragon', emoji: '๐Ÿฒ', rarity: 'transcendent' } ]; const CATCH_MESSAGES = [ @@ -173,8 +214,10 @@ export default { selectedRarity = 'mythic'; } else if (rand < 0.9995) { selectedRarity = 'secret'; - } else { + } else if (rand < 0.9998) { selectedRarity = 'divine'; + } else { + selectedRarity = 'transcendent'; } const availableFish = FISH_TYPES.filter( @@ -192,7 +235,8 @@ export default { legendary: [3750, 7500], mythic: [15000, 25000], secret: [30000, 50000], - divine: [62500, 175000] + divine: [62500, 175000], + transcendent: [250000, 500000] }; const [minPrice, maxPrice] = fishPrices[fishCaught.rarity]; @@ -236,7 +280,8 @@ export default { legendary: '#F1C40F', mythic: '#E91E63', secret: '#FF0000', - divine: '#00FFFF' + divine: '#00FFFF', + transcendent: '#FF00FF' }; const embed = createEmbed({ From c42188b7271a28016f23f9181cb4db3a82f730a8 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 14:09:43 +0000 Subject: [PATCH 25/53] feat: update workspace Co-authored-by: traeagent --- src/commands/Economy/fish.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 7dcba1a502..600966480d 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -119,7 +119,10 @@ const FISH_TYPES = [ { name: 'Primordial Serpent', emoji: '๐Ÿ', rarity: 'transcendent' }, { name: 'Infinity Koi', emoji: 'โ™พ๏ธ', rarity: 'transcendent' }, { name: 'Genesis Leviathan', emoji: '๐ŸŒŸ', rarity: 'transcendent' }, - { name: 'Celestia Dragon', emoji: '๐Ÿฒ', rarity: 'transcendent' } + { name: 'Celestia Dragon', emoji: '๐Ÿฒ', rarity: 'transcendent' }, + + // Celestial (Above Transcendent) โ€” Kitsune sengaja TIDAK bisa dipancing + { name: 'Kitsune', emoji: '๐ŸฆŠ', rarity: 'celestial' } ]; const CATCH_MESSAGES = [ @@ -236,7 +239,8 @@ export default { mythic: [15000, 25000], secret: [30000, 50000], divine: [62500, 175000], - transcendent: [250000, 500000] + transcendent: [250000, 500000], + celestial: [1000000, 5000000] }; const [minPrice, maxPrice] = fishPrices[fishCaught.rarity]; @@ -281,7 +285,8 @@ export default { mythic: '#E91E63', secret: '#FF0000', divine: '#00FFFF', - transcendent: '#FF00FF' + transcendent: '#FF00FF', + celestial: '#FF69B4' }; const embed = createEmbed({ From a73a1b1eccfe6c403648e740d38ac644bb1fbe2d Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 14:12:03 +0000 Subject: [PATCH 26/53] feat: update workspace Co-authored-by: traeagent --- src/commands/Economy/fish.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 600966480d..2e49114fd9 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -121,7 +121,7 @@ const FISH_TYPES = [ { name: 'Genesis Leviathan', emoji: '๐ŸŒŸ', rarity: 'transcendent' }, { name: 'Celestia Dragon', emoji: '๐Ÿฒ', rarity: 'transcendent' }, - // Celestial (Above Transcendent) โ€” Kitsune sengaja TIDAK bisa dipancing + // Celestial (Above Transcendent) โ€” Kitsune, peluang 2x lebih susah dari transcendent { name: 'Kitsune', emoji: '๐ŸฆŠ', rarity: 'celestial' } ]; @@ -217,10 +217,12 @@ export default { selectedRarity = 'mythic'; } else if (rand < 0.9995) { selectedRarity = 'secret'; - } else if (rand < 0.9998) { + } else if (rand < 0.9997) { selectedRarity = 'divine'; - } else { + } else if (rand < 0.9999) { selectedRarity = 'transcendent'; + } else { + selectedRarity = 'celestial'; } const availableFish = FISH_TYPES.filter( From 3d737b934b61288b7dcbd784620bdb17611b4880 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 14:13:26 +0000 Subject: [PATCH 27/53] feat: update workspace Co-authored-by: traeagent --- src/commands/Economy/fish.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 2e49114fd9..531598401f 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -194,7 +194,9 @@ export default { let luckMessage = ""; if (bestPotion) { luckMultiplier = bestPotion.effect.multiplier || 1; - rand = Math.min(rand * luckMultiplier, 0.9999); + // Potion tidak bisa menembus celestial (Kitsune): cap di bawah ambang 0.9999, + // jadi paling tinggi hanya sampai transcendent. + rand = Math.min(rand * luckMultiplier, 0.9998); const minutesLeft = Math.ceil( (Number(potionExpiries[bestPotion.id]) - now) / 60000 ); From 68f1455335f84733a7240f7f3d3bcef29ad383e3 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 14:21:57 +0000 Subject: [PATCH 28/53] fix: smoother luck potion bias; divine & transcendent stay rare --- src/commands/Economy/fish.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 531598401f..1881f512b4 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -194,9 +194,12 @@ export default { let luckMessage = ""; if (bestPotion) { luckMultiplier = bestPotion.effect.multiplier || 1; - // Potion tidak bisa menembus celestial (Kitsune): cap di bawah ambang 0.9999, - // jadi paling tinggi hanya sampai transcendent. - rand = Math.min(rand * luckMultiplier, 0.9998); + // Bias halus: potion menaikkan peluang ikan langka tanpa "membanjiri" + // tier atas (dulu rand dikali lalu di-cap, sehingga transcendent jadi + // sangat mudah). Hasil tetap dibatasi 0.9998 agar potion tidak bisa + // menembus celestial (Kitsune). + rand = 1 - Math.pow(1 - rand, luckMultiplier); + if (rand > 0.9998) rand = 0.9998; const minutesLeft = Math.ceil( (Number(potionExpiries[bestPotion.id]) - now) / 60000 ); From 8eb7206abd5efcdcaebffa45efdd0b9f4cf79e03 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 21:39:46 +0700 Subject: [PATCH 29/53] Update fish.js --- src/commands/Economy/fish.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 1881f512b4..0883582ad5 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -43,7 +43,7 @@ const FISH_TYPES = [ // Common { name: 'Bass', emoji: '๐ŸŸ', rarity: 'common' }, { name: 'Salmon', emoji: '๐Ÿ ', rarity: 'common' }, - { name: 'Trout', emoji: '๐ŸŸ', rarity: 'common' }, + { name: 'forel', emoji: '๐ŸŸ', rarity: 'common' }, { name: 'Carp', emoji: '๐ŸŸ', rarity: 'common' }, { name: 'Catfish', emoji: '๐ŸŸ', rarity: 'common' }, { name: 'Perch', emoji: '๐ŸŸ', rarity: 'common' }, @@ -126,11 +126,16 @@ const FISH_TYPES = [ ]; const CATCH_MESSAGES = [ - "You cast your line into the crystal clear waters...", - "You wait patiently as your bobber floats...", - "After a few minutes of waiting, you feel a tug...", - "The water ripples as something takes your bait...", - "You reel in your catch with expert precision...", + "Kamu melemparkan kailmu ke dalam air yang sangat jernih...", + "Kamu menunggu dengan sabar saat pelampung pancingmu mengapung...", + "Setelah beberapa menit menunggu, kamu merasakan tarikan pada kailmu...", + "Air mulai beriak saat sesuatu memakan umpanmu...", + "Kamu menarik hasil tangkapanmu dengan sangat ahli...", + "Pelampungmu tenggelam! Sepertinya ada ikan yang menyambar...", + "Air di sekitarmu mulai bergejolak...", + "Tarikan semakin kuat, sepertinya ikan ini cukup besar...", + "Kailmu bergerak liar, tangkapanmu berusaha melarikan diri...", + "Setelah perjuangan panjang, akhirnya kamu berhasil mendapatkan ikan...", ]; export default { From 12d8e1904741f99c2ee75924c1ec50cf27e16303 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 14:50:16 +0000 Subject: [PATCH 30/53] feat: add Admin Potion (buy 1x, 1min active/cooldown, divine 30%/transcendent 20%/kitsune 10%) --- src/commands/Economy/buy.js | 32 +++++++++++++- src/commands/Economy/fish.js | 86 ++++++++++++++++++++++-------------- src/config/shop/items.js | 21 +++++++++ 3 files changed, 105 insertions(+), 34 deletions(-) diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 0781d6f52b..5ee0926c21 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -7,12 +7,14 @@ import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHan import { InteractionHelper } from '../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; +const ADMIN_POTION_ID = 'admin_potion'; const LUCK_POTION_IDS = new Set([ 'luck_potion_1', 'luck_potion_2', 'luck_potion_3', 'luck_potion_4', 'luck_potion_5', + ADMIN_POTION_ID, ]); const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; @@ -107,6 +109,27 @@ export default { } } + if (itemId === ADMIN_POTION_ID) { + if (quantity > 1) { + throw createError( + "Invalid quantity for admin potion", + ErrorTypes.VALIDATION, + `You can only purchase **${item.name}** one at a time.`, + { itemId, quantity } + ); + } + const activeUntil = Number(userData.luckPotionExpiresAt?.[ADMIN_POTION_ID]) || 0; + if (activeUntil > Date.now()) { + const secondsLeft = Math.ceil((activeUntil - Date.now()) / 1000); + throw createError( + "Admin potion on cooldown", + ErrorTypes.RATE_LIMIT, + `**${item.name}** is still active. You can buy it again in **${secondsLeft}s**.`, + { itemId, secondsLeft } + ); + } + } + userData.wallet -= totalCost; let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; @@ -151,8 +174,13 @@ export default { const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; potionExpiries[itemId] = currentExpiry + (duration * quantity); userData.luckPotionExpiresAt = potionExpiries; - const activeMinutes = Math.ceil((potionExpiries[itemId] - now) / 60000); - successDescription += `\n\n๐Ÿงช **Active for ${activeMinutes} minutes. Timer started at purchase.**`; + if (itemId === ADMIN_POTION_ID) { + const activeSeconds = Math.ceil((potionExpiries[itemId] - now) / 1000); + successDescription += `\n\n๐Ÿ‘‘ **Active for ${activeSeconds}s: Divine 30% / Transcendent 20% / Kitsune 10%. Timer started at purchase.**`; + } else { + const activeMinutes = Math.ceil((potionExpiries[itemId] - now) / 60000); + successDescription += `\n\n๐Ÿงช **Active for ${activeMinutes} minutes. Timer started at purchase.**`; + } } else if (item.type === "consumable" || item.type === "tool") { userData.inventory[itemId] = (userData.inventory[itemId] || 0) + quantity; diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 1881f512b4..f5f9cfb9c0 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -10,6 +10,7 @@ const FISH_COOLDOWN = 0; const BASE_MIN_REWARD = 300; const BASE_MAX_REWARD = 900; const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; +const ADMIN_POTION_ID = 'admin_potion'; const PREMIUM_FISHING_MULTIPLIER = getItemById('premium_role')?.effect?.multiplier ?? 1.1; // Helper: find the highest-tier fishing rod the user owns @@ -189,45 +190,66 @@ export default { let rand = Math.random(); - // Terapkan potion terkuat yang masih aktif tanpa menghabiskannya. - let luckMultiplier = 1; + // Potion admin punya prioritas dan efek khusus; kalau tidak ada, pakai potion luck biasa. + const adminActive = Number(potionExpiries[ADMIN_POTION_ID]) > now; + const activePotion = adminActive ? getItemById(ADMIN_POTION_ID) : bestPotion; + const adminChances = activePotion?.effect?.admin ? activePotion.effect.chances : null; + let luckMessage = ""; - if (bestPotion) { - luckMultiplier = bestPotion.effect.multiplier || 1; - // Bias halus: potion menaikkan peluang ikan langka tanpa "membanjiri" - // tier atas (dulu rand dikali lalu di-cap, sehingga transcendent jadi - // sangat mudah). Hasil tetap dibatasi 0.9998 agar potion tidak bisa - // menembus celestial (Kitsune). - rand = 1 - Math.pow(1 - rand, luckMultiplier); - if (rand > 0.9998) rand = 0.9998; + if (activePotion) { const minutesLeft = Math.ceil( - (Number(potionExpiries[bestPotion.id]) - now) / 60000 + (Number(potionExpiries[activePotion.id]) - now) / 60000 ); - luckMessage = `\n๐Ÿงช **${bestPotion.name} Active: x${luckMultiplier} luck (${minutesLeft}m left)**`; + if (adminChances) { + luckMessage = `\n๐Ÿ‘‘ **${activePotion.name} Active: Divine ${Math.round(adminChances.divine * 100)}% / Transcendent ${Math.round(adminChances.transcendent * 100)}% / Kitsune ${Math.round(adminChances.celestial * 100)}% (${minutesLeft}m left)**`; + } else { + const luckMultiplier = activePotion.effect.multiplier || 1; + // Bias halus: potion menaikkan peluang ikan langka tanpa "membanjiri" + // tier atas. Hasil tetap dibatasi 0.9998 agar potion tidak bisa + // menembus celestial (Kitsune). + rand = 1 - Math.pow(1 - rand, luckMultiplier); + if (rand > 0.9998) rand = 0.9998; + luckMessage = `\n๐Ÿงช **${activePotion.name} Active: x${luckMultiplier} luck (${minutesLeft}m left)**`; + } } let selectedRarity; - if (rand < 0.45) { - selectedRarity = 'common'; - } else if (rand < 0.70) { - selectedRarity = 'uncommon'; - } else if (rand < 0.85) { - selectedRarity = 'rare'; - } else if (rand < 0.94) { - selectedRarity = 'epic'; - } else if (rand < 0.98) { - selectedRarity = 'legendary'; - } else if (rand < 0.995) { - selectedRarity = 'mythic'; - } else if (rand < 0.9995) { - selectedRarity = 'secret'; - } else if (rand < 0.9997) { - selectedRarity = 'divine'; - } else if (rand < 0.9999) { - selectedRarity = 'transcendent'; - } else { - selectedRarity = 'celestial'; + // Potion admin: peluang tetap divine/transcendent/celestial; sisanya (40%) normal. + if (adminChances) { + const roll = Math.random(); + let acc = 0; + if (roll < (acc += adminChances.divine)) { + selectedRarity = 'divine'; + } else if (roll < (acc += adminChances.transcendent)) { + selectedRarity = 'transcendent'; + } else if (roll < (acc += adminChances.celestial)) { + selectedRarity = 'celestial'; + } + } + + if (!selectedRarity) { + if (rand < 0.45) { + selectedRarity = 'common'; + } else if (rand < 0.70) { + selectedRarity = 'uncommon'; + } else if (rand < 0.85) { + selectedRarity = 'rare'; + } else if (rand < 0.94) { + selectedRarity = 'epic'; + } else if (rand < 0.98) { + selectedRarity = 'legendary'; + } else if (rand < 0.995) { + selectedRarity = 'mythic'; + } else if (rand < 0.9995) { + selectedRarity = 'secret'; + } else if (rand < 0.9997) { + selectedRarity = 'divine'; + } else if (rand < 0.9999) { + selectedRarity = 'transcendent'; + } else { + selectedRarity = 'celestial'; + } } const availableFish = FISH_TYPES.filter( diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 18ad2c2812..9b78da8a12 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -195,6 +195,27 @@ roleId: null, duration: 300000 } }, + + // ===== Admin Potion (bisa dibeli 1x, aktif 1 menit, cooldown 1 menit) ===== + { + id: 'admin_potion', + name: '๐Ÿ‘‘ Admin Potion', + price: 1000000, + description: 'Admin potion. For 1 minute, greatly boosts luck: Divine 30%, Transcendent 20%, Kitsune 10%. Max 1, can buy again after 1 minute.', + type: 'consumable', + maxQuantity: 1, + cooldown: 60000, + effect: { + type: 'fishing_luck', + admin: true, + chances: { + divine: 0.3, + transcendent: 0.2, + celestial: 0.1 + }, + duration: 60000 + } + }, { id: 'pickaxe', name: 'โ›๏ธ Pickaxe', From e67b97996ebf1be202bb3735f73f33acc1826883 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 21:52:44 +0700 Subject: [PATCH 31/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 9b78da8a12..f7d410895b 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -200,7 +200,7 @@ roleId: null, { id: 'admin_potion', name: '๐Ÿ‘‘ Admin Potion', - price: 1000000, + price: 1, description: 'Admin potion. For 1 minute, greatly boosts luck: Divine 30%, Transcendent 20%, Kitsune 10%. Max 1, can buy again after 1 minute.', type: 'consumable', maxQuantity: 1, From 580c0e167520a318d3da72309f4ab3a2e4ae6f6b Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 14:59:15 +0000 Subject: [PATCH 32/53] feat: admin potion becomes inventory item; add /use and /give for trading items - Admin Potion now stored in inventory (buy -> hold max 1 -> /use to activate 1 min) - New /use: activate fishing luck potions from inventory - New /give: transfer inventory items to another user (with max/ownership checks) - Remove misleading prefix alias give->pay --- src/commands/Economy/buy.js | 23 ++-- src/commands/Economy/give.js | 153 ++++++++++++++++++++++++++ src/commands/Economy/use.js | 101 +++++++++++++++++ src/config/commands/commandAliases.js | 1 - src/config/shop/items.js | 4 +- 5 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 src/commands/Economy/give.js create mode 100644 src/commands/Economy/use.js diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 5ee0926c21..a53b7a36dc 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -8,13 +8,14 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; const SHOP_ITEMS = shopItems; const ADMIN_POTION_ID = 'admin_potion'; +// Potion ini langsung aktif saat dibeli. Admin potion TIDAK termasuk di sini: +// admin potion disimpan ke inventory dulu supaya bisa dipakai (/use) atau dikirim (/give). const LUCK_POTION_IDS = new Set([ 'luck_potion_1', 'luck_potion_2', 'luck_potion_3', 'luck_potion_4', 'luck_potion_5', - ADMIN_POTION_ID, ]); const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; @@ -118,6 +119,15 @@ export default { { itemId, quantity } ); } + const held = Number(userData.inventory?.[ADMIN_POTION_ID]) || 0; + if (held >= 1) { + throw createError( + "Admin potion already held", + ErrorTypes.VALIDATION, + `You already hold an **${item.name}**. Use it with \`/use\` first.`, + { itemId } + ); + } const activeUntil = Number(userData.luckPotionExpiresAt?.[ADMIN_POTION_ID]) || 0; if (activeUntil > Date.now()) { const secondsLeft = Math.ceil((activeUntil - Date.now()) / 1000); @@ -174,18 +184,15 @@ export default { const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; potionExpiries[itemId] = currentExpiry + (duration * quantity); userData.luckPotionExpiresAt = potionExpiries; - if (itemId === ADMIN_POTION_ID) { - const activeSeconds = Math.ceil((potionExpiries[itemId] - now) / 1000); - successDescription += `\n\n๐Ÿ‘‘ **Active for ${activeSeconds}s: Divine 30% / Transcendent 20% / Kitsune 10%. Timer started at purchase.**`; - } else { - const activeMinutes = Math.ceil((potionExpiries[itemId] - now) / 60000); - successDescription += `\n\n๐Ÿงช **Active for ${activeMinutes} minutes. Timer started at purchase.**`; - } + const activeMinutes = Math.ceil((potionExpiries[itemId] - now) / 60000); + successDescription += `\n\n๐Ÿงช **Active for ${activeMinutes} minutes. Timer started at purchase.**`; } else if (item.type === "consumable" || item.type === "tool") { userData.inventory[itemId] = (userData.inventory[itemId] || 0) + quantity; if (item.type === "tool") { successDescription += `\n\n**๐Ÿ› ๏ธ ${item.name} added to your inventory!**`; + } else if (itemId === ADMIN_POTION_ID) { + successDescription += `\n\n๐Ÿ‘‘ **Added to your inventory!** Activate it with \`/use item_id:${ADMIN_POTION_ID}\` or gift it with \`/give\`.`; } } diff --git a/src/commands/Economy/give.js b/src/commands/Economy/give.js new file mode 100644 index 0000000000..179d80379d --- /dev/null +++ b/src/commands/Economy/give.js @@ -0,0 +1,153 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed, successEmbed } from '../../utils/embeds.js'; +import { getItemById } from '../../config/shop/items.js'; +import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +export default { + data: new SlashCommandBuilder() + .setName('give') + .setDescription('Give an item from your inventory to another user') + .addUserOption(option => + option + .setName('user') + .setDescription('User to give the item to') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('item_id') + .setDescription('ID of the item to give (e.g. admin_potion)') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('quantity') + .setDescription('Quantity to give (default: 1)') + .setRequired(false) + .setMinValue(1) + ), + + execute: withErrorHandling(async (interaction, config, client) => { + const deferred = await InteractionHelper.safeDefer(interaction); + if (!deferred) return; + + const senderId = interaction.user.id; + const receiver = interaction.options.getUser("user"); + const itemId = interaction.options.getString("item_id").toLowerCase(); + const quantity = interaction.options.getInteger("quantity") || 1; + const guildId = interaction.guildId; + + if (receiver.bot) { + throw createError( + "Cannot give to bot", + ErrorTypes.VALIDATION, + "You cannot give an item to a bot.", + { receiverId: receiver.id } + ); + } + + if (receiver.id === senderId) { + throw createError( + "Cannot give to self", + ErrorTypes.VALIDATION, + "You cannot give an item to yourself.", + { senderId } + ); + } + + const item = getItemById(itemId); + if (!item) { + throw createError( + `Item ${itemId} not found`, + ErrorTypes.VALIDATION, + `The item ID \`${itemId}\` does not exist.`, + { itemId } + ); + } + + const [senderData, receiverData] = await Promise.all([ + getEconomyData(client, guildId, senderId), + getEconomyData(client, guildId, receiver.id) + ]); + + const senderHeld = Number(senderData.inventory?.[itemId]) || 0; + if (senderHeld < quantity) { + throw createError( + "Not enough items", + ErrorTypes.VALIDATION, + `You only have **${senderHeld}x ${item.name}** in your inventory.`, + { itemId, senderHeld, quantity } + ); + } + + // Batasi kepemilikan penerima seperti aturan pembelian. + const receiverHeld = Number(receiverData.inventory?.[itemId]) || 0; + if (item.type === 'tool' && itemId !== 'bank_note' && receiverHeld > 0) { + throw createError( + "Receiver already owns item", + ErrorTypes.VALIDATION, + `**${receiver.username}** already owns a **${item.name}**.`, + { itemId, receiverId: receiver.id } + ); + } + if (item.type === 'consumable' && item.maxQuantity && receiverHeld + quantity > item.maxQuantity) { + throw createError( + "Receiver inventory limit", + ErrorTypes.VALIDATION, + `**${receiver.username}** can only hold a maximum of **${item.maxQuantity}x ${item.name}**.`, + { itemId, receiverId: receiver.id, receiverHeld, quantity, maxQuantity: item.maxQuantity } + ); + } + + if (senderHeld - quantity > 0) { + senderData.inventory[itemId] = senderHeld - quantity; + } else { + delete senderData.inventory[itemId]; + } + + receiverData.inventory[itemId] = receiverHeld + quantity; + + await Promise.all([ + setEconomyData(client, guildId, senderId, senderData), + setEconomyData(client, guildId, receiver.id, receiverData) + ]); + + const embed = successEmbed( + "๐ŸŽ Item Gifted", + `You gave **${quantity}x ${item.name}** to **${receiver.username}**!` + ).addFields( + { + name: "Item", + value: `${item.name} x${quantity}`, + inline: true, + }, + { + name: "Remaining", + value: `${Number(senderData.inventory[itemId]) || 0}x`, + inline: true, + }, + ); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + + logger.info(`[ECONOMY] Item gifted`, { + senderId, + receiverId: receiver.id, + itemId, + quantity, + }); + + try { + const receiverEmbed = createEmbed({ + title: "๐ŸŽ You received an item!", + description: `${interaction.user.username} gave you **${quantity}x ${item.name}**.` + }); + await receiver.send({ embeds: [receiverEmbed] }); + } catch (e) { + logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); + } + }, { command: 'give' }) +}; diff --git a/src/commands/Economy/use.js b/src/commands/Economy/use.js new file mode 100644 index 0000000000..5cba6c0efb --- /dev/null +++ b/src/commands/Economy/use.js @@ -0,0 +1,101 @@ +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; +import { getItemById } from '../../config/shop/items.js'; +import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +const ADMIN_POTION_ID = 'admin_potion'; +const DEFAULT_LUCK_POTION_DURATION_MS = 5 * 60 * 1000; + +export default { + data: new SlashCommandBuilder() + .setName('use') + .setDescription('Use a consumable item from your inventory (e.g. Admin Potion)') + .addStringOption(option => + option + .setName('item_id') + .setDescription('ID of the item to use (e.g. admin_potion)') + .setRequired(true) + ), + + execute: withErrorHandling(async (interaction, config, client) => { + const deferred = await InteractionHelper.safeDefer(interaction); + if (!deferred) return; + + const userId = interaction.user.id; + const guildId = interaction.guildId; + const itemId = interaction.options.getString("item_id").toLowerCase(); + + const item = getItemById(itemId); + if (!item) { + throw createError( + `Item ${itemId} not found`, + ErrorTypes.VALIDATION, + `The item ID \`${itemId}\` does not exist.`, + { itemId } + ); + } + + const userData = await getEconomyData(client, guildId, userId); + const held = Number(userData.inventory?.[itemId]) || 0; + + if (held <= 0) { + throw createError( + "Item not in inventory", + ErrorTypes.VALIDATION, + `You don't have **${item.name}** in your inventory.`, + { itemId } + ); + } + + if (item.effect?.type !== 'fishing_luck') { + throw createError( + "Item not usable", + ErrorTypes.VALIDATION, + `**${item.name}** cannot be used right now.`, + { itemId } + ); + } + + const now = Date.now(); + const potionExpiries = userData.luckPotionExpiresAt || {}; + + if (itemId === ADMIN_POTION_ID) { + const activeUntil = Number(potionExpiries[ADMIN_POTION_ID]) || 0; + if (activeUntil > now) { + const secondsLeft = Math.ceil((activeUntil - now) / 1000); + throw createError( + "Admin potion already active", + ErrorTypes.RATE_LIMIT, + `**${item.name}** is already active for **${secondsLeft}s**.`, + { itemId, secondsLeft } + ); + } + } + + const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; + const currentExpiry = Math.max(now, Number(potionExpiries[itemId]) || 0); + potionExpiries[itemId] = currentExpiry + duration; + userData.luckPotionExpiresAt = potionExpiries; + + const remaining = held - 1; + if (remaining > 0) { + userData.inventory[itemId] = remaining; + } else { + delete userData.inventory[itemId]; + } + + await setEconomyData(client, guildId, userId, userData); + + const activeSeconds = Math.ceil((potionExpiries[itemId] - now) / 1000); + let description = `You used **${item.name}**! It is now active for **${activeSeconds}s**.`; + if (itemId === ADMIN_POTION_ID) { + description += `\n\n๐Ÿ‘‘ **Luck: Divine 30% / Transcendent 20% / Kitsune 10%**`; + } + + const embed = successEmbed("โœจ Item Used", description); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); + }, { command: 'use' }) +}; diff --git a/src/config/commands/commandAliases.js b/src/config/commands/commandAliases.js index b23023a143..10b2086234 100644 --- a/src/config/commands/commandAliases.js +++ b/src/config/commands/commandAliases.js @@ -17,7 +17,6 @@ export const commandAliases = { 'rob': 'rob', 'crime': 'crime', 'pay': 'pay', - 'give': 'pay', 'send': 'pay', 'ping': 'ping', diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 9b78da8a12..afd6f682ce 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -196,12 +196,12 @@ roleId: null, } }, - // ===== Admin Potion (bisa dibeli 1x, aktif 1 menit, cooldown 1 menit) ===== + // ===== Admin Potion (disimpan di inventory, dipakai dengan /use, bisa dikirim dengan /give) ===== { id: 'admin_potion', name: '๐Ÿ‘‘ Admin Potion', price: 1000000, - description: 'Admin potion. For 1 minute, greatly boosts luck: Divine 30%, Transcendent 20%, Kitsune 10%. Max 1, can buy again after 1 minute.', + description: 'Admin potion. Activate with `/use` for 1 minute of great luck: Divine 30%, Transcendent 20%, Kitsune 10%. Max 1 held. Can be gifted with `/give`.', type: 'consumable', maxQuantity: 1, cooldown: 60000, From 664a0ef15e4c8e842c8191774933aafd43cc803d Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:03:31 +0000 Subject: [PATCH 33/53] feat(fish): show AI-generated image of the caught fish Each catch now renders an AI image (via Pollinations) based on the fish name and rarity, downloaded and attached to guarantee it displays. --- src/commands/Economy/fish.js | 44 ++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 2c5ba9e40b..ab0796d94b 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -1,4 +1,5 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommandBuilder, AttachmentBuilder } from 'discord.js'; +import axios from 'axios'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -323,6 +324,23 @@ export default { celestial: '#FF69B4' }; + // Gaya prompt gambar per rarity (dipakai untuk generate gambar AI ikan). + const rarityImageHints = { + common: 'common freshwater fish, realistic photo', + uncommon: 'tropical fish, realistic photo', + rare: 'large deep sea fish, dramatic lighting, realistic', + epic: 'rare glowing fish, vivid colors, fantasy', + legendary: 'legendary mythical sea creature, glowing aura, epic', + mythic: 'mythical giant sea monster, magical glow, epic fantasy', + secret: 'mysterious shadowy abyssal creature, dark magic, epic', + divine: 'divine celestial sea god creature, holy golden light, epic', + transcendent: 'transcendent cosmic sea leviathan, galaxies and starlight, ultra epic', + celestial: 'kawaii pink nine tailed fox fish, glowing pink aura, magical, cute anime style' + }; + + const imagePrompt = `${fishCaught.name}, ${rarityImageHints[fishCaught.rarity] || 'fish'}, single subject, centered, highly detailed`; + const fishImageUrl = `https://image.pollinations.ai/prompt/${encodeURIComponent(imagePrompt)}?width=768&height=768&nologo=true`; + const embed = createEmbed({ title: 'Fishing Success!', description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}${luckMessage}`, @@ -339,9 +357,27 @@ export default { value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), inline: true, } - ) - + ); + + // Ambil gambar AI ikan, lalu lampirkan (lebih andal daripada pasang URL langsung). + const files = []; + try { + const imageResponse = await axios.get(fishImageUrl, { + responseType: 'arraybuffer', + timeout: 15000, + }); + const imageBuffer = Buffer.from(imageResponse.data); + if (imageBuffer.length > 1000) { + files.push(new AttachmentBuilder(imageBuffer, { name: 'fish.jpg' })); + embed.setImage('attachment://fish.jpg'); + } else { + embed.setImage(fishImageUrl); + } + } catch { + // Kalau gagal generate, biarkan Discord mencoba fetch URL langsung. + embed.setImage(fishImageUrl); + } - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], files }); }, { command: 'fish' }) }; From 36a8c7ace737a66771672284bb409478c55b832e Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:05:31 +0000 Subject: [PATCH 34/53] perf(fish): reply instantly by using image URL directly (no download/wait) Use a deterministic per-fish seed so images are cached and load fast. --- src/commands/Economy/fish.js | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index ab0796d94b..56b4c03d56 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -1,5 +1,4 @@ -import { SlashCommandBuilder, AttachmentBuilder } from 'discord.js'; -import axios from 'axios'; +import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -339,7 +338,12 @@ export default { }; const imagePrompt = `${fishCaught.name}, ${rarityImageHints[fishCaught.rarity] || 'fish'}, single subject, centered, highly detailed`; - const fishImageUrl = `https://image.pollinations.ai/prompt/${encodeURIComponent(imagePrompt)}?width=768&height=768&nologo=true`; + // Seed tetap per ikan supaya gambarnya konsisten & cepat (ter-cache). + let seed = 0; + for (let i = 0; i < fishCaught.name.length; i++) { + seed = (seed * 31 + fishCaught.name.charCodeAt(i)) >>> 0; + } + const fishImageUrl = `https://image.pollinations.ai/prompt/${encodeURIComponent(imagePrompt)}?width=512&height=512&nologo=true&seed=${seed}`; const embed = createEmbed({ title: 'Fishing Success!', @@ -357,27 +361,10 @@ export default { value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), inline: true, } - ); - - // Ambil gambar AI ikan, lalu lampirkan (lebih andal daripada pasang URL langsung). - const files = []; - try { - const imageResponse = await axios.get(fishImageUrl, { - responseType: 'arraybuffer', - timeout: 15000, - }); - const imageBuffer = Buffer.from(imageResponse.data); - if (imageBuffer.length > 1000) { - files.push(new AttachmentBuilder(imageBuffer, { name: 'fish.jpg' })); - embed.setImage('attachment://fish.jpg'); - } else { - embed.setImage(fishImageUrl); - } - } catch { - // Kalau gagal generate, biarkan Discord mencoba fetch URL langsung. - embed.setImage(fishImageUrl); - } + ) + // Pasang URL gambar langsung: bot membalas instan, Discord yang memuat gambarnya. + .setImage(fishImageUrl); - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], files }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'fish' }) }; From 37fb91fc3bac5bdca0016fa0988b93bc4335b0d5 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:08:09 +0000 Subject: [PATCH 35/53] revert(fish): remove AI image feature (caused delay) --- src/commands/Economy/fish.js | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 56b4c03d56..c6d1d33ca0 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -323,28 +323,6 @@ export default { celestial: '#FF69B4' }; - // Gaya prompt gambar per rarity (dipakai untuk generate gambar AI ikan). - const rarityImageHints = { - common: 'common freshwater fish, realistic photo', - uncommon: 'tropical fish, realistic photo', - rare: 'large deep sea fish, dramatic lighting, realistic', - epic: 'rare glowing fish, vivid colors, fantasy', - legendary: 'legendary mythical sea creature, glowing aura, epic', - mythic: 'mythical giant sea monster, magical glow, epic fantasy', - secret: 'mysterious shadowy abyssal creature, dark magic, epic', - divine: 'divine celestial sea god creature, holy golden light, epic', - transcendent: 'transcendent cosmic sea leviathan, galaxies and starlight, ultra epic', - celestial: 'kawaii pink nine tailed fox fish, glowing pink aura, magical, cute anime style' - }; - - const imagePrompt = `${fishCaught.name}, ${rarityImageHints[fishCaught.rarity] || 'fish'}, single subject, centered, highly detailed`; - // Seed tetap per ikan supaya gambarnya konsisten & cepat (ter-cache). - let seed = 0; - for (let i = 0; i < fishCaught.name.length; i++) { - seed = (seed * 31 + fishCaught.name.charCodeAt(i)) >>> 0; - } - const fishImageUrl = `https://image.pollinations.ai/prompt/${encodeURIComponent(imagePrompt)}?width=512&height=512&nologo=true&seed=${seed}`; - const embed = createEmbed({ title: 'Fishing Success!', description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}${luckMessage}`, @@ -361,9 +339,7 @@ export default { value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), inline: true, } - ) - // Pasang URL gambar langsung: bot membalas instan, Discord yang memuat gambarnya. - .setImage(fishImageUrl); + ); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'fish' }) From af8a36342f122e1018b8e1600a48302747c14560 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:10:17 +0000 Subject: [PATCH 36/53] feat(buy): remove admin potion purchase cooldown/time gate --- src/commands/Economy/buy.js | 10 ---------- src/config/shop/items.js | 1 - 2 files changed, 11 deletions(-) diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index a53b7a36dc..4dd053e921 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -128,16 +128,6 @@ export default { { itemId } ); } - const activeUntil = Number(userData.luckPotionExpiresAt?.[ADMIN_POTION_ID]) || 0; - if (activeUntil > Date.now()) { - const secondsLeft = Math.ceil((activeUntil - Date.now()) / 1000); - throw createError( - "Admin potion on cooldown", - ErrorTypes.RATE_LIMIT, - `**${item.name}** is still active. You can buy it again in **${secondsLeft}s**.`, - { itemId, secondsLeft } - ); - } } userData.wallet -= totalCost; diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 73b0a5efdd..adb7daac3e 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -204,7 +204,6 @@ roleId: null, description: 'Admin potion. Activate with `/use` for 1 minute of great luck: Divine 30%, Transcendent 20%, Kitsune 10%. Max 1 held. Can be gifted with `/give`.', type: 'consumable', maxQuantity: 1, - cooldown: 60000, effect: { type: 'fishing_luck', admin: true, From 4b6e597f81d6e576fdd4b667be51a81dcf94e0e6 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:12:53 +0000 Subject: [PATCH 37/53] feat(admin_potion): remove purchase limit, extend duration to 5 minutes - Remove maxQuantity so multiple can be bought/held - Duration 1m -> 5m (300000ms) - /use no longer blocks while active; re-using extends the timer --- src/commands/Economy/buy.js | 20 -------------------- src/commands/Economy/use.js | 13 ------------- src/config/shop/items.js | 5 ++--- 3 files changed, 2 insertions(+), 36 deletions(-) diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index 4dd053e921..5fa66141c8 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -110,26 +110,6 @@ export default { } } - if (itemId === ADMIN_POTION_ID) { - if (quantity > 1) { - throw createError( - "Invalid quantity for admin potion", - ErrorTypes.VALIDATION, - `You can only purchase **${item.name}** one at a time.`, - { itemId, quantity } - ); - } - const held = Number(userData.inventory?.[ADMIN_POTION_ID]) || 0; - if (held >= 1) { - throw createError( - "Admin potion already held", - ErrorTypes.VALIDATION, - `You already hold an **${item.name}**. Use it with \`/use\` first.`, - { itemId } - ); - } - } - userData.wallet -= totalCost; let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; diff --git a/src/commands/Economy/use.js b/src/commands/Economy/use.js index 5cba6c0efb..a94beac6f9 100644 --- a/src/commands/Economy/use.js +++ b/src/commands/Economy/use.js @@ -61,19 +61,6 @@ export default { const now = Date.now(); const potionExpiries = userData.luckPotionExpiresAt || {}; - if (itemId === ADMIN_POTION_ID) { - const activeUntil = Number(potionExpiries[ADMIN_POTION_ID]) || 0; - if (activeUntil > now) { - const secondsLeft = Math.ceil((activeUntil - now) / 1000); - throw createError( - "Admin potion already active", - ErrorTypes.RATE_LIMIT, - `**${item.name}** is already active for **${secondsLeft}s**.`, - { itemId, secondsLeft } - ); - } - } - const duration = Number(item.effect?.duration) || DEFAULT_LUCK_POTION_DURATION_MS; const currentExpiry = Math.max(now, Number(potionExpiries[itemId]) || 0); potionExpiries[itemId] = currentExpiry + duration; diff --git a/src/config/shop/items.js b/src/config/shop/items.js index adb7daac3e..d288a9188f 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -201,9 +201,8 @@ roleId: null, id: 'admin_potion', name: '๐Ÿ‘‘ Admin Potion', price: 1, - description: 'Admin potion. Activate with `/use` for 1 minute of great luck: Divine 30%, Transcendent 20%, Kitsune 10%. Max 1 held. Can be gifted with `/give`.', + description: 'Admin potion. Activate with `/use` for 5 minutes of great luck: Divine 30%, Transcendent 20%, Kitsune 10%. Can be gifted with `/give`.', type: 'consumable', - maxQuantity: 1, effect: { type: 'fishing_luck', admin: true, @@ -212,7 +211,7 @@ roleId: null, transcendent: 0.2, celestial: 0.1 }, - duration: 60000 + duration: 300000 } }, { From 17b6b40c93bfc2f636d6286ff5a09e77dedaab4a Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 22:19:37 +0700 Subject: [PATCH 38/53] Update fish.js --- src/commands/Economy/fish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index c6d1d33ca0..d107111f38 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -294,7 +294,7 @@ export default { if (hasPremiumRole) { fishingMultiplier *= PREMIUM_FISHING_MULTIPLIER; const boostPct = Math.round((PREMIUM_FISHING_MULTIPLIER - 1) * 100); - bonusMessages.push(`โœจ **Premium Fishing Bonus: +${boostPct}%**`); + bonusMessages.push(`โœจ **Premium Role Bonus: +${boostPct}%**`); } const finalEarned = Math.floor(baseEarned * fishingMultiplier); const multiplierMessage = bonusMessages.length From dfb06b4f14a55529ae35e2420e1fc8752c7c2cfc Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 22:20:55 +0700 Subject: [PATCH 39/53] Update fish.js --- src/commands/Economy/fish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index d107111f38..98f621426e 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -274,7 +274,7 @@ export default { secret: [30000, 50000], divine: [62500, 175000], transcendent: [250000, 500000], - celestial: [1000000, 5000000] + celestial: [10000000, 20000000] }; const [minPrice, maxPrice] = fishPrices[fishCaught.rarity]; From 3f966c660ae410ecb54bbfac1f10996f80332ca2 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 22:23:47 +0700 Subject: [PATCH 40/53] Update items.js --- src/config/shop/items.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index d288a9188f..e66ac98097 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -46,7 +46,7 @@ cooldown: 86400000, roleId: null, effect: { type: 'fishing_yield', - multiplier: 5 + multiplier: 50000 } }, { @@ -191,7 +191,7 @@ roleId: null, maxQuantity: 10, effect: { type: 'fishing_luck', - multiplier: 2.2, + multiplier: 20000, duration: 300000 } }, From d1cb05d6072cc46a92492c6985604b7a12a5cc75 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 22:26:50 +0700 Subject: [PATCH 41/53] Update items.js --- src/config/shop/items.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index e66ac98097..8cedd4bded 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -46,7 +46,7 @@ cooldown: 86400000, roleId: null, effect: { type: 'fishing_yield', - multiplier: 50000 + multiplier: 4 } }, { @@ -133,7 +133,7 @@ roleId: null, { id: 'luck_potion_1', name: '๐Ÿงช Luck Potion I', - price: 10000, + price: 1000, description: 'Boosts your fishing luck slightly for 5 minutes. Higher chance of rarer fish.', type: 'consumable', maxQuantity: 10, @@ -146,7 +146,7 @@ roleId: null, { id: 'luck_potion_2', name: '๐Ÿงช Luck Potion II', - price: 30000, + price: 7000, description: 'Boosts your fishing luck for 5 minutes. Noticeably higher chance of rarer fish.', type: 'consumable', maxQuantity: 10, @@ -159,7 +159,7 @@ roleId: null, { id: 'luck_potion_3', name: '๐Ÿงช Luck Potion III', - price: 75000, + price: 50000, description: 'Boosts your fishing luck for 5 minutes. Good chance of rarer fish.', type: 'consumable', maxQuantity: 10, @@ -172,7 +172,7 @@ roleId: null, { id: 'luck_potion_4', name: '๐Ÿงช Luck Potion IV', - price: 150000, + price: 100000, description: 'Boosts your fishing luck for 5 minutes. Great chance of rarer fish.', type: 'consumable', maxQuantity: 10, @@ -185,14 +185,14 @@ roleId: null, { id: 'luck_potion_5', name: '๐Ÿงช Luck Potion V', - price: 300000, + price: 150000, description: 'Maximizes your fishing luck for 5 minutes. Best chance of legendary+ fish.', type: 'consumable', maxQuantity: 10, effect: { type: 'fishing_luck', multiplier: 20000, - duration: 300000 + duration: 2.1 } }, @@ -200,7 +200,7 @@ roleId: null, { id: 'admin_potion', name: '๐Ÿ‘‘ Admin Potion', - price: 1, + price: 100, description: 'Admin potion. Activate with `/use` for 5 minutes of great luck: Divine 30%, Transcendent 20%, Kitsune 10%. Can be gifted with `/give`.', type: 'consumable', effect: { From 7d1848345c1a8676a7f634ab4166281232e812ba Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:34:22 +0000 Subject: [PATCH 42/53] chore(fish): change admin potion display to gimmick luck text --- src/commands/Economy/fish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index c6d1d33ca0..c6835c08d8 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -206,7 +206,7 @@ export default { (Number(potionExpiries[activePotion.id]) - now) / 60000 ); if (adminChances) { - luckMessage = `\n๐Ÿ‘‘ **${activePotion.name} Active: Divine ${Math.round(adminChances.divine * 100)}% / Transcendent ${Math.round(adminChances.transcendent * 100)}% / Kitsune ${Math.round(adminChances.celestial * 100)}% (${minutesLeft}m left)**`; + luckMessage = `\n๐Ÿ‘‘ **${activePotion.name} Active: Luck +1000000% / Kitsune ${Math.round(adminChances.celestial * 100)}% (${minutesLeft}m left)**`; } else { const luckMultiplier = activePotion.effect.multiplier || 1; // Bias halus: potion menaikkan peluang ikan langka tanpa "membanjiri" From dea51de0fcd45e572f993a2c041022546da4df2b Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:37:13 +0000 Subject: [PATCH 43/53] chore(fish): remove gimmick luck percentage from potion message --- src/commands/Economy/fish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index ef0fc39a55..11623247bb 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -206,7 +206,7 @@ export default { (Number(potionExpiries[activePotion.id]) - now) / 60000 ); if (adminChances) { - luckMessage = `\n๐Ÿ‘‘ **${activePotion.name} Active: Luck +1000000% / Kitsune ${Math.round(adminChances.celestial * 100)}% (${minutesLeft}m left)**`; + luckMessage = `\n๐Ÿ‘‘ **${activePotion.name} Active: Kitsune ${Math.round(adminChances.celestial * 100)}% (${minutesLeft}m left)**`; } else { const luckMultiplier = activePotion.effect.multiplier || 1; // Bias halus: potion menaikkan peluang ikan langka tanpa "membanjiri" From 0719b386457c0d6a7025de7f28c35013ca32f75b Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:47:58 +0000 Subject: [PATCH 44/53] feat(economy): add personal and public fishdex with caught fish images --- src/commands/Economy/fish.js | 5 +- src/commands/Economy/fishdex.js | 178 ++++++++++++++++++++++++++++++++ src/utils/constants.js | 3 +- src/utils/schemas.js | 1 + 4 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 src/commands/Economy/fishdex.js diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 11623247bb..ba972374da 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -40,7 +40,7 @@ function getBestActiveLuckPotion(potionExpiries, now) { return null; } -const FISH_TYPES = [ +export const FISH_TYPES = [ // Common { name: 'Bass', emoji: '๐ŸŸ', rarity: 'common' }, { name: 'Salmon', emoji: '๐Ÿ ', rarity: 'common' }, @@ -305,6 +305,9 @@ export default { userData.wallet += finalEarned; userData.lastFish = now; + userData.fishCollection = userData.fishCollection || {}; + userData.fishCollection[fishCaught.name] = + (Number(userData.fishCollection[fishCaught.name]) || 0) + 1; userData.inventory = inventory; userData.luckPotionExpiresAt = potionExpiries; diff --git a/src/commands/Economy/fishdex.js b/src/commands/Economy/fishdex.js new file mode 100644 index 0000000000..df93c2e32d --- /dev/null +++ b/src/commands/Economy/fishdex.js @@ -0,0 +1,178 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + EmbedBuilder, + MessageFlags, + SlashCommandBuilder, +} from 'discord.js'; +import { FISH_TYPES } from './fish.js'; +import { getEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { logger } from '../../utils/logger.js'; + +const FISH_PER_PAGE = 5; +const RARITY_COLORS = { + common: 0x95A5A6, + uncommon: 0x2ECC71, + rare: 0x3498DB, + epic: 0x9B59B6, + legendary: 0xF1C40F, + mythic: 0xE91E63, + secret: 0xFF0000, + divine: 0x00FFFF, + transcendent: 0xFF00FF, + celestial: 0xFF69B4, +}; + +const IMAGE_HINTS = { + common: 'common freshwater fish, natural underwater photography', + uncommon: 'colorful tropical fish, natural underwater photography', + rare: 'rare deep sea fish, detailed underwater art', + epic: 'magical rare fish with vivid colors, fantasy art', + legendary: 'legendary sea creature with a glowing aura, epic fantasy art', + mythic: 'mythical giant sea creature, magical glow, epic fantasy art', + secret: 'mysterious shadowy abyssal sea creature, dark fantasy art', + divine: 'divine celestial sea creature, holy golden light, epic fantasy art', + transcendent: 'cosmic sea creature among galaxies and starlight, epic fantasy art', + celestial: 'cute pink nine-tailed fox fish, glowing magical aura, fantasy art', +}; + +function createImageUrl(fish) { + const prompt = `${fish.name}, ${IMAGE_HINTS[fish.rarity]}, single subject, centered, highly detailed`; + let seed = 0; + for (let i = 0; i < fish.name.length; i++) { + seed = (seed * 31 + fish.name.charCodeAt(i)) >>> 0; + } + return `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}?width=512&height=512&nologo=true&seed=${seed}`; +} + +function makeFishEmbeds(page, targetUser, fishCollection, totalPages) { + const start = page * FISH_PER_PAGE; + const pageFish = FISH_TYPES.slice(start, start + FISH_PER_PAGE); + const caughtSpecies = FISH_TYPES.filter( + fish => Number(fishCollection[fish.name]) > 0 + ).length; + + return pageFish.map((fish, index) => { + const count = Number(fishCollection[fish.name]) || 0; + const embed = new EmbedBuilder() + .setColor(RARITY_COLORS[fish.rarity] ?? 0x5865F2) + .setTitle(`${fish.emoji} ${fish.name}`); + + if (count > 0) { + embed + .setDescription( + `**Rarity:** ${fish.rarity.charAt(0).toUpperCase() + fish.rarity.slice(1)}\n**Tertangkap:** ${count} kali` + ) + .setImage(createImageUrl(fish)); + } else { + embed.setDescription('Belum tertangkap'); + } + + if (index === pageFish.length - 1) { + embed.setFooter({ + text: `${targetUser.username}: ${caughtSpecies}/${FISH_TYPES.length} spesies | Halaman ${page + 1}/${totalPages}`, + iconURL: targetUser.displayAvatarURL(), + }); + } + return embed; + }); +} + +function makeComponents(page, totalPages, customIdPrefix, disabled = false) { + if (totalPages <= 1) return []; + + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`${customIdPrefix}_prev`) + .setLabel('Sebelumnya') + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled || page === 0), + new ButtonBuilder() + .setCustomId(`${customIdPrefix}_next`) + .setLabel('Berikutnya') + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled || page === totalPages - 1), + ), + ]; +} + +export default { + slashOnly: true, + data: new SlashCommandBuilder() + .setName('fishdex') + .setDescription('Lihat index ikan tangkapanmu atau pengguna lain') + .addUserOption(option => + option + .setName('user') + .setDescription('Pengguna yang index ikannya ingin dilihat') + .setRequired(false) + ), + + execute: withErrorHandling(async (interaction, config, client) => { + const targetUser = interaction.options.getUser('user') || interaction.user; + if (targetUser.bot) { + throw createError( + 'Cannot view bot fishdex', + ErrorTypes.VALIDATION, + 'Bot tidak memiliki index tangkapan ikan.' + ); + } + + const userData = await getEconomyData(client, interaction.guildId, targetUser.id); + const fishCollection = userData.fishCollection || {}; + const totalPages = Math.ceil(FISH_TYPES.length / FISH_PER_PAGE); + const customIdPrefix = `fishdex_${interaction.id}`; + let page = 0; + + await interaction.reply({ + embeds: makeFishEmbeds(page, targetUser, fishCollection, totalPages), + components: makeComponents(page, totalPages, customIdPrefix), + }); + const message = await interaction.fetchReply(); + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 300000, + }); + + collector.on('collect', async buttonInteraction => { + if (buttonInteraction.user.id !== interaction.user.id) { + await buttonInteraction.reply({ + content: 'Hanya orang yang membuka fishdex ini yang bisa memakai tombolnya.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await buttonInteraction.deferUpdate(); + if (buttonInteraction.customId === `${customIdPrefix}_prev` && page > 0) { + page--; + } else if ( + buttonInteraction.customId === `${customIdPrefix}_next` + && page < totalPages - 1 + ) { + page++; + } + + await message.edit({ + embeds: makeFishEmbeds(page, targetUser, fishCollection, totalPages), + components: makeComponents(page, totalPages, customIdPrefix), + }); + }); + + collector.on('end', async () => { + try { + await message.edit({ + components: makeComponents(page, totalPages, customIdPrefix, true), + }); + } catch (error) { + logger.debug('fishdex: could not disable pagination buttons', { + error: error.message, + }); + } + }); + }, { command: 'fishdex' }), +}; diff --git a/src/utils/constants.js b/src/utils/constants.js index 18382d6225..bd1b1f10a0 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -18,6 +18,7 @@ export const DEFAULT_ECONOMY_DATA = { lastDeposit: 0, lastWithdraw: 0, inventory: {}, + fishCollection: {}, upgrades: {}, cooldowns: {} }; @@ -94,4 +95,4 @@ export default { DEFAULTS, ERROR_DEFAULTS, TIME -}; \ No newline at end of file +}; diff --git a/src/utils/schemas.js b/src/utils/schemas.js index 1ecc1af4bf..9778ded3f4 100644 --- a/src/utils/schemas.js +++ b/src/utils/schemas.js @@ -96,6 +96,7 @@ export const EconomyDataSchema = z xp: z.number().int().nonnegative().default(0), level: z.number().int().nonnegative().default(1), inventory: z.record(z.any()).default({}), + fishCollection: z.record(z.number().int().nonnegative()).default({}), cooldowns: z.record(z.number().int().nonnegative()).default({}) }) .passthrough(); From d621141e3b182e0980fc74ff1afb6f83d4a0f4d4 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 15:54:26 +0000 Subject: [PATCH 45/53] feat(fishdex): enable prefix command support --- src/commands/Economy/fishdex.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/Economy/fishdex.js b/src/commands/Economy/fishdex.js index df93c2e32d..fca545c071 100644 --- a/src/commands/Economy/fishdex.js +++ b/src/commands/Economy/fishdex.js @@ -101,7 +101,6 @@ function makeComponents(page, totalPages, customIdPrefix, disabled = false) { } export default { - slashOnly: true, data: new SlashCommandBuilder() .setName('fishdex') .setDescription('Lihat index ikan tangkapanmu atau pengguna lain') From 7d90f914ef1ac0618ea9123accc822e481b74958 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 16:01:12 +0000 Subject: [PATCH 46/53] feat(fishdex): show all fish grouped by rarity selector --- src/commands/Economy/fishdex.js | 69 +++++++++++++-------------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/src/commands/Economy/fishdex.js b/src/commands/Economy/fishdex.js index fca545c071..27a4655904 100644 --- a/src/commands/Economy/fishdex.js +++ b/src/commands/Economy/fishdex.js @@ -1,18 +1,17 @@ import { ActionRowBuilder, - ButtonBuilder, - ButtonStyle, ComponentType, EmbedBuilder, MessageFlags, SlashCommandBuilder, + StringSelectMenuBuilder, } from 'discord.js'; import { FISH_TYPES } from './fish.js'; import { getEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; -const FISH_PER_PAGE = 5; +const RARITIES = [...new Set(FISH_TYPES.map(fish => fish.rarity))]; const RARITY_COLORS = { common: 0x95A5A6, uncommon: 0x2ECC71, @@ -48,9 +47,8 @@ function createImageUrl(fish) { return `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}?width=512&height=512&nologo=true&seed=${seed}`; } -function makeFishEmbeds(page, targetUser, fishCollection, totalPages) { - const start = page * FISH_PER_PAGE; - const pageFish = FISH_TYPES.slice(start, start + FISH_PER_PAGE); +function makeFishEmbeds(rarity, targetUser, fishCollection) { + const pageFish = FISH_TYPES.filter(fish => fish.rarity === rarity); const caughtSpecies = FISH_TYPES.filter( fish => Number(fishCollection[fish.name]) > 0 ).length; @@ -73,7 +71,7 @@ function makeFishEmbeds(page, targetUser, fishCollection, totalPages) { if (index === pageFish.length - 1) { embed.setFooter({ - text: `${targetUser.username}: ${caughtSpecies}/${FISH_TYPES.length} spesies | Halaman ${page + 1}/${totalPages}`, + text: `${targetUser.username}: ${caughtSpecies}/${FISH_TYPES.length} spesies | ${pageFish.length} ikan ${rarity}`, iconURL: targetUser.displayAvatarURL(), }); } @@ -81,21 +79,18 @@ function makeFishEmbeds(page, targetUser, fishCollection, totalPages) { }); } -function makeComponents(page, totalPages, customIdPrefix, disabled = false) { - if (totalPages <= 1) return []; - +function makeComponents(selectedRarity, customIdPrefix, disabled = false) { return [ new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`${customIdPrefix}_prev`) - .setLabel('Sebelumnya') - .setStyle(ButtonStyle.Secondary) - .setDisabled(disabled || page === 0), - new ButtonBuilder() - .setCustomId(`${customIdPrefix}_next`) - .setLabel('Berikutnya') - .setStyle(ButtonStyle.Secondary) - .setDisabled(disabled || page === totalPages - 1), + new StringSelectMenuBuilder() + .setCustomId(`${customIdPrefix}_rarity`) + .setPlaceholder('Pilih rarity ikan') + .setDisabled(disabled) + .addOptions(RARITIES.map(rarity => ({ + label: rarity.charAt(0).toUpperCase() + rarity.slice(1), + value: rarity, + default: rarity === selectedRarity, + }))), ), ]; } @@ -123,49 +118,41 @@ export default { const userData = await getEconomyData(client, interaction.guildId, targetUser.id); const fishCollection = userData.fishCollection || {}; - const totalPages = Math.ceil(FISH_TYPES.length / FISH_PER_PAGE); const customIdPrefix = `fishdex_${interaction.id}`; - let page = 0; + let selectedRarity = RARITIES[0]; await interaction.reply({ - embeds: makeFishEmbeds(page, targetUser, fishCollection, totalPages), - components: makeComponents(page, totalPages, customIdPrefix), + embeds: makeFishEmbeds(selectedRarity, targetUser, fishCollection), + components: makeComponents(selectedRarity, customIdPrefix), }); const message = await interaction.fetchReply(); const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, + componentType: ComponentType.StringSelect, time: 300000, }); - collector.on('collect', async buttonInteraction => { - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ - content: 'Hanya orang yang membuka fishdex ini yang bisa memakai tombolnya.', + collector.on('collect', async selectInteraction => { + if (selectInteraction.user.id !== interaction.user.id) { + await selectInteraction.reply({ + content: 'Hanya orang yang membuka fishdex ini yang bisa memilih rarity.', flags: MessageFlags.Ephemeral, }); return; } - await buttonInteraction.deferUpdate(); - if (buttonInteraction.customId === `${customIdPrefix}_prev` && page > 0) { - page--; - } else if ( - buttonInteraction.customId === `${customIdPrefix}_next` - && page < totalPages - 1 - ) { - page++; - } + await selectInteraction.deferUpdate(); + selectedRarity = selectInteraction.values[0]; await message.edit({ - embeds: makeFishEmbeds(page, targetUser, fishCollection, totalPages), - components: makeComponents(page, totalPages, customIdPrefix), + embeds: makeFishEmbeds(selectedRarity, targetUser, fishCollection), + components: makeComponents(selectedRarity, customIdPrefix), }); }); collector.on('end', async () => { try { await message.edit({ - components: makeComponents(page, totalPages, customIdPrefix, true), + components: makeComponents(selectedRarity, customIdPrefix, true), }); } catch (error) { logger.debug('fishdex: could not disable pagination buttons', { From 81c980f27d65e4434bec87e3f6f744a4b4eaeb1b Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 16:03:05 +0000 Subject: [PATCH 47/53] perf(fishdex): shrink fish images to compact thumbnails --- src/commands/Economy/fishdex.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/commands/Economy/fishdex.js b/src/commands/Economy/fishdex.js index 27a4655904..03030a328f 100644 --- a/src/commands/Economy/fishdex.js +++ b/src/commands/Economy/fishdex.js @@ -44,7 +44,7 @@ function createImageUrl(fish) { for (let i = 0; i < fish.name.length; i++) { seed = (seed * 31 + fish.name.charCodeAt(i)) >>> 0; } - return `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}?width=512&height=512&nologo=true&seed=${seed}`; + return `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}?width=256&height=256&nologo=true&seed=${seed}`; } function makeFishEmbeds(rarity, targetUser, fishCollection) { @@ -61,10 +61,8 @@ function makeFishEmbeds(rarity, targetUser, fishCollection) { if (count > 0) { embed - .setDescription( - `**Rarity:** ${fish.rarity.charAt(0).toUpperCase() + fish.rarity.slice(1)}\n**Tertangkap:** ${count} kali` - ) - .setImage(createImageUrl(fish)); + .setDescription(`Tertangkap **${count}x**`) + .setThumbnail(createImageUrl(fish)); } else { embed.setDescription('Belum tertangkap'); } From afa1d19fb165fa517db074eb87ee520073305e35 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 23:13:25 +0700 Subject: [PATCH 48/53] Update items.js --- src/config/shop/items.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 8cedd4bded..138a5305d3 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -191,8 +191,8 @@ roleId: null, maxQuantity: 10, effect: { type: 'fishing_luck', - multiplier: 20000, - duration: 2.1 + multiplier: 2.1, + duration: 300000 } }, From 92a2efdacd4de64a2ce5d2896c4ea13053512179 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Wed, 11 Nov 2026 19:04:54 +0000 Subject: [PATCH 49/53] feat: Hapus Fitur Musik Co-authored-by: traeagent --- .env.example | 15 - README.md | 23 +- docker-compose.yml | 14 - lavalink/application.yml | 27 - lavalink/nodes.json | 30 -- package-lock.json | 21 - package.json | 3 +- src/app.js | 12 - src/commands/Core/help.js | 1 - src/commands/Music/join.js | 21 - src/commands/Music/music.js | 188 ------- src/commands/Music/nowplaying.js | 17 - src/commands/Music/play.js | 24 - src/commands/Music/queue.js | 24 - src/config/application.js | 1 - src/config/bot.js | 2 - src/config/commands/commandAliases.js | 3 - src/config/commands/commandCategories.js | 1 - src/config/commands/prefixRestrictions.js | 9 - src/config/music/lavalink.js | 85 --- src/events/messageCreate.js | 6 - src/events/ready.js | 5 - src/events/voiceStateUpdate.js | 7 - src/handlers/help/helpSelectMenus.js | 1 - src/handlers/musicButtons.js | 161 ------ src/interactions/buttons/music/music.js | 23 - src/services/music/musicActions.js | 612 ---------------------- src/services/music/musicEmbeds.js | 173 ------ src/services/music/musicVoiceState.js | 51 -- src/services/music/permissions.js | 14 - src/services/music/playerHandler.js | 254 --------- src/services/music/playerStore.js | 46 -- src/services/music/prefixSupport.js | 10 - src/services/music/riffySetup.js | 66 --- 34 files changed, 2 insertions(+), 1948 deletions(-) delete mode 100644 lavalink/application.yml delete mode 100644 lavalink/nodes.json delete mode 100644 src/commands/Music/join.js delete mode 100644 src/commands/Music/music.js delete mode 100644 src/commands/Music/nowplaying.js delete mode 100644 src/commands/Music/play.js delete mode 100644 src/commands/Music/queue.js delete mode 100644 src/config/music/lavalink.js delete mode 100644 src/handlers/musicButtons.js delete mode 100644 src/interactions/buttons/music/music.js delete mode 100644 src/services/music/musicActions.js delete mode 100644 src/services/music/musicEmbeds.js delete mode 100644 src/services/music/musicVoiceState.js delete mode 100644 src/services/music/permissions.js delete mode 100644 src/services/music/playerHandler.js delete mode 100644 src/services/music/playerStore.js delete mode 100644 src/services/music/prefixSupport.js delete mode 100644 src/services/music/riffySetup.js diff --git a/.env.example b/.env.example index c2eae4ad28..41e134a97b 100644 --- a/.env.example +++ b/.env.example @@ -51,20 +51,5 @@ BACKUP_DIR=./backups BACKUP_RETENTION_DAYS=14 POSTGRES_RESTORE_URL= -# Music (Lavalink + Riffy) โ€” requires Lavalink v4 nodes -# Default: loads public v4 SSL nodes from lavalink/nodes.json -# Source: https://lavalink.darrennathanael.com/SSL/Lavalink-SSL/ -# LAVALINK_NODES_FILE=lavalink/nodes.json -# Optional: override nodes as JSON array (takes priority over nodes file) -# LAVALINK_NODES=[{"host":"lavalink","port":2333,"password":"youshallnotpass","secure":false,"name":"Main"}] -# Self-hosted single node fallback (used only if nodes file/env array are absent): -# LAVALINK_HOST=localhost -# LAVALINK_PORT=2333 -# LAVALINK_PASSWORD=youshallnotpass -# LAVALINK_SECURE=false -# LAVALINK_NAME=Main -LAVALINK_SEARCH_PLATFORM=ytmsearch -LAVALINK_REST_VERSION=v4 - # Optional Feature/API Keys TMDB_API_KEY= diff --git a/README.md b/README.md index 700ac20739..84c728bb4f 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,6 @@ TitanBot offers a complete suite of tools for Discord server management and comm - **Auto Roles** - Assign roles on join - **Custom Embeds** - Personalized messages -### Music -- **24/7 Mode** - Play music 24/7 -- **Interative Button System** - Manage music through buttons -- **Supports EVERY platform** - Supports spotify, deezer, youtube, apple music - @@ -125,23 +120,7 @@ TitanBot is fully containerized for easy deployment. curl http://localhost:3000/health ``` -This starts the bot and PostgreSQL. The compose file sets `POSTGRES_SSL=false` and `AUTO_MIGRATE=true` for the bundled database. Music uses public Lavalink v4 nodes from `lavalink/nodes.json` by default. - -### Music - -Music uses [Lavalink v4](https://github.com/lavalink-devs/Lavalink) via [Riffy](https://github.com/riffy-rb/riffy), similar to [Musicify](https://github.com/codebymitch/Musicify). - -1. By default, the bot loads multiple public v4 SSL nodes from [`lavalink/nodes.json`](lavalink/nodes.json) (sourced from [lavalink.darrennathanael.com](https://lavalink.darrennathanael.com/SSL/Lavalink-SSL/)). Edit that file to add or remove nodes. -2. To self-host Lavalink instead, run `docker compose --profile local-lavalink up -d` and set single-node env vars in `.env`: - ```env - LAVALINK_HOST=lavalink - LAVALINK_PORT=2333 - LAVALINK_PASSWORD=youshallnotpass - LAVALINK_SECURE=false - ``` - Remove or rename `lavalink/nodes.json` so the bot falls back to those env vars. -3. Override nodes inline with `LAVALINK_NODES` (JSON array) or point at another file with `LAVALINK_NODES_FILE`. -4. Use `/play ` from a voice channel, or `/join` to connect without playing. Prefix shortcuts: `join`, `np`, `leave`, `pause`, `resume`, `skip`, `stop`, `volume <0-100>`, or `music `. Use `/nowplaying` and `/queue` for status; `/music` for loop, shuffle, seek, and other controls. +This starts the bot and PostgreSQL. The compose file sets `POSTGRES_SSL=false` and `AUTO_MIGRATE=true` for the bundled database. ### Using GitHub Container Registry diff --git a/docker-compose.yml b/docker-compose.yml index 9de8fec548..26180fd36b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,6 @@ services: - POSTGRES_SSL=false - AUTO_MIGRATE=true - LOG_LEVEL=${LOG_LEVEL:-warn} - - LAVALINK_NODES_FILE=lavalink/nodes.json - PORT=3000 - WEB_HOST=0.0.0.0 ports: @@ -37,19 +36,6 @@ services: networks: - titan-network - lavalink: - profiles: - - local-lavalink - image: ghcr.io/lavalink-devs/lavalink:4 - container_name: titanbot-lavalink - restart: unless-stopped - environment: - - LAVALINK_PASSWORD=${LAVALINK_PASSWORD:-youshallnotpass} - volumes: - - ./lavalink/application.yml:/opt/Lavalink/application.yml - networks: - - titan-network - db: image: postgres:15-alpine container_name: titanbot-db diff --git a/lavalink/application.yml b/lavalink/application.yml deleted file mode 100644 index 4c39a6fd7d..0000000000 --- a/lavalink/application.yml +++ /dev/null @@ -1,27 +0,0 @@ -server: - port: 2333 - address: 0.0.0.0 - -lavalink: - plugins: - - dependency: "dev.lavalink.youtube:youtube-plugin:1.13.5" - snapshot: false - server: - password: "${LAVALINK_PASSWORD:youshallnotpass}" - sources: - youtube: false - bandcamp: true - soundcloud: true - http: true - local: false - -plugins: - youtube: - enabled: true - allowSearch: true - allowDirectVideoIds: true - allowDirectPlaylistIds: true - clients: - - MUSIC - - ANDROID_TESTSUITE - - WEB diff --git a/lavalink/nodes.json b/lavalink/nodes.json deleted file mode 100644 index 78e107cec4..0000000000 --- a/lavalink/nodes.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "host": "lavalinkv4.serenetia.com", - "port": 443, - "password": "https://seretia.link/discord", - "secure": true, - "name": "Serenetia" - }, - { - "host": "lavalink.jirayu.net", - "port": 443, - "password": "youshallnotpass", - "secure": true, - "name": "Jirayu" - }, - { - "host": "lava-v4.millohost.my.id", - "port": 443, - "password": "https://discord.gg/mjS5J2K3ep", - "secure": true, - "name": "MilloHost" - }, - { - "host": "lavalink-v4.triniumhost.com", - "port": 443, - "password": "free", - "secure": true, - "name": "TriniumHost" - } -] diff --git a/package-lock.json b/package-lock.json index b11b7cdec6..f615f5e931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "express": "^5.1.0", "node-cron": "^4.2.1", "pg": "^8.11.3", - "riffy": "1.0.12", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" @@ -1543,16 +1542,6 @@ "node": ">= 6" } }, - "node_modules/riffy": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/riffy/-/riffy-1.0.12.tgz", - "integrity": "sha512-rFVIERWq0FeKINDOTKobUamrvX9m4xdP8NTJfA8thYbLrkOfK6vgZ6UlTjHeBippjFq6xlPv7OGVT2INl6Y0hQ==", - "dependencies": { - "jsdom": "^26.0.0", - "undici": ">=6.11.1", - "ws": "^8.16.0" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3106,16 +3095,6 @@ "util-deprecate": "^1.0.1" } }, - "riffy": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/riffy/-/riffy-1.0.12.tgz", - "integrity": "sha512-rFVIERWq0FeKINDOTKobUamrvX9m4xdP8NTJfA8thYbLrkOfK6vgZ6UlTjHeBippjFq6xlPv7OGVT2INl6Y0hQ==", - "requires": { - "jsdom": "^26.0.0", - "undici": "^6.27.0", - "ws": "^8.16.0" - } - }, "router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", diff --git a/package.json b/package.json index 2430aec17b..0590b6544e 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,7 @@ "pg": "^8.11.3", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", - "zod": "^3.25.76", - "riffy": "1.0.12" + "zod": "^3.25.76" }, "engines": { "node": ">=20.10.0" diff --git a/src/app.js b/src/app.js index 693cd228ea..e1ea97a949 100644 --- a/src/app.js +++ b/src/app.js @@ -13,8 +13,6 @@ import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/loaders/commandLoader.js'; import { runSafeTask, handleTaskError, ErrorCodes } from './utils/errorHandler.js'; -import { initializeMusic } from './services/music/riffySetup.js'; -import { shutdownMusic } from './services/music/playerHandler.js'; import pkg from '../package.json' with { type: 'json' }; import { EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL } from './config/database/schemaVersion.js'; @@ -84,8 +82,6 @@ class TitanBot extends Client { await this.loadHandlers(); startupLog('Handlers loaded'); - initializeMusic(this); - startupLog('Logging into Discord...'); await this.login(this.config.bot.token); startupLog('Discord login successful'); @@ -342,10 +338,6 @@ class TitanBot extends Client { cron.getTasks().forEach(task => task.stop()); logger.info('โœ… Cron jobs stopped'); - logger.info('Stopping music players...'); - await shutdownMusic(this); - logger.info('โœ… Music players stopped'); - if (this.webServer) { logger.info('Closing web server...'); await new Promise((resolve) => this.webServer.close(resolve)); @@ -406,10 +398,6 @@ try { logger.warn('Recoverable Discord interaction rejection:', reason?.message || reason); return; } - if (reason?.message?.includes('Queue is empty')) { - return; - } - // A stray rejection is a bug to fix, not a reason to take the bot down. // Log loudly with full context; the central task handler categorizes it. handleTaskError('unhandled_rejection', reason instanceof Error ? reason : new Error(String(reason)), { diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 3d9d7eb783..4a2a90c3ee 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -25,7 +25,6 @@ const CATEGORY_ICONS = { Core: "โ„น๏ธ", Moderation: "๐Ÿ›ก๏ธ", Economy: "๐Ÿ’ฐ", - Music: "๐ŸŽต", Fun: "๐ŸŽฎ", Leveling: "๐Ÿ“Š", Utility: "๐Ÿ”ง", diff --git a/src/commands/Music/join.js b/src/commands/Music/join.js deleted file mode 100644 index 10e6174103..0000000000 --- a/src/commands/Music/join.js +++ /dev/null @@ -1,21 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { joinVoiceChannel, replyMusicSuccess } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('join') - .setDescription('Join your voice channel without starting playback'), - - async execute(interaction, config, client) { - const deferred = await deferMusicCommand(interaction); - if (!deferred) { - return; - } - - const embed = await joinVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - }, -}; diff --git a/src/commands/Music/music.js b/src/commands/Music/music.js deleted file mode 100644 index fa8d7cfef6..0000000000 --- a/src/commands/Music/music.js +++ /dev/null @@ -1,188 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - skipTrack, - stopPlayback, - pausePlayback, - resumePlayback, - shuffleQueue, - setLoopMode, - setVolume, - seekTrack, - removeFromQueue, - moveInQueue, - clearQueue, - setTwentyFourSeven, - leaveVoiceChannel, - replyMusicSuccess, -} from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('music') - .setDescription('Manage playback, queue, and voice session settings') - .addSubcommand((sub) => - sub.setName('pause').setDescription('Pause playback'), - ) - .addSubcommand((sub) => - sub.setName('resume').setDescription('Resume playback'), - ) - .addSubcommand((sub) => - sub.setName('skip').setDescription('Skip the current track'), - ) - .addSubcommand((sub) => - sub.setName('stop').setDescription('Stop playback and clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('shuffle').setDescription('Shuffle the queue'), - ) - .addSubcommand((sub) => - sub - .setName('loop') - .setDescription('Set loop mode') - .addStringOption((opt) => - opt - .setName('mode') - .setDescription('Loop mode') - .setRequired(true) - .addChoices( - { name: 'Off', value: 'none' }, - { name: 'Track', value: 'track' }, - { name: 'Queue', value: 'queue' }, - ), - ), - ) - .addSubcommand((sub) => - sub - .setName('volume') - .setDescription('Set playback volume') - .addIntegerOption((opt) => - opt.setName('level').setDescription('Volume (0-100)').setRequired(true).setMinValue(0).setMaxValue(100), - ), - ) - .addSubcommand((sub) => - sub - .setName('seek') - .setDescription('Seek to a position in the current track') - .addIntegerOption((opt) => - opt.setName('seconds').setDescription('Position in seconds').setRequired(true).setMinValue(0), - ), - ) - .addSubcommand((sub) => - sub - .setName('remove') - .setDescription('Remove a track from the queue') - .addIntegerOption((opt) => - opt.setName('position').setDescription('Queue position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub - .setName('move') - .setDescription('Move a track in the queue') - .addIntegerOption((opt) => - opt.setName('from').setDescription('Current position').setRequired(true).setMinValue(1), - ) - .addIntegerOption((opt) => - opt.setName('to').setDescription('New position').setRequired(true).setMinValue(1), - ), - ) - .addSubcommand((sub) => - sub.setName('clear').setDescription('Clear the queue'), - ) - .addSubcommand((sub) => - sub.setName('leave').setDescription('Disconnect the bot from the voice channel'), - ) - .addSubcommand((sub) => - sub - .setName('247') - .setDescription('Toggle 24/7 mode (stay in voice channel when idle)') - .addBooleanOption((opt) => - opt.setName('enabled').setDescription('Enable or disable 24/7 mode').setRequired(true), - ), - ), - - async execute(interaction, config, client) { - await deferMusicCommand(interaction); - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'pause': { - const embed = await pausePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'resume': { - const embed = await resumePlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'skip': { - const embed = await skipTrack(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'stop': { - const embed = await stopPlayback(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'shuffle': { - const embed = await shuffleQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'loop': { - const embed = await setLoopMode(client, interaction, interaction.options.getString('mode')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'volume': { - const embed = await setVolume(client, interaction, interaction.options.getInteger('level')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'seek': { - const embed = await seekTrack(client, interaction, interaction.options.getInteger('seconds')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'remove': { - const embed = await removeFromQueue(client, interaction, interaction.options.getInteger('position')); - await replyMusicSuccess(interaction, embed); - break; - } - case 'move': { - const embed = await moveInQueue( - client, - interaction, - interaction.options.getInteger('from'), - interaction.options.getInteger('to'), - ); - await replyMusicSuccess(interaction, embed); - break; - } - case 'clear': { - const embed = await clearQueue(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case 'leave': { - const embed = await leaveVoiceChannel(client, interaction); - await replyMusicSuccess(interaction, embed); - break; - } - case '247': { - const embed = await setTwentyFourSeven(client, interaction, interaction.options.getBoolean('enabled')); - await replyMusicSuccess(interaction, embed); - break; - } - default: - await InteractionHelper.safeEditReply(interaction, { - content: 'Unknown music subcommand.', - }); - } - }, -}; diff --git a/src/commands/Music/nowplaying.js b/src/commands/Music/nowplaying.js deleted file mode 100644 index e50c685122..0000000000 --- a/src/commands/Music/nowplaying.js +++ /dev/null @@ -1,17 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildNowPlayingReply } from '../../services/music/musicActions.js'; -import { deferMusicCommand } from '../../services/music/prefixSupport.js'; - -export default { - category: 'Music', - data: new SlashCommandBuilder() - .setName('nowplaying') - .setDescription('Show the currently playing track'), - - async execute(interaction, config, client) { - await deferMusicCommand(interaction); - const payload = buildNowPlayingReply(client, interaction.guild.id); - await InteractionHelper.safeEditReply(interaction, payload); - }, -}; diff --git a/src/commands/Music/play.js b/src/commands/Music/play.js deleted file mode 100644 index 1d07665382..0000000000 --- a/src/commands/Music/play.js +++ /dev/null @@ -1,24 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { playQuery, replyMusicSuccess } from '../../services/music/musicActions.js'; - -export default { - slashOnly: true, - category: 'Music', - data: new SlashCommandBuilder() - .setName('play') - .setDescription('Play a song or add it to the queue') - .addStringOption((opt) => - opt.setName('query').setDescription('Song name or URL').setRequired(true), - ), - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const result = await playQuery(client, interaction, interaction.options.getString('query')); - await replyMusicSuccess(interaction, result.embed); - }, -}; diff --git a/src/commands/Music/queue.js b/src/commands/Music/queue.js deleted file mode 100644 index 66faa4eb9d..0000000000 --- a/src/commands/Music/queue.js +++ /dev/null @@ -1,24 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildQueueReply } from '../../services/music/musicActions.js'; - -export default { - slashOnly: true, - category: 'Music', - data: new SlashCommandBuilder() - .setName('queue') - .setDescription('Show the current music queue') - .addIntegerOption((opt) => - opt.setName('page').setDescription('Page number').setMinValue(1), - ), - - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - const page = (interaction.options.getInteger('page') || 1) - 1; - const payload = buildQueueReply(client, interaction.guild.id, page); - await InteractionHelper.safeEditReply(interaction, { - embeds: payload.embeds, - components: payload.components, - }); - }, -}; diff --git a/src/config/application.js b/src/config/application.js index 159b9eb295..43956ffea1 100644 --- a/src/config/application.js +++ b/src/config/application.js @@ -74,7 +74,6 @@ const appConfig = { features: { ...botConfig.features, - music: botConfig.features?.music ?? true, }, env: process.env.NODE_ENV || "development", diff --git a/src/config/bot.js b/src/config/bot.js index de9489bc04..9e066a8f42 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -473,7 +473,6 @@ export const botConfig = { utility: true, community: true, fun: true, - music: true, }, }; @@ -539,7 +538,6 @@ const COMMAND_CATEGORY_FEATURE_MAP = { leveling: "leveling", logging: "logging", moderation: "moderation", - music: "music", reaction_roles: "reactionRoles", search: "search", serverstats: "counter", diff --git a/src/config/commands/commandAliases.js b/src/config/commands/commandAliases.js index 10b2086234..cda08046fa 100644 --- a/src/config/commands/commandAliases.js +++ b/src/config/commands/commandAliases.js @@ -100,9 +100,6 @@ export const commandAliases = { 'jtc': 'jointocreate', 'jointocreate': 'jointocreate', - - 'np': 'nowplaying', - 'now': 'nowplaying', }; export const subcommandAliases = { diff --git a/src/config/commands/commandCategories.js b/src/config/commands/commandCategories.js index 3d3ec1b5ee..2783f0ed48 100644 --- a/src/config/commands/commandCategories.js +++ b/src/config/commands/commandCategories.js @@ -13,7 +13,6 @@ export const CATEGORY_ICONS = { Leveling: '๐Ÿ“Š', Logging: '๐Ÿ“', Moderation: '๐Ÿ›ก๏ธ', - Music: '๐ŸŽต', Reaction_roles: '๐ŸŽญ', Search: '๐Ÿ”', ServerStats: '๐Ÿ“ˆ', diff --git a/src/config/commands/prefixRestrictions.js b/src/config/commands/prefixRestrictions.js index 2b57f7bd60..498bbeff8f 100644 --- a/src/config/commands/prefixRestrictions.js +++ b/src/config/commands/prefixRestrictions.js @@ -24,15 +24,6 @@ export const GLOBAL_BLOCKED_SUBCOMMAND_GROUPS = new Set([ /** Per-command subcommands that stay slash-only (beyond the global block list). */ export const COMMAND_BLOCKED_SUBCOMMANDS = { - music: new Set([ - 'shuffle', - 'loop', - 'seek', - 'remove', - 'move', - 'clear', - '247', - ]), birthday: new Set(['setchannel']), report: new Set(['setchannel']), }; diff --git a/src/config/music/lavalink.js b/src/config/music/lavalink.js deleted file mode 100644 index e464557a9e..0000000000 --- a/src/config/music/lavalink.js +++ /dev/null @@ -1,85 +0,0 @@ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); - -function parseBoolean(value, defaultValue = false) { - if (value === undefined || value === null || value === '') { - return defaultValue; - } - return ['true', '1', 'yes'].includes(String(value).toLowerCase()); -} - -function parseNodesFromEnv() { - const raw = process.env.LAVALINK_NODES?.trim(); - if (!raw) { - return null; - } - - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : null; - } catch { - return null; - } -} - -function parseNodesPayload(parsed) { - if (Array.isArray(parsed)) { - return parsed; - } - if (Array.isArray(parsed?.nodes)) { - return parsed.nodes; - } - return null; -} - -function loadNodesFromFile() { - const nodesFile = process.env.LAVALINK_NODES_FILE?.trim() - || path.join(projectRoot, 'lavalink', 'nodes.json'); - - if (!existsSync(nodesFile)) { - return null; - } - - try { - const parsed = JSON.parse(readFileSync(nodesFile, 'utf8')); - return parseNodesPayload(parsed); - } catch { - return null; - } -} - -export function getLavalinkNodes() { - const fromJson = parseNodesFromEnv(); - if (fromJson?.length) { - return fromJson; - } - - const fromFile = loadNodesFromFile(); - if (fromFile?.length) { - return fromFile; - } - - const host = process.env.LAVALINK_HOST || 'localhost'; - const port = Number(process.env.LAVALINK_PORT || 2333); - const password = process.env.LAVALINK_PASSWORD || 'youshallnotpass'; - const secure = parseBoolean(process.env.LAVALINK_SECURE, false); - - return [{ - host, - port, - password, - secure, - name: process.env.LAVALINK_NAME || 'Main', - }]; -} - -export const lavalinkConfig = { - nodes: getLavalinkNodes(), - defaultSearchPlatform: process.env.LAVALINK_SEARCH_PLATFORM || 'ytmsearch', - restVersion: process.env.LAVALINK_REST_VERSION || 'v4', -}; - -export default lavalinkConfig; diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index fb1a165f31..e0b5850a9d 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -55,12 +55,6 @@ async function handlePrefixCommand(message, client) { } let { commandName, args } = parsed; - const musicPrefixShortcut = commandName.toLowerCase(); - const MUSIC_PREFIX_SHORTCUTS = new Set(['leave', 'pause', 'resume', 'skip', 'stop', 'volume']); - if (MUSIC_PREFIX_SHORTCUTS.has(musicPrefixShortcut)) { - commandName = 'music'; - args = [musicPrefixShortcut, ...args]; - } logger.info(`Prefix command detected: ${commandName}, args: ${args.join(', ')}`); diff --git a/src/events/ready.js b/src/events/ready.js index 2be8e5e424..4e94c77969 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -4,7 +4,6 @@ import config from "../config/application.js"; import { reconcileReactionRoleMessages } from "../services/reactionRoleService.js"; import { reconcileTicketPanels, reconcileVerificationPanels, reconcileReactionRolePanelHealth } from "../services/panelHealthService.js"; import { reconcileLevelRoles } from "../services/leveling/levelRoleSyncService.js"; -import { initRiffyAfterReady } from "../services/music/riffySetup.js"; export default { name: Events.ClientReady, @@ -18,10 +17,6 @@ export default { startupLog(`Serving ${client.guilds.cache.size} guild(s)`); startupLog(`Loaded ${client.commands.size} commands`); - if (client.config?.features?.music) { - initRiffyAfterReady(client); - } - const reconciliationSummary = await reconcileReactionRoleMessages(client); startupLog( `Reaction role reconciliation: scanned ${reconciliationSummary.scannedMessages}, removed ${reconciliationSummary.removedMessages}, errors ${reconciliationSummary.errors}` diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js index c76c1e123f..a2eabe7573 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.js @@ -8,7 +8,6 @@ import { } from '../utils/database.js'; import { sanitizeInput } from '../utils/validation.js'; import { logger } from '../utils/logger.js'; -import { handleMusicVoiceState } from '../services/music/musicVoiceState.js'; const channelCreationCooldown = new Map(); const VOICE_CREATE_COOLDOWN_MS = 2000; @@ -284,12 +283,6 @@ userLimit: userLimit === 0 ? undefined : userLimit, logger.error(`Failed to transfer ownership of channel ${channel.id}:`, error); } } - - if (client.config?.features?.music) { - handleMusicVoiceState(client, oldState, newState).catch((error) => { - logger.error('Music voice state handler error:', error); - }); - } } }; diff --git a/src/handlers/help/helpSelectMenus.js b/src/handlers/help/helpSelectMenus.js index 960826e7de..5f342e523f 100644 --- a/src/handlers/help/helpSelectMenus.js +++ b/src/handlers/help/helpSelectMenus.js @@ -22,7 +22,6 @@ const CATEGORY_ICONS = { Core: "โ„น๏ธ", Moderation: "๐Ÿ›ก๏ธ", Economy: "๐Ÿ’ฐ", - Music: "๐ŸŽต", Fun: "๐ŸŽฎ", Leveling: "๐Ÿ“Š", Utility: "๐Ÿ”ง", diff --git a/src/handlers/musicButtons.js b/src/handlers/musicButtons.js deleted file mode 100644 index 13b8acd9c6..0000000000 --- a/src/handlers/musicButtons.js +++ /dev/null @@ -1,161 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { logger } from '../utils/logger.js'; -import { handleInteractionError } from '../utils/errorHandler.js'; -import { getGuildMusicData } from '../services/music/playerStore.js'; -import { - getPlayer, - buildQueueReply, - destroyPlayerSession, - setLoopMode, - applyPause, - applyResume, -} from '../services/music/musicActions.js'; -import { canControlMusic, VOICE_CHANNEL_DENIAL } from '../services/music/permissions.js'; -import { refreshPlayerMessage } from '../services/music/playerHandler.js'; -import { MUSIC_BUTTON_IDS } from '../services/music/musicEmbeds.js'; -import { replyUserError, ErrorTypes } from '../utils/errorHandler.js'; - -async function handleMusicButton(interaction, client) { - const player = getPlayer(client, interaction.guild.id); - const guildData = getGuildMusicData(interaction.guild.id); - const customId = interaction.customId; - - if (customId === MUSIC_BUTTON_IDS.QUEUE) { - if (!player?.current) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Nothing is playing right now.' }); - } - if (!canControlMusic(interaction.member, player)) { - return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); - } - guildData.queuePages.set(interaction.user.id, 0); - const payload = buildQueueReply(client, interaction.guild.id, 0); - return interaction.reply({ - embeds: payload.embeds, - components: payload.components, - flags: MessageFlags.Ephemeral, - }); - } - - const queuePaginationIds = [ - MUSIC_BUTTON_IDS.QUEUE_FIRST, - MUSIC_BUTTON_IDS.QUEUE_PREV, - MUSIC_BUTTON_IDS.QUEUE_NEXT, - MUSIC_BUTTON_IDS.QUEUE_LAST, - ]; - - if (queuePaginationIds.includes(customId)) { - if (!player?.current) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Nothing is playing right now.' }); - } - if (!canControlMusic(interaction.member, player)) { - return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); - } - - await interaction.deferUpdate(); - const payload = buildQueueReply(client, interaction.guild.id, guildData.queuePages.get(interaction.user.id) || 0); - const totalPages = payload.totalPages; - let page = payload.page; - - switch (customId) { - case MUSIC_BUTTON_IDS.QUEUE_FIRST: - page = 0; - break; - case MUSIC_BUTTON_IDS.QUEUE_PREV: - page = Math.max(0, page - 1); - break; - case MUSIC_BUTTON_IDS.QUEUE_NEXT: - page = Math.min(totalPages - 1, page + 1); - break; - case MUSIC_BUTTON_IDS.QUEUE_LAST: - page = totalPages - 1; - break; - default: - break; - } - - guildData.queuePages.set(interaction.user.id, page); - const updated = buildQueueReply(client, interaction.guild.id, page); - return interaction.editReply({ - embeds: updated.embeds, - components: updated.components, - }); - } - - if (!player) { - return replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No music is playing. Use `/play` first.' }); - } - - if (!canControlMusic(interaction.member, player)) { - return replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: VOICE_CHANNEL_DENIAL }); - } - - await interaction.deferUpdate(); - - try { - switch (customId) { - case MUSIC_BUTTON_IDS.PAUSE: - await applyPause(client, interaction.guild.id); - break; - case MUSIC_BUTTON_IDS.RESUME: - await applyResume(client, interaction.guild.id); - break; - case MUSIC_BUTTON_IDS.SKIP: - // Under track-loop, stop() would replay the same track. Clear it so the - // skip advances; trackStart re-applies the stored loop to the next track. - if (player.loop === 'track') { - player.setLoop('none'); - } - player.stop(); - break; - case MUSIC_BUTTON_IDS.STOP: - await destroyPlayerSession(client, interaction.guild.id, player, guildData); - break; - case MUSIC_BUTTON_IDS.SHUFFLE: - if (player.queue.length > 0) { - player.queue.shuffle(); - guildData.shuffle = true; - await refreshPlayerMessage(client, interaction.guild.id); - } - break; - case MUSIC_BUTTON_IDS.LOOP: { - const guildDataLoop = getGuildMusicData(interaction.guild.id); - const next = guildDataLoop.loop === 'none' ? 'track' : guildDataLoop.loop === 'track' ? 'queue' : 'none'; - await setLoopMode(client, interaction, next); - break; - } - case MUSIC_BUTTON_IDS.VOL_DOWN: - guildData.volume = Math.max(0, guildData.volume - 10); - player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); - break; - case MUSIC_BUTTON_IDS.VOL_UP: - guildData.volume = Math.min(100, guildData.volume + 10); - player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); - break; - default: - break; - } - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'button', - customId: interaction.customId, - handler: 'music', - }); - } -} - -export const musicButtonHandler = { - async execute(interaction, client) { - try { - if (!client.riffy) { - return replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Music is unavailable โ€” Lavalink is not configured.' }); - } - await handleMusicButton(interaction, client); - } catch (error) { - await handleInteractionError(interaction, error, { handler: 'musicButton' }); - } - }, -}; - -export default musicButtonHandler; diff --git a/src/interactions/buttons/music/music.js b/src/interactions/buttons/music/music.js deleted file mode 100644 index 3a69f9f3fb..0000000000 --- a/src/interactions/buttons/music/music.js +++ /dev/null @@ -1,23 +0,0 @@ -import musicButtonHandler from '../../../handlers/musicButtons.js'; -import { MUSIC_BUTTON_IDS } from '../../../services/music/musicEmbeds.js'; - -const buttonIds = [ - MUSIC_BUTTON_IDS.PAUSE, - MUSIC_BUTTON_IDS.RESUME, - MUSIC_BUTTON_IDS.SKIP, - MUSIC_BUTTON_IDS.STOP, - MUSIC_BUTTON_IDS.SHUFFLE, - MUSIC_BUTTON_IDS.LOOP, - MUSIC_BUTTON_IDS.VOL_DOWN, - MUSIC_BUTTON_IDS.VOL_UP, - MUSIC_BUTTON_IDS.QUEUE, - MUSIC_BUTTON_IDS.QUEUE_FIRST, - MUSIC_BUTTON_IDS.QUEUE_PREV, - MUSIC_BUTTON_IDS.QUEUE_NEXT, - MUSIC_BUTTON_IDS.QUEUE_LAST, -]; - -export default buttonIds.map((name) => ({ - name, - execute: musicButtonHandler.execute, -})); diff --git a/src/services/music/musicActions.js b/src/services/music/musicActions.js deleted file mode 100644 index be46fb85d8..0000000000 --- a/src/services/music/musicActions.js +++ /dev/null @@ -1,612 +0,0 @@ -import { once } from 'node:events'; -import { MessageFlags, PermissionFlagsBits } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { botHasPermission } from '../../utils/permissionGuard.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildMusicData, clearUpdateInterval } from './playerStore.js'; -import { canControlMusic, requireVoiceChannel, VOICE_CHANNEL_DENIAL } from './permissions.js'; -import { - buildNowPlayingEmbed, - buildQueueEmbed, - buildQueuePaginationRow, - getQueuePageSize, -} from './musicEmbeds.js'; -import { refreshPlayerMessage } from './playerHandler.js'; - -const YOUTUBE_URL_PATTERN = /(?:youtube\.com|youtu\.be)/i; -const PLAYER_CONNECT_TIMEOUT_MS = 12_000; - -function getConnectedLavalinkNodes(client) { - if (!client.riffy?.nodeMap) { - return []; - } - - return [...client.riffy.nodeMap.values()].filter((node) => node.connected); -} - -export function assertLavalinkNodeAvailable(client) { - if (!getConnectedLavalinkNodes(client).length) { - throw new TitanBotError( - 'Lavalink unavailable', - ErrorTypes.CONFIGURATION, - 'Music is temporarily unavailable โ€” no Lavalink nodes are connected. Try again shortly or configure your own Lavalink server.', - ); - } -} - -function assertBotVoicePermissions(channel) { - if (!channel) { - throw new TitanBotError( - 'Voice channel unavailable', - ErrorTypes.CONFIGURATION, - 'Could not access that voice channel.', - ); - } - - if (!botHasPermission(channel, [PermissionFlagsBits.Connect, PermissionFlagsBits.Speak])) { - throw new TitanBotError( - 'Missing voice permissions', - ErrorTypes.PERMISSION, - 'I need **Connect** and **Speak** permissions in your voice channel.', - ); - } -} - -async function waitForPlayerConnection(player) { - if (player.connected) { - return; - } - - try { - await player.connection.resolve(); - } catch { - // Fall through to event-based wait below. - } - - if (player.connected) { - return; - } - - try { - await once(player, 'connectionRestored', { - signal: AbortSignal.timeout(PLAYER_CONNECT_TIMEOUT_MS), - }); - } catch { - // Timed out waiting for Lavalink to confirm the voice session. - } - - if (!player.connected) { - throw new TitanBotError( - 'Voice connection failed', - ErrorTypes.CONFIGURATION, - 'Could not connect to the voice channel. Ensure Lavalink is online, the bot has Connect and Speak permissions, then try again.', - ); - } -} - -async function startPlayback(player) { - await waitForPlayerConnection(player); - await player.play(); -} - -export function getPlayer(client, guildId) { - return client.riffy?.players?.get(guildId) || null; -} - -export function assertRiffyAvailable(client) { - if (!client.riffy) { - throw new TitanBotError( - 'Lavalink not configured', - ErrorTypes.CONFIGURATION, - 'Music is unavailable โ€” Lavalink is not configured.', - ); - } -} - -export function assertInVoice(member) { - if (!requireVoiceChannel(member)) { - throw new TitanBotError( - 'Not in voice channel', - ErrorTypes.USER_INPUT, - 'You need to be in a voice channel.', - ); - } -} - -export function assertCanControl(member, player) { - if (!canControlMusic(member, player)) { - throw new TitanBotError( - 'Wrong voice channel', - ErrorTypes.PERMISSION, - VOICE_CHANNEL_DENIAL, - ); - } -} - -export async function ensurePlayer(client, interaction) { - assertRiffyAvailable(client); - assertLavalinkNodeAvailable(client); - assertInVoice(interaction.member); - - const guildId = interaction.guild.id; - const guildData = getGuildMusicData(guildId); - let player = getPlayer(client, guildId); - - if (!player) { - player = client.riffy.createConnection({ - guildId, - voiceChannel: interaction.member.voice.channel.id, - textChannel: interaction.channel.id, - deaf: true, - }); - guildData.playerChannelId = interaction.channel.id; - } - - player.setVolume(guildData.volume); - return { player, guildData }; -} - -function isDuplicateTrack(player, track) { - const uri = track?.info?.uri; - if (!uri) { - return false; - } - if (player.current?.info?.uri === uri) { - return true; - } - return player.queue.some((existing) => existing.info?.uri === uri); -} - -export async function joinVoiceChannel(client, interaction) { - assertRiffyAvailable(client); - assertInVoice(interaction.member); - - const guildId = interaction.guild.id; - const guildData = getGuildMusicData(guildId); - const channel = interaction.member.voice.channel; - assertBotVoicePermissions(channel); - let player = getPlayer(client, guildId); - - if (player && player.voiceChannel !== channel.id) { - try { - player.destroy(); - } catch { - // player may already be gone - } - player = null; - } - - if (!player) { - player = client.riffy.createConnection({ - guildId, - voiceChannel: channel.id, - textChannel: interaction.channel.id, - deaf: true, - }); - guildData.playerChannelId = interaction.channel.id; - } - - player.setVolume(guildData.volume); - - return successEmbed( - 'Joined Voice Channel', - `Connected to **${channel.name}**. Use /play to start music, or /music for playback controls.`, - ); -} - -export async function playQuery(client, interaction, query) { - if (YOUTUBE_URL_PATTERN.test(query)) { - throw new TitanBotError( - 'YouTube URL blocked', - ErrorTypes.USER_INPUT, - 'YouTube links are not supported. Try a song name instead.', - ); - } - - const { player, guildData } = await ensurePlayer(client, interaction); - - const result = await client.riffy.resolve({ - query, - requester: interaction.user, - }); - - const { loadType, tracks, playlistInfo } = result; - - if (loadType === 'playlist' || loadType === 'PLAYLIST_LOADED') { - let added = 0; - let skipped = 0; - - for (const track of tracks) { - track.info.requester = interaction.user; - if (isDuplicateTrack(player, track)) { - skipped += 1; - continue; - } - player.queue.add(track); - added += 1; - } - - if (!player.playing && !player.paused) { - await startPlayback(player); - } - - return { - embed: successEmbed( - 'Playlist Added', - `**${playlistInfo?.name || 'Playlist'}**\nAdded ${added} of ${tracks.length} track(s).${skipped ? ` Skipped ${skipped} duplicate(s).` : ''}`, - ), - }; - } - - if ( - loadType === 'search' - || loadType === 'track' - || loadType === 'SEARCH_RESULT' - || loadType === 'TRACK_LOADED' - ) { - const track = tracks?.[0]; - if (!track) { - throw new TitanBotError('No results', ErrorTypes.USER_INPUT, 'No results found for that query.'); - } - - if (isDuplicateTrack(player, track)) { - throw new TitanBotError( - 'Duplicate track', - ErrorTypes.USER_INPUT, - `**${track.info.title}** is already in the queue or playing.`, - ); - } - - track.info.requester = interaction.user; - - const willPlayNow = !player.playing && !player.paused; - player.queue.add(track); - const queuePosition = player.queue.length; - - if (willPlayNow) { - await startPlayback(player); - } - - return { - embed: successEmbed( - willPlayNow ? 'Now Playing' : 'Track Added', - willPlayNow - ? `**${track.info.title}**\n${track.info.author}` - : `**${track.info.title}**\n${track.info.author}\nPosition: #${queuePosition} in queue`, - ), - }; - } - - throw new TitanBotError('No results', ErrorTypes.USER_INPUT, `No results found. (loadType: ${loadType})`); -} - -export async function skipTrack(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - assertCanControl(interaction.member, player); - const title = player.current.info?.title || 'Unknown'; - // Under track-loop, stop() would replay the same track. Clear it so the skip - // advances; trackStart re-applies the stored loop mode to the next track. - if (player.loop === 'track') { - player.setLoop('none'); - } - player.stop(); - return successEmbed('Skipped', `Skipped **${title}**.`); -} - -export async function stopPlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); - } - assertCanControl(interaction.member, player); - - const guildData = getGuildMusicData(interaction.guild.id); - const queueLength = player.queue?.length || 0; - - if (queueLength >= 5 && guildData.stopConfirmPending !== interaction.user.id) { - guildData.stopConfirmPending = interaction.user.id; - setTimeout(() => { - if (guildData.stopConfirmPending === interaction.user.id) { - guildData.stopConfirmPending = null; - } - }, 15000); - return successEmbed( - 'Confirm Stop', - `There are **${queueLength}** tracks in the queue. Run **/music stop** again within 15 seconds to confirm.`, - ); - } - - guildData.stopConfirmPending = null; - await destroyPlayerSession(client, interaction.guild.id, player, guildData); - return successEmbed('Stopped', 'Playback stopped and the queue was cleared.'); -} - -export async function applyPause(client, guildId) { - const player = getPlayer(client, guildId); - if (!player?.current || player.paused) { - return false; - } - - player.pause(true); - await refreshPlayerMessage(client, guildId); - return true; -} - -export async function applyResume(client, guildId) { - const player = getPlayer(client, guildId); - if (!player?.current || !player.paused) { - return false; - } - - player.pause(false); - await refreshPlayerMessage(client, guildId); - return true; -} - -export async function pausePlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - assertCanControl(interaction.member, player); - - if (player.paused) { - throw new TitanBotError('Already paused', ErrorTypes.USER_INPUT, 'Playback is already paused.'); - } - - await applyPause(client, interaction.guild.id); - return successEmbed('Paused', 'Playback paused.'); -} - -export async function resumePlayback(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - assertCanControl(interaction.member, player); - - if (!player.paused) { - throw new TitanBotError('Not paused', ErrorTypes.USER_INPUT, 'Playback is not paused.'); - } - - await applyResume(client, interaction.guild.id); - return successEmbed('Resumed', 'Playback resumed.'); -} - -export async function shuffleQueue(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); - } - assertCanControl(interaction.member, player); - player.queue.shuffle(); - getGuildMusicData(interaction.guild.id).shuffle = true; - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Shuffled', 'The queue has been shuffled.'); -} - -export async function setLoopMode(client, interaction, mode) { - const player = getPlayer(client, interaction.guild.id); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); - } - assertCanControl(interaction.member, player); - - const guildData = getGuildMusicData(interaction.guild.id); - guildData.loop = mode; - player.setLoop(mode); - - const labels = { none: 'Off', track: 'Track', queue: 'Queue' }; - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Loop Updated', `Loop mode set to **${labels[mode] || mode}**.`); -} - -export async function toggleLoop(client, interaction) { - const guildData = getGuildMusicData(interaction.guild.id); - const next = guildData.loop === 'none' ? 'track' : guildData.loop === 'track' ? 'queue' : 'none'; - return setLoopMode(client, interaction, next); -} - -export async function setVolume(client, interaction, volume) { - const player = getPlayer(client, interaction.guild.id); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); - } - assertCanControl(interaction.member, player); - - const guildData = getGuildMusicData(interaction.guild.id); - guildData.volume = Math.max(0, Math.min(100, volume)); - player.setVolume(guildData.volume); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Volume Updated', `Volume set to **${guildData.volume}%**.`); -} - -export async function adjustVolume(client, interaction, delta) { - const guildData = getGuildMusicData(interaction.guild.id); - return setVolume(client, interaction, guildData.volume + delta); -} - -export async function seekTrack(client, interaction, seconds) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - assertCanControl(interaction.member, player); - - const info = player.current.info || {}; - if (info.isStream || info.isSeekable === false) { - throw new TitanBotError( - 'Not seekable', - ErrorTypes.USER_INPUT, - 'This track cannot be seeked (it may be a live stream).', - ); - } - - const position = Math.max(0, seconds * 1000); - if (info.length && position > info.length) { - throw new TitanBotError( - 'Seek out of range', - ErrorTypes.USER_INPUT, - `You can only seek up to ${Math.floor(info.length / 1000)}s for this track.`, - ); - } - - player.seek(position); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Seeked', `Seeked to **${seconds}s**.`); -} - -export async function removeFromQueue(client, interaction, index) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); - } - assertCanControl(interaction.member, player); - - const queueIndex = index - 1; - if (queueIndex < 0 || queueIndex >= player.queue.length) { - throw new TitanBotError('Invalid index', ErrorTypes.USER_INPUT, `Invalid queue position. Queue has ${player.queue.length} track(s).`); - } - - const removed = player.queue[queueIndex]; - player.queue.remove(queueIndex); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Removed', `Removed **${removed.info?.title || 'track'}** from the queue.`); -} - -export async function moveInQueue(client, interaction, from, to) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is empty.'); - } - assertCanControl(interaction.member, player); - - const fromIndex = from - 1; - const toIndex = to - 1; - if (fromIndex < 0 || fromIndex >= player.queue.length || toIndex < 0 || toIndex >= player.queue.length) { - throw new TitanBotError('Invalid index', ErrorTypes.USER_INPUT, 'Invalid queue positions.'); - } - - const track = player.queue[fromIndex]; - player.queue.remove(fromIndex); - player.queue.splice(toIndex, 0, track); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Moved', `Moved **${track.info?.title || 'track'}** to position #${to}.`); -} - -export async function clearQueue(client, interaction) { - const player = getPlayer(client, interaction.guild.id); - if (!player?.queue?.length) { - throw new TitanBotError('Empty queue', ErrorTypes.USER_INPUT, 'The queue is already empty.'); - } - assertCanControl(interaction.member, player); - player.queue.clear(); - await refreshPlayerMessage(client, interaction.guild.id); - return successEmbed('Queue Cleared', 'All queued tracks were removed.'); -} - -export async function setTwentyFourSeven(client, interaction, enabled) { - const guildData = getGuildMusicData(interaction.guild.id); - guildData.twentyFourSeven = enabled; - return successEmbed( - '24/7 Mode', - enabled - ? '24/7 mode enabled. The bot will stay in the voice channel when the queue ends.' - : '24/7 mode disabled. The bot will leave after 30 seconds of idle time.', - ); -} - -export function buildNowPlayingReply(client, guildId) { - const player = getPlayer(client, guildId); - if (!player?.current) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'Nothing is playing right now.'); - } - const guildData = getGuildMusicData(guildId); - return { - embeds: [buildNowPlayingEmbed(player.current, player, guildData)], - }; -} - -export function buildQueueReply(client, guildId, page = 0) { - const player = getPlayer(client, guildId); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'No active music player.'); - } - - const totalPages = Math.max(1, Math.ceil((player.queue?.length || 0) / getQueuePageSize())); - const safePage = Math.min(Math.max(page, 0), totalPages - 1); - - return { - embeds: [buildQueueEmbed(player.queue, player.current, safePage)], - components: totalPages > 1 ? [buildQueuePaginationRow(safePage, totalPages)] : [], - page: safePage, - totalPages, - }; -} - -export async function destroyPlayerSession(client, guildId, player, guildData, { forceDisconnect = false } = {}) { - clearUpdateInterval(guildData); - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - - guildData.previousTracks = []; - guildData.stopConfirmPending = null; - guildData.autoPaused = false; - guildData.queuePages?.clear(); - - if (guildData.playerMessageId && guildData.playerChannelId) { - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch { - // message already deleted - } - } - - guildData.playerMessageId = null; - guildData.playerChannelId = null; - - if (player) { - player.queue.clear(); - player.stop(); - if (forceDisconnect || !guildData.twentyFourSeven) { - player.destroy(); - } - } -} - -export async function leaveVoiceChannel(client, interaction) { - assertRiffyAvailable(client); - - const guildId = interaction.guild.id; - const player = getPlayer(client, guildId); - if (!player) { - throw new TitanBotError('No player', ErrorTypes.USER_INPUT, 'I am not in a voice channel.'); - } - assertCanControl(interaction.member, player); - - const channel = interaction.guild.channels.cache.get(player.voiceChannel); - const channelName = channel?.name || 'voice channel'; - const guildData = getGuildMusicData(guildId); - - await destroyPlayerSession(client, guildId, player, guildData, { forceDisconnect: true }); - - return successEmbed('Left Voice Channel', `Disconnected from **${channelName}**.`); -} - -export async function replyMusicSuccess(interaction, embed) { - const options = { embeds: [embed] }; - if (!interaction._isPrefixCommand) { - options.flags = MessageFlags.Ephemeral; - } - await InteractionHelper.safeReply(interaction, options); -} diff --git a/src/services/music/musicEmbeds.js b/src/services/music/musicEmbeds.js deleted file mode 100644 index e57a74abe0..0000000000 --- a/src/services/music/musicEmbeds.js +++ /dev/null @@ -1,173 +0,0 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { getPaginationRow } from '../../utils/components.js'; - -const QUEUE_PAGE_SIZE = 10; - -export const MUSIC_BUTTON_IDS = { - PAUSE: 'music_pause', - RESUME: 'music_resume', - SKIP: 'music_skip', - STOP: 'music_stop', - SHUFFLE: 'music_shuffle', - LOOP: 'music_loop', - VOL_DOWN: 'music_vol_down', - VOL_UP: 'music_vol_up', - QUEUE: 'music_queue', - QUEUE_FIRST: 'music_queue_first', - QUEUE_PREV: 'music_queue_prev', - QUEUE_NEXT: 'music_queue_next', - QUEUE_LAST: 'music_queue_last', -}; - -export function formatDuration(ms) { - if (!ms || Number.isNaN(ms)) { - return 'Live'; - } - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) { - return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; - } - return `${minutes}:${String(seconds).padStart(2, '0')}`; -} - -function getTrackArtwork(track) { - return track?.info?.artworkUrl || track?.info?.thumbnail || null; -} - -function getLoopLabel(loop) { - switch (loop) { - case 'track': - return 'Track'; - case 'queue': - return 'Queue'; - default: - return 'Off'; - } -} - -export function buildNowPlayingEmbed(track, player, guildData) { - const requester = track?.info?.requester; - const requesterLabel = requester - ? (requester.username || requester.tag || 'Unknown') - : 'Unknown'; - - const position = formatDuration(player?.position || 0); - const duration = formatDuration(track?.info?.length || 0); - - return createEmbed({ - title: 'Now Playing', - description: track?.info?.title || 'Unknown track', - color: 'primary', - fields: [ - { name: 'Artist', value: track?.info?.author || 'Unknown', inline: true }, - { name: 'Requester', value: requesterLabel, inline: true }, - { name: 'Progress', value: `${position} / ${duration}`, inline: true }, - { name: 'Volume', value: `${guildData?.volume ?? 75}%`, inline: true }, - { name: 'Loop', value: getLoopLabel(guildData?.loop), inline: true }, - { name: 'Queue', value: `${player?.queue?.length || 0} track(s)`, inline: true }, - ], - thumbnail: getTrackArtwork(track), - footer: player?.paused ? 'Paused' : 'Playing', - }); -} - -export function buildQueueEmbed(queue, currentTrack, page = 0) { - const totalTracks = queue?.length || 0; - const totalPages = Math.max(1, Math.ceil(totalTracks / QUEUE_PAGE_SIZE)); - const safePage = Math.min(Math.max(page, 0), totalPages - 1); - const start = safePage * QUEUE_PAGE_SIZE; - const slice = queue?.slice(start, start + QUEUE_PAGE_SIZE) || []; - - let description = ''; - if (currentTrack) { - description += `**Now Playing**\n${currentTrack.info?.title || 'Unknown'} โ€” ${currentTrack.info?.author || 'Unknown'}\n\n`; - } - - if (slice.length === 0) { - description += 'The queue is empty.'; - } else { - description += slice - .map((track, index) => { - const num = start + index + 1; - return `${num}. ${track.info?.title || 'Unknown'} โ€” ${track.info?.author || 'Unknown'}`; - }) - .join('\n'); - } - - return createEmbed({ - title: 'Music Queue', - description: description.substring(0, 4096), - color: 'info', - footer: `Page ${safePage + 1} of ${totalPages} โ€ข ${totalTracks} queued`, - }); -} - -export function buildPlayerButtonRows(player, guildData) { - const paused = player?.paused; - const row1 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.PAUSE) - .setLabel('Pause') - .setStyle(ButtonStyle.Primary) - .setEmoji('โธ๏ธ') - .setDisabled(Boolean(paused)), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.RESUME) - .setLabel('Resume') - .setStyle(ButtonStyle.Success) - .setEmoji('โ–ถ๏ธ') - .setDisabled(!paused), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.SKIP) - .setLabel('Skip') - .setStyle(ButtonStyle.Secondary) - .setEmoji('โญ๏ธ'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.STOP) - .setLabel('Stop') - .setStyle(ButtonStyle.Danger) - .setEmoji('โน๏ธ'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.SHUFFLE) - .setLabel('Shuffle') - .setStyle(guildData?.shuffle ? ButtonStyle.Success : ButtonStyle.Secondary) - .setEmoji('๐Ÿ”€'), - ); - - const row2 = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.LOOP) - .setLabel('Loop') - .setStyle(guildData?.loop !== 'none' ? ButtonStyle.Success : ButtonStyle.Secondary) - .setEmoji('๐Ÿ”'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.VOL_DOWN) - .setLabel('Vol -') - .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ”‰'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.VOL_UP) - .setLabel('Vol +') - .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ”Š'), - new ButtonBuilder() - .setCustomId(MUSIC_BUTTON_IDS.QUEUE) - .setLabel('Queue') - .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“‹'), - ); - - return [row1, row2]; -} - -export function buildQueuePaginationRow(page, totalPages) { - return getPaginationRow('music_queue', page + 1, totalPages); -} - -export function getQueuePageSize() { - return QUEUE_PAGE_SIZE; -} diff --git a/src/services/music/musicVoiceState.js b/src/services/music/musicVoiceState.js deleted file mode 100644 index f6ba98d518..0000000000 --- a/src/services/music/musicVoiceState.js +++ /dev/null @@ -1,51 +0,0 @@ -import { successEmbed } from '../../utils/embeds.js'; -import { getGuildMusicData } from './playerStore.js'; -import { applyPause, applyResume, getPlayer } from './musicActions.js'; - -export async function handleMusicVoiceState(client, oldState, newState) { - if (!client.riffy) { - return; - } - - const guildId = newState.guild?.id || oldState.guild?.id; - if (!guildId) { - return; - } - - const player = getPlayer(client, guildId); - if (!player?.voiceChannel) { - return; - } - - const voiceChannel = client.channels.cache.get(player.voiceChannel); - if (!voiceChannel) { - return; - } - - const guildData = getGuildMusicData(guildId); - const humansInChannel = voiceChannel.members.filter((member) => !member.user.bot); - const hasUsers = humansInChannel.size > 0; - - if (!hasUsers && !player.paused && player.playing) { - guildData.autoPaused = true; - await applyPause(client, guildId); - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send({ embeds: [successEmbed('Paused', 'Voice channel is empty. Music paused until someone joins.')] }).catch(() => null); - } - } - return; - } - - if (hasUsers && guildData.autoPaused && player.paused) { - await applyResume(client, guildId); - guildData.autoPaused = false; - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send({ embeds: [successEmbed('Resumed', 'Someone joined the voice channel. Playback resumed.')] }).catch(() => null); - } - } - } -} diff --git a/src/services/music/permissions.js b/src/services/music/permissions.js deleted file mode 100644 index 2c0a09d908..0000000000 --- a/src/services/music/permissions.js +++ /dev/null @@ -1,14 +0,0 @@ -export const VOICE_CHANNEL_DENIAL = - 'You need to be in the same voice channel as the bot to use music controls.'; - -export function canControlMusic(member, player) { - const memberChannel = member?.voice?.channel; - if (!memberChannel || !player?.voiceChannel) { - return false; - } - return memberChannel.id === player.voiceChannel; -} - -export function requireVoiceChannel(member) { - return Boolean(member?.voice?.channel); -} diff --git a/src/services/music/playerHandler.js b/src/services/music/playerHandler.js deleted file mode 100644 index b16290fbc3..0000000000 --- a/src/services/music/playerHandler.js +++ /dev/null @@ -1,254 +0,0 @@ -// Player event handlers for Riffy. Adapted from Musicify playerHandler (Apache-2.0). - -import { logger } from '../../utils/logger.js'; -import { getGuildMusicData, clearUpdateInterval } from './playerStore.js'; -import { - buildNowPlayingEmbed, - buildPlayerButtonRows, -} from './musicEmbeds.js'; - -const UPDATE_INTERVAL_MS = 15 * 1000; -const IDLE_DISCONNECT_MS = 30 * 1000; - -async function editOrSendPlayerMessage(client, guildData, channelId, embed, components) { - const channel = client.channels.cache.get(channelId); - if (!channel) { - guildData.playerMessageId = null; - guildData.playerChannelId = null; - return; - } - - const payload = { embeds: [embed], components }; - - if (guildData.playerMessageId) { - try { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.edit(payload); - return; - } catch { - guildData.playerMessageId = null; - guildData.playerChannelId = null; - clearUpdateInterval(guildData); - } - } - - try { - const newMsg = await channel.send(payload); - guildData.playerMessageId = newMsg.id; - guildData.playerChannelId = channel.id; - } catch (error) { - logger.error('Failed to send music player message:', error); - } -} - -export async function refreshPlayerMessage(client, guildId) { - try { - const player = client.riffy?.players?.get(guildId); - if (!player?.current) { - return; - } - - const guildData = getGuildMusicData(guildId); - const embed = buildNowPlayingEmbed(player.current, player, guildData); - const components = buildPlayerButtonRows(player, guildData); - const channelId = guildData.playerChannelId || player.textChannel; - await editOrSendPlayerMessage(client, guildData, channelId, embed, components); - } catch (error) { - logger.error('Failed to refresh music player message:', error); - } -} - -function startUpdateInterval(client, guildId) { - const guildData = getGuildMusicData(guildId); - clearUpdateInterval(guildData); - guildData.updateInterval = setInterval(() => { - refreshPlayerMessage(client, guildId); - }, UPDATE_INTERVAL_MS); -} - -export function setupPlayerHandler(client) { - if (!client.riffy) { - logger.warn('Riffy not initialized; music player handlers not attached.'); - return; - } - - // Lavalink nodes often flap (reconnect -> error -> reconnect). Throttle all - // per-node messages to one line per interval, log the first connect only, - // and skip reconnect noise entirely since it is meaningless during flapping. - const nodeLogState = new Map(); - const NODE_LOG_INTERVAL_MS = 5 * 60 * 1000; - - const shouldLogNodeEvent = (nodeName) => { - const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; - const now = Date.now(); - if (now - prev.lastLogAt < NODE_LOG_INTERVAL_MS) { - return false; - } - nodeLogState.set(nodeName, { ...prev, lastLogAt: now }); - return true; - }; - - const markNodeConnected = (nodeName) => { - const prev = nodeLogState.get(nodeName) ?? { lastLogAt: 0, hasConnected: false }; - nodeLogState.set(nodeName, { ...prev, hasConnected: true }); - }; - - client.riffy.on('nodeConnect', (node) => { - const prev = nodeLogState.get(node.name) ?? { lastLogAt: 0, hasConnected: false }; - if (prev.hasConnected) { - return; - } - markNodeConnected(node.name); - logger.info(`Lavalink node "${node.name}" connected.`); - }); - - client.riffy.on('nodeReconnect', () => { - // Intentionally silent โ€” reconnect spam is not actionable during flapping. - }); - - client.riffy.on('nodeError', (node, error) => { - if (!shouldLogNodeEvent(node.name)) { - return; - } - logger.warn(`Lavalink node "${node.name}" error: ${error?.message || error}`); - }); - - client.riffy.on('nodeDisconnect', (node) => { - if (!shouldLogNodeEvent(node.name)) { - return; - } - logger.warn(`Lavalink node "${node.name}" disconnected.`); - }); - - client.riffy.on('trackStart', async (player, track) => { - try { - const guildData = getGuildMusicData(player.guildId); - - // Keep the Lavalink player's loop mode aligned with the stored preference. - // Skip temporarily clears track-loop so it can advance; restore it here. - if (guildData.loop && player.loop !== guildData.loop) { - player.setLoop(guildData.loop); - } - - if (player.previous) { - guildData.previousTracks.push(player.previous); - if (guildData.previousTracks.length > 20) { - guildData.previousTracks.shift(); - } - } - - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - - const embed = buildNowPlayingEmbed(track, player, guildData); - const components = buildPlayerButtonRows(player, guildData); - const channelId = guildData.playerChannelId || player.textChannel; - await editOrSendPlayerMessage(client, guildData, channelId, embed, components); - startUpdateInterval(client, player.guildId); - } catch (error) { - logger.error('Music trackStart error:', error); - } - }); - - client.riffy.on('queueEnd', async (player) => { - try { - const guildData = getGuildMusicData(player.guildId); - clearUpdateInterval(guildData); - - if (guildData.autoplay) { - player.autoplay(player); - return; - } - - if (guildData.playerMessageId && guildData.playerChannelId) { - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch { - // already deleted - } - guildData.playerMessageId = null; - guildData.playerChannelId = null; - } - - if (!guildData.twentyFourSeven) { - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - } - guildData.idleTimeout = setTimeout(() => { - try { - const currentPlayer = client.riffy.players.get(player.guildId); - if (currentPlayer && !currentPlayer.playing && !currentPlayer.paused && !currentPlayer.current) { - currentPlayer.destroy(); - } - } catch { - // player already destroyed - } - guildData.idleTimeout = null; - }, IDLE_DISCONNECT_MS); - } - } catch (error) { - logger.error('Music queueEnd error:', error); - } - }); - - client.riffy.on('playerDisconnect', async (player) => { - const guildData = getGuildMusicData(player.guildId); - clearUpdateInterval(guildData); - - if (guildData.playerMessageId && guildData.playerChannelId) { - try { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - const msg = await channel.messages.fetch(guildData.playerMessageId); - await msg.delete(); - } - } catch { - // already deleted - } - } - - guildData.playerMessageId = null; - guildData.playerChannelId = null; - guildData.previousTracks = []; - guildData.autoPaused = false; - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - guildData.idleTimeout = null; - } - }); - - client.riffy.on('trackError', async (player, track, payload) => { - logger.error(`Track error in ${player.guildId} for "${track?.info?.title}":`, payload?.error || payload); - const guildData = getGuildMusicData(player.guildId); - if (guildData.playerChannelId) { - const channel = client.channels.cache.get(guildData.playerChannelId); - if (channel) { - channel.send(`Failed to play **${track?.info?.title || 'track'}**. Skipping...`).catch(() => null); - } - } - }); - - client.riffy.on('trackStuck', async (player, track, payload) => { - logger.warn(`Track stuck in ${player.guildId} for "${track?.info?.title}" (${payload?.thresholdMs}ms)`); - }); -} - -export async function shutdownMusic(client) { - if (!client.riffy?.players) { - return; - } - - for (const player of client.riffy.players.values()) { - try { - player.destroy(); - } catch (error) { - logger.debug('Error destroying music player during shutdown:', error.message); - } - } -} diff --git a/src/services/music/playerStore.js b/src/services/music/playerStore.js deleted file mode 100644 index 009ad3e686..0000000000 --- a/src/services/music/playerStore.js +++ /dev/null @@ -1,46 +0,0 @@ -// Per-guild music session state (in-memory). Adapted from Musicify playerStore (Apache-2.0). - -export class GuildMusicData { - constructor() { - this.playerMessageId = null; - this.playerChannelId = null; - this.autoplay = false; - this.loop = 'none'; - this.volume = 75; - this.shuffle = false; - this.previousTracks = []; - this.twentyFourSeven = false; - this.queuePages = new Map(); - this.updateInterval = null; - this.idleTimeout = null; - this.autoPaused = false; - this.stopConfirmPending = null; - } -} - -export function clearUpdateInterval(guildData) { - if (guildData.updateInterval) { - clearInterval(guildData.updateInterval); - guildData.updateInterval = null; - } -} - -const guildStore = new Map(); - -export function getGuildMusicData(guildId) { - if (!guildStore.has(guildId)) { - guildStore.set(guildId, new GuildMusicData()); - } - return guildStore.get(guildId); -} - -export function deleteGuildMusicData(guildId) { - const guildData = guildStore.get(guildId); - if (guildData) { - clearUpdateInterval(guildData); - if (guildData.idleTimeout) { - clearTimeout(guildData.idleTimeout); - } - } - guildStore.delete(guildId); -} diff --git a/src/services/music/prefixSupport.js b/src/services/music/prefixSupport.js deleted file mode 100644 index ade295648a..0000000000 --- a/src/services/music/prefixSupport.js +++ /dev/null @@ -1,10 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export function getMusicDeferOptions(interaction) { - return interaction._isPrefixCommand ? {} : { flags: MessageFlags.Ephemeral }; -} - -export async function deferMusicCommand(interaction) { - return InteractionHelper.safeDefer(interaction, getMusicDeferOptions(interaction)); -} diff --git a/src/services/music/riffySetup.js b/src/services/music/riffySetup.js deleted file mode 100644 index 70fde72d50..0000000000 --- a/src/services/music/riffySetup.js +++ /dev/null @@ -1,66 +0,0 @@ -import { createRequire } from 'module'; -import { GatewayDispatchEvents } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import lavalinkConfig from '../../config/music/lavalink.js'; -import { setupPlayerHandler } from './playerHandler.js'; - -const require = createRequire(import.meta.url); -const { Riffy } = require('riffy'); - -export function initializeMusic(client) { - if (!lavalinkConfig.nodes?.length) { - logger.error('No Lavalink nodes configured. Add lavalink/nodes.json, set LAVALINK_NODES, or set LAVALINK_HOST in your environment.'); - return; - } - - client.riffy = new Riffy(client, lavalinkConfig.nodes, { - send: (payload) => { - const guildId = payload.d?.guild_id; - if (!guildId) { - return; - } - - const guild = client.guilds.cache.get(guildId); - if (guild?.shard) { - guild.shard.send(payload); - return; - } - - const shardCount = client.ws.shards.size || 1; - const shardId = Number((BigInt(guildId) >> 22n) % BigInt(shardCount)); - client.ws.shards.get(shardId)?.send(payload); - }, - defaultSearchPlatform: lavalinkConfig.defaultSearchPlatform, - restVersion: lavalinkConfig.restVersion, - bypassChecks: { - nodeFetchInfo: true, - }, - }); - - setupPlayerHandler(client); - - client.on('raw', (packet) => { - if ( - ![ - GatewayDispatchEvents.VoiceStateUpdate, - GatewayDispatchEvents.VoiceServerUpdate, - ].includes(packet.t) - ) { - return; - } - client.riffy.updateVoiceState(packet); - }); - - client.riffy.on('playerError', (player, error) => { - logger.error(`Music player error in guild ${player.guildId}:`, error); - }); - - logger.info(`Music initialized with ${lavalinkConfig.nodes.length} Lavalink node(s).`); -} - -export function initRiffyAfterReady(client) { - if (client.riffy && client.user?.id) { - client.riffy.init(client.user.id); - logger.info('Riffy voice connection manager initialized.'); - } -} From cea5fbfa0feab66ac94ff2d6c0e34487185b2360 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Thu, 12 Nov 2026 02:20:26 +0700 Subject: [PATCH 50/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 138a5305d3..f7492e7755 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -191,7 +191,7 @@ roleId: null, maxQuantity: 10, effect: { type: 'fishing_luck', - multiplier: 2.1, + multiplier: 2000000, duration: 300000 } }, From 20293a11217ae2c7b3627f9d444b5e60b397f444 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Thu, 12 Nov 2026 02:22:30 +0700 Subject: [PATCH 51/53] Update items.js --- src/config/shop/items.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index f7492e7755..ccab82b1ea 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -201,7 +201,7 @@ roleId: null, id: 'admin_potion', name: '๐Ÿ‘‘ Admin Potion', price: 100, - description: 'Admin potion. Activate with `/use` for 5 minutes of great luck: Divine 30%, Transcendent 20%, Kitsune 10%. Can be gifted with `/give`.', + description: 'Admin potion. Activate with `/use` for 5 minutes of great luck: Divine 30%,Transcendent 20%,Kitsune 10%. Can be gifted with `/give`.', type: 'consumable', effect: { type: 'fishing_luck', @@ -209,7 +209,7 @@ roleId: null, chances: { divine: 0.3, transcendent: 0.2, - celestial: 0.1 + celestial: 1000 }, duration: 300000 } From a6ad44287959c7fb419bd5e4532f2ff412e24a59 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Thu, 12 Nov 2026 02:27:29 +0700 Subject: [PATCH 52/53] Update items.js --- src/config/shop/items.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index ccab82b1ea..138279a496 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -207,9 +207,9 @@ roleId: null, type: 'fishing_luck', admin: true, chances: { - divine: 0.3, - transcendent: 0.2, - celestial: 1000 + divine: 0.5, + transcendent: 0.4, + celestial: 0.3 }, duration: 300000 } From d24b805662faba721c2a41f1ecca7006cdf0b1c6 Mon Sep 17 00:00:00 2001 From: mannnayla1-code Date: Thu, 12 Nov 2026 02:27:54 +0700 Subject: [PATCH 53/53] Update items.js --- src/config/shop/items.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/shop/items.js b/src/config/shop/items.js index 138279a496..5ecece77b2 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -191,7 +191,7 @@ roleId: null, maxQuantity: 10, effect: { type: 'fishing_luck', - multiplier: 2000000, + multiplier: 2.2, duration: 300000 } },