Última atividade 1 month ago

poll.ts Bruto
1import {
2 EmbedBuilder,
3 ButtonBuilder,
4 ButtonStyle,
5 ActionRowBuilder,
6 TextChannel,
7 GuildMember,
8} from "discord.js";
9import { PollState, VoteEntry, Nation, TGSlot } from "../types";
10import { cfg } from "./config";
11import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis";
12import { getActiveCharacter } from "./characters";
13import { resolveNation } from "./nations";
14import { getEntry, getBringer } from "./wrank";
15import { nowFormatted } from "./messages";
16
17// ─── Poll state ───────────────────────────────────────────────────────────────
18export const polls: Map<number, PollState> = new Map();
19
20const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
21const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map();
22
23export function setPublicOverride(userId: string, voteType: "yes" | "no", message: string): void {
24 const e = publicOverrides.get(userId) ?? {};
25 e[voteType] = message;
26 publicOverrides.set(userId, e);
27}
28export function clearPublicOverride(userId: string, voteType?: "yes" | "no"): void {
29 if (!voteType) { publicOverrides.delete(userId); return; }
30 const e = publicOverrides.get(userId);
31 if (e) delete e[voteType];
32}
33export function setEphemeralOverride(userId: string, voteType: "yes" | "no", message: string): void {
34 const e = ephemeralOverrides.get(userId) ?? {};
35 e[voteType] = message;
36 ephemeralOverrides.set(userId, e);
37}
38export function clearEphemeralOverride(userId: string, voteType?: "yes" | "no"): void {
39 if (!voteType) { ephemeralOverrides.delete(userId); return; }
40 const e = ephemeralOverrides.get(userId);
41 if (e) delete e[voteType];
42}
43export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined {
44 return publicOverrides.get(userId)?.[voteType];
45}
46export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined {
47 return ephemeralOverrides.get(userId)?.[voteType];
48}
49export function resetPollOverrides(): void {
50 publicOverrides.clear();
51 ephemeralOverrides.clear();
52}
53
54// ─── Character display ────────────────────────────────────────────────────────
55function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
56 const format = cfg("charDisplayFormat");
57 const nation = entry.characterNation;
58 const wRankEntry = entry.usermapKey ? getEntry(entry.usermapKey, nation ?? "Capella") : null;
59
60 let wrank = "";
61 if (wRankEntry) {
62 const goal = cfg("wRankGoal");
63 const isDone = wRankEntry.tgCount >= goal;
64 const rank = wRankEntry.currentRank;
65 const prev = wRankEntry.previousRank;
66 const delta = prev !== undefined ? rank - prev : 0;
67 // W.Rank emoji with text fallback
68 const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`;
69 const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`);
70
71 // Delta arrows with text fallback
72 let deltaStr = "";
73 if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`;
74 else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`;
75 else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`;
76
77 wrank = `${rankStr}${deltaStr}`;
78 }
79
80 const classStr = entry.characterClass
81 ? (getClassEmoji(entry.characterClass) || entry.characterClass)
82 : "";
83
84 const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any)
85 ? `${entry.characterLevel}`
86 : "";
87
88 let row = format
89 .replace("{wrank}", wrank)
90 .replace("{class}", classStr)
91 .replace("{level}", levelStr)
92 .replace("{name}", entry.characterName ?? entry.displayName)
93 .replace(/\s+/g, " ")
94 .trim();
95
96 // Bringer title — independent of W.Rank so override always shows
97 if (nation && entry.usermapKey) {
98 const bringer = getBringer(nation);
99 if (bringer && bringer === entry.usermapKey) {
100 const emoji = nation === "Capella"
101 ? (getEmoji("luminous_bringer") || "🔆")
102 : (getEmoji("storm_bringer") || "⚡");
103 const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer";
104 row += ` · ${emoji} **${title}**`;
105 }
106 }
107
108 if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`;
109
110 return row;
111}
112
113// ─── Embed building ───────────────────────────────────────────────────────────
114export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder {
115 const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] };
116 const noVoters: VoteEntry[] = [];
117 const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
118 const showNoInline = (cfg as any)("showNoInNationField") ?? false;
119
120 for (const entry of state.yes.values()) {
121 const nation = entry.characterNation ?? "Capella";
122 yesByNation[nation].push(entry);
123 allMessages.push({ entry, voteType: "yes" });
124 }
125 for (const entry of state.no.values()) {
126 noVoters.push(entry);
127 allMessages.push({ entry, voteType: "no" });
128 }
129
130 const capellaEmoji = getEmoji("capella");
131 const procyonEmoji = getEmoji("procyon");
132
133 const formatNationField = (nation: Nation): string => {
134 const yesEntries = yesByNation[nation];
135 const noEntries = showNoInline
136 ? noVoters.filter((e) => e.characterNation === nation)
137 : [];
138 const lines = [
139 ...yesEntries.map((e) => formatCharRow(e)),
140 ...noEntries.map((e) => `${formatCharRow(e)}`),
141 ];
142 return lines.length > 0 ? lines.join("\n") : "—";
143 };
144
145 const formatMessages = (): string => {
146 if (allMessages.length === 0) return "";
147 return allMessages
148 .map((m) => {
149 const name = m.entry.characterName ?? m.entry.displayName;
150 const prefix = m.voteType === "no" ? "✗ " : "✓ ";
151 const msg = m.entry.publicMessage ? `${m.entry.publicMessage}` : "";
152 return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
153 })
154 .join("\n");
155 };
156
157 const locked = state.locked;
158 const confirmed = state.confirmed;
159
160 const color =
161 confirmed === "yes" ? 0x57f287 :
162 confirmed === "no" ? 0xed4245 :
163 locked ? 0x888888 :
164 0xe8a317;
165
166 // Title with nation + no counts (hidden when confirmed or locked)
167 const counts = !locked && confirmed === null
168 ? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}`
169 : "";
170 const statusSuffix =
171 locked ? " 🔒" :
172 confirmed === "yes" ? " ✅" :
173 confirmed === "no" ? " ❌" : "";
174
175 const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
176
177 const embed = new EmbedBuilder()
178 .setTitle(title)
179 .setColor(color)
180 .addFields(
181 { name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false },
182 { name: "\u200b", value: "\u200b", inline: false },
183 { name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false },
184 )
185 .setTimestamp();
186
187 const msgSection = formatMessages();
188 if (msgSection) {
189 embed.addFields({ name: "\u200b", value: msgSection, inline: false });
190 }
191
192 let footer: string;
193 if (confirmed === "yes") footer = cfg("confirmYesMessage");
194 else if (confirmed === "no") footer = cfg("confirmNoMessage");
195 else if (locked) footer = overrideLockMsg ?? cfg("lockMessage");
196 else footer = `${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
197 embed.setFooter({ text: footer });
198
199 return embed;
200}
201
202export function buildButtons(disabled: boolean): ActionRowBuilder<ButtonBuilder> {
203 const yesBtn = new ButtonBuilder()
204 .setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled);
205 const noBtn = new ButtonBuilder()
206 .setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled);
207 return new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn);
208}
209
210export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise<void> {
211 const state = polls.get(slot);
212 if (!state?.messageId) return;
213 try {
214 const msg = await channel.messages.fetch(state.messageId);
215 const disabled = state.locked || state.confirmed !== null;
216 await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] });
217 } catch (err) {
218 console.error("Failed to update poll message:", err);
219 }
220}
221
222export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
223 resetPollOverrides();
224 const state: PollState = {
225 messageId: null, slot: slot.tgHour,
226 yes: new Map(), no: new Map(),
227 locked: false, confirmed: null,
228 };
229 polls.set(slot.tgHour, state);
230 const msg = await channel.send({ embeds: [buildEmbed(state)], components: [buildButtons(false)] });
231 state.messageId = msg.id;
232 console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
233}
234
235export function createVoteEntry(
236 userId: string,
237 member: GuildMember,
238 usermapKey: string | null,
239 discordUsername: string
240): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
241 const serverNickname = member.nickname ?? null;
242 const globalNickname = member.user.globalName ?? null;
243 const displayName = serverNickname ?? globalNickname ?? discordUsername;
244 const nation = resolveNation(member, usermapKey);
245 const char = usermapKey ? getActiveCharacter(usermapKey) : null;
246 return {
247 usermapKey: usermapKey ?? (undefined as any),
248 displayName,
249 characterName: char?.name,
250 characterClass: char?.class,
251 characterLevel: char?.level,
252 characterNation: nation ?? undefined,
253 };
254}