Ultima attività 1 month ago

poll.ts Raw
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, getCharacterByName } 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
54export function lockPoll(slot: number): void {
55 const state = polls.get(slot);
56 if (!state) return;
57 state.locked = true;
58
59 // Snapshot the userKeys that were in yes at lock time
60 state.lockedYesKeys = new Set(
61 [...state.yes.values()]
62 .map((e) => e.userKey)
63 .filter((k): k is string => !!k)
64 );
65}
66
67
68// ─── Character display ────────────────────────────────────────────────────────
69function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
70 const format = cfg("charDisplayFormat");
71 const nation = entry.characterNation;
72 const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null;
73
74 let wrank = "";
75 if (wRankEntry) {
76 const goal = cfg("wRankGoal");
77 const isDone = wRankEntry.tgCount >= goal;
78 const rank = wRankEntry.currentRank;
79 const prev = wRankEntry.previousRank;
80 const delta = prev !== undefined ? rank - prev : 0;
81 // W.Rank emoji with text fallback
82 const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`;
83 const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`);
84
85 // Delta arrows with text fallback
86 let deltaStr = "";
87 if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`;
88 else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`;
89 else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`;
90
91 wrank = `${rankStr}${deltaStr}`;
92 }
93
94 const classStr = entry.characterClass
95 ? (getClassEmoji(entry.characterClass) || entry.characterClass)
96 : "";
97
98 const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any)
99 ? `${entry.characterLevel}`
100 : "";
101
102 let row = format
103 .replace("{wrank}", wrank)
104 .replace("{class}", classStr)
105 .replace("{level}", levelStr)
106 .replace("{name}", entry.characterName ?? entry.displayName)
107 .replace(/\s+/g, " ")
108 .trim();
109
110 // Bringer title — independent of W.Rank so override always shows
111 if (nation && entry.userKey) {
112 const bringer = getBringer(nation);
113 if (bringer && bringer === entry.characterName) {
114 const emoji = nation === "Capella"
115 ? (getEmoji("luminous_bringer") || "🔆")
116 : (getEmoji("storm_bringer") || "⚡");
117 const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer";
118 row += ` · ${emoji} **${title}**`;
119 }
120 }
121
122 if (entry.borrowedFrom) {
123 row += ` ${getEmoji("borrowed") || "🔗"}`;
124 }
125
126 if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`;
127
128 return row;
129}
130
131// ─── Embed building ───────────────────────────────────────────────────────────
132export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder {
133 const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] };
134 const noVoters: VoteEntry[] = [];
135 const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
136 const showNoInline = (cfg as any)("showNoInNationField") ?? false;
137
138 for (const entry of state.yes.values()) {
139 const nation = entry.characterNation ?? "Capella";
140 yesByNation[nation].push(entry);
141 allMessages.push({ entry, voteType: "yes" });
142 }
143 for (const entry of state.no.values()) {
144 noVoters.push(entry);
145 allMessages.push({ entry, voteType: "no" });
146 }
147
148 const capellaEmoji = getEmoji("capella");
149 const procyonEmoji = getEmoji("procyon");
150
151 const formatNationField = (nation: Nation): string => {
152 const yesEntries = yesByNation[nation];
153 const noEntries = showNoInline
154 ? noVoters.filter((e) => e.characterNation === nation)
155 : [];
156 const lines = [
157 ...yesEntries.map((e) => formatCharRow(e)),
158 ...noEntries.map((e) => `${formatCharRow(e)}`),
159 ];
160 return lines.length > 0 ? lines.join("\n") : "—";
161 };
162
163 const formatMessages = (): string => {
164 if (allMessages.length === 0) return "";
165 return allMessages
166 .map((m) => {
167 const name = m.entry.characterName ?? m.entry.displayName;
168 const prefix = m.voteType === "no" ? "✗ " : "✓ ";
169 const msg = m.entry.publicMessage ? `${m.entry.publicMessage}` : "";
170 return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
171 })
172 .join("\n");
173 };
174
175 const locked = state.locked;
176 const confirmed = state.confirmed;
177
178 const color =
179 confirmed === "yes" ? 0x57f287 :
180 confirmed === "no" ? 0xed4245 :
181 locked ? 0x888888 :
182 0xe8a317;
183
184 // Title with nation + no counts (hidden when confirmed or locked)
185 const counts = !locked && confirmed === null
186 ? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}`
187 : "";
188 const statusSuffix =
189 locked ? " 🔒" :
190 confirmed === "yes" ? " ✅" :
191 confirmed === "no" ? " ❌" : "";
192
193 const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
194
195 const embed = new EmbedBuilder()
196 .setTitle(title)
197 .setColor(color)
198 .addFields(
199 { name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false },
200 { name: "\u200b", value: "\u200b", inline: false },
201 { name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false },
202 )
203 .setTimestamp();
204
205 const msgSection = formatMessages();
206 if (msgSection) {
207 embed.addFields({ name: "\u200b", value: msgSection, inline: false });
208 }
209
210 let footer: string;
211 if (confirmed === "yes") footer = cfg("confirmYesMessage");
212 else if (confirmed === "no") footer = cfg("confirmNoMessage");
213 else if (locked) footer = overrideLockMsg ?? cfg("lockMessage");
214 else footer = `${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
215 embed.setFooter({ text: footer });
216
217 return embed;
218}
219
220export function buildButtons(
221 disabled: boolean,
222 showSubmit?: boolean
223): ActionRowBuilder<ButtonBuilder>[] {
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
229 const voteRow = new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn);
230
231 if (!showSubmit) return [voteRow];
232
233 const submitBtn = new ButtonBuilder()
234 .setCustomId("tg_score_submit")
235 .setLabel("📊 Submit Score")
236 .setStyle(ButtonStyle.Primary);
237
238 const submitRow = new ActionRowBuilder<ButtonBuilder>().addComponents(submitBtn);
239 return [voteRow, submitRow];
240}
241
242export async function updatePollMessage(
243 channel: TextChannel,
244 slot: number,
245 overrideLockMsg?: string,
246 showSubmit?: boolean
247): Promise<void> {
248 const state = polls.get(slot);
249 if (!state?.messageId) return;
250 try {
251 const msg = await channel.messages.fetch(state.messageId);
252 const disabled = state.locked || state.confirmed !== null;
253 await msg.edit({
254 embeds: [buildEmbed(state, overrideLockMsg)],
255 components: buildButtons(disabled, showSubmit), // now returns array
256 });
257 } catch (err) {
258 console.error("Failed to update poll message:", err);
259 }
260}
261
262export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
263 resetPollOverrides();
264 const { clearSessionBorrows } = require("@systems/borrow");
265 const { clearAllImpersonations } = require("@systems/impersonate");
266
267 clearSessionBorrows();
268 clearAllImpersonations();
269
270 const state: PollState = {
271 messageId: null, slot: slot.tgHour,
272 yes: new Map(), no: new Map(),
273 locked: false, confirmed: null,
274 };
275 polls.set(slot.tgHour, state);
276 const msg = await channel.send({ embeds: [buildEmbed(state)], components: buildButtons(false) });
277 state.messageId = msg.id;
278 console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
279}
280
281export function createVoteEntry(
282 userId: string,
283 member: GuildMember,
284 userKey: string | null,
285 discordUsername: string
286): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
287 const serverNickname = member.nickname ?? null;
288 const globalNickname = member.user.globalName ?? null;
289 const displayName = serverNickname ?? globalNickname ?? discordUsername;
290
291 const { getEffectiveCharacter } = require("./borrow");
292 const { char, borrowedFrom: bf } = userKey
293 ? getEffectiveCharacter(userKey)
294 : { char: null, borrowedFrom: null };
295 console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`);
296
297 return {
298 userKey: userKey ?? (undefined as any),
299 displayName,
300 characterName: char?.name,
301 characterClass: char?.class,
302 characterLevel: char?.level,
303 characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined),
304 borrowedFrom: bf ?? undefined,
305 };
306}