Naposledy aktivní 1 month ago

nuno revidoval tento gist 1 month ago. Přejít na revizi

1 file changed, 209 insertions

modals.ts(vytvořil soubor)

@@ -0,0 +1,209 @@
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 + }
Novější Starší