From 95663924ea45b9ea768458a9f22de82f5e60b131 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 11:46:53 -0400 Subject: [PATCH] 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= 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 --- src/app/(frontend)/opord/actions.ts | 10 +- src/components/frontend/opord/OpordArena.tsx | 24 +- src/components/frontend/opord/OpordGame.tsx | 356 +++++++++++++------ src/lib/opord-recall.ts | 38 +- tests/e2e/opord-recall.e2e.spec.ts | 20 +- 5 files changed, 309 insertions(+), 139 deletions(-) diff --git a/src/app/(frontend)/opord/actions.ts b/src/app/(frontend)/opord/actions.ts index c0e8858..8b6a1d6 100644 --- a/src/app/(frontend)/opord/actions.ts +++ b/src/app/(frontend)/opord/actions.ts @@ -5,10 +5,12 @@ 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"; @@ -40,8 +42,6 @@ export type OpordRunInput = { timeMs: number; }; -const MAX_TIME_MS = 600_000; - async function authenticate() { const payloadConfig = await config; const payload = await getPayload({ config: payloadConfig }); @@ -71,14 +71,16 @@ export async function submitOpordRecallResult( 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 < 0 || - timeMs > MAX_TIME_MS + timeMs < minTimeMs || + timeMs > maxTimeMs ) { return { success: false, error: "Invalid result." }; } diff --git a/src/components/frontend/opord/OpordArena.tsx b/src/components/frontend/opord/OpordArena.tsx index ad07fe9..c60d54a 100644 --- a/src/components/frontend/opord/OpordArena.tsx +++ b/src/components/frontend/opord/OpordArena.tsx @@ -13,9 +13,9 @@ import { OpordGame } from "./OpordGame"; import { OpordLeaderboard } from "./OpordLeaderboard"; const TIER_OPTIONS = [ - { value: "recruit", label: "Recruit", detail: "25s briefing, 4 questions" }, - { value: "veteran", label: "Veteran", detail: "18s briefing, 6 questions" }, - { value: "elite", label: "Elite", detail: "12s briefing, 8 questions" }, + { value: "recruit", label: "Recruit", detail: "4 questions · 45s recall" }, + { value: "veteran", label: "Veteran", detail: "6 questions · 60s recall" }, + { value: "elite", label: "Elite", detail: "8 questions · 75s recall" }, ] as const satisfies ReadonlyArray<{ readonly value: OpordDifficulty; readonly label: string; @@ -61,8 +61,8 @@ export function OpordArena({ Choose a briefing tier - The briefing stays visible for a limited window. After it is withdrawn, answer every - question from memory. + Study the order for as long as you like. Once the recall quiz starts, the clock is + unforgiving. @@ -131,15 +131,19 @@ export function OpordArena({ Study The order covers the objective grid, azimuth, H-HOUR, phase lines, codewords, - callsign, and radio frequency. You can end the memorize window early with "Ready - early". + callsign, and radio frequency. It is rendered as an image with copy and context + menus blocked: there is no text to select, and "Begin recall" starts the quiz + whenever you are ready.
Recall - Questions come one at a time with no going back. Answers score{" "} - {OPORD_DIFFICULTIES[difficulty].correctPoints} points each on this tier. + Questions come one at a time with no going back, hard-capped at{" "} + {OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. The server only + accepts runs inside a plausible time band, so answers scored{" "} + {OPORD_DIFFICULTIES[difficulty].correctPoints} points each have to be earned against + the clock.
@@ -147,7 +151,7 @@ export function OpordArena({ A perfect run inside {OPORD_DIFFICULTIES[difficulty].parTimeMs / 1000}s earns a{" "} {OPORD_DIFFICULTIES[difficulty].perfectBonus}-point bonus. Each tier tracks its own - best score. + best score. Screenshots exist, but consulting one burns quiz time you need.
diff --git a/src/components/frontend/opord/OpordGame.tsx b/src/components/frontend/opord/OpordGame.tsx index e28d00e..56a55ae 100644 --- a/src/components/frontend/opord/OpordGame.tsx +++ b/src/components/frontend/opord/OpordGame.tsx @@ -27,6 +27,214 @@ type Phase = "ready" | "briefing" | "quiz" | "over"; const ANSWER_FEEDBACK_MS = 900; +const CANVAS_PAD_X = 18; +const CANVAS_MAX_W = 660; + +type BriefingRow = + | { kind: "title" | "subtitle" | "header" | "text"; text: string } + | { kind: "kv"; label: string; value: string } + | { kind: "rule" } + | { kind: "gap" }; + +const ROW_FONT: Record, string> = { + title: "bold 17px ui-monospace, Menlo, monospace", + subtitle: "10px ui-monospace, Menlo, monospace", + header: "bold 12px ui-monospace, Menlo, monospace", + text: "12px ui-monospace, Menlo, monospace", +}; + +const ROW_COLOR: Record, string> = { + title: "#fafafa", + subtitle: "#71717a", + header: "#fbbf24", + text: "#a1a1aa", +}; + +const ROW_HEIGHT: Record, number> = { + title: 26, + subtitle: 14, + header: 24, + text: 18, +}; + +function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { + const words = text.split(" "); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (ctx.measureText(candidate).width > maxWidth && current.length > 0) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current.length > 0) lines.push(current); + return lines; +} + +function buildBriefingRows( + measureCtx: CanvasRenderingContext2D, + scenario: OpordScenario, + maxWidth: number, +): BriefingRow[] { + const push = (rows: BriefingRow[], kind: "title" | "subtitle" | "header" | "text", text: string) => { + const font = ROW_FONT[kind]; + measureCtx.font = font; + for (const line of wrapText(measureCtx, text, maxWidth)) { + rows.push({ kind, text: line }); + } + }; + + const rows: BriefingRow[] = []; + push(rows, "subtitle", "COPY 1 OF 1 · POLARIS TASK FORCE"); + push(rows, "title", `OPERATION ${scenario.operationName}`); + push(rows, "subtitle", `OPERATIONS ORDER NO. ${String(scenario.seed).slice(-4).padStart(4, "0")}`); + rows.push({ kind: "rule" }); + push(rows, "header", "1. SITUATION"); + push( + rows, + "text", + `Enemy: ${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s) dug in on objective ${scenario.objective} at grid ${formatOpordGrid(scenario.grid)}.`, + ); + push(rows, "text", `Friendly: ${scenario.friendlyUnit} in support aboard ${scenario.transport}.`); + push(rows, "header", "2. MISSION"); + push( + rows, + "text", + `At H-HOUR ${scenario.hHour}, seize objective ${scenario.objective} (grid ${formatOpordGrid(scenario.grid)}), ${scenario.distanceKm} km on azimuth ${String(scenario.azimuth).padStart(3, "0")}.`, + ); + push(rows, "header", "3. EXECUTION"); + push( + rows, + "text", + `Line of departure is phase line ${scenario.ldPhaseLine}. Subsequent phase lines: ${scenario.otherPhaseLines.length > 0 ? scenario.otherPhaseLines.join(", ") : "none assigned"}.`, + ); + push(rows, "header", "4. SERVICE SUPPORT"); + push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`); + push(rows, "header", "5. COMMAND & SIGNAL"); + rows.push({ kind: "kv", label: "AUTHENTICATION CODEWORD:", value: scenario.codeword }); + rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword }); + rows.push({ kind: "gap" }); + return rows; +} + +function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): void { + const parent = canvas.parentElement; + const availableWidth = parent + ? parent.clientWidth - CANVAS_PAD_X * 2 + : CANVAS_MAX_W - CANVAS_PAD_X * 2; + const width = Math.min(CANVAS_MAX_W, Math.max(320, availableWidth)); + const dpr = window.devicePixelRatio || 1; + + const measure = canvas.getContext("2d"); + if (!measure) return; + measure.setTransform(1, 0, 0, 1, 0, 0); + measure.scale(dpr, dpr); + const textWidth = width - CANVAS_PAD_X * 2; + const rows = buildBriefingRows(measure, scenario, textWidth); + + let height = 20; + for (const row of rows) { + if (row.kind === "rule") height += 14; + else if (row.kind === "gap") height += 8; + else if (row.kind === "kv") height += ROW_HEIGHT.text; + else height += ROW_HEIGHT[row.kind]; + } + height += 20; + + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + + ctx.fillStyle = "#0a0a0b"; + ctx.fillRect(0, 0, width, height); + + let y = 20; + for (const row of rows) { + if (row.kind === "rule") { + y += 7; + ctx.strokeStyle = "#27272a"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(CANVAS_PAD_X, y); + ctx.lineTo(width - CANVAS_PAD_X, y); + ctx.stroke(); + y += 7; + continue; + } + if (row.kind === "gap") { + y += 8; + continue; + } + if (row.kind === "kv") { + ctx.font = ROW_FONT.text; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.fillStyle = "#a1a1aa"; + ctx.fillText(row.label, CANVAS_PAD_X, y + ROW_HEIGHT.text - 5); + const labelWidth = ctx.measureText(`${row.label} `).width; + ctx.fillStyle = "#fafafa"; + ctx.font = "bold 12px ui-monospace, Menlo, monospace"; + ctx.fillText(row.value, CANVAS_PAD_X + labelWidth, y + ROW_HEIGHT.text - 5); + y += ROW_HEIGHT.text; + continue; + } + ctx.font = ROW_FONT[row.kind]; + ctx.fillStyle = ROW_COLOR[row.kind]; + ctx.textBaseline = "alphabetic"; + if (row.kind === "title" || row.kind === "subtitle") { + ctx.textAlign = "center"; + ctx.fillText(row.text, width / 2, y + ROW_HEIGHT[row.kind] - 8); + } else { + ctx.textAlign = "left"; + ctx.fillText(row.text, CANVAS_PAD_X, y + ROW_HEIGHT[row.kind] - 6); + } + y += ROW_HEIGHT[row.kind]; + } +} + +function OpordBriefingCanvas({ scenario }: { scenario: OpordScenario }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + drawBriefing(canvas, scenario); + }, [scenario]); + + return ( +
event.preventDefault()} + onCut={(event) => event.preventDefault()} + onContextMenu={(event) => event.preventDefault()} + onKeyDown={(event) => { + const key = event.key.toLowerCase(); + if ( + (event.ctrlKey || event.metaKey) && + (key === "a" || key === "c" || key === "x" || key === "s" || key === "p") + ) { + event.preventDefault(); + } + }} + > + +
+ ); +} + function Metre({ label, value }: { label: string; value: string | number }) { return (
@@ -36,82 +244,6 @@ function Metre({ label, value }: { label: string; value: string | number }) { ); } -function BriefingDocument({ scenario }: { scenario: OpordScenario }) { - return ( -
-
-
- Copy 1 of 1 · Polaris Task Force -
-

- Operation {scenario.operationName} -

-
- Operations Order No. {String(scenario.seed).slice(-4).padStart(4, "0")} -
-
- -
-
-

- 1. Situation -

-

- Enemy force: {scenario.enemyVehicles} vehicle(s) and {scenario.enemySquads} infantry - squad(s) dug in on objective {scenario.objective} at grid {formatOpordGrid(scenario.grid)}. -

-

- Friendly: {scenario.friendlyUnit} in support aboard {scenario.transport}. -

-
- -
-

- 2. Mission -

-

- At H-HOUR {scenario.hHour}, seize objective {scenario.objective} (grid{" "} - {formatOpordGrid(scenario.grid)}), {scenario.distanceKm} km on azimuth{" "} - {String(scenario.azimuth).padStart(3, "0")}. -

-
- -
-

- 3. Execution -

-

- Line of departure is phase line {scenario.ldPhaseLine}. Subsequent phase lines:{" "} - {scenario.otherPhaseLines.length > 0 - ? scenario.otherPhaseLines.join(", ") - : "none assigned"} - . -

-
- -
-

- 4. Service Support -

-

- Element callsign {scenario.callsign} monitors {scenario.frequency} MHz. -

-
- -
-

- 5. Command & Signal -

-

- Authentication codeword: {scenario.codeword}. - Abort codeword: {scenario.abortCodeword}. -

-
-
-
- ); -} - export function OpordGame({ initialStats, difficulty, @@ -133,7 +265,6 @@ export function OpordGame({ const scenarioSeedRef = useRef(0); const quizStartedAtRef = useRef(0); const answersRef = useRef>([]); - const questionShownAtRef = useRef(0); const lastAnswerAtRef = useRef(0); const [phase, setPhase] = useState("ready"); @@ -141,7 +272,7 @@ export function OpordGame({ const [questions, setQuestions] = useState([]); const [questionIndex, setQuestionIndex] = useState(0); const [typedAnswer, setTypedAnswer] = useState(""); - const [memoRemainingMs, setMemoRemainingMs] = useState(0); + const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000); const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState(null); const [submitting, setSubmitting] = useState(false); const [lastRun, setLastRun] = useState<{ @@ -225,7 +356,11 @@ export function OpordGame({ const startBriefing = useCallback(() => { if (expiredRef.current) return; - const seed = Date.now() % 2147483647; + const seedParam = Number(new URLSearchParams(window.location.search).get("seed")); + const seed = + Number.isInteger(seedParam) && seedParam > 0 && seedParam < 2147483647 + ? seedParam + : Date.now() % 2147483647; scenarioSeedRef.current = seed; const nextScenario = generateOpordScenario(seed); setScenario(nextScenario); @@ -236,14 +371,13 @@ export function OpordGame({ lastAnswerAtRef.current = 0; setLastAnswerWasCorrect(null); setLastRun(null); - setMemoRemainingMs(tier.memoSeconds * 1000); + setQuizRemainingMs(tier.quizSeconds * 1000); setPhaseSafe("briefing"); - }, [difficulty, expiredRef, setPhaseSafe, tier.memoSeconds]); + }, [difficulty, expiredRef, setPhaseSafe, tier.quizSeconds]); const beginQuiz = useCallback(() => { if (phaseRef.current !== "briefing") return; quizStartedAtRef.current = Date.now(); - questionShownAtRef.current = Date.now(); setPhaseSafe("quiz"); }, [setPhaseSafe]); @@ -257,6 +391,7 @@ export function OpordGame({ if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) { return; } + if (quizRemainingMs <= 0) return; lastAnswerAtRef.current = now; answersRef.current.push({ question, value }); setLastAnswerWasCorrect( @@ -268,35 +403,35 @@ export function OpordGame({ window.setTimeout(() => { setLastAnswerWasCorrect(null); setTypedAnswer(""); + if (phaseRef.current !== "quiz") return; if (questionIndex + 1 >= questions.length) { void finishRun(); } else { setQuestionIndex((index) => index + 1); - questionShownAtRef.current = Date.now(); } }, ANSWER_FEEDBACK_MS); }, - [finishRun, questionIndex, questions], + [finishRun, questionIndex, questions, quizRemainingMs], ); useEffect(() => { - if (phase !== "briefing") return; - const deadline = Date.now() + memoRemainingMs; + if (phase !== "quiz") return; + const deadline = quizStartedAtRef.current + tier.quizSeconds * 1000; const interval = window.setInterval(() => { const remaining = deadline - Date.now(); if (remaining <= 0) { window.clearInterval(interval); - setMemoRemainingMs(0); - beginQuiz(); + setQuizRemainingMs(0); + void finishRun(); return; } - setMemoRemainingMs(remaining); + setQuizRemainingMs(remaining); }, 200); return () => window.clearInterval(interval); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [phase]); + }, [phase, tier.quizSeconds, finishRun]); const question = questions[questionIndex]; + const answersLocked = lastAnswerWasCorrect !== null || quizRemainingMs <= 0; return (
@@ -306,12 +441,14 @@ export function OpordGame({

OPORD Recall

- A live operations order will be on screen for {tier.memoSeconds} seconds. Memorize - the details, then answer {tier.questionCount} questions from memory. + Study the operations order at your own pace, then take a timed recall quiz:{" "} + {tier.quizSeconds} seconds for {tier.questionCount} questions.

Best score this tier:{" "} - {statsRef.current[`${difficulty}BestScore` as "recruitBestScore" | "veteranBestScore" | "eliteBestScore"] ?? 0} + {statsRef.current[ + `${difficulty}BestScore` as "recruitBestScore" | "veteranBestScore" | "eliteBestScore" + ] ?? 0}

- + )} @@ -346,9 +483,14 @@ export function OpordGame({ Question {questionIndex + 1} of {questions.length} - +
answerCurrent(String(index))} > {option} @@ -382,7 +524,7 @@ export function OpordGame({ className="flex w-full max-w-xs flex-col gap-2" onSubmit={(event) => { event.preventDefault(); - if (typedAnswer.trim().length === 0 || lastAnswerWasCorrect !== null) return; + if (typedAnswer.trim().length === 0 || answersLocked) return; answerCurrent(typedAnswer); }} > @@ -395,8 +537,9 @@ export function OpordGame({ maxLength={12} placeholder="Type your answer" aria-label="Answer" + disabled={answersLocked} /> - @@ -408,7 +551,10 @@ export function OpordGame({

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