Compare commits
3 Commits
f47eb4cdf3
...
10de5d21ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10de5d21ad | ||
|
|
7eadfbbb0c | ||
|
|
063b03ce25 |
92
chat/chat.go
Normal file
92
chat/chat.go
Normal 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
|
||||||
|
}
|
||||||
74
chat/components/chat.templ
Normal file
74
chat/components/chat.templ
Normal 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
45
chat/persist.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 }:
|
|
||||||
</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"
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
18
player/player.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user