Compare commits
4 commits
cb4aa0bf7e
...
0bda8c56e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bda8c56e3 | |||
| abb037fb05 | |||
| cb62a2f1b7 | |||
| bfffb5fdd3 |
5 changed files with 169 additions and 33 deletions
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Changelog
|
||||
|
||||
# v0.2.30
|
||||
|
||||
A big run for the training grounds: OPORD Recall joins the lineup, you can build your own minefields and radio tests, the Chronicle grows story beats, and the dashboard bends to your will.
|
||||
|
||||
### Features
|
||||
|
||||
- **OPORD Recall**: a new briefing-recall minigame. Study an Arma-style briefing (grids in the 034-672 format), then answer questions about what you read. Tiers scale to 10, 15, or 20 questions, drawing on garrison details, ROE, intel, and secondary objectives. Each answer unlocks behind a 2-second countdown with a draining timer bar, aborting mid-run offers six contingency options, and Esc or R resets at any time. Per-tier best times sit on the leaderboard, beating the par time pays a speed bonus, and an anti-cheat pass keeps results honest.
|
||||
- **Custom minefields**: author and share your own EOD minefields, with play and leaderboard included. Clear two sectors in the regular game to unlock them. Runs pay reduced XP and a slice goes to the field's creator. Authors decide whether edge mines are exposed up front, hidden cells show a question-mark fog, and a loss reveals only part of the field.
|
||||
- **Custom radio tests**: build your own radio traffic tests with the same play-and-leaderboard setup, unlocked through the radio game.
|
||||
- **Chronicle story beats**: the Chronicle now carries structured story beats with reactions, tracks what you've already read, and has a chapter table of contents with hover previews.
|
||||
- **Personal dashboard**: rearrange the dashboard with drag-and-drop widgets instead of a fixed layout.
|
||||
- **Campaigns rework**: the campaigns page splits into official and community sections. Official campaigns get the tactical card treatment, glitch flourish included, and the list can hide completed campaigns.
|
||||
- **Voiceover**: recording pages gate script spoilers behind a hold-to-confirm, script authors can keep drafts hidden from players, staff get an audio preview in the admin panel, and approved reviews now pay out XP and notify you.
|
||||
- **Missions**: a code name is no longer required, and missions can carry a custom cover image.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Side op uploads accept mod lists pasted as plain text or HTML, and the narrative section stays collapsed until you open it.
|
||||
- All minigames moved under `/minigames/*`. Old links redirect, so bookmarks keep working.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Mission visibility rules are now enforced everywhere: collection reads, the missions pages, the dashboard, and intel queries.
|
||||
- The radio unlock progress readout can no longer overstate how close you are to unlocking custom content.
|
||||
- The login return link lands you back on the page you came from, minigames included.
|
||||
- Dashboard cards that stretch now fill their cell and truncate instead of spilling over.
|
||||
- OPORD quiz answers wrap long text instead of running off the button.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.30",
|
||||
"version": "0.2.31",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ bot/
|
|||
ping.ts # /ping (global, DM-usable)
|
||||
signup.ts # /signup (DM-only, creates Payload user with temp password)
|
||||
link.ts # /link (global, links discordId to existing user)
|
||||
announce.ts # /announce (guild-only, staff only)
|
||||
announce.ts # /announce (guild-only, staff only; message omitted = compose modal, handler in this file)
|
||||
remindEvaluations.ts # /remind-evaluations (guild-only, staff only, manual evaluation-reminder trigger)
|
||||
|
||||
events/
|
||||
|
|
|
|||
|
|
@ -1,78 +1,180 @@
|
|||
import {
|
||||
ActionRowBuilder,
|
||||
ComponentType,
|
||||
EmbedBuilder,
|
||||
MessageFlags,
|
||||
ModalBuilder,
|
||||
SlashCommandBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
type InteractionReplyOptions,
|
||||
type ModalSubmitInteraction,
|
||||
type TextBasedChannelFields,
|
||||
} from "discord.js";
|
||||
import type { Payload } from "payload";
|
||||
import type { BotCommand } from "./index";
|
||||
import { botConfig } from "@/bot/config";
|
||||
import { isStaff } from "@/bot/lib/roles";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
||||
const ANNOUNCE_COLOR = 0xf59e0b;
|
||||
const ANNOUNCE_MODAL_PREFIX = "ptf-announce:";
|
||||
const TITLE_INPUT_ID = "ptf-announce-title";
|
||||
const BODY_INPUT_ID = "ptf-announce-body";
|
||||
/** Modal paragraph inputs cap at 4000 chars; the embed description limit is 4096. */
|
||||
const MAX_BODY_LENGTH = 4000;
|
||||
|
||||
const ephemeral = (content: string): InteractionReplyOptions => ({
|
||||
content,
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
|
||||
const modalText = (fields: ModalSubmitInteraction["fields"], customId: string): string => {
|
||||
const field = fields.fields.get(customId);
|
||||
return field?.type === ComponentType.TextInput ? field.value : "";
|
||||
};
|
||||
|
||||
const postAnnouncement = async (
|
||||
channel: TextBasedChannelFields,
|
||||
title: string,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
await channel.send({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setTitle(title || "Announcement")
|
||||
.setDescription(message)
|
||||
.setColor(ANNOUNCE_COLOR),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export const announceCommand: BotCommand = {
|
||||
builder: new SlashCommandBuilder()
|
||||
.setName("announce")
|
||||
.setDescription("Post an announcement to a channel")
|
||||
.addStringOption((option) =>
|
||||
option.setName("message").setDescription("The announcement text").setRequired(true),
|
||||
option
|
||||
.setName("message")
|
||||
.setDescription("Announcement text, or leave blank to compose in a dialog")
|
||||
.setRequired(false),
|
||||
)
|
||||
.addChannelOption((option) =>
|
||||
option.setName("channel").setDescription("Channel to post to"),
|
||||
) as SlashCommandBuilder,
|
||||
execute: async (interaction, payload) => {
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({
|
||||
content: "This command only works in a server.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
await interaction.reply(ephemeral("This command only works in a server."));
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||
if (!(await isStaff(member, payload))) {
|
||||
await interaction.reply({
|
||||
content: "You don't have permission to announce.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
await interaction.reply(ephemeral("You don't have permission to announce."));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = interaction.options.getString("message", true);
|
||||
const message = (interaction.options.getString("message") ?? "").trim();
|
||||
const option = interaction.options.getChannel("channel");
|
||||
const channelId = option?.id ?? botConfig.announceChannelId ?? botConfig.opsChannelId;
|
||||
if (!channelId) {
|
||||
await interaction.reply({
|
||||
content: "No announce channel is configured and no channel was given.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
await interaction.reply(
|
||||
ephemeral("No announce channel is configured and no channel was given."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
// Compose in a modal. The resolved channel rides along in the custom id,
|
||||
// because modal submit interactions do not carry the command's options.
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`${ANNOUNCE_MODAL_PREFIX}${channelId}`)
|
||||
.setTitle("Post an announcement");
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId(TITLE_INPUT_ID)
|
||||
.setLabel("Title (optional)")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setMaxLength(256)
|
||||
.setRequired(false),
|
||||
),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId(BODY_INPUT_ID)
|
||||
.setLabel("Announcement text")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setMaxLength(MAX_BODY_LENGTH)
|
||||
.setRequired(true)
|
||||
.setPlaceholder(
|
||||
"Paste your announcement. Markdown (headings, lists, bold, code) is supported.",
|
||||
),
|
||||
),
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
return;
|
||||
}
|
||||
|
||||
const optionChannel = option && "send" in option ? (option as TextBasedChannelFields) : null;
|
||||
const channel = optionChannel ?? (await interaction.guild.channels.fetch(channelId));
|
||||
if (!channel || !("send" in channel)) {
|
||||
await interaction.reply({
|
||||
content: "That channel isn't a text channel.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
await interaction.reply(ephemeral("That channel isn't a text channel."));
|
||||
return;
|
||||
}
|
||||
|
||||
await (channel as TextBasedChannelFields).send({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setTitle("Announcement")
|
||||
.setDescription(message)
|
||||
.setColor(ANNOUNCE_COLOR),
|
||||
],
|
||||
});
|
||||
await postAnnouncement(channel as TextBasedChannelFields, "Announcement", message);
|
||||
|
||||
const name = (channel as unknown as { name?: string | null }).name ?? channelId;
|
||||
await interaction.reply({
|
||||
content: `Announcement posted to #${name}.`,
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
await interaction.reply(ephemeral(`Announcement posted to #${name}.`));
|
||||
},
|
||||
};
|
||||
|
||||
export const handleAnnounceModal = async (
|
||||
interaction: ModalSubmitInteraction,
|
||||
payload: Payload,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply(ephemeral("This command only works in a server."));
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||
if (!(await isStaff(member, payload))) {
|
||||
await interaction.reply(ephemeral("You don't have permission to announce."));
|
||||
return;
|
||||
}
|
||||
|
||||
// The modal custom id is "ptf-announce:<channelId>", written when the modal
|
||||
// was opened from the slash command. Fall back to the configured channel.
|
||||
const customChannelId = interaction.customId.slice(ANNOUNCE_MODAL_PREFIX.length);
|
||||
const resolvedId = customChannelId || botConfig.announceChannelId || botConfig.opsChannelId;
|
||||
if (!resolvedId) {
|
||||
await interaction.reply(ephemeral("No announce channel is configured."));
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await interaction.guild.channels.fetch(resolvedId);
|
||||
if (!channel || !("send" in channel)) {
|
||||
await interaction.reply(ephemeral("That channel isn't a text channel."));
|
||||
return;
|
||||
}
|
||||
|
||||
const title = modalText(interaction.fields, TITLE_INPUT_ID).trim();
|
||||
const message = modalText(interaction.fields, BODY_INPUT_ID).trim();
|
||||
if (!message) {
|
||||
await interaction.reply(ephemeral("The announcement text was empty."));
|
||||
return;
|
||||
}
|
||||
|
||||
await postAnnouncement(channel as TextBasedChannelFields, title, message);
|
||||
|
||||
const name = (channel as unknown as { name?: string | null }).name ?? resolvedId;
|
||||
await interaction.reply(ephemeral(`Announcement posted to #${name}.`));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Bot] Announce modal failed: ${message}`);
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply(ephemeral("Something went wrong while posting the announcement."));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { getPayload } from "payload";
|
|||
import config from "@payload-config";
|
||||
import { botConfig } from "./config";
|
||||
import { commands } from "./commands";
|
||||
import { handleAnnounceModal } from "./commands/announce";
|
||||
import { services } from "./services";
|
||||
import { handleButtonInteraction } from "./events/interactionCreate";
|
||||
import { handleTransferInteraction } from "./events/transferInteractions";
|
||||
|
|
@ -80,6 +81,10 @@ const main = async () => {
|
|||
await handleTransferInteraction(interaction, payload);
|
||||
return;
|
||||
}
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith("ptf-announce:")) {
|
||||
await handleAnnounceModal(interaction, payload);
|
||||
return;
|
||||
}
|
||||
if (!interaction.isChatInputCommand()) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue