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