import { EmbedBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, TextChannel, GuildMember, } from "discord.js"; import { PollState, VoteEntry, Nation, TGSlot } from "../types"; import { cfg } from "./config"; import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis"; import { getActiveCharacter, getCharacterByName } from "./characters"; import { resolveNation } from "./nations"; import { getEntry, getBringer } from "./wrank"; import { nowFormatted } from "./messages"; // ─── Poll state ─────────────────────────────────────────────────────────────── export const polls: Map = new Map(); const publicOverrides: Map = new Map(); const ephemeralOverrides: Map = new Map(); export function setPublicOverride(userId: string, voteType: "yes" | "no", message: string): void { const e = publicOverrides.get(userId) ?? {}; e[voteType] = message; publicOverrides.set(userId, e); } export function clearPublicOverride(userId: string, voteType?: "yes" | "no"): void { if (!voteType) { publicOverrides.delete(userId); return; } const e = publicOverrides.get(userId); if (e) delete e[voteType]; } export function setEphemeralOverride(userId: string, voteType: "yes" | "no", message: string): void { const e = ephemeralOverrides.get(userId) ?? {}; e[voteType] = message; ephemeralOverrides.set(userId, e); } export function clearEphemeralOverride(userId: string, voteType?: "yes" | "no"): void { if (!voteType) { ephemeralOverrides.delete(userId); return; } const e = ephemeralOverrides.get(userId); if (e) delete e[voteType]; } export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined { return publicOverrides.get(userId)?.[voteType]; } export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined { return ephemeralOverrides.get(userId)?.[voteType]; } export function resetPollOverrides(): void { publicOverrides.clear(); ephemeralOverrides.clear(); } export function lockPoll(slot: number): void { const state = polls.get(slot); if (!state) return; state.locked = true; // Snapshot the userKeys that were in yes at lock time state.lockedYesKeys = new Set( [...state.yes.values()] .map((e) => e.userKey) .filter((k): k is string => !!k) ); } // ─── Character display ──────────────────────────────────────────────────────── function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { const format = cfg("charDisplayFormat"); const nation = entry.characterNation; const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null; let wrank = ""; if (wRankEntry) { const goal = cfg("wRankGoal"); const isDone = wRankEntry.tgCount >= goal; const rank = wRankEntry.currentRank; const prev = wRankEntry.previousRank; const delta = prev !== undefined ? rank - prev : 0; // W.Rank emoji with text fallback const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`; const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`); // Delta arrows with text fallback let deltaStr = ""; if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`; else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`; else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`; wrank = `${rankStr}${deltaStr}`; } const classStr = entry.characterClass ? (getClassEmoji(entry.characterClass) || entry.characterClass) : ""; const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any) ? `${entry.characterLevel}` : ""; let row = format .replace("{wrank}", wrank) .replace("{class}", classStr) .replace("{level}", levelStr) .replace("{name}", entry.characterName ?? entry.displayName) .replace(/\s+/g, " ") .trim(); // Bringer title — independent of W.Rank so override always shows if (nation && entry.userKey) { const bringer = getBringer(nation); if (bringer && bringer === entry.characterName) { const emoji = nation === "Capella" ? (getEmoji("luminous_bringer") || "🔆") : (getEmoji("storm_bringer") || "⚡"); const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"; row += ` · ${emoji} **${title}**`; } } if (entry.borrowedFrom) { row += ` ${getEmoji("borrowed") || "🔗"}`; } if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`; return row; } // ─── Embed building ─────────────────────────────────────────────────────────── export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder { const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] }; const noVoters: VoteEntry[] = []; const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; const showNoInline = (cfg as any)("showNoInNationField") ?? false; for (const entry of state.yes.values()) { const nation = entry.characterNation ?? "Capella"; yesByNation[nation].push(entry); allMessages.push({ entry, voteType: "yes" }); } for (const entry of state.no.values()) { noVoters.push(entry); allMessages.push({ entry, voteType: "no" }); } const capellaEmoji = getEmoji("capella"); const procyonEmoji = getEmoji("procyon"); const formatNationField = (nation: Nation): string => { const yesEntries = yesByNation[nation]; const noEntries = showNoInline ? noVoters.filter((e) => e.characterNation === nation) : []; const lines = [ ...yesEntries.map((e) => formatCharRow(e)), ...noEntries.map((e) => `❌ ${formatCharRow(e)}`), ]; return lines.length > 0 ? lines.join("\n") : "—"; }; const formatMessages = (): string => { if (allMessages.length === 0) return ""; return allMessages .map((m) => { const name = m.entry.characterName ?? m.entry.displayName; const prefix = m.voteType === "no" ? "✗ " : "✓ "; const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : ""; return `${prefix}${name} · ${m.entry.votedAt}${msg}`; }) .join("\n"); }; const locked = state.locked; const confirmed = state.confirmed; const color = confirmed === "yes" ? 0x57f287 : confirmed === "no" ? 0xed4245 : locked ? 0x888888 : 0xe8a317; // Title with nation + no counts (hidden when confirmed or locked) const counts = !locked && confirmed === null ? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}` : ""; const statusSuffix = locked ? " 🔒" : confirmed === "yes" ? " ✅" : confirmed === "no" ? " ❌" : ""; const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; const embed = new EmbedBuilder() .setTitle(title) .setColor(color) .addFields( { name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false }, { name: "\u200b", value: "\u200b", inline: false }, { name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false }, ) .setTimestamp(); const msgSection = formatMessages(); if (msgSection) { embed.addFields({ name: "\u200b", value: msgSection, inline: false }); } let footer: string; if (confirmed === "yes") footer = cfg("confirmYesMessage"); else if (confirmed === "no") footer = cfg("confirmNoMessage"); else if (locked) footer = overrideLockMsg ?? cfg("lockMessage"); else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`; embed.setFooter({ text: footer }); return embed; } export function buildButtons( disabled: boolean, showSubmit?: boolean ): ActionRowBuilder[] { const yesBtn = new ButtonBuilder() .setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled); const noBtn = new ButtonBuilder() .setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled); const voteRow = new ActionRowBuilder().addComponents(yesBtn, noBtn); if (!showSubmit) return [voteRow]; const submitBtn = new ButtonBuilder() .setCustomId("tg_score_submit") .setLabel("📊 Submit Score") .setStyle(ButtonStyle.Primary); const submitRow = new ActionRowBuilder().addComponents(submitBtn); return [voteRow, submitRow]; } export async function updatePollMessage( channel: TextChannel, slot: number, overrideLockMsg?: string, showSubmit?: boolean ): Promise { const state = polls.get(slot); if (!state?.messageId) return; try { const msg = await channel.messages.fetch(state.messageId); const disabled = state.locked || state.confirmed !== null; await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: buildButtons(disabled, showSubmit), // now returns array }); } catch (err) { console.error("Failed to update poll message:", err); } } export async function postPoll(channel: TextChannel, slot: TGSlot): Promise { resetPollOverrides(); const { clearSessionBorrows } = require("@systems/borrow"); const { clearAllImpersonations } = require("@systems/impersonate"); clearSessionBorrows(); clearAllImpersonations(); const state: PollState = { messageId: null, slot: slot.tgHour, yes: new Map(), no: new Map(), locked: false, confirmed: null, }; polls.set(slot.tgHour, state); const msg = await channel.send({ embeds: [buildEmbed(state)], components: buildButtons(false) }); state.messageId = msg.id; console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`); } export function createVoteEntry( userId: string, member: GuildMember, userKey: string | null, discordUsername: string ): Omit { const serverNickname = member.nickname ?? null; const globalNickname = member.user.globalName ?? null; const displayName = serverNickname ?? globalNickname ?? discordUsername; const { getEffectiveCharacter } = require("./borrow"); const { char, borrowedFrom: bf } = userKey ? getEffectiveCharacter(userKey) : { char: null, borrowedFrom: null }; console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`); return { userKey: userKey ?? (undefined as any), displayName, characterName: char?.name, characterClass: char?.class, characterLevel: char?.level, characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined), borrowedFrom: bf ?? undefined, }; }