From 2ca18aae0662aa4fce007be61c3b02300a271aee Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Sun, 13 Sep 2026 21:14:56 +0200 Subject: [PATCH] updated steal logic --- COOKIE_LOGIC.md | 99 ++ commands/applications/cookies.js | 1929 ++++++++++++++---------------- 2 files changed, 997 insertions(+), 1031 deletions(-) create mode 100644 COOKIE_LOGIC.md diff --git a/COOKIE_LOGIC.md b/COOKIE_LOGIC.md new file mode 100644 index 0000000..458d99f --- /dev/null +++ b/COOKIE_LOGIC.md @@ -0,0 +1,99 @@ +# Cookie Economy Command + +`cookies.js` implements the `/cookies` command and its five subcommands: `collect`, `steal`, `check`, `give`, and `leaderboard`. + +## Commands + +- `/cookies collect` delegates collection logic to `collectCookies()`, then displays the amount collected, updated vault, and next collection time. +- `/cookies check [target]` shows a user's current Cookie balance. Without a target it checks the invoking user. +- `/cookies give ` transfers Cookies to another non-bot user and records sender/recipient profile statistics. +- `/cookies leaderboard` reads `items_cookies`, sorts by balance, excludes the bot, and displays the ten richest users. +- `/cookies steal ` runs the PvP robbery system described below. + +## Steal balance + +| Setting | Value | +|---|---:| +| Cooldown | 15 minutes | +| Base/equal-wealth success chance | 55% | +| Success range | 30%–80% | +| Wealth scaling | 12.5 percentage points per 10x balance difference | +| Heat per recent attempt | 7.5 percentage points | +| Heat lifetime | 1 hour, linear decay | +| Victim hourly loss budget | 15% of balance when the window starts | +| Per-robbery hard cap | 25,000 Cookies | +| Base loot tiers | 1–3%, 3–5%, 5–8%, 8–10% | +| Loot multiplier | 0.75x–1.25x based on final robbery difficulty | +| Base failure penalty | 2%–10% of thief balance | +| Failure multiplier | 0.75x–1.50x based on final success chance | + +### Wealth chance curve + +Before heat is applied, relative wealth determines the success chance. Absolute economy size does not matter. + +| Attacker compared with target | Base chance | +|---|---:| +| 100x poorer | 80% | +| 10x poorer | 67.5% | +| Equal | 55% | +| 10x richer | 42.5% | +| 100x richer | 30% | + +### Heat protection (`stats_profile`) + +Each valid robbery attempt adds an entry without modifying existing achievement/profile fields: + +```js +stealProtection: { + attempts: [ + { + userId: "attacker-id", + timestamp: Date + } + ] +} +``` + +All attackers contribute to the same victim heat. Each attempt starts at a 7.5-point success penalty and fades linearly to zero over one hour. Expired entries are removed whenever the victim is targeted again. The current attempt is appended atomically while MongoDB returns the immediately previous profile state, so simultaneous attacks see newly-created heat while an attempt never penalizes itself. + +### Hourly victim-loss budget (`items_cookies`) + +Each Cookie record may contain: + +```js +stealable: { + amount: 15000, + windowStartedAt: Date +} +``` + +When no active window exists, the first valid robbery attempt after the attacker's cooldown check initializes the allowance to 15% of the victim's current balance. That first attempt is treated as breaching the Vault and adds a short `🔓 Vault Breached!` flavor message to the robbery embed. Attempts rejected by the attacker's cooldown do not open a new victim window. + +The exposed amount is shown in steal result embeds as **Vault Exposed**. The window lasts a fixed hour; successful robberies decrement `amount` without moving `windowStartedAt`. Failed robberies leave the exposed budget unchanged. When the hour expires, the next valid attempt recalculates the allowance from the victim's then-current balance and opens a new exposure window. + +A successful robbery reserves its amount from this budget with an atomic MongoDB update before calling `transferCookies()`. This prevents concurrent robberies from spending the same allowance. If the Cookie transfer fails, the reservation is refunded. + +When the allowance reaches zero, the Vault is no longer exposed. Further steal attempts are rejected without consuming the attacker's steal cooldown until the fixed window expires and can be breached again. + +### Loot and penalties + +The original weighted loot tiers remain: + +- 50% of successful steals roll 1–3%. +- 30% roll 3–5%. +- 15% roll 5–8%. +- 5% roll 8–10%. + +Those ranges are multiplied by a difficulty factor: easy/high-chance robberies pay less, while difficult/low-chance robberies can pay more. The final amount is also capped by the victim balance, the remaining hourly stealable budget, and the 25,000-Cookie per-hit cap. + +Failure penalties work in the opposite direction. Easy/high-chance robberies carry a larger penalty when they fail; difficult robberies carry a smaller one. The resulting range moves from roughly 1.5–7.5% at a 30% success chance to 3–15% at an 80% success chance. + +## Collections and utilities + +The file expects `getCollections()` to expose: + +- `items_cookies` as `cookies`, `itemsCookies`, or `items_cookies`. +- `stats_profile` as `statsProfile`, `statsProfiles`, or `stats_profile`. +- Alternatively, `getCollections()` may expose a `db`/`database` handle from which those raw MongoDB collection names can be opened. + +Existing shared utilities remain responsible for Cookie balances, transfers, cooldown claims, random helpers, profile event tracking, and collection logic. diff --git a/commands/applications/cookies.js b/commands/applications/cookies.js index b8b6ad4..07c6bc7 100644 --- a/commands/applications/cookies.js +++ b/commands/applications/cookies.js @@ -23,133 +23,76 @@ const { } = require("../../utils/crumbCollect"); -/* - * ============================================================ - * CONFIG - * ============================================================ - */ - -const STEAL_COOLDOWN = 15 * 60; // 15 minutes - - -/* - * ============================================================ - * COMMAND - * ============================================================ - */ - module.exports = { - data: - new SlashCommandBuilder() - .setName("cookies") - .setDescription("The ShaiBot cookie economy.") - - .addSubcommand( - subcommand => - subcommand - .setName("collect") - .setDescription( - "Collect a fresh batch of cookies." - ) - ) - - .addSubcommand( - subcommand => - subcommand - .setName("steal") - .setDescription( - "Attempt to steal another user's cookies." - ) - .addUserOption( - option => - option - .setName("target") - .setDescription( - "The unfortunate victim." - ) - .setRequired(true) - ) - ) - - .addSubcommand( - subcommand => - subcommand - .setName("check") - .setDescription( - "Check a cookie balance." - ) - .addUserOption( - option => - option - .setName("target") - .setDescription( - "The user whose cookies you want to check." - ) - ) - ) - - .addSubcommand( - subcommand => - subcommand - .setName("give") - .setDescription( - "Give some of your cookies to another user." - ) - .addUserOption( - option => - option - .setName("target") - .setDescription( - "The recipient." - ) - .setRequired(true) - ) - .addIntegerOption( - option => - option - .setName("amount") - .setDescription( - "How many cookies to give." - ) - .setRequired(true) - .setMinValue(1) - ) - ) - - .addSubcommand( - subcommand => - subcommand - .setName("leaderboard") - .setDescription( - "Show the richest cookie collectors." - ) - ), + data: new SlashCommandBuilder() + .setName("cookies") + .setDescription("The ShaiBot cookie economy.") + .addSubcommand(subcommand => + subcommand + .setName("collect") + .setDescription("Collect a fresh batch of cookies.") + ) + .addSubcommand(subcommand => + subcommand + .setName("steal") + .setDescription("Attempt to steal another user's cookies.") + .addUserOption(option => + option + .setName("target") + .setDescription("The unfortunate victim.") + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName("check") + .setDescription("Check a cookie balance.") + .addUserOption(option => + option + .setName("target") + .setDescription("The user whose cookies you want to check.") + ) + ) + .addSubcommand(subcommand => + subcommand + .setName("give") + .setDescription("Give some of your cookies to another user.") + .addUserOption(option => + option + .setName("target") + .setDescription("The recipient.") + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName("amount") + .setDescription("How many cookies to give.") + .setRequired(true) + .setMinValue(1) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName("leaderboard") + .setDescription("Show the richest cookie collectors.") + ), async execute(interaction) { - switch ( - interaction.options.getSubcommand() - ) { + switch (interaction.options.getSubcommand()) { case "collect": return collect(interaction); - case "steal": return steal(interaction); - case "check": return check(interaction); - case "give": return give(interaction); - case "leaderboard": return leaderboard(interaction); - default: return interaction.reply({ - content: - "Unknown subcommand.", - flags: - MessageFlags.Ephemeral + content: "Unknown subcommand.", + flags: MessageFlags.Ephemeral }); } } @@ -163,99 +106,47 @@ module.exports = { */ async function collect(interaction) { - const userId = - interaction.user.id; - - /* - * Shared collection logic from crumbCollect.js. - * - * This now owns: - * - * - Mongo cooldown - * - collect amount - * - balance mutation - * - profile tracking - */ - - const result = - await collectCookies( - interaction, - userId - ); - - - /* - * ======================================================== - * COOLDOWN - * ======================================================== - * - * Keep the existing /cookies behavior: - * cooldown errors are private/ephemeral. - */ + const userId = interaction.user.id; + const result = await collectCookies(interaction, userId); if (!result.collected) { return interaction.reply({ - content: - `The batch is still cooking. Come back 🍪`, - flags: - MessageFlags.Ephemeral + content: `The batch is still cooking. Come back 🍪`, + flags: MessageFlags.Ephemeral }); } - - /* - * ======================================================== - * SUCCESS EMBED - * ======================================================== - * - * Preserve the existing cookies.js embed and random - * response style. - */ - - const embed = - new EmbedBuilder() - .setTitle( - "🍪 Fresh Cookies!" + const embed = new EmbedBuilder() + .setTitle("🍪 Fresh Cookies!") + .setDescription( + randomCollectResponse( + result.amount, + interaction.user ) - .setDescription( - randomCollectResponse( - result.amount, - interaction.user - ) - ) - .addFields( - { - name: - "Collected", - value: - `**+${result.amount.toLocaleString()} 🍪**`, - inline: - true - }, - { - name: - "Your Vault", - value: - `**${result.balance.toLocaleString()} 🍪**`, - inline: - true - }, - { - name: - "Next Batch", - value: - ``, - inline: - false - } - ) - .setThumbnail( - interaction.user.displayAvatarURL({ - size: 256 - }) - ) - .setTimestamp(); - + ) + .addFields( + { + name: "Collected", + value: `**+${result.amount.toLocaleString()} 🍪**`, + inline: true + }, + { + name: "Your Vault", + value: `**${result.balance.toLocaleString()} 🍪**`, + inline: true + }, + { + name: "Next Batch", + value: ``, + inline: false + } + ) + .setThumbnail( + interaction.user.displayAvatarURL({ + size: 256 + }) + ) + .setTimestamp(); return interaction.reply({ embeds: [embed] @@ -270,502 +161,570 @@ async function collect(interaction) { */ async function steal(interaction) { - const user = - interaction.user; - - const target = - interaction.options.getUser( - "target", - true - ); - + const user = interaction.user; + const target = interaction.options.getUser("target", true); /* - * ======================================================== - * VALIDATION - * ======================================================== + * Command-specific balancing. + * + * Base success chance: + * 100x richer attacker -> 30% + * 10x richer attacker -> 42.5% + * equal wealth -> 55% + * 10x poorer attacker -> 67.5% + * 100x poorer attacker -> 80% */ + const cooldownSeconds = 15 * 60; - if ( - target.id === - user.id - ) { + const minSuccessChance = 0.30; + const maxSuccessChance = 0.80; + const baseSuccessChance = 0.55; + const wealthChanceScale = 0.125; + + const heatWindowMs = 60 * 60 * 1000; + const heatPerAttempt = 0.075; + + const hourlyStealablePercentage = 0.15; + const maxStealAmount = 25000; + + const baseFailurePenaltyMin = 0.02; + const baseFailurePenaltyMax = 0.10; + const minPenaltyMultiplier = 0.75; + const maxPenaltyMultiplier = 1.50; + + const minRewardMultiplier = 0.75; + const maxRewardMultiplier = 1.25; + + const stealTiers = [ + { threshold: 0.50, min: 0.01, max: 0.03 }, + { threshold: 0.80, min: 0.03, max: 0.05 }, + { threshold: 0.95, min: 0.05, max: 0.08 }, + { threshold: 1.00, min: 0.08, max: 0.10 } + ]; + + if (target.id === user.id) { return interaction.reply({ - content: - "C'mon, don't embarrass yourself. You can't steal from yourself.", - flags: - MessageFlags.Ephemeral + content: "C'mon, don't embarrass yourself. You can't steal from yourself.", + flags: MessageFlags.Ephemeral }); } - - if ( - target.bot - ) { + if (target.bot) { return interaction.reply({ - content: - "Bots have highly sophisticated Cookie security systems.", - flags: - MessageFlags.Ephemeral + content: "Bots have highly sophisticated Cookie security systems.", + flags: MessageFlags.Ephemeral }); } + const [yourCookies, theirCookies] = await Promise.all([ + getCookieBalance(user.id), + getCookieBalance(target.id) + ]); + + if (yourCookies < 1) { + return interaction.reply({ + content: "You need at least **1 Cookie** to attempt a robbery.", + flags: MessageFlags.Ephemeral + }); + } + + if (theirCookies < 1) { + return interaction.reply({ + content: `Shame on you! ${target} doesn't have any Cookies. D:`, + flags: MessageFlags.Ephemeral + }); + } + + const { + cookies, + statsProfile + } = getCookieCommandCollections({ + requireStatsProfile: true + }); + + const now = new Date(); + const stealWindowCutoff = new Date( + now.getTime() - heatWindowMs + ); /* - * ======================================================== - * BALANCES - * ======================================================== + * Check an active exhausted window before consuming the attacker's + * cooldown. Expired windows are allowed to continue and will be reopened + * only after the attacker passes their own cooldown check. */ + const existingTargetCookieRecord = await cookies.findOne( + { + userId: target.id + }, + { + projection: { + stealable: 1 + } + } + ); - const [ - yourCookies, - theirCookies - ] = - await Promise.all([ - getCookieBalance( - user.id - ), - getCookieBalance( - target.id + const existingStealable = + existingTargetCookieRecord?.stealable; + + const existingWindowStartedAt = + existingStealable?.windowStartedAt + ? new Date( + existingStealable.windowStartedAt ) - ]); + : null; + const existingWindowActive = + existingWindowStartedAt && + existingWindowStartedAt > stealWindowCutoff; if ( - yourCookies < - 1 + existingWindowActive && + existingStealable.amount <= 0 ) { - return interaction.reply({ - content: - "You need at least **1 Cookie** to attempt a robbery.", - flags: - MessageFlags.Ephemeral - }); - } - - - if ( - theirCookies < - 1 - ) { - return interaction.reply({ - content: - `Shame on you! ${target} doesn't have any Cookies. D:`, - flags: - MessageFlags.Ephemeral - }); - } - - - /* - * ======================================================== - * COOLDOWN - * ======================================================== - */ - - const cooldown = - await claimCooldown( - user.id, - interaction.guildId, - "steal", - STEAL_COOLDOWN + const availableAt = Math.floor( + ( + existingWindowStartedAt.getTime() + + heatWindowMs + ) / + 1000 ); - - if ( - !cooldown.claimed - ) { return interaction.reply({ - content: - `Lay low for a while. You can steal again .`, - flags: - MessageFlags.Ephemeral + content: `${target}'s Cookie vault is no longer exposed. Try again .`, + flags: MessageFlags.Ephemeral }); } + const cooldown = await claimCooldown( + user.id, + interaction.guildId, + "steal", + cooldownSeconds + ); + + if (!cooldown.claimed) { + return interaction.reply({ + content: `Lay low for a while. You can steal again .`, + flags: MessageFlags.Ephemeral + }); + } /* - * ======================================================== - * DYNAMIC SUCCESS CHANCE - * ======================================================== + * Hourly victim-loss budget. + * + * This is a fixed one-hour window. The timestamp does not move when + * Cookies are stolen. Once the window expires, the first valid robbery + * attempt opens a fresh window and exposes 15% of the victim's current + * balance. */ + const initialStealableAmount = Math.max( + 1, + Math.floor( + theirCookies * + hourlyStealablePercentage + ) + ); - const successChance = - getStealChance( - yourCookies, - theirCookies + const stealableWindowUpdate = await cookies.updateOne( + { + userId: target.id, + $or: [ + { + "stealable.windowStartedAt": { + $exists: false + } + }, + { + "stealable.windowStartedAt": { + $lte: stealWindowCutoff + } + } + ] + }, + { + $set: { + stealable: { + amount: initialStealableAmount, + windowStartedAt: now + } + } + } + ); + + const openedStealableWindow = + stealableWindowUpdate.modifiedCount === 1; + + const targetCookieRecord = await cookies.findOne( + { + userId: target.id + }, + { + projection: { + stealable: 1 + } + } + ); + + const stealable = targetCookieRecord?.stealable; + + if (!stealable) { + throw new Error( + `Missing stealable state for Cookie user ${target.id}.` + ); + } + + if (stealable.amount <= 0) { + const availableAt = Math.floor( + ( + new Date( + stealable.windowStartedAt + ).getTime() + + heatWindowMs + ) / + 1000 ); + return interaction.reply({ + content: `${target}'s Cookie vault is no longer exposed. Try again .`, + flags: MessageFlags.Ephemeral + }); + } - const success = - Math.random() < - successChance; - + const vaultBreachText = openedStealableWindow + ? ( + `🔓 **Vault Breached!**\n` + + `You've breached the security of ${target}'s Vault and exposed some of their assets. ` + + `These Cookies can now be stolen until the Vault is repaired!` + ) + : null; /* - * ======================================================== - * SUCCESS - * ======================================================== + * Rolling heat protection. + * + * Every valid attempt against this target contributes 7.5 percentage + * points of heat and fades linearly to zero over one hour. Attempts by + * all users count toward the same target heat. */ + await statsProfile.updateOne( + { + userId: target.id + }, + { + $pull: { + "stealProtection.attempts": { + timestamp: { + $lte: stealWindowCutoff + } + } + } + } + ); + + /* + * Append the current attempt atomically and return the profile state from + * immediately before it was appended. This lets simultaneous attackers + * see each other's newly-created heat as MongoDB serializes the updates, + * while the current attempt still never penalizes itself. + */ + const heatUpdateResult = await statsProfile.findOneAndUpdate( + { + userId: target.id + }, + { + $push: { + "stealProtection.attempts": { + userId: user.id, + timestamp: now + } + } + }, + { + upsert: true, + returnDocument: "before" + } + ); + + const targetProfile = + heatUpdateResult?.value ?? + heatUpdateResult; + + const activeAttempts = + targetProfile?.stealProtection?.attempts ?? []; + + const heat = activeAttempts.reduce( + (total, attempt) => { + const timestamp = new Date( + attempt.timestamp + ).getTime(); + + const age = now.getTime() - timestamp; + + if ( + !Number.isFinite(age) || + age < 0 || + age >= heatWindowMs + ) { + return total; + } + + const remainingWeight = + 1 - age / heatWindowMs; + + return total + + heatPerAttempt * + remainingWeight; + }, + 0 + ); + + const wealthDifference = Math.log10( + (theirCookies + 1) / + (yourCookies + 1) + ); + + const unheatedSuccessChance = Math.max( + minSuccessChance, + Math.min( + maxSuccessChance, + baseSuccessChance + + wealthDifference * + wealthChanceScale + ) + ); + + const successChance = Math.max( + minSuccessChance, + unheatedSuccessChance - heat + ); + + const success = Math.random() < successChance; if (success) { - const { - amount - } = - getStealAmount( - theirCookies - ); + /* + * Reward scales inversely with final success chance: + * 80% chance -> 0.75x loot range + * 55% chance -> 1.00x loot range + * 30% chance -> 1.25x loot range + */ + const chancePosition = + (successChance - minSuccessChance) / + (maxSuccessChance - minSuccessChance); + const rewardMultiplier = + maxRewardMultiplier - + chancePosition * + (maxRewardMultiplier - minRewardMultiplier); + + const lootRoll = Math.random(); + const lootTier = + stealTiers.find( + tier => lootRoll < tier.threshold + ) ?? stealTiers[stealTiers.length - 1]; + + const stealPercentage = randomFloat( + lootTier.min * rewardMultiplier, + lootTier.max * rewardMultiplier + ); + + const requestedAmount = Math.max( + 1, + Math.min( + Math.floor( + theirCookies * + stealPercentage + ), + theirCookies, + maxStealAmount, + stealable.amount + ) + ); + + /* + * Reserve from the hourly stealable budget atomically before the + * transfer. Concurrent robberies cannot reserve more than remains. + */ + const reservation = await cookies.updateOne( + { + userId: target.id, + "stealable.windowStartedAt": new Date( + stealable.windowStartedAt + ), + "stealable.amount": { + $gte: requestedAmount + } + }, + { + $inc: { + "stealable.amount": -requestedAmount + } + } + ); + + if (reservation.modifiedCount !== 1) { + return interaction.reply({ + content: `${target}'s robbery protection changed before you could finish the heist. No Cookies were taken.`, + flags: MessageFlags.Ephemeral + }); + } try { await transferCookies( target.id, user.id, - amount + requestedAmount + ); + } catch (error) { + /* + * Refund the reservation when the balance transfer cannot be + * completed. The fixed window timestamp remains unchanged. + */ + await cookies.updateOne( + { + userId: target.id, + "stealable.windowStartedAt": new Date( + stealable.windowStartedAt + ) + }, + { + $inc: { + "stealable.amount": requestedAmount + } + } ); - } - catch (error) { - if ( - error.message === - "INSUFFICIENT_COOKIES" - ) { + if (error.message === "INSUFFICIENT_COOKIES") { return interaction.reply({ - content: - `${target}'s Cookie balance changed before you could finish the robbery.`, - flags: - MessageFlags.Ephemeral + content: `${target}'s Cookie balance changed before you could finish the robbery.`, + flags: MessageFlags.Ephemeral }); } - throw error; } + const amount = requestedAmount; - /* - * Thief stats. - */ + await trackProfileEvent( + interaction, + user.id, + { + type: "steal_success", + source: "steal", + amount, + targetUserId: target.id + } + ); -await trackProfileEvent( - interaction, - user.id, - { - type: "steal_success", - source: "steal", - amount, - targetUserId: - target.id - } -); + await trackProfileEvent( + interaction, + user.id, + { + type: "cookie_gain", + source: "steal", + amount, + targetUserId: target.id + } + ); + await trackProfileEvent( + interaction, + target.id, + { + type: "stolen_from_me", + source: "steal", + amount, + thiefUserId: user.id + } + ); -await trackProfileEvent( - interaction, - user.id, - { - type: "cookie_gain", - source: "steal", - amount, - targetUserId: - target.id - } -); - - - /* - * Victim stats. - */ - -await trackProfileEvent( - interaction, - target.id, - { - type: "stolen_from_me", - source: "steal", - amount, - thiefUserId: - user.id - } -); - -await trackProfileEvent( - interaction, - target.id, - { - type: "cookie_loss", - source: "steal", - amount, - thiefUserId: - user.id - } -); - + await trackProfileEvent( + interaction, + target.id, + { + type: "cookie_loss", + source: "steal", + amount, + thiefUserId: user.id + } + ); const [ yourBalanceAfter, - targetBalanceAfter - ] = - await Promise.all([ - getCookieBalance( - user.id - ), - getCookieBalance( - target.id - ) - ]); - + targetBalanceAfter, + updatedTargetCookieRecord + ] = await Promise.all([ + getCookieBalance(user.id), + getCookieBalance(target.id), + cookies.findOne( + { + userId: target.id + }, + { + projection: { + stealable: 1 + } + } + ) + ]); const actualPercentage = - ( - amount / - theirCookies - ) - * + amount / + theirCookies * 100; + const remainingStealable = Math.max( + 0, + updatedTargetCookieRecord?.stealable?.amount ?? 0 + ); - const embed = - new EmbedBuilder() - .setTitle( - "🥷 Robbery Successful!" - ) - .setDescription( + const embed = new EmbedBuilder() + .setTitle("🥷 Robbery Successful!") + .setDescription( + [ + vaultBreachText, randomStealSuccessResponse( amount, target ) - ) - .addFields( - { - name: - "Cookies Stolen", - value: - `**${amount.toLocaleString()} 🍪**`, - inline: - true - }, - { - name: - "Vault Taken", - value: - `**${actualPercentage.toFixed(1)}%**`, - inline: - true - }, - { - name: - "Success Chance", - value: - `**${formatPercentage(successChance)}**`, - inline: - true - }, - { - name: - "Your Vault", - value: - `**${yourBalanceAfter.toLocaleString()} 🍪**`, - inline: - true - }, - { - name: - `${target.username}'s Vault`, - value: - `**${targetBalanceAfter.toLocaleString()} 🍪**`, - inline: - true - }, - { - name: - "Next Robbery", - value: - ``, - inline: - false - } - ) - .setThumbnail( - target.displayAvatarURL({ - size: 256 - }) - ) - .setTimestamp(); - - - return interaction.reply({ - embeds: [embed] - }); - } - - - /* - * ======================================================== - * FAILURE - * ======================================================== - */ - - const penaltyPercentage = - randomFloat( - 0.01, - 0.08 - ); - - - const penalty = - Math.max( - 1, - Math.min( - yourCookies, - Math.floor( - yourCookies * - penaltyPercentage - ) - ) - ); - - - try { - await transferCookies( - user.id, - target.id, - penalty - ); - } - - catch (error) { - if ( - error.message === - "INSUFFICIENT_COOKIES" - ) { - return interaction.reply({ - content: - "Your Cookie balance changed before the robbery could finish.", - flags: - MessageFlags.Ephemeral - }); - } - - - throw error; - } - - - /* - * Profile tracking. - */ - -await trackProfileEvent( - interaction, - user.id, - { - type: "cookie_loss", - source: "steal_penalty", - amount: - penalty, - targetUserId: - target.id - } -); - - -await trackProfileEvent( - interaction, - target.id, - { - type: "cookie_gain", - source: "steal_penalty", - amount: - penalty, - thiefUserId: - user.id - } -); - - - const [ - yourBalanceAfter, - targetBalanceAfter - ] = - await Promise.all([ - getCookieBalance( - user.id - ), - getCookieBalance( - target.id - ) - ]); - - - const actualPenaltyPercentage = - ( - penalty / - yourCookies - ) - * - 100; - - - const embed = - new EmbedBuilder() - .setTitle( - "🚨 Robbery Failed!" - ) - .setDescription( - randomStealFailResponse( - penalty, - target - ) + ] + .filter(Boolean) + .join("\n\n") ) .addFields( { - name: - "Fine", - value: - `**${penalty.toLocaleString()} 🍪**`, - inline: - true + name: "Cookies Stolen", + value: `**${amount.toLocaleString()} 🍪**`, + inline: true }, { - name: - "Vault Lost", - value: - `**${actualPenaltyPercentage.toFixed(1)}%**`, - inline: - true + name: "Vault Taken", + value: `**${actualPercentage.toFixed(1)}%**`, + inline: true }, { - name: - "Success Chance", - value: - `**${formatPercentage(successChance)}**`, - inline: - true + name: "Success Chance", + value: `**${formatPercentage(successChance)}**`, + inline: true }, { - name: - "Your Vault", - value: - `**${yourBalanceAfter.toLocaleString()} 🍪**`, - inline: - true + name: "Your Vault", + value: `**${yourBalanceAfter.toLocaleString()} 🍪**`, + inline: true }, { - name: - `${target.username}'s Vault`, - value: - `**${targetBalanceAfter.toLocaleString()} 🍪**`, - inline: - true + name: `${target.username}'s Vault`, + value: `**${targetBalanceAfter.toLocaleString()} 🍪**`, + inline: true }, { - name: - "Next Robbery", - value: - ``, - inline: - false + name: "Vault Exposed", + value: `**${remainingStealable.toLocaleString()} 🍪**`, + inline: true + }, + { + name: "Next Robbery", + value: ``, + inline: false } ) .setThumbnail( @@ -775,6 +734,166 @@ await trackProfileEvent( ) .setTimestamp(); + return interaction.reply({ + embeds: [embed] + }); + } + + /* + * Failure penalty scales with final success chance: + * 30% chance -> 1.5% to 7.5% + * 55% chance -> 2.25% to 11.25% + * 80% chance -> 3% to 15% + */ + const chancePosition = + (successChance - minSuccessChance) / + (maxSuccessChance - minSuccessChance); + + const penaltyMultiplier = + minPenaltyMultiplier + + chancePosition * + (maxPenaltyMultiplier - minPenaltyMultiplier); + + const penaltyPercentage = randomFloat( + baseFailurePenaltyMin * penaltyMultiplier, + baseFailurePenaltyMax * penaltyMultiplier + ); + + const penalty = Math.max( + 1, + Math.min( + yourCookies, + Math.floor( + yourCookies * + penaltyPercentage + ) + ) + ); + + try { + await transferCookies( + user.id, + target.id, + penalty + ); + } catch (error) { + if (error.message === "INSUFFICIENT_COOKIES") { + return interaction.reply({ + content: "Your Cookie balance changed before the robbery could finish.", + flags: MessageFlags.Ephemeral + }); + } + + throw error; + } + + await trackProfileEvent( + interaction, + user.id, + { + type: "cookie_loss", + source: "steal_penalty", + amount: penalty, + targetUserId: target.id + } + ); + + await trackProfileEvent( + interaction, + target.id, + { + type: "cookie_gain", + source: "steal_penalty", + amount: penalty, + thiefUserId: user.id + } + ); + + const [ + yourBalanceAfter, + targetBalanceAfter, + updatedTargetCookieRecord + ] = await Promise.all([ + getCookieBalance(user.id), + getCookieBalance(target.id), + cookies.findOne( + { + userId: target.id + }, + { + projection: { + stealable: 1 + } + } + ) + ]); + + const actualPenaltyPercentage = + penalty / + yourCookies * + 100; + + const remainingStealable = Math.max( + 0, + updatedTargetCookieRecord?.stealable?.amount ?? 0 + ); + + const embed = new EmbedBuilder() + .setTitle("🚨 Robbery Failed!") + .setDescription( + [ + vaultBreachText, + randomStealFailResponse( + penalty, + target + ) + ] + .filter(Boolean) + .join("\n\n") + ) + .addFields( + { + name: "Fine", + value: `**${penalty.toLocaleString()} 🍪**`, + inline: true + }, + { + name: "Vault Lost", + value: `**${actualPenaltyPercentage.toFixed(1)}%**`, + inline: true + }, + { + name: "Success Chance", + value: `**${formatPercentage(successChance)}**`, + inline: true + }, + { + name: "Your Vault", + value: `**${yourBalanceAfter.toLocaleString()} 🍪**`, + inline: true + }, + { + name: `${target.username}'s Vault`, + value: `**${targetBalanceAfter.toLocaleString()} 🍪**`, + inline: true + }, + { + name: "Vault Exposed", + value: `**${remainingStealable.toLocaleString()} 🍪**`, + inline: true + }, + { + name: "Next Robbery", + value: ``, + inline: false + } + ) + .setThumbnail( + target.displayAvatarURL({ + size: 256 + }) + ) + .setTimestamp(); return interaction.reply({ embeds: [embed] @@ -790,40 +909,26 @@ await trackProfileEvent( async function check(interaction) { const target = - interaction.options.getUser( - "target" - ) - ?? + interaction.options.getUser("target") ?? interaction.user; + const balance = await getCookieBalance(target.id); - const balance = - await getCookieBalance( - target.id - ); - - - const embed = - new EmbedBuilder() - .setTitle( - "🍪 Cookie Vault" - ) - .setDescription( - `${target} has **${balance.toLocaleString()} Cookie${balance === 1 ? "" : "s"}**.` - ) - .addFields({ - name: - "Vault Balance", - value: - `**${balance.toLocaleString()} 🍪**` + const embed = new EmbedBuilder() + .setTitle("🍪 Cookie Vault") + .setDescription( + `${target} has **${balance.toLocaleString()} Cookie${balance === 1 ? "" : "s"}**.` + ) + .addFields({ + name: "Vault Balance", + value: `**${balance.toLocaleString()} 🍪**` + }) + .setThumbnail( + target.displayAvatarURL({ + size: 256 }) - .setThumbnail( - target.displayAvatarURL({ - size: 256 - }) - ) - .setTimestamp(); - + ) + .setTimestamp(); return interaction.reply({ embeds: [embed] @@ -838,162 +943,99 @@ async function check(interaction) { */ async function give(interaction) { - const user = - interaction.user; + const user = interaction.user; + const target = interaction.options.getUser("target", true); + const amount = interaction.options.getInteger("amount", true); - const target = - interaction.options.getUser( - "target", - true - ); - - const amount = - interaction.options.getInteger( - "amount", - true - ); - - - if ( - target.id === - user.id - ) { + if (target.id === user.id) { return interaction.reply({ - content: - "You cannot give Cookies to yourself.", - flags: - MessageFlags.Ephemeral + content: "You cannot give Cookies to yourself.", + flags: MessageFlags.Ephemeral }); } - - if ( - target.bot - ) { + if (target.bot) { return interaction.reply({ - content: - "Bots don't need Cookies. Allegedly.", - flags: - MessageFlags.Ephemeral + content: "Bots don't need Cookies. Allegedly.", + flags: MessageFlags.Ephemeral }); } - try { await transferCookies( user.id, target.id, amount ); - } - - catch (error) { - if ( - error.message === - "INSUFFICIENT_COOKIES" - ) { + } catch (error) { + if (error.message === "INSUFFICIENT_COOKIES") { return interaction.reply({ - content: - "We don't run a charity here. You don't have enough Cookies!", - flags: - MessageFlags.Ephemeral + content: "We don't run a charity here. You don't have enough Cookies!", + flags: MessageFlags.Ephemeral }); } - throw error; } + await trackProfileEvent( + interaction, + user.id, + { + type: "cookies_given", + source: "give", + amount, + targetUserId: target.id + } + ); - /* - * Sender lifetime giving. - */ - -await trackProfileEvent( - interaction, - user.id, - { - type: "cookies_given", - source: "give", - amount, - targetUserId: - target.id - } -); - - - /* - * Recipient lifetime gain. - */ - -await trackProfileEvent( - interaction, - target.id, - { - type: "cookie_gain", - source: "give", - amount, - senderUserId: - user.id - } -); - + await trackProfileEvent( + interaction, + target.id, + { + type: "cookie_gain", + source: "give", + amount, + senderUserId: user.id + } + ); const [ senderBalance, recipientBalance - ] = - await Promise.all([ - getCookieBalance( - user.id - ), - getCookieBalance( - target.id - ) - ]); - - - const embed = - new EmbedBuilder() - .setTitle( - "🍪 Cookies Shared!" - ) - .setDescription( - `${user} shared **${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}** with ${target}.` - ) - .addFields( - { - name: - "Transferred", - value: - `**${amount.toLocaleString()} 🍪**`, - inline: - true - }, - { - name: - "Your Vault", - value: - `**${senderBalance.toLocaleString()} 🍪**`, - inline: - true - }, - { - name: - `${target.username}'s Vault`, - value: - `**${recipientBalance.toLocaleString()} 🍪**`, - inline: - true - } - ) - .setThumbnail( - target.displayAvatarURL({ - size: 256 - }) - ) - .setTimestamp(); + ] = await Promise.all([ + getCookieBalance(user.id), + getCookieBalance(target.id) + ]); + const embed = new EmbedBuilder() + .setTitle("🍪 Cookies Shared!") + .setDescription( + `${user} shared **${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}** with ${target}.` + ) + .addFields( + { + name: "Transferred", + value: `**${amount.toLocaleString()} 🍪**`, + inline: true + }, + { + name: "Your Vault", + value: `**${senderBalance.toLocaleString()} 🍪**`, + inline: true + }, + { + name: `${target.username}'s Vault`, + value: `**${recipientBalance.toLocaleString()} 🍪**`, + inline: true + } + ) + .setThumbnail( + target.displayAvatarURL({ + size: 256 + }) + ) + .setTimestamp(); return interaction.reply({ embeds: [embed] @@ -1010,67 +1052,42 @@ await trackProfileEvent( async function leaderboard(interaction) { const { cookies - } = - getCollections(); + } = getCookieCommandCollections(); + const results = await cookies + .find({ + cookies: { + $gt: 0 + }, + userId: { + $ne: interaction.client.user.id + } + }) + .sort({ + cookies: -1 + }) + .limit(10) + .toArray(); - const results = - await cookies - .find({ - cookies: { - $gt: 0 - }, + const description = results.length + ? results + .map((record, index) => { + const medal = getLeaderboardMedal(index); - userId: { - $ne: - interaction.client.user.id - } + return ( + `${medal} <@${record.userId}> — ` + + `**${record.cookies.toLocaleString()}** 🍪` + ); }) - .sort({ - cookies: -1 - }) - .limit(10) - .toArray(); + .join("\n") + : "Nobody has collected any Cookies yet."; + const embed = new EmbedBuilder() + .setTitle("🍪 Cookie Leaderboard") + .setDescription(description) + .setTimestamp(); - const description = - results.length - ? results - .map( - ( - record, - index - ) => { - const medal = - getLeaderboardMedal( - index - ); - - return ( - `${medal} <@${record.userId}> — ` + - `**${record.cookies.toLocaleString()}** 🍪` - ); - } - ) - .join("\n") - : "Nobody has collected any Cookies yet."; - - - const embed = - new EmbedBuilder() - .setTitle( - "🍪 Cookie Leaderboard" - ) - .setDescription( - description - ) - .setTimestamp(); - - - if ( - interaction.guild - ?.iconURL() - ) { + if (interaction.guild?.iconURL()) { embed.setThumbnail( interaction.guild.iconURL({ size: 256 @@ -1078,27 +1095,71 @@ async function leaderboard(interaction) { ); } - return interaction.reply({ embeds: [embed] }); } +/* + * ============================================================ + * COLLECTION ACCESS + * ============================================================ + * + * getCollections() already exposes the Cookie collection in the existing + * application. The fallbacks below allow either camelCase or raw Mongo + * collection names for the two collections used by this command. + */ + +function getCookieCommandCollections({ + requireStatsProfile = false +} = {}) { + const collections = getCollections(); + const database = + collections.db ?? + collections.database; + + const cookies = + collections.cookies ?? + collections.itemsCookies ?? + collections.items_cookies ?? + database?.collection?.("items_cookies"); + + const statsProfile = + collections.statsProfile ?? + collections.statsProfiles ?? + collections.stats_profile ?? + database?.collection?.("stats_profile"); + + if (!cookies) { + throw new Error( + "getCollections() must expose the items_cookies collection as cookies/itemsCookies/items_cookies, or expose a db handle." + ); + } + + if (requireStatsProfile && !statsProfile) { + throw new Error( + "getCollections() must expose the stats_profile collection as statsProfile/statsProfiles/stats_profile, or expose a db handle." + ); + } + + return { + cookies, + statsProfile + }; +} + + /* * ============================================================ * COLLECT RESPONSES * ============================================================ */ -function randomCollectResponse( - amount, - user -) { +function randomCollectResponse(amount, user) { const cookies = `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - const responses = [ `${user}, I made you ${cookies} 🍪`, `${user}, here's your ${cookies} 🍪 I ate the rest, sorry >.<`, @@ -1116,10 +1177,7 @@ function randomCollectResponse( `${user}, here's your ${cookies} — watch out, they may be booby-trapped.` ]; - - return randomItem( - responses - ); + return randomItem(responses); } @@ -1129,14 +1187,10 @@ function randomCollectResponse( * ============================================================ */ -function randomStealSuccessResponse( - amount, - target -) { +function randomStealSuccessResponse(amount, target) { const cookies = `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - const responses = [ `You've successfully stolen ${cookies} from ${target}!`, `Ninja mode activated. You snatched ${cookies} from ${target} without leaving a crumb behind.`, @@ -1150,10 +1204,7 @@ function randomStealSuccessResponse( `You emerged from the shadows carrying ${cookies} from ${target}.` ]; - - return randomItem( - responses - ); + return randomItem(responses); } @@ -1163,14 +1214,10 @@ function randomStealSuccessResponse( * ============================================================ */ -function randomStealFailResponse( - amount, - target -) { +function randomStealFailResponse(amount, target) { const cookies = `**${amount.toLocaleString()} Cookie${amount === 1 ? "" : "s"}**`; - const responses = [ `You were caught red-handed by ${target} and lost ${cookies} as a penalty!`, `Fail! ${target} caught you mid-bite — you dropped ${cookies}.`, @@ -1183,182 +1230,7 @@ function randomStealFailResponse( `Your pockets had holes... ${target} found the ${cookies} you dropped.` ]; - - return randomItem( - responses - ); -} - - -/* - * ============================================================ - * STEAL CHANCE - * ============================================================ - */ - -function getStealChance( - attackerCookies, - targetCookies -) { - const MIN_CHANCE = 0.15; - const MAX_CHANCE = 0.70; - const BASE_CHANCE = 0.50; - - - const targetPenalty = - Math.log10( - targetCookies + - 1 - ) - * - 0.06; - - - const wealthRatio = - attackerCookies / - Math.max( - targetCookies, - 1 - ); - - - const povertyBonus = - wealthRatio < - 1 - ? ( - 1 - - wealthRatio - ) - * - 0.18 - : 0; - - - const bullyPenalty = - wealthRatio > - 1 - ? Math.min( - Math.log10( - wealthRatio + - 1 - ) - * - 0.12, - 0.20 - ) - : 0; - - - const chance = - BASE_CHANCE - - - targetPenalty - + - povertyBonus - - - bullyPenalty; - - - return Math.max( - MIN_CHANCE, - Math.min( - MAX_CHANCE, - chance - ) - ); -} - - -/* - * ============================================================ - * STEAL AMOUNT - * ============================================================ - */ - -function getStealAmount( - targetCookies -) { - const MAX_STEAL_AMOUNT = - 5000; - - - const percentage = - getStealPercentage(); - - - const calculatedAmount = - Math.floor( - targetCookies * - percentage - ); - - - const amount = - Math.max( - 1, - Math.min( - calculatedAmount, - targetCookies, - MAX_STEAL_AMOUNT - ) - ); - - - return { - amount, - percentage - }; -} - - -/* - * ============================================================ - * STEAL PERCENTAGE - * ============================================================ - */ - -function getStealPercentage() { - const roll = - Math.random(); - - - if ( - roll < - 0.50 - ) { - return randomFloat( - 0.01, - 0.03 - ); - } - - - if ( - roll < - 0.80 - ) { - return randomFloat( - 0.03, - 0.05 - ); - } - - - if ( - roll < - 0.95 - ) { - return randomFloat( - 0.05, - 0.08 - ); - } - - - return randomFloat( - 0.08, - 0.10 - ); + return randomItem(responses); } @@ -1368,20 +1240,15 @@ function getStealPercentage() { * ============================================================ */ -function getLeaderboardMedal( - index -) { +function getLeaderboardMedal(index) { switch (index) { case 0: return "🥇"; - case 1: return "🥈"; - case 2: return "🥉"; - default: return `**${index + 1}.**`; } -} \ No newline at end of file +}