Last active 1 month ago

modals.ts Raw
1import {
2 ModalSubmitInteraction,
3 ModalBuilder,
4 TextInputBuilder,
5 TextInputStyle,
6 ActionRowBuilder,
7} from "discord.js";
8import { Logger } from "@systems/logger";
9import { Score } from "@systems/score";
10import { Emoji } from "@systems/emojis";
11import { resolveUser } from "@systems/users";
12import { getEffectiveCharacter } from "@systems/borrow";
13import { format } from "@format";
14import { SlotHour, ClassKey } from "@root/src/types";
15
16const 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.
21const HEALING_CLASSES: ClassKey[] = ["FA"];
22
23function 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
31export 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}