Utoljára aktív 1 month ago

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

1 file changed, 271 insertions

buttons.ts(fájl létrehozva)

@@ -0,0 +1,271 @@
1 + import {
2 + ButtonInteraction,
3 + StringSelectMenuBuilder,
4 + StringSelectMenuOptionBuilder,
5 + ActionRowBuilder,
6 + TextChannel
7 + } from "discord.js";
8 + import { cfg } from "@systems/config";
9 + import { pollReplyAndDelete } from "../utils";
10 + import { resolveUser } from "@systems/users";
11 + import { resolveMessage, nowFormatted } from "@systems/messages";
12 + import { resolveNation } from "@systems/nations";
13 + import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
14 + import { showConflictEmbed } from "@systems/conflict";
15 + import { getCharacters } from "@systems/characters";
16 + import { getImpersonation } from "@systems/impersonate";
17 + import { format } from "@format";
18 + import { Character } from "@src/types";
19 + import { modals } from "@handlers/modals";
20 +
21 + const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
22 +
23 + const clickCounts = new Map<string, { yes: number; no: number }>();
24 +
25 + // ─── Helpers ──────────────────────────────────────────────────────────────────
26 +
27 + function 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 +
42 + function isCharacterOwner(userKey: string | null, charName: string): boolean {
43 + if (!userKey) return false;
44 + return getCharacters(userKey).some((c) => c.name === charName);
45 + }
46 +
47 + async 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 +
83 + export 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 +
202 + export function resetClickCounts(): void {
203 + clickCounts.clear();
204 + }
205 +
206 +
207 + // ─── Score submission button handler ──────────────────────────────────────────────────────
208 +
209 + export 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 + // }
Újabb Régebbi