"use server"; import config from "@payload-config"; import { getPayload } from "payload"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { broadcastToUser } from "@/lib/realtime/bus"; interface ActionResult { success: boolean; error?: string; data?: T; } export interface EodStats { gamesPlayed: number; wins: number; losses: number; bestScore: number; eodXp: number; xpEarned: number; bestTrainingTime: number; bestPerimeterTime: number; bestSupplyRoadTime: number; } const DIFFICULTIES = { training: { label: "Training Pad", rows: 9, cols: 9, mines: 10, baseScore: 100, parSeconds: 60, maxSeconds: 300, }, perimeter: { label: "FOB Perimeter", rows: 16, cols: 16, mines: 40, baseScore: 300, parSeconds: 180, maxSeconds: 900, }, supplyRoad: { label: "Main Supply Road", rows: 16, cols: 30, mines: 99, baseScore: 600, parSeconds: 420, maxSeconds: 1800, }, } as const; export type EodDifficulty = keyof typeof DIFFICULTIES; const BEST_TIME_FIELDS = { training: "bestTrainingTime", perimeter: "bestPerimeterTime", supplyRoad: "bestSupplyRoadTime", } as const; const BEST_FLAGS_FIELDS = { training: "bestTrainingFlags", perimeter: "bestPerimeterFlags", supplyRoad: "bestSupplyRoadFlags", } as const; type EodTimeField = (typeof BEST_TIME_FIELDS)[EodDifficulty]; type EodFlagsField = (typeof BEST_FLAGS_FIELDS)[EodDifficulty]; function isEodDifficulty(value: string): value is EodDifficulty { return Object.prototype.hasOwnProperty.call(DIFFICULTIES, value); } async function authenticate() { const payloadConfig = await config; const payload = await getPayload({ config: payloadConfig }); const { headers } = await import("next/headers"); const hdrs = await headers(); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false, }); if (!user) { throw new Error("Unauthorized"); } return { payload, user }; } export async function submitEodResult(input: { difficulty: EodDifficulty; seconds: number; flags: number; won: boolean; }): Promise> { try { const { payload, user } = await authenticate(); if (typeof input.difficulty !== "string" || !isEodDifficulty(input.difficulty)) { return { success: false, error: "Invalid result." }; } const difficulty = input.difficulty; const difficultyConfig = DIFFICULTIES[difficulty]; if ( !Number.isFinite(input.seconds) || !Number.isInteger(input.seconds) || input.seconds <= 0 || input.seconds > difficultyConfig.maxSeconds ) { return { success: false, error: "Invalid result." }; } if (!Number.isInteger(input.flags) || input.flags < 0 || input.flags > difficultyConfig.mines) { return { success: false, error: "Invalid result." }; } const profileRes = await payload.find({ collection: "profiles", where: { user: { equals: user.id as number } }, limit: 1, depth: 0, overrideAccess: true, }); const profile = profileRes.docs[0]; if (!profile) { return { success: false, error: "No profile found for this user." }; } const progression = (profile.progression ?? {}) as { xp?: unknown; minigames?: { eodMinesweeper?: { gamesPlayed?: number; wins?: number; losses?: number; bestScore?: number; xpEarned?: number; bestTrainingTime?: number; bestPerimeterTime?: number; bestSupplyRoadTime?: number; bestTrainingFlags?: number; bestPerimeterFlags?: number; bestSupplyRoadFlags?: number; }; }; }; const current = progression.minigames?.eodMinesweeper ?? {}; const score = input.won ? difficultyConfig.baseScore + Math.max(0, difficultyConfig.parSeconds - input.seconds) : 0; const runXp = input.won ? Math.max(1, Math.floor(score / 50)) : 0; const gamesPlayed = (current.gamesPlayed ?? 0) + 1; const wins = (current.wins ?? 0) + (input.won ? 1 : 0); const losses = (current.losses ?? 0) + (input.won ? 0 : 1); const bestScore = Math.max(current.bestScore ?? 0, score); const eodXp = (current.xpEarned ?? 0) + runXp; const bestTimeField: EodTimeField = BEST_TIME_FIELDS[difficulty]; const bestFlagsField: EodFlagsField = BEST_FLAGS_FIELDS[difficulty]; const previousBestTime = current[bestTimeField] ?? 0; const previousBestFlags = current[bestFlagsField] ?? 0; const isBestRun = input.won && (previousBestTime === 0 || input.seconds < previousBestTime || (input.seconds === previousBestTime && input.flags < previousBestFlags)); const bestTime = isBestRun ? input.seconds : previousBestTime; const bestFlags = isBestRun ? input.flags : previousBestFlags; const { xp: _xp, ...progressionRest } = progression; await payload.update({ collection: "profiles", id: profile.id, data: { progression: { ...progressionRest, minigames: { ...(progressionRest.minigames ?? {}), eodMinesweeper: { gamesPlayed, wins, losses, bestScore, xpEarned: eodXp, bestTrainingTime: current.bestTrainingTime ?? 0, bestPerimeterTime: current.bestPerimeterTime ?? 0, bestSupplyRoadTime: current.bestSupplyRoadTime ?? 0, bestTrainingFlags: current.bestTrainingFlags ?? 0, bestPerimeterFlags: current.bestPerimeterFlags ?? 0, bestSupplyRoadFlags: current.bestSupplyRoadFlags ?? 0, [bestTimeField]: bestTime, [bestFlagsField]: bestFlags, }, }, }, }, overrideAccess: true, depth: 0, }); broadcastToUser("profile-update", Number(user.id), { reason: "xp", xpGained: runXp, profileId: profile.id, }); if (input.won) { await emitGameEvent(payload, { type: EventTypes.MinigameEod, message: `Cleared ${difficultyConfig.label} in ${input.seconds}s (+${runXp} XP)`, actor: user.id as number, targetCollection: "users", targetId: user.id as number, data: { difficulty, seconds: input.seconds, flags: input.flags, score, xpEarned: runXp, }, }); } return { success: true, data: { gamesPlayed, wins, losses, bestScore, eodXp, xpEarned: runXp, bestTrainingTime: bestTimeField === "bestTrainingTime" ? bestTime : (current.bestTrainingTime ?? 0), bestPerimeterTime: bestTimeField === "bestPerimeterTime" ? bestTime : (current.bestPerimeterTime ?? 0), bestSupplyRoadTime: bestTimeField === "bestSupplyRoadTime" ? bestTime : (current.bestSupplyRoadTime ?? 0), }, }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (message === "Unauthorized") { return { success: false, error: "You must be logged in." }; } return { success: false, error: message }; } }