Последняя активность 4 weeks ago

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