refactor: extract shared player, session, and chat packages #5

Merged
ryan merged 14 commits from refactor/shared-player-session-chat into main 2026-03-03 08:50:13 +00:00
10 changed files with 305 additions and 287 deletions
Showing only changes of commit 10de5d21ad - Show all commits

92
chat/chat.go Normal file
View File

@@ -0,0 +1,92 @@
// Package chat provides a reusable chat room backed by NATS pub/sub
// with optional database persistence.
package chat
import (
"encoding/json"
"sync"
"github.com/nats-io/nats.go"
"github.com/rs/zerolog/log"
)
// Message is the wire format for chat messages over NATS.
type Message struct {
Nickname string `json:"nickname"`
Slot int `json:"slot"` // player slot/color index
Message string `json:"message"`
Time int64 `json:"time"` // unix millis, zero for ephemeral messages
}
const maxMessages = 50
// Room manages an in-memory message buffer and NATS pub/sub for a single
// chat room (typically one per game).
type Room struct {
subject string
nc *nats.Conn
messages []Message
mu sync.Mutex
}
// 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 {
return &Room{
subject: subject,
nc: nc,
messages: initial,
}
}
// Send publishes a message to the room's NATS subject.
func (r *Room) Send(msg Message) {
data, err := json.Marshal(msg)
if err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
return
}
if err := r.nc.Publish(r.subject, data); err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to publish chat message")
}
}
// Receive processes an incoming NATS message, appending it to the buffer.
// Returns the new message and a snapshot of all messages.
func (r *Room) Receive(data []byte) (Message, []Message) {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return msg, nil
}
r.mu.Lock()
r.messages = append(r.messages, msg)
if len(r.messages) > maxMessages {
r.messages = r.messages[len(r.messages)-maxMessages:]
}
snapshot := make([]Message, len(r.messages))
copy(snapshot, r.messages)
r.mu.Unlock()
return msg, snapshot
}
// Messages returns a snapshot of the current message buffer.
func (r *Room) Messages() []Message {
r.mu.Lock()
defer r.mu.Unlock()
snapshot := make([]Message, len(r.messages))
copy(snapshot, r.messages)
return snapshot
}
// Subscribe creates a NATS channel subscription for the room's subject.
// Caller is responsible for unsubscribing.
func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
ch := make(chan *nats.Msg, 64)
sub, err := r.nc.ChanSubscribe(r.subject, ch)
if err != nil {
return nil, nil, err
}
return ch, sub, nil
}

View File

@@ -0,0 +1,74 @@
package components
import (
"fmt"
"github.com/ryanhamamura/c4/chat"
"github.com/starfederation/datastar-go/datastar"
)
// ColorFunc resolves a player slot to a CSS color string.
type ColorFunc func(slot int) string
// Config holds the game-specific settings for rendering a chat component.
type Config struct {
// CSSPrefix is used for element IDs and CSS classes (e.g. "c4" or "snake").
CSSPrefix string
// PostURL is the URL to POST chat messages to (e.g. "/games/{id}/chat").
PostURL string
// Color resolves a player slot to a CSS color string.
Color ColorFunc
// StopKeyPropagation adds data-on:keydown.stop="" to the input to prevent
// key events from propagating (needed for snake to avoid steering while typing).
StopKeyPropagation bool
}
templ Chat(messages []chat.Message, cfg Config) {
<div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }>
<div class={ cfg.CSSPrefix + "-chat-history" }>
for _, m := range messages {
<div class={ cfg.CSSPrefix + "-chat-msg" }>
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", cfg.Color(m.Slot)) }>
{ m.Nickname + ": " }
</span>
<span>{ m.Message }</span>
</div>
}
</div>
<div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore>
if cfg.StopKeyPropagation {
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.stop=""
data-on:keydown.key_enter={ datastar.PostSSE(cfg.PostURL) }
/>
} else {
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.enter={ datastar.PostSSE(cfg.PostURL) }
/>
}
<button
type="button"
data-on:click={ datastar.PostSSE(cfg.PostURL) }
>
Send
</button>
</div>
@chatAutoScroll(cfg.CSSPrefix)
</div>
}
script chatAutoScroll(cssPrefix string) {
var el = document.querySelector('.' + cssPrefix + '-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
}

45
chat/persist.go Normal file
View File

@@ -0,0 +1,45 @@
package chat
import (
"context"
"slices"
"github.com/ryanhamamura/c4/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
}

View File

@@ -1,69 +0,0 @@
package components
import (
"fmt"
"github.com/starfederation/datastar-go/datastar"
)
type ChatMessage struct {
Nickname string `json:"nickname"`
Color int `json:"color"`
Message string `json:"message"`
Time int64 `json:"time"`
}
var chatColors = map[int]string{
1: "#4a2a3a",
2: "#2a4545",
}
templ Chat(messages []ChatMessage, gameID string) {
<div id="c4-chat" class="c4-chat">
<div class="c4-chat-history">
for _, m := range messages {
<div class="c4-chat-msg">
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Color)) }>
{ m.Nickname }:&nbsp;
</span>
<span>{ m.Message }</span>
</div>
}
@chatAutoScroll()
</div>
<div class="c4-chat-input" data-morph-ignore>
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.enter={ datastar.PostSSE("/games/%s/chat", gameID) }
/>
<button
type="button"
data-on:click={ datastar.PostSSE("/games/%s/chat", gameID) }
>
Send
</button>
</div>
</div>
}
templ chatAutoScroll() {
<script>
(function(){
var el = document.querySelector('.c4-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
})();
</script>
}
func chatColor(color int) string {
if c, ok := chatColors[color]; ok {
return c
}
return "#666"
}

View File

@@ -1,12 +1,9 @@
package c4game
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"strconv"
"sync"
"time"
"github.com/alexedwards/scs/v2"
@@ -14,6 +11,8 @@ import (
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages"
@@ -21,6 +20,27 @@ import (
"github.com/ryanhamamura/c4/sessions"
)
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
var c4ChatColors = map[int]string{
0: "#4a2a3a", // color 1 stored as slot 0
1: "#2a4545", // color 2 stored as slot 1
}
func c4ChatColor(slot int) string {
if c, ok := c4ChatColors[slot]; ok {
return c
}
return "#666"
}
func c4ChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "c4",
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
Color: c4ChatColor,
}
}
func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -53,25 +73,21 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo
// Player not in game
isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest {
// Show join prompt (login vs guest)
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
// Show nickname prompt
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
// Player is in the game — render full game page
g := gi.GetGame()
chatMsgs := loadChatMessages(queries, gameID)
msgs := chatToComponents(chatMsgs)
msgs := chat.LoadMessages(queries, gameID)
if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil {
if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -94,13 +110,11 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
// Load initial chat messages
chatMsgs := loadChatMessages(queries, gameID)
var chatMu sync.Mutex
chatMessages := chatToComponents(chatMsgs)
chatCfg := c4ChatConfig(gameID)
room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID))
// Send initial render of all components
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
// Send initial render
sendGameComponents(sse, gi, myColor, room, chatCfg)
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
@@ -111,8 +125,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages
chatCh := make(chan *nats.Msg, 64)
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
chatCh, chatSub, err := room.Subscribe()
if err != nil {
return
}
@@ -124,30 +137,14 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
case <-ctx.Done():
return
case <-gameCh:
// Re-read player color in case we just joined
myColor = gi.GetPlayerColor(playerID)
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
sendGameComponents(sse, gi, myColor, room, chatCfg)
case msg := <-chatCh:
var uiMsg game.ChatMessage
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil {
_, snapshot := room.Receive(msg.Data)
if snapshot == nil {
continue
}
cm := components.ChatMessage{
Nickname: uiMsg.Nickname,
Color: uiMsg.Color,
Message: uiMsg.Message,
Time: uiMsg.Time,
}
chatMu.Lock()
chatMessages = append(chatMessages, cm)
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil {
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil {
return
}
}
@@ -180,9 +177,6 @@ func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.Handler
}
gi.DropPiece(col, myColor)
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
// Return empty SSE response.
datastar.NewSSE(w, r)
}
}
@@ -227,22 +221,18 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager
}
}
cm := game.ChatMessage{
// Map color (1-based) to slot (0-based) for the unified chat message
msg := chat.Message{
Nickname: nick,
Color: myColor,
Slot: myColor - 1,
Message: signals.ChatMsg,
Time: time.Now().UnixMilli(),
}
saveChatMessage(queries, gameID, cm)
chat.SaveMessage(queries, gameID, msg)
data, err := json.Marshal(cm)
if err != nil {
datastar.NewSSE(w, r)
return
}
nc.Publish("game.chat."+gameID, data) //nolint:errcheck
room := chat.NewRoom(nc, "game.chat."+gameID, nil)
room.Send(msg)
// Clear the chat input
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
}
@@ -314,61 +304,11 @@ func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFu
}
// sendGameComponents patches all game-related SSE components.
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) {
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
g := gi.GetGame()
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
chatMu.Lock()
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
}
// Chat persistence helpers — inlined from the former ChatPersister.
func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) {
queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck
GameID: gameID,
Nickname: msg.Nickname,
Color: int64(msg.Color),
Message: msg.Message,
CreatedAt: msg.Time,
})
}
func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage {
rows, err := queries.GetChatMessages(context.Background(), gameID)
if err != nil {
return nil
}
msgs := make([]game.ChatMessage, len(rows))
for i, r := range rows {
msgs[i] = game.ChatMessage{
Nickname: r.Nickname,
Color: int(r.Color),
Message: r.Message,
Time: r.CreatedAt,
}
}
// DB returns newest-first; reverse for display
slices.Reverse(msgs)
return msgs
}
func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage {
msgs := make([]components.ChatMessage, len(chatMsgs))
for i, m := range chatMsgs {
msgs[i] = components.ChatMessage{
Nickname: m.Nickname,
Color: m.Color,
Message: m.Message,
Time: m.Time,
}
}
return msgs
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
}

View File

@@ -1,6 +1,8 @@
package pages
import (
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/c4game/components"
sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
@@ -8,7 +10,7 @@ import (
"github.com/starfederation/datastar-go/datastar"
)
templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) {
templ GamePage(g *game.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
@layouts.Base("Connect 4") {
<main
class="flex flex-col items-center gap-4 p-4"
@@ -21,7 +23,7 @@ templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) {
@components.StatusBanner(g, myColor)
<div class="c4-game-area">
@components.Board(g, myColor)
@components.Chat(messages, g.ID)
@chatcomponents.Chat(messages, chatCfg)
</div>
if g.Status == game.StatusWaitingForPlayer {
@components.InviteLink(g.ID)

View File

@@ -1,66 +0,0 @@
package components
import (
"fmt"
"github.com/ryanhamamura/c4/snake"
"github.com/starfederation/datastar-go/datastar"
)
type ChatMessage struct {
Nickname string `json:"nickname"`
Slot int `json:"slot"`
Message string `json:"message"`
Time int64 `json:"time"`
}
templ Chat(messages []ChatMessage, gameID string) {
<div id="snake-chat" class="snake-chat">
<div class="snake-chat-history">
for _, m := range messages {
<div class="snake-chat-msg">
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Slot)) }>
{ m.Nickname + ": " }
</span>
<span>{ m.Message }</span>
</div>
}
</div>
<div class="snake-chat-input" data-morph-ignore>
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.stop=""
data-on:keydown.key_enter={ datastar.PostSSE("/snake/%s/chat", gameID) }
/>
<button
type="button"
data-on:click={ datastar.PostSSE("/snake/%s/chat", gameID) }
>
Send
</button>
</div>
@chatAutoScroll()
</div>
}
templ chatAutoScroll() {
<script>
(function(){
var el = document.querySelector('.snake-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
})();
</script>
}
func chatColor(slot int) string {
if slot >= 0 && slot < len(snake.SnakeColors) {
return snake.SnakeColors[slot]
}
return "#666"
}

View File

@@ -1,22 +1,39 @@
package snakegame
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/snakegame/components"
"github.com/ryanhamamura/c4/features/snakegame/pages"
"github.com/ryanhamamura/c4/sessions"
"github.com/ryanhamamura/c4/snake"
)
func snakeChatColor(slot int) string {
if slot >= 0 && slot < len(snake.SnakeColors) {
return snake.SnakeColors[slot]
}
return "#666"
}
func snakeChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "snake",
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
Color: snakeChatColor,
StopKeyPropagation: true,
}
}
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -32,20 +49,19 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
// Auto-join if nickname exists and not already in game
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
p := &snake.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
player.UserID = &userID
p.UserID = &userID
}
si.Join(player)
si.Join(p)
}
mySlot := si.GetPlayerSlot(playerID)
if mySlot < 0 {
// Not in game yet
isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest {
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
@@ -60,7 +76,7 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
}
sg := si.GetGame()
if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil {
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -82,13 +98,15 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
chatCfg := snakeChatConfig(gameID)
// Send initial render
sg := si.GetGame()
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
if sg.Mode == snake.ModeMultiplayer {
sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck
sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
}
@@ -105,12 +123,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
// Chat subscription (multiplayer only)
var chatCh chan *nats.Msg
var chatSub *nats.Subscription
var chatMessages []components.ChatMessage
var chatMu sync.Mutex
var room *chat.Room
if sg.Mode == snake.ModeMultiplayer {
chatCh = make(chan *nats.Msg, 64)
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh)
room = chat.NewRoom(nc, "snake.chat."+gameID, nil)
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
}
@@ -153,20 +170,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
if msg == nil {
continue
}
var cm components.ChatMessage
if err := json.Unmarshal(msg.Data, &cm); err != nil {
_, snapshot := room.Receive(msg.Data)
if snapshot == nil {
continue
}
chatMu.Lock()
chatMessages = append(chatMessages, cm)
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil {
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
return
}
}
@@ -233,16 +241,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
}
sg := si.GetGame()
cm := components.ChatMessage{
msg := chat.Message{
Nickname: sg.Players[slot].Nickname,
Slot: slot,
Message: signals.ChatMsg,
}
data, err := json.Marshal(cm)
if err != nil {
return
}
nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
room := chat.NewRoom(nc, "snake.chat."+gameID, nil)
room.Send(msg)
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
@@ -278,14 +284,14 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt
userID := sessions.GetUserID(sm, r)
if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
p := &snake.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
player.UserID = &userID
p.UserID = &userID
}
si.Join(player)
si.Join(p)
}
sse := datastar.NewSSE(w, r)

View File

@@ -3,6 +3,8 @@ package pages
import (
"fmt"
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components"
@@ -26,7 +28,7 @@ func keydownScript(gameID string) string {
)
}
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatMessage, gameID string) {
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
@layouts.Base("Snake") {
<main
class="snake-wrapper flex flex-col items-center gap-4 p-4"
@@ -43,13 +45,13 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatM
if sg.Mode == snake.ModeMultiplayer {
<div class="snake-game-area">
@snakecomponents.Board(sg)
@snakecomponents.Chat(messages, gameID)
@chatcomponents.Chat(messages, chatCfg)
</div>
} else {
@snakecomponents.Board(sg)
}
} else if sg.Mode == snake.ModeMultiplayer {
@snakecomponents.Chat(messages, gameID)
@chatcomponents.Chat(messages, chatCfg)
}
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
@snakecomponents.InviteLink(gameID)

View File

@@ -69,11 +69,3 @@ func (g *Game) WinningCellsFromJSON(data string) error {
}
return json.Unmarshal([]byte(data), &g.WinningCells)
}
// ChatMessage is the domain type for persisted C4 chat messages.
type ChatMessage struct {
Nickname string `json:"nickname"`
Color int `json:"color"` // 1=Red, 2=Yellow
Message string `json:"message"`
Time int64 `json:"time"`
}