feat(intelligence): track mission completion and campaign modes
This commit is contained in:
parent
d94da34af2
commit
64babe2608
11 changed files with 38875 additions and 10 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
19334
src/migrations/20260827_233224_add_mission_completed_at.json
Normal file
19334
src/migrations/20260827_233224_add_mission_completed_at.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260827_233224_add_mission_completed_at.ts
Normal file
11
src/migrations/20260827_233224_add_mission_completed_at.ts
Normal 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";`)
|
||||||
|
}
|
||||||
19350
src/migrations/20260828_041615_add_campaign_mode.json
Normal file
19350
src/migrations/20260828_041615_add_campaign_mode.json
Normal file
File diff suppressed because it is too large
Load diff
13
src/migrations/20260828_041615_add_campaign_mode.ts
Normal file
13
src/migrations/20260828_041615_add_campaign_mode.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue