1
0
Fork 0

feat(missions): auto-start and auto-complete ops, plus side-op scheduling

- The mission tick now runs two idempotent sweeps: auto-start flips Scheduled
  missions whose start time has arrived to Active, and auto-complete sets
  missions whose scheduled window (start plus duration) has fully passed to
  Completed with a stamped completedAt.
- Side ops are created as quiet Ready drafts and committed with a "Mark as
  Scheduled" control, which is what posts the Discord roll-call and hands the
  op to the lifecycle above.
- Daily schedule reminders nudge the creator to set a draft live (web
  notification plus Discord DM).
- Adds the mission lifecycle/commit event types and the matching notification
  labels, and updates the bot docs.
This commit is contained in:
Jason Fraley 2026-10-03 03:29:35 -04:00
parent 62573f74e6
commit 74564afb2b
16 changed files with 399 additions and 149 deletions

View file

@ -543,7 +543,11 @@ export async function createFridaySideOp(
...(input.coverImageId ? { coverImage: input.coverImageId } : {}),
createdBy: userId,
ownershipAndStatus: {
status: "Scheduled",
// Side ops are created as a quiet "Ready" draft. The creator commits
// them to "Scheduled" (markSideOpScheduled) to lock the Friday slot
// and trigger the roll-call; only then does the mission lifecycle
// (auto-start -> auto-complete) drive them.
status: "Ready",
visibility: "unit",
authors: [userId],
},
@ -823,7 +827,7 @@ export async function cancelFridaySideOp(
}
}
export async function markSideOpReady(
export async function markSideOpScheduled(
missionId: number,
): Promise<ActionResult<{ missionId: number }>> {
try {
@ -840,21 +844,21 @@ export async function markSideOpReady(
if (!canManage && !isSideOpOwner(mission, userId)) {
return {
success: false,
error: "Only the player who scheduled this side operation can mark it Ready.",
error: "Only the player who scheduled this side operation can commit it to the schedule.",
};
}
const status = mission.ownershipAndStatus.status;
if (status === "Ready") {
if (status === "Scheduled") {
return { success: true, data: { missionId: mission.id } };
}
if (!["Concept", "Planning", "Scheduled"].includes(status)) {
if (!["Concept", "Planning", "Ready"].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.`,
? "This mission is already underway and cannot be scheduled."
: `This mission is ${status.toLowerCase()} and can no longer be scheduled.`,
};
}
@ -865,14 +869,14 @@ export async function markSideOpReady(
overrideAccess: true,
data: {
ownershipAndStatus: {
status: "Ready",
status: "Scheduled",
},
},
});
await emitGameEvent(payload, {
type: EventTypes.MissionReady,
message: `${user.displayName || user.username} marked side operation "${missionRef(updated)}" as Ready. Details are locked in and the roll-call can be posted.`,
type: EventTypes.MissionCommit,
message: `${user.displayName || user.username} committed side operation "${missionRef(updated)}" to the schedule. The roll-call will be posted and the mission hooks delivered.`,
actor: userId,
targetCollection: "missions",
targetId: updated.id,

View file

@ -53,7 +53,7 @@ bot/
## Feature flow: attendance
Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). The embed title links to the mission page, a mission summary sits under the title (truncated to 300 characters with an ellipsis), and an "Open the op" field carries the URL; `attendanceHash` includes an embed schema version so layout-only changes re-render live roll-calls once (idempotent). Web UI: `MissionAttendance` component.
Bot posts RSVP embeds (Yes/Tentative/No) for future, Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). The embed title links to the mission page, a mission summary sits under the title (truncated to 300 characters with an ellipsis), and an "Open the op" field carries the URL; `attendanceHash` includes an embed schema version so layout-only changes re-render live roll-calls once (idempotent). Web UI: `MissionAttendance` component.
## Feature flow: notifications
@ -65,7 +65,7 @@ Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start
## Feature flow: imminent op reminder
15 minutes before a posted op starts (`visibility: "unit"`, Ready/Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`).
15 minutes before a posted op starts (`visibility: "unit"`, Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`).
## Feature flow: Friday open-slot notice

View file

@ -38,12 +38,12 @@ const missionTitle = (mission: Mission): string =>
const memberName = (user: User): string => user.displayName ?? user.username;
/**
* Roll call is sent once: as soon as the mission is Ready, or three days
* before a Scheduled mission, whichever happens first.
* Roll call is sent once, three days before a Scheduled mission starts. Side
* ops are created as a quiet "Ready" draft and only post a roll-call after the
* creator commits them to "Scheduled" (markSideOpScheduled).
*/
export const isRollCallDue = (mission: Mission, now = Date.now()): boolean => {
if (mission.discordAttendanceSentAt) return false;
if (mission.ownershipAndStatus.status === "Ready") return true;
if (mission.ownershipAndStatus.status !== "Scheduled") return false;
const start = new Date(mission.classification.startDateTime).getTime();
@ -118,12 +118,6 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
{ "ownershipAndStatus.visibility": { equals: "unit" } },
{
or: [
{
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.status": { equals: "Ready" } },
],
},
{
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
@ -158,9 +152,9 @@ const getCancelledMissions = async (payload: Payload): Promise<Mission[]> => {
};
/**
* Cancelled missions that are live again (status back to Ready/Scheduled) and
* still carry the cancellation marker: the reschedule announcement has not
* happened yet.
* Cancelled missions that are live again (status back to Scheduled) and still
* carry the cancellation marker: the reschedule announcement has not happened
* yet.
*/
const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
const res = await payload.find({
@ -172,7 +166,7 @@ const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
where: {
and: [
{ "ownershipAndStatus.visibility": { equals: "unit" } },
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
{ "ownershipAndStatus.status": { in: ["Scheduled"] } },
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ discordCancelledAt: { exists: true } },
],
@ -516,7 +510,7 @@ export const reconcileMission = async (
if (
mission.ownershipAndStatus.visibility !== "unit" ||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
!["Scheduled"].includes(mission.ownershipAndStatus.status) ||
(!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt)
) {
return;
@ -566,7 +560,7 @@ const cancelMissionEmbed = async (
};
/**
* A cancelled op came back (status Cancelled -> Ready/Scheduled): announce the
* A cancelled op came back (status Cancelled -> Scheduled): announce the
* new start time immediately, note when the fresh roll-call goes up (the usual
* lead time before the op, or right away when it is already due), then clear
* the marker so the reconcile loop posts the roll-call as usual.
@ -579,8 +573,7 @@ const announceReschedule = async (
): Promise<void> => {
const start = new Date(mission.classification.startDateTime).getTime();
const rollCallAlreadyLive = !!mission.discordAttendanceSentAt;
const rollCallDueNow =
mission.ownershipAndStatus.status === "Ready" || start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
const rollCallDueNow = start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
const rollCallDueUnix =
rollCallAlreadyLive || rollCallDueNow
? null

View file

@ -13,8 +13,9 @@ import {
/**
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a
* player-scheduled side op, the op's creator gets a once-per-day DM nudging
* them to finish the op and mark it "Ready" — the status that posts the
* roll-call, locks in details, and delivers the mission hooks.
* them to finish the op and commit it to "Scheduled" (markSideOpScheduled): the
* status that posts the roll-call, locks in details, and delivers the mission
* hooks.
*
* The DM deliberately ignores `preferences.discord.enabled` (the notification
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
@ -41,17 +42,17 @@ const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => {
const days = daysUntil(mission.classification.startDateTime, now);
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
return new EmbedBuilder()
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`)
.setTitle(`⏰ Schedule check: ${missionTitle(mission)}`)
.setDescription(
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` +
"Finish up the operation and set the mission status to **Ready** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
`Your op starts **${when}** and it isn't marked **Scheduled** yet.\n\n` +
"Finish up the operation and set the mission status to **Scheduled** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
)
.setColor(EMBED_COLOR)
.addFields({
name: "Open the op",
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
})
.setFooter({ text: "You'll get this reminder once a day until the op is Ready." });
.setFooter({ text: "You'll get this reminder once a day until the op is Scheduled." });
};
const processMission = async (

View file

@ -41,6 +41,8 @@ const TARGET_COLLECTIONS = [
{ label: "Custom Radio Tests", value: "custom-radio-tests" },
{ label: "Custom Radio Test Scores", value: "custom-radio-test-scores" },
{ label: "Ribbon Submissions", value: "ribbon-submissions" },
{ label: "Promotion Nominations", value: "promotion-nominations" },
{ label: "Leadership Transfers", value: "leadership-transfers" },
] as const;
export const GameEventLogs: CollectionConfig = {

View file

@ -272,7 +272,7 @@ export const Missions: CollectionConfig = {
type: "date",
admin: {
description:
"Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
"Server-local day the last web 'not Scheduled' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
hidden: true,
},
},
@ -281,7 +281,7 @@ export const Missions: CollectionConfig = {
type: "date",
admin: {
description:
"Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
"Server-local day the last 'not Scheduled' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
hidden: true,
},
},
@ -421,6 +421,10 @@ export const Missions: CollectionConfig = {
],
defaultValue: "Concept",
required: true,
admin: {
description:
"Side operations start as Ready (a quiet draft) and are committed to Scheduled by their creator, which posts the Discord roll-call. Only Scheduled/Active missions are driven by the automatic start/complete lifecycle; Concept/Planning/Ready are drafts.",
},
},
{
name: "visibility",

View file

@ -8,7 +8,7 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button";
import {
cancelFridaySideOp,
markSideOpReady,
markSideOpScheduled,
} from "@/app/(frontend)/intelligence/missions/actions";
import {
CreateSideOpDialog,
@ -37,16 +37,16 @@ export function SideOpControls({
const [isPending, startTransition] = useTransition();
const isTerminal = status === "Completed" || status === "Cancelled";
const canMarkReady = ["Concept", "Planning", "Scheduled"].includes(status);
const canMarkScheduled = ["Concept", "Planning", "Ready"].includes(status);
function handleMarkReady() {
function handleMarkScheduled() {
startTransition(async () => {
const result = await markSideOpReady(initial.missionId);
const result = await markSideOpScheduled(initial.missionId);
if (!result.success) {
toast.error(result.error ?? "Failed to mark the side operation as Ready.");
toast.error(result.error ?? "Failed to commit the side operation to the schedule.");
return;
}
toast.success("Side operation marked as Ready. Roll-call can now be posted.");
toast.success("Side operation scheduled. The roll-call will be posted soon.");
router.refresh();
});
}
@ -80,10 +80,10 @@ export function SideOpControls({
</ItemHeader>
<ItemContent>
<div className="flex flex-wrap gap-2">
{canMarkReady && (
<Button size="sm" variant="default" onClick={handleMarkReady} disabled={isPending}>
{canMarkScheduled && (
<Button size="sm" variant="default" onClick={handleMarkScheduled} disabled={isPending}>
<CheckCircleIcon className="size-3.5" />
Mark as Ready
Mark as Scheduled
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
@ -101,9 +101,9 @@ export function SideOpControls({
</Button>
</div>
<p className="text-xs text-muted-foreground">
Marking the operation Ready locks in the details so the Discord roll-call can be posted
and the mission hooks delivered. Editing covers details, campaign, and narrative.
Cancelling frees your scheduling slot and updates the Discord embed.
Scheduling the operation commits it: the Discord roll-call is posted within three days of
the op and the mission hooks are delivered. Editing covers details, campaign, and
narrative. Cancelling frees your scheduling slot and updates the Discord embed.
</p>
</ItemContent>

View file

@ -26,9 +26,9 @@ export const IMMINENT_REMINDER_WINDOW_MS = 15 * 60 * 1000;
export const IMMINENT_REMINDER_GRACE_MS = 15 * 60 * 1000;
/**
* Ops due for the imminent reminder: unit-visible, Ready/Scheduled, with a
* live roll-call message, starting within the reminder window (or just past
* it, within the grace bound), and without the one-shot marker yet.
* Ops due for the imminent reminder: unit-visible, Scheduled, with a live
* roll-call message, starting within the reminder window (or just past it,
* within the grace bound), and without the one-shot marker yet.
*/
export const getImminentReminderMissions = async (
payload: Payload,
@ -45,7 +45,7 @@ export const getImminentReminderMissions = async (
where: {
and: [
{ "ownershipAndStatus.visibility": { equals: "unit" } },
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
{ "ownershipAndStatus.status": { in: ["Scheduled"] } },
{ discordMessageId: { exists: true } },
{ imminentReminderSentAt: { exists: false } },
{ "classification.startDateTime": { greater_than: windowStartIso } },

View file

@ -6,52 +6,113 @@ 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
* Statuses that are committed to run and are auto-started (flipped to Active)
* the moment their scheduled start time arrives. Draft statuses
* (Concept/Planning/Ready) are left untouched — a mission that was never
* scheduled should not silently become Active.
*/
const AUTO_STARTABLE_STATUSES = ["Scheduled"] as const;
/**
* Statuses that count as "committed to run" and are auto-completed once their
* scheduled window (start + estimated duration) 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 }[];
/**
* Fallback duration (minutes) when a mission's `estimatedDuration` is missing
* or invalid. Mirrors the field's own default so a malformed mission still
* reaches a terminal state instead of being stuck Active forever.
*/
export const DEFAULT_MISSION_DURATION_MINUTES = 240;
export interface MissionLifecycleEntry {
id: number;
name: string;
codeName: string | null;
}
export interface MissionAutoCompleteOptions {
export interface MissionAutoStartSummary {
started: number;
missions: MissionLifecycleEntry[];
}
export interface MissionAutoCompleteSummary {
completed: number;
missions: MissionLifecycleEntry[];
}
export interface MissionLifecycleOptions {
/** 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;
/**
* Resolve a mission's end time (start + estimated duration), or null when the
* start timestamp is missing/unparseable. A missing or non-positive duration
* falls back to DEFAULT_MISSION_DURATION_MINUTES.
*/
export function missionEndTime(
startDateTime: string | null | undefined,
estimatedDurationMinutes: number | null | undefined,
): Date | null {
const start = new Date(startDateTime ?? "");
if (Number.isNaN(start.getTime())) return null;
const minutes =
typeof estimatedDurationMinutes === "number" &&
Number.isFinite(estimatedDurationMinutes) &&
estimatedDurationMinutes > 0
? estimatedDurationMinutes
: DEFAULT_MISSION_DURATION_MINUTES;
return new Date(start.getTime() + minutes * 60_000);
}
/**
* 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.
* True once the mission's scheduled start time has arrived. Unparseable dates
* fall closed so malformed data is never auto-started.
*/
export function isPastScheduledDay(startDateTime: string, now: Date = new Date()): boolean {
const start = new Date(startDateTime);
export function hasMissionStarted(
startDateTime: string | null | undefined,
now: Date = new Date(),
): boolean {
const start = new Date(startDateTime ?? "");
if (Number.isNaN(start.getTime())) return false;
return start.getTime() < startOfToday(now).getTime();
return start.getTime() <= now.getTime();
}
export async function autoCompleteMissions(
/**
* True once the mission's scheduled window (start + estimated duration) has
* fully passed. Unparseable dates fall closed so malformed data is never
* auto-completed.
*/
export function isMissionWindowComplete(
startDateTime: string | null | undefined,
estimatedDurationMinutes: number | null | undefined,
now: Date = new Date(),
): boolean {
const end = missionEndTime(startDateTime, estimatedDurationMinutes);
if (!end) return false;
return end.getTime() <= now.getTime();
}
/**
* Sweep missions that are committed to run and whose scheduled start time has
* arrived, flipping them to Active. Missions whose entire window has already
* elapsed are skipped here (auto-complete owns those) so an op that ran while
* the tick was down does not emit a spurious "started" event on its way to
* Completed. Idempotent: once Active, a mission is no longer startable.
*/
export async function autoStartMissions(
payload: PayloadType,
now: Date = new Date(),
options: MissionAutoCompleteOptions = {},
): Promise<MissionAutoCompleteSummary> {
const cutoff = startOfToday(now).toISOString();
options: MissionLifecycleOptions = {},
): Promise<MissionAutoStartSummary> {
const conditions: Where[] = [
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } },
{ "classification.startDateTime": { less_than: cutoff } },
{ "ownershipAndStatus.status": { in: [...AUTO_STARTABLE_STATUSES] } },
{ "classification.startDateTime": { less_than_equal: now.toISOString() } },
];
if (options.missionIds) {
conditions.push({ id: { in: options.missionIds } });
@ -66,13 +127,91 @@ export async function autoCompleteMissions(
overrideAccess: true,
})) as unknown as { docs: Mission[] };
const completed: MissionAutoCompleteSummary["missions"] = [];
const started: MissionLifecycleEntry[] = [];
for (const mission of due.docs) {
// A window that already fully elapsed belongs to auto-complete; starting it
// first would emit a misleading "started" event for an op that is over.
if (
isMissionWindowComplete(
mission.classification?.startDateTime,
mission.classification?.estimatedDuration,
now,
)
) {
continue;
}
const previousStatus = mission.ownershipAndStatus?.status ?? null;
try {
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Active" } },
overrideAccess: true,
});
await emitGameEvent(payload, {
type: EventTypes.MissionAutoStart,
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} is now active: the scheduled start time has arrived.`,
targetCollection: "missions",
targetId: mission.id,
data: { previousStatus },
});
started.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-start mission ${mission.id} (${mission.name}): ${message}`,
);
}
}
return { started: started.length, missions: started };
}
/**
* Sweep missions whose scheduled window (start + estimated duration) has fully
* passed and mark them Completed. Passing the sweep's `now` explicitly keeps
* the completion timestamp deterministic for tests; the collection's
* beforeChange hook preserves it because it only stamps when empty.
* Idempotent: Completed/Cancelled are never re-processed.
*/
export async function autoCompleteMissions(
payload: PayloadType,
now: Date = new Date(),
options: MissionLifecycleOptions = {},
): Promise<MissionAutoCompleteSummary> {
const conditions: Where[] = [
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } },
// Coarse prefilter: end <= now implies start < now, since duration > 0.
{ "classification.startDateTime": { less_than: now.toISOString() } },
];
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: MissionLifecycleEntry[] = [];
for (const mission of due.docs) {
if (
!isMissionWindowComplete(
mission.classification?.startDateTime,
mission.classification?.estimatedDuration,
now,
)
) {
continue;
}
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,
@ -81,7 +220,7 @@ export async function autoCompleteMissions(
});
await emitGameEvent(payload, {
type: EventTypes.MissionAutoComplete,
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed: the scheduled day has passed.`,
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed: the scheduled window has ended.`,
targetCollection: "missions",
targetId: mission.id,
data: { previousStatus },

View file

@ -7,18 +7,26 @@ import { notifyUser } from "@/lib/notifications";
*
* Starting READY_REMINDER_LEAD_DAYS days before an op's scheduled start, the
* creator gets a once-per-day reminder (web notification + Discord DM) to
* finish the op and mark it "Ready" — the status that triggers the roll-call
* embed, locks in details, and delivers the mission hooks. Reminders stop the
* moment the op is Ready (or cancelled/completed/active), and a marker field
* records the last server-local day each channel was sent so the cadence is
* exactly one reminder per day per channel.
* finish the op and commit it to "Scheduled" (markSideOpScheduled): the status
* that triggers the roll-call embed, locks in details, and delivers the mission
* hooks. Reminders stop the moment the op is Scheduled (or
* cancelled/completed/active), and a marker field records the last server-local
* day each channel was sent so the cadence is exactly one reminder per day per
* channel.
*
* Side ops are created as a quiet "Ready" draft (createFridaySideOp), so a
* reminder is due while the op is still Concept/Planning/Ready.
*
* Scope: only ops with a `createdBy` (player-scheduled via the Friday Ops
* calendar — always `operationType: "side"`). Staff/admin-created missions are
* calendar, always `operationType: "side"`). Staff/admin-created missions are
* never reminded.
*
* Shared by the mission-tick bin (web notifications) and the Discord bot's
* readyReminders service (DMs) — keep this module free of discord.js imports.
* readyReminders service (DMs): keep this module free of discord.js imports.
*
* Note: the module name, notification type ("mission:ready-reminder"), and
* marker fields keep their legacy "ready" spelling to avoid a data migration;
* only the user-facing copy talks about scheduling.
*/
/** How many days before the op the daily reminders begin. */
@ -28,11 +36,11 @@ export const READY_REMINDER_LEAD_DAYS = 5;
export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder";
/**
* Statuses that still need the creator's attention. "Ready" is the committed
* state the reminders push toward; Active/Completed/Cancelled are terminal or
* already underway.
* Statuses that still need the creator's attention. "Scheduled" is the
* committed state the reminders push toward; Active/Completed/Cancelled are
* terminal or already underway.
*/
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Scheduled"] as const;
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Ready"] as const;
export const missionTitle = (mission: Mission): string =>
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
@ -46,8 +54,8 @@ export const dayKey = (date: Date): string =>
/**
* A channel is due when it has never sent, or last sent on a previous
* server-local day. Day-based (not 24h-based) so retries within the same day
* stay deduped, and a status flapping Ready → Scheduled resumes reminders the
* next day instead of being suppressed forever.
* stay deduped, and a status flapping Scheduled back to Ready resumes reminders
* the next day instead of being suppressed forever.
*/
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
if (!marker) return true;
@ -60,10 +68,10 @@ export const daysUntil = (startIso: string, now: Date): number =>
Math.max(0, Math.ceil((new Date(startIso).getTime() - now.getTime()) / 86_400_000));
/**
* Ops due for reminders: player-created (createdBy set), still not Ready /
* Ops due for reminders: player-created (createdBy set), still not Scheduled /
* terminal, and starting within the lead window (future start, at most
* READY_REMINDER_LEAD_DAYS days out). Marker filtering is done per-channel by
* the caller via isReminderDue — the "not sent today" condition can't be
* the caller via isReminderDue: the "not sent today" condition can't be
* expressed in a single where clause.
*/
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
@ -122,8 +130,8 @@ export const sendWebReadyReminders = async (
await notifyUser(payload, {
userId: creatorId,
type: READY_REMINDER_NOTIFICATION_TYPE,
title: `Ready check: ${missionTitle(mission)}`,
message: `Your op starts ${when}. Finish up the details and set the mission status to Ready so the roll-call can be posted and the mission hooks delivered.`,
title: `Schedule check: ${missionTitle(mission)}`,
message: `Your op starts ${when}. Finish up the details and set the mission status to Scheduled so the roll-call can be posted and the mission hooks delivered.`,
link: `/intelligence/missions/${mission.id}`,
});
await payload.update({

View file

@ -96,7 +96,7 @@ export function notificationLabel(type?: string | null): string {
case "ticket:status":
return "Ticket updated";
case "mission:ready-reminder":
return "Ready reminder";
return "Schedule reminder";
case "assignment-transfer:request":
return "Transfer request";
case "assignment-transfer:approved":
@ -117,6 +117,24 @@ export function notificationLabel(type?: string | null): string {
return "Ribbon not approved";
case "personnel:promoted":
return "Promotion";
case "personnel:transferred":
return "Transferred";
case "promotion:nomination":
return "Promotion nomination";
case "promotion:endorsed":
return "Nomination endorsed";
case "promotion:approved":
return "Promotion approved";
case "promotion:rejected":
return "Promotion declined";
case "assignment:leadership-transfer-requested":
return "Removal request";
case "assignment:leadership-transfer":
return "Leadership transfer";
case "assignment:leadership-transfer-approved":
return "Removal approved";
case "assignment:leadership-transfer-denied":
return "Removal denied";
case "mission:role-promoted":
return "Role slot granted";
default:

View file

@ -1,6 +1,6 @@
import type { SanitizedConfig } from "payload";
import { getPayload } from "payload";
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
import { autoCompleteMissions, autoStartMissions } from "@/lib/intelligence/missionLifecycle";
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
@ -12,6 +12,9 @@ export const script = async (config: SanitizedConfig) => {
log.info("[Mission Tick] Starting mission tick...");
const nowIso = new Date().toISOString();
// Start before complete so a mission whose whole window elapsed between
// ticks is still driven through Active on its way to Completed.
const startResult = await autoStartMissions(payload);
const result = await autoCompleteMissions(payload);
const reminders = await sendWebReadyReminders(payload);
@ -38,7 +41,9 @@ export const script = async (config: SanitizedConfig) => {
log.warn("[Mission Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification");
}
log.info(`[Mission Tick] Complete. Auto-completed ${result.completed} mission(s).`);
log.info(
`[Mission Tick] Complete. Auto-started ${startResult.started} mission(s), auto-completed ${result.completed} mission(s).`,
);
process.exit(0);
} catch (error) {
log.error(

View file

@ -45,7 +45,9 @@ interface GameEventInput {
| "custom-minefield-scores"
| "custom-radio-tests"
| "custom-radio-test-scores"
| "ribbon-submissions";
| "ribbon-submissions"
| "promotion-nominations"
| "leadership-transfers";
targetId?: number;
data?: Record<string, unknown>;
}

View file

@ -139,6 +139,8 @@ export const EventTypes = {
// Personnel
PersonnelPromoted: "personnel:promoted",
PersonnelTransferred: "personnel:transferred",
PromotionNominationApproved: "promotion:approved",
// Awards
AwardGrant: "award:grant",
@ -148,10 +150,11 @@ export const EventTypes = {
AwardRibbonRejected: "award:ribbon-rejected",
// Missions
MissionAutoStart: "mission:auto-start",
MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
MissionSchedule: "mission:schedule",
MissionReady: "mission:ready",
MissionCommit: "mission:commit",
MissionUpdate: "mission:update",
MissionCancel: "mission:cancel",
MissionMoveCampaign: "mission:move-campaign",

View file

@ -149,15 +149,16 @@ describe("Imminent-op reminder query", () => {
return mission;
};
it("finds posted Ready/Scheduled ops starting inside the 15 minute window", async () => {
it("finds posted Scheduled ops starting inside the 15 minute window, not Ready drafts", async () => {
const now = new Date();
const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000);
const ready = await makeMission("in-window-ready", { status: "Ready", start });
const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start });
const found = await getImminentReminderMissions(payload, now);
expect(found.map((m) => m.id)).toContain(ready.id);
expect(found.map((m) => m.id)).toContain(scheduled.id);
// Ready is now a quiet draft (no roll-call), so it is never imminent-reminded.
expect(found.map((m) => m.id)).not.toContain(ready.id);
});
it("includes the exact window edge and the grace zone just past the start", async () => {

View file

@ -4,20 +4,26 @@ import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
import {
DEFAULT_MISSION_DURATION_MINUTES,
autoCompleteMissions,
isPastScheduledDay,
startOfToday,
autoStartMissions,
hasMissionStarted,
isMissionWindowComplete,
missionEndTime,
} from "@/lib/intelligence/missionLifecycle";
let payload: Payload;
const RUN = `mlc-${Date.now().toString(36)}`;
describe("Mission auto-complete", () => {
describe("Mission lifecycle (auto-start + auto-complete)", () => {
let mapId: number;
let campaignId: number;
let authorId: number;
const missionIds: number[] = [];
// Ids completed by the window sweep, so the idempotency check can scope to
// exactly those (the suite deliberately leaves other due missions behind).
const completedBySweep: number[] = [];
beforeAll(async () => {
const payloadConfig = await config;
@ -102,20 +108,21 @@ describe("Mission auto-complete", () => {
label: string,
status: Mission["ownershipAndStatus"]["status"],
start: Date,
estimatedDuration = 60,
): Promise<Mission> => {
const mission = (await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Auto-complete boundary test mission",
summary: "Mission lifecycle boundary test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: start.toISOString(),
estimatedDuration: 60,
estimatedDuration,
},
ownershipAndStatus: {
authors: [authorId],
@ -148,41 +155,37 @@ describe("Mission auto-complete", () => {
overrideAccess: true,
}) as unknown as Promise<Mission>;
it("completes Scheduled/Active missions only after the day after their scheduled date starts", async () => {
it("auto-starts only Scheduled missions whose start has arrived and whose window is still open", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const at = (dayOffset: number, hour: number): Date => {
const at = (hourOffset: number): Date => {
const d = new Date(now);
d.setDate(now.getDate() + dayOffset);
d.setHours(hour, 0, 0, 0);
d.setHours(now.getHours() + hourOffset);
return d;
};
const dueScheduled = await makeMission("due-scheduled", "Scheduled", at(-1, 20));
const dueActive = await makeMission("due-active", "Active", at(-3, 20));
const midnightEdge = await makeMission("midnight-edge", "Scheduled", at(-1, 23));
const sameDayEarlier = await makeMission("same-day-earlier", "Scheduled", at(0, 8));
const sameDayLater = await makeMission("same-day-later", "Scheduled", at(0, 13));
const draftReady = await makeMission("draft-ready", "Ready", at(-1, 20));
const cancelled = await makeMission("cancelled", "Cancelled", at(-1, 20));
const inWindow = await makeMission("start-in-window", "Scheduled", at(-1), 240);
const future = await makeMission("start-future", "Scheduled", at(1), 240);
const elapsed = await makeMission("start-elapsed", "Scheduled", at(-5), 60);
const alreadyActive = await makeMission("start-already-active", "Active", at(-1), 240);
const cancelled = await makeMission("start-cancelled", "Cancelled", at(-1), 240);
const ids = [inWindow.id, future.id, elapsed.id, alreadyActive.id, cancelled.id];
const result = await autoCompleteMissions(payload, now, { missionIds });
const result = await autoStartMissions(payload, now, { missionIds: ids });
expect(result.missions.map((m) => m.id).sort()).toEqual(
[dueScheduled.id, dueActive.id, midnightEdge.id].sort(),
);
expect(result.started).toBe(1);
expect(result.missions.map((m) => m.id)).toEqual([inWindow.id]);
expect((await fetchMission(dueScheduled.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(dueActive.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(midnightEdge.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(sameDayEarlier.id)).ownershipAndStatus.status).toBe("Scheduled");
expect((await fetchMission(sameDayLater.id)).ownershipAndStatus.status).toBe("Scheduled");
expect((await fetchMission(draftReady.id)).ownershipAndStatus.status).toBe("Ready");
expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
expect((await fetchMission(future.id)).ownershipAndStatus.status).toBe("Scheduled");
// Fully elapsed windows are auto-complete's job, not a spurious start.
expect((await fetchMission(elapsed.id)).ownershipAndStatus.status).toBe("Scheduled");
expect((await fetchMission(alreadyActive.id)).ownershipAndStatus.status).toBe("Active");
expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled");
// The partial group update must not wipe sibling fields.
const updated = await fetchMission(dueScheduled.id);
const updated = await fetchMission(inWindow.id);
expect(updated.ownershipAndStatus.visibility).toBe("unit");
const authorRefs = Array.isArray(updated.ownershipAndStatus.authors)
? updated.ownershipAndStatus.authors
@ -190,10 +193,49 @@ describe("Mission auto-complete", () => {
expect(authorRefs).toContain(authorId);
});
it("is idempotent — a second sweep finds nothing left to complete", async () => {
it("is idempotent — a second auto-start sweep finds nothing left to start", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const result = await autoCompleteMissions(payload, now, { missionIds });
const result = await autoStartMissions(payload, now, { missionIds });
expect(result.started).toBe(0);
expect(result.missions).toEqual([]);
});
it("auto-completes Scheduled and Active missions once start + estimatedDuration has passed", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const at = (hourOffset: number): Date => {
const d = new Date(now);
d.setHours(now.getHours() + hourOffset);
return d;
};
const fromScheduled = await makeMission("done-from-scheduled", "Scheduled", at(-6), 240);
const fromActive = await makeMission("done-from-active", "Active", at(-5), 60);
const inWindow = await makeMission("done-in-window", "Active", at(-1), 240);
const readyPast = await makeMission("done-ready", "Ready", at(-5), 60);
const cancelledPast = await makeMission("done-cancelled", "Cancelled", at(-5), 60);
const ids = [fromScheduled.id, fromActive.id, inWindow.id, readyPast.id, cancelledPast.id];
const result = await autoCompleteMissions(payload, now, { missionIds: ids });
expect(result.completed).toBe(2);
expect(result.missions.map((m) => m.id).sort()).toEqual([fromScheduled.id, fromActive.id].sort());
completedBySweep.push(...result.missions.map((m) => m.id));
expect((await fetchMission(fromScheduled.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(fromActive.id)).ownershipAndStatus.status).toBe("Completed");
expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
// Draft and terminal statuses are never auto-completed.
expect((await fetchMission(readyPast.id)).ownershipAndStatus.status).toBe("Ready");
expect((await fetchMission(cancelledPast.id)).ownershipAndStatus.status).toBe("Cancelled");
});
it("is idempotent — a second auto-complete sweep finds nothing left to complete", async () => {
const now = new Date();
now.setHours(12, 0, 0, 0);
const result = await autoCompleteMissions(payload, now, { missionIds: completedBySweep });
expect(result.completed).toBe(0);
expect(result.missions).toEqual([]);
});
@ -202,10 +244,10 @@ describe("Mission auto-complete", () => {
const now = new Date();
now.setHours(15, 0, 0, 0);
const start = new Date(now);
start.setDate(now.getDate() - 1);
start.setDate(now.getDate() - 2);
start.setHours(20, 0, 0, 0);
const due = await makeMission("timestamp-due", "Scheduled", start);
const due = await makeMission("timestamp-due", "Scheduled", start, 60);
const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] });
expect(result.completed).toBe(1);
@ -217,11 +259,11 @@ describe("Mission auto-complete", () => {
it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => {
const start = new Date();
// Future start date — the auto-complete sweep must never touch this mission.
// Future start date — neither sweep must ever touch this mission.
start.setDate(start.getDate() + 1);
start.setHours(20, 0, 0, 0);
const mission = await makeMission("manual-complete", "Scheduled", start);
const mission = await makeMission("manual-complete", "Scheduled", start, 60);
expect(mission.ownershipAndStatus.completedAt).toBeNull();
// Non-terminal transitions must not stamp a completion time.
@ -271,13 +313,41 @@ describe("Mission auto-complete", () => {
);
});
it("classifies day boundaries deterministically", () => {
const now = new Date(2026, 7, 24, 12, 0, 0);
expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime());
expect(isPastScheduledDay("2026-08-23T23:59:59.999", now)).toBe(true);
expect(isPastScheduledDay("2026-08-24T00:00:00.000", now)).toBe(false);
expect(isPastScheduledDay("2026-08-24T19:30:00.000", now)).toBe(false);
expect(isPastScheduledDay("2026-08-25T00:00:00.000", now)).toBe(false);
expect(isPastScheduledDay("not-a-date", now)).toBe(false);
it("classifies start and window boundaries deterministically", () => {
// missionEndTime adds the duration in minutes to the start.
expect(missionEndTime("2026-08-24T20:00:00.000Z", 240)?.toISOString()).toBe(
"2026-08-25T00:00:00.000Z",
);
// Missing / invalid durations fall back to the default.
expect(missionEndTime("2026-08-24T20:00:00.000Z", null)?.toISOString()).toBe(
new Date(
new Date("2026-08-24T20:00:00.000Z").getTime() +
DEFAULT_MISSION_DURATION_MINUTES * 60_000,
).toISOString(),
);
expect(missionEndTime("not-a-date", 60)).toBeNull();
const now = new Date("2026-08-24T20:00:00.000Z");
expect(hasMissionStarted("2026-08-24T19:59:59.000Z", now)).toBe(true);
expect(hasMissionStarted("2026-08-24T20:00:00.000Z", now)).toBe(true);
expect(hasMissionStarted("2026-08-24T20:00:01.000Z", now)).toBe(false);
expect(hasMissionStarted("not-a-date", now)).toBe(false);
expect(isMissionWindowComplete("2026-08-24T20:00:00.000Z", 240, now)).toBe(false);
expect(
isMissionWindowComplete(
"2026-08-24T20:00:00.000Z",
240,
new Date("2026-08-25T00:00:00.000Z"),
),
).toBe(true);
expect(
isMissionWindowComplete(
"2026-08-24T20:00:00.000Z",
240,
new Date("2026-08-24T23:59:59.999Z"),
),
).toBe(false);
expect(isMissionWindowComplete("not-a-date", 240, now)).toBe(false);
});
});