39 Commits

Author SHA1 Message Date
Ryan Hamamura
b1f754831a fix: limit request body size on auth form handlers (gosec G120)
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 45s
CI / Deploy / deploy (push) Successful in 1m34s
2026-03-11 10:19:03 -10:00
93147ffc46 Merge pull request 'fix: convert auth flows from SSE to standard HTTP to fix session cookies' (#14) from fix/login-session-cookie into main
Some checks failed
CI / Deploy / test (push) Successful in 7s
CI / Deploy / lint (push) Failing after 37s
CI / Deploy / deploy (push) Has been skipped
2026-03-11 20:14:35 +00:00
Ryan Hamamura
72d31fd143 fix: convert auth flows from SSE to standard HTTP to fix session cookies
Some checks failed
CI / Deploy / test (pull_request) Successful in 33s
CI / Deploy / lint (pull_request) Failing after 38s
CI / Deploy / deploy (pull_request) Has been skipped
Datastar's NewSSE() flushes HTTP headers before SCS's session middleware
can attach the Set-Cookie header, so the session cookie never reaches the
browser after login/register/logout.

Convert login, register, and logout to standard HTML forms with HTTP
redirects, which lets SCS write cookies normally. Also fix return_url
capture on the login page (was never being stored in the session).

Add handler tests covering login, register, and logout flows.
2026-03-11 10:10:28 -10:00
Ryan Hamamura
8573e87bf6 fix: add /assets/ prefix to hashfs paths in prod
All checks were successful
CI / Deploy / test (push) Successful in 13s
CI / Deploy / lint (push) Successful in 24s
CI / Deploy / deploy (push) Successful in 1m27s
2026-03-03 13:37:04 -10:00
67a768ea22 Fix SSE architecture for reliable connections (#13)
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m32s
2026-03-03 23:33:13 +00:00
Ryan Hamamura
331c4c8759 docs: add AGENTS.md with coding guidelines for AI agents
All checks were successful
CI / Deploy / test (push) Successful in 15s
CI / Deploy / lint (push) Successful in 28s
CI / Deploy / deploy (push) Successful in 1m28s
Includes build/test commands, code style guidelines, naming conventions,
error handling patterns, and Go/templ/Datastar patterns used in this repo.
2026-03-03 10:53:14 -10:00
f6c5949247 Merge pull request 'Fix connection indicator script duplication on SSE patches' (#12) from fix/connection-indicator-script into main
All checks were successful
CI / Deploy / test (push) Successful in 18s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m27s
2026-03-03 20:44:56 +00:00
Ryan Hamamura
d6e64763cc fix: use templ.NewOnceHandle to prevent script duplication on SSE patches
All checks were successful
CI / Deploy / test (pull_request) Successful in 15s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Replace ConnectionIndicatorWithScript wrapper with a single ConnectionIndicator
component that uses templ.NewOnceHandle() to ensure the watcher script is only
rendered once per page, even when the indicator is patched via SSE.
2026-03-03 10:43:23 -10:00
589d1f09e8 Merge pull request 'Refactor connection indicator to patch with timestamp' (#11) from refactor/patch-connection-indicator into main
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m22s
2026-03-03 20:32:11 +00:00
Ryan Hamamura
06b3839c3a refactor: patch connection indicator with timestamp
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 26s
CI / Deploy / deploy (pull_request) Has been skipped
Server patches the ConnectionIndicator element with a timestamp on
each heartbeat. Client-side JS checks every second if the timestamp
is stale (>20s) and toggles red/green accordingly.

This properly detects connection loss since the indicator will turn
red if no patches are received.
2026-03-03 10:30:55 -10:00
99f14ca170 Merge pull request 'Add connection status indicator with SSE heartbeat' (#10) from feat/sse-heartbeat into main
All checks were successful
CI / Deploy / test (push) Successful in 15s
CI / Deploy / lint (push) Successful in 27s
CI / Deploy / deploy (push) Successful in 1m23s
2026-03-03 20:15:29 +00:00
Ryan Hamamura
da82f31d46 feat: add connection status indicator with SSE heartbeat
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
- Add ConnectionIndicator component showing green/red dot
- Send lastPing signal every 15 seconds via SSE
- Indicator turns red if no ping received in 20 seconds
- Gives users confidence the live connection is active
2026-03-03 10:05:03 -10:00
ffbff8cca5 Merge pull request 'Simplify chat subscription API' (#9) from refactor/chat-subscribe-messages into main
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m25s
2026-03-03 19:54:21 +00:00
Ryan Hamamura
bcb1fa3872 refactor: simplify chat subscription API
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Room.Subscribe() now returns a channel of parsed Message structs
instead of raw NATS messages. The room handles NATS subscription
and message parsing internally, so callers no longer need to call
Receive() separately.
2026-03-03 09:45:56 -10:00
bf9a8755f0 Merge pull request 'Add version display in UI footer' (#8) from feat/version-display into main
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 26s
CI / Deploy / deploy (push) Successful in 1m27s
2026-03-03 19:41:59 +00:00
90ef970d14 Merge pull request 'Fix chat messages not appearing without refresh' (#7) from fix/chat-append-messages into main
Some checks failed
CI / Deploy / lint (push) Has been cancelled
CI / Deploy / deploy (push) Has been cancelled
CI / Deploy / test (push) Has been cancelled
2026-03-03 19:41:52 +00:00
Ryan Hamamura
eb75654403 feat: display app version in UI footer
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
- Add version package with build-time variables
- Inject version via ldflags in Dockerfile using git describe
- Show version in footer on every page
- Log version and commit on server startup
2026-03-03 09:40:23 -10:00
Ryan Hamamura
c52c389f0c Reapply "fix: append chat messages instead of re-rendering entire game"
Some checks failed
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Failing after 2s
CI / Deploy / deploy (pull_request) Has been skipped
This reverts commit 513467470c.
2026-03-03 09:15:46 -10:00
Ryan Hamamura
513467470c Revert "fix: append chat messages instead of re-rendering entire game"
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 3s
This reverts commit 6976b773bd.
2026-03-03 09:15:42 -10:00
Ryan Hamamura
6976b773bd fix: append chat messages instead of re-rendering entire game
All checks were successful
CI / Deploy / test (push) Successful in 15s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 33s
Previously patchAll() re-rendered the full GameContent on every chat
message, which was inefficient and could cause UI glitches. Now we
append just the new ChatMessage to the chat history element.
2026-03-03 09:09:51 -10:00
Ryan Hamamura
ac2492e7c1 fix: correct volume mount path for database persistence
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 33s
The container runs from / so data/games.db resolves to /data/games.db,
but the volume was mounted at /app/data.
2026-03-03 08:57:11 -10:00
65dc672186 Merge pull request 'Fix SSE live updates being cancelled on user interaction' (#6) from fix/sse-request-cancellation into main
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 2m8s
2026-03-03 18:52:06 +00:00
Ryan Hamamura
1db6b2596e fix: disable SSE request cancellation for live game updates
All checks were successful
CI / Deploy / test (pull_request) Successful in 25s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
The default Datastar requestCancellation:'auto' was causing SSE
connections to be cancelled whenever users interacted with the page
(making moves, sending chat messages, etc.), breaking live updates.
2026-03-03 08:49:35 -10:00
Ryan Hamamura
64b5d384ed fix: use correct Datastar keydown event syntax
All checks were successful
CI / Deploy / test (push) Successful in 15s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 34s
Replace invalid .key_enter and .enter modifiers with evt.key === 'Enter'
guard in the expression, per Datastar docs. Also fix __stop and __throttle
modifier syntax to use double underscores.
2026-03-02 23:05:11 -10:00
Ryan Hamamura
235e4afbe3 revert: remove one-time c4 container cleanup from deploy workflow
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 3s
2026-03-02 22:57:30 -10:00
Ryan Hamamura
649762e6c6 fix: stop old c4 container before starting renamed games container
Some checks failed
CI / Deploy / test (push) Successful in 13s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Has been cancelled
The renamed container can't bind port 8080 while the old c4 container
still holds it.
2026-03-02 22:56:23 -10:00
Ryan Hamamura
8780b7c9b1 fix: run templ generate in Dockerfile before build
Some checks failed
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Failing after 34s
The _templ.go files are gitignored, so the Docker build context doesn't
include them. Generate them before compiling.
2026-03-02 22:53:00 -10:00
d77e4af1e2 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
2026-03-03 08:50:13 +00:00
Ryan Hamamura
718e0c55c9 fix: satisfy staticcheck comment style for exported consts
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
2026-03-02 22:48:16 -10:00
Ryan Hamamura
dcf76bb773 refactor: replace session key strings with consts
Some checks failed
CI / Deploy / test (pull_request) Successful in 13s
CI / Deploy / lint (pull_request) Failing after 20s
CI / Deploy / deploy (pull_request) Has been skipped
Define KeyPlayerID, KeyUserID, and KeyNickname in the sessions package
and use them across all handlers to avoid duplicated magic strings.
2026-03-02 22:40:10 -10:00
Ryan Hamamura
4faf4f73b0 refactor: patch entire game content for snake SSE handler
Some checks failed
CI / Deploy / test (pull_request) Successful in 16s
CI / Deploy / lint (pull_request) Failing after 20s
CI / Deploy / deploy (pull_request) Has been skipped
Same approach as connect4 — extract GameContent component and patch it
as a single element, letting DOM morphing handle the diff.
2026-03-02 22:34:20 -10:00
Ryan Hamamura
0808c4d972 refactor: patch entire game content instead of individual components
Some checks failed
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Failing after 21s
CI / Deploy / deploy (pull_request) Has been skipped
Extract GameContent from GamePage so the SSE handler can patch a single
element and let DOM morphing diff the changes, replacing the per-component
sendGameComponents helper.
2026-03-02 21:43:25 -10:00
Ryan Hamamura
42211439c9 refactor: drop redundant WithSelectorID from SSE patches
Some checks failed
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Failing after 22s
CI / Deploy / deploy (pull_request) Has been skipped
All templ components already have id attributes on their root elements,
which PatchElementTempl uses automatically.
2026-03-02 21:34:46 -10:00
Ryan Hamamura
fb6c0e3d90 refactor: replace hardcoded NATS subjects with typed helpers
Some checks failed
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Failing after 21s
CI / Deploy / deploy (pull_request) Has been skipped
Add GameSubject/ChatSubject helpers to connect4 and snake packages,
eliminating magic string concatenation from handlers and main.go.
2026-03-02 21:30:47 -10:00
Ryan Hamamura
2cfd42b606 refactor: integrate chat persistence into Room
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Move SaveMessage/LoadMessages logic into Room as private methods.
NewPersistentRoom auto-loads history and auto-saves on Send, removing
the need for handlers to coordinate persistence separately.
2026-03-02 21:25:03 -10:00
Ryan Hamamura
6d43bdea16 refactor: rename remaining c4 references to games
All checks were successful
CI / Deploy / test (pull_request) Successful in 16s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Update binary name, DB path, session cookie, deploy scripts, systemd
service, Docker config, CI workflow, and .dockerignore. Remove stale
Claude command and settings files.
2026-03-02 21:16:12 -10:00
Ryan Hamamura
c6885a069b refactor: rename Go module from c4 to games
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Rename module path github.com/ryanhamamura/c4 to
github.com/ryanhamamura/games across go.mod, all source files,
and golangci config.
2026-03-02 20:41:20 -10:00
Ryan Hamamura
38eb9ee398 refactor: rename game package to connect4, drop Game prefix from types
All checks were successful
CI / Deploy / test (pull_request) Successful in 16s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Rename game/ -> connect4/ to avoid c4/game stutter. Drop redundant
Game prefix from exported types (GameStore -> Store, GameInstance ->
Instance, GameStatus -> Status). Rename NATS subjects from game.{id}
to connect4.{id}. URL routes unchanged.
2026-03-02 20:31:00 -10:00
Ryan Hamamura
f71acfc73e fix: use format string for datastar.PostSSE in chat component
All checks were successful
CI / Deploy / test (pull_request) Successful in 16s
CI / Deploy / lint (pull_request) Successful in 24s
CI / Deploy / deploy (pull_request) Has been skipped
PostSSE requires a constant format string; pass "%s" with the URL
as an argument instead of passing the URL directly.
2026-03-02 19:47:05 -10:00
63 changed files with 4177 additions and 737 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:
@@ -48,6 +48,8 @@ jobs:
runs-on: games runs-on: games
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for git describe
- name: Sync to deploy directory - name: Sync to deploy directory
run: | run: |
@@ -59,4 +61,8 @@ jobs:
mkdir -p $DEPLOY_DIR/data mkdir -p $DEPLOY_DIR/data
- name: Rebuild and restart - name: Rebuild and restart
run: cd $DEPLOY_DIR && docker compose up -d --build --remove-orphans run: |
cd $DEPLOY_DIR
VERSION=$(git describe --tags --always)
COMMIT=$(git rev-parse --short HEAD)
VERSION=$VERSION COMMIT=$COMMIT docker compose up -d --build --remove-orphans

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@
!.env.example !.env.example
!LICENSE !LICENSE
!AGENTS.md
!assets/**/* !assets/**/*

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:

253
AGENTS.md Normal file
View File

@@ -0,0 +1,253 @@
# 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.

View File

@@ -1,5 +1,8 @@
FROM docker.io/golang:1.25.4-alpine AS build FROM docker.io/golang:1.25.4-alpine AS build
ARG VERSION=dev
ARG COMMIT=unknown
RUN apk add --no-cache upx RUN apk add --no-cache upx
WORKDIR /src WORKDIR /src
@@ -7,12 +10,14 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN go tool templ generate
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 . MODULE=$(head -1 go.mod | awk '{print $2}') && \
RUN upx -9 -k /bin/c4 CGO_ENABLED=0 go build -ldflags="-s -X $MODULE/version.Version=$VERSION -X $MODULE/version.Commit=$COMMIT" -o /bin/games .
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

5
assets/assets.go Normal file
View File

@@ -0,0 +1,5 @@
// 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

22
assets/static_dev.go Normal file
View File

@@ -0,0 +1,22 @@
//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
}

26
assets/static_prod.go Normal file
View File

@@ -0,0 +1,26 @@
//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)
}

View File

@@ -3,11 +3,15 @@
package chat package chat
import ( import (
"context"
"encoding/json" "encoding/json"
"slices"
"sync" "sync"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/ryanhamamura/games/db/repository"
) )
// Message is the wire format for chat messages over NATS. // Message is the wire format for chat messages over NATS.
@@ -21,26 +25,47 @@ type Message struct {
const maxMessages = 50 const maxMessages = 50
// Room manages an in-memory message buffer and NATS pub/sub for a single // Room manages an in-memory message buffer and NATS pub/sub for a single
// chat room (typically one per game). // chat room (typically one per game). When created with NewPersistentRoom,
// messages are automatically loaded from and saved to the database.
type Room struct { type Room struct {
subject string subject string
nc *nats.Conn nc *nats.Conn
messages []Message messages []Message
mu sync.Mutex mu sync.Mutex
// Optional persistence; nil for ephemeral rooms (e.g. snake).
queries *repository.Queries
roomID string
} }
// NewRoom creates a chat room that publishes and subscribes on the given // NewRoom creates an ephemeral chat room with no database persistence.
// NATS subject (e.g. "chat.abc123"). func NewRoom(nc *nats.Conn, subject string) *Room {
func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room {
return &Room{ return &Room{
subject: subject, subject: subject,
nc: nc, nc: nc,
messages: initial,
} }
} }
// Send publishes a message to the room's NATS subject. // 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) { func (r *Room) Send(msg Message) {
if r.queries != nil {
r.saveMessage(msg)
}
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
if err != nil { if err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message") log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
@@ -51,12 +76,11 @@ func (r *Room) Send(msg Message) {
} }
} }
// Receive processes an incoming NATS message, appending it to the buffer. // 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, bool) {
func (r *Room) Receive(data []byte) (Message, []Message) {
var msg Message var msg Message
if err := json.Unmarshal(data, &msg); err != nil { if err := json.Unmarshal(data, &msg); err != nil {
return msg, nil return msg, false
} }
r.mu.Lock() r.mu.Lock()
@@ -64,11 +88,9 @@ func (r *Room) Receive(data []byte) (Message, []Message) {
if len(r.messages) > maxMessages { if len(r.messages) > maxMessages {
r.messages = r.messages[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() r.mu.Unlock()
return msg, snapshot return msg, true
} }
// Messages returns a snapshot of the current message buffer. // Messages returns a snapshot of the current message buffer.
@@ -80,13 +102,62 @@ func (r *Room) Messages() []Message {
return snapshot return snapshot
} }
// Subscribe creates a NATS channel subscription for the room's subject. // Subscribe returns a channel of parsed messages and a cleanup function.
// Caller is responsible for unsubscribing. // The room handles NATS subscription internally and buffers messages.
func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) { func (r *Room) Subscribe() (<-chan Message, func()) {
ch := make(chan *nats.Msg, 64) natsCh := make(chan *nats.Msg, 64)
sub, err := r.nc.ChanSubscribe(r.subject, ch) msgCh := make(chan Message, 64)
sub, err := r.nc.ChanSubscribe(r.subject, natsCh)
if err != nil { if err != nil {
return nil, nil, err close(msgCh)
return msgCh, func() {}
} }
return ch, sub, nil
go func() {
for natsMsg := range natsCh {
if msg, ok := r.receive(natsMsg.Data); ok {
msgCh <- msg
}
}
close(msgCh)
}()
cleanup := func() {
_ = sub.Unsubscribe()
}
return msgCh, cleanup
}
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

@@ -3,7 +3,7 @@ package components
import ( import (
"fmt" "fmt"
"github.com/ryanhamamura/c4/chat" "github.com/ryanhamamura/games/chat"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
@@ -23,16 +23,21 @@ type Config struct {
StopKeyPropagation bool StopKeyPropagation bool
} }
// ChatMessage renders a single chat message. Used for appending new messages via SSE.
templ ChatMessage(m chat.Message, cfg Config) {
<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>
}
templ Chat(messages []chat.Message, cfg Config) { templ Chat(messages []chat.Message, cfg Config) {
<div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }> <div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }>
<div class={ cfg.CSSPrefix + "-chat-history" }> <div id={ cfg.CSSPrefix + "-chat-history" } class={ cfg.CSSPrefix + "-chat-history" }>
for _, m := range messages { for _, m := range messages {
<div class={ cfg.CSSPrefix + "-chat-msg" }> @ChatMessage(m, cfg)
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", cfg.Color(m.Slot)) }>
{ m.Nickname + ": " }
</span>
<span>{ m.Message }</span>
</div>
} }
</div> </div>
<div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore> <div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore>
@@ -42,21 +47,20 @@ templ Chat(messages []chat.Message, cfg Config) {
placeholder="Chat..." placeholder="Chat..."
autocomplete="off" autocomplete="off"
data-bind="chatMsg" data-bind="chatMsg"
data-on:keydown.stop="" data-on:keydown__stop={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", cfg.PostURL) }
data-on:keydown.key_enter={ datastar.PostSSE(cfg.PostURL) } />
/> } else {
} else { <input
<input type="text"
type="text" placeholder="Chat..."
placeholder="Chat..." autocomplete="off"
autocomplete="off" data-bind="chatMsg"
data-bind="chatMsg" data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", cfg.PostURL) }
data-on:keydown.enter={ datastar.PostSSE(cfg.PostURL) } />
/> }
} <button
<button type="button"
type="button" data-on:click={ datastar.PostSSE("%s", cfg.PostURL) }
data-on:click={ datastar.PostSSE(cfg.PostURL) }
> >
Send Send
</button> </button>

View File

@@ -1,45 +0,0 @@
package chat
import (
"context"
"slices"
"github.com/ryanhamamura/c4/db/repository"
"github.com/rs/zerolog/log"
)
// SaveMessage persists a chat message to the database.
func SaveMessage(queries *repository.Queries, roomID string, msg Message) {
err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
GameID: roomID,
Nickname: msg.Nickname,
Color: int64(msg.Slot),
Message: msg.Message,
CreatedAt: msg.Time,
})
if err != nil {
log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message")
}
}
// LoadMessages loads persisted chat messages for a room, returning them
// in chronological order (oldest first).
func LoadMessages(queries *repository.Queries, roomID string) []Message {
rows, err := queries.GetChatMessages(context.Background(), roomID)
if err != nil {
return nil
}
msgs := make([]Message, len(rows))
for i, r := range rows {
msgs[i] = Message{
Nickname: r.Nickname,
Slot: int(r.Color),
Message: r.Message,
Time: r.CreatedAt,
}
}
// DB returns newest-first; reverse for chronological display
slices.Reverse(msgs)
return msgs
}

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,15 +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/c4/player" "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")
@@ -17,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")
} }
@@ -48,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
} }
@@ -61,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),
}) })
} }
@@ -83,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 {

View File

@@ -1,78 +1,78 @@
package game package connect4
import ( import (
"context" "context"
"sync" "sync"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/c4/player" "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 := player.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:
@@ -82,51 +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
} }
type GameInstance struct { type Instance 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()
@@ -153,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 player.ID) 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 {
@@ -170,7 +170,7 @@ func (gi *GameInstance) GetPlayerColor(pid player.ID) 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()
@@ -178,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
} }
@@ -194,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,11 +1,20 @@
package game package connect4
import ( import (
"encoding/json" "encoding/json"
"github.com/ryanhamamura/c4/player" "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 player.ID ID player.ID
UserID *string // UUID for authenticated users, nil for guests UserID *string // UUID for authenticated users, nil for guests
@@ -13,10 +22,10 @@ type Player struct {
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
@@ -27,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

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,11 @@
services: services:
c4: games:
build: . build:
container_name: c4 context: .
args:
VERSION: ${VERSION:-dev}
COMMIT: ${COMMIT:-unknown}
container_name: games
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
@@ -11,4 +15,4 @@ services:
environment: environment:
- PORT=8080 - PORT=8080
volumes: volumes:
- ./data:/app/data - ./data:/data

View File

@@ -3,30 +3,26 @@ package auth
import ( import (
"database/sql" "database/sql"
"net/http" "net/http"
"net/url"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/google/uuid" "github.com/google/uuid"
"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 { func HandleLoginPage(sessions *scs.SessionManager) http.HandlerFunc {
Username string `json:"username"`
Password string `json:"password"` //nolint:gosec // form input, not stored
}
type RegisterSignals struct {
Username string `json:"username"`
Password string `json:"password"` //nolint:gosec // form input, not stored
Confirm string `json:"confirm"`
}
func HandleLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := pages.LoginPage().Render(r.Context(), w); err != nil { // Capture return_url so we can redirect back after login
if returnURL := r.URL.Query().Get("return_url"); returnURL != "" {
sessions.Put(r.Context(), "return_url", returnURL)
}
errorMsg := r.URL.Query().Get("error")
if err := pages.LoginPage(errorMsg).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
} }
@@ -34,7 +30,8 @@ func HandleLoginPage() http.HandlerFunc {
func HandleRegisterPage() http.HandlerFunc { func HandleRegisterPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := pages.RegisterPage().Render(r.Context(), w); err != nil { errorMsg := r.URL.Query().Get("error")
if err := pages.RegisterPage(errorMsg).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
} }
@@ -42,32 +39,28 @@ func HandleRegisterPage() http.HandlerFunc {
func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc { func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var signals LoginSignals r.Body = http.MaxBytesReader(w, r.Body, 1024)
if err := datastar.ReadSignals(r, &signals); err != nil { username := r.FormValue("username")
http.Error(w, err.Error(), http.StatusBadRequest) password := r.FormValue("password")
return
}
sse := datastar.NewSSE(w, r) user, err := queries.GetUserByUsername(r.Context(), username)
user, err := queries.GetUserByUsername(r.Context(), signals.Username)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
sse.MarshalAndPatchSignals(map[string]any{"error": "Invalid username or password"}) //nolint:errcheck http.Redirect(w, r, "/login?error="+url.QueryEscape("Invalid username or password"), http.StatusSeeOther)
return return
} }
if err != nil { if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "An error occurred"}) //nolint:errcheck http.Redirect(w, r, "/login?error="+url.QueryEscape("An error occurred"), http.StatusSeeOther)
return return
} }
if !auth.CheckPassword(signals.Password, user.PasswordHash) { if !auth.CheckPassword(password, user.PasswordHash) {
sse.MarshalAndPatchSignals(map[string]any{"error": "Invalid username or password"}) //nolint:errcheck http.Redirect(w, r, "/login?error="+url.QueryEscape("Invalid username or password"), http.StatusSeeOther)
return return
} }
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 != "" {
@@ -75,53 +68,50 @@ func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http
redirectURL = returnURL redirectURL = returnURL
} }
sse.Redirect(redirectURL) //nolint:errcheck http.Redirect(w, r, redirectURL, http.StatusSeeOther)
} }
} }
func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc { func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var signals RegisterSignals r.Body = http.MaxBytesReader(w, r.Body, 1024)
if err := datastar.ReadSignals(r, &signals); err != nil { username := r.FormValue("username")
http.Error(w, err.Error(), http.StatusBadRequest) password := r.FormValue("password")
confirm := r.FormValue("confirm")
if err := auth.ValidateUsername(username); err != nil {
http.Redirect(w, r, "/register?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
if err := auth.ValidatePassword(password); err != nil {
http.Redirect(w, r, "/register?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
if password != confirm {
http.Redirect(w, r, "/register?error="+url.QueryEscape("Passwords do not match"), http.StatusSeeOther)
return return
} }
sse := datastar.NewSSE(w, r) hash, err := auth.HashPassword(password)
if err := auth.ValidateUsername(signals.Username); err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": err.Error()}) //nolint:errcheck
return
}
if err := auth.ValidatePassword(signals.Password); err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": err.Error()}) //nolint:errcheck
return
}
if signals.Password != signals.Confirm {
sse.MarshalAndPatchSignals(map[string]any{"error": "Passwords do not match"}) //nolint:errcheck
return
}
hash, err := auth.HashPassword(signals.Password)
if err != nil { if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "An error occurred"}) //nolint:errcheck http.Redirect(w, r, "/register?error="+url.QueryEscape("An error occurred"), http.StatusSeeOther)
return return
} }
user, err := queries.CreateUser(r.Context(), repository.CreateUserParams{ user, err := queries.CreateUser(r.Context(), repository.CreateUserParams{
ID: uuid.New().String(), ID: uuid.New().String(),
Username: signals.Username, Username: username,
PasswordHash: hash, PasswordHash: hash,
}) })
if err != nil { if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "Username already taken"}) //nolint:errcheck http.Redirect(w, r, "/register?error="+url.QueryEscape("Username already taken"), http.StatusSeeOther)
return return
} }
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 != "" {
@@ -129,6 +119,6 @@ func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) h
redirectURL = returnURL redirectURL = returnURL
} }
sse.Redirect(redirectURL) //nolint:errcheck http.Redirect(w, r, redirectURL, http.StatusSeeOther)
} }
} }

View File

@@ -0,0 +1,351 @@
package auth_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"github.com/ryanhamamura/games/auth"
"github.com/ryanhamamura/games/db/repository"
featauth "github.com/ryanhamamura/games/features/auth"
"github.com/ryanhamamura/games/features/lobby"
appsessions "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/testutil"
)
// sessionCookieName is the default SCS cookie name used in tests.
const sessionCookieName = "session"
type testSetup struct {
db *sql.DB
queries *repository.Queries
sm *scs.SessionManager
}
func (s *testSetup) ctx() context.Context {
return context.Background()
}
func newTestSetup(t *testing.T) *testSetup {
t.Helper()
db, queries := testutil.NewTestDB(t)
sm := testutil.NewTestSessionManager(t, db)
return &testSetup{db: db, queries: queries, sm: sm}
}
// createTestUser inserts a user into the test database and returns the user ID.
func createTestUser(t *testing.T, setup *testSetup, username, password string) string {
t.Helper()
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("hashing password: %v", err)
}
id := uuid.New().String()
_, err = setup.queries.CreateUser(setup.ctx(), repository.CreateUserParams{
ID: id,
Username: username,
PasswordHash: hash,
})
if err != nil {
t.Fatalf("creating test user: %v", err)
}
return id
}
// postForm sends a POST request with form-encoded body through the session middleware,
// forwarding any cookies from a previous response.
func postForm(handler http.Handler, path string, values url.Values, cookies []*http.Cookie) *httptest.ResponseRecorder {
body := strings.NewReader(values.Encode())
req := httptest.NewRequest(http.MethodPost, path, body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
// getPage sends a GET request through the session middleware, forwarding cookies.
func getPage(handler http.Handler, path string, cookies []*http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
// extractSessionValue makes a GET request with the given cookies to a test endpoint
// that reads a session value, verifying the session was persisted correctly.
func extractSessionValue(t *testing.T, setup *testSetup, cookies []*http.Cookie, key string) string {
t.Helper()
var value string
handler := setup.sm.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
value = setup.sm.GetString(r.Context(), key)
}))
req := httptest.NewRequest(http.MethodGet, "/check-session", nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("session check returned %d", rec.Code)
}
return value
}
func TestHandleLogin_Success(t *testing.T) {
setup := newTestSetup(t)
createTestUser(t, setup, "alice", "password123")
handler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
rec := postForm(handler, "/auth/login", url.Values{
"username": {"alice"},
"password": {"password123"},
}, nil)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/" {
t.Errorf("expected redirect to /, got %q", loc)
}
// Verify the response sets a session cookie
cookies := rec.Result().Cookies()
if !hasCookie(cookies, sessionCookieName) {
t.Fatal("response did not set a session cookie")
}
// Verify session contains user data by reading it back
userID := extractSessionValue(t, setup, cookies, appsessions.KeyUserID)
if userID == "" {
t.Error("session does not contain user_id after login")
}
nickname := extractSessionValue(t, setup, cookies, appsessions.KeyNickname)
if nickname != "alice" {
t.Errorf("expected nickname %q, got %q", "alice", nickname)
}
}
func TestHandleLogin_InvalidPassword(t *testing.T) {
setup := newTestSetup(t)
createTestUser(t, setup, "alice", "password123")
handler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
rec := postForm(handler, "/auth/login", url.Values{
"username": {"alice"},
"password": {"wrongpassword"},
}, nil)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/login?error=") {
t.Errorf("expected redirect to /login?error=..., got %q", loc)
}
}
func TestHandleLogin_UnknownUser(t *testing.T) {
setup := newTestSetup(t)
handler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
rec := postForm(handler, "/auth/login", url.Values{
"username": {"nonexistent"},
"password": {"password123"},
}, nil)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/login?error=") {
t.Errorf("expected redirect to /login?error=..., got %q", loc)
}
}
func TestHandleLogin_ReturnURL(t *testing.T) {
setup := newTestSetup(t)
createTestUser(t, setup, "alice", "password123")
// First, visit the login page with a return_url to store it in the session
loginPageHandler := setup.sm.LoadAndSave(featauth.HandleLoginPage(setup.sm))
pageRec := getPage(loginPageHandler, "/login?return_url=/games/abc", nil)
cookies := pageRec.Result().Cookies()
// Now log in with those cookies so the handler can read return_url from session
loginHandler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
rec := postForm(loginHandler, "/auth/login", url.Values{
"username": {"alice"},
"password": {"password123"},
}, cookies)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/games/abc" {
t.Errorf("expected redirect to /games/abc, got %q", loc)
}
}
func TestHandleRegister_Success(t *testing.T) {
setup := newTestSetup(t)
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
rec := postForm(handler, "/auth/register", url.Values{
"username": {"newuser"},
"password": {"password123"},
"confirm": {"password123"},
}, nil)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/" {
t.Errorf("expected redirect to /, got %q", loc)
}
cookies := rec.Result().Cookies()
if !hasCookie(cookies, sessionCookieName) {
t.Fatal("response did not set a session cookie")
}
userID := extractSessionValue(t, setup, cookies, appsessions.KeyUserID)
if userID == "" {
t.Error("session does not contain user_id after registration")
}
}
func TestHandleRegister_PasswordMismatch(t *testing.T) {
setup := newTestSetup(t)
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
rec := postForm(handler, "/auth/register", url.Values{
"username": {"newuser"},
"password": {"password123"},
"confirm": {"differentpassword"},
}, nil)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.Contains(loc, "Passwords+do+not+match") {
t.Errorf("expected error about password mismatch, got %q", loc)
}
}
func TestHandleRegister_InvalidUsername(t *testing.T) {
setup := newTestSetup(t)
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
rec := postForm(handler, "/auth/register", url.Values{
"username": {"ab"}, // too short
"password": {"password123"},
"confirm": {"password123"},
}, nil)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/register?error=") {
t.Errorf("expected redirect to /register?error=..., got %q", loc)
}
}
func TestHandleRegister_ShortPassword(t *testing.T) {
setup := newTestSetup(t)
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
rec := postForm(handler, "/auth/register", url.Values{
"username": {"validuser"},
"password": {"short"},
"confirm": {"short"},
}, nil)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/register?error=") {
t.Errorf("expected redirect to /register?error=..., got %q", loc)
}
}
func TestHandleRegister_DuplicateUsername(t *testing.T) {
setup := newTestSetup(t)
createTestUser(t, setup, "taken", "password123")
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
rec := postForm(handler, "/auth/register", url.Values{
"username": {"taken"},
"password": {"password123"},
"confirm": {"password123"},
}, nil)
if rec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.Contains(loc, "Username+already+taken") {
t.Errorf("expected error about duplicate username, got %q", loc)
}
}
func TestHandleLogout(t *testing.T) {
setup := newTestSetup(t)
createTestUser(t, setup, "alice", "password123")
// Log in first to establish a session
loginHandler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
loginRec := postForm(loginHandler, "/auth/login", url.Values{
"username": {"alice"},
"password": {"password123"},
}, nil)
cookies := loginRec.Result().Cookies()
// Verify we're logged in
userID := extractSessionValue(t, setup, cookies, appsessions.KeyUserID)
if userID == "" {
t.Fatal("expected to be logged in before testing logout")
}
// Now log out
logoutHandler := setup.sm.LoadAndSave(lobby.HandleLogout(setup.sm))
logoutRec := postForm(logoutHandler, "/logout", nil, cookies)
if logoutRec.Code != http.StatusSeeOther {
t.Errorf("expected status %d, got %d", http.StatusSeeOther, logoutRec.Code)
}
if loc := logoutRec.Header().Get("Location"); loc != "/" {
t.Errorf("expected redirect to /, got %q", loc)
}
// Verify session is cleared — use the cookies from the logout response
logoutCookies := logoutRec.Result().Cookies()
userID = extractSessionValue(t, setup, logoutCookies, appsessions.KeyUserID)
if userID != "" {
t.Errorf("expected empty user_id after logout, got %q", userID)
}
}
func hasCookie(cookies []*http.Cookie, name string) bool {
for _, c := range cookies {
if c.Name == name {
return true
}
}
return false
}

View File

@@ -1,45 +1,39 @@
package pages package pages
import ( import "github.com/ryanhamamura/games/features/common/layouts"
"github.com/ryanhamamura/c4/features/common/layouts"
"github.com/starfederation/datastar-go/datastar"
)
templ LoginPage() { templ LoginPage(errorMsg string) {
@layouts.Base("Login") { @layouts.Base("Login") {
<main class="max-w-sm mx-auto mt-8 text-center" data-signals="{username: '', password: '', error: ''}"> <main class="max-w-sm mx-auto mt-8 text-center">
<h1 class="text-3xl font-bold">Login</h1> <h1 class="text-3xl font-bold">Login</h1>
<p class="mb-4">Sign in to your account</p> <p class="mb-4">Sign in to your account</p>
<div data-show="$error != ''" class="alert alert-error mb-4" data-text="$error"></div> if errorMsg != "" {
<div> <div class="alert alert-error mb-4">{ errorMsg }</div>
}
<form method="POST" action="/auth/login">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="username">Username</label> <label class="label" for="username">Username</label>
<input <input
class="input input-bordered w-full" class="input input-bordered w-full"
id="username" id="username"
name="username"
type="text" type="text"
placeholder="Enter your username" placeholder="Enter your username"
data-bind="username" autofocus
data-on:keydown.key_enter={ datastar.PostSSE("/auth/login") } />
autofocus
/>
<label class="label" for="password">Password</label> <label class="label" for="password">Password</label>
<input <input
class="input input-bordered w-full" class="input input-bordered w-full"
id="password" id="password"
name="password"
type="password" type="password"
placeholder="Enter your password" placeholder="Enter your password"
data-bind="password"
data-on:keydown.key_enter={ datastar.PostSSE("/auth/login") }
/> />
</fieldset> </fieldset>
<button <button type="submit" class="btn btn-primary w-full">
class="btn btn-primary w-full"
data-on:click={ datastar.PostSSE("/auth/login") }
>
Login Login
</button> </button>
</div> </form>
<p> <p>
Don't have an account? <a class="link" href="/register">Register</a> Don't have an account? <a class="link" href="/register">Register</a>
</p> </p>

View File

@@ -1,54 +1,47 @@
package pages package pages
import ( import "github.com/ryanhamamura/games/features/common/layouts"
"github.com/ryanhamamura/c4/features/common/layouts"
"github.com/starfederation/datastar-go/datastar"
)
templ RegisterPage() { templ RegisterPage(errorMsg string) {
@layouts.Base("Register") { @layouts.Base("Register") {
<main class="max-w-sm mx-auto mt-8 text-center" data-signals="{username: '', password: '', confirm: '', error: ''}"> <main class="max-w-sm mx-auto mt-8 text-center">
<h1 class="text-3xl font-bold">Register</h1> <h1 class="text-3xl font-bold">Register</h1>
<p class="mb-4">Create a new account</p> <p class="mb-4">Create a new account</p>
<div data-show="$error != ''" class="alert alert-error mb-4" data-text="$error"></div> if errorMsg != "" {
<div> <div class="alert alert-error mb-4">{ errorMsg }</div>
}
<form method="POST" action="/auth/register">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="username">Username</label> <label class="label" for="username">Username</label>
<input <input
class="input input-bordered w-full" class="input input-bordered w-full"
id="username" id="username"
name="username"
type="text" type="text"
placeholder="Choose a username" placeholder="Choose a username"
data-bind="username" autofocus
data-on:keydown.key_enter={ datastar.PostSSE("/auth/register") } />
autofocus
/>
<label class="label" for="password">Password</label> <label class="label" for="password">Password</label>
<input <input
class="input input-bordered w-full" class="input input-bordered w-full"
id="password" id="password"
name="password"
type="password" type="password"
placeholder="Choose a password (min 8 chars)" placeholder="Choose a password (min 8 chars)"
data-bind="password" />
data-on:keydown.key_enter={ datastar.PostSSE("/auth/register") }
/>
<label class="label" for="confirm">Confirm Password</label> <label class="label" for="confirm">Confirm Password</label>
<input <input
class="input input-bordered w-full" class="input input-bordered w-full"
id="confirm" id="confirm"
name="confirm"
type="password" type="password"
placeholder="Confirm your password" placeholder="Confirm your password"
data-bind="confirm"
data-on:keydown.key_enter={ datastar.PostSSE("/auth/register") }
/> />
</fieldset> </fieldset>
<button <button type="submit" class="btn btn-primary w-full">
class="btn btn-primary w-full"
data-on:click={ datastar.PostSSE("/auth/register") }
>
Register Register
</button> </button>
</div> </form>
<p> <p>
Already have an account? <a class="link" href="/login">Login</a> Already have an account? <a class="link" href="/login">Login</a>
</p> </p>

View File

@@ -5,11 +5,11 @@ 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) {
router.Get("/login", HandleLoginPage()) router.Get("/login", HandleLoginPage(sessions))
router.Get("/register", HandleRegisterPage()) router.Get("/register", HandleRegisterPage())
router.Post("/auth/login", HandleLogin(queries, sessions)) router.Post("/auth/login", HandleLogin(queries, sessions))
router.Post("/auth/register", HandleRegister(queries, sessions)) router.Post("/auth/register", HandleRegister(queries, sessions))

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,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,47 +1,23 @@
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/c4/chat" "github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/games/features/c4game/services"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/c4/sessions"
) )
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors. func HandleGamePage(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
var c4ChatColors = map[int]string{
0: "#4a2a3a", // color 1 stored as slot 0
1: "#2a4545", // color 2 stored as slot 1
}
func c4ChatColor(slot int) string {
if c, ok := c4ChatColors[slot]; ok {
return c
}
return "#666"
}
func c4ChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "c4",
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
Color: c4ChatColor,
}
}
func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -57,14 +33,14 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo
// 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 {
p := &game.Player{ p := &connect4.Player{
ID: playerID, ID: playerID,
Nickname: nickname, Nickname: nickname,
} }
if userID != "" { if userID != "" {
p.UserID = &userID p.UserID = &userID
} }
gi.Join(&game.PlayerSession{Player: p}) gi.Join(&connect4.PlayerSession{Player: p})
} }
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
@@ -85,16 +61,17 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo
} }
g := gi.GetGame() g := gi.GetGame()
msgs := chat.LoadMessages(queries, gameID) room := svc.ChatRoom(gameID)
if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { if err := pages.GamePage(g, myColor, room.Messages(), svc.ChatConfig(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, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) 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)
@@ -104,47 +81,74 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
} }
playerID := sessions.GetPlayerID(sm, r) playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression( // Subscribe to game state updates BEFORE creating SSE
datastar.WithBrotli(datastar.WithBrotliLevel(5)), gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
))
chatCfg := c4ChatConfig(gameID)
room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID))
// Send initial render
sendGameComponents(sse, gi, myColor, room, chatCfg)
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
defer gameSub.Unsubscribe() //nolint:errcheck defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages // Subscribe to chat messages BEFORE creating SSE
chatCh, chatSub, err := room.Subscribe() chatCfg := svc.ChatConfig(gameID)
if err != nil { 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(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
// Define patch function
patchAll := func() error {
myColor := gi.GetPlayerColor(playerID)
g := gi.GetGame()
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
}
// Send initial state
if err := patchAll(); err != nil {
return return
} }
defer chatSub.Unsubscribe() //nolint:errcheck
ctx := r.Context() // Event loop
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-gameCh: case <-gameCh:
myColor = gi.GetPlayerColor(playerID) // Drain rapid-fire notifications
sendGameComponents(sse, gi, myColor, room, chatCfg) drainGame:
case msg := <-chatCh: for {
_, snapshot := room.Receive(msg.Data) select {
if snapshot == nil { case <-gameCh:
continue default:
break drainGame
}
} }
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil { if err := patchAll(); err != nil {
return
}
case chatMsg := <-chatCh:
if err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("c4-chat-history"),
datastar.WithModeAppend(),
); err != nil {
return
}
case <-heartbeat.C:
// Heartbeat refreshes game state to keep connection alive
if err := patchAll(); err != nil {
return return
} }
} }
@@ -152,7 +156,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
} }
} }
func HandleDropPiece(store *game.GameStore, sm *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")
@@ -181,7 +185,7 @@ func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.Handler
} }
} }
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleSendChat(store *connect4.Store, svc *services.GameService, 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")
@@ -228,9 +232,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager
Message: signals.ChatMsg, Message: signals.ChatMsg,
Time: time.Now().UnixMilli(), Time: time.Now().UnixMilli(),
} }
chat.SaveMessage(queries, gameID, msg) room := svc.ChatRoom(gameID)
room := chat.NewRoom(nc, "game.chat."+gameID, nil)
room.Send(msg) room.Send(msg)
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
@@ -238,7 +240,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager
} }
} }
func HandleSetNickname(store *game.GameStore, sm *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")
@@ -263,20 +265,20 @@ func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.Handl
return return
} }
sm.Put(r.Context(), "nickname", signals.Nickname) sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
playerID := sessions.GetPlayerID(sm, r) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetUserID(sm, r) userID := sessions.GetUserID(sm, r)
if gi.GetPlayerColor(playerID) == 0 { if gi.GetPlayerColor(playerID) == 0 {
p := &game.Player{ p := &connect4.Player{
ID: playerID, ID: playerID,
Nickname: signals.Nickname, Nickname: signals.Nickname,
} }
if userID != "" { if userID != "" {
p.UserID = &userID p.UserID = &userID
} }
gi.Join(&game.PlayerSession{Player: p}) gi.Join(&connect4.PlayerSession{Player: p})
} }
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
@@ -284,7 +286,7 @@ func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.Handl
} }
} }
func HandleRematch(store *game.GameStore, sm *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")
@@ -302,13 +304,3 @@ func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFu
} }
} }
} }
// sendGameComponents patches all game-related SSE components.
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
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
sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
}

View File

@@ -1,37 +1,45 @@
package pages package pages
import ( import (
"github.com/ryanhamamura/c4/chat" "fmt"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"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"
"github.com/starfederation/datastar-go/datastar" sharedcomponents "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/features/common/layouts"
) )
templ GamePage(g *game.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) { 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={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", 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)
@chatcomponents.Chat(messages, chatCfg)
</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.LiveClock()
@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

@@ -4,24 +4,22 @@ 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/c4/db/repository" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/features/c4game/services"
) )
func SetupRoutes( func SetupRoutes(
router chi.Router, router chi.Router,
store *game.GameStore, store *connect4.Store,
nc *nats.Conn, svc *services.GameService,
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, sessions, queries)) r.Get("/", HandleGamePage(store, svc, sessions))
r.Get("/events", HandleGameEvents(store, nc, sessions, queries)) r.Get("/events", HandleGameEvents(store, svc, sessions))
r.Post("/drop", HandleDropPiece(store, sessions)) r.Post("/drop", HandleDropPiece(store, sessions))
r.Post("/chat", HandleSendChat(store, nc, sessions, queries)) r.Post("/chat", HandleSendChat(store, svc, sessions))
r.Post("/join", HandleSetNickname(store, sessions)) r.Post("/join", HandleSetNickname(store, sessions))
r.Post("/rematch", HandleRematch(store, sessions)) r.Post("/rematch", HandleRematch(store, sessions))
}) })

View File

@@ -0,0 +1,70 @@
// 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)
}

View File

@@ -1,6 +1,10 @@
package components package components
import "github.com/starfederation/datastar-go/datastar" import (
"time"
"github.com/starfederation/datastar-go/datastar"
)
templ BackToLobby() { templ BackToLobby() {
<a class="link text-sm opacity-70" href="/">&larr; Back</a> <a class="link text-sm opacity-70" href="/">&larr; Back</a>
@@ -28,7 +32,7 @@ templ NicknamePrompt(returnPath string) {
type="text" type="text"
placeholder="Enter your nickname" placeholder="Enter your nickname"
data-bind="nickname" data-bind="nickname"
data-on:keydown.key_enter={ datastar.PostSSE("%s", returnPath) } data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", returnPath) }
required required
autofocus autofocus
/> />
@@ -44,6 +48,15 @@ templ NicknamePrompt(returnPath string) {
</main> </main>
} }
// LiveClock shows the current server time, updated every second via SSE.
// If the clock stops updating, users know the connection is stale.
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>
{ time.Now().Format("15:04:05") }
</div>
}
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>

View File

@@ -1,6 +1,10 @@
package layouts package layouts
import "github.com/ryanhamamura/c4/config" import (
"github.com/ryanhamamura/games/assets"
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/version"
)
templ Base(title string) { templ Base(title string) {
<!DOCTYPE html> <!DOCTYPE html>
@@ -8,14 +12,17 @@ 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/js/datastar.js"></script> <script defer type="module" src={ assets.StaticPath("js/datastar.js") }></script>
<link href="/assets/css/output.css" rel="stylesheet" type="text/css"/> <link href={ assets.StaticPath("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 {
<div data-init="@get('/reload', {retryMaxCount: 1000, retryInterval:20, retryMaxWaitMs:200})"></div> <div data-init="@get('/reload', {retryMaxCount: 1000, retryInterval:20, retryMaxWaitMs:200})"></div>
} }
{ children... } { children... }
<footer class="fixed bottom-1 right-2 text-xs text-gray-500">
{ version.Version }
</footer>
</body> </body>
</html> </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" {
@@ -170,7 +171,6 @@ func HandleLogout(sessions *scs.SessionManager) http.HandlerFunc {
return return
} }
sse := datastar.NewSSE(w, r) http.Redirect(w, r, "/", http.StatusSeeOther)
sse.ExecuteScript("window.location.href='/'") //nolint:errcheck
} }
} }

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"
) )
@@ -20,13 +20,11 @@ templ LobbyPage(data LobbyData) {
if data.IsLoggedIn { if data.IsLoggedIn {
<div class="flex justify-center items-center gap-4 mb-4 p-2 bg-base-200 rounded-lg"> <div class="flex justify-center items-center gap-4 mb-4 p-2 bg-base-200 rounded-lg">
<span>Logged in as <strong>{ data.Username }</strong></span> <span>Logged in as <strong>{ data.Username }</strong></span>
<button <form method="POST" action="/logout" class="inline">
type="button" <button type="submit" class="btn btn-ghost btn-sm">
class="btn btn-ghost btn-sm" Logout
data-on:click={ datastar.PostSSE("/logout") } </button>
> </form>
Logout
</button>
</div> </div>
} else { } else {
<div class="alert text-sm mb-4"> <div class="alert text-sm mb-4">
@@ -73,7 +71,7 @@ templ LobbyPage(data LobbyData) {
placeholder="Enter your nickname" placeholder="Enter your nickname"
data-bind="nickname" data-bind="nickname"
required required
data-on:keydown.enter={ datastar.PostSSE("/games") } data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("/games") }
/> />
</fieldset> </fieldset>
<button <button

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

@@ -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,40 +1,24 @@
package snakegame package snakegame
import ( import (
"fmt" "errors"
"net/http" "net/http"
"strconv" "strconv"
"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/c4/chat" "github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/c4/features/snakegame/components" "github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/c4/features/snakegame/pages" "github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/c4/sessions" "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
) )
func snakeChatColor(slot int) string { func HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
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)
@@ -76,13 +60,14 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
} }
sg := si.GetGame() sg := si.GetGame()
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil { chatCfg := svc.ChatConfig(gameID)
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, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc { func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService, 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)
@@ -94,44 +79,59 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
playerID := sessions.GetPlayerID(sm, r) playerID := sessions.GetPlayerID(sm, r)
mySlot := si.GetPlayerSlot(playerID) mySlot := si.GetPlayerSlot(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression( // Subscribe to game updates BEFORE creating SSE (following portigo pattern)
datastar.WithBrotli(datastar.WithBrotliLevel(5)), gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
))
chatCfg := snakeChatConfig(gameID)
// Send initial render
sg := si.GetGame()
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
if sg.Mode == snake.ModeMultiplayer {
sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
}
}
// Subscribe to game updates via NATS
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
defer gameSub.Unsubscribe() //nolint:errcheck defer gameSub.Unsubscribe() //nolint:errcheck
// Chat subscription (multiplayer only) sse := datastar.NewSSE(w, r, datastar.WithCompression(
var chatCh chan *nats.Msg datastar.WithBrotli(datastar.WithBrotliLevel(5)),
var chatSub *nats.Subscription ))
var room *chat.Room
chatCfg := svc.ChatConfig(gameID)
// Chat room (multiplayer only)
var room *chat.Room
sg := si.GetGame()
if sg.Mode == snake.ModeMultiplayer { if sg.Mode == snake.ModeMultiplayer {
room = chat.NewRoom(nc, "snake.chat."+gameID, nil) room = svc.ChatRoom(gameID)
chatCh, chatSub, err = room.Subscribe() }
if err != nil {
return chatMessages := func() []chat.Message {
if room == nil {
return nil
} }
defer chatSub.Unsubscribe() //nolint:errcheck return room.Messages()
}
patchAll := func() error {
si, ok = snakeStore.Get(gameID)
if !ok {
return errors.New("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
}
heartbeat := time.NewTicker(1 * time.Second)
defer heartbeat.Stop()
// Chat subscription (multiplayer only)
var chatCh <-chan chat.Message
var cleanupChat func()
if room != nil {
chatCh, cleanupChat = room.Subscribe()
defer cleanupChat()
} }
ctx := r.Context() ctx := r.Context()
@@ -140,6 +140,12 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
case <-ctx.Done(): case <-ctx.Done():
return return
case <-heartbeat.C:
// Heartbeat refreshes game state to keep connection alive
if err := patchAll(); err != nil {
return
}
case <-gameCh: case <-gameCh:
// Drain backed-up game updates // Drain backed-up game updates
for { for {
@@ -150,31 +156,20 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
} }
} }
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
} }
case msg := <-chatCh: case chatMsg, ok := <-chatCh:
if msg == nil { if !ok {
continue continue
} }
_, snapshot := room.Receive(msg.Data) err := sse.PatchElementTempl(
if snapshot == nil { chatcomponents.ChatMessage(chatMsg, chatCfg),
continue datastar.WithSelectorID("snake-chat-history"),
} datastar.WithModeAppend(),
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil { )
if err != nil {
return return
} }
} }
@@ -214,7 +209,7 @@ type chatSignals struct {
ChatMsg string `json:"chatMsg"` ChatMsg string `json:"chatMsg"`
} }
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc { func HandleSendChat(snakeStore *snake.SnakeStore, svc *services.GameService, 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)
@@ -247,7 +242,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
Message: signals.ChatMsg, Message: signals.ChatMsg,
} }
room := chat.NewRoom(nc, "snake.chat."+gameID, nil) room := svc.ChatRoom(gameID)
room.Send(msg) room.Send(msg)
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
@@ -278,7 +273,7 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt
return return
} }
sm.Put(r.Context(), "nickname", signals.Nickname) sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
playerID := sessions.GetPlayerID(sm, r) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetUserID(sm, r) userID := sessions.GetUserID(sm, r)

View File

@@ -3,12 +3,12 @@ package pages
import ( import (
"fmt" "fmt"
"github.com/ryanhamamura/c4/chat" "github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components" chatcomponents "github.com/ryanhamamura/games/chat/components"
"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"
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components" snakecomponents "github.com/ryanhamamura/games/features/snakegame/components"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
@@ -33,33 +33,40 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
<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"
data-signals={ `{"chatMsg":""}` } data-signals={ `{"chatMsg":""}` }
data-init={ datastar.GetSSE("/snake/%s/events", gameID) } data-init={ fmt.Sprintf("@get('/snake/%s/events',{requestCancellation:'disabled'})", gameID) }
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)
@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)
}
</main> </main>
} }
} }
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
<div id="game-content">
@components.LiveClock()
@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

@@ -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/c4/snake" "github.com/ryanhamamura/games/features/snakegame/services"
"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, svc *services.GameService, sessions *scs.SessionManager) {
router.Route("/snake/{id}", func(r chi.Router) { router.Route("/snake/{id}", func(r chi.Router) {
r.Get("/", HandleSnakePage(snakeStore, sessions)) r.Get("/", HandleSnakePage(snakeStore, svc, sessions))
r.Get("/events", HandleSnakeEvents(snakeStore, nc, sessions)) r.Get("/events", HandleSnakeEvents(snakeStore, svc, sessions))
r.Post("/dir", HandleSetDirection(snakeStore, sessions)) r.Post("/dir", HandleSetDirection(snakeStore, sessions))
r.Post("/chat", HandleSendChat(snakeStore, nc, sessions)) r.Post("/chat", HandleSendChat(snakeStore, svc, 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))
}) })

View File

@@ -0,0 +1,62 @@
// 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)
}

6
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
@@ -68,6 +68,7 @@ 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
@@ -170,6 +171,9 @@ 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
View File

@@ -136,6 +136,8 @@ 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=
@@ -565,6 +567,12 @@ 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=

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,10 +5,11 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/ryanhamamura/c4/config" "github.com/go-chi/chi/v5/middleware"
"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 (
@@ -64,25 +65,15 @@ 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()
rw := &responseWriter{ResponseWriter: w} ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(rw, r) next.ServeHTTP(ww, r)
status := rw.status status := ww.Status()
if status == 0 { if status == 0 {
status = http.StatusOK status = http.StatusOK
} }

47
main.go
View File

@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"embed"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -11,31 +10,35 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/ryanhamamura/c4/config"
"github.com/ryanhamamura/c4/db"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/logging"
appnats "github.com/ryanhamamura/c4/nats"
"github.com/ryanhamamura/c4/router"
"github.com/ryanhamamura/c4/sessions"
"github.com/ryanhamamura/c4/snake"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
slogzerolog "github.com/samber/slog-zerolog/v2"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
)
//go:embed assets "github.com/ryanhamamura/games/config"
var assets embed.FS "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/logging"
appnats "github.com/ryanhamamura/games/nats"
"github.com/ryanhamamura/games/router"
"github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
"github.com/ryanhamamura/games/version"
)
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
logging.SetupLogger(cfg.Environment, cfg.LogLevel) zerologLogger := 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")
@@ -45,7 +48,7 @@ func main() {
func run(ctx context.Context) error { func run(ctx context.Context) error {
cfg := config.Global cfg := config.Global
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port) addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
slog.Info("server starting", "addr", addr) slog.Info("server starting", "addr", addr, "version", version.Version, "commit", version.Commit)
defer slog.Info("server shutdown complete") defer slog.Info("server shutdown complete")
eg, egctx := errgroup.WithContext(ctx) eg, egctx := errgroup.WithContext(ctx)
@@ -71,14 +74,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
@@ -90,7 +93,7 @@ func run(ctx context.Context) error {
sessionManager.LoadAndSave, sessionManager.LoadAndSave,
) )
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, assets) router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore)
// HTTP server // HTTP server
srv := &http.Server{ srv := &http.Server{
@@ -100,6 +103,10 @@ 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 {

View File

@@ -2,24 +2,25 @@
package router package router
import ( import (
"embed"
"io/fs"
"net/http" "net/http"
"sync" "sync"
"github.com/ryanhamamura/c4/config"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/auth"
"github.com/ryanhamamura/c4/features/c4game"
"github.com/ryanhamamura/c4/features/lobby"
"github.com/ryanhamamura/c4/features/snakegame"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"github.com/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/games/assets"
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/auth"
"github.com/ryanhamamura/games/features/c4game"
c4services "github.com/ryanhamamura/games/features/c4game/services"
"github.com/ryanhamamura/games/features/lobby"
"github.com/ryanhamamura/games/features/snakegame"
snakeservices "github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/snake"
) )
func SetupRoutes( func SetupRoutes(
@@ -27,23 +28,25 @@ 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,
) { ) {
// Static assets // Static assets
subFS, _ := fs.Sub(assets, "assets") router.Handle("/assets/*", assets.Handler())
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, nc, sessions, queries) c4game.SetupRoutes(router, store, c4Svc, sessions)
snakegame.SetupRoutes(router, snakeStore, nc, sessions) snakegame.SetupRoutes(router, snakeStore, snakeSvc, sessions)
} }
func setupReload(router chi.Router) { func setupReload(router chi.Router) {

View File

@@ -8,12 +8,19 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/ryanhamamura/c4/player" "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()) {
@@ -23,10 +30,10 @@ 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 = false
sessionManager.Cookie.SameSite = http.SameSiteLaxMode sessionManager.Cookie.SameSite = http.SameSiteLaxMode
slog.Info("session manager configured") slog.Info("session manager configured")
@@ -38,12 +45,12 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
// Authenticated users get their user UUID; guests get a random ID that // Authenticated users get their user UUID; guests get a random ID that
// is generated and persisted on first access. // is generated and persisted on first access.
func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID { func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
pid := sm.GetString(r.Context(), "player_id") pid := sm.GetString(r.Context(), KeyPlayerID)
if pid == "" { if pid == "" {
pid = player.GenerateID(8) pid = player.GenerateID(8)
sm.Put(r.Context(), "player_id", pid) sm.Put(r.Context(), KeyPlayerID, pid)
} }
if userID := sm.GetString(r.Context(), "user_id"); userID != "" { if userID := sm.GetString(r.Context(), KeyUserID); userID != "" {
return player.ID(userID) return player.ID(userID)
} }
return player.ID(pid) return player.ID(pid)
@@ -51,10 +58,10 @@ func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
// GetUserID returns the authenticated user's UUID, or empty string for guests. // GetUserID returns the authenticated user's UUID, or empty string for guests.
func GetUserID(sm *scs.SessionManager, r *http.Request) string { func GetUserID(sm *scs.SessionManager, r *http.Request) string {
return sm.GetString(r.Context(), "user_id") return sm.GetString(r.Context(), KeyUserID)
} }
// GetNickname returns the player's display name from the session. // GetNickname returns the player's display name from the session.
func GetNickname(sm *scs.SessionManager, r *http.Request) string { func GetNickname(sm *scs.SessionManager, r *http.Request) string {
return sm.GetString(r.Context(), "nickname") return sm.GetString(r.Context(), KeyNickname)
} }

View File

@@ -3,8 +3,8 @@ package snake
import ( import (
"context" "context"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/c4/player" "github.com/ryanhamamura/games/player"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )

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/player" "github.com/ryanhamamura/games/player"
) )
type SnakeStore struct { type SnakeStore struct {

View File

@@ -4,9 +4,18 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/ryanhamamura/c4/player" "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 (

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"

10
version/version.go Normal file
View File

@@ -0,0 +1,10 @@
// Package version holds build-time version information injected via ldflags.
package version
// Version and Commit are set at build time via:
//
// -ldflags "-X github.com/ryanhamamura/games/version.Version=... -X github.com/ryanhamamura/games/version.Commit=..."
var (
Version = "dev"
Commit = "unknown"
)