import { ObjectType, v } from 'convex/values'; import { GameId, parseGameId } from './ids'; import { conversationId, playerId } from './ids'; import { Player } from './player'; import { inputHandler } from './inputHandler'; import { TYPING_TIMEOUT, CONVERSATION_DISTANCE } from '../constants'; import { distance, normalize, vector } from '../util/geometry'; import { Point } from '../util/types'; import { Game } from './game'; import { stopPlayer, blocked, movePlayer } from './movement'; import { ConversationMembership, serializedConversationMembership } from './conversationMembership'; import { parseMap, serializeMap } from '../util/object'; import {CycleState, gameCycleSchema} from './gameCycle' export class Conversation { id: GameId<'conversations'>; creator: GameId<'players'>; created: number; cycleState:CycleState; isTyping?: { playerId: GameId<'players'>; messageUuid: string; since: number; }; lastMessage?: { author: GameId<'players'>; timestamp: number; }; numMessages: number; participants: Map, ConversationMembership>; constructor(serialized: SerializedConversation) { const { id, creator, created, cycleState, isTyping, lastMessage, numMessages, participants } = serialized; this.id = parseGameId('conversations', id); this.creator = parseGameId('players', creator); this.created = created; this.cycleState = cycleState; this.isTyping = isTyping && { playerId: parseGameId('players', isTyping.playerId), messageUuid: isTyping.messageUuid, since: isTyping.since, }; this.lastMessage = lastMessage && { author: parseGameId('players', lastMessage.author), timestamp: lastMessage.timestamp, }; this.numMessages = numMessages; this.participants = parseMap(participants, ConversationMembership, (m) => m.playerId); } tick(game: Game, now: number) { if (this.isTyping && this.isTyping.since + TYPING_TIMEOUT < now) { delete this.isTyping; } if (this.participants.size !== 2) { console.warn(`Conversation ${this.id} has ${this.participants.size} participants`); return; } const [playerId1, playerId2] = [...this.participants.keys()]; const member1 = this.participants.get(playerId1)!; const member2 = this.participants.get(playerId2)!; const player1 = game.world.players.get(playerId1)!; const player2 = game.world.players.get(playerId2)!; // during the night, villagers cant talk const { cycleState } = game.world.gameCycle; if (cycleState === 'Night' || cycleState === 'PlayerKillVoting') { if (player1.playerType(game) === 'villager' || player2.playerType(game) === 'villager') { this.stop(game, now); return; } } const playerDistance = distance(player1?.position, player2?.position); // If the players are both in the "walkingOver" state and they're sufficiently close, transition both // of them to "participating" and stop their paths. if (member1.status.kind === 'walkingOver' && member2.status.kind === 'walkingOver') { if (playerDistance < CONVERSATION_DISTANCE) { console.log(`Starting conversation between ${player1.id} and ${player2.id}`); // First, stop the two players from moving. stopPlayer(player1); stopPlayer(player2); member1.status = { kind: 'participating', started: now }; member2.status = { kind: 'participating', started: now }; // Try to move the first player to grid point nearest the other player. const neighbors = (p: Point) => [ { x: p.x + 1, y: p.y }, { x: p.x - 1, y: p.y }, { x: p.x, y: p.y + 1 }, { x: p.x, y: p.y - 1 }, ]; const floorPos1 = { x: Math.floor(player1.position.x), y: Math.floor(player1.position.y) }; const p1Candidates = neighbors(floorPos1).filter((p) => !blocked(game, now, p, player1.id)); p1Candidates.sort((a, b) => distance(a, player2.position) - distance(b, player2.position)); if (p1Candidates.length > 0) { const p1Candidate = p1Candidates[0]; // Try to move the second player to the grid point nearest the first player's // destination. const p2Candidates = neighbors(p1Candidate).filter( (p) => !blocked(game, now, p, player2.id), ); p2Candidates.sort( (a, b) => distance(a, player2.position) - distance(b, player2.position), ); if (p2Candidates.length > 0) { const p2Candidate = p2Candidates[0]; movePlayer(game, now, player1, p1Candidate, true); movePlayer(game, now, player2, p2Candidate, true); } } } } // Orient the two players towards each other if they're not moving. if (member1.status.kind === 'participating' && member2.status.kind === 'participating') { const v = normalize(vector(player1.position, player2.position)); if (!player1.pathfinding && v) { player1.facing = v; } if (!player2.pathfinding && v) { player2.facing.dx = -v.dx; player2.facing.dy = -v.dy; } } } static start(game: Game, now: number, player: Player, invitee: Player) { if (player.id === invitee.id) { throw new Error(`Can't invite yourself to a conversation`); } // Ensure the players still exist. if ([...game.world.conversations.values()].find((c) => c.participants.has(player.id))) { const reason = `Player ${player.id} is already in a conversation`; console.log(reason); return { error: reason }; } if ([...game.world.conversations.values()].find((c) => c.participants.has(invitee.id))) { const reason = `Player ${player.id} is already in a conversation`; console.log(reason); return { error: reason }; } // Forbid villagers to talk in the night const { cycleState } = game.world.gameCycle; if (cycleState === 'Night' || cycleState === 'PlayerKillVoting') { if (player.playerType(game) === 'villager') { const reason = `You are not supposed to talk at night`; console.log(reason); return { error: reason }; } else if (invitee.playerType(game) === 'villager') { const reason = `You can't talk to humans at night`; console.log(reason); return { error: reason }; } } const conversationId = game.allocId('conversations'); console.log(`Creating conversation ${conversationId}`); game.world.conversations.set( conversationId, new Conversation({ id: conversationId, created: now, creator: player.id, cycleState: game.world.gameCycle.cycleState, numMessages: 0, participants: [ { playerId: player.id, invited: now, status: { kind: 'walkingOver' } }, { playerId: invitee.id, invited: now, status: { kind: 'invited' } }, ], }), ); console.log(`Starting conversation during ${game.world.gameCycle.cycleState}`); return { conversationId }; } setIsTyping(now: number, player: Player, messageUuid: string) { if (this.isTyping) { if (this.isTyping.playerId !== player.id) { throw new Error(`Player ${this.isTyping.playerId} is already typing in ${this.id}`); } return; } this.isTyping = { playerId: player.id, messageUuid, since: now }; } acceptInvite(game: Game, player: Player) { const member = this.participants.get(player.id); if (!member) { throw new Error(`Player ${player.id} not in conversation ${this.id}`); } if (member.status.kind !== 'invited') { throw new Error( `Invalid membership status for ${player.id}:${this.id}: ${JSON.stringify(member)}`, ); } member.status = { kind: 'walkingOver' }; } rejectInvite(game: Game, now: number, player: Player) { const member = this.participants.get(player.id); if (!member) { throw new Error(`Player ${player.id} not in conversation ${this.id}`); } if (member.status.kind !== 'invited') { throw new Error( `Rejecting invite in wrong membership state: ${this.id}:${player.id}: ${JSON.stringify( member, )}`, ); } this.stop(game, now); } stop(game: Game, now: number) { delete this.isTyping; for (const [playerId, member] of this.participants.entries()) { const agent = [...game.world.agents.values()].find((a) => a.playerId === playerId); if (agent) { agent.lastConversation = now; agent.toRemember = this.id; } } game.world.conversations.delete(this.id); } leave(game: Game, now: number, player: Player) { const member = this.participants.get(player.id); if (!member) { throw new Error(`Couldn't find membership for ${this.id}:${player.id}`); } this.stop(game, now); } serialize(): SerializedConversation { const { id, creator, created, cycleState, isTyping, lastMessage, numMessages } = this; return { id, creator, created, cycleState, isTyping, lastMessage, numMessages, participants: serializeMap(this.participants), }; } } export const serializedConversation = { id: conversationId, creator: playerId, created: v.number(), cycleState: gameCycleSchema.cycleState, isTyping: v.optional( v.object({ playerId, messageUuid: v.string(), since: v.number(), }), ), lastMessage: v.optional( v.object({ author: playerId, timestamp: v.number(), }), ), numMessages: v.number(), participants: v.array(v.object(serializedConversationMembership)), }; export type SerializedConversation = ObjectType; export const conversationInputs = { // Start a conversation, inviting the specified player. // Conversations can only have two participants for now, // so we don't have a separate "invite" input. startConversation: inputHandler({ args: { playerId, invitee: playerId, }, handler: (game: Game, now: number, args): GameId<'conversations'> => { const playerId = parseGameId('players', args.playerId); const player = game.world.players.get(playerId); if (!player) { throw new Error(`Invalid player ID: ${playerId}`); } const inviteeId = parseGameId('players', args.invitee); const invitee = game.world.players.get(inviteeId); if (!invitee) { throw new Error(`Invalid player ID: ${inviteeId}`); } console.log(`Starting ${playerId} ${inviteeId}...`); const { conversationId, error } = Conversation.start(game, now, player, invitee); if (!conversationId) { // TODO: pass it back to the client for them to show an error. throw new Error(error); } return conversationId; }, }), startTyping: inputHandler({ args: { playerId, conversationId, messageUuid: v.string(), }, handler: (game: Game, now: number, args): null => { const playerId = parseGameId('players', args.playerId); const player = game.world.players.get(playerId); if (!player) { throw new Error(`Invalid player ID: ${playerId}`); } const conversationId = parseGameId('conversations', args.conversationId); const conversation = game.world.conversations.get(conversationId); if (!conversation) { throw new Error(`Invalid conversation ID: ${conversationId}`); } if (conversation.isTyping && conversation.isTyping.playerId !== playerId) { throw new Error( `Player ${conversation.isTyping.playerId} is already typing in ${conversationId}`, ); } conversation.isTyping = { playerId, messageUuid: args.messageUuid, since: now }; return null; }, }), finishSendingMessage: inputHandler({ args: { playerId, conversationId, timestamp: v.number(), }, handler: (game: Game, now: number, args): null => { const playerId = parseGameId('players', args.playerId); const conversationId = parseGameId('conversations', args.conversationId); const conversation = game.world.conversations.get(conversationId); if (!conversation) { throw new Error(`Invalid conversation ID: ${conversationId}`); } if (conversation.isTyping && conversation.isTyping.playerId === playerId) { delete conversation.isTyping; } conversation.lastMessage = { author: playerId, timestamp: args.timestamp }; conversation.numMessages++; return null; }, }), // Accept an invite to a conversation, which puts the // player in the "walkingOver" state until they're close // enough to the other participant. acceptInvite: inputHandler({ args: { playerId, conversationId, }, handler: (game: Game, now: number, args): null => { const playerId = parseGameId('players', args.playerId); const player = game.world.players.get(playerId); if (!player) { throw new Error(`Invalid player ID ${playerId}`); } const conversationId = parseGameId('conversations', args.conversationId); const conversation = game.world.conversations.get(conversationId); if (!conversation) { throw new Error(`Invalid conversation ID ${conversationId}`); } conversation.acceptInvite(game, player); return null; }, }), // Reject the invite. Eventually we might add a message // that explains why! rejectInvite: inputHandler({ args: { playerId, conversationId, }, handler: (game: Game, now: number, args): null => { const playerId = parseGameId('players', args.playerId); const player = game.world.players.get(playerId); if (!player) { throw new Error(`Invalid player ID ${playerId}`); } const conversationId = parseGameId('conversations', args.conversationId); const conversation = game.world.conversations.get(conversationId); if (!conversation) { throw new Error(`Invalid conversation ID ${conversationId}`); } conversation.rejectInvite(game, now, player); return null; }, }), // Leave a conversation. leaveConversation: inputHandler({ args: { playerId, conversationId, }, handler: (game: Game, now: number, args): null => { const playerId = parseGameId('players', args.playerId); const player = game.world.players.get(playerId); if (!player) { throw new Error(`Invalid player ID ${playerId}`); } const conversationId = parseGameId('conversations', args.conversationId); const conversation = game.world.conversations.get(conversationId); if (!conversation) { throw new Error(`Invalid conversation ID ${conversationId}`); } conversation.leave(game, now, player); return null; }, }), };