309 lines
No EOL
13 KiB
TypeScript
309 lines
No EOL
13 KiB
TypeScript
import {
|
|
ButtonBuilder,
|
|
ButtonStyle,
|
|
ActionRowBuilder,
|
|
EmbedBuilder,
|
|
ButtonInteraction,
|
|
TextChannel,
|
|
} from "discord.js";
|
|
import { cfg } from "@systems/config";
|
|
import { getCharacters, setActiveCharacter } from "@systems/characters";
|
|
import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow";
|
|
import { getImpersonation } from "@systems/impersonate";
|
|
import { polls, updatePollMessage } from "@systems/poll";
|
|
import { resolveMessage, nowFormatted } from "@systems/messages";
|
|
import { Emoji } from "@systems/emojis";
|
|
import { format } from "@systems/format";
|
|
import { Character } from "@types";
|
|
import { buildCharSelectButtons } from "@systems/charSelect";
|
|
|
|
|
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
const RECLAIM_STYLE = ButtonStyle.Secondary;
|
|
const SWITCH_STYLE = ButtonStyle.Secondary;
|
|
const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false";
|
|
const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false";
|
|
|
|
// ─── State ────────────────────────────────────────────────────────────────────
|
|
const pendingConflicts = new Map<string, {
|
|
ownerKey: string;
|
|
borrowerKey: string;
|
|
charName: string;
|
|
ownerId: string;
|
|
page: number;
|
|
}>();
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder {
|
|
const emojiStr = Emoji.class(char.class);
|
|
const emoji = format.emoji(emojiStr);
|
|
btn.setLabel(`${char.level} ${char.name}`);
|
|
if (emoji) btn.setEmoji(emoji as any);
|
|
return btn;
|
|
}
|
|
|
|
function buildConflictEmbed(borrowerKey: string, char: Character): EmbedBuilder {
|
|
return new EmbedBuilder()
|
|
.setTitle("⚠️ Character Conflict")
|
|
.setDescription(
|
|
`**${format.char(char)}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.`
|
|
)
|
|
.setColor(0xe8a317);
|
|
}
|
|
|
|
function buildConflictButtons(
|
|
ownerKey: string,
|
|
borrowerKey: string,
|
|
borrowedCharName: string,
|
|
ownerId: string,
|
|
allChars: Character[],
|
|
page: number
|
|
): ActionRowBuilder<ButtonBuilder>[] {
|
|
const PAGE_SIZE = 4;
|
|
const otherChars = allChars.filter((c) => c.name !== borrowedCharName);
|
|
const pageChars = otherChars.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
const hasMore = otherChars.length > (page + 1) * PAGE_SIZE;
|
|
const hasPrev = page > 0;
|
|
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
|
|
|
// Row 1: Reclaim button
|
|
const reclaimId = `conflict_reclaim:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}`;
|
|
pendingConflicts.set(reclaimId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page });
|
|
const borrowed = allChars.find((c) => c.name === borrowedCharName);
|
|
const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE);
|
|
if (borrowed) {
|
|
reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`);
|
|
const emojiStr = Emoji.class(borrowed.class);
|
|
const emoji = format.emoji(emojiStr);
|
|
if (emoji) reclaimBtn.setEmoji(emoji as any);
|
|
} else {
|
|
reclaimBtn.setLabel(`Reclaim ${borrowedCharName}`);
|
|
}
|
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(reclaimBtn));
|
|
|
|
// Row 2: Switch buttons
|
|
const charButtons = pageChars.map((char) => {
|
|
const id = `conflict_switch:${ownerKey}:${borrowerKey}:${char.name}:${ownerId}`;
|
|
pendingConflicts.set(id, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page });
|
|
return applyCharToButton(new ButtonBuilder().setCustomId(id).setStyle(SWITCH_STYLE), char);
|
|
});
|
|
if (charButtons.length > 0) {
|
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...charButtons));
|
|
}
|
|
|
|
// Row 3: Pagination
|
|
const navButtons: ButtonBuilder[] = [];
|
|
if (hasPrev) {
|
|
const prevId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page - 1}`;
|
|
pendingConflicts.set(prevId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page - 1 });
|
|
navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary));
|
|
}
|
|
if (hasMore) {
|
|
const nextId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page + 1}`;
|
|
pendingConflicts.set(nextId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page + 1 });
|
|
navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary));
|
|
}
|
|
if (navButtons.length > 0) {
|
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navButtons));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
export async function showConflictEmbed(
|
|
interaction: ButtonInteraction,
|
|
ownerKey: string,
|
|
borrowerKey: string,
|
|
borrowedChar: Character,
|
|
allOwnerChars: Character[]
|
|
): Promise<void> {
|
|
const embed = buildConflictEmbed(borrowerKey, borrowedChar);
|
|
const buttons = buildConflictButtons(ownerKey, borrowerKey, borrowedChar.name, interaction.user.id, allOwnerChars, 0);
|
|
await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true });
|
|
}
|
|
|
|
export async function handleConflictButton(interaction: ButtonInteraction): Promise<void> {
|
|
console.log("[conflict] button received:", interaction.customId);
|
|
const { customId } = interaction;
|
|
|
|
// ── Pagination ──────────────────────────────────────────────────────────────
|
|
if (customId.startsWith("conflict_page:")) {
|
|
const parts = customId.split(":");
|
|
const ownerKey = parts[1];
|
|
const borrowerKey = parts[2];
|
|
const charName = parts[3];
|
|
const ownerId = parts[4];
|
|
const page = parseInt(parts[5]);
|
|
|
|
const allChars = getCharacters(ownerKey);
|
|
const borrowed = allChars.find((c) => c.name === charName);
|
|
if (!borrowed) return void interaction.reply({ content: "❌ Character not found.", ephemeral: true });
|
|
|
|
const embed = buildConflictEmbed(borrowerKey, borrowed);
|
|
const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page);
|
|
await interaction.update({ embeds: [embed], components: buttons });
|
|
return;
|
|
}
|
|
|
|
// ── Switch to another char ──────────────────────────────────────────────────
|
|
if (customId.startsWith("conflict_switch:")) {
|
|
const parts = customId.split(":");
|
|
const ownerKey = parts[1];
|
|
const borrowerKey = parts[2];
|
|
const newCharName = parts[3];
|
|
const ownerId = parts[4];
|
|
|
|
setActiveCharacter(ownerKey, newCharName);
|
|
clearSessionBorrowForUser(ownerKey);
|
|
|
|
const impersonating = getImpersonation(ownerId);
|
|
const voteId = impersonating ? `impersonated:${impersonating}` : ownerId;
|
|
const slot = [...polls.keys()][0];
|
|
const state = slot !== undefined ? polls.get(slot) : null;
|
|
|
|
if (state && AUTO_VOTE_ON_SWITCH) {
|
|
const guild = interaction.guild!;
|
|
const member = await guild.members.fetch(ownerId);
|
|
const { char } = getEffectiveCharacter(ownerKey);
|
|
const now = nowFormatted();
|
|
const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
|
|
|
|
state.yes.set(voteId, {
|
|
userKey: ownerKey,
|
|
displayName: member.nickname ?? member.user.globalName ?? member.user.username,
|
|
characterName: char?.name,
|
|
characterClass: char?.class,
|
|
characterLevel: char?.level,
|
|
characterNation: char?.nation,
|
|
votedAt: now,
|
|
publicMessage: publicMsg ?? undefined,
|
|
});
|
|
|
|
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
|
await updatePollMessage(channel, slot!);
|
|
}
|
|
|
|
const newChar = getCharacters(ownerKey).find((c) => c.name === newCharName);
|
|
const charDisplay = newChar ? format.char(newChar) : newCharName;
|
|
|
|
await interaction.update({
|
|
embeds: [new EmbedBuilder()
|
|
.setTitle("🔄 Switched")
|
|
.setDescription(`${charDisplay}${AUTO_VOTE_ON_SWITCH ? " — voted Yes." : ""}`)
|
|
.setColor(0x57f287)],
|
|
components: [],
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ── Reclaim ─────────────────────────────────────────────────────────────────
|
|
if (customId.startsWith("conflict_reclaim:")) {
|
|
console.log("[reclaim] handler triggered");
|
|
const parts = customId.split(":");
|
|
const ownerKey = parts[1];
|
|
const borrowerKey = parts[2];
|
|
const charName = parts[3];
|
|
const ownerId = parts[4];
|
|
|
|
const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert";
|
|
const slot = [...polls.keys()][0];
|
|
const state = slot !== undefined ? polls.get(slot) : null;
|
|
|
|
let borrowerDiscordId: string | undefined;
|
|
let borrowerVoteType: "yes" | "no" = "yes"; // default to yes
|
|
|
|
if (state) {
|
|
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
|
const isYes = state.yes.has(id);
|
|
if (entry.userKey === borrowerKey) {
|
|
borrowerVoteType = isYes ? "yes" : "no";
|
|
// Capture borrower's Discord ID for notification
|
|
borrowerDiscordId = (entry as any).discordId;
|
|
|
|
if (reclaimBehavior === "remove") {
|
|
state.yes.delete(id);
|
|
state.no.delete(id);
|
|
} else {
|
|
// Clear borrow so getEffectiveCharacter returns own char
|
|
clearSessionBorrowForUser(borrowerKey);
|
|
clearPersistentPreference(borrowerKey);
|
|
const { char: ownChar } = getEffectiveCharacter(borrowerKey);
|
|
if (ownChar) {
|
|
entry.characterName = ownChar.name;
|
|
entry.characterClass = ownChar.class;
|
|
entry.characterLevel = ownChar.level;
|
|
entry.characterNation = ownChar.nation;
|
|
entry.borrowedFrom = undefined;
|
|
} else {
|
|
state.yes.delete(id);
|
|
state.no.delete(id);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Owner joins with their reclaimed char
|
|
const guild = interaction.guild!;
|
|
const member = await guild.members.fetch(ownerId);
|
|
const impersonating = getImpersonation(ownerId);
|
|
const voteId = impersonating ? `impersonated:${impersonating}` : ownerId;
|
|
|
|
setActiveCharacter(ownerKey, charName);
|
|
clearSessionBorrowForUser(ownerKey);
|
|
const { char } = getEffectiveCharacter(ownerKey);
|
|
const now = nowFormatted();
|
|
const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
|
|
|
|
state.yes.set(voteId, {
|
|
userKey: ownerKey,
|
|
displayName: member.nickname ?? member.user.globalName ?? member.user.username,
|
|
characterName: char?.name,
|
|
characterClass: char?.class,
|
|
characterLevel: char?.level,
|
|
characterNation: char?.nation,
|
|
votedAt: now,
|
|
publicMessage: publicMsg ?? undefined,
|
|
});
|
|
|
|
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
|
await updatePollMessage(channel, slot!);
|
|
|
|
console.log("[reclaim notify] borrowerDiscordId:", borrowerDiscordId, "notify:", RECLAIM_NOTIFY_BORROWER);
|
|
|
|
// Notify borrower if enabled and we have their Discord ID
|
|
if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) {
|
|
try {
|
|
const borrowerMember = await guild.members.fetch(borrowerDiscordId);
|
|
|
|
const btns = buildCharSelectButtons(borrowerKey, {
|
|
customIdPrefix: `switch_after_reclaim:${borrowerKey}`,
|
|
excludeCharName: charName,
|
|
appendToCustomId: `:${borrowerVoteType}`,
|
|
});
|
|
console.log("[reclaim notify] btns length:", btns.length);
|
|
console.log("[reclaim notify] btns:", JSON.stringify(btns.map(r => r.toJSON())));
|
|
await borrowerMember.send({
|
|
content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. Pick another character:`,
|
|
components: btns.length > 0 ? btns : [],
|
|
});
|
|
} catch {
|
|
// DM may be disabled — silently ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
const borrowed = getCharacters(ownerKey).find((c) => c.name === charName);
|
|
const charDisplay = borrowed ? format.char(borrowed) : charName;
|
|
|
|
await interaction.update({
|
|
embeds: [new EmbedBuilder()
|
|
.setTitle("↩️ Reclaimed")
|
|
.setDescription(`${charDisplay} reclaimed from **${borrowerKey}**.`)
|
|
.setColor(0x57f287)],
|
|
components: [],
|
|
});
|
|
return;
|
|
}
|
|
} |