Spaces:
Sleeping
Sleeping
| 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<GameId<'players'>, 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<typeof serializedConversation>; | |
| 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; | |
| }, | |
| }), | |
| }; | |