Ostatnio aktywny 1 month ago

conflcit.ts Surowy
1import {
2 Client,
3 ButtonBuilder,
4 ButtonStyle,
5 ActionRowBuilder,
6 EmbedBuilder,
7 ButtonInteraction,
8 TextChannel,
9} from "discord.js";
10
11// Configurable button styles
12const RECLAIM_STYLE = ButtonStyle.Secondary; // gray — change to ButtonStyle.Danger for red
13const SWITCH_STYLE = ButtonStyle.Secondary;
14import { cfg } from "@systems/config";
15import { getCharacters, getCharacterByName, setActiveCharacter } from "@systems/characters";
16import { clearSessionBorrowForUser, setPersistentPreference, getEffectiveCharacter } from "@systems/borrow";
17import { polls, updatePollMessage, createVoteEntry } from "@systems/poll";
18import { resolveMessage, nowFormatted } from "@systems/messages";
19import { getClassEmoji } from "@systems/emojis";
20import { Character } from "@src/types";
21import { format } from "@format";
22
23const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false";
24
25// Stores pending conflict resolutions: buttonId → { ownerUsermapKey, borrowerUsermapKey, charName, ownerId }
26const pendingConflicts = new Map<string, {
27 ownerUsermapKey: string;
28 borrowerUsermapKey: string;
29 charName: string;
30 ownerId: string;
31 page: number;
32}>();
33
34function formatChar(char: Character): string {
35 const emoji = getClassEmoji(char.class) || char.class;
36 return `${emoji} ${char.level} ${char.name}`;
37}
38
39// Parse <:name:id> or unicode emoji string for use with ButtonBuilder.setEmoji()
40function parseEmoji(emojiStr: string): { name: string; id: string } | string | null {
41 if (!emojiStr) return null;
42 const match = emojiStr.match(/^<:(\w+):(\d+)>$/);
43 if (match) return { name: match[1], id: match[2] };
44 return emojiStr; // unicode fallback
45}
46
47// For button labels — emoji via setEmoji(), text only in label
48function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder {
49 const emojiStr = getClassEmoji(char.class);
50 const emoji = format.emoji(emojiStr);
51 btn.setLabel(`${char.level} ${char.name}`);
52 if (emoji) btn.setEmoji(emoji as any);
53 return btn;
54}
55
56function buildConflictEmbed(
57 borrowerKey: string,
58 char: Character,
59 ownerKey: string
60): EmbedBuilder {
61 const charDisplay = formatChar(char);
62 return new EmbedBuilder()
63 .setTitle("⚠️ Character Conflict")
64 .setDescription(
65 `**${charDisplay}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.`
66 )
67 .setColor(0xe8a317);
68}
69
70function buildConflictButtons(
71 ownerUsermapKey: string,
72 borrowerUsermapKey: string,
73 borrowedCharName: string,
74 ownerId: string,
75 allChars: Character[],
76 page: number
77): ActionRowBuilder<ButtonBuilder>[] {
78 const PAGE_SIZE = 4; // leave 1 slot for reclaim on first row
79 const otherChars = allChars.filter((c) => c.name !== borrowedCharName);
80 const pageChars = otherChars.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
81 const hasMore = otherChars.length > (page + 1) * PAGE_SIZE;
82 const hasPrev = page > 0;
83
84 const rows: ActionRowBuilder<ButtonBuilder>[] = [];
85
86 // Row 1: char switch buttons
87 const charButtons = pageChars.map((char) => {
88 const id = `conflict_switch:${ownerUsermapKey}:${borrowerUsermapKey}:${char.name}:${ownerId}`;
89 pendingConflicts.set(id, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page });
90 return applyCharToButton(
91 new ButtonBuilder().setCustomId(id).setStyle(ButtonStyle.Secondary),
92 char
93 );
94 });
95
96 if (charButtons.length > 0) {
97 rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...charButtons));
98 }
99
100 // Row 2: reclaim + pagination
101 const reclaimId = `conflict_reclaim:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}`;
102 pendingConflicts.set(reclaimId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page });
103
104 const borrowed = allChars.find((c) => c.name === borrowedCharName);
105 const reclaimBtn = borrowed
106 ? applyCharToButton(
107 new ButtonBuilder().setCustomId(reclaimId).setStyle(ButtonStyle.Danger),
108 borrowed
109 ).setLabel(`${borrowed.level} ${borrowed.name}`)
110 : new ButtonBuilder().setCustomId(reclaimId).setLabel(`↩️ Reclaim ${borrowedCharName}`).setStyle(RECLAIM_STYLE);
111
112 const navButtons: ButtonBuilder[] = [reclaimBtn];
113
114 if (hasPrev) {
115 const prevId = `conflict_page:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}:${page - 1}`;
116 pendingConflicts.set(prevId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page: page - 1 });
117 navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary));
118 }
119 if (hasMore) {
120 const nextId = `conflict_page:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}:${page + 1}`;
121 pendingConflicts.set(nextId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page: page + 1 });
122 navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary));
123 }
124
125 rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navButtons));
126 return rows;
127}
128
129// Show conflict embed to owner
130export async function showConflictEmbed(
131 interaction: ButtonInteraction,
132 ownerUsermapKey: string,
133 borrowerUsermapKey: string,
134 borrowedChar: Character,
135 allOwnerChars: Character[]
136): Promise<void> {
137 const embed = buildConflictEmbed(borrowerUsermapKey, borrowedChar, ownerUsermapKey);
138 const buttons = buildConflictButtons(
139 ownerUsermapKey, borrowerUsermapKey, borrowedChar.name,
140 interaction.user.id, allOwnerChars, 0
141 );
142 await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true });
143}
144
145// Handle conflict button interactions
146export async function handleConflictButton(interaction: ButtonInteraction): Promise<void> {
147 const { customId } = interaction;
148
149 if (customId.startsWith("conflict_page:")) {
150 const parts = customId.split(":");
151 const ownerKey = parts[1];
152 const borrowerKey = parts[2];
153 const charName = parts[3];
154 const ownerId = parts[4];
155 const page = parseInt(parts[5]);
156
157 const allChars = getCharacters(ownerKey);
158 const borrowed = allChars.find((c) => c.name === charName);
159 if (!borrowed) return void interaction.reply({ content: "❌ Character not found.", ephemeral: true });
160
161 const embed = buildConflictEmbed(borrowerKey, borrowed, ownerKey);
162 const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page);
163 await interaction.update({ embeds: [embed], components: buttons });
164 return;
165 }
166
167 if (customId.startsWith("conflict_switch:")) {
168 const parts = customId.split(":");
169 const ownerKey = parts[1];
170 const borrowerKey = parts[2];
171 const newCharName = parts[3];
172 const ownerId = parts[4];
173
174 // Switch owner to the selected char
175 setActiveCharacter(ownerKey, newCharName);
176 clearSessionBorrowForUser(ownerKey);
177
178 const slot = [...polls.keys()][0];
179 const state = slot !== undefined ? polls.get(slot) : null;
180
181 if (state && AUTO_VOTE_ON_SWITCH) {
182 // Auto-vote Yes for owner with new char
183 const guild = interaction.guild!;
184 const member = await guild.members.fetch(ownerId);
185 const { char } = getEffectiveCharacter(ownerKey);
186 const now = nowFormatted();
187 const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
188
189 state.yes.set(ownerId, {
190 userKey: ownerKey,
191 displayName: member.nickname ?? member.user.globalName ?? member.user.username,
192 characterName: char?.name,
193 characterClass: char?.class,
194 characterLevel: char?.level,
195 characterNation: char?.nation,
196 votedAt: now,
197 publicMessage: publicMsg ?? undefined,
198 });
199
200 const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
201 await updatePollMessage(channel, slot!);
202 }
203
204 await interaction.update({
205 embeds: [new EmbedBuilder()
206 .setTitle("✅ Switched")
207 .setDescription(`Switched to **${newCharName}**${AUTO_VOTE_ON_SWITCH ? " and voted Yes." : "."}`)
208 .setColor(0x57f287)],
209 components: [],
210 });
211 return;
212 }
213
214 if (customId.startsWith("conflict_reclaim:")) {
215 const parts = customId.split(":");
216 const ownerKey = parts[1];
217 const borrowerKey = parts[2];
218 const charName = parts[3];
219 const ownerId = parts[4];
220
221 const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert"; // "revert" | "remove"
222 const slot = [...polls.keys()][0];
223 const state = slot !== undefined ? polls.get(slot) : null;
224
225 if (state) {
226 // Find borrower's vote entry
227 for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
228 if (entry.userKey === borrowerKey) {
229 if (reclaimBehavior === "remove") {
230 state.yes.delete(id);
231 state.no.delete(id);
232 } else {
233 // Revert borrower to their own active char
234 clearSessionBorrowForUser(borrowerKey);
235 const { char: ownChar } = getEffectiveCharacter(borrowerKey);
236 if (ownChar) {
237 entry.characterName = ownChar.name;
238 entry.characterClass = ownChar.class;
239 entry.characterLevel = ownChar.level;
240 entry.characterNation = ownChar.nation;
241 entry.borrowedFrom = undefined;
242 } else {
243 // No own char — remove from poll
244 state.yes.delete(id);
245 state.no.delete(id);
246 }
247 }
248 break;
249 }
250 }
251
252 // Owner joins with their character
253 const guild = interaction.guild!;
254 const member = await guild.members.fetch(ownerId);
255 setActiveCharacter(ownerKey, charName);
256 clearSessionBorrowForUser(ownerKey);
257 const { char } = getEffectiveCharacter(ownerKey);
258 const now = nowFormatted();
259 const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
260
261 state.yes.set(ownerId, {
262 userKey: ownerKey,
263 displayName: member.nickname ?? member.user.globalName ?? member.user.username,
264 characterName: char?.name,
265 characterClass: char?.class,
266 characterLevel: char?.level,
267 characterNation: char?.nation,
268 votedAt: now,
269 publicMessage: publicMsg ?? undefined,
270 });
271
272 const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
273 await updatePollMessage(channel, slot!);
274 }
275
276 await interaction.update({
277 embeds: [new EmbedBuilder()
278 .setTitle("↩️ Reclaimed")
279 .setDescription(`**${charName}** has been reclaimed from **${borrowerKey}** and you've been added to the poll.`)
280 .setColor(0x57f287)],
281 components: [],
282 });
283 return;
284 }
285}