1
0
Fork 0

feat(missions): add Friday side-op scheduling server actions

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-06 23:29:52 -04:00
parent dd15e4f3e0
commit 7664aad4ef

View file

@ -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<T = undefined> {
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<ReturnType<typeof getPayload>>,
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<ReturnType<typeof getPayload>>,
userId: number,
excludeMissionId?: number,
): Promise<number> {
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<ActionResult<{ modlistId: number; modlistName: string }>> {
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<typeof textToLexical> }[];
missionObjectives?: FridaySideOpInput["missionObjectives"];
civilianPresence?: FridaySideOpInput["civilianPresence"];
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
commsAndDocs?: { radioPlan?: ReturnType<typeof textToLexical> };
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<ReturnType<typeof getPayload>>,
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<ReturnType<typeof getPayload>>,
input: { mapId?: number | null; customMapName?: string | null },
): Promise<number | null> {
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<ActionResult<{ missionId: number }>> {
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<ActionResult<{ missionId: number }>> {
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<ActionResult<{ missionId: number }>> {
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<ActionResult<{ missionId: number }>> {
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 };
}
}