Utoljára aktív 1 month ago

nuno gist felülvizsgálása 1 month ago. Revízióhoz ugrás

1 file changed, 305 insertions

poll.ts(fájl létrehozva)

@@ -0,0 +1,305 @@
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 "@src/types";
10 + import { cfg } from "@systems/config";
11 + import { getEmoji, getClassEmoji, getNationEmoji } from "@systems/emojis";
12 + import { getActiveCharacter, getCharacterByName } from "@systems/characters";
13 + import { resolveNation } from "@systems/nations";
14 + import { getEntry, getBringer } from "@systems/wrank";
15 + import { nowFormatted } from "@systems/messages";
16 + import { format } from "@format";
17 +
18 + // ─── Poll state ───────────────────────────────────────────────────────────────
19 + export const polls: Map<number, PollState> = new Map();
20 +
21 + const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
22 + const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map();
23 +
24 + export 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 + }
29 + export 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 + }
34 + export 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 + }
39 + export 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 + }
44 + export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined {
45 + return publicOverrides.get(userId)?.[voteType];
46 + }
47 + export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined {
48 + return ephemeralOverrides.get(userId)?.[voteType];
49 + }
50 + export function resetPollOverrides(): void {
51 + publicOverrides.clear();
52 + ephemeralOverrides.clear();
53 + }
54 +
55 + export 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 ────────────────────────────────────────────────────────
70 + function 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 ───────────────────────────────────────────────────────────
133 + export 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 +
221 + export 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 +
242 + export 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 +
261 + export 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 +
280 + export 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 + }
Újabb Régebbi