1
0
Fork 0

refactor(minigames): move routes, components, and libs under minigames parents

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-09 00:02:59 -04:00
parent 76fbaba293
commit 25975db23f
63 changed files with 250 additions and 92 deletions

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@ import { headers as nextHeaders } from "next/headers";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Bomb } from "lucide-react"; import { ArrowLeft, Bomb } from "lucide-react";
import { CustomMinefieldArena } from "@/components/frontend/eod/CustomMinefieldArena"; import { CustomMinefieldArena } from "@/components/frontend/minigames/eod/CustomMinefieldArena";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Play Custom Minefield (Polaris Task Force)", title: "Play Custom Minefield (Polaris Task Force)",
@ -51,7 +51,7 @@ export default async function CustomMinefieldPlayPage({
<div className="flex min-w-0 flex-col gap-6 p-5"> <div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/eod/custom" href="/minigames/eod/custom"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
> >
<ArrowLeft className="size-4" /> All custom minefields <ArrowLeft className="size-4" /> All custom minefields

View file

@ -6,7 +6,7 @@ import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { broadcastToUser } from "@/lib/realtime/bus"; import { broadcastToUser } from "@/lib/realtime/bus";
import { creditEodXp } from "@/lib/custom-minigames/payouts"; import { creditEodXp } from "@/lib/minigames/custom/payouts";
import { import {
applyCreatorBank, applyCreatorBank,
customPlayerXp, customPlayerXp,
@ -16,7 +16,7 @@ import {
validateMinefieldName, validateMinefieldName,
validateMinefieldScore, validateMinefieldScore,
validateMinePositions, validateMinePositions,
} from "@/lib/custom-minigames/eod"; } from "@/lib/minigames/custom/eod";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
success: boolean; success: boolean;

View file

@ -3,8 +3,8 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { Bomb } from "lucide-react"; import { Bomb } from "lucide-react";
import { CustomMinefieldBrowser } from "@/components/frontend/eod/CustomMinefieldBrowser"; import { CustomMinefieldBrowser } from "@/components/frontend/minigames/eod/CustomMinefieldBrowser";
import { isEodUnlocked } from "@/lib/custom-minigames/eod"; import { isEodUnlocked } from "@/lib/minigames/custom/eod";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Custom Minefields (Polaris Task Force)", title: "Custom Minefields (Polaris Task Force)",

View file

@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Bomb } from "lucide-react"; import { Bomb } from "lucide-react";
import { EodArena } from "@/components/frontend/eod/EodArena"; import { EodArena } from "@/components/frontend/minigames/eod/EodArena";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Minefield Clearance (Polaris Task Force)", title: "Minefield Clearance (Polaris Task Force)",
@ -18,7 +18,7 @@ export default async function EodPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) { if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/eod")}`); redirect(`/login?returnTo=${encodeURIComponent("/minigames/eod")}`);
} }
const profileRes = await payload.find({ const profileRes = await payload.find({

View file

@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Bird } from "lucide-react"; import { Bird } from "lucide-react";
import { FlappyArena } from "@/components/frontend/flappy/FlappyArena"; import { FlappyArena } from "@/components/frontend/minigames/flappy/FlappyArena";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Flight Simulator: Polaris Task Force", title: "Flight Simulator: Polaris Task Force",

View file

@ -12,7 +12,7 @@ import {
isOpordDifficulty, isOpordDifficulty,
minOpordRunTimeMs, minOpordRunTimeMs,
type OpordDifficulty, type OpordDifficulty,
} from "@/lib/opord-recall"; } from "@/lib/minigames/opord-recall";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { broadcastToUser } from "@/lib/realtime/bus"; import { broadcastToUser } from "@/lib/realtime/bus";

View file

@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ScrollText } from "lucide-react"; import { ScrollText } from "lucide-react";
import { OpordArena } from "@/components/frontend/opord/OpordArena"; import { OpordArena } from "@/components/frontend/minigames/opord/OpordArena";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "OPORD Recall (Polaris Task Force)", title: "OPORD Recall (Polaris Task Force)",
@ -18,7 +18,7 @@ export default async function OpordPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) { if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/opord")}`); redirect(`/login?returnTo=${encodeURIComponent("/minigames/opord")}`);
} }
const profileRes = await payload.find({ const profileRes = await payload.find({

View file

@ -14,7 +14,7 @@ import {
MAX_HITS, MAX_HITS,
MAX_DESPAWNED_TARGETS, MAX_DESPAWNED_TARGETS,
MAX_CIVILIAN_HITS, MAX_CIVILIAN_HITS,
} from "@/lib/aim-trainer"; } from "@/lib/minigames/aim-trainer";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
success: boolean; success: boolean;

View file

@ -5,8 +5,8 @@ import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Crosshair } from "lucide-react"; import { Crosshair } from "lucide-react";
import type { AimTrainerStats } from "@/app/(frontend)/qualification/actions"; import type { AimTrainerStats } from "@/app/(frontend)/minigames/qualification/actions";
import { AimTrainerArena } from "@/components/frontend/qualification/AimTrainerArena"; import { AimTrainerArena } from "@/components/frontend/minigames/qualification/AimTrainerArena";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Qualification Course: Polaris Task Force", title: "Qualification Course: Polaris Task Force",
@ -20,7 +20,7 @@ export default async function QualificationPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) { if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/qualification")}`); redirect(`/login?returnTo=${encodeURIComponent("/minigames/qualification")}`);
} }
const profileRes = await payload.find({ const profileRes = await payload.find({

View file

@ -5,7 +5,7 @@ import { headers as nextHeaders } from "next/headers";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Radio } from "lucide-react"; import { ArrowLeft, Radio } from "lucide-react";
import { CustomRadioArena } from "@/components/frontend/radio/CustomRadioArena"; import { CustomRadioArena } from "@/components/frontend/minigames/radio/CustomRadioArena";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Play Custom Radio Test (Polaris Task Force)", title: "Play Custom Radio Test (Polaris Task Force)",
@ -45,7 +45,7 @@ export default async function CustomRadioTestPlayPage({
<div className="flex min-w-0 flex-col gap-6 p-5"> <div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/radio/custom" href="/minigames/radio/custom"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
> >
<ArrowLeft className="size-4" /> All custom radio tests <ArrowLeft className="size-4" /> All custom radio tests

View file

@ -11,7 +11,7 @@ import {
validateMessages, validateMessages,
validateRadioScore, validateRadioScore,
validateTestName, validateTestName,
} from "@/lib/custom-minigames/radio"; } from "@/lib/minigames/custom/radio";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
success: boolean; success: boolean;

View file

@ -3,8 +3,8 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { Radio } from "lucide-react"; import { Radio } from "lucide-react";
import { CustomTestBrowser } from "@/components/frontend/radio/CustomTestBrowser"; import { CustomTestBrowser } from "@/components/frontend/minigames/radio/CustomTestBrowser";
import { isRadioUnlocked } from "@/lib/custom-minigames/radio"; import { isRadioUnlocked } from "@/lib/minigames/custom/radio";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Custom Radio Tests (Polaris Task Force)", title: "Custom Radio Tests (Polaris Task Force)",

View file

@ -4,7 +4,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Radio } from "lucide-react"; import { Radio } from "lucide-react";
import { RadioArena } from "@/components/frontend/radio/RadioArena"; import { RadioArena } from "@/components/frontend/minigames/radio/RadioArena";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Radio Traffic (Polaris Task Force)", title: "Radio Traffic (Polaris Task Force)",
@ -18,7 +18,7 @@ export default async function RadioPage() {
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) { if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/radio")}`); redirect(`/login?returnTo=${encodeURIComponent("/minigames/radio")}`);
} }
const profileRes = await payload.find({ const profileRes = await payload.find({

View file

@ -224,35 +224,35 @@ export const navigationCommands: NavigationCommand[] = [
id: "nav:qualification-course", id: "nav:qualification-course",
label: "Qualification Course", label: "Qualification Course",
icon: Crosshair, icon: Crosshair,
href: "/qualification", href: "/minigames/qualification",
keywords: ["aim trainer", "minigame", "shooting", "practice"], keywords: ["aim trainer", "minigame", "shooting", "practice"],
}, },
{ {
id: "nav:radio-traffic", id: "nav:radio-traffic",
label: "Radio Traffic", label: "Radio Traffic",
icon: Radio, icon: Radio,
href: "/radio", href: "/minigames/radio",
keywords: ["minigame", "comms", "messages", "training"], keywords: ["minigame", "comms", "messages", "training"],
}, },
{ {
id: "nav:flight-simulator", id: "nav:flight-simulator",
label: "Flight Simulator", label: "Flight Simulator",
icon: PlaneTakeoff, icon: PlaneTakeoff,
href: "/flappy", href: "/minigames/flappy",
keywords: ["minigame", "flappy", "fly", "training"], keywords: ["minigame", "flappy", "fly", "training"],
}, },
{ {
id: "nav:minefield-clearance", id: "nav:minefield-clearance",
label: "Minefield Clearance", label: "Minefield Clearance",
icon: Bomb, icon: Bomb,
href: "/eod", href: "/minigames/eod",
keywords: ["minigame", "eod", "mines", "training"], keywords: ["minigame", "eod", "mines", "training"],
}, },
{ {
id: "nav:opord-recall", id: "nav:opord-recall",
label: "OPORD Recall", label: "OPORD Recall",
icon: ScrollText, icon: ScrollText,
href: "/opord", href: "/minigames/opord",
keywords: ["minigame", "briefing", "memory", "operations order", "training"], keywords: ["minigame", "briefing", "memory", "operations order", "training"],
}, },
{ {

View file

@ -144,31 +144,31 @@ export function NavUser({
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent> <DropdownMenuSubContent>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/flappy"> <Link href="/minigames/flappy">
<Bird /> <Bird />
Flight Simulator Flight Simulator
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/eod"> <Link href="/minigames/eod">
<Bomb /> <Bomb />
Minefield Clearance Minefield Clearance
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/radio"> <Link href="/minigames/radio">
<Radio /> <Radio />
Radio Traffic Radio Traffic
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/qualification"> <Link href="/minigames/qualification">
<Crosshair /> <Crosshair />
Qualification Course Qualification Course
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/opord"> <Link href="/minigames/opord">
<FileTextIcon /> <FileTextIcon />
OPORD Recall OPORD Recall
</Link> </Link>

View file

@ -0,0 +1,51 @@
import Link from "next/link";
import { ExternalLink, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/**
* Fully-featured nav button for a minigame's custom (UGC) area: an eyebrow
* status line, a title, and a pitch subtitle. Neutral outline styling, and the
* eyebrow flips between the unlocked celebration and the locked state.
*/
export function UgcLinkButton({
href,
title,
subtitle,
unlocked,
className,
}: {
href: string;
title: string;
subtitle: string;
unlocked: boolean;
className?: string;
}) {
return (
<Button
asChild
variant="outline"
className={cn("relative h-auto w-full gap-4 whitespace-normal px-4 py-4 text-left", className)}
>
<Link href={href}>
<span
aria-hidden
className="pointer-events-none absolute inset-0 animate-pulse rounded-md border-2 border-white/60"
/>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<Wrench className="size-5" />
</span>
<span className="flex min-w-0 flex-col items-start gap-1">
<span className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
{unlocked ? "You've unlocked" : "Locked"}
</span>
<span className="text-sm font-semibold text-foreground">{title}</span>
<span className="text-xs font-normal leading-relaxed text-muted-foreground">
{subtitle}
</span>
</span>
<ExternalLink className="ml-auto shrink-0 text-muted-foreground" />
</Link>
</Button>
);
}

View file

@ -16,7 +16,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createMinefield } from "@/app/(frontend)/eod/custom/actions"; import { createMinefield } from "@/app/(frontend)/minigames/eod/custom/actions";
const MIN_SIZE = 9; const MIN_SIZE = 9;
const MAX_SIZE = 500; const MAX_SIZE = 500;

View file

@ -127,7 +127,7 @@ export function CustomMinefieldBrowser({
</span> </span>
</div> </div>
<Button asChild size="sm"> <Button asChild size="sm">
<Link href={`/eod/custom/${row.id}`}> <Link href={`/minigames/eod/custom/${row.id}`}>
<Play /> Play <Play /> Play
</Link> </Link>
</Button> </Button>

View file

@ -3,9 +3,9 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react"; import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Bomb, CircleHelp, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react"; import { Bomb, CircleHelp, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { submitCustomMinefieldScore } from "@/app/(frontend)/eod/custom/actions"; import { submitCustomMinefieldScore } from "@/app/(frontend)/minigames/eod/custom/actions";
import { useSessionExpired } from "@/hooks/useSessionExpired"; import { useSessionExpired } from "@/hooks/useSessionExpired";
import { edgeMineIndices } from "@/lib/custom-minigames/eod"; import { edgeMineIndices } from "@/lib/minigames/custom/eod";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { playClear, playExplosion, playFlag, playReveal } from "./sounds"; import { playClear, playExplosion, playFlag, playReveal } from "./sounds";

View file

@ -1,13 +1,12 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import type { EodDifficulty, EodStats } from "@/app/(frontend)/minigames/eod/actions";
import { Bomb } from "lucide-react"; import { isEodUnlocked } from "@/lib/minigames/custom/eod";
import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions";
import { isEodUnlocked } from "@/lib/custom-minigames/eod";
import { useRealtimePresence } from "@/hooks/useRealtimePresence"; import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { UnlockNotice } from "@/components/frontend/minigames/UnlockNotice"; import { UnlockNotice } from "@/components/frontend/minigames/UnlockNotice";
import { UgcLinkButton } from "@/components/frontend/minigames/UgcLinkButton";
import { EodLeaderboard } from "./EodLeaderboard"; import { EodLeaderboard } from "./EodLeaderboard";
import { MinesweeperBoard } from "./MinesweeperBoard"; import { MinesweeperBoard } from "./MinesweeperBoard";
@ -44,13 +43,13 @@ export function EodArena({
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<Link <UgcLinkButton
href="/eod/custom" href="/minigames/eod/custom"
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-sm text-amber-300 transition-colors hover:bg-amber-500/20" title="UGC Minefields"
> subtitle="Play, compete for, and create custom minefields you can share with other unit members! You can even earn XP for playing maps, and as an author, every payout on your map nets you some XP, too."
<Bomb className="size-4" /> unlocked={customUnlocked}
Custom minefields className="max-w-5xl"
</Link> />
{!customUnlocked && ( {!customUnlocked && (
<UnlockNotice <UnlockNotice
title="custom minefields" title="custom minefields"

View file

@ -10,7 +10,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { EodDifficulty } from "@/app/(frontend)/eod/actions"; import type { EodDifficulty } from "@/app/(frontend)/minigames/eod/actions";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
interface ApiUser { interface ApiUser {

View file

@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { updateMinefield } from "@/app/(frontend)/eod/custom/actions"; import { updateMinefield } from "@/app/(frontend)/minigames/eod/custom/actions";
export function ExposureRuleToggle({ export function ExposureRuleToggle({
minefieldId, minefieldId,

View file

@ -15,7 +15,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions"; import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/minigames/eod/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick"; import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired"; import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink"; import { LoginLink } from "@/components/frontend/auth/LoginLink";
@ -662,7 +662,7 @@ export function MinesweeperBoard({
phase === "playing" && "pointer-events-none opacity-40", phase === "playing" && "pointer-events-none opacity-40",
)} )}
> >
<div className="grid gap-2 md:grid-cols-3"> <div className={cn("grid gap-2", customUnlocked ? "md:grid-cols-4" : "md:grid-cols-3")}>
{DIFFICULTY_KEYS.map((key) => { {DIFFICULTY_KEYS.map((key) => {
const option = DIFFICULTIES[key]; const option = DIFFICULTIES[key];
return ( return (
@ -684,8 +684,26 @@ export function MinesweeperBoard({
</Button> </Button>
); );
})} })}
{customUnlocked && (
<Button
key="custom"
type="button"
variant={difficulty === "custom" ? "default" : "outline"}
className="h-auto justify-start px-3 py-2 text-left"
disabled={submitting || expired}
aria-pressed={difficulty === "custom"}
onClick={() => reset("custom")}
>
<span className="flex flex-col items-start gap-0.5">
<span>Custom</span>
<span className="text-[10px] font-normal opacity-75">
{customConfig.rows}×{customConfig.cols} grid · {customConfig.mines} charges
</span>
</span>
</Button>
)}
</div> </div>
{customUnlocked ? ( {customUnlocked && difficulty === "custom" ? (
<div className="rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3"> <div className="rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3">
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -792,11 +810,11 @@ export function MinesweeperBoard({
{CUSTOM_MAX_SIZE}×{CUSTOM_MAX_SIZE} field. {CUSTOM_MAX_SIZE}×{CUSTOM_MAX_SIZE} field.
</p> </p>
</div> </div>
) : ( ) : !customUnlocked ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Clear all three standard locations to unlock custom minefields. Clear all three standard locations to unlock custom minefields.
</p> </p>
)} ) : null}
</fieldset> </fieldset>
<div <div

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import type { FlappyStats } from "@/app/(frontend)/flappy/actions"; import type { FlappyStats } from "@/app/(frontend)/minigames/flappy/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence"; import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { FlappyBird } from "./FlappyBird"; import { FlappyBird } from "./FlappyBird";
import { FlappyLeaderboard } from "./FlappyLeaderboard"; import { FlappyLeaderboard } from "./FlappyLeaderboard";

View file

@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions"; import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/minigames/flappy/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick"; import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired"; import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink"; import { LoginLink } from "@/components/frontend/auth/LoginLink";

View file

@ -2,8 +2,8 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { OPORD_DIFFICULTIES, type OpordDifficulty } from "@/lib/opord-recall"; import { OPORD_DIFFICULTIES, type OpordDifficulty } from "@/lib/minigames/opord-recall";
import type { OpordStats } from "@/app/(frontend)/opord/actions"; import type { OpordStats } from "@/app/(frontend)/minigames/opord/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence"; import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

View file

@ -15,9 +15,9 @@ import {
type OpordDifficulty, type OpordDifficulty,
type OpordQuestion, type OpordQuestion,
type OpordScenario, type OpordScenario,
} from "@/lib/opord-recall"; } from "@/lib/minigames/opord-recall";
import type { OpordStats } from "@/app/(frontend)/opord/actions"; import type { OpordStats } from "@/app/(frontend)/minigames/opord/actions";
import { submitOpordRecallResult } from "@/app/(frontend)/opord/actions"; import { submitOpordRecallResult } from "@/app/(frontend)/minigames/opord/actions";
import { useSessionExpired } from "@/hooks/useSessionExpired"; import { useSessionExpired } from "@/hooks/useSessionExpired";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick"; import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";

View file

@ -3,7 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Trophy } from "lucide-react"; import { Trophy } from "lucide-react";
import type { OpordDifficulty } from "@/lib/opord-recall"; import type { OpordDifficulty } from "@/lib/minigames/opord-recall";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; import type { PresenceParticipant } from "@/hooks/useRealtimePresence";

View file

@ -1,4 +1,4 @@
import type { OpordDifficulty } from "@/lib/opord-recall"; import type { OpordDifficulty } from "@/lib/minigames/opord-recall";
export type OpordLeaderboardFields = { export type OpordLeaderboardFields = {
readonly label: string; readonly label: string;

View file

@ -6,8 +6,8 @@ import {
AIM_TRAINER_DIFFICULTIES, AIM_TRAINER_DIFFICULTIES,
isAimTrainerDifficulty, isAimTrainerDifficulty,
type AimTrainerDifficulty, type AimTrainerDifficulty,
} from "@/lib/aim-trainer"; } from "@/lib/minigames/aim-trainer";
import type { AimTrainerStats } from "@/app/(frontend)/qualification/actions"; import type { AimTrainerStats } from "@/app/(frontend)/minigames/qualification/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence"; import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

View file

@ -3,7 +3,7 @@ import {
calculateAimTrainerScore, calculateAimTrainerScore,
COURSE_DURATION_MS, COURSE_DURATION_MS,
type AimTrainerTarget, type AimTrainerTarget,
} from "@/lib/aim-trainer"; } from "@/lib/minigames/aim-trainer";
export const AIM_TRAINER_CANVAS_WIDTH = 720; export const AIM_TRAINER_CANVAS_WIDTH = 720;
export const AIM_TRAINER_CANVAS_HEIGHT = 480; export const AIM_TRAINER_CANVAS_HEIGHT = 480;

View file

@ -19,12 +19,12 @@ import {
stepAimTrainerTargets, stepAimTrainerTargets,
TORSO_POINTS, TORSO_POINTS,
type AimTrainerDifficulty, type AimTrainerDifficulty,
} from "@/lib/aim-trainer"; } from "@/lib/minigames/aim-trainer";
import { import {
type AimTrainerResultInput, type AimTrainerResultInput,
type AimTrainerStats, type AimTrainerStats,
submitAimTrainerResult, submitAimTrainerResult,
} from "@/app/(frontend)/qualification/actions"; } from "@/app/(frontend)/minigames/qualification/actions";
import { LoginLink } from "@/components/frontend/auth/LoginLink"; import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";

View file

@ -3,7 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Trophy } from "lucide-react"; import { Trophy } from "lucide-react";
import type { AimTrainerDifficulty } from "@/lib/aim-trainer"; import type { AimTrainerDifficulty } from "@/lib/minigames/aim-trainer";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; import type { PresenceParticipant } from "@/hooks/useRealtimePresence";

View file

@ -1,4 +1,4 @@
import type { AimTrainerDifficulty } from "@/lib/aim-trainer"; import type { AimTrainerDifficulty } from "@/lib/minigames/aim-trainer";
export type AimTrainerLeaderboardFields = { export type AimTrainerLeaderboardFields = {
readonly label: string; readonly label: string;

View file

@ -21,7 +21,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { createTest } from "@/app/(frontend)/radio/custom/actions"; import { createTest } from "@/app/(frontend)/minigames/radio/custom/actions";
const MAX_MESSAGES = 100; const MAX_MESSAGES = 100;

View file

@ -123,7 +123,7 @@ export function CustomTestBrowser({
</span> </span>
</div> </div>
<Button asChild size="sm"> <Button asChild size="sm">
<Link href={`/radio/custom/${row.id}`}> <Link href={`/minigames/radio/custom/${row.id}`}>
<Play /> Play <Play /> Play
</Link> </Link>
</Button> </Button>

View file

@ -1,16 +1,15 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import type { RadioStats } from "@/app/(frontend)/minigames/radio/actions";
import { Radio } from "lucide-react";
import type { RadioStats } from "@/app/(frontend)/radio/actions";
import { import {
isRadioUnlocked, isRadioUnlocked,
SPRINT_ACCURACY_REQUIREMENT, SPRINT_ACCURACY_REQUIREMENT,
WATCH_ACCURACY_REQUIREMENT, WATCH_ACCURACY_REQUIREMENT,
} from "@/lib/custom-minigames/radio"; } from "@/lib/minigames/custom/radio";
import { useRealtimePresence } from "@/hooks/useRealtimePresence"; import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { UnlockNotice } from "@/components/frontend/minigames/UnlockNotice"; import { UnlockNotice } from "@/components/frontend/minigames/UnlockNotice";
import { UgcLinkButton } from "@/components/frontend/minigames/UgcLinkButton";
import { RadioLeaderboard } from "./RadioLeaderboard"; import { RadioLeaderboard } from "./RadioLeaderboard";
import { RadioTraffic } from "./RadioTraffic"; import { RadioTraffic } from "./RadioTraffic";
@ -49,13 +48,13 @@ export function RadioArena({
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<Link <UgcLinkButton
href="/radio/custom" href="/minigames/radio/custom"
className="flex items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-1.5 text-sm text-emerald-300 transition-colors hover:bg-emerald-500/20" title="UGC Radio Tests"
> subtitle="Play and create custom radio tests you can share with other unit members! Runs are unranked, so it is a great place to practice before an official transmission."
<Radio className="size-4" /> unlocked={customUnlocked}
Custom radio tests className="max-w-[520px]"
</Link> />
{!customUnlocked && ( {!customUnlocked && (
<UnlockNotice <UnlockNotice
title="custom radio tests" title="custom radio tests"

View file

@ -5,8 +5,8 @@ import type { FormEvent, KeyboardEvent } from "react";
import { Check, Clock3, Radio, Target, Volume2 } from "lucide-react"; import { Check, Clock3, Radio, Target, Volume2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { type RadioStats, submitRadioResult } from "@/app/(frontend)/radio/actions"; import { type RadioStats, submitRadioResult } from "@/app/(frontend)/minigames/radio/actions";
import { submitCustomTestScore } from "@/app/(frontend)/radio/custom/actions"; import { submitCustomTestScore } from "@/app/(frontend)/minigames/radio/custom/actions";
import { LoginLink } from "@/components/frontend/auth/LoginLink"; import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";

View file

@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"e5c04ca02ae11ff5ff00-7644b2c9657ee2dfb45d"
]
}

View file

@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { formatOpordGrid, generateOpordScenario } from "../../src/lib/opord-recall"; import { formatOpordGrid, generateOpordScenario } from "../../src/lib/minigames/opord-recall";
const FIXED_SEED = 424242; const FIXED_SEED = 424242;
const QUIZ_OPTION_BUTTON = "button.font-mono"; const QUIZ_OPTION_BUTTON = "button.font-mono";
@ -14,7 +14,7 @@ async function login(page: Page): Promise<void> {
} }
async function playOpordRound(page: Page): Promise<void> { async function playOpordRound(page: Page): Promise<void> {
await page.goto(`/opord?seed=${FIXED_SEED}`); await page.goto(`/minigames/opord?seed=${FIXED_SEED}`);
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible(); await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
await page.getByRole("button", { name: "Receive briefing" }).click(); await page.getByRole("button", { name: "Receive briefing" }).click();

View file

@ -29,7 +29,7 @@ import {
resolveAimTrainerTargetHit, resolveAimTrainerTargetHit,
stepAimTrainerTarget, stepAimTrainerTarget,
stepAimTrainerTargets, stepAimTrainerTargets,
} from "@/lib/aim-trainer"; } from "@/lib/minigames/aim-trainer";
/** Deterministic LCG-style RNG so target creation is reproducible across runs. */ /** Deterministic LCG-style RNG so target creation is reproducible across runs. */
function seededRng(seed: number): () => number { function seededRng(seed: number): () => number {

View file

@ -17,8 +17,8 @@ import {
validateMinefieldName, validateMinefieldName,
validateMinefieldScore, validateMinefieldScore,
validateMinePositions, validateMinePositions,
} from "@/lib/custom-minigames/eod"; } from "@/lib/minigames/custom/eod";
import { creditEodXp } from "@/lib/custom-minigames/payouts"; import { creditEodXp } from "@/lib/minigames/custom/payouts";
let payload: Payload; let payload: Payload;
const RUN = `cmf-${Date.now().toString(36)}`; const RUN = `cmf-${Date.now().toString(36)}`;

View file

@ -11,7 +11,7 @@ import {
validateMessages, validateMessages,
validateRadioScore, validateRadioScore,
validateTestName, validateTestName,
} from "@/lib/custom-minigames/radio"; } from "@/lib/minigames/custom/radio";
let payload: Payload; let payload: Payload;
const RUN = `crt-${Date.now().toString(36)}`; const RUN = `crt-${Date.now().toString(36)}`;