diff --git a/AGENTS.md b/AGENTS.md index 6d7957d..9eaea3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//.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 `/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). diff --git a/src/lib/intelligence/missionLifecycle.ts b/src/lib/intelligence/missionLifecycle.ts new file mode 100644 index 0000000..5fb99e9 --- /dev/null +++ b/src/lib/intelligence/missionLifecycle.ts @@ -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>; + +/** + * 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 { + 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 }; +} diff --git a/src/payload.config.ts b/src/payload.config.ts index 2ec5c3b..cdede6f 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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", diff --git a/src/scripts/missionTick.ts b/src/scripts/missionTick.ts new file mode 100644 index 0000000..f86e806 --- /dev/null +++ b/src/scripts/missionTick.ts @@ -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); + } +}; diff --git a/tests/int/mission-lifecycle.int.spec.ts b/tests/int/mission-lifecycle.int.spec.ts new file mode 100644 index 0000000..a7dd21a --- /dev/null +++ b/tests/int/mission-lifecycle.int.spec.ts @@ -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 => { + 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 => + payload.findByID({ + collection: "missions", + id, + depth: 0, + overrideAccess: true, + }) as unknown as Promise; + + 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); + }); +});