modals.ts
· 8.1 KiB · TypeScript
Raw
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:<slot> — 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<TextInputBuilder>().addComponents(ptsInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(kdInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(atkInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(defInput),
...(showHeal ? [new ActionRowBuilder<TextInputBuilder>().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<void> {
if (interaction.customId.startsWith("score_submit:")) {
await handleScoreSubmit(interaction);
return;
}
// Future modals routed here by customId prefix
}
async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> {
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}` : ""}`
);
}
}
| 1 | import { |
| 2 | ModalSubmitInteraction, |
| 3 | ModalBuilder, |
| 4 | TextInputBuilder, |
| 5 | TextInputStyle, |
| 6 | ActionRowBuilder, |
| 7 | } from "discord.js"; |
| 8 | import { Logger } from "@systems/logger"; |
| 9 | import { Score } from "@systems/score"; |
| 10 | import { Emoji } from "@systems/emojis"; |
| 11 | import { resolveUser } from "@systems/users"; |
| 12 | import { getEffectiveCharacter } from "@systems/borrow"; |
| 13 | import { format } from "@format"; |
| 14 | import { SlotHour, ClassKey } from "@root/src/types"; |
| 15 | |
| 16 | const log = Logger.for("modals"); |
| 17 | |
| 18 | // ─── Healing classes ────────────────────────────────────────────────────────── |
| 19 | // Only these classes see the Heal field in the score modal. |
| 20 | // Extend this array when new healing classes are added. |
| 21 | const HEALING_CLASSES: ClassKey[] = ["FA"]; |
| 22 | |
| 23 | function isHealingClass(cls: ClassKey): boolean { |
| 24 | return HEALING_CLASSES.includes(cls); |
| 25 | } |
| 26 | |
| 27 | // ─── Modal IDs ──────────────────────────────────────────────────────────────── |
| 28 | // |
| 29 | // score_submit:<slot> — score submission modal, slot baked into the customId |
| 30 | |
| 31 | export namespace modals { |
| 32 | |
| 33 | // ─── Builder ─────────────────────────────────────────────────────────────── |
| 34 | |
| 35 | export function buildScoreModal(userKey: string, slot: number): ModalBuilder { |
| 36 | const { char } = getEffectiveCharacter(userKey); |
| 37 | const charLabel = char ? format.char(char, { emoji: false }) : "your character"; |
| 38 | const classKey = (typeof char?.class === "object" ? (char.class as any)?.key : char?.class) as ClassKey | undefined; |
| 39 | const showHeal = !!classKey && isHealingClass(classKey); |
| 40 | |
| 41 | const modal = new ModalBuilder() |
| 42 | .setCustomId(`score_submit:${slot}`) |
| 43 | .setTitle(`Score for ${charLabel} — ${String(slot).padStart(2, "0")}:00`); |
| 44 | |
| 45 | const ptsInput = new TextInputBuilder() |
| 46 | .setCustomId("pts") |
| 47 | .setLabel("Points") |
| 48 | .setStyle(TextInputStyle.Short) |
| 49 | .setPlaceholder("e.g. 3000") |
| 50 | .setRequired(true); |
| 51 | |
| 52 | const kdInput = new TextInputBuilder() |
| 53 | .setCustomId("kd") |
| 54 | .setLabel("Kills / Deaths (e.g. 5/2)") |
| 55 | .setStyle(TextInputStyle.Short) |
| 56 | .setPlaceholder("5/2") |
| 57 | .setRequired(false); |
| 58 | |
| 59 | const atkInput = new TextInputBuilder() |
| 60 | .setCustomId("atk") |
| 61 | .setLabel("ATK") |
| 62 | .setStyle(TextInputStyle.Short) |
| 63 | .setPlaceholder("e.g. 120") |
| 64 | .setRequired(false); |
| 65 | |
| 66 | const defInput = new TextInputBuilder() |
| 67 | .setCustomId("def") |
| 68 | .setLabel("DEF") |
| 69 | .setStyle(TextInputStyle.Short) |
| 70 | .setPlaceholder("e.g. 80") |
| 71 | .setRequired(false); |
| 72 | |
| 73 | const healInput = new TextInputBuilder() |
| 74 | .setCustomId("heal") |
| 75 | .setLabel("Heal") |
| 76 | .setStyle(TextInputStyle.Short) |
| 77 | .setPlaceholder("e.g. 500") |
| 78 | .setRequired(false); |
| 79 | |
| 80 | // Base fields always shown: pts, k/d, atk, def (4 fields) |
| 81 | // Heal replaces def when class is a healer — keeps total at 4 or 5 |
| 82 | // With heal: pts, k/d, atk, def, heal = 5 (Discord max) |
| 83 | modal.addComponents( |
| 84 | new ActionRowBuilder<TextInputBuilder>().addComponents(ptsInput), |
| 85 | new ActionRowBuilder<TextInputBuilder>().addComponents(kdInput), |
| 86 | new ActionRowBuilder<TextInputBuilder>().addComponents(atkInput), |
| 87 | new ActionRowBuilder<TextInputBuilder>().addComponents(defInput), |
| 88 | ...(showHeal ? [new ActionRowBuilder<TextInputBuilder>().addComponents(healInput)] : []), |
| 89 | ); |
| 90 | |
| 91 | return modal; |
| 92 | } |
| 93 | |
| 94 | // ─── Parser helpers ──────────────────────────────────────────────────────── |
| 95 | |
| 96 | function parseSlashPair(raw: string | null): [number, number] | null { |
| 97 | if (!raw) return null; |
| 98 | const parts = raw.trim().split("/"); |
| 99 | if (parts.length !== 2) return null; |
| 100 | const a = parseInt(parts[0], 10); |
| 101 | const b = parseInt(parts[1], 10); |
| 102 | if (isNaN(a) || isNaN(b)) return null; |
| 103 | return [a, b]; |
| 104 | } |
| 105 | |
| 106 | function parseOptionalInt(raw: string | null): number | undefined { |
| 107 | if (!raw) return undefined; |
| 108 | const n = parseInt(raw.trim(), 10); |
| 109 | return isNaN(n) ? undefined : n; |
| 110 | } |
| 111 | |
| 112 | // ─── Handler ─────────────────────────────────────────────────────────────── |
| 113 | |
| 114 | export async function handleModal(interaction: ModalSubmitInteraction): Promise<void> { |
| 115 | if (interaction.customId.startsWith("score_submit:")) { |
| 116 | await handleScoreSubmit(interaction); |
| 117 | return; |
| 118 | } |
| 119 | // Future modals routed here by customId prefix |
| 120 | } |
| 121 | |
| 122 | async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> { |
| 123 | await interaction.deferReply({ ephemeral: true }); |
| 124 | |
| 125 | const slotStr = interaction.customId.split(":")[1]; |
| 126 | const slot = parseInt(slotStr, 10); |
| 127 | if (isNaN(slot)) { |
| 128 | await interaction.editReply("❌ Invalid slot in modal."); |
| 129 | return; |
| 130 | } |
| 131 | |
| 132 | const member = await interaction.guild!.members.fetch(interaction.user.id); |
| 133 | const user = await resolveUser(member); |
| 134 | if (!user.userKey) { |
| 135 | await interaction.editReply("❌ You are not registered in the system."); |
| 136 | return; |
| 137 | } |
| 138 | |
| 139 | const { char, borrowedFrom } = getEffectiveCharacter(user.userKey); |
| 140 | if (!char) { |
| 141 | await interaction.editReply("❌ No active character found. Use `/tg char set-active` first."); |
| 142 | return; |
| 143 | } |
| 144 | |
| 145 | const classKey = (typeof char.class === "object" ? (char.class as any)?.key : char.class) as ClassKey; |
| 146 | const showHeal = isHealingClass(classKey); |
| 147 | |
| 148 | // Parse fields |
| 149 | const ptsRaw = interaction.fields.getTextInputValue("pts"); |
| 150 | const kdRaw = interaction.fields.getTextInputValue("kd") || null; |
| 151 | const atkRaw = interaction.fields.getTextInputValue("atk") || null; |
| 152 | const defRaw = interaction.fields.getTextInputValue("def") || null; |
| 153 | // Heal field only exists in the modal for healing classes — safe to attempt, |
| 154 | // getTextInputValue returns "" for missing fields on some discord.js versions, |
| 155 | // so we guard with try/catch |
| 156 | let healRaw: string | null = null; |
| 157 | if (showHeal) { |
| 158 | try { healRaw = interaction.fields.getTextInputValue("heal") || null; } |
| 159 | catch { healRaw = null; } |
| 160 | } |
| 161 | |
| 162 | const pts = parseInt(ptsRaw.trim(), 10); |
| 163 | if (isNaN(pts)) { |
| 164 | await interaction.editReply("❌ Points must be a number."); |
| 165 | return; |
| 166 | } |
| 167 | |
| 168 | const kd = parseSlashPair(kdRaw); |
| 169 | const atk = parseOptionalInt(atkRaw); |
| 170 | const def = parseOptionalInt(defRaw); |
| 171 | const heal = parseOptionalInt(healRaw); |
| 172 | |
| 173 | if (kdRaw && !kd) { |
| 174 | await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`."); |
| 175 | return; |
| 176 | } |
| 177 | |
| 178 | log.debug(`Score submit via modal: userKey=${user.userKey} char=${char.name} slot=${slot} showHeal=${showHeal}`); |
| 179 | |
| 180 | // ── Canonical path — same as /tg score set ──────────────────────────────── |
| 181 | // Score.submit emits RuntimeEvents → Leaderboard.update + Result.post |
| 182 | await Score.submit({ |
| 183 | character: char, |
| 184 | playedBy: borrowedFrom ? user.userKey : undefined, |
| 185 | pts, |
| 186 | k: kd?.[0], |
| 187 | d: kd?.[1], |
| 188 | atk, |
| 189 | def, |
| 190 | heal, |
| 191 | slot: slot as SlotHour, |
| 192 | submittedByOfficer: false, |
| 193 | }); |
| 194 | |
| 195 | const scoreEmoji = Emoji.get("score") || "📊"; |
| 196 | const kdEmoji = Emoji.get("kd") || "⚔️"; |
| 197 | const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; |
| 198 | const kdNote = kd ? `\n${kdEmoji} ${kd[0]}/${kd[1]}` : ""; |
| 199 | const statsNote = [ |
| 200 | atk !== undefined ? `ATK: ${atk}` : null, |
| 201 | def !== undefined ? `DEF: ${def}` : null, |
| 202 | heal !== undefined ? `HEAL: ${heal}` : null, |
| 203 | ].filter(Boolean).join(" · "); |
| 204 | |
| 205 | await interaction.editReply( |
| 206 | `✅ ${scoreEmoji} **${pts}** submitted for **${char.name}**${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}` |
| 207 | ); |
| 208 | } |
| 209 | } |