最後活躍 1 month ago

修訂 a0fbbb2411b6e64977ca98ff6e63bd6b12703412

modals.ts 原始檔案
1import {
2 ModalSubmitInteraction,
3 ModalBuilder,
4 TextInputBuilder,
5 TextInputStyle,
6 ActionRowBuilder,
7} from "discord.js";
8import { resolveUser } from "@systems/users";
9import { getEffectiveCharacter } from "@systems/borrow";
10import { score } from "@subcommands/score/submitCore";
11import { format } from "@format";
12
13// ─── Modal IDs ────────────────────────────────────────────────────────────────
14//
15// score_submit:<slot> — score submission modal, slot baked into the customId
16// so we don't need to pass state through the modal itself
17
18export namespace modals {
19
20 // ─── Builder ───────────────────────────────────────────────────────────────
21
22 /**
23 * Build the score submission modal for a given userKey + slot.
24 * Title shows the active character so the user knows what they're submitting for.
25 */
26 export function buildScoreModal(userKey: string, slot: number): ModalBuilder {
27 const { char } = getEffectiveCharacter(userKey);
28 // const charLabel = char ? format.char(char) : "your character";
29 const charLabel = char ? format.char(char, { emoji: false }) : "your character";
30
31 const modal = new ModalBuilder()
32 .setCustomId(`score_submit:${slot}`)
33 .setTitle(`Score for ${charLabel}${String(slot).padStart(2, "0")}:00`);
34
35 const ptsInput = new TextInputBuilder()
36 .setCustomId("pts")
37 .setLabel("Points")
38 .setStyle(TextInputStyle.Short)
39 .setPlaceholder("e.g. 3000")
40 .setRequired(true);
41
42 const kdInput = new TextInputBuilder()
43 .setCustomId("kd")
44 .setLabel("Kills / Deaths (e.g. 5/2)")
45 .setStyle(TextInputStyle.Short)
46 .setPlaceholder("5/2")
47 .setRequired(false);
48
49 const atkDefInput = new TextInputBuilder()
50 .setCustomId("atkdef")
51 .setLabel("ATK / DEF (e.g. 120/80)")
52 .setStyle(TextInputStyle.Short)
53 .setPlaceholder("120/80")
54 .setRequired(false);
55
56 const healInput = new TextInputBuilder()
57 .setCustomId("heal")
58 .setLabel("Heal")
59 .setStyle(TextInputStyle.Short)
60 .setPlaceholder("e.g. 500")
61 .setRequired(false);
62
63 modal.addComponents(
64 new ActionRowBuilder<TextInputBuilder>().addComponents(ptsInput),
65 new ActionRowBuilder<TextInputBuilder>().addComponents(kdInput),
66 new ActionRowBuilder<TextInputBuilder>().addComponents(atkDefInput),
67 new ActionRowBuilder<TextInputBuilder>().addComponents(healInput),
68 );
69
70 return modal;
71 }
72
73 // ─── Parser helpers ────────────────────────────────────────────────────────
74
75 function parseSlashPair(raw: string | null): [number, number] | null {
76 if (!raw) return null;
77 const parts = raw.trim().split("/");
78 if (parts.length !== 2) return null;
79 const a = parseInt(parts[0], 10);
80 const b = parseInt(parts[1], 10);
81 if (isNaN(a) || isNaN(b)) return null;
82 return [a, b];
83 }
84
85 function parseOptionalInt(raw: string | null): number | undefined {
86 if (!raw) return undefined;
87 const n = parseInt(raw.trim(), 10);
88 return isNaN(n) ? undefined : n;
89 }
90
91 // ─── Handler ───────────────────────────────────────────────────────────────
92
93 export async function handleModal(interaction: ModalSubmitInteraction): Promise<void> {
94 if (interaction.customId.startsWith("score_submit:")) {
95 await handleScoreSubmit(interaction);
96 return;
97 }
98 // Future modals routed here by customId prefix
99 }
100
101 async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> {
102 await interaction.deferReply({ ephemeral: true });
103
104 const slotStr = interaction.customId.split(":")[1];
105 const slot = parseInt(slotStr, 10);
106 if (isNaN(slot)) {
107 await interaction.editReply("❌ Invalid slot in modal.");
108 return;
109 }
110
111 const member = await interaction.guild!.members.fetch(interaction.user.id);
112 const user = await resolveUser(member);
113 if (!user.userKey) {
114 await interaction.editReply("❌ You are not registered in the system.");
115 return;
116 }
117
118 // Parse fields
119 const ptsRaw = interaction.fields.getTextInputValue("pts");
120 const kdRaw = interaction.fields.getTextInputValue("kd") || null;
121 const atkDefRaw = interaction.fields.getTextInputValue("atkdef") || null;
122 const healRaw = interaction.fields.getTextInputValue("heal") || null;
123
124 const pts = parseInt(ptsRaw.trim(), 10);
125 if (isNaN(pts)) {
126 await interaction.editReply("❌ Points must be a number.");
127 return;
128 }
129
130 const kd = parseSlashPair(kdRaw);
131 const atkDef = parseSlashPair(atkDefRaw);
132 const heal = parseOptionalInt(healRaw);
133
134 if (kdRaw && !kd) {
135 await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`.");
136 return;
137 }
138 if (atkDefRaw && !atkDef) {
139 await interaction.editReply("❌ ATK/DEF must be in `atk/def` format, e.g. `120/80`.");
140 return;
141 }
142
143 const result = await score.submitForUser({
144 userKey: user.userKey,
145 pts,
146 slot,
147 k: kd?.[0],
148 d: kd?.[1],
149 atk: atkDef?.[0],
150 def: atkDef?.[1],
151 heal,
152 });
153
154 await interaction.editReply(result.message);
155 }
156}