feat(missions): auto-complete past missions via mission-tick bin
Add the mission-tick Payload bin (external cron): sweeps missions whose status is Scheduled/Active and whose scheduled calendar day (classification.startDateTime, server-local) has fully passed and sets them to Completed, emitting a mission:auto-complete event per mission. Draft and terminal statuses are never touched; idempotent. Notifies clients via the game-tick SSE path. Logic lives in src/lib/intelligence/missionLifecycle.ts with an integration test in tests/int/mission-lifecycle.int.spec.ts.
This commit is contained in:
parent
de1230a757
commit
c628f8a90b
5 changed files with 350 additions and 0 deletions
|
|
@ -148,6 +148,7 @@ Shipping simulation (`src/collections/logistics/Shipments.ts`, `src/scripts/`, `
|
|||
- **Shipment fields**: `origin`/`destination` → `game-structures`, `transportVehicle` → `game-vehicles`, `cargo[]` (relationship to resources/assets/vehicles + amount), `distance`, `fuelCost`/`fuelConsumed`, `status` (`pending`/`dispatched`/`in_transit`/`arrived`/`completed`/`cancelled`/`failed`/`stranded`), `autoReturn` checkbox, `failureReason`.
|
||||
- **Game tick**: `bun run payload game-tick` — a `bin` registered on `payload.config.ts`, NOT an npm script. It processes active shipments + fuel consumption, then `process.exit(0)`.
|
||||
- **Bin file logging**: all four bins (`game-tick`, `market-tick`, `mission-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
|
||||
- **Mission auto-completion**: `bun run payload mission-tick` — bin registered like the others (needs external cron). Sweeps missions whose status is `Scheduled`/`Active` and whose scheduled date (`classification.startDateTime`) has fully passed — from the start of the following **server-local** day — and sets them to `Completed`, emitting a `mission:auto-complete` event per mission (logic: `src/lib/intelligence/missionLifecycle.ts`, test: `tests/int/mission-lifecycle.int.spec.ts`). Draft statuses (Concept/Planning/Ready) and terminal statuses (Completed/Cancelled) are never touched; idempotent. Notifies clients via the same SSE path as the other ticks.
|
||||
- **Arrival handling** (`src/scripts/processShipmentTick.ts`): destination storage rules are re-checked on arrival; rejected cargo bounces back to origin, shipment goes `failed`, `ShipmentFail` event logged.
|
||||
- **GameRules tuning**: `proximityThreshold` and `gameTickIntervalMinutes` live on the global `game-rules` doc.
|
||||
- **UI**: `src/app/(frontend)/logistics/shipments/` (list + `[id]` detail with `ShipmentActions` controls), `src/app/(frontend)/logistics/game-vehicles/` (deployed vehicle views).
|
||||
|
|
|
|||
96
src/lib/intelligence/missionLifecycle.ts
Normal file
96
src/lib/intelligence/missionLifecycle.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { Where } from "payload";
|
||||
import type { Mission } from "@/payload-types";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
/**
|
||||
* Statuses that count as "committed to run" and are auto-completed once the
|
||||
* calendar day of their scheduled start has fully passed. Draft statuses
|
||||
* (Concept/Planning/Ready) are left untouched — a mission that never ran
|
||||
* should not silently become Completed — and terminal statuses
|
||||
* (Completed/Cancelled) are never re-processed.
|
||||
*/
|
||||
const AUTO_COMPLETABLE_STATUSES = ["Scheduled", "Active"] as const;
|
||||
|
||||
export interface MissionAutoCompleteSummary {
|
||||
completed: number;
|
||||
missions: { id: number; name: string; codeName: string | null }[];
|
||||
}
|
||||
|
||||
export interface MissionAutoCompleteOptions {
|
||||
/** Restrict the sweep to specific mission ids (lets tests stay hermetic). */
|
||||
missionIds?: number[];
|
||||
}
|
||||
|
||||
/** Start of `now`'s calendar day in the server's local timezone. */
|
||||
export function startOfToday(now: Date = new Date()): Date {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return start;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the calendar day of `startDateTime` (server-local) has fully
|
||||
* ended — i.e. from the start of the day AFTER the scheduled date. A mission
|
||||
* scheduled for today never counts, even after its start time has passed.
|
||||
* Unparseable dates fall closed so malformed data is never auto-completed.
|
||||
*/
|
||||
export function isPastScheduledDay(startDateTime: string, now: Date = new Date()): boolean {
|
||||
const start = new Date(startDateTime);
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start.getTime() < startOfToday(now).getTime();
|
||||
}
|
||||
|
||||
export async function autoCompleteMissions(
|
||||
payload: PayloadType,
|
||||
now: Date = new Date(),
|
||||
options: MissionAutoCompleteOptions = {},
|
||||
): Promise<MissionAutoCompleteSummary> {
|
||||
const cutoff = startOfToday(now).toISOString();
|
||||
const conditions: Where[] = [
|
||||
{ "ownershipAndStatus.status": { in: [...AUTO_COMPLETABLE_STATUSES] } },
|
||||
{ "classification.startDateTime": { less_than: cutoff } },
|
||||
];
|
||||
if (options.missionIds) {
|
||||
conditions.push({ id: { in: options.missionIds } });
|
||||
}
|
||||
|
||||
const due = (await payload.find({
|
||||
collection: "missions",
|
||||
where: { and: conditions },
|
||||
sort: "classification.startDateTime",
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Mission[] };
|
||||
|
||||
const completed: MissionAutoCompleteSummary["missions"] = [];
|
||||
for (const mission of due.docs) {
|
||||
const previousStatus = mission.ownershipAndStatus?.status ?? null;
|
||||
try {
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { ownershipAndStatus: { status: "Completed" } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.MissionAutoComplete,
|
||||
message: `Mission "${mission.name}"${mission.codeName ? ` (${mission.codeName})` : ""} auto-completed — the scheduled day has passed.`,
|
||||
targetCollection: "missions",
|
||||
targetId: mission.id,
|
||||
data: { previousStatus },
|
||||
});
|
||||
completed.push({ id: mission.id, name: mission.name, codeName: mission.codeName ?? null });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(
|
||||
`[Mission Tick] Failed to auto-complete mission ${mission.id} (${mission.name}): ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { completed: completed.length, missions: completed };
|
||||
}
|
||||
|
|
@ -277,6 +277,10 @@ export default buildConfig({
|
|||
scriptPath: path.resolve(dirname, "scripts/marketTick.ts"),
|
||||
key: "market-tick",
|
||||
},
|
||||
{
|
||||
scriptPath: path.resolve(dirname, "scripts/missionTick.ts"),
|
||||
key: "mission-tick",
|
||||
},
|
||||
{
|
||||
scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"),
|
||||
key: "generate-mission",
|
||||
|
|
|
|||
41
src/scripts/missionTick.ts
Normal file
41
src/scripts/missionTick.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { SanitizedConfig } from "payload";
|
||||
import { getPayload } from "payload";
|
||||
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
const payload = await getPayload({ config });
|
||||
const log = createBinLogger("mission-tick", payload.logger);
|
||||
|
||||
try {
|
||||
log.info("[Mission Tick] Starting mission tick...");
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const result = await autoCompleteMissions(payload);
|
||||
|
||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||
if (notifySecret) {
|
||||
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-game-tick-secret": notifySecret,
|
||||
},
|
||||
body: JSON.stringify({ processedAt: nowIso, source: "mission-tick" }),
|
||||
}).catch((error: unknown) => {
|
||||
log.error(`[Mission Tick] Failed to notify app clients: ${errorMessage(error)}`);
|
||||
});
|
||||
} else {
|
||||
log.warn("[Mission Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification");
|
||||
}
|
||||
|
||||
log.info(`[Mission Tick] Complete. Auto-completed ${result.completed} mission(s).`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`[Mission Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
208
tests/int/mission-lifecycle.int.spec.ts
Normal file
208
tests/int/mission-lifecycle.int.spec.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
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 {
|
||||
autoCompleteMissions,
|
||||
isPastScheduledDay,
|
||||
startOfToday,
|
||||
} from "@/lib/intelligence/missionLifecycle";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `mlc-${Date.now().toString(36)}`;
|
||||
|
||||
describe("Mission auto-complete", () => {
|
||||
let mapId: number;
|
||||
let campaignId: number;
|
||||
let authorId: number;
|
||||
const missionIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
const map = (await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map` },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MissionMap;
|
||||
mapId = map.id;
|
||||
|
||||
const campaign = (await payload.create({
|
||||
collection: "campaigns",
|
||||
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Campaign;
|
||||
campaignId = campaign.id;
|
||||
|
||||
const author = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-author`,
|
||||
discordUsername: `${RUN}-author`,
|
||||
displayName: "AUTHOR",
|
||||
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||
password: "Test123",
|
||||
roles: ["user"],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
authorId = author.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of missionIds) {
|
||||
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload
|
||||
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
|
||||
// User deletion trips FK constraints unless the hook-provisioned
|
||||
// personal bank account and profile are removed first.
|
||||
const accounts = await payload
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: { ownerUser: { equals: authorId } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const account of accounts?.docs ?? []) {
|
||||
await payload
|
||||
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
const profiles = await payload
|
||||
.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: authorId } },
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const profile of profiles?.docs ?? []) {
|
||||
await payload
|
||||
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "users", id: authorId, overrideAccess: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const makeMission = async (
|
||||
label: string,
|
||||
status: Mission["ownershipAndStatus"]["status"],
|
||||
start: Date,
|
||||
): Promise<Mission> => {
|
||||
const mission = (await payload.create({
|
||||
collection: "missions",
|
||||
data: {
|
||||
name: `${RUN} ${label}`,
|
||||
codeName: `${RUN}-${label}`,
|
||||
summary: "Auto-complete boundary test mission",
|
||||
operationType: "main",
|
||||
classification: {
|
||||
map: mapId,
|
||||
missionType: "PvE",
|
||||
campaign: campaignId,
|
||||
startDateTime: start.toISOString(),
|
||||
estimatedDuration: 60,
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
authors: [authorId],
|
||||
status,
|
||||
visibility: "unit",
|
||||
},
|
||||
missionRoles: {
|
||||
maxPlayers: 16,
|
||||
},
|
||||
gameDetails: {
|
||||
serverDetails: {
|
||||
serverIp: "127.0.0.1",
|
||||
serverPort: 2302,
|
||||
},
|
||||
},
|
||||
briefing: [],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Mission;
|
||||
missionIds.push(mission.id);
|
||||
return mission;
|
||||
};
|
||||
|
||||
const fetchMission = (id: number): Promise<Mission> =>
|
||||
payload.findByID({
|
||||
collection: "missions",
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
}) as unknown as Promise<Mission>;
|
||||
|
||||
it("completes Scheduled/Active missions only after the day after their scheduled date starts", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
|
||||
const at = (dayOffset: number, hour: number): Date => {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() + dayOffset);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
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 result = await autoCompleteMissions(payload, now, { missionIds });
|
||||
|
||||
expect(result.missions.map((m) => m.id).sort()).toEqual(
|
||||
[dueScheduled.id, dueActive.id, midnightEdge.id].sort(),
|
||||
);
|
||||
|
||||
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(cancelled.id)).ownershipAndStatus.status).toBe("Cancelled");
|
||||
|
||||
// The partial group update must not wipe sibling fields.
|
||||
const updated = await fetchMission(dueScheduled.id);
|
||||
expect(updated.ownershipAndStatus.visibility).toBe("unit");
|
||||
const authorRefs = Array.isArray(updated.ownershipAndStatus.authors)
|
||||
? updated.ownershipAndStatus.authors
|
||||
: [updated.ownershipAndStatus.authors];
|
||||
expect(authorRefs).toContain(authorId);
|
||||
});
|
||||
|
||||
it("is idempotent — a second sweep finds nothing left to complete", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(12, 0, 0, 0);
|
||||
const result = await autoCompleteMissions(payload, now, { missionIds });
|
||||
expect(result.completed).toBe(0);
|
||||
expect(result.missions).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue