import { Client, ButtonBuilder, ButtonStyle, ActionRowBuilder, EmbedBuilder, ButtonInteraction, TextChannel, } from "discord.js"; // Configurable button styles const RECLAIM_STYLE = ButtonStyle.Secondary; // gray — change to ButtonStyle.Danger for red const SWITCH_STYLE = ButtonStyle.Secondary; import { cfg } from "@systems/config"; import { getCharacters, getCharacterByName, setActiveCharacter } from "@systems/characters"; import { clearSessionBorrowForUser, setPersistentPreference, getEffectiveCharacter } from "@systems/borrow"; import { polls, updatePollMessage, createVoteEntry } from "@systems/poll"; import { resolveMessage, nowFormatted } from "@systems/messages"; import { getClassEmoji } from "@systems/emojis"; import { Character } from "@src/types"; import { format } from "@format"; const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false"; // Stores pending conflict resolutions: buttonId → { ownerUsermapKey, borrowerUsermapKey, charName, ownerId } const pendingConflicts = new Map(); function formatChar(char: Character): string { const emoji = getClassEmoji(char.class) || char.class; return `${emoji} ${char.level} ${char.name}`; } // Parse <:name:id> or unicode emoji string for use with ButtonBuilder.setEmoji() function parseEmoji(emojiStr: string): { name: string; id: string } | string | null { if (!emojiStr) return null; const match = emojiStr.match(/^<:(\w+):(\d+)>$/); if (match) return { name: match[1], id: match[2] }; return emojiStr; // unicode fallback } // For button labels — emoji via setEmoji(), text only in label function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder { const emojiStr = getClassEmoji(char.class); const emoji = format.emoji(emojiStr); btn.setLabel(`${char.level} ${char.name}`); if (emoji) btn.setEmoji(emoji as any); return btn; } function buildConflictEmbed( borrowerKey: string, char: Character, ownerKey: string ): EmbedBuilder { const charDisplay = formatChar(char); return new EmbedBuilder() .setTitle("⚠️ Character Conflict") .setDescription( `**${charDisplay}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.` ) .setColor(0xe8a317); } function buildConflictButtons( ownerUsermapKey: string, borrowerUsermapKey: string, borrowedCharName: string, ownerId: string, allChars: Character[], page: number ): ActionRowBuilder[] { const PAGE_SIZE = 4; // leave 1 slot for reclaim on first row const otherChars = allChars.filter((c) => c.name !== borrowedCharName); const pageChars = otherChars.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); const hasMore = otherChars.length > (page + 1) * PAGE_SIZE; const hasPrev = page > 0; const rows: ActionRowBuilder[] = []; // Row 1: char switch buttons const charButtons = pageChars.map((char) => { const id = `conflict_switch:${ownerUsermapKey}:${borrowerUsermapKey}:${char.name}:${ownerId}`; pendingConflicts.set(id, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page }); return applyCharToButton( new ButtonBuilder().setCustomId(id).setStyle(ButtonStyle.Secondary), char ); }); if (charButtons.length > 0) { rows.push(new ActionRowBuilder().addComponents(...charButtons)); } // Row 2: reclaim + pagination const reclaimId = `conflict_reclaim:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}`; pendingConflicts.set(reclaimId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page }); const borrowed = allChars.find((c) => c.name === borrowedCharName); const reclaimBtn = borrowed ? applyCharToButton( new ButtonBuilder().setCustomId(reclaimId).setStyle(ButtonStyle.Danger), borrowed ).setLabel(`${borrowed.level} ${borrowed.name}`) : new ButtonBuilder().setCustomId(reclaimId).setLabel(`↩️ Reclaim ${borrowedCharName}`).setStyle(RECLAIM_STYLE); const navButtons: ButtonBuilder[] = [reclaimBtn]; if (hasPrev) { const prevId = `conflict_page:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}:${page - 1}`; pendingConflicts.set(prevId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page: page - 1 }); navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary)); } if (hasMore) { const nextId = `conflict_page:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}:${page + 1}`; pendingConflicts.set(nextId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page: page + 1 }); navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary)); } rows.push(new ActionRowBuilder().addComponents(...navButtons)); return rows; } // Show conflict embed to owner export async function showConflictEmbed( interaction: ButtonInteraction, ownerUsermapKey: string, borrowerUsermapKey: string, borrowedChar: Character, allOwnerChars: Character[] ): Promise { const embed = buildConflictEmbed(borrowerUsermapKey, borrowedChar, ownerUsermapKey); const buttons = buildConflictButtons( ownerUsermapKey, borrowerUsermapKey, borrowedChar.name, interaction.user.id, allOwnerChars, 0 ); await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true }); } // Handle conflict button interactions export async function handleConflictButton(interaction: ButtonInteraction): Promise { const { customId } = interaction; if (customId.startsWith("conflict_page:")) { const parts = customId.split(":"); const ownerKey = parts[1]; const borrowerKey = parts[2]; const charName = parts[3]; const ownerId = parts[4]; const page = parseInt(parts[5]); const allChars = getCharacters(ownerKey); const borrowed = allChars.find((c) => c.name === charName); if (!borrowed) return void interaction.reply({ content: "❌ Character not found.", ephemeral: true }); const embed = buildConflictEmbed(borrowerKey, borrowed, ownerKey); const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page); await interaction.update({ embeds: [embed], components: buttons }); return; } if (customId.startsWith("conflict_switch:")) { const parts = customId.split(":"); const ownerKey = parts[1]; const borrowerKey = parts[2]; const newCharName = parts[3]; const ownerId = parts[4]; // Switch owner to the selected char setActiveCharacter(ownerKey, newCharName); clearSessionBorrowForUser(ownerKey); const slot = [...polls.keys()][0]; const state = slot !== undefined ? polls.get(slot) : null; if (state && AUTO_VOTE_ON_SWITCH) { // Auto-vote Yes for owner with new char const guild = interaction.guild!; const member = await guild.members.fetch(ownerId); const { char } = getEffectiveCharacter(ownerKey); const now = nowFormatted(); const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null); state.yes.set(ownerId, { userKey: ownerKey, displayName: member.nickname ?? member.user.globalName ?? member.user.username, characterName: char?.name, characterClass: char?.class, characterLevel: char?.level, characterNation: char?.nation, votedAt: now, publicMessage: publicMsg ?? undefined, }); const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot!); } await interaction.update({ embeds: [new EmbedBuilder() .setTitle("✅ Switched") .setDescription(`Switched to **${newCharName}**${AUTO_VOTE_ON_SWITCH ? " and voted Yes." : "."}`) .setColor(0x57f287)], components: [], }); return; } if (customId.startsWith("conflict_reclaim:")) { const parts = customId.split(":"); const ownerKey = parts[1]; const borrowerKey = parts[2]; const charName = parts[3]; const ownerId = parts[4]; const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert"; // "revert" | "remove" const slot = [...polls.keys()][0]; const state = slot !== undefined ? polls.get(slot) : null; if (state) { // Find borrower's vote entry for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { if (entry.userKey === borrowerKey) { if (reclaimBehavior === "remove") { state.yes.delete(id); state.no.delete(id); } else { // Revert borrower to their own active char clearSessionBorrowForUser(borrowerKey); const { char: ownChar } = getEffectiveCharacter(borrowerKey); if (ownChar) { entry.characterName = ownChar.name; entry.characterClass = ownChar.class; entry.characterLevel = ownChar.level; entry.characterNation = ownChar.nation; entry.borrowedFrom = undefined; } else { // No own char — remove from poll state.yes.delete(id); state.no.delete(id); } } break; } } // Owner joins with their character const guild = interaction.guild!; const member = await guild.members.fetch(ownerId); setActiveCharacter(ownerKey, charName); clearSessionBorrowForUser(ownerKey); const { char } = getEffectiveCharacter(ownerKey); const now = nowFormatted(); const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null); state.yes.set(ownerId, { userKey: ownerKey, displayName: member.nickname ?? member.user.globalName ?? member.user.username, characterName: char?.name, characterClass: char?.class, characterLevel: char?.level, characterNation: char?.nation, votedAt: now, publicMessage: publicMsg ?? undefined, }); const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot!); } await interaction.update({ embeds: [new EmbedBuilder() .setTitle("↩️ Reclaimed") .setDescription(`**${charName}** has been reclaimed from **${borrowerKey}** and you've been added to the poll.`) .setColor(0x57f287)], components: [], }); return; } }