Остання активність 1 month ago

nuno ревизій цього gist 1 month ago. До ревизії

1 file changed, 294 insertions

poll.ts(файл створено)

@@ -0,0 +1,294 @@
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 + import { persist } from "@systems/pollPersistence"
18 +
19 + // ─── Poll state ───────────────────────────────────────────────────────────────
20 + export const polls: Map<number, PollState> = new Map();
21 +
22 + const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
23 + const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map();
24 +
25 + export 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 + }
30 + export 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 + }
35 + export 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 + }
40 + export 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 + }
45 + export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined {
46 + return publicOverrides.get(userId)?.[voteType];
47 + }
48 + export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined {
49 + return ephemeralOverrides.get(userId)?.[voteType];
50 + }
51 + export function resetPollOverrides(): void {
52 + publicOverrides.clear();
53 + ephemeralOverrides.clear();
54 + }
55 +
56 + export 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 ────────────────────────────────────────────────────────
73 + function 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 ───────────────────────────────────────────────────────────
122 + export 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 +
210 + export 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 +
231 + export 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 +
250 + export 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 +
269 + export 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 + }
Новіше Пізніше