1
0
Fork 0
polaris-task-force/src/lib/intelligence/missionLifecycle.ts

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 };
}