Spaces:
Sleeping
Sleeping
| 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; | |
| type?:string | |
| 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) { | |
| if (game.world.gameCycle.cycleState==="LobbyState"){ | |
| return | |
| } | |
| 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<Name extends keyof AgentOperations>( | |
| game: Game, | |
| now: number, | |
| name: Name, | |
| args: Omit<FunctionArgs<AgentOperations[Name]>, '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<typeof serializedAgent>; | |
| 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; | |
| }, | |
| }); | |