Awards can now upgrade through a series: collection fields and migration, grant flow support, profile and account UI updates, and regression coverage. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
726 lines
22 KiB
TypeScript
726 lines
22 KiB
TypeScript
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<string, number | null>;
|
|
minigames?: {
|
|
flappyBird?: { xpEarned?: number | null };
|
|
eodMinesweeper?: { xpEarned?: number | null };
|
|
radioTraffic?: { xpEarned?: number | null };
|
|
aimTrainer?: { xpEarned?: number | null };
|
|
opordRecall?: { xpEarned?: number | null };
|
|
poker?: { 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 opordRecallXp = progressionData?.minigames?.opordRecall?.xpEarned ?? 0;
|
|
const pokerXp = progressionData?.minigames?.poker?.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;
|
|
xpAcc += opordRecallXp;
|
|
// Poker sit-and-go: XP from placement payouts
|
|
xpAcc += pokerXp;
|
|
// 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: "upgradedFrom",
|
|
type: "relationship",
|
|
relationTo: "awards",
|
|
admin: {
|
|
description: "For advanced ribbons: the previous tier this entry replaced.",
|
|
},
|
|
},
|
|
{
|
|
name: "upgradedAt",
|
|
type: "date",
|
|
admin: {
|
|
description: "When this entry was upgraded to the current tier.",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
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: "opordRecall",
|
|
label: "OPORD Recall",
|
|
type: "group",
|
|
fields: [
|
|
{
|
|
name: "gamesPlayed",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
{
|
|
name: "bestScore",
|
|
label: "Best Score",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
{
|
|
name: "bestAccuracy",
|
|
label: "Best Accuracy",
|
|
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: "recruitBestTimeMs",
|
|
label: "Recruit Best Time (ms)",
|
|
type: "number",
|
|
},
|
|
{
|
|
name: "veteranBestTimeMs",
|
|
label: "Veteran Best Time (ms)",
|
|
type: "number",
|
|
},
|
|
{
|
|
name: "eliteBestTimeMs",
|
|
label: "Elite Best Time (ms)",
|
|
type: "number",
|
|
},
|
|
{
|
|
name: "xpEarned",
|
|
label: "XP Earned",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "poker",
|
|
label: "Poker",
|
|
type: "group",
|
|
fields: [
|
|
{
|
|
name: "gamesPlayed",
|
|
label: "Games Played",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
{
|
|
name: "wins",
|
|
label: "Wins",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
{
|
|
name: "secondPlaces",
|
|
label: "Second Places",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
{
|
|
name: "thirdPlaces",
|
|
label: "Third Places",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
{
|
|
name: "fourthPlaces",
|
|
label: "Fourth Places",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
{
|
|
name: "handsWon",
|
|
label: "Hands Won",
|
|
type: "number",
|
|
defaultValue: 0,
|
|
},
|
|
{
|
|
name: "bestScore",
|
|
label: "Best Score (chips)",
|
|
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,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|