46 Commits

Author SHA1 Message Date
Ryan Hamamura
551190b801 Switch to datastar-pro and stop tracking downloaded libs
All checks were successful
CI / Deploy / test (pull_request) Successful in 15s
CI / Deploy / lint (pull_request) Successful in 28s
CI / Deploy / deploy (pull_request) Has been skipped
Datastar-pro is fetched from a private Gitea repo (ryan/vendor-libs)
using VENDOR_TOKEN for CI/Docker builds, with a local fallback from
../optional/ for development. DaisyUI is pinned to v5.5.19 instead of
tracking latest. Downloaded files are now gitignored and fetched at
build time via 'task download', which is a dependency of both build
and live tasks.
2026-03-11 13:17:50 -10:00
8789c5414e Merge pull request 'fix: restore flex layout on #game-content wrapper' (#15) from fix/game-content-layout into main
All checks were successful
CI / Deploy / test (push) Successful in 19s
CI / Deploy / lint (push) Successful in 29s
CI / Deploy / deploy (push) Successful in 1m34s
2026-03-11 20:39:04 +00:00
Ryan Hamamura
7a1c91c858 fix: restore flex layout on #game-content wrapper
All checks were successful
CI / Deploy / test (pull_request) Successful in 17s
CI / Deploy / lint (pull_request) Successful in 27s
CI / Deploy / deploy (pull_request) Has been skipped
The SSE patching refactor (0808c4d) wrapped game elements in a bare
<div id="game-content"> without propagating the flex classes from
<main>. This broke center-alignment and vertical spacing for both
Connect 4 and Snake game pages.
2026-03-11 10:35:29 -10:00
Ryan Hamamura
2ad0abaf44 ci: prune dangling Docker images after deploy
All checks were successful
CI / Deploy / test (push) Successful in 17s
CI / Deploy / lint (push) Successful in 27s
CI / Deploy / deploy (push) Successful in 1m27s
2026-03-11 10:22:55 -10:00
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
Ryan Hamamura
10de5d21ad refactor: extract standalone chat package from game-specific handlers
Some checks failed
CI / Deploy / test (pull_request) Failing after 11s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Create chat/ package with Message type, Room (NATS pub/sub + buffer),
DB persistence helpers, and a unified templ component parameterized by
Config (CSS prefix, post URL, color function, key propagation).

Both c4game and snakegame now use chat.Room for message management and
chatcomponents.Chat for rendering, eliminating the duplicated
ChatMessage types, chat templ components, chatAutoScroll scripts,
color functions, and inline buffer management.
2026-03-02 19:20:21 -10:00
Ryan Hamamura
7eadfbbb0c refactor: extract session helpers for player identity resolution
Add GetPlayerID, GetUserID, GetNickname to the sessions package.
Remove the inline player-ID-from-session pattern duplicated across
every handler in c4game and snakegame, and the local getPlayerID
helper in snakegame.
2026-03-02 19:16:09 -10:00
Ryan Hamamura
063b03ce25 refactor: extract shared player.ID type and GenerateID to player package
Both game and snake packages had identical PlayerID types and the snake
package imported game.GenerateID. Now both use player.ID and
player.GenerateID from the shared player package.
2026-03-02 19:09:01 -10:00
72 changed files with 4631 additions and 2092 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
@@ -11,6 +11,10 @@
# PORT=7331 # PORT=7331
# Goose CLI migration config (only needed for running goose manually) # Goose CLI migration config (only needed for running goose manually)
# Gitea API token for downloading datastar-pro from private repo (CI/Docker only).
# Not needed for local dev — falls back to copying from ../optional/.
# VENDOR_TOKEN=
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,13 @@ 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 env:
VENDOR_TOKEN: ${{ secrets.VENDOR_TOKEN }}
run: |
cd $DEPLOY_DIR
VERSION=$(git describe --tags --always)
COMMIT=$(git rev-parse --short HEAD)
VERSION=$VERSION COMMIT=$COMMIT VENDOR_TOKEN=$VENDOR_TOKEN docker compose up -d --build --remove-orphans
- name: Prune unused images
run: docker image prune -f

5
.gitignore vendored
View File

@@ -19,6 +19,7 @@
!.env.example !.env.example
!LICENSE !LICENSE
!AGENTS.md
!assets/**/* !assets/**/*
@@ -26,6 +27,10 @@
*_templ.go *_templ.go
assets/css/output.css assets/css/output.css
# Downloaded client-side libs (fetched by cmd/downloader)
assets/js/datastar/*
assets/css/daisyui/*
# Deploy scripts and configs # Deploy scripts and configs
!deploy/*.sh !deploy/*.sh
!deploy/*.service !deploy/*.service

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:

218
AGENTS.md Normal file
View File

@@ -0,0 +1,218 @@
# 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
task download # Download pinned client-side libs (datastar-pro, daisyui)
# Quality
task test # Run all tests: go test ./...
task lint # Run linter: golangci-lint run
# Single test
go test -run TestName ./path/to/package
go test -v -run TestHandleLogin_Success ./features/auth
# Code generation
task build:templ # Compile .templ files (go tool templ generate)
task build:styles # Build TailwindCSS (go tool gotailwind)
```
Tools (templ, air, gotailwind, goose, sqlc) are managed via Go 1.25's `tool` directive in go.mod — no separate installs needed.
## 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, no HTTP)
├── features/ # Feature modules (handlers, routes, templates)
│ ├── auth/ # Login/register (standard HTTP, not SSE)
│ ├── c4game/ # Connect 4 UI + services
│ ├── snakegame/ # Snake UI + services
│ ├── lobby/ # Game lobby
│ └── common/ # Shared components, layouts
├── chat/ # Reusable chat room (NATS + optional DB persistence)
├── auth/ # Password hashing/validation (pure, no HTTP)
├── db/ # SQLite, migrations, sqlc queries
├── cmd/downloader/ # Build-time tool: fetches datastar-pro + daisyui
├── assets/ # Static files (embedded in prod, filesystem in dev)
└── config/, logging/, nats/, sessions/, router/, player/, version/
```
## Code Style
### Imports
Three groups separated by blank lines: stdlib, third-party, local. Enforced by goimports with `local-prefixes: github.com/ryanhamamura/games`.
```go
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/ryanhamamura/games/connect4"
)
```
### Error Handling
```go
// Wrap errors with context
return fmt.Errorf("loading game %s: %w", id, err)
// Combine cleanup errors with errors.Join
return nil, errors.Join(fmt.Errorf("pinging database: %w", err), DB.Close())
// Best-effort operations
nc.Publish(subject, nil) //nolint:errcheck // best-effort notification
// HTTP errors
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.
- Function comments for exported functions.
## 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) { /* ... */ }
}
```
### Cleanup Function Returns
Infrastructure init functions return a cleanup func the caller defers:
```go
cleanupDB, err := db.Init(cfg.DBPath)
defer cleanupDB()
```
### Store/Instance Pattern
Game state uses a two-tier pattern: a thread-safe **Store** (map + RWMutex) holding **Instance** wrappers (individual game + own mutex + DB queries). Stores lazy-load from DB on cache miss.
### Build Tags
`//go:build dev` and `//go:build !dev` switch behavior for static asset serving (filesystem vs embedded hashfs) and config loading.
## Templ + Datastar Patterns
### Architecture: Everything Is a Stream
The core mental model: **the server owns all state and continuously projects it to the browser over SSE**. There is no client-side state management. The browser connects to an event stream, and the server pushes full HTML fragments whenever something changes. Datastar morphs these into the DOM — the client is a thin rendering surface.
User actions (clicks, keypresses) trigger short POST/DELETE requests back to the server. The server mutates state, publishes a NATS signal, and every connected SSE stream picks up the change and re-renders. The client never needs to know what changed — it just receives the new truth and morphs to match.
This means: **always send whole components down the wire.** Don't try to diff or send minimal patches. Render the full templ component, call `sse.PatchElementTempl()`, and let Datastar's morph handle the rest. The only exception is appending to a list (e.g. chat messages).
**Signals follow command-query segregation.** Signals are *commands* — they carry the user's intent to the server (form input values, button clicks). The SSE stream is the *query* — it continuously projects the server's truth into the DOM. Keep signals thin: form input buffers (`chatMsg`, `nickname`), pure UI state the server never needs (`activeTab`), and request indicators. Don't use signals to hold application state — that arrives from the server via SSE.
### SSE Event Loop
Both game event handlers follow the same structure:
1. Subscribe to NATS channels **before** creating SSE (avoids missed messages)
2. Send initial full-state patch
3. `select` loop over: context done, game updates (drain channel first), chat messages (append), 1-second heartbeat (full re-render)
```go
// Handler side — long-lived SSE with Brotli compression
sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
sse.PatchElementTempl(components.GameBoard(game))
// Template side — disable Datastar's default SSE cancellation on interaction
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
```
### Client-Server Interactions
```go
// Trigger SSE actions from templates
data-on:click={ datastar.PostSSE("/games/%s/drop?col=%d", g.ID, colIdx) }
data-on:click={ datastar.DeleteSSE("/games/%s", g.ID) }
// Read client signals in handlers
var signals struct { ChatMsg string `json:"chatMsg"` }
datastar.ReadSignals(r, &signals)
// Clear input after submission
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""})
// Redirect via SSE
sse.Redirectf("/games/%s", newGame.ID())
```
### Appending Elements (Chat Messages)
The one exception to whole-component morphing is chat, where messages are appended individually:
```go
sse.PatchElementTempl(
chatcomponents.ChatMessage(msg, cfg),
datastar.WithSelectorID("c4-chat-history"),
datastar.WithModeAppend(),
)
```
### Datastar Template Attributes
- `data-signals` — declare reactive state
- `data-bind` — two-way input binding
- `data-show` — conditional visibility
- `data-class` — reactive CSS classes
- `data-morph-ignore` — prevent SSE from overwriting an element (e.g. chat input)
## Testing
```bash
task test # All tests
go test -run TestHandleLogin_Success ./features/auth # Single test
go test -v ./features/auth # Verbose package
```
- Use `testutil.NewTestDB(t)` for tests needing a database
- Use `testutil.NewTestSessionManager(db)` for session-aware tests
- Use `config.LoadForTest()` to set safe defaults without .env
- Tests use external test packages (`package auth_test`)
## Tech Stack
| Layer | Technology |
|-------|------------|
| Templates | templ (type-safe HTML) |
| Reactivity | Datastar Pro (SSE-driven) |
| CSS | TailwindCSS v4 + daisyUI |
| Router | chi/v5 |
| Sessions | scs/v2 (SQLite-backed) |
| Database | SQLite (modernc.org/sqlite) |
| Migrations | goose (embedded SQL) |
| SQL codegen | sqlc |
| Pub/sub | Embedded NATS (nil-payload signals) |
| Logging | zerolog + slog (bridged via slog-zerolog) |

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,19 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN --mount=type=secret,id=vendor_token \
VENDOR_TOKEN=$(cat /run/secrets/vendor_token) \
go run cmd/downloader/main.go
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

@@ -2,9 +2,12 @@ version: "3"
tasks: tasks:
download: download:
desc: Download latest client-side libs desc: Download pinned client-side libs
cmds: cmds:
- go run cmd/downloader/main.go - go run cmd/downloader/main.go
status:
- test -f assets/js/datastar/datastar.js
- test -f assets/css/daisyui/daisyui.js
build:templ: build:templ:
desc: Compile .templ files to Go desc: Compile .templ files to Go
@@ -27,10 +30,11 @@ 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:
- download
- build:templ - build:templ
- build:styles - build:styles
@@ -49,8 +53,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"
@@ -58,6 +62,7 @@ tasks:
live: live:
desc: Dev mode with hot-reload desc: Dev mode with hot-reload
deps: deps:
- download
- live:templ - live:templ
- live:styles - live:styles
- live:server - live:server
@@ -75,7 +80,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

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
@import 'tailwindcss'; @import 'tailwindcss';
@source not "./daisyui{,*}.mjs"; @source not "./daisyui/daisyui{,*}.js";
@plugin "./daisyui.mjs"; @plugin "./daisyui/daisyui.js";
@plugin "./daisyui-theme.mjs" { @plugin "./daisyui/daisyui-theme.js" {
name: "stealth"; name: "stealth";
default: true; default: true;
color-scheme: light; color-scheme: light;

File diff suppressed because one or more lines are too long

1
assets/js/README.md Normal file
View File

@@ -0,0 +1 @@
Downloaded by cmd/downloader at build time.

File diff suppressed because one or more lines are too long

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

163
chat/chat.go Normal file
View File

@@ -0,0 +1,163 @@
// Package chat provides a reusable chat room backed by NATS pub/sub
// with optional database persistence.
package chat
import (
"context"
"encoding/json"
"slices"
"sync"
"github.com/nats-io/nats.go"
"github.com/rs/zerolog/log"
"github.com/ryanhamamura/games/db/repository"
)
// Message is the wire format for chat messages over NATS.
type Message struct {
Nickname string `json:"nickname"`
Slot int `json:"slot"` // player slot/color index
Message string `json:"message"`
Time int64 `json:"time"` // unix millis, zero for ephemeral messages
}
const maxMessages = 50
// Room manages an in-memory message buffer and NATS pub/sub for a single
// chat room (typically one per game). When created with NewPersistentRoom,
// messages are automatically loaded from and saved to the database.
type Room struct {
subject string
nc *nats.Conn
messages []Message
mu sync.Mutex
// Optional persistence; nil for ephemeral rooms (e.g. snake).
queries *repository.Queries
roomID string
}
// NewRoom creates an ephemeral chat room with no database persistence.
func NewRoom(nc *nats.Conn, subject string) *Room {
return &Room{
subject: subject,
nc: nc,
}
}
// NewPersistentRoom creates a chat room backed by the database. It loads
// existing messages on creation and auto-saves new messages on Send.
func NewPersistentRoom(nc *nats.Conn, subject string, queries *repository.Queries, roomID string) *Room {
r := &Room{
subject: subject,
nc: nc,
queries: queries,
roomID: roomID,
}
r.messages = r.loadMessages()
return r
}
// Send publishes a message to the room's NATS subject and persists it
// if the room is backed by a database.
func (r *Room) Send(msg Message) {
if r.queries != nil {
r.saveMessage(msg)
}
data, err := json.Marshal(msg)
if err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
return
}
if err := r.nc.Publish(r.subject, data); err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to publish chat message")
}
}
// receive processes an incoming NATS message, appending it to the buffer.
func (r *Room) receive(data []byte) (Message, bool) {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return msg, false
}
r.mu.Lock()
r.messages = append(r.messages, msg)
if len(r.messages) > maxMessages {
r.messages = r.messages[len(r.messages)-maxMessages:]
}
r.mu.Unlock()
return msg, true
}
// Messages returns a snapshot of the current message buffer.
func (r *Room) Messages() []Message {
r.mu.Lock()
defer r.mu.Unlock()
snapshot := make([]Message, len(r.messages))
copy(snapshot, r.messages)
return snapshot
}
// Subscribe returns a channel of parsed messages and a cleanup function.
// The room handles NATS subscription internally and buffers messages.
func (r *Room) Subscribe() (<-chan Message, func()) {
natsCh := make(chan *nats.Msg, 64)
msgCh := make(chan Message, 64)
sub, err := r.nc.ChanSubscribe(r.subject, natsCh)
if err != nil {
close(msgCh)
return msgCh, func() {}
}
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

@@ -0,0 +1,78 @@
package components
import (
"fmt"
"github.com/ryanhamamura/games/chat"
"github.com/starfederation/datastar-go/datastar"
)
// ColorFunc resolves a player slot to a CSS color string.
type ColorFunc func(slot int) string
// Config holds the game-specific settings for rendering a chat component.
type Config struct {
// CSSPrefix is used for element IDs and CSS classes (e.g. "c4" or "snake").
CSSPrefix string
// PostURL is the URL to POST chat messages to (e.g. "/games/{id}/chat").
PostURL string
// Color resolves a player slot to a CSS color string.
Color ColorFunc
// StopKeyPropagation adds data-on:keydown.stop="" to the input to prevent
// key events from propagating (needed for snake to avoid steering while typing).
StopKeyPropagation bool
}
// 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) {
<div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }>
<div id={ cfg.CSSPrefix + "-chat-history" } class={ cfg.CSSPrefix + "-chat-history" }>
for _, m := range messages {
@ChatMessage(m, cfg)
}
</div>
<div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore>
if cfg.StopKeyPropagation {
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown__stop={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", cfg.PostURL) }
/>
} else {
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", cfg.PostURL) }
/>
}
<button
type="button"
data-on:click={ datastar.PostSSE("%s", cfg.PostURL) }
>
Send
</button>
</div>
@chatAutoScroll(cfg.CSSPrefix)
</div>
}
script chatAutoScroll(cssPrefix string) {
var el = document.querySelector('.' + cssPrefix + '-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
}

View File

@@ -1,30 +1,20 @@
package main package main
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
)
// Asset directories, relative to project root. "github.com/ryanhamamura/games/assets"
const (
jsDir = "assets/js"
cssDir = "assets/css"
) )
// files maps download URLs to local destination paths.
var files = map[string]string{
"https://raw.githubusercontent.com/starfederation/datastar/main/bundles/datastar.js": jsDir + "/datastar.js",
"https://raw.githubusercontent.com/starfederation/datastar/main/bundles/datastar.js.map": jsDir + "/datastar.js.map",
"https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.mjs": cssDir + "/daisyui.mjs",
"https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.mjs": cssDir + "/daisyui-theme.mjs",
}
func main() { func main() {
if err := run(); err != nil { if err := run(); err != nil {
slog.Error("failure", "error", err) slog.Error("failure", "error", err)
@@ -32,16 +22,243 @@ func main() {
} }
} }
// Pinned dependency versions — update these to upgrade.
const (
datastarVersion = "v1.0.0-RC.8" // Pro build — fetched from private Gitea repo
daisyuiVersion = "v5.5.19"
)
// dependencies tracks pinned versions alongside their GitHub coordinates
// so the version check can look up the latest release for each.
var dependencies = []dependency{
{name: "datastar", owner: "starfederation", repo: "datastar", pinnedVersion: datastarVersion},
{name: "daisyui", owner: "saadeghi", repo: "daisyui", pinnedVersion: daisyuiVersion},
}
type dependency struct {
name string
owner string
repo string
pinnedVersion string
}
// datastar-pro sources, in order of preference.
const (
giteaRawURL = "https://gitea.adriatica.io/ryan/vendor-libs/raw/branch/main/datastar/datastar.js"
localFallbackPath = "../optional/web/resources/static/datastar/datastar.js"
)
func run() error { func run() error {
dirs := []string{jsDir, cssDir} jsDir := assets.DirectoryPath + "/js/datastar"
cssDir := assets.DirectoryPath + "/css/daisyui"
for _, dir := range dirs { daisyuiBase := "https://github.com/saadeghi/daisyui/releases/download/" + daisyuiVersion + "/"
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err) downloads := map[string]string{
} daisyuiBase + "daisyui.js": cssDir + "/daisyui.js",
daisyuiBase + "daisyui-theme.js": cssDir + "/daisyui-theme.js",
} }
return download(files) directories := []string{jsDir, cssDir}
if err := removeDirectories(directories); err != nil {
return err
}
if err := createDirectories(directories); err != nil {
return err
}
if err := acquireDatastar(jsDir + "/datastar.js"); err != nil {
return err
}
if err := download(downloads); err != nil {
return err
}
checkForUpdates()
return nil
}
// acquireDatastar fetches datastar-pro from the private Gitea repo when
// GITEA_TOKEN is set, otherwise copies from the local optional project.
func acquireDatastar(dest string) error {
if token := os.Getenv("VENDOR_TOKEN"); token != "" {
slog.Info("downloading datastar-pro from private repo...")
return downloadWithAuth(giteaRawURL, dest, token)
}
slog.Info("copying datastar-pro from local fallback...", "src", localFallbackPath)
return copyFile(localFallbackPath, dest)
}
func copyFile(src, dest string) error {
in, err := os.Open(src) //nolint:gosec // paths are hardcoded constants
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer in.Close() //nolint:errcheck
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
if err != nil {
return fmt.Errorf("create %s: %w", dest, err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close() //nolint:errcheck
return fmt.Errorf("copy to %s: %w", dest, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close %s: %w", dest, err)
}
return nil
}
func downloadWithAuth(rawURL, dest, token string) error {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return fmt.Errorf("create request for %s: %w", rawURL, err)
}
req.Header.Set("Authorization", "token "+token)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL is built from compile-time constants
if err != nil {
return fmt.Errorf("GET %s: %w", rawURL, err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: status %s", rawURL, resp.Status)
}
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
if err != nil {
return fmt.Errorf("create %s: %w", dest, err)
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close() //nolint:errcheck
return fmt.Errorf("write %s: %w", dest, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close %s: %w", dest, err)
}
return nil
}
// checkForUpdates queries the GitHub releases API for each dependency
// and logs a notice if a newer version is available. Failures are
// logged but never cause the download to fail.
func checkForUpdates() {
var wg sync.WaitGroup
for _, dep := range dependencies {
wg.Go(func() {
latest, err := latestGitHubRelease(dep.owner, dep.repo)
if err != nil {
slog.Warn("could not check for updates", "dependency", dep.name, "error", err)
return
}
if latest != dep.pinnedVersion {
slog.Warn("newer version available",
"dependency", dep.name,
"pinned", dep.pinnedVersion,
"latest", latest,
)
}
})
}
wg.Wait()
}
// githubRelease is the minimal subset of the GitHub releases API response we need.
type githubRelease struct {
TagName string `json:"tag_name"`
}
func latestGitHubRelease(owner, repo string) (string, error) {
u := &url.URL{
Scheme: "https",
Host: "api.github.com",
Path: fmt.Sprintf("/repos/%s/%s/releases/latest", owner, repo),
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req) //nolint:gosec
if err != nil {
return "", fmt.Errorf("fetching release: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %s", resp.Status)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("decoding response: %w", err)
}
return release.TagName, nil
}
func removeDirectories(dirs []string) error {
var wg sync.WaitGroup
errCh := make(chan error, len(dirs))
for _, path := range dirs {
wg.Go(func() {
if err := os.RemoveAll(path); err != nil {
errCh <- fmt.Errorf("remove directory %s: %w", path, err)
}
})
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func createDirectories(dirs []string) error {
var wg sync.WaitGroup
errCh := make(chan error, len(dirs))
for _, path := range dirs {
wg.Go(func() {
if err := os.MkdirAll(path, 0755); err != nil {
errCh <- fmt.Errorf("create directory %s: %w", path, err)
}
})
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errors.Join(errs...)
} }
func download(files map[string]string) error { func download(files map[string]string) error {
@@ -71,15 +288,15 @@ func download(files map[string]string) error {
return errors.Join(errs...) return errors.Join(errs...)
} }
func downloadFile(url, dest string) error { func downloadFile(rawURL, dest string) error {
resp, err := http.Get(url) //nolint:gosec,noctx // static URLs, simple tool resp, err := http.Get(rawURL) //nolint:gosec,noctx // static URLs, simple tool
if err != nil { if err != nil {
return fmt.Errorf("GET %s: %w", url, err) return fmt.Errorf("GET %s: %w", rawURL, err)
} }
defer resp.Body.Close() //nolint:errcheck defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: status %s", url, resp.Status) return fmt.Errorf("GET %s: status %s", rawURL, resp.Status)
} }
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,31 @@
package game package connect4
import "encoding/json" import (
"encoding/json"
type PlayerID string "github.com/ryanhamamura/games/player"
)
// SubjectPrefix is the NATS subject namespace for connect4 games.
const SubjectPrefix = "connect4"
// GameSubject returns the NATS subject for game state updates.
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
// ChatSubject returns the NATS subject for chat messages.
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
type Player struct { type Player struct {
ID PlayerID ID player.ID
UserID *string // UUID for authenticated users, nil for guests UserID *string // UUID for authenticated users, nil for guests
Nickname string Nickname string
Color int // 1 = Red, 2 = Yellow Color int // 1 = Red, 2 = Yellow
} }
type GameStatus int type Status int
const ( const (
StatusWaitingForPlayer GameStatus = iota StatusWaitingForPlayer Status = iota
StatusInProgress StatusInProgress
StatusWon StatusWon
StatusDraw StatusDraw
@@ -25,7 +36,7 @@ type Game struct {
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow) Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
CurrentTurn int // 1 or 2 (matches player color) CurrentTurn int // 1 or 2 (matches player color)
Status GameStatus Status Status
Winner *Player Winner *Player
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
RematchGameID *string // ID of the rematch game, if one was created RematchGameID *string // ID of the rematch game, if one was created
@@ -67,11 +78,3 @@ func (g *Game) WinningCellsFromJSON(data string) error {
} }
return json.Unmarshal([]byte(data), &g.WinningCells) return json.Unmarshal([]byte(data), &g.WinningCells)
} }
// ChatMessage is the domain type for persisted C4 chat messages.
type ChatMessage struct {
Nickname string `json:"nickname"`
Color int `json:"color"` // 1=Red, 2=Yellow
Message string `json:"message"`
Time int64 `json:"time"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,13 @@
services: services:
c4: games:
build: . build:
container_name: c4 context: .
args:
VERSION: ${VERSION:-dev}
COMMIT: ${COMMIT:-unknown}
secrets:
- vendor_token
container_name: games
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
@@ -11,4 +17,8 @@ services:
environment: environment:
- PORT=8080 - PORT=8080
volumes: volumes:
- ./data:/app/data - ./data:/data
secrets:
vendor_token:
environment: VENDOR_TOKEN

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

View File

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

View File

@@ -1,26 +1,23 @@
package c4game package c4game
import ( import (
"context"
"encoding/json"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"sync"
"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/db/repository" "github.com/ryanhamamura/games/chat"
"github.com/ryanhamamura/c4/features/c4game/components" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/games/features/c4game/services"
"github.com/ryanhamamura/games/sessions"
) )
func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleGamePage(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")
@@ -30,29 +27,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
if playerID == "" { userID := sessions.GetUserID(sm, r)
playerID = game.PlayerID(game.GenerateID(8)) nickname := sessions.GetNickname(sm, r)
sessions.Put(r.Context(), "player_id", string(playerID))
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
nickname := sessions.GetString(r.Context(), "nickname")
// Auto-join if player has a nickname but isn't in the game yet // Auto-join if player has a nickname but isn't in the game yet
if nickname != "" && gi.GetPlayerColor(playerID) == 0 { if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ p := &connect4.Player{
ID: playerID, ID: playerID,
Nickname: nickname, Nickname: nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
gi.Join(&game.PlayerSession{Player: player}) gi.Join(&connect4.PlayerSession{Player: p})
} }
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
@@ -61,32 +49,29 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
// Player not in game // Player not in game
isGuest := r.URL.Query().Get("guest") == "1" isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest { if userID == "" && !isGuest {
// Show join prompt (login vs guest)
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
return return
} }
// Show nickname prompt
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil { if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
return return
} }
// Player is in the game — render full game page
g := gi.GetGame() g := gi.GetGame()
chatMsgs := loadChatMessages(queries, gameID) room := svc.ChatRoom(gameID)
msgs := chatToComponents(chatMsgs)
if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil { if err := pages.GamePage(g, myColor, room.Messages(), 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, sessions *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)
@@ -95,72 +80,75 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) // Subscribe to game state updates BEFORE creating SSE
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
// Load initial chat messages
chatMsgs := loadChatMessages(queries, gameID)
var chatMu sync.Mutex
chatMessages := chatToComponents(chatMsgs)
// Send initial render of all components
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
// 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 := make(chan *nats.Msg, 64) chatCfg := svc.ChatConfig(gameID)
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh) room := svc.ChatRoom(gameID)
if err != nil { 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:
// Re-read player color in case we just joined
myColor = gi.GetPlayerColor(playerID)
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
case msg := <-chatCh:
var uiMsg game.ChatMessage
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil {
continue
}
cm := components.ChatMessage{
Nickname: uiMsg.Nickname,
Color: uiMsg.Color,
Message: uiMsg.Message,
Time: uiMsg.Time,
}
chatMu.Lock()
chatMessages = append(chatMessages, cm)
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil { case <-gameCh:
// Drain rapid-fire notifications
drainGame:
for {
select {
case <-gameCh:
default:
break drainGame
}
}
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
} }
} }
@@ -168,7 +156,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
} }
} }
func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -185,12 +173,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
if myColor == 0 { if myColor == 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -198,14 +181,11 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
} }
gi.DropPiece(col, myColor) gi.DropPiece(col, myColor)
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
// Return empty SSE response.
datastar.NewSSE(w, r) datastar.NewSSE(w, r)
} }
} }
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleSendChat(store *connect4.Store, 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")
@@ -229,12 +209,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
return return
} }
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
myColor := gi.GetPlayerColor(playerID) myColor := gi.GetPlayerColor(playerID)
if myColor == 0 { if myColor == 0 {
datastar.NewSSE(w, r) datastar.NewSSE(w, r)
@@ -250,28 +225,22 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
} }
} }
cm := game.ChatMessage{ // Map color (1-based) to slot (0-based) for the unified chat message
msg := chat.Message{
Nickname: nick, Nickname: nick,
Color: myColor, Slot: myColor - 1,
Message: signals.ChatMsg, Message: signals.ChatMsg,
Time: time.Now().UnixMilli(), Time: time.Now().UnixMilli(),
} }
saveChatMessage(queries, gameID, cm) room := svc.ChatRoom(gameID)
room.Send(msg)
data, err := json.Marshal(cm)
if err != nil {
datastar.NewSSE(w, r)
return
}
nc.Publish("game.chat."+gameID, data) //nolint:errcheck
// Clear the chat input
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
} }
} }
func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -296,23 +265,20 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
return return
} }
sessions.Put(r.Context(), "nickname", signals.Nickname) sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
if userID != "" {
playerID = game.PlayerID(userID)
}
if gi.GetPlayerColor(playerID) == 0 { if gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{ p := &connect4.Player{
ID: playerID, ID: playerID,
Nickname: signals.Nickname, Nickname: signals.Nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
gi.Join(&game.PlayerSession{Player: player}) gi.Join(&connect4.PlayerSession{Player: p})
} }
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
@@ -320,7 +286,7 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
} }
} }
func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -338,63 +304,3 @@ func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.Han
} }
} }
} }
// sendGameComponents patches all game-related SSE components.
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) {
g := gi.GetGame()
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
chatMu.Lock()
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
}
// Chat persistence helpers — inlined from the former ChatPersister.
func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) {
queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck
GameID: gameID,
Nickname: msg.Nickname,
Color: int64(msg.Color),
Message: msg.Message,
CreatedAt: msg.Time,
})
}
func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage {
rows, err := queries.GetChatMessages(context.Background(), gameID)
if err != nil {
return nil
}
msgs := make([]game.ChatMessage, len(rows))
for i, r := range rows {
msgs[i] = game.ChatMessage{
Nickname: r.Nickname,
Color: int(r.Color),
Message: r.Message,
Time: r.CreatedAt,
}
}
// DB returns newest-first; reverse for display
slices.Reverse(msgs)
return msgs
}
func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage {
msgs := make([]components.ChatMessage, len(chatMsgs))
for i, m := range chatMsgs {
msgs[i] = components.ChatMessage{
Nickname: m.Nickname,
Color: m.Color,
Message: m.Message,
Time: m.Time,
}
}
return msgs
}

View File

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

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/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"
data-on:click={ datastar.PostSSE("/logout") }
>
Logout Logout
</button> </button>
</form>
</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

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

View File

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

View File

@@ -1,36 +1,24 @@
package snakegame package snakegame
import ( import (
"encoding/json" "errors"
"net/http" "net/http"
"strconv" "strconv"
"sync" "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/features/snakegame/components" "github.com/ryanhamamura/games/chat"
"github.com/ryanhamamura/c4/features/snakegame/pages" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
) )
func getPlayerID(sessions *scs.SessionManager, r *http.Request) snake.PlayerID { func HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
pid := sessions.GetString(r.Context(), "player_id")
if pid == "" {
pid = game.GenerateID(8)
sessions.Put(r.Context(), "player_id", pid)
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
return snake.PlayerID(userID)
}
return snake.PlayerID(pid)
}
func HandleSnakePage(snakeStore *snake.SnakeStore, 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")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -39,26 +27,25 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
nickname := sessions.GetString(r.Context(), "nickname") nickname := sessions.GetNickname(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
// Auto-join if nickname exists and not already in game // Auto-join if nickname exists and not already in game
if nickname != "" && si.GetPlayerSlot(playerID) < 0 { if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{ p := &snake.Player{
ID: playerID, ID: playerID,
Nickname: nickname, Nickname: nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
si.Join(player) si.Join(p)
} }
mySlot := si.GetPlayerSlot(playerID) mySlot := si.GetPlayerSlot(playerID)
if mySlot < 0 { if mySlot < 0 {
// Not in game yet
isGuest := r.URL.Query().Get("guest") == "1" isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest { if userID == "" && !isGuest {
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
@@ -73,13 +60,14 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager)
} }
sg := si.GetGame() sg := si.GetGame()
if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil { 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, sessions *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)
@@ -88,46 +76,62 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
mySlot := si.GetPlayerSlot(playerID) mySlot := si.GetPlayerSlot(playerID)
// Subscribe to game updates BEFORE creating SSE (following portigo pattern)
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
defer gameSub.Unsubscribe() //nolint:errcheck
sse := datastar.NewSSE(w, r, datastar.WithCompression( sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)), datastar.WithBrotli(datastar.WithBrotliLevel(5)),
)) ))
// Send initial render chatCfg := svc.ChatConfig(gameID)
// Chat room (multiplayer only)
var room *chat.Room
sg := si.GetGame() sg := si.GetGame()
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
if sg.Mode == snake.ModeMultiplayer { if sg.Mode == snake.ModeMultiplayer {
sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck room = svc.ChatRoom(gameID)
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
}
} }
// Subscribe to game updates via NATS chatMessages := func() []chat.Message {
gameCh := make(chan *nats.Msg, 64) if room == nil {
gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh) return nil
if err != nil { }
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 return
} }
defer gameSub.Unsubscribe() //nolint:errcheck
heartbeat := time.NewTicker(1 * time.Second)
defer heartbeat.Stop()
// Chat subscription (multiplayer only) // Chat subscription (multiplayer only)
var chatCh chan *nats.Msg var chatCh <-chan chat.Message
var chatSub *nats.Subscription var cleanupChat func()
var chatMessages []components.ChatMessage
var chatMu sync.Mutex
if sg.Mode == snake.ModeMultiplayer { if room != nil {
chatCh = make(chan *nats.Msg, 64) chatCh, cleanupChat = room.Subscribe()
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh) defer cleanupChat()
if err != nil {
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
} }
ctx := r.Context() ctx := r.Context()
@@ -136,6 +140,12 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
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 {
@@ -146,40 +156,20 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
} }
} }
drained: drained:
si, ok = snakeStore.Get(gameID) if err := patchAll(); err != nil {
return
}
case chatMsg, ok := <-chatCh:
if !ok { if !ok {
return
}
mySlot = si.GetPlayerSlot(playerID)
sg = si.GetGame()
if err := sse.PatchElementTempl(components.Board(sg)); err != nil {
return
}
if err := sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)); err != nil {
return
}
if err := sse.PatchElementTempl(components.PlayerList(sg, mySlot)); err != nil {
return
}
case msg := <-chatCh:
if msg == nil {
continue continue
} }
var cm components.ChatMessage err := sse.PatchElementTempl(
if err := json.Unmarshal(msg.Data, &cm); err != nil { chatcomponents.ChatMessage(chatMsg, chatCfg),
continue datastar.WithSelectorID("snake-chat-history"),
} datastar.WithModeAppend(),
chatMu.Lock() )
chatMessages = append(chatMessages, cm) if err != nil {
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil {
return return
} }
} }
@@ -187,7 +177,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc
} }
} }
func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleSetDirection(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -196,7 +186,7 @@ func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManag
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
slot := si.GetPlayerSlot(playerID) slot := si.GetPlayerSlot(playerID)
if slot < 0 { if slot < 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -219,7 +209,7 @@ type chatSignals struct {
ChatMsg string `json:"chatMsg"` ChatMsg string `json:"chatMsg"`
} }
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { func HandleSendChat(snakeStore *snake.SnakeStore, 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)
@@ -238,7 +228,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
return return
} }
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
slot := si.GetPlayerSlot(playerID) slot := si.GetPlayerSlot(playerID)
if slot < 0 { if slot < 0 {
http.Error(w, "not in game", http.StatusForbidden) http.Error(w, "not in game", http.StatusForbidden)
@@ -246,16 +236,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S
} }
sg := si.GetGame() sg := si.GetGame()
cm := components.ChatMessage{ msg := chat.Message{
Nickname: sg.Players[slot].Nickname, Nickname: sg.Players[slot].Nickname,
Slot: slot, Slot: slot,
Message: signals.ChatMsg, Message: signals.ChatMsg,
} }
data, err := json.Marshal(cm)
if err != nil { room := svc.ChatRoom(gameID)
return room.Send(msg)
}
nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
@@ -266,7 +254,7 @@ type nicknameSignals struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
} }
func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -285,20 +273,20 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
return return
} }
sessions.Put(r.Context(), "nickname", signals.Nickname) sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
playerID := getPlayerID(sessions, r) playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetString(r.Context(), "user_id") userID := sessions.GetUserID(sm, r)
if si.GetPlayerSlot(playerID) < 0 { if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{ p := &snake.Player{
ID: playerID, ID: playerID,
Nickname: signals.Nickname, Nickname: signals.Nickname,
} }
if userID != "" { if userID != "" {
player.UserID = &userID p.UserID = &userID
} }
si.Join(player) si.Join(p)
} }
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
@@ -306,7 +294,7 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage
} }
} }
func HandleRematch(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { func HandleRematch(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)

View File

@@ -3,10 +3,12 @@ package pages
import ( import (
"fmt" "fmt"
"github.com/ryanhamamura/c4/features/common/components" "github.com/ryanhamamura/games/chat"
"github.com/ryanhamamura/c4/features/common/layouts" chatcomponents "github.com/ryanhamamura/games/chat/components"
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components" "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/games/features/common/layouts"
snakecomponents "github.com/ryanhamamura/games/features/snakegame/components"
"github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
@@ -26,15 +28,23 @@ func keydownScript(gameID string) string {
) )
} }
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatMessage, gameID string) { templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
@layouts.Base("Snake") { @layouts.Base("Snake") {
<main <main
class="snake-wrapper flex flex-col items-center gap-4 p-4" class="snake-wrapper flex flex-col items-center gap-4 p-4"
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"
> >
@GameContent(sg, mySlot, messages, chatCfg, gameID)
</main>
}
}
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
<div id="game-content" class="flex flex-col items-center gap-4">
@components.LiveClock()
@components.BackToLobby() @components.BackToLobby()
<h1 class="text-3xl font-bold">~~~~</h1> <h1 class="text-3xl font-bold">~~~~</h1>
@snakecomponents.PlayerList(sg, mySlot) @snakecomponents.PlayerList(sg, mySlot)
@@ -43,19 +53,18 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatM
if sg.Mode == snake.ModeMultiplayer { if sg.Mode == snake.ModeMultiplayer {
<div class="snake-game-area"> <div class="snake-game-area">
@snakecomponents.Board(sg) @snakecomponents.Board(sg)
@snakecomponents.Chat(messages, gameID) @chatcomponents.Chat(messages, chatCfg)
</div> </div>
} else { } else {
@snakecomponents.Board(sg) @snakecomponents.Board(sg)
} }
} else if sg.Mode == snake.ModeMultiplayer { } else if sg.Mode == snake.ModeMultiplayer {
@snakecomponents.Chat(messages, gameID) @chatcomponents.Chat(messages, chatCfg)
} }
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) { if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
@snakecomponents.InviteLink(gameID) @snakecomponents.InviteLink(gameID)
} }
</main> </div>
}
} }
templ JoinPage(gameID string) { templ JoinPage(gameID string) {

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 {

18
player/player.go Normal file
View File

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

View File

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

@@ -1,4 +1,5 @@
// Package sessions configures the SCS session manager backed by SQLite. // Package sessions configures the SCS session manager and provides
// helpers for resolving player identity from the session.
package sessions package sessions
import ( import (
@@ -7,10 +8,19 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/ryanhamamura/games/player"
"github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
) )
// Session key names.
const (
KeyPlayerID = "player_id"
KeyUserID = "user_id"
KeyNickname = "nickname"
)
// SetupSessionManager creates a configured session manager backed by SQLite. // SetupSessionManager creates a configured session manager backed by SQLite.
// Returns the manager and a cleanup function the caller should defer. // Returns the manager and a cleanup function the caller should defer.
func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
@@ -20,13 +30,38 @@ 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")
return sessionManager, cleanup return sessionManager, cleanup
} }
// GetPlayerID returns the current player's identity from the session.
// Authenticated users get their user UUID; guests get a random ID that
// is generated and persisted on first access.
func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
pid := sm.GetString(r.Context(), KeyPlayerID)
if pid == "" {
pid = player.GenerateID(8)
sm.Put(r.Context(), KeyPlayerID, pid)
}
if userID := sm.GetString(r.Context(), KeyUserID); userID != "" {
return player.ID(userID)
}
return player.ID(pid)
}
// GetUserID returns the authenticated user's UUID, or empty string for guests.
func GetUserID(sm *scs.SessionManager, r *http.Request) string {
return sm.GetString(r.Context(), KeyUserID)
}
// GetNickname returns the player's display name from the session.
func GetNickname(sm *scs.SessionManager, r *http.Request) string {
return sm.GetString(r.Context(), KeyNickname)
}

View File

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

View File

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

View File

@@ -3,8 +3,19 @@ package snake
import ( import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/ryanhamamura/games/player"
) )
// SubjectPrefix is the NATS subject namespace for snake games.
const SubjectPrefix = "snake"
// GameSubject returns the NATS subject for game state updates.
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
// ChatSubject returns the NATS subject for chat messages.
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
type Direction int type Direction int
const ( const (
@@ -78,10 +89,8 @@ const (
StatusFinished StatusFinished
) )
type PlayerID string
type Player struct { type Player struct {
ID PlayerID ID player.ID
UserID *string UserID *string
Nickname string Nickname string
Slot int // 0-7 Slot int // 0-7

View File

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

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"
)