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"; import { OpordLeaderboard } from "./OpordLeaderboard";
const TIER_OPTIONS = [ const TIER_OPTIONS = [
{ value: "recruit", label: "Recruit", detail: "10 questions · 110s recall · core order" }, { value: "recruit", label: "Recruit", detail: "10 questions · 130s recall · core order" },
{ value: "veteran", label: "Veteran", detail: "15 questions · 165s recall · +coordination annex" }, { value: "veteran", label: "Veteran", detail: "15 questions · 195s recall · +coordination annex" },
{ value: "elite", label: "Elite", detail: "20 questions · 225s recall · full order" }, { value: "elite", label: "Elite", detail: "20 questions · 265s recall · full order" },
] as const satisfies ReadonlyArray<{ ] as const satisfies ReadonlyArray<{
readonly value: OpordDifficulty; readonly value: OpordDifficulty;
readonly label: string; readonly label: string;

View file

@ -6,6 +6,7 @@ import { BookOpen, ClipboardCheck, Timer } from "lucide-react";
import { import {
OPORD_DIFFICULTIES, OPORD_DIFFICULTIES,
QUESTION_UNLOCK_DELAY_MS,
buildOpordQuestions, buildOpordQuestions,
formatOpordGrid, formatOpordGrid,
generateOpordScenario, generateOpordScenario,
@ -373,6 +374,7 @@ export function OpordGame({
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);
const [typedAnswer, setTypedAnswer] = useState(""); const [typedAnswer, setTypedAnswer] = useState("");
const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000); const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000);
const [unlockRemainingMs, setUnlockRemainingMs] = useState(0);
const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null); const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [lastRun, setLastRun] = useState<{ const [lastRun, setLastRun] = useState<{
@ -526,7 +528,7 @@ export function OpordGame({
if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) { if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) {
return; return;
} }
if (quizRemainingMs <= 0) return; if (quizRemainingMs <= 0 || unlockRemainingMs > 0) return;
lastAnswerAtRef.current = now; lastAnswerAtRef.current = now;
answersRef.current.push({ question, value }); answersRef.current.push({ question, value });
setLastAnswerWasCorrect( setLastAnswerWasCorrect(
@ -546,7 +548,7 @@ export function OpordGame({
} }
}, ANSWER_FEEDBACK_MS); }, ANSWER_FEEDBACK_MS);
}, },
[finishRun, questionIndex, questions, quizRemainingMs], [finishRun, questionIndex, questions, quizRemainingMs, unlockRemainingMs],
); );
useEffect(() => { useEffect(() => {
@ -565,8 +567,18 @@ export function OpordGame({
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [phase, tier.quizSeconds, finishRun]); }, [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 question = questions[questionIndex];
const answersLocked = lastAnswerWasCorrect !== null || quizRemainingMs <= 0; const answersLocked =
lastAnswerWasCorrect !== null || quizRemainingMs <= 0 || unlockRemainingMs > 0;
return ( return (
<div className="flex w-full max-w-3xl flex-col items-center gap-4"> <div className="flex w-full max-w-3xl flex-col items-center gap-4">
@ -642,6 +654,12 @@ export function OpordGame({
aria-hidden="true" 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> <p className="text-center text-lg font-semibold">{question.prompt}</p>
{question.kind === "choice" ? ( {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. */ /** Grace added to the quiz limit for network/rounding before the server rejects. */
export const QUIZ_TIME_GRACE_MS = 2_500; 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> = { export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
recruit: { recruit: {
studyHintSeconds: 30, studyHintSeconds: 30,
questionCount: 10, questionCount: 10,
quizSeconds: 110, quizSeconds: 130,
correctPoints: 100, correctPoints: 100,
parTimeMs: 80_000, parTimeMs: 100_000,
perfectBonus: 150, perfectBonus: 150,
}, },
veteran: { veteran: {
studyHintSeconds: 25, studyHintSeconds: 25,
questionCount: 15, questionCount: 15,
quizSeconds: 165, quizSeconds: 195,
correctPoints: 150, correctPoints: 150,
parTimeMs: 120_000, parTimeMs: 150_000,
perfectBonus: 300, perfectBonus: 300,
}, },
elite: { elite: {
// Extra clock headroom: elite relational questions include time arithmetic. // Extra clock headroom: elite relational questions include time arithmetic.
studyHintSeconds: 20, studyHintSeconds: 20,
questionCount: 20, questionCount: 20,
quizSeconds: 225, quizSeconds: 265,
correctPoints: 200, correctPoints: 200,
parTimeMs: 160_000, parTimeMs: 200_000,
perfectBonus: 500, perfectBonus: 500,
}, },
}; };
/** /**
* Fastest total quiz time a human could plausibly post: every question except * Fastest total quiz time a human could plausibly post: every question incurs
* the last needs reading + click, plus the fixed answer-feedback delay. The * the mandatory QUESTION_UNLOCK_DELAY_MS countdown before answering opens,
* server rejects submissions below this as scripted. * plus a small read/submit buffer. The server rejects submissions below this
* as scripted.
*/ */
export function minOpordRunTimeMs(difficulty: OpordDifficulty): number { 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. // XP tuning: modest flat rewards, replays motivated by score.