diff --git a/app/games/[gameId]/page.tsx b/app/games/[gameId]/page.tsx index 8b1875c..2299280 100644 --- a/app/games/[gameId]/page.tsx +++ b/app/games/[gameId]/page.tsx @@ -6,12 +6,17 @@ import { Page } from "@/components/Page"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; -export default function GamePage({ params }: { params: { gameId: string } }) { +export default function GamePage({ + params, +}: { + params: { gameId: Id<"games"> }; +}) { const game = useQuery(api.games.getGame, { - gameId: params.gameId as Id<"games">, + gameId: params.gameId, }); + const results = useQuery(api.results.getResults, { - gameId: params.gameId as Id<"games">, + gameId: params.gameId, }); return ( diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index ebed310..f1545de 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -14,16 +14,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api } from "@/convex/_generated/api"; -// Define the types for the data -interface Ranking { - _id: string; - modelId: string; - level?: string; - wins: number; - losses: number; -} - -interface Stats { +interface LeaderBoardStats { wins: number; losses: number; total: number; @@ -31,34 +22,22 @@ interface Stats { } const LeaderBoard = () => { - const globalRanking = useQuery(api.leaderboard.getGlobalRankings) as - | Ranking[] - | undefined; - const levelRanking = useQuery(api.leaderboard.getLevelRankings, {}) as - | Ranking[] - | undefined; - - // Transform the levelRanking data into a pivot table structure - const pivotLevelData = (levelRanking: Ranking[] | undefined) => { - const levels: Record> = {}; - - levelRanking?.forEach((item) => { - if (!levels[item.level!]) { - levels[item.level!] = {}; - } - - levels[item.level!][item.modelId] = { - wins: item.wins, - losses: item.losses, - total: item.wins + item.losses, - ratio: item.wins / (item.wins + item.losses), - }; - }); + const globalRanking = useQuery(api.leaderboard.getGlobalRankings); + const levelRanking = useQuery(api.leaderboard.getLevelRankings, {}); + const pivotedLevelData: Record> = {}; - return levels; - }; + levelRanking?.forEach((item) => { + if (!pivotedLevelData[item.level]) { + pivotedLevelData[item.level] = {}; + } - const pivotedLevelData = pivotLevelData(levelRanking); + pivotedLevelData[item.level][item.modelId] = { + wins: item.wins, + losses: item.losses, + total: item.wins + item.losses, + ratio: item.wins / (item.wins + item.losses), + }; + }); // Get all unique model IDs to dynamically create columns const allModels = Array.from( diff --git a/app/multiplayer/[multiplayerGameId]/page.tsx b/app/multiplayer/[multiplayerGameId]/page.tsx new file mode 100644 index 0000000..e5e1e8c --- /dev/null +++ b/app/multiplayer/[multiplayerGameId]/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useQuery } from "convex/react"; +import { Page, PageTitle } from "@/components/Page"; +import { Visualizer } from "@/components/Visualizer"; +import { api } from "@/convex/_generated/api"; +import { Id } from "@/convex/_generated/dataModel"; + +export default function MultiplayerPage({ + params, +}: { + params: { multiplayerGameId: Id<"multiplayerGames"> }; +}) { + const multiplayerGame = useQuery(api.multiplayerGames.getMultiplayerGame, { + multiplayerGameId: params.multiplayerGameId, + }); + + if (multiplayerGame === undefined) { + return
Loading...
; + } + + if (multiplayerGame === null) { + return
Game not found.
; + } + + return ( + + Multiplayer +
+ +
+
+ ); +} diff --git a/app/play/[level]/[attempt]/page.tsx b/app/play/[level]/[attempt]/page.tsx index f829ec6..861c922 100644 --- a/app/play/[level]/[attempt]/page.tsx +++ b/app/play/[level]/[attempt]/page.tsx @@ -1,6 +1,5 @@ "use client"; -import * as React from "react"; import { ChevronLeftIcon } from "@radix-ui/react-icons"; import { useQuery } from "convex/react"; import Link from "next/link"; diff --git a/app/play/[level]/page.tsx b/app/play/[level]/page.tsx index f551949..0c4f886 100644 --- a/app/play/[level]/page.tsx +++ b/app/play/[level]/page.tsx @@ -22,7 +22,7 @@ import { import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { DEFAULT_REPLAY_SPEED } from "@/constants/visualizer"; import { api } from "@/convex/_generated/api"; -import { ZombieSurvival } from "@/simulators/zombie-survival"; +import { ZombieSurvival } from "@/simulator"; type PlacementMode = "player" | "block" | "landmine"; diff --git a/app/play/page.tsx b/app/play/page.tsx index 7d8d6f8..44f3603 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -27,7 +27,7 @@ import { cn } from "@/lib/utils"; export default function PlayPage() { const isAdmin = useQuery(api.users.isAdmin); - const maps = useQuery(api.maps.getMaps, {}); + const maps = useQuery(api.maps.getMaps); const userMapResults = useQuery(api.playerresults.getUserMapStatus); const mapCountResults = useQuery(api.playerresults.getMapsWins); const adminDeleteMapMutation = useMutation(api.maps.deleteMap); @@ -51,17 +51,11 @@ export default function PlayPage() { const res = new Map(); const ctr = new Map(); - for (const result of userMapResults as { - mapId: string; - hasWon: boolean; - }[]) { + for (const result of userMapResults) { res.set(result.mapId, result.hasWon); } - for (const result of mapCountResults as { - mapId: string; - count: number; - }[]) { + for (const result of mapCountResults) { ctr.set(result.mapId, result.count); } diff --git a/app/playground/page.tsx b/app/playground/page.tsx index cf9f968..f2f6879 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -31,7 +31,7 @@ import { Id } from "@/convex/_generated/dataModel"; import { SIGN_IN_ERROR_MESSAGE } from "@/convex/users"; import { useAITesting } from "@/hooks/useAITesting"; import { errorMessage } from "@/lib/utils"; -import { ZombieSurvival } from "@/simulators/zombie-survival"; +import { ZombieSurvival } from "@/simulator"; const STORAGE_MAP_KEY = "playground-map"; const STORAGE_MODEL_KEY = "playground-model"; @@ -39,7 +39,6 @@ const STORAGE_MODEL_KEY = "playground-model"; export default function PlaygroundPage() { const isAdmin = useQuery(api.users.isAdmin); const submitMap = useMutation(api.maps.submitMap); - const testMap = useAction(api.maps.testMap); const searchParams = useSearchParams(); const mapId = searchParams.get("map") as Id<"maps"> | null; const adminMapMaybe = useQuery( diff --git a/app/prompts/[promptId]/page.tsx b/app/prompts/[promptId]/page.tsx index b7c8301..313e692 100644 --- a/app/prompts/[promptId]/page.tsx +++ b/app/prompts/[promptId]/page.tsx @@ -8,11 +8,12 @@ import { Page } from "@/components/Page"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/convex/_generated/api"; +import { Id } from "@/convex/_generated/dataModel"; export default function EditPromptPage({ params, }: { - params: { promptId: string }; + params: { promptId: Id<"prompts"> }; }) { const prompt = useQuery(api.prompts.getPromptById, { promptId: params.promptId, diff --git a/app/result.tsx b/app/result.tsx index 2deec76..e99c8df 100644 --- a/app/result.tsx +++ b/app/result.tsx @@ -12,7 +12,7 @@ export default function Result({ result }: { result: ResultWithGame }) { {result.status === "completed" && ( { - return testModel({ - modelId: model.slug, - }); + return testModel({ modelId: model.slug }); }), ); diff --git a/components/CopyMapButton.tsx b/components/CopyMapButton.tsx index 2062333..7ec7d05 100644 --- a/components/CopyMapButton.tsx +++ b/components/CopyMapButton.tsx @@ -1,11 +1,11 @@ "use client"; -import * as React from "react"; +import { useEffect, useRef, useState } from "react"; import { ClipboardCopyIcon, SmileIcon } from "lucide-react"; export function CopyMapButton({ map }: { map: string[][] }) { - const timeout = React.useRef | null>(null); - const [copied, setCopied] = React.useState(false); + const timeout = useRef | null>(null); + const [copied, setCopied] = useState(false); async function handleClick() { await navigator.clipboard.writeText(JSON.stringify(map)); @@ -17,7 +17,7 @@ export function CopyMapButton({ map }: { map: string[][] }) { }, 2000); } - React.useEffect(() => { + useEffect(() => { return () => { if (timeout.current !== null) { clearTimeout(timeout.current); diff --git a/components/MapStatus.tsx b/components/MapStatus.tsx index 378c9f9..d9110f3 100644 --- a/components/MapStatus.tsx +++ b/components/MapStatus.tsx @@ -1,4 +1,4 @@ -import { ZombieSurvival } from "@/simulators/zombie-survival"; +import { ZombieSurvival } from "@/simulator"; export function MapStatus({ map }: { map: string[][] }) { const isWin = ZombieSurvival.isWin(map); diff --git a/components/ModelSelector.tsx b/components/ModelSelector.tsx index 3598898..3cec5e7 100644 --- a/components/ModelSelector.tsx +++ b/components/ModelSelector.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import { useEffect } from "react"; import { useQuery } from "convex/react"; import { Select, @@ -18,7 +18,7 @@ export function ModelSelector({ }) { const models = useQuery(api.models.getActiveModels); - React.useEffect(() => { + useEffect(() => { if (models !== undefined && models.length !== 0 && value === "") { onChange(models[0].slug); } diff --git a/components/PlayMapButton.tsx b/components/PlayMapButton.tsx index b14ff45..30d0ebe 100644 --- a/components/PlayMapButton.tsx +++ b/components/PlayMapButton.tsx @@ -1,6 +1,5 @@ "use client"; -import * as React from "react"; import { ExternalLinkIcon } from "lucide-react"; import Link from "next/link"; import { Button } from "./ui/button"; diff --git a/components/Renderer.tsx b/components/Renderer.tsx new file mode 100644 index 0000000..234b5ba --- /dev/null +++ b/components/Renderer.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { DEFAULT_REPLAY_SPEED } from "@/constants/visualizer"; +import { Renderer } from "@/renderer"; +import { ZombieSurvival } from "@/simulator"; + +export function useRenderer( + map: string[][] | null | undefined, + canvas: React.MutableRefObject, + cellSize: number = 64, + replaySpeed: number = DEFAULT_REPLAY_SPEED, +) { + const [renderer, setRenderer] = useState(null); + + useEffect(() => { + if (map === null || map === undefined) { + return; + } + + const boardWidth = ZombieSurvival.boardWidth(map); + const boardHeight = ZombieSurvival.boardHeight(map); + + async function handleInitializeRenderer() { + if (canvas.current === null) { + return; + } + + const renderer = new Renderer( + boardWidth, + boardHeight, + canvas.current, + cellSize, + replaySpeed, + ); + + await renderer.initialize(); + setRenderer(renderer); + } + + void handleInitializeRenderer(); + }, [map, cellSize, replaySpeed]); + + return renderer; +} diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx index 56e2114..7cb22fa 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -1,60 +1,58 @@ -import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { useRenderer } from "./Renderer"; import { Button } from "@/components/ui/button"; import { AUTO_REPLAY_SPEED, DEFAULT_REPLAY_SPEED, } from "@/constants/visualizer"; -import { Renderer } from "@/renderer"; -import { ZombieSurvival } from "@/simulators/zombie-survival"; +import { ZombieSurvival, type ZombieSurvivalOptions } from "@/simulator"; export function Visualizer({ autoReplay = false, autoStart = false, controls = true, - cellSize = "64", + cellSize = 64, map, onReset, onSimulationEnd, replaySpeed = DEFAULT_REPLAY_SPEED, + simulatorOptions, }: { autoReplay?: boolean; autoStart?: boolean; controls?: boolean; - cellSize?: string; + cellSize?: number; map: string[][]; onReset?: () => unknown; onSimulationEnd?: (isWin: boolean) => unknown; replaySpeed?: number; + simulatorOptions?: ZombieSurvivalOptions; }) { - const simulator = React.useRef(new ZombieSurvival(map)); - const renderer = React.useRef(null); - const interval = React.useRef | null>(null); - const timeout = React.useRef | null>(null); - const canvas = React.useRef(null); - const visible = React.useRef(false); - const [running, setRunning] = React.useState(false); - - React.useEffect(() => { - if (canvas.current !== null) { - renderer.current = new Renderer( - ZombieSurvival.boardHeight(map), - ZombieSurvival.boardWidth(map), - canvas.current, - Number.parseInt(cellSize, 10), - replaySpeed, - ); - } - }, [canvas, cellSize, map, replaySpeed]); + const simulator = useRef( + new ZombieSurvival(map, simulatorOptions), + ); - React.useEffect(() => { - if (autoStart) { + const interval = useRef | null>(null); + const timeout = useRef | null>(null); + const canvas = useRef(null); + const renderer = useRenderer(map, canvas, cellSize, replaySpeed); + const visible = useRef(false); + const [running, setRunning] = useState(false); + + useEffect(() => { + if (autoStart && renderer !== null) { startSimulation(); } - }, [autoStart]); + }, [autoStart, renderer]); + + useEffect(() => { + if (renderer !== null) { + simulator.current = new ZombieSurvival(map, simulatorOptions); + renderer?.render(simulator.current.getAllEntities()); + } + }, [renderer]); function startSimulation() { - simulator.current = new ZombieSurvival(map); - renderer.current?.render(simulator.current); setRunning(true); interval.current = setInterval(() => { @@ -64,7 +62,7 @@ export function Visualizer({ if (!simulator.current.finished()) { simulator.current.step(); - renderer.current?.render(simulator.current); + renderer?.render(simulator.current.getAllEntities()); return; } @@ -74,6 +72,10 @@ export function Visualizer({ if (autoReplay) { timeout.current = setTimeout(() => { timeout.current = null; + + simulator.current = new ZombieSurvival(map, simulatorOptions); + renderer?.render(simulator.current.getAllEntities()); + startSimulation(); }, AUTO_REPLAY_SPEED); @@ -88,7 +90,7 @@ export function Visualizer({ }, replaySpeed); } - React.useEffect(() => { + useEffect(() => { if (canvas.current === null) { return; } @@ -104,7 +106,7 @@ export function Visualizer({ }; }, [canvas]); - React.useEffect(() => { + useEffect(() => { return () => { if (interval.current) { clearInterval(interval.current); diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 7756b31..1c3f982 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -21,6 +21,7 @@ import type * as init from "../init.js"; import type * as leaderboard from "../leaderboard.js"; import type * as maps from "../maps.js"; import type * as models from "../models.js"; +import type * as multiplayerGames from "../multiplayerGames.js"; import type * as playerresults from "../playerresults.js"; import type * as prompts from "../prompts.js"; import type * as rateLimits from "../rateLimits.js"; @@ -53,6 +54,7 @@ declare const fullApi: ApiFromModules<{ leaderboard: typeof leaderboard; maps: typeof maps; models: typeof models; + multiplayerGames: typeof multiplayerGames; playerresults: typeof playerresults; prompts: typeof prompts; rateLimits: typeof rateLimits; diff --git a/convex/constants.ts b/convex/constants.ts index 136f28e..7595257 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -1,27 +1,29 @@ -export const AI_MODELS = [ - { - model: "gemini-1.5-pro", - name: "Google - Gemini 1.5 Pro", - }, - { - model: "gpt-4o", +export const AI_MODELS = { + "gpt-4o": { + slug: "gpt-4o", name: "OpenAI - GPT-4o", }, - { - model: "claude-3.5-sonnet", + "claude-3.5-sonnet": { + slug: "claude-3.5-sonnet", name: "Anthropic - Claude 3.5 Sonnet", }, - { - model: "perplexity-llama-3.1", + "perplexity-llama-3.1": { + slug: "perplexity-llama-3.1", name: "Perplexity - Llama 3.1", }, - { - model: "mistral-large-2", + "mistral-large-2": { + slug: "mistral-large-2", name: "Mistral - Large 2", }, -]; + "gemini-1.5-pro": { + slug: "gemini-1.5-pro", + name: "Google - Gemini 1.5 Pro", + }, +} as const; + +export type ModelSlug = (typeof AI_MODELS)[keyof typeof AI_MODELS]["slug"]; -export const AI_MODEL_IDS = AI_MODELS.map((model) => model.model); +export const AI_MODEL_SLUGS = Object.keys(AI_MODELS) as ModelSlug[]; // how long between each level when the AI models start playing. // spacing out the levels to make it easier to watch in the games list and reduce ai token usage. diff --git a/convex/flags.ts b/convex/flags.ts index 2addc37..88be059 100644 --- a/convex/flags.ts +++ b/convex/flags.ts @@ -1,7 +1,6 @@ import { query } from "./_generated/server"; export const getFlags = query({ - args: {}, handler: async () => { return { showTestPage: process.env.FLAG_TEST_PAGE === "true", diff --git a/convex/games.ts b/convex/games.ts index 835c835..5193b82 100644 --- a/convex/games.ts +++ b/convex/games.ts @@ -2,7 +2,7 @@ import { v } from "convex/values"; import { api, internal } from "./_generated/api"; import { Id } from "./_generated/dataModel"; import { internalMutation, mutation, query } from "./_generated/server"; -import { AI_MODEL_IDS } from "./constants"; +import { AI_MODEL_SLUGS, ModelSlug } from "./constants"; export const testModel = mutation({ args: { @@ -11,22 +11,27 @@ export const testModel = mutation({ handler: async (ctx, args) => { const flags = await ctx.runQuery(api.flags.getFlags); - if (!flags?.showTestPage) { + if (!flags.showTestPage) { throw new Error("Test page is not enabled"); } - const gameId = (await ctx.runMutation(internal.games.startNewGame, { - modelId: args.modelId, - })) as Id<"games">; + const gameId: Id<"games"> = await ctx.runMutation( + internal.games.startNewGame, + { + modelId: args.modelId, + }, + ); return gameId; }, }); export const startNewGame = internalMutation({ - args: { modelId: v.string() }, + args: { + modelId: v.string(), + }, handler: async (ctx, args) => { - if (!AI_MODEL_IDS.includes(args.modelId)) { + if (!AI_MODEL_SLUGS.includes(args.modelId as ModelSlug)) { throw new Error("Invalid model ID"); } @@ -56,7 +61,9 @@ export const startNewGame = internalMutation({ }); export const getGame = query({ - args: { gameId: v.id("games") }, + args: { + gameId: v.id("games"), + }, handler: async ({ db }, args) => { return db.get(args.gameId); }, diff --git a/convex/leaderboard.ts b/convex/leaderboard.ts index 3bd5c31..f4a5f92 100644 --- a/convex/leaderboard.ts +++ b/convex/leaderboard.ts @@ -60,10 +60,6 @@ export const updateRankings = internalMutation({ handler: async (ctx, args) => { const activePrompt = await ctx.runQuery(api.prompts.getActivePrompt); - if (!activePrompt) { - throw new Error("Active prompt not found"); - } - const globalRanking = await ctx.db .query("globalRankings") .withIndex("by_modelId_promptId", (q) => diff --git a/convex/maps.ts b/convex/maps.ts index d4c5088..45e9288 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -1,9 +1,10 @@ import { runModel } from "../models"; -import { ZombieSurvival } from "../simulators/zombie-survival"; +import { ZombieSurvival } from "../simulator"; import { getAuthUserId } from "@convex-dev/auth/server"; import { isRateLimitError } from "@convex-dev/rate-limiter"; import { v } from "convex/values"; import { api, internal } from "./_generated/api"; +import { Doc } from "./_generated/dataModel"; import { action, internalAction, @@ -11,7 +12,6 @@ import { mutation, query, } from "./_generated/server"; -import { Prompt } from "./prompts"; import { rateLimiter } from "./rateLimits"; import { adminMutationBuilder, @@ -216,8 +216,7 @@ export const getUnreviewedMaps = adminQueryBuilder({ }); export const getMaps = query({ - args: {}, - handler: async (ctx, args) => { + handler: async (ctx) => { return await ctx.db .query("maps") .withIndex("by_isReviewed_level", (q) => q.eq("isReviewed", true)) @@ -288,7 +287,9 @@ export const deleteMap = adminMutationBuilder({ }); export const getMapByLevel = query({ - args: { level: v.number() }, + args: { + level: v.number(), + }, handler: async (ctx, args) => { return await ctx.db .query("maps") @@ -331,22 +332,18 @@ export const playMapAction = internalAction({ if (process.env.FLAG_MOCK_MODELS === "true") { const existingMap = ZombieSurvival.cloneMap(map.grid); - const playerPosition = ZombieSurvival.nextValidPosition(existingMap); + const validLocations = ZombieSurvival.validLocations(existingMap); - if (playerPosition !== null) { - existingMap[playerPosition.y][playerPosition.x] = "P"; + if (validLocations.length > 0) { + existingMap[validLocations[0][0]][validLocations[0][1]] = "P"; } - const firstBoxPosition = ZombieSurvival.nextValidPosition(existingMap); - - if (firstBoxPosition !== null) { - existingMap[firstBoxPosition.y][firstBoxPosition.x] = "B"; + if (validLocations.length > 1) { + existingMap[validLocations[1][0]][validLocations[1][1]] = "B"; } - const secondBoxPosition = ZombieSurvival.nextValidPosition(existingMap); - - if (secondBoxPosition !== null) { - existingMap[secondBoxPosition.y][secondBoxPosition.x] = "B"; + if (validLocations.length > 2) { + existingMap[validLocations[2][0]][validLocations[2][1]] = "B"; } await ctx.runMutation(internal.results.updateResult, { @@ -359,17 +356,12 @@ export const playMapAction = internalAction({ return; } - const activePromptQuery = await ctx.runQuery(api.prompts.getActivePrompt); - const activePrompt = activePromptQuery && activePromptQuery.prompt; - - if (!activePrompt) { - throw new Error("Active prompt not found"); - } + const activePrompt = await ctx.runQuery(api.prompts.getActivePrompt); const { solution, reasoning, error } = await runModel( args.modelId, map.grid, - activePrompt, + activePrompt.prompt, ); await ctx.runMutation(internal.results.updateResult, { @@ -389,17 +381,14 @@ export const testMap = action({ }, handler: async (ctx, args) => { const isAdmin = await ctx.runQuery(api.users.isAdmin); - const activePrompt: Prompt = await ctx.runQuery( - api.prompts.getActivePrompt, - ); if (!isAdmin) { throw new Error("Test map is available only for admin"); } - if (!activePrompt) { - throw new Error("Active prompt not found"); - } + const activePrompt: Doc<"prompts"> = await ctx.runQuery( + api.prompts.getActivePrompt, + ); return await runModel(args.modelId, args.map, activePrompt.prompt); }, @@ -424,13 +413,7 @@ export const testAIModel = action({ throw new Error("Map not found"); } - const activePrompt: Prompt = await ctx.runQuery( - api.prompts.getActivePrompt, - ); - - if (!activePrompt) { - throw new Error("Active prompt not found"); - } + const activePrompt = await ctx.runQuery(api.prompts.getActivePrompt); const { solution, diff --git a/convex/models.ts b/convex/models.ts index d9e8851..89e6270 100644 --- a/convex/models.ts +++ b/convex/models.ts @@ -1,3 +1,4 @@ +import { v } from "convex/values"; import { api, internal } from "./_generated/api"; import { internalMutation, query } from "./_generated/server"; import { AI_MODELS } from "./constants"; @@ -13,7 +14,9 @@ export const runActiveModelsGames = internalMutation({ await Promise.all( models.map((model) => - ctx.runMutation(internal.games.startNewGame, { modelId: model.slug }), + ctx.runMutation(internal.games.startNewGame, { + modelId: model.slug, + }), ), ); }, @@ -24,8 +27,8 @@ export const seedModels = internalMutation({ const models = await ctx.db.query("models").collect(); const promises = []; - for (const model of AI_MODELS) { - const existingModel = models.find((it) => it.slug === model.model); + for (const model of Object.values(AI_MODELS)) { + const existingModel = models.find((it) => it.slug === model.slug); if (existingModel !== undefined) { continue; @@ -33,7 +36,7 @@ export const seedModels = internalMutation({ promises.push( ctx.db.insert("models", { - slug: model.model, + slug: model.slug, name: model.name, active: false, }), @@ -44,6 +47,24 @@ export const seedModels = internalMutation({ }, }); +export const getActiveModelBySlug = query({ + args: { + slug: v.string(), + }, + handler: async (ctx, args) => { + const record = await ctx.db + .query("models") + .withIndex("by_slug", (q) => q.eq("slug", args.slug)) + .first(); + + if (record === null) { + throw new Error(`Model with name '${args.slug}' was not found`); + } + + return record; + }, +}); + export const getActiveModels = query({ handler: async (ctx) => { return await ctx.db diff --git a/convex/multiplayerGames.ts b/convex/multiplayerGames.ts new file mode 100644 index 0000000..39bfb1f --- /dev/null +++ b/convex/multiplayerGames.ts @@ -0,0 +1,403 @@ +import { runMultiplayerModel } from "../models/multiplayer"; +import { + Direction, + ZombieSurvival, + fromDirectionString, + move, +} from "../simulator"; +import { v } from "convex/values"; +import { api, internal } from "./_generated/api"; +import { internalAction, internalMutation, query } from "./_generated/server"; +import { AI_MODELS, ModelSlug } from "./constants"; + +const HARD_CODED_PLAYER_TOKEN = "1"; +const TURN_DELAY = 0; + +export const startMultiplayerGame = internalMutation({ + handler: async (ctx) => { + const gameId = await ctx.db.insert("multiplayerGames", { + boardState: [ + [ + "Z", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "Z", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + "Z", + "Z", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + "R", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + "R", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + "R", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + "R", + " ", + " ", + " ", + "R", + "R", + "B", + "B", + "R", + "R", + " ", + " ", + " ", + " ", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + HARD_CODED_PLAYER_TOKEN, + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + [ + "Z", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + ], + playerMap: [ + { + modelSlug: AI_MODELS["gpt-4o"].slug, + playerToken: HARD_CODED_PLAYER_TOKEN, + }, + ], + }); + + await ctx.scheduler.runAfter( + 0, + internal.multiplayerGames.runMultiplayerGameTurn, + { + multiplayerGameId: gameId, + turn: HARD_CODED_PLAYER_TOKEN, + }, + ); + + return gameId; + }, +}); + +export const getMultiplayerGame = query({ + args: { + multiplayerGameId: v.id("multiplayerGames"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.multiplayerGameId); + }, +}); + +export const updateMultiplayerGameBoardState = internalMutation({ + args: { + multiplayerGameId: v.id("multiplayerGames"), + boardState: v.array(v.array(v.string())), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.multiplayerGameId, { boardState: args.boardState }); + }, +}); + +export const runMultiplayerGameTurn = internalAction({ + args: { + turn: v.string(), + multiplayerGameId: v.id("multiplayerGames"), + }, + handler: async (ctx, args) => { + const { turn, multiplayerGameId } = args; + + const multiplayerGame = await ctx.runQuery( + api.multiplayerGames.getMultiplayerGame, + { + multiplayerGameId, + }, + ); + + if (!multiplayerGame) { + throw new Error("Multiplayer game not found"); + } + + const map = new ZombieSurvival(multiplayerGame.boardState); + + if (turn === "Z") { + map.stepZombies(); + + await ctx.runMutation( + internal.multiplayerGames.updateMultiplayerGameBoardState, + { + multiplayerGameId, + boardState: map.getState(), + }, + ); + } else if (turn === HARD_CODED_PLAYER_TOKEN) { + const model = multiplayerGame.playerMap.find( + (entry) => entry.playerToken === turn, + ); + + if (!model) { + throw new Error("Model not found"); + } + + const results = await runMultiplayerModel( + model.modelSlug as ModelSlug, + map.getState(), + HARD_CODED_PLAYER_TOKEN, + ); + + if (results.moveDirection && results.moveDirection !== "STAY") { + const moveDirection = fromDirectionString(results.moveDirection); + const movePosition = move( + map.getPlayer(turn).getPosition(), + moveDirection, + ); + + if ( + map.isValidPosition(movePosition) && + map.isPositionEmpty(movePosition) + ) { + // only move if the position was valid, otherwise we don't move + map.getPlayer(turn).moveTo(movePosition); + } + } + + if (results.zombieToShoot) { + const zombieToShoot = results.zombieToShoot; + map.getZombieAt({ x: zombieToShoot[1], y: zombieToShoot[0] })?.hit(); + } + + await ctx.runMutation( + internal.multiplayerGames.updateMultiplayerGameBoardState, + { + multiplayerGameId, + boardState: map.getState(), + }, + ); + } + + if (!map.finished()) { + await ctx.scheduler.runAfter( + TURN_DELAY, + internal.multiplayerGames.runMultiplayerGameTurn, + { + multiplayerGameId, + turn: turn === "Z" ? HARD_CODED_PLAYER_TOKEN : "Z", + }, + ); + } + }, +}); diff --git a/convex/prompts.ts b/convex/prompts.ts index 67bf9ee..40225de 100644 --- a/convex/prompts.ts +++ b/convex/prompts.ts @@ -1,5 +1,4 @@ import { v } from "convex/values"; -import { Id } from "./_generated/dataModel"; import { internalMutation, query } from "./_generated/server"; import { adminMutationBuilder } from "./users"; @@ -63,17 +62,8 @@ The 2d Grid is made up of characters, where each character has a meaning. "reasoning": "REASONING" }`; -export type Prompt = { - _id: Id<"prompts">; - _creationTime: number; - promptName: string; - prompt: string; - isActive: boolean; -}; - export const getActivePrompt = query({ - args: {}, - handler: async (ctx): Promise => { + handler: async (ctx) => { const prompt = await ctx.db.query("prompts").withIndex("by_active").first(); if (!prompt) { throw new Error("No active prompt found"); @@ -84,25 +74,16 @@ export const getActivePrompt = query({ export const getPromptById = query({ args: { - promptId: v.string(), + promptId: v.id("prompts"), }, - handler: async (ctx, args): Promise => { - const prompt = await ctx.db - .query("prompts") - .filter((q) => q.eq(q.field("_id"), args.promptId)) - .first(); - return prompt; + handler: async (ctx, args) => { + return await ctx.db.get(args.promptId); }, }); export const getAllPrompts = query({ - args: {}, - handler: async (ctx): Promise => { - const prompts = await ctx.db - .query("prompts") - .withIndex("by_active") - .collect(); - return prompts; + handler: async (ctx) => { + return await ctx.db.query("prompts").withIndex("by_active").collect(); }, }); @@ -174,7 +155,6 @@ export const enablePrompt = adminMutationBuilder({ }); export const seedPrompts = internalMutation({ - args: {}, handler: async (ctx) => { // Insert the default prompt into the "prompts" collection and set it as active await ctx.db.insert("prompts", { diff --git a/convex/results.ts b/convex/results.ts index 402aa73..c7cec43 100644 --- a/convex/results.ts +++ b/convex/results.ts @@ -8,7 +8,9 @@ export type ResultWithGame = Awaited< >[number]; export const getResults = query({ - args: { gameId: v.id("games") }, + args: { + gameId: v.id("games"), + }, handler: async ({ db }, args) => { const results = await db .query("results") diff --git a/convex/schema.ts b/convex/schema.ts index 429836f..48897b9 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -28,7 +28,9 @@ export default defineSchema({ slug: v.string(), active: v.boolean(), name: v.string(), - }).index("by_active", ["active"]), + }) + .index("by_active", ["active"]) + .index("by_slug", ["slug"]), results: defineTable({ gameId: v.id("games"), level: v.number(), @@ -68,7 +70,9 @@ export default defineSchema({ mapId: v.id("maps"), attempts: v.array(v.id("attempts")), hasWon: v.boolean(), - }).index("by_mapId_userId", ["mapId", "userId"]), + }) + .index("by_mapId_userId", ["mapId", "userId"]) + .index("by_userId", ["userId"]), admins: defineTable({ userId: v.id("users"), }).index("by_userId", ["userId"]), @@ -77,4 +81,13 @@ export default defineSchema({ prompt: v.string(), isActive: v.boolean(), }).index("by_active", ["isActive"]), + multiplayerGames: defineTable({ + boardState: v.array(v.array(v.string())), + playerMap: v.array( + v.object({ + modelSlug: v.string(), + playerToken: v.string(), + }), + ), + }), }); diff --git a/convex/scores.ts b/convex/scores.ts index a328f59..4113e31 100644 --- a/convex/scores.ts +++ b/convex/scores.ts @@ -9,15 +9,15 @@ export const incrementScore = internalMutation({ handler: async (ctx, args) => { const score = await ctx.db .query("scores") - .filter((q) => q.eq(q.field("modelId"), args.modelId)) + .withIndex("by_modelId", (q) => q.eq("modelId", args.modelId)) .first(); const activePrompt = await ctx.runQuery(api.prompts.getActivePrompt); - if (!score && activePrompt) { + if (!score) { await ctx.db.insert("scores", { modelId: args.modelId, - promptId: activePrompt?._id, + promptId: activePrompt._id, score: 1, }); } else if (score) { diff --git a/convex/tsconfig.json b/convex/tsconfig.json index 3a62739..edea9c1 100644 --- a/convex/tsconfig.json +++ b/convex/tsconfig.json @@ -18,8 +18,11 @@ "forceConsistentCasingInFileNames": true, "module": "ESNext", "isolatedModules": true, - "noEmit": true + "noEmit": true, + "paths": { + "@/*": ["../*"] + } }, - "include": ["./**/*", "../simulators/**/*"], + "include": ["./**/*", "../simulator/**/*"], "exclude": ["./_generated"] } diff --git a/convex/users.ts b/convex/users.ts index 8943cc6..45f4c95 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -4,11 +4,10 @@ import { customMutation, customQuery, } from "convex-helpers/server/customFunctions"; -import { ConvexError, v } from "convex/values"; +import { ConvexError } from "convex/values"; import { mutation, query } from "./_generated/server"; export const viewer = query({ - args: {}, handler: async (ctx) => { const userId = await getAuthUserId(ctx); if (userId === null) { @@ -23,7 +22,6 @@ export const viewer = query({ }); export const getUserOrNull = query({ - args: {}, handler: async (ctx) => { const userId = await getAuthUserId(ctx); if (userId === null) { @@ -128,7 +126,7 @@ export const deleteUserById = authenticatedMutation({ const userResults = await ctx.db .query("userResults") - .filter((q) => q.eq(q.field("userId"), userId)) + .withIndex("by_userId", (q) => q.eq("userId", userId)) .collect(); const promises: Promise[] = []; diff --git a/hooks/useAITesting.ts b/hooks/useAITesting.ts index 35e79e2..5dd4a93 100644 --- a/hooks/useAITesting.ts +++ b/hooks/useAITesting.ts @@ -81,7 +81,7 @@ export function useAITesting({ testingType, level }: UseAITestingProps) { setAiTotalTokensUsed(result.totalTokensUsed ?? null); setAiTotalRunCost(result.totalRunCost ?? null); - return result as AITestResult; + return result; } catch (error) { console.error("Error testing AI model:", error); setAiError(errorMessage(error)); diff --git a/simulators/zombie-survival/lib/closestEntity.ts b/lib/closestEntity.ts similarity index 94% rename from simulators/zombie-survival/lib/closestEntity.ts rename to lib/closestEntity.ts index 87687bd..fdf5e94 100644 --- a/simulators/zombie-survival/lib/closestEntity.ts +++ b/lib/closestEntity.ts @@ -1,4 +1,4 @@ -import { Entity } from "../Entity"; +import { type Entity } from "@/simulator"; export interface ClosestEntityScore { distance: number; diff --git a/simulators/zombie-survival/lib/entityAt.ts b/lib/entityAt.ts similarity index 82% rename from simulators/zombie-survival/lib/entityAt.ts rename to lib/entityAt.ts index d59eb04..06d5f40 100644 --- a/simulators/zombie-survival/lib/entityAt.ts +++ b/lib/entityAt.ts @@ -1,5 +1,4 @@ -import { Entity } from "../Entity"; -import { Position } from "../Position"; +import { Entity, Position } from "@/simulator"; export function entityAt( entities: Entity[], diff --git a/models/index.ts b/models/index.ts index 668de69..927d674 100644 --- a/models/index.ts +++ b/models/index.ts @@ -1,10 +1,11 @@ import { errorMessage } from "../lib/utils"; -import { ZombieSurvival } from "../simulators/zombie-survival"; +import { ZombieSurvival } from "../simulator"; import { claude35sonnet } from "./claude-3-5-sonnet"; import { gemini15pro } from "./gemini-1.5-pro"; import { gpt4o } from "./gpt-4o"; import { mistralLarge2 } from "./mistral-large-2"; import { perplexityLlama31 } from "./perplexity-llama-3.1"; +import { AI_MODELS } from "@/convex/constants"; export interface ModelHandlerConfig { maxTokens: number; @@ -62,23 +63,23 @@ export async function runModel( try { switch (modelId) { - case "gemini-1.5-pro": { + case AI_MODELS["gemini-1.5-pro"].slug: { result = await gemini15pro(prompt, userPrompt, CONFIG); break; } - case "gpt-4o": { + case AI_MODELS["gpt-4o"].slug: { result = await gpt4o(prompt, userPrompt, CONFIG); break; } - case "claude-3.5-sonnet": { + case AI_MODELS["claude-3.5-sonnet"].slug: { result = await claude35sonnet(prompt, userPrompt, CONFIG); break; } - case "perplexity-llama-3.1": { + case AI_MODELS["perplexity-llama-3.1"].slug: { result = await perplexityLlama31(prompt, userPrompt, CONFIG); break; } - case "mistral-large-2": { + case AI_MODELS["mistral-large-2"].slug: { result = await mistralLarge2(prompt, userPrompt, CONFIG); break; } diff --git a/models/multiplayer/gpt-4o.ts b/models/multiplayer/gpt-4o.ts new file mode 100644 index 0000000..a6e24e3 --- /dev/null +++ b/models/multiplayer/gpt-4o.ts @@ -0,0 +1,48 @@ +import { type MultiplayerModelHandler } from "."; +import OpenAI from "openai"; +import { zodResponseFormat } from "openai/helpers/zod"; +import { z } from "zod"; + +const responseSchema = z.object({ + moveDirection: z.string(), + zombieToShoot: z.array(z.number()), +}); + +export const gpt4o: MultiplayerModelHandler = async ( + systemPrompt, + userPrompt, + config, +) => { + const openai = new OpenAI(); + + const completion = await openai.beta.chat.completions.parse({ + model: "gpt-4o-2024-08-06", + max_tokens: config.maxTokens, + temperature: config.temperature, + top_p: config.topP, + messages: [ + { + role: "system", + content: systemPrompt, + }, + { + role: "user", + content: userPrompt, + }, + ], + response_format: zodResponseFormat(responseSchema, "game_map"), + }); + + const response = completion.choices[0].message; + + if (response.refusal) { + throw new Error(`Refusal: ${response.refusal}`); + } else if (!response.parsed) { + throw new Error("Failed to run model GPT-4o"); + } + + return { + moveDirection: response.parsed.moveDirection, + zombieToShoot: response.parsed.zombieToShoot, + }; +}; diff --git a/models/multiplayer/index.ts b/models/multiplayer/index.ts new file mode 100644 index 0000000..c599a4a --- /dev/null +++ b/models/multiplayer/index.ts @@ -0,0 +1,125 @@ +import { gpt4o } from "./gpt-4o"; +import { ModelSlug } from "@/convex/constants"; +import { errorMessage } from "@/lib/utils"; +import { ZombieSurvival } from "@/simulator"; + +const SYSTEM_PROMPT = `Your task is to play a game. We will give you a 2d array of characters that represent the game board. + +# Grid Descriptions +The 2d Grid is made up of characters, where each character has a meaning. +" " represents an empty space. +"Z" represents a zombie. "Z:2" represents a zombie with 2 health. +"R" represents rocks which zombies can not pass through and path finding will not allow them to go through. +"1", "2", "3", "4", "5", "6" represents the players who can move around and throw popsicles at zombies. +"B" represents blocks that can be placed before the round begins to hinder the zombies. + +# Game Rules +- The game is turn based. +- At the start of your turn, you can throw a popsicle at any one zombie on the map +- You can also move DOWN, LEFT, RIGHT, UP, STAY only if the spot they are trying to move into is empty +- A zombie is removed from the game when its health reaches 0. +- When all players die, the game ends + +# Zombie Rules +- Zombies have 2 health. +- Zombies can only move horizontally or vertically. +- Zombies pathfinding will always be in the order of DOWN, LEFT, RIGHT, UP +- Zombies can't move diagonally. +- Zombies can't move through rocks. +- Zombies can't move through each other. +- Zombies always try to move towards the playing using BFS algorithm. + +# Player Rules +- Players can move horizontally or vertically. +- Players can't move into occupied spaces or outside the grid. +- Players can throw one popsickle at a zombie each turn. +- Players should move away from zombies. +- Players should probably shoot at the closest zombie + +# Output Format + +- Respond only with valid JSON. Do not write an introduction or summary. +- Assume a position on the 2d grid is always represented as [ROW, COL]. +- Your output should be a JSON object with the following format: + +{ + "moveDirection": "DOWN" | "LEFT" | "RIGHT" | "UP" | "STAY", + "zombieToShoot": [ROW, COL] +} +`; + +export interface ModelHandlerConfig { + maxTokens: number; + temperature: number; + topP: number; +} + +export type MultiplayerModelHandler = ( + systemPrompt: string, + userPrompt: string, + config: ModelHandlerConfig, +) => Promise<{ + moveDirection: string; + zombieToShoot: number[]; +}>; + +const MAX_RETRIES = 1; + +const CONFIG: ModelHandlerConfig = { + maxTokens: 1024, + temperature: 0.5, + topP: 0.95, +}; + +export type RunModelResult = { + error?: string; + moveDirection?: string; + zombieToShoot?: number[]; + reasoning?: string; +}; + +export async function runMultiplayerModel( + modelSlug: ModelSlug, + map: string[][], + playerToken: string, + retry = 1, +): Promise { + const validDirections = [ + ...ZombieSurvival.validMoveDirections(map, playerToken), + "STAY", + ]; + + const userPrompt = + `Grid: ${JSON.stringify(map)}\n\n` + + `Your Player Token: ${playerToken}\n\n` + + `Valid Move Locations: ${JSON.stringify(validDirections)}`; + + let result; + let reasoning: string | null = null; + + try { + switch (modelSlug) { + case "gpt-4o": { + result = await gpt4o(SYSTEM_PROMPT, userPrompt, CONFIG); + break; + } + default: { + throw new Error(`Tried running unknown model '${modelSlug}'`); + } + } + + return { + moveDirection: result.moveDirection, + zombieToShoot: result.zombieToShoot, + }; + } catch (error) { + if (retry === MAX_RETRIES || reasoning === null) { + return { + error: errorMessage(error), + reasoning: reasoning ?? "Internal error", + }; + } + + return await runMultiplayerModel(modelSlug, map, playerToken, retry + 1); + } +} diff --git a/renderer/Assets.ts b/renderer/Assets.ts index 48e347c..61b6081 100644 --- a/renderer/Assets.ts +++ b/renderer/Assets.ts @@ -1,97 +1,83 @@ -export interface RendererAssets { - loading: boolean; - loaded: boolean; - bg: HTMLImageElement | null; - box: HTMLImageElement | null; - landmine: HTMLImageElement | null; - player: HTMLImageElement | null; - rock: HTMLImageElement | null; - zombieDead: HTMLImageElement | null; - zombieIdleFrame1: HTMLImageElement | null; - zombieIdleFrame2: HTMLImageElement | null; - zombieIdleFrame3: HTMLImageElement | null; - zombieIdleFrame4: HTMLImageElement | null; - zombieWalkingFrame1: HTMLImageElement | null; - zombieWalkingFrame2: HTMLImageElement | null; - zombieWalkingFrame3: HTMLImageElement | null; - zombieWalkingFrame4: HTMLImageElement | null; -} +export class RendererAssets extends EventTarget { + public loaded: boolean = false; + public bg: HTMLImageElement | null = null; + public box: HTMLImageElement | null = null; + public landmine: HTMLImageElement | null = null; + public player: HTMLImageElement | null = null; + public rock: HTMLImageElement | null = null; + public zombieDead: HTMLImageElement | null = null; + public zombieIdleFrame1: HTMLImageElement | null = null; + public zombieIdleFrame2: HTMLImageElement | null = null; + public zombieIdleFrame3: HTMLImageElement | null = null; + public zombieIdleFrame4: HTMLImageElement | null = null; + public zombieWalkingFrame1: HTMLImageElement | null = null; + public zombieWalkingFrame2: HTMLImageElement | null = null; + public zombieWalkingFrame3: HTMLImageElement | null = null; + public zombieWalkingFrame4: HTMLImageElement | null = null; -export const assets: RendererAssets = { - loading: false, - loaded: false, - bg: null, - box: null, - landmine: null, - player: null, - rock: null, - zombieDead: null, - zombieIdleFrame1: null, - zombieIdleFrame2: null, - zombieIdleFrame3: null, - zombieIdleFrame4: null, - zombieWalkingFrame1: null, - zombieWalkingFrame2: null, - zombieWalkingFrame3: null, - zombieWalkingFrame4: null, -}; + constructor() { + super(); -export async function loadAssets() { - if (assets.loading || assets.loaded) { - return; + if (typeof window !== "undefined") { + void this.load(); + } } - assets.loading = true; + private async load() { + const [ + bg, + box, + landmine, + player, + rock, + zombieDead, + zombieIdleFrame1, + zombieIdleFrame2, + zombieIdleFrame3, + zombieIdleFrame4, + zombieWalkingFrame1, + zombieWalkingFrame2, + zombieWalkingFrame3, + zombieWalkingFrame4, + ] = await Promise.all([ + loadAssetImage("/map.webp"), + loadAssetImage("/entities/box.svg"), + loadAssetImage("/entities/landmine.svg"), + loadAssetImage("/entities/player-attacking.svg"), + loadAssetImage("/entities/rock.svg"), + loadAssetImage("/entities/zombie-dead.png"), + loadAssetImage("/entities/zombie-idle-frame1.png"), + loadAssetImage("/entities/zombie-idle-frame2.png"), + loadAssetImage("/entities/zombie-idle-frame3.png"), + loadAssetImage("/entities/zombie-idle-frame4.png"), + loadAssetImage("/entities/zombie-walking-frame1.png"), + loadAssetImage("/entities/zombie-walking-frame2.png"), + loadAssetImage("/entities/zombie-walking-frame3.png"), + loadAssetImage("/entities/zombie-walking-frame4.png"), + ]); - const [ - bg, - box, - landmine, - player, - rock, - zombieDead, - zombieIdleFrame1, - zombieIdleFrame2, - zombieIdleFrame3, - zombieIdleFrame4, - zombieWalkingFrame1, - zombieWalkingFrame2, - zombieWalkingFrame3, - zombieWalkingFrame4, - ] = await Promise.all([ - loadAssetImage("/map.webp"), - loadAssetImage("/entities/box.svg"), - loadAssetImage("/entities/landmine.svg"), - loadAssetImage("/entities/player-attacking.svg"), - loadAssetImage("/entities/rock.svg"), - loadAssetImage("/entities/zombie-dead.png"), - loadAssetImage("/entities/zombie-idle-frame1.png"), - loadAssetImage("/entities/zombie-idle-frame2.png"), - loadAssetImage("/entities/zombie-idle-frame3.png"), - loadAssetImage("/entities/zombie-idle-frame4.png"), - loadAssetImage("/entities/zombie-walking-frame1.png"), - loadAssetImage("/entities/zombie-walking-frame2.png"), - loadAssetImage("/entities/zombie-walking-frame3.png"), - loadAssetImage("/entities/zombie-walking-frame4.png"), - ]); + assets.loaded = true; + assets.bg = bg; + assets.box = box; + assets.landmine = landmine; + assets.player = player; + assets.rock = rock; + assets.zombieDead = zombieDead; + assets.zombieIdleFrame1 = zombieIdleFrame1; + assets.zombieIdleFrame2 = zombieIdleFrame2; + assets.zombieIdleFrame3 = zombieIdleFrame3; + assets.zombieIdleFrame4 = zombieIdleFrame4; + assets.zombieWalkingFrame1 = zombieWalkingFrame1; + assets.zombieWalkingFrame2 = zombieWalkingFrame2; + assets.zombieWalkingFrame3 = zombieWalkingFrame3; + assets.zombieWalkingFrame4 = zombieWalkingFrame4; - assets.loaded = true; - assets.bg = bg; - assets.box = box; - assets.landmine = landmine; - assets.player = player; - assets.rock = rock; - assets.zombieDead = zombieDead; - assets.zombieIdleFrame1 = zombieIdleFrame1; - assets.zombieIdleFrame2 = zombieIdleFrame2; - assets.zombieIdleFrame3 = zombieIdleFrame3; - assets.zombieIdleFrame4 = zombieIdleFrame4; - assets.zombieWalkingFrame1 = zombieWalkingFrame1; - assets.zombieWalkingFrame2 = zombieWalkingFrame2; - assets.zombieWalkingFrame3 = zombieWalkingFrame3; - assets.zombieWalkingFrame4 = zombieWalkingFrame4; + this.dispatchEvent(new Event("loaded")); + } } +export const assets = new RendererAssets(); + export async function loadAssetImage(src: string): Promise { return await new Promise((resolve) => { const img = new Image(); diff --git a/renderer/Effect.ts b/renderer/Effect.ts index 41c8137..426c4e1 100644 --- a/renderer/Effect.ts +++ b/renderer/Effect.ts @@ -1,4 +1,4 @@ -import { type Position } from "@/simulators/zombie-survival"; +import { type Position } from "@/simulator"; export enum RendererEffectType { AssetSwap, diff --git a/renderer/Item.ts b/renderer/Item.ts index 5cd892b..4b9e2d8 100644 --- a/renderer/Item.ts +++ b/renderer/Item.ts @@ -1,5 +1,5 @@ import { type RendererEffect, type RendererEffectType } from "./Effect"; -import { type Position } from "@/simulators/zombie-survival"; +import { type Position } from "@/simulator"; export class RendererItem { data: HTMLImageElement | string; diff --git a/renderer/Renderer.ts b/renderer/Renderer.ts index 658c183..503bd00 100644 --- a/renderer/Renderer.ts +++ b/renderer/Renderer.ts @@ -1,4 +1,4 @@ -import { assets, loadAssets } from "./Assets"; +import { assets } from "./Assets"; import * as Canvas from "./Canvas"; import { type RendererEffect, RendererEffectType } from "./Effect"; import { RendererItem } from "./Item"; @@ -6,10 +6,11 @@ import { type Entity, EntityType, type Position, + VisualEventType, Zombie, - type ZombieSurvival, -} from "@/simulators/zombie-survival"; -import { ChangeType } from "@/simulators/zombie-survival/Change"; +} from "@/simulator"; + +const ANIMATABLE_DEAD_ENTITIES = [EntityType.Zombie]; export class Renderer { private readonly cellSize: number; @@ -20,12 +21,13 @@ export class Renderer { private canvas2: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private ctx2: CanvasRenderingContext2D; + private initialized = false; private items: RendererItem[] = []; private req: number | null = null; public constructor( - boardHeight: number, boardWidth: number, + boardHeight: number, canvas: HTMLCanvasElement, cellSize: number, replaySpeed: number, @@ -59,17 +61,35 @@ export class Renderer { ctx.scale(window.devicePixelRatio, window.devicePixelRatio); ctx2.scale(window.devicePixelRatio, window.devicePixelRatio); + } - void loadAssets(); + public isInitialized() { + return this.initialized; } - public render(simulator: ZombieSurvival) { + public async initialize() { + if (this.initialized) { + return; + } + + if (!assets.loaded) { + await new Promise((resolve) => { + assets.addEventListener("loaded", () => { + resolve(); + }); + }); + } + + this.initialized = true; + } + + public render(entities: Entity[]) { if (this.req !== null) { window.cancelAnimationFrame(this.req); this.req = null; } - this.register(simulator); + this.register(entities); this.draw(); } @@ -169,9 +189,9 @@ export class Renderer { return assets.rock; } case EntityType.Zombie: { - if (entity.hasChange(ChangeType.Killed)) { + if (entity.hasVisualEvent(VisualEventType.Destructured)) { return assets.zombieDead; - } else if (entity.hasChange(ChangeType.Walking)) { + } else if (entity.hasVisualEvent(VisualEventType.Moving)) { return assets.zombieWalkingFrame1; } else { return assets.zombieIdleFrame1; @@ -180,12 +200,10 @@ export class Renderer { } } - private register(simulator: ZombieSurvival) { + private register(entities: Entity[]) { this.items = []; this.registerBg(); - const entities = simulator.getAllEntities(); - for (const entity of entities) { this.registerEntity(entity); } @@ -235,7 +253,15 @@ export class Renderer { private registerEntity(entity: Entity) { const entityImage = this.getEntityImage(entity); - if (entityImage === null || (entity.dead() && !entity.hasChanges())) { + if (entityImage === null) { + return; + } + + const animatableAfterDeath = + entity.hasVisualEvents() && + ANIMATABLE_DEAD_ENTITIES.includes(entity.getType()); + + if (entity.dead() && !animatableAfterDeath) { return; } @@ -244,20 +270,6 @@ export class Renderer { y: entity.getPosition().y * this.cellSize, }; - const healthBarItem = new RendererItem( - "#F00", - position, - (entity.getHealth() / Zombie.Health) * this.cellSize, - 2, - ); - - const healthBarBgItem = new RendererItem( - "#FFF", - position, - this.cellSize, - 2, - ); - const rendererItem = new RendererItem( entityImage, position, @@ -265,9 +277,9 @@ export class Renderer { this.cellSize, ); - if (entity.hasChange(ChangeType.Walking)) { - const change = entity.getChange(ChangeType.Walking); - const { to, from } = change; + if (entity.hasVisualEvent(VisualEventType.Moving)) { + const visualEvent = entity.getVisualEvent(VisualEventType.Moving); + const { to, from } = visualEvent; position.x = from.x * this.cellSize; position.y = from.y * this.cellSize; @@ -282,8 +294,6 @@ export class Renderer { }, }; - healthBarItem.addEffect(positionToEffect); - healthBarBgItem.addEffect(positionToEffect); rendererItem.addEffect(positionToEffect); if (from.x >= to.x) { @@ -291,7 +301,12 @@ export class Renderer { type: RendererEffectType.FlipHorizontal, }); } + } + if ( + entity.getType() === EntityType.Zombie && + entity.hasVisualEvent(VisualEventType.Moving) + ) { if ( assets.zombieWalkingFrame2 !== null && assets.zombieWalkingFrame3 !== null && @@ -332,6 +347,44 @@ export class Renderer { this.items.push(rendererItem); if (entity.getType() === EntityType.Zombie && !entity.dead()) { + const healthBarItem = new RendererItem( + "#F00", + { + x: position.x + this.cellSize * 0.1, + y: position.y, + }, + (entity.getHealth() / Zombie.Health) * (this.cellSize * 0.8), + 2, + ); + + const healthBarBgItem = new RendererItem( + "#FFF", + { + x: position.x + this.cellSize * 0.1, + y: position.y, + }, + this.cellSize * 0.8, + 2, + ); + + if (entity.hasVisualEvent(VisualEventType.Moving)) { + const visualEvent = entity.getVisualEvent(VisualEventType.Moving); + const { to } = visualEvent; + + const positionToEffect: RendererEffect = { + type: RendererEffectType.PositionTo, + duration: this.replaySpeed, + startedAt: Date.now(), + to: { + x: to.x * this.cellSize + this.cellSize * 0.1, + y: to.y * this.cellSize, + }, + }; + + healthBarItem.addEffect(positionToEffect); + healthBarBgItem.addEffect(positionToEffect); + } + this.items.push(healthBarBgItem); this.items.push(healthBarItem); } diff --git a/simulators/zombie-survival/Direction.ts b/simulator/Direction.ts similarity index 78% rename from simulators/zombie-survival/Direction.ts rename to simulator/Direction.ts index 1894376..9b595ae 100644 --- a/simulators/zombie-survival/Direction.ts +++ b/simulator/Direction.ts @@ -1,4 +1,4 @@ -import { Position } from "./Position"; +import { type Position } from "./Position"; export enum Direction { Down, @@ -14,6 +14,26 @@ export const allDirections = [ Direction.Up, ]; +export function fromDirectionString(direction: string): Direction { + switch (direction) { + case "DOWN": { + return Direction.Down; + } + case "LEFT": { + return Direction.Left; + } + case "RIGHT": { + return Direction.Right; + } + case "UP": { + return Direction.Up; + } + default: { + throw new Error(`Can't parse direction: ${direction}`); + } + } +} + export function directionToString(direction: Direction): string { switch (direction) { case Direction.Down: { diff --git a/simulator/Entity.ts b/simulator/Entity.ts new file mode 100644 index 0000000..577436c --- /dev/null +++ b/simulator/Entity.ts @@ -0,0 +1,122 @@ +import { type Position } from "./Position"; +import { type VisualEvent, VisualEventType } from "./VisualEvent"; + +export enum EntityType { + Box, + Landmine, + Player, + Rock, + Zombie, +} + +export abstract class Entity { + protected destructible: boolean; + protected health: number; + protected position: Position; + protected type: EntityType; + protected visualEvents: VisualEvent[] = []; + + public abstract getToken(): string; + + public constructor( + type: EntityType, + destructible: boolean, + health: number, + position: Position, + ) { + this.destructible = destructible; + this.health = health; + this.position = position; + this.type = type; + } + + public addVisualEvent(visualEvent: VisualEvent): void { + this.visualEvents.push(visualEvent); + } + + public clearVisualEvents(): void { + this.visualEvents = []; + } + + public dead(): boolean { + return this.health === 0; + } + + public die(): void { + this.health = 0; + } + + public getVisualEvent(type: T) { + const visualEvent = this.visualEvents.find( + (visualEvent) => visualEvent.type === type, + ); + + if (visualEvent === undefined) { + throw new Error("Unable to find visual event of this type"); + } + + return visualEvent as Extract; + } + + public getChanges(): VisualEvent[] { + return this.visualEvents; + } + + public getPosition(): Position { + return this.position; + } + + public getPositionId(): string { + return `${this.position.x}.${this.position.y}`; + } + + public getPositionAsNumber(): number { + return this.position.x + this.position.y; + } + + public getType(): EntityType { + return this.type; + } + + public hasVisualEvent(type: VisualEventType): boolean { + return this.visualEvents.some((visualEvent) => visualEvent.type === type); + } + + public hasVisualEvents(): boolean { + return this.visualEvents.length !== 0; + } + + public hit() { + if (!this.destructible) { + return; + } + + const initialHealth = this.health; + this.health--; + + if (initialHealth !== 0 && this.health === 0) { + this.addVisualEvent({ type: VisualEventType.Destructured }); + } else if (initialHealth !== this.health) { + this.addVisualEvent({ type: VisualEventType.Hit }); + } + } + + public getHealth(): number { + return this.health; + } + + public isDestructible(): boolean { + return this.destructible; + } + + public moveTo(position: Position) { + const initialPosition = { ...this.position }; + this.position = position; + + this.addVisualEvent({ + type: VisualEventType.Moving, + from: initialPosition, + to: this.position, + }); + } +} diff --git a/simulators/zombie-survival/Position.ts b/simulator/Position.ts similarity index 100% rename from simulators/zombie-survival/Position.ts rename to simulator/Position.ts diff --git a/simulator/VisualEvent.ts b/simulator/VisualEvent.ts new file mode 100644 index 0000000..d24bc33 --- /dev/null +++ b/simulator/VisualEvent.ts @@ -0,0 +1,26 @@ +import { type Position } from "./Position"; + +export enum VisualEventType { + Destructured, + Hit, + Moving, +} + +export type VisualEvent = + | DestructuredVisualEvent + | HitVisualEvent + | MovingVisualEvent; + +export interface DestructuredVisualEvent { + type: VisualEventType.Destructured; +} + +export interface HitVisualEvent { + type: VisualEventType.Hit; +} + +export interface MovingVisualEvent { + type: VisualEventType.Moving; + from: Position; + to: Position; +} diff --git a/simulators/zombie-survival/ZombieSurvival.spec.ts b/simulator/ZombieSurvival.spec.ts similarity index 95% rename from simulators/zombie-survival/ZombieSurvival.spec.ts rename to simulator/ZombieSurvival.spec.ts index fae5121..7aea8d4 100644 --- a/simulators/zombie-survival/ZombieSurvival.spec.ts +++ b/simulator/ZombieSurvival.spec.ts @@ -1,9 +1,9 @@ import { expect, test } from "vitest"; import { ZombieSurvival } from "./ZombieSurvival"; -test("fails on invalid config", () => { - expect(() => new ZombieSurvival([])).toThrowError("Config is empty"); - expect(() => new ZombieSurvival([[]])).toThrowError("Config is empty"); +test("fails on invalid map", () => { + expect(() => new ZombieSurvival([])).toThrowError("Map is empty"); + expect(() => new ZombieSurvival([[]])).toThrowError("Map is empty"); expect( () => @@ -18,7 +18,7 @@ test("fails on invalid config", () => { [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], ]), - ).toThrowError("Config has no player"); + ).toThrowError("Single player map has no player"); expect( () => @@ -33,7 +33,7 @@ test("fails on invalid config", () => { [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], ]), - ).toThrowError("Config contains multiple players"); + ).toThrowError("Single player map contains multiple players"); expect( () => @@ -48,10 +48,10 @@ test("fails on invalid config", () => { [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], ]), - ).toThrowError("Config has no zombies"); + ).toThrowError("Map has no zombies"); }); -test("fails on impossible to beat config", () => { +test("fails on impossible to beat map", () => { const game = new ZombieSurvival([ [" ", " ", " ", " ", "R", " ", " ", " ", " ", " "], [" ", " ", " ", "P", "R", " ", " ", " ", " ", " "], @@ -64,7 +64,9 @@ test("fails on impossible to beat config", () => { [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], ]); - expect(() => game.step()).toThrowError("Unable to solve game"); + expect(() => game.step()).toThrowError( + "Unable to find path for the next move", + ); }); test("works with different boards sizes", () => { @@ -427,3 +429,15 @@ test("player kills closest zombie", () => { expect(game.finished()).toBeTruthy(); }); + +test("the game state should persist numbered players", () => { + const game = new ZombieSurvival([ + ["Z", " "], + [" ", "1"], + ]); + + expect(game.getState()).toStrictEqual([ + ["Z", " "], + [" ", "1"], + ]); +}); diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts new file mode 100644 index 0000000..3f2c97c --- /dev/null +++ b/simulator/ZombieSurvival.ts @@ -0,0 +1,355 @@ +import { entityAt } from "../lib/entityAt"; +import { Direction, allDirections, move } from "./Direction"; +import { type Entity } from "./Entity"; +import { type Position } from "./Position"; +import { Box } from "./entities/Box"; +import { Landmine } from "./entities/Landmine"; +import { Player } from "./entities/Player"; +import { Rock } from "./entities/Rock"; +import { Zombie } from "./entities/Zombie"; + +export interface ZombieSurvivalOptions { + multiplayer?: boolean; +} + +export class ZombieSurvival { + public readonly boardHeight: number; + public readonly boardWidth: number; + private entities: Entity[] = []; + private multiplayer; + private players: Player[] = []; + private zombies: Zombie[] = []; + + public constructor(map: string[][], options: ZombieSurvivalOptions = {}) { + if (ZombieSurvival.mapIsEmpty(map)) { + throw new Error("Map is empty"); + } + + this.boardWidth = map[0].length; + this.boardHeight = map.length; + this.multiplayer = options.multiplayer === true; + let isSinglePlayer = false; + + for (let y = 0; y < this.boardHeight; y++) { + for (let x = 0; x < this.boardWidth; x++) { + const code = map[y][x]; + + switch (code.toLowerCase().substring(0, 1)) { + case "b": { + this.entities.push(new Box({ x, y })); + break; + } + case "l": { + this.entities.push(new Landmine({ x, y })); + break; + } + case "r": { + this.entities.push(new Rock({ x, y })); + break; + } + case "z": { + const [, health] = code.split(":"); + if (health) { + this.zombies.push(new Zombie(this, { x, y }, parseInt(health))); + } else { + this.zombies.push(new Zombie(this, { x, y })); + } + break; + } + case "p": { + if (this.multiplayer) { + throw new Error( + "Mixing multiplayer and single player maps is not allowed", + ); + } + + if (this.players.length !== 0) { + throw new Error("Single player map contains multiple players"); + } + + if (!isSinglePlayer) { + isSinglePlayer = true; + } + + const player = new Player(this, { x, y }, code); + this.players.push(player); + + break; + } + case "1": + case "2": + case "3": + case "4": + case "5": + case "6": { + if (isSinglePlayer) { + throw new Error( + "Mixing multiplayer and single player maps is not allowed", + ); + } + + if (!this.multiplayer) { + this.multiplayer = true; + } + + const player = new Player(this, { x, y }, code); + this.players.push(player); + + break; + } + } + } + } + + if (!this.multiplayer && this.players.length === 0) { + throw new Error("Single player map has no player"); + } + + if (!this.multiplayer && this.zombies.length === 0) { + throw new Error("Map has no zombies"); + } + } + + public static boardHeight(map: string[][]): number { + return map.length; + } + + public static boardWidth(map: string[][]): number { + return map[0]?.length ?? 0; + } + + public static cloneMap(map: string[][]): string[][] { + return [...map.map((row) => [...row])]; + } + + public static entityPosition(map: string[][], token: string): Position { + for (let y = 0; y < map.length; y++) { + for (let x = 0; x < map[y].length; x++) { + if (map[y][x] === token) { + return { x, y }; + } + } + } + + throw new Error(`Entity position for token '${token}' not found`); + } + + public static isWin(map: string[][]): boolean { + if (ZombieSurvival.mapIsEmpty(map)) { + return false; + } + + const game = new ZombieSurvival(map); + + while (!game.finished()) { + game.step(); + } + + return game.getPlayer() === null || !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 static mapIsMultiplayer(map: string[][]): boolean { + return map.flat().some((it) => ["1", "2", "3", "4", "5", "6"].includes(it)); + } + + public static validLocations(map: string[][]): Array<[number, number]> { + return map.flatMap((row, y) => + row.reduce( + (acc, cell, x) => { + if (cell === " ") { + acc.push([y, x]); + } + return acc; + }, + [] as Array<[number, number]>, + ), + ); + } + + public static validMoveDirections( + map: string[][], + playerToken: string, + ): string[] { + const position = ZombieSurvival.entityPosition(map, playerToken); + const validDirections: string[] = []; + + for (const direction of allDirections) { + const newPosition = move(position, direction); + + if ( + newPosition.x >= 0 && + newPosition.x < map[0].length && + newPosition.y >= 0 && + newPosition.y < map.length + ) { + if (map[newPosition.y][newPosition.x] === " ") { + switch (direction) { + case 0: + validDirections.push("DOWN"); + break; + case 1: + validDirections.push("LEFT"); + break; + case 2: + validDirections.push("RIGHT"); + break; + case 3: + validDirections.push("UP"); + break; + } + } + } + } + + return validDirections; + } + + public finished(): boolean { + return ( + this.players.every((player) => player.dead()) || + this.zombies.every((zombie) => zombie.dead()) + ); + } + + public getClosestPlayer(position: Position): Player | undefined { + let closestPlayer: Player | undefined; + let closestDistance = Infinity; + + for (const player of this.players) { + const distance = Math.sqrt( + (player.getPosition().x - position.x) ** 2 + + (player.getPosition().y - position.y) ** 2, + ); + + if (distance < closestDistance) { + closestDistance = distance; + closestPlayer = player; + } + } + + return closestPlayer; + } + + public getAllEntities(): Entity[] { + return [this.entities, this.zombies, this.players].flat(); + } + + public getEntities(): Entity[] { + return this.entities; + } + + public getPlayer(token: string | null = null): Player { + if (!this.multiplayer) { + return this.players[0]; + } + + if (token === null) { + throw new Error("Tried getting a player for a multiplayer simulator"); + } + + for (const player of this.players) { + if (player.getToken() === token) { + return player; + } + } + + throw new Error(`Tried getting non-existing player '${token}'`); + } + + public getZombieAt(position: Position): Zombie | undefined { + return this.zombies.find( + (zombie) => + zombie.getPosition().x === position.x && + zombie.getPosition().y === position.y, + ); + } + + public getState(): string[][] { + const entities = this.getAllEntities(); + let result: string[][] = []; + + for (let y = 0; y < this.boardHeight; y++) { + const item: string[] = []; + + for (let x = 0; x < this.boardWidth; x++) { + const entity = entityAt(entities, { x, y }); + item.push(entity === null ? " " : entity.getToken()); + } + + result.push(item); + } + + return result; + } + + public getZombies(): Zombie[] { + return this.zombies; + } + + public resetVisualEvents() { + const entities = this.getAllEntities(); + + for (const entity of entities) { + entity.clearVisualEvents(); + } + } + + public step(): void { + this.resetVisualEvents(); + this.stepPlayers(); + this.stepZombies(); + } + + public stepPlayer(token: string): void { + this.getPlayer(token).shoot(); + } + + public stepPlayers(): void { + for (const player of this.players) { + player.shoot(); + } + } + + public stepZombies(): void { + for (let i = 0; i < this.zombies.length && !this.finished(); i++) { + this.zombies[i].walk(); + } + } + + public isValidPosition(position: Position): boolean { + return ( + position.x >= 0 && + position.x < this.boardWidth && + position.y >= 0 && + position.y < this.boardHeight + ); + } + + public isPositionEmpty(position: Position): boolean { + return ( + this.getAllEntities().find( + (entity) => + entity.getPosition().x === position.x && + entity.getPosition().y === position.y, + ) === undefined + ); + } +} diff --git a/simulators/zombie-survival/entities/Box.ts b/simulator/entities/Box.ts similarity index 73% rename from simulators/zombie-survival/entities/Box.ts rename to simulator/entities/Box.ts index 9cd339e..e74f468 100644 --- a/simulators/zombie-survival/entities/Box.ts +++ b/simulator/entities/Box.ts @@ -1,5 +1,5 @@ import { Entity, EntityType } from "../Entity"; -import { Position } from "../Position"; +import { type Position } from "../Position"; export class Box extends Entity { public static Destructible = true; @@ -8,4 +8,8 @@ export class Box extends Entity { public constructor(position: Position) { super(EntityType.Box, Box.Destructible, Box.Health, position); } + + public getToken(): string { + return "B"; + } } diff --git a/simulators/zombie-survival/entities/Landmine.ts b/simulator/entities/Landmine.ts similarity index 76% rename from simulators/zombie-survival/entities/Landmine.ts rename to simulator/entities/Landmine.ts index 8200a8b..48070f7 100644 --- a/simulators/zombie-survival/entities/Landmine.ts +++ b/simulator/entities/Landmine.ts @@ -1,5 +1,5 @@ import { Entity, EntityType } from "../Entity"; -import { Position } from "../Position"; +import { type Position } from "../Position"; export class Landmine extends Entity { public static Destructible = true; @@ -13,4 +13,8 @@ export class Landmine extends Entity { position, ); } + + public getToken(): string { + return "L"; + } } diff --git a/simulators/zombie-survival/entities/Player.ts b/simulator/entities/Player.ts similarity index 50% rename from simulators/zombie-survival/entities/Player.ts rename to simulator/entities/Player.ts index b21c222..3e2d830 100644 --- a/simulators/zombie-survival/entities/Player.ts +++ b/simulator/entities/Player.ts @@ -1,21 +1,34 @@ +import { closestEntity } from "../../lib/closestEntity"; import { Entity, EntityType } from "../Entity"; -import { Position } from "../Position"; -import { ZombieSurvival } from "../ZombieSurvival"; -import { closestEntity } from "../lib/closestEntity"; +import { type Position } from "../Position"; +import { type ZombieSurvival } from "../ZombieSurvival"; export class Player extends Entity { public static Destructible = true; public static Health = 1; public static ShootDistance = Infinity; + public token = "P"; private game: ZombieSurvival; - public constructor(game: ZombieSurvival, position: Position) { + public constructor(game: ZombieSurvival, position: Position, token?: string) { super(EntityType.Player, Player.Destructible, Player.Health, position); this.game = game; + + if (token !== undefined) { + this.token = token; + } + } + + public getToken(): string { + return this.token; } public shoot() { + if (this.dead()) { + return; + } + const zombie = closestEntity(this, this.game.getZombies()); zombie.hit(); } diff --git a/simulators/zombie-survival/entities/Rock.ts b/simulator/entities/Rock.ts similarity index 73% rename from simulators/zombie-survival/entities/Rock.ts rename to simulator/entities/Rock.ts index bf14e4f..49547fb 100644 --- a/simulators/zombie-survival/entities/Rock.ts +++ b/simulator/entities/Rock.ts @@ -1,5 +1,5 @@ import { Entity, EntityType } from "../Entity"; -import { Position } from "../Position"; +import { type Position } from "../Position"; export class Rock extends Entity { public static Destructible = false; @@ -8,4 +8,8 @@ export class Rock extends Entity { public constructor(position: Position) { super(EntityType.Rock, Rock.Destructible, Rock.Health, position); } + + public getToken(): string { + return "R"; + } } diff --git a/simulator/entities/Zombie.ts b/simulator/entities/Zombie.ts new file mode 100644 index 0000000..4e053db --- /dev/null +++ b/simulator/entities/Zombie.ts @@ -0,0 +1,133 @@ +import { entityAt } from "../../lib/entityAt"; +import { type Direction, allDirections, move } from "../Direction"; +import { Entity, EntityType } from "../Entity"; +import { type Position } from "../Position"; +import { type ZombieSurvival } from "../ZombieSurvival"; + +export class Zombie extends Entity { + public static Destructible = true; + public static Health = 2; + + private game: ZombieSurvival; + + public constructor( + game: ZombieSurvival, + position: Position, + health?: number, + ) { + super(EntityType.Zombie, Zombie.Destructible, Zombie.Health, position); + this.game = game; + this.health = health ?? Zombie.Health; + } + + public getToken(): string { + return "Z" + ":" + this.health; + } + + public walk(direction: Direction | null = null) { + if (this.dead()) { + return; + } + + const nextDirection = direction ?? this.findPath()[0]; + const entities = this.game.getAllEntities(); + const newPosition = move(this.position, nextDirection); + const entity = entityAt(entities, newPosition); + + if (entity?.getType() === EntityType.Landmine) { + this.die(); + } + + if (entity !== null && entity.getType() !== EntityType.Zombie) { + entity.hit(); + } + + this.moveTo(newPosition); + } + + private findPath(): Direction[] { + const player = this.game.getClosestPlayer(this.position); + + if (player === undefined) { + throw new Error("No player found"); + } + + const initialPosition = this.getPosition(); + + const queue: Array<{ x: number; y: number; path: Direction[] }> = [ + { x: initialPosition.x, y: initialPosition.y, path: [] }, + ]; + + const visited = new Set(); + + while (queue.length > 0) { + const { x, y, path } = queue.shift()!; + const positionKey = `${x},${y}`; + + if (visited.has(positionKey)) { + continue; + } + + visited.add(positionKey); + + if (player.getPosition().x === x && player.getPosition().y === y) { + return path; + } + + for (const direction of allDirections) { + const position = move({ x, y }, direction); + + if ( + position.x < 0 || + position.y < 0 || + position.x >= this.game.boardWidth || + position.y >= this.game.boardHeight + ) { + continue; + } + + const entity = entityAt(this.game.getEntities(), position); + + if (entity !== null && !entity.isDestructible()) { + continue; + } + + queue.push({ + x: position.x, + y: position.y, + path: [...path, direction], + }); + } + } + + throw new Error("Unable to find path for the next move"); + } + + private listMoves(): Direction[] { + const entities = this.game.getAllEntities(); + const result: Direction[] = []; + + for (const direction of allDirections) { + const position = move(this.position, direction); + + if ( + position.x < 0 || + position.y < 0 || + position.x >= this.game.boardWidth || + position.y >= this.game.boardHeight + ) { + continue; + } + + const entity = entityAt(entities, position); + + if (entity !== null && !entity.isDestructible()) { + continue; + } + + result.push(direction); + } + + return result; + } +} diff --git a/simulators/zombie-survival/index.ts b/simulator/index.ts similarity index 90% rename from simulators/zombie-survival/index.ts rename to simulator/index.ts index 73a56d3..2a8fc58 100644 --- a/simulators/zombie-survival/index.ts +++ b/simulator/index.ts @@ -6,4 +6,5 @@ export * from "./entities/Zombie"; export * from "./Direction"; export * from "./Entity"; export * from "./Position"; +export * from "./VisualEvent"; export * from "./ZombieSurvival"; diff --git a/simulators/zombie-survival/Change.ts b/simulators/zombie-survival/Change.ts deleted file mode 100644 index 4baa116..0000000 --- a/simulators/zombie-survival/Change.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Position } from "./Position"; - -export enum ChangeType { - Hit, - Killed, - Walking, -} - -export type Change = HitChange | KilledChange | WalkingChange; - -export interface HitChange { - type: ChangeType.Hit; -} - -export interface KilledChange { - type: ChangeType.Killed; -} - -export interface WalkingChange { - type: ChangeType.Walking; - from: Position; - to: Position; -} diff --git a/simulators/zombie-survival/Entity.ts b/simulators/zombie-survival/Entity.ts deleted file mode 100644 index 5d68dd2..0000000 --- a/simulators/zombie-survival/Entity.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { type Change, ChangeType } from "./Change"; -import { Position } from "./Position"; - -export enum EntityType { - Box, - Landmine, - Player, - Rock, - Zombie, -} - -export class Entity { - protected destructible: boolean; - protected changes: Change[] = []; - protected health: number; - protected position: Position; - protected type: EntityType; - - public constructor( - type: EntityType, - destructible: boolean, - health: number, - position: Position, - ) { - this.destructible = destructible; - this.health = health; - this.position = position; - this.type = type; - } - - public addChange(change: Change): void { - this.changes.push(change); - } - - public clearChanges(): void { - this.changes = []; - } - - public dead(): boolean { - return this.health === 0; - } - - public die(): void { - this.health = 0; - } - - public getChange(type: T) { - const change = this.changes.find((change) => change.type === type); - - if (change === undefined) { - throw new Error("Unable to find change of this type"); - } - - return change as Extract; - } - - public getChanges(): Change[] { - return this.changes; - } - - public getPosition(): Position { - return this.position; - } - - public getPositionId(): string { - return `${this.position.x}.${this.position.y}`; - } - - public getPositionAsNumber(): number { - return this.position.x + this.position.y; - } - - public getType(): EntityType { - return this.type; - } - - public hasChange(type: ChangeType): boolean { - return this.changes.some((change) => change.type === type); - } - - public hasChanges(): boolean { - return this.changes.length !== 0; - } - - public hit() { - if (!this.destructible) { - return; - } - - this.health--; - } - - public getHealth(): number { - return this.health; - } - - public isDestructible(): boolean { - return this.destructible; - } - - public toConfig(): string { - let letter = " "; - - if (this.type === EntityType.Box) { - letter = "B"; - } else if (this.type === EntityType.Landmine) { - letter = "L"; - } else if (this.type === EntityType.Player) { - letter = "P"; - } else if (this.type === EntityType.Rock) { - letter = "R"; - } else if (this.type === EntityType.Zombie) { - letter = "Z"; - } - - return letter; - } -} diff --git a/simulators/zombie-survival/ZombieSurvival.ts b/simulators/zombie-survival/ZombieSurvival.ts deleted file mode 100644 index 88f374b..0000000 --- a/simulators/zombie-survival/ZombieSurvival.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { ChangeType } from "./Change"; -import { Entity } from "./Entity"; -import { Position, samePosition } from "./Position"; -import { Box } from "./entities/Box"; -import { Landmine } from "./entities/Landmine"; -import { Player } from "./entities/Player"; -import { Rock } from "./entities/Rock"; -import { Zombie } from "./entities/Zombie"; -import { entityAt } from "./lib/entityAt"; - -export class ZombieSurvival { - public readonly boardHeight: number; - public readonly boardWidth: number; - private entities: Entity[]; - private player: Player; - private zombies: Zombie[]; - - public static boardHeight(map: string[][]): number { - return map.length; - } - - public static boardWidth(map: string[][]): number { - return map[0]?.length ?? 0; - } - - public static cloneMap(map: string[][]): string[][] { - return [...map.map((row) => [...row])]; - } - - public static fromSnapshot(snapshot: string): ZombieSurvival { - const config = snapshot.split(".").map((it) => it.split("")); - return new ZombieSurvival(config); - } - - public static isWin(config: string[][]): boolean { - if (ZombieSurvival.mapIsEmpty(config)) { - return false; - } - - const game = new ZombieSurvival(config); - - while (!game.finished()) { - game.step(); - } - - 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 static nextValidPosition(map: string[][]): Position | null { - for (let y = 0; y < map.length; y++) { - for (let x = 0; x < map[y].length; x++) { - if (map[y][x] === " ") { - return { x, y }; - } - } - } - - return null; - } - - public static validLocations(map: string[][]): number[][] { - return map.flatMap((row, y) => - row.reduce((acc, cell, x) => { - if (cell === " ") { - acc.push([y, x]); - } - return acc; - }, [] as number[][]), - ); - } - - public constructor(config: string[][]) { - if (ZombieSurvival.mapIsEmpty(config)) { - throw new Error("Config is empty"); - } - - this.boardWidth = config[0].length; - this.boardHeight = config.length; - this.entities = []; - this.zombies = []; - - let player: Player | null = null; - - for (let y = 0; y < this.boardHeight; y++) { - for (let x = 0; x < this.boardWidth; x++) { - const code = config[y][x]; - - switch (code.toLowerCase()) { - case "b": { - this.entities.push(new Box({ x, y })); - break; - } - case "l": { - this.entities.push(new Landmine({ x, y })); - break; - } - case "p": { - if (player !== null) { - throw new Error("Config contains multiple players"); - } - - player = new Player(this, { x, y }); - break; - } - case "r": { - this.entities.push(new Rock({ x, y })); - break; - } - case "z": { - this.zombies.push(new Zombie(this, { x, y })); - break; - } - } - } - } - - if (player === null) { - throw new Error("Config has no player"); - } - - this.player = player; - - if (this.zombies.length === 0) { - throw new Error("Config has no zombies"); - } - } - - public finished(): boolean { - return this.player.dead() || this.zombies.every((zombie) => zombie.dead()); - } - - public getAllEntities(): Entity[] { - return [this.entities, this.zombies, this.player].flat(); - } - - public getAllAliveEntities(): Entity[] { - return [this.entities, this.zombies, this.player] - .flat() - .filter((entity) => !entity.dead()); - } - - public getEntities(): Entity[] { - return this.entities; - } - - public getPlayer(): Player { - return this.player; - } - - public getSnapshot(): string { - return this.getState() - .map((it) => it.join("")) - .join("."); - } - - public getState(): string[][] { - const entities = this.getAllEntities(); - let config: string[][] = []; - - for (let y = 0; y < this.boardHeight; y++) { - const item: string[] = []; - - for (let x = 0; x < this.boardWidth; x++) { - const entity = entityAt(entities, { x, y }); - item.push(entity === null ? " " : entity.toConfig()); - } - - config.push(item); - } - - return config; - } - - public getEntityAt(position: Position): Entity | null { - return entityAt(this.getAllEntities(), position); - } - - public getZombie(): Zombie { - return this.zombies[0]; - } - - public getZombies(): Zombie[] { - return this.zombies; - } - - public setZombies(zombies: Zombie[]): this { - if (zombies.length === 0) { - throw new Error("Tried setting zero zombies"); - } - - this.zombies = zombies; - return this; - } - - public step() { - const initialHealth = this.zombies.map((zombie) => zombie.getHealth()); - - this.player.clearChanges(); - this.player.shoot(); - - for (let i = 0; i < this.zombies.length && !this.player.dead(); i++) { - const zombie = this.zombies[i]; - const initialPosition = zombie.getPosition(); - const initialZombieHealth = initialHealth[i]; - - zombie.clearChanges(); - zombie.walk(); - - if (initialZombieHealth !== 0 && zombie.getHealth() === 0) { - zombie.addChange({ type: ChangeType.Killed }); - } - - if (initialZombieHealth !== zombie.getHealth()) { - zombie.addChange({ type: ChangeType.Hit }); - } - - const currentPosition = zombie.getPosition(); - - if (!samePosition(initialPosition, currentPosition)) { - zombie.addChange({ - type: ChangeType.Walking, - from: initialPosition, - to: currentPosition, - }); - } - } - } -} diff --git a/simulators/zombie-survival/entities/Zombie.ts b/simulators/zombie-survival/entities/Zombie.ts deleted file mode 100644 index d467195..0000000 --- a/simulators/zombie-survival/entities/Zombie.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Direction, allDirections, move } from "../Direction"; -import { Entity, EntityType } from "../Entity"; -import { Position } from "../Position"; -import { ZombieSurvival } from "../ZombieSurvival"; -import { entityAt } from "../lib/entityAt"; -import { pathfinder } from "../lib/pathfinder"; - -export class Zombie extends Entity { - public static Destructible = true; - public static Health = 2; - - private game: ZombieSurvival; - - public constructor(game: ZombieSurvival, position: Position) { - super(EntityType.Zombie, Zombie.Destructible, Zombie.Health, position); - this.game = game; - } - - public listMoves(): Direction[] { - const entities = this.game.getAllEntities(); - const result: Direction[] = []; - - for (const direction of allDirections) { - const position = move(this.position, direction); - - if ( - position.x < 0 || - position.y < 0 || - position.x >= this.game.boardWidth || - position.y >= this.game.boardHeight - ) { - continue; - } - - const entity = entityAt(entities, position); - - if (entity !== null && !entity.isDestructible()) { - continue; - } - - result.push(direction); - } - - return result; - } - - public walk(direction: Direction | null = null) { - if (this.dead()) { - return; - } - - let nextDirection = direction ?? pathfinder(this.game, this)[0]; - - const entities = this.game.getAllEntities(); - const newPosition = move(this.position, nextDirection); - const entity = entityAt(entities, newPosition); - - if (entity !== null) { - if (entity.getType() !== EntityType.Zombie) { - if (entity.getType() === EntityType.Landmine) { - this.die(); - } - - entity.hit(); - } - - return; - } - - this.walkTo(newPosition); - } - - public walkTo(position: Position) { - this.position = position; - } -} diff --git a/simulators/zombie-survival/lib/pathfinder.ts b/simulators/zombie-survival/lib/pathfinder.ts deleted file mode 100644 index 3a81627..0000000 --- a/simulators/zombie-survival/lib/pathfinder.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Direction, allDirections, move } from "../Direction"; -import { ZombieSurvival } from "../ZombieSurvival"; -import { Zombie } from "../entities/Zombie"; -import { entityAt } from "./entityAt"; - -export function pathfinder( - initialGame: ZombieSurvival, - initialZombie: Zombie, -): Direction[] { - const player = initialGame.getPlayer(); - - const initialPosition = initialZombie.getPosition(); - const queue: Array<{ x: number; y: number; path: Direction[] }> = [ - { x: initialPosition.x, y: initialPosition.y, path: [] }, - ]; - const visited = new Set(); - - while (queue.length > 0) { - const { x, y, path } = queue.shift()!; - const positionKey = `${x},${y}`; - - if (visited.has(positionKey)) { - continue; - } - visited.add(positionKey); - - if (player.getPosition().x === x && player.getPosition().y === y) { - return path; - } - - for (const direction of allDirections) { - const position = move({ x, y }, direction); - - if ( - position.x < 0 || - position.y < 0 || - position.x >= initialGame.boardWidth || - position.y >= initialGame.boardHeight - ) { - continue; - } - - const entity = entityAt(initialGame.getEntities(), position); - - if (entity !== null && !entity.isDestructible()) { - continue; - } - - queue.push({ x: position.x, y: position.y, path: [...path, direction] }); - } - } - - throw new Error("Unable to solve game"); -}