Merge pull request 'refactor: extract shared player, session, and chat packages' (#5) from refactor/shared-player-session-chat into main
Some checks failed
CI / Deploy / test (push) Successful in 13s
CI / Deploy / lint (push) Successful in 24s
CI / Deploy / deploy (push) Failing after 1m6s

This commit was merged in pull request #5.
This commit is contained in:
2026-03-03 08:50:13 +00:00
53 changed files with 758 additions and 740 deletions

View File

@@ -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 <version> --target main -t "<version>" -n "<release notes>"
```
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.

View File

@@ -1,10 +1,10 @@
c4 games
c4.db games.db
data/ data/
deploy/ deploy/
.env .env
.git .git
.gitignore .gitignore
assets/css/output.css assets/css/output.css
c4-deploy-*.tar.gz games-deploy-*.tar.gz
c4-deploy-*_b64*.txt games-deploy-*_b64*.txt

View File

@@ -1,8 +1,8 @@
# Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO. # Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO.
# LOG_LEVEL=DEBUG # LOG_LEVEL=DEBUG
# SQLite database path. Defaults to data/c4.db. # SQLite database path. Defaults to data/games.db.
# DB_PATH=data/c4.db # DB_PATH=data/games.db
# Application URL for invite links. Defaults to https://games.adriatica.io. # Application URL for invite links. Defaults to https://games.adriatica.io.
# APP_URL=http://localhost:7331 # APP_URL=http://localhost:7331
@@ -12,5 +12,5 @@
# Goose CLI migration config (only needed for running goose manually) # Goose CLI migration config (only needed for running goose manually)
GOOSE_DRIVER=sqlite3 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 GOOSE_MIGRATION_DIR=db/migrations

View File

@@ -6,7 +6,7 @@ on:
pull_request: pull_request:
env: env:
DEPLOY_DIR: /home/ryan/c4 DEPLOY_DIR: /home/ryan/games
jobs: jobs:
test: test:

View File

@@ -35,7 +35,7 @@ formatters:
settings: settings:
goimports: goimports:
local-prefixes: local-prefixes:
- github.com/ryanhamamura/c4 - github.com/ryanhamamura/games
issues: issues:
exclude-rules: exclude-rules:

View File

@@ -9,10 +9,10 @@ RUN go mod download
COPY . . COPY . .
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s" -o /bin/c4 . CGO_ENABLED=0 go build -ldflags="-s" -o /bin/games .
RUN upx -9 -k /bin/c4 RUN upx -9 -k /bin/games
FROM scratch FROM scratch
ENV PORT=8080 ENV PORT=8080
COPY --from=build /bin/c4 / COPY --from=build /bin/games /
ENTRYPOINT ["/c4"] ENTRYPOINT ["/games"]

View File

@@ -27,9 +27,9 @@ tasks:
- "assets/css/output.css" - "assets/css/output.css"
build: build:
desc: Production build to bin/c4 desc: Production build to bin/games
cmds: cmds:
- go build -o bin/c4 . - go build -o bin/games .
deps: deps:
- build:templ - build:templ
- build:styles - build:styles
@@ -49,8 +49,8 @@ tasks:
cmds: cmds:
- | - |
go tool air \ go tool air \
-build.cmd "go build -tags=dev -o tmp/bin/c4 ." \ -build.cmd "go build -tags=dev -o tmp/bin/games ." \
-build.bin "tmp/bin/c4" \ -build.bin "tmp/bin/games" \
-build.exclude_dir "data,bin,tmp,deploy" \ -build.exclude_dir "data,bin,tmp,deploy" \
-build.include_ext "go,templ" \ -build.include_ext "go,templ" \
-misc.clean_on_exit "true" -misc.clean_on_exit "true"
@@ -75,7 +75,7 @@ tasks:
run: run:
desc: Build and run the server desc: Build and run the server
cmds: cmds:
- ./bin/c4 - ./bin/games
deps: deps:
- build - build

149
chat/chat.go Normal file
View File

@@ -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
}

View File

@@ -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) {
<div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }>
<div class={ cfg.CSSPrefix + "-chat-history" }>
for _, m := range messages {
<div class={ cfg.CSSPrefix + "-chat-msg" }>
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", cfg.Color(m.Slot)) }>
{ m.Nickname + ": " }
</span>
<span>{ m.Message }</span>
</div>
}
</div>
<div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore>
if cfg.StopKeyPropagation {
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.stop=""
data-on:keydown.key_enter={ datastar.PostSSE("%s", cfg.PostURL) }
/>
} else {
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.enter={ datastar.PostSSE("%s", cfg.PostURL) }
/>
}
<button
type="button"
data-on:click={ datastar.PostSSE("%s", cfg.PostURL) }
>
Send
</button>
</div>
@chatAutoScroll(cfg.CSSPrefix)
</div>
}
script chatAutoScroll(cssPrefix string) {
var el = document.querySelector('.' + cssPrefix + '-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
}

View File

@@ -71,6 +71,6 @@ func loadBase() *Config {
} }
}(), }(),
AppURL: getEnv("APP_URL", "https://games.adriatica.io"), AppURL: getEnv("APP_URL", "https://games.adriatica.io"),
DBPath: getEnv("DB_PATH", "data/c4.db"), DBPath: getEnv("DB_PATH", "data/games.db"),
} }
} }

View File

@@ -1,5 +1,5 @@
// Package game implements Connect 4 game logic, state management, and persistence. // Package connect4 implements Connect 4 game logic, state management, and persistence.
package game package connect4
// DropPiece attempts to drop a piece in the given column. // DropPiece attempts to drop a piece in the given column.
// Returns (row placed, success). // Returns (row placed, success).

View File

@@ -1,14 +1,15 @@
package game package connect4
import ( import (
"context" "context"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/player"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func (gi *GameInstance) save() error { func (gi *Instance) save() error {
err := saveGame(gi.queries, gi.game) err := saveGame(gi.queries, gi.game)
if err != nil { if err != nil {
log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game") 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 return err
} }
func (gi *GameInstance) savePlayer(player *Player, slot int) error { func (gi *Instance) savePlayer(p *Player, slot int) error {
err := saveGamePlayer(gi.queries, gi.game.ID, player, slot) err := saveGamePlayer(gi.queries, gi.game.ID, p, slot)
if err != nil { if err != nil {
log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player") 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 var userID, guestPlayerID *string
if player.UserID != nil { if p.UserID != nil {
userID = player.UserID userID = p.UserID
} else { } else {
id := string(player.ID) id := string(p.ID)
guestPlayerID = &id guestPlayerID = &id
} }
@@ -60,8 +61,8 @@ func saveGamePlayer(queries *repository.Queries, gameID string, player *Player,
GameID: gameID, GameID: gameID,
UserID: userID, UserID: userID,
GuestPlayerID: guestPlayerID, GuestPlayerID: guestPlayerID,
Nickname: player.Nickname, Nickname: p.Nickname,
Color: int64(player.Color), Color: int64(p.Color),
Slot: int64(slot), Slot: int64(slot),
}) })
} }
@@ -82,13 +83,11 @@ func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error)
return playersFromRows(rows), nil return playersFromRows(rows), nil
} }
// Domain ↔ DB mapping helpers.
func gameFromRow(row *repository.Game) (*Game, error) { func gameFromRow(row *repository.Game) (*Game, error) {
g := &Game{ g := &Game{
ID: row.ID, ID: row.ID,
CurrentTurn: int(row.CurrentTurn), CurrentTurn: int(row.CurrentTurn),
Status: GameStatus(row.Status), Status: Status(row.Status),
} }
if err := g.BoardFromJSON(row.Board); err != nil { 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 { func playersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows)) players := make([]*Player, 0, len(rows))
for _, row := range rows { for _, row := range rows {
player := &Player{ p := &Player{
Nickname: row.Nickname, Nickname: row.Nickname,
Color: int(row.Color), Color: int(row.Color),
} }
if row.UserID != nil { if row.UserID != nil {
player.UserID = row.UserID p.UserID = row.UserID
player.ID = PlayerID(*row.UserID) p.ID = player.ID(*row.UserID)
} else if row.GuestPlayerID != nil { } else if row.GuestPlayerID != nil {
player.ID = PlayerID(*row.GuestPlayerID) p.ID = player.ID(*row.GuestPlayerID)
} }
players = append(players, player) players = append(players, p)
} }
return players return players
} }

View File

@@ -1,79 +1,78 @@
package game package connect4
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"sync" "sync"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/player"
) )
type PlayerSession struct { type PlayerSession struct {
Player *Player Player *Player
} }
type GameStore struct { type Store struct {
games map[string]*GameInstance games map[string]*Instance
gamesMu sync.RWMutex gamesMu sync.RWMutex
queries *repository.Queries queries *repository.Queries
notifyFunc func(gameID string) notifyFunc func(gameID string)
} }
func NewGameStore(queries *repository.Queries) *GameStore { func NewStore(queries *repository.Queries) *Store {
return &GameStore{ return &Store{
games: make(map[string]*GameInstance), games: make(map[string]*Instance),
queries: queries, queries: queries,
} }
} }
func (gs *GameStore) SetNotifyFunc(f func(gameID string)) { func (s *Store) SetNotifyFunc(f func(gameID string)) {
gs.notifyFunc = f s.notifyFunc = f
} }
func (gs *GameStore) makeNotify(gameID string) func() { func (s *Store) makeNotify(gameID string) func() {
return func() { return func() {
if gs.notifyFunc != nil { if s.notifyFunc != nil {
gs.notifyFunc(gameID) s.notifyFunc(gameID)
} }
} }
} }
func (gs *GameStore) Create() *GameInstance { func (s *Store) Create() *Instance {
id := GenerateID(4) id := player.GenerateID(4)
gi := NewGameInstance(id) gi := NewInstance(id)
gi.queries = gs.queries gi.queries = s.queries
gi.notify = gs.makeNotify(id) gi.notify = s.makeNotify(id)
gs.gamesMu.Lock() s.gamesMu.Lock()
gs.games[id] = gi s.games[id] = gi
gs.gamesMu.Unlock() s.gamesMu.Unlock()
if gs.queries != nil { if s.queries != nil {
gi.save() //nolint:errcheck gi.save() //nolint:errcheck
} }
return gi return gi
} }
func (gs *GameStore) Get(id string) (*GameInstance, bool) { func (s *Store) Get(id string) (*Instance, bool) {
gs.gamesMu.RLock() s.gamesMu.RLock()
gi, ok := gs.games[id] gi, ok := s.games[id]
gs.gamesMu.RUnlock() s.gamesMu.RUnlock()
if ok { if ok {
return gi, true return gi, true
} }
if gs.queries == nil { if s.queries == nil {
return nil, false return nil, false
} }
g, err := loadGame(gs.queries, id) g, err := loadGame(s.queries, id)
if err != nil || g == nil { if err != nil || g == nil {
return nil, false return nil, false
} }
players, _ := loadGamePlayers(gs.queries, id) players, _ := loadGamePlayers(s.queries, id)
for _, p := range players { for _, p := range players {
switch p.Color { switch p.Color {
case 1: case 1:
@@ -83,57 +82,51 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) {
} }
} }
gi = &GameInstance{ gi = &Instance{
game: g, game: g,
queries: gs.queries, queries: s.queries,
notify: gs.makeNotify(id), notify: s.makeNotify(id),
} }
gs.gamesMu.Lock() s.gamesMu.Lock()
gs.games[id] = gi s.games[id] = gi
gs.gamesMu.Unlock() s.gamesMu.Unlock()
return gi, true return gi, true
} }
func (gs *GameStore) Delete(id string) error { func (s *Store) Delete(id string) error {
gs.gamesMu.Lock() s.gamesMu.Lock()
delete(gs.games, id) delete(s.games, id)
gs.gamesMu.Unlock() s.gamesMu.Unlock()
if gs.queries != nil { if s.queries != nil {
return gs.queries.DeleteGame(context.Background(), id) return s.queries.DeleteGame(context.Background(), id)
} }
return nil return nil
} }
func GenerateID(size int) string { type Instance struct {
b := make([]byte, size)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
type GameInstance struct {
game *Game game *Game
gameMu sync.RWMutex gameMu sync.RWMutex
notify func() notify func()
queries *repository.Queries queries *repository.Queries
} }
func NewGameInstance(id string) *GameInstance { func NewInstance(id string) *Instance {
return &GameInstance{ return &Instance{
game: NewGame(id), game: NewGame(id),
notify: func() {}, notify: func() {},
} }
} }
func (gi *GameInstance) ID() string { func (gi *Instance) ID() string {
gi.gameMu.RLock() gi.gameMu.RLock()
defer gi.gameMu.RUnlock() defer gi.gameMu.RUnlock()
return gi.game.ID return gi.game.ID
} }
func (gi *GameInstance) Join(ps *PlayerSession) bool { func (gi *Instance) Join(ps *PlayerSession) bool {
gi.gameMu.Lock() gi.gameMu.Lock()
defer gi.gameMu.Unlock() defer gi.gameMu.Unlock()
@@ -160,13 +153,13 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
return true return true
} }
func (gi *GameInstance) GetGame() *Game { func (gi *Instance) GetGame() *Game {
gi.gameMu.RLock() gi.gameMu.RLock()
defer gi.gameMu.RUnlock() defer gi.gameMu.RUnlock()
return gi.game return gi.game
} }
func (gi *GameInstance) GetPlayerColor(pid PlayerID) int { func (gi *Instance) GetPlayerColor(pid player.ID) int {
gi.gameMu.RLock() gi.gameMu.RLock()
defer gi.gameMu.RUnlock() defer gi.gameMu.RUnlock()
for _, p := range gi.game.Players { for _, p := range gi.game.Players {
@@ -177,7 +170,7 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
return 0 return 0
} }
func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { func (gi *Instance) CreateRematch(s *Store) *Instance {
gi.gameMu.Lock() gi.gameMu.Lock()
defer gi.gameMu.Unlock() defer gi.gameMu.Unlock()
@@ -185,13 +178,13 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
return nil return nil
} }
newGI := gs.Create() newGI := s.Create()
newID := newGI.ID() newID := newGI.ID()
gi.game.RematchGameID = &newID gi.game.RematchGameID = &newID
if gi.queries != nil { if gi.queries != nil {
if err := gi.save(); err != nil { if err := gi.save(); err != nil {
gs.Delete(newID) //nolint:errcheck s.Delete(newID) //nolint:errcheck
gi.game.RematchGameID = nil gi.game.RematchGameID = nil
return nil return nil
} }
@@ -201,7 +194,7 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
return newGI return newGI
} }
func (gi *GameInstance) DropPiece(col int, playerColor int) bool { func (gi *Instance) DropPiece(col int, playerColor int) bool {
gi.gameMu.Lock() gi.gameMu.Lock()
defer gi.gameMu.Unlock() defer gi.gameMu.Unlock()

View File

@@ -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 { type Player struct {
ID PlayerID ID player.ID
UserID *string // UUID for authenticated users, nil for guests UserID *string // UUID for authenticated users, nil for guests
Nickname string Nickname string
Color int // 1 = Red, 2 = Yellow Color int // 1 = Red, 2 = Yellow
} }
type GameStatus int type Status int
const ( const (
StatusWaitingForPlayer GameStatus = iota StatusWaitingForPlayer Status = iota
StatusInProgress StatusInProgress
StatusWon StatusWon
StatusDraw StatusDraw
@@ -25,7 +36,7 @@ type Game struct {
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow 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) Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
CurrentTurn int // 1 or 2 (matches player color) CurrentTurn int // 1 or 2 (matches player color)
Status GameStatus Status Status
Winner *Player Winner *Player
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
RematchGameID *string // ID of the rematch game, if one was created 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) 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"`
}

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/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). # Works from the repo (builds first) or from an extracted tarball (pre-built binary).
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
INSTALL_DIR="/opt/c4" INSTALL_DIR="/opt/games"
BINARY="$ROOT_DIR/c4" BINARY="$ROOT_DIR/games"
# If Go is available and we have source, build fresh # If Go is available and we have source, build fresh
if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then 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) (cd "$ROOT_DIR" && go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify)
echo "Building binary..." echo "Building binary..."
(cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o c4 .) (cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o games .)
fi fi
if [[ ! -f "$BINARY" ]]; then if [[ ! -f "$BINARY" ]]; then
@@ -22,10 +22,10 @@ if [[ ! -f "$BINARY" ]]; then
fi fi
echo "Installing to $INSTALL_DIR..." 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..." echo "Restarting service..."
systemctl restart c4.service systemctl restart games.service
echo "Done. Status:" echo "Done. Status:"
systemctl status c4.service --no-pager systemctl status games.service --no-pager

View File

@@ -1,13 +1,13 @@
[Unit] [Unit]
Description=C4 Game Lobby Description=Games Lobby
After=network.target After=network.target
[Service] [Service]
Type=simple Type=simple
User=games User=games
Group=games Group=games
WorkingDirectory=/opt/c4 WorkingDirectory=/opt/games
ExecStart=/opt/c4/c4 ExecStart=/opt/games/games
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
@@ -17,7 +17,7 @@ Environment=PORT=8080
NoNewPrivileges=true NoNewPrivileges=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true
ReadWritePaths=/opt/c4 ReadWritePaths=/opt/games
PrivateTmp=true PrivateTmp=true
[Install] [Install]

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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. # base64-encode it, and split into 25MB chunks for transfer.
set -euo pipefail set -euo pipefail
@@ -7,14 +7,14 @@ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_DIR" cd "$REPO_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S) TIMESTAMP=$(date +%Y%m%d-%H%M%S)
TARBALL="c4-deploy-${TIMESTAMP}.tar.gz" TARBALL="games-deploy-${TIMESTAMP}.tar.gz"
BASE64_FILE="c4-deploy-${TIMESTAMP}_b64.txt" BASE64_FILE="games-deploy-${TIMESTAMP}_b64.txt"
#============================================================================== #==============================================================================
# Clean previous artifacts # Clean previous artifacts
#============================================================================== #==============================================================================
echo "--- Cleaning old 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 # Build
@@ -23,18 +23,18 @@ echo "--- Building CSS ---"
go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
echo "--- Building binary (linux/amd64) ---" 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 # Verify required files
#============================================================================== #==============================================================================
echo "--- Verifying files ---" echo "--- Verifying files ---"
REQUIRED_FILES=( REQUIRED_FILES=(
c4 games
deploy/setup.sh deploy/setup.sh
deploy/deploy.sh deploy/deploy.sh
deploy/reassemble.sh deploy/reassemble.sh
deploy/c4.service deploy/games.service
) )
for f in "${REQUIRED_FILES[@]}"; do for f in "${REQUIRED_FILES[@]}"; do
if [[ ! -f "$f" ]]; then if [[ ! -f "$f" ]]; then
@@ -48,12 +48,12 @@ done
# Create tarball # Create tarball
#============================================================================== #==============================================================================
echo "--- Creating tarball ---" echo "--- Creating tarball ---"
tar -czf "/tmp/${TARBALL}" --transform 's,^,c4/,' \ tar -czf "/tmp/${TARBALL}" --transform 's,^,games/,' \
c4 \ games \
deploy/setup.sh \ deploy/setup.sh \
deploy/deploy.sh \ deploy/deploy.sh \
deploy/reassemble.sh \ deploy/reassemble.sh \
deploy/c4.service deploy/games.service
mv "/tmp/${TARBALL}" . mv "/tmp/${TARBALL}" .
echo " -> ${TARBALL} ($(du -h "${TARBALL}" | cut -f1))" 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 " -> ${BASE64_FILE} ($(du -h "${BASE64_FILE}" | cut -f1))"
echo "--- Splitting into 25MB chunks ---" 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}" rm -f "${BASE64_FILE}"
CHUNKS=(c4-deploy-${TIMESTAMP}_b64_part*.txt) CHUNKS=(games-deploy-${TIMESTAMP}_b64_part*.txt)
echo " -> ${#CHUNKS[@]} chunk(s):" echo " -> ${#CHUNKS[@]} chunk(s):"
for chunk in "${CHUNKS[@]}"; do for chunk in "${CHUNKS[@]}"; do
echo " $chunk ($(du -h "$chunk" | cut -f1))" echo " $chunk ($(du -h "$chunk" | cut -f1))"
@@ -83,5 +83,5 @@ echo "=== Package Complete ==="
echo "" echo ""
echo "Transfer the chunk files to the target server, then run:" echo "Transfer the chunk files to the target server, then run:"
echo " ./reassemble.sh" echo " ./reassemble.sh"
echo " cd ~/c4 && sudo ./deploy/setup.sh # first time only" echo " cd ~/games && sudo ./deploy/setup.sh # first time only"
echo " cd ~/c4 && sudo ./deploy/deploy.sh" echo " cd ~/games && sudo ./deploy/deploy.sh"

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/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. # Expects chunk files in the current directory.
set -euo pipefail set -euo pipefail
cd "$HOME" cd "$HOME"
echo "=== C4 Deployment Reassembler ===" echo "=== Games Deployment Reassembler ==="
echo "Working directory: $HOME" echo "Working directory: $HOME"
echo "" echo ""
@@ -14,10 +14,10 @@ echo ""
#============================================================================== #==============================================================================
echo "--- Finding chunk files ---" 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 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 exit 1
fi fi
@@ -32,8 +32,8 @@ done
echo "" echo ""
echo "--- Reassembling chunks ---" echo "--- Reassembling chunks ---"
TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/c4-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/') TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/games-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/')
TARBALL="c4-deploy-${TIMESTAMP}.tar.gz" TARBALL="games-deploy-${TIMESTAMP}.tar.gz"
COMBINED="combined_b64.txt" COMBINED="combined_b64.txt"
echo "Concatenating chunks..." echo "Concatenating chunks..."
@@ -58,12 +58,12 @@ fi
echo "" echo ""
echo "--- Archiving existing source ---" echo "--- Archiving existing source ---"
if [[ -d c4 ]]; then if [[ -d games ]]; then
rm -rf c4.bak rm -rf games.bak
mv c4 c4.bak mv games games.bak
echo " -> Moved c4 -> c4.bak" echo " -> Moved games -> games.bak"
else else
echo " -> No existing c4 directory" echo " -> No existing games directory"
fi fi
#============================================================================== #==============================================================================
@@ -73,7 +73,7 @@ echo ""
echo "--- Extracting tarball ---" echo "--- Extracting tarball ---"
tar -xzf "$TARBALL" tar -xzf "$TARBALL"
echo " -> Extracted to ~/c4" echo " -> Extracted to ~/games"
#============================================================================== #==============================================================================
# Cleanup # Cleanup
@@ -91,6 +91,6 @@ echo ""
echo "=== Reassembly Complete ===" echo "=== Reassembly Complete ==="
echo "" echo ""
echo "Next steps:" echo "Next steps:"
echo " cd ~/c4" echo " cd ~/games"
echo " sudo ./deploy/setup.sh # first time only" echo " sudo ./deploy/setup.sh # first time only"
echo " sudo ./deploy/deploy.sh" echo " sudo ./deploy/deploy.sh"

View File

@@ -10,20 +10,20 @@ fi
# Create system user if it doesn't exist # Create system user if it doesn't exist
if ! id -u games &>/dev/null; then 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" echo "Created system user: games"
else else
echo "User 'games' already exists" echo "User 'games' already exists"
fi fi
# Ensure install directory exists with correct ownership # 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/games
install -d -o games -g games -m 755 /opt/c4/data install -d -o games -g games -m 755 /opt/games/data
# Install systemd unit # Install systemd unit
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 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 daemon-reload
systemctl enable c4.service systemctl enable games.service
echo "Setup complete. Run deploy.sh to build and start the service." echo "Setup complete. Run deploy.sh to build and start the service."

View File

@@ -1,7 +1,7 @@
services: services:
c4: games:
build: . build: .
container_name: c4 container_name: games
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"

View File

@@ -8,9 +8,10 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/auth" "github.com/ryanhamamura/games/auth"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/c4/features/auth/pages" "github.com/ryanhamamura/games/features/auth/pages"
appsessions "github.com/ryanhamamura/games/sessions"
) )
type LoginSignals struct { type LoginSignals struct {
@@ -65,9 +66,9 @@ func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http
} }
sessions.RenewToken(r.Context()) //nolint:errcheck 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(), "username", user.Username)
sessions.Put(r.Context(), "nickname", user.Username) sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
redirectURL := "/" redirectURL := "/"
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" { 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.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(), "username", user.Username)
sessions.Put(r.Context(), "nickname", user.Username) sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
redirectURL := "/" redirectURL := "/"
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" { if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {

View File

@@ -1,7 +1,7 @@
package pages package pages
import ( import (
"github.com/ryanhamamura/c4/features/common/layouts" "github.com/ryanhamamura/games/features/common/layouts"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )

View File

@@ -1,7 +1,7 @@
package pages package pages
import ( import (
"github.com/ryanhamamura/c4/features/common/layouts" "github.com/ryanhamamura/games/features/common/layouts"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )

View File

@@ -5,7 +5,7 @@ import (
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "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) { func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) {

View File

@@ -3,11 +3,11 @@ package components
import ( import (
"fmt" "fmt"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/connect4"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
templ Board(g *game.Game, myColor int) { templ Board(g *connect4.Game, myColor int) {
<div id="c4-board" class="board"> <div id="c4-board" class="board">
for col := 0; col < 7; col++ { for col := 0; col < 7; col++ {
@column(g, col, myColor) @column(g, col, myColor)
@@ -15,8 +15,8 @@ templ Board(g *game.Game, myColor int) {
</div> </div>
} }
templ column(g *game.Game, colIdx int, myColor int) { templ column(g *connect4.Game, colIdx int, myColor int) {
if g.Status == game.StatusInProgress && myColor == g.CurrentTurn { if g.Status == connect4.StatusInProgress && myColor == g.CurrentTurn {
<div <div
class="column clickable" class="column clickable"
data-on:click={ datastar.PostSSE("/games/%s/drop?col=%d", g.ID, colIdx) } data-on:click={ datastar.PostSSE("/games/%s/drop?col=%d", g.ID, colIdx) }
@@ -34,14 +34,14 @@ templ column(g *game.Game, colIdx int, myColor int) {
} }
} }
templ cell(g *game.Game, row int, col int) { templ cell(g *connect4.Game, row int, col int) {
<div class={ cellClass(g, row, col) }></div> <div class={ cellClass(g, row, col) }></div>
} }
func cellClass(g *game.Game, row, col int) string { func cellClass(g *connect4.Game, row, col int) string {
color := g.Board[row][col] color := g.Board[row][col]
activeTurn := 0 activeTurn := 0
if g.Status == game.StatusInProgress { if g.Status == connect4.StatusInProgress {
activeTurn = g.CurrentTurn activeTurn = g.CurrentTurn
} }

View File

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

View File

@@ -1,12 +1,12 @@
package components package components
import ( import (
"github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/connect4"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
templ StatusBanner(g *game.Game, myColor int) { templ StatusBanner(g *connect4.Game, myColor int) {
<div id="c4-status" class={ statusClass(g, myColor) }> <div id="c4-status" class={ statusClass(g, myColor) }>
{ statusMessage(g, myColor) } { statusMessage(g, myColor) }
if g.IsFinished() { if g.IsFinished() {
@@ -30,7 +30,7 @@ templ StatusBanner(g *game.Game, myColor int) {
</div> </div>
} }
templ PlayerInfo(g *game.Game, myColor int) { templ PlayerInfo(g *connect4.Game, myColor int) {
<div id="c4-players" class="flex gap-8 mb-2"> <div id="c4-players" class="flex gap-8 mb-2">
for _, info := range playerInfoPairs(g, myColor) { for _, info := range playerInfoPairs(g, myColor) {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -61,36 +61,36 @@ script copyToClipboard(url string) {
navigator.clipboard.writeText(url) navigator.clipboard.writeText(url)
} }
func statusClass(g *game.Game, myColor int) string { func statusClass(g *connect4.Game, myColor int) string {
switch g.Status { switch g.Status {
case game.StatusWaitingForPlayer: case connect4.StatusWaitingForPlayer:
return "alert bg-base-200 text-xl font-bold" return "alert bg-base-200 text-xl font-bold"
case game.StatusInProgress: case connect4.StatusInProgress:
if g.CurrentTurn == myColor { if g.CurrentTurn == myColor {
return "alert alert-success text-xl font-bold" return "alert alert-success text-xl font-bold"
} }
return "alert bg-base-200 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 { if g.Winner != nil && g.Winner.Color == myColor {
return "alert alert-success text-xl font-bold" return "alert alert-success text-xl font-bold"
} }
return "alert alert-error 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 alert-warning text-xl font-bold"
} }
return "alert bg-base-200 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 { switch g.Status {
case game.StatusWaitingForPlayer: case connect4.StatusWaitingForPlayer:
return "Waiting for opponent..." return "Waiting for opponent..."
case game.StatusInProgress: case connect4.StatusInProgress:
if g.CurrentTurn == myColor { if g.CurrentTurn == myColor {
return "Your turn!" return "Your turn!"
} }
return opponentName(g, myColor) + "'s turn" return opponentName(g, myColor) + "'s turn"
case game.StatusWon: case connect4.StatusWon:
if g.Winner != nil && g.Winner.Color == myColor { if g.Winner != nil && g.Winner.Color == myColor {
return "You win!" return "You win!"
} }
@@ -98,13 +98,13 @@ func statusMessage(g *game.Game, myColor int) string {
return g.Winner.Nickname + " wins!" return g.Winner.Nickname + " wins!"
} }
return "Game over" return "Game over"
case game.StatusDraw: case connect4.StatusDraw:
return "It's a draw!" return "It's a draw!"
} }
return "" return ""
} }
func opponentName(g *game.Game, myColor int) string { func opponentName(g *connect4.Game, myColor int) string {
for _, p := range g.Players { for _, p := range g.Players {
if p != nil && p.Color != myColor { if p != nil && p.Color != myColor {
return p.Nickname return p.Nickname
@@ -118,7 +118,7 @@ type playerInfoData struct {
Label string Label string
} }
func playerInfoPairs(g *game.Game, myColor int) []playerInfoData { func playerInfoPairs(g *connect4.Game, myColor int) []playerInfoData {
var result []playerInfoData var result []playerInfoData
var myName, oppName string var myName, oppName string

View File

@@ -1,12 +1,9 @@
package c4game package c4game
import ( import (
"context" "fmt"
"encoding/json"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
@@ -14,13 +11,36 @@ import (
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/chat"
"github.com/ryanhamamura/c4/features/c4game/components" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/game" "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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -30,29 +50,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
if playerID == "" { userID := sessions.GetUserID(sm, r)
playerID = game.PlayerID(game.GenerateID(8)) nickname := sessions.GetNickname(sm, r)
sessions.Put(r.Context(), "player_id", string(playerID))
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
nickname := sessions.GetString(r.Context(), "nickname")
// Auto-join if player has a nickname but isn't in the game yet // Auto-join if player has a nickname but isn't in the game yet
if nickname != "" && gi.GetPlayerColor(playerID) == 0 { if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ p := &connect4.Player{
ID: playerID, ID: playerID,
Nickname: nickname, Nickname: nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
gi.Join(&game.PlayerSession{Player: player}) gi.Join(&connect4.PlayerSession{Player: p})
} }
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
@@ -61,31 +72,27 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
// Player not in game // Player not in game
isGuest := r.URL.Query().Get("guest") == "1" isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest { if userID == "" && !isGuest {
// Show join prompt (login vs guest)
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
return return
} }
// Show nickname prompt
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil { if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
return return
} }
// Player is in the game — render full game page
g := gi.GetGame() g := gi.GetGame()
chatMsgs := loadChatMessages(queries, gameID) room := chat.NewPersistentRoom(nil, "", queries, gameID)
msgs := chatToComponents(chatMsgs)
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) 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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -95,37 +102,37 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression( sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)), datastar.WithBrotli(datastar.WithBrotliLevel(5)),
)) ))
// Load initial chat messages chatCfg := c4ChatConfig(gameID)
chatMsgs := loadChatMessages(queries, gameID) room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
var chatMu sync.Mutex
chatMessages := chatToComponents(chatMsgs)
// Send initial render of all components patchAll := func() error {
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) 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 // Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64) gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh) gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
if err != nil { if err != nil {
return return
} }
defer gameSub.Unsubscribe() //nolint:errcheck defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages // Subscribe to chat messages
chatCh := make(chan *nats.Msg, 64) chatCh, chatSub, err := room.Subscribe()
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
if err != nil { if err != nil {
return return
} }
@@ -137,30 +144,12 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
case <-ctx.Done(): case <-ctx.Done():
return return
case <-gameCh: case <-gameCh:
// Re-read player color in case we just joined if err := patchAll(); err != nil {
myColor = gi.GetPlayerColor(playerID) return
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) }
case msg := <-chatCh: case msg := <-chatCh:
var uiMsg game.ChatMessage room.Receive(msg.Data)
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil { if err := patchAll(); 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 {
return 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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -185,12 +174,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
if myColor == 0 { if myColor == 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -198,14 +182,11 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
} }
gi.DropPiece(col, myColor) gi.DropPiece(col, myColor)
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
// Return empty SSE response.
datastar.NewSSE(w, r) datastar.NewSSE(w, r)
} }
} }
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -229,12 +210,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
if myColor == 0 { if myColor == 0 {
datastar.NewSSE(w, r) datastar.NewSSE(w, r)
@@ -250,28 +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, Nickname: nick,
Color: myColor, Slot: myColor - 1,
Message: signals.ChatMsg, Message: signals.ChatMsg,
Time: time.Now().UnixMilli(), Time: time.Now().UnixMilli(),
} }
saveChatMessage(queries, gameID, cm) 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 := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
} }
} }
func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -296,23 +266,20 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
return 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")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
if userID != "" {
playerID = game.PlayerID(userID)
}
if gi.GetPlayerColor(playerID) == 0 { if gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ p := &connect4.Player{
ID: playerID, ID: playerID,
Nickname: signals.Nickname, Nickname: signals.Nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
gi.Join(&game.PlayerSession{Player: player}) gi.Join(&connect4.PlayerSession{Player: p})
} }
sse := datastar.NewSSE(w, r) 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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") 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
}

View File

@@ -1,35 +1,43 @@
package pages package pages
import ( import (
"github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/games/chat"
sharedcomponents "github.com/ryanhamamura/c4/features/common/components" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/c4/features/common/layouts" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/game" "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" "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") { @layouts.Base("Connect 4") {
<main <main
class="flex flex-col items-center gap-4 p-4" class="flex flex-col items-center gap-4 p-4"
data-signals="{chatMsg: ''}" data-signals="{chatMsg: ''}"
data-init={ datastar.GetSSE("/games/%s/events", g.ID) } data-init={ datastar.GetSSE("/games/%s/events", g.ID) }
> >
@sharedcomponents.BackToLobby() @GameContent(g, myColor, messages, chatCfg)
@sharedcomponents.StealthTitle("text-3xl font-bold")
@components.PlayerInfo(g, myColor)
@components.StatusBanner(g, myColor)
<div class="c4-game-area">
@components.Board(g, myColor)
@components.Chat(messages, g.ID)
</div>
if g.Status == game.StatusWaitingForPlayer {
@components.InviteLink(g.ID)
}
</main> </main>
} }
} }
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
<div id="game-content">
@sharedcomponents.BackToLobby()
@sharedcomponents.StealthTitle("text-3xl font-bold")
@components.PlayerInfo(g, myColor)
@components.StatusBanner(g, myColor)
<div class="c4-game-area">
@components.Board(g, myColor)
@chatcomponents.Chat(messages, chatCfg)
</div>
if g.Status == connect4.StatusWaitingForPlayer {
@components.InviteLink(g.ID)
}
</div>
}
templ JoinPage(gameID string) { templ JoinPage(gameID string) {
@layouts.Base("Connect 4 - Join") { @layouts.Base("Connect 4 - Join") {
@sharedcomponents.GameJoinPrompt( @sharedcomponents.GameJoinPrompt(

View File

@@ -6,13 +6,13 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/db/repository"
) )
func SetupRoutes( func SetupRoutes(
router chi.Router, router chi.Router,
store *game.GameStore, store *connect4.Store,
nc *nats.Conn, nc *nats.Conn,
sessions *scs.SessionManager, sessions *scs.SessionManager,
queries *repository.Queries, queries *repository.Queries,

View File

@@ -1,6 +1,6 @@
package layouts package layouts
import "github.com/ryanhamamura/c4/config" import "github.com/ryanhamamura/games/config"
templ Base(title string) { templ Base(title string) {
<!DOCTYPE html> <!DOCTYPE html>

View File

@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/connect4"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
@@ -46,10 +46,10 @@ templ gameListEntry(g GameListItem) {
} }
func statusText(g GameListItem) string { func statusText(g GameListItem) string {
switch game.GameStatus(g.Status) { switch connect4.Status(g.Status) {
case game.StatusWaitingForPlayer: case connect4.StatusWaitingForPlayer:
return "Waiting for opponent" return "Waiting for opponent"
case game.StatusInProgress: case connect4.StatusInProgress:
if g.IsMyTurn { if g.IsMyTurn {
return "Your turn!" return "Your turn!"
} }
@@ -59,10 +59,10 @@ func statusText(g GameListItem) string {
} }
func statusClass(g GameListItem) string { func statusClass(g GameListItem) string {
switch game.GameStatus(g.Status) { switch connect4.Status(g.Status) {
case game.StatusWaitingForPlayer: case connect4.StatusWaitingForPlayer:
return "text-sm opacity-60" return "text-sm opacity-60"
case game.StatusInProgress: case connect4.StatusInProgress:
if g.IsMyTurn { if g.IsMyTurn {
return "text-sm text-success font-bold" return "text-sm text-success font-bold"
} }

View File

@@ -7,11 +7,12 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/connect4"
lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/c4/features/lobby/pages" lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/features/lobby/pages"
"github.com/ryanhamamura/c4/snake" appsessions "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -21,7 +22,7 @@ import (
// HandleLobbyPage renders the main lobby page with active games for logged-in users. // 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 { func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, snakeStore *snake.SnakeStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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") username := sessions.GetString(r.Context(), "username")
isLoggedIn := userID != "" 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. // 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) { return func(w http.ResponseWriter, r *http.Request) {
type Signals struct { type Signals struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
@@ -95,7 +96,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.
return return
} }
sessions.Put(r.Context(), "nickname", signals.Nickname) sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
gi := store.Create() gi := store.Create()
sse := datastar.NewSSE(w, r) 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. // 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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
if gameID == "" { if gameID == "" {
@@ -137,7 +138,7 @@ func HandleCreateSnakeGame(snakeStore *snake.SnakeStore, sessions *scs.SessionMa
return return
} }
sessions.Put(r.Context(), "nickname", signals.Nickname) sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
mode := snake.ModeMultiplayer mode := snake.ModeMultiplayer
if r.URL.Query().Get("mode") == "solo" { if r.URL.Query().Get("mode") == "solo" {

View File

@@ -3,10 +3,10 @@ package pages
import ( import (
"fmt" "fmt"
"github.com/ryanhamamura/c4/features/common/components" "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts" "github.com/ryanhamamura/games/features/common/layouts"
lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components" lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )

View File

@@ -1,6 +1,6 @@
package pages 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. // SnakeGameListItem represents a joinable snake game in the lobby.
type SnakeGameListItem struct { type SnakeGameListItem struct {

View File

@@ -2,9 +2,9 @@
package lobby package lobby
import ( import (
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -14,7 +14,7 @@ func SetupRoutes(
router chi.Router, router chi.Router,
queries *repository.Queries, queries *repository.Queries,
sessions *scs.SessionManager, sessions *scs.SessionManager,
store *game.GameStore, store *connect4.Store,
snakeStore *snake.SnakeStore, snakeStore *snake.SnakeStore,
) { ) {
router.Get("/", HandleLobbyPage(queries, sessions, snakeStore)) router.Get("/", HandleLobbyPage(queries, sessions, snakeStore))

View File

@@ -3,7 +3,7 @@ package components
import ( import (
"fmt" "fmt"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
) )
func cellSizeForGrid(width, height int) int { func cellSizeForGrid(width, height int) int {

View File

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

View File

@@ -5,8 +5,8 @@ import (
"math" "math"
"time" "time"
"github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )

View File

@@ -1,36 +1,39 @@
package snakegame package snakegame
import ( import (
"encoding/json" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"sync"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/features/snakegame/components" "github.com/ryanhamamura/games/chat"
"github.com/ryanhamamura/c4/features/snakegame/pages" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
) )
func getPlayerID(sessions *scs.SessionManager, r *http.Request) snake.PlayerID { func snakeChatColor(slot int) string {
pid := sessions.GetString(r.Context(), "player_id") if slot >= 0 && slot < len(snake.SnakeColors) {
if pid == "" { return snake.SnakeColors[slot]
pid = game.GenerateID(8)
sessions.Put(r.Context(), "player_id", pid)
} }
userID := sessions.GetString(r.Context(), "user_id") return "#666"
if userID != "" {
return snake.PlayerID(userID)
}
return snake.PlayerID(pid)
} }
func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { func snakeChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "snake",
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
Color: snakeChatColor,
StopKeyPropagation: true,
}
}
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -39,26 +42,25 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
nickname := sessions.GetString(r.Context(), "nickname") nickname := sessions.GetNickname(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
// Auto-join if nickname exists and not already in game // Auto-join if nickname exists and not already in game
if nickname != "" && si.GetPlayerSlot(playerID) < 0 { if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{ p := &snake.Player{
ID: playerID, ID: playerID,
Nickname: nickname, Nickname: nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
si.Join(player) si.Join(p)
} }
mySlot := si.GetPlayerSlot(playerID) mySlot := si.GetPlayerSlot(playerID)
if mySlot < 0 { if mySlot < 0 {
// Not in game yet
isGuest := r.URL.Query().Get("guest") == "1" isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest { if userID == "" && !isGuest {
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
@@ -73,13 +75,13 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
} }
sg := si.GetGame() sg := si.GetGame()
if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil { if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
} }
} }
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -88,28 +90,47 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
mySlot := si.GetPlayerSlot(playerID) mySlot := si.GetPlayerSlot(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression( sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)), datastar.WithBrotli(datastar.WithBrotliLevel(5)),
)) ))
// Send initial render chatCfg := snakeChatConfig(gameID)
// Chat room (multiplayer only)
var room *chat.Room
sg := si.GetGame() 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 { if sg.Mode == snake.ModeMultiplayer {
sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck room = chat.NewRoom(nc, snake.ChatSubject(gameID))
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown { }
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
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 // Subscribe to game updates via NATS
gameCh := make(chan *nats.Msg, 64) gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh) gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
if err != nil { if err != nil {
return return
} }
@@ -118,12 +139,9 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
// Chat subscription (multiplayer only) // Chat subscription (multiplayer only)
var chatCh chan *nats.Msg var chatCh chan *nats.Msg
var chatSub *nats.Subscription var chatSub *nats.Subscription
var chatMessages []components.ChatMessage
var chatMu sync.Mutex
if sg.Mode == snake.ModeMultiplayer { if room != nil {
chatCh = make(chan *nats.Msg, 64) chatCh, chatSub, err = room.Subscribe()
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh)
if err != nil { if err != nil {
return return
} }
@@ -146,19 +164,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
} }
} }
drained: drained:
si, ok = snakeStore.Get(gameID) if err := patchAll(); err != nil {
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 return
} }
@@ -166,20 +172,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
if msg == nil { if msg == nil {
continue continue
} }
var cm components.ChatMessage room.Receive(msg.Data)
if err := json.Unmarshal(msg.Data, &cm); err != nil { if err := patchAll(); 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 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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -196,7 +190,7 @@ func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManag
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
slot := si.GetPlayerSlot(playerID) slot := si.GetPlayerSlot(playerID)
if slot < 0 { if slot < 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -219,7 +213,7 @@ type chatSignals struct {
ChatMsg string `json:"chatMsg"` ChatMsg string `json:"chatMsg"`
} }
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -238,7 +232,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
slot := si.GetPlayerSlot(playerID) slot := si.GetPlayerSlot(playerID)
if slot < 0 { if slot < 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -246,16 +240,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
} }
sg := si.GetGame() sg := si.GetGame()
cm := components.ChatMessage{ msg := chat.Message{
Nickname: sg.Players[slot].Nickname, Nickname: sg.Players[slot].Nickname,
Slot: slot, Slot: slot,
Message: signals.ChatMsg, Message: signals.ChatMsg,
} }
data, err := json.Marshal(cm)
if err != nil { room := chat.NewRoom(nc, snake.ChatSubject(gameID))
return room.Send(msg)
}
nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
@@ -266,7 +258,7 @@ type nicknameSignals struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
} }
func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -285,20 +277,20 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
return return
} }
sessions.Put(r.Context(), "nickname", signals.Nickname) sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
if si.GetPlayerSlot(playerID) < 0 { if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{ p := &snake.Player{
ID: playerID, ID: playerID,
Nickname: signals.Nickname, Nickname: signals.Nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
si.Join(player) si.Join(p)
} }
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
@@ -306,7 +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) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)

View File

@@ -3,10 +3,12 @@ package pages
import ( import (
"fmt" "fmt"
"github.com/ryanhamamura/c4/features/common/components" "github.com/ryanhamamura/games/chat"
"github.com/ryanhamamura/c4/features/common/layouts" chatcomponents "github.com/ryanhamamura/games/chat/components"
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components" "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/c4/snake" "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" "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") { @layouts.Base("Snake") {
<main <main
class="snake-wrapper flex flex-col items-center gap-4 p-4" class="snake-wrapper flex flex-col items-center gap-4 p-4"
@@ -35,29 +37,35 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatM
data-on:keydown.throttle_100ms={ keydownScript(gameID) } data-on:keydown.throttle_100ms={ keydownScript(gameID) }
tabindex="0" tabindex="0"
> >
@components.BackToLobby() @GameContent(sg, mySlot, messages, chatCfg, gameID)
<h1 class="text-3xl font-bold">~~~~</h1>
@snakecomponents.PlayerList(sg, mySlot)
@snakecomponents.StatusBanner(sg, mySlot, gameID)
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
if sg.Mode == snake.ModeMultiplayer {
<div class="snake-game-area">
@snakecomponents.Board(sg)
@snakecomponents.Chat(messages, gameID)
</div>
} 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)
}
</main> </main>
} }
} }
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
<div id="game-content">
@components.BackToLobby()
<h1 class="text-3xl font-bold">~~~~</h1>
@snakecomponents.PlayerList(sg, mySlot)
@snakecomponents.StatusBanner(sg, mySlot, gameID)
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
if sg.Mode == snake.ModeMultiplayer {
<div class="snake-game-area">
@snakecomponents.Board(sg)
@chatcomponents.Chat(messages, chatCfg)
</div>
} 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)
}
</div>
}
templ JoinPage(gameID string) { templ JoinPage(gameID string) {
@layouts.Base("Snake - Join") { @layouts.Base("Snake - Join") {
@components.GameJoinPrompt( @components.GameJoinPrompt(

View File

@@ -6,7 +6,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
) )
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) { func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) {

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/ryanhamamura/c4 module github.com/ryanhamamura/games
go 1.25.4 go 1.25.4

View File

@@ -6,7 +6,7 @@ import (
stdlog "log" stdlog "log"
"os" "os"
"github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/games/config"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"

View File

@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/games/config"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"

24
main.go
View File

@@ -11,15 +11,15 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/db"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/c4/logging" "github.com/ryanhamamura/games/logging"
appnats "github.com/ryanhamamura/c4/nats" appnats "github.com/ryanhamamura/games/nats"
"github.com/ryanhamamura/c4/router" "github.com/ryanhamamura/games/router"
"github.com/ryanhamamura/c4/sessions" "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
@@ -71,14 +71,14 @@ func run(ctx context.Context) error {
defer cleanupNATS() defer cleanupNATS()
// Game stores // Game stores
store := game.NewGameStore(queries) store := connect4.NewStore(queries)
store.SetNotifyFunc(func(gameID string) { 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 := snake.NewSnakeStore(queries)
snakeStore.SetNotifyFunc(func(gameID string) { 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 // Router

18
player/player.go Normal file
View File

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

View File

@@ -7,14 +7,14 @@ import (
"net/http" "net/http"
"sync" "sync"
"github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/features/auth" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/c4/features/c4game" "github.com/ryanhamamura/games/features/auth"
"github.com/ryanhamamura/c4/features/lobby" "github.com/ryanhamamura/games/features/c4game"
"github.com/ryanhamamura/c4/features/snakegame" "github.com/ryanhamamura/games/features/lobby"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/features/snakegame"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -27,7 +27,7 @@ func SetupRoutes(
queries *repository.Queries, queries *repository.Queries,
sessions *scs.SessionManager, sessions *scs.SessionManager,
nc *nats.Conn, nc *nats.Conn,
store *game.GameStore, store *connect4.Store,
snakeStore *snake.SnakeStore, snakeStore *snake.SnakeStore,
assets embed.FS, assets embed.FS,
) { ) {

View File

@@ -1,4 +1,5 @@
// Package sessions configures the SCS session manager backed by SQLite. // Package sessions configures the SCS session manager and provides
// helpers for resolving player identity from the session.
package sessions package sessions
import ( import (
@@ -7,10 +8,19 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/ryanhamamura/games/player"
"github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2" "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. // SetupSessionManager creates a configured session manager backed by SQLite.
// Returns the manager and a cleanup function the caller should defer. // Returns the manager and a cleanup function the caller should defer.
func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
@@ -20,7 +30,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
sessionManager := scs.New() sessionManager := scs.New()
sessionManager.Store = store sessionManager.Store = store
sessionManager.Lifetime = 30 * 24 * time.Hour sessionManager.Lifetime = 30 * 24 * time.Hour
sessionManager.Cookie.Name = "c4_session" sessionManager.Cookie.Name = "games_session"
sessionManager.Cookie.Path = "/" sessionManager.Cookie.Path = "/"
sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.Secure = true sessionManager.Cookie.Secure = true
@@ -30,3 +40,28 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
return sessionManager, cleanup return sessionManager, cleanup
} }
// GetPlayerID returns the current player's identity from the session.
// Authenticated users get their user UUID; guests get a random ID that
// is generated and persisted on first access.
func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
pid := sm.GetString(r.Context(), 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)
}

View File

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

View File

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

View File

@@ -3,8 +3,19 @@ package snake
import ( import (
"encoding/json" "encoding/json"
"time" "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 type Direction int
const ( const (
@@ -78,10 +89,8 @@ const (
StatusFinished StatusFinished
) )
type PlayerID string
type Player struct { type Player struct {
ID PlayerID ID player.ID
UserID *string UserID *string
Nickname string Nickname string
Slot int // 0-7 Slot int // 0-7

View File

@@ -8,8 +8,8 @@ import (
"io/fs" "io/fs"
"testing" "testing"
"github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/games/db"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/db/repository"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"