diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 1c3f982..719e27d 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -16,6 +16,7 @@ import type * as constants from "../constants.js"; import type * as crons from "../crons.js"; import type * as flags from "../flags.js"; import type * as games from "../games.js"; +import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; import type * as init from "../init.js"; import type * as leaderboard from "../leaderboard.js"; @@ -49,6 +50,7 @@ declare const fullApi: ApiFromModules<{ crons: typeof crons; flags: typeof flags; games: typeof games; + helpers: typeof helpers; http: typeof http; init: typeof init; leaderboard: typeof leaderboard; diff --git a/convex/constants.ts b/convex/constants.ts index 7595257..d1ff30d 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -21,6 +21,13 @@ export const AI_MODELS = { }, } as const; +export enum ActionType { + PlayerShoot = "player-shoot", + PlayerWalk = "player-walk", + ZombieSpawn = "zombie-spawn", + ZombieStep = "zombie-step", +} + export type ModelSlug = (typeof AI_MODELS)[keyof typeof AI_MODELS]["slug"]; export const AI_MODEL_SLUGS = Object.keys(AI_MODELS) as ModelSlug[]; diff --git a/convex/helpers.ts b/convex/helpers.ts new file mode 100644 index 0000000..bbca6c2 --- /dev/null +++ b/convex/helpers.ts @@ -0,0 +1,19 @@ +import { v } from "convex/values"; +import { ActionType } from "./constants"; + +export const multiplayerGameActionValidator = v.object({ + // type: v.union(...Object.values(ActionType).map((t) => v.literal(t))), + type: v.union( + v.literal("player-shoot"), + v.literal("player-walk"), + v.literal("zombie-spawn"), + v.literal("zombie-step"), + ), + token: v.string(), + position: v.optional( + v.object({ + x: v.number(), + y: v.number(), + }), + ), +}); diff --git a/convex/multiplayerGames.ts b/convex/multiplayerGames.ts index ec392c9..d16f488 100644 --- a/convex/multiplayerGames.ts +++ b/convex/multiplayerGames.ts @@ -1,9 +1,16 @@ import { runMultiplayerModel } from "../models/multiplayer"; -import { ZombieSurvival, fromDirectionString, move } from "../simulator"; -import { v } from "convex/values"; +import { + Position, + ZombieSurvival, + directionFromString, + move, +} from "../simulator"; +import { type Infer, v } from "convex/values"; import { api, internal } from "./_generated/api"; +import { Doc } from "./_generated/dataModel"; import { internalAction, internalMutation, query } from "./_generated/server"; -import { ModelSlug } from "./constants"; +import { ActionType, ModelSlug } from "./constants"; +import { multiplayerGameActionValidator } from "./helpers"; const TURN_DELAY = 500; @@ -19,6 +26,32 @@ const boardState = ` . . . . . . .B. . . . . . . , `; +function multiplayerGameTurn(multiplayerGame: Doc<"multiplayerGames">): string { + const prevAction = multiplayerGame.actions.at(-1); + + if (prevAction === undefined) { + return multiplayerGame.playerMap[0].playerToken; + } + + const players = multiplayerGame.playerMap; + const simulator = new ZombieSurvival(multiplayerGame.boardState); + + const playerIndex = players.findIndex( + (player) => player.playerToken === prevAction.token, + ); + + for (let i = playerIndex + 1; i < players.length; i++) { + const turn = players[i].playerToken; + const player = simulator.getPlayer(turn); + + if (player !== undefined) { + return turn; + } + } + + return "Z"; +} + export const startMultiplayerGame = internalMutation({ args: { playerMap: v.array( @@ -50,18 +83,16 @@ export const startMultiplayerGame = internalMutation({ } const gameId = await ctx.db.insert("multiplayerGames", { + map: initialBoard, boardState: initialBoard, playerMap: args.playerMap, - completedTurns: 0, + actions: [], }); await ctx.scheduler.runAfter( 0, internal.multiplayerGames.runMultiplayerGameTurn, - { - multiplayerGameId: gameId, - turn: args.playerMap[0].playerToken, - }, + { multiplayerGameId: gameId }, ); return gameId; @@ -81,68 +112,74 @@ export const updateMultiplayerGameBoardState = internalMutation({ args: { multiplayerGameId: v.id("multiplayerGames"), boardState: v.array(v.array(v.string())), - completedTurns: v.number(), cost: v.optional(v.number()), + actions: v.array(multiplayerGameActionValidator), }, handler: async (ctx, args) => { - const patch: { - boardState: string[][]; - completedTurns: number; - cost?: number; - } = { - boardState: args.boardState, - completedTurns: args.completedTurns, - }; - - if (args.cost !== undefined) { - patch.cost = args.cost; + const { actions, boardState, cost, multiplayerGameId } = args; + + const multiplayerGame = await ctx.runQuery( + api.multiplayerGames.getMultiplayerGame, + { multiplayerGameId }, + ); + + if (!multiplayerGame) { + throw new Error("Multiplayer game not found"); } - await ctx.db.patch(args.multiplayerGameId, patch); + await ctx.db.patch(multiplayerGame._id, { + boardState: boardState, + cost: cost ?? multiplayerGame.cost, + actions: [...multiplayerGame.actions, ...actions], + }); }, }); export const runMultiplayerGameTurn = internalAction({ args: { - turn: v.string(), multiplayerGameId: v.id("multiplayerGames"), }, handler: async (ctx, args) => { - const { turn, multiplayerGameId } = args; + const { multiplayerGameId } = args; const multiplayerGame = await ctx.runQuery( api.multiplayerGames.getMultiplayerGame, - { - multiplayerGameId, - }, + { multiplayerGameId }, ); if (!multiplayerGame) { throw new Error("Multiplayer game not found"); } - const map = new ZombieSurvival(multiplayerGame.boardState); + const simulator = new ZombieSurvival(multiplayerGame.boardState); + const turn = multiplayerGameTurn(multiplayerGame); + const actions: Array> = []; + let cost = multiplayerGame.cost ?? 0; if (turn === "Z") { - map.stepZombies(); + simulator.stepZombies(); + + actions.push({ + type: ActionType.ZombieStep, + token: "Z", + }); const numPlayers = multiplayerGame.playerMap.length; + const zombiesToSpawn = Math.min( Math.floor(Math.random() * numPlayers) + 1, numPlayers, ); - for (let i = 0; i < zombiesToSpawn; i++) { - map.spawnRandomZombie(); - } - await ctx.runMutation( - internal.multiplayerGames.updateMultiplayerGameBoardState, - { - multiplayerGameId, - boardState: map.getState(), - completedTurns: multiplayerGame.completedTurns + 1, - }, - ); + for (let i = 0; i < zombiesToSpawn && simulator.hasEmptyCells(); i++) { + const position = simulator.spawnRandomZombie(); + + actions.push({ + type: ActionType.ZombieSpawn, + token: "Z", + position, + }); + } } else { const model = multiplayerGame.playerMap.find( (entry) => entry.playerToken === turn, @@ -152,92 +189,73 @@ export const runMultiplayerGameTurn = internalAction({ throw new Error("Model not found"); } - const player = map.getPlayer(turn); - if (!player) { - const currentPlayerIndex = multiplayerGame.playerMap.findIndex( - (entry) => entry.playerToken === turn, - ); - const nextPlayerIndex = currentPlayerIndex + 1; - let nextPlayer: string; - if (nextPlayerIndex >= multiplayerGame.playerMap.length) { - nextPlayer = "Z"; - } else { - nextPlayer = multiplayerGame.playerMap[nextPlayerIndex].playerToken; - } - - await ctx.scheduler.runAfter( - 0, - internal.multiplayerGames.runMultiplayerGameTurn, - { - multiplayerGameId, - turn: nextPlayer, - }, - ); - - return; - } - const results = await runMultiplayerModel( model.modelSlug as ModelSlug, - map.getState(), + simulator.getState(), turn, ); console.log("cost", results.cost); if (results.moveDirection && results.moveDirection !== "STAY") { - const moveDirection = fromDirectionString(results.moveDirection); - const p = map.getPlayer(turn); + const moveDirection = directionFromString(results.moveDirection); + const player = simulator.getPlayer(turn); - if (p) { - const movePosition = move(p.getPosition(), moveDirection); + if (player) { + const movePosition = move(player.getPosition(), moveDirection); if ( - map.isValidPosition(movePosition) && - map.isPositionEmpty(movePosition) + simulator.isValidPosition(movePosition) && + simulator.isPositionEmpty(movePosition) ) { - // only move if the position was valid, otherwise we don't move - p.moveTo(movePosition); + player.moveTo(movePosition); + + actions.push({ + type: ActionType.PlayerWalk, + token: turn, + position: movePosition, + }); } } } - if (results.zombieToShoot) { - const zombieToShoot = results.zombieToShoot; - map.getZombieAt({ x: zombieToShoot[1], y: zombieToShoot[0] })?.hit(); - } + if (results.zombieToShoot !== undefined) { + const zombiePosition: Position = { + x: results.zombieToShoot[1], + y: results.zombieToShoot[0], + }; - await ctx.runMutation( - internal.multiplayerGames.updateMultiplayerGameBoardState, - { - multiplayerGameId, - boardState: map.getState(), - completedTurns: multiplayerGame.completedTurns, - cost: (multiplayerGame.cost ?? 0) + (results.cost ?? 0), - }, - ); - } + const zombie = simulator.getZombieAt(zombiePosition); - if (!map.allPlayersDead()) { - let nextPlayer: string; + if (zombie !== undefined) { + zombie.hit(); - const currentPlayerIndex = multiplayerGame.playerMap.findIndex( - (entry) => entry.playerToken === turn, - ); - const nextPlayerIndex = currentPlayerIndex + 1; - if (nextPlayerIndex >= multiplayerGame.playerMap.length) { - nextPlayer = "Z"; - } else { - nextPlayer = multiplayerGame.playerMap[nextPlayerIndex].playerToken; + actions.push({ + type: ActionType.PlayerShoot, + token: turn, + position: zombiePosition, + }); + } } + cost += results.cost ?? 0; + } + + await ctx.runMutation( + internal.multiplayerGames.updateMultiplayerGameBoardState, + { + multiplayerGameId, + boardState: simulator.getState(), + actions, + cost, + }, + ); + + if (!simulator.allPlayersDead()) { await ctx.scheduler.runAfter( TURN_DELAY, internal.multiplayerGames.runMultiplayerGameTurn, - { - multiplayerGameId, - turn: nextPlayer, - }, + { multiplayerGameId }, ); } }, diff --git a/convex/schema.ts b/convex/schema.ts index 9e13f8d..de939ef 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,6 +1,7 @@ import { authTables } from "@convex-dev/auth/server"; import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +import { multiplayerGameActionValidator } from "./helpers"; // The schema is normally optional, but Convex Auth // requires indexes defined on `authTables`. @@ -82,9 +83,10 @@ export default defineSchema({ isActive: v.boolean(), }).index("by_active", ["isActive"]), multiplayerGames: defineTable({ + map: v.array(v.array(v.string())), boardState: v.array(v.array(v.string())), - completedTurns: v.number(), cost: v.optional(v.number()), + actions: v.array(multiplayerGameActionValidator), playerMap: v.array( v.object({ modelSlug: v.string(), diff --git a/simulator/Direction.ts b/simulator/Direction.ts index 9b595ae..493fefb 100644 --- a/simulator/Direction.ts +++ b/simulator/Direction.ts @@ -14,7 +14,7 @@ export const allDirections = [ Direction.Up, ]; -export function fromDirectionString(direction: string): Direction { +export function directionFromString(direction: string): Direction { switch (direction) { case "DOWN": { return Direction.Down; @@ -51,26 +51,6 @@ export function directionToString(direction: Direction): string { } } -export function directionFromString(val: string): Direction { - switch (val) { - case "0": { - return Direction.Down; - } - case "1": { - return Direction.Left; - } - case "2": { - return Direction.Right; - } - case "3": { - return Direction.Up; - } - default: { - throw new Error("Can't parse direction"); - } - } -} - export function determine(p1: Position, p2: Position): Direction { if (p1.x > p2.x) { return Direction.Left; diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index 0116b93..1e3c06f 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -296,7 +296,13 @@ export class ZombieSurvival { return undefined; } - public spawnRandomZombie() { + public hasEmptyCells(): boolean { + return this.getState() + .flat() + .some((it) => it === " "); + } + + public spawnRandomZombie(): Position { for (let i = 0; i < 10; i++) { let x: number; let y: number; @@ -312,11 +318,15 @@ export class ZombieSurvival { y = Math.random() < 0.5 ? 0 : this.boardHeight - 1; } - if (this.isPositionEmpty({ x, y })) { - this.zombies.push(new Zombie(this, { x, y })); - return; + const position = { x, y }; + + if (this.isPositionEmpty(position)) { + this.zombies.push(new Zombie(this, position)); + return position; } } + + throw new Error("Unable to spawn random zombie"); } public getZombieAt(position: Position): Zombie | undefined {