Debrief
-
+
diff --git a/src/lib/opord-recall.ts b/src/lib/opord-recall.ts
index 36949ee..dff2e14 100644
--- a/src/lib/opord-recall.ts
+++ b/src/lib/opord-recall.ts
@@ -12,12 +12,12 @@ export type OpordDifficulty = "recruit" | "veteran" | "elite";
const DIFFICULTY_VALUES: readonly OpordDifficulty[] = ["recruit", "veteran", "elite"];
export interface OpordTierConfig {
- /** Seconds the briefing stays on screen. */
- readonly memoSeconds: number;
+ /** Suggested study time shown to the player (briefing itself is self-paced). */
+ readonly studyHintSeconds: number;
/** Number of quiz questions asked after the briefing. */
readonly questionCount: number;
- /** Options shown for multiple-choice questions. */
- readonly choiceCount: number;
+ /** Hard recall time limit in seconds; server rejects runs slower than this. */
+ readonly quizSeconds: number;
/** Points awarded per correct answer. */
readonly correctPoints: number;
/** Total answer time under which a perfect run earns the speed bonus. */
@@ -26,33 +26,45 @@ export interface OpordTierConfig {
readonly perfectBonus: number;
}
+/** Grace added to the quiz limit for network/rounding before the server rejects. */
+export const QUIZ_TIME_GRACE_MS = 2_500;
+
export const OPORD_DIFFICULTIES: Record = {
recruit: {
- memoSeconds: 25,
+ studyHintSeconds: 25,
questionCount: 4,
- choiceCount: 3,
+ quizSeconds: 45,
correctPoints: 100,
- parTimeMs: 45_000,
+ parTimeMs: 32_000,
perfectBonus: 150,
},
veteran: {
- memoSeconds: 18,
+ studyHintSeconds: 18,
questionCount: 6,
- choiceCount: 4,
+ quizSeconds: 60,
correctPoints: 150,
- parTimeMs: 70_000,
+ parTimeMs: 45_000,
perfectBonus: 300,
},
elite: {
- memoSeconds: 12,
+ studyHintSeconds: 12,
questionCount: 8,
- choiceCount: 4,
+ quizSeconds: 75,
correctPoints: 200,
- parTimeMs: 100_000,
+ parTimeMs: 58_000,
perfectBonus: 500,
},
};
+/**
+ * Fastest total quiz time a human could plausibly post: every question except
+ * the last needs reading + click, plus the fixed answer-feedback delay. The
+ * server rejects submissions below this as scripted.
+ */
+export function minOpordRunTimeMs(difficulty: OpordDifficulty): number {
+ return (OPORD_DIFFICULTIES[difficulty].questionCount - 1) * 1_500 + 800;
+}
+
// XP tuning: modest flat rewards, replays motivated by score.
const COMPLETION_XP = 2;
const POINTS_PER_XP = 250;
diff --git a/tests/e2e/opord-recall.e2e.spec.ts b/tests/e2e/opord-recall.e2e.spec.ts
index 211375c..d2f2a9b 100644
--- a/tests/e2e/opord-recall.e2e.spec.ts
+++ b/tests/e2e/opord-recall.e2e.spec.ts
@@ -1,5 +1,8 @@
import { expect, test, type Page } from "@playwright/test";
+import { formatOpordGrid, generateOpordScenario } from "../../src/lib/opord-recall";
+
+const FIXED_SEED = 424242;
const QUIZ_OPTION_BUTTON = "button.font-mono";
async function login(page: Page): Promise {
@@ -11,18 +14,17 @@ async function login(page: Page): Promise {
}
async function playOpordRound(page: Page): Promise {
- await page.goto("/opord");
+ await page.goto(`/opord?seed=${FIXED_SEED}`);
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
await page.getByRole("button", { name: "Receive briefing" }).click();
- await expect(page.getByText("Memorize the order")).toBeVisible();
+ await expect(page.getByText("Study at your own pace")).toBeVisible();
- const briefingText = await page.locator("body").innerText();
- const gridMatch = briefingText.match(/grid (\d{4}) (\d{4})/);
- if (!gridMatch) throw new Error("Objective grid not found in briefing text");
- const grid = `${gridMatch[1]} ${gridMatch[2]}`;
+ // The briefing is drawn on canvas, so derive the expected grid from the lib.
+ const scenario = generateOpordScenario(FIXED_SEED);
+ const grid = formatOpordGrid(scenario.grid);
- await page.getByRole("button", { name: "Ready early" }).click();
+ await page.getByRole("button", { name: "Begin recall" }).click();
const questionCounter = page.getByText(/Question \d+ of \d+/);
await expect(questionCounter).toBeVisible();
@@ -50,6 +52,10 @@ test.describe("OPORD Recall", () => {
await expect(page.locator("body")).toContainText(/Recalled/);
await expect(page.getByText(/^\+\d+$/).first()).toBeVisible();
+ // The briefing must not expose selectable text to copy.
+ const bodyText = await page.locator("body").innerText();
+ expect(bodyText).not.toContain(formatOpordGrid(generateOpordScenario(FIXED_SEED).grid));
+
// The leaderboard refetches after the run and shows the recorded run for this user.
const leaderboard = page.getByRole("table");
await expect(leaderboard).toBeVisible({ timeout: 15_000 });