import { ObjectType, v } from 'convex/values'; import { GameId, parseGameId } from './ids'; import { agentId, conversationId, playerId } from './ids'; import { serializedPlayer } from './player'; import { Game } from './game'; import { ACTION_TIMEOUT, AWKWARD_CONVERSATION_TIMEOUT, CONVERSATION_COOLDOWN, CONVERSATION_DISTANCE, INVITE_ACCEPT_PROBABILITY, INVITE_TIMEOUT, MAX_CONVERSATION_DURATION, MAX_CONVERSATION_MESSAGES, MESSAGE_COOLDOWN, MIDPOINT_THRESHOLD, PLAYER_CONVERSATION_COOLDOWN, } from '../constants'; import { FunctionArgs } from 'convex/server'; import { MutationCtx, internalMutation, internalQuery } from '../_generated/server'; import { distance } from '../util/geometry'; import { internal } from '../_generated/api'; import { movePlayer } from './movement'; import { insertInput } from './insertInput'; export class Agent { id: GameId<'agents'>; playerId: GameId<'players'>; toRemember?: GameId<'conversations'>; lastConversation?: number; lastInviteAttempt?: number; inProgressOperation?: { name: string; operationId: string; started: number; }; constructor(serialized: SerializedAgent) { const { id, lastConversation, lastInviteAttempt, inProgressOperation } = serialized; const playerId = parseGameId('players', serialized.playerId); this.id = parseGameId('agents', id); this.playerId = playerId; this.toRemember = serialized.toRemember !== undefined ? parseGameId('conversations', serialized.toRemember) : undefined; this.lastConversation = lastConversation; this.lastInviteAttempt = lastInviteAttempt; this.inProgressOperation = inProgressOperation; } tick(game: Game, now: number) { const player = game.world.players.get(this.playerId); if (!player) { throw new Error(`Invalid player ID ${this.playerId}`); } if (this.inProgressOperation) { if (now < this.inProgressOperation.started + ACTION_TIMEOUT) { // Wait on the operation to finish. return; } console.log(`Timing out ${JSON.stringify(this.inProgressOperation)}`); delete this.inProgressOperation; } const conversation = game.world.playerConversation(player); const member = conversation?.participants.get(player.id); const recentlyAttemptedInvite = this.lastInviteAttempt && now < this.lastInviteAttempt + CONVERSATION_COOLDOWN; const doingActivity = player.activity && player.activity.until > now; if (doingActivity && (conversation || player.pathfinding)) { player.activity!.until = now; } // If we're not in a conversation, do something. // If we aren't doing an activity or moving, do something. // If we have been wandering but haven't thought about something to do for // a while, do something. if (!conversation && !doingActivity && (!player.pathfinding || !recentlyAttemptedInvite)) { this.startOperation(game, now, 'agentDoSomething', { worldId: game.worldId, player: player.serialize(), otherFreePlayers: [...game.world.players.values()] .filter((p) => p.id !== player.id) .filter( (p) => ![...game.world.conversations.values()].find((c) => c.participants.has(p.id)), ) .map((p) => p.serialize()), agent: this.serialize(), map: game.worldMap.serialize(), }); return; } // Check to see if we have a conversation we need to remember. if (this.toRemember) { // Fire off the action to remember the conversation. console.log(`Agent ${this.id} remembering conversation ${this.toRemember}`); this.startOperation(game, now, 'agentRememberConversation', { worldId: game.worldId, playerId: this.playerId, agentId: this.id, conversationId: this.toRemember, }); delete this.toRemember; return; } if (conversation && member) { const [otherPlayerId, otherMember] = [...conversation.participants.entries()].find( ([id]) => id !== player.id, )!; const otherPlayer = game.world.players.get(otherPlayerId)!; if (member.status.kind === 'invited') { // Accept a conversation with another agent with some probability and with // a human unconditionally. if (otherPlayer.human || Math.random() < INVITE_ACCEPT_PROBABILITY) { console.log(`Agent ${player.id} accepting invite from ${otherPlayer.id}`); conversation.acceptInvite(game, player); // Stop moving so we can start walking towards the other player. if (player.pathfinding) { delete player.pathfinding; } } else { console.log(`Agent ${player.id} rejecting invite from ${otherPlayer.id}`); conversation.rejectInvite(game, now, player); } return; } if (member.status.kind === 'walkingOver') { // Leave a conversation if we've been waiting for too long. if (member.invited + INVITE_TIMEOUT < now) { console.log(`Giving up on invite to ${otherPlayer.id}`); conversation.leave(game, now, player); return; } // Don't keep moving around if we're near enough. const playerDistance = distance(player.position, otherPlayer.position); if (playerDistance < CONVERSATION_DISTANCE) { return; } // Keep moving towards the other player. // If we're close enough to the player, just walk to them directly. if (!player.pathfinding) { let destination; if (playerDistance < MIDPOINT_THRESHOLD) { destination = { x: Math.floor(otherPlayer.position.x), y: Math.floor(otherPlayer.position.y), }; } else { destination = { x: Math.floor((player.position.x + otherPlayer.position.x) / 2), y: Math.floor((player.position.y + otherPlayer.position.y) / 2), }; } console.log(`Agent ${player.id} walking towards ${otherPlayer.id}...`, destination); movePlayer(game, now, player, destination); } return; } if (member.status.kind === 'participating') { const started = member.status.started; if (conversation.isTyping && conversation.isTyping.playerId !== player.id) { // Wait for the other player to finish typing. return; } if (!conversation.lastMessage) { const isInitiator = conversation.creator === player.id; const awkwardDeadline = started + AWKWARD_CONVERSATION_TIMEOUT; // Send the first message if we're the initiator or if we've been waiting for too long. if (isInitiator || awkwardDeadline < now) { // Grab the lock on the conversation and send a "start" message. console.log(`${player.id} initiating conversation with ${otherPlayer.id}.`); const messageUuid = crypto.randomUUID(); conversation.setIsTyping(now, player, messageUuid); this.startOperation(game, now, 'agentGenerateMessage', { worldId: game.worldId, playerId: player.id, agentId: this.id, conversationId: conversation.id, otherPlayerId: otherPlayer.id, messageUuid, type: 'start', }); return; } else { // Wait on the other player to say something up to the awkward deadline. return; } } // See if the conversation has been going on too long and decide to leave. const tooLongDeadline = started + MAX_CONVERSATION_DURATION; if (tooLongDeadline < now || conversation.numMessages > MAX_CONVERSATION_MESSAGES) { console.log(`${player.id} leaving conversation with ${otherPlayer.id}.`); const messageUuid = crypto.randomUUID(); conversation.setIsTyping(now, player, messageUuid); this.startOperation(game, now, 'agentGenerateMessage', { worldId: game.worldId, playerId: player.id, agentId: this.id, conversationId: conversation.id, otherPlayerId: otherPlayer.id, messageUuid, type: 'leave', }); return; } // Wait for the awkward deadline if we sent the last message. if (conversation.lastMessage.author === player.id) { const awkwardDeadline = conversation.lastMessage.timestamp + AWKWARD_CONVERSATION_TIMEOUT; if (now < awkwardDeadline) { return; } } // Wait for a cooldown after the last message to simulate "reading" the message. const messageCooldown = conversation.lastMessage.timestamp + MESSAGE_COOLDOWN; if (now < messageCooldown) { return; } // Grab the lock and send a message! console.log(`${player.id} continuing conversation with ${otherPlayer.id}.`); const messageUuid = crypto.randomUUID(); conversation.setIsTyping(now, player, messageUuid); this.startOperation(game, now, 'agentGenerateMessage', { worldId: game.worldId, playerId: player.id, agentId: this.id, conversationId: conversation.id, otherPlayerId: otherPlayer.id, messageUuid, type: 'continue', }); return; } } } startOperation( game: Game, now: number, name: Name, args: Omit, 'operationId'>, ) { if (this.inProgressOperation) { throw new Error( `Agent ${this.id} already has an operation: ${JSON.stringify(this.inProgressOperation)}`, ); } const operationId = game.allocId('operations'); console.log(`Agent ${this.id} starting operation ${name} (${operationId})`); game.scheduleOperation(name, { operationId, ...args } as any); this.inProgressOperation = { name, operationId, started: now, }; } kill(game: Game, now: number) { console.log(`agent ${ this.id } is killed`) // Remove scheduled operation if any. const operationId = this.inProgressOperation?.operationId; if (operationId !== undefined) { const index = game.pendingOperations.findIndex(op => op.args[0] === operationId); if (index !== -1) { game.pendingOperations.splice(index, 1); } } game.world.agents.delete(this.id); } serialize(): SerializedAgent { return { id: this.id, playerId: this.playerId, toRemember: this.toRemember, lastConversation: this.lastConversation, lastInviteAttempt: this.lastInviteAttempt, inProgressOperation: this.inProgressOperation, }; } } export const serializedAgent = { id: agentId, playerId: playerId, toRemember: v.optional(conversationId), lastConversation: v.optional(v.number()), lastInviteAttempt: v.optional(v.number()), inProgressOperation: v.optional( v.object({ name: v.string(), operationId: v.string(), started: v.number(), }), ), }; export type SerializedAgent = ObjectType; type AgentOperations = typeof internal.aiTown.agentOperations; export async function runAgentOperation(ctx: MutationCtx, operation: string, args: any) { let reference; switch (operation) { case 'agentRememberConversation': reference = internal.aiTown.agentOperations.agentRememberConversation; break; case 'agentGenerateMessage': reference = internal.aiTown.agentOperations.agentGenerateMessage; break; case 'agentDoSomething': reference = internal.aiTown.agentOperations.agentDoSomething; break; default: throw new Error(`Unknown operation: ${operation}`); } await ctx.scheduler.runAfter(0, reference, args); } export const agentSendMessage = internalMutation({ args: { worldId: v.id('worlds'), conversationId, agentId, playerId, text: v.string(), messageUuid: v.string(), leaveConversation: v.boolean(), operationId: v.string(), }, handler: async (ctx, args) => { await ctx.db.insert('messages', { conversationId: args.conversationId, author: args.playerId, text: args.text, messageUuid: args.messageUuid, worldId: args.worldId, }); await insertInput(ctx, args.worldId, 'agentFinishSendingMessage', { conversationId: args.conversationId, agentId: args.agentId, timestamp: Date.now(), leaveConversation: args.leaveConversation, operationId: args.operationId, }); }, }); export const findConversationCandidate = internalQuery({ args: { now: v.number(), worldId: v.id('worlds'), player: v.object(serializedPlayer), otherFreePlayers: v.array(v.object(serializedPlayer)), }, handler: async (ctx, { now, worldId, player, otherFreePlayers }) => { const { position } = player; const candidates = []; for (const otherPlayer of otherFreePlayers) { // Find the latest conversation we're both members of. const lastMember = await ctx.db .query('participatedTogether') .withIndex('edge', (q) => q.eq('worldId', worldId).eq('player1', player.id).eq('player2', otherPlayer.id), ) .order('desc') .first(); if (lastMember) { if (now < lastMember.ended + PLAYER_CONVERSATION_COOLDOWN) { continue; } } candidates.push({ id: otherPlayer.id, position }); } // Sort by distance and take the nearest candidate. candidates.sort((a, b) => distance(a.position, position) - distance(b.position, position)); return candidates[0]?.id; }, });