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.
325 lines
8.3 KiB
Go
325 lines
8.3 KiB
Go
package snakegame
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/starfederation/datastar-go/datastar"
|
|
|
|
"github.com/ryanhamamura/c4/features/snakegame/components"
|
|
"github.com/ryanhamamura/c4/features/snakegame/pages"
|
|
"github.com/ryanhamamura/c4/player"
|
|
"github.com/ryanhamamura/c4/snake"
|
|
)
|
|
|
|
func getPlayerID(sessions *scs.SessionManager, r *http.Request) player.ID {
|
|
pid := sessions.GetString(r.Context(), "player_id")
|
|
if pid == "" {
|
|
pid = player.GenerateID(8)
|
|
sessions.Put(r.Context(), "player_id", pid)
|
|
}
|
|
userID := sessions.GetString(r.Context(), "user_id")
|
|
if userID != "" {
|
|
return player.ID(userID)
|
|
}
|
|
return player.ID(pid)
|
|
}
|
|
|
|
func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
playerID := getPlayerID(sessions, r)
|
|
nickname := sessions.GetString(r.Context(), "nickname")
|
|
userID := sessions.GetString(r.Context(), "user_id")
|
|
|
|
// Auto-join if nickname exists and not already in game
|
|
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
|
player := &snake.Player{
|
|
ID: playerID,
|
|
Nickname: nickname,
|
|
}
|
|
if userID != "" {
|
|
player.UserID = &userID
|
|
}
|
|
si.Join(player)
|
|
}
|
|
|
|
mySlot := si.GetPlayerSlot(playerID)
|
|
|
|
if mySlot < 0 {
|
|
// Not in game yet
|
|
isGuest := r.URL.Query().Get("guest") == "1"
|
|
if userID == "" && !isGuest {
|
|
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
sg := si.GetGame()
|
|
if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
playerID := getPlayerID(sessions, r)
|
|
mySlot := si.GetPlayerSlot(playerID)
|
|
|
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
|
))
|
|
|
|
// Send initial render
|
|
sg := si.GetGame()
|
|
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
|
|
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
|
|
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
|
|
if sg.Mode == snake.ModeMultiplayer {
|
|
sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck
|
|
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
|
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
// Subscribe to game updates via NATS
|
|
gameCh := make(chan *nats.Msg, 64)
|
|
gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer gameSub.Unsubscribe() //nolint:errcheck
|
|
|
|
// Chat subscription (multiplayer only)
|
|
var chatCh chan *nats.Msg
|
|
var chatSub *nats.Subscription
|
|
var chatMessages []components.ChatMessage
|
|
var chatMu sync.Mutex
|
|
|
|
if sg.Mode == snake.ModeMultiplayer {
|
|
chatCh = make(chan *nats.Msg, 64)
|
|
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer chatSub.Unsubscribe() //nolint:errcheck
|
|
}
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case <-gameCh:
|
|
// Drain backed-up game updates
|
|
for {
|
|
select {
|
|
case <-gameCh:
|
|
default:
|
|
goto drained
|
|
}
|
|
}
|
|
drained:
|
|
si, ok = snakeStore.Get(gameID)
|
|
if !ok {
|
|
return
|
|
}
|
|
mySlot = si.GetPlayerSlot(playerID)
|
|
sg = si.GetGame()
|
|
if err := sse.PatchElementTempl(components.Board(sg)); err != nil {
|
|
return
|
|
}
|
|
if err := sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)); err != nil {
|
|
return
|
|
}
|
|
if err := sse.PatchElementTempl(components.PlayerList(sg, mySlot)); err != nil {
|
|
return
|
|
}
|
|
|
|
case msg := <-chatCh:
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
var cm components.ChatMessage
|
|
if err := json.Unmarshal(msg.Data, &cm); err != nil {
|
|
continue
|
|
}
|
|
chatMu.Lock()
|
|
chatMessages = append(chatMessages, cm)
|
|
if len(chatMessages) > 50 {
|
|
chatMessages = chatMessages[len(chatMessages)-50:]
|
|
}
|
|
msgs := make([]components.ChatMessage, len(chatMessages))
|
|
copy(msgs, chatMessages)
|
|
chatMu.Unlock()
|
|
|
|
if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
playerID := getPlayerID(sessions, r)
|
|
slot := si.GetPlayerSlot(playerID)
|
|
if slot < 0 {
|
|
http.Error(w, "not in game", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
dStr := r.URL.Query().Get("d")
|
|
d, err := strconv.Atoi(dStr)
|
|
if err != nil || d < 0 || d > 3 {
|
|
http.Error(w, "invalid direction", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
si.SetDirection(slot, snake.Direction(d))
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}
|
|
|
|
type chatSignals struct {
|
|
ChatMsg string `json:"chatMsg"`
|
|
}
|
|
|
|
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var signals chatSignals
|
|
if err := datastar.ReadSignals(r, &signals); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if signals.ChatMsg == "" {
|
|
return
|
|
}
|
|
|
|
playerID := getPlayerID(sessions, r)
|
|
slot := si.GetPlayerSlot(playerID)
|
|
if slot < 0 {
|
|
http.Error(w, "not in game", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
sg := si.GetGame()
|
|
cm := components.ChatMessage{
|
|
Nickname: sg.Players[slot].Nickname,
|
|
Slot: slot,
|
|
Message: signals.ChatMsg,
|
|
}
|
|
data, err := json.Marshal(cm)
|
|
if err != nil {
|
|
return
|
|
}
|
|
nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
|
|
|
|
sse := datastar.NewSSE(w, r)
|
|
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
type nicknameSignals struct {
|
|
Nickname string `json:"nickname"`
|
|
}
|
|
|
|
func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var signals nicknameSignals
|
|
if err := datastar.ReadSignals(r, &signals); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if signals.Nickname == "" {
|
|
return
|
|
}
|
|
|
|
sessions.Put(r.Context(), "nickname", signals.Nickname)
|
|
|
|
playerID := getPlayerID(sessions, r)
|
|
userID := sessions.GetString(r.Context(), "user_id")
|
|
|
|
if si.GetPlayerSlot(playerID) < 0 {
|
|
player := &snake.Player{
|
|
ID: playerID,
|
|
Nickname: signals.Nickname,
|
|
}
|
|
if userID != "" {
|
|
player.UserID = &userID
|
|
}
|
|
si.Join(player)
|
|
}
|
|
|
|
sse := datastar.NewSSE(w, r)
|
|
sse.Redirect("/snake/" + gameID) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
func HandleRematch(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
gameID := chi.URLParam(r, "id")
|
|
si, ok := snakeStore.Get(gameID)
|
|
if !ok {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
newSI := si.CreateRematch()
|
|
sse := datastar.NewSSE(w, r)
|
|
if newSI != nil {
|
|
sse.Redirect("/snake/" + newSI.ID()) //nolint:errcheck
|
|
}
|
|
}
|
|
}
|