874 lines
28 KiB
TypeScript
874 lines
28 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 { 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 {
|
|
nextDayKey,
|
|
parseDateKey,
|
|
wallTimeToInstant,
|
|
zonedDateKey,
|
|
zonedParts,
|
|
zonedWeekday,
|
|
} from "@/lib/intelligence/fridayTime";
|
|
import type { Mission } 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";
|
|
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 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 },
|
|
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 markSideOpReady(
|
|
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 mark it Ready.",
|
|
};
|
|
}
|
|
|
|
const status = mission.ownershipAndStatus.status;
|
|
if (status === "Ready") {
|
|
return { success: true, data: { missionId: mission.id } };
|
|
}
|
|
if (!["Concept", "Planning", "Scheduled"].includes(status)) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
status === "Active"
|
|
? "This mission is already underway and cannot be marked Ready."
|
|
: `This mission is ${status.toLowerCase()} and can no longer be marked Ready.`,
|
|
};
|
|
}
|
|
|
|
const updated = await payload.update({
|
|
collection: "missions",
|
|
id: missionId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
data: {
|
|
ownershipAndStatus: {
|
|
status: "Ready",
|
|
},
|
|
},
|
|
});
|
|
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.MissionReady,
|
|
message: `${user.displayName || user.username} marked side operation "${updated.name}" (${updated.codeName}) as Ready. Details are locked in and the roll-call can be posted.`,
|
|
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 };
|
|
}
|
|
}
|