import { ButtonBuilder, ButtonStyle, ActionRowBuilder, EmbedBuilder, ButtonInteraction, TextChannel, } from "discord.js"; import { cfg } from "@systems/config"; import { getCharacters, setActiveCharacter } from "@systems/characters"; import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow"; import { getImpersonation } from "@systems/impersonate"; import { polls, updatePollMessage } from "@systems/poll"; import { resolveMessage, nowFormatted } from "@systems/messages"; import { Emoji } from "@systems/emojis"; import { format } from "@systems/format"; import { Character } from "@types"; import { buildCharSelectButtons } from "@systems/charSelect"; // ─── Config ─────────────────────────────────────────────────────────────────── const RECLAIM_STYLE = ButtonStyle.Secondary; const SWITCH_STYLE = ButtonStyle.Secondary; const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false"; const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false"; // ─── State ──────────────────────────────────────────────────────────────────── const pendingConflicts = new Map(); // ─── Helpers ────────────────────────────────────────────────────────────────── function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder { const emojiStr = Emoji.class(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): EmbedBuilder { return new EmbedBuilder() .setTitle("⚠️ Character Conflict") .setDescription( `**${format.char(char)}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.` ) .setColor(0xe8a317); } function buildConflictButtons( ownerKey: string, borrowerKey: string, borrowedCharName: string, ownerId: string, allChars: Character[], page: number ): ActionRowBuilder[] { const PAGE_SIZE = 4; 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: Reclaim button const reclaimId = `conflict_reclaim:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}`; pendingConflicts.set(reclaimId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page }); const borrowed = allChars.find((c) => c.name === borrowedCharName); const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE); if (borrowed) { reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`); const emojiStr = Emoji.class(borrowed.class); const emoji = format.emoji(emojiStr); if (emoji) reclaimBtn.setEmoji(emoji as any); } else { reclaimBtn.setLabel(`Reclaim ${borrowedCharName}`); } rows.push(new ActionRowBuilder().addComponents(reclaimBtn)); // Row 2: Switch buttons const charButtons = pageChars.map((char) => { const id = `conflict_switch:${ownerKey}:${borrowerKey}:${char.name}:${ownerId}`; pendingConflicts.set(id, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page }); return applyCharToButton(new ButtonBuilder().setCustomId(id).setStyle(SWITCH_STYLE), char); }); if (charButtons.length > 0) { rows.push(new ActionRowBuilder().addComponents(...charButtons)); } // Row 3: Pagination const navButtons: ButtonBuilder[] = []; if (hasPrev) { const prevId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page - 1}`; pendingConflicts.set(prevId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page - 1 }); navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary)); } if (hasMore) { const nextId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page + 1}`; pendingConflicts.set(nextId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page + 1 }); navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary)); } if (navButtons.length > 0) { rows.push(new ActionRowBuilder().addComponents(...navButtons)); } return rows; } // ─── Public API ─────────────────────────────────────────────────────────────── export async function showConflictEmbed( interaction: ButtonInteraction, ownerKey: string, borrowerKey: string, borrowedChar: Character, allOwnerChars: Character[] ): Promise { const embed = buildConflictEmbed(borrowerKey, borrowedChar); const buttons = buildConflictButtons(ownerKey, borrowerKey, borrowedChar.name, interaction.user.id, allOwnerChars, 0); await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true }); } export async function handleConflictButton(interaction: ButtonInteraction): Promise { console.log("[conflict] button received:", interaction.customId); const { customId } = interaction; // ── Pagination ────────────────────────────────────────────────────────────── 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); const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page); await interaction.update({ embeds: [embed], components: buttons }); return; } // ── Switch to another char ────────────────────────────────────────────────── 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]; setActiveCharacter(ownerKey, newCharName); clearSessionBorrowForUser(ownerKey); const impersonating = getImpersonation(ownerId); const voteId = impersonating ? `impersonated:${impersonating}` : ownerId; const slot = [...polls.keys()][0]; const state = slot !== undefined ? polls.get(slot) : null; if (state && AUTO_VOTE_ON_SWITCH) { 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(voteId, { 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!); } const newChar = getCharacters(ownerKey).find((c) => c.name === newCharName); const charDisplay = newChar ? format.char(newChar) : newCharName; await interaction.update({ embeds: [new EmbedBuilder() .setTitle("🔄 Switched") .setDescription(`${charDisplay}${AUTO_VOTE_ON_SWITCH ? " — voted Yes." : ""}`) .setColor(0x57f287)], components: [], }); return; } // ── Reclaim ───────────────────────────────────────────────────────────────── if (customId.startsWith("conflict_reclaim:")) { console.log("[reclaim] handler triggered"); 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"; const slot = [...polls.keys()][0]; const state = slot !== undefined ? polls.get(slot) : null; let borrowerDiscordId: string | undefined; let borrowerVoteType: "yes" | "no" = "yes"; // default to yes if (state) { for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { const isYes = state.yes.has(id); if (entry.userKey === borrowerKey) { borrowerVoteType = isYes ? "yes" : "no"; // Capture borrower's Discord ID for notification borrowerDiscordId = (entry as any).discordId; if (reclaimBehavior === "remove") { state.yes.delete(id); state.no.delete(id); } else { // Clear borrow so getEffectiveCharacter returns own char clearSessionBorrowForUser(borrowerKey); clearPersistentPreference(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 { state.yes.delete(id); state.no.delete(id); } } break; } } // Owner joins with their reclaimed char const guild = interaction.guild!; const member = await guild.members.fetch(ownerId); const impersonating = getImpersonation(ownerId); const voteId = impersonating ? `impersonated:${impersonating}` : 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(voteId, { 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!); console.log("[reclaim notify] borrowerDiscordId:", borrowerDiscordId, "notify:", RECLAIM_NOTIFY_BORROWER); // Notify borrower if enabled and we have their Discord ID if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) { try { const borrowerMember = await guild.members.fetch(borrowerDiscordId); const btns = buildCharSelectButtons(borrowerKey, { customIdPrefix: `switch_after_reclaim:${borrowerKey}`, excludeCharName: charName, appendToCustomId: `:${borrowerVoteType}`, }); console.log("[reclaim notify] btns length:", btns.length); console.log("[reclaim notify] btns:", JSON.stringify(btns.map(r => r.toJSON()))); await borrowerMember.send({ content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. Pick another character:`, components: btns.length > 0 ? btns : [], }); } catch { // DM may be disabled — silently ignore } } } const borrowed = getCharacters(ownerKey).find((c) => c.name === charName); const charDisplay = borrowed ? format.char(borrowed) : charName; await interaction.update({ embeds: [new EmbedBuilder() .setTitle("↩️ Reclaimed") .setDescription(`${charDisplay} reclaimed from **${borrowerKey}**.`) .setColor(0x57f287)], components: [], }); return; } }