Compare commits
5 commits
0bda8c56e3
...
c204581366
| Author | SHA1 | Date | |
|---|---|---|---|
| c204581366 | |||
| 4f160c624f | |||
| c12bccca7c | |||
| 0125cd6d4d | |||
| ecdbcbd15b |
5 changed files with 44 additions and 20 deletions
|
|
@ -53,7 +53,10 @@ APP_PORT=3000
|
||||||
# are optional.
|
# are optional.
|
||||||
# DISCORD_TOKEN=
|
# DISCORD_TOKEN=
|
||||||
# DISCORD_GUILD_ID=
|
# DISCORD_GUILD_ID=
|
||||||
|
# Roll call: mission attendance embeds and roll-call posts go here (ops channel).
|
||||||
# DISCORD_OPS_CHANNEL_ID=
|
# DISCORD_OPS_CHANNEL_ID=
|
||||||
|
# App updates: where /announce posts when no channel is given; also the
|
||||||
|
# channel preselected in the /announce compose dialog.
|
||||||
# DISCORD_ANNOUNCE_CHANNEL_ID=
|
# DISCORD_ANNOUNCE_CHANNEL_ID=
|
||||||
# DISCORD_STAFF_ROLE_IDS=
|
# DISCORD_STAFF_ROLE_IDS=
|
||||||
# DISCORD_ATTENDANCE_POLL_MS=
|
# DISCORD_ATTENDANCE_POLL_MS=
|
||||||
|
|
|
||||||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -2,22 +2,22 @@
|
||||||
|
|
||||||
# v0.2.30
|
# 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.
|
A big run for the training grounds: OPORD Recall joins the lineup, you can build your own minefields and radio tests, and the dashboard bends to your will.
|
||||||
|
|
||||||
### Features
|
### 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.
|
- **OPORD Recall**: a new briefing-recall minigame. Study an Arma-style briefing, 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, while beating the par time pays a speed bonus.
|
||||||
- **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 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.
|
- **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.
|
- **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.
|
||||||
- **Personal dashboard**: rearrange the dashboard with drag-and-drop widgets instead of a fixed layout.
|
- **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.
|
- **Campaigns rework**: the campaigns page splits into official and community sections.
|
||||||
- **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.
|
- **Voiceover**: recording pages gate script spoilers behind a hold-to-confirm 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.
|
- **Missions**: a code name is no longer required, and missions can carry a custom cover image.
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
- Side op uploads accept mod lists pasted as plain text or HTML, and the narrative section stays collapsed until you open it.
|
- Side op uploads accept mod lists pasted as plain text or HTML, and the narrative section stays collapsed until you open it. Briefing sections are no longer required unless you open that section.
|
||||||
- All minigames moved under `/minigames/*`. Old links redirect, so bookmarks keep working.
|
- All minigames moved under `/minigames/*`. Old links redirect, so bookmarks keep working.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.31",
|
"version": "0.2.32",
|
||||||
"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.",
|
"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",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ bot/
|
||||||
ping.ts # /ping (global, DM-usable)
|
ping.ts # /ping (global, DM-usable)
|
||||||
signup.ts # /signup (DM-only, creates Payload user with temp password)
|
signup.ts # /signup (DM-only, creates Payload user with temp password)
|
||||||
link.ts # /link (global, links discordId to existing user)
|
link.ts # /link (global, links discordId to existing user)
|
||||||
announce.ts # /announce (guild-only, staff only; message omitted = compose modal, handler in this file)
|
announce.ts # /announce (guild-only, staff only; message omitted = compose modal with channel picker, handler in this file)
|
||||||
remindEvaluations.ts # /remind-evaluations (guild-only, staff only, manual evaluation-reminder trigger)
|
remindEvaluations.ts # /remind-evaluations (guild-only, staff only, manual evaluation-reminder trigger)
|
||||||
|
|
||||||
events/
|
events/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import {
|
import {
|
||||||
ActionRowBuilder,
|
ChannelSelectMenuBuilder,
|
||||||
|
ChannelType,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
|
LabelBuilder,
|
||||||
MessageFlags,
|
MessageFlags,
|
||||||
ModalBuilder,
|
ModalBuilder,
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
|
|
@ -20,6 +22,7 @@ const ANNOUNCE_COLOR = 0xf59e0b;
|
||||||
const ANNOUNCE_MODAL_PREFIX = "ptf-announce:";
|
const ANNOUNCE_MODAL_PREFIX = "ptf-announce:";
|
||||||
const TITLE_INPUT_ID = "ptf-announce-title";
|
const TITLE_INPUT_ID = "ptf-announce-title";
|
||||||
const BODY_INPUT_ID = "ptf-announce-body";
|
const BODY_INPUT_ID = "ptf-announce-body";
|
||||||
|
const CHANNEL_INPUT_ID = "ptf-announce-channel";
|
||||||
/** Modal paragraph inputs cap at 4000 chars; the embed description limit is 4096. */
|
/** Modal paragraph inputs cap at 4000 chars; the embed description limit is 4096. */
|
||||||
const MAX_BODY_LENGTH = 4000;
|
const MAX_BODY_LENGTH = 4000;
|
||||||
|
|
||||||
|
|
@ -33,6 +36,12 @@ const modalText = (fields: ModalSubmitInteraction["fields"], customId: string):
|
||||||
return field?.type === ComponentType.TextInput ? field.value : "";
|
return field?.type === ComponentType.TextInput ? field.value : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const modalChannelId = (fields: ModalSubmitInteraction["fields"]): string | null => {
|
||||||
|
const field = fields.fields.get(CHANNEL_INPUT_ID);
|
||||||
|
if (field?.type !== ComponentType.ChannelSelect) return null;
|
||||||
|
return field.channels?.first()?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
const postAnnouncement = async (
|
const postAnnouncement = async (
|
||||||
channel: TextBasedChannelFields,
|
channel: TextBasedChannelFields,
|
||||||
title: string,
|
title: string,
|
||||||
|
|
@ -75,33 +84,33 @@ export const announceCommand: BotCommand = {
|
||||||
|
|
||||||
const message = (interaction.options.getString("message") ?? "").trim();
|
const message = (interaction.options.getString("message") ?? "").trim();
|
||||||
const option = interaction.options.getChannel("channel");
|
const option = interaction.options.getChannel("channel");
|
||||||
const channelId = option?.id ?? botConfig.announceChannelId ?? botConfig.opsChannelId;
|
const channelId = option?.id ?? botConfig.announceChannelId;
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
await interaction.reply(
|
await interaction.reply(
|
||||||
ephemeral("No announce channel is configured and no channel was given."),
|
ephemeral(
|
||||||
|
"No announce channel is configured (set DISCORD_ANNOUNCE_CHANNEL_ID) and no channel was given.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
// Compose in a modal. The resolved channel rides along in the custom id,
|
// customId carries the default channel as the fallback: modal submits
|
||||||
// because modal submit interactions do not carry the command's options.
|
// don't carry the command's options.
|
||||||
const modal = new ModalBuilder()
|
const modal = new ModalBuilder()
|
||||||
.setCustomId(`${ANNOUNCE_MODAL_PREFIX}${channelId}`)
|
.setCustomId(`${ANNOUNCE_MODAL_PREFIX}${channelId}`)
|
||||||
.setTitle("Post an announcement");
|
.setTitle("Post an announcement");
|
||||||
modal.addComponents(
|
modal.addLabelComponents(
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
new LabelBuilder().setLabel("Title (optional)").setTextInputComponent(
|
||||||
new TextInputBuilder()
|
new TextInputBuilder()
|
||||||
.setCustomId(TITLE_INPUT_ID)
|
.setCustomId(TITLE_INPUT_ID)
|
||||||
.setLabel("Title (optional)")
|
|
||||||
.setStyle(TextInputStyle.Short)
|
.setStyle(TextInputStyle.Short)
|
||||||
.setMaxLength(256)
|
.setMaxLength(256)
|
||||||
.setRequired(false),
|
.setRequired(false),
|
||||||
),
|
),
|
||||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
new LabelBuilder().setLabel("Announcement text").setTextInputComponent(
|
||||||
new TextInputBuilder()
|
new TextInputBuilder()
|
||||||
.setCustomId(BODY_INPUT_ID)
|
.setCustomId(BODY_INPUT_ID)
|
||||||
.setLabel("Announcement text")
|
|
||||||
.setStyle(TextInputStyle.Paragraph)
|
.setStyle(TextInputStyle.Paragraph)
|
||||||
.setMaxLength(MAX_BODY_LENGTH)
|
.setMaxLength(MAX_BODY_LENGTH)
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
|
|
@ -109,6 +118,16 @@ export const announceCommand: BotCommand = {
|
||||||
"Paste your announcement. Markdown (headings, lists, bold, code) is supported.",
|
"Paste your announcement. Markdown (headings, lists, bold, code) is supported.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
new LabelBuilder()
|
||||||
|
.setLabel("Channel (optional)")
|
||||||
|
.setDescription("Defaults to the configured app updates channel.")
|
||||||
|
.setChannelSelectMenuComponent(
|
||||||
|
new ChannelSelectMenuBuilder()
|
||||||
|
.setCustomId(CHANNEL_INPUT_ID)
|
||||||
|
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
|
||||||
|
.setDefaultChannels(channelId)
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await interaction.showModal(modal);
|
await interaction.showModal(modal);
|
||||||
return;
|
return;
|
||||||
|
|
@ -145,9 +164,11 @@ export const handleAnnounceModal = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// The modal custom id is "ptf-announce:<channelId>", written when the modal
|
// The modal custom id is "ptf-announce:<channelId>", written when the modal
|
||||||
// was opened from the slash command. Fall back to the configured channel.
|
// was opened. A channel picked in the modal overrides it.
|
||||||
const customChannelId = interaction.customId.slice(ANNOUNCE_MODAL_PREFIX.length);
|
const customChannelId = interaction.customId.slice(ANNOUNCE_MODAL_PREFIX.length);
|
||||||
const resolvedId = customChannelId || botConfig.announceChannelId || botConfig.opsChannelId;
|
const resolvedId =
|
||||||
|
modalChannelId(interaction.fields) ??
|
||||||
|
(customChannelId || botConfig.announceChannelId);
|
||||||
if (!resolvedId) {
|
if (!resolvedId) {
|
||||||
await interaction.reply(ephemeral("No announce channel is configured."));
|
await interaction.reply(ephemeral("No announce channel is configured."));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue