168 lines
6.8 KiB
TypeScript
168 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import { OPORD_DIFFICULTIES, type OpordDifficulty } from "@/lib/opord-recall";
|
|
import type { OpordStats } from "@/app/(frontend)/opord/actions";
|
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
|
import { useSidebar } from "@/components/ui/sidebar";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { cn } from "@/lib/utils";
|
|
import { OpordGame } from "./OpordGame";
|
|
import { OpordLeaderboard } from "./OpordLeaderboard";
|
|
|
|
const TIER_OPTIONS = [
|
|
{ value: "recruit", label: "Recruit", detail: "10 questions · 130s recall · core order" },
|
|
{ value: "veteran", label: "Veteran", detail: "15 questions · 195s recall · extended order" },
|
|
{ value: "elite", label: "Elite", detail: "20 questions · 265s recall · full order" },
|
|
] as const satisfies ReadonlyArray<{
|
|
readonly value: OpordDifficulty;
|
|
readonly label: string;
|
|
readonly detail: string;
|
|
}>;
|
|
|
|
const TIER_STAT_KEYS = {
|
|
recruit: "recruitBestScore",
|
|
veteran: "veteranBestScore",
|
|
elite: "eliteBestScore",
|
|
} as const satisfies Record<OpordDifficulty, "recruitBestScore" | "veteranBestScore" | "eliteBestScore">;
|
|
|
|
export function OpordArena({
|
|
initialStats,
|
|
currentUserId,
|
|
}: {
|
|
initialStats: OpordStats;
|
|
currentUserId: number | string;
|
|
}) {
|
|
const [stats, setStats] = useState<OpordStats>(initialStats);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const [difficulty, setDifficulty] = useState<OpordDifficulty>("recruit");
|
|
const [playing, setPlaying] = useState(false);
|
|
const { isMobile, setOpenMobile } = useSidebar();
|
|
const activeOperators = useRealtimePresence("opord", playing, true);
|
|
|
|
const handlePhaseChange = useCallback((nextPhase: "ready" | "briefing" | "quiz" | "over") => {
|
|
setPlaying(nextPhase === "briefing" || nextPhase === "quiz");
|
|
}, []);
|
|
|
|
const handleGameRecorded = useCallback((nextStats: OpordStats) => {
|
|
setStats(nextStats);
|
|
setRefreshKey((key) => key + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (playing && isMobile) setOpenMobile(false);
|
|
}, [isMobile, playing, setOpenMobile]);
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-6">
|
|
<Card className="w-full">
|
|
<CardHeader>
|
|
<CardTitle>Choose a briefing tier</CardTitle>
|
|
<CardDescription>
|
|
Study the order for as long as you like. Once the recall quiz starts, the clock is
|
|
unforgiving.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<fieldset
|
|
disabled={playing}
|
|
className={cn(
|
|
"m-0 min-w-0 border-0 p-0 transition-opacity duration-200 motion-reduce:transition-none",
|
|
playing && "opacity-60",
|
|
)}
|
|
>
|
|
<RadioGroup
|
|
value={difficulty}
|
|
onValueChange={(value) => {
|
|
if (value === "recruit" || value === "veteran" || value === "elite") {
|
|
setDifficulty(value);
|
|
}
|
|
}}
|
|
aria-label="OPORD Recall difficulty"
|
|
className="grid gap-2 md:grid-cols-3"
|
|
>
|
|
{TIER_OPTIONS.map((option) => (
|
|
<label
|
|
key={option.value}
|
|
htmlFor={`opord-tier-${option.value}`}
|
|
className={cn(
|
|
"flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-background px-3 py-3 transition-colors focus-within:ring-2 focus-within:ring-ring motion-reduce:transition-none",
|
|
difficulty === option.value && "border-primary bg-accent",
|
|
playing && "cursor-not-allowed",
|
|
)}
|
|
>
|
|
<RadioGroupItem
|
|
id={`opord-tier-${option.value}`}
|
|
value={option.value}
|
|
aria-label={`${option.label} briefing tier`}
|
|
/>
|
|
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<span className="flex items-center justify-between gap-2">
|
|
<span className="font-medium">{option.label}</span>
|
|
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
|
{stats[TIER_STAT_KEYS[option.value]]} pts
|
|
</span>
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">{option.detail}</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</RadioGroup>
|
|
</fieldset>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<OpordGame
|
|
key={difficulty}
|
|
initialStats={stats}
|
|
difficulty={difficulty}
|
|
onGameRecordedAction={handleGameRecorded}
|
|
onPhaseChangeAction={handlePhaseChange}
|
|
/>
|
|
|
|
<Card className="w-full max-w-3xl">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm">Recall protocol</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 text-xs text-muted-foreground sm:grid-cols-3">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="font-medium text-foreground">Study</span>
|
|
<span>
|
|
The order covers the objective grid, azimuth, H-HOUR, phase lines, codewords,
|
|
callsign, and radio frequency. Veteran tiers add garrison tasks, rules of
|
|
engagement, and a secondary objective; elite adds intelligence targets and a
|
|
tertiary objective. Study it at your own pace, then press "Begin recall" when
|
|
you are ready for the quiz.
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="font-medium text-foreground">Recall</span>
|
|
<span>
|
|
Questions come one at a time with no going back, and the whole quiz is capped at{" "}
|
|
{OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. Each correct answer
|
|
is worth {OPORD_DIFFICULTIES[difficulty].correctPoints} points, so speed and accuracy
|
|
both count.
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="font-medium text-foreground">Debrief</span>
|
|
<span>
|
|
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.
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<OpordLeaderboard
|
|
refreshKey={refreshKey}
|
|
difficulty={difficulty}
|
|
currentUserId={currentUserId}
|
|
activeOperators={activeOperators}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|