Jofthomas's picture
Jofthomas HF staff
bulk morning
df2ef4f
raw
history blame
13.9 kB
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<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;
},
});