From 7664aad4ef9d9dc9ea54a80e2e1c898492b9016d Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 6 Sep 2026 23:29:52 -0400 Subject: [PATCH] feat(missions): add Friday side-op scheduling server actions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../intelligence/missions/actions.ts | 731 +++++++++++++++++- 1 file changed, 730 insertions(+), 1 deletion(-) diff --git a/src/app/(frontend)/intelligence/missions/actions.ts b/src/app/(frontend)/intelligence/missions/actions.ts index 81025c2..ca8d8a6 100644 --- a/src/app/(frontend)/intelligence/missions/actions.ts +++ b/src/app/(frontend)/intelligence/missions/actions.ts @@ -5,6 +5,12 @@ import { getPayload } from "payload"; import type { AttendanceResponse } from "@/lib/attendance"; import { setMissionAttendance as setAttendance } from "@/lib/attendance"; import { notifyAttendanceChanged } from "@/lib/attendance/realtime"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import { textToLexical } from "@/lib/intelligence/briefingText"; +import type { Mission } from "@/payload-types"; export interface ActionResult { success: boolean; @@ -19,7 +25,7 @@ async function authenticate() { const hdrs = await headers(); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); - if (!user) { + if (!user || !isPayloadUser(user)) { throw new Error("Unauthorized"); } @@ -57,3 +63,726 @@ export async function setMissionAttendance(input: { : { success: false, error: message }; } } + +const MISSION_TYPES = ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"] as const; + +// Not exported: "use server" files may only export async functions. +const MAX_SCHEDULED_AHEAD_MISSIONS = 2; + +function isFridayDate(value: string): boolean { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return false; + } + return date.getDay() === 5; +} + +// Side ops always start 20:00 server-local (Eastern) on the validated Friday. +function fridayStartDateTime(value: string): string { + const date = new Date(value); + date.setHours(20, 0, 0, 0); + return date.toISOString(); +} + +type SideOpMissionUser = { + id: number; + username: string; + displayName?: string | null; +}; + +async function resolveSideOpCampaign( + payload: Awaited>, + user: SideOpMissionUser, + campaignId?: number | null, + newCampaignName?: string | null, +): Promise<{ campaignId: number; created: boolean }> { + if (campaignId) { + const campaign = await payload.findByID({ + collection: "campaigns", + id: campaignId, + depth: 0, + }); + const ownerId = typeof campaign.owner === "object" ? campaign.owner?.id : campaign.owner; + if (ownerId !== user.id) { + throw new Error("You can only schedule missions into campaigns you own."); + } + return { campaignId: campaign.id, created: false }; + } + + const name = + newCampaignName?.trim() || `${user.displayName || user.username}'s Side Ops`; + + const existing = await payload.find({ + collection: "campaigns", + where: { and: [{ name: { equals: name } }, { owner: { equals: user.id } }] }, + limit: 1, + depth: 0, + }); + if (existing.docs.length > 0) { + return { campaignId: existing.docs[0].id, created: false }; + } + + const campaign = await payload.create({ + collection: "campaigns", + overrideAccess: true, + data: { + name, + summary: "Player-organized Friday side operations.", + status: "concept", + campaignMode: "custom", + owner: user.id, + startDate: new Date().toISOString().slice(0, 10), + }, + }); + return { campaignId: campaign.id, created: true }; +} + +async function countUpcomingPlayerMissions( + payload: Awaited>, + userId: number, + excludeMissionId?: number, +): Promise { + const result = await payload.count({ + collection: "missions", + where: { + and: [ + { createdBy: { equals: userId } }, + { "classification.startDateTime": { greater_than: new Date().toISOString() } }, + { "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } }, + ...(excludeMissionId ? [{ id: { not_equals: excludeMissionId } }] : []), + ], + }, + }); + return result.totalDocs; +} + +function isSideOpOwner( + mission: { createdBy?: number | { id: number } | null }, + userId: number, +): boolean { + const ownerId = typeof mission.createdBy === "object" ? mission.createdBy?.id : mission.createdBy; + return ownerId === userId; +} + +type InfilExfilMethod = "helicopter" | "aircraft" | "paradrop" | "boat" | "car" | "onFoot"; + +const INFIL_EXFIL_METHODS: readonly InfilExfilMethod[] = [ + "helicopter", + "aircraft", + "paradrop", + "boat", + "car", + "onFoot", +]; + +export async function uploadSideOpModlist( + formData: FormData, +): Promise> { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + + const file = formData.get("file"); + const name = formData.get("name"); + if (!(file instanceof File) || file.size === 0) { + return { success: false, error: "Please choose a mod list file." }; + } + if (typeof name !== "string" || !name.trim()) { + return { success: false, error: "Please give the mod list a name." }; + } + if (file.size > 10 * 1024 * 1024) { + return { success: false, error: "Mod list file must be 10 MB or smaller." }; + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const created = await payload.create({ + collection: "mod-lists", + overrideAccess: true, + data: { + modlistName: name.trim(), + author: userId, + }, + file: { + data: buffer, + mimetype: file.type || "text/html", + name: file.name, + size: file.size, + }, + }); + + return { success: true, data: { modlistId: created.id, modlistName: created.modlistName } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export interface FridaySideOpInput { + name: string; + codeName: string; + summary: string; + mapId?: number; + missionType: (typeof MISSION_TYPES)[number]; + startDateTime: string; + estimatedDuration?: number; + maxPlayers?: number; + campaignId?: number | null; + newCampaignName?: string | null; + noCampaign?: boolean; + customMapName?: string | null; + requiredModsId?: number | null; + briefing?: { sectionName: string; details: string }[] | null; + missionObjectives?: { + title: string; + description?: string | null; + missionCritical?: boolean | null; + successOrFailConditions?: string | null; + redacted?: boolean | null; + }[] | null; + civilianPresence?: "None" | "Low" | "Medium" | "High" | null; + factionsInvolved?: { + faction: number; + threatLevel: "ally" | "friendly" | "neutral" | "hostile"; + }[] | null; + radioPlan?: string | null; + insertion?: InfilExfilMethod[] | null; + extraction?: InfilExfilMethod[] | null; + assetsAvailable?: { + assetType: string; + assetCount?: string | null; + doesRespawn?: boolean | null; + assetCallsign?: string | null; + }[] | null; +} + +type SideOpNarrativeData = { + briefing?: { sectionName: string; details: ReturnType }[]; + missionObjectives?: FridaySideOpInput["missionObjectives"]; + civilianPresence?: FridaySideOpInput["civilianPresence"]; + factionsInvolved?: FridaySideOpInput["factionsInvolved"]; + commsAndDocs?: { radioPlan?: ReturnType }; + logisticsAndSupport?: { + insertion?: InfilExfilMethod[] | null; + extraction?: InfilExfilMethod[] | null; + assetsAvailable?: FridaySideOpInput["assetsAvailable"]; + }; +}; + +function narrativeData(input: { + briefing?: FridaySideOpInput["briefing"]; + missionObjectives?: FridaySideOpInput["missionObjectives"]; + civilianPresence?: FridaySideOpInput["civilianPresence"]; + factionsInvolved?: FridaySideOpInput["factionsInvolved"]; + radioPlan?: FridaySideOpInput["radioPlan"]; + insertion?: FridaySideOpInput["insertion"]; + extraction?: FridaySideOpInput["extraction"]; + assetsAvailable?: FridaySideOpInput["assetsAvailable"]; +}): SideOpNarrativeData { + const commsAndDocs: SideOpNarrativeData["commsAndDocs"] = {}; + if (input.radioPlan !== undefined) { + commsAndDocs.radioPlan = textToLexical(input.radioPlan ?? ""); + } + const logisticsAndSupport: SideOpNarrativeData["logisticsAndSupport"] = {}; + if (input.insertion !== undefined) { + logisticsAndSupport.insertion = input.insertion; + } + if (input.extraction !== undefined) { + logisticsAndSupport.extraction = input.extraction; + } + if (input.assetsAvailable !== undefined) { + logisticsAndSupport.assetsAvailable = input.assetsAvailable; + } + return { + ...(input.briefing !== undefined + ? { + briefing: (input.briefing ?? []).map((entry) => ({ + sectionName: entry.sectionName, + details: textToLexical(entry.details), + })), + } + : {}), + ...(input.missionObjectives !== undefined ? { missionObjectives: input.missionObjectives } : {}), + ...(input.civilianPresence !== undefined ? { civilianPresence: input.civilianPresence } : {}), + ...(input.factionsInvolved !== undefined ? { factionsInvolved: input.factionsInvolved } : {}), + ...(Object.keys(commsAndDocs).length > 0 ? { commsAndDocs } : {}), + ...(Object.keys(logisticsAndSupport).length > 0 ? { logisticsAndSupport } : {}), + }; +} + +async function findActiveMissionOnFriday( + payload: Awaited>, + startDateTime: string, + excludeMissionId?: number, +): Promise<{ id: number; name: string; codeName: string } | null> { + const start = new Date(fridayStartDateTime(startDateTime)); + const dayStart = new Date(start); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayEnd.getDate() + 1); + + const res = await payload.find({ + collection: "missions", + where: { + and: [ + { "classification.startDateTime": { greater_than_equal: dayStart.toISOString() } }, + { "classification.startDateTime": { less_than: dayEnd.toISOString() } }, + { "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } }, + ...(excludeMissionId ? [{ id: { not_equals: excludeMissionId } }] : []), + ], + }, + depth: 0, + limit: 1, + overrideAccess: true, + select: { name: true, codeName: true }, + }); + return res.docs[0] ?? null; +} + +async function resolveSideOpMap( + payload: Awaited>, + input: { mapId?: number | null; customMapName?: string | null }, +): Promise { + const customName = input.customMapName?.trim(); + if (customName) { + const exact = await payload.find({ + collection: "maps", + where: { name: { equals: customName } }, + depth: 0, + limit: 1, + overrideAccess: true, + }); + if (exact.docs.length > 0) return exact.docs[0].id; + const all = await payload.find({ + collection: "maps", + depth: 0, + limit: 500, + overrideAccess: true, + sort: "name", + }); + const existing = all.docs.find( + (m) => m.name.toLocaleLowerCase() === customName.toLocaleLowerCase(), + ); + if (existing) return existing.id; + const created = await payload.create({ + collection: "maps", + data: { name: customName }, + overrideAccess: true, + }); + return created.id; + } + return input.mapId ?? null; +} + +export async function createFridaySideOp( + input: FridaySideOpInput, +): Promise> { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + + if (!input.name?.trim() || !input.summary?.trim() || !input.codeName?.trim()) { + return { success: false, error: "Name, code name, and summary are required." }; + } + if (!input.mapId && !input.customMapName?.trim()) { + return { success: false, error: "Please choose a map." }; + } + if (!MISSION_TYPES.includes(input.missionType)) { + return { success: false, error: "Please choose a valid mission type." }; + } + if (!isFridayDate(input.startDateTime)) { + return { + success: false, + error: "Side operations can only be scheduled on a Friday.", + }; + } + const startDate = new Date(input.startDateTime); + if (Number.isNaN(startDate.getTime())) { + return { success: false, error: "Invalid start date/time." }; + } + if (startDate.getTime() <= Date.now()) { + return { success: false, error: "Side operations must be scheduled in the future." }; + } + + const scheduledAhead = await countUpcomingPlayerMissions(payload, userId); + if (scheduledAhead >= MAX_SCHEDULED_AHEAD_MISSIONS) { + return { + success: false, + error: `You already have ${MAX_SCHEDULED_AHEAD_MISSIONS} upcoming missions scheduled. Cancel or complete one before scheduling another.`, + }; + } + + const fridayConflict = await findActiveMissionOnFriday(payload, input.startDateTime); + if (fridayConflict) { + return { + success: false, + error: `That Friday is already taken by "${fridayConflict.name}" (${fridayConflict.codeName}) — only one operation per Friday, first come first served.`, + }; + } + + const campaignId = input.noCampaign + ? null + : ( + await resolveSideOpCampaign( + payload, + { id: userId, username: user.username, displayName: user.displayName }, + input.campaignId ?? null, + input.newCampaignName ?? null, + ) + ).campaignId; + + const resolvedMapId = await resolveSideOpMap(payload, { + mapId: input.mapId, + customMapName: input.customMapName, + }); + if (resolvedMapId == null) { + return { success: false, error: "Please choose a map." }; + } + + const mission = await payload.create({ + collection: "missions", + overrideAccess: true, + data: { + name: input.name.trim(), + codeName: input.codeName.trim(), + summary: input.summary.trim(), + operationType: "side", + createdBy: userId, + ownershipAndStatus: { + status: "Scheduled", + visibility: "unit", + authors: [userId], + }, + classification: { + map: resolvedMapId, + missionType: input.missionType, + campaign: campaignId, + startDateTime: fridayStartDateTime(input.startDateTime), + estimatedDuration: input.estimatedDuration ?? 240, + }, + missionRoles: { + maxPlayers: input.maxPlayers ?? 32, + }, + gameDetails: { + serverDetails: { + serverIp: "arma.onyxsimple.com", + serverPort: 2302, + }, + ...(input.requiredModsId + ? { gameSetup: { requiredMods: input.requiredModsId } } + : {}), + }, + ...narrativeData(input), + }, + }); + + const startIso = fridayStartDateTime(input.startDateTime); + await emitGameEvent(payload, { + type: EventTypes.MissionSchedule, + message: `${user.displayName || user.username} scheduled side operation "${mission.name}" (${mission.codeName}) for ${new Date(startIso).toLocaleString()}.`, + actor: userId, + targetCollection: "missions", + targetId: mission.id, + data: { startDateTime: startIso, campaignId }, + }); + + return { success: true, data: { missionId: mission.id } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export interface UpdateFridaySideOpInput { + missionId: number; + name?: string; + codeName?: string; + summary?: string; + mapId?: number; + missionType?: (typeof MISSION_TYPES)[number]; + startDateTime?: string; + estimatedDuration?: number; + maxPlayers?: number; + campaignId?: number | null; + newCampaignName?: string | null; + noCampaign?: boolean; + customMapName?: string | null; + requiredModsId?: number | null; + briefing?: { sectionName: string; details: string }[] | null; + missionObjectives?: FridaySideOpInput["missionObjectives"]; + civilianPresence?: FridaySideOpInput["civilianPresence"]; + factionsInvolved?: FridaySideOpInput["factionsInvolved"]; + radioPlan?: string | null; + insertion?: InfilExfilMethod[] | null; + extraction?: InfilExfilMethod[] | null; + assetsAvailable?: FridaySideOpInput["assetsAvailable"]; +} + +export async function updateFridaySideOp( + input: UpdateFridaySideOpInput, +): Promise> { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + + const mission = await payload.findByID({ + collection: "missions", + id: input.missionId, + depth: 0, + }); + + const canManage = await hasPermission(payload, user, "missions:update"); + if (!canManage && !isSideOpOwner(mission, userId)) { + return { + success: false, + error: "Only the player who scheduled this side operation can edit it.", + }; + } + if (["Completed", "Cancelled"].includes(mission.ownershipAndStatus.status)) { + return { + success: false, + error: `This mission is ${mission.ownershipAndStatus.status.toLowerCase()} and can no longer be edited.`, + }; + } + + if (input.startDateTime !== undefined) { + if (!isFridayDate(input.startDateTime)) { + return { + success: false, + error: "Side operations can only be scheduled on a Friday.", + }; + } + const startDate = new Date(input.startDateTime); + if (Number.isNaN(startDate.getTime())) { + return { success: false, error: "Invalid start date/time." }; + } + if (startDate.getTime() <= Date.now()) { + return { success: false, error: "Side operations must be scheduled in the future." }; + } + const fridayConflict = await findActiveMissionOnFriday( + payload, + input.startDateTime, + input.missionId, + ); + if (fridayConflict) { + return { + success: false, + error: `That Friday is already taken by "${fridayConflict.name}" (${fridayConflict.codeName}) — only one operation per Friday, first come first served.`, + }; + } + } + + let resolvedCampaignId: number | null = null; + let campaignChangeRequested = false; + if (input.noCampaign) { + campaignChangeRequested = true; + } else if (input.campaignId != null || (input.newCampaignName && input.newCampaignName.trim())) { + const resolved = await resolveSideOpCampaign( + payload, + user, + input.campaignId ?? null, + input.newCampaignName ?? null, + ); + resolvedCampaignId = resolved.campaignId; + campaignChangeRequested = true; + } + + type SideOpClassificationUpdate = { + map?: number; + missionType?: Mission["classification"]["missionType"]; + campaign?: number | null; + startDateTime?: string; + estimatedDuration?: number; + }; + const classification: SideOpClassificationUpdate = {}; + const data: { + name?: string; + codeName?: string; + summary?: string; + classification?: SideOpClassificationUpdate; + missionRoles?: { maxPlayers?: number }; + gameDetails?: { gameSetup?: { requiredMods?: number | null } }; + } = {}; + if (input.name !== undefined) data.name = input.name.trim(); + if (input.codeName !== undefined) data.codeName = input.codeName.trim(); + if (input.summary !== undefined) data.summary = input.summary.trim(); + if (input.estimatedDuration !== undefined) { + classification.estimatedDuration = input.estimatedDuration; + } + if (input.mapId !== undefined || input.customMapName !== undefined) { + const resolvedMapId = await resolveSideOpMap(payload, { + mapId: input.mapId, + customMapName: input.customMapName, + }); + if (resolvedMapId != null) { + classification.map = resolvedMapId; + } + } + if (input.missionType !== undefined) { + classification.missionType = input.missionType; + } + if (input.startDateTime !== undefined) { + classification.startDateTime = fridayStartDateTime(input.startDateTime); + } + if (campaignChangeRequested) { + classification.campaign = resolvedCampaignId; + } + if (Object.keys(classification).length > 0) { + data.classification = classification; + } + if (input.maxPlayers !== undefined) { + data.missionRoles = { maxPlayers: input.maxPlayers }; + } + if (input.requiredModsId !== undefined) { + data.gameDetails = { gameSetup: { requiredMods: input.requiredModsId } }; + } + Object.assign(data, narrativeData(input)); + + const updated = await payload.update({ + collection: "missions", + id: input.missionId, + depth: 0, + overrideAccess: true, + data, + }); + + await emitGameEvent(payload, { + type: EventTypes.MissionUpdate, + message: `${user.displayName || user.username} updated side operation "${updated.name}" (${updated.codeName}).`, + actor: userId, + targetCollection: "missions", + targetId: updated.id, + data: { fields: Object.keys(data) }, + }); + + return { success: true, data: { missionId: updated.id } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function cancelFridaySideOp( + missionId: number, +): Promise> { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + + const mission = await payload.findByID({ + collection: "missions", + id: missionId, + depth: 0, + }); + + const canManage = + (await hasPermission(payload, user, "missions:update")) || + (await hasPermission(payload, user, "missions:delete")); + if (!canManage && !isSideOpOwner(mission, userId)) { + return { + success: false, + error: "Only the player who scheduled this side operation can cancel it.", + }; + } + if (["Completed", "Cancelled"].includes(mission.ownershipAndStatus.status)) { + return { + success: false, + error: `This mission is already ${mission.ownershipAndStatus.status.toLowerCase()}.`, + }; + } + + const updated = await payload.update({ + collection: "missions", + id: missionId, + depth: 0, + overrideAccess: true, + data: { + ownershipAndStatus: { + status: "Cancelled", + }, + }, + }); + + await emitGameEvent(payload, { + type: EventTypes.MissionCancel, + message: `${user.displayName || user.username} cancelled side operation "${updated.name}" (${updated.codeName}).`, + actor: userId, + targetCollection: "missions", + targetId: updated.id, + }); + + return { success: true, data: { missionId: updated.id } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +} + +export async function moveFridaySideOpToCampaign( + missionId: number, + campaignId: number, +): Promise> { + try { + const { payload, user } = await authenticate(); + const userId = user.id as number; + + const mission = await payload.findByID({ + collection: "missions", + id: missionId, + depth: 0, + }); + + const canManage = await hasPermission(payload, user, "missions:update"); + if (!canManage && !isSideOpOwner(mission, userId)) { + return { + success: false, + error: "Only the player who scheduled this side operation can move it.", + }; + } + + const campaign = await payload.findByID({ + collection: "campaigns", + id: campaignId, + depth: 0, + }); + const ownerId = typeof campaign.owner === "object" ? campaign.owner?.id : campaign.owner; + if (ownerId !== userId) { + return { success: false, error: "You can only move missions into campaigns you own." }; + } + + await payload.update({ + collection: "missions", + id: missionId, + depth: 0, + overrideAccess: true, + data: { + classification: { + campaign: campaignId, + }, + }, + }); + + await emitGameEvent(payload, { + type: EventTypes.MissionMoveCampaign, + message: `${user.displayName || user.username} moved side operation "${mission.name}" (${mission.codeName}) to campaign "${campaign.name}".`, + actor: userId, + targetCollection: "missions", + targetId: missionId, + data: { campaignId }, + }); + + return { success: true, data: { missionId } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return message === "Unauthorized" + ? { success: false, error: "You must be logged in." } + : { success: false, error: message }; + } +}