47 Commits

Author SHA1 Message Date
8983628846 Merge pull request 'Switch to datastar-pro and stop tracking downloaded libs' (#16) from feat/datastar-pro into main
All checks were successful
CI / Deploy / test (push) Successful in 11s
CI / Deploy / lint (push) Successful in 29s
CI / Deploy / deploy (push) Successful in 1m33s
2026-03-12 00:16:54 +00:00
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
c4.db
games
games.db
data/
deploy/
.env
.git
.gitignore
assets/css/output.css
c4-deploy-*.tar.gz
c4-deploy-*_b64*.txt
games-deploy-*.tar.gz
games-deploy-*_b64*.txt

View File

@@ -1,8 +1,8 @@
# Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO.
# LOG_LEVEL=DEBUG
# SQLite database path. Defaults to data/c4.db.
# DB_PATH=data/c4.db
# SQLite database path. Defaults to data/games.db.
# DB_PATH=data/games.db
# Application URL for invite links. Defaults to https://games.adriatica.io.
# APP_URL=http://localhost:7331
@@ -11,6 +11,10 @@
# PORT=7331
# 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_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

View File

@@ -6,7 +6,7 @@ on:
pull_request:
env:
DEPLOY_DIR: /home/ryan/c4
DEPLOY_DIR: /home/ryan/games
jobs:
test:
@@ -48,6 +48,8 @@ jobs:
runs-on: games
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for git describe
- name: Sync to deploy directory
run: |
@@ -59,4 +61,13 @@ jobs:
mkdir -p $DEPLOY_DIR/data
- 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
!LICENSE
!AGENTS.md
!assets/**/*
@@ -26,6 +27,10 @@
*_templ.go
assets/css/output.css
# Downloaded client-side libs (fetched by cmd/downloader)
assets/js/datastar/*
assets/css/daisyui/*
# Deploy scripts and configs
!deploy/*.sh
!deploy/*.service

View File

@@ -35,7 +35,7 @@ formatters:
settings:
goimports:
local-prefixes:
- github.com/ryanhamamura/c4
- github.com/ryanhamamura/games
issues:
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
ARG VERSION=dev
ARG COMMIT=unknown
RUN apk add --no-cache upx
WORKDIR /src
@@ -7,12 +10,19 @@ COPY go.mod go.sum ./
RUN go mod download
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 --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s" -o /bin/c4 .
RUN upx -9 -k /bin/c4
MODULE=$(head -1 go.mod | awk '{print $2}') && \
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
ENV PORT=8080
COPY --from=build /bin/c4 /
ENTRYPOINT ["/c4"]
COPY --from=build /bin/games /
ENTRYPOINT ["/games"]

View File

@@ -2,9 +2,12 @@ version: "3"
tasks:
download:
desc: Download latest client-side libs
desc: Download pinned client-side libs
cmds:
- go run cmd/downloader/main.go
status:
- test -f assets/js/datastar/datastar.js
- test -f assets/css/daisyui/daisyui.js
build:templ:
desc: Compile .templ files to Go
@@ -27,10 +30,11 @@ tasks:
- "assets/css/output.css"
build:
desc: Production build to bin/c4
desc: Production build to bin/games
cmds:
- go build -o bin/c4 .
- go build -o bin/games .
deps:
- download
- build:templ
- build:styles
@@ -49,8 +53,8 @@ tasks:
cmds:
- |
go tool air \
-build.cmd "go build -tags=dev -o tmp/bin/c4 ." \
-build.bin "tmp/bin/c4" \
-build.cmd "go build -tags=dev -o tmp/bin/games ." \
-build.bin "tmp/bin/games" \
-build.exclude_dir "data,bin,tmp,deploy" \
-build.include_ext "go,templ" \
-misc.clean_on_exit "true"
@@ -58,6 +62,7 @@ tasks:
live:
desc: Dev mode with hot-reload
deps:
- download
- live:templ
- live:styles
- live:server
@@ -75,7 +80,7 @@ tasks:
run:
desc: Build and run the server
cmds:
- ./bin/c4
- ./bin/games
deps:
- 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';
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs";
@plugin "./daisyui-theme.mjs" {
@source not "./daisyui/daisyui{,*}.js";
@plugin "./daisyui/daisyui.js";
@plugin "./daisyui/daisyui-theme.js" {
name: "stealth";
default: true;
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
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
)
// Asset directories, relative to project root.
const (
jsDir = "assets/js"
cssDir = "assets/css"
"github.com/ryanhamamura/games/assets"
)
// 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() {
if err := run(); err != nil {
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 {
dirs := []string{jsDir, cssDir}
jsDir := assets.DirectoryPath + "/js/datastar"
cssDir := assets.DirectoryPath + "/css/daisyui"
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
daisyuiBase := "https://github.com/saadeghi/daisyui/releases/download/" + daisyuiVersion + "/"
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 {
@@ -71,15 +288,15 @@ func download(files map[string]string) error {
return errors.Join(errs...)
}
func downloadFile(url, dest string) error {
resp, err := http.Get(url) //nolint:gosec,noctx // static URLs, simple tool
func downloadFile(rawURL, dest string) error {
resp, err := http.Get(rawURL) //nolint:gosec,noctx // static URLs, simple tool
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
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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,30 +3,26 @@ package auth
import (
"database/sql"
"net/http"
"net/url"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/auth"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/auth/pages"
"github.com/ryanhamamura/games/auth"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/auth/pages"
appsessions "github.com/ryanhamamura/games/sessions"
)
type LoginSignals struct {
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 {
func HandleLoginPage(sessions *scs.SessionManager) http.HandlerFunc {
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)
}
}
@@ -34,7 +30,8 @@ func HandleLoginPage() http.HandlerFunc {
func HandleRegisterPage() http.HandlerFunc {
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)
}
}
@@ -42,32 +39,28 @@ func HandleRegisterPage() http.HandlerFunc {
func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var signals LoginSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1024)
username := r.FormValue("username")
password := r.FormValue("password")
sse := datastar.NewSSE(w, r)
user, err := queries.GetUserByUsername(r.Context(), signals.Username)
user, err := queries.GetUserByUsername(r.Context(), username)
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
}
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
}
if !auth.CheckPassword(signals.Password, user.PasswordHash) {
sse.MarshalAndPatchSignals(map[string]any{"error": "Invalid username or password"}) //nolint:errcheck
if !auth.CheckPassword(password, user.PasswordHash) {
http.Redirect(w, r, "/login?error="+url.QueryEscape("Invalid username or password"), http.StatusSeeOther)
return
}
sessions.RenewToken(r.Context()) //nolint:errcheck
sessions.Put(r.Context(), "user_id", user.ID)
sessions.Put(r.Context(), appsessions.KeyUserID, user.ID)
sessions.Put(r.Context(), "username", user.Username)
sessions.Put(r.Context(), "nickname", user.Username)
sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
redirectURL := "/"
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
@@ -75,53 +68,50 @@ func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http
redirectURL = returnURL
}
sse.Redirect(redirectURL) //nolint:errcheck
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
}
func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var signals RegisterSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
r.Body = http.MaxBytesReader(w, r.Body, 1024)
username := r.FormValue("username")
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
}
sse := datastar.NewSSE(w, r)
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)
hash, err := auth.HashPassword(password)
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
}
user, err := queries.CreateUser(r.Context(), repository.CreateUserParams{
ID: uuid.New().String(),
Username: signals.Username,
Username: username,
PasswordHash: hash,
})
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
}
sessions.RenewToken(r.Context()) //nolint:errcheck
sessions.Put(r.Context(), "user_id", user.ID)
sessions.Put(r.Context(), appsessions.KeyUserID, user.ID)
sessions.Put(r.Context(), "username", user.Username)
sessions.Put(r.Context(), "nickname", user.Username)
sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
redirectURL := "/"
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
@@ -129,6 +119,6 @@ func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) h
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
import (
"github.com/ryanhamamura/c4/features/common/layouts"
"github.com/starfederation/datastar-go/datastar"
)
import "github.com/ryanhamamura/games/features/common/layouts"
templ LoginPage() {
templ LoginPage(errorMsg string) {
@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>
<p class="mb-4">Sign in to your account</p>
<div data-show="$error != ''" class="alert alert-error mb-4" data-text="$error"></div>
<div>
if errorMsg != "" {
<div class="alert alert-error mb-4">{ errorMsg }</div>
}
<form method="POST" action="/auth/login">
<fieldset class="fieldset">
<label class="label" for="username">Username</label>
<input
class="input input-bordered w-full"
id="username"
name="username"
type="text"
placeholder="Enter your username"
data-bind="username"
data-on:keydown.key_enter={ datastar.PostSSE("/auth/login") }
autofocus
/>
<label class="label" for="password">Password</label>
<input
class="input input-bordered w-full"
id="password"
name="password"
type="password"
placeholder="Enter your password"
data-bind="password"
data-on:keydown.key_enter={ datastar.PostSSE("/auth/login") }
/>
</fieldset>
<button
class="btn btn-primary w-full"
data-on:click={ datastar.PostSSE("/auth/login") }
>
<button type="submit" class="btn btn-primary w-full">
Login
</button>
</div>
</form>
<p>
Don't have an account? <a class="link" href="/register">Register</a>
</p>

View File

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

View File

@@ -5,11 +5,11 @@ import (
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/games/db/repository"
)
func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) {
router.Get("/login", HandleLoginPage())
router.Get("/login", HandleLoginPage(sessions))
router.Get("/register", HandleRegisterPage())
router.Post("/auth/login", HandleLogin(queries, sessions))
router.Post("/auth/register", HandleRegister(queries, sessions))

View File

@@ -3,11 +3,11 @@ package components
import (
"fmt"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/games/connect4"
"github.com/starfederation/datastar-go/datastar"
)
templ Board(g *game.Game, myColor int) {
templ Board(g *connect4.Game, myColor int) {
<div id="c4-board" class="board">
for col := 0; col < 7; col++ {
@column(g, col, myColor)
@@ -15,8 +15,8 @@ templ Board(g *game.Game, myColor int) {
</div>
}
templ column(g *game.Game, colIdx int, myColor int) {
if g.Status == game.StatusInProgress && myColor == g.CurrentTurn {
templ column(g *connect4.Game, colIdx int, myColor int) {
if g.Status == connect4.StatusInProgress && myColor == g.CurrentTurn {
<div
class="column clickable"
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>
}
func cellClass(g *game.Game, row, col int) string {
func cellClass(g *connect4.Game, row, col int) string {
color := g.Board[row][col]
activeTurn := 0
if g.Status == game.StatusInProgress {
if g.Status == connect4.StatusInProgress {
activeTurn = g.CurrentTurn
}

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

View File

@@ -1,26 +1,23 @@
package c4game
import (
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"sync"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/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) {
gameID := chi.URLParam(r, "id")
@@ -30,29 +27,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
return
}
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
if playerID == "" {
playerID = game.PlayerID(game.GenerateID(8))
sessions.Put(r.Context(), "player_id", string(playerID))
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
nickname := sessions.GetString(r.Context(), "nickname")
playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetUserID(sm, r)
nickname := sessions.GetNickname(sm, r)
// Auto-join if player has a nickname but isn't in the game yet
if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
p := &connect4.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
player.UserID = &userID
p.UserID = &userID
}
gi.Join(&game.PlayerSession{Player: player})
gi.Join(&connect4.PlayerSession{Player: p})
}
myColor := gi.GetPlayerColor(playerID)
@@ -61,32 +49,29 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries
// Player not in game
isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest {
// Show join prompt (login vs guest)
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
// Show nickname prompt
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
// Player is in the game — render full game page
g := gi.GetGame()
chatMsgs := loadChatMessages(queries, gameID)
msgs := chatToComponents(chatMsgs)
room := svc.ChatRoom(gameID)
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)
}
}
}
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) {
ctx := r.Context()
gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID)
@@ -95,72 +80,75 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
return
}
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
// Load initial chat messages
chatMsgs := loadChatMessages(queries, gameID)
var chatMu sync.Mutex
chatMessages := chatToComponents(chatMsgs)
// 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)
// Subscribe to game state updates BEFORE creating SSE
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages
chatCh := make(chan *nats.Msg, 64)
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
if err != nil {
// Subscribe to chat messages BEFORE creating SSE
chatCfg := svc.ChatConfig(gameID)
room := svc.ChatRoom(gameID)
chatCh, cleanupChat := room.Subscribe()
defer cleanupChat()
// Setup heartbeat BEFORE creating SSE
heartbeat := time.NewTicker(1 * time.Second)
defer heartbeat.Stop()
// NOW create SSE
sse := datastar.NewSSE(w, r, datastar.WithCompression(
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
}
defer chatSub.Unsubscribe() //nolint:errcheck
ctx := r.Context()
// Event loop
for {
select {
case <-ctx.Done():
return
case <-gameCh:
// Re-read player color in case we just joined
myColor = gi.GetPlayerColor(playerID)
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
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
}
}
@@ -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) {
gameID := chi.URLParam(r, "id")
@@ -185,12 +173,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
return
}
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
http.Error(w, "not in game", http.StatusForbidden)
@@ -198,14 +181,11 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
}
gi.DropPiece(col, myColor)
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
// Return empty SSE response.
datastar.NewSSE(w, r)
}
}
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
func HandleSendChat(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -229,12 +209,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
return
}
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
datastar.NewSSE(w, r)
@@ -250,28 +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,
Color: myColor,
Slot: myColor - 1,
Message: signals.ChatMsg,
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.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
}
}
func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -296,23 +265,20 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http
return
}
sessions.Put(r.Context(), "nickname", signals.Nickname)
sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = game.PlayerID(userID)
}
playerID := sessions.GetPlayerID(sm, r)
userID := sessions.GetUserID(sm, r)
if gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
p := &connect4.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
player.UserID = &userID
p.UserID = &userID
}
gi.Join(&game.PlayerSession{Player: player})
gi.Join(&connect4.PlayerSession{Player: p})
}
sse := datastar.NewSSE(w, r)
@@ -320,7 +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) {
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
import (
"github.com/ryanhamamura/c4/features/c4game/components"
sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
"github.com/ryanhamamura/c4/game"
"github.com/starfederation/datastar-go/datastar"
"fmt"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/features/c4game/components"
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/features/common/layouts"
)
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") {
<main
class="flex flex-col items-center gap-4 p-4"
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.StealthTitle("text-3xl font-bold")
@components.PlayerInfo(g, myColor)
@components.StatusBanner(g, myColor)
<div class="c4-game-area">
@components.Board(g, myColor)
@components.Chat(messages, g.ID)
@chatcomponents.Chat(messages, chatCfg)
</div>
if g.Status == game.StatusWaitingForPlayer {
if g.Status == connect4.StatusWaitingForPlayer {
@components.InviteLink(g.ID)
}
</main>
}
</div>
}
templ JoinPage(gameID string) {

View File

@@ -4,24 +4,22 @@ package c4game
import (
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/features/c4game/services"
)
func SetupRoutes(
router chi.Router,
store *game.GameStore,
nc *nats.Conn,
store *connect4.Store,
svc *services.GameService,
sessions *scs.SessionManager,
queries *repository.Queries,
) {
router.Route("/games/{id}", func(r chi.Router) {
r.Get("/", HandleGamePage(store, sessions, queries))
r.Get("/events", HandleGameEvents(store, nc, sessions, queries))
r.Get("/", HandleGamePage(store, svc, sessions))
r.Get("/events", HandleGameEvents(store, svc, 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("/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
import "github.com/starfederation/datastar-go/datastar"
import (
"time"
"github.com/starfederation/datastar-go/datastar"
)
templ BackToLobby() {
<a class="link text-sm opacity-70" href="/">&larr; Back</a>
@@ -28,7 +32,7 @@ templ NicknamePrompt(returnPath string) {
type="text"
placeholder="Enter your nickname"
data-bind="nickname"
data-on:keydown.key_enter={ datastar.PostSSE("%s", returnPath) }
data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", returnPath) }
required
autofocus
/>
@@ -44,6 +48,15 @@ templ NicknamePrompt(returnPath string) {
</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) {
<main class="max-w-sm mx-auto mt-8 text-center">
<h1 class="text-3xl font-bold">Join Game</h1>

View File

@@ -1,6 +1,10 @@
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) {
<!DOCTYPE html>
@@ -8,14 +12,17 @@ templ Base(title string) {
<head>
<title>{ title }</title>
<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>
<link href="/assets/css/output.css" rel="stylesheet" type="text/css"/>
<script defer type="module" src={ assets.StaticPath("js/datastar/datastar.js") }></script>
<link href={ assets.StaticPath("css/output.css") } rel="stylesheet" type="text/css"/>
</head>
<body class="flex flex-col h-screen">
if config.Global.Environment == config.Dev {
<div data-init="@get('/reload', {retryMaxCount: 1000, retryInterval:20, retryMaxWaitMs:200})"></div>
}
{ children... }
<footer class="fixed bottom-1 right-2 text-xs text-gray-500">
{ version.Version }
</footer>
</body>
</html>
}

View File

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

View File

@@ -7,11 +7,12 @@ import (
"strconv"
"time"
"github.com/ryanhamamura/c4/db/repository"
lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components"
"github.com/ryanhamamura/c4/features/lobby/pages"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
"github.com/ryanhamamura/games/features/lobby/pages"
appsessions "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
@@ -21,7 +22,7 @@ import (
// HandleLobbyPage renders the main lobby page with active games for logged-in users.
func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, snakeStore *snake.SnakeStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := sessions.GetString(r.Context(), "user_id")
userID := sessions.GetString(r.Context(), appsessions.KeyUserID)
username := sessions.GetString(r.Context(), "username")
isLoggedIn := userID != ""
@@ -80,7 +81,7 @@ func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager,
}
// HandleCreateGame reads the nickname signal, creates a connect4 game, and redirects via SSE.
func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
func HandleCreateGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type Signals struct {
Nickname string `json:"nickname"`
@@ -95,7 +96,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.
return
}
sessions.Put(r.Context(), "nickname", signals.Nickname)
sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
gi := store.Create()
sse := datastar.NewSSE(w, r)
@@ -104,7 +105,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.
}
// HandleDeleteGame deletes a connect4 game and redirects to the lobby.
func HandleDeleteGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
func HandleDeleteGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
if gameID == "" {
@@ -137,7 +138,7 @@ func HandleCreateSnakeGame(snakeStore *snake.SnakeStore, sessions *scs.SessionMa
return
}
sessions.Put(r.Context(), "nickname", signals.Nickname)
sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
mode := snake.ModeMultiplayer
if r.URL.Query().Get("mode") == "solo" {
@@ -170,7 +171,6 @@ func HandleLogout(sessions *scs.SessionManager) http.HandlerFunc {
return
}
sse := datastar.NewSSE(w, r)
sse.ExecuteScript("window.location.href='/'") //nolint:errcheck
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}

View File

@@ -3,10 +3,10 @@ package pages
import (
"fmt"
"github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/features/common/layouts"
lobbycomponents "github.com/ryanhamamura/games/features/lobby/components"
"github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar"
)
@@ -20,13 +20,11 @@ templ LobbyPage(data LobbyData) {
if data.IsLoggedIn {
<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>
<button
type="button"
class="btn btn-ghost btn-sm"
data-on:click={ datastar.PostSSE("/logout") }
>
<form method="POST" action="/logout" class="inline">
<button type="submit" class="btn btn-ghost btn-sm">
Logout
</button>
</form>
</div>
} else {
<div class="alert text-sm mb-4">
@@ -73,7 +71,7 @@ templ LobbyPage(data LobbyData) {
placeholder="Enter your nickname"
data-bind="nickname"
required
data-on:keydown.enter={ datastar.PostSSE("/games") }
data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("/games") }
/>
</fieldset>
<button

View File

@@ -1,6 +1,6 @@
package pages
import "github.com/ryanhamamura/c4/features/lobby/components"
import "github.com/ryanhamamura/games/features/lobby/components"
// SnakeGameListItem represents a joinable snake game in the lobby.
type SnakeGameListItem struct {

View File

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

View File

@@ -3,7 +3,7 @@ package components
import (
"fmt"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/games/snake"
)
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"
"time"
"github.com/ryanhamamura/c4/config"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar"
)

View File

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

View File

@@ -3,10 +3,12 @@ package pages
import (
"fmt"
"github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/features/common/components"
"github.com/ryanhamamura/games/features/common/layouts"
snakecomponents "github.com/ryanhamamura/games/features/snakegame/components"
"github.com/ryanhamamura/games/snake"
"github.com/starfederation/datastar-go/datastar"
)
@@ -26,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") {
<main
class="snake-wrapper flex flex-col items-center gap-4 p-4"
data-signals={ `{"chatMsg":""}` }
data-init={ datastar.GetSSE("/snake/%s/events", gameID) }
data-on:keydown.throttle_100ms={ keydownScript(gameID) }
data-init={ fmt.Sprintf("@get('/snake/%s/events',{requestCancellation:'disabled'})", gameID) }
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
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()
<h1 class="text-3xl font-bold">~~~~</h1>
@snakecomponents.PlayerList(sg, mySlot)
@@ -43,19 +53,18 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatM
if sg.Mode == snake.ModeMultiplayer {
<div class="snake-game-area">
@snakecomponents.Board(sg)
@snakecomponents.Chat(messages, gameID)
@chatcomponents.Chat(messages, chatCfg)
</div>
} else {
@snakecomponents.Board(sg)
}
} 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) {
@snakecomponents.InviteLink(gameID)
}
</main>
}
</div>
}
templ JoinPage(gameID string) {

View File

@@ -4,17 +4,17 @@ package snakegame
import (
"github.com/alexedwards/scs/v2"
"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) {
r.Get("/", HandleSnakePage(snakeStore, sessions))
r.Get("/events", HandleSnakeEvents(snakeStore, nc, sessions))
r.Get("/", HandleSnakePage(snakeStore, svc, sessions))
r.Get("/events", HandleSnakeEvents(snakeStore, svc, 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("/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
@@ -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/sts v1.41.5 // 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/golibsass v1.2.0 // 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/riza-io/grpc-go v0.2.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/sethvargo/go-retry v0.3.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/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
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/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
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/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
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/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=

View File

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

View File

@@ -5,10 +5,11 @@ import (
"net/http"
"time"
"github.com/ryanhamamura/c4/config"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/ryanhamamura/games/config"
)
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 {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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 {
status = http.StatusOK
}

47
main.go
View File

@@ -2,7 +2,6 @@ package main
import (
"context"
"embed"
"fmt"
"log/slog"
"net"
@@ -11,31 +10,35 @@ import (
"syscall"
"time"
"github.com/ryanhamamura/c4/config"
"github.com/ryanhamamura/c4/db"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/logging"
appnats "github.com/ryanhamamura/c4/nats"
"github.com/ryanhamamura/c4/router"
"github.com/ryanhamamura/c4/sessions"
"github.com/ryanhamamura/c4/snake"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
slogzerolog "github.com/samber/slog-zerolog/v2"
"golang.org/x/sync/errgroup"
)
//go:embed assets
var assets embed.FS
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/logging"
appnats "github.com/ryanhamamura/games/nats"
"github.com/ryanhamamura/games/router"
"github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
"github.com/ryanhamamura/games/version"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
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 {
log.Fatal().Err(err).Msg("server error")
@@ -45,7 +48,7 @@ func main() {
func run(ctx context.Context) error {
cfg := config.Global
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")
eg, egctx := errgroup.WithContext(ctx)
@@ -71,14 +74,14 @@ func run(ctx context.Context) error {
defer cleanupNATS()
// Game stores
store := game.NewGameStore(queries)
store := connect4.NewStore(queries)
store.SetNotifyFunc(func(gameID string) {
nc.Publish("game."+gameID, nil) //nolint:errcheck // best-effort notification
nc.Publish(connect4.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
})
snakeStore := snake.NewSnakeStore(queries)
snakeStore.SetNotifyFunc(func(gameID string) {
nc.Publish("snake."+gameID, nil) //nolint:errcheck // best-effort notification
nc.Publish(snake.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
})
// Router
@@ -90,7 +93,7 @@ func run(ctx context.Context) error {
sessionManager.LoadAndSave,
)
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, assets)
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore)
// HTTP server
srv := &http.Server{
@@ -100,6 +103,10 @@ func run(ctx context.Context) error {
BaseContext: func(l net.Listener) context.Context {
return egctx
},
ErrorLog: slog.NewLogLogger(
slog.Default().Handler(),
slog.LevelError,
),
}
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
import (
"embed"
"io/fs"
"net/http"
"sync"
"github.com/ryanhamamura/c4/config"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/auth"
"github.com/ryanhamamura/c4/features/c4game"
"github.com/ryanhamamura/c4/features/lobby"
"github.com/ryanhamamura/c4/features/snakegame"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/assets"
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/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(
@@ -27,23 +28,25 @@ func SetupRoutes(
queries *repository.Queries,
sessions *scs.SessionManager,
nc *nats.Conn,
store *game.GameStore,
store *connect4.Store,
snakeStore *snake.SnakeStore,
assets embed.FS,
) {
// Static assets
subFS, _ := fs.Sub(assets, "assets")
router.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServerFS(subFS)))
router.Handle("/assets/*", assets.Handler())
// Hot-reload for development
if config.Global.Environment == config.Dev {
setupReload(router)
}
// Services
c4Svc := c4services.NewGameService(nc, queries)
snakeSvc := snakeservices.NewGameService(nc)
auth.SetupRoutes(router, queries, sessions)
lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
c4game.SetupRoutes(router, store, nc, sessions, queries)
snakegame.SetupRoutes(router, snakeStore, nc, sessions)
c4game.SetupRoutes(router, store, c4Svc, sessions)
snakegame.SetupRoutes(router, snakeStore, snakeSvc, sessions)
}
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
import (
@@ -7,10 +8,19 @@ import (
"net/http"
"time"
"github.com/ryanhamamura/games/player"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
)
// Session key names.
const (
KeyPlayerID = "player_id"
KeyUserID = "user_id"
KeyNickname = "nickname"
)
// SetupSessionManager creates a configured session manager backed by SQLite.
// Returns the manager and a cleanup function the caller should defer.
func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
@@ -20,13 +30,38 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
sessionManager := scs.New()
sessionManager.Store = store
sessionManager.Lifetime = 30 * 24 * time.Hour
sessionManager.Cookie.Name = "c4_session"
sessionManager.Cookie.Name = "games_session"
sessionManager.Cookie.Path = "/"
sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.Secure = true
sessionManager.Cookie.Secure = false
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
slog.Info("session manager configured")
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 (
"context"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/player"
"github.com/rs/zerolog/log"
)
@@ -122,19 +123,19 @@ func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) {
func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows))
for _, row := range rows {
player := &Player{
p := &Player{
Nickname: row.Nickname,
Slot: int(row.Slot),
}
if row.UserID != nil {
player.UserID = row.UserID
player.ID = PlayerID(*row.UserID)
p.UserID = row.UserID
p.ID = player.ID(*row.UserID)
} else if row.GuestPlayerID != nil {
player.ID = PlayerID(*row.GuestPlayerID)
p.ID = player.ID(*row.GuestPlayerID)
}
players = append(players, player)
players = append(players, p)
}
return players
}

View File

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

View File

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

View File

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

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