refactor: extract shared player, session, and chat packages #5
73
chat/chat.go
73
chat/chat.go
@@ -3,11 +3,15 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
)
|
||||
|
||||
// Message is the wire format for chat messages over NATS.
|
||||
@@ -21,26 +25,47 @@ type Message struct {
|
||||
const maxMessages = 50
|
||||
|
||||
// Room manages an in-memory message buffer and NATS pub/sub for a single
|
||||
// chat room (typically one per game).
|
||||
// chat room (typically one per game). When created with NewPersistentRoom,
|
||||
// messages are automatically loaded from and saved to the database.
|
||||
type Room struct {
|
||||
subject string
|
||||
nc *nats.Conn
|
||||
messages []Message
|
||||
mu sync.Mutex
|
||||
|
||||
// Optional persistence; nil for ephemeral rooms (e.g. snake).
|
||||
queries *repository.Queries
|
||||
roomID string
|
||||
}
|
||||
|
||||
// NewRoom creates a chat room that publishes and subscribes on the given
|
||||
// NATS subject (e.g. "chat.abc123").
|
||||
func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room {
|
||||
// NewRoom creates an ephemeral chat room with no database persistence.
|
||||
func NewRoom(nc *nats.Conn, subject string) *Room {
|
||||
return &Room{
|
||||
subject: subject,
|
||||
nc: nc,
|
||||
messages: initial,
|
||||
subject: subject,
|
||||
nc: nc,
|
||||
}
|
||||
}
|
||||
|
||||
// Send publishes a message to the room's NATS subject.
|
||||
// NewPersistentRoom creates a chat room backed by the database. It loads
|
||||
// existing messages on creation and auto-saves new messages on Send.
|
||||
func NewPersistentRoom(nc *nats.Conn, subject string, queries *repository.Queries, roomID string) *Room {
|
||||
r := &Room{
|
||||
subject: subject,
|
||||
nc: nc,
|
||||
queries: queries,
|
||||
roomID: roomID,
|
||||
}
|
||||
r.messages = r.loadMessages()
|
||||
return r
|
||||
}
|
||||
|
||||
// Send publishes a message to the room's NATS subject and persists it
|
||||
// if the room is backed by a database.
|
||||
func (r *Room) Send(msg Message) {
|
||||
if r.queries != nil {
|
||||
r.saveMessage(msg)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
|
||||
@@ -90,3 +115,35 @@ func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
|
||||
}
|
||||
return ch, sub, nil
|
||||
}
|
||||
|
||||
func (r *Room) saveMessage(msg Message) {
|
||||
err := r.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
|
||||
GameID: r.roomID,
|
||||
Nickname: msg.Nickname,
|
||||
Color: int64(msg.Slot),
|
||||
Message: msg.Message,
|
||||
CreatedAt: msg.Time,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("room_id", r.roomID).Msg("failed to save chat message")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Room) loadMessages() []Message {
|
||||
rows, err := r.queries.GetChatMessages(context.Background(), r.roomID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
msgs := make([]Message, len(rows))
|
||||
for i, row := range rows {
|
||||
msgs[i] = Message{
|
||||
Nickname: row.Nickname,
|
||||
Slot: int(row.Color),
|
||||
Message: row.Message,
|
||||
Time: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
// DB returns newest-first; reverse for chronological display
|
||||
slices.Reverse(msgs)
|
||||
return msgs
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// SaveMessage persists a chat message to the database.
|
||||
func SaveMessage(queries *repository.Queries, roomID string, msg Message) {
|
||||
err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
|
||||
GameID: roomID,
|
||||
Nickname: msg.Nickname,
|
||||
Color: int64(msg.Slot),
|
||||
Message: msg.Message,
|
||||
CreatedAt: msg.Time,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message")
|
||||
}
|
||||
}
|
||||
|
||||
// LoadMessages loads persisted chat messages for a room, returning them
|
||||
// in chronological order (oldest first).
|
||||
func LoadMessages(queries *repository.Queries, roomID string) []Message {
|
||||
rows, err := queries.GetChatMessages(context.Background(), roomID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
msgs := make([]Message, len(rows))
|
||||
for i, r := range rows {
|
||||
msgs[i] = Message{
|
||||
Nickname: r.Nickname,
|
||||
Slot: int(r.Color),
|
||||
Message: r.Message,
|
||||
Time: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
// DB returns newest-first; reverse for chronological display
|
||||
slices.Reverse(msgs)
|
||||
return msgs
|
||||
}
|
||||
@@ -85,9 +85,9 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo
|
||||
}
|
||||
|
||||
g := gi.GetGame()
|
||||
msgs := chat.LoadMessages(queries, gameID)
|
||||
room := chat.NewPersistentRoom(nil, "", queries, gameID)
|
||||
|
||||
if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
|
||||
if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
|
||||
))
|
||||
|
||||
chatCfg := c4ChatConfig(gameID)
|
||||
room := chat.NewRoom(nc, "connect4.chat."+gameID, chat.LoadMessages(queries, gameID))
|
||||
room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID)
|
||||
|
||||
// Send initial render
|
||||
sendGameComponents(sse, gi, myColor, room, chatCfg)
|
||||
@@ -228,9 +228,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager
|
||||
Message: signals.ChatMsg,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
chat.SaveMessage(queries, gameID, msg)
|
||||
|
||||
room := chat.NewRoom(nc, "connect4.chat."+gameID, nil)
|
||||
room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID)
|
||||
room.Send(msg)
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
|
||||
@@ -126,7 +126,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
|
||||
var room *chat.Room
|
||||
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
room = chat.NewRoom(nc, "snake.chat."+gameID, nil)
|
||||
room = chat.NewRoom(nc, "snake.chat."+gameID)
|
||||
chatCh, chatSub, err = room.Subscribe()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -247,7 +247,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
|
||||
Message: signals.ChatMsg,
|
||||
}
|
||||
|
||||
room := chat.NewRoom(nc, "snake.chat."+gameID, nil)
|
||||
room := chat.NewRoom(nc, "snake.chat."+gameID)
|
||||
room.Send(msg)
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
|
||||
Reference in New Issue
Block a user