Last active 1 month ago

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