const { MessageFlags } = require("discord.js"); const { mClient } = require("../index"); /* * ============================================================ * DATABASE CONFIG * ============================================================ * * Set M_DB in .env to the name of your Mongo database. * * Example: * * M_DB=ShaiBot */ const DATABASE_NAME = process.env.M_DB; /* * Cookie balances: * * { * userId: "123456789", * cookies: 2500 * } */ const COOKIE_COLLECTION = "items_cookies"; /* * Cooldowns: * * { * userId: "123456789", * guildId: "987654321", * * collect: 1787000000, * steal: 1787000000, * heist: 1787000000 * } * * Values are Unix timestamps representing when * the action becomes available again. * * No createdAt / updatedAt fields. */ const COOLDOWN_COLLECTION = "cooldown_cookies"; /* * ============================================================ * 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, cookies: db.collection( COOKIE_COLLECTION ), cooldowns: db.collection( COOLDOWN_COLLECTION ) }; } /* * ============================================================ * UNIX TIME * ============================================================ */ function getUnixTime() { return Math.floor( Date.now() / 1000 ); } /* * ============================================================ * COOKIE BALANCE * ============================================================ */ async function getCookieBalance( userId ) { const { cookies } = getCollections(); const document = await cookies.findOne({ userId }); return Math.max( 0, document?.cookies ?? 0 ); } /* * ============================================================ * CHANGE COOKIE BALANCE * ============================================================ * * Positive: * * changeCookies(userId, 50) * * Negative: * * changeCookies(userId, -50) * * Negative changes are protected against taking a * balance below zero. */ async function changeCookies( userId, amount ) { if ( !Number.isSafeInteger( amount ) ) { throw new Error( "INVALID_COOKIE_AMOUNT" ); } if ( amount === 0 ) { return getCookieBalance( userId ); } const { cookies } = getCollections(); /* * Adding Cookies. */ if ( amount > 0 ) { await cookies.updateOne( { userId }, { $inc: { cookies: amount }, $setOnInsert: { userId } }, { upsert: true } ); return getCookieBalance( userId ); } /* * Removing Cookies. * * Atomic balance check prevents the account * from becoming negative. */ const removeAmount = Math.abs( amount ); const result = await cookies.updateOne( { userId, cookies: { $gte: removeAmount } }, { $inc: { cookies: -removeAmount } } ); if ( result.modifiedCount !== 1 ) { throw new Error( "INSUFFICIENT_COOKIES" ); } return getCookieBalance( userId ); } /* * ============================================================ * IDEMPOTENT COOKIE CHANGE * ============================================================ * * Applies a Cookie mutation at most once for a user-scoped * operationId. This is useful for persistent game buy-ins and * cash-outs that may be retried after a crash or restart. * * The Cookie balance mutation and operation marker are written * atomically to the same user document. */ async function changeCookiesOnce( userId, amount, operationId ) { if ( !Number.isSafeInteger( amount ) ) { throw new Error( "INVALID_COOKIE_AMOUNT" ); } if ( typeof operationId !== "string" || !/^[A-Za-z0-9:_-]{1,160}$/.test( operationId ) ) { throw new Error( "INVALID_COOKIE_OPERATION_ID" ); } if ( amount === 0 ) { return { applied: false, balance: await getCookieBalance( userId ) }; } const { cookies } = getCollections(); const filter = { userId, appliedCookieOperations: { $ne: operationId } }; if ( amount < 0 ) { filter.cookies = { $gte: Math.abs( amount ) }; } const update = { $inc: { cookies: amount }, $addToSet: { appliedCookieOperations: operationId } }; if ( amount > 0 ) { update.$setOnInsert = { userId }; } try { const result = await cookies.updateOne( filter, update, { upsert: amount > 0 } ); if ( result.modifiedCount === 1 || result.upsertedCount === 1 ) { return { applied: true, balance: await getCookieBalance( userId ) }; } } catch (error) { /* * A concurrent positive retry can race an upsert. * The unique userId index may reject the losing insert. * Re-read below and treat an already-recorded operation * as a successful idempotent retry. */ if ( error?.code !== 11000 ) { throw error; } } const document = await cookies.findOne( { userId }, { projection: { cookies: 1, appliedCookieOperations: 1 } } ); const operations = Array.isArray( document ?.appliedCookieOperations ) ? document .appliedCookieOperations : []; if ( operations.includes( operationId ) ) { return { applied: false, balance: Math.max( 0, Number( document?.cookies ?? 0 ) ) }; } if ( amount < 0 ) { throw new Error( "INSUFFICIENT_COOKIES" ); } throw new Error( "COOKIE_OPERATION_FAILED" ); } async function clearCookieOperation( userId, operationId ) { if ( typeof operationId !== "string" || !/^[A-Za-z0-9:_-]{1,160}$/.test( operationId ) ) { throw new Error( "INVALID_COOKIE_OPERATION_ID" ); } const { cookies } = getCollections(); const result = await cookies.updateOne( { userId }, { $pull: { appliedCookieOperations: operationId } } ); /* * Keep the Cookie document tidy once the final temporary * idempotency marker has been cleared. */ await cookies.updateOne( { userId, appliedCookieOperations: { $size: 0 } }, { $unset: { appliedCookieOperations: "" } } ); return result.modifiedCount === 1; } async function hasCookieOperation( userId, operationId ) { if ( typeof operationId !== "string" || !/^[A-Za-z0-9:_-]{1,160}$/.test( operationId ) ) { throw new Error( "INVALID_COOKIE_OPERATION_ID" ); } const { cookies } = getCollections(); const document = await cookies.findOne( { userId, appliedCookieOperations: operationId }, { projection: { _id: 1 } } ); return Boolean( document ); } /* * ============================================================ * COOKIE TRANSFER * ============================================================ * * This intentionally DOES NOT use Mongo transactions. * * Your Mongo deployment does not support transactions / * retryable transactional writes. * * The debit is atomic. * * If crediting the destination fails after the debit, * we attempt to compensate by returning the Cookies * to the source account. */ async function transferCookies( fromUserId, toUserId, amount ) { if ( fromUserId === toUserId ) { throw new Error( "SAME_ACCOUNT" ); } if ( !Number.isSafeInteger( amount ) || amount <= 0 ) { throw new Error( "INVALID_COOKIE_AMOUNT" ); } const { cookies } = getCollections(); /* * ======================================================== * DEBIT SOURCE * ======================================================== * * This is the important atomic operation. * * Two simultaneous attempts cannot both spend the * same Cookies unless the balance actually covers both. */ const debit = await cookies.updateOne( { userId: fromUserId, cookies: { $gte: amount } }, { $inc: { cookies: -amount } } ); if ( debit.modifiedCount !== 1 ) { throw new Error( "INSUFFICIENT_COOKIES" ); } /* * ======================================================== * CREDIT DESTINATION * ======================================================== */ try { await cookies.updateOne( { userId: toUserId }, { $inc: { cookies: amount }, $setOnInsert: { userId: toUserId } }, { upsert: true } ); } catch (error) { /* * Best-effort compensation. * * If destination credit fails, return the * Cookies we already removed. */ try { await cookies.updateOne( { userId: fromUserId }, { $inc: { cookies: amount }, $setOnInsert: { userId: fromUserId } }, { upsert: true } ); } catch (refundError) { console.error( "CRITICAL: Cookie transfer compensation failed:", { fromUserId, toUserId, amount, refundError } ); } throw error; } return { amount, from: fromUserId, to: toUserId }; } /* * ============================================================ * GET COOLDOWN * ============================================================ * * Returns the Unix timestamp when the cooldown becomes * available again. * * Returns 0 when no cooldown exists. */ async function getCooldown( userId, guildId, cooldownName ) { validateCooldownName( cooldownName ); const { cooldowns } = getCollections(); const document = await cooldowns.findOne( { userId, guildId }, { projection: { [cooldownName]: 1 } } ); return Number( document?.[ cooldownName ] ?? 0 ); } /* * ============================================================ * SET COOLDOWN * ============================================================ * * durationSeconds is added to the current Unix timestamp. * * Returns the timestamp when the action becomes available. */ async function setCooldown( userId, guildId, cooldownName, durationSeconds ) { validateCooldownName( cooldownName ); validateCooldownDuration( durationSeconds ); const { cooldowns } = getCollections(); const next = getUnixTime() + durationSeconds; await cooldowns.updateOne( { userId, guildId }, { $set: { [cooldownName]: next }, $setOnInsert: { userId, guildId } }, { upsert: true } ); return next; } /* * ============================================================ * CLAIM COOLDOWN * ============================================================ * * Used for actions where rapid double clicks must not be * allowed to resolve twice. * * Vault Heist uses: * * claimCooldown( * userId, * guildId, * "heist", * HEIST_COOLDOWN * ) * * Sneak / Hack / Smash therefore all share ONE cooldown. * * Returns: * * { * claimed: true, * next: 1787000000 * } * * or: * * { * claimed: false, * next: 1787000000 * } */ async function claimCooldown( userId, guildId, cooldownName, durationSeconds ) { validateCooldownName( cooldownName ); validateCooldownDuration( durationSeconds ); const { cooldowns } = getCollections(); const now = getUnixTime(); const next = now + durationSeconds; /* * First attempt: * * Update an existing cooldown document only if * this particular cooldown is absent or expired. * * This is atomic. */ const existing = await cooldowns.updateOne( { userId, guildId, $or: [ { [cooldownName]: { $exists: false } }, { [cooldownName]: { $lte: now } } ] }, { $set: { [cooldownName]: next } } ); if ( existing.modifiedCount === 1 ) { return { claimed: true, next }; } /* * The document might not exist at all yet. * * Try inserting one. * * If another interaction created it at the exact * same moment, the unique index will reject one * side and we'll simply read the winning cooldown. */ try { await cooldowns.insertOne({ userId, guildId, [cooldownName]: next }); return { claimed: true, next }; } catch (error) { /* * 11000 = duplicate key. * * That is expected when two interactions race * to create the same cooldown record. */ if ( error?.code !== 11000 ) { throw error; } } /* * Someone else already owns the active cooldown. */ const current = await getCooldown( userId, guildId, cooldownName ); /* * Rare boundary case: * * the cooldown expired between our previous write * attempt and this read. * * Try one more atomic claim. */ if ( current <= getUnixTime() ) { const retry = await cooldowns.updateOne( { userId, guildId, [cooldownName]: { $lte: getUnixTime() } }, { $set: { [cooldownName]: next } } ); if ( retry.modifiedCount === 1 ) { return { claimed: true, next }; } } return { claimed: false, next: current }; } /* * ============================================================ * CLEAR COOLDOWN * ============================================================ * * Useful during testing/admin work. */ async function clearCooldown( userId, guildId, cooldownName ) { validateCooldownName( cooldownName ); const { cooldowns } = getCollections(); await cooldowns.updateOne( { userId, guildId }, { $unset: { [cooldownName]: "" } } ); } /* * ============================================================ * TEMPORARY EPHEMERAL REPLY * ============================================================ * * Usage: * * await temporaryEphemeralReply( * interaction, * { * content: "Nope." * }, * 6000 * ); * * If the interaction was already acknowledged, * this automatically falls back to an ephemeral follow-up. */ async function temporaryEphemeralReply( interaction, options, deleteAfter = 6000 ) { if ( interaction.replied || interaction.deferred ) { return temporaryEphemeralFollowUp( interaction, options, deleteAfter ); } await interaction.reply({ ...options, flags: MessageFlags.Ephemeral }); scheduleDeleteReply( interaction, deleteAfter ); } /* * ============================================================ * TEMPORARY EPHEMERAL FOLLOW-UP * ============================================================ * * Primarily used after deferUpdate(). */ 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; } /* * ============================================================ * TEMPORARY REPLY DELETE * ============================================================ */ function scheduleDeleteReply( interaction, deleteAfter ) { if ( !deleteAfter || deleteAfter <= 0 ) { return; } setTimeout( async () => { try { await interaction.deleteReply(); } catch {} }, deleteAfter ); } /* * ============================================================ * RANDOM INTEGER * ============================================================ * * Inclusive: * * randomInteger(1, 10) * * can return 1 through 10. */ 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 * ============================================================ * * formatPercentage(0.523) * * => "52.3%" */ 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" ); } } /* * ============================================================ * DATABASE INDEXES * ============================================================ * * Call this once during startup if desired. * * The unique cooldown index is particularly useful for * claimCooldown() because it prevents duplicate * userId + guildId cooldown records. */ async function ensureCookieIndexes() { const { cookies, cooldowns } = getCollections(); await cookies.createIndex( { userId: 1 }, { unique: true } ); await cooldowns.createIndex( { userId: 1, guildId: 1 }, { unique: true } ); } /* * ============================================================ * EXPORTS * ============================================================ */ module.exports = { getCollections, getCookieBalance, changeCookies, changeCookiesOnce, hasCookieOperation, clearCookieOperation, transferCookies, getCooldown, setCooldown, claimCooldown, clearCooldown, temporaryEphemeralReply, temporaryEphemeralFollowUp, getUnixTime, randomInteger, randomFloat, randomItem, formatPercentage, ensureCookieIndexes };