From 4135debe5be133f07d59397dd4ccffa19e4b9b2c Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 14:20:10 +0300 Subject: [PATCH 01/12] Replace ResultStatus with MapStatus --- app/games/[gameId]/result.tsx | 4 ++-- app/result.tsx | 4 ++-- components/MapStatus.tsx | 11 +++++++++++ components/ResultStatus.tsx | 11 ----------- 4 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 components/MapStatus.tsx delete mode 100644 components/ResultStatus.tsx 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/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/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"} -
- ); -} From 8b9aa30240240be08d69578d63c5514678028bff Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 14:20:45 +0300 Subject: [PATCH 02/12] More static checks for ZombieSurvival --- simulators/zombie-survival/ZombieSurvival.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/simulators/zombie-survival/ZombieSurvival.ts b/simulators/zombie-survival/ZombieSurvival.ts index df4a1d2..9a2ef9f 100644 --- a/simulators/zombie-survival/ZombieSurvival.ts +++ b/simulators/zombie-survival/ZombieSurvival.ts @@ -28,10 +28,20 @@ export class ZombieSurvival { return !game.getPlayer().dead(); } + 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 mapMultiplePlayers(map: string[][]): boolean { + return ( + map.map((row) => row.filter((cell) => cell === "P")).flat().length > 1 + ); + } + public constructor(config: string[][]) { if (config.length === 0 || config[0].length === 0) { throw new Error("Config is empty"); From ee6e0976a66e608ddfa71872c62cb185b12f9416 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 14:21:04 +0300 Subject: [PATCH 03/12] Use icons inside CopyMapButton --- components/CopyMapButton.tsx | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) 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 ( - + ); } From 627a2e390b788f4cf7247f7f86b72a82a56f3e65 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 14:21:38 +0300 Subject: [PATCH 04/12] Add ability to place play entities in MapBuilder --- components/MapBuilder.tsx | 188 ++++++++++++++++++++++---------------- 1 file changed, 108 insertions(+), 80 deletions(-) diff --git a/components/MapBuilder.tsx b/components/MapBuilder.tsx index e98cf30..fec489f 100644 --- a/components/MapBuilder.tsx +++ b/components/MapBuilder.tsx @@ -10,10 +10,12 @@ 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; @@ -24,9 +26,20 @@ export function MapBuilder({ } }, [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 +81,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 +186,7 @@ export function MapBuilder({ className={buttonClassName} disabled={disabled === true} key={`${rowIdx}.${cellIdx}`} - onClick={() => handleClickCell(rowIdx, cellIdx, cell)} + onClick={() => handleClickCell(rowIdx, cellIdx)} type="button" > {cell} From 96dfa52e0882d240b56b297bbacd2d3ac9dffaca Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 14:22:04 +0300 Subject: [PATCH 05/12] Ability to manually play in Playground --- app/playground/page.tsx | 148 +++++++++++++++++++++++++++++----------- 1 file changed, 109 insertions(+), 39 deletions(-) diff --git a/app/playground/page.tsx b/app/playground/page.tsx index 7ad0413..eb7c2c6 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -5,6 +5,7 @@ import { useAction } 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"; @@ -18,6 +19,10 @@ export default function PlaygroundPage() { const [solution, setSolution] = React.useState(null); const [reasoning, setReasoning] = React.useState(null); 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 handleSimulate() { setError(null); @@ -59,62 +64,127 @@ 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.mapMultiplePlayers(userSolution)) { + alert("Map has multiple players"); + return; + } + + setVisualizingUserSolution(true); + } + + function handleStopVisualization() { + setVisualizingUserSolution(false); + } + + const visualizing = solution !== null || visualizingUserSolution; + return (
-

Playground

+

Playground

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

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

+ +
+

* Click on a cell to place entity

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

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

-

* Click on a cell to see magic

-
-
- -
-
- )} -
-
-

Model (~$0.002)

- + )} + {!visualizing && ( + + )} +
+
+
+
+

Model (~$0.002)

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

{error}

} - {reasoning !== null &&

{reasoning}

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

{error}

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

{reasoning}

+
+ )}
From c1a08af05b77ce2179e904fe518bccae91bc2523 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 14:29:08 +0300 Subject: [PATCH 06/12] Move testMap closer to testAIModel --- convex/maps.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/convex/maps.ts b/convex/maps.ts index e1c250c..ef61aff 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -171,22 +171,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 +305,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 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 testAIModel = action({ args: { level: v.number(), From 7615b97b33f49eb04fe1f4a017867966450627d6 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 14:35:40 +0300 Subject: [PATCH 07/12] Fix watch page errors --- app/playground/page.tsx | 2 +- components/MapBuilder.tsx | 5 +++-- simulators/zombie-survival/ZombieSurvival.ts | 18 +++++++++++++----- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/playground/page.tsx b/app/playground/page.tsx index eb7c2c6..34d6895 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -84,7 +84,7 @@ export default function PlaygroundPage() { return; } - if (ZombieSurvival.mapMultiplePlayers(userSolution)) { + if (ZombieSurvival.mapHasMultiplePlayers(userSolution)) { alert("Map has multiple players"); return; } diff --git a/components/MapBuilder.tsx b/components/MapBuilder.tsx index fec489f..b07826d 100644 --- a/components/MapBuilder.tsx +++ b/components/MapBuilder.tsx @@ -5,6 +5,7 @@ import { ChevronRightIcon, ChevronUpIcon, } from "lucide-react"; +import { ZombieSurvival } from "@/simulators/zombie-survival"; import { cn } from "@/lib/utils"; export function MapBuilder({ @@ -18,10 +19,10 @@ export function MapBuilder({ 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]); diff --git a/simulators/zombie-survival/ZombieSurvival.ts b/simulators/zombie-survival/ZombieSurvival.ts index 9a2ef9f..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,6 +32,12 @@ 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")); } @@ -36,14 +46,12 @@ export class ZombieSurvival { return map.some((row) => row.includes("Z")); } - public static mapMultiplePlayers(map: string[][]): boolean { - return ( - map.map((row) => row.filter((cell) => cell === "P")).flat().length > 1 - ); + 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"); } From 772c8b37bd7b8b4800c86bdbea3033611c1dc37c Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 14:43:02 +0300 Subject: [PATCH 08/12] Allow everyone to use playground --- app/header.tsx | 14 +++++------- app/playground/page.tsx | 49 +++++++++++++++++++++++------------------ convex/maps.ts | 6 ++--- 3 files changed, 36 insertions(+), 33 deletions(-) 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/playground/page.tsx b/app/playground/page.tsx index 34d6895..c69dac9 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { useAction } from "convex/react"; +import { useAction, useQuery } from "convex/react"; import { Button } from "@/components/ui/button"; import { CopyMapButton } from "@/components/CopyMapButton"; import { MapBuilder } from "@/components/MapBuilder"; @@ -13,6 +13,7 @@ import { api } from "@/convex/_generated/api"; export default function PlaygroundPage() { const testMap = useAction(api.maps.testMap); + const isAdmin = useQuery(api.users.isAdmin); const [map, setMap] = React.useState([]); const [model, setModel] = React.useState(""); const [error, setError] = React.useState(null); @@ -136,27 +137,31 @@ export default function PlaygroundPage() {
-

Model (~$0.002)

- - {!userPlaying && solution === null && ( - - )} - {(solution !== null || userPlaying) && ( - + {isAdmin && ( + <> +

Model (~$0.002)

+ + {!userPlaying && solution === null && ( + + )} + {(solution !== null || userPlaying) && ( + + )} + )} {solution === null && !simulating && !userPlaying && (
{ + 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(); From 972bb999c5b91cacf8e3b61444cc1ff6d5432402 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Fri, 18 Oct 2024 15:31:21 +0300 Subject: [PATCH 12/12] Show publish button only for admin --- app/playground/page.tsx | 22 ++++++++++++---------- models/index.ts | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/playground/page.tsx b/app/playground/page.tsx index 667c582..4e709ea 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -149,16 +149,18 @@ export default function PlaygroundPage() {

* Click on a cell to place entity

- + {isAdmin && ( + + )}