diff --git a/src/app/(frontend)/qualification/actions.ts b/src/app/(frontend)/qualification/actions.ts new file mode 100644 index 0000000..f6dcea4 --- /dev/null +++ b/src/app/(frontend)/qualification/actions.ts @@ -0,0 +1,239 @@ +"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"; +import { + isAimTrainerDifficulty, + calculateAimTrainerAccuracy, + calculateAimTrainerScore, + calculateAimTrainerXp, + MAX_SHOTS_FIRED, + MAX_HITS, + MAX_DESPAWNED_TARGETS, +} from "@/lib/aim-trainer"; + +interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +export interface AimTrainerStats { + gamesPlayed: number; + bestScore: number; + recruitBestScore: number; + veteranBestScore: number; + eliteBestScore: number; + recruitBestAccuracy: number; + veteranBestAccuracy: number; + eliteBestAccuracy: number; + aimTrainerXp: number; + xpEarned: number; + score: number; + accuracy: number; +} + +export interface AimTrainerResultInput { + difficulty: string; + headshots: number; + torsoHits: number; + shotsFired: number; + despawnedUnhitTargets: number; +} + +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 submitAimTrainerResult( + input: AimTrainerResultInput, +): Promise> { + try { + const { payload, user } = await authenticate(); + + if (!isAimTrainerDifficulty(input.difficulty)) { + return { success: false, error: "Invalid difficulty." }; + } + + const { headshots, torsoHits, shotsFired, despawnedUnhitTargets } = input; + if ( + !Number.isInteger(headshots) || + headshots < 0 || + !Number.isInteger(torsoHits) || + torsoHits < 0 || + !Number.isInteger(shotsFired) || + shotsFired < 0 || + !Number.isInteger(despawnedUnhitTargets) || + despawnedUnhitTargets < 0 + ) { + return { success: false, error: "Invalid result." }; + } + if (shotsFired > MAX_SHOTS_FIRED) { + return { success: false, error: "Too many shots fired." }; + } + const hits = headshots + torsoHits; + if (hits > MAX_HITS) { + return { success: false, error: "Too many hits." }; + } + if (hits > shotsFired) { + return { success: false, error: "Hits cannot exceed shots fired." }; + } + if (despawnedUnhitTargets > MAX_DESPAWNED_TARGETS) { + return { success: false, error: "Too many despawned targets." }; + } + + 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?: { + aimTrainer?: { + gamesPlayed?: number; + bestScore?: number; + recruitBestScore?: number; + veteranBestScore?: number; + eliteBestScore?: number; + recruitBestAccuracy?: number; + veteranBestAccuracy?: number; + eliteBestAccuracy?: number; + xpEarned?: number; + }; + }; + }; + const current = progression.minigames?.aimTrainer ?? {}; + + const accuracy = calculateAimTrainerAccuracy(hits, shotsFired, despawnedUnhitTargets); + const score = calculateAimTrainerScore(headshots, torsoHits, shotsFired, despawnedUnhitTargets); + + const difficultyBestScoreKey = `${input.difficulty}BestScore` as + | "recruitBestScore" + | "veteranBestScore" + | "eliteBestScore"; + const difficultyBestAccuracyKey = `${input.difficulty}BestAccuracy` as + | "recruitBestAccuracy" + | "veteranBestAccuracy" + | "eliteBestAccuracy"; + + const isNewTierBest = score > (current[difficultyBestScoreKey] ?? 0); + const xp = calculateAimTrainerXp(score, isNewTierBest); + + const gamesPlayed = (current.gamesPlayed ?? 0) + 1; + const bestScore = Math.max(current.bestScore ?? 0, score); + const difficultyBestScore = Math.max(current[difficultyBestScoreKey] ?? 0, score); + const difficultyBestAccuracy = Math.max(current[difficultyBestAccuracyKey] ?? 0, accuracy); + const aimTrainerXp = (current.xpEarned ?? 0) + xp; + + const { xp: _xp, ...progressionRest } = progression; + + const recruitBestScore = input.difficulty === "recruit" ? difficultyBestScore : (current.recruitBestScore ?? 0); + const veteranBestScore = input.difficulty === "veteran" ? difficultyBestScore : (current.veteranBestScore ?? 0); + const eliteBestScore = input.difficulty === "elite" ? difficultyBestScore : (current.eliteBestScore ?? 0); + const recruitBestAccuracy = + input.difficulty === "recruit" ? difficultyBestAccuracy : (current.recruitBestAccuracy ?? 0); + const veteranBestAccuracy = + input.difficulty === "veteran" ? difficultyBestAccuracy : (current.veteranBestAccuracy ?? 0); + const eliteBestAccuracy = + input.difficulty === "elite" ? difficultyBestAccuracy : (current.eliteBestAccuracy ?? 0); + + await payload.update({ + collection: "profiles", + id: profile.id, + data: { + progression: { + ...progressionRest, + minigames: { + ...(progressionRest.minigames ?? {}), + aimTrainer: { + gamesPlayed, + bestScore, + recruitBestScore, + veteranBestScore, + eliteBestScore, + recruitBestAccuracy, + veteranBestAccuracy, + eliteBestAccuracy, + xpEarned: aimTrainerXp, + }, + }, + }, + }, + overrideAccess: true, + depth: 0, + }); + broadcastToUser("profile-update", Number(user.id), { + reason: "xp", + xpGained: xp, + profileId: profile.id, + }); + + if (score > 0) { + const pct = Math.round(accuracy * 100); + await emitGameEvent(payload, { + type: EventTypes.MinigameAimTrainer, + message: `completed the Qualification Course (${input.difficulty}) with a score of ${score} (${pct}% accuracy)`, + actor: user.id as number, + targetCollection: "users", + targetId: user.id as number, + data: { + difficulty: input.difficulty, + score, + accuracy, + xpEarned: xp, + bestScore, + difficultyBestScore, + aimTrainerXp, + }, + }); + } + + return { + success: true, + data: { + gamesPlayed, + bestScore, + recruitBestScore, + veteranBestScore, + eliteBestScore, + recruitBestAccuracy, + veteranBestAccuracy, + eliteBestAccuracy, + aimTrainerXp, + xpEarned: xp, + score, + accuracy, + }, + }; + } 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 }; + } +} diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index 4d1dc8c..affa04f 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -64,6 +64,7 @@ export const EventTypes = { MinigameFlappy: "minigame:flappy", MinigameEod: "minigame:eod", MinigameRadioTraffic: "minigame:radio-traffic", + MinigameAimTrainer: "minigame:aim-trainer", // Market MarketListingCreate: "market:listing-create",