import { ModalSubmitInteraction, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, } from "discord.js"; import { Logger } from "@systems/logger"; import { Score } from "@systems/score"; import { Emoji } from "@systems/emojis"; import { resolveUser } from "@systems/users"; import { getEffectiveCharacter } from "@systems/borrow"; import { format } from "@format"; import { SlotHour, ClassKey } from "@root/src/types"; const log = Logger.for("modals"); // ─── Healing classes ────────────────────────────────────────────────────────── // Only these classes see the Heal field in the score modal. // Extend this array when new healing classes are added. const HEALING_CLASSES: ClassKey[] = ["FA"]; function isHealingClass(cls: ClassKey): boolean { return HEALING_CLASSES.includes(cls); } // ─── Modal IDs ──────────────────────────────────────────────────────────────── // // score_submit: — score submission modal, slot baked into the customId export namespace modals { // ─── Builder ─────────────────────────────────────────────────────────────── export function buildScoreModal(userKey: string, slot: number): ModalBuilder { const { char } = getEffectiveCharacter(userKey); const charLabel = char ? format.char(char, { emoji: false }) : "your character"; const classKey = (typeof char?.class === "object" ? (char.class as any)?.key : char?.class) as ClassKey | undefined; const showHeal = !!classKey && isHealingClass(classKey); const modal = new ModalBuilder() .setCustomId(`score_submit:${slot}`) .setTitle(`Score for ${charLabel} — ${String(slot).padStart(2, "0")}:00`); const ptsInput = new TextInputBuilder() .setCustomId("pts") .setLabel("Points") .setStyle(TextInputStyle.Short) .setPlaceholder("e.g. 3000") .setRequired(true); const kdInput = new TextInputBuilder() .setCustomId("kd") .setLabel("Kills / Deaths (e.g. 5/2)") .setStyle(TextInputStyle.Short) .setPlaceholder("5/2") .setRequired(false); const atkInput = new TextInputBuilder() .setCustomId("atk") .setLabel("ATK") .setStyle(TextInputStyle.Short) .setPlaceholder("e.g. 120") .setRequired(false); const defInput = new TextInputBuilder() .setCustomId("def") .setLabel("DEF") .setStyle(TextInputStyle.Short) .setPlaceholder("e.g. 80") .setRequired(false); const healInput = new TextInputBuilder() .setCustomId("heal") .setLabel("Heal") .setStyle(TextInputStyle.Short) .setPlaceholder("e.g. 500") .setRequired(false); // Base fields always shown: pts, k/d, atk, def (4 fields) // Heal replaces def when class is a healer — keeps total at 4 or 5 // With heal: pts, k/d, atk, def, heal = 5 (Discord max) modal.addComponents( new ActionRowBuilder().addComponents(ptsInput), new ActionRowBuilder().addComponents(kdInput), new ActionRowBuilder().addComponents(atkInput), new ActionRowBuilder().addComponents(defInput), ...(showHeal ? [new ActionRowBuilder().addComponents(healInput)] : []), ); return modal; } // ─── Parser helpers ──────────────────────────────────────────────────────── function parseSlashPair(raw: string | null): [number, number] | null { if (!raw) return null; const parts = raw.trim().split("/"); if (parts.length !== 2) return null; const a = parseInt(parts[0], 10); const b = parseInt(parts[1], 10); if (isNaN(a) || isNaN(b)) return null; return [a, b]; } function parseOptionalInt(raw: string | null): number | undefined { if (!raw) return undefined; const n = parseInt(raw.trim(), 10); return isNaN(n) ? undefined : n; } // ─── Handler ─────────────────────────────────────────────────────────────── export async function handleModal(interaction: ModalSubmitInteraction): Promise { if (interaction.customId.startsWith("score_submit:")) { await handleScoreSubmit(interaction); return; } // Future modals routed here by customId prefix } async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise { await interaction.deferReply({ ephemeral: true }); const slotStr = interaction.customId.split(":")[1]; const slot = parseInt(slotStr, 10); if (isNaN(slot)) { await interaction.editReply("❌ Invalid slot in modal."); return; } const member = await interaction.guild!.members.fetch(interaction.user.id); const user = await resolveUser(member); if (!user.userKey) { await interaction.editReply("❌ You are not registered in the system."); return; } const { char, borrowedFrom } = getEffectiveCharacter(user.userKey); if (!char) { await interaction.editReply("❌ No active character found. Use `/tg char set-active` first."); return; } const classKey = (typeof char.class === "object" ? (char.class as any)?.key : char.class) as ClassKey; const showHeal = isHealingClass(classKey); // Parse fields const ptsRaw = interaction.fields.getTextInputValue("pts"); const kdRaw = interaction.fields.getTextInputValue("kd") || null; const atkRaw = interaction.fields.getTextInputValue("atk") || null; const defRaw = interaction.fields.getTextInputValue("def") || null; // Heal field only exists in the modal for healing classes — safe to attempt, // getTextInputValue returns "" for missing fields on some discord.js versions, // so we guard with try/catch let healRaw: string | null = null; if (showHeal) { try { healRaw = interaction.fields.getTextInputValue("heal") || null; } catch { healRaw = null; } } const pts = parseInt(ptsRaw.trim(), 10); if (isNaN(pts)) { await interaction.editReply("❌ Points must be a number."); return; } const kd = parseSlashPair(kdRaw); const atk = parseOptionalInt(atkRaw); const def = parseOptionalInt(defRaw); const heal = parseOptionalInt(healRaw); if (kdRaw && !kd) { await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`."); return; } log.debug(`Score submit via modal: userKey=${user.userKey} char=${char.name} slot=${slot} showHeal=${showHeal}`); // ── Canonical path — same as /tg score set ──────────────────────────────── // Score.submit emits RuntimeEvents → Leaderboard.update + Result.post await Score.submit({ character: char, playedBy: borrowedFrom ? user.userKey : undefined, pts, k: kd?.[0], d: kd?.[1], atk, def, heal, slot: slot as SlotHour, submittedByOfficer: false, }); const scoreEmoji = Emoji.get("score") || "📊"; const kdEmoji = Emoji.get("kd") || "⚔️"; const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; const kdNote = kd ? `\n${kdEmoji} ${kd[0]}/${kd[1]}` : ""; const statsNote = [ atk !== undefined ? `ATK: ${atk}` : null, def !== undefined ? `DEF: ${def}` : null, heal !== undefined ? `HEAL: ${heal}` : null, ].filter(Boolean).join(" · "); await interaction.editReply( `✅ ${scoreEmoji} **${pts}** submitted for **${char.name}**${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}` ); } }