diff --git a/src/commands/Economy/chopshop.js b/src/commands/Economy/chopshop.js new file mode 100644 index 000000000..2f0e9339a --- /dev/null +++ b/src/commands/Economy/chopshop.js @@ -0,0 +1,77 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +const COOLDOWN = 45 * 60 * 1000; +const JAIL_TIME = 60 * 60 * 1000; + +// NPC cars by tier. Higher tier = more pay + more heat. +const CARS = [ + { name: 'Karin Sultan', tier: 1, min: 1500, max: 3000, risk: 0.25 }, + { name: 'Bravado Buffalo', tier: 1, min: 1800, max: 3500, risk: 0.25 }, + { name: 'Declasse Tornado', tier: 1, min: 1200, max: 2500, risk: 0.20 }, + { name: 'Vapid Dominator', tier: 2, min: 3000, max: 6000, risk: 0.35 }, + { name: 'Obey Tailgater', tier: 2, min: 3500, max: 6500, risk: 0.35 }, + { name: 'Bravado Banshee', tier: 3, min: 6000, max: 12000, risk: 0.45 }, + { name: 'Pegassi Infernus', tier: 4, min: 12000, max: 22000, risk: 0.55 }, + { name: 'Truffade Adder', tier: 4, min: 18000, max: 30000, risk: 0.60 }, + { name: 'Pegassi Zentorno', tier: 5, min: 25000, max: 45000, risk: 0.70 }, +]; + +export default { + data: new SlashCommandBuilder() + .setName('chopshop') + .setDescription('Steal an NPC car and run it to the chop shop'), + + execute: withErrorHandling(async (interaction, config, client) => { + await InteractionHelper.safeDefer(interaction); + + const userId = interaction.user.id; + const guildId = interaction.guildId; + const now = Date.now(); + + const userData = await getEconomyData(client, guildId, userId); + + if (userData.jailedUntil && userData.jailedUntil > now) { + const m = Math.ceil((userData.jailedUntil - now) / 60000); + throw createError('Jailed', ErrorTypes.RATE_LIMIT, `You're locked up for ${m} more minutes.`); + } + + const last = userData.cooldowns?.chopshop || 0; + if (now < last + COOLDOWN) { + const m = Math.ceil((last + COOLDOWN - now) / 60000); + throw createError('Cooldown', ErrorTypes.RATE_LIMIT, `Heat's still on. Wait ${m} more minutes.`); + } + + const car = CARS[Math.floor(Math.random() * CARS.length)]; + const success = Math.random() > car.risk; + + userData.cooldowns = userData.cooldowns || {}; + userData.cooldowns.chopshop = now; + + if (success) { + const payout = Math.floor(Math.random() * (car.max - car.min + 1)) + car.min; + userData.wallet = (userData.wallet || 0) + payout; + await setEconomyData(client, guildId, userId, userData); + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed( + '๐Ÿ”ง Chop Shop Payout', + `You boosted a **${car.name}** (Tier ${car.tier}) and dropped it at the chop shop.\nPaid out: **$${payout.toLocaleString()}**` + )] + }); + } else { + const fine = Math.floor(car.min * 0.5); + userData.wallet = Math.max(0, (userData.wallet || 0) - fine); + userData.jailedUntil = now + JAIL_TIME; + await setEconomyData(client, guildId, userId, userData); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + '๐Ÿš“ Busted', + `LSPD caught you boosting a **${car.name}**. Fined **$${fine.toLocaleString()}** and jailed for 1 hour.` + )] + }); + } + }, { command: 'chopshop' }) +}; diff --git a/src/commands/Economy/killboard.js b/src/commands/Economy/killboard.js new file mode 100644 index 000000000..2584e4004 --- /dev/null +++ b/src/commands/Economy/killboard.js @@ -0,0 +1,82 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getRankForXp } from '../../utils/murder.js'; + +export default { + data: new SlashCommandBuilder() + .setName('killboard') + .setDescription("View this server's top 10 killers.") + .setDMPermission(false), + + execute: withErrorHandling(async (interaction, config, client) => { + const deferred = await InteractionHelper.safeDefer(interaction); + if (!deferred) return; + + const guildId = interaction.guildId; + const prefix = `economy:${guildId}:`; + + let allKeys = await client.db.list(prefix); + if (!Array.isArray(allKeys)) allKeys = []; + + if (allKeys.length === 0) { + throw createError( + 'No data', + ErrorTypes.VALIDATION, + 'No killer data found for this server yet. Be the first.' + ); + } + + const players = []; + for (const key of allKeys) { + const userId = key.replace(prefix, ''); + const userData = await client.db.get(key); + if (!userData) continue; + const kills = userData.kills || 0; + const xp = userData.murderXp || 0; + if (kills === 0 && xp === 0) continue; + players.push({ + userId, + kills, + deaths: userData.deaths || 0, + xp, + bestStreak: userData.bestKillStreak || 0, + }); + } + + if (players.length === 0) { + throw createError( + 'No kills yet', + ErrorTypes.VALIDATION, + 'Nobody on this server has any kills yet. Open the season with `/murder @someone`.' + ); + } + + // Sort by murderXp desc, then kills desc as tiebreaker + players.sort((a, b) => b.xp - a.xp || b.kills - a.kills); + + const top = players.slice(0, 10); + const myRank = players.findIndex(p => p.userId === interaction.user.id) + 1; + const medals = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰']; + + const lines = top.map((p, i) => { + const rank = getRankForXp(p.xp); + const tag = medals[i] || `**#${i + 1}**`; + const kd = (p.kills / Math.max(1, p.deaths)).toFixed(2); + return `${tag} <@${p.userId}> โ€” **${rank.name}** ยท ๐Ÿ’€ ${p.kills} kills ยท K/D ${kd} ยท ๐Ÿ”ฅ best ${p.bestStreak}`; + }); + + logger.info('[MURDER] Killboard generated', { guildId, count: players.length }); + + const embed = createEmbed({ + title: '๐Ÿ”ช Server Killboard', + description: lines.join('\n'), + footer: `Your rank: ${myRank > 0 ? `#${myRank} of ${players.length}` : 'Unranked โ€” get your first kill with /murder'}`, + color: 0xb00020, + }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + }, { command: 'killboard' }) +}; diff --git a/src/commands/Economy/killstats.js b/src/commands/Economy/killstats.js new file mode 100644 index 000000000..bfef21db3 --- /dev/null +++ b/src/commands/Economy/killstats.js @@ -0,0 +1,69 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { getEconomyData } from '../../utils/economy.js'; +import { withErrorHandling } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { ensureMurderFields, getRankForXp, getNextRank, MURDER_RANKS } from '../../utils/murder.js'; + +export default { + data: new SlashCommandBuilder() + .setName('killstats') + .setDescription("View a player's murder rank, kills, deaths, and progress.") + .setDMPermission(false) + .addUserOption(option => + option + .setName('user') + .setDescription('User to view (defaults to you)') + .setRequired(false) + ), + + execute: withErrorHandling(async (interaction, config, client) => { + const deferred = await InteractionHelper.safeDefer(interaction); + if (!deferred) return; + + const target = interaction.options.getUser('user') || interaction.user; + const guildId = interaction.guildId; + + const data = await getEconomyData(client, guildId, target.id); + ensureMurderFields(data); + + const xp = data.murderXp || 0; + const rank = getRankForXp(xp); + const next = getNextRank(xp); + const kills = data.kills || 0; + const deaths = data.deaths || 0; + const kd = (kills / Math.max(1, deaths)).toFixed(2); + + // Build a simple progress bar for next rank + let progressLine = '**MAX RANK**'; + if (next) { + const span = next.minXp - rank.minXp; + const into = xp - rank.minXp; + const pct = Math.max(0, Math.min(1, into / span)); + const filled = Math.round(pct * 12); + const bar = 'โ–ˆ'.repeat(filled) + 'โ–‘'.repeat(12 - filled); + progressLine = `\`${bar}\` ${into}/${span} XP โ†’ **${next.name}**`; + } + + const embed = createEmbed({ + title: `๐Ÿ”ช Kill Stats โ€” ${target.username}`, + description: + `**Rank:** ${rank.name}\n` + + `**Murder XP:** ${xp.toLocaleString()}\n` + + `${progressLine}`, + color: 0xb00020, + }) + .setThumbnail(target.displayAvatarURL()) + .addFields( + { name: '๐Ÿ’€ Kills', value: kills.toLocaleString(), inline: true }, + { name: 'โšฐ๏ธ Deaths', value: deaths.toLocaleString(), inline: true }, + { name: '๐Ÿ“Š K/D', value: kd, inline: true }, + { name: '๐Ÿ”ฅ Current Streak', value: String(data.killStreak || 0), inline: true }, + { name: '๐Ÿ† Best Streak', value: String(data.bestKillStreak || 0), inline: true }, + { name: '๐Ÿ’ฐ Base Payout', value: `$${rank.payoutMin.toLocaleString()} โ€“ $${rank.payoutMax.toLocaleString()}`, inline: true }, + ) + .setFooter({ text: `Tiers: ${MURDER_RANKS.map(r => r.name).join(' โ†’ ')}` }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + }, { command: 'killstats' }) +}; diff --git a/src/commands/Economy/lscm.js b/src/commands/Economy/lscm.js new file mode 100644 index 000000000..621f1ab48 --- /dev/null +++ b/src/commands/Economy/lscm.js @@ -0,0 +1,98 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { errorEmbed, successEmbed, infoEmbed } from '../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +// LSCM โ€” Los Santos Customs Marketplace (personal car buy/sell) +const CATALOG = [ + { id: 'sultan', name: 'Karin Sultan', price: 12000, resale: 0.6 }, + { id: 'buffalo', name: 'Bravado Buffalo', price: 15000, resale: 0.6 }, + { id: 'tornado', name: 'Declasse Tornado', price: 10000, resale: 0.6 }, + { id: 'dominator', name: 'Vapid Dominator', price: 22000, resale: 0.6 }, + { id: 'tailgater', name: 'Obey Tailgater', price: 25000, resale: 0.6 }, + { id: 'banshee', name: 'Bravado Banshee', price: 45000, resale: 0.65 }, + { id: 'infernus', name: 'Pegassi Infernus', price: 85000, resale: 0.7 }, + { id: 'adder', name: 'Truffade Adder', price: 150000, resale: 0.75 }, + { id: 'zentorno', name: 'Pegassi Zentorno', price: 220000, resale: 0.75 }, +]; + +function fmt(n) { return `$${n.toLocaleString()}`; } + +export default { + data: new SlashCommandBuilder() + .setName('lscm') + .setDescription('Los Santos Customs โ€” buy, sell, or list your personal cars') + .addSubcommand(s => s.setName('list').setDescription('See cars for sale')) + .addSubcommand(s => s.setName('garage').setDescription('See cars you own')) + .addSubcommand(s => s + .setName('buy') + .setDescription('Buy a car') + .addStringOption(o => o.setName('car').setDescription('Car id (from /lscm list)').setRequired(true))) + .addSubcommand(s => s + .setName('sell') + .setDescription('Sell a car back to LSCM') + .addStringOption(o => o.setName('car').setDescription('Car id from your garage').setRequired(true))), + + execute: withErrorHandling(async (interaction, config, client) => { + await InteractionHelper.safeDefer(interaction); + + const sub = interaction.options.getSubcommand(); + const userId = interaction.user.id; + const guildId = interaction.guildId; + const userData = await getEconomyData(client, guildId, userId); + userData.garage = userData.garage || []; + + if (sub === 'list') { + const lines = CATALOG.map(c => `\`${c.id}\` โ€” **${c.name}** โ€” ${fmt(c.price)}`).join('\n'); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [infoEmbed('๐Ÿ LSCM โ€” For Sale', lines)] + }); + } + + if (sub === 'garage') { + if (userData.garage.length === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [infoEmbed('๐Ÿ  Your Garage', 'Empty. Hit `/lscm list` to pick one up.')] + }); + } + const lines = userData.garage.map(c => `\`${c.id}\` โ€” **${c.name}** (bought ${fmt(c.boughtFor)})`).join('\n'); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [infoEmbed('๐Ÿ  Your Garage', lines)] + }); + } + + if (sub === 'buy') { + const carId = interaction.options.getString('car').toLowerCase(); + const car = CATALOG.find(c => c.id === carId); + if (!car) throw createError('No such car', ErrorTypes.VALIDATION, 'That ID is not in the catalog. Try `/lscm list`.'); + + const wallet = userData.wallet || 0; + if (wallet < car.price) { + throw createError('Broke', ErrorTypes.VALIDATION, `Need ${fmt(car.price)}, you got ${fmt(wallet)}.`); + } + + userData.wallet = wallet - car.price; + userData.garage.push({ id: car.id, name: car.name, boughtFor: car.price, resale: car.resale }); + await setEconomyData(client, guildId, userId, userData); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('๐Ÿ”‘ Sold!', `You drove off the lot in a **${car.name}** for **${fmt(car.price)}**.`)] + }); + } + + if (sub === 'sell') { + const carId = interaction.options.getString('car').toLowerCase(); + const idx = userData.garage.findIndex(c => c.id === carId); + if (idx === -1) throw createError('Not in garage', ErrorTypes.VALIDATION, "You don't own that one."); + + const car = userData.garage[idx]; + const payout = Math.floor(car.boughtFor * (car.resale || 0.6)); + userData.garage.splice(idx, 1); + userData.wallet = (userData.wallet || 0) + payout; + await setEconomyData(client, guildId, userId, userData); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('๐Ÿ’ธ Sold', `LSCM took the **${car.name}** off your hands for **${fmt(payout)}**.`)] + }); + } + }, { command: 'lscm' }) +}; diff --git a/src/commands/Economy/murder.js b/src/commands/Economy/murder.js new file mode 100644 index 000000000..b9915330c --- /dev/null +++ b/src/commands/Economy/murder.js @@ -0,0 +1,206 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { + MURDER_COOLDOWN, + HOSPITAL_TIME, + MIN_VICTIM_WALLET, + calculateSuccessChance, + calculatePayout, + ensureMurderFields, + getRankForXp, + getNextRank, +} from '../../utils/murder.js'; + +export default { + data: new SlashCommandBuilder() + .setName('murder') + .setDescription('Attempt to take out another player for cash and a rank-up.') + .setDMPermission(false) + .addUserOption(option => + option + .setName('target') + .setDescription('Who do you want to take out?') + .setRequired(true) + ), + + execute: withErrorHandling(async (interaction, config, client) => { + const deferred = await InteractionHelper.safeDefer(interaction); + if (!deferred) return; + + const attackerId = interaction.user.id; + const target = interaction.options.getUser('target'); + const guildId = interaction.guildId; + const now = Date.now(); + + // Basic validation + if (attackerId === target.id) { + throw createError( + 'Cannot murder self', + ErrorTypes.VALIDATION, + "You can't murder yourself. Try `/crime` if you want to ruin your own day.", + { attackerId } + ); + } + if (target.bot) { + throw createError( + 'Cannot murder bot', + ErrorTypes.VALIDATION, + "Bots are immortal in this town. Pick a real player.", + { targetId: target.id } + ); + } + + // Load economy data for both users + const attackerData = await getEconomyData(client, guildId, attackerId); + const victimData = await getEconomyData(client, guildId, target.id); + + if (!attackerData || !victimData) { + throw createError( + 'Failed to load economy data', + ErrorTypes.DATABASE, + 'Failed to load player data. Try again in a moment.', + { attackerOk: !!attackerData, victimOk: !!victimData } + ); + } + + ensureMurderFields(attackerData); + ensureMurderFields(victimData); + + // Hospitalized / jailed checks for attacker + if (attackerData.hospitalizedUntil && attackerData.hospitalizedUntil > now) { + const minsLeft = Math.ceil((attackerData.hospitalizedUntil - now) / 60000); + throw createError( + 'Attacker hospitalized', + ErrorTypes.RATE_LIMIT, + `You're still in the hospital from your last fight. **${minsLeft}m** left.`, + { remaining: attackerData.hospitalizedUntil - now } + ); + } + if (attackerData.jailedUntil && attackerData.jailedUntil > now) { + const minsLeft = Math.ceil((attackerData.jailedUntil - now) / 60000); + throw createError( + 'Attacker jailed', + ErrorTypes.RATE_LIMIT, + `You're locked up. **${minsLeft}m** left in jail.`, + { remaining: attackerData.jailedUntil - now } + ); + } + + // Cooldown + if (now < (attackerData.lastMurder || 0) + MURDER_COOLDOWN) { + const remaining = (attackerData.lastMurder || 0) + MURDER_COOLDOWN - now; + const hours = Math.floor(remaining / (60 * 60 * 1000)); + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / 60000); + throw createError( + 'Murder cooldown active', + ErrorTypes.RATE_LIMIT, + `The heat's still on you. Lay low for **${hours}h ${minutes}m** before your next hit.`, + { remaining, cooldownType: 'murder' } + ); + } + + // Victim must have some wallet โ€” discourages farming inactive accounts + if ((victimData.wallet || 0) < MIN_VICTIM_WALLET) { + throw createError( + 'Victim too broke', + ErrorTypes.VALIDATION, + `${target.username} is broke โ€” not worth the bullet. They need at least $${MIN_VICTIM_WALLET} in their wallet.`, + { victimWallet: victimData.wallet } + ); + } + + // Protection items โ€” fail attempt cleanly but still consume cooldown + const victimInventory = victimData.inventory || {}; + const hasVest = (victimInventory['bulletproof_vest'] || 0) > 0; + const hasSafe = (victimInventory['personal_safe'] || 0) > 0; + if (hasVest || hasSafe) { + attackerData.lastMurder = now; + attackerData.killStreak = 0; // failed attempt breaks streak + await setEconomyData(client, guildId, attackerId, attackerData); + + const reason = hasVest ? 'a **Bulletproof Vest**' : 'a **Personal Safe** of bodyguards'; + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Hit Failed', + `${target.username} was wearing ${reason}. Your shot bounced off and you slipped away โ€” empty handed. Your killstreak resets.` + ), + ], + }); + } + + // Roll the dice + const successChance = calculateSuccessChance( + attackerData.murderXp || 0, + victimData.murderXp || 0, + attackerData.killStreak || 0 + ); + const isSuccess = Math.random() < successChance; + + attackerData.lastMurder = now; + + if (isSuccess) { + const payout = calculatePayout(attackerData.murderXp || 0, attackerData.killStreak || 0); + const stolen = Math.min(victimData.wallet || 0, Math.floor((victimData.wallet || 0) * 0.10)); + const totalEarned = payout + stolen; + + const oldRank = getRankForXp(attackerData.murderXp || 0); + attackerData.wallet = (attackerData.wallet || 0) + totalEarned; + attackerData.kills = (attackerData.kills || 0) + 1; + attackerData.killStreak = (attackerData.killStreak || 0) + 1; + attackerData.bestKillStreak = Math.max(attackerData.bestKillStreak || 0, attackerData.killStreak); + attackerData.murderXp = (attackerData.murderXp || 0) + oldRank.xpReward; + + victimData.wallet = Math.max(0, (victimData.wallet || 0) - stolen); + victimData.deaths = (victimData.deaths || 0) + 1; + victimData.killStreak = 0; + + const newRank = getRankForXp(attackerData.murderXp); + const rankedUp = newRank.name !== oldRank.name; + const next = getNextRank(attackerData.murderXp); + + await setEconomyData(client, guildId, attackerId, attackerData); + await setEconomyData(client, guildId, target.id, victimData); + + const embed = successEmbed( + rankedUp ? `RANK UP โ€” ${newRank.name}` : 'Clean Kill', + `You took out **${target.username}** and walked away with **$${totalEarned.toLocaleString()}** ` + + `(${payout.toLocaleString()} contract + ${stolen.toLocaleString()} lifted from their wallet).` + ).addFields( + { name: 'Murder XP', value: `+${oldRank.xpReward} (total: ${attackerData.murderXp})`, inline: true }, + { name: 'Killstreak', value: `๐Ÿ”ฅ ${attackerData.killStreak}`, inline: true }, + { name: 'Rank', value: rankedUp ? `**${oldRank.name} โ†’ ${newRank.name}**` : newRank.name, inline: true }, + { + name: 'Next Rank', + value: next ? `${next.name} (need ${next.minXp - attackerData.murderXp} more XP)` : 'MAX RANK', + inline: false, + }, + ).setFooter({ text: `Total kills: ${attackerData.kills} | K/D: ${(attackerData.kills / Math.max(1, attackerData.deaths)).toFixed(2)} | Next hit in 2h` }); + + return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } + + // Failure path โ€” attacker gets hospitalized and pays medical bill + const wallet = attackerData.wallet || 0; + const medicalBill = Math.min(wallet, Math.floor(wallet * 0.15) + 100); + attackerData.wallet = Math.max(0, wallet - medicalBill); + attackerData.killStreak = 0; + attackerData.hospitalizedUntil = now + HOSPITAL_TIME; + + await setEconomyData(client, guildId, attackerId, attackerData); + + const embed = errorEmbed( + 'Hit Failed โ€” You Got Lit Up', + `${target.username} pulled first and put you in the hospital. You paid **$${medicalBill.toLocaleString()}** in medical bills and lost your killstreak.` + ).addFields( + { name: 'Hospital Time', value: '30 minutes', inline: true }, + { name: 'Killstreak', value: '๐Ÿ’€ 0', inline: true }, + { name: 'Success Chance Was', value: `${Math.round(successChance * 100)}%`, inline: true }, + ).setFooter({ text: `Next hit available in 2h (after hospital).` }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + }, { command: 'murder' }) +}; diff --git a/src/commands/Economy/robbery.js b/src/commands/Economy/robbery.js new file mode 100644 index 000000000..3516ec875 --- /dev/null +++ b/src/commands/Economy/robbery.js @@ -0,0 +1,106 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { getEconomyData, setEconomyData } from '../../utils/economy.js'; +import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +const COOLDOWN = 90 * 60 * 1000; +const JAIL_TIME = 2 * 60 * 60 * 1000; + +const TARGETS = [ + { name: 'LTD Gas Station', min: 500, max: 1500, risk: 0.30 }, + { name: 'Liquor Store', min: 800, max: 2500, risk: 0.35 }, + { name: '24/7 Convenience', min: 1200, max: 3500, risk: 0.40 }, + { name: 'Jewelry Store', min: 5000, max: 15000, risk: 0.60 }, + { name: 'Pacific Standard Bank', min: 15000, max: 40000, risk: 0.80 }, +]; + +// Weapon multipliers โ€” owning the gun lowers risk & boosts payout +const WEAPONS = [ + { id: 'bm_rifle', name: 'Assault Rifle', mult: 2.0, riskCut: 0.30 }, + { id: 'bm_smg', name: 'SMG', mult: 1.5, riskCut: 0.20 }, + { id: 'bm_pistol', name: 'Pistol', mult: 1.2, riskCut: 0.10 }, +]; + +function findBestWeapon(inv = []) { + for (const w of WEAPONS) { + if (inv.find(i => i.id === w.id && (i.quantity || 1) > 0)) return w; + } + return null; +} + +export default { + data: new SlashCommandBuilder() + .setName('robbery') + .setDescription('Armed robbery (211 in progress). Payout scales with your weapon.') + .addStringOption(o => o + .setName('target') + .setDescription('What you hitting?') + .setRequired(true) + .addChoices( + { name: 'LTD Gas Station', value: 'ltd' }, + { name: 'Liquor Store', value: 'liquor' }, + { name: '24/7 Convenience', value: '247' }, + { name: 'Jewelry Store', value: 'jewelry' }, + { name: 'Pacific Standard Bank', value: 'bank' }, + )), + + execute: withErrorHandling(async (interaction, config, client) => { + await InteractionHelper.safeDefer(interaction); + + const userId = interaction.user.id; + const guildId = interaction.guildId; + const now = Date.now(); + const userData = await getEconomyData(client, guildId, userId); + + if (userData.jailedUntil && userData.jailedUntil > now) { + const m = Math.ceil((userData.jailedUntil - now) / 60000); + throw createError('Jailed', ErrorTypes.RATE_LIMIT, `You're locked up for ${m} more minutes.`); + } + + const last = userData.cooldowns?.robbery || 0; + if (now < last + COOLDOWN) { + const m = Math.ceil((last + COOLDOWN - now) / 60000); + throw createError('Cooldown', ErrorTypes.RATE_LIMIT, `Cool it. Try again in ${m} minutes.`); + } + + const map = { ltd: 0, liquor: 1, '247': 2, jewelry: 3, bank: 4 }; + const target = TARGETS[map[interaction.options.getString('target')]]; + + const weapon = findBestWeapon(userData.inventory || []); + if (!weapon) { + throw createError('Unarmed', ErrorTypes.VALIDATION, + 'No gun in your inventory. Hit `/shop` and get a black market piece first.'); + } + + const finalRisk = Math.max(0.05, target.risk - weapon.riskCut); + const success = Math.random() > finalRisk; + + userData.cooldowns = userData.cooldowns || {}; + userData.cooldowns.robbery = now; + + if (success) { + const base = Math.floor(Math.random() * (target.max - target.min + 1)) + target.min; + const payout = Math.floor(base * weapon.mult); + userData.wallet = (userData.wallet || 0) + payout; + await setEconomyData(client, guildId, userId, userData); + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed( + '๐Ÿ”ซ 211 โ€” Successful', + `Hit the **${target.name}** with a **${weapon.name}**.\nGot away with **$${payout.toLocaleString()}**.` + )] + }); + } else { + const fine = Math.floor(target.min * 0.6); + userData.wallet = Math.max(0, (userData.wallet || 0) - fine); + userData.jailedUntil = now + JAIL_TIME; + await setEconomyData(client, guildId, userId, userData); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + '๐Ÿšจ 211 โ€” Failed', + `LSPD swarmed the **${target.name}**. Fined **$${fine.toLocaleString()}** and jailed for 2 hours.` + )] + }); + } + }, { command: 'robbery' }) +}; diff --git a/src/config/ai.js b/src/config/ai.js new file mode 100644 index 000000000..ba1a0b844 --- /dev/null +++ b/src/config/ai.js @@ -0,0 +1,17 @@ +// AI dispatch/scene settings + channel locks +export const aiConfig = { + enabled: process.env.AI_ENABLED !== 'false', + apiKey: process.env.OPENAI_API_KEY, + model: process.env.OPENAI_MODEL || 'gpt-4o-mini', + triggerWord: (process.env.AI_TRIGGER_WORD || 'tmo').toLowerCase(), + maxTokens: 200, + temperature: 0.8, + + // Channel locks โ€” T MO AI only responds in these channels. + // Slash commands are locked separately via commandsChannelId. + rpChannelId: process.env.AI_RP_CHANNEL_ID || '1508536082038390945', + dispatchChannelId: process.env.AI_DISPATCH_CHANNEL_ID || '1508808162327789639', + commandsChannelId: process.env.COMMANDS_CHANNEL_ID || '1508530649957400606', +}; + +export default aiConfig; diff --git a/src/config/shop/items.js b/src/config/shop/items.js index b027daac7..c2973176c 100644 --- a/src/config/shop/items.js +++ b/src/config/shop/items.js @@ -139,6 +139,82 @@ roleId: null, type: 'robbery_protection', protection: true } + }, + + // โ”€โ”€โ”€ GTA V RP: Drug Trade โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: 'weed_oz', + name: '๐ŸŒฟ Weed (1oz)', + price: 500, + description: 'A baggie of LS Kush. Move it on the block. Boosts `/work` payout.', + type: 'consumable', + maxQuantity: 50, + effect: { type: 'work_yield', multiplier: 1.3, uses: 1 } + }, + { + id: 'meth_g', + name: '๐Ÿงช Meth (1g)', + price: 1500, + description: 'Sandy Shores special. Risky to move. Boosts `/crime` payout.', + type: 'consumable', + maxQuantity: 25, + effect: { type: 'crime_yield', multiplier: 1.5, uses: 1 } + }, + { + id: 'coke_g', + name: 'โ„๏ธ Cocaine (1g)', + price: 2500, + description: 'Vinewood nose candy. High demand, high heat. Big `/crime` boost.', + type: 'consumable', + maxQuantity: 25, + effect: { type: 'crime_yield', multiplier: 1.8, uses: 1 } + }, + { + id: 'burner_phone', + name: '๐Ÿ“ฑ Burner Phone', + price: 1500, + description: 'Untraceable. Reduces the chance of getting busted on `/crime`.', + type: 'consumable', + maxQuantity: 10, + effect: { type: 'crime_safety', multiplier: 1.4, uses: 3 } + }, + { + id: 'drug_stash', + name: '๐Ÿ’ผ Drug Kingpin Stash', + price: 50000, + description: 'A safehouse stash. Permanent +25% on all drug-related earnings.', + type: 'upgrade', + maxLevel: 3, + effect: { type: 'work_yield', multiplier: 1.25 } + }, + + // โ”€โ”€โ”€ GTA V RP: Black Market Guns โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: 'bm_pistol', + name: '๐Ÿ”ซ Black Market Pistol', + price: 3000, + description: 'A cheap iron from the docks. Boosts `/rob` success.', + type: 'tool', + durability: 50, + effect: { type: 'robbery_yield', multiplier: 1.4 } + }, + { + id: 'bm_smg', + name: '๐Ÿ”ซ Black Market SMG', + price: 12000, + description: 'Spray and pray. Big boost on `/crime` and `/rob`.', + type: 'tool', + durability: 75, + effect: { type: 'robbery_yield', multiplier: 1.8 } + }, + { + id: 'bm_rifle', + name: '๐Ÿ’ฃ Black Market Assault Rifle', + price: 35000, + description: 'Heist-grade hardware. Max-tier robbery boost.', + type: 'tool', + durability: 100, + effect: { type: 'robbery_yield', multiplier: 2.5 } } ]; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 450ace66b..675465710 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -9,6 +9,16 @@ import { InteractionHelper } from '../utils/interactionHelper.js'; import { createInteractionTraceContext, runWithTraceContext } from '../utils/traceContext.js'; import { validateChatInputPayloadOrThrow } from '../utils/commandInputValidation.js'; import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; +import { aiConfig } from '../config/ai.js'; + +// Commands allowed in ANY channel (admin/utility โ€” not gated to #bot-commands) +const UNGATED_COMMANDS = new Set([ + 'help', 'ping', 'uptime', 'stats', 'overview', 'bug', 'support', + 'ban', 'unban', 'kick', 'timeout', 'untimeout', 'warn', 'warnings', + 'cases', 'purge', 'lock', 'unlock', 'massban', 'masskick', 'dm', + 'usernotes', 'verify', 'autoverify', 'verification', 'logging', + 'ticket', 'claim', 'close', 'priority', 'apply', 'app-admin', 'report', +]); function withTraceContext(context = {}, traceContext = {}) { return { @@ -33,6 +43,20 @@ export default { if (interaction.isChatInputCommand()) { try { + // Lock most slash commands to the designated #bot-commands channel + if ( + aiConfig.commandsChannelId && + interaction.guild && + interaction.channel?.id !== aiConfig.commandsChannelId && + !UNGATED_COMMANDS.has(interaction.commandName) + ) { + await interaction.reply({ + content: `๐Ÿšซ Please use commands in <#${aiConfig.commandsChannelId}>.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + logger.info(`Command executed: /${interaction.commandName} by ${interaction.user.tag}`, { event: 'interaction.command.received', traceId: interactionTraceContext.traceId, diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index b7f9841dc..36ef55928 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -1,13 +1,9 @@ - - - - - import { Events } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getLevelingConfig, getUserLevelData } from '../services/leveling.js'; import { addXp } from '../services/xpSystem.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; +import { shouldRespond, handleMessage } from '../services/dispatchService.js'; const MESSAGE_XP_RATE_LIMIT_ATTEMPTS = 12; const MESSAGE_XP_RATE_LIMIT_WINDOW_MS = 10000; @@ -16,9 +12,13 @@ export default { name: Events.MessageCreate, async execute(message, client) { try { - if (message.author.bot || !message.guild) return; + // T MO AI dispatch / scene handler + if (shouldRespond(message, client)) { + await handleMessage(message, client); + } + await handleLeveling(message, client); } catch (error) { logger.error('Error in messageCreate event:', error); @@ -26,13 +26,6 @@ export default { } }; - - - - - - - async function handleLeveling(message, client) { try { const rateLimitKey = `xp-event:${message.guild.id}:${message.author.id}`; @@ -42,17 +35,15 @@ async function handleLeveling(message, client) { } const levelingConfig = await getLevelingConfig(client, message.guild.id); - + if (!levelingConfig?.enabled) { return; } - if (levelingConfig.ignoredChannels?.includes(message.channel.id)) { return; } - if (levelingConfig.ignoredRoles?.length > 0) { const member = await message.guild.members.fetch(message.author.id).catch(() => { return null; @@ -62,48 +53,39 @@ async function handleLeveling(message, client) { } } - if (levelingConfig.blacklistedUsers?.includes(message.author.id)) { return; } - if (!message.content || message.content.trim().length === 0) { return; } const userData = await getUserLevelData(client, message.guild.id, message.author.id); - - + const cooldownTime = levelingConfig.xpCooldown || 60; const now = Date.now(); const timeSinceLastMessage = now - (userData.lastMessage || 0); - - + if (timeSinceLastMessage < cooldownTime * 1000) { return; } - const minXP = levelingConfig.xpRange?.min || levelingConfig.xpPerMessage?.min || 15; const maxXP = levelingConfig.xpRange?.max || levelingConfig.xpPerMessage?.max || 25; - const safeMinXP = Math.max(1, minXP); const safeMaxXP = Math.max(safeMinXP, maxXP); - const xpToGive = Math.floor(Math.random() * (safeMaxXP - safeMinXP + 1)) + safeMinXP; - let finalXP = xpToGive; if (levelingConfig.xpMultiplier && levelingConfig.xpMultiplier > 1) { finalXP = Math.floor(finalXP * levelingConfig.xpMultiplier); } - const result = await addXp(client, message.guild, message.member, finalXP); - + if (result.success && result.leveledUp) { logger.info( `${message.author.tag} leveled up to level ${result.level} in ${message.guild.name}` @@ -113,5 +95,3 @@ async function handleLeveling(message, client) { logger.error('Error handling leveling for message:', error); } } - - diff --git a/src/services/aiService.js b/src/services/aiService.js new file mode 100644 index 000000000..894231ce7 --- /dev/null +++ b/src/services/aiService.js @@ -0,0 +1,73 @@ +// Minimal OpenAI client using built-in fetch. No new dependencies. +import { aiConfig } from '../config/ai.js'; +import { logger } from '../utils/logger.js'; + +export async function chat(systemPrompt, userMessage) { + if (!aiConfig.apiKey) { + logger.warn('OPENAI_API_KEY not set'); + return null; + } + try { + const res = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${aiConfig.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: aiConfig.model, + temperature: aiConfig.temperature, + max_tokens: aiConfig.maxTokens, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + }), + }); + if (!res.ok) { + logger.error(`OpenAI ${res.status}: ${await res.text()}`); + return null; + } + const data = await res.json(); + return data.choices?.[0]?.message?.content?.trim() || null; + } catch (err) { + logger.error('aiService.chat error:', err.message); + return null; + } +} + +export async function classifyIntent(text) { + if (!aiConfig.apiKey) return 'chat'; + const system = `Classify the message into ONE word only: "dispatch", "scene", or "chat". +- dispatch: calling police/EMS, reporting crime, requesting backup, 911 +- scene: setting up roleplay scene, narration, describing a location +- chat: anything else`; + try { + const res = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${aiConfig.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: aiConfig.model, + temperature: 0, + max_tokens: 5, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: text }, + ], + }), + }); + if (!res.ok) return 'chat'; + const data = await res.json(); + const out = data.choices?.[0]?.message?.content?.toLowerCase().trim() || 'chat'; + if (out.includes('dispatch')) return 'dispatch'; + if (out.includes('scene')) return 'scene'; + return 'chat'; + } catch { + return 'chat'; + } +} + +export default { chat, classifyIntent }; diff --git a/src/services/dispatchService.js b/src/services/dispatchService.js new file mode 100644 index 000000000..fd4362d86 --- /dev/null +++ b/src/services/dispatchService.js @@ -0,0 +1,113 @@ +// Dispatch + scene management for GTA V RP +import { aiConfig } from '../config/ai.js'; +import { chat, classifyIntent } from './aiService.js'; +import { logger } from '../utils/logger.js'; + +const DISPATCH_PROMPT = `You are T MO, LSPD/BCSO dispatch for a GTA V roleplay car club. +Respond like a real police dispatcher. Use ten-codes (10-4, 10-20, Code 3, 10-50, 10-15). +Give a believable Los Santos or Blaine County location, assign a unit, keep it 1-3 sentences. +End with a status line like "Units en route" or "Copy, stand by." + +You MUST also call in narcotics activity when reported. Use proper codes: +- 11-550 = narcotics activity / drug deal in progress +- 10-50 = traffic / possible deal at vehicle +- Code 6 = stand by, suspect on scene +Drug callouts: weed (11-357), meth (11-590), cocaine (11-550C). +Locations to use for drug calls: Grove Street, Strawberry, Sandy Shores trailer park, +Davis projects, Vinewood back alleys, Vespucci canals, Paleto Bay docks, Cypress Flats. + +Stay in character. Never mention you are AI.`; + +const SCENE_PROMPT = `You are T MO, scene director for a GTA V roleplay car club. +Set the scene in 2-4 sentences with sensory detail (engine sounds, neon, weather, smells of Los Santos). +End with ONE question that pushes the scene forward. Stay in character. Never mention you are AI.`; + +const CHAT_PROMPT = `You are T MO, the AI for a GTA V console RP car club. Street-smart, brief (1-2 sentences), +always in character in the GTA V world. Never mention you are AI.`; + +// Keywords that should auto-trigger a dispatch broadcast from RP chat โ†’ dispatch channel +const DRUG_KEYWORDS = [ + 'weed', 'kush', 'blunt', 'ounce', 'oz', + 'coke', 'cocaine', 'kilo', 'brick', 'snow', + 'meth', 'crystal', 'tweak', 'shards', + 'deal', 'dealing', 'plug', 'dope', 'narcotics', 'drug' +]; + +export function looksLikeDrugDeal(text) { + const t = text.toLowerCase(); + return DRUG_KEYWORDS.some(k => new RegExp(`\\b${k}\\b`, 'i').test(t)); +} + +// Decide if T MO should respond based on channel + trigger +export function shouldRespond(message, client) { + if (!aiConfig.enabled) return false; + if (message.author.bot || !message.guild) return false; + if (!message.content) return false; + + const channelId = message.channel.id; + const isRpChannel = channelId === aiConfig.rpChannelId; + const isDispatchChannel = channelId === aiConfig.dispatchChannelId; + if (!isRpChannel && !isDispatchChannel) return false; + + // In RP/dispatch channels, every message triggers T MO (no @mention needed) + return true; +} + +function cleanText(message, client) { + return message.content + .replace(new RegExp(`<@!?${client.user.id}>`, 'g'), '') + .replace(new RegExp(`\\b${aiConfig.triggerWord}\\b`, 'gi'), '') + .trim(); +} + +export async function handleMessage(message, client) { + const text = cleanText(message, client); + if (!text) return; + + const channelId = message.channel.id; + const isDispatchChannel = channelId === aiConfig.dispatchChannelId; + + try { + await message.channel.sendTyping().catch(() => {}); + + let prompt, prefix; + if (isDispatchChannel) { + // Dispatch channel: ALWAYS dispatch mode. No intent classification needed (saves $). + prompt = DISPATCH_PROMPT; + prefix = '๐Ÿ“ป **Dispatch** โ€” '; + } else { + // RP channel: classify between scene vs chat + const intent = await classifyIntent(text); + logger.info(`T MO intent=${intent} | ${message.author.username}: ${text.slice(0, 80)}`); + if (intent === 'scene') { prompt = SCENE_PROMPT; prefix = '๐ŸŽฌ '; } + else { prompt = CHAT_PROMPT; prefix = ''; } + + // Bonus: if a drug deal is mentioned in RP chat, also broadcast to dispatch channel + if (looksLikeDrugDeal(text) && aiConfig.dispatchChannelId) { + broadcastDispatch(client, text, message.author.username).catch(err => + logger.error('broadcastDispatch error:', err.message) + ); + } + } + + const reply = await chat(prompt, text); + if (reply) await message.reply(`${prefix}${reply}`); + } catch (err) { + logger.error('dispatchService error:', err); + } +} + +async function broadcastDispatch(client, rpText, username) { + const dispatchChannel = await client.channels.fetch(aiConfig.dispatchChannelId).catch(() => null); + if (!dispatchChannel) return; + const callout = await chat( + DISPATCH_PROMPT, + `Anonymous tip just came in about possible narcotics activity. Tipster overheard: "${rpText}". Issue the narcotics call now.` + ); + if (callout) { + await dispatchChannel.send(`๐Ÿšจ **Narcotics Tip** โ€” ${callout}`).catch(() => {}); + logger.info(`Drug-deal callout broadcast to dispatch from ${username}`); + } +} + +export default { shouldRespond, handleMessage }; diff --git a/src/utils/murder.js b/src/utils/murder.js new file mode 100644 index 000000000..cfc27f9be --- /dev/null +++ b/src/utils/murder.js @@ -0,0 +1,94 @@ +// Murder / kill-ranking system constants and helpers. +// Data is stored on the existing economy record (EconomyDataSchema uses +// .passthrough()), so no migration is required. + +export const MURDER_COOLDOWN = 2 * 60 * 60 * 1000; // 2 hours +export const HOSPITAL_TIME = 30 * 60 * 1000; // 30 minutes if you fail +export const MIN_VICTIM_WALLET = 250; // target must be "worth it" +export const KILLSTREAK_BONUS_PCT = 0.05; // +5% reward per active streak kill (capped) +export const KILLSTREAK_BONUS_CAP = 1.0; // max +100% from streak + +// Rank tiers - unlocked by murderXp. Each tier improves base success chance +// and payout multiplier. Lowest rank first. +export const MURDER_RANKS = [ + { name: 'Petty Thug', minXp: 0, baseSuccess: 0.30, payoutMult: 1.00, payoutMin: 150, payoutMax: 600, xpReward: 10 }, + { name: 'Street Hitman', minXp: 100, baseSuccess: 0.35, payoutMult: 1.15, payoutMin: 300, payoutMax: 900, xpReward: 15 }, + { name: 'Enforcer', minXp: 300, baseSuccess: 0.40, payoutMult: 1.30, payoutMin: 500, payoutMax: 1500, xpReward: 20 }, + { name: 'Assassin', minXp: 750, baseSuccess: 0.45, payoutMult: 1.50, payoutMin: 800, payoutMax: 2500, xpReward: 30 }, + { name: 'Cartel Boss', minXp: 1500, baseSuccess: 0.50, payoutMult: 1.75, payoutMin: 1200, payoutMax: 4000, xpReward: 40 }, + { name: 'The Reaper', minXp: 3000, baseSuccess: 0.55, payoutMult: 2.00, payoutMin: 2000, payoutMax: 6500, xpReward: 55 }, + { name: 'Legendary Killer', minXp: 6000, baseSuccess: 0.60, payoutMult: 2.50, payoutMin: 3500, payoutMax: 10000, xpReward: 75 }, +]; + +/** + * Return the rank object matching a given murderXp value. + * @param {number} xp + */ +export function getRankForXp(xp = 0) { + let current = MURDER_RANKS[0]; + for (const rank of MURDER_RANKS) { + if (xp >= rank.minXp) { + current = rank; + } else { + break; + } + } + return current; +} + +/** + * Return the next rank object (or null if already at top). + * @param {number} xp + */ +export function getNextRank(xp = 0) { + const current = getRankForXp(xp); + const idx = MURDER_RANKS.indexOf(current); + if (idx === -1 || idx >= MURDER_RANKS.length - 1) return null; + return MURDER_RANKS[idx + 1]; +} + +/** + * Compute the actual success chance for an attacker vs. a victim. + * Attacker rank gives base success. Victim's own rank slightly resists. + * Streak adds a small bonus. Capped between 0.05 and 0.90 so neither side + * is hopeless or guaranteed. + */ +export function calculateSuccessChance(attackerXp = 0, victimXp = 0, killStreak = 0) { + const atk = getRankForXp(attackerXp); + const vic = getRankForXp(victimXp); + const attackerIdx = MURDER_RANKS.indexOf(atk); + const victimIdx = MURDER_RANKS.indexOf(vic); + const rankDelta = attackerIdx - victimIdx; // negative = attacker outranked + const resistance = Math.max(0, victimIdx - attackerIdx) * 0.04; + const streakBoost = Math.min(killStreak * 0.02, 0.15); + const chance = atk.baseSuccess + (rankDelta > 0 ? rankDelta * 0.03 : 0) - resistance + streakBoost; + return Math.min(0.90, Math.max(0.05, chance)); +} + +/** + * Compute the dollar payout for a successful murder, including the + * attacker rank's range, multiplier, and an active killstreak bonus. + * Returns a positive integer. + */ +export function calculatePayout(attackerXp = 0, killStreak = 0) { + const rank = getRankForXp(attackerXp); + const base = Math.floor(Math.random() * (rank.payoutMax - rank.payoutMin + 1)) + rank.payoutMin; + const withMult = Math.floor(base * rank.payoutMult); + const streakMult = 1 + Math.min(killStreak * KILLSTREAK_BONUS_PCT, KILLSTREAK_BONUS_CAP); + return Math.floor(withMult * streakMult); +} + +/** + * Ensure murder-related fields exist on a userData object. Mutates and returns. + */ +export function ensureMurderFields(userData) { + if (!userData) return userData; + if (typeof userData.kills !== 'number') userData.kills = 0; + if (typeof userData.deaths !== 'number') userData.deaths = 0; + if (typeof userData.murderXp !== 'number') userData.murderXp = 0; + if (typeof userData.killStreak !== 'number') userData.killStreak = 0; + if (typeof userData.bestKillStreak !== 'number') userData.bestKillStreak = 0; + if (typeof userData.lastMurder !== 'number') userData.lastMurder = 0; + if (typeof userData.hospitalizedUntil !== 'number') userData.hospitalizedUntil = 0; + return userData; +}