Last active 1 month ago

Revision 938e658d897c4a7b076e0836b04753eb24a6d5a6

buttons.ts Raw
1import { ButtonInteraction, TextChannel } from "discord.js";
2import { cfg } from "@systems/config";
3import { pollReplyAndDelete } from "../utils";
4import { resolveUser } from "@systems/users";
5import { resolveMessage, nowFormatted } from "@systems/messages";
6import { resolveNation } from "@systems/nations";
7import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
8import { showConflictEmbed } from "@systems/conflict";
9import { getCharacters } from "@systems/characters";
10import { getImpersonation } from "@systems/impersonate";
11import { format } from "@format";
12import { Character } from "@src/types";
13
14const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
15
16const clickCounts = new Map<string, { yes: number; no: number }>();
17
18// ─── Helpers ──────────────────────────────────────────────────────────────────
19
20function isCharacterInPoll(
21 state: ReturnType<typeof polls.get>,
22 charName: string,
23 excludeVoteId: string,
24 excludeUserKey: string = ""
25): { found: boolean; entryUserKey: string | null; borrowedFrom: string | undefined } {
26 if (!state) return { found: false, entryUserKey: null, borrowedFrom: undefined };
27 for (const [otherId, entry] of state.yes.entries()) {
28 if (otherId !== excludeVoteId && entry.userKey !== excludeUserKey && entry.characterName === charName) {
29 return { found: true, entryUserKey: entry.userKey ?? null, borrowedFrom: entry.borrowedFrom };
30 }
31 }
32 return { found: false, entryUserKey: null, borrowedFrom: undefined };
33}
34
35function isCharacterOwner(userKey: string | null, charName: string): boolean {
36 if (!userKey) return false;
37 return getCharacters(userKey).some((c) => c.name === charName);
38}
39
40async function handleCharacterConflict(
41 interaction: ButtonInteraction,
42 userKey: string | null,
43 char: Character,
44 entryUserKey: string | null,
45 clicks: { yes: number; no: number },
46 votedYes: boolean
47): Promise<boolean> {
48 // Decrement click since we're blocking this vote
49 if (votedYes) clicks.yes -= 1;
50 else clicks.no -= 1;
51
52 const isOwner = isCharacterOwner(userKey, char.name);
53
54 if (isOwner && userKey) {
55 const allChars = getCharacters(userKey);
56 const borrowedChar = allChars.find((c) => c.name === char.name);
57 if (borrowedChar && entryUserKey) {
58 await showConflictEmbed(interaction, userKey, entryUserKey, borrowedChar, allChars);
59 return true;
60 }
61 }
62
63 const slot = [...polls.keys()][0];
64 const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20;
65
66 await interaction.followUp({
67 content: `${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
68 // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`,
69 ephemeral: true
70 });
71 return true;
72}
73
74// ─── Main button handler ──────────────────────────────────────────────────────
75
76export async function handleButton(interaction: ButtonInteraction): Promise<void> {
77 if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
78
79 try {
80 await interaction.deferUpdate();
81 } catch {
82 return;
83 }
84
85 const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
86 if (slot === undefined) return;
87
88 const state = polls.get(slot)!;
89 if (state.locked || state.confirmed !== null) return;
90
91 const userId = interaction.user.id;
92 const member = interaction.guild!.members.cache.get(userId)
93 ?? await interaction.guild!.members.fetch(userId);
94 const user = await resolveUser(member);
95 const votedYes = interaction.customId === "tg_yes";
96 const now = nowFormatted();
97
98 const impersonating = getImpersonation(userId);
99 const voteId = impersonating ? `impersonated:${impersonating}` : userId;
100 const lookupUsername = user.lookupUsername ?? user.discordUsername;
101
102 // Nation check
103 const nation = resolveNation(member, user.userKey);
104 if (!nation) {
105 const capella = format.nation("Capella");
106 const procyon = format.nation("Procyon");
107 await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
108 return;
109 }
110
111 // Click tracking
112 if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
113 const clicks = clickCounts.get(voteId)!;
114
115 if (votedYes && clicks.yes >= LOCK_AT) return;
116 if (!votedYes && clicks.no >= LOCK_AT) return;
117
118 // Ignore same vote
119 if (votedYes && state.yes.has(voteId)) return;
120 if (!votedYes && state.no.has(voteId)) return;
121
122 // Increment click (may be decremented in conflict handler)
123 if (votedYes) clicks.yes += 1;
124 else clicks.no += 1;
125
126 const clickCount = votedYes ? clicks.yes : clicks.no;
127
128 // Resolve messages
129 const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
130 ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
131
132 const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
133 ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
134
135 const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername);
136
137 // Character conflict check — applies to both Yes and No
138 if (baseEntry.characterName) {
139 const conflictChar = {
140 name: baseEntry.characterName!,
141 class: baseEntry.characterClass!,
142 level: baseEntry.characterLevel!,
143 nation: baseEntry.characterNation!,
144 active: false, // not needed for display
145 };
146
147 const { found, entryUserKey, borrowedFrom } = isCharacterInPoll(
148 state, baseEntry.characterName, voteId, user.userKey ?? ""
149 );
150 if (found) {
151 await handleCharacterConflict(
152 interaction, user.userKey, conflictChar,
153 entryUserKey, clicks, votedYes
154 );
155 return;
156 }
157 }
158
159 // Register vote
160 if (votedYes) {
161 const previousNo = state.no.get(voteId);
162 state.no.delete(voteId);
163 state.yes.set(voteId, {
164 ...baseEntry,
165 discordId: userId,
166 votedAt: now,
167 previousNoAt: previousNo?.votedAt,
168 publicMessage: publicMsg ?? undefined,
169 });
170 } else {
171 const previousYes = state.yes.get(voteId);
172 state.yes.delete(voteId);
173 state.no.set(voteId, {
174 ...baseEntry,
175 votedAt: now,
176 discordId: userId,
177 previousYesAt: previousYes?.votedAt,
178 publicMessage: publicMsg ?? undefined,
179 });
180 }
181
182 const locked = clickCount >= LOCK_AT;
183 if (locked) state.locked = true;
184
185 const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
186 const msgContent = ephemeralMsg
187 ? `${ephemeralMsg}${lockedSuffix}`
188 : locked ? "🔒 You've been locked in." : null;
189 await pollReplyAndDelete(interaction, msgContent);
190
191 const channel = interaction.channel as TextChannel;
192 await updatePollMessage(channel, slot);
193}
194
195export function resetClickCounts(): void {
196 clickCounts.clear();
197}