Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions PVP_MATCHMAKING_SETUP.md
Original file line number Diff line number Diff line change
@@ -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:<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:<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.
112 changes: 112 additions & 0 deletions src/commands/PvP/pvp.js
Original file line number Diff line number Diff line change
@@ -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.'}`);
}
},
};
8 changes: 5 additions & 3 deletions src/config/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
},
],
Expand Down Expand Up @@ -457,6 +457,7 @@ export const botConfig = {

// Community engagement systems.
tickets: true,
pvpMatchmaking: true,
giveaways: true,
birthday: true,
counter: true,
Expand Down Expand Up @@ -544,6 +545,7 @@ const COMMAND_CATEGORY_FEATURE_MAP = {
search: "search",
serverstats: "counter",
ticket: "tickets",
pvp: "pvpMatchmaking",
tools: "tools",
utility: "utility",
verification: "verification",
Expand Down Expand Up @@ -648,4 +650,4 @@ export function getRandomColor() {
return colors[Math.floor(Math.random() * colors.length)];
}

export default botConfig;
export default botConfig;
3 changes: 3 additions & 0 deletions src/events/ready.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`
Expand Down
60 changes: 60 additions & 0 deletions src/interactions/buttons/pvp/pvpMatch.js
Original file line number Diff line number Diff line change
@@ -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 });
}
},
},
];
10 changes: 10 additions & 0 deletions src/interactions/buttons/pvp/pvpQueueLeave.js
Original file line number Diff line number Diff line change
@@ -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 });
},
};
19 changes: 19 additions & 0 deletions src/interactions/selectMenus/pvp/pvpQueue.js
Original file line number Diff line number Diff line change
@@ -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 });
}
},
};
Loading