const { MessageFlags } = require("discord.js"); const { mClient } = require("../index"); /* * ============================================================ * DATABASE * ============================================================ */ const DATABASE_NAME = process.env.M_DB; /* * Persistent user-owned Nut balance. * * Existing schema is preserved: * * { * userId: "123456789", * nuts: 500 * } */ const NUT_COLLECTION = "items_nuts"; /* * Unified per-user cooldown state. * * Example: * * { * userId: "123456789", * * nut: 1787000000, * steal: 1787000000, * heist: 1787000000, * blackjack: 1787000000, * roulette: 1787000000 * } */ const COOLDOWN_COLLECTION = "items_cooldowns"; /* * ============================================================ * COLLECTION ACCESS * ============================================================ */ function getCollections() { if (!DATABASE_NAME) { throw new Error( "M_DB is not configured in .env" ); } const db = mClient.db( DATABASE_NAME ); return { db, nuts: db.collection( NUT_COLLECTION ), cooldowns: db.collection( COOLDOWN_COLLECTION ) }; } /* * ============================================================ * UNIX TIME * ============================================================ */ function getUnixTime() { return Math.floor( Date.now() / 1000 ); } /* * ============================================================ * GET NUT BALANCE * ============================================================ */ async function getNutBalance( userId ) { const { nuts } = getCollections(); const document = await nuts.findOne({ userId: userId }); return Math.max( 0, document?.nuts ?? 0 ); } /* * ============================================================ * CHANGE NUT BALANCE * ============================================================ * * Positive: * * changeNuts(userId, 50) * * Negative: * * changeNuts(userId, -50) * * Negative changes are protected against dropping * the balance below zero. */ async function changeNuts( userId, amount ) { if ( !Number.isSafeInteger( amount ) ) { throw new Error( "INVALID_NUT_AMOUNT" ); } if ( amount === 0 ) { return getNutBalance( userId ); } const { nuts } = getCollections(); /* * Add Nuts. */ if ( amount > 0 ) { await nuts.updateOne( { userId: userId }, { $inc: { nuts: amount }, $setOnInsert: { userId: userId } }, { upsert: true } ); return getNutBalance( userId ); } /* * Remove Nuts atomically. */ const removeAmount = Math.abs( amount ); const result = await nuts.updateOne( { userId: userId, nuts: { $gte: removeAmount } }, { $inc: { nuts: -removeAmount } } ); if ( result.modifiedCount !== 1 ) { throw new Error( "INSUFFICIENT_NUTS" ); } return getNutBalance( userId ); } /* * ============================================================ * TRANSFER NUTS * ============================================================ * * No Mongo transaction required. * * Source debit is atomic. * * If destination credit fails, a best-effort * compensating credit is sent back to the source. */ async function transferNuts( fromuserId, touserId, amount ) { if ( fromuserId === touserId ) { throw new Error( "SAME_ACCOUNT" ); } if ( !Number.isSafeInteger( amount ) || amount <= 0 ) { throw new Error( "INVALID_NUT_AMOUNT" ); } const { nuts } = getCollections(); /* * Atomic source debit. */ const debit = await nuts.updateOne( { userId: fromuserId, nuts: { $gte: amount } }, { $inc: { nuts: -amount } } ); if ( debit.modifiedCount !== 1 ) { throw new Error( "INSUFFICIENT_NUTS" ); } /* * Destination credit. */ try { await nuts.updateOne( { userId: touserId }, { $inc: { nuts: amount }, $setOnInsert: { userId: touserId } }, { upsert: true } ); } catch (error) { /* * Best-effort compensation. */ try { await nuts.updateOne( { userId: fromuserId }, { $inc: { nuts: amount }, $setOnInsert: { userId: fromuserId } }, { upsert: true } ); } catch (refundError) { console.error( "CRITICAL: Nut transfer compensation failed:", { fromuserId, touserId, amount, refundError } ); } throw error; } return { from: fromuserId, to: touserId, amount }; } /* * ============================================================ * GET COOLDOWN * ============================================================ * * Returns the Unix timestamp at which the named * cooldown expires. * * Returns 0 if it does not exist. */ async function getCooldown( userId, cooldownName ) { validateCooldownName( cooldownName ); const { cooldowns } = getCollections(); const document = await cooldowns.findOne( { userId }, { projection: { [cooldownName]: 1 } } ); return Number( document?.[ cooldownName ] ?? 0 ); } /* * ============================================================ * SET COOLDOWN * ============================================================ */ async function setCooldown( userId, cooldownName, durationSeconds ) { validateCooldownName( cooldownName ); validateCooldownDuration( durationSeconds ); const { cooldowns } = getCollections(); const next = getUnixTime() + durationSeconds; await cooldowns.updateOne( { userId }, { $set: { [cooldownName]: next }, $setOnInsert: { userId } }, { upsert: true } ); return next; } /* * ============================================================ * CLAIM COOLDOWN * ============================================================ * * Atomic helper for actions where double-clicking must * not allow the same action to resolve twice. * * Returns: * * { * claimed: true, * next: 1787000000 * } * * or: * * { * claimed: false, * next: 1787000000 * } */ async function claimCooldown( userId, cooldownName, durationSeconds ) { validateCooldownName( cooldownName ); validateCooldownDuration( durationSeconds ); const { cooldowns } = getCollections(); const now = getUnixTime(); const next = now + durationSeconds; /* * ======================================================== * ENSURE USER DOCUMENT EXISTS * ======================================================== * * This creates: * * { * userId: "..." * } * * only when the user does not already have a * cooldown document. */ await cooldowns.updateOne( { userId }, { $setOnInsert: { userId } }, { upsert: true } ); /* * ======================================================== * ATOMIC COOLDOWN CLAIM * ======================================================== * * Mongo will only update the document when: * * - the cooldown does not exist * OR * - the cooldown has expired */ const result = await cooldowns.updateOne( { userId, $or: [ { [cooldownName]: { $exists: false } }, { [cooldownName]: { $lte: now } } ] }, { $set: { [cooldownName]: next } } ); /* * We successfully claimed it. */ if ( result.modifiedCount === 1 ) { return { claimed: true, next }; } /* * ======================================================== * COOLDOWN IS ACTIVE * ======================================================== */ const current = await getCooldown( userId, cooldownName ); return { claimed: false, next: current }; } /* * ============================================================ * CLEAR COOLDOWN * ============================================================ */ async function clearCooldown( userId, cooldownName ) { validateCooldownName( cooldownName ); const { cooldowns } = getCollections(); await cooldowns.updateOne( { userId }, { $unset: { [cooldownName]: "" } } ); } /* * ============================================================ * TEMPORARY EPHEMERAL REPLY * ============================================================ */ async function temporaryEphemeralReply( interaction, options, deleteAfter = 6000 ) { if ( interaction.replied || interaction.deferred ) { return temporaryEphemeralFollowUp( interaction, options, deleteAfter ); } await interaction.reply({ ...options, flags: MessageFlags.Ephemeral }); if ( deleteAfter > 0 ) { setTimeout( async () => { try { await interaction.deleteReply(); } catch {} }, deleteAfter ); } } /* * ============================================================ * TEMPORARY EPHEMERAL FOLLOW-UP * ============================================================ */ async function temporaryEphemeralFollowUp( interaction, options, deleteAfter = 6000 ) { const message = await interaction.followUp({ ...options, flags: MessageFlags.Ephemeral, fetchReply: true }); if ( deleteAfter > 0 ) { setTimeout( async () => { try { await interaction.webhook.deleteMessage( message.id ); } catch {} }, deleteAfter ); } return message; } /* * ============================================================ * RANDOM INTEGER * ============================================================ * * Inclusive. */ function randomInteger( min, max ) { const minimum = Math.ceil( min ); const maximum = Math.floor( max ); return Math.floor( Math.random() * ( maximum - minimum + 1 ) ) + minimum; } /* * ============================================================ * RANDOM FLOAT * ============================================================ */ function randomFloat( min, max ) { return ( Math.random() * ( max - min ) ) + min; } /* * ============================================================ * RANDOM ITEM * ============================================================ */ function randomItem( items ) { if ( !Array.isArray( items ) || items.length === 0 ) { return undefined; } return items[ Math.floor( Math.random() * items.length ) ]; } /* * ============================================================ * FORMAT PERCENTAGE * ============================================================ */ function formatPercentage( value, decimals = 1 ) { return ( ( value * 100 ) .toFixed( decimals ) + "%" ); } /* * ============================================================ * VALIDATION * ============================================================ */ function validateCooldownName( cooldownName ) { if ( typeof cooldownName !== "string" || !/^[A-Za-z0-9_]+$/.test( cooldownName ) ) { throw new Error( "INVALID_COOLDOWN_NAME" ); } } function validateCooldownDuration( durationSeconds ) { if ( !Number.isSafeInteger( durationSeconds ) || durationSeconds < 0 ) { throw new Error( "INVALID_COOLDOWN_DURATION" ); } } /* * ============================================================ * EXPORTS * ============================================================ */ module.exports = { getCollections, getNutBalance, changeNuts, transferNuts, getCooldown, setCooldown, claimCooldown, clearCooldown, temporaryEphemeralReply, temporaryEphemeralFollowUp, getUnixTime, randomInteger, randomFloat, randomItem, formatPercentage };