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

Версія 54b1c9a0177fcc83ab052be784d3148413bff60f

buttons.ts Неформатований
1import {
2 ButtonInteraction,
3 StringSelectMenuBuilder,
4 StringSelectMenuOptionBuilder,
5 ActionRowBuilder,
6 TextChannel
7} from "discord.js";
8import { cfg } from "@systems/config";
9import { pollReplyAndDelete } from "../utils";
10import { resolveUser } from "@systems/users";
11import { resolveMessage, nowFormatted } from "@systems/messages";
12import { resolveNation } from "@systems/nations";
13import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
14import { showConflictEmbed } from "@systems/conflict";
15import { getCharacters } from "@systems/characters";
16import { getImpersonation } from "@systems/impersonate";
17import { format } from "@format";
18import { Character } from "@src/types";
19import { modals } from "@handlers/modals";
20
21const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
22
23const clickCounts = new Map<string, { yes: number; no: number }>();
24
25// ─── Helpers ──────────────────────────────────────────────────────────────────
26
27function isCharacterInPoll(
28 state: ReturnType<typeof polls.get>,
29 charName: string,
30 excludeVoteId: string,
31 excludeUserKey: string = ""
32): { found: boolean; entryUserKey: string | null; borrowedFrom: string | undefined } {
33 if (!state) return { found: false, entryUserKey: null, borrowedFrom: undefined };
34 for (const [otherId, entry] of state.yes.entries()) {
35 if (otherId !== excludeVoteId && entry.userKey !== excludeUserKey && entry.characterName === charName) {
36 return { found: true, entryUserKey: entry.userKey ?? null, borrowedFrom: entry.borrowedFrom };
37 }
38 }
39 return { found: false, entryUserKey: null, borrowedFrom: undefined };
40}
41
42function isCharacterOwner(userKey: string | null, charName: string): boolean {
43 if (!userKey) return false;
44 return getCharacters(userKey).some((c) => c.name === charName);
45}
46
47async function handleCharacterConflict(
48 interaction: ButtonInteraction,
49 userKey: string | null,
50 char: Character,
51 entryUserKey: string | null,
52 clicks: { yes: number; no: number },
53 votedYes: boolean
54): Promise<boolean> {
55 // Decrement click since we're blocking this vote
56 if (votedYes) clicks.yes -= 1;
57 else clicks.no -= 1;
58
59 const isOwner = isCharacterOwner(userKey, char.name);
60
61 if (isOwner && userKey) {
62 const allChars = getCharacters(userKey);
63 const borrowedChar = allChars.find((c) => c.name === char.name);
64 if (borrowedChar && entryUserKey) {
65 await showConflictEmbed(interaction, userKey, entryUserKey, borrowedChar, allChars);
66 return true;
67 }
68 }
69
70 const slot = [...polls.keys()][0];
71 const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20;
72
73 await interaction.followUp({
74 content: `${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
75 // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`,
76 ephemeral: true
77 });
78 return true;
79}
80
81// ─── Main button handler ──────────────────────────────────────────────────────
82
83export async function handleButton(interaction: ButtonInteraction): Promise<void> {
84 if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
85
86 try {
87 await interaction.deferUpdate();
88 } catch {
89 return;
90 }
91
92 const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
93 if (slot === undefined) return;
94
95 const state = polls.get(slot)!;
96 if (state.locked || state.confirmed !== null) return;
97
98 const userId = interaction.user.id;
99 const member = interaction.guild!.members.cache.get(userId)
100 ?? await interaction.guild!.members.fetch(userId);
101 const user = await resolveUser(member);
102 const votedYes = interaction.customId === "tg_yes";
103 const now = nowFormatted();
104
105 const impersonating = getImpersonation(userId);
106 const voteId = impersonating ? `impersonated:${impersonating}` : userId;
107 const lookupUsername = user.lookupUsername ?? user.discordUsername;
108
109 // Nation check
110 const nation = resolveNation(member, user.userKey);
111 if (!nation) {
112 const capella = format.nation("Capella");
113 const procyon = format.nation("Procyon");
114 await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
115 return;
116 }
117
118 // Click tracking
119 if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
120 const clicks = clickCounts.get(voteId)!;
121
122 if (votedYes && clicks.yes >= LOCK_AT) return;
123 if (!votedYes && clicks.no >= LOCK_AT) return;
124
125 // Ignore same vote
126 if (votedYes && state.yes.has(voteId)) return;
127 if (!votedYes && state.no.has(voteId)) return;
128
129 // Increment click (may be decremented in conflict handler)
130 if (votedYes) clicks.yes += 1;
131 else clicks.no += 1;
132
133 const clickCount = votedYes ? clicks.yes : clicks.no;
134
135 // Resolve messages
136 const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
137 ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
138
139 const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
140 ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
141
142 const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername);
143
144 // Character conflict check — applies to both Yes and No
145 if (baseEntry.characterName) {
146 const conflictChar = {
147 name: baseEntry.characterName!,
148 class: baseEntry.characterClass!,
149 level: baseEntry.characterLevel!,
150 nation: baseEntry.characterNation!,
151 active: false, // not needed for display
152 };
153
154 const { found, entryUserKey, borrowedFrom } = isCharacterInPoll(
155 state, baseEntry.characterName, voteId, user.userKey ?? ""
156 );
157 if (found) {
158 await handleCharacterConflict(
159 interaction, user.userKey, conflictChar,
160 entryUserKey, clicks, votedYes
161 );
162 return;
163 }
164 }
165
166 // Register vote
167 if (votedYes) {
168 const previousNo = state.no.get(voteId);
169 state.no.delete(voteId);
170 state.yes.set(voteId, {
171 ...baseEntry,
172 discordId: userId,
173 votedAt: now,
174 previousNoAt: previousNo?.votedAt,
175 publicMessage: publicMsg ?? undefined,
176 });
177 } else {
178 const previousYes = state.yes.get(voteId);
179 state.yes.delete(voteId);
180 state.no.set(voteId, {
181 ...baseEntry,
182 votedAt: now,
183 discordId: userId,
184 previousYesAt: previousYes?.votedAt,
185 publicMessage: publicMsg ?? undefined,
186 });
187 }
188
189 const locked = clickCount >= LOCK_AT;
190 if (locked) state.locked = true;
191
192 const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
193 const msgContent = ephemeralMsg
194 ? `${ephemeralMsg}${lockedSuffix}`
195 : locked ? "🔒 You've been locked in." : null;
196 await pollReplyAndDelete(interaction, msgContent);
197
198 const channel = interaction.channel as TextChannel;
199 await updatePollMessage(channel, slot);
200}
201
202export function resetClickCounts(): void {
203 clickCounts.clear();
204}
205
206
207// ─── Score submission button handler ──────────────────────────────────────────────────────
208
209export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise<void> {
210 const member = await interaction.guild!.members.fetch(interaction.user.id);
211 const user = await resolveUser(member);
212 if (!user.userKey) {
213 await interaction.reply({ content: "❌ You are not registered in the system.", ephemeral: true });
214 return;
215 }
216
217 const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
218 const state = slot !== undefined ? polls.get(slot) : null;
219
220 if (!state?.lockedYesKeys?.has(user.userKey)) {
221 await interaction.reply({ content: "❌ You weren't in this TG.", ephemeral: true });
222 return;
223 }
224
225 // Slot is known from the poll — go straight to modal, no select needed
226 await interaction.showModal(modals.buildScoreModal(user.userKey, slot!));
227}
228
229// export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise<void> {
230// await interaction.deferReply({ ephemeral: true });
231
232// const member = await interaction.guild!.members.fetch(interaction.user.id);
233// const user = await resolveUser(member);
234// if (!user.userKey) {
235// await interaction.editReply("❌ You are not registered in the system.");
236// return;
237// }
238
239// // Find the poll this message belongs to
240// const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
241// const state = slot !== undefined ? polls.get(slot) : null;
242
243// // Enforce: only players who were locked in at TG start can submit
244// if (!state?.lockedYesKeys?.has(user.userKey)) {
245// await interaction.editReply("❌ You weren't in this TG.");
246// return;
247// }
248
249// // Build slot selector — all valid slots, with the active TG pre-selected
250// const validSlots = cfg("slots").map((s) => s.tgHour) as number[];
251// const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
252
253// const select = new StringSelectMenuBuilder()
254// .setCustomId(`score_slot_select:${user.userKey}`)
255// .setPlaceholder("Select TG slot")
256// .addOptions(
257// validSlots.map((h) =>
258// new StringSelectMenuOptionBuilder()
259// .setLabel(`${String(h).padStart(2, "0")}:00 TG`)
260// .setValue(String(h))
261// .setDefault(h === activeSlot)
262// )
263// );
264
265// const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
266
267// await interaction.editReply({
268// content: "Which TG are you submitting for?",
269// components: [row],
270// });
271// }