1
0
Fork 0

feat(opord): lock answering behind a 2s countdown per question

This commit is contained in:
Jason Fraley 2026-09-08 13:00:43 -04:00
parent 50b9b7e46c
commit 246a5796f8
3 changed files with 46 additions and 18 deletions

View file

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

View file

@ -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<boolean | null>(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 (
<div className="flex w-full max-w-3xl flex-col items-center gap-4">
@ -642,6 +654,12 @@ export function OpordGame({
aria-hidden="true"
/>
{unlockRemainingMs > 0 && lastAnswerWasCorrect === null && (
<p className="font-mono text-xs uppercase tracking-wider text-muted-foreground">
Answering opens in {Math.ceil(unlockRemainingMs / 1000)}s
</p>
)}
<p className="text-center text-lg font-semibold">{question.prompt}</p>
{question.kind === "choice" ? (

View file

@ -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<OpordDifficulty, OpordTierConfig> = {
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.