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:
parent
dd15e4f3e0
commit
7664aad4ef
1 changed files with 730 additions and 1 deletions
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue