diff --git a/app/header.tsx b/app/header.tsx index 352742c..e8ec595 100644 --- a/app/header.tsx +++ b/app/header.tsx @@ -56,9 +56,14 @@ export default function Header() { )} {flags?.showTestPage && ( - - - + <> + + + + + + + )} diff --git a/app/playground/page.tsx b/app/playground/page.tsx new file mode 100644 index 0000000..7ad0413 --- /dev/null +++ b/app/playground/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import React from "react"; +import { useAction } from "convex/react"; +import { Button } from "@/components/ui/button"; +import { CopyMapButton } from "@/components/CopyMapButton"; +import { MapBuilder } from "@/components/MapBuilder"; +import { ModelSelector } from "@/components/ModelSelector"; +import { Visualizer } from "@/components/Visualizer"; +import { ZombieSurvival } from "@/simulators/zombie-survival"; +import { api } from "@/convex/_generated/api"; + +export default function PlaygroundPage() { + const testMap = useAction(api.maps.testMap); + 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 [simulating, setSimulating] = React.useState(false); + + async function handleSimulate() { + setError(null); + setSolution(null); + setReasoning(null); + + if (!ZombieSurvival.mapHasZombies(map)) { + alert("Add some zombies to the map first"); + return; + } + + setSimulating(true); + + const { error, solution, reasoning } = await testMap({ + modelId: model, + map: map, + }); + + if (typeof error !== "undefined") { + setError(error); + } else { + setSolution(solution!); + setReasoning(reasoning!); + } + + setSimulating(false); + } + + function handleChangeMap(value: string[][]) { + setMap(value); + setError(null); + } + + function handleChangeModel(value: string) { + setModel(value); + setError(null); + } + + function handleEdit() { + setSolution(null); + setReasoning(null); + } + + return ( +
+

Playground

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

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

+

* Click on a cell to see magic

+
+
+ +
+
+ )} +
+
+

Model (~$0.002)

+ + + +
+ {error !== null &&

{error}

} + {reasoning !== null &&

{reasoning}

} +
+
+
+
+ ); +} diff --git a/components/CopyMapButton.tsx b/components/CopyMapButton.tsx new file mode 100644 index 0000000..b59ca1e --- /dev/null +++ b/components/CopyMapButton.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +import { Button } from "./ui/button"; + +export function CopyMapButton({ map }: { map: string[][] }) { + async function handleClick() { + await navigator.clipboard.writeText(JSON.stringify(map)); + } + + return ( + + ); +} diff --git a/components/MapBuilder.tsx b/components/MapBuilder.tsx new file mode 100644 index 0000000..e98cf30 --- /dev/null +++ b/components/MapBuilder.tsx @@ -0,0 +1,171 @@ +import React from "react"; +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronUpIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +export function MapBuilder({ + disabled, + onChange, + value, +}: { + disabled?: boolean; + onChange: (value: string[][]) => unknown; + value: string[][]; +}) { + const map = value.length === 0 || value[0].length === 0 ? [[" "]] : value; + + React.useEffect(() => { + if (value.length === 0 || value[0].length === 0) { + onChange([[" "]]); + } + }, [value]); + + function handleClickCell(row: number, cell: number, initialValue: string) { + const newValue = + initialValue === " " ? "R" : initialValue === "R" ? "Z" : " "; + + const newMap = [...map.map((row) => [...row])]; + newMap[row][cell] = newValue; + onChange(newMap); + } + + function handleIncreaseDown() { + onChange([...map.map((row) => [...row]), [...map[0].map(() => " ")]]); + } + + function handleDecreaseDown() { + onChange([...map.slice(0, -1).map((row) => [...row])]); + } + + function handleIncreaseLeft() { + onChange(map.map((row) => [" ", ...row])); + } + + function handleDecreaseLeft() { + onChange(map.map((row) => [...row.slice(1)])); + } + + function handleIncreaseRight() { + onChange(map.map((row) => [...row, " "])); + } + + function handleDecreaseRight() { + onChange(map.map((row) => [...row.slice(0, -1)])); + } + + function handleIncreaseUp() { + onChange([[...map[0].map(() => " ")], ...map.map((row) => [...row])]); + } + + function handleDecreaseUp() { + onChange([...map.slice(1).map((row) => [...row])]); + } + + 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 controlClassName = cn( + "h-8 absolute hover:scale-125 transition disabled:opacity-50", + ); + + return ( +
+ + {moreThanOneRow && ( + + )} + + {moreThanOneCell && ( + + )} + + {moreThanOneRow && ( + + )} + + {moreThanOneCell && ( + + )} + {map.map((row, rowIdx) => ( +
+ {row.map((cell, cellIdx) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/convex/maps.ts b/convex/maps.ts index fe411ed..51f3b5b 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -88,6 +88,22 @@ 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()), diff --git a/models/index.ts b/models/index.ts index 6ee3d47..bfa64d3 100644 --- a/models/index.ts +++ b/models/index.ts @@ -87,31 +87,43 @@ export async function runModel( }> { let result; - switch (modelId) { - case "gemini-1.5-pro": { - result = await gemini15pro(prompt, map); - break; - } - case "gpt-4o": { - result = await gpt4o(prompt, map); - break; - } - case "claude-3.5-sonnet": { - result = await claude35sonnet(prompt, map); - break; - } - case "perplexity-llama-3.1": { - result = await perplexityModel(prompt, map); - break; - } - default: { - throw new Error(`Tried running unknown model '${modelId}'`); + try { + switch (modelId) { + case "gemini-1.5-pro": { + result = await gemini15pro(prompt, map); + break; + } + case "gpt-4o": { + result = await gpt4o(prompt, map); + break; + } + case "claude-3.5-sonnet": { + result = await claude35sonnet(prompt, map); + break; + } + case "perplexity-llama-3.1": { + result = await perplexityModel(prompt, map); + break; + } + default: { + throw new Error(`Tried running unknown model '${modelId}'`); + } } + } catch (error) { + return { + reasoning: "Internal error", + error: + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error", + }; } const originalMap = JSON.parse(JSON.stringify(map)); - const [playerRow, playerCol] = result.playerCoordinates; + if (originalMap[playerRow][playerCol] !== " ") { return { reasoning: result.reasoning, diff --git a/simulators/zombie-survival/ZombieSurvival.ts b/simulators/zombie-survival/ZombieSurvival.ts index 11ab446..df4a1d2 100644 --- a/simulators/zombie-survival/ZombieSurvival.ts +++ b/simulators/zombie-survival/ZombieSurvival.ts @@ -28,6 +28,10 @@ export class ZombieSurvival { return !game.getPlayer().dead(); } + public static mapHasZombies(map: string[][]): boolean { + return map.some((row) => row.includes("Z")); + } + public constructor(config: string[][]) { if (config.length === 0 || config[0].length === 0) { throw new Error("Config is empty");