From 74564afb2bd0ec42021c30c4e41d7a9e2c27cff2 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sat, 3 Oct 2026 03:29:35 -0400 Subject: [PATCH] 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. --- .../intelligence/missions/actions.ts | 24 ++- src/bot/AGENTS.md | 4 +- src/bot/services/missionEmbeds.ts | 27 +-- src/bot/services/readyReminders.ts | 13 +- src/collections/game/GameEventLogs.ts | 2 + src/collections/intelligence/Missions.ts | 8 +- .../frontend/intelligence/SideOpControls.tsx | 24 +-- src/lib/intelligence/imminentReminders.ts | 8 +- src/lib/intelligence/missionLifecycle.ts | 201 +++++++++++++++--- src/lib/intelligence/readyReminders.ts | 42 ++-- src/lib/notifications/index.ts | 20 +- src/scripts/missionTick.ts | 9 +- src/utils/event-log/emit.ts | 4 +- src/utils/event-log/eventTypes.ts | 5 +- tests/int/imminent-reminders.int.spec.ts | 5 +- tests/int/mission-lifecycle.int.spec.ts | 152 +++++++++---- 16 files changed, 399 insertions(+), 149 deletions(-) diff --git a/src/app/(frontend)/intelligence/missions/actions.ts b/src/app/(frontend)/intelligence/missions/actions.ts index 192e2ab..cfabd22 100644 --- a/src/app/(frontend)/intelligence/missions/actions.ts +++ b/src/app/(frontend)/intelligence/missions/actions.ts @@ -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> { 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, diff --git a/src/bot/AGENTS.md b/src/bot/AGENTS.md index 3c398ea..1b05f7e 100644 --- a/src/bot/AGENTS.md +++ b/src/bot/AGENTS.md @@ -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: ") 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: ") 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 diff --git a/src/bot/services/missionEmbeds.ts b/src/bot/services/missionEmbeds.ts index b34cec9..0fd7805 100644 --- a/src/bot/services/missionEmbeds.ts +++ b/src/bot/services/missionEmbeds.ts @@ -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 { "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 => { }; /** - * 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 => { const res = await payload.find({ @@ -172,7 +166,7 @@ const getRescheduledMissions = async (payload: Payload): Promise => { 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 => { 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 diff --git a/src/bot/services/readyReminders.ts b/src/bot/services/readyReminders.ts index 9ecbf28..aee1518 100644 --- a/src/bot/services/readyReminders.ts +++ b/src/bot/services/readyReminders.ts @@ -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 ( diff --git a/src/collections/game/GameEventLogs.ts b/src/collections/game/GameEventLogs.ts index 20f4ad0..a8c075b 100644 --- a/src/collections/game/GameEventLogs.ts +++ b/src/collections/game/GameEventLogs.ts @@ -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 = { diff --git a/src/collections/intelligence/Missions.ts b/src/collections/intelligence/Missions.ts index f8e3616..4ff34cb 100644 --- a/src/collections/intelligence/Missions.ts +++ b/src/collections/intelligence/Missions.ts @@ -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", diff --git a/src/components/frontend/intelligence/SideOpControls.tsx b/src/components/frontend/intelligence/SideOpControls.tsx index fe9607d..0ecc01c 100644 --- a/src/components/frontend/intelligence/SideOpControls.tsx +++ b/src/components/frontend/intelligence/SideOpControls.tsx @@ -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({
- {canMarkReady && ( - )}

- 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.

diff --git a/src/lib/intelligence/imminentReminders.ts b/src/lib/intelligence/imminentReminders.ts index 537c092..d88802a 100644 --- a/src/lib/intelligence/imminentReminders.ts +++ b/src/lib/intelligence/imminentReminders.ts @@ -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 } }, diff --git a/src/lib/intelligence/missionLifecycle.ts b/src/lib/intelligence/missionLifecycle.ts index 896bcfd..0aaa763 100644 --- a/src/lib/intelligence/missionLifecycle.ts +++ b/src/lib/intelligence/missionLifecycle.ts @@ -6,52 +6,113 @@ 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 + * 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 { - const cutoff = startOfToday(now).toISOString(); + options: MissionLifecycleOptions = {}, +): Promise { 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 { + 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 }, diff --git a/src/lib/intelligence/readyReminders.ts b/src/lib/intelligence/readyReminders.ts index c58bc78..6ea640e 100644 --- a/src/lib/intelligence/readyReminders.ts +++ b/src/lib/intelligence/readyReminders.ts @@ -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 => { @@ -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({ diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index b4bdaa4..dcb37b6 100644 --- a/src/lib/notifications/index.ts +++ b/src/lib/notifications/index.ts @@ -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: diff --git a/src/scripts/missionTick.ts b/src/scripts/missionTick.ts index ca81786..efcceab 100644 --- a/src/scripts/missionTick.ts +++ b/src/scripts/missionTick.ts @@ -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( diff --git a/src/utils/event-log/emit.ts b/src/utils/event-log/emit.ts index ca5cfb9..1dea185 100644 --- a/src/utils/event-log/emit.ts +++ b/src/utils/event-log/emit.ts @@ -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; } diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index 4b876b2..0c57140 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -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", diff --git a/tests/int/imminent-reminders.int.spec.ts b/tests/int/imminent-reminders.int.spec.ts index f29f827..ee44aeb 100644 --- a/tests/int/imminent-reminders.int.spec.ts +++ b/tests/int/imminent-reminders.int.spec.ts @@ -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 () => { diff --git a/tests/int/mission-lifecycle.int.spec.ts b/tests/int/mission-lifecycle.int.spec.ts index c323959..4ada35a 100644 --- a/tests/int/mission-lifecycle.int.spec.ts +++ b/tests/int/mission-lifecycle.int.spec.ts @@ -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 => { 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; - 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); }); });