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; | |
}, | |
}), | |
}; | |