1
0
Fork 0
polaris-task-force/src/components/frontend/opord/OpordArena.tsx

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 · +coordination annex" },
{ 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>
);
}