From 246a5796f8935b6c2de78be2585418ab8ee134b2 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 13:00:43 -0400 Subject: [PATCH] feat(opord): lock answering behind a 2s countdown per question --- src/components/frontend/opord/OpordArena.tsx | 6 ++-- src/components/frontend/opord/OpordGame.tsx | 24 ++++++++++++-- src/lib/opord-recall.ts | 34 +++++++++++++------- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/components/frontend/opord/OpordArena.tsx b/src/components/frontend/opord/OpordArena.tsx index a07fc43..b7b664e 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: "10 questions · 110s recall · core order" }, - { value: "veteran", label: "Veteran", detail: "15 questions · 165s recall · +coordination annex" }, - { value: "elite", label: "Elite", detail: "20 questions · 225s recall · full order" }, + { value: "recruit", label: "Recruit", detail: "10 questions · 130s recall · core order" }, + { value: "veteran", label: "Veteran", detail: "15 questions · 195s recall · +coordination annex" }, + { value: "elite", label: "Elite", detail: "20 questions · 265s recall · full order" }, ] as const satisfies ReadonlyArray<{ readonly value: OpordDifficulty; readonly label: string; diff --git a/src/components/frontend/opord/OpordGame.tsx b/src/components/frontend/opord/OpordGame.tsx index 38cc6d1..83acfd0 100644 --- a/src/components/frontend/opord/OpordGame.tsx +++ b/src/components/frontend/opord/OpordGame.tsx @@ -6,6 +6,7 @@ import { BookOpen, ClipboardCheck, Timer } from "lucide-react"; import { OPORD_DIFFICULTIES, + QUESTION_UNLOCK_DELAY_MS, buildOpordQuestions, formatOpordGrid, generateOpordScenario, @@ -373,6 +374,7 @@ export function OpordGame({ const [questionIndex, setQuestionIndex] = useState(0); const [typedAnswer, setTypedAnswer] = useState(""); const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000); + const [unlockRemainingMs, setUnlockRemainingMs] = useState(0); const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState(null); const [submitting, setSubmitting] = useState(false); const [lastRun, setLastRun] = useState<{ @@ -526,7 +528,7 @@ export function OpordGame({ if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) { return; } - if (quizRemainingMs <= 0) return; + if (quizRemainingMs <= 0 || unlockRemainingMs > 0) return; lastAnswerAtRef.current = now; answersRef.current.push({ question, value }); setLastAnswerWasCorrect( @@ -546,7 +548,7 @@ export function OpordGame({ } }, ANSWER_FEEDBACK_MS); }, - [finishRun, questionIndex, questions, quizRemainingMs], + [finishRun, questionIndex, questions, quizRemainingMs, unlockRemainingMs], ); useEffect(() => { @@ -565,8 +567,18 @@ export function OpordGame({ return () => window.clearInterval(interval); }, [phase, tier.quizSeconds, finishRun]); + useEffect(() => { + if (phase !== "quiz") return; + setUnlockRemainingMs(QUESTION_UNLOCK_DELAY_MS); + const interval = window.setInterval(() => { + setUnlockRemainingMs((remaining) => Math.max(0, remaining - 100)); + }, 100); + return () => window.clearInterval(interval); + }, [phase, questionIndex]); + const question = questions[questionIndex]; - const answersLocked = lastAnswerWasCorrect !== null || quizRemainingMs <= 0; + const answersLocked = + lastAnswerWasCorrect !== null || quizRemainingMs <= 0 || unlockRemainingMs > 0; return (
@@ -642,6 +654,12 @@ export function OpordGame({ aria-hidden="true" /> + {unlockRemainingMs > 0 && lastAnswerWasCorrect === null && ( +

+ Answering opens in {Math.ceil(unlockRemainingMs / 1000)}s +

+ )} +

{question.prompt}

{question.kind === "choice" ? ( diff --git a/src/lib/opord-recall.ts b/src/lib/opord-recall.ts index b7a30d7..6338cbe 100644 --- a/src/lib/opord-recall.ts +++ b/src/lib/opord-recall.ts @@ -31,43 +31,53 @@ export interface OpordTierConfig { /** Grace added to the quiz limit for network/rounding before the server rejects. */ export const QUIZ_TIME_GRACE_MS = 2_500; -// Tiers keep the 4:6:8 ratio scaled to 10:15:20 questions; the recall clock -// (~11s/question) and par time (~8s/question) scale with the question count. +/** + * Countdown each question shows before answering unlocks; the client disables + * the options/input while it runs, and the server's minimum-run-time floor is + * derived from it. + */ +export const QUESTION_UNLOCK_DELAY_MS = 2_000; + +// Tiers keep the 4:6:8 ratio scaled to 10:15:20 questions. The recall clock +// (~11s/question of active answering) and par time (~8s/question) scale with +// the question count; the per-question QUESTION_UNLOCK_DELAY_MS wait runs +// inside the clock, so both budgets include questionCount x 2s of locked time. export const OPORD_DIFFICULTIES: Record = { recruit: { studyHintSeconds: 30, questionCount: 10, - quizSeconds: 110, + quizSeconds: 130, correctPoints: 100, - parTimeMs: 80_000, + parTimeMs: 100_000, perfectBonus: 150, }, veteran: { studyHintSeconds: 25, questionCount: 15, - quizSeconds: 165, + quizSeconds: 195, correctPoints: 150, - parTimeMs: 120_000, + parTimeMs: 150_000, perfectBonus: 300, }, elite: { // Extra clock headroom: elite relational questions include time arithmetic. studyHintSeconds: 20, questionCount: 20, - quizSeconds: 225, + quizSeconds: 265, correctPoints: 200, - parTimeMs: 160_000, + parTimeMs: 200_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. + * Fastest total quiz time a human could plausibly post: every question incurs + * the mandatory QUESTION_UNLOCK_DELAY_MS countdown before answering opens, + * plus a small read/submit buffer. The server rejects submissions below this + * as scripted. */ export function minOpordRunTimeMs(difficulty: OpordDifficulty): number { - return (OPORD_DIFFICULTIES[difficulty].questionCount - 1) * 1_500 + 800; + return OPORD_DIFFICULTIES[difficulty].questionCount * QUESTION_UNLOCK_DELAY_MS + 500; } // XP tuning: modest flat rewards, replays motivated by score.