import { CollectionConfig } from "payload"; import { Award } from "@/payload-types"; import { broadcastToUser } from "@/lib/realtime/bus"; import { notifyAwardGrants } from "@/lib/awards"; import { hasPermission } from "@/utils/access-control/hasPermission"; import { lexicalPlaintextLength } from "@/lib/lexical/text"; export const Profiles: CollectionConfig = { slug: "profiles", admin: { useAsTitle: "profileTitle", group: "Users", defaultColumns: ["profileTitle", "user", "rank"], }, access: { read: async ({ req }) => { return await hasPermission(req.payload, req.user, "profiles:read"); }, create: async ({ req }) => { return await hasPermission(req.payload, req.user, "profiles:create"); }, update: async ({ req }) => { return await hasPermission(req.payload, req.user, "profiles:update"); }, delete: async ({ req }) => { return await hasPermission(req.payload, req.user, "profiles:delete"); }, }, hooks: { afterChange: [ async ({ doc }) => { const userId = typeof doc.user === "object" ? doc.user?.id : doc.user; if (typeof userId === "number") { broadcastToUser("profile-update", userId, { profileId: doc.id }); } }, async ({ doc, previousDoc, req }) => { await notifyAwardGrants(req.payload, previousDoc, doc); }, ], }, fields: [ { name: "user", type: "relationship", relationTo: "users", required: true, }, { name: "rank", type: "relationship", relationTo: "ranks", required: true, }, { name: "profileTitle", type: "text", virtual: "user.displayName", admin: { hidden: true, }, label: "Operator", }, { name: "dossier", label: "Personnel Dossier", type: "group", fields: [ { name: "enlistmentDate", type: "date", required: true, defaultValue: ({ user }) => user?.createdAt, }, { name: "personalExcerpt", label: "Personal Excerpt", type: "richText", validate: (value: unknown) => { if (lexicalPlaintextLength(value) > 1000) { return "Personal excerpt must be 1000 characters or fewer."; } return true; }, }, { name: "intelExcerpt", label: "Intelligence Record", type: "richText", access: { create: async ({ req }) => { return await hasPermission(req.payload, req.user, "profiles:intel_excerpt:update"); }, update: async ({ req }) => { return await hasPermission(req.payload, req.user, "profiles:intel_excerpt:update"); }, }, }, ], }, { name: "progression", label: "Progression & XP", type: "group", fields: [ { name: "xp", label: "Experience Points (XP)", type: "number", virtual: true, defaultValue: 0, admin: { readOnly: true, }, hooks: { // beforeChange: [({ siblingData }) => delete siblingData["xp"]], afterRead: [ async ({ req, siblingData }) => { let xpAcc = 0; const progressionData = siblingData as { awards?: Array< | { award?: number | Award; } | number >; scoreboard?: Record; minigames?: { flappyBird?: { xpEarned?: number | null }; eodMinesweeper?: { xpEarned?: number | null }; radioTraffic?: { xpEarned?: number | null }; aimTrainer?: { xpEarned?: number | null }; }; voiceover?: { xpEarned?: number | null }; }; const awardsList = progressionData?.awards || []; const awardIds = awardsList.flatMap((entry) => { const award = typeof entry === "object" ? entry.award : entry; if (typeof award === "number") return [award]; if (award && typeof award.id === "number") return [award.id]; return []; }); const scoreboard = progressionData?.scoreboard || {}; const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0; const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0; const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0; const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0; const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0; if (awardIds.length > 0) { await req.payload .find({ collection: "awards", where: { id: { in: awardIds, }, }, }) .then((res) => { const awards: Award[] = res.docs; xpAcc += awards.reduce((prev, cur) => prev + (cur.experiencePoints ?? 0), 0); }); } Object.entries(scoreboard).forEach(([k, v]) => { if (!v) return; switch (k) { case "vehicleKills": xpAcc += v * 20; break; case "armorKills": case "airKills": xpAcc += v * 30; break; case "infantryKills": default: xpAcc += v * 10; break; } }); // Flappy Bird training sim: 1 XP per 15 pipes passed in a single run xpAcc += flappyXp; // EOD Minesweeper: XP from cleared minefields xpAcc += eodXp; xpAcc += radioTrafficXp; xpAcc += aimTrainerXp; // Voiceover submissions: XP awarded per accepted submission xpAcc += voiceoverXp; return xpAcc; }, ], }, }, { name: "awards", type: "array", fields: [ { name: "award", type: "relationship", relationTo: "awards", required: true, admin: { appearance: "drawer", }, }, { name: "awardedBy", type: "relationship", relationTo: "profiles", // This is a self-relation. Do not recursively populate the // issuer's progression/awards when a profile is read. maxDepth: 0, admin: { description: 'Who issued the award. If no one, the award will be attributed as "on behalf of the unit."', }, }, { name: "awardedAt", type: "date", required: true, defaultValue: () => new Date().toISOString(), admin: { description: "The issuing date of the award.", }, }, { name: "awardReason", type: "textarea", admin: { description: "An optional reason for the award.", }, }, ], }, { name: "qualifications", type: "relationship", relationTo: "qualifications", hasMany: true, admin: { appearance: "drawer", }, }, { name: "scoreboard", type: "group", admin: { readOnly: true, }, fields: [ { name: "infantryKills", type: "number", defaultValue: 0, }, { name: "vehicleKills", type: "number", defaultValue: 0, }, { name: "armorKills", type: "number", defaultValue: 0, }, { name: "airKills", type: "number", defaultValue: 0, }, { name: "deaths", type: "number", defaultValue: 0, }, ], }, { name: "minigames", label: "Minigames", type: "group", admin: { components: { Field: { path: "@/components/admin/groups/CollapsibleGroupField", clientProps: { initCollapsed: true }, }, }, }, fields: [ { name: "flappyBird", label: "Flappy Bird", type: "group", fields: [ { name: "gamesPlayed", type: "number", defaultValue: 0, }, { name: "totalPasses", label: "Total Pipes Passed", type: "number", defaultValue: 0, }, { name: "bestScore", label: "Best Run (Pipes)", type: "number", defaultValue: 0, }, { name: "xpEarned", label: "XP Earned", type: "number", defaultValue: 0, }, ], }, { name: "eodMinesweeper", label: "EOD Minesweeper", type: "group", fields: [ { name: "gamesPlayed", type: "number", defaultValue: 0, }, { name: "wins", label: "Wins", type: "number", defaultValue: 0, }, { name: "losses", label: "Losses", type: "number", defaultValue: 0, }, { name: "bestScore", label: "Best Score", type: "number", defaultValue: 0, }, { name: "xpEarned", label: "XP Earned", type: "number", defaultValue: 0, }, { name: "bestTrainingTime", label: "Best Training Pad Time (s)", type: "number", defaultValue: 0, }, { name: "bestPerimeterTime", label: "Best FOB Perimeter Time (s)", type: "number", defaultValue: 0, }, { name: "bestSupplyRoadTime", label: "Best Main Supply Road Time (s)", type: "number", defaultValue: 0, }, { name: "bestTrainingFlags", label: "Flags on Best Training Pad Run", type: "number", defaultValue: 0, }, { name: "bestPerimeterFlags", label: "Flags on Best FOB Perimeter Run", type: "number", defaultValue: 0, }, { name: "bestSupplyRoadFlags", label: "Flags on Best Main Supply Road Run", type: "number", defaultValue: 0, }, ], }, { name: "radioTraffic", label: "Radio Traffic", type: "group", fields: [ { name: "gamesPlayed", type: "number", defaultValue: 0, }, { name: "totalMessages", label: "Total Messages", type: "number", defaultValue: 0, }, { name: "incorrectMessages", label: "Incorrect Messages", type: "number", defaultValue: 0, }, { name: "bestScore", label: "Best Score", type: "number", defaultValue: 0, }, { name: "bestAccuracy", label: "Best Accuracy", type: "number", defaultValue: 0, }, { name: "bestSprintTimeMs", label: "Best Sprint Time (ms)", type: "number", defaultValue: 0, }, { name: "sprintGamesPlayed", label: "Sprint Games Played", type: "number", defaultValue: 0, }, { name: "sprintTotalMessages", label: "Sprint Total Messages", type: "number", defaultValue: 0, }, { name: "sprintIncorrectMessages", label: "Sprint Incorrect Messages", type: "number", defaultValue: 0, }, { name: "sprintBestScore", label: "Sprint Best Score", type: "number", defaultValue: 0, }, { name: "sprintBestAccuracy", label: "Sprint Best Accuracy", type: "number", defaultValue: 0, }, { name: "watchGamesPlayed", label: "Watch Games Played", type: "number", defaultValue: 0, }, { name: "watchTotalMessages", label: "Watch Total Messages", type: "number", defaultValue: 0, }, { name: "watchIncorrectMessages", label: "Watch Incorrect Messages", type: "number", defaultValue: 0, }, { name: "watchBestScore", label: "Watch Best Score", type: "number", defaultValue: 0, }, { name: "watchBestAccuracy", label: "Watch Best Accuracy", type: "number", defaultValue: 0, }, { name: "xpEarned", label: "XP Earned", type: "number", defaultValue: 0, }, ], }, { name: "aimTrainer", label: "Aim Trainer", type: "group", fields: [ { name: "gamesPlayed", type: "number", defaultValue: 0, }, { name: "bestScore", label: "Best Score", type: "number", defaultValue: 0, }, { name: "recruitBestScore", label: "Recruit Best Score", type: "number", defaultValue: 0, }, { name: "veteranBestScore", label: "Veteran Best Score", type: "number", defaultValue: 0, }, { name: "eliteBestScore", label: "Elite Best Score", type: "number", defaultValue: 0, }, { name: "recruitBestAccuracy", label: "Recruit Best Accuracy", type: "number", defaultValue: 0, }, { name: "veteranBestAccuracy", label: "Veteran Best Accuracy", type: "number", defaultValue: 0, }, { name: "eliteBestAccuracy", label: "Elite Best Accuracy", type: "number", defaultValue: 0, }, { name: "xpEarned", label: "XP Earned", type: "number", defaultValue: 0, }, ], }, ], }, { name: "voiceover", label: "Voiceover", type: "group", fields: [ { name: "xpEarned", label: "XP Earned", type: "number", defaultValue: 0, }, ], }, ], }, ], };