import { Infer, ObjectType, v } from 'convex/values'; import { Point, Vector, path, point, vector } from '../util/types'; import { GameId, parseGameId } from './ids'; import { playerId } from './ids'; import { PATHFINDING_TIMEOUT, PATHFINDING_BACKOFF, HUMAN_IDLE_TOO_LONG, MAX_HUMAN_PLAYERS, MAX_PATHFINDS_PER_STEP, } from '../constants'; import { pointsEqual, pathPosition } from '../util/geometry'; import { Game } from './game'; import { stopPlayer, findRoute, blocked, movePlayer } from './movement'; import { inputHandler } from './inputHandler'; import { characters } from '../../data/characters'; import { CharacterType, CharacterTypeSchema, PlayerDescription } from './playerDescription'; import { gameVote, llmVote } from './voting'; const pathfinding = v.object({ destination: point, started: v.number(), state: v.union( v.object({ kind: v.literal('needsPath'), }), v.object({ kind: v.literal('waiting'), until: v.number(), }), v.object({ kind: v.literal('moving'), path, }), ), }); export type Pathfinding = Infer; export const activity = v.object({ description: v.string(), emoji: v.optional(v.string()), until: v.number(), }); export type Activity = Infer; export const serializedPlayer = { id: playerId, human: v.optional(v.string()), pathfinding: v.optional(pathfinding), activity: v.optional(activity), // The last time they did something. lastInput: v.number(), position: point, facing: vector, speed: v.number(), }; export type SerializedPlayer = ObjectType; export class Player { id: GameId<'players'>; human?: string; pathfinding?: Pathfinding; activity?: Activity; lastInput: number; position: Point; facing: Vector; speed: number; constructor(serialized: SerializedPlayer) { const { id, human, pathfinding, activity, lastInput, position, facing, speed } = serialized; this.id = parseGameId('players', id); this.human = human; this.pathfinding = pathfinding; this.activity = activity; this.lastInput = lastInput; this.position = position; this.facing = facing; this.speed = speed; } playerType(game: Game) { const playerDescription = game.playerDescriptions.get(this.id) return playerDescription?.type; } tick(game: Game, now: number) { if (this.human && this.lastInput < now - HUMAN_IDLE_TOO_LONG) { this.leave(game, now); } } tickPathfinding(game: Game, now: number) { // There's nothing to do if we're not moving. const { pathfinding, position } = this; if (!pathfinding) { return; } // Stop pathfinding if we've reached our destination. if (pathfinding.state.kind === 'moving' && pointsEqual(pathfinding.destination, position)) { stopPlayer(this); } // Stop pathfinding if we've timed out. if (pathfinding.started + PATHFINDING_TIMEOUT < now) { console.warn(`Timing out pathfinding for ${this.id}`); stopPlayer(this); } // Transition from "waiting" to "needsPath" if we're past the deadline. if (pathfinding.state.kind === 'waiting' && pathfinding.state.until < now) { pathfinding.state = { kind: 'needsPath' }; } // Perform pathfinding if needed. if (pathfinding.state.kind === 'needsPath' && game.numPathfinds < MAX_PATHFINDS_PER_STEP) { game.numPathfinds++; if (game.numPathfinds === MAX_PATHFINDS_PER_STEP) { console.warn(`Reached max pathfinds for this step`); } const route = findRoute(game, now, this, pathfinding.destination); if (route === null) { console.log(`Failed to route to ${JSON.stringify(pathfinding.destination)}`); stopPlayer(this); } else { if (route.newDestination) { console.warn( `Updating destination from ${JSON.stringify( pathfinding.destination, )} to ${JSON.stringify(route.newDestination)}`, ); pathfinding.destination = route.newDestination; } pathfinding.state = { kind: 'moving', path: route.path }; } } } tickPosition(game: Game, now: number) { // There's nothing to do if we're not moving. if (!this.pathfinding || this.pathfinding.state.kind !== 'moving') { this.speed = 0; return; } // Compute a candidate new position and check if it collides // with anything. const candidate = pathPosition(this.pathfinding.state.path as any, now); if (!candidate) { console.warn(`Path out of range of ${now} for ${this.id}`); return; } const { position, facing, velocity } = candidate; const collisionReason = blocked(game, now, position, this.id); if (collisionReason !== null) { const backoff = Math.random() * PATHFINDING_BACKOFF; console.warn(`Stopping path for ${this.id}, waiting for ${backoff}ms: ${collisionReason}`); this.pathfinding.state = { kind: 'waiting', until: now + backoff, }; return; } // Update the player's location. this.position = position; this.facing = facing; this.speed = velocity; } static join( game: Game, now: number, name: string, character: string, description: string, type: CharacterType, tokenIdentifier?: string, ) { if (tokenIdentifier) { let numHumans = 0; for (const player of game.world.players.values()) { if (player.human) { numHumans++; } if (player.human === tokenIdentifier) { throw new Error(`You are already in this game!`); } } if (numHumans >= MAX_HUMAN_PLAYERS) { throw new Error(`Only ${MAX_HUMAN_PLAYERS} human players allowed at once.`); } } let position; for (let attempt = 0; attempt < 10; attempt++) { const candidate = { x: Math.floor(Math.random() * game.worldMap.width), y: Math.floor(Math.random() * game.worldMap.height), }; if (blocked(game, now, candidate)) { continue; } position = candidate; break; } if (!position) { throw new Error(`Failed to find a free position!`); } const facingOptions = [ { dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 }, ]; const facing = facingOptions[Math.floor(Math.random() * facingOptions.length)]; if (!characters.find((c) => c.name === character)) { throw new Error(`Invalid character: ${character}`); } const playerId = game.allocId('players'); game.world.players.set( playerId, new Player({ id: playerId, human: tokenIdentifier, lastInput: now, position, facing, speed: 0, }), ); // add to duplicate players game.world.playersInit.set( playerId, new Player({ id: playerId, human: tokenIdentifier, lastInput: now, position, facing, speed: 0, }), ); game.playerDescriptions.set( playerId, new PlayerDescription({ playerId, character, description, name, type, }), ); game.descriptionsModified = true; return playerId; } leave(game: Game, now: number) { // Stop our conversation if we're leaving the game. const conversation = [...game.world.conversations.values()].find((c) => c.participants.has(this.id), ); if (conversation) { conversation.stop(game, now); } game.world.players.delete(this.id); } kill(game: Game, now: number) { const playerId = this.id console.log(`player ${ playerId } is killed`) // first leave: this.leave(game, now) // if the player is npc, kill agent as well const agent = [...game.world.agents.values()].find( agent => agent.playerId === playerId ) if (agent) { agent.kill(game, now) } } serialize(): SerializedPlayer { const { id, human, pathfinding, activity, lastInput, position, facing, speed } = this; return { id, human, pathfinding, activity, lastInput, position, facing, speed, }; } } export const playerInputs = { join: inputHandler({ args: { name: v.string(), character: v.string(), description: v.string(), tokenIdentifier: v.optional(v.string()), type: CharacterTypeSchema }, handler: (game, now, args) => { Player.join(game, now, args.name, args.character, args.description, args.type ,args.tokenIdentifier); // Temporary role assignment for testing game.assignRoles() return null; }, }), leave: inputHandler({ args: { playerId }, handler: (game, now, args) => { const playerId = parseGameId('players', args.playerId); const player = game.world.players.get(playerId); if (!player) { throw new Error(`Invalid player ID ${playerId}`); } player.leave(game, now); return null; }, }), moveTo: inputHandler({ args: { playerId, destination: v.union(point, v.null()), }, handler: (game, now, args) => { const playerId = parseGameId('players', args.playerId); const player = game.world.players.get(playerId); if (!player) { throw new Error(`Invalid player ID ${playerId}`); } if (args.destination) { movePlayer(game, now, player, args.destination); } else { stopPlayer(player); } return null; }, }), gameVote: inputHandler({ args: { voter: playerId, votedPlayerIds: v.array(playerId), }, handler: (game, now, args) => { const voterId = parseGameId('players', args.voter); const votedPlayerIds = args.votedPlayerIds.map((playerId) => parseGameId('players', playerId)); gameVote(game, voterId, votedPlayerIds); return null; }, }), llmVote: inputHandler({ args: { voter: playerId, votedPlayerIds: v.array(playerId), }, handler: (game, now, args) => { const voterId = parseGameId('players', args.voter); const votedPlayerIds = args.votedPlayerIds.map((playerId) => parseGameId('players', playerId)); llmVote(game, voterId, votedPlayerIds); return null; }, }), };