3 Commits

Author SHA1 Message Date
Ryan Hamamura
10de5d21ad refactor: extract standalone chat package from game-specific handlers
Some checks failed
CI / Deploy / test (pull_request) Failing after 11s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Create chat/ package with Message type, Room (NATS pub/sub + buffer),
DB persistence helpers, and a unified templ component parameterized by
Config (CSS prefix, post URL, color function, key propagation).

Both c4game and snakegame now use chat.Room for message management and
chatcomponents.Chat for rendering, eliminating the duplicated
ChatMessage types, chat templ components, chatAutoScroll scripts,
color functions, and inline buffer management.
2026-03-02 19:20:21 -10:00
Ryan Hamamura
7eadfbbb0c refactor: extract session helpers for player identity resolution
Add GetPlayerID, GetUserID, GetNickname to the sessions package.
Remove the inline player-ID-from-session pattern duplicated across
every handler in c4game and snakegame, and the local getPlayerID
helper in snakegame.
2026-03-02 19:16:09 -10:00
Ryan Hamamura
063b03ce25 refactor: extract shared player.ID type and GenerateID to player package
Both game and snake packages had identical PlayerID types and the snake
package imported game.GenerateID. Now both use player.ID and
player.GenerateID from the shared player package.
2026-03-02 19:09:01 -10:00
17 changed files with 413 additions and 391 deletions

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 package c4game
import ( import (
"context" "fmt"
"encoding/json"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
@@ -14,13 +11,37 @@ import (
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar" "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/db/repository"
"github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/c4/features/c4game/pages"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/sessions"
) )
func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { // 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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -30,29 +51,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
if playerID == "" { userID := sessions.GetUserID(sm, r)
playerID = game.PlayerID(game.GenerateID(8)) nickname := sessions.GetNickname(sm, r)
sessions.Put(r.Context(), "player_id", string(playerID))
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
nickname := sessions.GetString(r.Context(), "nickname")
// Auto-join if player has a nickname but isn't in the game yet // Auto-join if player has a nickname but isn't in the game yet
if nickname != "" && gi.GetPlayerColor(playerID) == 0 { if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ p := &game.Player{
ID: playerID, ID: playerID,
Nickname: nickname, Nickname: nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
gi.Join(&game.PlayerSession{Player: player}) gi.Join(&game.PlayerSession{Player: p})
} }
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
@@ -61,31 +73,27 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
// Player not in game // Player not in game
isGuest := r.URL.Query().Get("guest") == "1" isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest { if userID == "" && !isGuest {
// Show join prompt (login vs guest)
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
return return
} }
// Show nickname prompt
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil { if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
return return
} }
// Player is in the game — render full game page
g := gi.GetGame() g := gi.GetGame()
chatMsgs := loadChatMessages(queries, gameID) msgs := chat.LoadMessages(queries, gameID)
msgs := chatToComponents(chatMsgs)
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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
} }
} }
func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -95,25 +103,18 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression( sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)), datastar.WithBrotli(datastar.WithBrotliLevel(5)),
)) ))
// Load initial chat messages chatCfg := c4ChatConfig(gameID)
chatMsgs := loadChatMessages(queries, gameID) room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID))
var chatMu sync.Mutex
chatMessages := chatToComponents(chatMsgs)
// Send initial render of all components // Send initial render
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) sendGameComponents(sse, gi, myColor, room, chatCfg)
// Subscribe to game state updates // Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64) gameCh := make(chan *nats.Msg, 64)
@@ -124,8 +125,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
defer gameSub.Unsubscribe() //nolint:errcheck defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages // Subscribe to chat messages
chatCh := make(chan *nats.Msg, 64) chatCh, chatSub, err := room.Subscribe()
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
if err != nil { if err != nil {
return return
} }
@@ -137,30 +137,14 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
case <-ctx.Done(): case <-ctx.Done():
return return
case <-gameCh: case <-gameCh:
// Re-read player color in case we just joined
myColor = gi.GetPlayerColor(playerID) myColor = gi.GetPlayerColor(playerID)
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) sendGameComponents(sse, gi, myColor, room, chatCfg)
case msg := <-chatCh: case msg := <-chatCh:
var uiMsg game.ChatMessage _, snapshot := room.Receive(msg.Data)
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil { if snapshot == nil {
continue continue
} }
cm := components.ChatMessage{ if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil {
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 {
return return
} }
} }
@@ -168,7 +152,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
} }
} }
func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -185,12 +169,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
if myColor == 0 { if myColor == 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -198,14 +177,11 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
} }
gi.DropPiece(col, myColor) gi.DropPiece(col, myColor)
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
// Return empty SSE response.
datastar.NewSSE(w, r) datastar.NewSSE(w, r)
} }
} }
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -229,12 +205,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
if myColor == 0 { if myColor == 0 {
datastar.NewSSE(w, r) datastar.NewSSE(w, r)
@@ -250,28 +221,24 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
} }
} }
cm := game.ChatMessage{ // Map color (1-based) to slot (0-based) for the unified chat message
msg := chat.Message{
Nickname: nick, Nickname: nick,
Color: myColor, Slot: myColor - 1,
Message: signals.ChatMsg, Message: signals.ChatMsg,
Time: time.Now().UnixMilli(), Time: time.Now().UnixMilli(),
} }
saveChatMessage(queries, gameID, cm) chat.SaveMessage(queries, gameID, msg)
data, err := json.Marshal(cm) room := chat.NewRoom(nc, "game.chat."+gameID, nil)
if err != nil { room.Send(msg)
datastar.NewSSE(w, r)
return
}
nc.Publish("game.chat."+gameID, data) //nolint:errcheck
// Clear the chat input
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
} }
} }
func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -296,23 +263,20 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
return return
} }
sessions.Put(r.Context(), "nickname", signals.Nickname) sm.Put(r.Context(), "nickname", signals.Nickname)
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
if userID != "" {
playerID = game.PlayerID(userID)
}
if gi.GetPlayerColor(playerID) == 0 { if gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ p := &game.Player{
ID: playerID, ID: playerID,
Nickname: signals.Nickname, Nickname: signals.Nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
gi.Join(&game.PlayerSession{Player: player}) gi.Join(&game.PlayerSession{Player: p})
} }
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
@@ -320,7 +284,7 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
} }
} }
func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -340,61 +304,11 @@ func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.Han
} }
// sendGameComponents patches all game-related SSE components. // 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() g := gi.GetGame()
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck 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.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //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
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
} }

View File

@@ -1,6 +1,8 @@
package pages package pages
import ( import (
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/components"
sharedcomponents "github.com/ryanhamamura/c4/features/common/components" sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts" "github.com/ryanhamamura/c4/features/common/layouts"
@@ -8,7 +10,7 @@ import (
"github.com/starfederation/datastar-go/datastar" "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") { @layouts.Base("Connect 4") {
<main <main
class="flex flex-col items-center gap-4 p-4" 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) @components.StatusBanner(g, myColor)
<div class="c4-game-area"> <div class="c4-game-area">
@components.Board(g, myColor) @components.Board(g, myColor)
@components.Chat(messages, g.ID) @chatcomponents.Chat(messages, chatCfg)
</div> </div>
if g.Status == game.StatusWaitingForPlayer { if g.Status == game.StatusWaitingForPlayer {
@components.InviteLink(g.ID) @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,36 +1,40 @@
package snakegame package snakegame
import ( import (
"encoding/json" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"sync"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar" "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/components"
"github.com/ryanhamamura/c4/features/snakegame/pages" "github.com/ryanhamamura/c4/features/snakegame/pages"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/sessions"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/c4/snake"
) )
func getPlayerID(sessions *scs.SessionManager, r *http.Request) snake.PlayerID { func snakeChatColor(slot int) string {
pid := sessions.GetString(r.Context(), "player_id") if slot >= 0 && slot < len(snake.SnakeColors) {
if pid == "" { return snake.SnakeColors[slot]
pid = game.GenerateID(8)
sessions.Put(r.Context(), "player_id", pid)
} }
userID := sessions.GetString(r.Context(), "user_id") return "#666"
if userID != "" {
return snake.PlayerID(userID)
}
return snake.PlayerID(pid)
} }
func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { 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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -39,26 +43,25 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
nickname := sessions.GetString(r.Context(), "nickname") nickname := sessions.GetNickname(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
// Auto-join if nickname exists and not already in game // Auto-join if nickname exists and not already in game
if nickname != "" && si.GetPlayerSlot(playerID) < 0 { if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{ p := &snake.Player{
ID: playerID, ID: playerID,
Nickname: nickname, Nickname: nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
si.Join(player) si.Join(p)
} }
mySlot := si.GetPlayerSlot(playerID) mySlot := si.GetPlayerSlot(playerID)
if mySlot < 0 { if mySlot < 0 {
// Not in game yet
isGuest := r.URL.Query().Get("guest") == "1" isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest { if userID == "" && !isGuest {
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
@@ -73,13 +76,13 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
} }
sg := si.GetGame() 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) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
} }
} }
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -88,20 +91,22 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
mySlot := si.GetPlayerSlot(playerID) mySlot := si.GetPlayerSlot(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression( sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)), datastar.WithBrotli(datastar.WithBrotliLevel(5)),
)) ))
chatCfg := snakeChatConfig(gameID)
// Send initial render // Send initial render
sg := si.GetGame() sg := si.GetGame()
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
if sg.Mode == snake.ModeMultiplayer { 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 { if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
} }
@@ -118,12 +123,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
// Chat subscription (multiplayer only) // Chat subscription (multiplayer only)
var chatCh chan *nats.Msg var chatCh chan *nats.Msg
var chatSub *nats.Subscription var chatSub *nats.Subscription
var chatMessages []components.ChatMessage var room *chat.Room
var chatMu sync.Mutex
if sg.Mode == snake.ModeMultiplayer { if sg.Mode == snake.ModeMultiplayer {
chatCh = make(chan *nats.Msg, 64) room = chat.NewRoom(nc, "snake.chat."+gameID, nil)
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh) chatCh, chatSub, err = room.Subscribe()
if err != nil { if err != nil {
return return
} }
@@ -166,20 +170,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
if msg == nil { if msg == nil {
continue continue
} }
var cm components.ChatMessage _, snapshot := room.Receive(msg.Data)
if err := json.Unmarshal(msg.Data, &cm); err != nil { if snapshot == nil {
continue continue
} }
chatMu.Lock() if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
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 {
return return
} }
} }
@@ -187,7 +182,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
} }
} }
func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleSetDirection(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -196,7 +191,7 @@ func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManag
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
slot := si.GetPlayerSlot(playerID) slot := si.GetPlayerSlot(playerID)
if slot < 0 { if slot < 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -219,7 +214,7 @@ type chatSignals struct {
ChatMsg string `json:"chatMsg"` ChatMsg string `json:"chatMsg"`
} }
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -238,7 +233,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
slot := si.GetPlayerSlot(playerID) slot := si.GetPlayerSlot(playerID)
if slot < 0 { if slot < 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -246,16 +241,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
} }
sg := si.GetGame() sg := si.GetGame()
cm := components.ChatMessage{ msg := chat.Message{
Nickname: sg.Players[slot].Nickname, Nickname: sg.Players[slot].Nickname,
Slot: slot, Slot: slot,
Message: signals.ChatMsg, Message: signals.ChatMsg,
} }
data, err := json.Marshal(cm)
if err != nil { room := chat.NewRoom(nc, "snake.chat."+gameID, nil)
return room.Send(msg)
}
nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
@@ -266,7 +259,7 @@ type nicknameSignals struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
} }
func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -285,20 +278,20 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
return return
} }
sessions.Put(r.Context(), "nickname", signals.Nickname) sm.Put(r.Context(), "nickname", signals.Nickname)
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
if si.GetPlayerSlot(playerID) < 0 { if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{ p := &snake.Player{
ID: playerID, ID: playerID,
Nickname: signals.Nickname, Nickname: signals.Nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
si.Join(player) si.Join(p)
} }
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
@@ -306,7 +299,7 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
} }
} }
func HandleRematch(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleRematch(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)

View File

@@ -3,6 +3,8 @@ package pages
import ( import (
"fmt" "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/components"
"github.com/ryanhamamura/c4/features/common/layouts" "github.com/ryanhamamura/c4/features/common/layouts"
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components" 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") { @layouts.Base("Snake") {
<main <main
class="snake-wrapper flex flex-col items-center gap-4 p-4" 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 { if sg.Mode == snake.ModeMultiplayer {
<div class="snake-game-area"> <div class="snake-game-area">
@snakecomponents.Board(sg) @snakecomponents.Board(sg)
@snakecomponents.Chat(messages, gameID) @chatcomponents.Chat(messages, chatCfg)
</div> </div>
} else { } else {
@snakecomponents.Board(sg) @snakecomponents.Board(sg)
} }
} else if sg.Mode == snake.ModeMultiplayer { } 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) { if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
@snakecomponents.InviteLink(gameID) @snakecomponents.InviteLink(gameID)

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/player"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -109,19 +110,19 @@ func gameFromRow(row *repository.Game) (*Game, error) {
func playersFromRows(rows []*repository.GamePlayer) []*Player { func playersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows)) players := make([]*Player, 0, len(rows))
for _, row := range rows { for _, row := range rows {
player := &Player{ p := &Player{
Nickname: row.Nickname, Nickname: row.Nickname,
Color: int(row.Color), Color: int(row.Color),
} }
if row.UserID != nil { if row.UserID != nil {
player.UserID = row.UserID p.UserID = row.UserID
player.ID = PlayerID(*row.UserID) p.ID = player.ID(*row.UserID)
} else if row.GuestPlayerID != nil { } else if row.GuestPlayerID != nil {
player.ID = PlayerID(*row.GuestPlayerID) p.ID = player.ID(*row.GuestPlayerID)
} }
players = append(players, player) players = append(players, p)
} }
return players return players
} }

View File

@@ -2,11 +2,10 @@ package game
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"sync" "sync"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/player"
) )
type PlayerSession struct { type PlayerSession struct {
@@ -40,7 +39,7 @@ func (gs *GameStore) makeNotify(gameID string) func() {
} }
func (gs *GameStore) Create() *GameInstance { func (gs *GameStore) Create() *GameInstance {
id := GenerateID(4) id := player.GenerateID(4)
gi := NewGameInstance(id) gi := NewGameInstance(id)
gi.queries = gs.queries gi.queries = gs.queries
gi.notify = gs.makeNotify(id) gi.notify = gs.makeNotify(id)
@@ -107,12 +106,6 @@ func (gs *GameStore) Delete(id string) error {
return nil return nil
} }
func GenerateID(size int) string {
b := make([]byte, size)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
type GameInstance struct { type GameInstance struct {
game *Game game *Game
gameMu sync.RWMutex gameMu sync.RWMutex
@@ -166,7 +159,7 @@ func (gi *GameInstance) GetGame() *Game {
return gi.game return gi.game
} }
func (gi *GameInstance) GetPlayerColor(pid PlayerID) int { func (gi *GameInstance) GetPlayerColor(pid player.ID) int {
gi.gameMu.RLock() gi.gameMu.RLock()
defer gi.gameMu.RUnlock() defer gi.gameMu.RUnlock()
for _, p := range gi.game.Players { for _, p := range gi.game.Players {

View File

@@ -1,11 +1,13 @@
package game package game
import "encoding/json" import (
"encoding/json"
type PlayerID string "github.com/ryanhamamura/c4/player"
)
type Player struct { type Player struct {
ID PlayerID ID player.ID
UserID *string // UUID for authenticated users, nil for guests UserID *string // UUID for authenticated users, nil for guests
Nickname string Nickname string
Color int // 1 = Red, 2 = Yellow Color int // 1 = Red, 2 = Yellow
@@ -67,11 +69,3 @@ func (g *Game) WinningCellsFromJSON(data string) error {
} }
return json.Unmarshal([]byte(data), &g.WinningCells) 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"`
}

18
player/player.go Normal file
View File

@@ -0,0 +1,18 @@
// Package player provides shared identity types used across game packages.
package player
import (
"crypto/rand"
"encoding/hex"
)
// ID uniquely identifies a player within a session. For authenticated users
// this is their user UUID; for guests it's a random hex string.
type ID string
// GenerateID returns a random hex string of 2*size characters.
func GenerateID(size int) string {
b := make([]byte, size)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -1,4 +1,5 @@
// Package sessions configures the SCS session manager backed by SQLite. // Package sessions configures the SCS session manager and provides
// helpers for resolving player identity from the session.
package sessions package sessions
import ( import (
@@ -7,6 +8,8 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/ryanhamamura/c4/player"
"github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
) )
@@ -30,3 +33,28 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
return sessionManager, cleanup return sessionManager, cleanup
} }
// GetPlayerID returns the current player's identity from the session.
// Authenticated users get their user UUID; guests get a random ID that
// is generated and persisted on first access.
func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
pid := sm.GetString(r.Context(), "player_id")
if pid == "" {
pid = player.GenerateID(8)
sm.Put(r.Context(), "player_id", pid)
}
if userID := sm.GetString(r.Context(), "user_id"); userID != "" {
return player.ID(userID)
}
return player.ID(pid)
}
// GetUserID returns the authenticated user's UUID, or empty string for guests.
func GetUserID(sm *scs.SessionManager, r *http.Request) string {
return sm.GetString(r.Context(), "user_id")
}
// GetNickname returns the player's display name from the session.
func GetNickname(sm *scs.SessionManager, r *http.Request) string {
return sm.GetString(r.Context(), "nickname")
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/player"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -122,19 +123,19 @@ func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) {
func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player { func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows)) players := make([]*Player, 0, len(rows))
for _, row := range rows { for _, row := range rows {
player := &Player{ p := &Player{
Nickname: row.Nickname, Nickname: row.Nickname,
Slot: int(row.Slot), Slot: int(row.Slot),
} }
if row.UserID != nil { if row.UserID != nil {
player.UserID = row.UserID p.UserID = row.UserID
player.ID = PlayerID(*row.UserID) p.ID = player.ID(*row.UserID)
} else if row.GuestPlayerID != nil { } else if row.GuestPlayerID != nil {
player.ID = PlayerID(*row.GuestPlayerID) p.ID = player.ID(*row.GuestPlayerID)
} }
players = append(players, player) players = append(players, p)
} }
return players return players
} }

View File

@@ -5,7 +5,7 @@ import (
"sync" "sync"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/player"
) )
type SnakeStore struct { type SnakeStore struct {
@@ -38,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
if speed <= 0 { if speed <= 0 {
speed = DefaultSpeed speed = DefaultSpeed
} }
id := game.GenerateID(4) id := player.GenerateID(4)
sg := &SnakeGame{ sg := &SnakeGame{
ID: id, ID: id,
State: &GameState{ State: &GameState{
@@ -172,7 +172,7 @@ func (si *SnakeGameInstance) GetGame() *SnakeGame {
return si.game.snapshot() return si.game.snapshot()
} }
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int { func (si *SnakeGameInstance) GetPlayerSlot(pid player.ID) int {
si.gameMu.RLock() si.gameMu.RLock()
defer si.gameMu.RUnlock() defer si.gameMu.RUnlock()
for i, p := range si.game.Players { for i, p := range si.game.Players {

View File

@@ -3,6 +3,8 @@ package snake
import ( import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/ryanhamamura/c4/player"
) )
type Direction int type Direction int
@@ -78,10 +80,8 @@ const (
StatusFinished StatusFinished
) )
type PlayerID string
type Player struct { type Player struct {
ID PlayerID ID player.ID
UserID *string UserID *string
Nickname string Nickname string
Slot int // 0-7 Slot int // 0-7