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:
parent
62573f74e6
commit
74564afb2b
16 changed files with 399 additions and 149 deletions
|
|
@ -543,7 +543,11 @@ export async function createFridaySideOp(
|
||||||
...(input.coverImageId ? { coverImage: input.coverImageId } : {}),
|
...(input.coverImageId ? { coverImage: input.coverImageId } : {}),
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
ownershipAndStatus: {
|
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",
|
visibility: "unit",
|
||||||
authors: [userId],
|
authors: [userId],
|
||||||
},
|
},
|
||||||
|
|
@ -823,7 +827,7 @@ export async function cancelFridaySideOp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markSideOpReady(
|
export async function markSideOpScheduled(
|
||||||
missionId: number,
|
missionId: number,
|
||||||
): Promise<ActionResult<{ missionId: number }>> {
|
): Promise<ActionResult<{ missionId: number }>> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -840,21 +844,21 @@ export async function markSideOpReady(
|
||||||
if (!canManage && !isSideOpOwner(mission, userId)) {
|
if (!canManage && !isSideOpOwner(mission, userId)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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;
|
const status = mission.ownershipAndStatus.status;
|
||||||
if (status === "Ready") {
|
if (status === "Scheduled") {
|
||||||
return { success: true, data: { missionId: mission.id } };
|
return { success: true, data: { missionId: mission.id } };
|
||||||
}
|
}
|
||||||
if (!["Concept", "Planning", "Scheduled"].includes(status)) {
|
if (!["Concept", "Planning", "Ready"].includes(status)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error:
|
||||||
status === "Active"
|
status === "Active"
|
||||||
? "This mission is already underway and cannot be marked Ready."
|
? "This mission is already underway and cannot be scheduled."
|
||||||
: `This mission is ${status.toLowerCase()} and can no longer be marked Ready.`,
|
: `This mission is ${status.toLowerCase()} and can no longer be scheduled.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -865,14 +869,14 @@ export async function markSideOpReady(
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
data: {
|
data: {
|
||||||
ownershipAndStatus: {
|
ownershipAndStatus: {
|
||||||
status: "Ready",
|
status: "Scheduled",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.MissionReady,
|
type: EventTypes.MissionCommit,
|
||||||
message: `${user.displayName || user.username} marked side operation "${missionRef(updated)}" as Ready. Details are locked in and the roll-call can be posted.`,
|
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,
|
actor: userId,
|
||||||
targetCollection: "missions",
|
targetCollection: "missions",
|
||||||
targetId: updated.id,
|
targetId: updated.id,
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ bot/
|
||||||
|
|
||||||
## Feature flow: attendance
|
## 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
|
## Feature flow: notifications
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start
|
||||||
|
|
||||||
## Feature flow: imminent op reminder
|
## 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
|
## Feature flow: Friday open-slot notice
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,12 @@ const missionTitle = (mission: Mission): string =>
|
||||||
const memberName = (user: User): string => user.displayName ?? user.username;
|
const memberName = (user: User): string => user.displayName ?? user.username;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roll call is sent once: as soon as the mission is Ready, or three days
|
* Roll call is sent once, three days before a Scheduled mission starts. Side
|
||||||
* before a Scheduled mission, whichever happens first.
|
* 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 => {
|
export const isRollCallDue = (mission: Mission, now = Date.now()): boolean => {
|
||||||
if (mission.discordAttendanceSentAt) return false;
|
if (mission.discordAttendanceSentAt) return false;
|
||||||
if (mission.ownershipAndStatus.status === "Ready") return true;
|
|
||||||
if (mission.ownershipAndStatus.status !== "Scheduled") return false;
|
if (mission.ownershipAndStatus.status !== "Scheduled") return false;
|
||||||
|
|
||||||
const start = new Date(mission.classification.startDateTime).getTime();
|
const start = new Date(mission.classification.startDateTime).getTime();
|
||||||
|
|
@ -118,12 +118,6 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
|
||||||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||||
{
|
{
|
||||||
or: [
|
or: [
|
||||||
{
|
|
||||||
and: [
|
|
||||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
|
||||||
{ "ownershipAndStatus.status": { equals: "Ready" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
and: [
|
and: [
|
||||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
{ "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
|
* Cancelled missions that are live again (status back to Scheduled) and still
|
||||||
* still carry the cancellation marker: the reschedule announcement has not
|
* carry the cancellation marker: the reschedule announcement has not happened
|
||||||
* happened yet.
|
* yet.
|
||||||
*/
|
*/
|
||||||
const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
|
const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
|
||||||
const res = await payload.find({
|
const res = await payload.find({
|
||||||
|
|
@ -172,7 +166,7 @@ const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||||
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
|
{ "ownershipAndStatus.status": { in: ["Scheduled"] } },
|
||||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||||
{ discordCancelledAt: { exists: true } },
|
{ discordCancelledAt: { exists: true } },
|
||||||
],
|
],
|
||||||
|
|
@ -516,7 +510,7 @@ export const reconcileMission = async (
|
||||||
|
|
||||||
if (
|
if (
|
||||||
mission.ownershipAndStatus.visibility !== "unit" ||
|
mission.ownershipAndStatus.visibility !== "unit" ||
|
||||||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
!["Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
||||||
(!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt)
|
(!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt)
|
||||||
) {
|
) {
|
||||||
return;
|
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
|
* 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
|
* 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.
|
* the marker so the reconcile loop posts the roll-call as usual.
|
||||||
|
|
@ -579,8 +573,7 @@ const announceReschedule = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const start = new Date(mission.classification.startDateTime).getTime();
|
const start = new Date(mission.classification.startDateTime).getTime();
|
||||||
const rollCallAlreadyLive = !!mission.discordAttendanceSentAt;
|
const rollCallAlreadyLive = !!mission.discordAttendanceSentAt;
|
||||||
const rollCallDueNow =
|
const rollCallDueNow = start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
|
||||||
mission.ownershipAndStatus.status === "Ready" || start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
|
|
||||||
const rollCallDueUnix =
|
const rollCallDueUnix =
|
||||||
rollCallAlreadyLive || rollCallDueNow
|
rollCallAlreadyLive || rollCallDueNow
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ import {
|
||||||
/**
|
/**
|
||||||
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a
|
* 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
|
* 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
|
* them to finish the op and commit it to "Scheduled" (markSideOpScheduled): the
|
||||||
* roll-call, locks in details, and delivers the mission hooks.
|
* status that posts the roll-call, locks in details, and delivers the mission
|
||||||
|
* hooks.
|
||||||
*
|
*
|
||||||
* The DM deliberately ignores `preferences.discord.enabled` (the notification
|
* The DM deliberately ignores `preferences.discord.enabled` (the notification
|
||||||
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
|
* 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 days = daysUntil(mission.classification.startDateTime, now);
|
||||||
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
|
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
|
||||||
return new EmbedBuilder()
|
return new EmbedBuilder()
|
||||||
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`)
|
.setTitle(`⏰ Schedule check: ${missionTitle(mission)}`)
|
||||||
.setDescription(
|
.setDescription(
|
||||||
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` +
|
`Your op starts **${when}** and it isn't marked **Scheduled** 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.",
|
"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)
|
.setColor(EMBED_COLOR)
|
||||||
.addFields({
|
.addFields({
|
||||||
name: "Open the op",
|
name: "Open the op",
|
||||||
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
|
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 (
|
const processMission = async (
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ const TARGET_COLLECTIONS = [
|
||||||
{ label: "Custom Radio Tests", value: "custom-radio-tests" },
|
{ label: "Custom Radio Tests", value: "custom-radio-tests" },
|
||||||
{ label: "Custom Radio Test Scores", value: "custom-radio-test-scores" },
|
{ label: "Custom Radio Test Scores", value: "custom-radio-test-scores" },
|
||||||
{ label: "Ribbon Submissions", value: "ribbon-submissions" },
|
{ label: "Ribbon Submissions", value: "ribbon-submissions" },
|
||||||
|
{ label: "Promotion Nominations", value: "promotion-nominations" },
|
||||||
|
{ label: "Leadership Transfers", value: "leadership-transfers" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const GameEventLogs: CollectionConfig = {
|
export const GameEventLogs: CollectionConfig = {
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,7 @@ export const Missions: CollectionConfig = {
|
||||||
type: "date",
|
type: "date",
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
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,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -281,7 +281,7 @@ export const Missions: CollectionConfig = {
|
||||||
type: "date",
|
type: "date",
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
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,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -421,6 +421,10 @@ export const Missions: CollectionConfig = {
|
||||||
],
|
],
|
||||||
defaultValue: "Concept",
|
defaultValue: "Concept",
|
||||||
required: true,
|
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",
|
name: "visibility",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
cancelFridaySideOp,
|
cancelFridaySideOp,
|
||||||
markSideOpReady,
|
markSideOpScheduled,
|
||||||
} from "@/app/(frontend)/intelligence/missions/actions";
|
} from "@/app/(frontend)/intelligence/missions/actions";
|
||||||
import {
|
import {
|
||||||
CreateSideOpDialog,
|
CreateSideOpDialog,
|
||||||
|
|
@ -37,16 +37,16 @@ export function SideOpControls({
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const isTerminal = status === "Completed" || status === "Cancelled";
|
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 () => {
|
startTransition(async () => {
|
||||||
const result = await markSideOpReady(initial.missionId);
|
const result = await markSideOpScheduled(initial.missionId);
|
||||||
if (!result.success) {
|
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;
|
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();
|
router.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -80,10 +80,10 @@ export function SideOpControls({
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{canMarkReady && (
|
{canMarkScheduled && (
|
||||||
<Button size="sm" variant="default" onClick={handleMarkReady} disabled={isPending}>
|
<Button size="sm" variant="default" onClick={handleMarkScheduled} disabled={isPending}>
|
||||||
<CheckCircleIcon className="size-3.5" />
|
<CheckCircleIcon className="size-3.5" />
|
||||||
Mark as Ready
|
Mark as Scheduled
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
|
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
|
||||||
|
|
@ -101,9 +101,9 @@ export function SideOpControls({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Marking the operation Ready locks in the details so the Discord roll-call can be posted
|
Scheduling the operation commits it: the Discord roll-call is posted within three days of
|
||||||
and the mission hooks delivered. Editing covers details, campaign, and narrative.
|
the op and the mission hooks are delivered. Editing covers details, campaign, and
|
||||||
Cancelling frees your scheduling slot and updates the Discord embed.
|
narrative. Cancelling frees your scheduling slot and updates the Discord embed.
|
||||||
</p>
|
</p>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ export const IMMINENT_REMINDER_WINDOW_MS = 15 * 60 * 1000;
|
||||||
export const IMMINENT_REMINDER_GRACE_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
|
* Ops due for the imminent reminder: unit-visible, Scheduled, with a live
|
||||||
* live roll-call message, starting within the reminder window (or just past
|
* roll-call message, starting within the reminder window (or just past it,
|
||||||
* it, within the grace bound), and without the one-shot marker yet.
|
* within the grace bound), and without the one-shot marker yet.
|
||||||
*/
|
*/
|
||||||
export const getImminentReminderMissions = async (
|
export const getImminentReminderMissions = async (
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
|
|
@ -45,7 +45,7 @@ export const getImminentReminderMissions = async (
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||||
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
|
{ "ownershipAndStatus.status": { in: ["Scheduled"] } },
|
||||||
{ discordMessageId: { exists: true } },
|
{ discordMessageId: { exists: true } },
|
||||||
{ imminentReminderSentAt: { exists: false } },
|
{ imminentReminderSentAt: { exists: false } },
|
||||||
{ "classification.startDateTime": { greater_than: windowStartIso } },
|
{ "classification.startDateTime": { greater_than: windowStartIso } },
|
||||||
|
|
|
||||||
|
|
@ -6,52 +6,113 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statuses that count as "committed to run" and are auto-completed once the
|
* Statuses that are committed to run and are auto-started (flipped to Active)
|
||||||
* calendar day of their scheduled start has fully passed. Draft statuses
|
* the moment their scheduled start time arrives. Draft statuses
|
||||||
* (Concept/Planning/Ready) are left untouched — a mission that never ran
|
* (Concept/Planning/Ready) are left untouched — a mission that was never
|
||||||
* should not silently become Completed — and terminal statuses
|
* 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.
|
* (Completed/Cancelled) are never re-processed.
|
||||||
*/
|
*/
|
||||||
const AUTO_COMPLETABLE_STATUSES = ["Scheduled", "Active"] as const;
|
const AUTO_COMPLETABLE_STATUSES = ["Scheduled", "Active"] as const;
|
||||||
|
|
||||||
export interface MissionAutoCompleteSummary {
|
/**
|
||||||
completed: number;
|
* Fallback duration (minutes) when a mission's `estimatedDuration` is missing
|
||||||
missions: { id: number; name: string; codeName: string | null }[];
|
* 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). */
|
/** Restrict the sweep to specific mission ids (lets tests stay hermetic). */
|
||||||
missionIds?: number[];
|
missionIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start of `now`'s calendar day in the server's local timezone. */
|
/**
|
||||||
export function startOfToday(now: Date = new Date()): Date {
|
* Resolve a mission's end time (start + estimated duration), or null when the
|
||||||
const start = new Date(now);
|
* start timestamp is missing/unparseable. A missing or non-positive duration
|
||||||
start.setHours(0, 0, 0, 0);
|
* falls back to DEFAULT_MISSION_DURATION_MINUTES.
|
||||||
return start;
|
*/
|
||||||
|
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
|
* True once the mission's scheduled start time has arrived. Unparseable dates
|
||||||
* ended — i.e. from the start of the day AFTER the scheduled date. A mission
|
* fall closed so malformed data is never auto-started.
|
||||||
* scheduled for today never counts, even after its start time has passed.
|
|
||||||
* Unparseable dates fall closed so malformed data is never auto-completed.
|
|
||||||
*/
|
*/
|
||||||
export function isPastScheduledDay(startDateTime: string, now: Date = new Date()): boolean {
|
export function hasMissionStarted(
|
||||||
const start = new Date(startDateTime);
|
startDateTime: string | null | undefined,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
const start = new Date(startDateTime ?? "");
|
||||||
if (Number.isNaN(start.getTime())) return false;
|
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,
|
payload: PayloadType,
|
||||||
now: Date = new Date(),
|
now: Date = new Date(),
|
||||||
options: MissionAutoCompleteOptions = {},
|
options: MissionLifecycleOptions = {},
|
||||||
): Promise<MissionAutoCompleteSummary> {
|
): Promise<MissionAutoStartSummary> {
|
||||||
const cutoff = startOfToday(now).toISOString();
|
|
||||||
const conditions: Where[] = [
|
const conditions: Where[] = [
|
||||||
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } },
|
{ "ownershipAndStatus.status": { in: [...AUTO_STARTABLE_STATUSES] } },
|
||||||
{ "classification.startDateTime": { less_than: cutoff } },
|
{ "classification.startDateTime": { less_than_equal: now.toISOString() } },
|
||||||
];
|
];
|
||||||
if (options.missionIds) {
|
if (options.missionIds) {
|
||||||
conditions.push({ id: { in: options.missionIds } });
|
conditions.push({ id: { in: options.missionIds } });
|
||||||
|
|
@ -66,13 +127,91 @@ export async function autoCompleteMissions(
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
})) as unknown as { docs: Mission[] };
|
})) as unknown as { docs: Mission[] };
|
||||||
|
|
||||||
const completed: MissionAutoCompleteSummary["missions"] = [];
|
const started: MissionLifecycleEntry[] = [];
|
||||||
for (const mission of due.docs) {
|
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;
|
const previousStatus = mission.ownershipAndStatus?.status ?? null;
|
||||||
try {
|
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({
|
await payload.update({
|
||||||
collection: "missions",
|
collection: "missions",
|
||||||
id: mission.id,
|
id: mission.id,
|
||||||
|
|
@ -81,7 +220,7 @@ export async function autoCompleteMissions(
|
||||||
});
|
});
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.MissionAutoComplete,
|
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",
|
targetCollection: "missions",
|
||||||
targetId: mission.id,
|
targetId: mission.id,
|
||||||
data: { previousStatus },
|
data: { previousStatus },
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,26 @@ import { notifyUser } from "@/lib/notifications";
|
||||||
*
|
*
|
||||||
* Starting READY_REMINDER_LEAD_DAYS days before an op's scheduled start, the
|
* 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
|
* 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
|
* finish the op and commit it to "Scheduled" (markSideOpScheduled): the status
|
||||||
* embed, locks in details, and delivers the mission hooks. Reminders stop the
|
* that triggers the roll-call embed, locks in details, and delivers the mission
|
||||||
* moment the op is Ready (or cancelled/completed/active), and a marker field
|
* hooks. Reminders stop the moment the op is Scheduled (or
|
||||||
* records the last server-local day each channel was sent so the cadence is
|
* cancelled/completed/active), and a marker field records the last server-local
|
||||||
* exactly one reminder per day per channel.
|
* 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
|
* 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.
|
* never reminded.
|
||||||
*
|
*
|
||||||
* Shared by the mission-tick bin (web notifications) and the Discord bot's
|
* 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. */
|
/** 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";
|
export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statuses that still need the creator's attention. "Ready" is the committed
|
* Statuses that still need the creator's attention. "Scheduled" is the
|
||||||
* state the reminders push toward; Active/Completed/Cancelled are terminal or
|
* committed state the reminders push toward; Active/Completed/Cancelled are
|
||||||
* already underway.
|
* 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 =>
|
export const missionTitle = (mission: Mission): string =>
|
||||||
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
|
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
|
* 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
|
* 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
|
* stay deduped, and a status flapping Scheduled back to Ready resumes reminders
|
||||||
* next day instead of being suppressed forever.
|
* the next day instead of being suppressed forever.
|
||||||
*/
|
*/
|
||||||
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
|
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
|
||||||
if (!marker) return true;
|
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));
|
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
|
* terminal, and starting within the lead window (future start, at most
|
||||||
* READY_REMINDER_LEAD_DAYS days out). Marker filtering is done per-channel by
|
* 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.
|
* expressed in a single where clause.
|
||||||
*/
|
*/
|
||||||
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
|
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
|
||||||
|
|
@ -122,8 +130,8 @@ export const sendWebReadyReminders = async (
|
||||||
await notifyUser(payload, {
|
await notifyUser(payload, {
|
||||||
userId: creatorId,
|
userId: creatorId,
|
||||||
type: READY_REMINDER_NOTIFICATION_TYPE,
|
type: READY_REMINDER_NOTIFICATION_TYPE,
|
||||||
title: `Ready check: ${missionTitle(mission)}`,
|
title: `Schedule 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.`,
|
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}`,
|
link: `/intelligence/missions/${mission.id}`,
|
||||||
});
|
});
|
||||||
await payload.update({
|
await payload.update({
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ export function notificationLabel(type?: string | null): string {
|
||||||
case "ticket:status":
|
case "ticket:status":
|
||||||
return "Ticket updated";
|
return "Ticket updated";
|
||||||
case "mission:ready-reminder":
|
case "mission:ready-reminder":
|
||||||
return "Ready reminder";
|
return "Schedule reminder";
|
||||||
case "assignment-transfer:request":
|
case "assignment-transfer:request":
|
||||||
return "Transfer request";
|
return "Transfer request";
|
||||||
case "assignment-transfer:approved":
|
case "assignment-transfer:approved":
|
||||||
|
|
@ -117,6 +117,24 @@ export function notificationLabel(type?: string | null): string {
|
||||||
return "Ribbon not approved";
|
return "Ribbon not approved";
|
||||||
case "personnel:promoted":
|
case "personnel:promoted":
|
||||||
return "Promotion";
|
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":
|
case "mission:role-promoted":
|
||||||
return "Role slot granted";
|
return "Role slot granted";
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import { getPayload } 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 { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
|
||||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||||
|
|
||||||
|
|
@ -12,6 +12,9 @@ export const script = async (config: SanitizedConfig) => {
|
||||||
log.info("[Mission Tick] Starting mission tick...");
|
log.info("[Mission Tick] Starting mission tick...");
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
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 result = await autoCompleteMissions(payload);
|
||||||
|
|
||||||
const reminders = await sendWebReadyReminders(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.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);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ interface GameEventInput {
|
||||||
| "custom-minefield-scores"
|
| "custom-minefield-scores"
|
||||||
| "custom-radio-tests"
|
| "custom-radio-tests"
|
||||||
| "custom-radio-test-scores"
|
| "custom-radio-test-scores"
|
||||||
| "ribbon-submissions";
|
| "ribbon-submissions"
|
||||||
|
| "promotion-nominations"
|
||||||
|
| "leadership-transfers";
|
||||||
targetId?: number;
|
targetId?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,8 @@ export const EventTypes = {
|
||||||
|
|
||||||
// Personnel
|
// Personnel
|
||||||
PersonnelPromoted: "personnel:promoted",
|
PersonnelPromoted: "personnel:promoted",
|
||||||
|
PersonnelTransferred: "personnel:transferred",
|
||||||
|
PromotionNominationApproved: "promotion:approved",
|
||||||
|
|
||||||
// Awards
|
// Awards
|
||||||
AwardGrant: "award:grant",
|
AwardGrant: "award:grant",
|
||||||
|
|
@ -148,10 +150,11 @@ export const EventTypes = {
|
||||||
AwardRibbonRejected: "award:ribbon-rejected",
|
AwardRibbonRejected: "award:ribbon-rejected",
|
||||||
|
|
||||||
// Missions
|
// Missions
|
||||||
|
MissionAutoStart: "mission:auto-start",
|
||||||
MissionAutoComplete: "mission:auto-complete",
|
MissionAutoComplete: "mission:auto-complete",
|
||||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||||
MissionSchedule: "mission:schedule",
|
MissionSchedule: "mission:schedule",
|
||||||
MissionReady: "mission:ready",
|
MissionCommit: "mission:commit",
|
||||||
MissionUpdate: "mission:update",
|
MissionUpdate: "mission:update",
|
||||||
MissionCancel: "mission:cancel",
|
MissionCancel: "mission:cancel",
|
||||||
MissionMoveCampaign: "mission:move-campaign",
|
MissionMoveCampaign: "mission:move-campaign",
|
||||||
|
|
|
||||||
|
|
@ -149,15 +149,16 @@ describe("Imminent-op reminder query", () => {
|
||||||
return mission;
|
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 now = new Date();
|
||||||
const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000);
|
const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000);
|
||||||
const ready = await makeMission("in-window-ready", { status: "Ready", start });
|
const ready = await makeMission("in-window-ready", { status: "Ready", start });
|
||||||
const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start });
|
const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start });
|
||||||
|
|
||||||
const found = await getImminentReminderMissions(payload, now);
|
const found = await getImminentReminderMissions(payload, now);
|
||||||
expect(found.map((m) => m.id)).toContain(ready.id);
|
|
||||||
expect(found.map((m) => m.id)).toContain(scheduled.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 () => {
|
it("includes the exact window edge and the grace zone just past the start", async () => {
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,26 @@ import config from "@/payload.config";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
|
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_MISSION_DURATION_MINUTES,
|
||||||
autoCompleteMissions,
|
autoCompleteMissions,
|
||||||
isPastScheduledDay,
|
autoStartMissions,
|
||||||
startOfToday,
|
hasMissionStarted,
|
||||||
|
isMissionWindowComplete,
|
||||||
|
missionEndTime,
|
||||||
} from "@/lib/intelligence/missionLifecycle";
|
} from "@/lib/intelligence/missionLifecycle";
|
||||||
|
|
||||||
let payload: Payload;
|
let payload: Payload;
|
||||||
|
|
||||||
const RUN = `mlc-${Date.now().toString(36)}`;
|
const RUN = `mlc-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
describe("Mission auto-complete", () => {
|
describe("Mission lifecycle (auto-start + auto-complete)", () => {
|
||||||
let mapId: number;
|
let mapId: number;
|
||||||
let campaignId: number;
|
let campaignId: number;
|
||||||
let authorId: number;
|
let authorId: number;
|
||||||
const missionIds: 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 () => {
|
beforeAll(async () => {
|
||||||
const payloadConfig = await config;
|
const payloadConfig = await config;
|
||||||
|
|
@ -102,20 +108,21 @@ describe("Mission auto-complete", () => {
|
||||||
label: string,
|
label: string,
|
||||||
status: Mission["ownershipAndStatus"]["status"],
|
status: Mission["ownershipAndStatus"]["status"],
|
||||||
start: Date,
|
start: Date,
|
||||||
|
estimatedDuration = 60,
|
||||||
): Promise<Mission> => {
|
): Promise<Mission> => {
|
||||||
const mission = (await payload.create({
|
const mission = (await payload.create({
|
||||||
collection: "missions",
|
collection: "missions",
|
||||||
data: {
|
data: {
|
||||||
name: `${RUN} ${label}`,
|
name: `${RUN} ${label}`,
|
||||||
codeName: `${RUN}-${label}`,
|
codeName: `${RUN}-${label}`,
|
||||||
summary: "Auto-complete boundary test mission",
|
summary: "Mission lifecycle boundary test mission",
|
||||||
operationType: "main",
|
operationType: "main",
|
||||||
classification: {
|
classification: {
|
||||||
map: mapId,
|
map: mapId,
|
||||||
missionType: "PvE",
|
missionType: "PvE",
|
||||||
campaign: campaignId,
|
campaign: campaignId,
|
||||||
startDateTime: start.toISOString(),
|
startDateTime: start.toISOString(),
|
||||||
estimatedDuration: 60,
|
estimatedDuration,
|
||||||
},
|
},
|
||||||
ownershipAndStatus: {
|
ownershipAndStatus: {
|
||||||
authors: [authorId],
|
authors: [authorId],
|
||||||
|
|
@ -148,41 +155,37 @@ describe("Mission auto-complete", () => {
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
}) as unknown as Promise<Mission>;
|
}) 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();
|
const now = new Date();
|
||||||
now.setHours(12, 0, 0, 0);
|
now.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
const at = (dayOffset: number, hour: number): Date => {
|
const at = (hourOffset: number): Date => {
|
||||||
const d = new Date(now);
|
const d = new Date(now);
|
||||||
d.setDate(now.getDate() + dayOffset);
|
d.setHours(now.getHours() + hourOffset);
|
||||||
d.setHours(hour, 0, 0, 0);
|
|
||||||
return d;
|
return d;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dueScheduled = await makeMission("due-scheduled", "Scheduled", at(-1, 20));
|
const inWindow = await makeMission("start-in-window", "Scheduled", at(-1), 240);
|
||||||
const dueActive = await makeMission("due-active", "Active", at(-3, 20));
|
const future = await makeMission("start-future", "Scheduled", at(1), 240);
|
||||||
const midnightEdge = await makeMission("midnight-edge", "Scheduled", at(-1, 23));
|
const elapsed = await makeMission("start-elapsed", "Scheduled", at(-5), 60);
|
||||||
const sameDayEarlier = await makeMission("same-day-earlier", "Scheduled", at(0, 8));
|
const alreadyActive = await makeMission("start-already-active", "Active", at(-1), 240);
|
||||||
const sameDayLater = await makeMission("same-day-later", "Scheduled", at(0, 13));
|
const cancelled = await makeMission("start-cancelled", "Cancelled", at(-1), 240);
|
||||||
const draftReady = await makeMission("draft-ready", "Ready", at(-1, 20));
|
const ids = [inWindow.id, future.id, elapsed.id, alreadyActive.id, cancelled.id];
|
||||||
const cancelled = await makeMission("cancelled", "Cancelled", at(-1, 20));
|
|
||||||
|
|
||||||
const result = await autoCompleteMissions(payload, now, { missionIds });
|
const result = await autoStartMissions(payload, now, { missionIds: ids });
|
||||||
|
|
||||||
expect(result.missions.map((m) => m.id).sort()).toEqual(
|
expect(result.started).toBe(1);
|
||||||
[dueScheduled.id, dueActive.id, midnightEdge.id].sort(),
|
expect(result.missions.map((m) => m.id)).toEqual([inWindow.id]);
|
||||||
);
|
|
||||||
|
|
||||||
expect((await fetchMission(dueScheduled.id)).ownershipAndStatus.status).toBe("Completed");
|
expect((await fetchMission(inWindow.id)).ownershipAndStatus.status).toBe("Active");
|
||||||
expect((await fetchMission(dueActive.id)).ownershipAndStatus.status).toBe("Completed");
|
expect((await fetchMission(future.id)).ownershipAndStatus.status).toBe("Scheduled");
|
||||||
expect((await fetchMission(midnightEdge.id)).ownershipAndStatus.status).toBe("Completed");
|
// Fully elapsed windows are auto-complete's job, not a spurious start.
|
||||||
expect((await fetchMission(sameDayEarlier.id)).ownershipAndStatus.status).toBe("Scheduled");
|
expect((await fetchMission(elapsed.id)).ownershipAndStatus.status).toBe("Scheduled");
|
||||||
expect((await fetchMission(sameDayLater.id)).ownershipAndStatus.status).toBe("Scheduled");
|
expect((await fetchMission(alreadyActive.id)).ownershipAndStatus.status).toBe("Active");
|
||||||
expect((await fetchMission(draftReady.id)).ownershipAndStatus.status).toBe("Ready");
|
|
||||||
expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled");
|
expect((await fetchMission(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled");
|
||||||
|
|
||||||
// The partial group update must not wipe sibling fields.
|
// 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");
|
expect(updated.ownershipAndStatus.visibility).toBe("unit");
|
||||||
const authorRefs = Array.isArray(updated.ownershipAndStatus.authors)
|
const authorRefs = Array.isArray(updated.ownershipAndStatus.authors)
|
||||||
? updated.ownershipAndStatus.authors
|
? updated.ownershipAndStatus.authors
|
||||||
|
|
@ -190,10 +193,49 @@ describe("Mission auto-complete", () => {
|
||||||
expect(authorRefs).toContain(authorId);
|
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();
|
const now = new Date();
|
||||||
now.setHours(12, 0, 0, 0);
|
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.completed).toBe(0);
|
||||||
expect(result.missions).toEqual([]);
|
expect(result.missions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
@ -202,10 +244,10 @@ describe("Mission auto-complete", () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setHours(15, 0, 0, 0);
|
now.setHours(15, 0, 0, 0);
|
||||||
const start = new Date(now);
|
const start = new Date(now);
|
||||||
start.setDate(now.getDate() - 1);
|
start.setDate(now.getDate() - 2);
|
||||||
start.setHours(20, 0, 0, 0);
|
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] });
|
const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] });
|
||||||
expect(result.completed).toBe(1);
|
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 () => {
|
it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => {
|
||||||
const start = new Date();
|
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.setDate(start.getDate() + 1);
|
||||||
start.setHours(20, 0, 0, 0);
|
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();
|
expect(mission.ownershipAndStatus.completedAt).toBeNull();
|
||||||
|
|
||||||
// Non-terminal transitions must not stamp a completion time.
|
// Non-terminal transitions must not stamp a completion time.
|
||||||
|
|
@ -271,13 +313,41 @@ describe("Mission auto-complete", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifies day boundaries deterministically", () => {
|
it("classifies start and window boundaries deterministically", () => {
|
||||||
const now = new Date(2026, 7, 24, 12, 0, 0);
|
// missionEndTime adds the duration in minutes to the start.
|
||||||
expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime());
|
expect(missionEndTime("2026-08-24T20:00:00.000Z", 240)?.toISOString()).toBe(
|
||||||
expect(isPastScheduledDay("2026-08-23T23:59:59.999", now)).toBe(true);
|
"2026-08-25T00:00:00.000Z",
|
||||||
expect(isPastScheduledDay("2026-08-24T00:00:00.000", now)).toBe(false);
|
);
|
||||||
expect(isPastScheduledDay("2026-08-24T19:30:00.000", now)).toBe(false);
|
// Missing / invalid durations fall back to the default.
|
||||||
expect(isPastScheduledDay("2026-08-25T00:00:00.000", now)).toBe(false);
|
expect(missionEndTime("2026-08-24T20:00:00.000Z", null)?.toISOString()).toBe(
|
||||||
expect(isPastScheduledDay("not-a-date", now)).toBe(false);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue