Skip to content

Commit 22be2c1

Browse files
committed
feat: Chat messaging system
1 parent 12720f2 commit 22be2c1

File tree

11 files changed

+185
-47
lines changed

11 files changed

+185
-47
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { api, IAPI } from "@/api/api";
88
import { Post } from "@/network/posts/post.types";
99
import { useGame } from "@/core/store/game-store";
1010
import { getRandomMessage } from "@/network/posts/content";
11+
import { toast } from "@/hooks/use-toast";
12+
import { ToastAction, ToastActionElement } from "@/common/components/ui/toast";
13+
import React from "react";
1114

1215
export const coreAddon: Addon = {
1316
id: "core",
@@ -29,6 +32,35 @@ export const coreAddon: Addon = {
2932

3033
triggerRandomEvent();
3134
});
35+
36+
// Listen for chat messages globally and respond
37+
api.event.on("chat/messageSent", (event: any) => {
38+
if (!event.message.isPlayer) return;
39+
// Respond after a short delay
40+
setTimeout(() => {
41+
const responseMsg = {
42+
id: useGame.getState().gameState.world.time! + 1,
43+
characterId: event.characterId,
44+
content: "Hello, I received your message!",
45+
timestamp: useGame.getState().gameState.world.time!,
46+
isPlayer: false,
47+
};
48+
api.character.addChatMessage(event.characterId, responseMsg);
49+
}, Math.floor(Math.random() * 3000) + 2000);
50+
});
51+
52+
// Message received toast
53+
api.event.on("chat/messageSent", (event) => {
54+
if (event.message.isPlayer) return;
55+
56+
toast({
57+
title: `Message from ${api.character.getFullName(
58+
api.character.getCharacterById(event.characterId)!
59+
)}`,
60+
description: event.message.content,
61+
action: <ToastAction altText="Try again">Try again</ToastAction>,
62+
});
63+
});
3264
},
3365
onDisabled: () => {
3466
// Usually, here you would remove all registered stuff from onEnabled,

src/api/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,11 @@ class API implements IAPI {
4141

4242
export const api = new API();
4343

44+
declare global {
45+
interface Window {
46+
api: API;
47+
}
48+
}
49+
4450
// Expose API to browser console
4551
window.api = api;

src/api/character.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Character, Gender } from "@/character/character.types";
22
import { Company } from "@/company/company.types";
33
import { useGame } from "@/core/store/game-store";
44
import { api } from "./api";
5+
import { Message } from "@/core/core.types";
56

67
export interface ICharacterAPI {
78
first_names_male: string[];
@@ -157,4 +158,29 @@ export class CharacterAPI implements ICharacterAPI {
157158

158159
return characters[randomIndex];
159160
}
161+
162+
addChatMessage(characterId: number, message: Message): void {
163+
const { gameState, updateGameState } = useGame.getState();
164+
const chats = { ...gameState.chats };
165+
const isFirstMessage =
166+
!chats[characterId] || chats[characterId].length === 0;
167+
if (!chats[characterId]) chats[characterId] = [];
168+
chats[characterId] = [...chats[characterId], message];
169+
updateGameState({ chats });
170+
171+
// Trigger chat/messageSent event
172+
api.event.trigger({
173+
type: "chat/messageSent",
174+
message,
175+
characterId,
176+
});
177+
178+
// Trigger chat/conversationStarted if first message
179+
if (isFirstMessage) {
180+
api.event.trigger({
181+
type: "chat/conversationStarted",
182+
characterId,
183+
});
184+
}
185+
}
160186
}

src/common/components/root/chat.tsx

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useState, useRef, useEffect } from "react";
4+
import { random } from "lodash";
45
import { Input } from "@/common/components/ui/input";
56
import { Button } from "@/common/components/ui/button";
67
import { ScrollArea } from "@/common/components/ui/scroll-area";
@@ -9,13 +10,7 @@ import { Send, Search } from "lucide-react";
910
import { useGame } from "@/core/store/game-store";
1011
import { api } from "@/api/api";
1112

12-
interface Message {
13-
id: number;
14-
characterId: number;
15-
content: string;
16-
timestamp: number;
17-
isPlayer: boolean;
18-
}
13+
import { Message } from "@/core/core.types";
1914

2015
export function Chat() {
2116
const { gameState } = useGame();
@@ -25,6 +20,7 @@ export function Chat() {
2520
)
2621
);
2722
const [filteredCharacters, setFilteredCharacters] = useState(characters);
23+
const [isTyping, setIsTyping] = useState(false);
2824

2925
useEffect(() => {
3026
setCharacters(
@@ -40,23 +36,7 @@ export function Chat() {
4036
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
4137
};
4238

43-
// Initial conversations with each character
44-
const initialConversations: Record<string, Message[]> = {};
45-
46-
characters.forEach((character) => {
47-
initialConversations[character.id] = [
48-
{
49-
id: Date.now() + Math.random(),
50-
characterId: character.id,
51-
content: "Hi",
52-
timestamp: useGame.getState().gameState.world?.time ?? 0,
53-
isPlayer: false,
54-
},
55-
];
56-
});
57-
5839
const [selectedCharacter, setSelectedCharacter] = useState(characters[0]);
59-
const [conversations, setConversations] = useState(initialConversations);
6040
const [newMessage, setNewMessage] = useState("");
6141
const [searchQuery, setSearchQuery] = useState("");
6242
const scrollAreaRef = useRef<HTMLDivElement>(null);
@@ -78,32 +58,31 @@ export function Chat() {
7858
const scrollContainer = scrollAreaRef.current;
7959
scrollContainer.scrollTop = scrollContainer.scrollHeight;
8060
}
81-
}, [conversations, selectedCharacter]);
61+
}, [gameState.chats, selectedCharacter]);
8262

8363
const handleSendMessage = () => {
8464
if (newMessage.trim() === "") return;
85-
86-
// Add player message
8765
const playerMsg: Message = {
88-
id: Date.now(),
89-
characterId: 0,
66+
id: useGame.getState().gameState.world.time!,
67+
characterId: selectedCharacter.id,
9068
content: newMessage,
91-
timestamp: Date.now(),
69+
timestamp: useGame.getState().gameState.world.time!,
9270
isPlayer: true,
9371
};
94-
95-
// Update the conversation with the selected character
96-
setConversations((prev) => ({
97-
...prev,
98-
[selectedCharacter.id]: [
99-
...(prev[selectedCharacter.id] || []),
100-
playerMsg,
101-
],
102-
}));
103-
72+
api.character.addChatMessage(selectedCharacter.id, playerMsg);
10473
setNewMessage("");
10574
};
10675

76+
// Typing animation when player sends a message
77+
useEffect(() => {
78+
if (!isTyping) return;
79+
const typingDelay = random(2000, 5000);
80+
const timeout = setTimeout(() => {
81+
setIsTyping(false);
82+
}, typingDelay);
83+
return () => clearTimeout(timeout);
84+
}, [isTyping]);
85+
10786
console.log("Characters", characters);
10887
console.log("Filtered Characters", filteredCharacters);
10988

@@ -183,7 +162,8 @@ export function Chat() {
183162

184163
<ScrollArea className="flex-1 p-4" ref={scrollAreaRef}>
185164
<div className="space-y-6">
186-
{conversations[selectedCharacter.id]?.map((message) => (
165+
{/* Chat messages */}
166+
{gameState.chats[selectedCharacter.id]?.map((message) => (
187167
<div
188168
key={message.id}
189169
className={`flex gap-4 ${
@@ -204,7 +184,7 @@ export function Chat() {
204184
>
205185
<div
206186
className={`px-4 py-2 rounded-lg ${
207-
message.isPlayer ? "bg-primary" : "bg-muted"
187+
message.isPlayer ? "bg-popover" : "bg-muted"
208188
}`}
209189
>
210190
<p className="text-sm">{message.content}</p>
@@ -220,6 +200,28 @@ export function Chat() {
220200
)}
221201
</div>
222202
))}
203+
{/* Typing animation for character (left side only) */}
204+
{isTyping && (
205+
<div className="flex gap-4 justify-start">
206+
<Avatar>
207+
<AvatarFallback>
208+
{api.character.getInitial(selectedCharacter)}
209+
</AvatarFallback>
210+
</Avatar>
211+
<div className="flex flex-col max-w-[70%] items-start">
212+
<div className="px-4 py-2 rounded-lg bg-muted flex items-center">
213+
<span className="animate-pulse flex gap-1">
214+
<span className="w-2 h-2 bg-gray-400 rounded-full inline-block"></span>
215+
<span className="w-2 h-2 bg-gray-400 rounded-full inline-block"></span>
216+
<span className="w-2 h-2 bg-gray-400 rounded-full inline-block"></span>
217+
</span>
218+
</div>
219+
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 px-1">
220+
Typing...
221+
</div>
222+
</div>
223+
</div>
224+
)}
223225
</div>
224226
</ScrollArea>
225227

src/common/components/ui/global-dropdown-menu.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "@/common/components/ui/dropdown-menu";
99
import { Button } from "@/common/components/ui/button";
1010
import React from "react";
11+
import { EllipsisVertical } from "lucide-react";
1112

1213
export type MenuOption = {
1314
id: string;
@@ -32,7 +33,7 @@ export const GlobalDropdownMenu: React.FC<GlobalDropdownMenuProps> = ({
3233
label = "Actions",
3334
trigger = (
3435
<Button variant="ghost" size="icon">
35-
<span></span>
36+
<EllipsisVertical size={24} />
3637
</Button>
3738
),
3839
}) => {

src/core/core.types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface GameState {
2626
};
2727
};
2828
world: World;
29+
chats: Record<string, Message[]>;
2930
}
3031

3132
export interface DebugState {
@@ -51,8 +52,17 @@ export const initialState: GameState = {
5152
world: {
5253
time: Date.now(),
5354
},
55+
chats: {},
5456
};
5557

58+
export interface Message {
59+
id: number;
60+
characterId: number;
61+
content: string;
62+
timestamp: number;
63+
isPlayer: boolean;
64+
}
65+
5666
export interface GameContextType {
5767
gameState: GameState;
5868
updateGameState: (update: Partial<GameState>) => void;

src/core/event/event-manager.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@ import { HireEvent } from "./events/hire";
22
import { NetworkPostEvent } from "./events/network-post";
33
import { NewGameEvent } from "./events/new-game";
44
import { TickEvent } from "./events/tick";
5+
import { ChatMessageSentEvent } from "./events/chat-message-sent";
6+
import { ChatConversationStartedEvent } from "./events/chat-conversation-started";
57

6-
type GameEvent = TickEvent | NewGameEvent | HireEvent | NetworkPostEvent;
8+
type GameEvent =
9+
| TickEvent
10+
| NewGameEvent
11+
| HireEvent
12+
| NetworkPostEvent
13+
| ChatMessageSentEvent
14+
| ChatConversationStartedEvent;
715

816
interface IEventManager {
917
/**
@@ -30,6 +38,22 @@ export class EventManager implements IEventManager {
3038
>;
3139
} = {};
3240

41+
/**
42+
* Removes a callback for a specific event type.
43+
* @param eventType - The type of event to stop listening for.
44+
* @param callback - The function to remove.
45+
*/
46+
off<K extends GameEvent["type"]>(
47+
eventType: K,
48+
callback: (event: Extract<GameEvent, { type: K }>) => void
49+
) {
50+
const arr = this.listeners[eventType];
51+
if (!arr) return;
52+
this.listeners[eventType] = arr.filter(
53+
(cb) => cb !== callback
54+
) as typeof arr;
55+
}
56+
3357
on<K extends GameEvent["type"]>(
3458
eventType: K,
3559
callback: (event: Extract<GameEvent, { type: K }>) => void
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { BaseEvent } from "../event";
2+
3+
export interface ChatConversationStartedEvent extends BaseEvent {
4+
type: "chat/conversationStarted";
5+
characterId: number;
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { BaseEvent } from "../event";
2+
import { Message } from "@/core/core.types";
3+
4+
export interface ChatMessageSentEvent extends BaseEvent {
5+
type: "chat/messageSent";
6+
message: Message;
7+
characterId: number;
8+
}

src/core/store/game-store.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GameState, initialState } from "@/core/core.types";
1+
import { GameState, initialState, Message } from "@/core/core.types";
22
import { create } from "zustand";
33
import seedrandom from "seedrandom";
44
import { Generator } from "@/core/generation/generator";

0 commit comments

Comments
 (0)