Compare commits
1 Commits
v0.1.6
...
0e344de9c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e344de9c1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,7 +19,6 @@
|
|||||||
|
|
||||||
!.env.example
|
!.env.example
|
||||||
!LICENSE
|
!LICENSE
|
||||||
!AGENTS.md
|
|
||||||
|
|
||||||
!assets/**/*
|
!assets/**/*
|
||||||
|
|
||||||
|
|||||||
253
AGENTS.md
253
AGENTS.md
@@ -1,253 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
Instructions for AI coding agents working in this repository.
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
task live # Hot-reload dev server (templ + tailwind + air)
|
|
||||||
task build # Production build to bin/games
|
|
||||||
task run # Build and run server
|
|
||||||
|
|
||||||
# Quality
|
|
||||||
task test # Run all tests: go test ./...
|
|
||||||
task lint # Run linter: golangci-lint run
|
|
||||||
|
|
||||||
# Single test
|
|
||||||
go test -run TestName ./path/to/package
|
|
||||||
|
|
||||||
# Code generation
|
|
||||||
task build:templ # Compile .templ files
|
|
||||||
task build:styles # Build TailwindCSS
|
|
||||||
go generate ./... # Run sqlc for DB queries
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Rules
|
|
||||||
|
|
||||||
- **Never merge PRs without explicit user approval.** Create the PR, push changes, then wait.
|
|
||||||
- Always use PRs via `tea` CLI - never push directly to main.
|
|
||||||
- Write semantic commit messages focusing on "why" not "what".
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
games/
|
|
||||||
├── connect4/, snake/ # Game logic packages (pure Go)
|
|
||||||
├── features/ # Feature modules (handlers, routes, templates)
|
|
||||||
│ ├── auth/ # Login/register
|
|
||||||
│ ├── c4game/ # Connect 4 UI
|
|
||||||
│ ├── snakegame/ # Snake UI
|
|
||||||
│ ├── lobby/ # Game lobby
|
|
||||||
│ └── common/ # Shared components, layouts
|
|
||||||
├── chat/ # Reusable chat room (NATS + persistence)
|
|
||||||
├── db/ # SQLite, migrations, sqlc queries
|
|
||||||
├── assets/ # Static files (embedded)
|
|
||||||
└── config/, logging/, nats/, sessions/, router/ # Infrastructure
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
### Imports
|
|
||||||
|
|
||||||
Organize in three groups: stdlib, third-party, local. The linter enforces this.
|
|
||||||
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/connect4"
|
|
||||||
"github.com/ryanhamamura/games/db/repository"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
| Type | Convention | Examples |
|
|
||||||
|------|------------|----------|
|
|
||||||
| Files | lowercase, underscores | `config_dev.go`, `handlers.go` |
|
|
||||||
| HTTP handlers | `Handle` prefix | `HandleGamePage`, `HandleLogin` |
|
|
||||||
| Constructors | `New` prefix | `NewStore`, `NewRoom` |
|
|
||||||
| Getters | `Get` prefix | `GetPlayerID`, `GetGame` |
|
|
||||||
| Setup functions | `Setup` prefix | `SetupRoutes`, `SetupLogger` |
|
|
||||||
| Types | PascalCase | `Game`, `Player`, `Instance` |
|
|
||||||
| Status enums | `Status` prefix | `StatusWaitingForPlayer`, `StatusInProgress` |
|
|
||||||
| Session keys | `Key` prefix | `KeyPlayerID`, `KeyUserID` |
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
1. **Wrap errors with context:**
|
|
||||||
```go
|
|
||||||
return fmt.Errorf("loading game %s: %w", id, err)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Return (result, error) tuples:**
|
|
||||||
```go
|
|
||||||
func loadGame(queries *repository.Queries, id string) (*Game, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Best-effort operations** - use nolint comment:
|
|
||||||
```go
|
|
||||||
nc.Publish(subject, nil) //nolint:errcheck // best-effort notification
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **HTTP errors:**
|
|
||||||
```go
|
|
||||||
http.Error(w, "game not found", http.StatusNotFound)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Comments
|
|
||||||
|
|
||||||
- Focus on **why**, not **how**. Avoid superfluous comments.
|
|
||||||
- Package comments at top of primary file:
|
|
||||||
```go
|
|
||||||
// Package connect4 implements Connect 4 game logic, state management, and persistence.
|
|
||||||
package connect4
|
|
||||||
```
|
|
||||||
- Function comments for exported functions:
|
|
||||||
```go
|
|
||||||
// DropPiece attempts to drop a piece in the given column.
|
|
||||||
// Returns (row placed, success).
|
|
||||||
func (g *Game) DropPiece(col, playerColor int) (int, bool)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Go Patterns
|
|
||||||
|
|
||||||
### Dependency Injection via Closures
|
|
||||||
|
|
||||||
Handlers receive dependencies and return `http.HandlerFunc`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func HandleGamePage(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// use store, sm here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mutex for Concurrent Access
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Store struct {
|
|
||||||
games map[string]*Instance
|
|
||||||
gamesMu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Get(id string) (*Instance, bool) {
|
|
||||||
s.gamesMu.RLock()
|
|
||||||
defer s.gamesMu.RUnlock()
|
|
||||||
inst, ok := s.games[id]
|
|
||||||
return inst, ok
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build Tags for Environment
|
|
||||||
|
|
||||||
```go
|
|
||||||
//go:build dev
|
|
||||||
|
|
||||||
//go:build !dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Embedded Filesystems
|
|
||||||
|
|
||||||
```go
|
|
||||||
//go:embed assets
|
|
||||||
var assets embed.FS
|
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
|
||||||
var MigrationFS embed.FS
|
|
||||||
```
|
|
||||||
|
|
||||||
### Graceful Shutdown
|
|
||||||
|
|
||||||
```go
|
|
||||||
eg, egctx := errgroup.WithContext(ctx)
|
|
||||||
eg.Go(func() error { return server.ListenAndServe() })
|
|
||||||
eg.Go(func() error {
|
|
||||||
<-egctx.Done()
|
|
||||||
return server.Shutdown(context.Background())
|
|
||||||
})
|
|
||||||
return eg.Wait()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Templ + Datastar Patterns
|
|
||||||
|
|
||||||
### SSE Connection with Disabled Cancellation
|
|
||||||
|
|
||||||
Datastar cancels SSE on user interaction by default. Disable for persistent connections:
|
|
||||||
|
|
||||||
```go
|
|
||||||
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prevent Script Duplication on SSE Patches
|
|
||||||
|
|
||||||
Use `templ.NewOnceHandle()` for scripts in components that get patched:
|
|
||||||
|
|
||||||
```go
|
|
||||||
var scriptHandle = templ.NewOnceHandle()
|
|
||||||
|
|
||||||
templ MyComponent() {
|
|
||||||
<div id="my-component">...</div>
|
|
||||||
@scriptHandle.Once() {
|
|
||||||
@myScript()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conditional Classes with templ.KV
|
|
||||||
|
|
||||||
```go
|
|
||||||
class={
|
|
||||||
"status status-sm",
|
|
||||||
templ.KV("status-success", isConnected),
|
|
||||||
templ.KV("status-error", !isConnected),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datastar SSE Responses
|
|
||||||
|
|
||||||
```go
|
|
||||||
sse := datastar.NewSSE(w, r)
|
|
||||||
sse.MergeFragmentTempl(components.GameBoard(game))
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|-------|------------|
|
|
||||||
| Templates | templ (type-safe HTML) |
|
|
||||||
| Reactivity | Datastar (SSE-driven) |
|
|
||||||
| CSS | TailwindCSS v4 + daisyUI |
|
|
||||||
| Router | chi/v5 |
|
|
||||||
| Sessions | scs/v2 |
|
|
||||||
| Database | SQLite (modernc.org/sqlite) |
|
|
||||||
| Migrations | goose |
|
|
||||||
| SQL codegen | sqlc |
|
|
||||||
| Pub/sub | Embedded NATS |
|
|
||||||
| Logging | zerolog |
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All tests
|
|
||||||
task test
|
|
||||||
|
|
||||||
# Single test
|
|
||||||
go test -run TestDropPiece ./connect4
|
|
||||||
|
|
||||||
# With verbose output
|
|
||||||
go test -v -run TestDropPiece ./connect4
|
|
||||||
|
|
||||||
# Test a package
|
|
||||||
go test ./connect4/...
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `testutil.SetupTestDB()` for tests requiring database access.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Package assets provides static file serving with build-tag switching
|
|
||||||
// between live filesystem (dev) and embedded hashfs (prod).
|
|
||||||
package assets
|
|
||||||
|
|
||||||
const DirectoryPath = "assets"
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
|
|||||||
//go:build dev
|
|
||||||
|
|
||||||
package assets
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Handler() http.Handler {
|
|
||||||
log.Debug().Str("path", DirectoryPath).Msg("static assets served from filesystem")
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
http.StripPrefix("/assets/", http.FileServerFS(os.DirFS(DirectoryPath))).ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func StaticPath(path string) string {
|
|
||||||
return "/assets/" + path
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
//go:build !dev
|
|
||||||
|
|
||||||
package assets
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/benbjohnson/hashfs"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
//go:embed css js
|
|
||||||
staticFiles embed.FS
|
|
||||||
staticSys = hashfs.NewFS(staticFiles)
|
|
||||||
)
|
|
||||||
|
|
||||||
func Handler() http.Handler {
|
|
||||||
log.Debug().Msg("static assets are embedded with hashfs")
|
|
||||||
return http.StripPrefix("/assets/", hashfs.FileServer(staticSys))
|
|
||||||
}
|
|
||||||
|
|
||||||
func StaticPath(path string) string {
|
|
||||||
return "/assets/" + staticSys.HashName(path)
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,47 @@
|
|||||||
package c4game
|
package c4game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/chat"
|
"github.com/ryanhamamura/games/chat"
|
||||||
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||||
"github.com/ryanhamamura/games/connect4"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/games/features/c4game/pages"
|
"github.com/ryanhamamura/games/features/c4game/pages"
|
||||||
"github.com/ryanhamamura/games/features/c4game/services"
|
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
||||||
"github.com/ryanhamamura/games/sessions"
|
"github.com/ryanhamamura/games/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleGamePage(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) 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")
|
||||||
|
|
||||||
@@ -61,17 +85,16 @@ func HandleGamePage(store *connect4.Store, svc *services.GameService, sm *scs.Se
|
|||||||
}
|
}
|
||||||
|
|
||||||
g := gi.GetGame()
|
g := gi.GetGame()
|
||||||
room := svc.ChatRoom(gameID)
|
room := chat.NewPersistentRoom(nil, "", queries, gameID)
|
||||||
|
|
||||||
if err := pages.GamePage(g, myColor, room.Messages(), svc.ChatConfig(gameID)).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 *connect4.Store, svc *services.GameService, sm *scs.SessionManager) 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) {
|
||||||
ctx := r.Context()
|
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
gi, exists := store.Get(gameID)
|
gi, exists := store.Get(gameID)
|
||||||
@@ -81,74 +104,68 @@ func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.
|
|||||||
}
|
}
|
||||||
|
|
||||||
playerID := sessions.GetPlayerID(sm, r)
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
|
myColor := gi.GetPlayerColor(playerID)
|
||||||
|
|
||||||
// Subscribe to game state updates BEFORE creating SSE
|
|
||||||
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
|
||||||
|
|
||||||
// Subscribe to chat messages BEFORE creating SSE
|
|
||||||
chatCfg := svc.ChatConfig(gameID)
|
|
||||||
room := svc.ChatRoom(gameID)
|
|
||||||
chatCh, cleanupChat := room.Subscribe()
|
|
||||||
defer cleanupChat()
|
|
||||||
|
|
||||||
// Setup heartbeat BEFORE creating SSE
|
|
||||||
heartbeat := time.NewTicker(1 * time.Second)
|
|
||||||
defer heartbeat.Stop()
|
|
||||||
|
|
||||||
// NOW create SSE
|
|
||||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Define patch function
|
chatCfg := c4ChatConfig(gameID)
|
||||||
|
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
|
||||||
|
|
||||||
patchAll := func() error {
|
patchAll := func() error {
|
||||||
myColor := gi.GetPlayerColor(playerID)
|
myColor = gi.GetPlayerColor(playerID)
|
||||||
g := gi.GetGame()
|
g := gi.GetGame()
|
||||||
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
|
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send initial state
|
sendPing := func() error {
|
||||||
|
return sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial render and ping
|
||||||
|
if err := sendPing(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := patchAll(); err != nil {
|
if err := patchAll(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event loop
|
heartbeat := time.NewTicker(15 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
|
||||||
|
// Subscribe to game state updates
|
||||||
|
gameCh := make(chan *nats.Msg, 64)
|
||||||
|
gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||||
|
|
||||||
|
// Subscribe to chat messages
|
||||||
|
chatCh, cleanupChat := room.Subscribe()
|
||||||
|
defer cleanupChat()
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
case <-heartbeat.C:
|
||||||
case <-gameCh:
|
if err := sendPing(); err != nil {
|
||||||
// Drain rapid-fire notifications
|
return
|
||||||
drainGame:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-gameCh:
|
|
||||||
default:
|
|
||||||
break drainGame
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
case <-gameCh:
|
||||||
if err := patchAll(); err != nil {
|
if err := patchAll(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case chatMsg := <-chatCh:
|
case chatMsg := <-chatCh:
|
||||||
if err := sse.PatchElementTempl(
|
err := sse.PatchElementTempl(
|
||||||
chatcomponents.ChatMessage(chatMsg, chatCfg),
|
chatcomponents.ChatMessage(chatMsg, chatCfg),
|
||||||
datastar.WithSelectorID("c4-chat-history"),
|
datastar.WithSelectorID("c4-chat-history"),
|
||||||
datastar.WithModeAppend(),
|
datastar.WithModeAppend(),
|
||||||
); err != nil {
|
)
|
||||||
return
|
if err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
case <-heartbeat.C:
|
|
||||||
// Heartbeat refreshes game state to keep connection alive
|
|
||||||
if err := patchAll(); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +202,7 @@ func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.Handler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSendChat(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) 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")
|
||||||
|
|
||||||
@@ -232,7 +249,7 @@ func HandleSendChat(store *connect4.Store, svc *services.GameService, sm *scs.Se
|
|||||||
Message: signals.ChatMsg,
|
Message: signals.ChatMsg,
|
||||||
Time: time.Now().UnixMilli(),
|
Time: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
room := svc.ChatRoom(gameID)
|
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
|
||||||
room.Send(msg)
|
room.Send(msg)
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
|
|||||||
data-signals="{chatMsg: ''}"
|
data-signals="{chatMsg: ''}"
|
||||||
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
||||||
>
|
>
|
||||||
|
@sharedcomponents.ConnectionIndicatorWithScript(0)
|
||||||
@GameContent(g, myColor, messages, chatCfg)
|
@GameContent(g, myColor, messages, chatCfg)
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
@@ -25,7 +26,6 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
|
|||||||
|
|
||||||
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
|
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
|
||||||
<div id="game-content">
|
<div id="game-content">
|
||||||
@sharedcomponents.LiveClock()
|
|
||||||
@sharedcomponents.BackToLobby()
|
@sharedcomponents.BackToLobby()
|
||||||
@sharedcomponents.StealthTitle("text-3xl font-bold")
|
@sharedcomponents.StealthTitle("text-3xl font-bold")
|
||||||
@components.PlayerInfo(g, myColor)
|
@components.PlayerInfo(g, myColor)
|
||||||
|
|||||||
@@ -4,22 +4,24 @@ package c4game
|
|||||||
import (
|
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/nats-io/nats.go"
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/connect4"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/games/features/c4game/services"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(
|
func SetupRoutes(
|
||||||
router chi.Router,
|
router chi.Router,
|
||||||
store *connect4.Store,
|
store *connect4.Store,
|
||||||
svc *services.GameService,
|
nc *nats.Conn,
|
||||||
sessions *scs.SessionManager,
|
sessions *scs.SessionManager,
|
||||||
|
queries *repository.Queries,
|
||||||
) {
|
) {
|
||||||
router.Route("/games/{id}", func(r chi.Router) {
|
router.Route("/games/{id}", func(r chi.Router) {
|
||||||
r.Get("/", HandleGamePage(store, svc, sessions))
|
r.Get("/", HandleGamePage(store, sessions, queries))
|
||||||
r.Get("/events", HandleGameEvents(store, svc, sessions))
|
r.Get("/events", HandleGameEvents(store, nc, sessions, queries))
|
||||||
r.Post("/drop", HandleDropPiece(store, sessions))
|
r.Post("/drop", HandleDropPiece(store, sessions))
|
||||||
r.Post("/chat", HandleSendChat(store, svc, sessions))
|
r.Post("/chat", HandleSendChat(store, nc, sessions, queries))
|
||||||
r.Post("/join", HandleSetNickname(store, sessions))
|
r.Post("/join", HandleSetNickname(store, sessions))
|
||||||
r.Post("/rematch", HandleRematch(store, sessions))
|
r.Post("/rematch", HandleRematch(store, sessions))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
// Package services provides the game service layer for Connect 4,
|
|
||||||
// handling NATS subscriptions and chat room management.
|
|
||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/chat"
|
|
||||||
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
|
||||||
"github.com/ryanhamamura/games/connect4"
|
|
||||||
"github.com/ryanhamamura/games/db/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
// c4ChatColors maps player slot (0-indexed) to CSS background colors.
|
|
||||||
var c4ChatColors = map[int]string{
|
|
||||||
0: "#4a2a3a", // Red player
|
|
||||||
1: "#2a4545", // Yellow player
|
|
||||||
}
|
|
||||||
|
|
||||||
func c4ChatColor(slot int) string {
|
|
||||||
if c, ok := c4ChatColors[slot]; ok {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
return "#666"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameService manages NATS subscriptions and chat for Connect 4 games.
|
|
||||||
type GameService struct {
|
|
||||||
nc *nats.Conn
|
|
||||||
queries *repository.Queries
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGameService creates a new game service.
|
|
||||||
func NewGameService(nc *nats.Conn, queries *repository.Queries) *GameService {
|
|
||||||
return &GameService{
|
|
||||||
nc: nc,
|
|
||||||
queries: queries,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubscribeGameUpdates returns a NATS subscription and channel for game state updates.
|
|
||||||
func (s *GameService) SubscribeGameUpdates(gameID string) (*nats.Subscription, <-chan *nats.Msg, error) {
|
|
||||||
ch := make(chan *nats.Msg, 64)
|
|
||||||
sub, err := s.nc.ChanSubscribe(connect4.GameSubject(gameID), ch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("subscribing to game updates: %w", err)
|
|
||||||
}
|
|
||||||
return sub, ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatConfig returns the chat configuration for a game.
|
|
||||||
func (s *GameService) ChatConfig(gameID string) chatcomponents.Config {
|
|
||||||
return chatcomponents.Config{
|
|
||||||
CSSPrefix: "c4",
|
|
||||||
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
|
|
||||||
Color: c4ChatColor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatRoom returns a persistent chat room for a game.
|
|
||||||
func (s *GameService) ChatRoom(gameID string) *chat.Room {
|
|
||||||
return chat.NewPersistentRoom(s.nc, connect4.ChatSubject(gameID), s.queries, gameID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublishGameUpdate sends a notification that the game state has changed.
|
|
||||||
func (s *GameService) PublishGameUpdate(gameID string) error {
|
|
||||||
return s.nc.Publish(connect4.GameSubject(gameID), nil)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"fmt"
|
||||||
|
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
@@ -48,15 +48,65 @@ templ NicknamePrompt(returnPath string) {
|
|||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
// LiveClock shows the current server time, updated every second via SSE.
|
func isStale(lastPing int64) bool {
|
||||||
// If the clock stops updating, users know the connection is stale.
|
return lastPing == 0
|
||||||
templ LiveClock() {
|
}
|
||||||
<div class="fixed top-2 right-2 flex items-center gap-1.5 text-xs opacity-60 font-mono">
|
|
||||||
<div style="width: 6px; height: 6px; border-radius: 50%; background-color: #22c55e;"></div>
|
// ConnectionIndicator shows a small dot indicating SSE connection status.
|
||||||
{ time.Now().Format("15:04:05") }
|
// Server patches this with a timestamp; client JS detects staleness.
|
||||||
|
// Use ConnectionIndicatorWithScript for initial render, ConnectionIndicator for patches.
|
||||||
|
templ ConnectionIndicator(lastPing int64) {
|
||||||
|
<div
|
||||||
|
id="connection-indicator"
|
||||||
|
class="fixed top-2 right-2"
|
||||||
|
data-last-ping={ fmt.Sprintf("%d", lastPing) }
|
||||||
|
>
|
||||||
|
<div class="inline-grid *:[grid-area:1/1]">
|
||||||
|
<div
|
||||||
|
id="connection-ping"
|
||||||
|
class={
|
||||||
|
"status status-sm",
|
||||||
|
templ.KV("status-success animate-ping", !isStale(lastPing)),
|
||||||
|
templ.KV("status-error", isStale(lastPing)),
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="connection-dot"
|
||||||
|
class={
|
||||||
|
"status status-sm",
|
||||||
|
templ.KV("status-success", !isStale(lastPing)),
|
||||||
|
templ.KV("status-error", isStale(lastPing)),
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectionIndicatorWithScript renders the indicator with the staleness watcher script.
|
||||||
|
// Use this for initial page render only.
|
||||||
|
templ ConnectionIndicatorWithScript(lastPing int64) {
|
||||||
|
@ConnectionIndicator(lastPing)
|
||||||
|
@connectionWatcher()
|
||||||
|
}
|
||||||
|
|
||||||
|
script connectionWatcher() {
|
||||||
|
setInterval(function() {
|
||||||
|
var el = document.getElementById('connection-indicator');
|
||||||
|
var dot = document.getElementById('connection-dot');
|
||||||
|
var ping = document.getElementById('connection-ping');
|
||||||
|
if (!el || !dot || !ping) return;
|
||||||
|
|
||||||
|
var lastPing = parseInt(el.dataset.lastPing, 10) || 0;
|
||||||
|
var stale = Date.now() - lastPing > 20000;
|
||||||
|
|
||||||
|
dot.classList.toggle('status-success', !stale);
|
||||||
|
dot.classList.toggle('status-error', stale);
|
||||||
|
ping.classList.toggle('status-success', !stale);
|
||||||
|
ping.classList.toggle('status-error', stale);
|
||||||
|
ping.classList.toggle('animate-ping', !stale);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {
|
templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {
|
||||||
<main class="max-w-sm mx-auto mt-8 text-center">
|
<main class="max-w-sm mx-auto mt-8 text-center">
|
||||||
<h1 class="text-3xl font-bold">Join Game</h1>
|
<h1 class="text-3xl font-bold">Join Game</h1>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package layouts
|
package layouts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/ryanhamamura/games/assets"
|
|
||||||
"github.com/ryanhamamura/games/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
"github.com/ryanhamamura/games/version"
|
"github.com/ryanhamamura/games/version"
|
||||||
)
|
)
|
||||||
@@ -12,8 +11,8 @@ templ Base(title string) {
|
|||||||
<head>
|
<head>
|
||||||
<title>{ title }</title>
|
<title>{ title }</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||||
<script defer type="module" src={ assets.StaticPath("js/datastar.js") }></script>
|
<script defer type="module" src="/assets/js/datastar.js"></script>
|
||||||
<link href={ assets.StaticPath("css/output.css") } rel="stylesheet" type="text/css"/>
|
<link href="/assets/css/output.css" rel="stylesheet" type="text/css"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="flex flex-col h-screen">
|
<body class="flex flex-col h-screen">
|
||||||
if config.Global.Environment == config.Dev {
|
if config.Global.Environment == config.Dev {
|
||||||
|
|||||||
@@ -1,24 +1,41 @@
|
|||||||
package snakegame
|
package snakegame
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/chat"
|
"github.com/ryanhamamura/games/chat"
|
||||||
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||||
|
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
||||||
"github.com/ryanhamamura/games/features/snakegame/pages"
|
"github.com/ryanhamamura/games/features/snakegame/pages"
|
||||||
"github.com/ryanhamamura/games/features/snakegame/services"
|
|
||||||
"github.com/ryanhamamura/games/sessions"
|
"github.com/ryanhamamura/games/sessions"
|
||||||
"github.com/ryanhamamura/games/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
func snakeChatColor(slot int) string {
|
||||||
|
if slot >= 0 && slot < len(snake.SnakeColors) {
|
||||||
|
return snake.SnakeColors[slot]
|
||||||
|
}
|
||||||
|
return "#666"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -60,14 +77,13 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm
|
|||||||
}
|
}
|
||||||
|
|
||||||
sg := si.GetGame()
|
sg := si.GetGame()
|
||||||
chatCfg := svc.ChatConfig(gameID)
|
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
|
||||||
if err := pages.GamePage(sg, mySlot, nil, chatCfg, 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, svc *services.GameService, sm *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)
|
||||||
@@ -79,25 +95,17 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService,
|
|||||||
playerID := sessions.GetPlayerID(sm, r)
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
mySlot := si.GetPlayerSlot(playerID)
|
mySlot := si.GetPlayerSlot(playerID)
|
||||||
|
|
||||||
// Subscribe to game updates BEFORE creating SSE (following portigo pattern)
|
|
||||||
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||||
))
|
))
|
||||||
|
|
||||||
chatCfg := svc.ChatConfig(gameID)
|
chatCfg := snakeChatConfig(gameID)
|
||||||
|
|
||||||
// Chat room (multiplayer only)
|
// Chat room (multiplayer only)
|
||||||
var room *chat.Room
|
var room *chat.Room
|
||||||
sg := si.GetGame()
|
sg := si.GetGame()
|
||||||
if sg.Mode == snake.ModeMultiplayer {
|
if sg.Mode == snake.ModeMultiplayer {
|
||||||
room = svc.ChatRoom(gameID)
|
room = chat.NewRoom(nc, snake.ChatSubject(gameID))
|
||||||
}
|
}
|
||||||
|
|
||||||
chatMessages := func() []chat.Message {
|
chatMessages := func() []chat.Message {
|
||||||
@@ -110,21 +118,36 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService,
|
|||||||
patchAll := func() error {
|
patchAll := func() error {
|
||||||
si, ok = snakeStore.Get(gameID)
|
si, ok = snakeStore.Get(gameID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("game not found")
|
return fmt.Errorf("game not found")
|
||||||
}
|
}
|
||||||
mySlot = si.GetPlayerSlot(playerID)
|
mySlot = si.GetPlayerSlot(playerID)
|
||||||
sg = si.GetGame()
|
sg = si.GetGame()
|
||||||
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
|
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send initial render
|
sendPing := func() error {
|
||||||
|
return sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial render and ping
|
||||||
|
if err := sendPing(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := patchAll(); err != nil {
|
if err := patchAll(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeat := time.NewTicker(1 * time.Second)
|
heartbeat := time.NewTicker(15 * time.Second)
|
||||||
defer heartbeat.Stop()
|
defer heartbeat.Stop()
|
||||||
|
|
||||||
|
// Subscribe to game updates via NATS
|
||||||
|
gameCh := make(chan *nats.Msg, 64)
|
||||||
|
gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||||
|
|
||||||
// Chat subscription (multiplayer only)
|
// Chat subscription (multiplayer only)
|
||||||
var chatCh <-chan chat.Message
|
var chatCh <-chan chat.Message
|
||||||
var cleanupChat func()
|
var cleanupChat func()
|
||||||
@@ -141,8 +164,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService,
|
|||||||
return
|
return
|
||||||
|
|
||||||
case <-heartbeat.C:
|
case <-heartbeat.C:
|
||||||
// Heartbeat refreshes game state to keep connection alive
|
if err := sendPing(); err != nil {
|
||||||
if err := patchAll(); err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +231,7 @@ type chatSignals struct {
|
|||||||
ChatMsg string `json:"chatMsg"`
|
ChatMsg string `json:"chatMsg"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSendChat(snakeStore *snake.SnakeStore, svc *services.GameService, sm *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)
|
||||||
@@ -242,7 +264,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, svc *services.GameService, sm
|
|||||||
Message: signals.ChatMsg,
|
Message: signals.ChatMsg,
|
||||||
}
|
}
|
||||||
|
|
||||||
room := svc.ChatRoom(gameID)
|
room := chat.NewRoom(nc, snake.ChatSubject(gameID))
|
||||||
room.Send(msg)
|
room.Send(msg)
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
|
|||||||
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
|
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
@components.ConnectionIndicatorWithScript(0)
|
||||||
@GameContent(sg, mySlot, messages, chatCfg, gameID)
|
@GameContent(sg, mySlot, messages, chatCfg, gameID)
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,6 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
|
|||||||
|
|
||||||
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
|
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
|
||||||
<div id="game-content">
|
<div id="game-content">
|
||||||
@components.LiveClock()
|
|
||||||
@components.BackToLobby()
|
@components.BackToLobby()
|
||||||
<h1 class="text-3xl font-bold">~~~~</h1>
|
<h1 class="text-3xl font-bold">~~~~</h1>
|
||||||
@snakecomponents.PlayerList(sg, mySlot)
|
@snakecomponents.PlayerList(sg, mySlot)
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ package snakegame
|
|||||||
import (
|
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/nats-io/nats.go"
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/features/snakegame/services"
|
|
||||||
"github.com/ryanhamamura/games/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, svc *services.GameService, sessions *scs.SessionManager) {
|
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) {
|
||||||
router.Route("/snake/{id}", func(r chi.Router) {
|
router.Route("/snake/{id}", func(r chi.Router) {
|
||||||
r.Get("/", HandleSnakePage(snakeStore, svc, sessions))
|
r.Get("/", HandleSnakePage(snakeStore, sessions))
|
||||||
r.Get("/events", HandleSnakeEvents(snakeStore, svc, sessions))
|
r.Get("/events", HandleSnakeEvents(snakeStore, nc, sessions))
|
||||||
r.Post("/dir", HandleSetDirection(snakeStore, sessions))
|
r.Post("/dir", HandleSetDirection(snakeStore, sessions))
|
||||||
r.Post("/chat", HandleSendChat(snakeStore, svc, sessions))
|
r.Post("/chat", HandleSendChat(snakeStore, nc, sessions))
|
||||||
r.Post("/join", HandleSetNickname(snakeStore, sessions))
|
r.Post("/join", HandleSetNickname(snakeStore, sessions))
|
||||||
r.Post("/rematch", HandleRematch(snakeStore, sessions))
|
r.Post("/rematch", HandleRematch(snakeStore, sessions))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
// Package services provides the game service layer for Snake,
|
|
||||||
// handling NATS subscriptions and chat room management.
|
|
||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/chat"
|
|
||||||
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
|
||||||
"github.com/ryanhamamura/games/snake"
|
|
||||||
)
|
|
||||||
|
|
||||||
func snakeChatColor(slot int) string {
|
|
||||||
if slot >= 0 && slot < len(snake.SnakeColors) {
|
|
||||||
return snake.SnakeColors[slot]
|
|
||||||
}
|
|
||||||
return "#666"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GameService manages NATS subscriptions and chat for Snake games.
|
|
||||||
type GameService struct {
|
|
||||||
nc *nats.Conn
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGameService creates a new game service.
|
|
||||||
func NewGameService(nc *nats.Conn) *GameService {
|
|
||||||
return &GameService{
|
|
||||||
nc: nc,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubscribeGameUpdates returns a NATS subscription and channel for game state updates.
|
|
||||||
func (s *GameService) SubscribeGameUpdates(gameID string) (*nats.Subscription, <-chan *nats.Msg, error) {
|
|
||||||
ch := make(chan *nats.Msg, 64)
|
|
||||||
sub, err := s.nc.ChanSubscribe(snake.GameSubject(gameID), ch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("subscribing to game updates: %w", err)
|
|
||||||
}
|
|
||||||
return sub, ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatConfig returns the chat configuration for a game.
|
|
||||||
func (s *GameService) ChatConfig(gameID string) chatcomponents.Config {
|
|
||||||
return chatcomponents.Config{
|
|
||||||
CSSPrefix: "snake",
|
|
||||||
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
|
|
||||||
Color: snakeChatColor,
|
|
||||||
StopKeyPropagation: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatRoom returns a chat room for a game (ephemeral, not persisted).
|
|
||||||
func (s *GameService) ChatRoom(gameID string) *chat.Room {
|
|
||||||
return chat.NewRoom(s.nc, snake.ChatSubject(gameID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublishGameUpdate sends a notification that the game state has changed.
|
|
||||||
func (s *GameService) PublishGameUpdate(gameID string) error {
|
|
||||||
return s.nc.Publish(snake.GameSubject(gameID), nil)
|
|
||||||
}
|
|
||||||
4
go.mod
4
go.mod
@@ -68,7 +68,6 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
|
||||||
github.com/aws/smithy-go v1.24.0 // indirect
|
github.com/aws/smithy-go v1.24.0 // indirect
|
||||||
github.com/benbjohnson/hashfs v0.2.2 // indirect
|
|
||||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||||
github.com/bep/golibsass v1.2.0 // indirect
|
github.com/bep/golibsass v1.2.0 // indirect
|
||||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
|
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
|
||||||
@@ -171,9 +170,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/riza-io/grpc-go v0.2.0 // indirect
|
github.com/riza-io/grpc-go v0.2.0 // indirect
|
||||||
github.com/sajari/fuzzy v1.0.0 // indirect
|
github.com/sajari/fuzzy v1.0.0 // indirect
|
||||||
github.com/samber/lo v1.52.0 // indirect
|
|
||||||
github.com/samber/slog-common v0.20.0 // indirect
|
|
||||||
github.com/samber/slog-zerolog/v2 v2.9.1 // indirect
|
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -136,8 +136,6 @@ github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl
|
|||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/benbjohnson/hashfs v0.2.2 h1:vFZtksphM5LcnMRFctj49jCUkCc7wp3NP6INyfjkse4=
|
|
||||||
github.com/benbjohnson/hashfs v0.2.2/go.mod h1:7OMXaMVo1YkfiIPxKrl7OXkUTUgWjmsAKyR+E6xDIRM=
|
|
||||||
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
|
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
|
||||||
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
|
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
@@ -567,12 +565,6 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
|
|||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
|
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
|
||||||
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
|
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
|
||||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
|
||||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
|
||||||
github.com/samber/slog-common v0.20.0 h1:WaLnm/aCvBJSk5nR5aXZTFBaV0B47A+AEaEOiZDeUnc=
|
|
||||||
github.com/samber/slog-common v0.20.0/go.mod h1:+Ozat1jgnnE59UAlmNX1IF3IByHsODnnwf9jUcBZ+m8=
|
|
||||||
github.com/samber/slog-zerolog/v2 v2.9.1 h1:RMOq8XqzfuGx1X0TEIlS9OXbbFmqLY2/wJppghz66YY=
|
|
||||||
github.com/samber/slog-zerolog/v2 v2.9.1/go.mod h1:DQYYve14WgCRN/XnKeHl4266jXK0DgYkYXkfZ4Fp98k=
|
|
||||||
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
|
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
|
||||||
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/ryanhamamura/games/config"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -65,15 +64,25 @@ func colorLatency(d time.Duration, useColor bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) WriteHeader(code int) {
|
||||||
|
rw.status = code
|
||||||
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
func RequestLogger(logger *zerolog.Logger, env config.Environment) func(http.Handler) http.Handler {
|
func RequestLogger(logger *zerolog.Logger, env config.Environment) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
rw := &responseWriter{ResponseWriter: w}
|
||||||
|
|
||||||
next.ServeHTTP(ww, r)
|
next.ServeHTTP(rw, r)
|
||||||
|
|
||||||
status := ww.Status()
|
status := rw.status
|
||||||
if status == 0 {
|
if status == 0 {
|
||||||
status = http.StatusOK
|
status = http.StatusOK
|
||||||
}
|
}
|
||||||
|
|||||||
28
main.go
28
main.go
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -10,12 +11,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
slogzerolog "github.com/samber/slog-zerolog/v2"
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
"github.com/ryanhamamura/games/connect4"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/games/db"
|
"github.com/ryanhamamura/games/db"
|
||||||
@@ -26,19 +21,22 @@ import (
|
|||||||
"github.com/ryanhamamura/games/sessions"
|
"github.com/ryanhamamura/games/sessions"
|
||||||
"github.com/ryanhamamura/games/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
"github.com/ryanhamamura/games/version"
|
"github.com/ryanhamamura/games/version"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed assets
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cfg := config.Global
|
cfg := config.Global
|
||||||
zerologLogger := logging.SetupLogger(cfg.Environment, cfg.LogLevel)
|
logging.SetupLogger(cfg.Environment, cfg.LogLevel)
|
||||||
slog.SetDefault(slog.New(slogzerolog.Option{
|
|
||||||
Level: slogzerolog.ZeroLogLeveler{Logger: zerologLogger},
|
|
||||||
Logger: zerologLogger,
|
|
||||||
NoTimestamp: true,
|
|
||||||
}.NewZerologHandler()))
|
|
||||||
|
|
||||||
if err := run(ctx); err != nil && err != http.ErrServerClosed {
|
if err := run(ctx); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatal().Err(err).Msg("server error")
|
log.Fatal().Err(err).Msg("server error")
|
||||||
@@ -93,7 +91,7 @@ func run(ctx context.Context) error {
|
|||||||
sessionManager.LoadAndSave,
|
sessionManager.LoadAndSave,
|
||||||
)
|
)
|
||||||
|
|
||||||
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore)
|
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, assets)
|
||||||
|
|
||||||
// HTTP server
|
// HTTP server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -103,10 +101,6 @@ func run(ctx context.Context) error {
|
|||||||
BaseContext: func(l net.Listener) context.Context {
|
BaseContext: func(l net.Listener) context.Context {
|
||||||
return egctx
|
return egctx
|
||||||
},
|
},
|
||||||
ErrorLog: slog.NewLogLogger(
|
|
||||||
slog.Default().Handler(),
|
|
||||||
slog.LevelError,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
|
|||||||
@@ -2,25 +2,24 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/assets"
|
|
||||||
"github.com/ryanhamamura/games/config"
|
"github.com/ryanhamamura/games/config"
|
||||||
"github.com/ryanhamamura/games/connect4"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/games/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/games/features/auth"
|
"github.com/ryanhamamura/games/features/auth"
|
||||||
"github.com/ryanhamamura/games/features/c4game"
|
"github.com/ryanhamamura/games/features/c4game"
|
||||||
c4services "github.com/ryanhamamura/games/features/c4game/services"
|
|
||||||
"github.com/ryanhamamura/games/features/lobby"
|
"github.com/ryanhamamura/games/features/lobby"
|
||||||
"github.com/ryanhamamura/games/features/snakegame"
|
"github.com/ryanhamamura/games/features/snakegame"
|
||||||
snakeservices "github.com/ryanhamamura/games/features/snakegame/services"
|
|
||||||
"github.com/ryanhamamura/games/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
|
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(
|
func SetupRoutes(
|
||||||
@@ -30,23 +29,21 @@ func SetupRoutes(
|
|||||||
nc *nats.Conn,
|
nc *nats.Conn,
|
||||||
store *connect4.Store,
|
store *connect4.Store,
|
||||||
snakeStore *snake.SnakeStore,
|
snakeStore *snake.SnakeStore,
|
||||||
|
assets embed.FS,
|
||||||
) {
|
) {
|
||||||
// Static assets
|
// Static assets
|
||||||
router.Handle("/assets/*", assets.Handler())
|
subFS, _ := fs.Sub(assets, "assets")
|
||||||
|
router.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServerFS(subFS)))
|
||||||
|
|
||||||
// Hot-reload for development
|
// Hot-reload for development
|
||||||
if config.Global.Environment == config.Dev {
|
if config.Global.Environment == config.Dev {
|
||||||
setupReload(router)
|
setupReload(router)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Services
|
|
||||||
c4Svc := c4services.NewGameService(nc, queries)
|
|
||||||
snakeSvc := snakeservices.NewGameService(nc)
|
|
||||||
|
|
||||||
auth.SetupRoutes(router, queries, sessions)
|
auth.SetupRoutes(router, queries, sessions)
|
||||||
lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
|
lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
|
||||||
c4game.SetupRoutes(router, store, c4Svc, sessions)
|
c4game.SetupRoutes(router, store, nc, sessions, queries)
|
||||||
snakegame.SetupRoutes(router, snakeStore, snakeSvc, sessions)
|
snakegame.SetupRoutes(router, snakeStore, nc, sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupReload(router chi.Router) {
|
func setupReload(router chi.Router) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
|
|||||||
sessionManager.Cookie.Name = "games_session"
|
sessionManager.Cookie.Name = "games_session"
|
||||||
sessionManager.Cookie.Path = "/"
|
sessionManager.Cookie.Path = "/"
|
||||||
sessionManager.Cookie.HttpOnly = true
|
sessionManager.Cookie.HttpOnly = true
|
||||||
sessionManager.Cookie.Secure = false
|
sessionManager.Cookie.Secure = true
|
||||||
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
|
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
|
||||||
slog.Info("session manager configured")
|
slog.Info("session manager configured")
|
||||||
|
|||||||
Reference in New Issue
Block a user