1
0
Fork 0

feat(intelligence): track mission completion and campaign modes

This commit is contained in:
Jason Fraley 2026-08-28 00:20:47 -04:00
parent d94da34af2
commit 64babe2608
11 changed files with 38875 additions and 10 deletions

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission"; import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission";
import { isDeveloper } from "@/utils/access-control/isRole";
import type { User } from "@/payload-types"; import type { User } from "@/payload-types";
export const Campaigns: CollectionConfig = { export const Campaigns: CollectionConfig = {
@ -47,6 +48,30 @@ export const Campaigns: CollectionConfig = {
required: true, required: true,
defaultValue: "concept", defaultValue: "concept",
}, },
{
name: "campaignMode",
type: "select",
label: "Campaign Mode",
options: [
{
label: "Official Campaign",
value: "official",
},
{
label: "Custom Campaign",
value: "custom",
},
],
required: true,
defaultValue: "custom",
access: {
create: isDeveloper,
update: isDeveloper,
},
admin: {
description: "Only developers can designate a campaign as official or custom.",
},
},
{ {
name: "description", name: "description",
type: "richText", type: "richText",

View file

@ -38,6 +38,29 @@ export const Missions: CollectionConfig = {
group: "Intelligence", group: "Intelligence",
useAsTitle: "name", useAsTitle: "name",
}, },
hooks: {
beforeChange: [
// Loop-safe by construction: this mutates the incoming data of the very
// operation it runs in, so it never triggers a second write. It only
// stamps `ownershipAndStatus.completedAt` when a transition lands on
// "Completed" and no completion time exists yet — auto-complete passes
// its own sweep time explicitly (preserved by the existing-value check),
// and re-completing an already-stamped mission keeps the original stamp.
async ({ data, originalDoc }) => {
if (data?.ownershipAndStatus?.status !== "Completed") return data;
const existing =
data.ownershipAndStatus.completedAt ?? originalDoc?.ownershipAndStatus?.completedAt;
if (existing) return data;
return {
...data,
ownershipAndStatus: {
...data.ownershipAndStatus,
completedAt: new Date().toISOString(),
},
};
},
],
},
access: { access: {
create: async ({ req }) => { create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:create"); return await hasPermission(req.payload, req.user, "missions:create");
@ -308,6 +331,15 @@ export const Missions: CollectionConfig = {
defaultValue: "leadership", defaultValue: "leadership",
required: true, required: true,
}, },
{
name: "completedAt",
type: "date",
admin: {
description:
"When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form.",
disabled: true,
},
},
], ],
}, },
{ {

View file

@ -70,10 +70,13 @@ export async function autoCompleteMissions(
for (const mission of due.docs) { for (const mission of due.docs) {
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,
data: { ownershipAndStatus: { status: "Completed" } }, data: { ownershipAndStatus: { status: "Completed", completedAt: now.toISOString() } },
overrideAccess: true, overrideAccess: true,
}); });
await emitGameEvent(payload, { await emitGameEvent(payload, {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" ADD COLUMN "ownership_and_status_completed_at" timestamp(3) with time zone;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" DROP COLUMN "ownership_and_status_completed_at";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_campaigns_campaign_mode" AS ENUM('official', 'custom');
ALTER TABLE "campaigns" ADD COLUMN "campaign_mode" "enum_campaigns_campaign_mode" DEFAULT 'custom' NOT NULL;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaigns" DROP COLUMN "campaign_mode";
DROP TYPE "public"."enum_campaigns_campaign_mode";`)
}

View file

@ -5,6 +5,8 @@ import * as migration_20260826_060000_add_game_servers_event_target from './2026
import * as migration_20260827_005757 from './20260827_005757'; import * as migration_20260827_005757 from './20260827_005757';
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags'; import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection'; import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
export const migrations = [ export const migrations = [
{ {
@ -40,6 +42,16 @@ export const migrations = [
{ {
up: migration_20260827_213857_add_evaluations_collection.up, up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down, down: migration_20260827_213857_add_evaluations_collection.down,
name: '20260827_213857_add_evaluations_collection' name: '20260827_213857_add_evaluations_collection',
},
{
up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down,
name: '20260827_233224_add_mission_completed_at',
},
{
up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down,
name: '20260828_041615_add_campaign_mode'
}, },
]; ];

View file

@ -1099,6 +1099,10 @@ export interface Campaign {
name: string; name: string;
summary: string; summary: string;
status: 'concept' | 'inProgress' | 'completed'; status: 'concept' | 'inProgress' | 'completed';
/**
* Only developers can designate a campaign as official or custom.
*/
campaignMode: 'official' | 'custom';
description?: { description?: {
root: { root: {
type: string; type: string;
@ -1920,6 +1924,10 @@ export interface Mission {
zeus?: (number | User)[] | null; zeus?: (number | User)[] | null;
status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled'; status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled';
visibility: 'unit' | 'leadership' | 'intel' | 'private'; visibility: 'unit' | 'leadership' | 'intel' | 'private';
/**
* When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form.
*/
completedAt?: string | null;
}; };
missionRoles: { missionRoles: {
maxPlayers: number; maxPlayers: number;
@ -3632,6 +3640,7 @@ export interface MissionsSelect<T extends boolean = true> {
zeus?: T; zeus?: T;
status?: T; status?: T;
visibility?: T; visibility?: T;
completedAt?: T;
}; };
missionRoles?: missionRoles?:
| T | T
@ -3741,6 +3750,7 @@ export interface CampaignsSelect<T extends boolean = true> {
name?: T; name?: T;
summary?: T; summary?: T;
status?: T; status?: T;
campaignMode?: T;
description?: T; description?: T;
startDate?: T; startDate?: T;
endDate?: T; endDate?: T;

View file

@ -118,7 +118,7 @@ describe("Leadership evaluations", () => {
const campaign = (await payload.create({ const campaign = (await payload.create({
collection: "campaigns", collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept" }, data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
})) as unknown as Campaign; })) as unknown as Campaign;

View file

@ -33,7 +33,7 @@ describe("Mission auto-complete", () => {
const campaign = (await payload.create({ const campaign = (await payload.create({
collection: "campaigns", collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept" }, data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
})) as unknown as Campaign; })) as unknown as Campaign;
@ -93,7 +93,9 @@ describe("Mission auto-complete", () => {
.delete({ collection: "profiles", id: profile.id, overrideAccess: true }) .delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {}); .catch(() => {});
} }
await payload.delete({ collection: "users", id: authorId, overrideAccess: true }).catch(() => {}); await payload
.delete({ collection: "users", id: authorId, overrideAccess: true })
.catch(() => {});
}); });
const makeMission = async ( const makeMission = async (
@ -196,6 +198,79 @@ describe("Mission auto-complete", () => {
expect(result.missions).toEqual([]); 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", () => { it("classifies day boundaries deterministically", () => {
const now = new Date(2026, 7, 24, 12, 0, 0); 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(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime());