diff --git a/app/games/[gameId]/result.tsx b/app/games/[gameId]/result.tsx index 0dca204..9e70015 100644 --- a/app/games/[gameId]/result.tsx +++ b/app/games/[gameId]/result.tsx @@ -3,7 +3,7 @@ import { Doc } from "@/convex/_generated/dataModel"; import { api } from "@/convex/_generated/api"; import { useQuery } from "convex/react"; -import { ResultStatus } from "@/components/ResultStatus"; +import { MapStatus } from "@/components/MapStatus"; import { Visualizer } from "@/components/Visualizer"; import Link from "next/link"; @@ -38,7 +38,7 @@ export const Result = ({ result }: { result: Doc<"results"> }) => { )}
- + {result.reasoning !== "" &&

{result.reasoning}

}
diff --git a/app/header.tsx b/app/header.tsx index e8ec595..684f951 100644 --- a/app/header.tsx +++ b/app/header.tsx @@ -45,6 +45,9 @@ export default function Header() { + + + {isAuthenticated && ( @@ -56,14 +59,9 @@ export default function Header() { )} {flags?.showTestPage && ( - <> - - - - - - - + + + )} diff --git a/app/play/[level]/test-mode.tsx b/app/play/[level]/test-mode.tsx index 3882e98..6ce534d 100644 --- a/app/play/[level]/test-mode.tsx +++ b/app/play/[level]/test-mode.tsx @@ -13,6 +13,7 @@ import { SelectValue, } from "@/components/ui/select"; import { AI_MODELS } from "@/convex/constants"; +import { errorMessage } from "@/lib/utils"; interface TestModeProps { level: number; @@ -51,9 +52,7 @@ export default function TestMode({ level, map }: TestModeProps) { setAiReasoning(result.reasoning); } catch (error) { console.error("Error testing AI model:", error); - setAiError( - error instanceof Error ? error.message : "An unexpected error occurred", - ); + setAiError(errorMessage(error)); } finally { setIsSimulating(false); } diff --git a/app/play/page.tsx b/app/play/page.tsx index f384264..b1f1fa5 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -14,6 +14,9 @@ import { } from "@/components/ui/card"; import { useEffect, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; +import { StarFilledIcon } from "@radix-ui/react-icons"; export default function PlayPage() { const maps = useQuery(api.maps.getMaps, {}); @@ -22,6 +25,7 @@ export default function PlayPage() { const [resMap, setResMap] = useState(new Map()); const [countMap, setCountMap] = useState(new Map()); + const [filter, setFilter] = useState("all"); useEffect(() => { if (userMapResults && mapCountResults) { @@ -47,11 +51,32 @@ export default function PlayPage() { } }, [userMapResults, mapCountResults]); - if (!maps) { + const filteredMaps = maps?.filter((map) => { + if (filter === "all") return true; + if (filter === "beaten") return resMap.get(map._id); + if (filter === "unbeaten") return !resMap.get(map._id); + return true; + }); + + if (!filteredMaps) { return (

Choose a Night

+ + setFilter(value)} + > + All + Beaten + Unbeaten + + +
{Array.from({ length: 6 }).map((_, index) => ( @@ -65,9 +90,37 @@ export default function PlayPage() {

Choose a Night

+ + setFilter(value)} + > + All + Beaten + Unbeaten + + +
- {maps.map((map) => ( + {filteredMaps.map((map) => ( + + {resMap.get(map._id) && ( + + )} Night #{map.level} @@ -92,7 +145,7 @@ export default function PlayPage() { )}
Won by {countMap.has(map._id) ? countMap.get(map._id) : 0}{" "} - Players + Player{countMap.get(map._id) !== 1 ? "s" : ""}
@@ -100,7 +153,7 @@ export default function PlayPage() {
Won by {countMap.has(map._id) ? countMap.get(map._id) : 0}{" "} - Players + Player{countMap.get(map._id) !== 1 ? "s" : ""}
diff --git a/app/playground/page.tsx b/app/playground/page.tsx index 7ad0413..4e709ea 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -1,23 +1,63 @@ "use client"; import React from "react"; -import { useAction } from "convex/react"; +import { CircleAlertIcon } from "lucide-react"; +import { useAction, useMutation, useQuery } from "convex/react"; import { Button } from "@/components/ui/button"; import { CopyMapButton } from "@/components/CopyMapButton"; import { MapBuilder } from "@/components/MapBuilder"; +import { MapStatus } from "@/components/MapStatus"; import { ModelSelector } from "@/components/ModelSelector"; import { Visualizer } from "@/components/Visualizer"; import { ZombieSurvival } from "@/simulators/zombie-survival"; import { api } from "@/convex/_generated/api"; +import { errorMessage } from "@/lib/utils"; +import { useToast } from "@/components/ui/use-toast"; export default function PlaygroundPage() { + const isAdmin = useQuery(api.users.isAdmin); + const publishMap = useMutation(api.maps.publishMap); const testMap = useAction(api.maps.testMap); + const { toast } = useToast(); const [map, setMap] = React.useState([]); const [model, setModel] = React.useState(""); const [error, setError] = React.useState(null); const [solution, setSolution] = React.useState(null); const [reasoning, setReasoning] = React.useState(null); + const [publishing, setPublishing] = React.useState(false); const [simulating, setSimulating] = React.useState(false); + const [userPlaying, setUserPlaying] = React.useState(false); + const [userSolution, setUserSolution] = React.useState([]); + const [visualizingUserSolution, setVisualizingUserSolution] = + React.useState(false); + + async function handlePublish() { + if (!ZombieSurvival.mapHasZombies(map)) { + alert("Add some zombies to the map first"); + return; + } + + if (!confirm("Are you sure?")) { + return; + } + + setPublishing(true); + + try { + await publishMap({ map }); + + toast({ + description: "Map published successfully!", + }); + } catch (error) { + toast({ + description: errorMessage(error), + variant: "destructive", + }); + } finally { + setPublishing(false); + } + } async function handleSimulate() { setError(null); @@ -59,62 +99,145 @@ export default function PlaygroundPage() { function handleEdit() { setSolution(null); setReasoning(null); + setUserPlaying(false); + setVisualizingUserSolution(false); + } + + function handleUserPlay() { + if (!ZombieSurvival.mapHasZombies(map)) { + alert("Add player to the map first"); + return; + } + + setUserPlaying(true); + setUserSolution(map); + } + + function handleVisualize() { + if (!ZombieSurvival.mapHasPlayer(userSolution)) { + alert("Add player to the map first"); + return; + } + + if (ZombieSurvival.mapHasMultiplePlayers(userSolution)) { + alert("Map has multiple players"); + return; + } + + setVisualizingUserSolution(true); + } + + function handleStopVisualization() { + setVisualizingUserSolution(false); } + const visualizing = solution !== null || visualizingUserSolution; + return (
-

Playground

+

Playground

-
-
- {solution !== null && ( -
- -
- )} - {solution === null && ( -
-
+
+
+
+
+

Map ({map.length}x{map[0]?.length ?? 0})

-

* Click on a cell to see magic

-
-
- +
+

* Click on a cell to place entity

- )} -
-
-

Model (~$0.002)

- + {isAdmin && ( + )} +
+
+ {visualizing && ( + + )} + {!visualizing && ( + + )} +
+
+
+
+ {isAdmin && ( + <> +

Model (~$0.002)

+ + {!userPlaying && solution === null && ( + + )} + {(solution !== null || userPlaying) && ( + + )} + + )} + {solution === null && !simulating && !userPlaying && ( + + )} + {userPlaying && ( + - -
- {error !== null &&

{error}

} - {reasoning !== null &&

{reasoning}

} + )}
+ {error !== null &&

{error}

} + {visualizingUserSolution && } + {reasoning !== null && ( +
+ +

{reasoning}

+
+ )}
diff --git a/app/provider.tsx b/app/provider.tsx index 1bf1a56..3d19e55 100644 --- a/app/provider.tsx +++ b/app/provider.tsx @@ -4,6 +4,7 @@ import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs"; import { ConvexReactClient } from "convex/react"; import { ThemeProvider } from "next-themes"; import PlausibleProvider from "next-plausible"; +import { Toaster } from "@/components/ui/toaster"; const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); @@ -17,6 +18,7 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} + diff --git a/app/result.tsx b/app/result.tsx index a1dab32..714f1f4 100644 --- a/app/result.tsx +++ b/app/result.tsx @@ -1,6 +1,6 @@ "use client"; -import { ResultStatus } from "@/components/ResultStatus"; +import { MapStatus } from "@/components/MapStatus"; import { type ResultWithGame } from "@/convex/results"; import { Visualizer } from "@/components/Visualizer"; import { format } from "date-fns"; @@ -51,7 +51,7 @@ export default function Result({ result }: { result: ResultWithGame }) { {result.status === "inProgress" ? ( "Started" ) : ( - + )}{" "} at {format(new Date(result._creationTime), "h:mma")}
diff --git a/components/CopyMapButton.tsx b/components/CopyMapButton.tsx index b59ca1e..fc44c40 100644 --- a/components/CopyMapButton.tsx +++ b/components/CopyMapButton.tsx @@ -1,16 +1,41 @@ "use client"; import React from "react"; -import { Button } from "./ui/button"; +import { ClipboardCopyIcon, SmileIcon } from "lucide-react"; export function CopyMapButton({ map }: { map: string[][] }) { + const timeout = React.useRef | null>(null); + const [copied, setCopied] = React.useState(false); + async function handleClick() { await navigator.clipboard.writeText(JSON.stringify(map)); + setCopied(true); + + timeout.current = setTimeout(() => { + timeout.current = null; + setCopied(false); + }, 2000); } + React.useEffect(() => { + return () => { + if (timeout.current !== null) { + clearTimeout(timeout.current); + } + }; + }, []); + return ( - + ); } diff --git a/components/MapBuilder.tsx b/components/MapBuilder.tsx index e98cf30..b07826d 100644 --- a/components/MapBuilder.tsx +++ b/components/MapBuilder.tsx @@ -5,28 +5,42 @@ import { ChevronRightIcon, ChevronUpIcon, } from "lucide-react"; +import { ZombieSurvival } from "@/simulators/zombie-survival"; import { cn } from "@/lib/utils"; export function MapBuilder({ disabled, onChange, + play, value, }: { disabled?: boolean; onChange: (value: string[][]) => unknown; + play?: boolean; value: string[][]; }) { - const map = value.length === 0 || value[0].length === 0 ? [[" "]] : value; + const map = ZombieSurvival.mapIsEmpty(value) ? [[" "]] : value; React.useEffect(() => { - if (value.length === 0 || value[0].length === 0) { + if (ZombieSurvival.mapIsEmpty(value)) { onChange([[" "]]); } }, [value]); - function handleClickCell(row: number, cell: number, initialValue: string) { - const newValue = - initialValue === " " ? "R" : initialValue === "R" ? "Z" : " "; + function handleClickCell(row: number, cell: number) { + const initialValue = map[row][cell]; + let newValue; + + if (play) { + if (initialValue === "R" || initialValue === "Z") { + alert("You can't replace zombie or rock in play mode"); + return; + } + + newValue = initialValue === " " ? "P" : initialValue === "P" ? "B" : " "; + } else { + newValue = initialValue === " " ? "Z" : initialValue === "Z" ? "R" : " "; + } const newMap = [...map.map((row) => [...row])]; newMap[row][cell] = newValue; @@ -68,88 +82,103 @@ export function MapBuilder({ const moreThanOneRow = map.length > 1; const moreThanOneCell = map[0].length > 1; - const buttonClassName = cn("border border-white w-8 h-8 disabled:opacity-50"); + const buttonClassName = cn( + "border border-white w-[64px] h-[64px] disabled:opacity-50", + ); const controlClassName = cn( - "h-8 absolute hover:scale-125 transition disabled:opacity-50", + "h-8 absolute enabled:hover:scale-125 transition disabled:opacity-50", ); return (
- - {moreThanOneRow && ( - - )} - - {moreThanOneCell && ( - - )} - - {moreThanOneRow && ( - - )} - - {moreThanOneCell && ( - + {!play && ( + <> + + {moreThanOneRow && ( + + )} + + {moreThanOneCell && ( + + )} + + {moreThanOneRow && ( + + )} + + {moreThanOneCell && ( + + )} + )} {map.map((row, rowIdx) => (
@@ -158,7 +187,7 @@ export function MapBuilder({ className={buttonClassName} disabled={disabled === true} key={`${rowIdx}.${cellIdx}`} - onClick={() => handleClickCell(rowIdx, cellIdx, cell)} + onClick={() => handleClickCell(rowIdx, cellIdx)} type="button" > {cell} diff --git a/components/MapStatus.tsx b/components/MapStatus.tsx new file mode 100644 index 0000000..2ca1391 --- /dev/null +++ b/components/MapStatus.tsx @@ -0,0 +1,11 @@ +import { ZombieSurvival } from "@/simulators/zombie-survival"; + +export function MapStatus({ map }: { map: string[][] }) { + const isWin = ZombieSurvival.isWin(map); + + return ( +
+ {isWin ? "WON" : "LOST"} +
+ ); +} diff --git a/components/ResultStatus.tsx b/components/ResultStatus.tsx deleted file mode 100644 index 3053d85..0000000 --- a/components/ResultStatus.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Doc } from "@/convex/_generated/dataModel"; - -export function ResultStatus({ result }: { result: Doc<"results"> }) { - return ( -
- {result.isWin ? "WON" : "LOST"} -
- ); -} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index dee8ecd..ea3b0f6 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -13,8 +13,15 @@ export function ThemeToggle() { type="single" size="sm" value={theme} - onValueChange={(e) => setTheme(e)} - className={`${"flex px-1 py-1 rounded-md"} ${theme === "light" || (theme === "system" && darkMode === "light") ? "bg-blue-200" : "bg-slate-700"}`} + onValueChange={(newTheme) => { + // This check is needed because if the user clicks on a button twice the button gets unselected and the newTheme is undefined + if (newTheme) { + setTheme(newTheme); + } else { + console.log("No theme selected, keeping current theme:", theme); + } + }} + className="flex px-1 py-1 rounded-md bg-blue-200 dark:bg-slate-700" > {theme === "light" || (theme === "system" && darkMode === "light") ? ( diff --git a/convex/maps.ts b/convex/maps.ts index e1c250c..fdccccc 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -148,6 +148,39 @@ export const addMap = mutation({ }, }); +export const publishMap = mutation({ + args: { + map: v.array(v.array(v.string())), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + + if (!userId) { + throw new Error("User not authenticated"); + } + + const isAdmin = await ctx.runQuery(api.users.isAdmin); + + if (!isAdmin) { + throw new Error("Publishing maps is available only for admins"); + } + + const maps = await ctx.db + .query("maps") + .filter((q) => q.neq("level", undefined)) + .collect(); + + const lastLevel = maps.sort((a, b) => b.level! - a.level!)[120].level!; + + await ctx.db.insert("maps", { + grid: args.map, + level: lastLevel + 1, + submittedBy: userId, + isReviewed: true, + }); + }, +}); + export const seedMaps = internalMutation({ handler: async (ctx) => { const maps = await ctx.db.query("maps").collect(); @@ -171,22 +204,6 @@ export const seedMaps = internalMutation({ }, }); -export const testMap = action({ - args: { - modelId: v.string(), - map: v.array(v.array(v.string())), - }, - handler: async (ctx, args) => { - const flags = await ctx.runQuery(api.flags.getFlags); - - if (!flags.showTestPage) { - throw new Error("Test page is not enabled"); - } - - return await runModel(args.modelId, args.map); - }, -}); - export const getMaps = query({ args: { isReviewed: v.optional(v.boolean()), @@ -321,6 +338,22 @@ export const playMapAction = internalAction({ }, }); +export const testMap = action({ + args: { + modelId: v.string(), + map: v.array(v.array(v.string())), + }, + handler: async (ctx, args) => { + const isAdmin = await ctx.runQuery(api.users.isAdmin); + + if (!isAdmin) { + throw new Error("Test map is available only for admin"); + } + + return await runModel(args.modelId, args.map); + }, +}); + export const testAIModel = action({ args: { level: v.number(), diff --git a/lib/utils.ts b/lib/utils.ts index 365058c..5a7b05a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function errorMessage(error: unknown) { + return error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "An unexpected error occurred"; +} diff --git a/models/index.ts b/models/index.ts index 50853fa..d71881c 100644 --- a/models/index.ts +++ b/models/index.ts @@ -2,6 +2,7 @@ import { gemini15pro } from "./gemini-1.5-pro"; import { gpt4o } from "./gpt-4o"; import { claude35sonnet } from "./claude-3-5-sonnet"; import { perplexityModel } from "./perplexity-llama"; +import { errorMessage } from "../lib/utils"; export type ModelHandler = ( prompt: string, @@ -141,12 +142,7 @@ export async function runModel( } catch (error) { return { reasoning: "Internal error", - error: - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error", + error: errorMessage(error), }; } } diff --git a/simulators/zombie-survival/ZombieSurvival.ts b/simulators/zombie-survival/ZombieSurvival.ts index df4a1d2..20d03b3 100644 --- a/simulators/zombie-survival/ZombieSurvival.ts +++ b/simulators/zombie-survival/ZombieSurvival.ts @@ -19,6 +19,10 @@ export class ZombieSurvival { } public static isWin(config: string[][]): boolean { + if (ZombieSurvival.mapIsEmpty(config)) { + return false; + } + const game = new ZombieSurvival(config); while (!game.finished()) { @@ -28,12 +32,26 @@ export class ZombieSurvival { return !game.getPlayer().dead(); } + public static mapHasMultiplePlayers(map: string[][]): boolean { + return ( + map.map((row) => row.filter((cell) => cell === "P")).flat().length > 1 + ); + } + + public static mapHasPlayer(map: string[][]): boolean { + return map.some((row) => row.includes("P")); + } + public static mapHasZombies(map: string[][]): boolean { return map.some((row) => row.includes("Z")); } + public static mapIsEmpty(map: string[][]): boolean { + return map.length === 0 || map[0].length === 0; + } + public constructor(config: string[][]) { - if (config.length === 0 || config[0].length === 0) { + if (ZombieSurvival.mapIsEmpty(config)) { throw new Error("Config is empty"); }