diff --git a/PVP_MATCHMAKING_SETUP.md b/PVP_MATCHMAKING_SETUP.md new file mode 100644 index 0000000000..14783ec0dd --- /dev/null +++ b/PVP_MATCHMAKING_SETUP.md @@ -0,0 +1,106 @@ +# Miracle LA Discord-Only PvP Matchmaking + +This is integrated into the existing DayBreak/TitanBot project. It does **not** connect to FiveM or require an in-game resource. + +## What it adds + +- `Anon Queue` (1v1) +- `3v3 Anon Queue` +- `4v4 Anon Queue` +- `Hop Out Queue` (2v2) +- `Gang Rival/War Queue` (4v4) +- Native Discord dropdown panel +- Automatic team matching +- Discord party system (up to 4 players) +- 60-second match acceptance through Discord DMs +- Private Discord match rooms +- Staff result buttons +- MMR, wins, losses, streak and leaderboard +- Persistent storage through the bot's existing database wrapper +- Queue/match scheduler started automatically on `ready` + +## Setup + +1. Install dependencies normally: + +```bash +npm install +``` + +2. Start the existing bot: + +```bash +npm start +``` + +No new environment variables are required. + +3. In your Discord server, create a category for match rooms and optionally a log channel. + +4. As a server manager, run: + +```text +/pvp setup +``` + +Choose the match-room category. Optionally choose a log channel and paste your Miracle LA banner URL. + +5. In the channel where you want the queue panel, run: + +```text +/pvp panel +``` + +## Player usage + +Players can use the panel dropdown, or the slash commands: + +```text +/pvp join mode: +/pvp leave +/pvp status +/pvp match +/pvp profile +/pvp leaderboard +``` + +## Party usage + +```text +/pvp party-create +/pvp party-invite user:@player +/pvp party-accept party_id: +/pvp party-info +/pvp party-leave +``` + +The party leader queues the party. A party can be up to 4 players. The bot never splits a party across opposing teams. + +## Match flow + +1. Players/parties select a mode. +2. The scheduler searches the queue every 2.5 seconds. +3. Two valid teams are formed. +4. Every matched player receives a Discord DM with Accept/Decline buttons. +5. Everyone has 60 seconds to accept. +6. If anyone declines or times out, the original party groups are returned to the queue. +7. Once everyone accepts, the bot creates a private Discord match channel. +8. Players fight in FiveM separately. +9. Staff records Team A or Team B as the winner using the match buttons or `/pvp result`. +10. MMR and player stats update. + +## Required Discord permissions + +The bot needs permission to: + +- View Channels +- Send Messages +- Read Message History +- Embed Links +- Manage Channels (for private match-room creation/deletion) + +Players need to be able to receive DMs from the server/bot for the 60-second acceptance step. + +## Important anonymity note + +The matchmaking messages mask player identities as Team A / Team B. Discord cannot hide usernames from users who have access to the private match channel, so the system should be treated as **anonymous matchmaking before the match room is created**, not identity-proof anonymity. diff --git a/src/commands/PvP/pvp.js b/src/commands/PvP/pvp.js new file mode 100644 index 0000000000..d0f7098a3a --- /dev/null +++ b/src/commands/PvP/pvp.js @@ -0,0 +1,112 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ChannelType, + MessageFlags, + EmbedBuilder, +} from 'discord.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { PVP_MODES, buildPanel, setupGuild, joinQueue, leaveQueue, getQueueStatus, createParty, inviteParty, acceptPartyInvite, leaveParty, getProfile, getLeaderboard, getActiveMatch, recordResult, cancelMatch, getConfig } from '../../services/pvpMatchmakingService.js'; + +function modeChoices(option) { + return option.addChoices(...Object.entries(PVP_MODES).map(([value, m]) => ({ name: `${m.emoji} ${m.label}`, value }))); +} + +export default { + data: new SlashCommandBuilder() + .setName('pvp') + .setDescription('Discord-only PvP matchmaking') + .setDMPermission(false) + .addSubcommand(s => s.setName('setup').setDescription('Configure the PvP matchmaking system') + .addChannelOption(o => o.setName('category').setDescription('Category for private match rooms').setRequired(true).addChannelTypes(ChannelType.GuildCategory)) + .addChannelOption(o => o.setName('log_channel').setDescription('Channel for match logs').setRequired(false).addChannelTypes(ChannelType.GuildText)) + .addStringOption(o => o.setName('banner').setDescription('Optional image URL for the matchmaking panel').setRequired(false))) + .addSubcommand(s => s.setName('panel').setDescription('Post the Anon Matchmaking Queue panel in this channel')) + .addSubcommand(s => modeChoices(s.setName('join').setDescription('Join a PvP matchmaking queue').addStringOption(o => o.setName('mode').setDescription('Queue mode').setRequired(true)))) + .addSubcommand(s => s.setName('leave').setDescription('Leave your current matchmaking queue')) + .addSubcommand(s => s.setName('status').setDescription('Show live matchmaking queue counts')) + .addSubcommand(s => s.setName('match').setDescription('Show your current match')) + .addSubcommand(s => s.setName('profile').setDescription('Show your PvP profile')) + .addSubcommand(s => s.setName('leaderboard').setDescription('Show the PvP MMR leaderboard')) + .addSubcommand(s => s.setName('party-create').setDescription('Create a PvP party')) + .addSubcommand(s => s.setName('party-invite').setDescription('Invite a player to your PvP party').addUserOption(o => o.setName('user').setDescription('Player to invite').setRequired(true))) + .addSubcommand(s => s.setName('party-accept').setDescription('Accept a PvP party invite').addStringOption(o => o.setName('party_id').setDescription('Party ID from the invite').setRequired(true))) + .addSubcommand(s => s.setName('party-leave').setDescription('Leave your PvP party')) + .addSubcommand(s => s.setName('party-info').setDescription('Show your PvP party')) + .addSubcommand(s => s.setName('result').setDescription('Record a completed match result') + .addStringOption(o => o.setName('match_id').setDescription('Match ID').setRequired(true)) + .addStringOption(o => o.setName('winner').setDescription('Winning team').setRequired(true).addChoices({ name: 'Team A', value: 'A' }, { name: 'Team B', value: 'B' }))) + .addSubcommand(s => s.setName('cancel').setDescription('Cancel an active/accepting match') + .addStringOption(o => o.setName('match_id').setDescription('Match ID').setRequired(true))), + + async execute(interaction, config, client) { + if (!interaction.guild) return interaction.reply({ content: 'This system can only be used in a server.', flags: MessageFlags.Ephemeral }); + const sub = interaction.options.getSubcommand(); + if (['setup', 'result', 'cancel'].includes(sub) && !interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return interaction.reply({ content: '❌ You need **Manage Server** permission for that action.', flags: MessageFlags.Ephemeral }); + } + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + try { + if (sub === 'setup') { + const category = interaction.options.getChannel('category'); + const logChannel = interaction.options.getChannel('log_channel'); + const banner = interaction.options.getString('banner'); + const cfg = await setupGuild(client, interaction.guild.id, { categoryId: category.id, logChannelId: logChannel?.id || null, bannerUrl: banner || null }); + return interaction.editReply(`✅ PvP matchmaking configured.\nMatch category: ${category}\nLog channel: ${logChannel || 'Disabled'}\nPanel banner: ${cfg.bannerUrl ? 'Enabled' : 'Disabled'}`); + } + if (sub === 'panel') { + const cfg = await getConfig(client, interaction.guild.id); + if (!cfg.categoryId) return interaction.editReply('❌ Run `/pvp setup` first so the bot knows where to create match rooms.'); + const panel = await interaction.channel.send(buildPanel(cfg)); + await setupGuild(client, interaction.guild.id, { }); + return interaction.editReply(`✅ PvP matchmaking panel posted: ${panel}`); + } + if (sub === 'join') { + const mode = interaction.options.getString('mode', true); + const result = await joinQueue(client, interaction.guild, interaction.user.id, mode); + return interaction.editReply(`✅ You joined **${result.mode.label}**.\nQueue position: **${result.position}**\n\nThe bot will automatically search for an opponent.`); + } + if (sub === 'leave') return interaction.editReply(await leaveQueue(client, interaction.guild, interaction.user.id) ? '✅ You left the matchmaking queue.' : 'ℹ️ You are not currently in a queue.'); + if (sub === 'status') { + const status = await getQueueStatus(client, interaction.guild.id); + const desc = Object.values(status).map(m => `${m.emoji} **${m.label}** — ${m.players} player(s) queued`).join('\n'); + return interaction.editReply({ embeds: [new EmbedBuilder().setTitle('🔴 PvP Queue Status').setDescription(desc).setColor(0x8b0000).setTimestamp()] }); + } + if (sub === 'match') { + const match = await getActiveMatch(client, interaction.guild.id, interaction.user.id); + if (!match) return interaction.editReply('ℹ️ You do not have an active match.'); + return interaction.editReply({ embeds: [new EmbedBuilder().setTitle(`⚔️ Match ${match.id}`).setDescription(`Mode: **${match.modeLabel}**\nStatus: **${match.status}**\nMatch room: ${match.channelId ? `<#${match.channelId}>` : 'Waiting for acceptance'}`).setColor(0x8b0000)] }); + } + if (sub === 'profile') { + const p = await getProfile(client, interaction.guild.id, interaction.user.id); + const wr = p.matches ? ((p.wins / p.matches) * 100).toFixed(1) : '0.0'; + return interaction.editReply({ embeds: [new EmbedBuilder().setTitle(`⚔️ ${interaction.member.displayName} — PvP Profile`).addFields({ name: 'MMR', value: String(p.mmr), inline: true }, { name: 'Wins', value: String(p.wins), inline: true }, { name: 'Losses', value: String(p.losses), inline: true }, { name: 'Win Rate', value: `${wr}%`, inline: true }, { name: 'Streak', value: String(p.streak), inline: true }).setColor(0x8b0000)] }); + } + if (sub === 'leaderboard') { + const rows = await getLeaderboard(client, interaction.guild.id); + const text = rows.length ? rows.map(([id, p], i) => `**${i + 1}.** <@${id}> — **${p.mmr} MMR** • ${p.wins}-${p.losses}`).join('\n') : 'No ranked players yet.'; + return interaction.editReply({ embeds: [new EmbedBuilder().setTitle('🏆 PvP Leaderboard').setDescription(text).setColor(0x8b0000)] }); + } + if (sub === 'party-create') { const party = await createParty(client, interaction.guild, interaction.user.id); return interaction.editReply(`✅ Party created. Your party ID is **${party.id}**. Use /pvp party-invite to add players.`); } + if (sub === 'party-invite') { + const user = interaction.options.getUser('user', true); + const party = await inviteParty(client, interaction.guild, interaction.user.id, user.id); + await user.send(`🎮 You were invited to a PvP party in **${interaction.guild.name}** by <@${interaction.user.id}>. Use /pvp party-accept and enter party ID ${party.id}.`).catch(() => {}); + return interaction.editReply(`✅ Invited ${user} to party **${party.id}**.`); + } + if (sub === 'party-accept') return interaction.editReply(`✅ You joined party **${(await acceptPartyInvite(client, interaction.guild, interaction.user.id, interaction.options.getString('party_id', true))).id}**.`); + if (sub === 'party-leave') { await leaveParty(client, interaction.guild, interaction.user.id); return interaction.editReply('✅ You left your PvP party.'); } + if (sub === 'party-info') { + const state = await import('../../services/pvpMatchmakingService.js'); + const raw = await state.loadGuildState(client, interaction.guild.id); + const party = Object.values(raw.parties).find(p => p.members.includes(interaction.user.id)); + if (!party) return interaction.editReply('ℹ️ You are not in a party.'); + return interaction.editReply({ embeds: [new EmbedBuilder().setTitle(`🎮 PvP Party ${party.id}`).setDescription(`Leader: <@${party.leaderId}>\nMembers:\n${party.members.map(id => `• <@${id}>`).join('\n')}`).setColor(0x8b0000)] }); + } + if (sub === 'result') { await recordResult(client, interaction.guild, interaction.member, interaction.options.getString('match_id', true), interaction.options.getString('winner', true)); return interaction.editReply('🏆 Match result recorded and MMR updated.'); } + if (sub === 'cancel') { await cancelMatch(client, interaction.guild, interaction.member, interaction.options.getString('match_id', true)); return interaction.editReply('🛑 Match cancelled.'); } + } catch (error) { + return interaction.editReply(`❌ ${error.message || 'Something went wrong.'}`); + } + }, +}; diff --git a/src/config/bot.js b/src/config/bot.js index 86dc861cc3..5d50f79d06 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -11,7 +11,7 @@ export const botConfig = { // - "invisible" = appears offline presence: { // Current online state shown on Discord. - status: "online", + status: "idle", // Activity lines shown under the bot name. // `type` number mapping from Discord: @@ -24,7 +24,7 @@ export const botConfig = { activities: [ { name: "Custom Status", // required by Discord API, not shown in the client - state: "stalking", // this is what people actually see + state: "Ballhog", // this is what people actually see type: 4, // Custom }, ], @@ -457,6 +457,7 @@ export const botConfig = { // Community engagement systems. tickets: true, + pvpMatchmaking: true, giveaways: true, birthday: true, counter: true, @@ -544,6 +545,7 @@ const COMMAND_CATEGORY_FEATURE_MAP = { search: "search", serverstats: "counter", ticket: "tickets", + pvp: "pvpMatchmaking", tools: "tools", utility: "utility", verification: "verification", @@ -648,4 +650,4 @@ export function getRandomColor() { return colors[Math.floor(Math.random() * colors.length)]; } -export default botConfig; \ No newline at end of file +export default botConfig; diff --git a/src/events/ready.js b/src/events/ready.js index 2be8e5e424..f20c2d18c0 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -5,6 +5,7 @@ import { reconcileReactionRoleMessages } from "../services/reactionRoleService.j import { reconcileTicketPanels, reconcileVerificationPanels, reconcileReactionRolePanelHealth } from "../services/panelHealthService.js"; import { reconcileLevelRoles } from "../services/leveling/levelRoleSyncService.js"; import { initRiffyAfterReady } from "../services/music/riffySetup.js"; +import { startPvpScheduler } from "../services/pvpMatchmakingService.js"; export default { name: Events.ClientReady, @@ -22,6 +23,8 @@ export default { initRiffyAfterReady(client); } + startPvpScheduler(client); + const reconciliationSummary = await reconcileReactionRoleMessages(client); startupLog( `Reaction role reconciliation: scanned ${reconciliationSummary.scannedMessages}, removed ${reconciliationSummary.removedMessages}, errors ${reconciliationSummary.errors}` diff --git a/src/interactions/buttons/pvp/pvpMatch.js b/src/interactions/buttons/pvp/pvpMatch.js new file mode 100644 index 0000000000..e01f53bde0 --- /dev/null +++ b/src/interactions/buttons/pvp/pvpMatch.js @@ -0,0 +1,60 @@ +import { MessageFlags } from 'discord.js'; +import { acceptMatch, declineMatch, recordResult, cancelMatch } from '../../../services/pvpMatchmakingService.js'; + +export default [ + { + name: 'pvp_accept', + async execute(interaction, client, args) { + const [guildId, matchId] = args; + const guild = await client.guilds.fetch(guildId).catch(() => null); + if (!guild) return interaction.reply({ content: '❌ The matchmaking server could not be found.', flags: MessageFlags.Ephemeral }); + try { + const result = await acceptMatch(client, guild, interaction.user.id, matchId); + if (result.complete) { + return interaction.update({ content: `✅ Match **${matchId}** accepted. The private match room is ready.`, components: [] }); + } + return interaction.update({ content: `✅ Match **${matchId}** accepted. Waiting for the other players (${result.match.accepted.length}/${result.match.players.length}).`, components: interaction.message.components }); + } catch (error) { + return interaction.reply({ content: `❌ ${error.message}`, flags: MessageFlags.Ephemeral }); + } + }, + }, + { + name: 'pvp_decline', + async execute(interaction, client, args) { + const [guildId, matchId] = args; + const guild = await client.guilds.fetch(guildId).catch(() => null); + if (!guild) return interaction.reply({ content: '❌ The matchmaking server could not be found.', flags: MessageFlags.Ephemeral }); + try { + await declineMatch(client, guild, interaction.user.id, matchId); + return interaction.update({ content: `❌ Match **${matchId}** declined. Players have been returned to the queue.`, components: [] }); + } catch (error) { + return interaction.reply({ content: `❌ ${error.message}`, flags: MessageFlags.Ephemeral }); + } + }, + }, + { + name: 'pvp_result', + async execute(interaction, client, args) { + const [matchId, winner] = args; + try { + await recordResult(client, interaction.guild, interaction.member, matchId, winner); + await interaction.reply({ content: `🏆 Team **${winner}** recorded as the winner. MMR has been updated.`, flags: MessageFlags.Ephemeral }); + } catch (error) { + await interaction.reply({ content: `❌ ${error.message}`, flags: MessageFlags.Ephemeral }); + } + }, + }, + { + name: 'pvp_cancel', + async execute(interaction, client, args) { + const [matchId] = args; + try { + await cancelMatch(client, interaction.guild, interaction.member, matchId); + await interaction.reply({ content: `🛑 Match **${matchId}** cancelled.`, flags: MessageFlags.Ephemeral }); + } catch (error) { + await interaction.reply({ content: `❌ ${error.message}`, flags: MessageFlags.Ephemeral }); + } + }, + }, +]; diff --git a/src/interactions/buttons/pvp/pvpQueueLeave.js b/src/interactions/buttons/pvp/pvpQueueLeave.js new file mode 100644 index 0000000000..70e72e5457 --- /dev/null +++ b/src/interactions/buttons/pvp/pvpQueueLeave.js @@ -0,0 +1,10 @@ +import { leaveQueue } from '../../../services/pvpMatchmakingService.js'; + +export default { + name: 'pvp_leave', + async execute(interaction, client) { + if (!interaction.guild) return interaction.reply({ content: 'This button only works in a server.', ephemeral: true }); + const removed = await leaveQueue(client, interaction.guild, interaction.user.id); + await interaction.reply({ content: removed ? '✅ You left the PvP queue.' : 'ℹ️ You are not currently queued.', ephemeral: true }); + }, +}; diff --git a/src/interactions/selectMenus/pvp/pvpQueue.js b/src/interactions/selectMenus/pvp/pvpQueue.js new file mode 100644 index 0000000000..319d0107ac --- /dev/null +++ b/src/interactions/selectMenus/pvp/pvpQueue.js @@ -0,0 +1,19 @@ +import { joinQueue, PVP_MODES } from '../../../services/pvpMatchmakingService.js'; + +export default { + name: 'pvp_queue', + async execute(interaction, client) { + const mode = interaction.values[0]; + const spec = PVP_MODES[mode]; + if (!spec || !interaction.guild) return interaction.reply({ content: '❌ Invalid matchmaking option.', ephemeral: true }); + try { + const result = await joinQueue(client, interaction.guild, interaction.user.id, mode); + await interaction.reply({ + content: `✅ You joined **${result.mode.label}**.\nQueue position: **${result.position}**\n\nThe bot is searching for an opponent now.`, + ephemeral: true, + }); + } catch (error) { + await interaction.reply({ content: `❌ ${error.message || 'Unable to join the queue.'}`, ephemeral: true }); + } + }, +}; diff --git a/src/services/pvpMatchmakingService.js b/src/services/pvpMatchmakingService.js new file mode 100644 index 0000000000..249de4cd7b --- /dev/null +++ b/src/services/pvpMatchmakingService.js @@ -0,0 +1,449 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, + PermissionFlagsBits, + StringSelectMenuBuilder, +} from 'discord.js'; +import { logger } from '../utils/logger.js'; + +const STATE_PREFIX = 'pvp:state:'; +const LOCKS = new Map(); +const ACCEPT_TIMEOUT_MS = 60_000; +const MATCH_INTERVAL_MS = 2500; + +export const PVP_MODES = { + anon: { label: 'Anon Queue', short: '1v1', slots: 1, emoji: '🔴', description: '1v1 anonymous KOS matchmaking' }, + anon3v3: { label: '3v3 Anon Queue', short: '3v3', slots: 3, emoji: '🔴', description: '3v3 anonymous KOS matchmaking' }, + anon4v4: { label: '4v4 Anon Queue', short: '4v4', slots: 4, emoji: '🔴', description: '4v4 anonymous KOS matchmaking' }, + hopout: { label: 'Hop Out Queue', short: '2v2', slots: 2, emoji: '🔴', description: '2v2 Hop Out matchmaking' }, + gangwar: { label: 'Gang Rival/War Queue', short: '4v4', slots: 4, emoji: '🔴', description: '4v4 Gang Rival/War matchmaking' }, +}; + +const key = guildId => `${STATE_PREFIX}${guildId}`; +const emptyState = () => ({ version: 1, config: {}, queues: {}, parties: {}, profiles: {}, matches: {}, invites: {} }); + +async function lock(guildId, fn) { + const previous = LOCKS.get(guildId) || Promise.resolve(); + let release; + const current = new Promise(resolve => { release = resolve; }); + LOCKS.set(guildId, current); + await previous.catch(() => {}); + try { return await fn(); } finally { release(); if (LOCKS.get(guildId) === current) LOCKS.delete(guildId); } +} + +export async function loadGuildState(client, guildId) { + const raw = await client.db.get(key(guildId), null); + if (!raw || typeof raw !== 'object') return emptyState(); + return { + ...emptyState(), ...raw, + config: { ...(raw.config || {}) }, + queues: raw.queues || {}, parties: raw.parties || {}, profiles: raw.profiles || {}, + matches: raw.matches || {}, invites: raw.invites || {}, + }; +} + +async function save(client, guildId, state) { await client.db.set(key(guildId), state); } + +function profile(state, userId) { + state.profiles[userId] ||= { mmr: 1000, wins: 0, losses: 0, streak: 0, matches: 0, history: [] }; + return state.profiles[userId]; +} +function modeOf(mode) { return PVP_MODES[mode]; } +function partyOf(state, userId) { return Object.values(state.parties).find(p => p.members?.includes(userId)); } +function queued(state, userId) { return Object.values(state.queues).some(q => (q || []).some(e => e.members?.includes(userId))); } +function activeMatch(state, userId) { return Object.values(state.matches).find(m => m.status !== 'finished' && m.players?.includes(userId)); } +function nameOf(guild, id) { return guild.members.cache.get(id)?.displayName || guild.members.cache.get(id)?.user?.username || `Player ${id.slice(-4)}`; } + +function combos(entries, target, start = 0, chosen = [], total = 0) { + if (total === target) return [chosen]; + if (total > target) return []; + for (let i = start; i < entries.length; i++) { + const result = combos(entries, target, i + 1, [...chosen, entries[i]], total + entries[i].members.length); + if (result.length) return result; + } + return []; +} + +function findMatch(entries, slots) { + const usable = entries.filter(e => e.members.length <= slots); + for (const first of usable) { + const teamA = combos(usable, slots).find(c => c.includes(first)); + if (!teamA) continue; + const used = new Set(teamA.map(x => x.groupId)); + const remaining = usable.filter(x => !used.has(x.groupId)); + const teamB = combos(remaining, slots)[0]; + if (teamB) return { teamA, teamB }; + } + return null; +} + +export function buildPanel(config = {}) { + const embed = new EmbedBuilder() + .setTitle('🔴 Anon Matchmaking Queue') + .setDescription('From the dropdown below **select Anon Matchmaking Queue Mode** and choose your queue mode to enter the **KOS Matchmaking queue!**') + .setColor(0x8b0000) + .setFooter({ text: 'Miracle LA | PvP Matchmaking' }); + if (config.bannerUrl) embed.setImage(config.bannerUrl); + + const menu = new StringSelectMenuBuilder() + .setCustomId('pvp_queue') + .setPlaceholder('Select Anon Matchmaking Queue Mode') + .addOptions(Object.entries(PVP_MODES).map(([value, mode]) => ({ + label: mode.label, + description: mode.description, + value, + emoji: mode.emoji, + }))); + return { embeds: [embed], components: [new ActionRowBuilder().addComponents(menu)] }; +} + +function acceptRows(matchId, guildId) { + return [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`pvp_accept:${guildId}:${matchId}`).setLabel('Accept Match').setStyle(ButtonStyle.Success).setEmoji('✅'), + new ButtonBuilder().setCustomId(`pvp_decline:${guildId}:${matchId}`).setLabel('Decline').setStyle(ButtonStyle.Danger).setEmoji('❌'), + )]; +} + +function resultRows(matchId) { + return [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`pvp_result:${matchId}:A`).setLabel('Team A Won').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId(`pvp_result:${matchId}:B`).setLabel('Team B Won').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId(`pvp_cancel:${matchId}`).setLabel('Cancel Match').setStyle(ButtonStyle.Danger), + )]; +} + +function matchEmbed(match, title = '⚔️ MATCH FOUND') { + return new EmbedBuilder() + .setTitle(title) + .setDescription(`**${match.modeLabel}**\n\n🔴 **TEAM A**\n${match.teamA.map((_, i) => `Player ${i + 1}`).join('\n')}\n\n🔵 **TEAM B**\n${match.teamB.map((_, i) => `Player ${i + 1}`).join('\n')}\n\n**Match ID:** ${match.id}\n**Status:** ${match.status === 'accepting' ? '🟡 Waiting for acceptance' : '🟢 Ready'}`) + .setColor(0x8b0000) + .setFooter({ text: 'Miracle LA | PvP Matchmaking' }) + .setTimestamp(); +} + +async function dmMatch(guild, userId, match) { + const user = await guild.client.users.fetch(userId).catch(() => null); + if (!user) return false; + return user.send({ embeds: [matchEmbed(match)], components: acceptRows(match.id, guild.id) }).then(() => true).catch(() => false); +} + +export async function setupGuild(client, guildId, { categoryId, logChannelId, bannerUrl } = {}) { + return lock(guildId, async () => { + const state = await loadGuildState(client, guildId); + state.config = { ...state.config, ...(categoryId !== undefined ? { categoryId } : {}), ...(logChannelId !== undefined ? { logChannelId } : {}), ...(bannerUrl !== undefined ? { bannerUrl } : {}) }; + await save(client, guildId, state); + return state.config; + }); +} + +export async function getConfig(client, guildId) { + const state = await loadGuildState(client, guildId); + return state.config; +} + +export async function joinQueue(client, guild, userId, mode) { + const spec = modeOf(mode); + if (!spec) throw new Error('Invalid matchmaking mode.'); + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + if (queued(state, userId)) throw new Error('You are already in a matchmaking queue.'); + if (activeMatch(state, userId)) throw new Error('You are already in an active match.'); + const party = partyOf(state, userId); + const members = party ? [...party.members] : [userId]; + if (party && party.leaderId !== userId) throw new Error('Only the party leader can queue the party.'); + if (members.length > spec.slots) throw new Error(`Your party has ${members.length} players, but **${spec.short}** only supports ${spec.slots} players per team.`); + for (const id of members) if (queued(state, id) || activeMatch(state, id)) throw new Error('A party member is already queued or in a match.'); + + state.queues[mode] ||= []; + const entry = { groupId: `${mode}-${userId}-${Date.now()}`, leaderId: userId, members, joinedAt: Date.now() }; + state.queues[mode].push(entry); + members.forEach(id => profile(state, id)); + await save(client, guild.id, state); + return { mode: spec, entry, position: state.queues[mode].length }; + }); +} + +export async function leaveQueue(client, guild, userId) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + let removed = null; + for (const [mode, entries] of Object.entries(state.queues)) { + const index = (entries || []).findIndex(e => e.members.includes(userId)); + if (index !== -1) { removed = entries.splice(index, 1); break; } + } + if (!removed) return false; + await save(client, guild.id, state); + return true; + }); +} + +export async function createParty(client, guild, userId) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + if (partyOf(state, userId)) throw new Error('You are already in a party.'); + const id = `party-${userId}-${Date.now()}`; + state.parties[id] = { id, leaderId: userId, members: [userId], createdAt: Date.now(), pending: [] }; + await save(client, guild.id, state); + return state.parties[id]; + }); +} + +export async function inviteParty(client, guild, leaderId, targetId) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + const party = partyOf(state, leaderId); + if (!party || party.leaderId !== leaderId) throw new Error('You must be the party leader.'); + if (party.members.includes(targetId)) throw new Error('That player is already in your party.'); + if (party.members.length >= 4) throw new Error('A party can contain at most 4 players.'); + if (partyOf(state, targetId)) throw new Error('That player is already in a party.'); + state.invites[targetId] ||= []; + if (!state.invites[targetId].includes(party.id)) state.invites[targetId].push(party.id); + await save(client, guild.id, state); + return party; + }); +} + +export async function acceptPartyInvite(client, guild, userId, partyId) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + if (partyOf(state, userId)) throw new Error('You are already in a party.'); + const party = state.parties[partyId]; + if (!party || !(state.invites[userId] || []).includes(partyId)) throw new Error('That party invite is invalid or expired.'); + if (party.members.length >= 4) throw new Error('That party is full.'); + party.members.push(userId); + state.invites[userId] = (state.invites[userId] || []).filter(id => id !== partyId); + await save(client, guild.id, state); + return party; + }); +} + +export async function leaveParty(client, guild, userId) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + const party = partyOf(state, userId); + if (!party) throw new Error('You are not in a party.'); + if (queued(state, userId)) throw new Error('Leave the matchmaking queue before leaving the party.'); + party.members = party.members.filter(id => id !== userId); + if (party.leaderId === userId && party.members.length) party.leaderId = party.members[0]; + if (!party.members.length) delete state.parties[party.id]; + await save(client, guild.id, state); + return true; + }); +} + +export async function getQueueStatus(client, guildId) { + const state = await loadGuildState(client, guildId); + return Object.fromEntries(Object.entries(PVP_MODES).map(([mode, spec]) => [mode, { + ...spec, + groups: (state.queues[mode] || []).length, + players: (state.queues[mode] || []).reduce((n, e) => n + e.members.length, 0), + }])); +} + +async function createMatchRoom(guild, state, match) { + const categoryId = state.config.categoryId; + const category = categoryId ? await guild.channels.fetch(categoryId).catch(() => null) : null; + const overwrites = [ + { id: guild.roles.everyone.id, deny: [PermissionFlagsBits.ViewChannel] }, + { id: guild.client.user.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.ManageChannels, PermissionFlagsBits.EmbedLinks] }, + ...match.players.map(id => ({ id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] })), + ]; + const channel = await guild.channels.create({ + name: `match-${match.id}`.toLowerCase().slice(0, 90), + type: ChannelType.GuildText, + parent: category?.type === ChannelType.GuildCategory ? category.id : undefined, + permissionOverwrites: overwrites, + topic: `${match.modeLabel} | Match ${match.id}`, + }); + match.channelId = channel.id; + return channel; +} + +async function announceMatch(guild, state, match) { + const channel = await createMatchRoom(guild, state, match); + const embed = matchEmbed(match, '⚔️ MATCH READY'); + await channel.send({ content: '🎮 **Your Discord matchmaking match is ready.** Play your FiveM fight, then use the result buttons when finished.', embeds: [embed], components: resultRows(match.id) }); + await save(guild.client, guild.id, state); + if (state.config.logChannelId) { + const log = await guild.channels.fetch(state.config.logChannelId).catch(() => null); + if (log?.isTextBased()) await log.send({ embeds: [new EmbedBuilder().setTitle('🎮 PvP Match Created').setDescription(`Match **${match.id}**\nMode: **${match.modeLabel}**\nPlayers: **${match.players.length}**\nChannel: ${channel}`).setColor(0x8b0000).setTimestamp()] }).catch(() => {}); + } +} + +async function startMatch(guild, state, mode, teamAEntries, teamBEntries) { + const spec = modeOf(mode); + const teamA = teamAEntries.flatMap(e => e.members); + const teamB = teamBEntries.flatMap(e => e.members); + const id = Math.random().toString(36).slice(2, 8).toUpperCase(); + const match = { + id, mode, modeLabel: spec.short, status: 'accepting', + teamA, teamB, teamAGroupData: teamAEntries.map(e => ({ leaderId: e.leaderId, members: [...e.members] })), teamBGroupData: teamBEntries.map(e => ({ leaderId: e.leaderId, members: [...e.members] })), players: [...teamA, ...teamB], + accepted: [], createdAt: Date.now(), expiresAt: Date.now() + ACCEPT_TIMEOUT_MS, + channelId: null, + }; + state.matches[id] = match; + const entries = [...teamAEntries, ...teamBEntries]; + state.queues[mode] = (state.queues[mode] || []).filter(e => !entries.some(x => x.groupId === e.groupId)); + await save(guild.client, guild.id, state); + + for (const userId of match.players) { + const ok = await dmMatch(guild, userId, match); + if (!ok) { + const member = await guild.members.fetch(userId).catch(() => null); + if (member) await member.send({ content: `⚔️ Match **${id}** found in **${spec.short}**. Please use the matchmaking channel/panel to accept.` }).catch(() => {}); + } + } + return match; +} + +export async function processQueues(client) { + for (const guild of client.guilds.cache.values()) { + await lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + let changed = false; + for (const [mode, spec] of Object.entries(PVP_MODES)) { + const entries = state.queues[mode] || []; + if (entries.length < 2) continue; + const found = findMatch(entries, spec.slots); + if (!found) continue; + await startMatch(guild, state, mode, found.teamA, found.teamB); + changed = true; + } + if (changed) await save(client, guild.id, state); + }).catch(error => logger.error(`PvP queue processing failed for ${guild.id}:`, error)); + } +} + +export async function acceptMatch(client, guild, userId, matchId) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + const match = state.matches[matchId]; + if (!match || match.status !== 'accepting') throw new Error('That match is no longer accepting responses.'); + if (!match.players.includes(userId)) throw new Error('You are not part of this match.'); + if (Date.now() > match.expiresAt) throw new Error('The acceptance window has expired.'); + if (!match.accepted.includes(userId)) match.accepted.push(userId); + if (match.accepted.length === match.players.length) { + match.status = 'active'; + match.acceptedAt = Date.now(); + await save(client, guild.id, state); + await announceMatch(guild, state, match); + return { match, complete: true }; + } + await save(client, guild.id, state); + return { match, complete: false }; + }); +} + +async function requeueMatchPlayers(state, match) { + const sourceGroups = [...(match.teamAGroupData || []), ...(match.teamBGroupData || [])]; + const groups = sourceGroups.length + ? sourceGroups.map((group, index) => ({ + groupId: `requeue-${match.id}-${index}-${Date.now()}`, + leaderId: group.leaderId || group.members[0], members: [...group.members], joinedAt: Date.now(), + })) + : [match.teamA, match.teamB].map((members, index) => ({ + groupId: `requeue-${match.id}-${index}-${Date.now()}`, leaderId: members[0], members: [...members], joinedAt: Date.now(), + })); + state.queues[match.mode] ||= []; + state.queues[match.mode].push(...groups); +} + +export async function declineMatch(client, guild, userId, matchId) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + const match = state.matches[matchId]; + if (!match || match.status !== 'accepting') throw new Error('That match is no longer accepting responses.'); + if (!match.players.includes(userId)) throw new Error('You are not part of this match.'); + await requeueMatchPlayers(state, match); + match.status = 'cancelled'; match.cancelledBy = userId; match.cancelReason = 'declined'; match.finishedAt = Date.now(); + await save(client, guild.id, state); + return match; + }); +} + +export async function expireMatches(client) { + for (const guild of client.guilds.cache.values()) { + await lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + let changed = false; + for (const match of Object.values(state.matches)) { + if (match.status === 'accepting' && Date.now() > match.expiresAt) { + await requeueMatchPlayers(state, match); + match.status = 'cancelled'; match.cancelReason = 'timeout'; match.finishedAt = Date.now(); changed = true; + } + } + if (changed) await save(client, guild.id, state); + }).catch(error => logger.error(`PvP expiration failed for ${guild.id}:`, error)); + } +} + +export async function recordResult(client, guild, staffMember, matchId, winningTeam) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + const match = state.matches[matchId]; + if (!match || match.status !== 'active') throw new Error('That match is not active.'); + if (!staffMember.permissions.has(PermissionFlagsBits.ManageGuild)) throw new Error('You need Manage Server permission to record a match result.'); + if (!['A', 'B'].includes(winningTeam)) throw new Error('Invalid winning team.'); + const winners = winningTeam === 'A' ? match.teamA : match.teamB; + const losers = winningTeam === 'A' ? match.teamB : match.teamA; + const winnerAvg = winners.reduce((n, id) => n + profile(state, id).mmr, 0) / winners.length; + const loserAvg = losers.reduce((n, id) => n + profile(state, id).mmr, 0) / losers.length; + const expected = 1 / (1 + 10 ** ((loserAvg - winnerAvg) / 400)); + const delta = Math.max(10, Math.round(32 * (1 - expected))); + for (const id of winners) { + const p = profile(state, id); p.mmr += delta; p.wins++; p.matches++; p.streak = Math.max(1, p.streak + 1); p.history.unshift({ matchId, result: 'W', at: Date.now(), mode: match.mode }); p.history = p.history.slice(0, 25); + } + for (const id of losers) { + const p = profile(state, id); p.mmr = Math.max(0, p.mmr - delta); p.losses++; p.matches++; p.streak = Math.min(0, p.streak - 1); p.history.unshift({ matchId, result: 'L', at: Date.now(), mode: match.mode }); p.history = p.history.slice(0, 25); + } + match.status = 'finished'; match.winner = winningTeam; match.finishedAt = Date.now(); + await save(client, guild.id, state); + const channel = match.channelId ? await guild.channels.fetch(match.channelId).catch(() => null) : null; + if (channel?.isTextBased()) await channel.send({ embeds: [new EmbedBuilder().setTitle('🏆 Match Complete').setDescription(`**${match.id}**\nWinning Team: **${winningTeam}**\nMMR change: **±${delta}**`).setColor(0x8b0000).setTimestamp()] }).catch(() => {}); + return match; + }); +} + +export async function cancelMatch(client, guild, staffMember, matchId) { + return lock(guild.id, async () => { + const state = await loadGuildState(client, guild.id); + const match = state.matches[matchId]; + if (!match || !['accepting', 'active'].includes(match.status)) throw new Error('That match cannot be cancelled.'); + if (!staffMember.permissions.has(PermissionFlagsBits.ManageGuild)) throw new Error('You need Manage Server permission.'); + if (match.status === 'accepting') await requeueMatchPlayers(state, match); + match.status = 'cancelled'; match.cancelReason = 'staff'; match.finishedAt = Date.now(); + await save(client, guild.id, state); + if (match.channelId) { const channel = await guild.channels.fetch(match.channelId).catch(() => null); if (channel) await channel.delete('PvP match cancelled').catch(() => {}); } + return match; + }); +} + +export async function getProfile(client, guildId, userId) { + const state = await loadGuildState(client, guildId); + return profile(state, userId); +} + +export async function getLeaderboard(client, guildId) { + const state = await loadGuildState(client, guildId); + return Object.entries(state.profiles).sort((a, b) => (b[1].mmr || 0) - (a[1].mmr || 0)).slice(0, 10); +} + +export async function getActiveMatch(client, guildId, userId) { + const state = await loadGuildState(client, guildId); + return Object.values(state.matches).find(m => m.status !== 'finished' && m.status !== 'cancelled' && m.players?.includes(userId)) || null; +} + +export function startPvpScheduler(client) { + if (client.__pvpScheduler) return; + client.__pvpScheduler = setInterval(async () => { + await processQueues(client); + await expireMatches(client); + }, MATCH_INTERVAL_MS); + client.__pvpScheduler.unref?.(); + logger.info('PvP matchmaking scheduler started.'); +} + +export const pvpInternals = { nameOf, modeOf, profile };