Skip to content
77 changes: 77 additions & 0 deletions src/commands/Economy/chopshop.js
Original file line number Diff line number Diff line change
@@ -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' })
};
82 changes: 82 additions & 0 deletions src/commands/Economy/killboard.js
Original file line number Diff line number Diff line change
@@ -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' })
};
69 changes: 69 additions & 0 deletions src/commands/Economy/killstats.js
Original file line number Diff line number Diff line change
@@ -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' })
};
98 changes: 98 additions & 0 deletions src/commands/Economy/lscm.js
Original file line number Diff line number Diff line change
@@ -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' })
};
Loading