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 { 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", }, 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: "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: "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: "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, }, ], }, ], }, ], }, ], }, ], };