modals.ts
· 5.6 KiB · TypeScript
原始文件
import {
ModalSubmitInteraction,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ActionRowBuilder,
} from "discord.js";
import { resolveUser } from "@systems/users";
import { getEffectiveCharacter } from "@systems/borrow";
import { score } from "@subcommands/score/submitCore";
import { format } from "@format";
// ─── Modal IDs ────────────────────────────────────────────────────────────────
//
// score_submit:<slot> — score submission modal, slot baked into the customId
// so we don't need to pass state through the modal itself
export namespace modals {
// ─── Builder ───────────────────────────────────────────────────────────────
/**
* Build the score submission modal for a given userKey + slot.
* Title shows the active character so the user knows what they're submitting for.
*/
export function buildScoreModal(userKey: string, slot: number): ModalBuilder {
const { char } = getEffectiveCharacter(userKey);
// const charLabel = char ? format.char(char) : "your character";
const charLabel = char ? format.char(char, { emoji: false }) : "your character";
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 atkDefInput = new TextInputBuilder()
.setCustomId("atkdef")
.setLabel("ATK / DEF (e.g. 120/80)")
.setStyle(TextInputStyle.Short)
.setPlaceholder("120/80")
.setRequired(false);
const healInput = new TextInputBuilder()
.setCustomId("heal")
.setLabel("Heal")
.setStyle(TextInputStyle.Short)
.setPlaceholder("e.g. 500")
.setRequired(false);
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(ptsInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(kdInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(atkDefInput),
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;
}
// Parse fields
const ptsRaw = interaction.fields.getTextInputValue("pts");
const kdRaw = interaction.fields.getTextInputValue("kd") || null;
const atkDefRaw = interaction.fields.getTextInputValue("atkdef") || null;
const healRaw = interaction.fields.getTextInputValue("heal") || null;
const pts = parseInt(ptsRaw.trim(), 10);
if (isNaN(pts)) {
await interaction.editReply("❌ Points must be a number.");
return;
}
const kd = parseSlashPair(kdRaw);
const atkDef = parseSlashPair(atkDefRaw);
const heal = parseOptionalInt(healRaw);
if (kdRaw && !kd) {
await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`.");
return;
}
if (atkDefRaw && !atkDef) {
await interaction.editReply("❌ ATK/DEF must be in `atk/def` format, e.g. `120/80`.");
return;
}
const result = await score.submitForUser({
userKey: user.userKey,
pts,
slot,
k: kd?.[0],
d: kd?.[1],
atk: atkDef?.[0],
def: atkDef?.[1],
heal,
});
await interaction.editReply(result.message);
}
}
| 1 | import { |
| 2 | ModalSubmitInteraction, |
| 3 | ModalBuilder, |
| 4 | TextInputBuilder, |
| 5 | TextInputStyle, |
| 6 | ActionRowBuilder, |
| 7 | } from "discord.js"; |
| 8 | import { resolveUser } from "@systems/users"; |
| 9 | import { getEffectiveCharacter } from "@systems/borrow"; |
| 10 | import { score } from "@subcommands/score/submitCore"; |
| 11 | import { 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 | |
| 18 | export 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 | } |