Matou-Garou / convex /aiTown /conversation.ts
Jofthomas's picture
Jofthomas HF staff
bulk
ce8b18b
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;
},
}),
};