Dernière activité 1 month ago

nuno a révisé ce gist 1 month ago. Aller à la révision

1 file changed, 267 insertions

poll.ts(fichier créé)

@@ -0,0 +1,267 @@
1 + import {
2 + EmbedBuilder,
3 + ButtonBuilder,
4 + ButtonStyle,
5 + ActionRowBuilder,
6 + TextChannel,
7 + GuildMember,
8 + } from "discord.js";
9 + import { PollState, VoteEntry, Nation, TGSlot } from "../types";
10 + import { cfg } from "./config";
11 + import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis";
12 + import { getActiveCharacter, getCharacterByName } from "./characters";
13 + import { resolveNation } from "./nations";
14 + import { getEntry, getBringer } from "./wrank";
15 + import { nowFormatted } from "./messages";
16 +
17 + // ─── Poll state ───────────────────────────────────────────────────────────────
18 + export const polls: Map<number, PollState> = new Map();
19 +
20 + const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
21 + const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map();
22 +
23 + export 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 + }
28 + export 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 + }
33 + export 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 + }
38 + export 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 + }
43 + export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined {
44 + return publicOverrides.get(userId)?.[voteType];
45 + }
46 + export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined {
47 + return ephemeralOverrides.get(userId)?.[voteType];
48 + }
49 + export function resetPollOverrides(): void {
50 + publicOverrides.clear();
51 + ephemeralOverrides.clear();
52 + }
53 +
54 + // ─── Character display ────────────────────────────────────────────────────────
55 + function 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 (entry.borrowedFrom) {
109 + row += ` ${getEmoji("borrowed") || "🔗"}`;
110 + }
111 +
112 + if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`;
113 +
114 + return row;
115 + }
116 +
117 + // ─── Embed building ───────────────────────────────────────────────────────────
118 + export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder {
119 + const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] };
120 + const noVoters: VoteEntry[] = [];
121 + const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = [];
122 + const showNoInline = (cfg as any)("showNoInNationField") ?? false;
123 +
124 + for (const entry of state.yes.values()) {
125 + const nation = entry.characterNation ?? "Capella";
126 + yesByNation[nation].push(entry);
127 + allMessages.push({ entry, voteType: "yes" });
128 + }
129 + for (const entry of state.no.values()) {
130 + noVoters.push(entry);
131 + allMessages.push({ entry, voteType: "no" });
132 + }
133 +
134 + const capellaEmoji = getEmoji("capella");
135 + const procyonEmoji = getEmoji("procyon");
136 +
137 + const formatNationField = (nation: Nation): string => {
138 + const yesEntries = yesByNation[nation];
139 + const noEntries = showNoInline
140 + ? noVoters.filter((e) => e.characterNation === nation)
141 + : [];
142 + const lines = [
143 + ...yesEntries.map((e) => formatCharRow(e)),
144 + ...noEntries.map((e) => `❌ ${formatCharRow(e)}`),
145 + ];
146 + return lines.length > 0 ? lines.join("\n") : "—";
147 + };
148 +
149 + const formatMessages = (): string => {
150 + if (allMessages.length === 0) return "";
151 + return allMessages
152 + .map((m) => {
153 + const name = m.entry.characterName ?? m.entry.displayName;
154 + const prefix = m.voteType === "no" ? "✗ " : "✓ ";
155 + const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : "";
156 + return `${prefix}${name} · ${m.entry.votedAt}${msg}`;
157 + })
158 + .join("\n");
159 + };
160 +
161 + const locked = state.locked;
162 + const confirmed = state.confirmed;
163 +
164 + const color =
165 + confirmed === "yes" ? 0x57f287 :
166 + confirmed === "no" ? 0xed4245 :
167 + locked ? 0x888888 :
168 + 0xe8a317;
169 +
170 + // Title with nation + no counts (hidden when confirmed or locked)
171 + const counts = !locked && confirmed === null
172 + ? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}`
173 + : "";
174 + const statusSuffix =
175 + locked ? " 🔒" :
176 + confirmed === "yes" ? " ✅" :
177 + confirmed === "no" ? " ❌" : "";
178 +
179 + const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`;
180 +
181 + const embed = new EmbedBuilder()
182 + .setTitle(title)
183 + .setColor(color)
184 + .addFields(
185 + { name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false },
186 + { name: "\u200b", value: "\u200b", inline: false },
187 + { name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false },
188 + )
189 + .setTimestamp();
190 +
191 + const msgSection = formatMessages();
192 + if (msgSection) {
193 + embed.addFields({ name: "\u200b", value: msgSection, inline: false });
194 + }
195 +
196 + let footer: string;
197 + if (confirmed === "yes") footer = cfg("confirmYesMessage");
198 + else if (confirmed === "no") footer = cfg("confirmNoMessage");
199 + else if (locked) footer = overrideLockMsg ?? cfg("lockMessage");
200 + else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
201 + embed.setFooter({ text: footer });
202 +
203 + return embed;
204 + }
205 +
206 + export function buildButtons(disabled: boolean): ActionRowBuilder<ButtonBuilder> {
207 + const yesBtn = new ButtonBuilder()
208 + .setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled);
209 + const noBtn = new ButtonBuilder()
210 + .setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled);
211 + return new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn);
212 + }
213 +
214 + export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise<void> {
215 + const state = polls.get(slot);
216 + if (!state?.messageId) return;
217 + try {
218 + const msg = await channel.messages.fetch(state.messageId);
219 + const disabled = state.locked || state.confirmed !== null;
220 + await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] });
221 + } catch (err) {
222 + console.error("Failed to update poll message:", err);
223 + }
224 + }
225 +
226 + export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
227 + resetPollOverrides();
228 + const { clearSessionBorrows } = require("./borrow");
229 + clearSessionBorrows();
230 +
231 + const state: PollState = {
232 + messageId: null, slot: slot.tgHour,
233 + yes: new Map(), no: new Map(),
234 + locked: false, confirmed: null,
235 + };
236 + polls.set(slot.tgHour, state);
237 + const msg = await channel.send({ embeds: [buildEmbed(state)], components: [buildButtons(false)] });
238 + state.messageId = msg.id;
239 + console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
240 + }
241 +
242 + export function createVoteEntry(
243 + userId: string,
244 + member: GuildMember,
245 + usermapKey: string | null,
246 + discordUsername: string
247 + ): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
248 + const serverNickname = member.nickname ?? null;
249 + const globalNickname = member.user.globalName ?? null;
250 + const displayName = serverNickname ?? globalNickname ?? discordUsername;
251 +
252 + const { getEffectiveCharacter } = require("./borrow");
253 + const { char, borrowedFrom: bf } = usermapKey
254 + ? getEffectiveCharacter(usermapKey)
255 + : { char: null, borrowedFrom: null };
256 + console.log(`[createVoteEntry] usermapKey=${usermapKey} char=${char?.name} borrowedFrom=${bf}`);
257 +
258 + return {
259 + usermapKey: usermapKey ?? (undefined as any),
260 + displayName,
261 + characterName: char?.name,
262 + characterClass: char?.class,
263 + characterLevel: char?.level,
264 + characterNation: char?.nation ?? (resolveNation(member, usermapKey) ?? undefined),
265 + borrowedFrom: bf ?? undefined,
266 + };
267 + }
Plus récent Plus ancien