import { ButtonInteraction, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, ActionRowBuilder, TextChannel } from "discord.js"; import { cfg } from "@systems/config"; import { pollReplyAndDelete } from "../utils"; import { resolveUser } from "@systems/users"; import { resolveMessage, nowFormatted } from "@systems/messages"; import { resolveNation } from "@systems/nations"; import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll"; import { persist } from "@systems/pollPersistence" import { showConflictEmbed } from "@systems/conflict"; import { getCharacters } from "@systems/characters"; import { getImpersonation } from "@systems/impersonate"; import { format } from "@format"; import { Character } from "@src/types"; import { modals } from "@handlers/modals"; const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); const clickCounts = new Map(); // ─── Helpers ────────────────────────────────────────────────────────────────── function isCharacterInPoll( state: ReturnType, charName: string, excludeVoteId: string, excludeUserKey: string = "" ): { found: boolean; entryUserKey: string | null; borrowedFrom: string | undefined } { if (!state) return { found: false, entryUserKey: null, borrowedFrom: undefined }; for (const [otherId, entry] of state.yes.entries()) { if (otherId !== excludeVoteId && entry.userKey !== excludeUserKey && entry.characterName === charName) { return { found: true, entryUserKey: entry.userKey ?? null, borrowedFrom: entry.borrowedFrom }; } } return { found: false, entryUserKey: null, borrowedFrom: undefined }; } function isCharacterOwner(userKey: string | null, charName: string): boolean { if (!userKey) return false; return getCharacters(userKey).some((c) => c.name === charName); } async function handleCharacterConflict( interaction: ButtonInteraction, userKey: string | null, char: Character, entryUserKey: string | null, clicks: { yes: number; no: number }, votedYes: boolean ): Promise { // Decrement click since we're blocking this vote if (votedYes) clicks.yes -= 1; else clicks.no -= 1; const isOwner = isCharacterOwner(userKey, char.name); if (isOwner && userKey) { const allChars = getCharacters(userKey); const borrowedChar = allChars.find((c) => c.name === char.name); if (borrowedChar && entryUserKey) { await showConflictEmbed(interaction, userKey, entryUserKey, borrowedChar, allChars); return true; } } const slot = [...polls.keys()][0]; const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20; // await interaction.followUp({ // content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, // // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`, // ephemeral: true // }); const { buildCharSelectButtons } = require("@systems/charSelect"); const buttons = buildCharSelectButtons(userKey ?? "", { customIdPrefix: `switch_after_reclaim:${userKey}`, excludeCharName: char.name, appendToCustomId: ":yes", }); await interaction.followUp({ content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, components: buttons, ephemeral: true, }); return true; } // ─── Main button handler ────────────────────────────────────────────────────── export async function handleButton(interaction: ButtonInteraction): Promise { if (!["tg_yes", "tg_no"].includes(interaction.customId)) return; try { await interaction.deferUpdate(); } catch { return; } const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; if (slot === undefined) return; const state = polls.get(slot)!; if (state.locked || state.confirmed !== null) return; const userId = interaction.user.id; const member = interaction.guild!.members.cache.get(userId) ?? await interaction.guild!.members.fetch(userId); const user = await resolveUser(member); const votedYes = interaction.customId === "tg_yes"; const now = nowFormatted(); const impersonating = getImpersonation(userId); const voteId = impersonating ? `impersonated:${impersonating}` : userId; const lookupUsername = user.lookupUsername ?? user.discordUsername; // Nation check const nation = resolveNation(member, user.userKey); if (!nation) { const capella = format.nation("Capella"); const procyon = format.nation("Procyon"); await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true }); return; } // Click tracking if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 }); const clicks = clickCounts.get(voteId)!; if (votedYes && clicks.yes >= LOCK_AT) return; if (!votedYes && clicks.no >= LOCK_AT) return; // Ignore same vote if (votedYes && state.yes.has(voteId)) return; if (!votedYes && state.no.has(voteId)) return; // Increment click (may be decremented in conflict handler) if (votedYes) clicks.yes += 1; else clicks.no += 1; const clickCount = votedYes ? clicks.yes : clicks.no; // Resolve messages const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no") ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no") ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername); // Character conflict check — applies to both Yes and No if (baseEntry.characterName) { const conflictChar = { name: baseEntry.characterName!, class: baseEntry.characterClass!, level: baseEntry.characterLevel!, nation: baseEntry.characterNation!, active: false, // not needed for display }; const { found, entryUserKey, borrowedFrom } = isCharacterInPoll( state, baseEntry.characterName, voteId, user.userKey ?? "" ); if (found) { await handleCharacterConflict( interaction, user.userKey, conflictChar, entryUserKey, clicks, votedYes ); return; } } // Register vote if (votedYes) { const previousNo = state.no.get(voteId); state.no.delete(voteId); state.yes.set(voteId, { ...baseEntry, discordId: userId, votedAt: now, previousNoAt: previousNo?.votedAt, publicMessage: publicMsg ?? undefined, }); } else { const previousYes = state.yes.get(voteId); state.yes.delete(voteId); state.no.set(voteId, { ...baseEntry, votedAt: now, discordId: userId, previousYesAt: previousYes?.votedAt, publicMessage: publicMsg ?? undefined, }); } const locked = clickCount >= LOCK_AT; if (locked) state.locked = true; persist.save(polls); const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; const msgContent = ephemeralMsg ? `${ephemeralMsg}${lockedSuffix}` : locked ? "🔒 You've been locked in." : null; await pollReplyAndDelete(interaction, msgContent); const channel = interaction.channel as TextChannel; await updatePollMessage(channel, slot); } export function resetClickCounts(): void { clickCounts.clear(); } // ─── Score submission button handler ────────────────────────────────────────────────────── export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); const user = await resolveUser(member); if (!user.userKey) { await interaction.reply({ content: "❌ You are not registered in the system.", ephemeral: true }); return; } const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; const state = slot !== undefined ? polls.get(slot) : null; if (!state?.lockedYesKeys?.has(user.userKey)) { await interaction.reply({ content: "❌ You weren't in this TG.", ephemeral: true }); return; } // Slot is known from the poll — go straight to modal, no select needed await interaction.showModal(modals.buildScoreModal(user.userKey, slot!)); } // export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise { // await interaction.deferReply({ ephemeral: true }); // const member = await interaction.guild!.members.fetch(interaction.user.id); // const user = await resolveUser(member); // if (!user.userKey) { // await interaction.editReply("❌ You are not registered in the system."); // return; // } // // Find the poll this message belongs to // const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; // const state = slot !== undefined ? polls.get(slot) : null; // // Enforce: only players who were locked in at TG start can submit // if (!state?.lockedYesKeys?.has(user.userKey)) { // await interaction.editReply("❌ You weren't in this TG."); // return; // } // // Build slot selector — all valid slots, with the active TG pre-selected // const validSlots = cfg("slots").map((s) => s.tgHour) as number[]; // const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; // const select = new StringSelectMenuBuilder() // .setCustomId(`score_slot_select:${user.userKey}`) // .setPlaceholder("Select TG slot") // .addOptions( // validSlots.map((h) => // new StringSelectMenuOptionBuilder() // .setLabel(`${String(h).padStart(2, "0")}:00 TG`) // .setValue(String(h)) // .setDefault(h === activeSlot) // ) // ); // const row = new ActionRowBuilder().addComponents(select); // await interaction.editReply({ // content: "Which TG are you submitting for?", // components: [row], // }); // }