import type { Where } from "payload"; import type { Mission } from "@/payload-types"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; type PayloadType = Awaited>; /** * Statuses that count as "committed to run" and are auto-completed once the * calendar day of their scheduled start has fully passed. Draft statuses * (Concept/Planning/Ready) are left untouched — a mission that never ran * should not silently become Completed — and terminal statuses * (Completed/Cancelled) are never re-processed. */ const AUTO_COMPLETABLE_STATUSES = ["Scheduled", "Active"] as const; export interface MissionAutoCompleteSummary { completed: number; missions: { id: number; name: string; codeName: string | null }[]; } export interface MissionAutoCompleteOptions { /** Restrict the sweep to specific mission ids (lets tests stay hermetic). */ missionIds?: number[]; } /** Start of `now`'s calendar day in the server's local timezone. */ export function startOfToday(now: Date = new Date()): Date { const start = new Date(now); start.setHours(0, 0, 0, 0); return start; } /** * True once the calendar day of `startDateTime` (server-local) has fully * ended — i.e. from the start of the day AFTER the scheduled date. A mission * scheduled for today never counts, even after its start time has passed. * Unparseable dates fall closed so malformed data is never auto-completed. */ export function isPastScheduledDay(startDateTime: string, now: Date = new Date()): boolean { const start = new Date(startDateTime); if (Number.isNaN(start.getTime())) return false; return start.getTime() < startOfToday(now).getTime(); } export async function autoCompleteMissions( payload: PayloadType, now: Date = new Date(), options: MissionAutoCompleteOptions = {}, ): Promise { const cutoff = startOfToday(now).toISOString(); const conditions: Where[] = [ { "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } }, { "classification.startDateTime": { less_than: cutoff } }, ]; if (options.missionIds) { conditions.push({ id: { in: options.missionIds } }); } const due = (await payload.find({ collection: "missions", where: { and: conditions }, sort: "classification.startDateTime", limit: 500, depth: 0, overrideAccess: true, })) as unknown as { docs: Mission[] }; const completed: MissionAutoCompleteSummary["missions"] = []; for (const mission of due.docs) { const previousStatus = mission.ownershipAndStatus?.status ?? null; try { await payload.update({ collection: "missions", id: mission.id, data: { ownershipAndStatus: { status: "Completed" } }, overrideAccess: true, }); await emitGameEvent(payload, { type: EventTypes.MissionAutoComplete, message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed — the scheduled day has passed.`, targetCollection: "missions", targetId: mission.id, data: { previousStatus }, }); completed.push({ id: mission.id, name: mission.name, codeName: mission.codeName ?? null }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; payload.logger.error( `[Mission Tick] Failed to auto-complete mission ${mission.id} (${mission.name}): ${message}`, ); } } return { completed: completed.length, missions: completed }; }