Última actividad 1 month ago

Revisión 581f358143346e6c1f323ef38bd1b9fbf5093507

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