feat(qualification): record aim trainer results
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
a6a7a5639d
commit
282e23edc2
2 changed files with 240 additions and 0 deletions
239
src/app/(frontend)/qualification/actions.ts
Normal file
239
src/app/(frontend)/qualification/actions.ts
Normal file
|
|
@ -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<T = undefined> {
|
||||||
|
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<ActionResult<AimTrainerStats>> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,7 @@ export const EventTypes = {
|
||||||
MinigameFlappy: "minigame:flappy",
|
MinigameFlappy: "minigame:flappy",
|
||||||
MinigameEod: "minigame:eod",
|
MinigameEod: "minigame:eod",
|
||||||
MinigameRadioTraffic: "minigame:radio-traffic",
|
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||||
|
MinigameAimTrainer: "minigame:aim-trainer",
|
||||||
|
|
||||||
// Market
|
// Market
|
||||||
MarketListingCreate: "market:listing-create",
|
MarketListingCreate: "market:listing-create",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue