feat(opord): lock answering behind a 2s countdown per question
This commit is contained in:
parent
50b9b7e46c
commit
246a5796f8
3 changed files with 46 additions and 18 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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" ? (
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue