From c9a0a810d928e0b15922fd4b50dd350fc4f39549 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sun, 3 Nov 2024 12:48:09 -0500 Subject: [PATCH 01/24] adding in the ability for multiple models to play the game --- app/multiplayer/[multiplayerGameId]/page.tsx | 57 +++++++ components/Visualizer.tsx | 32 ++-- convex/_generated/api.d.ts | 2 + convex/multiplayerGame.ts | 112 +++++++++++++ convex/schema.ts | 9 + renderer/Renderer.ts | 12 +- simulators/zombie-survival/Entity.ts | 5 +- .../zombie-survival/ZombieSurvival.spec.ts | 12 ++ simulators/zombie-survival/ZombieSurvival.ts | 158 ++++++++++-------- simulators/zombie-survival/entities/Player.ts | 10 +- simulators/zombie-survival/lib/pathfinder.ts | 4 + 11 files changed, 323 insertions(+), 90 deletions(-) create mode 100644 app/multiplayer/[multiplayerGameId]/page.tsx create mode 100644 convex/multiplayerGame.ts diff --git a/app/multiplayer/[multiplayerGameId]/page.tsx b/app/multiplayer/[multiplayerGameId]/page.tsx new file mode 100644 index 0000000..bf06081 --- /dev/null +++ b/app/multiplayer/[multiplayerGameId]/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "convex/react"; +import { Page, PageTitle } from "@/components/Page"; +import { api } from "@/convex/_generated/api"; +import { Id } from "@/convex/_generated/dataModel"; +import { Renderer } from "@/renderer"; +import { ZombieSurvival } from "@/simulators/zombie-survival"; + +export default function Multiplayer({ + params, +}: { + params: { multiplayerGameId: Id<"multiplayerGame"> }; +}) { + const multiplayerGame = useQuery(api.multiplayerGame.getMultiplayerGame, { + multiplayerGameId: params.multiplayerGameId, + }); + const renderer = useRef(null); + const canvas = useRef(null); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + if (multiplayerGame && canvas.current !== null) { + const map = multiplayerGame.boardState; + renderer.current = new Renderer( + ZombieSurvival.boardHeight(map), + ZombieSurvival.boardWidth(map), + canvas.current, + Number.parseInt("64", 10), + 1000, + ); + renderer.current.initialize().then(() => { + setIsInitialized(true); + }); + } + }, [multiplayerGame]); + + useEffect(() => { + if (renderer.current && isInitialized && multiplayerGame) { + const simulator = new ZombieSurvival(multiplayerGame.boardState); + renderer.current?.render(simulator.getAllEntities()); + } + }, [isInitialized, multiplayerGame]); + + if (!multiplayerGame) { + return
Loading...
; + } + return ( + + Multiplayer +
+ +
+
+ ); +} diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx index 56e2114..1cad8c3 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { AUTO_REPLAY_SPEED, @@ -26,15 +26,15 @@ export function Visualizer({ onSimulationEnd?: (isWin: boolean) => unknown; replaySpeed?: number; }) { - 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(() => { + const simulator = useRef(new ZombieSurvival(map)); + const renderer = useRef(null); + const interval = useRef | null>(null); + const timeout = useRef | null>(null); + const canvas = useRef(null); + const visible = useRef(false); + const [running, setRunning] = useState(false); + + useEffect(() => { if (canvas.current !== null) { renderer.current = new Renderer( ZombieSurvival.boardHeight(map), @@ -46,7 +46,7 @@ export function Visualizer({ } }, [canvas, cellSize, map, replaySpeed]); - React.useEffect(() => { + useEffect(() => { if (autoStart) { startSimulation(); } @@ -54,7 +54,7 @@ export function Visualizer({ function startSimulation() { simulator.current = new ZombieSurvival(map); - renderer.current?.render(simulator.current); + renderer.current?.render(simulator.current.getAllEntities()); setRunning(true); interval.current = setInterval(() => { @@ -64,7 +64,7 @@ export function Visualizer({ if (!simulator.current.finished()) { simulator.current.step(); - renderer.current?.render(simulator.current); + renderer.current?.render(simulator.current.getAllEntities()); return; } @@ -83,12 +83,12 @@ export function Visualizer({ setRunning(false); if (onSimulationEnd) { - onSimulationEnd(!simulator.current.getPlayer().dead()); + onSimulationEnd(!simulator.current.getPlayer()?.dead()); } }, replaySpeed); } - React.useEffect(() => { + useEffect(() => { if (canvas.current === null) { return; } @@ -104,7 +104,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..77b3cbd 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 multiplayerGame from "../multiplayerGame.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; + multiplayerGame: typeof multiplayerGame; playerresults: typeof playerresults; prompts: typeof prompts; rateLimits: typeof rateLimits; diff --git a/convex/multiplayerGame.ts b/convex/multiplayerGame.ts new file mode 100644 index 0000000..8339bc1 --- /dev/null +++ b/convex/multiplayerGame.ts @@ -0,0 +1,112 @@ +import { ZombieSurvival } from "../simulators/zombie-survival"; +import { v } from "convex/values"; +import { api, internal } from "./_generated/api"; +import { Id } from "./_generated/dataModel"; +import { internalAction, internalMutation, query } from "./_generated/server"; + +const HARD_CODED_MODEL_ID = "m1757gn800qrc8s4jpdcshbgah72s9e7" as Id<"models">; +const HARD_CODED_PLAYER_TOKEN = "1"; +const TURN_DELAY = 5000; + +export const startMultiplayerGame = internalMutation({ + args: {}, + handler: async (ctx) => { + const gameId = await ctx.db.insert("multiplayerGame", { + boardState: [ + ["Z", " ", " ", " ", " "], + [" ", " ", " ", " ", " "], + [" ", " ", " ", " ", " "], + [" ", " ", " ", " ", " "], + [" ", " ", " ", " ", " "], + [" ", " ", " ", " ", " "], + [" ", " ", " ", " ", HARD_CODED_PLAYER_TOKEN], + ], + playerMap: [ + { modelId: HARD_CODED_MODEL_ID, playerToken: HARD_CODED_PLAYER_TOKEN }, + ], + }); + + await ctx.scheduler.runAfter( + 0, + internal.multiplayerGame.runMultiplayerGameTurn, + { + multiplayerGameId: gameId, + turn: HARD_CODED_PLAYER_TOKEN, + }, + ); + + return gameId; + }, +}); + +export const getMultiplayerGame = query({ + args: { multiplayerGameId: v.id("multiplayerGame") }, + handler: async (ctx, args) => { + return await ctx.db.get(args.multiplayerGameId); + }, +}); + +export const updateMultiplayerGameBoardState = internalMutation({ + args: { + multiplayerGameId: v.id("multiplayerGame"), + 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("multiplayerGame"), + }, + handler: async (ctx, args) => { + const { turn, multiplayerGameId } = args; + + const multiplayerGame = await ctx.runQuery( + api.multiplayerGame.getMultiplayerGame, + { + multiplayerGameId, + }, + ); + + if (!multiplayerGame) { + throw new Error("Multiplayer game not found"); + } + + console.log(multiplayerGame.boardState); + + const map = new ZombieSurvival(multiplayerGame.boardState); + + if (turn === "Z") { + map.moveAllZombies(); + console.log("ZOMBIES MOVED"); + console.log(map.getState()); + await ctx.runMutation( + internal.multiplayerGame.updateMultiplayerGameBoardState, + { + multiplayerGameId, + boardState: map.getState(), + }, + ); + } else if (turn === HARD_CODED_PLAYER_TOKEN) { + // TODO: based on who's turn it is, lookup the LLM model + // run the LLM model over the player's location + // the LLM model should return the next move and which zombie it should shoot + // update the board state with the new player location + } + + // if any players are alive, schedule the next turn + if (!map.finished()) { + await ctx.scheduler.runAfter( + TURN_DELAY, + internal.multiplayerGame.runMultiplayerGameTurn, + { + multiplayerGameId, + turn: turn === "Z" ? HARD_CODED_PLAYER_TOKEN : "Z", + }, + ); + } + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 429836f..a283969 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -77,4 +77,13 @@ export default defineSchema({ prompt: v.string(), isActive: v.boolean(), }).index("by_active", ["isActive"]), + multiplayerGame: defineTable({ + boardState: v.array(v.array(v.string())), + playerMap: v.array( + v.object({ + modelId: v.id("models"), + playerToken: v.string(), + }), + ), + }), }); diff --git a/renderer/Renderer.ts b/renderer/Renderer.ts index 658c183..f8633ee 100644 --- a/renderer/Renderer.ts +++ b/renderer/Renderer.ts @@ -59,17 +59,19 @@ export class Renderer { ctx.scale(window.devicePixelRatio, window.devicePixelRatio); ctx2.scale(window.devicePixelRatio, window.devicePixelRatio); + } - void loadAssets(); + public async initialize() { + return loadAssets(); } - public render(simulator: ZombieSurvival) { + public render(entities: Entity[]) { if (this.req !== null) { window.cancelAnimationFrame(this.req); this.req = null; } - this.register(simulator); + this.register(entities); this.draw(); } @@ -180,12 +182,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); } diff --git a/simulators/zombie-survival/Entity.ts b/simulators/zombie-survival/Entity.ts index 5d68dd2..92654db 100644 --- a/simulators/zombie-survival/Entity.ts +++ b/simulators/zombie-survival/Entity.ts @@ -1,5 +1,6 @@ import { type Change, ChangeType } from "./Change"; import { Position } from "./Position"; +import { Player } from "./entities/Player"; export enum EntityType { Box, @@ -98,6 +99,8 @@ export class Entity { return this.destructible; } + // TODO: maybe the entity itself should be refactor to implement the abstract method, + // and return the correct LETTER public toConfig(): string { let letter = " "; @@ -106,7 +109,7 @@ export class Entity { } else if (this.type === EntityType.Landmine) { letter = "L"; } else if (this.type === EntityType.Player) { - letter = "P"; + letter = (this as unknown as Player).playerToken; // TODO: this feels hacky } else if (this.type === EntityType.Rock) { letter = "R"; } else if (this.type === EntityType.Zombie) { diff --git a/simulators/zombie-survival/ZombieSurvival.spec.ts b/simulators/zombie-survival/ZombieSurvival.spec.ts index fae5121..f408c05 100644 --- a/simulators/zombie-survival/ZombieSurvival.spec.ts +++ b/simulators/zombie-survival/ZombieSurvival.spec.ts @@ -427,3 +427,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/simulators/zombie-survival/ZombieSurvival.ts b/simulators/zombie-survival/ZombieSurvival.ts index 88f374b..94b57cf 100644 --- a/simulators/zombie-survival/ZombieSurvival.ts +++ b/simulators/zombie-survival/ZombieSurvival.ts @@ -12,9 +12,79 @@ export class ZombieSurvival { public readonly boardHeight: number; public readonly boardWidth: number; private entities: Entity[]; - private player: Player; + private player: Player | null; private zombies: Zombie[]; + 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 "r": { + this.entities.push(new Rock({ x, y })); + break; + } + case "z": { + this.zombies.push(new Zombie(this, { x, y })); + break; + } + case "1": { + player = new Player( + this, + { + x, + y, + }, + code.toLocaleLowerCase(), + ); + break; + } + case "p": { + if (player !== null) { + throw new Error("Config contains multiple players"); + } + + player = new Player(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 static boardHeight(map: string[][]): number { return map.length; } @@ -43,7 +113,7 @@ export class ZombieSurvival { game.step(); } - return !game.getPlayer().dead(); + return game.getPlayer() === null || !game.getPlayer()?.dead(); } public static mapHasMultiplePlayers(map: string[][]): boolean { @@ -87,81 +157,32 @@ export class ZombieSurvival { ); } - 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()); + return ( + !this.player || + this.player.dead() || + this.zombies.every((zombie) => zombie.dead()) + ); } public getAllEntities(): Entity[] { - return [this.entities, this.zombies, this.player].flat(); + return [this.entities, this.zombies, this.player] + .flat() + .filter(Boolean) as Entity[]; } public getAllAliveEntities(): Entity[] { return [this.entities, this.zombies, this.player] .flat() - .filter((entity) => !entity.dead()); + .filter(Boolean) + .filter((entity) => entity && !entity.dead()) as Entity[]; } public getEntities(): Entity[] { return this.entities; } - public getPlayer(): Player { + public getPlayer(): Player | null { return this.player; } @@ -210,13 +231,12 @@ export class ZombieSurvival { return this; } - public step() { + public moveAllZombies() { const initialHealth = this.zombies.map((zombie) => zombie.getHealth()); - this.player.clearChanges(); - this.player.shoot(); + const isPlayerDead = this.player === null || this.player.dead(); - for (let i = 0; i < this.zombies.length && !this.player.dead(); i++) { + for (let i = 0; i < this.zombies.length && !isPlayerDead; i++) { const zombie = this.zombies[i]; const initialPosition = zombie.getPosition(); const initialZombieHealth = initialHealth[i]; @@ -243,4 +263,10 @@ export class ZombieSurvival { } } } + + public step() { + this.player?.clearChanges(); + this.player?.shoot(); + this.moveAllZombies(); + } } diff --git a/simulators/zombie-survival/entities/Player.ts b/simulators/zombie-survival/entities/Player.ts index b21c222..c6deb19 100644 --- a/simulators/zombie-survival/entities/Player.ts +++ b/simulators/zombie-survival/entities/Player.ts @@ -7,12 +7,20 @@ export class Player extends Entity { public static Destructible = true; public static Health = 1; public static ShootDistance = Infinity; + public playerToken: string = "P"; private game: ZombieSurvival; - public constructor(game: ZombieSurvival, position: Position) { + public constructor( + game: ZombieSurvival, + position: Position, + playerToken?: string, + ) { super(EntityType.Player, Player.Destructible, Player.Health, position); this.game = game; + if (playerToken) { + this.playerToken = playerToken; + } } public shoot() { diff --git a/simulators/zombie-survival/lib/pathfinder.ts b/simulators/zombie-survival/lib/pathfinder.ts index 3a81627..bb15619 100644 --- a/simulators/zombie-survival/lib/pathfinder.ts +++ b/simulators/zombie-survival/lib/pathfinder.ts @@ -9,6 +9,10 @@ export function pathfinder( ): Direction[] { const player = initialGame.getPlayer(); + if (!player) { + throw new Error("Player not found"); + } + const initialPosition = initialZombie.getPosition(); const queue: Array<{ x: number; y: number; path: Direction[] }> = [ { x: initialPosition.x, y: initialPosition.y, path: [] }, From d9176779be5b7753e2f2a62c9b1c091701d0528c Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sun, 3 Nov 2024 13:17:20 -0500 Subject: [PATCH 02/24] working towards getting the LLM to move the players --- convex/multiplayerGame.ts | 10 +- models/multiplayer/gpt-4o.ts | 47 ++++++ models/multiplayer/index.ts | 152 +++++++++++++++++++ simulators/zombie-survival/ZombieSurvival.ts | 35 +++++ 4 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 models/multiplayer/gpt-4o.ts create mode 100644 models/multiplayer/index.ts diff --git a/convex/multiplayerGame.ts b/convex/multiplayerGame.ts index 8339bc1..846c18c 100644 --- a/convex/multiplayerGame.ts +++ b/convex/multiplayerGame.ts @@ -1,3 +1,4 @@ +import { runMutiplayerModel } from "../models/multiplayer"; import { ZombieSurvival } from "../simulators/zombie-survival"; import { v } from "convex/values"; import { api, internal } from "./_generated/api"; @@ -75,14 +76,10 @@ export const runMultiplayerGameTurn = internalAction({ throw new Error("Multiplayer game not found"); } - console.log(multiplayerGame.boardState); - const map = new ZombieSurvival(multiplayerGame.boardState); if (turn === "Z") { map.moveAllZombies(); - console.log("ZOMBIES MOVED"); - console.log(map.getState()); await ctx.runMutation( internal.multiplayerGame.updateMultiplayerGameBoardState, { @@ -93,6 +90,11 @@ export const runMultiplayerGameTurn = internalAction({ } else if (turn === HARD_CODED_PLAYER_TOKEN) { // TODO: based on who's turn it is, lookup the LLM model // run the LLM model over the player's location + const results = await runMutiplayerModel( + HARD_CODED_MODEL_ID, + map.getState(), + HARD_CODED_PLAYER_TOKEN, + ); // the LLM model should return the next move and which zombie it should shoot // update the board state with the new player location } diff --git a/models/multiplayer/gpt-4o.ts b/models/multiplayer/gpt-4o.ts new file mode 100644 index 0000000..7049fd7 --- /dev/null +++ b/models/multiplayer/gpt-4o.ts @@ -0,0 +1,47 @@ +import { type MutiplayerModelHandler } from "."; +import OpenAI from "openai"; +import { zodResponseFormat } from "openai/helpers/zod"; +import { z } from "zod"; + +const responseSchema = z.object({ + moveLocation: z.array(z.number()), +}); + +export const gpt4o: MutiplayerModelHandler = 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 { + moveLocation: response.parsed.moveLocation, + reasoning: "because", + }; +}; diff --git a/models/multiplayer/index.ts b/models/multiplayer/index.ts new file mode 100644 index 0000000..41ae296 --- /dev/null +++ b/models/multiplayer/index.ts @@ -0,0 +1,152 @@ +import { gpt4o } from "./gpt-4o"; +import { errorMessage } from "@/lib/utils"; +import { ZombieSurvival } from "@/simulators/zombie-survival"; + +// TODO: rewrite this prompt to work for multiplayer +const SYSTEM_PROMPT = `Your task is to play a game. We will give you a 2d array of characters that represent the game board. Before the game starts, you have these two tasks: + +1. Place two blocks ("B") in locations which maximize the player's survival. +2. Place the player ("P") in a location which maximize the player's survival. + +# Placing Rules +- You can not place blocks in locations already used by zombies or rocks. +- You can not place the player in a location already used by a zombie or rock. +- You can not place a block over the player or another block. +- You must place both blocks and the player before starting the game. + +# Grid Descriptions +The 2d Grid is made up of characters, where each character has a meaning. +" " represents an empty space. +"Z" represents a zombie. +"R" represents rocks which zombies can not pass through and path finding will not allow them to go through. +"P" represents the player, who cannot move. The player's goal is to throw popsicle at zombies before they reach them. +"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 the turn, the player (P) throws a popsicle at the closest zombie (using euclidean distance). +- Popsicle deal 1 damage to zombies. +- A zombie is removed from the game when its health reaches 0. +- When all zombies are removed, the player wins. +- If a zombie reaches a player, the player loses. + +# 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 not move. +- Players throw one lollipops at the closest zombie at the start of each turn. + +# Placement Strategies + +- often it's good to wall off between the zombies and players if possible, this will slow the zombies down. +- You should never put a player directly next to a zombie. +- You should try to put blocks directly next to players +- If the player is behind a choke point, blocking the path to the player is the best option. + +# Output Format + +- Respond only with valid JSON. Do not write an introduction or summary. +- Assume a single paragraph explaining your placement strategy is always represented as REASONING. +- Assume a position on the 2d grid is always represented as [ROW, COL]. +- Your output should be a JSON object with the following format: + +{ + "boxCoordinates": [[ROW, COL], [ROW, COL]], + "playerCoordinates": [ROW, COL], + "reasoning": "REASONING" +} +`; + +export interface ModelHandlerConfig { + maxTokens: number; + temperature: number; + topP: number; +} + +export type MutiplayerModelHandler = ( + systemPrompt: string, + userPrompt: string, + config: ModelHandlerConfig, +) => Promise<{ + moveLocation: number[]; + reasoning: string; +}>; + +const MAX_RETRIES = 1; + +const CONFIG: ModelHandlerConfig = { + maxTokens: 1024, + temperature: 0.5, + topP: 0.95, +}; + +export type RunModelResult = { + solution?: string[][]; + reasoning: string; + promptTokens?: number; + outputTokens?: number; + totalTokensUsed?: number; + totalRunCost?: number; + error?: string; +}; + +export async function runMutiplayerModel( + modelId: string, + map: string[][], + playerToken: string, + retry = 1, +): Promise { + const playerToMove = ZombieSurvival.getPlayerByToken(map, playerToken); + + if (!playerToMove) { + throw new Error(`Player token '${playerToken}' not found`); + } + + const userPrompt = + `Grid: ${JSON.stringify(map)}\n\n` + + `Valid Move Locations: ${JSON.stringify( + ZombieSurvival.validMoveLocations(map, playerToMove), + )}`; + + let result; + let reasoning: string | null = null; + + try { + switch (modelId) { + // TODO: do not hard code this + case "m1757gn800qrc8s4jpdcshbgah72s9e7": { + // gpt-4o + result = await gpt4o(SYSTEM_PROMPT, userPrompt, CONFIG); + break; + } + default: { + throw new Error(`Tried running unknown model '${modelId}'`); + } + } + + reasoning = result.reasoning; + + const originalMap = ZombieSurvival.cloneMap(map); + + return { + solution: originalMap, + reasoning: result.reasoning, + }; + } catch (error) { + if (retry === MAX_RETRIES || reasoning === null) { + return { + reasoning: reasoning ?? "Internal error", + error: errorMessage(error), + }; + } + + return await runMutiplayerModel(modelId, map, playerToken, retry + 1); + } +} diff --git a/simulators/zombie-survival/ZombieSurvival.ts b/simulators/zombie-survival/ZombieSurvival.ts index 94b57cf..fe6d48b 100644 --- a/simulators/zombie-survival/ZombieSurvival.ts +++ b/simulators/zombie-survival/ZombieSurvival.ts @@ -1,4 +1,5 @@ import { ChangeType } from "./Change"; +import { allDirections, move } from "./Direction"; import { Entity } from "./Entity"; import { Position, samePosition } from "./Position"; import { Box } from "./entities/Box"; @@ -146,6 +147,40 @@ export class ZombieSurvival { return null; } + public static getPlayerByToken( + map: string[][], + playerToken: string, + ): Player | null { + return map + .flat() + .find((cell) => cell === playerToken) as unknown as Player | null; + } + + public static validMoveLocations( + map: string[][], + entity: Entity, + ): number[][] { + const position = entity.getPosition(); + const validMoves: number[][] = []; + + 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] === " ") { + validMoves.push([newPosition.y, newPosition.x]); + } + } + } + + return validMoves; + } + public static validLocations(map: string[][]): number[][] { return map.flatMap((row, y) => row.reduce((acc, cell, x) => { From adb6dc0e819f656ff44aa89f91cd85ca41115b25 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Sun, 3 Nov 2024 23:43:54 +0200 Subject: [PATCH 03/24] Move all logic to simulator directory --- app/multiplayer/[multiplayerGameId]/page.tsx | 2 +- app/play/[level]/page.tsx | 2 +- app/playground/page.tsx | 2 +- components/MapStatus.tsx | 2 +- components/Visualizer.tsx | 2 +- convex/maps.ts | 2 +- convex/multiplayerGame.ts | 2 +- convex/tsconfig.json | 2 +- models/index.ts | 2 +- models/multiplayer/index.ts | 2 +- renderer/Effect.ts | 2 +- renderer/Item.ts | 2 +- renderer/Renderer.ts | 5 ++--- {simulators/zombie-survival => simulator}/Change.ts | 0 {simulators/zombie-survival => simulator}/Direction.ts | 0 {simulators/zombie-survival => simulator}/Entity.ts | 0 {simulators/zombie-survival => simulator}/Position.ts | 0 .../zombie-survival => simulator}/ZombieSurvival.spec.ts | 0 {simulators/zombie-survival => simulator}/ZombieSurvival.ts | 0 {simulators/zombie-survival => simulator}/entities/Box.ts | 0 .../zombie-survival => simulator}/entities/Landmine.ts | 0 {simulators/zombie-survival => simulator}/entities/Player.ts | 0 {simulators/zombie-survival => simulator}/entities/Rock.ts | 0 {simulators/zombie-survival => simulator}/entities/Zombie.ts | 0 {simulators/zombie-survival => simulator}/index.ts | 1 + .../zombie-survival => simulator}/lib/closestEntity.ts | 0 {simulators/zombie-survival => simulator}/lib/entityAt.ts | 0 {simulators/zombie-survival => simulator}/lib/pathfinder.ts | 0 28 files changed, 15 insertions(+), 15 deletions(-) rename {simulators/zombie-survival => simulator}/Change.ts (100%) rename {simulators/zombie-survival => simulator}/Direction.ts (100%) rename {simulators/zombie-survival => simulator}/Entity.ts (100%) rename {simulators/zombie-survival => simulator}/Position.ts (100%) rename {simulators/zombie-survival => simulator}/ZombieSurvival.spec.ts (100%) rename {simulators/zombie-survival => simulator}/ZombieSurvival.ts (100%) rename {simulators/zombie-survival => simulator}/entities/Box.ts (100%) rename {simulators/zombie-survival => simulator}/entities/Landmine.ts (100%) rename {simulators/zombie-survival => simulator}/entities/Player.ts (100%) rename {simulators/zombie-survival => simulator}/entities/Rock.ts (100%) rename {simulators/zombie-survival => simulator}/entities/Zombie.ts (100%) rename {simulators/zombie-survival => simulator}/index.ts (91%) rename {simulators/zombie-survival => simulator}/lib/closestEntity.ts (100%) rename {simulators/zombie-survival => simulator}/lib/entityAt.ts (100%) rename {simulators/zombie-survival => simulator}/lib/pathfinder.ts (100%) diff --git a/app/multiplayer/[multiplayerGameId]/page.tsx b/app/multiplayer/[multiplayerGameId]/page.tsx index bf06081..e211576 100644 --- a/app/multiplayer/[multiplayerGameId]/page.tsx +++ b/app/multiplayer/[multiplayerGameId]/page.tsx @@ -6,7 +6,7 @@ import { Page, PageTitle } from "@/components/Page"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; import { Renderer } from "@/renderer"; -import { ZombieSurvival } from "@/simulators/zombie-survival"; +import { ZombieSurvival } from "@/simulator"; export default function Multiplayer({ params, 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/playground/page.tsx b/app/playground/page.tsx index cf9f968..25c871f 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"; 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/Visualizer.tsx b/components/Visualizer.tsx index 1cad8c3..a04ee82 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -5,7 +5,7 @@ import { DEFAULT_REPLAY_SPEED, } from "@/constants/visualizer"; import { Renderer } from "@/renderer"; -import { ZombieSurvival } from "@/simulators/zombie-survival"; +import { ZombieSurvival } from "@/simulator"; export function Visualizer({ autoReplay = false, diff --git a/convex/maps.ts b/convex/maps.ts index d4c5088..44e13cc 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -1,5 +1,5 @@ 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"; diff --git a/convex/multiplayerGame.ts b/convex/multiplayerGame.ts index 846c18c..d3b3bdc 100644 --- a/convex/multiplayerGame.ts +++ b/convex/multiplayerGame.ts @@ -1,5 +1,5 @@ import { runMutiplayerModel } from "../models/multiplayer"; -import { ZombieSurvival } from "../simulators/zombie-survival"; +import { ZombieSurvival } from "../simulator"; import { v } from "convex/values"; import { api, internal } from "./_generated/api"; import { Id } from "./_generated/dataModel"; diff --git a/convex/tsconfig.json b/convex/tsconfig.json index 3a62739..5625fef 100644 --- a/convex/tsconfig.json +++ b/convex/tsconfig.json @@ -20,6 +20,6 @@ "isolatedModules": true, "noEmit": true }, - "include": ["./**/*", "../simulators/**/*"], + "include": ["./**/*", "../simulator/**/*"], "exclude": ["./_generated"] } diff --git a/models/index.ts b/models/index.ts index 668de69..866348b 100644 --- a/models/index.ts +++ b/models/index.ts @@ -1,5 +1,5 @@ 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"; diff --git a/models/multiplayer/index.ts b/models/multiplayer/index.ts index 41ae296..d7f8255 100644 --- a/models/multiplayer/index.ts +++ b/models/multiplayer/index.ts @@ -1,6 +1,6 @@ import { gpt4o } from "./gpt-4o"; import { errorMessage } from "@/lib/utils"; -import { ZombieSurvival } from "@/simulators/zombie-survival"; +import { ZombieSurvival } from "@/simulator"; // TODO: rewrite this prompt to work for multiplayer const SYSTEM_PROMPT = `Your task is to play a game. We will give you a 2d array of characters that represent the game board. Before the game starts, you have these two tasks: 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 f8633ee..f10c687 100644 --- a/renderer/Renderer.ts +++ b/renderer/Renderer.ts @@ -3,13 +3,12 @@ import * as Canvas from "./Canvas"; import { type RendererEffect, RendererEffectType } from "./Effect"; import { RendererItem } from "./Item"; import { + ChangeType, type Entity, EntityType, type Position, Zombie, - type ZombieSurvival, -} from "@/simulators/zombie-survival"; -import { ChangeType } from "@/simulators/zombie-survival/Change"; +} from "@/simulator"; export class Renderer { private readonly cellSize: number; diff --git a/simulators/zombie-survival/Change.ts b/simulator/Change.ts similarity index 100% rename from simulators/zombie-survival/Change.ts rename to simulator/Change.ts diff --git a/simulators/zombie-survival/Direction.ts b/simulator/Direction.ts similarity index 100% rename from simulators/zombie-survival/Direction.ts rename to simulator/Direction.ts diff --git a/simulators/zombie-survival/Entity.ts b/simulator/Entity.ts similarity index 100% rename from simulators/zombie-survival/Entity.ts rename to simulator/Entity.ts 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/simulators/zombie-survival/ZombieSurvival.spec.ts b/simulator/ZombieSurvival.spec.ts similarity index 100% rename from simulators/zombie-survival/ZombieSurvival.spec.ts rename to simulator/ZombieSurvival.spec.ts diff --git a/simulators/zombie-survival/ZombieSurvival.ts b/simulator/ZombieSurvival.ts similarity index 100% rename from simulators/zombie-survival/ZombieSurvival.ts rename to simulator/ZombieSurvival.ts diff --git a/simulators/zombie-survival/entities/Box.ts b/simulator/entities/Box.ts similarity index 100% rename from simulators/zombie-survival/entities/Box.ts rename to simulator/entities/Box.ts diff --git a/simulators/zombie-survival/entities/Landmine.ts b/simulator/entities/Landmine.ts similarity index 100% rename from simulators/zombie-survival/entities/Landmine.ts rename to simulator/entities/Landmine.ts diff --git a/simulators/zombie-survival/entities/Player.ts b/simulator/entities/Player.ts similarity index 100% rename from simulators/zombie-survival/entities/Player.ts rename to simulator/entities/Player.ts diff --git a/simulators/zombie-survival/entities/Rock.ts b/simulator/entities/Rock.ts similarity index 100% rename from simulators/zombie-survival/entities/Rock.ts rename to simulator/entities/Rock.ts diff --git a/simulators/zombie-survival/entities/Zombie.ts b/simulator/entities/Zombie.ts similarity index 100% rename from simulators/zombie-survival/entities/Zombie.ts rename to simulator/entities/Zombie.ts diff --git a/simulators/zombie-survival/index.ts b/simulator/index.ts similarity index 91% rename from simulators/zombie-survival/index.ts rename to simulator/index.ts index 73a56d3..62a0825 100644 --- a/simulators/zombie-survival/index.ts +++ b/simulator/index.ts @@ -3,6 +3,7 @@ export * from "./entities/Landmine"; export * from "./entities/Player"; export * from "./entities/Rock"; export * from "./entities/Zombie"; +export * from "./Change"; export * from "./Direction"; export * from "./Entity"; export * from "./Position"; diff --git a/simulators/zombie-survival/lib/closestEntity.ts b/simulator/lib/closestEntity.ts similarity index 100% rename from simulators/zombie-survival/lib/closestEntity.ts rename to simulator/lib/closestEntity.ts diff --git a/simulators/zombie-survival/lib/entityAt.ts b/simulator/lib/entityAt.ts similarity index 100% rename from simulators/zombie-survival/lib/entityAt.ts rename to simulator/lib/entityAt.ts diff --git a/simulators/zombie-survival/lib/pathfinder.ts b/simulator/lib/pathfinder.ts similarity index 100% rename from simulators/zombie-survival/lib/pathfinder.ts rename to simulator/lib/pathfinder.ts From 7948780506754c64805ef2aa324b333ed897f64e Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:03:24 +0200 Subject: [PATCH 04/24] Abstract entity get token --- simulator/Entity.ts | 25 +++---------------------- simulator/entities/Box.ts | 4 ++++ simulator/entities/Landmine.ts | 4 ++++ simulator/entities/Player.ts | 17 +++++++++-------- simulator/entities/Rock.ts | 4 ++++ simulator/entities/Zombie.ts | 4 ++++ 6 files changed, 28 insertions(+), 30 deletions(-) diff --git a/simulator/Entity.ts b/simulator/Entity.ts index 92654db..3703a21 100644 --- a/simulator/Entity.ts +++ b/simulator/Entity.ts @@ -1,6 +1,5 @@ import { type Change, ChangeType } from "./Change"; import { Position } from "./Position"; -import { Player } from "./entities/Player"; export enum EntityType { Box, @@ -10,13 +9,15 @@ export enum EntityType { Zombie, } -export class Entity { +export abstract class Entity { protected destructible: boolean; protected changes: Change[] = []; protected health: number; protected position: Position; protected type: EntityType; + public abstract getToken(): string; + public constructor( type: EntityType, destructible: boolean, @@ -98,24 +99,4 @@ export class Entity { public isDestructible(): boolean { return this.destructible; } - - // TODO: maybe the entity itself should be refactor to implement the abstract method, - // and return the correct LETTER - 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 = (this as unknown as Player).playerToken; // TODO: this feels hacky - } else if (this.type === EntityType.Rock) { - letter = "R"; - } else if (this.type === EntityType.Zombie) { - letter = "Z"; - } - - return letter; - } } diff --git a/simulator/entities/Box.ts b/simulator/entities/Box.ts index 9cd339e..dfb665b 100644 --- a/simulator/entities/Box.ts +++ b/simulator/entities/Box.ts @@ -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/simulator/entities/Landmine.ts b/simulator/entities/Landmine.ts index 8200a8b..0591a64 100644 --- a/simulator/entities/Landmine.ts +++ b/simulator/entities/Landmine.ts @@ -13,4 +13,8 @@ export class Landmine extends Entity { position, ); } + + public getToken(): string { + return "L"; + } } diff --git a/simulator/entities/Player.ts b/simulator/entities/Player.ts index c6deb19..bd60460 100644 --- a/simulator/entities/Player.ts +++ b/simulator/entities/Player.ts @@ -7,22 +7,23 @@ export class Player extends Entity { public static Destructible = true; public static Health = 1; public static ShootDistance = Infinity; - public playerToken: string = "P"; + public token = "P"; private game: ZombieSurvival; - public constructor( - game: ZombieSurvival, - position: Position, - playerToken?: string, - ) { + public constructor(game: ZombieSurvival, position: Position, token?: string) { super(EntityType.Player, Player.Destructible, Player.Health, position); this.game = game; - if (playerToken) { - this.playerToken = playerToken; + + if (token !== undefined) { + this.token = token; } } + public getToken(): string { + return this.token; + } + public shoot() { const zombie = closestEntity(this, this.game.getZombies()); zombie.hit(); diff --git a/simulator/entities/Rock.ts b/simulator/entities/Rock.ts index bf14e4f..4ddbf1c 100644 --- a/simulator/entities/Rock.ts +++ b/simulator/entities/Rock.ts @@ -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 index d467195..b785a90 100644 --- a/simulator/entities/Zombie.ts +++ b/simulator/entities/Zombie.ts @@ -16,6 +16,10 @@ export class Zombie extends Entity { this.game = game; } + public getToken(): string { + return "Z"; + } + public listMoves(): Direction[] { const entities = this.game.getAllEntities(); const result: Direction[] = []; From d1823f139f4d027635c8cb542246c9ce5b531db3 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:04:29 +0200 Subject: [PATCH 05/24] Rename multiplayerGame to Games --- ...multiplayerGame.ts => multiplayerGames.ts} | 48 +++++++++++-------- convex/schema.ts | 2 +- 2 files changed, 28 insertions(+), 22 deletions(-) rename convex/{multiplayerGame.ts => multiplayerGames.ts} (69%) diff --git a/convex/multiplayerGame.ts b/convex/multiplayerGames.ts similarity index 69% rename from convex/multiplayerGame.ts rename to convex/multiplayerGames.ts index d3b3bdc..ac15627 100644 --- a/convex/multiplayerGame.ts +++ b/convex/multiplayerGames.ts @@ -1,18 +1,22 @@ -import { runMutiplayerModel } from "../models/multiplayer"; +import { runMultiplayerModel } from "../models/multiplayer"; import { ZombieSurvival } from "../simulator"; import { v } from "convex/values"; import { api, internal } from "./_generated/api"; import { Id } from "./_generated/dataModel"; import { internalAction, internalMutation, query } from "./_generated/server"; +import { AI_MODELS } from "./constants"; -const HARD_CODED_MODEL_ID = "m1757gn800qrc8s4jpdcshbgah72s9e7" as Id<"models">; const HARD_CODED_PLAYER_TOKEN = "1"; const TURN_DELAY = 5000; export const startMultiplayerGame = internalMutation({ - args: {}, handler: async (ctx) => { - const gameId = await ctx.db.insert("multiplayerGame", { + const modelId = "ks7dm9g4t91bm8cy3z2544br0h72se9x" as Id<"models">; + // const modelId = await ctx.runQuery(api.models.getActiveModelByName, { + // name: AI_MODELS[1].name, + // }); + + const gameId = await ctx.db.insert("multiplayerGames", { boardState: [ ["Z", " ", " ", " ", " "], [" ", " ", " ", " ", " "], @@ -22,14 +26,12 @@ export const startMultiplayerGame = internalMutation({ [" ", " ", " ", " ", " "], [" ", " ", " ", " ", HARD_CODED_PLAYER_TOKEN], ], - playerMap: [ - { modelId: HARD_CODED_MODEL_ID, playerToken: HARD_CODED_PLAYER_TOKEN }, - ], + playerMap: [{ modelId: modelId, playerToken: HARD_CODED_PLAYER_TOKEN }], }); await ctx.scheduler.runAfter( 0, - internal.multiplayerGame.runMultiplayerGameTurn, + internal.multiplayerGames.runMultiplayerGameTurn, { multiplayerGameId: gameId, turn: HARD_CODED_PLAYER_TOKEN, @@ -41,7 +43,9 @@ export const startMultiplayerGame = internalMutation({ }); export const getMultiplayerGame = query({ - args: { multiplayerGameId: v.id("multiplayerGame") }, + args: { + multiplayerGameId: v.id("multiplayerGames"), + }, handler: async (ctx, args) => { return await ctx.db.get(args.multiplayerGameId); }, @@ -49,7 +53,7 @@ export const getMultiplayerGame = query({ export const updateMultiplayerGameBoardState = internalMutation({ args: { - multiplayerGameId: v.id("multiplayerGame"), + multiplayerGameId: v.id("multiplayerGames"), boardState: v.array(v.array(v.string())), }, handler: async (ctx, args) => { @@ -60,13 +64,13 @@ export const updateMultiplayerGameBoardState = internalMutation({ export const runMultiplayerGameTurn = internalAction({ args: { turn: v.string(), - multiplayerGameId: v.id("multiplayerGame"), + multiplayerGameId: v.id("multiplayerGames"), }, handler: async (ctx, args) => { const { turn, multiplayerGameId } = args; const multiplayerGame = await ctx.runQuery( - api.multiplayerGame.getMultiplayerGame, + api.multiplayerGames.getMultiplayerGame, { multiplayerGameId, }, @@ -79,9 +83,10 @@ export const runMultiplayerGameTurn = internalAction({ const map = new ZombieSurvival(multiplayerGame.boardState); if (turn === "Z") { - map.moveAllZombies(); + map.step({ skipPlayer: true }); + await ctx.runMutation( - internal.multiplayerGame.updateMultiplayerGameBoardState, + internal.multiplayerGames.updateMultiplayerGameBoardState, { multiplayerGameId, boardState: map.getState(), @@ -90,20 +95,21 @@ export const runMultiplayerGameTurn = internalAction({ } else if (turn === HARD_CODED_PLAYER_TOKEN) { // TODO: based on who's turn it is, lookup the LLM model // run the LLM model over the player's location - const results = await runMutiplayerModel( - HARD_CODED_MODEL_ID, - map.getState(), - HARD_CODED_PLAYER_TOKEN, - ); + // const results = await runMultiplayerModel( + // HARD_CODED_MODEL_ID, + // map.getState(), + // HARD_CODED_PLAYER_TOKEN, + // ); // the LLM model should return the next move and which zombie it should shoot // update the board state with the new player location + + return; } - // if any players are alive, schedule the next turn if (!map.finished()) { await ctx.scheduler.runAfter( TURN_DELAY, - internal.multiplayerGame.runMultiplayerGameTurn, + internal.multiplayerGames.runMultiplayerGameTurn, { multiplayerGameId, turn: turn === "Z" ? HARD_CODED_PLAYER_TOKEN : "Z", diff --git a/convex/schema.ts b/convex/schema.ts index a283969..6ce0b8b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -77,7 +77,7 @@ export default defineSchema({ prompt: v.string(), isActive: v.boolean(), }).index("by_active", ["isActive"]), - multiplayerGame: defineTable({ + multiplayerGames: defineTable({ boardState: v.array(v.array(v.string())), playerMap: v.array( v.object({ From 6195790ddd91964c876d63729fd8d9489924cf3e Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:06:28 +0200 Subject: [PATCH 06/24] Await renderer assets load --- renderer/Assets.ts | 157 +++++++++++++++++++------------------------ renderer/Renderer.ts | 23 ++++++- 2 files changed, 90 insertions(+), 90 deletions(-) diff --git a/renderer/Assets.ts b/renderer/Assets.ts index 48e347c..34b5979 100644 --- a/renderer/Assets.ts +++ b/renderer/Assets.ts @@ -1,97 +1,80 @@ -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 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, -}; +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 async function loadAssets() { - if (assets.loading || assets.loaded) { - return; + constructor() { + super(); + 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/Renderer.ts b/renderer/Renderer.ts index f10c687..d7d15e4 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"; @@ -19,12 +19,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, @@ -60,8 +61,24 @@ export class Renderer { ctx2.scale(window.devicePixelRatio, window.devicePixelRatio); } + public isInitialized() { + return this.initialized; + } + public async initialize() { - return loadAssets(); + if (this.initialized) { + return; + } + + if (!assets.loaded) { + await new Promise((resolve) => { + assets.addEventListener("loaded", () => { + resolve(); + }); + }); + } + + this.initialized = true; } public render(entities: Entity[]) { From 8c98b156794a67a8294987769090ae01b1f2c559 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:07:50 +0200 Subject: [PATCH 07/24] Move renderer logic to useRenderer hook --- app/multiplayer/[multiplayerGameId]/page.tsx | 37 +++++------------ app/playground/page.tsx | 1 - app/result.tsx | 2 +- components/Renderer.tsx | 43 ++++++++++++++++++++ components/Visualizer.tsx | 30 ++++---------- 5 files changed, 64 insertions(+), 49 deletions(-) create mode 100644 components/Renderer.tsx diff --git a/app/multiplayer/[multiplayerGameId]/page.tsx b/app/multiplayer/[multiplayerGameId]/page.tsx index e211576..069d0b9 100644 --- a/app/multiplayer/[multiplayerGameId]/page.tsx +++ b/app/multiplayer/[multiplayerGameId]/page.tsx @@ -1,51 +1,36 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import { useQuery } from "convex/react"; import { Page, PageTitle } from "@/components/Page"; +import { useRenderer } from "@/components/Renderer"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; -import { Renderer } from "@/renderer"; import { ZombieSurvival } from "@/simulator"; -export default function Multiplayer({ +export default function MultiplayerPage({ params, }: { - params: { multiplayerGameId: Id<"multiplayerGame"> }; + params: { multiplayerGameId: Id<"multiplayerGames"> }; }) { - const multiplayerGame = useQuery(api.multiplayerGame.getMultiplayerGame, { + const multiplayerGame = useQuery(api.multiplayerGames.getMultiplayerGame, { multiplayerGameId: params.multiplayerGameId, }); - const renderer = useRef(null); - const canvas = useRef(null); - const [isInitialized, setIsInitialized] = useState(false); - useEffect(() => { - if (multiplayerGame && canvas.current !== null) { - const map = multiplayerGame.boardState; - renderer.current = new Renderer( - ZombieSurvival.boardHeight(map), - ZombieSurvival.boardWidth(map), - canvas.current, - Number.parseInt("64", 10), - 1000, - ); - renderer.current.initialize().then(() => { - setIsInitialized(true); - }); - } - }, [multiplayerGame]); + const canvas = useRef(null); + const renderer = useRenderer(multiplayerGame?.boardState, canvas); useEffect(() => { - if (renderer.current && isInitialized && multiplayerGame) { + if (renderer !== null && multiplayerGame) { const simulator = new ZombieSurvival(multiplayerGame.boardState); - renderer.current?.render(simulator.getAllEntities()); + renderer.render(simulator.getAllEntities()); } - }, [isInitialized, multiplayerGame]); + }, [multiplayerGame, renderer]); if (!multiplayerGame) { return
Loading...
; } + return ( Multiplayer diff --git a/app/playground/page.tsx b/app/playground/page.tsx index 25c871f..f2f6879 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -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/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" && ( , + 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 a04ee82..33bfe05 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -1,17 +1,17 @@ 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 "@/simulator"; export function Visualizer({ autoReplay = false, autoStart = false, controls = true, - cellSize = "64", + cellSize = 64, map, onReset, onSimulationEnd, @@ -20,41 +20,29 @@ export function Visualizer({ autoReplay?: boolean; autoStart?: boolean; controls?: boolean; - cellSize?: string; + cellSize?: number; map: string[][]; onReset?: () => unknown; onSimulationEnd?: (isWin: boolean) => unknown; replaySpeed?: number; }) { const simulator = useRef(new ZombieSurvival(map)); - const renderer = useRef(null); 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 (canvas.current !== null) { - renderer.current = new Renderer( - ZombieSurvival.boardHeight(map), - ZombieSurvival.boardWidth(map), - canvas.current, - Number.parseInt(cellSize, 10), - replaySpeed, - ); - } - }, [canvas, cellSize, map, replaySpeed]); - - useEffect(() => { - if (autoStart) { + if (autoStart && renderer !== null) { startSimulation(); } - }, [autoStart]); + }, [autoStart, renderer]); function startSimulation() { simulator.current = new ZombieSurvival(map); - renderer.current?.render(simulator.current.getAllEntities()); + renderer?.render(simulator.current.getAllEntities()); setRunning(true); interval.current = setInterval(() => { @@ -64,7 +52,7 @@ export function Visualizer({ if (!simulator.current.finished()) { simulator.current.step(); - renderer.current?.render(simulator.current.getAllEntities()); + renderer?.render(simulator.current.getAllEntities()); return; } @@ -83,7 +71,7 @@ export function Visualizer({ setRunning(false); if (onSimulationEnd) { - onSimulationEnd(!simulator.current.getPlayer()?.dead()); + onSimulationEnd(!simulator.current.getPlayer().dead()); } }, replaySpeed); } From c60f07c5b39e75a6cf9e70701ab4dced4b51838b Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:08:36 +0200 Subject: [PATCH 08/24] Support for multi players simulations --- simulator/ZombieSurvival.ts | 174 ++++++++++++++---------------------- simulator/lib/pathfinder.ts | 7 +- 2 files changed, 67 insertions(+), 114 deletions(-) diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index fe6d48b..c9f9aee 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -12,25 +12,22 @@ import { entityAt } from "./lib/entityAt"; export class ZombieSurvival { public readonly boardHeight: number; public readonly boardWidth: number; - private entities: Entity[]; - private player: Player | null; - private zombies: Zombie[]; - - public constructor(config: string[][]) { - if (ZombieSurvival.mapIsEmpty(config)) { - throw new Error("Config is empty"); + private entities: Entity[] = []; + private multiplayer = false; + private players: Player[] = []; + private zombies: Zombie[] = []; + + public constructor(map: string[][]) { + if (ZombieSurvival.mapIsEmpty(map)) { + throw new Error("Map is empty"); } - this.boardWidth = config[0].length; - this.boardHeight = config.length; - this.entities = []; - this.zombies = []; - - let player: Player | null = null; + this.boardWidth = map[0].length; + this.boardHeight = map.length; for (let y = 0; y < this.boardHeight; y++) { for (let x = 0; x < this.boardWidth; x++) { - const code = config[y][x]; + const code = map[y][x]; switch (code.toLowerCase()) { case "b": { @@ -49,40 +46,34 @@ export class ZombieSurvival { this.zombies.push(new Zombie(this, { x, y })); break; } - case "1": { - player = new Player( - this, - { - x, - y, - }, - code.toLocaleLowerCase(), - ); - break; - } + case "1": + case "2": + case "3": + case "4": + case "5": + case "6": case "p": { - if (player !== null) { - throw new Error("Config contains multiple players"); + if (this.players.length !== 0) { + throw new Error("Map contains multiple players"); } - player = new Player(this, { - x, - y, - }); + const player = new Player(this, { x, y }, code.toLocaleLowerCase()); + this.players.push(player); + break; } } } } - // if (player === null) { - // throw new Error("Config has no player"); - // } + if (ZombieSurvival.mapHasPlayer(map) && this.players.length === 0) { + throw new Error("Map has no player"); + } - this.player = player; + this.multiplayer = this.players.length > 1; if (this.zombies.length === 0) { - throw new Error("Config has no zombies"); + throw new Error("Map has no zombies"); } } @@ -98,9 +89,16 @@ export class ZombieSurvival { 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 entityPosition(map: string[][], token: string): Position { + for (let y = 0; y < map.length - 1; y++) { + for (let x = 0; x < map[y].length - 1; x++) { + if (map[y][x] === token) { + return { x, y }; + } + } + } + + throw new Error(`Entity position for token '${token}' not found`); } public static isWin(config: string[][]): boolean { @@ -147,20 +145,19 @@ export class ZombieSurvival { return null; } - public static getPlayerByToken( - map: string[][], - playerToken: string, - ): Player | null { - return map - .flat() - .find((cell) => cell === playerToken) as unknown as Player | 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 static validMoveLocations( - map: string[][], - entity: Entity, - ): number[][] { - const position = entity.getPosition(); + public static validMoveLocations(map: string[][], token: string): number[][] { + const position = ZombieSurvival.entityPosition(map, token); const validMoves: number[][] = []; for (const direction of allDirections) { @@ -181,97 +178,62 @@ export class ZombieSurvival { return validMoves; } - 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 finished(): boolean { return ( - !this.player || - this.player.dead() || + this.players.every((player) => player.dead()) || this.zombies.every((zombie) => zombie.dead()) ); } public getAllEntities(): Entity[] { - return [this.entities, this.zombies, this.player] + return [this.entities, this.zombies, this.players] .flat() .filter(Boolean) as Entity[]; } - public getAllAliveEntities(): Entity[] { - return [this.entities, this.zombies, this.player] - .flat() - .filter(Boolean) - .filter((entity) => entity && !entity.dead()) as Entity[]; - } - public getEntities(): Entity[] { return this.entities; } - public getPlayer(): Player | null { - return this.player; - } + public getPlayer(): Player { + if (this.multiplayer) { + throw new Error("Tried getting a player for a multiplayer simulator"); + } - public getSnapshot(): string { - return this.getState() - .map((it) => it.join("")) - .join("."); + return this.players[0]; } public getState(): string[][] { const entities = this.getAllEntities(); - let config: string[][] = []; + 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.toConfig()); + item.push(entity === null ? " " : entity.getToken()); } - config.push(item); + result.push(item); } - return config; - } - - public getEntityAt(position: Position): Entity | null { - return entityAt(this.getAllEntities(), position); - } - - public getZombie(): Zombie { - return this.zombies[0]; + return result; } 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 moveAllZombies() { + public step({ skipPlayer = false }: { skipPlayer?: boolean } = {}): void { const initialHealth = this.zombies.map((zombie) => zombie.getHealth()); - const isPlayerDead = this.player === null || this.player.dead(); + if (!skipPlayer) { + this.getPlayer().clearChanges(); + this.getPlayer().shoot(); + } - for (let i = 0; i < this.zombies.length && !isPlayerDead; i++) { + for (let i = 0; i < this.zombies.length && !this.finished(); i++) { const zombie = this.zombies[i]; const initialPosition = zombie.getPosition(); const initialZombieHealth = initialHealth[i]; @@ -298,10 +260,4 @@ export class ZombieSurvival { } } } - - public step() { - this.player?.clearChanges(); - this.player?.shoot(); - this.moveAllZombies(); - } } diff --git a/simulator/lib/pathfinder.ts b/simulator/lib/pathfinder.ts index bb15619..c2be4b7 100644 --- a/simulator/lib/pathfinder.ts +++ b/simulator/lib/pathfinder.ts @@ -8,15 +8,12 @@ export function pathfinder( initialZombie: Zombie, ): Direction[] { const player = initialGame.getPlayer(); - - if (!player) { - throw new Error("Player not found"); - } - 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) { From 213d8890a3cd9e5426c86d524ac38c98c378d00c Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:09:33 +0200 Subject: [PATCH 09/24] Use paths with convex tsconfig --- convex/_generated/api.d.ts | 4 ++-- convex/tsconfig.json | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 77b3cbd..1c3f982 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -21,7 +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 multiplayerGame from "../multiplayerGame.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"; @@ -54,7 +54,7 @@ declare const fullApi: ApiFromModules<{ leaderboard: typeof leaderboard; maps: typeof maps; models: typeof models; - multiplayerGame: typeof multiplayerGame; + multiplayerGames: typeof multiplayerGames; playerresults: typeof playerresults; prompts: typeof prompts; rateLimits: typeof rateLimits; diff --git a/convex/tsconfig.json b/convex/tsconfig.json index 5625fef..edea9c1 100644 --- a/convex/tsconfig.json +++ b/convex/tsconfig.json @@ -18,7 +18,10 @@ "forceConsistentCasingInFileNames": true, "module": "ESNext", "isolatedModules": true, - "noEmit": true + "noEmit": true, + "paths": { + "@/*": ["../*"] + } }, "include": ["./**/*", "../simulator/**/*"], "exclude": ["./_generated"] From 707eec0464fa8f69c086972dfc86055be7199b26 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:10:20 +0200 Subject: [PATCH 10/24] Use constant model names --- models/index.ts | 11 ++++++----- models/multiplayer/gpt-4o.ts | 4 ++-- models/multiplayer/index.ts | 37 ++++++++++++++---------------------- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/models/index.ts b/models/index.ts index 866348b..732b6ff 100644 --- a/models/index.ts +++ b/models/index.ts @@ -5,6 +5,7 @@ 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[0].name: { result = await gemini15pro(prompt, userPrompt, CONFIG); break; } - case "gpt-4o": { + case AI_MODELS[1].name: { result = await gpt4o(prompt, userPrompt, CONFIG); break; } - case "claude-3.5-sonnet": { + case AI_MODELS[2].name: { result = await claude35sonnet(prompt, userPrompt, CONFIG); break; } - case "perplexity-llama-3.1": { + case AI_MODELS[3].name: { result = await perplexityLlama31(prompt, userPrompt, CONFIG); break; } - case "mistral-large-2": { + case AI_MODELS[4].name: { result = await mistralLarge2(prompt, userPrompt, CONFIG); break; } diff --git a/models/multiplayer/gpt-4o.ts b/models/multiplayer/gpt-4o.ts index 7049fd7..f5f3c51 100644 --- a/models/multiplayer/gpt-4o.ts +++ b/models/multiplayer/gpt-4o.ts @@ -1,4 +1,4 @@ -import { type MutiplayerModelHandler } from "."; +import { type MultiplayerModelHandler } from "."; import OpenAI from "openai"; import { zodResponseFormat } from "openai/helpers/zod"; import { z } from "zod"; @@ -7,7 +7,7 @@ const responseSchema = z.object({ moveLocation: z.array(z.number()), }); -export const gpt4o: MutiplayerModelHandler = async ( +export const gpt4o: MultiplayerModelHandler = async ( systemPrompt, userPrompt, config, diff --git a/models/multiplayer/index.ts b/models/multiplayer/index.ts index d7f8255..4518628 100644 --- a/models/multiplayer/index.ts +++ b/models/multiplayer/index.ts @@ -1,4 +1,5 @@ import { gpt4o } from "./gpt-4o"; +import { AI_MODELS } from "@/convex/constants"; import { errorMessage } from "@/lib/utils"; import { ZombieSurvival } from "@/simulator"; @@ -70,7 +71,7 @@ export interface ModelHandlerConfig { topP: number; } -export type MutiplayerModelHandler = ( +export type MultiplayerModelHandler = ( systemPrompt: string, userPrompt: string, config: ModelHandlerConfig, @@ -88,41 +89,32 @@ const CONFIG: ModelHandlerConfig = { }; export type RunModelResult = { - solution?: string[][]; - reasoning: string; - promptTokens?: number; - outputTokens?: number; - totalTokensUsed?: number; - totalRunCost?: number; error?: string; + reasoning: string; + solution?: string[][]; }; -export async function runMutiplayerModel( +export async function runMultiplayerModel( modelId: string, map: string[][], playerToken: string, retry = 1, ): Promise { - const playerToMove = ZombieSurvival.getPlayerByToken(map, playerToken); - - if (!playerToMove) { - throw new Error(`Player token '${playerToken}' not found`); - } + const validMoveLocations = ZombieSurvival.validMoveLocations( + map, + playerToken, + ); const userPrompt = `Grid: ${JSON.stringify(map)}\n\n` + - `Valid Move Locations: ${JSON.stringify( - ZombieSurvival.validMoveLocations(map, playerToMove), - )}`; + `Valid Move Locations: ${JSON.stringify(validMoveLocations)}`; let result; let reasoning: string | null = null; try { switch (modelId) { - // TODO: do not hard code this - case "m1757gn800qrc8s4jpdcshbgah72s9e7": { - // gpt-4o + case AI_MODELS[1].name: { result = await gpt4o(SYSTEM_PROMPT, userPrompt, CONFIG); break; } @@ -132,21 +124,20 @@ export async function runMutiplayerModel( } reasoning = result.reasoning; - const originalMap = ZombieSurvival.cloneMap(map); return { - solution: originalMap, reasoning: result.reasoning, + solution: originalMap, }; } catch (error) { if (retry === MAX_RETRIES || reasoning === null) { return { - reasoning: reasoning ?? "Internal error", error: errorMessage(error), + reasoning: reasoning ?? "Internal error", }; } - return await runMutiplayerModel(modelId, map, playerToken, retry + 1); + return await runMultiplayerModel(modelId, map, playerToken, retry + 1); } } From 0e1ab61a89627b0c9a038a7ebb82fcbaada8570a Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:12:14 +0200 Subject: [PATCH 11/24] Add getActiveModelByName query --- convex/models.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/convex/models.ts b/convex/models.ts index d9e8851..9e64477 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"; @@ -44,6 +45,24 @@ export const seedModels = internalMutation({ }, }); +export const getActiveModelByName = query({ + args: { + name: v.string(), + }, + handler: async (ctx, args) => { + const record = await ctx.db + .query("models") + .filter((q) => q.eq(q.field("slug"), args.name)) + .first(); + + if (record === null) { + throw new Error(`Model with name '${args.name}' was not found`); + } + + return record; + }, +}); + export const getActiveModels = query({ handler: async (ctx) => { return await ctx.db From 4ca6ea6ef7fe5210c2d9160fe5ca002b5bc14965 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:18:58 +0200 Subject: [PATCH 12/24] Add deleted todo comment --- convex/multiplayerGames.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/convex/multiplayerGames.ts b/convex/multiplayerGames.ts index ac15627..1f23fee 100644 --- a/convex/multiplayerGames.ts +++ b/convex/multiplayerGames.ts @@ -11,6 +11,7 @@ const TURN_DELAY = 5000; export const startMultiplayerGame = internalMutation({ handler: async (ctx) => { + // TODO: need to figure out how to get id from the table by name instead const modelId = "ks7dm9g4t91bm8cy3z2544br0h72se9x" as Id<"models">; // const modelId = await ctx.runQuery(api.models.getActiveModelByName, { // name: AI_MODELS[1].name, From 7a8223e6c98b5738ec94978c30cf35455db0edfb Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:52:47 +0200 Subject: [PATCH 13/24] Use visualizer on multiplayer page --- app/multiplayer/[multiplayerGameId]/page.tsx | 22 +++++++------------- components/Visualizer.tsx | 9 ++++++-- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/app/multiplayer/[multiplayerGameId]/page.tsx b/app/multiplayer/[multiplayerGameId]/page.tsx index 069d0b9..6e533c4 100644 --- a/app/multiplayer/[multiplayerGameId]/page.tsx +++ b/app/multiplayer/[multiplayerGameId]/page.tsx @@ -1,12 +1,10 @@ "use client"; -import { useEffect, useRef } from "react"; import { useQuery } from "convex/react"; import { Page, PageTitle } from "@/components/Page"; -import { useRenderer } from "@/components/Renderer"; +import { Visualizer } from "@/components/Visualizer"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; -import { ZombieSurvival } from "@/simulator"; export default function MultiplayerPage({ params, @@ -17,25 +15,19 @@ export default function MultiplayerPage({ multiplayerGameId: params.multiplayerGameId, }); - const canvas = useRef(null); - const renderer = useRenderer(multiplayerGame?.boardState, canvas); - - useEffect(() => { - if (renderer !== null && multiplayerGame) { - const simulator = new ZombieSurvival(multiplayerGame.boardState); - renderer.render(simulator.getAllEntities()); - } - }, [multiplayerGame, renderer]); - - if (!multiplayerGame) { + if (multiplayerGame === undefined) { return
Loading...
; } + if (multiplayerGame === null) { + return
Game not found.
; + } + return ( Multiplayer
- +
); diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx index 33bfe05..fc306ac 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -40,9 +40,14 @@ export function Visualizer({ } }, [autoStart, renderer]); + useEffect(() => { + if (renderer !== null) { + simulator.current = new ZombieSurvival(map); + renderer?.render(simulator.current.getAllEntities()); + } + }, [renderer]); + function startSimulation() { - simulator.current = new ZombieSurvival(map); - renderer?.render(simulator.current.getAllEntities()); setRunning(true); interval.current = setInterval(() => { From d16c6a15ab7653fdce97c8c5fce4c20e1c3f8f0a Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:53:00 +0200 Subject: [PATCH 14/24] Fix Assets hydration issue --- renderer/Assets.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/renderer/Assets.ts b/renderer/Assets.ts index 34b5979..61b6081 100644 --- a/renderer/Assets.ts +++ b/renderer/Assets.ts @@ -17,7 +17,10 @@ export class RendererAssets extends EventTarget { constructor() { super(); - void this.load(); + + if (typeof window !== "undefined") { + void this.load(); + } } private async load() { From ff7263fe1c64f16edf93255e647ce8f2c25d6d62 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 03:58:05 +0200 Subject: [PATCH 15/24] Rename config to map in simulator --- simulator/ZombieSurvival.spec.ts | 14 +++++++------- simulator/ZombieSurvival.ts | 12 ++++++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/simulator/ZombieSurvival.spec.ts b/simulator/ZombieSurvival.spec.ts index f408c05..ed40102 100644 --- a/simulator/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("Map has no player"); expect( () => @@ -33,7 +33,7 @@ test("fails on invalid config", () => { [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], ]), - ).toThrowError("Config contains multiple players"); + ).toThrowError("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", " ", " ", " ", " ", " "], diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index c9f9aee..a5ab589 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -66,7 +66,7 @@ export class ZombieSurvival { } } - if (ZombieSurvival.mapHasPlayer(map) && this.players.length === 0) { + if (!ZombieSurvival.mapIsMultiplayer(map) && this.players.length === 0) { throw new Error("Map has no player"); } @@ -101,12 +101,12 @@ export class ZombieSurvival { throw new Error(`Entity position for token '${token}' not found`); } - public static isWin(config: string[][]): boolean { - if (ZombieSurvival.mapIsEmpty(config)) { + public static isWin(map: string[][]): boolean { + if (ZombieSurvival.mapIsEmpty(map)) { return false; } - const game = new ZombieSurvival(config); + const game = new ZombieSurvival(map); while (!game.finished()) { game.step(); @@ -133,6 +133,10 @@ export class ZombieSurvival { 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 nextValidPosition(map: string[][]): Position | null { for (let y = 0; y < map.length; y++) { for (let x = 0; x < map[y].length; x++) { From bd01dba754ea708d4937b9381d6d9547f45b2f0f Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 04:07:59 +0200 Subject: [PATCH 16/24] Use valid location with model mocks --- convex/maps.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/convex/maps.ts b/convex/maps.ts index 44e13cc..33c9ce8 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -331,22 +331,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, { From e9ab77add68b8d3a996f24ace335ecdb3c1dc417 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 05:00:31 +0200 Subject: [PATCH 17/24] Simulator structure improvements --- app/multiplayer/[multiplayerGameId]/page.tsx | 6 +- components/Visualizer.tsx | 15 ++- convex/multiplayerGames.ts | 3 +- {simulator/lib => lib}/closestEntity.ts | 2 +- {simulator/lib => lib}/entityAt.ts | 3 +- models/multiplayer/index.ts | 2 +- renderer/Renderer.ts | 24 ++-- simulator/Change.ts | 23 ---- simulator/Entity.ts | 45 +++++--- simulator/VisualEvent.ts | 26 +++++ simulator/ZombieSurvival.ts | 113 +++++++++--------- simulator/entities/Player.ts | 6 +- simulator/entities/Zombie.ts | 115 ++++++++++++++----- simulator/index.ts | 2 +- simulator/lib/pathfinder.ts | 55 --------- 15 files changed, 240 insertions(+), 200 deletions(-) rename {simulator/lib => lib}/closestEntity.ts (94%) rename {simulator/lib => lib}/entityAt.ts (82%) delete mode 100644 simulator/Change.ts create mode 100644 simulator/VisualEvent.ts delete mode 100644 simulator/lib/pathfinder.ts diff --git a/app/multiplayer/[multiplayerGameId]/page.tsx b/app/multiplayer/[multiplayerGameId]/page.tsx index 6e533c4..e5e1e8c 100644 --- a/app/multiplayer/[multiplayerGameId]/page.tsx +++ b/app/multiplayer/[multiplayerGameId]/page.tsx @@ -27,7 +27,11 @@ export default function MultiplayerPage({ Multiplayer
- +
); diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx index fc306ac..7cb22fa 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -5,7 +5,7 @@ import { AUTO_REPLAY_SPEED, DEFAULT_REPLAY_SPEED, } from "@/constants/visualizer"; -import { ZombieSurvival } from "@/simulator"; +import { ZombieSurvival, type ZombieSurvivalOptions } from "@/simulator"; export function Visualizer({ autoReplay = false, @@ -16,6 +16,7 @@ export function Visualizer({ onReset, onSimulationEnd, replaySpeed = DEFAULT_REPLAY_SPEED, + simulatorOptions, }: { autoReplay?: boolean; autoStart?: boolean; @@ -25,8 +26,12 @@ export function Visualizer({ onReset?: () => unknown; onSimulationEnd?: (isWin: boolean) => unknown; replaySpeed?: number; + simulatorOptions?: ZombieSurvivalOptions; }) { - const simulator = useRef(new ZombieSurvival(map)); + const simulator = useRef( + new ZombieSurvival(map, simulatorOptions), + ); + const interval = useRef | null>(null); const timeout = useRef | null>(null); const canvas = useRef(null); @@ -42,7 +47,7 @@ export function Visualizer({ useEffect(() => { if (renderer !== null) { - simulator.current = new ZombieSurvival(map); + simulator.current = new ZombieSurvival(map, simulatorOptions); renderer?.render(simulator.current.getAllEntities()); } }, [renderer]); @@ -67,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); diff --git a/convex/multiplayerGames.ts b/convex/multiplayerGames.ts index 1f23fee..ff22a26 100644 --- a/convex/multiplayerGames.ts +++ b/convex/multiplayerGames.ts @@ -84,7 +84,7 @@ export const runMultiplayerGameTurn = internalAction({ const map = new ZombieSurvival(multiplayerGame.boardState); if (turn === "Z") { - map.step({ skipPlayer: true }); + map.stepZombies(); await ctx.runMutation( internal.multiplayerGames.updateMultiplayerGameBoardState, @@ -103,6 +103,7 @@ export const runMultiplayerGameTurn = internalAction({ // ); // the LLM model should return the next move and which zombie it should shoot // update the board state with the new player location + // map.stepPlayer(playerToken); return; } diff --git a/simulator/lib/closestEntity.ts b/lib/closestEntity.ts similarity index 94% rename from simulator/lib/closestEntity.ts rename to lib/closestEntity.ts index 87687bd..fdf5e94 100644 --- a/simulator/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/simulator/lib/entityAt.ts b/lib/entityAt.ts similarity index 82% rename from simulator/lib/entityAt.ts rename to lib/entityAt.ts index d59eb04..06d5f40 100644 --- a/simulator/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/multiplayer/index.ts b/models/multiplayer/index.ts index 4518628..ac504e8 100644 --- a/models/multiplayer/index.ts +++ b/models/multiplayer/index.ts @@ -100,7 +100,7 @@ export async function runMultiplayerModel( playerToken: string, retry = 1, ): Promise { - const validMoveLocations = ZombieSurvival.validMoveLocations( + const validMoveLocations = ZombieSurvival.validPlayerMoveLocations( map, playerToken, ); diff --git a/renderer/Renderer.ts b/renderer/Renderer.ts index d7d15e4..37ca3be 100644 --- a/renderer/Renderer.ts +++ b/renderer/Renderer.ts @@ -3,13 +3,15 @@ import * as Canvas from "./Canvas"; import { type RendererEffect, RendererEffectType } from "./Effect"; import { RendererItem } from "./Item"; import { - ChangeType, type Entity, EntityType, type Position, + VisualEventType, Zombie, } from "@/simulator"; +const ANIMATABLE_DEAD_ENTITIES = [EntityType.Zombie]; + export class Renderer { private readonly cellSize: number; private readonly replaySpeed: number; @@ -187,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; @@ -251,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; } @@ -281,9 +291,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; diff --git a/simulator/Change.ts b/simulator/Change.ts deleted file mode 100644 index 4baa116..0000000 --- a/simulator/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/simulator/Entity.ts b/simulator/Entity.ts index 3703a21..296a7d0 100644 --- a/simulator/Entity.ts +++ b/simulator/Entity.ts @@ -1,5 +1,5 @@ -import { type Change, ChangeType } from "./Change"; -import { Position } from "./Position"; +import { type Position } from "./Position"; +import { type VisualEvent, VisualEventType } from "./VisualEvent"; export enum EntityType { Box, @@ -11,10 +11,10 @@ export enum EntityType { export abstract class Entity { protected destructible: boolean; - protected changes: Change[] = []; protected health: number; protected position: Position; protected type: EntityType; + protected visualEvents: VisualEvent[] = []; public abstract getToken(): string; @@ -30,12 +30,12 @@ export abstract class Entity { this.type = type; } - public addChange(change: Change): void { - this.changes.push(change); + public addVisualEvent(visualEvent: VisualEvent): void { + this.visualEvents.push(visualEvent); } - public clearChanges(): void { - this.changes = []; + public clearVisualEvents(): void { + this.visualEvents = []; } public dead(): boolean { @@ -46,18 +46,20 @@ export abstract class Entity { this.health = 0; } - public getChange(type: T) { - const change = this.changes.find((change) => change.type === type); + public getVisualEvent(type: T) { + const visualEvent = this.visualEvents.find( + (visualEvent) => visualEvent.type === type, + ); - if (change === undefined) { - throw new Error("Unable to find change of this type"); + if (visualEvent === undefined) { + throw new Error("Unable to find visual event of this type"); } - return change as Extract; + return visualEvent as Extract; } - public getChanges(): Change[] { - return this.changes; + public getChanges(): VisualEvent[] { + return this.visualEvents; } public getPosition(): Position { @@ -76,12 +78,12 @@ export abstract class Entity { return this.type; } - public hasChange(type: ChangeType): boolean { - return this.changes.some((change) => change.type === type); + public hasVisualEvent(type: VisualEventType): boolean { + return this.visualEvents.some((visualEvent) => visualEvent.type === type); } - public hasChanges(): boolean { - return this.changes.length !== 0; + public hasVisualEvents(): boolean { + return this.visualEvents.length !== 0; } public hit() { @@ -89,7 +91,14 @@ export abstract class Entity { 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 { diff --git a/simulator/VisualEvent.ts b/simulator/VisualEvent.ts new file mode 100644 index 0000000..7608947 --- /dev/null +++ b/simulator/VisualEvent.ts @@ -0,0 +1,26 @@ +import { 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/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index a5ab589..303a0a9 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -1,13 +1,16 @@ -import { ChangeType } from "./Change"; import { allDirections, move } from "./Direction"; import { Entity } from "./Entity"; -import { Position, samePosition } from "./Position"; +import { 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"; -import { entityAt } from "./lib/entityAt"; +import { entityAt } from "@/lib/entityAt"; + +export interface ZombieSurvivalOptions { + multiplayer?: boolean; +} export class ZombieSurvival { public readonly boardHeight: number; @@ -17,7 +20,7 @@ export class ZombieSurvival { private players: Player[] = []; private zombies: Zombie[] = []; - public constructor(map: string[][]) { + public constructor(map: string[][], options: ZombieSurvivalOptions = {}) { if (ZombieSurvival.mapIsEmpty(map)) { throw new Error("Map is empty"); } @@ -66,7 +69,7 @@ export class ZombieSurvival { } } - if (!ZombieSurvival.mapIsMultiplayer(map) && this.players.length === 0) { + if (!options.multiplayer && this.players.length === 0) { throw new Error("Map has no player"); } @@ -137,31 +140,25 @@ export class ZombieSurvival { return map.flat().some((it) => ["1", "2", "3", "4", "5", "6"].includes(it)); } - 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[][] { + 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 number[][]), + row.reduce( + (acc, cell, x) => { + if (cell === " ") { + acc.push([y, x]); + } + return acc; + }, + [] as Array<[number, number]>, + ), ); } - public static validMoveLocations(map: string[][], token: string): number[][] { - const position = ZombieSurvival.entityPosition(map, token); + public static validPlayerMoveLocations( + map: string[][], + playerToken: string, + ): number[][] { + const position = ZombieSurvival.entityPosition(map, playerToken); const validMoves: number[][] = []; for (const direction of allDirections) { @@ -199,12 +196,22 @@ export class ZombieSurvival { return this.entities; } - public getPlayer(): Player { - if (this.multiplayer) { + 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"); } - return this.players[0]; + for (const player of this.players) { + if (player.getToken() === token) { + return player; + } + } + + throw new Error(`Tried getting non-existing player '${token}'`); } public getState(): string[][] { @@ -229,39 +236,33 @@ export class ZombieSurvival { return this.zombies; } - public step({ skipPlayer = false }: { skipPlayer?: boolean } = {}): void { - const initialHealth = this.zombies.map((zombie) => zombie.getHealth()); + public resetVisualEvents() { + const entities = this.getAllEntities(); - if (!skipPlayer) { - this.getPlayer().clearChanges(); - this.getPlayer().shoot(); + for (const entity of entities) { + entity.clearVisualEvents(); } + } - for (let i = 0; i < this.zombies.length && !this.finished(); 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 }); - } + public step(): void { + this.resetVisualEvents(); + this.stepPlayers(); + this.stepZombies(); + } - if (initialZombieHealth !== zombie.getHealth()) { - zombie.addChange({ type: ChangeType.Hit }); - } + public stepPlayer(token: string): void { + this.getPlayer(token).shoot(); + } - const currentPosition = zombie.getPosition(); + public stepPlayers(): void { + for (const player of this.players) { + player.shoot(); + } + } - if (!samePosition(initialPosition, currentPosition)) { - zombie.addChange({ - type: ChangeType.Walking, - from: initialPosition, - to: currentPosition, - }); - } + public stepZombies(): void { + for (let i = 0; i < this.zombies.length && !this.finished(); i++) { + this.zombies[i].walk(); } } } diff --git a/simulator/entities/Player.ts b/simulator/entities/Player.ts index bd60460..cc2018d 100644 --- a/simulator/entities/Player.ts +++ b/simulator/entities/Player.ts @@ -1,7 +1,7 @@ import { Entity, EntityType } from "../Entity"; import { Position } from "../Position"; import { ZombieSurvival } from "../ZombieSurvival"; -import { closestEntity } from "../lib/closestEntity"; +import { closestEntity } from "@/lib/closestEntity"; export class Player extends Entity { public static Destructible = true; @@ -25,6 +25,10 @@ export class Player extends Entity { } public shoot() { + if (this.dead()) { + return; + } + const zombie = closestEntity(this, this.game.getZombies()); zombie.hit(); } diff --git a/simulator/entities/Zombie.ts b/simulator/entities/Zombie.ts index b785a90..067dca2 100644 --- a/simulator/entities/Zombie.ts +++ b/simulator/entities/Zombie.ts @@ -1,9 +1,9 @@ import { Direction, allDirections, move } from "../Direction"; import { Entity, EntityType } from "../Entity"; import { Position } from "../Position"; +import { VisualEventType } from "../VisualEvent"; import { ZombieSurvival } from "../ZombieSurvival"; -import { entityAt } from "../lib/entityAt"; -import { pathfinder } from "../lib/pathfinder"; +import { entityAt } from "@/lib/entityAt"; export class Zombie extends Entity { public static Destructible = true; @@ -20,7 +20,81 @@ export class Zombie extends Entity { return "Z"; } - public listMoves(): Direction[] { + 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.walkTo(newPosition); + } + + private findPath(): Direction[] { + const player = this.game.getPlayer(); + 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[] = []; @@ -48,33 +122,14 @@ export class Zombie extends Entity { 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) { + private walkTo(position: Position) { + const initialPosition = { ...this.position }; this.position = position; + + this.addVisualEvent({ + type: VisualEventType.Moving, + from: initialPosition, + to: this.position, + }); } } diff --git a/simulator/index.ts b/simulator/index.ts index 62a0825..2a8fc58 100644 --- a/simulator/index.ts +++ b/simulator/index.ts @@ -3,8 +3,8 @@ export * from "./entities/Landmine"; export * from "./entities/Player"; export * from "./entities/Rock"; export * from "./entities/Zombie"; -export * from "./Change"; export * from "./Direction"; export * from "./Entity"; export * from "./Position"; +export * from "./VisualEvent"; export * from "./ZombieSurvival"; diff --git a/simulator/lib/pathfinder.ts b/simulator/lib/pathfinder.ts deleted file mode 100644 index c2be4b7..0000000 --- a/simulator/lib/pathfinder.ts +++ /dev/null @@ -1,55 +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"); -} From e967cb45cd249acae644b53b9c5be10d88069c8a Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 13:45:52 +0200 Subject: [PATCH 18/24] Fix convex typings everywhere --- app/games/[gameId]/page.tsx | 11 +++++-- app/leaderboard/page.tsx | 51 ++++++++++----------------------- app/play/page.tsx | 12 ++------ app/prompts/[promptId]/page.tsx | 3 +- convex/flags.ts | 1 - convex/games.ts | 9 ++++-- convex/leaderboard.ts | 4 --- convex/maps.ts | 31 ++++++-------------- convex/models.ts | 2 +- convex/multiplayerGames.ts | 15 +++++----- convex/prompts.ts | 32 ++++----------------- convex/schema.ts | 8 ++++-- convex/scores.ts | 6 ++-- convex/users.ts | 6 ++-- hooks/useAITesting.ts | 2 +- simulator/ZombieSurvival.ts | 4 +-- 16 files changed, 70 insertions(+), 127 deletions(-) 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/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/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/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..71bb4c2 100644 --- a/convex/games.ts +++ b/convex/games.ts @@ -15,9 +15,12 @@ export const testModel = mutation({ 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; }, 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 33c9ce8..96044ca 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -4,6 +4,7 @@ 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)) @@ -355,17 +354,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, { @@ -385,17 +379,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); }, @@ -420,13 +411,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 9e64477..787b366 100644 --- a/convex/models.ts +++ b/convex/models.ts @@ -52,7 +52,7 @@ export const getActiveModelByName = query({ handler: async (ctx, args) => { const record = await ctx.db .query("models") - .filter((q) => q.eq(q.field("slug"), args.name)) + .withIndex("by_slug", (q) => q.eq("slug", args.name)) .first(); if (record === null) { diff --git a/convex/multiplayerGames.ts b/convex/multiplayerGames.ts index ff22a26..26611c2 100644 --- a/convex/multiplayerGames.ts +++ b/convex/multiplayerGames.ts @@ -2,7 +2,7 @@ import { runMultiplayerModel } from "../models/multiplayer"; import { ZombieSurvival } from "../simulator"; import { v } from "convex/values"; import { api, internal } from "./_generated/api"; -import { Id } from "./_generated/dataModel"; +import { Doc } from "./_generated/dataModel"; import { internalAction, internalMutation, query } from "./_generated/server"; import { AI_MODELS } from "./constants"; @@ -11,11 +11,12 @@ const TURN_DELAY = 5000; export const startMultiplayerGame = internalMutation({ handler: async (ctx) => { - // TODO: need to figure out how to get id from the table by name instead - const modelId = "ks7dm9g4t91bm8cy3z2544br0h72se9x" as Id<"models">; - // const modelId = await ctx.runQuery(api.models.getActiveModelByName, { - // name: AI_MODELS[1].name, - // }); + const model: Doc<"models"> = await ctx.runQuery( + api.models.getActiveModelByName, + { + name: AI_MODELS[1].name, + }, + ); const gameId = await ctx.db.insert("multiplayerGames", { boardState: [ @@ -27,7 +28,7 @@ export const startMultiplayerGame = internalMutation({ [" ", " ", " ", " ", " "], [" ", " ", " ", " ", HARD_CODED_PLAYER_TOKEN], ], - playerMap: [{ modelId: modelId, playerToken: HARD_CODED_PLAYER_TOKEN }], + playerMap: [{ modelId: model._id, playerToken: HARD_CODED_PLAYER_TOKEN }], }); await ctx.scheduler.runAfter( 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/schema.ts b/convex/schema.ts index 6ce0b8b..bf02c21 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"]), 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/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/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index 303a0a9..d3c6b97 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -187,9 +187,7 @@ export class ZombieSurvival { } public getAllEntities(): Entity[] { - return [this.entities, this.zombies, this.players] - .flat() - .filter(Boolean) as Entity[]; + return [this.entities, this.zombies, this.players].flat(); } public getEntities(): Entity[] { From f84f9e31e92f21e8e8a8e97d3e57b9ed3663a94f Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 13:58:27 +0200 Subject: [PATCH 19/24] Various code style fixes --- app/play/[level]/[attempt]/page.tsx | 1 - app/test/page.tsx | 8 +++----- components/CopyMapButton.tsx | 8 ++++---- components/ModelSelector.tsx | 4 ++-- components/PlayMapButton.tsx | 1 - convex/games.ts | 10 +++++++--- convex/maps.ts | 4 +++- convex/results.ts | 4 +++- 8 files changed, 22 insertions(+), 18 deletions(-) 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/test/page.tsx b/app/test/page.tsx index b170126..e93464c 100644 --- a/app/test/page.tsx +++ b/app/test/page.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import { useState } from "react"; import { useMutation, useQuery } from "convex/react"; import { useRouter } from "next/navigation"; import { ModelSelector } from "@/components/ModelSelector"; @@ -10,7 +10,7 @@ import { api } from "@/convex/_generated/api"; export default function TestPage() { const testModel = useMutation(api.games.testModel); - const [model, setModel] = React.useState(""); + const [model, setModel] = useState(""); const router = useRouter(); const activeModels = useQuery(api.models.getActiveModels); @@ -39,9 +39,7 @@ export default function TestPage() { await Promise.all( activeModels.map((model) => { - 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/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/convex/games.ts b/convex/games.ts index 71bb4c2..1096e60 100644 --- a/convex/games.ts +++ b/convex/games.ts @@ -11,7 +11,7 @@ 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"); } @@ -27,7 +27,9 @@ export const testModel = mutation({ }); export const startNewGame = internalMutation({ - args: { modelId: v.string() }, + args: { + modelId: v.string(), + }, handler: async (ctx, args) => { if (!AI_MODEL_IDS.includes(args.modelId)) { throw new Error("Invalid model ID"); @@ -59,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/maps.ts b/convex/maps.ts index 96044ca..45e9288 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -287,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") 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") From 7d6e62b825982c5882e3cdf7c70e06c842b848ea Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 14:16:49 +0200 Subject: [PATCH 20/24] Apply visual events within entity --- simulator/Direction.ts | 2 +- simulator/Entity.ts | 11 +++++++++++ simulator/VisualEvent.ts | 2 +- simulator/ZombieSurvival.ts | 4 ++-- simulator/entities/Box.ts | 2 +- simulator/entities/Landmine.ts | 2 +- simulator/entities/Player.ts | 4 ++-- simulator/entities/Rock.ts | 2 +- simulator/entities/Zombie.ts | 20 ++++---------------- 9 files changed, 24 insertions(+), 25 deletions(-) diff --git a/simulator/Direction.ts b/simulator/Direction.ts index 1894376..b5252a5 100644 --- a/simulator/Direction.ts +++ b/simulator/Direction.ts @@ -1,4 +1,4 @@ -import { Position } from "./Position"; +import { type Position } from "./Position"; export enum Direction { Down, diff --git a/simulator/Entity.ts b/simulator/Entity.ts index 296a7d0..577436c 100644 --- a/simulator/Entity.ts +++ b/simulator/Entity.ts @@ -108,4 +108,15 @@ export abstract class Entity { 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/simulator/VisualEvent.ts b/simulator/VisualEvent.ts index 7608947..d24bc33 100644 --- a/simulator/VisualEvent.ts +++ b/simulator/VisualEvent.ts @@ -1,4 +1,4 @@ -import { Position } from "./Position"; +import { type Position } from "./Position"; export enum VisualEventType { Destructured, diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index d3c6b97..0cd91c5 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -1,6 +1,6 @@ import { allDirections, move } from "./Direction"; -import { Entity } from "./Entity"; -import { Position } from "./Position"; +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"; diff --git a/simulator/entities/Box.ts b/simulator/entities/Box.ts index dfb665b..e74f468 100644 --- a/simulator/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; diff --git a/simulator/entities/Landmine.ts b/simulator/entities/Landmine.ts index 0591a64..48070f7 100644 --- a/simulator/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; diff --git a/simulator/entities/Player.ts b/simulator/entities/Player.ts index cc2018d..59bb43d 100644 --- a/simulator/entities/Player.ts +++ b/simulator/entities/Player.ts @@ -1,6 +1,6 @@ import { Entity, EntityType } from "../Entity"; -import { Position } from "../Position"; -import { ZombieSurvival } from "../ZombieSurvival"; +import { type Position } from "../Position"; +import { type ZombieSurvival } from "../ZombieSurvival"; import { closestEntity } from "@/lib/closestEntity"; export class Player extends Entity { diff --git a/simulator/entities/Rock.ts b/simulator/entities/Rock.ts index 4ddbf1c..49547fb 100644 --- a/simulator/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; diff --git a/simulator/entities/Zombie.ts b/simulator/entities/Zombie.ts index 067dca2..72698ce 100644 --- a/simulator/entities/Zombie.ts +++ b/simulator/entities/Zombie.ts @@ -1,8 +1,7 @@ -import { Direction, allDirections, move } from "../Direction"; +import { type Direction, allDirections, move } from "../Direction"; import { Entity, EntityType } from "../Entity"; -import { Position } from "../Position"; -import { VisualEventType } from "../VisualEvent"; -import { ZombieSurvival } from "../ZombieSurvival"; +import { type Position } from "../Position"; +import { type ZombieSurvival } from "../ZombieSurvival"; import { entityAt } from "@/lib/entityAt"; export class Zombie extends Entity { @@ -38,7 +37,7 @@ export class Zombie extends Entity { entity.hit(); } - this.walkTo(newPosition); + this.moveTo(newPosition); } private findPath(): Direction[] { @@ -121,15 +120,4 @@ export class Zombie extends Entity { return result; } - - private walkTo(position: Position) { - const initialPosition = { ...this.position }; - this.position = position; - - this.addVisualEvent({ - type: VisualEventType.Moving, - from: initialPosition, - to: this.position, - }); - } } From 7051e65a8063de54960fd3211919204cb381a16e Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 14:38:00 +0200 Subject: [PATCH 21/24] Fix animations in renderer --- renderer/Renderer.ts | 59 ++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/renderer/Renderer.ts b/renderer/Renderer.ts index 37ca3be..503bd00 100644 --- a/renderer/Renderer.ts +++ b/renderer/Renderer.ts @@ -270,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, @@ -308,8 +294,6 @@ export class Renderer { }, }; - healthBarItem.addEffect(positionToEffect); - healthBarBgItem.addEffect(positionToEffect); rendererItem.addEffect(positionToEffect); if (from.x >= to.x) { @@ -317,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 && @@ -358,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); } From 0c2b440b19e7b835c5c8f6b36814a0fff0010c1b Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 15:01:06 +0200 Subject: [PATCH 22/24] Fix multiplayer logic in simulator --- simulator/ZombieSurvival.ts | 49 ++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index 0cd91c5..88be091 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -16,7 +16,7 @@ export class ZombieSurvival { public readonly boardHeight: number; public readonly boardWidth: number; private entities: Entity[] = []; - private multiplayer = false; + private multiplayer; private players: Player[] = []; private zombies: Zombie[] = []; @@ -27,12 +27,14 @@ export class ZombieSurvival { 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]; + const code = map[y][x].toLowerCase(); - switch (code.toLowerCase()) { + switch (code) { case "b": { this.entities.push(new Box({ x, y })); break; @@ -49,18 +51,43 @@ export class ZombieSurvival { 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": - case "p": { - if (this.players.length !== 0) { - throw new Error("Map contains multiple players"); + 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.toLocaleLowerCase()); + const player = new Player(this, { x, y }, code); this.players.push(player); break; @@ -69,12 +96,10 @@ export class ZombieSurvival { } } - if (!options.multiplayer && this.players.length === 0) { - throw new Error("Map has no player"); + if (!this.multiplayer && this.players.length === 0) { + throw new Error("Single player map has no player"); } - this.multiplayer = this.players.length > 1; - if (this.zombies.length === 0) { throw new Error("Map has no zombies"); } From 3d9e6b6d20595e9566c6489a28e1eea5b1c62ec7 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Mon, 4 Nov 2024 15:04:51 +0200 Subject: [PATCH 23/24] Fix simple simulator tests --- simulator/ZombieSurvival.spec.ts | 8 +++++--- simulator/ZombieSurvival.ts | 6 +++--- simulator/entities/Player.ts | 2 +- simulator/entities/Zombie.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/simulator/ZombieSurvival.spec.ts b/simulator/ZombieSurvival.spec.ts index ed40102..7aea8d4 100644 --- a/simulator/ZombieSurvival.spec.ts +++ b/simulator/ZombieSurvival.spec.ts @@ -18,7 +18,7 @@ test("fails on invalid map", () => { [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], ]), - ).toThrowError("Map has no player"); + ).toThrowError("Single player map has no player"); expect( () => @@ -33,7 +33,7 @@ test("fails on invalid map", () => { [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], ]), - ).toThrowError("Map contains multiple players"); + ).toThrowError("Single player map contains multiple players"); expect( () => @@ -64,7 +64,9 @@ test("fails on impossible to beat map", () => { [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], ]); - 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", () => { diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index 88be091..36ce80e 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -1,3 +1,4 @@ +import { entityAt } from "../lib/entityAt"; import { allDirections, move } from "./Direction"; import { type Entity } from "./Entity"; import { type Position } from "./Position"; @@ -6,7 +7,6 @@ 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 interface ZombieSurvivalOptions { multiplayer?: boolean; @@ -32,9 +32,9 @@ export class ZombieSurvival { for (let y = 0; y < this.boardHeight; y++) { for (let x = 0; x < this.boardWidth; x++) { - const code = map[y][x].toLowerCase(); + const code = map[y][x]; - switch (code) { + switch (code.toLowerCase()) { case "b": { this.entities.push(new Box({ x, y })); break; diff --git a/simulator/entities/Player.ts b/simulator/entities/Player.ts index 59bb43d..3e2d830 100644 --- a/simulator/entities/Player.ts +++ b/simulator/entities/Player.ts @@ -1,7 +1,7 @@ +import { closestEntity } from "../../lib/closestEntity"; import { Entity, EntityType } from "../Entity"; import { type Position } from "../Position"; import { type ZombieSurvival } from "../ZombieSurvival"; -import { closestEntity } from "@/lib/closestEntity"; export class Player extends Entity { public static Destructible = true; diff --git a/simulator/entities/Zombie.ts b/simulator/entities/Zombie.ts index 72698ce..ac08b7f 100644 --- a/simulator/entities/Zombie.ts +++ b/simulator/entities/Zombie.ts @@ -1,8 +1,8 @@ +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"; -import { entityAt } from "@/lib/entityAt"; export class Zombie extends Entity { public static Destructible = true; From fa2027d214e5fbdb90579ab0e0a4e5c337fb289a Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Mon, 4 Nov 2024 11:17:01 -0500 Subject: [PATCH 24/24] getting the multiplayer game to work --- convex/constants.ts | 32 ++-- convex/games.ts | 4 +- convex/models.ts | 18 +- convex/multiplayerGames.ts | 342 +++++++++++++++++++++++++++++++---- convex/schema.ts | 2 +- models/index.ts | 10 +- models/multiplayer/gpt-4o.ts | 7 +- models/multiplayer/index.ts | 82 ++++----- simulator/Direction.ts | 20 ++ simulator/ZombieSurvival.ts | 86 +++++++-- simulator/entities/Zombie.ts | 16 +- 11 files changed, 490 insertions(+), 129 deletions(-) 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/games.ts b/convex/games.ts index 1096e60..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: { @@ -31,7 +31,7 @@ export const startNewGame = internalMutation({ 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"); } diff --git a/convex/models.ts b/convex/models.ts index 787b366..89e6270 100644 --- a/convex/models.ts +++ b/convex/models.ts @@ -14,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, + }), ), ); }, @@ -25,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; @@ -34,7 +36,7 @@ export const seedModels = internalMutation({ promises.push( ctx.db.insert("models", { - slug: model.model, + slug: model.slug, name: model.name, active: false, }), @@ -45,18 +47,18 @@ export const seedModels = internalMutation({ }, }); -export const getActiveModelByName = query({ +export const getActiveModelBySlug = query({ args: { - name: v.string(), + slug: v.string(), }, handler: async (ctx, args) => { const record = await ctx.db .query("models") - .withIndex("by_slug", (q) => q.eq("slug", args.name)) + .withIndex("by_slug", (q) => q.eq("slug", args.slug)) .first(); if (record === null) { - throw new Error(`Model with name '${args.name}' was not found`); + throw new Error(`Model with name '${args.slug}' was not found`); } return record; diff --git a/convex/multiplayerGames.ts b/convex/multiplayerGames.ts index 26611c2..39bfb1f 100644 --- a/convex/multiplayerGames.ts +++ b/convex/multiplayerGames.ts @@ -1,34 +1,284 @@ import { runMultiplayerModel } from "../models/multiplayer"; -import { ZombieSurvival } from "../simulator"; +import { + Direction, + ZombieSurvival, + fromDirectionString, + move, +} from "../simulator"; import { v } from "convex/values"; import { api, internal } from "./_generated/api"; -import { Doc } from "./_generated/dataModel"; import { internalAction, internalMutation, query } from "./_generated/server"; -import { AI_MODELS } from "./constants"; +import { AI_MODELS, ModelSlug } from "./constants"; const HARD_CODED_PLAYER_TOKEN = "1"; -const TURN_DELAY = 5000; +const TURN_DELAY = 0; export const startMultiplayerGame = internalMutation({ handler: async (ctx) => { - const model: Doc<"models"> = await ctx.runQuery( - api.models.getActiveModelByName, - { - name: AI_MODELS[1].name, - }, - ); - const gameId = await ctx.db.insert("multiplayerGames", { boardState: [ - ["Z", " ", " ", " ", " "], - [" ", " ", " ", " ", " "], - [" ", " ", " ", " ", " "], - [" ", " ", " ", " ", " "], - [" ", " ", " ", " ", " "], - [" ", " ", " ", " ", " "], - [" ", " ", " ", " ", HARD_CODED_PLAYER_TOKEN], + [ + "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, + }, ], - playerMap: [{ modelId: model._id, playerToken: HARD_CODED_PLAYER_TOKEN }], }); await ctx.scheduler.runAfter( @@ -95,18 +345,48 @@ export const runMultiplayerGameTurn = internalAction({ }, ); } else if (turn === HARD_CODED_PLAYER_TOKEN) { - // TODO: based on who's turn it is, lookup the LLM model - // run the LLM model over the player's location - // const results = await runMultiplayerModel( - // HARD_CODED_MODEL_ID, - // map.getState(), - // HARD_CODED_PLAYER_TOKEN, - // ); - // the LLM model should return the next move and which zombie it should shoot - // update the board state with the new player location - // map.stepPlayer(playerToken); - - return; + 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()) { diff --git a/convex/schema.ts b/convex/schema.ts index bf02c21..48897b9 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -85,7 +85,7 @@ export default defineSchema({ boardState: v.array(v.array(v.string())), playerMap: v.array( v.object({ - modelId: v.id("models"), + modelSlug: v.string(), playerToken: v.string(), }), ), diff --git a/models/index.ts b/models/index.ts index 732b6ff..927d674 100644 --- a/models/index.ts +++ b/models/index.ts @@ -63,23 +63,23 @@ export async function runModel( try { switch (modelId) { - case AI_MODELS[0].name: { + case AI_MODELS["gemini-1.5-pro"].slug: { result = await gemini15pro(prompt, userPrompt, CONFIG); break; } - case AI_MODELS[1].name: { + case AI_MODELS["gpt-4o"].slug: { result = await gpt4o(prompt, userPrompt, CONFIG); break; } - case AI_MODELS[2].name: { + case AI_MODELS["claude-3.5-sonnet"].slug: { result = await claude35sonnet(prompt, userPrompt, CONFIG); break; } - case AI_MODELS[3].name: { + case AI_MODELS["perplexity-llama-3.1"].slug: { result = await perplexityLlama31(prompt, userPrompt, CONFIG); break; } - case AI_MODELS[4].name: { + 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 index f5f3c51..a6e24e3 100644 --- a/models/multiplayer/gpt-4o.ts +++ b/models/multiplayer/gpt-4o.ts @@ -4,7 +4,8 @@ import { zodResponseFormat } from "openai/helpers/zod"; import { z } from "zod"; const responseSchema = z.object({ - moveLocation: z.array(z.number()), + moveDirection: z.string(), + zombieToShoot: z.array(z.number()), }); export const gpt4o: MultiplayerModelHandler = async ( @@ -41,7 +42,7 @@ export const gpt4o: MultiplayerModelHandler = async ( } return { - moveLocation: response.parsed.moveLocation, - reasoning: "because", + moveDirection: response.parsed.moveDirection, + zombieToShoot: response.parsed.zombieToShoot, }; }; diff --git a/models/multiplayer/index.ts b/models/multiplayer/index.ts index ac504e8..c599a4a 100644 --- a/models/multiplayer/index.ts +++ b/models/multiplayer/index.ts @@ -1,35 +1,24 @@ import { gpt4o } from "./gpt-4o"; -import { AI_MODELS } from "@/convex/constants"; +import { ModelSlug } from "@/convex/constants"; import { errorMessage } from "@/lib/utils"; import { ZombieSurvival } from "@/simulator"; -// TODO: rewrite this prompt to work for multiplayer -const SYSTEM_PROMPT = `Your task is to play a game. We will give you a 2d array of characters that represent the game board. Before the game starts, you have these two tasks: - -1. Place two blocks ("B") in locations which maximize the player's survival. -2. Place the player ("P") in a location which maximize the player's survival. - -# Placing Rules -- You can not place blocks in locations already used by zombies or rocks. -- You can not place the player in a location already used by a zombie or rock. -- You can not place a block over the player or another block. -- You must place both blocks and the player before starting the game. +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" 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. -"P" represents the player, who cannot move. The player's goal is to throw popsicle at zombies before they reach them. +"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 the turn, the player (P) throws a popsicle at the closest zombie (using euclidean distance). -- Popsicle deal 1 damage to zombies. +- 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 zombies are removed, the player wins. -- If a zombie reaches a player, the player loses. +- When all players die, the game ends # Zombie Rules - Zombies have 2 health. @@ -41,27 +30,21 @@ The 2d Grid is made up of characters, where each character has a meaning. - Zombies always try to move towards the playing using BFS algorithm. # Player Rules -- Players can not move. -- Players throw one lollipops at the closest zombie at the start of each turn. - -# Placement Strategies - -- often it's good to wall off between the zombies and players if possible, this will slow the zombies down. -- You should never put a player directly next to a zombie. -- You should try to put blocks directly next to players -- If the player is behind a choke point, blocking the path to the player is the best option. +- 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 single paragraph explaining your placement strategy is always represented as REASONING. - Assume a position on the 2d grid is always represented as [ROW, COL]. - Your output should be a JSON object with the following format: { - "boxCoordinates": [[ROW, COL], [ROW, COL]], - "playerCoordinates": [ROW, COL], - "reasoning": "REASONING" + "moveDirection": "DOWN" | "LEFT" | "RIGHT" | "UP" | "STAY", + "zombieToShoot": [ROW, COL] } `; @@ -76,8 +59,8 @@ export type MultiplayerModelHandler = ( userPrompt: string, config: ModelHandlerConfig, ) => Promise<{ - moveLocation: number[]; - reasoning: string; + moveDirection: string; + zombieToShoot: number[]; }>; const MAX_RETRIES = 1; @@ -90,45 +73,44 @@ const CONFIG: ModelHandlerConfig = { export type RunModelResult = { error?: string; - reasoning: string; - solution?: string[][]; + moveDirection?: string; + zombieToShoot?: number[]; + reasoning?: string; }; export async function runMultiplayerModel( - modelId: string, + modelSlug: ModelSlug, map: string[][], playerToken: string, retry = 1, ): Promise { - const validMoveLocations = ZombieSurvival.validPlayerMoveLocations( - map, - playerToken, - ); + const validDirections = [ + ...ZombieSurvival.validMoveDirections(map, playerToken), + "STAY", + ]; const userPrompt = `Grid: ${JSON.stringify(map)}\n\n` + - `Valid Move Locations: ${JSON.stringify(validMoveLocations)}`; + `Your Player Token: ${playerToken}\n\n` + + `Valid Move Locations: ${JSON.stringify(validDirections)}`; let result; let reasoning: string | null = null; try { - switch (modelId) { - case AI_MODELS[1].name: { + switch (modelSlug) { + case "gpt-4o": { result = await gpt4o(SYSTEM_PROMPT, userPrompt, CONFIG); break; } default: { - throw new Error(`Tried running unknown model '${modelId}'`); + throw new Error(`Tried running unknown model '${modelSlug}'`); } } - reasoning = result.reasoning; - const originalMap = ZombieSurvival.cloneMap(map); - return { - reasoning: result.reasoning, - solution: originalMap, + moveDirection: result.moveDirection, + zombieToShoot: result.zombieToShoot, }; } catch (error) { if (retry === MAX_RETRIES || reasoning === null) { @@ -138,6 +120,6 @@ export async function runMultiplayerModel( }; } - return await runMultiplayerModel(modelId, map, playerToken, retry + 1); + return await runMultiplayerModel(modelSlug, map, playerToken, retry + 1); } } diff --git a/simulator/Direction.ts b/simulator/Direction.ts index b5252a5..9b595ae 100644 --- a/simulator/Direction.ts +++ b/simulator/Direction.ts @@ -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/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index 36ce80e..3f2c97c 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -1,5 +1,5 @@ import { entityAt } from "../lib/entityAt"; -import { allDirections, move } from "./Direction"; +import { Direction, allDirections, move } from "./Direction"; import { type Entity } from "./Entity"; import { type Position } from "./Position"; import { Box } from "./entities/Box"; @@ -34,7 +34,7 @@ export class ZombieSurvival { for (let x = 0; x < this.boardWidth; x++) { const code = map[y][x]; - switch (code.toLowerCase()) { + switch (code.toLowerCase().substring(0, 1)) { case "b": { this.entities.push(new Box({ x, y })); break; @@ -48,7 +48,12 @@ export class ZombieSurvival { break; } case "z": { - this.zombies.push(new Zombie(this, { x, y })); + 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": { @@ -100,7 +105,7 @@ export class ZombieSurvival { throw new Error("Single player map has no player"); } - if (this.zombies.length === 0) { + if (!this.multiplayer && this.zombies.length === 0) { throw new Error("Map has no zombies"); } } @@ -118,8 +123,8 @@ export class ZombieSurvival { } public static entityPosition(map: string[][], token: string): Position { - for (let y = 0; y < map.length - 1; y++) { - for (let x = 0; x < map[y].length - 1; x++) { + 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 }; } @@ -179,12 +184,12 @@ export class ZombieSurvival { ); } - public static validPlayerMoveLocations( + public static validMoveDirections( map: string[][], playerToken: string, - ): number[][] { + ): string[] { const position = ZombieSurvival.entityPosition(map, playerToken); - const validMoves: number[][] = []; + const validDirections: string[] = []; for (const direction of allDirections) { const newPosition = move(position, direction); @@ -196,12 +201,25 @@ export class ZombieSurvival { newPosition.y < map.length ) { if (map[newPosition.y][newPosition.x] === " ") { - validMoves.push([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 validMoves; + return validDirections; } public finished(): boolean { @@ -211,6 +229,25 @@ export class ZombieSurvival { ); } + 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(); } @@ -237,6 +274,14 @@ export class ZombieSurvival { 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[][] = []; @@ -288,4 +333,23 @@ export class ZombieSurvival { 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/simulator/entities/Zombie.ts b/simulator/entities/Zombie.ts index ac08b7f..4e053db 100644 --- a/simulator/entities/Zombie.ts +++ b/simulator/entities/Zombie.ts @@ -10,13 +10,18 @@ export class Zombie extends Entity { private game: ZombieSurvival; - public constructor(game: ZombieSurvival, position: Position) { + 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"; + return "Z" + ":" + this.health; } public walk(direction: Direction | null = null) { @@ -41,7 +46,12 @@ export class Zombie extends Entity { } private findPath(): Direction[] { - const player = this.game.getPlayer(); + 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[] }> = [