99 lines
3.8 KiB
TypeScript
99 lines
3.8 KiB
TypeScript
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<ReturnType<typeof import("payload").getPayload>>;
|
|
|
|
/**
|
|
* 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<MissionAutoCompleteSummary> {
|
|
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 {
|
|
// Pass the sweep's `now` explicitly so the completion timestamp is the
|
|
// exact tick time (deterministic for tests); the collection's
|
|
// beforeChange hook preserves it because it only stamps when empty.
|
|
await payload.update({
|
|
collection: "missions",
|
|
id: mission.id,
|
|
data: { ownershipAndStatus: { status: "Completed", completedAt: now.toISOString() } },
|
|
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 };
|
|
}
|