diff --git a/.claude/commands/release.md b/.claude/commands/release.md deleted file mode 100644 index df59678..0000000 --- a/.claude/commands/release.md +++ /dev/null @@ -1,45 +0,0 @@ -Create a new Gitea release for this project using semantic versioning. - -## Current state - -Fetch tags and find the latest version: - -``` -!git fetch --tags && git tag --sort=-v:refname | head -5 -``` - -Commits since the last release (if no tags exist, this shows all commits): - -``` -!git log $(git describe --tags --abbrev=0 2>/dev/null && echo "$(git describe --tags --abbrev=0)..HEAD" || echo "") --oneline -``` - -## Instructions - -1. **Determine current version** from the tag output above. If no `vX.Y.Z` tags exist, treat current version as `v0.0.0`. - -2. **Analyze commits** using conventional commit prefixes to pick the semver bump: - - Breaking changes (`!` after type, or `BREAKING CHANGE` in body) → **major** bump - - `feat:` → **minor** bump - - `fix:`, `chore:`, `deps:`, `revert:`, and everything else → **patch** bump - - Use the **highest** applicable bump level across all commits - -3. **Generate release notes** — group commits into sections: - - **Features** — `feat:` commits - - **Fixes** — `fix:` commits - - **Other** — everything else (`chore:`, `deps:`, `revert:`, etc.) - - Omit empty sections. Each commit is a bullet point with its short description (strip the prefix). - -4. **Present for approval** — show the user: - - Current version → proposed new version - - The full release notes - - The exact `tea` command that will run - - Ask the user to confirm before proceeding - -5. **Create the release** — on user approval, run: - ``` - tea releases create --login gitea --repo ryan/c4 --tag --target main -t "" -n "" - ``` - Do NOT create a local git tag — Gitea creates it server-side. - -6. **Verify** — run `tea releases ls --login gitea --repo ryan/c4` to confirm the release was created. diff --git a/.dockerignore b/.dockerignore index 8362dd4..5471e11 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,10 @@ -c4 -c4.db +games +games.db data/ deploy/ .env .git .gitignore assets/css/output.css -c4-deploy-*.tar.gz -c4-deploy-*_b64*.txt +games-deploy-*.tar.gz +games-deploy-*_b64*.txt diff --git a/.env.example b/.env.example index 7ae4ff9..c317103 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO. # LOG_LEVEL=DEBUG -# SQLite database path. Defaults to data/c4.db. -# DB_PATH=data/c4.db +# SQLite database path. Defaults to data/games.db. +# DB_PATH=data/games.db # Application URL for invite links. Defaults to https://games.adriatica.io. # APP_URL=http://localhost:7331 @@ -12,5 +12,5 @@ # Goose CLI migration config (only needed for running goose manually) GOOSE_DRIVER=sqlite3 -GOOSE_DBSTRING=data/c4.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL) +GOOSE_DBSTRING=data/games.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL) GOOSE_MIGRATION_DIR=db/migrations diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 28baa5a..9aa9673 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: pull_request: env: - DEPLOY_DIR: /home/ryan/c4 + DEPLOY_DIR: /home/ryan/games jobs: test: diff --git a/.golangci.yml b/.golangci.yml index 961c8c8..59bc7bc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -35,7 +35,7 @@ formatters: settings: goimports: local-prefixes: - - github.com/ryanhamamura/c4 + - github.com/ryanhamamura/games issues: exclude-rules: diff --git a/Dockerfile b/Dockerfile index a22afc9..e0b0d87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,10 @@ RUN go mod download COPY . . RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify RUN --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 go build -ldflags="-s" -o /bin/c4 . -RUN upx -9 -k /bin/c4 + CGO_ENABLED=0 go build -ldflags="-s" -o /bin/games . +RUN upx -9 -k /bin/games FROM scratch ENV PORT=8080 -COPY --from=build /bin/c4 / -ENTRYPOINT ["/c4"] +COPY --from=build /bin/games / +ENTRYPOINT ["/games"] diff --git a/Taskfile.yml b/Taskfile.yml index e0df8ef..fcb0a9d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -27,9 +27,9 @@ tasks: - "assets/css/output.css" build: - desc: Production build to bin/c4 + desc: Production build to bin/games cmds: - - go build -o bin/c4 . + - go build -o bin/games . deps: - build:templ - build:styles @@ -49,8 +49,8 @@ tasks: cmds: - | go tool air \ - -build.cmd "go build -tags=dev -o tmp/bin/c4 ." \ - -build.bin "tmp/bin/c4" \ + -build.cmd "go build -tags=dev -o tmp/bin/games ." \ + -build.bin "tmp/bin/games" \ -build.exclude_dir "data,bin,tmp,deploy" \ -build.include_ext "go,templ" \ -misc.clean_on_exit "true" @@ -75,7 +75,7 @@ tasks: run: desc: Build and run the server cmds: - - ./bin/c4 + - ./bin/games deps: - build diff --git a/chat/chat.go b/chat/chat.go new file mode 100644 index 0000000..c5b98be --- /dev/null +++ b/chat/chat.go @@ -0,0 +1,149 @@ +// Package chat provides a reusable chat room backed by NATS pub/sub +// with optional database persistence. +package chat + +import ( + "context" + "encoding/json" + "slices" + "sync" + + "github.com/nats-io/nats.go" + "github.com/rs/zerolog/log" + + "github.com/ryanhamamura/games/db/repository" +) + +// Message is the wire format for chat messages over NATS. +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). When created with NewPersistentRoom, +// messages are automatically loaded from and saved to the database. +type Room struct { + subject string + nc *nats.Conn + messages []Message + mu sync.Mutex + + // Optional persistence; nil for ephemeral rooms (e.g. snake). + queries *repository.Queries + roomID string +} + +// NewRoom creates an ephemeral chat room with no database persistence. +func NewRoom(nc *nats.Conn, subject string) *Room { + return &Room{ + subject: subject, + nc: nc, + } +} + +// NewPersistentRoom creates a chat room backed by the database. It loads +// existing messages on creation and auto-saves new messages on Send. +func NewPersistentRoom(nc *nats.Conn, subject string, queries *repository.Queries, roomID string) *Room { + r := &Room{ + subject: subject, + nc: nc, + queries: queries, + roomID: roomID, + } + r.messages = r.loadMessages() + return r +} + +// Send publishes a message to the room's NATS subject and persists it +// if the room is backed by a database. +func (r *Room) Send(msg Message) { + if r.queries != nil { + r.saveMessage(msg) + } + + data, err := json.Marshal(msg) + if err != nil { + log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message") + 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 +} + +func (r *Room) saveMessage(msg Message) { + err := r.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ + GameID: r.roomID, + Nickname: msg.Nickname, + Color: int64(msg.Slot), + Message: msg.Message, + CreatedAt: msg.Time, + }) + if err != nil { + log.Error().Err(err).Str("room_id", r.roomID).Msg("failed to save chat message") + } +} + +func (r *Room) loadMessages() []Message { + rows, err := r.queries.GetChatMessages(context.Background(), r.roomID) + if err != nil { + return nil + } + msgs := make([]Message, len(rows)) + for i, row := range rows { + msgs[i] = Message{ + Nickname: row.Nickname, + Slot: int(row.Color), + Message: row.Message, + Time: row.CreatedAt, + } + } + // DB returns newest-first; reverse for chronological display + slices.Reverse(msgs) + return msgs +} diff --git a/chat/components/chat.templ b/chat/components/chat.templ new file mode 100644 index 0000000..ec10136 --- /dev/null +++ b/chat/components/chat.templ @@ -0,0 +1,74 @@ +package components + +import ( + "fmt" + + "github.com/ryanhamamura/games/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) { +
+
+ for _, m := range messages { +
+ + { m.Nickname + ": " } + + { m.Message } +
+ } +
+
+ if cfg.StopKeyPropagation { + + } else { + + } + +
+ @chatAutoScroll(cfg.CSSPrefix) +
+} + +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}); +} diff --git a/config/config.go b/config/config.go index 31774f2..330c660 100644 --- a/config/config.go +++ b/config/config.go @@ -71,6 +71,6 @@ func loadBase() *Config { } }(), AppURL: getEnv("APP_URL", "https://games.adriatica.io"), - DBPath: getEnv("DB_PATH", "data/c4.db"), + DBPath: getEnv("DB_PATH", "data/games.db"), } } diff --git a/game/logic.go b/connect4/logic.go similarity index 95% rename from game/logic.go rename to connect4/logic.go index 7a4d167..8abbb74 100644 --- a/game/logic.go +++ b/connect4/logic.go @@ -1,5 +1,5 @@ -// Package game implements Connect 4 game logic, state management, and persistence. -package game +// Package connect4 implements Connect 4 game logic, state management, and persistence. +package connect4 // DropPiece attempts to drop a piece in the given column. // Returns (row placed, success). diff --git a/game/persist.go b/connect4/persist.go similarity index 78% rename from game/persist.go rename to connect4/persist.go index 0b615b7..5e311ad 100644 --- a/game/persist.go +++ b/connect4/persist.go @@ -1,14 +1,15 @@ -package game +package connect4 import ( "context" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/player" "github.com/rs/zerolog/log" ) -func (gi *GameInstance) save() error { +func (gi *Instance) save() error { err := saveGame(gi.queries, gi.game) if err != nil { log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game") @@ -16,8 +17,8 @@ func (gi *GameInstance) save() error { return err } -func (gi *GameInstance) savePlayer(player *Player, slot int) error { - err := saveGamePlayer(gi.queries, gi.game.ID, player, slot) +func (gi *Instance) savePlayer(p *Player, slot int) error { + err := saveGamePlayer(gi.queries, gi.game.ID, p, slot) if err != nil { log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player") } @@ -47,12 +48,12 @@ func saveGame(queries *repository.Queries, g *Game) error { }) } -func saveGamePlayer(queries *repository.Queries, gameID string, player *Player, slot int) error { +func saveGamePlayer(queries *repository.Queries, gameID string, p *Player, slot int) error { var userID, guestPlayerID *string - if player.UserID != nil { - userID = player.UserID + if p.UserID != nil { + userID = p.UserID } else { - id := string(player.ID) + id := string(p.ID) guestPlayerID = &id } @@ -60,8 +61,8 @@ func saveGamePlayer(queries *repository.Queries, gameID string, player *Player, GameID: gameID, UserID: userID, GuestPlayerID: guestPlayerID, - Nickname: player.Nickname, - Color: int64(player.Color), + Nickname: p.Nickname, + Color: int64(p.Color), Slot: int64(slot), }) } @@ -82,13 +83,11 @@ func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error) return playersFromRows(rows), nil } -// Domain ↔ DB mapping helpers. - func gameFromRow(row *repository.Game) (*Game, error) { g := &Game{ ID: row.ID, CurrentTurn: int(row.CurrentTurn), - Status: GameStatus(row.Status), + Status: Status(row.Status), } if err := g.BoardFromJSON(row.Board); err != nil { @@ -109,19 +108,19 @@ func gameFromRow(row *repository.Game) (*Game, error) { func playersFromRows(rows []*repository.GamePlayer) []*Player { players := make([]*Player, 0, len(rows)) for _, row := range rows { - player := &Player{ + p := &Player{ Nickname: row.Nickname, Color: int(row.Color), } if row.UserID != nil { - player.UserID = row.UserID - player.ID = PlayerID(*row.UserID) + p.UserID = row.UserID + p.ID = player.ID(*row.UserID) } 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 } diff --git a/game/store.go b/connect4/store.go similarity index 54% rename from game/store.go rename to connect4/store.go index d919441..2f1bba6 100644 --- a/game/store.go +++ b/connect4/store.go @@ -1,79 +1,78 @@ -package game +package connect4 import ( "context" - "crypto/rand" - "encoding/hex" "sync" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/player" ) type PlayerSession struct { Player *Player } -type GameStore struct { - games map[string]*GameInstance +type Store struct { + games map[string]*Instance gamesMu sync.RWMutex queries *repository.Queries notifyFunc func(gameID string) } -func NewGameStore(queries *repository.Queries) *GameStore { - return &GameStore{ - games: make(map[string]*GameInstance), +func NewStore(queries *repository.Queries) *Store { + return &Store{ + games: make(map[string]*Instance), queries: queries, } } -func (gs *GameStore) SetNotifyFunc(f func(gameID string)) { - gs.notifyFunc = f +func (s *Store) SetNotifyFunc(f func(gameID string)) { + s.notifyFunc = f } -func (gs *GameStore) makeNotify(gameID string) func() { +func (s *Store) makeNotify(gameID string) func() { return func() { - if gs.notifyFunc != nil { - gs.notifyFunc(gameID) + if s.notifyFunc != nil { + s.notifyFunc(gameID) } } } -func (gs *GameStore) Create() *GameInstance { - id := GenerateID(4) - gi := NewGameInstance(id) - gi.queries = gs.queries - gi.notify = gs.makeNotify(id) - gs.gamesMu.Lock() - gs.games[id] = gi - gs.gamesMu.Unlock() +func (s *Store) Create() *Instance { + id := player.GenerateID(4) + gi := NewInstance(id) + gi.queries = s.queries + gi.notify = s.makeNotify(id) + s.gamesMu.Lock() + s.games[id] = gi + s.gamesMu.Unlock() - if gs.queries != nil { + if s.queries != nil { gi.save() //nolint:errcheck } return gi } -func (gs *GameStore) Get(id string) (*GameInstance, bool) { - gs.gamesMu.RLock() - gi, ok := gs.games[id] - gs.gamesMu.RUnlock() +func (s *Store) Get(id string) (*Instance, bool) { + s.gamesMu.RLock() + gi, ok := s.games[id] + s.gamesMu.RUnlock() if ok { return gi, true } - if gs.queries == nil { + if s.queries == nil { return nil, false } - g, err := loadGame(gs.queries, id) + g, err := loadGame(s.queries, id) if err != nil || g == nil { return nil, false } - players, _ := loadGamePlayers(gs.queries, id) + players, _ := loadGamePlayers(s.queries, id) for _, p := range players { switch p.Color { case 1: @@ -83,57 +82,51 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) { } } - gi = &GameInstance{ + gi = &Instance{ game: g, - queries: gs.queries, - notify: gs.makeNotify(id), + queries: s.queries, + notify: s.makeNotify(id), } - gs.gamesMu.Lock() - gs.games[id] = gi - gs.gamesMu.Unlock() + s.gamesMu.Lock() + s.games[id] = gi + s.gamesMu.Unlock() return gi, true } -func (gs *GameStore) Delete(id string) error { - gs.gamesMu.Lock() - delete(gs.games, id) - gs.gamesMu.Unlock() +func (s *Store) Delete(id string) error { + s.gamesMu.Lock() + delete(s.games, id) + s.gamesMu.Unlock() - if gs.queries != nil { - return gs.queries.DeleteGame(context.Background(), id) + if s.queries != nil { + return s.queries.DeleteGame(context.Background(), id) } return nil } -func GenerateID(size int) string { - b := make([]byte, size) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -type GameInstance struct { +type Instance struct { game *Game gameMu sync.RWMutex notify func() queries *repository.Queries } -func NewGameInstance(id string) *GameInstance { - return &GameInstance{ +func NewInstance(id string) *Instance { + return &Instance{ game: NewGame(id), notify: func() {}, } } -func (gi *GameInstance) ID() string { +func (gi *Instance) ID() string { gi.gameMu.RLock() defer gi.gameMu.RUnlock() return gi.game.ID } -func (gi *GameInstance) Join(ps *PlayerSession) bool { +func (gi *Instance) Join(ps *PlayerSession) bool { gi.gameMu.Lock() defer gi.gameMu.Unlock() @@ -160,13 +153,13 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool { return true } -func (gi *GameInstance) GetGame() *Game { +func (gi *Instance) GetGame() *Game { gi.gameMu.RLock() defer gi.gameMu.RUnlock() return gi.game } -func (gi *GameInstance) GetPlayerColor(pid PlayerID) int { +func (gi *Instance) GetPlayerColor(pid player.ID) int { gi.gameMu.RLock() defer gi.gameMu.RUnlock() for _, p := range gi.game.Players { @@ -177,7 +170,7 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int { return 0 } -func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { +func (gi *Instance) CreateRematch(s *Store) *Instance { gi.gameMu.Lock() defer gi.gameMu.Unlock() @@ -185,13 +178,13 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { return nil } - newGI := gs.Create() + newGI := s.Create() newID := newGI.ID() gi.game.RematchGameID = &newID if gi.queries != nil { if err := gi.save(); err != nil { - gs.Delete(newID) //nolint:errcheck + s.Delete(newID) //nolint:errcheck gi.game.RematchGameID = nil return nil } @@ -201,7 +194,7 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { return newGI } -func (gi *GameInstance) DropPiece(col int, playerColor int) bool { +func (gi *Instance) DropPiece(col int, playerColor int) bool { gi.gameMu.Lock() defer gi.gameMu.Unlock() diff --git a/game/types.go b/connect4/types.go similarity index 70% rename from game/types.go rename to connect4/types.go index 71f0ae8..078947c 100644 --- a/game/types.go +++ b/connect4/types.go @@ -1,20 +1,31 @@ -package game +package connect4 -import "encoding/json" +import ( + "encoding/json" -type PlayerID string + "github.com/ryanhamamura/games/player" +) + +// SubjectPrefix is the NATS subject namespace for connect4 games. +const SubjectPrefix = "connect4" + +// GameSubject returns the NATS subject for game state updates. +func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID } + +// ChatSubject returns the NATS subject for chat messages. +func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID } type Player struct { - ID PlayerID + ID player.ID UserID *string // UUID for authenticated users, nil for guests Nickname string Color int // 1 = Red, 2 = Yellow } -type GameStatus int +type Status int const ( - StatusWaitingForPlayer GameStatus = iota + StatusWaitingForPlayer Status = iota StatusInProgress StatusWon StatusDraw @@ -25,7 +36,7 @@ type Game struct { Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow) CurrentTurn int // 1 or 2 (matches player color) - Status GameStatus + Status Status Winner *Player WinningCells [][2]int // Coordinates of winning 4 cells for highlighting RematchGameID *string // ID of the rematch game, if one was created @@ -67,11 +78,3 @@ func (g *Game) WinningCellsFromJSON(data string) error { } return json.Unmarshal([]byte(data), &g.WinningCells) } - -// ChatMessage is the domain type for persisted C4 chat messages. -type ChatMessage struct { - Nickname string `json:"nickname"` - Color int `json:"color"` // 1=Red, 2=Yellow - Message string `json:"message"` - Time int64 `json:"time"` -} diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 4834fc9..c1f96f5 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Deploy the c4 binary to /opt/c4, then restart the service. +# Deploy the games binary to /opt/games, then restart the service. # Works from the repo (builds first) or from an extracted tarball (pre-built binary). set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -INSTALL_DIR="/opt/c4" -BINARY="$ROOT_DIR/c4" +INSTALL_DIR="/opt/games" +BINARY="$ROOT_DIR/games" # If Go is available and we have source, build fresh if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then @@ -13,7 +13,7 @@ if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then (cd "$ROOT_DIR" && go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify) echo "Building binary..." - (cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o c4 .) + (cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o games .) fi if [[ ! -f "$BINARY" ]]; then @@ -22,10 +22,10 @@ if [[ ! -f "$BINARY" ]]; then fi echo "Installing to $INSTALL_DIR..." -install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/c4" +install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/games" echo "Restarting service..." -systemctl restart c4.service +systemctl restart games.service echo "Done. Status:" -systemctl status c4.service --no-pager +systemctl status games.service --no-pager diff --git a/deploy/c4.service b/deploy/games.service similarity index 70% rename from deploy/c4.service rename to deploy/games.service index f1aa7c9..1836bac 100644 --- a/deploy/c4.service +++ b/deploy/games.service @@ -1,13 +1,13 @@ [Unit] -Description=C4 Game Lobby +Description=Games Lobby After=network.target [Service] Type=simple User=games Group=games -WorkingDirectory=/opt/c4 -ExecStart=/opt/c4/c4 +WorkingDirectory=/opt/games +ExecStart=/opt/games/games Restart=on-failure RestartSec=5 @@ -17,7 +17,7 @@ Environment=PORT=8080 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/opt/c4 +ReadWritePaths=/opt/games PrivateTmp=true [Install] diff --git a/deploy/package.sh b/deploy/package.sh index add6a3e..d200f67 100755 --- a/deploy/package.sh +++ b/deploy/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build the c4 binary, bundle it with deploy files into a tarball, +# Build the games binary, bundle it with deploy files into a tarball, # base64-encode it, and split into 25MB chunks for transfer. set -euo pipefail @@ -7,14 +7,14 @@ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_DIR" TIMESTAMP=$(date +%Y%m%d-%H%M%S) -TARBALL="c4-deploy-${TIMESTAMP}.tar.gz" -BASE64_FILE="c4-deploy-${TIMESTAMP}_b64.txt" +TARBALL="games-deploy-${TIMESTAMP}.tar.gz" +BASE64_FILE="games-deploy-${TIMESTAMP}_b64.txt" #============================================================================== # Clean previous artifacts #============================================================================== echo "--- Cleaning old artifacts ---" -rm -f ./c4 c4-deploy-*.tar.gz c4-deploy-*_b64*.txt +rm -f ./games games-deploy-*.tar.gz games-deploy-*_b64*.txt #============================================================================== # Build @@ -23,18 +23,18 @@ echo "--- Building CSS ---" go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify echo "--- Building binary (linux/amd64) ---" -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o c4 . +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o games . #============================================================================== # Verify required files #============================================================================== echo "--- Verifying files ---" REQUIRED_FILES=( - c4 + games deploy/setup.sh deploy/deploy.sh deploy/reassemble.sh - deploy/c4.service + deploy/games.service ) for f in "${REQUIRED_FILES[@]}"; do if [[ ! -f "$f" ]]; then @@ -48,12 +48,12 @@ done # Create tarball #============================================================================== echo "--- Creating tarball ---" -tar -czf "/tmp/${TARBALL}" --transform 's,^,c4/,' \ - c4 \ +tar -czf "/tmp/${TARBALL}" --transform 's,^,games/,' \ + games \ deploy/setup.sh \ deploy/deploy.sh \ deploy/reassemble.sh \ - deploy/c4.service + deploy/games.service mv "/tmp/${TARBALL}" . echo " -> ${TARBALL} ($(du -h "${TARBALL}" | cut -f1))" @@ -66,10 +66,10 @@ base64 "${TARBALL}" > "${BASE64_FILE}" echo " -> ${BASE64_FILE} ($(du -h "${BASE64_FILE}" | cut -f1))" echo "--- Splitting into 25MB chunks ---" -split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "c4-deploy-${TIMESTAMP}_b64_part" +split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "games-deploy-${TIMESTAMP}_b64_part" rm -f "${BASE64_FILE}" -CHUNKS=(c4-deploy-${TIMESTAMP}_b64_part*.txt) +CHUNKS=(games-deploy-${TIMESTAMP}_b64_part*.txt) echo " -> ${#CHUNKS[@]} chunk(s):" for chunk in "${CHUNKS[@]}"; do echo " $chunk ($(du -h "$chunk" | cut -f1))" @@ -83,5 +83,5 @@ echo "=== Package Complete ===" echo "" echo "Transfer the chunk files to the target server, then run:" echo " ./reassemble.sh" -echo " cd ~/c4 && sudo ./deploy/setup.sh # first time only" -echo " cd ~/c4 && sudo ./deploy/deploy.sh" +echo " cd ~/games && sudo ./deploy/setup.sh # first time only" +echo " cd ~/games && sudo ./deploy/deploy.sh" diff --git a/deploy/reassemble.sh b/deploy/reassemble.sh index 73127ee..59278ed 100755 --- a/deploy/reassemble.sh +++ b/deploy/reassemble.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Reassembles base64 chunks and extracts the c4 deployment tarball. +# Reassembles base64 chunks and extracts the games deployment tarball. # Expects chunk files in the current directory. set -euo pipefail cd "$HOME" -echo "=== C4 Deployment Reassembler ===" +echo "=== Games Deployment Reassembler ===" echo "Working directory: $HOME" echo "" @@ -14,10 +14,10 @@ echo "" #============================================================================== echo "--- Finding chunk files ---" -CHUNKS=($(ls -1 c4-deploy-*_b64_part*.txt 2>/dev/null | sort)) +CHUNKS=($(ls -1 games-deploy-*_b64_part*.txt 2>/dev/null | sort)) if [[ ${#CHUNKS[@]} -eq 0 ]]; then - echo "ERROR: No chunk files found matching c4-deploy-*_b64_part*.txt" + echo "ERROR: No chunk files found matching games-deploy-*_b64_part*.txt" exit 1 fi @@ -32,8 +32,8 @@ done echo "" echo "--- Reassembling chunks ---" -TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/c4-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/') -TARBALL="c4-deploy-${TIMESTAMP}.tar.gz" +TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/games-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/') +TARBALL="games-deploy-${TIMESTAMP}.tar.gz" COMBINED="combined_b64.txt" echo "Concatenating chunks..." @@ -58,12 +58,12 @@ fi echo "" echo "--- Archiving existing source ---" -if [[ -d c4 ]]; then - rm -rf c4.bak - mv c4 c4.bak - echo " -> Moved c4 -> c4.bak" +if [[ -d games ]]; then + rm -rf games.bak + mv games games.bak + echo " -> Moved games -> games.bak" else - echo " -> No existing c4 directory" + echo " -> No existing games directory" fi #============================================================================== @@ -73,7 +73,7 @@ echo "" echo "--- Extracting tarball ---" tar -xzf "$TARBALL" -echo " -> Extracted to ~/c4" +echo " -> Extracted to ~/games" #============================================================================== # Cleanup @@ -91,6 +91,6 @@ echo "" echo "=== Reassembly Complete ===" echo "" echo "Next steps:" -echo " cd ~/c4" +echo " cd ~/games" echo " sudo ./deploy/setup.sh # first time only" echo " sudo ./deploy/deploy.sh" diff --git a/deploy/setup.sh b/deploy/setup.sh index 920dde2..917137f 100755 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -10,20 +10,20 @@ fi # Create system user if it doesn't exist if ! id -u games &>/dev/null; then - useradd --system --shell /usr/sbin/nologin --home-dir /opt/c4 --create-home games + useradd --system --shell /usr/sbin/nologin --home-dir /opt/games --create-home games echo "Created system user: games" else echo "User 'games' already exists" fi # Ensure install directory exists with correct ownership -install -d -o games -g games -m 755 /opt/c4 -install -d -o games -g games -m 755 /opt/c4/data +install -d -o games -g games -m 755 /opt/games +install -d -o games -g games -m 755 /opt/games/data # Install systemd unit SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cp "$SCRIPT_DIR/c4.service" /etc/systemd/system/c4.service +cp "$SCRIPT_DIR/games.service" /etc/systemd/system/games.service systemctl daemon-reload -systemctl enable c4.service +systemctl enable games.service echo "Setup complete. Run deploy.sh to build and start the service." diff --git a/docker-compose.yml b/docker-compose.yml index c22954e..79083e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: - c4: + games: build: . - container_name: c4 + container_name: games restart: unless-stopped ports: - "8080:8080" diff --git a/features/auth/handlers.go b/features/auth/handlers.go index dc34675..3ca7f6b 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -8,9 +8,10 @@ import ( "github.com/google/uuid" "github.com/starfederation/datastar-go/datastar" - "github.com/ryanhamamura/c4/auth" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/features/auth/pages" + "github.com/ryanhamamura/games/auth" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/features/auth/pages" + appsessions "github.com/ryanhamamura/games/sessions" ) type LoginSignals struct { @@ -65,9 +66,9 @@ func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http } sessions.RenewToken(r.Context()) //nolint:errcheck - sessions.Put(r.Context(), "user_id", user.ID) + sessions.Put(r.Context(), appsessions.KeyUserID, user.ID) sessions.Put(r.Context(), "username", user.Username) - sessions.Put(r.Context(), "nickname", user.Username) + sessions.Put(r.Context(), appsessions.KeyNickname, user.Username) redirectURL := "/" if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" { @@ -119,9 +120,9 @@ func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) h } sessions.RenewToken(r.Context()) //nolint:errcheck - sessions.Put(r.Context(), "user_id", user.ID) + sessions.Put(r.Context(), appsessions.KeyUserID, user.ID) sessions.Put(r.Context(), "username", user.Username) - sessions.Put(r.Context(), "nickname", user.Username) + sessions.Put(r.Context(), appsessions.KeyNickname, user.Username) redirectURL := "/" if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" { diff --git a/features/auth/pages/login.templ b/features/auth/pages/login.templ index 3a4bcae..8a93ff9 100644 --- a/features/auth/pages/login.templ +++ b/features/auth/pages/login.templ @@ -1,7 +1,7 @@ package pages import ( - "github.com/ryanhamamura/c4/features/common/layouts" + "github.com/ryanhamamura/games/features/common/layouts" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/auth/pages/register.templ b/features/auth/pages/register.templ index 00ef50a..92fd0c4 100644 --- a/features/auth/pages/register.templ +++ b/features/auth/pages/register.templ @@ -1,7 +1,7 @@ package pages import ( - "github.com/ryanhamamura/c4/features/common/layouts" + "github.com/ryanhamamura/games/features/common/layouts" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/auth/routes.go b/features/auth/routes.go index 16e5e85..bb39e44 100644 --- a/features/auth/routes.go +++ b/features/auth/routes.go @@ -5,7 +5,7 @@ import ( "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/db/repository" ) func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) { diff --git a/features/c4game/components/board.templ b/features/c4game/components/board.templ index ee4cb72..07bbd2a 100644 --- a/features/c4game/components/board.templ +++ b/features/c4game/components/board.templ @@ -3,11 +3,11 @@ package components import ( "fmt" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/games/connect4" "github.com/starfederation/datastar-go/datastar" ) -templ Board(g *game.Game, myColor int) { +templ Board(g *connect4.Game, myColor int) {
for col := 0; col < 7; col++ { @column(g, col, myColor) @@ -15,8 +15,8 @@ templ Board(g *game.Game, myColor int) {
} -templ column(g *game.Game, colIdx int, myColor int) { - if g.Status == game.StatusInProgress && myColor == g.CurrentTurn { +templ column(g *connect4.Game, colIdx int, myColor int) { + if g.Status == connect4.StatusInProgress && myColor == g.CurrentTurn {
} -func cellClass(g *game.Game, row, col int) string { +func cellClass(g *connect4.Game, row, col int) string { color := g.Board[row][col] activeTurn := 0 - if g.Status == game.StatusInProgress { + if g.Status == connect4.StatusInProgress { activeTurn = g.CurrentTurn } diff --git a/features/c4game/components/chat.templ b/features/c4game/components/chat.templ deleted file mode 100644 index c1e6c07..0000000 --- a/features/c4game/components/chat.templ +++ /dev/null @@ -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) { -
-
- for _, m := range messages { -
- - { m.Nickname }:  - - { m.Message } -
- } - @chatAutoScroll() -
-
- - -
-
-} - -templ chatAutoScroll() { - -} - -func chatColor(color int) string { - if c, ok := chatColors[color]; ok { - return c - } - return "#666" -} diff --git a/features/c4game/components/status.templ b/features/c4game/components/status.templ index d4f4e71..161d1c6 100644 --- a/features/c4game/components/status.templ +++ b/features/c4game/components/status.templ @@ -1,12 +1,12 @@ package components import ( - "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/games/config" + "github.com/ryanhamamura/games/connect4" "github.com/starfederation/datastar-go/datastar" ) -templ StatusBanner(g *game.Game, myColor int) { +templ StatusBanner(g *connect4.Game, myColor int) {
{ statusMessage(g, myColor) } if g.IsFinished() { @@ -30,7 +30,7 @@ templ StatusBanner(g *game.Game, myColor int) {
} -templ PlayerInfo(g *game.Game, myColor int) { +templ PlayerInfo(g *connect4.Game, myColor int) {
for _, info := range playerInfoPairs(g, myColor) {
@@ -61,36 +61,36 @@ script copyToClipboard(url string) { navigator.clipboard.writeText(url) } -func statusClass(g *game.Game, myColor int) string { +func statusClass(g *connect4.Game, myColor int) string { switch g.Status { - case game.StatusWaitingForPlayer: + case connect4.StatusWaitingForPlayer: return "alert bg-base-200 text-xl font-bold" - case game.StatusInProgress: + case connect4.StatusInProgress: if g.CurrentTurn == myColor { return "alert alert-success text-xl font-bold" } return "alert bg-base-200 text-xl font-bold" - case game.StatusWon: + case connect4.StatusWon: if g.Winner != nil && g.Winner.Color == myColor { return "alert alert-success text-xl font-bold" } return "alert alert-error text-xl font-bold" - case game.StatusDraw: + case connect4.StatusDraw: return "alert alert-warning text-xl font-bold" } return "alert bg-base-200 text-xl font-bold" } -func statusMessage(g *game.Game, myColor int) string { +func statusMessage(g *connect4.Game, myColor int) string { switch g.Status { - case game.StatusWaitingForPlayer: + case connect4.StatusWaitingForPlayer: return "Waiting for opponent..." - case game.StatusInProgress: + case connect4.StatusInProgress: if g.CurrentTurn == myColor { return "Your turn!" } return opponentName(g, myColor) + "'s turn" - case game.StatusWon: + case connect4.StatusWon: if g.Winner != nil && g.Winner.Color == myColor { return "You win!" } @@ -98,13 +98,13 @@ func statusMessage(g *game.Game, myColor int) string { return g.Winner.Nickname + " wins!" } return "Game over" - case game.StatusDraw: + case connect4.StatusDraw: return "It's a draw!" } return "" } -func opponentName(g *game.Game, myColor int) string { +func opponentName(g *connect4.Game, myColor int) string { for _, p := range g.Players { if p != nil && p.Color != myColor { return p.Nickname @@ -118,7 +118,7 @@ type playerInfoData struct { Label string } -func playerInfoPairs(g *game.Game, myColor int) []playerInfoData { +func playerInfoPairs(g *connect4.Game, myColor int) []playerInfoData { var result []playerInfoData var myName, oppName string diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 8b9d0d7..c5a5bc0 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -1,12 +1,9 @@ package c4game import ( - "context" - "encoding/json" + "fmt" "net/http" - "slices" "strconv" - "sync" "time" "github.com/alexedwards/scs/v2" @@ -14,13 +11,36 @@ import ( "github.com/nats-io/nats.go" "github.com/starfederation/datastar-go/datastar" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/features/c4game/components" - "github.com/ryanhamamura/c4/features/c4game/pages" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/games/chat" + chatcomponents "github.com/ryanhamamura/games/chat/components" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/features/c4game/pages" + "github.com/ryanhamamura/games/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 *connect4.Store, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -30,29 +50,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries return } - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) - if playerID == "" { - playerID = game.PlayerID(game.GenerateID(8)) - 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") + playerID := sessions.GetPlayerID(sm, r) + userID := sessions.GetUserID(sm, r) + nickname := sessions.GetNickname(sm, r) // Auto-join if player has a nickname but isn't in the game yet if nickname != "" && gi.GetPlayerColor(playerID) == 0 { - player := &game.Player{ + p := &connect4.Player{ ID: playerID, Nickname: nickname, } if userID != "" { - player.UserID = &userID + p.UserID = &userID } - gi.Join(&game.PlayerSession{Player: player}) + gi.Join(&connect4.PlayerSession{Player: p}) } myColor := gi.GetPlayerColor(playerID) @@ -61,31 +72,27 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries // Player not in game isGuest := r.URL.Query().Get("guest") == "1" if userID == "" && !isGuest { - // Show join prompt (login vs guest) if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } - // Show nickname prompt if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } - // Player is in the game — render full game page g := gi.GetGame() - chatMsgs := loadChatMessages(queries, gameID) - msgs := chatToComponents(chatMsgs) + room := chat.NewPersistentRoom(nil, "", queries, gameID) - if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil { + if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } } -func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -95,37 +102,37 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio return } - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = game.PlayerID(userID) - } - + playerID := sessions.GetPlayerID(sm, r) myColor := gi.GetPlayerColor(playerID) sse := datastar.NewSSE(w, r, datastar.WithCompression( datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) - // Load initial chat messages - chatMsgs := loadChatMessages(queries, gameID) - var chatMu sync.Mutex - chatMessages := chatToComponents(chatMsgs) + chatCfg := c4ChatConfig(gameID) + room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID) - // Send initial render of all components - sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) + patchAll := func() error { + myColor = gi.GetPlayerColor(playerID) + g := gi.GetGame() + return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg)) + } + + // Send initial render + if err := patchAll(); err != nil { + return + } // Subscribe to game state updates gameCh := make(chan *nats.Msg, 64) - gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh) + gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh) if err != nil { return } defer gameSub.Unsubscribe() //nolint:errcheck // Subscribe to chat messages - chatCh := make(chan *nats.Msg, 64) - chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh) + chatCh, chatSub, err := room.Subscribe() if err != nil { return } @@ -137,30 +144,12 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio case <-ctx.Done(): return case <-gameCh: - // Re-read player color in case we just joined - myColor = gi.GetPlayerColor(playerID) - sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) + if err := patchAll(); err != nil { + return + } case msg := <-chatCh: - var uiMsg game.ChatMessage - if err := json.Unmarshal(msg.Data, &uiMsg); err != nil { - continue - } - cm := components.ChatMessage{ - Nickname: uiMsg.Nickname, - Color: uiMsg.Color, - Message: uiMsg.Message, - Time: uiMsg.Time, - } - chatMu.Lock() - chatMessages = append(chatMessages, cm) - if len(chatMessages) > 50 { - chatMessages = chatMessages[len(chatMessages)-50:] - } - msgs := make([]components.ChatMessage, len(chatMessages)) - copy(msgs, chatMessages) - chatMu.Unlock() - - if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil { + room.Receive(msg.Data) + if err := patchAll(); err != nil { return } } @@ -168,7 +157,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 *connect4.Store, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -185,12 +174,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H return } - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = game.PlayerID(userID) - } - + playerID := sessions.GetPlayerID(sm, r) myColor := gi.GetPlayerColor(playerID) if myColor == 0 { http.Error(w, "not in game", http.StatusForbidden) @@ -198,14 +182,11 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H } gi.DropPiece(col, myColor) - - // The store's notifyFunc publishes to NATS, which triggers SSE updates. - // Return empty SSE response. datastar.NewSSE(w, r) } } -func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -229,12 +210,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM return } - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = game.PlayerID(userID) - } - + playerID := sessions.GetPlayerID(sm, r) myColor := gi.GetPlayerColor(playerID) if myColor == 0 { datastar.NewSSE(w, r) @@ -250,28 +226,22 @@ 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, - Color: myColor, + Slot: myColor - 1, Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } - saveChatMessage(queries, gameID, cm) + room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID) + room.Send(msg) - data, err := json.Marshal(cm) - if err != nil { - datastar.NewSSE(w, r) - return - } - nc.Publish("game.chat."+gameID, data) //nolint:errcheck - - // Clear the chat input sse := datastar.NewSSE(w, r) sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck } } -func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -296,23 +266,20 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http return } - sessions.Put(r.Context(), "nickname", signals.Nickname) + sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname) - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = game.PlayerID(userID) - } + playerID := sessions.GetPlayerID(sm, r) + userID := sessions.GetUserID(sm, r) if gi.GetPlayerColor(playerID) == 0 { - player := &game.Player{ + p := &connect4.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { - player.UserID = &userID + p.UserID = &userID } - gi.Join(&game.PlayerSession{Player: player}) + gi.Join(&connect4.PlayerSession{Player: p}) } sse := datastar.NewSSE(w, r) @@ -320,7 +287,7 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http } } -func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -338,63 +305,3 @@ func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.Han } } } - -// 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) { - g := gi.GetGame() - - sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck - sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck - sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck - - chatMu.Lock() - msgs := make([]components.ChatMessage, len(chatMessages)) - copy(msgs, chatMessages) - chatMu.Unlock() - - sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck -} - -// Chat persistence helpers — inlined from the former ChatPersister. - -func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) { - queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck - GameID: gameID, - Nickname: msg.Nickname, - Color: int64(msg.Color), - Message: msg.Message, - CreatedAt: msg.Time, - }) -} - -func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage { - rows, err := queries.GetChatMessages(context.Background(), gameID) - if err != nil { - return nil - } - msgs := make([]game.ChatMessage, len(rows)) - for i, r := range rows { - msgs[i] = game.ChatMessage{ - Nickname: r.Nickname, - Color: int(r.Color), - Message: r.Message, - Time: r.CreatedAt, - } - } - // DB returns newest-first; reverse for display - slices.Reverse(msgs) - return msgs -} - -func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage { - msgs := make([]components.ChatMessage, len(chatMsgs)) - for i, m := range chatMsgs { - msgs[i] = components.ChatMessage{ - Nickname: m.Nickname, - Color: m.Color, - Message: m.Message, - Time: m.Time, - } - } - return msgs -} diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ index eb328ad..c175b42 100644 --- a/features/c4game/pages/game.templ +++ b/features/c4game/pages/game.templ @@ -1,35 +1,43 @@ package pages import ( - "github.com/ryanhamamura/c4/features/c4game/components" - sharedcomponents "github.com/ryanhamamura/c4/features/common/components" - "github.com/ryanhamamura/c4/features/common/layouts" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/games/chat" + chatcomponents "github.com/ryanhamamura/games/chat/components" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/features/c4game/components" + sharedcomponents "github.com/ryanhamamura/games/features/common/components" + "github.com/ryanhamamura/games/features/common/layouts" "github.com/starfederation/datastar-go/datastar" ) -templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) { +templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) { @layouts.Base("Connect 4") {
- @sharedcomponents.BackToLobby() - @sharedcomponents.StealthTitle("text-3xl font-bold") - @components.PlayerInfo(g, myColor) - @components.StatusBanner(g, myColor) -
- @components.Board(g, myColor) - @components.Chat(messages, g.ID) -
- if g.Status == game.StatusWaitingForPlayer { - @components.InviteLink(g.ID) - } + @GameContent(g, myColor, messages, chatCfg)
} } +templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) { +
+ @sharedcomponents.BackToLobby() + @sharedcomponents.StealthTitle("text-3xl font-bold") + @components.PlayerInfo(g, myColor) + @components.StatusBanner(g, myColor) +
+ @components.Board(g, myColor) + @chatcomponents.Chat(messages, chatCfg) +
+ if g.Status == connect4.StatusWaitingForPlayer { + @components.InviteLink(g.ID) + } +
+} + templ JoinPage(gameID string) { @layouts.Base("Connect 4 - Join") { @sharedcomponents.GameJoinPrompt( diff --git a/features/c4game/routes.go b/features/c4game/routes.go index 2ffd2dc..e936fd4 100644 --- a/features/c4game/routes.go +++ b/features/c4game/routes.go @@ -6,13 +6,13 @@ import ( "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" ) func SetupRoutes( router chi.Router, - store *game.GameStore, + store *connect4.Store, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries, diff --git a/features/common/layouts/base.templ b/features/common/layouts/base.templ index ffd9585..86d563a 100644 --- a/features/common/layouts/base.templ +++ b/features/common/layouts/base.templ @@ -1,6 +1,6 @@ package layouts -import "github.com/ryanhamamura/c4/config" +import "github.com/ryanhamamura/games/config" templ Base(title string) { diff --git a/features/lobby/components/gamelist.templ b/features/lobby/components/gamelist.templ index 24563e5..73efab3 100644 --- a/features/lobby/components/gamelist.templ +++ b/features/lobby/components/gamelist.templ @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/games/connect4" "github.com/starfederation/datastar-go/datastar" ) @@ -46,10 +46,10 @@ templ gameListEntry(g GameListItem) { } func statusText(g GameListItem) string { - switch game.GameStatus(g.Status) { - case game.StatusWaitingForPlayer: + switch connect4.Status(g.Status) { + case connect4.StatusWaitingForPlayer: return "Waiting for opponent" - case game.StatusInProgress: + case connect4.StatusInProgress: if g.IsMyTurn { return "Your turn!" } @@ -59,10 +59,10 @@ func statusText(g GameListItem) string { } func statusClass(g GameListItem) string { - switch game.GameStatus(g.Status) { - case game.StatusWaitingForPlayer: + switch connect4.Status(g.Status) { + case connect4.StatusWaitingForPlayer: return "text-sm opacity-60" - case game.StatusInProgress: + case connect4.StatusInProgress: if g.IsMyTurn { return "text-sm text-success font-bold" } diff --git a/features/lobby/handlers.go b/features/lobby/handlers.go index 938c02a..074b0aa 100644 --- a/features/lobby/handlers.go +++ b/features/lobby/handlers.go @@ -7,11 +7,12 @@ import ( "strconv" "time" - "github.com/ryanhamamura/c4/db/repository" - lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components" - "github.com/ryanhamamura/c4/features/lobby/pages" - "github.com/ryanhamamura/c4/game" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" + lobbycomponents "github.com/ryanhamamura/games/features/lobby/components" + "github.com/ryanhamamura/games/features/lobby/pages" + appsessions "github.com/ryanhamamura/games/sessions" + "github.com/ryanhamamura/games/snake" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" @@ -21,7 +22,7 @@ import ( // HandleLobbyPage renders the main lobby page with active games for logged-in users. func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, snakeStore *snake.SnakeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID := sessions.GetString(r.Context(), "user_id") + userID := sessions.GetString(r.Context(), appsessions.KeyUserID) username := sessions.GetString(r.Context(), "username") isLoggedIn := userID != "" @@ -80,7 +81,7 @@ func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, } // HandleCreateGame reads the nickname signal, creates a connect4 game, and redirects via SSE. -func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleCreateGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { type Signals struct { Nickname string `json:"nickname"` @@ -95,7 +96,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http. return } - sessions.Put(r.Context(), "nickname", signals.Nickname) + sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname) gi := store.Create() sse := datastar.NewSSE(w, r) @@ -104,7 +105,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http. } // HandleDeleteGame deletes a connect4 game and redirects to the lobby. -func HandleDeleteGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleDeleteGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") if gameID == "" { @@ -137,7 +138,7 @@ func HandleCreateSnakeGame(snakeStore *snake.SnakeStore, sessions *scs.SessionMa return } - sessions.Put(r.Context(), "nickname", signals.Nickname) + sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname) mode := snake.ModeMultiplayer if r.URL.Query().Get("mode") == "solo" { diff --git a/features/lobby/pages/lobby.templ b/features/lobby/pages/lobby.templ index cff0080..c2dde2e 100644 --- a/features/lobby/pages/lobby.templ +++ b/features/lobby/pages/lobby.templ @@ -3,10 +3,10 @@ package pages import ( "fmt" - "github.com/ryanhamamura/c4/features/common/components" - "github.com/ryanhamamura/c4/features/common/layouts" - lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/features/common/components" + "github.com/ryanhamamura/games/features/common/layouts" + lobbycomponents "github.com/ryanhamamura/games/features/lobby/components" + "github.com/ryanhamamura/games/snake" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/lobby/pages/types.go b/features/lobby/pages/types.go index a386a6f..89acc28 100644 --- a/features/lobby/pages/types.go +++ b/features/lobby/pages/types.go @@ -1,6 +1,6 @@ package pages -import "github.com/ryanhamamura/c4/features/lobby/components" +import "github.com/ryanhamamura/games/features/lobby/components" // SnakeGameListItem represents a joinable snake game in the lobby. type SnakeGameListItem struct { diff --git a/features/lobby/routes.go b/features/lobby/routes.go index ea8a433..bec75e2 100644 --- a/features/lobby/routes.go +++ b/features/lobby/routes.go @@ -2,9 +2,9 @@ package lobby import ( - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/game" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/snake" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" @@ -14,7 +14,7 @@ func SetupRoutes( router chi.Router, queries *repository.Queries, sessions *scs.SessionManager, - store *game.GameStore, + store *connect4.Store, snakeStore *snake.SnakeStore, ) { router.Get("/", HandleLobbyPage(queries, sessions, snakeStore)) diff --git a/features/snakegame/components/board.templ b/features/snakegame/components/board.templ index 6083935..9c4c156 100644 --- a/features/snakegame/components/board.templ +++ b/features/snakegame/components/board.templ @@ -3,7 +3,7 @@ package components import ( "fmt" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/snake" ) func cellSizeForGrid(width, height int) int { diff --git a/features/snakegame/components/chat.templ b/features/snakegame/components/chat.templ deleted file mode 100644 index 58c137b..0000000 --- a/features/snakegame/components/chat.templ +++ /dev/null @@ -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) { -
-
- for _, m := range messages { -
- - { m.Nickname + ": " } - - { m.Message } -
- } -
-
- - -
- @chatAutoScroll() -
-} - -templ chatAutoScroll() { - -} - -func chatColor(slot int) string { - if slot >= 0 && slot < len(snake.SnakeColors) { - return snake.SnakeColors[slot] - } - return "#666" -} diff --git a/features/snakegame/components/status.templ b/features/snakegame/components/status.templ index b09613d..d1b045e 100644 --- a/features/snakegame/components/status.templ +++ b/features/snakegame/components/status.templ @@ -5,8 +5,8 @@ import ( "math" "time" - "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/config" + "github.com/ryanhamamura/games/snake" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index 03292a6..dffb6a0 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -1,36 +1,39 @@ package snakegame import ( - "encoding/json" + "fmt" "net/http" "strconv" - "sync" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" "github.com/starfederation/datastar-go/datastar" - "github.com/ryanhamamura/c4/features/snakegame/components" - "github.com/ryanhamamura/c4/features/snakegame/pages" - "github.com/ryanhamamura/c4/game" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/chat" + chatcomponents "github.com/ryanhamamura/games/chat/components" + "github.com/ryanhamamura/games/features/snakegame/pages" + "github.com/ryanhamamura/games/sessions" + "github.com/ryanhamamura/games/snake" ) -func getPlayerID(sessions *scs.SessionManager, r *http.Request) snake.PlayerID { - pid := sessions.GetString(r.Context(), "player_id") - if pid == "" { - pid = game.GenerateID(8) - sessions.Put(r.Context(), "player_id", pid) +func snakeChatColor(slot int) string { + if slot >= 0 && slot < len(snake.SnakeColors) { + return snake.SnakeColors[slot] } - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - return snake.PlayerID(userID) - } - return snake.PlayerID(pid) + return "#666" } -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) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -39,26 +42,25 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) return } - playerID := getPlayerID(sessions, r) - nickname := sessions.GetString(r.Context(), "nickname") - userID := sessions.GetString(r.Context(), "user_id") + playerID := sessions.GetPlayerID(sm, r) + nickname := sessions.GetNickname(sm, r) + userID := sessions.GetUserID(sm, r) // Auto-join if nickname exists and not already in game if nickname != "" && si.GetPlayerSlot(playerID) < 0 { - player := &snake.Player{ + p := &snake.Player{ ID: playerID, Nickname: nickname, } if userID != "" { - player.UserID = &userID + p.UserID = &userID } - si.Join(player) + si.Join(p) } mySlot := si.GetPlayerSlot(playerID) if mySlot < 0 { - // Not in game yet isGuest := r.URL.Query().Get("guest") == "1" if userID == "" && !isGuest { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { @@ -73,13 +75,13 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) } 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) } } } -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) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -88,28 +90,47 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc return } - playerID := getPlayerID(sessions, r) + playerID := sessions.GetPlayerID(sm, r) mySlot := si.GetPlayerSlot(playerID) sse := datastar.NewSSE(w, r, datastar.WithCompression( datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) - // Send initial render + chatCfg := snakeChatConfig(gameID) + + // Chat room (multiplayer only) + var room *chat.Room 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 + room = chat.NewRoom(nc, snake.ChatSubject(gameID)) + } + + chatMessages := func() []chat.Message { + if room == nil { + return nil } + return room.Messages() + } + + patchAll := func() error { + si, ok = snakeStore.Get(gameID) + if !ok { + return fmt.Errorf("game not found") + } + mySlot = si.GetPlayerSlot(playerID) + sg = si.GetGame() + return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID)) + } + + // Send initial render + if err := patchAll(); err != nil { + return } // Subscribe to game updates via NATS gameCh := make(chan *nats.Msg, 64) - gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh) + gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh) if err != nil { return } @@ -118,12 +139,9 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc // 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 room != nil { + chatCh, chatSub, err = room.Subscribe() if err != nil { return } @@ -146,19 +164,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc } } 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 { + if err := patchAll(); err != nil { return } @@ -166,20 +172,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc 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 { + room.Receive(msg.Data) + if err := patchAll(); err != nil { return } } @@ -187,7 +181,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) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -196,7 +190,7 @@ func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManag return } - playerID := getPlayerID(sessions, r) + playerID := sessions.GetPlayerID(sm, r) slot := si.GetPlayerSlot(playerID) if slot < 0 { http.Error(w, "not in game", http.StatusForbidden) @@ -219,7 +213,7 @@ type chatSignals struct { 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) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -238,7 +232,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S return } - playerID := getPlayerID(sessions, r) + playerID := sessions.GetPlayerID(sm, r) slot := si.GetPlayerSlot(playerID) if slot < 0 { http.Error(w, "not in game", http.StatusForbidden) @@ -246,16 +240,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S } sg := si.GetGame() - cm := components.ChatMessage{ + msg := chat.Message{ Nickname: sg.Players[slot].Nickname, Slot: slot, Message: signals.ChatMsg, } - data, err := json.Marshal(cm) - if err != nil { - return - } - nc.Publish("snake.chat."+gameID, data) //nolint:errcheck + + room := chat.NewRoom(nc, snake.ChatSubject(gameID)) + room.Send(msg) sse := datastar.NewSSE(w, r) sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck @@ -266,7 +258,7 @@ type nicknameSignals struct { 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) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -285,20 +277,20 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage return } - sessions.Put(r.Context(), "nickname", signals.Nickname) + sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname) - playerID := getPlayerID(sessions, r) - userID := sessions.GetString(r.Context(), "user_id") + playerID := sessions.GetPlayerID(sm, r) + userID := sessions.GetUserID(sm, r) if si.GetPlayerSlot(playerID) < 0 { - player := &snake.Player{ + p := &snake.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { - player.UserID = &userID + p.UserID = &userID } - si.Join(player) + si.Join(p) } sse := datastar.NewSSE(w, r) @@ -306,7 +298,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) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ index 49b90de..98b98fb 100644 --- a/features/snakegame/pages/game.templ +++ b/features/snakegame/pages/game.templ @@ -3,10 +3,12 @@ package pages import ( "fmt" - "github.com/ryanhamamura/c4/features/common/components" - "github.com/ryanhamamura/c4/features/common/layouts" - snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/chat" + chatcomponents "github.com/ryanhamamura/games/chat/components" + "github.com/ryanhamamura/games/features/common/components" + "github.com/ryanhamamura/games/features/common/layouts" + snakecomponents "github.com/ryanhamamura/games/features/snakegame/components" + "github.com/ryanhamamura/games/snake" "github.com/starfederation/datastar-go/datastar" ) @@ -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") {
- @components.BackToLobby() -

~~~~

- @snakecomponents.PlayerList(sg, mySlot) - @snakecomponents.StatusBanner(sg, mySlot, gameID) - if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { - if sg.Mode == snake.ModeMultiplayer { -
- @snakecomponents.Board(sg) - @snakecomponents.Chat(messages, gameID) -
- } else { - @snakecomponents.Board(sg) - } - } else if sg.Mode == snake.ModeMultiplayer { - @snakecomponents.Chat(messages, gameID) - } - if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) { - @snakecomponents.InviteLink(gameID) - } + @GameContent(sg, mySlot, messages, chatCfg, gameID)
} } +templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) { +
+ @components.BackToLobby() +

~~~~

+ @snakecomponents.PlayerList(sg, mySlot) + @snakecomponents.StatusBanner(sg, mySlot, gameID) + if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { + if sg.Mode == snake.ModeMultiplayer { +
+ @snakecomponents.Board(sg) + @chatcomponents.Chat(messages, chatCfg) +
+ } else { + @snakecomponents.Board(sg) + } + } else if sg.Mode == snake.ModeMultiplayer { + @chatcomponents.Chat(messages, chatCfg) + } + if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) { + @snakecomponents.InviteLink(gameID) + } +
+} + templ JoinPage(gameID string) { @layouts.Base("Snake - Join") { @components.GameJoinPrompt( diff --git a/features/snakegame/routes.go b/features/snakegame/routes.go index 880c073..0f30757 100644 --- a/features/snakegame/routes.go +++ b/features/snakegame/routes.go @@ -6,7 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/snake" ) func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) { diff --git a/go.mod b/go.mod index d7a24c5..069eb86 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/ryanhamamura/c4 +module github.com/ryanhamamura/games go 1.25.4 diff --git a/logging/log.go b/logging/log.go index 4cdaed3..7a4245d 100644 --- a/logging/log.go +++ b/logging/log.go @@ -6,7 +6,7 @@ import ( stdlog "log" "os" - "github.com/ryanhamamura/c4/config" + "github.com/ryanhamamura/games/config" "github.com/rs/zerolog" "github.com/rs/zerolog/log" diff --git a/logging/middleware.go b/logging/middleware.go index dd6404e..be6c21a 100644 --- a/logging/middleware.go +++ b/logging/middleware.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/ryanhamamura/c4/config" + "github.com/ryanhamamura/games/config" "github.com/rs/zerolog" "github.com/rs/zerolog/log" diff --git a/main.go b/main.go index 1b97103..041e13c 100644 --- a/main.go +++ b/main.go @@ -11,15 +11,15 @@ import ( "syscall" "time" - "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/db" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/game" - "github.com/ryanhamamura/c4/logging" - appnats "github.com/ryanhamamura/c4/nats" - "github.com/ryanhamamura/c4/router" - "github.com/ryanhamamura/c4/sessions" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/config" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/logging" + appnats "github.com/ryanhamamura/games/nats" + "github.com/ryanhamamura/games/router" + "github.com/ryanhamamura/games/sessions" + "github.com/ryanhamamura/games/snake" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -71,14 +71,14 @@ func run(ctx context.Context) error { defer cleanupNATS() // Game stores - store := game.NewGameStore(queries) + store := connect4.NewStore(queries) store.SetNotifyFunc(func(gameID string) { - nc.Publish("game."+gameID, nil) //nolint:errcheck // best-effort notification + nc.Publish(connect4.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification }) snakeStore := snake.NewSnakeStore(queries) snakeStore.SetNotifyFunc(func(gameID string) { - nc.Publish("snake."+gameID, nil) //nolint:errcheck // best-effort notification + nc.Publish(snake.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification }) // Router diff --git a/player/player.go b/player/player.go new file mode 100644 index 0000000..ca65a3f --- /dev/null +++ b/player/player.go @@ -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) +} diff --git a/router/router.go b/router/router.go index 6f8b6eb..1a2cd8f 100644 --- a/router/router.go +++ b/router/router.go @@ -7,14 +7,14 @@ import ( "net/http" "sync" - "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/features/auth" - "github.com/ryanhamamura/c4/features/c4game" - "github.com/ryanhamamura/c4/features/lobby" - "github.com/ryanhamamura/c4/features/snakegame" - "github.com/ryanhamamura/c4/game" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/config" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/features/auth" + "github.com/ryanhamamura/games/features/c4game" + "github.com/ryanhamamura/games/features/lobby" + "github.com/ryanhamamura/games/features/snakegame" + "github.com/ryanhamamura/games/snake" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" @@ -27,7 +27,7 @@ func SetupRoutes( queries *repository.Queries, sessions *scs.SessionManager, nc *nats.Conn, - store *game.GameStore, + store *connect4.Store, snakeStore *snake.SnakeStore, assets embed.FS, ) { diff --git a/sessions/sessions.go b/sessions/sessions.go index 5b52bb4..3880d47 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -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 import ( @@ -7,10 +8,19 @@ import ( "net/http" "time" + "github.com/ryanhamamura/games/player" + "github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/v2" ) +// Session key names. +const ( + KeyPlayerID = "player_id" + KeyUserID = "user_id" + KeyNickname = "nickname" +) + // SetupSessionManager creates a configured session manager backed by SQLite. // Returns the manager and a cleanup function the caller should defer. func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { @@ -20,7 +30,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { sessionManager := scs.New() sessionManager.Store = store sessionManager.Lifetime = 30 * 24 * time.Hour - sessionManager.Cookie.Name = "c4_session" + sessionManager.Cookie.Name = "games_session" sessionManager.Cookie.Path = "/" sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.Secure = true @@ -30,3 +40,28 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { 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(), KeyPlayerID) + if pid == "" { + pid = player.GenerateID(8) + sm.Put(r.Context(), KeyPlayerID, pid) + } + if userID := sm.GetString(r.Context(), KeyUserID); 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(), KeyUserID) +} + +// GetNickname returns the player's display name from the session. +func GetNickname(sm *scs.SessionManager, r *http.Request) string { + return sm.GetString(r.Context(), KeyNickname) +} diff --git a/snake/persist.go b/snake/persist.go index dba9da9..e044a61 100644 --- a/snake/persist.go +++ b/snake/persist.go @@ -3,7 +3,8 @@ package snake import ( "context" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/player" "github.com/rs/zerolog/log" ) @@ -122,19 +123,19 @@ func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) { func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player { players := make([]*Player, 0, len(rows)) for _, row := range rows { - player := &Player{ + p := &Player{ Nickname: row.Nickname, Slot: int(row.Slot), } if row.UserID != nil { - player.UserID = row.UserID - player.ID = PlayerID(*row.UserID) + p.UserID = row.UserID + p.ID = player.ID(*row.UserID) } 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 } diff --git a/snake/store.go b/snake/store.go index 4543a92..888dfa2 100644 --- a/snake/store.go +++ b/snake/store.go @@ -4,8 +4,8 @@ import ( "context" "sync" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/player" ) type SnakeStore struct { @@ -38,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake if speed <= 0 { speed = DefaultSpeed } - id := game.GenerateID(4) + id := player.GenerateID(4) sg := &SnakeGame{ ID: id, State: &GameState{ @@ -172,7 +172,7 @@ func (si *SnakeGameInstance) GetGame() *SnakeGame { return si.game.snapshot() } -func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int { +func (si *SnakeGameInstance) GetPlayerSlot(pid player.ID) int { si.gameMu.RLock() defer si.gameMu.RUnlock() for i, p := range si.game.Players { diff --git a/snake/types.go b/snake/types.go index 8272765..d94b5d2 100644 --- a/snake/types.go +++ b/snake/types.go @@ -3,8 +3,19 @@ package snake import ( "encoding/json" "time" + + "github.com/ryanhamamura/games/player" ) +// SubjectPrefix is the NATS subject namespace for snake games. +const SubjectPrefix = "snake" + +// GameSubject returns the NATS subject for game state updates. +func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID } + +// ChatSubject returns the NATS subject for chat messages. +func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID } + type Direction int const ( @@ -78,10 +89,8 @@ const ( StatusFinished ) -type PlayerID string - type Player struct { - ID PlayerID + ID player.ID UserID *string Nickname string Slot int // 0-7 diff --git a/testutil/db.go b/testutil/db.go index d2cb2f9..e642aba 100644 --- a/testutil/db.go +++ b/testutil/db.go @@ -8,8 +8,8 @@ import ( "io/fs" "testing" - "github.com/ryanhamamura/c4/db" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/db" + "github.com/ryanhamamura/games/db/repository" "github.com/pressly/goose/v3" _ "modernc.org/sqlite"