最后活跃于 1 month ago

nuno 修订了这个 Gist 1 month ago. 转到此修订

1 file changed, 321 insertions

gistfile1.txt(文件已创建)

@@ -0,0 +1,321 @@
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 + import { clearSessionBorrows } from "@systems/borrow";
19 + import { clearAllImpersonations } from "@systems/impersonate";
20 +
21 + // ─── Poll state ───────────────────────────────────────────────────────────────
22 + export const polls: Map<number, PollState> = new Map();
23 +
24 + const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
25 + const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map();
26 +
27 + export 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 + }
32 + export 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 + }
37 + export 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 + }
42 + export 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 + }
47 + export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined {
48 + return publicOverrides.get(userId)?.[voteType];
49 + }
50 + export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined {
51 + return ephemeralOverrides.get(userId)?.[voteType];
52 + }
53 + export function resetPollOverrides(): void {
54 + publicOverrides.clear();
55 + ephemeralOverrides.clear();
56 + }
57 +
58 + export 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 ────────────────────────────────────────────────────────
75 + function 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 +
90 + function 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 +
98 + function 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 ───────────────────────────────────────────────────────────
147 + export 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 +
236 + export 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 +
257 + export 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 +
276 + export 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 +
296 + export 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 + }
上一页 下一页