1
0
Fork 0
polaris-task-force/src/app/(frontend)/opord/actions.ts
Z8MB1E 95663924ea feat(minigames): anti-cheat pass for OPORD Recall
- 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
2026-09-08 11:46:53 -04:00

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 };
}
}