diff --git a/src/app/(frontend)/radio/actions.ts b/src/app/(frontend)/radio/actions.ts new file mode 100644 index 0000000..1776a7f --- /dev/null +++ b/src/app/(frontend)/radio/actions.ts @@ -0,0 +1,207 @@ +"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 RadioStats { + gamesPlayed: number; + totalMessages: number; + bestScore: number; + bestAccuracy: number; + bestSprintTimeMs: number; + radioTrafficXp: number; + xpEarned: number; +} + +const SPRINT_MESSAGE_COUNT = 10; +const MAX_WATCH_MESSAGES = 30; +const MAX_SCORE_PER_GAME = 5000; +const MAX_SPRINT_TIME_MS = 600_000; + +type RadioMode = "sprint" | "watch"; + +function isRadioMode(value: string): value is RadioMode { + return value === "sprint" || value === "watch"; +} + +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 submitRadioResult(input: { + score: number; + messageCount: number; + correctMessages: number; + accuracy: number; + mode: RadioMode; + timeMs?: number; +}): Promise> { + try { + const { payload, user } = await authenticate(); + + if (!isRadioMode(input.mode)) { + return { success: false, error: "Invalid result." }; + } + const score = Math.floor(input.score); + if (!Number.isFinite(score) || score < 0 || score > MAX_SCORE_PER_GAME) { + return { success: false, error: "Invalid score." }; + } + if ( + !Number.isInteger(input.messageCount) || + (input.mode === "sprint" + ? input.messageCount !== SPRINT_MESSAGE_COUNT + : input.messageCount < 1 || input.messageCount > MAX_WATCH_MESSAGES) + ) { + return { success: false, error: "Invalid result." }; + } + if ( + !Number.isInteger(input.correctMessages) || + input.correctMessages < 0 || + input.correctMessages > input.messageCount + ) { + return { success: false, error: "Invalid result." }; + } + if (!Number.isFinite(input.accuracy) || input.accuracy < 0 || input.accuracy > 1) { + return { success: false, error: "Invalid result." }; + } + const sprintTime = input.timeMs; + if ( + input.mode === "sprint" && + (typeof sprintTime !== "number" || !Number.isFinite(sprintTime) || sprintTime <= 0 || sprintTime > MAX_SPRINT_TIME_MS) + ) { + 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?: { + radioTraffic?: { + gamesPlayed?: number; + totalMessages?: number; + bestScore?: number; + bestAccuracy?: number; + bestSprintTimeMs?: number; + xpEarned?: number; + }; + }; + }; + const current = progression.minigames?.radioTraffic ?? {}; + const runXp = Math.round(input.correctMessages * 0.1 * 10) / 10; + const gamesPlayed = (current.gamesPlayed ?? 0) + 1; + const totalMessages = (current.totalMessages ?? 0) + input.correctMessages; + const bestScore = Math.max(current.bestScore ?? 0, score); + const bestAccuracy = Math.round(Math.max(current.bestAccuracy ?? 0, input.accuracy) * 10000) / 10000; + const previousBestSprintTimeMs = current.bestSprintTimeMs ?? 0; + const bestSprintTimeMs = + input.mode === "sprint" && typeof sprintTime === "number" + ? Math.min(previousBestSprintTimeMs > 0 ? previousBestSprintTimeMs : Infinity, Math.round(sprintTime)) + : previousBestSprintTimeMs; + const radioTrafficXp = Math.round(((current.xpEarned ?? 0) + runXp) * 10) / 10; + + const { xp: _xp, ...progressionRest } = progression; + + await payload.update({ + collection: "profiles", + id: profile.id, + data: { + progression: { + ...progressionRest, + minigames: { + ...(progressionRest.minigames ?? {}), + radioTraffic: { + gamesPlayed, + totalMessages, + bestScore, + bestAccuracy, + bestSprintTimeMs, + xpEarned: radioTrafficXp, + }, + }, + }, + }, + overrideAccess: true, + depth: 0, + }); + broadcastToUser("profile-update", Number(user.id), { + reason: "xp", + xpGained: runXp, + profileId: profile.id, + }); + + if (score > 0) { + const modeLabel = input.mode === "sprint" ? "sprint" : "watch"; + await emitGameEvent(payload, { + type: EventTypes.MinigameRadioTraffic, + message: `Completed a radio traffic ${modeLabel} run with ${input.correctMessages}/${input.messageCount} messages accurate (+${runXp} XP)`, + actor: user.id as number, + targetCollection: "users", + targetId: user.id as number, + data: { + mode: input.mode, + score, + messageCount: input.messageCount, + correctMessages: input.correctMessages, + accuracy: input.accuracy, + xpEarned: runXp, + bestScore, + bestSprintTimeMs, + totalMessages, + radioTrafficXp, + }, + }); + } + + return { + success: true, + data: { + gamesPlayed, + totalMessages, + bestScore, + bestAccuracy, + bestSprintTimeMs, + radioTrafficXp, + xpEarned: runXp, + }, + }; + } 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/app/(frontend)/radio/page.tsx b/src/app/(frontend)/radio/page.tsx new file mode 100644 index 0000000..c5df515 --- /dev/null +++ b/src/app/(frontend)/radio/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from "next"; +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import { redirect } from "next/navigation"; +import { Radio } from "lucide-react"; +import { RadioArena } from "@/components/frontend/radio/RadioArena"; + +export const metadata: Metadata = { + title: "Radio Traffic — Polaris Task Force", + description: "Intercept and type incoming radio traffic fast + accurately.", +}; + +export default async function RadioPage() { + const headers = await nextHeaders(); + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + + if (!user) { + redirect(`/login?returnTo=${encodeURIComponent("/radio")}`); + } + + const profileRes = await payload.find({ + collection: "profiles", + where: { user: { equals: user.id as number } }, + limit: 1, + depth: 0, + overrideAccess: true, + select: { + progression: { + minigames: true, + }, + }, + }); + + const radioTraffic = profileRes.docs[0]?.progression?.minigames?.radioTraffic ?? {}; + const stats = { + gamesPlayed: radioTraffic.gamesPlayed ?? 0, + totalMessages: radioTraffic.totalMessages ?? 0, + bestScore: radioTraffic.bestScore ?? 0, + bestAccuracy: radioTraffic.bestAccuracy ?? 0, + bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0, + radioTrafficXp: radioTraffic.xpEarned ?? 0, + xpEarned: 0, + }; + + return ( +
+
+
+ +

Radio Traffic

+
+

+ Intercept and type incoming radio traffic fast + accurately. +

+
+ + +
+ ); +}