- briefing is now self-paced (player clicks Begin recall; no memo countdown) - recall quiz has a hard server-validated time band per tier (45/60/75s limits, minimum plausible human time, 2.5s grace); scripted instant or over-limit submissions are rejected - briefing is rendered on canvas: no selectable text in the DOM, copy/cut/context-menu/ctrl-a-c-x-s-p blocked; screenshots remain possible but burn quiz time - ?seed=<n> URL param pins a deterministic scenario (no server meaning; used by the e2e spec to know the grid) - e2e spec updated for the new flow and asserts no grid text leaks into the DOM
215 lines
5.9 KiB
TypeScript
215 lines
5.9 KiB
TypeScript
"use server";
|
|
|
|
import config from "@payload-config";
|
|
import { getPayload } from "payload";
|
|
|
|
import {
|
|
OPORD_DIFFICULTIES,
|
|
QUIZ_TIME_GRACE_MS,
|
|
calculateOpordAccuracy,
|
|
calculateOpordScore,
|
|
calculateOpordXp,
|
|
isOpordDifficulty,
|
|
minOpordRunTimeMs,
|
|
type OpordDifficulty,
|
|
} from "@/lib/opord-recall";
|
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
|
|
|
interface ActionResult<T = undefined> {
|
|
success: boolean;
|
|
error?: string;
|
|
data?: T;
|
|
}
|
|
|
|
export interface OpordStats {
|
|
gamesPlayed: number;
|
|
bestScore: number;
|
|
bestAccuracy: number;
|
|
recruitBestScore: number;
|
|
veteranBestScore: number;
|
|
eliteBestScore: number;
|
|
opordRecallXp: number;
|
|
xpEarned: number;
|
|
score: number;
|
|
accuracy: number;
|
|
}
|
|
|
|
export type OpordRunInput = {
|
|
difficulty: OpordDifficulty;
|
|
correct: number;
|
|
timeMs: 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 submitOpordRecallResult(
|
|
input: OpordRunInput,
|
|
): Promise<ActionResult<OpordStats>> {
|
|
try {
|
|
const { payload, user } = await authenticate();
|
|
|
|
if (!isOpordDifficulty(input.difficulty)) {
|
|
return { success: false, error: "Invalid difficulty." };
|
|
}
|
|
|
|
const tier = OPORD_DIFFICULTIES[input.difficulty];
|
|
const { correct, timeMs } = input;
|
|
const minTimeMs = minOpordRunTimeMs(input.difficulty);
|
|
const maxTimeMs = tier.quizSeconds * 1000 + QUIZ_TIME_GRACE_MS;
|
|
if (
|
|
!Number.isInteger(correct) ||
|
|
correct < 0 ||
|
|
correct > tier.questionCount ||
|
|
typeof timeMs !== "number" ||
|
|
!Number.isFinite(timeMs) ||
|
|
timeMs < minTimeMs ||
|
|
timeMs > maxTimeMs
|
|
) {
|
|
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?: {
|
|
opordRecall?: {
|
|
gamesPlayed?: number;
|
|
bestScore?: number;
|
|
bestAccuracy?: number;
|
|
recruitBestScore?: number;
|
|
veteranBestScore?: number;
|
|
eliteBestScore?: number;
|
|
xpEarned?: number;
|
|
};
|
|
};
|
|
};
|
|
const current = progression.minigames?.opordRecall ?? {};
|
|
|
|
const score = calculateOpordScore(input.difficulty, correct, timeMs);
|
|
const accuracy = calculateOpordAccuracy(correct, tier.questionCount);
|
|
const tierBestScoreKey = `${input.difficulty}BestScore` as
|
|
| "recruitBestScore"
|
|
| "veteranBestScore"
|
|
| "eliteBestScore";
|
|
const isNewTierBest = score > (current[tierBestScoreKey] ?? 0);
|
|
const xp = calculateOpordXp(score, isNewTierBest);
|
|
|
|
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
|
const bestAccuracy =
|
|
Math.round(Math.max(current.bestAccuracy ?? 0, accuracy) * 10000) / 10000;
|
|
const tierBestScore = Math.max(current[tierBestScoreKey] ?? 0, score);
|
|
const opordRecallXp = (current.xpEarned ?? 0) + xp;
|
|
|
|
const recruitBestScore =
|
|
input.difficulty === "recruit" ? tierBestScore : (current.recruitBestScore ?? 0);
|
|
const veteranBestScore =
|
|
input.difficulty === "veteran" ? tierBestScore : (current.veteranBestScore ?? 0);
|
|
const eliteBestScore =
|
|
input.difficulty === "elite" ? tierBestScore : (current.eliteBestScore ?? 0);
|
|
|
|
const { xp: _xp, ...progressionRest } = progression;
|
|
|
|
await payload.update({
|
|
collection: "profiles",
|
|
id: profile.id,
|
|
data: {
|
|
progression: {
|
|
...progressionRest,
|
|
minigames: {
|
|
...(progressionRest.minigames ?? {}),
|
|
opordRecall: {
|
|
gamesPlayed,
|
|
bestScore,
|
|
bestAccuracy,
|
|
recruitBestScore,
|
|
veteranBestScore,
|
|
eliteBestScore,
|
|
xpEarned: opordRecallXp,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
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.MinigameOpordRecall,
|
|
message: `recalled an ${input.difficulty} briefing with ${correct}/${tier.questionCount} details correct (${pct}% accuracy, +${xp} XP)`,
|
|
actor: user.id as number,
|
|
targetCollection: "users",
|
|
targetId: user.id as number,
|
|
data: {
|
|
difficulty: input.difficulty,
|
|
correct,
|
|
total: tier.questionCount,
|
|
score,
|
|
accuracy,
|
|
timeMs: Math.round(timeMs),
|
|
xpEarned: xp,
|
|
bestScore,
|
|
tierBestScore,
|
|
opordRecallXp,
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
gamesPlayed,
|
|
bestScore,
|
|
bestAccuracy,
|
|
recruitBestScore,
|
|
veteranBestScore,
|
|
eliteBestScore,
|
|
opordRecallXp,
|
|
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 };
|
|
}
|
|
}
|