Sends one-shot DMs once a mission is evaluable asking members to rate their leadership (and leaders to rate subordinates), gated by a new evaluationRemindersSentAt marker on Missions with its migration. Adds the /remind-evaluations staff command (manual trigger/sweep) and DISCORD_EVALUATION_POLL_MS config; updates bot and root docs. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
798 lines
26 KiB
TypeScript
798 lines
26 KiB
TypeScript
import type { CollectionConfig, Payload } from "payload";
|
|
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
|
|
|
|
type AssignmentRef = { id: number } | number;
|
|
|
|
async function isInAssignments(
|
|
payload: Payload,
|
|
userId: number,
|
|
assignmentIds: number[],
|
|
): Promise<boolean> {
|
|
if (assignmentIds.length === 0) return false;
|
|
const assignments = await payload.find({
|
|
collection: "assignments",
|
|
where: { id: { in: assignmentIds } },
|
|
limit: assignmentIds.length,
|
|
depth: 0,
|
|
select: { leader: true, members: true },
|
|
});
|
|
return assignments.docs.some((a) => {
|
|
const leaderId = a.leader && typeof a.leader === "object" ? a.leader.id : a.leader;
|
|
const memberIds = (a.members ?? []).map((m) => (typeof m === "object" ? m.id : m));
|
|
return (
|
|
(leaderId !== null && leaderId !== undefined && leaderId === userId) ||
|
|
memberIds.includes(userId)
|
|
);
|
|
});
|
|
}
|
|
|
|
function extractIds(refs: unknown[] | undefined): number[] {
|
|
return (refs ?? []).map((r) =>
|
|
typeof r === "object" && r !== null ? (r as { id: number }).id : (r as number),
|
|
);
|
|
}
|
|
|
|
export const Missions: CollectionConfig = {
|
|
slug: "missions",
|
|
admin: {
|
|
group: "Intelligence",
|
|
useAsTitle: "name",
|
|
},
|
|
hooks: {
|
|
beforeChange: [
|
|
// Loop-safe by construction: this mutates the incoming data of the very
|
|
// operation it runs in, so it never triggers a second write. It only
|
|
// stamps `ownershipAndStatus.completedAt` when a transition lands on
|
|
// "Completed" and no completion time exists yet — auto-complete passes
|
|
// its own sweep time explicitly (preserved by the existing-value check),
|
|
// and re-completing an already-stamped mission keeps the original stamp.
|
|
async ({ data, originalDoc }) => {
|
|
if (data?.ownershipAndStatus?.status !== "Completed") return data;
|
|
const existing =
|
|
data.ownershipAndStatus.completedAt ?? originalDoc?.ownershipAndStatus?.completedAt;
|
|
if (existing) return data;
|
|
return {
|
|
...data,
|
|
ownershipAndStatus: {
|
|
...data.ownershipAndStatus,
|
|
completedAt: new Date().toISOString(),
|
|
},
|
|
};
|
|
},
|
|
],
|
|
},
|
|
access: {
|
|
create: async ({ req }) => {
|
|
return await hasPermission(req.payload, req.user, "missions:create");
|
|
},
|
|
update: async ({ req }) => {
|
|
return await hasPermission(req.payload, req.user, "missions:update");
|
|
},
|
|
delete: async ({ req }) => {
|
|
return await hasPermission(req.payload, req.user, "missions:delete");
|
|
},
|
|
read: async ({ req, data }) => {
|
|
if (!req.user) return false;
|
|
if (await hasPermission(req.payload, req.user, "missions:read")) return true;
|
|
|
|
const userId = req.user.id;
|
|
|
|
const gameRules = await req.payload.findGlobal({ slug: "game-rules" });
|
|
const commandIds = extractIds(gameRules.commandAssignments as AssignmentRef[] | undefined);
|
|
const intelIds = extractIds(gameRules.intelAssignments as AssignmentRef[] | undefined);
|
|
|
|
const [isCommand, isIntel] = await Promise.all([
|
|
isInAssignments(req.payload, userId, commandIds),
|
|
isInAssignments(req.payload, userId, intelIds),
|
|
]);
|
|
|
|
const authorIds = extractIds(data?.ownershipAndStatus?.authors as unknown[] | undefined);
|
|
const zeusIds = extractIds(data?.ownershipAndStatus?.zeus as unknown[] | undefined);
|
|
const isAuthorOrZeus = authorIds.includes(userId) || zeusIds.includes(userId);
|
|
|
|
const status = data?.ownershipAndStatus?.status;
|
|
const isDraft = status === "Concept" || status === "Planning";
|
|
|
|
if (isDraft) {
|
|
return isAuthorOrZeus || isCommand || isIntel;
|
|
}
|
|
|
|
const visibility = data?.ownershipAndStatus?.visibility;
|
|
switch (visibility) {
|
|
case "unit":
|
|
return true;
|
|
case "leadership":
|
|
return isCommand || isIntel || isAuthorOrZeus;
|
|
case "intel":
|
|
return isIntel || isAuthorOrZeus;
|
|
case "private":
|
|
return isAuthorOrZeus;
|
|
default:
|
|
return true;
|
|
}
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: "name",
|
|
type: "text",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "codeName",
|
|
type: "text",
|
|
admin: {
|
|
description:
|
|
'A "code name" provides a unique way to reference this mission beyond just the mission\'s name. For example, a mission called "Operation Dragon Break" could be codenamed "OP:DRGBRK".',
|
|
},
|
|
required: true,
|
|
unique: true,
|
|
},
|
|
{
|
|
name: "summary",
|
|
type: "textarea",
|
|
admin: {
|
|
description:
|
|
"A summary of the mission. This is not the same as a briefing; instead, it describes the mission's theme and concept to the unit as a whole.",
|
|
},
|
|
required: true,
|
|
},
|
|
{
|
|
name: "operationType",
|
|
type: "select",
|
|
options: [
|
|
{
|
|
label: "Main Operation",
|
|
value: "main",
|
|
},
|
|
{
|
|
label: "Side Operation",
|
|
value: "side",
|
|
},
|
|
{
|
|
label: "External Operation",
|
|
value: "external",
|
|
},
|
|
],
|
|
filterOptions: ({ req }) => {
|
|
return [
|
|
{
|
|
label: "Side Operation",
|
|
value: "side",
|
|
},
|
|
{
|
|
label: "External Operation",
|
|
value: "external",
|
|
},
|
|
{
|
|
label: "Main Operation",
|
|
value: "main",
|
|
},
|
|
];
|
|
},
|
|
defaultValue: ({ req }) => "side",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "discordMessageId",
|
|
type: "text",
|
|
admin: {
|
|
description: "ID of the attendance embed message in Discord (managed by the bot).",
|
|
hidden: true,
|
|
},
|
|
},
|
|
{
|
|
name: "discordAttendanceHash",
|
|
type: "text",
|
|
admin: {
|
|
description: "Hash of the last attendance state rendered to Discord (managed by the bot).",
|
|
hidden: true,
|
|
},
|
|
},
|
|
{
|
|
name: "discordAttendanceSentAt",
|
|
type: "date",
|
|
admin: {
|
|
description: "When the attendance roll-call was first posted to Discord (managed by the bot).",
|
|
hidden: true,
|
|
},
|
|
},
|
|
{
|
|
name: "evaluationRemindersSentAt",
|
|
type: "date",
|
|
admin: {
|
|
description:
|
|
"When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).",
|
|
hidden: true,
|
|
},
|
|
},
|
|
{
|
|
name: "coverImage",
|
|
type: "upload",
|
|
relationTo: "media",
|
|
admin: {
|
|
description:
|
|
"Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider.",
|
|
},
|
|
},
|
|
{
|
|
type: "tabs",
|
|
tabs: [
|
|
{
|
|
label: "Mission Configuration",
|
|
description:
|
|
"Essential mission configuration, like the type of mission, what map it is played on, who authored it, etc.",
|
|
fields: [
|
|
{
|
|
name: "classification",
|
|
type: "group",
|
|
fields: [
|
|
{
|
|
name: "map",
|
|
type: "relationship",
|
|
relationTo: "maps",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "missionType",
|
|
type: "select",
|
|
options: ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"],
|
|
defaultValue: "Zeus",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "campaign",
|
|
type: "relationship",
|
|
relationTo: "campaigns",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "customScripts",
|
|
type: "checkbox",
|
|
label: "Contains Custom Scripts",
|
|
admin: {
|
|
description: "This mission contains custom SQF scripts.",
|
|
},
|
|
},
|
|
{
|
|
name: "startDateTime",
|
|
type: "date",
|
|
admin: {
|
|
date: {
|
|
pickerAppearance: "dayAndTime",
|
|
},
|
|
},
|
|
defaultValue: () => {
|
|
const today = new Date();
|
|
const day = today.getDay();
|
|
const diff = (6 - day) % 7;
|
|
const nextSaturday = new Date(today);
|
|
nextSaturday.setDate(today.getDate() + diff + 7);
|
|
nextSaturday.setHours(20, 0, 0, 0);
|
|
return nextSaturday;
|
|
},
|
|
required: true,
|
|
},
|
|
{
|
|
name: "estimatedDuration",
|
|
type: "number",
|
|
label: "Estimated Duration (in minutes)",
|
|
defaultValue: 60 * 4, // Usually about 4 hours
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "ownershipAndStatus",
|
|
type: "group",
|
|
label: "Ownership & Status",
|
|
fields: [
|
|
{
|
|
name: "authors",
|
|
type: "relationship",
|
|
relationTo: "users",
|
|
hasMany: true,
|
|
required: true,
|
|
},
|
|
{
|
|
name: "zeus",
|
|
type: "relationship",
|
|
relationTo: "users",
|
|
hasMany: true,
|
|
required: false,
|
|
},
|
|
{
|
|
name: "status",
|
|
type: "select",
|
|
options: [
|
|
"Concept",
|
|
"Planning",
|
|
"Ready",
|
|
"Scheduled",
|
|
"Active",
|
|
"Completed",
|
|
"Cancelled",
|
|
],
|
|
defaultValue: "Concept",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "visibility",
|
|
type: "select",
|
|
options: [
|
|
{
|
|
label: "Entire Unit",
|
|
value: "unit",
|
|
},
|
|
{
|
|
label: "Leadership & Intel Only",
|
|
value: "leadership",
|
|
},
|
|
{
|
|
label: "Intel Only",
|
|
value: "intel",
|
|
},
|
|
{
|
|
label: "Private",
|
|
value: "private",
|
|
},
|
|
],
|
|
defaultValue: "leadership",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "completedAt",
|
|
type: "date",
|
|
admin: {
|
|
description:
|
|
"When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form.",
|
|
disabled: true,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "missionRoles",
|
|
type: "group",
|
|
fields: [
|
|
{
|
|
name: "maxPlayers",
|
|
type: "number",
|
|
defaultValue: 32,
|
|
max: 32,
|
|
required: true,
|
|
},
|
|
{
|
|
name: "providedRoles",
|
|
type: "array",
|
|
label: "Roles Provided",
|
|
fields: [
|
|
{
|
|
name: "roleName",
|
|
type: "text",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "missionCritical",
|
|
type: "checkbox",
|
|
admin: {
|
|
description:
|
|
"If true, this mission cannot be executed without someone fulfilling this role.",
|
|
},
|
|
defaultValue: true,
|
|
},
|
|
{
|
|
name: "requiredMos",
|
|
type: "relationship",
|
|
hasMany: true,
|
|
relationTo: "qualifications",
|
|
label: "Required MOS/Qualifications",
|
|
admin: {
|
|
description:
|
|
"This role can only be fulfilled by someone possessing these qualifications.",
|
|
},
|
|
},
|
|
{
|
|
name: "minimumRank",
|
|
type: "relationship",
|
|
relationTo: "ranks",
|
|
admin: {
|
|
description:
|
|
"This role can only be fulfilled by someone of this rank or higher.",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "notes",
|
|
type: "textarea",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "Narrative",
|
|
description:
|
|
"Narrative details, like the mission briefing, objectives, civilian presence, RoEs, etc.",
|
|
fields: [
|
|
{
|
|
name: "briefing",
|
|
type: "array",
|
|
maxRows: 8,
|
|
labels: {
|
|
plural: "Briefing Entries",
|
|
singular: "Briefing Entry",
|
|
},
|
|
fields: [
|
|
{
|
|
name: "sectionName",
|
|
type: "text",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "details",
|
|
type: "richText",
|
|
required: true,
|
|
},
|
|
],
|
|
defaultValue: [
|
|
{
|
|
sectionName: "Situation",
|
|
},
|
|
{
|
|
sectionName: "Mission",
|
|
},
|
|
{
|
|
sectionName: "Execution",
|
|
},
|
|
{
|
|
sectionName: "Signals",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "missionObjectives",
|
|
type: "array",
|
|
fields: [
|
|
{
|
|
name: "title",
|
|
type: "text",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "description",
|
|
type: "textarea",
|
|
},
|
|
{
|
|
name: "missionCritical",
|
|
admin: {
|
|
description:
|
|
"If true, this mission will be considered a failure if this objective is failed.",
|
|
},
|
|
type: "checkbox",
|
|
defaultValue: true,
|
|
},
|
|
{
|
|
name: "successOrFailConditions",
|
|
type: "textarea",
|
|
label: "Success / Fail Conditions",
|
|
},
|
|
{
|
|
name: "redacted",
|
|
type: "checkbox",
|
|
label: "Redacted",
|
|
admin: {
|
|
description:
|
|
"If true, this objective will be hidden from players until revealed.",
|
|
},
|
|
defaultValue: false,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "civilianPresence",
|
|
type: "select",
|
|
options: ["None", "Low", "Medium", "High"],
|
|
defaultValue: "None",
|
|
},
|
|
{
|
|
name: "factionsInvolved",
|
|
type: "array",
|
|
fields: [
|
|
{
|
|
name: "faction",
|
|
type: "relationship",
|
|
relationTo: "factions",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "threatLevel",
|
|
type: "select",
|
|
options: [
|
|
{
|
|
label: "Allied",
|
|
value: "ally",
|
|
},
|
|
{
|
|
label: "Friendly",
|
|
value: "friendly",
|
|
},
|
|
{
|
|
label: "Neutral",
|
|
value: "neutral",
|
|
},
|
|
{
|
|
label: "Hostile",
|
|
value: "hostile",
|
|
},
|
|
],
|
|
defaultValue: "hostile",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "Dedicated Server",
|
|
description: "Server-specific details, like the IP, port, mission file, mod list, etc.",
|
|
fields: [
|
|
{
|
|
name: "gameDetails",
|
|
type: "group",
|
|
fields: [
|
|
{
|
|
name: "serverDetails",
|
|
type: "group",
|
|
fields: [
|
|
{
|
|
name: "serverIp",
|
|
type: "text",
|
|
label: "Server IP or Domain Name",
|
|
defaultValue: "arma.onyxsimple.com",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "serverPort",
|
|
type: "number",
|
|
defaultValue: 2302,
|
|
required: true,
|
|
},
|
|
{
|
|
name: "serverPassword",
|
|
type: "text",
|
|
hidden: true,
|
|
defaultValue: "ptf313",
|
|
access: {
|
|
read: async ({ req }) => {
|
|
return await hasPermission(req.payload, req.user, "missions:read-sensitive");
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "gameSetup",
|
|
type: "group",
|
|
fields: [
|
|
{
|
|
name: "missionFile",
|
|
type: "upload",
|
|
relationTo: "mission-files",
|
|
},
|
|
{
|
|
name: "requiredMods",
|
|
type: "upload",
|
|
admin: {
|
|
description:
|
|
"This field is not required only because you may be running a vanilla gamemode. If you have any mods at all in your mission, you MUST include a mod list.",
|
|
},
|
|
relationTo: "mod-lists",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "Advanced Details",
|
|
description:
|
|
"Advanced mission details and configuration. Most of these fields are optional, but for those who want extra-detailed missions, this section is for you.",
|
|
fields: [
|
|
{
|
|
name: "logisticsAndSupport",
|
|
type: "group",
|
|
label: "Logistics & Support",
|
|
admin: {
|
|
description:
|
|
"Mission requirements and conditions surrounding the deployment and use of logistics and support assets during the mission's runtime.",
|
|
},
|
|
fields: [
|
|
{
|
|
type: "row",
|
|
fields: [
|
|
{
|
|
name: "insertion",
|
|
label: "Allowed/Provided Insertion Method(s)",
|
|
type: "select",
|
|
options: [
|
|
{
|
|
label: "Helicopter",
|
|
value: "helicopter",
|
|
},
|
|
{
|
|
label: "Aircraft",
|
|
value: "aircraft",
|
|
},
|
|
{
|
|
label: "HALO / Paradrop",
|
|
value: "paradrop",
|
|
},
|
|
{
|
|
label: "Boat",
|
|
value: "boat",
|
|
},
|
|
{
|
|
label: "Car",
|
|
value: "car",
|
|
},
|
|
{
|
|
label: "On Foot",
|
|
value: "onFoot",
|
|
},
|
|
],
|
|
hasMany: true,
|
|
},
|
|
{
|
|
name: "extraction",
|
|
label: "Allowed/Provided Extraction Method(s)",
|
|
type: "select",
|
|
options: [
|
|
{
|
|
label: "Helicopter",
|
|
value: "helicopter",
|
|
},
|
|
{
|
|
label: "Aircraft",
|
|
value: "aircraft",
|
|
},
|
|
{
|
|
label: "HALO / Paradrop",
|
|
value: "paradrop",
|
|
},
|
|
{
|
|
label: "Boat",
|
|
value: "boat",
|
|
},
|
|
{
|
|
label: "Car",
|
|
value: "car",
|
|
},
|
|
{
|
|
label: "On Foot",
|
|
value: "onFoot",
|
|
},
|
|
],
|
|
hasMany: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "assetsAvailable",
|
|
type: "array",
|
|
labels: {
|
|
plural: "Assets",
|
|
singular: "Asset",
|
|
},
|
|
fields: [
|
|
{
|
|
name: "assetType",
|
|
type: "text",
|
|
admin: {
|
|
description:
|
|
'The name or type of the asset provided (e.g. "Artillery", "M1A1 Tank", "Fuel Truck", "Close Air Support", etc.)',
|
|
},
|
|
required: true,
|
|
},
|
|
{
|
|
name: "assetCount",
|
|
type: "text",
|
|
admin: {
|
|
description:
|
|
"If applicable, the number of this asset that will be available at the start of the mission.",
|
|
},
|
|
},
|
|
{
|
|
name: "doesRespawn",
|
|
type: "checkbox",
|
|
label: "Is Respawnable?",
|
|
admin: {
|
|
description:
|
|
"Whether or not this asset respawns during the mission, either automatically or manually via Zeus.",
|
|
},
|
|
},
|
|
{
|
|
name: "assetCallsign",
|
|
type: "text",
|
|
admin: {
|
|
description:
|
|
"If applicable, the callsign of this asset (such as an aicraft providing close air support.)",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "commsAndDocs",
|
|
type: "group",
|
|
label: "Comms & SOPs",
|
|
admin: {
|
|
description:
|
|
"Communications and Standard Operating Procedures specific to this mission.",
|
|
},
|
|
fields: [
|
|
{
|
|
name: "radioPlan",
|
|
type: "richText",
|
|
admin: {
|
|
description:
|
|
"A detailed description of how radios will be used during this operation, including signals, contingency plans, and other relevant details.",
|
|
},
|
|
},
|
|
{
|
|
name: "frequencies",
|
|
type: "array",
|
|
admin: {
|
|
description:
|
|
"A list and description of each of the radio frequencies used for this mission.",
|
|
},
|
|
fields: [
|
|
{
|
|
type: "row",
|
|
fields: [
|
|
{
|
|
name: "frequency",
|
|
type: "number",
|
|
required: true,
|
|
},
|
|
{
|
|
name: "channel",
|
|
type: "number",
|
|
admin: {
|
|
description:
|
|
"Must be provided if the frequency used requires a separate radio channel.",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "netName",
|
|
type: "text",
|
|
admin: {
|
|
description:
|
|
'The name assigned to this frequency (+ channel.) Examples of names include "COMNET", "PLTNET", "CASNET", etc.',
|
|
},
|
|
required: true,
|
|
},
|
|
{
|
|
name: "freqUsage",
|
|
type: "textarea",
|
|
admin: {
|
|
description:
|
|
"An explanation of the expected usage and purpose of this radio net.",
|
|
},
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|