tg-bot-ts/src/systems/conflict.ts

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;
}
}