From fa2027d214e5fbdb90579ab0e0a4e5c337fb289a Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Mon, 4 Nov 2024 11:17:01 -0500 Subject: [PATCH] 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[] }> = [