- The mission tick now runs two idempotent sweeps: auto-start flips Scheduled missions whose start time has arrived to Active, and auto-complete sets missions whose scheduled window (start plus duration) has fully passed to Completed with a stamped completedAt. - Side ops are created as quiet Ready drafts and committed with a "Mark as Scheduled" control, which is what posts the Discord roll-call and hands the op to the lifecycle above. - Daily schedule reminders nudge the creator to set a draft live (web notification plus Discord DM). - Adds the mission lifecycle/commit event types and the matching notification labels, and updates the bot docs.
1054 lines
34 KiB
TypeScript
1054 lines
34 KiB
TypeScript
"use server";
|
|
|
|
import config from "@payload-config";
|
|
import { getPayload } from "payload";
|
|
import type { AttendanceResponse } from "@/lib/attendance";
|
|
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
|
|
import { notifyAttendanceChanged } from "@/lib/attendance/realtime";
|
|
import { isAdminOrDeveloper } from "@/lib/promotions/authority";
|
|
import {
|
|
removeRoleSignup,
|
|
signupForRole,
|
|
withdrawRoleSignup,
|
|
} from "@/lib/intelligence/roleSignups";
|
|
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 {
|
|
nextDayKey,
|
|
parseDateKey,
|
|
wallTimeToInstant,
|
|
zonedDateKey,
|
|
zonedParts,
|
|
zonedWeekday,
|
|
} from "@/lib/intelligence/fridayTime";
|
|
import type { Mission, User } from "@/payload-types";
|
|
|
|
export interface ActionResult<T = undefined> {
|
|
success: boolean;
|
|
error?: string;
|
|
data?: T;
|
|
}
|
|
|
|
async function authenticate() {
|
|
const payloadConfig = await config;
|
|
const payload = await getPayload({ config: payloadConfig });
|
|
const { headers } = await import("next/headers");
|
|
const hdrs = await headers();
|
|
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
|
|
|
if (!user || !isPayloadUser(user)) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
|
|
return { payload, user };
|
|
}
|
|
|
|
export async function setMissionAttendance(input: {
|
|
missionId: number;
|
|
response: AttendanceResponse;
|
|
}): Promise<ActionResult> {
|
|
try {
|
|
const { payload, user } = await authenticate();
|
|
const userId = user.id as number;
|
|
|
|
await setAttendance(payload, {
|
|
missionId: input.missionId,
|
|
userId,
|
|
response: input.response,
|
|
});
|
|
// The bot also polls as a recovery path, but this makes web RSVP changes
|
|
// visible in Discord immediately when the bot is running.
|
|
void notifyAttendanceChanged(payload, input.missionId).catch((error) => {
|
|
payload.logger.warn(
|
|
`Attendance changed, but Discord realtime notification failed: ${
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
}`,
|
|
);
|
|
});
|
|
|
|
return { success: true };
|
|
} 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 };
|
|
}
|
|
}
|
|
|
|
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 zonedWeekday(date) === 5;
|
|
}
|
|
|
|
// Side ops always start 20:00 America/New_York on the validated Friday,
|
|
// regardless of the server's own timezone (prod runs UTC).
|
|
function fridayStartDateTime(value: string): string {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return date.toISOString();
|
|
}
|
|
const p = zonedParts(date);
|
|
return wallTimeToInstant(p.year, p.month, p.day, 20, 0).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." };
|
|
}
|
|
// Only HTML mod lists exported from the Arma 3 launcher are supported. Some
|
|
// browsers report an empty file.type, so also accept by extension.
|
|
const isHtml = file.type === "text/html" || /\.html?$/i.test(file.name);
|
|
if (!isHtml) {
|
|
return {
|
|
success: false,
|
|
error: "Mod list must be an HTML file exported from the Arma 3 launcher.",
|
|
};
|
|
}
|
|
|
|
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";
|
|
if (/invalid: file/i.test(message)) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
"That file type is not accepted. Please upload the .html file exported from the Arma 3 launcher, or a .txt mod list.",
|
|
};
|
|
}
|
|
return message === "Unauthorized"
|
|
? { success: false, error: "You must be logged in." }
|
|
: { success: false, error: message };
|
|
}
|
|
}
|
|
|
|
function missionRef(mission: { name: string; codeName?: string | null }): string {
|
|
return mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
|
|
}
|
|
|
|
export interface FridaySideOpInput {
|
|
name: string;
|
|
codeName?: string;
|
|
summary: string;
|
|
coverImageId?: number | null;
|
|
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?: Record<string, unknown> | null }[] | 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?: Record<string, unknown> | 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: MissionLexicalState }[];
|
|
missionObjectives?: FridaySideOpInput["missionObjectives"];
|
|
civilianPresence?: FridaySideOpInput["civilianPresence"];
|
|
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
|
|
commsAndDocs?: { radioPlan?: MissionLexicalState | null };
|
|
logisticsAndSupport?: {
|
|
insertion?: InfilExfilMethod[] | null;
|
|
extraction?: InfilExfilMethod[] | null;
|
|
assetsAvailable?: FridaySideOpInput["assetsAvailable"];
|
|
};
|
|
};
|
|
|
|
type MissionLexicalState = NonNullable<NonNullable<Mission["briefing"]>[number]["details"]>;
|
|
|
|
type NarrativeDataResult = { success: true; data: SideOpNarrativeData } | { success: false; error: string };
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isLexicalValue(value: unknown): value is MissionLexicalState | null | undefined {
|
|
return value === undefined || value === null || (isRecord(value) && isRecord(value.root));
|
|
}
|
|
|
|
const EMPTY_LEXICAL_STATE: MissionLexicalState = {
|
|
root: {
|
|
type: "root",
|
|
children: [],
|
|
direction: null,
|
|
format: "",
|
|
indent: 0,
|
|
version: 1,
|
|
},
|
|
};
|
|
|
|
function normalizeLexicalValue(
|
|
value: Record<string, unknown> | null | undefined,
|
|
): MissionLexicalState {
|
|
if (value === null || value === undefined || !isLexicalValue(value)) {
|
|
return EMPTY_LEXICAL_STATE;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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"];
|
|
}): NarrativeDataResult {
|
|
const radioPlan = input.radioPlan;
|
|
if (!isLexicalValue(radioPlan)) {
|
|
return { success: false, error: "Invalid radio plan format." };
|
|
}
|
|
if (input.briefing?.some((entry) => !isLexicalValue(entry.details))) {
|
|
return { success: false, error: "Invalid briefing details format." };
|
|
}
|
|
|
|
const commsAndDocs: SideOpNarrativeData["commsAndDocs"] = {};
|
|
if (radioPlan !== undefined) {
|
|
commsAndDocs.radioPlan = 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 {
|
|
success: true,
|
|
data: {
|
|
...(input.briefing !== undefined
|
|
? {
|
|
briefing: (input.briefing ?? []).map((entry) => ({
|
|
sectionName: entry.sectionName,
|
|
details: normalizeLexicalValue(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 } | null> {
|
|
const start = new Date(fridayStartDateTime(startDateTime));
|
|
const key = zonedDateKey(start);
|
|
const { year, month, day } = parseDateKey(key);
|
|
const next = parseDateKey(nextDayKey(key));
|
|
const dayStart = wallTimeToInstant(year, month, day);
|
|
const dayEnd = wallTimeToInstant(next.year, next.month, next.day);
|
|
|
|
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,
|
|
worldSizeWidth: 8192,
|
|
worldSizeHeight: 8192,
|
|
basemapMode: "image",
|
|
},
|
|
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()) {
|
|
return { success: false, error: "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 "${missionRef(fridayConflict)}". 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 narrative = narrativeData(input);
|
|
if (!narrative.success) return narrative;
|
|
|
|
const mission = await payload.create({
|
|
collection: "missions",
|
|
overrideAccess: true,
|
|
data: {
|
|
name: input.name.trim(),
|
|
...(input.codeName?.trim() ? { codeName: input.codeName.trim() } : {}),
|
|
summary: input.summary.trim(),
|
|
operationType: "side",
|
|
...(input.coverImageId ? { coverImage: input.coverImageId } : {}),
|
|
createdBy: userId,
|
|
ownershipAndStatus: {
|
|
// Side ops are created as a quiet "Ready" draft. The creator commits
|
|
// them to "Scheduled" (markSideOpScheduled) to lock the Friday slot
|
|
// and trigger the roll-call; only then does the mission lifecycle
|
|
// (auto-start -> auto-complete) drive them.
|
|
status: "Ready",
|
|
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 } }
|
|
: {}),
|
|
},
|
|
...narrative.data,
|
|
},
|
|
});
|
|
|
|
const startIso = fridayStartDateTime(input.startDateTime);
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.MissionSchedule,
|
|
message: `${user.displayName || user.username} scheduled side operation "${missionRef(mission)}" 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;
|
|
coverImageId?: number | null;
|
|
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?: Record<string, unknown> | null }[] | null;
|
|
missionObjectives?: FridaySideOpInput["missionObjectives"];
|
|
civilianPresence?: FridaySideOpInput["civilianPresence"];
|
|
factionsInvolved?: FridaySideOpInput["factionsInvolved"];
|
|
radioPlan?: Record<string, unknown> | 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 | null;
|
|
summary?: string;
|
|
coverImage?: number | null;
|
|
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() || null;
|
|
if (input.summary !== undefined) data.summary = input.summary.trim();
|
|
if (input.coverImageId !== undefined) data.coverImage = input.coverImageId;
|
|
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 } };
|
|
}
|
|
const narrative = narrativeData(input);
|
|
if (!narrative.success) return narrative;
|
|
Object.assign(data, narrative.data);
|
|
|
|
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 "${missionRef(updated)}".`,
|
|
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 "${missionRef(updated)}".`,
|
|
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 markSideOpScheduled(
|
|
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");
|
|
if (!canManage && !isSideOpOwner(mission, userId)) {
|
|
return {
|
|
success: false,
|
|
error: "Only the player who scheduled this side operation can commit it to the schedule.",
|
|
};
|
|
}
|
|
|
|
const status = mission.ownershipAndStatus.status;
|
|
if (status === "Scheduled") {
|
|
return { success: true, data: { missionId: mission.id } };
|
|
}
|
|
if (!["Concept", "Planning", "Ready"].includes(status)) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
status === "Active"
|
|
? "This mission is already underway and cannot be scheduled."
|
|
: `This mission is ${status.toLowerCase()} and can no longer be scheduled.`,
|
|
};
|
|
}
|
|
|
|
const updated = await payload.update({
|
|
collection: "missions",
|
|
id: missionId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
data: {
|
|
ownershipAndStatus: {
|
|
status: "Scheduled",
|
|
},
|
|
},
|
|
});
|
|
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.MissionCommit,
|
|
message: `${user.displayName || user.username} committed side operation "${missionRef(updated)}" to the schedule. The roll-call will be posted and the mission hooks delivered.`,
|
|
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 "${missionRef(mission)}" 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 };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Role sign-ups
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function signupForMissionRole(input: {
|
|
missionId: number;
|
|
roleIndex: number;
|
|
}): Promise<ActionResult> {
|
|
try {
|
|
const { payload, user } = await authenticate();
|
|
const userId = user.id as number;
|
|
|
|
const mission = await payload
|
|
.findByID({
|
|
collection: "missions",
|
|
id: input.missionId,
|
|
depth: 0,
|
|
user,
|
|
overrideAccess: false,
|
|
})
|
|
.catch(() => null);
|
|
if (!mission) {
|
|
return { success: false, error: "You do not have access to this mission." };
|
|
}
|
|
|
|
await signupForRole(payload, {
|
|
missionId: input.missionId,
|
|
userId,
|
|
roleIndex: input.roleIndex,
|
|
bypassEligibility: isAdminOrDeveloper(user as User),
|
|
});
|
|
|
|
return { success: true };
|
|
} 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 withdrawMissionRoleSignup(input: {
|
|
missionId: number;
|
|
}): Promise<ActionResult> {
|
|
try {
|
|
const { payload, user } = await authenticate();
|
|
const userId = user.id as number;
|
|
|
|
const mission = await payload
|
|
.findByID({
|
|
collection: "missions",
|
|
id: input.missionId,
|
|
depth: 0,
|
|
user,
|
|
overrideAccess: false,
|
|
})
|
|
.catch(() => null);
|
|
if (!mission) {
|
|
return { success: false, error: "You do not have access to this mission." };
|
|
}
|
|
|
|
await withdrawRoleSignup(payload, { missionId: input.missionId, userId });
|
|
|
|
return { success: true };
|
|
} 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 removeMissionRoleSignup(input: {
|
|
missionId: number;
|
|
signupId: number;
|
|
}): Promise<ActionResult> {
|
|
try {
|
|
const { payload, user } = await authenticate();
|
|
const userId = user.id as number;
|
|
|
|
const canManage = await hasPermission(payload, user, "missions:update");
|
|
if (!canManage) {
|
|
return { success: false, error: "You do not have permission to manage role sign-ups." };
|
|
}
|
|
|
|
await removeRoleSignup(payload, {
|
|
missionId: input.missionId,
|
|
signupId: input.signupId,
|
|
actorId: userId,
|
|
});
|
|
|
|
return { success: true };
|
|
} 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 };
|
|
}
|
|
}
|