Utoljára aktív 1 month ago

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

1 file changed, 284 insertions

gistfile1.txt(fájl létrehozva)

@@ -0,0 +1,284 @@
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 { persist } from "@systems/pollPersistence"
15 + import { showConflictEmbed } from "@systems/conflict";
16 + import { getCharacters } from "@systems/characters";
17 + import { getImpersonation } from "@systems/impersonate";
18 + import { format } from "@format";
19 + import { Character } from "@src/types";
20 + import { modals } from "@handlers/modals";
21 +
22 + const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
23 +
24 + const clickCounts = new Map<string, { yes: number; no: number }>();
25 +
26 + // ─── Helpers ──────────────────────────────────────────────────────────────────
27 +
28 + function 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 +
43 + function isCharacterOwner(userKey: string | null, charName: string): boolean {
44 + if (!userKey) return false;
45 + return getCharacters(userKey).some((c) => c.name === charName);
46 + }
47 +
48 + async 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 +
95 + export 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 +
215 + export function resetClickCounts(): void {
216 + clickCounts.clear();
217 + }
218 +
219 +
220 + // ─── Score submission button handler ──────────────────────────────────────────────────────
221 +
222 + export 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 + // }
Újabb Régebbi