Add maps basemap config (world size, image/tiles mode, zoom defaults, isActive), map-roads, map-zones, and resource-nodes collections with maps:* permissions. Track game-structures map placement, meter coordinates, and label visibility/priority. Bump proximityThreshold to meters and adapt side-op map creation plus test fixtures to the new required fields. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
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`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
|
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", campaignMode: "custom" },
|
|
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("stamps the exact sweep time as completedAt when auto-completing", async () => {
|
|
const now = new Date();
|
|
now.setHours(15, 0, 0, 0);
|
|
const start = new Date(now);
|
|
start.setDate(now.getDate() - 1);
|
|
start.setHours(20, 0, 0, 0);
|
|
|
|
const due = await makeMission("timestamp-due", "Scheduled", start);
|
|
|
|
const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] });
|
|
expect(result.completed).toBe(1);
|
|
|
|
const updated = await fetchMission(due.id);
|
|
expect(updated.ownershipAndStatus.status).toBe("Completed");
|
|
expect(updated.ownershipAndStatus.completedAt).toBe(now.toISOString());
|
|
});
|
|
|
|
it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => {
|
|
const start = new Date();
|
|
// Future start date — the auto-complete sweep must never touch this mission.
|
|
start.setDate(start.getDate() + 1);
|
|
start.setHours(20, 0, 0, 0);
|
|
|
|
const mission = await makeMission("manual-complete", "Scheduled", start);
|
|
expect(mission.ownershipAndStatus.completedAt).toBeNull();
|
|
|
|
// Non-terminal transitions must not stamp a completion time.
|
|
await payload.update({
|
|
collection: "missions",
|
|
id: mission.id,
|
|
data: { ownershipAndStatus: { status: "Active" } },
|
|
overrideAccess: true,
|
|
});
|
|
expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBeNull();
|
|
|
|
const before = Date.now();
|
|
await payload.update({
|
|
collection: "missions",
|
|
id: mission.id,
|
|
data: { ownershipAndStatus: { status: "Completed" } },
|
|
overrideAccess: true,
|
|
});
|
|
const after = Date.now();
|
|
|
|
const completed = await fetchMission(mission.id);
|
|
expect(completed.ownershipAndStatus.completedAt).toBeTruthy();
|
|
// timestamptz(3) truncates sub-millisecond precision, hence the 1ms tolerance.
|
|
const stamped = new Date(completed.ownershipAndStatus.completedAt!).getTime();
|
|
expect(stamped).toBeGreaterThanOrEqual(before - 1);
|
|
expect(stamped).toBeLessThanOrEqual(after);
|
|
|
|
// Cancelling and re-completing keeps the original stamp.
|
|
await payload.update({
|
|
collection: "missions",
|
|
id: mission.id,
|
|
data: { ownershipAndStatus: { status: "Cancelled" } },
|
|
overrideAccess: true,
|
|
});
|
|
expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBe(
|
|
completed.ownershipAndStatus.completedAt,
|
|
);
|
|
await payload.update({
|
|
collection: "missions",
|
|
id: mission.id,
|
|
data: { ownershipAndStatus: { status: "Completed" } },
|
|
overrideAccess: true,
|
|
});
|
|
const recompleted = await fetchMission(mission.id);
|
|
expect(recompleted.ownershipAndStatus.completedAt).toBe(
|
|
completed.ownershipAndStatus.completedAt,
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|